From d462c50684b7d63a2dbc20d39fc42b7db88ecf8e Mon Sep 17 00:00:00 2001 From: bicochan Date: Mon, 27 Apr 2026 10:11:36 +0900 Subject: [PATCH 1/6] =?UTF-8?q?[st]=20feat:=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EA=B2=BD=EB=A1=9C=20=EC=A1=B0=ED=95=A9=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/{ => statistics}/Dashboard.tsx | 0 .../pages/statistics/ui/statistics-page.tsx | 34 ++++++++++--------- 2 files changed, 18 insertions(+), 16 deletions(-) rename apps/st/src/components/{ => statistics}/Dashboard.tsx (100%) diff --git a/apps/st/src/components/Dashboard.tsx b/apps/st/src/components/statistics/Dashboard.tsx similarity index 100% rename from apps/st/src/components/Dashboard.tsx rename to apps/st/src/components/statistics/Dashboard.tsx diff --git a/apps/st/src/pages/statistics/ui/statistics-page.tsx b/apps/st/src/pages/statistics/ui/statistics-page.tsx index a228a4d46..e50f90a0d 100644 --- a/apps/st/src/pages/statistics/ui/statistics-page.tsx +++ b/apps/st/src/pages/statistics/ui/statistics-page.tsx @@ -1,26 +1,28 @@ "use client"; -import { Suspense, useRef, useEffect } from "react"; +import { Suspense, useEffect, useRef } from "react"; import dynamic from "next/dynamic"; -import { useTabStore } from "@/store/tab-store"; -import { useAdminStore } from "@/store/admin-store"; -import Dashboard from "@/components/Dashboard"; + import { SkeletonPage } from "@/shared/ui/skeleton-page"; import "@rsone/components/index.css"; +import Dashboard from "@/components/statistics/Dashboard"; +import { useAdminStore } from "@/store/admin-store"; +import { useTabStore } from "@/store/tab-store"; + const StatisticsPage = () => { const { tabs, activeTabId } = useTabStore(); const fetchAdminInfo = useAdminStore((state) => state.fetchAdminInfo); - + // 컴포넌트 캐시를 useRef로 유지 (재렌더링 시에도 유지됨) const componentCache = useRef(new Map()); // 닫힌 탭의 컴포넌트는 캐시에서 제거 (메모리 정리) useEffect(() => { - const currentTabIds = new Set(tabs.map(tab => tab.id)); + const currentTabIds = new Set(tabs.map((tab) => tab.id)); const cachedTabIds = Array.from(componentCache.current.keys()); - + cachedTabIds.forEach((cachedId) => { if (!currentTabIds.has(cachedId)) { componentCache.current.delete(cachedId); @@ -48,15 +50,15 @@ const StatisticsPage = () => { if (!componentCache.current.has(tab.id)) { const componentName = tab.urlPath; const parentComponentName = tab.parentEngNm; - + const DynamicComponent = dynamic( - () => import(`@/components/statistics/${parentComponentName}/${componentName}`), - { - ssr: false, - loading: () => - } + () => + import( + `@/components/statistics${parentComponentName ? `/${parentComponentName}` : ""}/${componentName}` + ), + { ssr: false, loading: () => }, ); - + componentCache.current.set(tab.id, DynamicComponent); } @@ -68,7 +70,7 @@ const StatisticsPage = () => { return (
}> @@ -80,4 +82,4 @@ const StatisticsPage = () => { ); }; -export default StatisticsPage; \ No newline at end of file +export default StatisticsPage; From 47b582c5cc7d3ff6ab4445c891c4b2a0866cf3fd Mon Sep 17 00:00:00 2001 From: bicochan Date: Tue, 28 Apr 2026 10:16:01 +0900 Subject: [PATCH 2/6] =?UTF-8?q?[st]=20WIP:=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/st/package.json | 5 +- .../src/components/statistics/Dashboard.tsx | 103 +++++++++++++++-- apps/st/src/entities/post/api/post-queries.ts | 31 +++++ apps/st/src/entities/post/api/post-service.ts | 22 ++++ apps/st/src/entities/post/index.ts | 8 ++ .../src/entities/post/model/post-interface.ts | 42 +++++++ .../entities/schedule/api/schedule-queries.ts | 29 +++++ .../entities/schedule/api/schedule-service.ts | 22 ++++ apps/st/src/entities/schedule/index.ts | 7 ++ .../schedule/model/schedule-interface.ts | 27 +++++ .../api/dashboard/dashboard-queries.ts | 106 ++++++++++++++++++ .../api/dashboard/dashboard-service.ts | 66 +++++++++++ .../api/dashboard/schedule-info-queries.ts | 35 ++++++ .../api/dashboard/schedule-info-service.ts | 22 ++++ apps/st/src/entities/statistics/index.ts | 28 +++++ .../model/dashboard/dashboard-interface.ts | 74 ++++++++++++ .../dashboard/schedule-info-interface.ts | 30 +++++ .../statistics/lib/hooks/use-dnd-sensors.ts | 16 +++ .../statistics/lib/hooks/use-panel-order.ts | 64 +++++++++++ .../pages/statistics/model/panel-config.tsx | 60 ++++++++++ apps/st/src/pages/statistics/model/types.ts | 5 + .../statistics/ui/panels/notice-panel.tsx | 85 ++++++++++++++ .../ui/panels/schedule-calendar-panel.tsx | 78 +++++++++++++ .../ui/panels/schedule-info-panel.tsx | 73 ++++++++++++ .../ui/panels/stats-submission-panel.tsx | 80 +++++++++++++ .../statistics/ui/panels/unsubmit-panel.tsx | 72 ++++++++++++ .../statistics/ui/panels/vrfc-err-panel.tsx | 81 +++++++++++++ .../ui/panels/vrfc-report-panel.tsx | 98 ++++++++++++++++ .../pages/statistics/ui/sortable-panel.tsx | 60 ++++++++++ apps/st/src/shared/config/api.ts | 1 + apps/st/src/shared/lib/use-data-table.tsx | 56 ++++----- apps/st/src/shared/lib/use-user-store.ts | 16 +-- apps/st/src/shared/store/user-store.ts | 84 +++++++------- 33 files changed, 1503 insertions(+), 83 deletions(-) create mode 100644 apps/st/src/entities/post/api/post-queries.ts create mode 100644 apps/st/src/entities/post/api/post-service.ts create mode 100644 apps/st/src/entities/post/index.ts create mode 100644 apps/st/src/entities/post/model/post-interface.ts create mode 100644 apps/st/src/entities/schedule/api/schedule-queries.ts create mode 100644 apps/st/src/entities/schedule/api/schedule-service.ts create mode 100644 apps/st/src/entities/schedule/index.ts create mode 100644 apps/st/src/entities/schedule/model/schedule-interface.ts create mode 100644 apps/st/src/entities/statistics/api/dashboard/dashboard-queries.ts create mode 100644 apps/st/src/entities/statistics/api/dashboard/dashboard-service.ts create mode 100644 apps/st/src/entities/statistics/api/dashboard/schedule-info-queries.ts create mode 100644 apps/st/src/entities/statistics/api/dashboard/schedule-info-service.ts create mode 100644 apps/st/src/entities/statistics/model/dashboard/dashboard-interface.ts create mode 100644 apps/st/src/entities/statistics/model/dashboard/schedule-info-interface.ts create mode 100644 apps/st/src/pages/statistics/lib/hooks/use-dnd-sensors.ts create mode 100644 apps/st/src/pages/statistics/lib/hooks/use-panel-order.ts create mode 100644 apps/st/src/pages/statistics/model/panel-config.tsx create mode 100644 apps/st/src/pages/statistics/model/types.ts create mode 100644 apps/st/src/pages/statistics/ui/panels/notice-panel.tsx create mode 100644 apps/st/src/pages/statistics/ui/panels/schedule-calendar-panel.tsx create mode 100644 apps/st/src/pages/statistics/ui/panels/schedule-info-panel.tsx create mode 100644 apps/st/src/pages/statistics/ui/panels/stats-submission-panel.tsx create mode 100644 apps/st/src/pages/statistics/ui/panels/unsubmit-panel.tsx create mode 100644 apps/st/src/pages/statistics/ui/panels/vrfc-err-panel.tsx create mode 100644 apps/st/src/pages/statistics/ui/panels/vrfc-report-panel.tsx create mode 100644 apps/st/src/pages/statistics/ui/sortable-panel.tsx diff --git a/apps/st/package.json b/apps/st/package.json index 5cf4bf67d..ef32c14cd 100644 --- a/apps/st/package.json +++ b/apps/st/package.json @@ -14,7 +14,9 @@ "@common/components": "workspace:*", "@common/config": "workspace:*", "@common/network": "workspace:*", - "@rsone/components": "workspace:*", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@faker-js/faker": "^9.9.0", "@fontsource-variable/noto-sans-kr": "^5.2.5", "@frontend-opensource/use-react-hooks": "^1.1.4", @@ -29,6 +31,7 @@ "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", + "@rsone/components": "workspace:*", "@tanstack/react-query": "^5.67.1", "@tanstack/react-query-devtools": "^5.75.5", "@tanstack/react-table": "^8.21.2", diff --git a/apps/st/src/components/statistics/Dashboard.tsx b/apps/st/src/components/statistics/Dashboard.tsx index f3682d1e0..b3734e386 100644 --- a/apps/st/src/components/statistics/Dashboard.tsx +++ b/apps/st/src/components/statistics/Dashboard.tsx @@ -1,9 +1,96 @@ -const Dashboard = () => { - return ( -
- Dashboard -
- ); -}; +"use client"; -export default Dashboard; \ No newline at end of file +import { useEffect, useState } from "react"; + +import { closestCenter, DndContext } from "@dnd-kit/core"; +import { rectSortingStrategy, SortableContext } from "@dnd-kit/sortable"; +import dayjs from "dayjs"; + +import { useUserStore } from "@/shared/lib"; +import { Skeleton } from "@common/components"; + +import { useDndSensors } from "@/pages/statistics/lib/hooks/use-dnd-sensors"; +import { usePanelOrder } from "@/pages/statistics/lib/hooks/use-panel-order"; +import { getInitialPanels } from "@/pages/statistics/model/panel-config"; +import { SortablePanel } from "@/pages/statistics/ui/sortable-panel"; + +interface PanelItem { + id: string; + component: React.ReactNode; + className?: string; +} + +function Dashboard() { + const userInfo = useUserStore((state) => state.userInfo); + const [initialPanels, setInitialPanels] = useState([]); + const [isMounted, setIsMounted] = useState(false); + const sensors = useDndSensors(); + + const { panels, handleDragEnd } = usePanelOrder({ + initialPanels, + storageKey: "statistics-dashboard-panels-order", + }); + + useEffect(() => { + setIsMounted(true); + }, []); + + useEffect(() => { + if (!userInfo) return; + const initialPanels = getInitialPanels({ + dashboardParams: { + statsYr: String(dayjs().year() - 1), + dtlInstCd: userInfo.dtlInstCd, + enoCd: userInfo.enoCd, + laeSeCd: userInfo.laeSeCd, + }, + scheduleInfoParams: { + statsYr: String(dayjs().year() - 1), + }, + }); + setInitialPanels(initialPanels); + }, [userInfo]); + + return ( + <> + {isMounted && initialPanels.length > 0 ? ( + + p.id)} + strategy={rectSortingStrategy} + > + {/* TBD: 그리드 레이아웃 비율 정의 필요 + 현재: 3열 그리드, 반응형 브레이크포인트 적용 + SPEC 레이아웃에 따라 조정 필요 */} +
+ {panels.map((panel) => ( + + {panel.component} + + ))} +
+
+
+ ) : ( +
+ + + + + + +
+ )} + + ); +} + +export default Dashboard; diff --git a/apps/st/src/entities/post/api/post-queries.ts b/apps/st/src/entities/post/api/post-queries.ts new file mode 100644 index 000000000..22c14e419 --- /dev/null +++ b/apps/st/src/entities/post/api/post-queries.ts @@ -0,0 +1,31 @@ +import { useQuery } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import type { RecentPostSearchParams } from "../model/post-interface"; + +import { postApi } from "./post-service"; + +export function useRecentPostList(params?: RecentPostSearchParams) { + return useQuery({ + queryKey: createRecentPostListQueryKey(params), + queryFn: () => postApi.getRecentPostList(params), + refetchOnWindowFocus: false, + }); +} + +function createRecentPostListQueryKey(params?: RecentPostSearchParams) { + return [ + ENDPOINT.BOARD_SERVICE.POSTS, + "recentPosts", + params?.page ?? "", + params?.size ?? "", + params?.sort ?? "", + params?.keywordType ?? "", + params?.keyword ?? "", + params?.bbsId ?? "", + params?.delYn ?? "", + params?.frDateYmd ?? "", + params?.toDateYmd ?? "", + ]; +} diff --git a/apps/st/src/entities/post/api/post-service.ts b/apps/st/src/entities/post/api/post-service.ts new file mode 100644 index 000000000..5a8a77df6 --- /dev/null +++ b/apps/st/src/entities/post/api/post-service.ts @@ -0,0 +1,22 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import type { + PostService, + RecentPostListResponse, + RecentPostSearchParams, +} from "../model/post-interface"; + +async function getRecentPostList( + params?: RecentPostSearchParams, +): Promise { + const response = await client.get( + `${ENDPOINT.BOARD_SERVICE.POSTS}/recentPosts`, + { params }, + ); + return response.data; +} + +export const postApi: PostService = { + getRecentPostList, +}; diff --git a/apps/st/src/entities/post/index.ts b/apps/st/src/entities/post/index.ts new file mode 100644 index 000000000..451271145 --- /dev/null +++ b/apps/st/src/entities/post/index.ts @@ -0,0 +1,8 @@ +export { useRecentPostList } from "./api/post-queries"; +export { postApi } from "./api/post-service"; +export { + type PostService, + type PstRecentListResponse, + type RecentPostListResponse, + type RecentPostSearchParams, +} from "./model/post-interface"; diff --git a/apps/st/src/entities/post/model/post-interface.ts b/apps/st/src/entities/post/model/post-interface.ts new file mode 100644 index 000000000..9282b2f35 --- /dev/null +++ b/apps/st/src/entities/post/model/post-interface.ts @@ -0,0 +1,42 @@ +import type { PageableList, PageableParams } from "@/shared/model"; + +export interface RecentPostSearchParams extends PageableParams { + keywordType?: string; + keyword?: string; + bbsId?: number; + delYn?: string; + frDateYmd?: string; + toDateYmd?: string; +} + +export interface PstRecentListResponse { + rowNo: number; + bbsId: number; + pstNo: number; + pstTtl: string; + pstCn: string; + inqNocs: number; + rfrncUrl: string; + popupYn: string; + upndFixYn: string; + prdPstgYn: string; + rootPath: string; + pstPath: string; + ansPstn: number; + wrtrNm: string; + wrtrCplc: string; + atflCd: string; + regDt: string; + rgtrNm: string; + cmntCnt: number; + bbsTypeCd: string; + bbsNm: string; +} + +export type RecentPostListResponse = PageableList; + +export interface PostService { + getRecentPostList( + params?: RecentPostSearchParams, + ): Promise; +} diff --git a/apps/st/src/entities/schedule/api/schedule-queries.ts b/apps/st/src/entities/schedule/api/schedule-queries.ts new file mode 100644 index 000000000..b1b6d818f --- /dev/null +++ b/apps/st/src/entities/schedule/api/schedule-queries.ts @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import type { ScheduleSearchParams } from "../model/schedule-interface"; + +import { scheduleApi } from "./schedule-service"; + +export function useSchedules(params?: ScheduleSearchParams) { + return useQuery({ + queryKey: createSchedulesQueryKey(params), + queryFn: () => scheduleApi.getSchedules(params), + refetchOnWindowFocus: false, + }); +} + +function createSchedulesQueryKey(params?: ScheduleSearchParams) { + return [ + ENDPOINT.CMS_SERVICE.SCHEDULES, + "list", + params?.siteId ?? "", + params?.schdlTtl ?? "", + params?.schdlSeCd ?? "", + params?.schdlBgngYmd ?? "", + params?.schdlEndYmd ?? "", + params?.reptYn ?? "", + params?.commSchdlYn ?? "", + ]; +} diff --git a/apps/st/src/entities/schedule/api/schedule-service.ts b/apps/st/src/entities/schedule/api/schedule-service.ts new file mode 100644 index 000000000..d5b20ef02 --- /dev/null +++ b/apps/st/src/entities/schedule/api/schedule-service.ts @@ -0,0 +1,22 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import type { + Schedule, + ScheduleSearchParams, + ScheduleService, +} from "../model/schedule-interface"; + +async function getSchedules( + params?: ScheduleSearchParams, +): Promise { + const response = await client.get( + ENDPOINT.CMS_SERVICE.SCHEDULES, + { params }, + ); + return response.data; +} + +export const scheduleApi: ScheduleService = { + getSchedules, +}; diff --git a/apps/st/src/entities/schedule/index.ts b/apps/st/src/entities/schedule/index.ts new file mode 100644 index 000000000..2fda886ec --- /dev/null +++ b/apps/st/src/entities/schedule/index.ts @@ -0,0 +1,7 @@ +export { useSchedules } from "./api/schedule-queries"; +export { scheduleApi } from "./api/schedule-service"; +export { + type Schedule, + type ScheduleSearchParams, + type ScheduleService, +} from "./model/schedule-interface"; diff --git a/apps/st/src/entities/schedule/model/schedule-interface.ts b/apps/st/src/entities/schedule/model/schedule-interface.ts new file mode 100644 index 000000000..827942d51 --- /dev/null +++ b/apps/st/src/entities/schedule/model/schedule-interface.ts @@ -0,0 +1,27 @@ +export interface ScheduleSearchParams { + siteId?: string; + schdlTtl?: string; + schdlSeCd?: string; + schdlBgngYmd?: string; + schdlEndYmd?: string; + reptYn?: string; + commSchdlYn?: string; +} + +export interface Schedule { + schdlBgngDt: string; + schdlEndDt: string; + actualDate: string; + schdlSn: number; + siteId: string; + schdlTtl: string; + schdlSeCd: string; + schdlCn: string; + reptYn: string; + commSchdlYn: string; + atflCd: string; +} + +export interface ScheduleService { + getSchedules(params?: ScheduleSearchParams): Promise; +} diff --git a/apps/st/src/entities/statistics/api/dashboard/dashboard-queries.ts b/apps/st/src/entities/statistics/api/dashboard/dashboard-queries.ts new file mode 100644 index 000000000..2f37dbaa2 --- /dev/null +++ b/apps/st/src/entities/statistics/api/dashboard/dashboard-queries.ts @@ -0,0 +1,106 @@ +import { useQuery } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import type { DashboardSearchParams } from "../../model/dashboard/dashboard-interface"; + +import { dashboardApi } from "./dashboard-service"; + +// 이상치 선별보고서 조회 +export function useDashboardVrfcReport(params: DashboardSearchParams | null) { + return useQuery({ + queryKey: createDashboardQueryKey("vrfc-report", params), + queryFn: async () => { + if (!params) + throw new Error("Dashboard search params are required."); + const response = await dashboardApi.getVrfcReport(params); + return response.data; + }, + enabled: hasRequiredDashboardParams(params), + refetchOnWindowFocus: false, + }); +} + +// 검증오류 수 조회 +export function useDashboardVrfcErrCount(params: DashboardSearchParams | null) { + return useQuery({ + queryKey: createDashboardQueryKey("vrfc-err-count", params), + queryFn: async () => { + if (!params) + throw new Error("Dashboard search params are required."); + const response = await dashboardApi.getVrfcErrCount(params); + return response.data; + }, + enabled: hasRequiredDashboardParams(params), + refetchOnWindowFocus: false, + }); +} + +// 미제출 수 & 목록 조회 +export function useDashboardUnSubmitCount( + params: DashboardSearchParams | null, +) { + return useQuery({ + queryKey: createDashboardQueryKey("un-submit-count", params), + queryFn: async () => { + if (!params) + throw new Error("Dashboard search params are required."); + const response = await dashboardApi.getUnSubmitCount(params); + return response.data; + }, + enabled: hasRequiredDashboardParams(params), + refetchOnWindowFocus: false, + }); +} + +// 통계입력현황 조회 +export function useDashboardStatsSubmissionRate( + params: DashboardSearchParams | null, +) { + return useQuery({ + queryKey: createDashboardQueryKey("stats-submission-rate", params), + queryFn: async () => { + if (!params) + throw new Error("Dashboard search params are required."); + const response = await dashboardApi.getStatsSubmissionRate(params); + return response.data; + }, + enabled: hasRequiredDashboardParams(params), + refetchOnWindowFocus: false, + }); +} + +// 예외신청 수 조회 +export function useDashboardEcptReqCount(params: DashboardSearchParams | null) { + return useQuery({ + queryKey: createDashboardQueryKey("ecpt-req-count", params), + queryFn: async () => { + if (!params) + throw new Error("Dashboard search params are required."); + const response = await dashboardApi.getEcptReqCount(params); + return response.data; + }, + enabled: hasRequiredDashboardParams(params), + refetchOnWindowFocus: false, + }); +} + +function createDashboardQueryKey( + name: string, + params: DashboardSearchParams | null, +) { + return [ + ENDPOINT.ST_SERVICE.DASHBOARD, + name, + params?.statsYr ?? "", + params?.dtlInstCd ?? "", + params?.enoCd ?? "", + params?.laeSeCd ?? "", + ]; +} + +function hasRequiredDashboardParams( + params: DashboardSearchParams | null, +): params is DashboardSearchParams { + return Boolean(params?.statsYr && params.dtlInstCd); +} diff --git a/apps/st/src/entities/statistics/api/dashboard/dashboard-service.ts b/apps/st/src/entities/statistics/api/dashboard/dashboard-service.ts new file mode 100644 index 000000000..5fcaa3ac8 --- /dev/null +++ b/apps/st/src/entities/statistics/api/dashboard/dashboard-service.ts @@ -0,0 +1,66 @@ +import { stClient } from "@/shared/api/client"; +import { ENDPOINT } from "@/shared/config"; + +import type { + DashboardApiResponse, + DashboardSearchParams, + DashboardService, + EcptReqCountResponse, + StatsSubmissionResponse, + UnSubmitCountResponse, + VrfcErrCountResponse, + VrfcReportResponse, +} from "../../model/dashboard/dashboard-interface"; + +async function getVrfcReport( + params: DashboardSearchParams, +): Promise> { + const response = await stClient.get< + DashboardApiResponse + >(`${ENDPOINT.ST_SERVICE.DASHBOARD}/vrfc-report`, { params }); + return response.data; +} + +async function getVrfcErrCount( + params: DashboardSearchParams, +): Promise> { + const response = await stClient.get< + DashboardApiResponse + >(`${ENDPOINT.ST_SERVICE.DASHBOARD}/vrfc-err-count`, { params }); + return response.data; +} + +async function getUnSubmitCount( + params: DashboardSearchParams, +): Promise> { + const response = await stClient.get< + DashboardApiResponse + >(`${ENDPOINT.ST_SERVICE.DASHBOARD}/un-submit-count`, { params }); + return response.data; +} + +async function getStatsSubmissionRate( + params: DashboardSearchParams, +): Promise> { + const response = await stClient.get< + DashboardApiResponse + >(`${ENDPOINT.ST_SERVICE.DASHBOARD}/stats-submission-rate`, { params }); + return response.data; +} + +async function getEcptReqCount( + params: DashboardSearchParams, +): Promise> { + const response = await stClient.get< + DashboardApiResponse + >(`${ENDPOINT.ST_SERVICE.DASHBOARD}/ecpt-req-count`, { params }); + return response.data; +} + +export const dashboardApi: DashboardService = { + getVrfcReport, + getVrfcErrCount, + getUnSubmitCount, + getStatsSubmissionRate, + getEcptReqCount, +}; diff --git a/apps/st/src/entities/statistics/api/dashboard/schedule-info-queries.ts b/apps/st/src/entities/statistics/api/dashboard/schedule-info-queries.ts new file mode 100644 index 000000000..055eba519 --- /dev/null +++ b/apps/st/src/entities/statistics/api/dashboard/schedule-info-queries.ts @@ -0,0 +1,35 @@ +import { useQuery } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import type { ScheduleInfoSearchParams } from "../../model/dashboard/schedule-info-interface"; + +import { scheduleInfoApi } from "./schedule-info-service"; + +export function useScheduleInfo(params: ScheduleInfoSearchParams | null) { + return useQuery({ + queryKey: createScheduleInfoQueryKey(params), + queryFn: async () => { + if (!params) + throw new Error("Schedule info search params are required."); + const response = await scheduleInfoApi.getScheduleInfo(params); + return response.data; + }, + enabled: hasRequiredScheduleInfoParams(params), + refetchOnWindowFocus: false, + }); +} + +function createScheduleInfoQueryKey(params: ScheduleInfoSearchParams | null) { + return [ + ENDPOINT.ST_SERVICE.DASHBOARD, + "schedule-info", + params?.statsYr ?? "", + ]; +} + +function hasRequiredScheduleInfoParams( + params: ScheduleInfoSearchParams | null, +): params is ScheduleInfoSearchParams { + return Boolean(params?.statsYr); +} diff --git a/apps/st/src/entities/statistics/api/dashboard/schedule-info-service.ts b/apps/st/src/entities/statistics/api/dashboard/schedule-info-service.ts new file mode 100644 index 000000000..b9b593d0b --- /dev/null +++ b/apps/st/src/entities/statistics/api/dashboard/schedule-info-service.ts @@ -0,0 +1,22 @@ +import { stClient } from "@/shared/api/client"; +import { ENDPOINT } from "@/shared/config"; + +import type { + ScheduleInfoApiResponse, + ScheduleInfoResponse, + ScheduleInfoSearchParams, + ScheduleInfoService, +} from "../../model/dashboard/schedule-info-interface"; + +async function getScheduleInfo( + params: ScheduleInfoSearchParams, +): Promise> { + const response = await stClient.get< + ScheduleInfoApiResponse + >(`${ENDPOINT.ST_SERVICE.DASHBOARD}/schedule-info`, { params }); + return response.data; +} + +export const scheduleInfoApi: ScheduleInfoService = { + getScheduleInfo, +}; diff --git a/apps/st/src/entities/statistics/index.ts b/apps/st/src/entities/statistics/index.ts index 9c29551aa..ff37b0535 100644 --- a/apps/st/src/entities/statistics/index.ts +++ b/apps/st/src/entities/statistics/index.ts @@ -1,3 +1,13 @@ +export { + useDashboardEcptReqCount, + useDashboardStatsSubmissionRate, + useDashboardUnSubmitCount, + useDashboardVrfcErrCount, + useDashboardVrfcReport, +} from "./api/dashboard/dashboard-queries"; +export { dashboardApi } from "./api/dashboard/dashboard-service"; +export { useScheduleInfo } from "./api/dashboard/schedule-info-queries"; +export { scheduleInfoApi } from "./api/dashboard/schedule-info-service"; export { useStAnlsSidoCombo, useStAnlsSigunguCombo, @@ -14,6 +24,24 @@ export { type JobStatusDto, type OutlierRecordDto, } from "./model/analysis/analysis-interface"; +export { + type DashboardApiResponse, + type DashboardSearchParams, + type DashboardService, + type EcptReqCountResponse, + type StatsSubmissionResponse, + type UnSubmitCountResponse, + type UnSubmitStatsResponse, + type VrfcErrCountResponse, + type VrfcReportResponse, + type VrfcReportSelect, +} from "./model/dashboard/dashboard-interface"; +export { + type ScheduleInfoApiResponse, + type ScheduleInfoResponse, + type ScheduleInfoSearchParams, + type ScheduleInfoService, +} from "./model/dashboard/schedule-info-interface"; export { type VerifyResultItem } from "./model/general-verification-interface"; export { IMR_DETAIL_VISIBLE_COLUMNS_BY_STATS_CD, diff --git a/apps/st/src/entities/statistics/model/dashboard/dashboard-interface.ts b/apps/st/src/entities/statistics/model/dashboard/dashboard-interface.ts new file mode 100644 index 000000000..f66eec7dc --- /dev/null +++ b/apps/st/src/entities/statistics/model/dashboard/dashboard-interface.ts @@ -0,0 +1,74 @@ +export interface DashboardSearchParams { + statsYr: string; + dtlInstCd: string; + enoCd?: string; + laeSeCd?: string; +} + +export interface DashboardApiResponse { + status: string; + message: string; + data: T; + code: string; + httpStatus: number; +} + +export interface VrfcReportResponse { + typeCounts: Record; + list: VrfcReportSelect[]; +} + +export interface VrfcReportSelect { + vrfcCd: string; + statsNm: string; + rsnExpln: string; + admdstNm: string; + nowVlCn: string; + compVlCn: string; +} + +export interface VrfcErrCountResponse { + statsYr: string; + vrfcErrCount: number; +} + +export interface UnSubmitCountResponse { + statsYr: string; + unSubmitCount: number; + unSubmitList: UnSubmitStatsResponse[]; +} + +export interface UnSubmitStatsResponse { + statsCd: string; + statsNm: string; +} + +export interface StatsSubmissionResponse { + totalCount: number; + submittedCount: number; + submissionRate: number; + statusLevel: string; +} + +export interface EcptReqCountResponse { + statsYr: string; + ecptReqCount: number; +} + +export interface DashboardService { + getVrfcReport( + params: DashboardSearchParams, + ): Promise>; + getVrfcErrCount( + params: DashboardSearchParams, + ): Promise>; + getUnSubmitCount( + params: DashboardSearchParams, + ): Promise>; + getStatsSubmissionRate( + params: DashboardSearchParams, + ): Promise>; + getEcptReqCount( + params: DashboardSearchParams, + ): Promise>; +} diff --git a/apps/st/src/entities/statistics/model/dashboard/schedule-info-interface.ts b/apps/st/src/entities/statistics/model/dashboard/schedule-info-interface.ts new file mode 100644 index 000000000..f93d110c7 --- /dev/null +++ b/apps/st/src/entities/statistics/model/dashboard/schedule-info-interface.ts @@ -0,0 +1,30 @@ +export interface ScheduleInfoSearchParams { + statsYr: string; +} + +export interface ScheduleInfoApiResponse { + status: string; + message: string; + data: T; + code: string; + httpStatus: number; +} + +export interface ScheduleInfoResponse { + statsYr: string; + inptSeCd: number; + inptBgngYmd: string; + inptEndYmd: string; + vrfcSeCd: number; + vrfcBgngYmd: string; + vrfcEndYmd: string; + currentSe: string; + dday: string; + ddayLabel: string; +} + +export interface ScheduleInfoService { + getScheduleInfo( + params: ScheduleInfoSearchParams, + ): Promise>; +} diff --git a/apps/st/src/pages/statistics/lib/hooks/use-dnd-sensors.ts b/apps/st/src/pages/statistics/lib/hooks/use-dnd-sensors.ts new file mode 100644 index 000000000..f78c405a5 --- /dev/null +++ b/apps/st/src/pages/statistics/lib/hooks/use-dnd-sensors.ts @@ -0,0 +1,16 @@ +import { + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { sortableKeyboardCoordinates } from "@dnd-kit/sortable"; + +export function useDndSensors() { + return useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); +} diff --git a/apps/st/src/pages/statistics/lib/hooks/use-panel-order.ts b/apps/st/src/pages/statistics/lib/hooks/use-panel-order.ts new file mode 100644 index 000000000..51c18cd2c --- /dev/null +++ b/apps/st/src/pages/statistics/lib/hooks/use-panel-order.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; + +import type { DragEndEvent } from "@dnd-kit/core"; +import { arrayMove } from "@dnd-kit/sortable"; + +import type { PanelItem } from "../../model/types"; + +interface UsePanelOrderProps { + initialPanels: PanelItem[]; + storageKey: string; +} + +export function usePanelOrder({ + initialPanels, + storageKey, +}: UsePanelOrderProps) { + const [panels, setPanels] = useState(initialPanels); + const [isInitialized, setIsInitialized] = useState(false); + + useEffect(() => { + if (initialPanels.length === 0 || isInitialized) return; + + const saved = localStorage.getItem(storageKey); + if (saved) { + try { + const savedIds = JSON.parse(saved); + const restoredPanels = savedIds + .map((id: string) => initialPanels.find((p) => p.id === id)) + .filter(Boolean); + if (restoredPanels.length === initialPanels.length) { + setPanels(restoredPanels); + } + } catch { + setPanels(initialPanels); + } + } else { + setPanels(initialPanels); + } + setIsInitialized(true); + }, [storageKey, initialPanels, isInitialized]); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + setPanels((items) => { + const oldIndex = items.findIndex( + (item) => item.id === active.id, + ); + const newIndex = items.findIndex((item) => item.id === over.id); + const newItems = arrayMove(items, oldIndex, newIndex); + + localStorage.setItem( + storageKey, + JSON.stringify(newItems.map((item) => item.id)), + ); + + return newItems; + }); + } + }; + + return { panels, handleDragEnd }; +} diff --git a/apps/st/src/pages/statistics/model/panel-config.tsx b/apps/st/src/pages/statistics/model/panel-config.tsx new file mode 100644 index 000000000..75f678a7f --- /dev/null +++ b/apps/st/src/pages/statistics/model/panel-config.tsx @@ -0,0 +1,60 @@ +import type { DashboardSearchParams } from "@/entities/statistics/model/dashboard/dashboard-interface"; +import type { ScheduleInfoSearchParams } from "@/entities/statistics/model/dashboard/schedule-info-interface"; + +import { NoticePanel } from "../ui/panels/notice-panel"; +import { ScheduleCalendarPanel } from "../ui/panels/schedule-calendar-panel"; +import { ScheduleInfoPanel } from "../ui/panels/schedule-info-panel"; +import { StatsSubmissionPanel } from "../ui/panels/stats-submission-panel"; +import { UnSubmitPanel } from "../ui/panels/unsubmit-panel"; +import { VrfcErrPanel } from "../ui/panels/vrfc-err-panel"; +import { VrfcReportPanel } from "../ui/panels/vrfc-report-panel"; + +import type { PanelItem } from "./types"; + +interface GetInitialPanelsParams { + dashboardParams: DashboardSearchParams | null; + scheduleInfoParams: ScheduleInfoSearchParams | null; +} + +export function getInitialPanels({ + dashboardParams, + scheduleInfoParams, +}: GetInitialPanelsParams): PanelItem[] { + return [ + { + id: "panel-a", + component: , + className: "", + }, + { + id: "panel-b", + component: , + className: "", + }, + { + id: "panel-c", + component: , + className: "", + }, + { + id: "panel-d", + component: , + className: "", + }, + { + id: "panel-e", + component: , + className: "", + }, + { + id: "panel-f", + component: , + className: "", + }, + { + id: "panel-g", + component: , + className: "", + }, + ]; +} diff --git a/apps/st/src/pages/statistics/model/types.ts b/apps/st/src/pages/statistics/model/types.ts new file mode 100644 index 000000000..34b1ea791 --- /dev/null +++ b/apps/st/src/pages/statistics/model/types.ts @@ -0,0 +1,5 @@ +export interface PanelItem { + id: string; + component: React.ReactNode; + className?: string; +} diff --git a/apps/st/src/pages/statistics/ui/panels/notice-panel.tsx b/apps/st/src/pages/statistics/ui/panels/notice-panel.tsx new file mode 100644 index 000000000..8580ce2f0 --- /dev/null +++ b/apps/st/src/pages/statistics/ui/panels/notice-panel.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import { createColumnHelper, getCoreRowModel } from "@tanstack/react-table"; +import dayjs from "dayjs"; + +import { useDataTable } from "@/shared/lib"; +import { type PstRecentListResponse, useRecentPostList } from "@/entities/post"; +import { Button } from "@common/components"; + +import { useDragHandle } from "../sortable-panel"; + +export function NoticePanel() { + const { dragHandleProps, isDragging } = useDragHandle(); + const router = useRouter(); + + const recentPostListQuery = useRecentPostList({ + page: 0, + size: 3, + keywordType: "bbsTypeCd", + keyword: "BBSNOTICE", + }); + + const handleRowClick = (post: PstRecentListResponse) => { + router.push( + `/cms/${post.bbsTypeCd.toLowerCase()}/${post.bbsId}/${post.pstNo}`, + ); + }; + + const handleMoreClick = () => { + router.push("/cms/bbsnotice/7"); + }; + + const { tableComponent } = useDataTable({ + query: recentPostListQuery, + tableOptions: { + columns: noticeColumns, + getCoreRowModel: getCoreRowModel(), + }, + dataTableOptions: { + variant: "line", + onRowClick: handleRowClick, + }, + }); + + return ( +
+
+

공지사항

+ +
+ {tableComponent} +
+ ); +} + +const columnHelper = createColumnHelper(); + +const noticeColumns = [ + columnHelper.accessor("pstTtl", { + header: "제목", + cell: ({ row }) => ( +
+
+ {row.original.pstTtl} +
+
+ {row.original.wrtrNm} + {formatDate(row.original.regDt)} +
+
+ ), + }), +]; + +function formatDate(value: string) { + return dayjs(value).format("YYYY-MM-DD"); +} diff --git a/apps/st/src/pages/statistics/ui/panels/schedule-calendar-panel.tsx b/apps/st/src/pages/statistics/ui/panels/schedule-calendar-panel.tsx new file mode 100644 index 000000000..4bc29a441 --- /dev/null +++ b/apps/st/src/pages/statistics/ui/panels/schedule-calendar-panel.tsx @@ -0,0 +1,78 @@ +"use client"; + +import dynamic from "next/dynamic"; + +import dayGridPlugin from "@fullcalendar/daygrid"; +import timeGridPlugin from "@fullcalendar/timegrid"; +import dayjs from "dayjs"; + +import { SITE_ID } from "@/shared/config"; +import { useSchedules } from "@/entities/schedule"; + +import { useDragHandle } from "../sortable-panel"; + +// FullCalendar는 클라이언트 전용이므로 dynamic import +const FullCalendar = dynamic(() => import("@fullcalendar/react"), { + ssr: false, +}); + +export function ScheduleCalendarPanel() { + const { dragHandleProps, isDragging } = useDragHandle(); + + // TBD: 초기 날짜 범위 설정 로직 + const today = dayjs(); + const startOfMonth = today.startOf("month").format("YYYY-MM-DD"); + const endOfMonth = today.endOf("month").format("YYYY-MM-DD"); + + const { data, isLoading } = useSchedules({ + siteId: SITE_ID, + schdlBgngYmd: startOfMonth, + schdlEndYmd: endOfMonth, + }); + + return ( +
+
+

일정관리

+
+
+ {isLoading && ( +
로딩 중...
+ )} + {data && ( +
+ {/* TBD: FullCalendar 상세 설정 (locale, plugins, 이벤트 색상 등) */} + ({ + id: schedule.schdlSn.toString(), + title: schedule.schdlTtl, + start: schedule.schdlBgngDt, + end: schedule.schdlEndDt, + // TBD: allDay 판단 기준 (1439분 이상?) + allDay: true, + }))} + // TBD: 이벤트 클릭 핸들러 (현재 SPEC에서 제거됨) + height="100%" + /> +
+ )} +
+
+ ); +} diff --git a/apps/st/src/pages/statistics/ui/panels/schedule-info-panel.tsx b/apps/st/src/pages/statistics/ui/panels/schedule-info-panel.tsx new file mode 100644 index 000000000..7d2ab0eb6 --- /dev/null +++ b/apps/st/src/pages/statistics/ui/panels/schedule-info-panel.tsx @@ -0,0 +1,73 @@ +"use client"; + +import type { ScheduleInfoSearchParams } from "@/entities/statistics"; +import { useScheduleInfo } from "@/entities/statistics"; + +import { useDragHandle } from "../sortable-panel"; + +interface ScheduleInfoPanelProps { + params: ScheduleInfoSearchParams | null; +} + +export function ScheduleInfoPanel({ params }: ScheduleInfoPanelProps) { + const { dragHandleProps, isDragging } = useDragHandle(); + const { data, isLoading, error } = useScheduleInfo(params); + + return ( +
+
+

일정안내

+
+
+ {isLoading && ( +
로딩 중...
+ )} + {error && ( +
+ 데이터를 불러올 수 없습니다. +
+ )} + {data && ( +
+ {/* D-day 표시 */} +
+
+ {data.ddayLabel} +
+
+ {data.dday} +
+
+ {data.currentSe} +
+
+ + {/* TBD: 일정 목록 표시 형식 정의 필요 */} +
+
+
+ 입력기간 +
+
+ {data.inptBgngYmd} ~ {data.inptEndYmd} +
+
+
+
+ 검증기간 +
+
+ {data.vrfcBgngYmd} ~ {data.vrfcEndYmd} +
+
+
+
+ )} +
+
+ ); +} diff --git a/apps/st/src/pages/statistics/ui/panels/stats-submission-panel.tsx b/apps/st/src/pages/statistics/ui/panels/stats-submission-panel.tsx new file mode 100644 index 000000000..23fe0c963 --- /dev/null +++ b/apps/st/src/pages/statistics/ui/panels/stats-submission-panel.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useDashboardStatsSubmissionRate } from "@/entities/statistics"; +import type { DashboardSearchParams } from "@/entities/statistics/model/dashboard/dashboard-interface"; +import { LoadingWithAnimation } from "@common/components"; + +import { useDragHandle } from "../sortable-panel"; + +interface StatsSubmissionPanelProps { + params: DashboardSearchParams | null; +} + +export function StatsSubmissionPanel({ params }: StatsSubmissionPanelProps) { + const { dragHandleProps, isDragging } = useDragHandle(); + const { data, isLoading, error } = useDashboardStatsSubmissionRate(params); + + return ( +
+
+

통계입력현황

+
+
+ {isLoading && ( +
+
+ +
+
+ )} + {error && ( +
+ 데이터를 불러올 수 없습니다. +
+ )} + {data && ( +
+ {/* TBD: statusLevel별 이모지 매핑 규칙 필요 */} +
+
+ {/* TBD: statusLevel 값에 따른 이모지 표시 */} + {data.statusLevel === "GOOD" + ? "😊" + : data.statusLevel === "WARNING" + ? "⚠️" + : "📊"} +
+
+ {data.statusLevel} +
+
+ + {/* 통계제출률 프로그레스바 */} +
+
+ 통계제출률 + + {data.submissionRate?.toFixed(1)}% + +
+
+
+
+
+ 제출: {data.submittedCount} + 전체: {data.totalCount} +
+
+
+ )} +
+
+ ); +} diff --git a/apps/st/src/pages/statistics/ui/panels/unsubmit-panel.tsx b/apps/st/src/pages/statistics/ui/panels/unsubmit-panel.tsx new file mode 100644 index 000000000..d8c1fc832 --- /dev/null +++ b/apps/st/src/pages/statistics/ui/panels/unsubmit-panel.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useDashboardUnSubmitCount } from "@/entities/statistics"; +import type { DashboardSearchParams } from "@/entities/statistics/model/dashboard/dashboard-interface"; +import { Badge, LoadingWithAnimation } from "@common/components"; + +import { useDragHandle } from "../sortable-panel"; + +interface UnSubmitPanelProps { + params: DashboardSearchParams | null; +} + +export function UnSubmitPanel({ params }: UnSubmitPanelProps) { + const { dragHandleProps, isDragging } = useDragHandle(); + const { data, isLoading, error } = useDashboardUnSubmitCount(params); + + return ( +
+
+

미제출 지표

+ {data && ( + + {data.unSubmitCount}건 + + )} +
+
+ {isLoading && ( +
+
+ +
+
+ )} + {error && ( +
+ 데이터를 불러올 수 없습니다. +
+ )} + {data && ( +
+ {data.unSubmitList.length === 0 ? ( +
+ 미제출 지표가 없습니다. +
+ ) : ( +
+ {data.unSubmitList.map((item) => ( +
+

+ {item.statsNm} +

+
+ ))} +
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/st/src/pages/statistics/ui/panels/vrfc-err-panel.tsx b/apps/st/src/pages/statistics/ui/panels/vrfc-err-panel.tsx new file mode 100644 index 000000000..2a753196c --- /dev/null +++ b/apps/st/src/pages/statistics/ui/panels/vrfc-err-panel.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { OctagonAlert, OctagonX } from "lucide-react"; + +import { + useDashboardEcptReqCount, + useDashboardVrfcErrCount, +} from "@/entities/statistics"; +import type { DashboardSearchParams } from "@/entities/statistics/model/dashboard/dashboard-interface"; + +import { useDragHandle } from "../sortable-panel"; + +interface VrfcErrPanelProps { + params: DashboardSearchParams | null; +} + +export function VrfcErrPanel({ params }: VrfcErrPanelProps) { + const { dragHandleProps, isDragging } = useDragHandle(); + const vrfcErrQuery = useDashboardVrfcErrCount(params); + const ecptReqQuery = useDashboardEcptReqCount(params); + + return ( +
+
+

검증오류 및 예외

+
+
+
+ {/* 검증오류 수 카드 */} +
+
+ + 검증오류 수 +
+ {vrfcErrQuery.isLoading && ( +
-
+ )} + {vrfcErrQuery.error && ( +
+ 데이터를 불러올 수 없습니다. +
+ )} + {vrfcErrQuery.data && ( +
+ {vrfcErrQuery.data.vrfcErrCount.toLocaleString()} +
+ )} +
+ + {/* 예외신청 수 카드 */} +
+
+ + 예외신청 수 +
+ {ecptReqQuery.isLoading && ( +
-
+ )} + {ecptReqQuery.error && ( +
+ 데이터를 불러올 수 없습니다. +
+ )} + {ecptReqQuery.data && ( +
+ {ecptReqQuery.data.ecptReqCount.toLocaleString()} +
+ )} +
+
+
+
+ ); +} diff --git a/apps/st/src/pages/statistics/ui/panels/vrfc-report-panel.tsx b/apps/st/src/pages/statistics/ui/panels/vrfc-report-panel.tsx new file mode 100644 index 000000000..193e71a37 --- /dev/null +++ b/apps/st/src/pages/statistics/ui/panels/vrfc-report-panel.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useDashboardVrfcReport } from "@/entities/statistics"; +import type { DashboardSearchParams } from "@/entities/statistics/model/dashboard/dashboard-interface"; + +import { useDragHandle } from "../sortable-panel"; + +interface VrfcReportPanelProps { + params: DashboardSearchParams | null; +} + +export function VrfcReportPanel({ params }: VrfcReportPanelProps) { + const { dragHandleProps, isDragging } = useDragHandle(); + const { data, isLoading, error } = useDashboardVrfcReport(params); + + return ( +
+
+

이상치 선별보고서

+
+
+ {isLoading && ( +
로딩 중...
+ )} + {error && ( +
+ 데이터를 불러올 수 없습니다. +
+ )} + {data && ( +
+ {/* TBD: typeCounts 표시 방법 (탭? 필터?) */} + {data.typeCounts && + Object.keys(data.typeCounts).length > 0 && ( +
+ {Object.entries(data.typeCounts).map( + ([type, count]) => ( + + {type}: {count} + + ), + )} +
+ )} + + {/* TBD: 테이블 컬럼 정의, 증감 표시 방법 */} +
+ {data.list.length === 0 ? ( +
+ 이상치 항목이 없습니다. +
+ ) : ( +
+ {data.list.map((item, index) => ( +
+
+
+ {item.statsNm} +
+ {/* TBD: vrfcCd 값별 표시 레이블 */} + + {item.vrfcCd} + +
+
+ {item.rsnExpln} +
+
+ {item.admdstNm} + + {/* TBD: 증감 표시 방법 (nowVlCn - compVlCn 계산?) */} + 현재: {item.nowVlCn} / 비교:{" "} + {item.compVlCn} + +
+ {/* TBD: 등급/점수/단계/업데이트 시각 필드 미제공 */} +
+ ))} +
+ )} +
+
+ )} +
+
+ ); +} diff --git a/apps/st/src/pages/statistics/ui/sortable-panel.tsx b/apps/st/src/pages/statistics/ui/sortable-panel.tsx new file mode 100644 index 000000000..89789d54b --- /dev/null +++ b/apps/st/src/pages/statistics/ui/sortable-panel.tsx @@ -0,0 +1,60 @@ +import { createContext, useContext } from "react"; + +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +interface SortablePanelProps { + id: string; + children: React.ReactNode; + className?: string; +} + +interface DragHandleContextValue { + dragHandleProps: React.HTMLAttributes; + isDragging: boolean; +} + +const DragHandleContext = createContext(null); + +export function SortablePanel({ id, children, className }: SortablePanelProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + const contextValue: DragHandleContextValue = { + dragHandleProps: { ...attributes, ...listeners }, + isDragging, + }; + + return ( + +
+ {children} +
+
+ ); +} + +export function useDragHandle() { + const context = useContext(DragHandleContext); + + if (!context) { + return { + dragHandleProps: {}, + isDragging: false, + }; + } + + return context; +} diff --git a/apps/st/src/shared/config/api.ts b/apps/st/src/shared/config/api.ts index 73aa77322..318bc6656 100644 --- a/apps/st/src/shared/config/api.ts +++ b/apps/st/src/shared/config/api.ts @@ -74,6 +74,7 @@ export const ENDPOINT = { INPUTVERIFY: "/st-input-service/api/v1/verify", TOTAL: "/st-management-service/api/v1/stats", APPROVAL: "/st-management-service/api/stats", + DASHBOARD: "/st-management-service/api/v1/dashboard", }, STATVERIFY_SERVICE: { VRFCTGT_MNG: "/statverify-service/api/v1/vrfc-trgt-mng", diff --git a/apps/st/src/shared/lib/use-data-table.tsx b/apps/st/src/shared/lib/use-data-table.tsx index 51638f746..906518f49 100644 --- a/apps/st/src/shared/lib/use-data-table.tsx +++ b/apps/st/src/shared/lib/use-data-table.tsx @@ -1,42 +1,46 @@ import type { HTMLAttributes } from "react"; + import type { UseQueryResult } from "@tanstack/react-query"; -import { useReactTable, type TableOptions } from "@tanstack/react-table"; +import { type TableOptions, useReactTable } from "@tanstack/react-table"; + import { DataTable } from "@common/components"; -import { NoData } from "../ui/no-data"; + import type { PageableList } from "../model"; +import { NoData } from "../ui/no-data"; + import { matchQueryStatus } from "./match-query-status"; type UseDataTableOptions = { - query: UseQueryResult>; - tableOptions: Omit, "data" | "rowCount">; - dataTableOptions?: DataTableOptions; + query: UseQueryResult>; + tableOptions: Omit, "data" | "rowCount">; + dataTableOptions?: DataTableOptions; }; type DataTableOptions = { - variant?: "default" | "secondary" | "line"; - className?: HTMLAttributes["className"]; - viewPagination?: boolean; - onRowClick?: (row: T) => void; + variant?: "default" | "secondary" | "line"; + className?: HTMLAttributes["className"]; + viewPagination?: boolean; + onRowClick?: (row: T) => void; }; export const useDataTable = ({ - query, - tableOptions, - dataTableOptions, + query, + tableOptions, + dataTableOptions, }: UseDataTableOptions) => { - const table = useReactTable({ - ...tableOptions, - data: query.data?.content ?? [], - rowCount: query.data?.totalElements ?? 0, - }); + const table = useReactTable({ + ...tableOptions, + data: query.data?.content ?? [], + rowCount: query.data?.totalElements ?? 0, + }); - return { - tableComponent: matchQueryStatus(query, { - Empty: , - Success: () => { - return ; - }, - }), - tableInstance: table, - }; + return { + tableComponent: matchQueryStatus(query, { + Empty: , + Success: () => { + return ; + }, + }), + tableInstance: table, + }; }; diff --git a/apps/st/src/shared/lib/use-user-store.ts b/apps/st/src/shared/lib/use-user-store.ts index 748caed4c..4dc360dfe 100644 --- a/apps/st/src/shared/lib/use-user-store.ts +++ b/apps/st/src/shared/lib/use-user-store.ts @@ -1,14 +1,16 @@ -import { useStore } from "zustand"; import { useContext } from "react"; -import type { UserStore } from "../store/user-store"; + +import { useStore } from "zustand"; + import { StoreContext } from "../providers/store-provider"; +import type { UserStore } from "../store/user-store"; export const useUserStore = (selector: (store: UserStore) => T): T => { - const storeContext = useContext(StoreContext); + const storeContext = useContext(StoreContext); - if (!storeContext) { - throw new Error("useUserStore must be used within StoreProvider"); - } + if (!storeContext) { + throw new Error("useUserStore must be used within StoreProvider"); + } - return useStore(storeContext.userStore, selector); + return useStore(storeContext.userStore, selector); }; diff --git a/apps/st/src/shared/store/user-store.ts b/apps/st/src/shared/store/user-store.ts index 31740697e..69d46ad11 100644 --- a/apps/st/src/shared/store/user-store.ts +++ b/apps/st/src/shared/store/user-store.ts @@ -1,59 +1,61 @@ import { createStore } from "zustand/vanilla"; export interface IUserInfo { - regDt: string; - mdfcnDt: string; - rgtr: string; - mdfr: string; - adminRoleYn: string; - userUnqId: string; - userId: string; - userName: string; - email: string; - encryptedPassword: string; - refreshToken: string; - userStateCode: string; - lastLoginDate: string; - loginFailCount: number; - pswdHnt: string; - pswdAns: string; - mphn: string; - telno: string; - zip: string; - addr: string; - daddr: string; - laeSeCd: string; - /** 기관 구분 코드 — 통계 공통 조회 등 query `instSeCd`로 전달 */ - instSeCd?: string; - ogdpSeCd: string; - aprvYn: string; - autzrId: string; - expryYmd: string; - lastPswdChgDt: string; - googleId: string; - kakaoId: string; - naverId: string; - isSocialUser: boolean; - hasPassword: boolean; + regDt: string; + mdfcnDt: string; + rgtr: string; + mdfr: string; + adminRoleYn: string; + userUnqId: string; + userId: string; + userName: string; + email: string; + encryptedPassword: string; + enoCd: string; + refreshToken: string; + userStateCode: string; + lastLoginDate: string; + loginFailCount: number; + pswdHnt: string; + pswdAns: string; + mphn: string; + telno: string; + zip: string; + addr: string; + daddr: string; + dtlInstCd: string; + laeSeCd: string; + /** 기관 구분 코드 — 통계 공통 조회 등 query `instSeCd`로 전달 */ + instSeCd?: string; + ogdpSeCd: string; + aprvYn: string; + autzrId: string; + expryYmd: string; + lastPswdChgDt: string; + googleId: string; + kakaoId: string; + naverId: string; + isSocialUser: boolean; + hasPassword: boolean; } export type UserState = { - userInfo: IUserInfo | null; + userInfo: IUserInfo | null; }; export type UserActions = { - setUserInfo: (userInfo: IUserInfo | null) => void; + setUserInfo: (userInfo: IUserInfo | null) => void; }; export type UserStore = UserState & UserActions; export const defaultInitState: UserState = { - userInfo: null, + userInfo: null, }; export const createUserStore = (initState: UserState = defaultInitState) => { - return createStore()((set) => ({ - ...initState, - setUserInfo: (userInfo: IUserInfo | null) => set(() => ({ userInfo })), - })); + return createStore()((set) => ({ + ...initState, + setUserInfo: (userInfo: IUserInfo | null) => set(() => ({ userInfo })), + })); }; From 78defdd05059ee85ff8106d1bf924b073d0899bf Mon Sep 17 00:00:00 2001 From: bicochan Date: Tue, 28 Apr 2026 10:59:10 +0900 Subject: [PATCH 3/6] =?UTF-8?q?[st]=20feat:=20HB=20=ED=86=B5=EA=B3=84=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../verification/detail/Indicators.tsx | 18 +++---- .../verification/detail/RegionalGrid.tsx | 52 ++++++------------- .../verification/detail/ScatterChart.tsx | 10 ++-- .../statistics/model/regional-grid-columns.ts | 6 +-- 4 files changed, 31 insertions(+), 55 deletions(-) diff --git a/apps/st/src/components/statistics/verification/detail/Indicators.tsx b/apps/st/src/components/statistics/verification/detail/Indicators.tsx index f311e1667..30cc56552 100644 --- a/apps/st/src/components/statistics/verification/detail/Indicators.tsx +++ b/apps/st/src/components/statistics/verification/detail/Indicators.tsx @@ -65,21 +65,15 @@ const Indicators: React.FC = ({ anlsSn, vrfcTrgtSn }) => { return (
- + + + - - - - + +
diff --git a/apps/st/src/components/statistics/verification/detail/RegionalGrid.tsx b/apps/st/src/components/statistics/verification/detail/RegionalGrid.tsx index 8f5c420da..eeea43b3a 100644 --- a/apps/st/src/components/statistics/verification/detail/RegionalGrid.tsx +++ b/apps/st/src/components/statistics/verification/detail/RegionalGrid.tsx @@ -46,11 +46,6 @@ const RegionalGrid: React.FC = ({ selectedRowId, appliedFilter, }) => { - const [selectedSido, setSelectedSido] = useState(""); - const [selectedGugun, setSelectedGugun] = useState(""); - const [selectedStatus, setSelectedStatus] = useState(""); - const [showAllOnEmptyFilter, setShowAllOnEmptyFilter] = - useState(true); const [isDownloading, setIsDownloading] = useState(false); const [gridRowData, setGridRowData] = useState< @@ -58,7 +53,7 @@ const RegionalGrid: React.FC = ({ >([]); const { data: sidoData } = useStAnlsSidoCombo(); - const { data: gugunData } = useStAnlsSigunguCombo(selectedSido); + const { data: gugunData } = useStAnlsSigunguCombo(appliedFilter.sido ?? ""); const sidoOptions = sidoData ?? EMPTY_ADST_LIST; const gugunOptions = gugunData ?? EMPTY_ADST_LIST; @@ -69,14 +64,7 @@ const RegionalGrid: React.FC = ({ const rowData = useMemo(() => { let data = (resultData?.content ?? []) as StAnlsRsltResponse[]; - - if ( - !appliedFilter.sido && - !appliedFilter.gugun && - !appliedFilter.status - ) { - data = showAllOnEmptyFilter ? data : []; - } else { + { const sidoCd = appliedFilter.sido; if (sidoCd) { data = data.filter((item) => @@ -116,32 +104,18 @@ const RegionalGrid: React.FC = ({ })); return mappedData; - }, [resultData, appliedFilter, showAllOnEmptyFilter]); + }, [resultData, appliedFilter]); const handleSidoChange = (val: string) => { - setSelectedSido(val); - setSelectedGugun(""); - }; - - const handleSearch = () => { - onFilterChange({ - sido: selectedSido, - gugun: selectedGugun, - status: selectedStatus, - }); - setShowAllOnEmptyFilter(false); + onFilterChange({ ...appliedFilter, sido: val, gugun: "" }); }; const handleReset = () => { - setSelectedSido(""); - setSelectedGugun(""); - setSelectedStatus(""); onFilterChange({ sido: "", gugun: "", status: "", }); - setShowAllOnEmptyFilter(true); }; const handleDownload = async () => { @@ -182,7 +156,7 @@ const RegionalGrid: React.FC = ({
+ setValues({ ...values, isSummary: value }) + } + > + + + + + 선택 안함 + {[1,2,3,4,5,6,7,8,9,10].map((i) => ( + {`${i}개년`} + )) + } + + +
+ )} {mergedProps.order && (
diff --git a/apps/st/src/components/module/SidoSggFacilityNameSearchForm.tsx b/apps/st/src/components/module/SidoSggFacilityNameSearchForm.tsx index d8647037e..82a1bae17 100644 --- a/apps/st/src/components/module/SidoSggFacilityNameSearchForm.tsx +++ b/apps/st/src/components/module/SidoSggFacilityNameSearchForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import useSWR from "swr"; import { ValueType } from "realgrid"; @@ -29,8 +29,13 @@ import type { ColumnInfo } from "../type/DataGrid.type"; import type { SeweragePipeFacilitiesListRequest, SeweragePipeFacilityAdminAreaListRequest, + SeweragePipeFactConnectionSaveRequest, + SeweragePipeFactConnectionSaveItem, } from "@/entities/statistics/model/input/statistics-sewage-pipe-input-interface"; import { statisticsInputSewPipeApiService } from "@/entities/statistics/api/input/statistics-sewage-pipe-input-service"; +import type { SearchFormValues } from "../type/SearchForm.type"; +import { doNameList } from "../util/column/input/DoList"; +import { getUserInfo } from "../../../../../packages/common/components/src/api"; /** 다이얼로그 좌측: 처리시설 정보 (순번 없음) */ const treatmentFacilityColumnInfo: ColumnInfo = { @@ -48,21 +53,35 @@ const treatmentFacilityColumnInfo: ColumnInfo = { editable: false, }, { - name: "fctNm", - fieldName: "fctNm", + name: "fcltNm", + fieldName: "fcltNm", header: { text: "시설명" }, editable: false, }, { - name: "fctCpc", - fieldName: "fctCpc", + name: "fcltCd", + fieldName: "fcltCd", + header: { text: "시설코드" }, + editable: false, + visible: false, + }, + { + name: "fcltSn", + fieldName: "fcltSn", + header: { text: "시설일련번호" }, + editable: false, + visible: false, + }, + { + name: "fcltCpct", + fieldName: "fcltCpct", header: { text: "시설용량(m³/일)" }, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false, }, ], - columnLayout: ["ctpvNm", "sggNm", "fctNm", "fctCpc"], + columnLayout: ["ctpvNm", "sggNm", "fcltNm", "fcltCpct", "fcltCd"], }; /** 다이얼로그 우측: 행정구역 정보 (순번 없음, 헤더 체크박스는 showHeaderCheckbox) */ @@ -103,12 +122,26 @@ const getSggAllListFetcher = async ( type SidoSggFacilityNameSearchFormProps = { /** 상위(SewagePipeInp 등) 검색 조건의 통계연도 — facilitiesList 등 API 필수값 */ statsYr?: string; + /** 상위(SewagePipeInp) 마지막 검색 조건 */ + initialSearchValues?: Pick | null; + /** 저장 성공 후 부모 탭 데이터 재조회 훅 */ + onSaveSuccess?: () => Promise | void; }; const SidoSggFacilityNameSearchForm = ({ statsYr, + initialSearchValues, + onSaveSuccess, }: SidoSggFacilityNameSearchFormProps) => { const { setMessage: alert } = useAlertStore((state) => state); + const initialSidoValue = + initialSearchValues?.sido && initialSearchValues.sido !== "ALL" + ? initialSearchValues.sido + : "ALL"; + const initialGugunValue = + initialSearchValues?.gugun && initialSearchValues.gugun !== "ALL" + ? initialSearchValues.gugun + : "ALL"; const { data: sidoList } = useSWR>( "sido-list", @@ -134,9 +167,9 @@ const SidoSggFacilityNameSearchForm = ({ }, ); - const [sido, setSido] = useState("ALL"); - const [gugun, setGugun] = useState("ALL"); - const [fctNm, setFctNm] = useState(""); + const [sido, setSido] = useState(initialSidoValue); + const [gugun, setGugun] = useState(initialGugunValue); + const [fcltNm, setFcltNm] = useState(""); const [isLoading, setIsLoading] = useState(false); const [treatmentFacilityRows, setTreatmentFacilityRows] = useState< @@ -145,6 +178,10 @@ const SidoSggFacilityNameSearchForm = ({ const [adminRegionRows, setAdminRegionRows] = useState< Record[] >([]); + const [selectedFacilityRow, setSelectedFacilityRow] = useState< + Record | null + >(null); + const didAutoFetchRef = useRef(false); const sggKey: [string, string] | null = sido && sido !== "ALL" ? ["sgg-all", sido] : null; @@ -198,7 +235,7 @@ const SidoSggFacilityNameSearchForm = ({ alert("시도를 선택해주세요."); return; } - const trimmedFclty = fctNm.trim(); + const trimmedFclty = fcltNm.trim(); const sewerBzmnCd = resolveSewerBzmnCd(); @@ -225,13 +262,15 @@ const SidoSggFacilityNameSearchForm = ({ list.map((item) => ({ ctpvNm: item.ctpvNm, sggNm: item.sggNm, - fctNm: item.bscFctNm, - fctCpc: item.fctCpc, - bscFctCd: item.bscFctCd, + fcltNm: item.fcltNm, + fcltCpct: item.fcltCpct, + fcltCd: item.fcltCd, + fcltSn: item.fcltSn, admdstCd: item.admdstCd, statsYr: item.statsYr, })), ); + setSelectedFacilityRow(null); setAdminRegionRows([]); } catch { alert("처리시설 목록 조회 중 오류가 발생했습니다."); @@ -247,7 +286,7 @@ const SidoSggFacilityNameSearchForm = ({ alert("상단에서 통계연도로 조회한 뒤 이용해 주세요."); return; } - const swtrmtFcltCd = String(row.bscFctCd ?? "").trim(); + const swtrmtFcltCd = String(row.fcltCd ?? "").trim(); if (!swtrmtFcltCd) { alert("처리시설 코드가 없습니다."); return; @@ -261,13 +300,14 @@ const SidoSggFacilityNameSearchForm = ({ const params: SeweragePipeFacilityAdminAreaListRequest = { statsYr: statsYr.trim(), swtrmtFcltCd, - sewerBzmnCd: String(row.admdstCd ?? "").trim(), + sewerBzmnCd: initialSearchValues?.bsnmCd || '', ctpvNm, - sggNm, + ...(doNameList.includes(ctpvNm) ? { sggNm } : {}), }; try { setIsLoading(true); + setSelectedFacilityRow(row); const response = await statisticsInputSewPipeApiService.getFacilityAdminAreaList( params, @@ -283,6 +323,8 @@ const SidoSggFacilityNameSearchForm = ({ sggNm: item.sggNm, emdNm: item.admdstNm, admdstCd: item.admdstCd, + isConnection: item.isConnection, + __checked: item.isConnection === "1" ? "1" : "0", })), ); } catch { @@ -293,9 +335,172 @@ const SidoSggFacilityNameSearchForm = ({ }; const handleSaveAdminRegions = () => { - alert("저장 API 연동 예정입니다."); + if (!statsYr?.trim()) { + alert("상단에서 통계연도로 조회한 뒤 이용해 주세요."); + return; + } + if (!selectedFacilityRow) { + alert("처리시설을 먼저 선택해주세요."); + return; + } + + const hasChanged = adminRegionRows.some( + (row) => + (row.__checked === "1" && row.isConnection !== "1") || + (row.__checked !== "1" && row.isConnection === "1"), + ); + + const saveList: SeweragePipeFactConnectionSaveItem[] = adminRegionRows + .filter((row) => row.__checked === "1") + .map((row) => ({ + admdstCd: String(row.admdstCd ?? ""), + admdstNm: String(row.emdNm ?? ""), + })) + .filter((row) => row.admdstCd && row.admdstNm); + + const deleteList = adminRegionRows + .filter((row) => row.__checked !== "1" && row.isConnection === "1") + .map((row) => String(row.admdstCd ?? "")) + .filter(Boolean); + + if (!hasChanged) { + alert("변경된 연결 정보가 없습니다."); + return; + } + + const run = async () => { + try { + setIsLoading(true); + const userInfo = await getUserInfo(); + const request: SeweragePipeFactConnectionSaveRequest = { + ctpvNm: String(selectedFacilityRow.ctpvNm ?? ""), + sggNm: String(selectedFacilityRow.sggNm ?? ""), + fcltSn: String(selectedFacilityRow.fcltSn ?? ""), + statsYr: statsYr.trim(), + swtrmtFcltCd: String(selectedFacilityRow.fcltCd ?? ""), + swtrmtFcltNm: String(selectedFacilityRow.fcltNm ?? ""), + prcsFcltCpct: Number(selectedFacilityRow.fcltCpct ?? 0), + sewerBzmnCd: initialSearchValues?.bsnmCd?.trim() ?? "", + userId: userInfo.userUnqId, + saveList, + deleteList, + }; + + const response = + await statisticsInputSewPipeApiService.saveFactConnection( + request, + ); + + if (response.status !== "success") { + alert(response.message ?? "연결관계 저장에 실패했습니다."); + return; + } + + setAdminRegionRows((prev) => + prev.map((row) => ({ + ...row, + isConnection: row.__checked === "1" ? "1" : "0", + })), + ); + await onSaveSuccess?.(); + alert("저장되었습니다."); + } catch { + alert("연결관계 저장 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + void run(); }; + const handleAdminRegionItemChecked = ( + itemIndex: number, + checked: boolean, + ) => { + setAdminRegionRows((prev) => + prev.map((row, idx) => + idx === itemIndex + ? { ...row, __checked: checked ? "1" : "0" } + : row, + ), + ); + }; + + const handleAdminRegionItemAllChecked = (checked: boolean) => { + setAdminRegionRows((prev) => + prev.map((row) => ({ ...row, __checked: checked ? "1" : "0" })), + ); + }; + + useEffect(() => { + if (!initialSearchValues?.sido || initialSearchValues.sido === "ALL") return; + setSido(initialSearchValues.sido); + setGugun( + initialSearchValues.gugun && initialSearchValues.gugun !== "ALL" + ? initialSearchValues.gugun + : "ALL", + ); + }, [initialSearchValues?.sido, initialSearchValues?.gugun]); + + useEffect(() => { + if (didAutoFetchRef.current) return; + if (!statsYr?.trim()) return; + if (!initialSearchValues?.sido || initialSearchValues.sido === "ALL") return; + + const nextSido = initialSearchValues.sido; + const nextGugun = + initialSearchValues.gugun && initialSearchValues.gugun !== "ALL" + ? initialSearchValues.gugun + : "ALL"; + + didAutoFetchRef.current = true; + setSido(nextSido); + setGugun(nextGugun); + + const params: SeweragePipeFacilitiesListRequest = { + statsYr: statsYr.trim(), + ...(initialSearchValues.bsnmCd?.trim() + ? { sewerBzmnCd: initialSearchValues.bsnmCd.trim() } + : {}), + ctpvNm: nextSido, + ...(nextGugun !== "ALL" ? { sggNm: nextGugun } : {}), + }; + + const run = async () => { + try { + setIsLoading(true); + const response = + await statisticsInputSewPipeApiService.getFacilitiesList(params); + if (response.status !== "success") { + alert(response.message ?? "처리시설 목록 조회에 실패했습니다."); + return; + } + const list = response.data ?? []; + setTreatmentFacilityRows( + list.map((item) => ({ + ctpvNm: item.ctpvNm, + sggNm: item.sggNm, + fcltNm: item.fcltNm, + fcltCpct: item.fcltCpct, + fcltCd: item.fcltCd, + fcltSn: item.fcltSn, + admdstCd: item.admdstCd, + statsYr: item.statsYr, + })), + ); + setSelectedFacilityRow(null); + setAdminRegionRows([]); + } catch { + alert("처리시설 목록 조회 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + void run(); + }, [statsYr, initialSearchValues, alert]); + return (
@@ -346,8 +551,8 @@ const SidoSggFacilityNameSearchForm = ({ setFctNm(e.target.value)} + value={fcltNm} + onChange={(e) => setFcltNm(e.target.value)} placeholder="시설명 입력" />
@@ -396,6 +601,8 @@ const SidoSggFacilityNameSearchForm = ({ rowData={adminRegionRows} setRowData={setAdminRegionRows} showHeaderCheckbox + onItemChecked={handleAdminRegionItemChecked} + onItemAllChecked={handleAdminRegionItemAllChecked} compactFullWidth // compactGridHeightClass="h-[min(40vh,28rem)] min-h-[16rem]" /> diff --git a/apps/st/src/components/statistics/input/SewagePipeInp.tsx b/apps/st/src/components/statistics/input/SewagePipeInp.tsx index 91eaaacc3..5ea57e205 100644 --- a/apps/st/src/components/statistics/input/SewagePipeInp.tsx +++ b/apps/st/src/components/statistics/input/SewagePipeInp.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useRef } from "react"; +import { useState, useMemo, useRef, useEffect } from "react"; import SearchForm from "@/components/module/SearchForm"; import DataGrid from "@/components/module/DataGrid"; @@ -7,41 +7,44 @@ import GridTopActionButton from "@/components/module/GridTopActionButton"; import SubmitButton from "@/components/button/SumitButton"; import SaveButton from "@/components/button/SaveButton"; import LastYearDataButton from "@/components/button/LastYearDataButton"; -import SubmitExceptionButton from "@/components/button/SubmitExceptionButton"; +import VerificationResultButton from "@/components/button/VerificationResultButton"; +import { VerificationResultHistoryDialog } from "@/components/module/VerificationResultHistoryDialog"; import { SewagePipeInpColumnInfoList } from "@/components/util/column/input/sewagePipe/SewagePipeInpColumn"; import type { ColumnInfo, ColumnInfoList, ColumnConfig } from "@/components/type/DataGrid.type"; import type { SearchFormValues } from "@/components/type/SearchForm.type"; -import { useAlertStore } from "@/shared/lib"; +import { useAlertStore, useGlobalProgressStore } from "@/shared/lib"; import { useAdminStore } from "@/store/admin-store"; import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { doList } from "@/components/util/column/input/DoList"; import { statisticsInputSewPipeApiService } from "@/entities/statistics/api/input/statistics-sewage-pipe-input-service"; import type { + SeweragePipeInputFactInstallStatusSaveRequest, SeweragePipeInputRegionalFactStatusSaveRequest, SeweragePipeInputStatusInfo, + SeweragePipeInputVerificationResult, SeweragePipeRequestParams, } from "@/entities/statistics/model/input/statistics-sewage-pipe-input-interface"; +import { isInputPeriodCurrent } from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface"; +import { FactInstallStatusInpColumn } from "@/components/util/column/input/sewagePipe/FactInstallStatusInpColumn"; import { RegionalFactStatusInpColumn } from "@/components/util/column/input/sewagePipe/RegionalFactStatusInpColumn"; import { toGridRows } from "@/components/util/grid/gridFunction"; import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components"; import { getUserInfo } from "../../../../../../packages/common/components/src/api"; -const applyEnterableFromStatus = ( +/** 탭별 status + 입력기간으로 편집 가능 여부 */ +const computeTabEnterable = ( statusInfo: SeweragePipeInputStatusInfo | null | undefined, periodEnterable: boolean, - setIsEnterable: (v: boolean) => void -) => { +): boolean => { if (statusInfo?.laeSbmsnSttsCd) { if (["2", "3"].includes(statusInfo.laeSbmsnSttsCd)) { - setIsEnterable(false); - } else { - setIsEnterable(periodEnterable); + return false; } - } else { - setIsEnterable(periodEnterable); + return periodEnterable; } + return periodEnterable; }; function buildSewagePipeSearchParam( @@ -58,6 +61,10 @@ function buildSewagePipeSearchParam( } /** ST00020006(연결)은 행이 좌·우 그리드 키에만 있음 */ +type GridRowWithVerification = Record & { + verificationResult?: SeweragePipeInputVerificationResult; +}; + function tabRowDataExists( m: Map[]>, tabKey: string, @@ -74,6 +81,43 @@ function tabRowDataExists( return Array.isArray(rows) && rows.length > 0; } +/** 조회 직후 해당 탭에만 저장하는 상단 툴바 노출 조건 */ +type TabToolbarSnapshot = { + showSaveInToolbar: boolean; + showEnterableToolbarButtons: boolean; + showActionToolbarCard: boolean; +}; + +function buildGugunOkForToolbar(values: SearchFormValues | null): boolean { + return doList.includes(values?.bsnmCd || "") + ? Boolean(values?.gugun && values.gugun !== "ALL") + : true; +} + +/** + * @param searchContext 조회에 사용된 검색값(해당 요청 기준으로 구군·사업자 조건 반영) + * @param treatAsSearched (hasRow || 이 값) 일 때 카드 후보 — load 내부에서는 searchDoneRef 반영용 + */ +function computeTabToolbarSnapshot( + statusInfo: SeweragePipeInputStatusInfo | null, + hasRowData: boolean, + treatAsSearched: boolean, + searchContext: SearchFormValues | null, + periodEnterable: boolean, + isAdminInfo: boolean, +): TabToolbarSnapshot { + const gugunOk = buildGugunOkForToolbar(searchContext); + const enterable = computeTabEnterable(statusInfo, periodEnterable); + const showSaveInToolbar = (enterable || isAdminInfo) && gugunOk; + const showEnterableToolbarButtons = enterable && gugunOk; + const showActionToolbarCard = + (hasRowData || treatAsSearched) && + (showSaveInToolbar || + (statusInfo?.laeSbmsnSttsCd === "2" && + (isAdminInfo || (enterable && gugunOk)))); + return { showSaveInToolbar, showEnterableToolbarButtons, showActionToolbarCard }; +} + // 하수도 통계 관리 > 02. 하수관로 const SewagePipeInp = () => { const [rowDataMap, setRowDataMap] = useState[]>>(new Map()); @@ -81,19 +125,31 @@ const SewagePipeInp = () => { const [activeTab, setActiveTab] = useState(SewagePipeInpColumnInfoList[0]?.tabKey ?? "ST00020001"); const [isLoading, setIsLoading] = useState(false); const [searchValues, setSearchValues] = useState(null); - const [isEnterable, setIsEnterable] = useState(true); const [lastSearchedYear, setLastSearchedYear] = useState(null); const [lastSearchedValues, setLastSearchedValues] = useState(null); - const [statusInfo, setStatusInfo] = useState(null); + const [tabStatusInfoMap, setTabStatusInfoMap] = useState>(new Map()); // const [showDailog, setShowDailog] = useState(false); // const [isExceptionSubmitFlow, setIsExceptionSubmitFlow] = useState(false); - const [_statsCd, setStatsCd] = useState(''); + const [statsCd, setStatsCd] = useState(''); + const [tabStatsCdMap, setTabStatsCdMap] = useState>(new Map()); + const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false); /** API 연결 전에도 검색 후 툴바 등 UI를 쓸 수 있게 함 */ const [hasSearched, setHasSearched] = useState(false); - // const { confirm } = useConfirm(); + /** checkInputPeriod — columnInfo useMemo가 입력기간 변경을 반영하도록 state로도 유지 */ + const [inputPeriodEnterable, setInputPeriodEnterable] = useState(true); + /** 탭별 조회 시점의 상단 버튼 노출 조건(해당 탭을 다시 조회할 때만 그 탭 항목 갱신) */ + const [toolbarSnapshotByTab, setToolbarSnapshotByTab] = useState>( + () => new Map(), + ); + const { confirm } = useConfirm(); + const showGlobalProgress = useGlobalProgressStore((state) => state.show); + const setGlobalProgress = useGlobalProgressStore((state) => state.setProgress); + const hideGlobalProgress = useGlobalProgressStore((state) => state.hide); + const globalProgressVisible = useGlobalProgressStore((state) => state.visible); const { isAdminInfo } = useAdminStore(); const getGridDataRef = useRef<(() => Record[]) | null>(null); + const getFactInstallGridDataRef = useRef<(() => Record[]) | null>(null); const searchParamRef = useRef(null); /** 검색 시 checkInputPeriod 결과 — 탭 전환 지연 조회 시 편집 가능 여부와 동일하게 맞춤 */ const periodEnterableRef = useRef(true); @@ -102,23 +158,41 @@ const SewagePipeInp = () => { const lazyTabLoggedRef = useRef>(new Set()); const firstTabKey = SewagePipeInpColumnInfoList[0]?.tabKey ?? "ST00020001"; + const activeTabRef = useRef(firstTabKey); + + useEffect(() => { + activeTabRef.current = activeTab; + }, [activeTab]); + + const syncTopStatusIfStillOnTab = (loadedTabKey: string, nextStatsCd: string) => { + if (loadedTabKey === activeTabRef.current) { + setStatsCd(nextStatsCd); + } + }; const handleReset = () => { setRowDataMap(new Map()); - setStatusInfo(null); + setTabStatusInfoMap(new Map()); + setTabStatsCdMap(new Map()); setLastSearchedYear(null); setLastSearchedValues(null); setHasSearched(false); + setInputPeriodEnterable(true); + setToolbarSnapshotByTab(new Map()); searchParamRef.current = null; searchDoneRef.current = false; lazyTabLoggedRef.current.clear(); periodEnterableRef.current = true; + activeTabRef.current = firstTabKey; setActiveTab(firstTabKey); + setStatsCd(""); }; const columnInfo = useMemo(() => { - const canEdit = isEnterable || isAdminInfo; return SewagePipeInpColumnInfoList.map((tab: ColumnInfoList) => { + const canEdit = + computeTabEnterable(tabStatusInfoMap.get(tab.tabKey) ?? null, inputPeriodEnterable) || + isAdminInfo; const applyEditable = (cols: ColumnConfig[] | undefined) => (cols ?? []).map((col: ColumnConfig) => canEdit @@ -138,14 +212,36 @@ const SewagePipeInp = () => { columns: applyEditable(tab.columns), }; }); - }, [isEnterable, isAdminInfo]); + }, [tabStatusInfoMap, isAdminInfo, inputPeriodEnterable]); + + const commitToolbarSnapshotForLoadedTab = ( + tabKey: string, + statusInfo: SeweragePipeInputStatusInfo | 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; + }); + }; /** mergeIntoExistingMap: true면 기존 맵에 병합(다른 탭 rowData·검색 후 다른 탭 캐시 유지). false면 맵 전체 교체 */ const loadSewagePipeActiveTabData = async ( tabKey: string, searchParam: SeweragePipeRequestParams, values: SearchFormValues, - checkEnterable: boolean, mergeIntoExistingMap: boolean, updateLastSearchedValues: boolean, ): Promise => { @@ -172,10 +268,16 @@ const SewagePipeInp = () => { ...d, })); applyRowMap((m) => m.set('ST00020001', toGridRows(convertedData))); - setStatusInfo(response.data.statusInfo); - setStatsCd(commonInfo.statsCd); - applyEnterableFromStatus(response.data.statusInfo, checkEnterable, setIsEnterable); + setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, response.data.statusInfo)); + setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd)); + syncTopStatusIfStillOnTab(tabKey, commonInfo.statsCd); if (updateLastSearchedValues) setLastSearchedValues(values); + commitToolbarSnapshotForLoadedTab( + tabKey, + response.data.statusInfo, + convertedData.length > 0, + values, + ); return true; } alert(response.message || '데이터 조회 중 오류가 발생했습니다.'); @@ -190,10 +292,16 @@ const SewagePipeInp = () => { ...d, })); applyRowMap((m) => m.set('ST00020002', toGridRows(convertedData))); - setStatusInfo(response.data.statusInfo); - setStatsCd(commonInfo.statsCd); - applyEnterableFromStatus(response.data.statusInfo, checkEnterable, setIsEnterable); + setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, response.data.statusInfo)); + setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd)); + syncTopStatusIfStillOnTab(tabKey, commonInfo.statsCd); if (updateLastSearchedValues) setLastSearchedValues(values); + commitToolbarSnapshotForLoadedTab( + tabKey, + response.data.statusInfo, + convertedData.length > 0, + values, + ); return true; } alert(response.message || '데이터 조회 중 오류가 발생했습니다.'); @@ -208,10 +316,16 @@ const SewagePipeInp = () => { ...d, })); applyRowMap((m) => m.set('ST00020003', toGridRows(convertedData))); - setStatusInfo(response.data.statusInfo); - setStatsCd(commonInfo.statsCd); - applyEnterableFromStatus(response.data.statusInfo, checkEnterable, setIsEnterable); + setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, response.data.statusInfo)); + setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd)); + syncTopStatusIfStillOnTab(tabKey, commonInfo.statsCd); if (updateLastSearchedValues) setLastSearchedValues(values); + commitToolbarSnapshotForLoadedTab( + tabKey, + response.data.statusInfo, + convertedData.length > 0, + values, + ); return true; } alert(response.message || '데이터 조회 중 오류가 발생했습니다.'); @@ -226,10 +340,16 @@ const SewagePipeInp = () => { ...d, })); applyRowMap((m) => m.set('ST00020004', toGridRows(convertedData))); - setStatusInfo(response.data.statusInfo); - setStatsCd(commonInfo.statsCd); - applyEnterableFromStatus(response.data.statusInfo, checkEnterable, setIsEnterable); + setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, response.data.statusInfo)); + setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd)); + syncTopStatusIfStillOnTab(tabKey, commonInfo.statsCd); if (updateLastSearchedValues) setLastSearchedValues(values); + commitToolbarSnapshotForLoadedTab( + tabKey, + response.data.statusInfo, + convertedData.length > 0, + values, + ); return true; } alert(response.message || '데이터 조회 중 오류가 발생했습니다.'); @@ -244,10 +364,16 @@ const SewagePipeInp = () => { ...d, })); applyRowMap((m) => m.set('ST00020005', toGridRows(convertedData))); - setStatusInfo(response.data.statusInfo); - setStatsCd(commonInfo.statsCd); - applyEnterableFromStatus(response.data.statusInfo, checkEnterable, setIsEnterable); + setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, response.data.statusInfo)); + setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd)); + syncTopStatusIfStillOnTab(tabKey, commonInfo.statsCd); if (updateLastSearchedValues) setLastSearchedValues(values); + commitToolbarSnapshotForLoadedTab( + tabKey, + response.data.statusInfo, + convertedData.length > 0, + values, + ); return true; } alert(response.message || '데이터 조회 중 오류가 발생했습니다.'); @@ -268,10 +394,16 @@ const SewagePipeInp = () => { m.set('treatmentPlantConnectionInp', toGridRows(facilityConvertedData)); m.set('adminAreaConnectionInp', toGridRows(adminConvertedData)); }); - setStatusInfo(response.data.statusInfo); - setStatsCd(commonInfo.statsCd); - applyEnterableFromStatus(response.data.statusInfo, checkEnterable, setIsEnterable); + setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, response.data.statusInfo)); + setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd)); + syncTopStatusIfStillOnTab(tabKey, commonInfo.statsCd); if (updateLastSearchedValues) setLastSearchedValues(values); + commitToolbarSnapshotForLoadedTab( + tabKey, + response.data.statusInfo, + facilityConvertedData.length > 0 || adminConvertedData.length > 0, + values, + ); return true; } alert(response.message || '데이터 조회 중 오류가 발생했습니다.'); @@ -281,7 +413,6 @@ const SewagePipeInp = () => { setRowDataMap(new Map()); } if (updateLastSearchedValues) setLastSearchedValues(values); - applyEnterableFromStatus(null, checkEnterable, setIsEnterable); return true; }; @@ -299,8 +430,21 @@ const SewagePipeInp = () => { return; } - const checkEnterable = periodResponse.data; - periodEnterableRef.current = checkEnterable; + const periodOk = isInputPeriodCurrent(periodResponse.data); + periodEnterableRef.current = periodOk; + setInputPeriodEnterable(periodOk); + + /** 연도가 바뀐 검색만 전 탭 캐시 초기화 + 첫 탭으로 이동. 연도 외 조건만 바뀌면 다른 탭은 이전 조회 데이터 유지 */ + const yearChangedFromLastSearch = + lastSearchedValues != null && + (lastSearchedValues.year ?? "") !== (values.year ?? ""); + if (yearChangedFromLastSearch) { + setRowDataMap(new Map()); + setTabStatusInfoMap(new Map()); + setTabStatsCdMap(new Map()); + setStatsCd(""); + setToolbarSnapshotByTab(new Map()); + } lazyTabLoggedRef.current.clear(); @@ -312,7 +456,15 @@ const SewagePipeInp = () => { setHasSearched(true); setLastSearchedYear(values.year ?? null); - await loadSewagePipeActiveTabData(activeTab, searchParam, values, checkEnterable, true, true); + const tabKeyToLoad = yearChangedFromLastSearch ? firstTabKey : activeTab; + + if (yearChangedFromLastSearch) { + activeTabRef.current = firstTabKey; + setActiveTab(firstTabKey); + } else { + activeTabRef.current = activeTab; + } + await loadSewagePipeActiveTabData(tabKeyToLoad, searchParam, values, true, true); } catch { alert('데이터 조회 중 오류가 발생했습니다.'); } finally { @@ -321,7 +473,9 @@ const SewagePipeInp = () => { }; const handleActiveTabChange = async (tabKey: string) => { + activeTabRef.current = tabKey; setActiveTab(tabKey); + setStatsCd(tabStatsCdMap.get(tabKey) ?? ""); const hasActiveTabData = tabRowDataExists(rowDataMap, tabKey); @@ -339,14 +493,7 @@ const SewagePipeInp = () => { try { setIsLoading(true); - const ok = await loadSewagePipeActiveTabData( - tabKey, - searchParam, - values, - periodEnterableRef.current, - true, - false, - ); + const ok = await loadSewagePipeActiveTabData(tabKey, searchParam, values, true, false); if (ok) lazyTabLoggedRef.current.add(tabKey); } catch { alert('데이터 조회 중 오류가 발생했습니다.'); @@ -355,11 +502,6 @@ const SewagePipeInp = () => { } }; - const SubmitExceptionFunc = () => { - console.log('submit exception'); - // 예외 제출 로직 - }; - const getLastYearDataFunc = async () => { const values = lastSearchedValues ?? searchValues; if (!values?.sido || values.sido === 'ALL') { @@ -373,7 +515,7 @@ const SewagePipeInp = () => { try { setIsLoading(true); const param = buildSewagePipeSearchParam(values, { mode: 'PRE' }); - await loadSewagePipeActiveTabData(activeTab, param, values, periodEnterableRef.current, true, false); + await loadSewagePipeActiveTabData(activeTab, param, values, true, false); } catch { alert('데이터 조회 중 오류가 발생했습니다.'); } finally { @@ -386,115 +528,237 @@ const SewagePipeInp = () => { // 제출 로직 }; - const saveFunc = async () => { - if (activeTab !== "ST00020001") { + const refreshConnectionStatusAfterPopupSave = async () => { + const searchParam = searchParamRef.current; + const values = lastSearchedValues ?? searchValues; + if (!searchParam || !values) return; + await loadSewagePipeActiveTabData("ST00020006", searchParam, values, true, false); + }; + + const openVerificationHistoryDialog = () => { + const activeTabStatsCd = tabStatsCdMap.get(activeTab) ?? statsCd; + if (!searchValues?.year || !searchValues?.sido || !searchValues?.bsnmCd || !activeTabStatsCd) { + alert("먼저 조회를 진행해주세요."); return; } + setVerificationHistoryOpen(true); + }; + + const saveFunc = async () => { + if (!(await confirm("저장 하시겠습니까?"))) return; + + const values = lastSearchedValues ?? searchValues; + if (!values?.year) { + alert("검색 후 저장해 주세요."); + return; + } + try { - const currentData = getGridDataRef.current?.(); - if (!currentData || currentData.length === 0) { - alert("저장할 데이터가 없습니다."); - return; - } - const values = lastSearchedValues ?? searchValues; - if (!values?.year) { - alert("검색 후 저장해 주세요."); - return; - } + showGlobalProgress({ label: "저장 진행 중...", progress: 0 }); - const columnFormatMap = new Map(); - RegionalFactStatusInpColumn.columns.forEach((col) => { - if (col.fieldName && col.dataFormat) { - columnFormatMap.set(col.fieldName, col.dataFormat); + switch (activeTab) { + case "ST00020001": { + const currentData = getGridDataRef.current?.(); + if (!currentData || currentData.length === 0) { + alert("저장할 데이터가 없습니다."); + return; + } + + const columnFormatMap = new Map(); + RegionalFactStatusInpColumn.columns.forEach((col) => { + if (col.fieldName && col.dataFormat) { + columnFormatMap.set(col.fieldName, col.dataFormat); + } + }); + + const formatNumber = (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); + }; + + const userInfo = await getUserInfo(); + + const saveList = currentData.map((d) => ({ + admdstCd: String(d.admdstCd ?? ""), + sggNm: String(d.sggNm ?? ""), + admdstNm: String(d.admdstNm ?? ""), + goalYr: String(d.goalYr ?? ""), + wholPplMtncPlanPrlg: formatNumber(d.wholPplMtncPlanPrlg, "wholPplMtncPlanPrlg"), + wholPlanPrlg: formatNumber(d.wholPlanPrlg, "wholPlanPrlg"), + wholFcltPrlg: formatNumber(d.wholFcltPrlg, "wholFcltPrlg"), + swagePplPsplyRt: formatNumber(d.swagePplPsplyRt, "swagePplPsplyRt"), + cbsysPplMtncPlanArea: formatNumber(d.cbsysPplMtncPlanArea, "cbsysPplMtncPlanArea"), + cbsysPplMtncPlanPrlg: formatNumber(d.cbsysPplMtncPlanPrlg, "cbsysPplMtncPlanPrlg"), + cbsysPlanPrlg: formatNumber(d.cbsysPlanPrlg, "cbsysPlanPrlg"), + cbsysFcltPrlg: formatNumber(d.cbsysFcltPrlg, "cbsysFcltPrlg"), + cbsysSqrePrlg: formatNumber(d.cbsysSqrePrlg, "cbsysSqrePrlg"), + cbsysCiclPrlg: formatNumber(d.cbsysCiclPrlg, "cbsysCiclPrlg"), + cbsysOpnPrlg: formatNumber(d.cbsysOpnPrlg, "cbsysOpnPrlg"), + cbsysGttrPrlg: formatNumber(d.cbsysGttrPrlg, "cbsysGttrPrlg"), + drtwPplMtncPlanArea: formatNumber(d.drtwPplMtncPlanArea, "drtwPplMtncPlanArea"), + drtwPplMtncPlanPrlg: formatNumber(d.drtwPplMtncPlanPrlg, "drtwPplMtncPlanPrlg"), + drtwPplPlanPrlg: formatNumber(d.drtwPplPlanPrlg, "drtwPplPlanPrlg"), + drtwPplFcltPrlg: formatNumber(d.drtwPplFcltPrlg, "drtwPplFcltPrlg"), + drtwPplSqrePrlg: formatNumber(d.drtwPplSqrePrlg, "drtwPplSqrePrlg"), + drtwPplCiclPrlg: formatNumber(d.drtwPplCiclPrlg, "drtwPplCiclPrlg"), + exlnPplMtncPlanArea: formatNumber(d.exlnPplMtncPlanArea, "exlnPplMtncPlanArea"), + exlnPplMtncPlanPrlg: formatNumber(d.exlnPplMtncPlanPrlg, "exlnPplMtncPlanPrlg"), + exlnPplPlanPrlg: formatNumber(d.exlnPplPlanPrlg, "exlnPplPlanPrlg"), + exlnPplFcltPrlg: formatNumber(d.exlnPplFcltPrlg, "exlnPplFcltPrlg"), + exlnPplSqrePrlg: formatNumber(d.exlnPplSqrePrlg, "exlnPplSqrePrlg"), + exlnPplCiclPrlg: formatNumber(d.exlnPplCiclPrlg, "exlnPplCiclPrlg"), + exlnPplOpnPrlg: formatNumber(d.exlnPplOpnPrlg, "exlnPplOpnPrlg"), + exlnPplGttrPrlg: formatNumber(d.exlnPplGttrPrlg, "exlnPplGttrPrlg"), + 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"), + exlnOtflCnt: formatNumber(d.exlnOtflCnt, "exlnOtflCnt"), + sewerLdgrEdpPrlg: formatNumber(d.sewerLdgrEdpPrlg, "sewerLdgrEdpPrlg"), + sewerLdgrEdpRt: formatNumber(d.sewerLdgrEdpRt, "sewerLdgrEdpRt"), + rmrk: (d.rmrk as string | null) ?? null, + })); + + const param: SeweragePipeInputRegionalFactStatusSaveRequest = { + statsYr: values.year ?? "", + ctpvNm: values.sido ?? "", + sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""), + sewerBzmnCd: values.bsnmCd ?? "", + userId: userInfo.userUnqId, + superAdmin: isAdminInfo ? "superAdmin" : "", + saveList, + }; + + const response = + await statisticsInputSewPipeApiService.saveRegionalFactStatus(param); + if (response.status === "success") { + setGlobalProgress(50); + const refreshedParam = buildSewagePipeSearchParam(values); + await loadSewagePipeActiveTabData(activeTab, refreshedParam, values, true, false); + setGlobalProgress(100); + alert("저장되었습니다."); + } else { + alert(response.message || "데이터 저장 중 오류가 발생했습니다."); + } + break; } - }); + case "ST00020002": { + const currentData = getFactInstallGridDataRef.current?.(); + if (!currentData || currentData.length === 0) { + alert("저장할 데이터가 없습니다."); + return; + } - const formatNumber = (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); - }; + const columnFormatMap = new Map(); + FactInstallStatusInpColumn.columns.forEach((col) => { + if (col.fieldName && col.dataFormat) { + columnFormatMap.set(col.fieldName, col.dataFormat); + } + }); - const userInfo = await getUserInfo(); + const formatNumber = (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); + }; - const saveList = currentData.map((d) => ({ - admdstCd: String(d.admdstCd ?? ""), - sggNm: String(d.sggNm ?? ""), - admdstNm: String(d.admdstNm ?? ""), - goalYr: String(d.goalYr ?? ""), - wholPplMtncPlanPrlg: formatNumber(d.wholPplMtncPlanPrlg, "wholPplMtncPlanPrlg"), - wholPlanPrlg: formatNumber(d.wholPlanPrlg, "wholPlanPrlg"), - wholFcltPrlg: formatNumber(d.wholFcltPrlg, "wholFcltPrlg"), - swagePplPsplyRt: formatNumber(d.swagePplPsplyRt, "swagePplPsplyRt"), - cbsysPplMtncPlanArea: formatNumber(d.cbsysPplMtncPlanArea, "cbsysPplMtncPlanArea"), - cbsysPplMtncPlanPrlg: formatNumber(d.cbsysPplMtncPlanPrlg, "cbsysPplMtncPlanPrlg"), - cbsysPlanPrlg: formatNumber(d.cbsysPlanPrlg, "cbsysPlanPrlg"), - cbsysFcltPrlg: formatNumber(d.cbsysFcltPrlg, "cbsysFcltPrlg"), - cbsysSqrePrlg: formatNumber(d.cbsysSqrePrlg, "cbsysSqrePrlg"), - cbsysCiclPrlg: formatNumber(d.cbsysCiclPrlg, "cbsysCiclPrlg"), - cbsysOpnPrlg: formatNumber(d.cbsysOpnPrlg, "cbsysOpnPrlg"), - cbsysGttrPrlg: formatNumber(d.cbsysGttrPrlg, "cbsysGttrPrlg"), - drtwPplMtncPlanArea: formatNumber(d.drtwPplMtncPlanArea, "drtwPplMtncPlanArea"), - drtwPplMtncPlanPrlg: formatNumber(d.drtwPplMtncPlanPrlg, "drtwPplMtncPlanPrlg"), - drtwPplPlanPrlg: formatNumber(d.drtwPplPlanPrlg, "drtwPplPlanPrlg"), - drtwPplFcltPrlg: formatNumber(d.drtwPplFcltPrlg, "drtwPplFcltPrlg"), - drtwPplSqrePrlg: formatNumber(d.drtwPplSqrePrlg, "drtwPplSqrePrlg"), - drtwPplCiclPrlg: formatNumber(d.drtwPplCiclPrlg, "drtwPplCiclPrlg"), - exlnPplMtncPlanArea: formatNumber(d.exlnPplMtncPlanArea, "exlnPplMtncPlanArea"), - exlnPplMtncPlanPrlg: formatNumber(d.exlnPplMtncPlanPrlg, "exlnPplMtncPlanPrlg"), - exlnPplPlanPrlg: formatNumber(d.exlnPplPlanPrlg, "exlnPplPlanPrlg"), - exlnPplFcltPrlg: formatNumber(d.exlnPplFcltPrlg, "exlnPplFcltPrlg"), - exlnPplSqrePrlg: formatNumber(d.exlnPplSqrePrlg, "exlnPplSqrePrlg"), - exlnPplCiclPrlg: formatNumber(d.exlnPplCiclPrlg, "exlnPplCiclPrlg"), - exlnPplOpnPrlg: formatNumber(d.exlnPplOpnPrlg, "exlnPplOpnPrlg"), - exlnPplGttrPrlg: formatNumber(d.exlnPplGttrPrlg, "exlnPplGttrPrlg"), - 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"), - exlnOtflCnt: formatNumber(d.exlnOtflCnt, "exlnOtflCnt"), - sewerLdgrEdpPrlg: formatNumber(d.sewerLdgrEdpPrlg, "sewerLdgrEdpPrlg"), - sewerLdgrEdpRt: formatNumber(d.sewerLdgrEdpRt, "sewerLdgrEdpRt"), - rmrk: (d.rmrk as string | null) ?? null, - })); + const userInfo = await getUserInfo(); - const param: SeweragePipeInputRegionalFactStatusSaveRequest = { - statsYr: values.year ?? "", - ctpvNm: values.sido ?? "", - sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""), - sewerBzmnCd: values.bsnmCd ?? "", - userId: userInfo.userUnqId, - superAdmin: isAdminInfo ? "superAdmin" : "", - saveList, - }; + const saveList = currentData.map((d) => ({ + admdstCd: String(d.admdstCd ?? ""), + sggNm: String(d.sggNm ?? ""), + admdstNm: String(d.admdstNm ?? ""), + fcltSn: String(d.fcltSn ?? ""), + swtrmtFcltCd: String(d.swtrmtFcltCd ?? ""), + swtrmtFcltNm: String(d.swtrmtFcltNm ?? ""), + prcsFcltCpct: formatNumber(d.prcsFcltCpct, "prcsFcltCpct"), + prcsZoneArea: formatNumber(d.prcsZoneArea, "prcsZoneArea"), + cbsysPrcsZoneArea: formatNumber(d.cbsysPrcsZoneArea, "cbsysPrcsZoneArea"), + sprsysPrcsZoneArea: formatNumber(d.sprsysPrcsZoneArea, "sprsysPrcsZoneArea"), + sprsysVartnRt: formatNumber(d.sprsysVartnRt, "sprsysVartnRt"), + mltvEqpmCnt: formatNumber(d.mltvEqpmCnt, "mltvEqpmCnt"), + cbsysMltvEqpmCnt: formatNumber(d.cbsysMltvEqpmCnt, "cbsysMltvEqpmCnt"), + sprsysMltvEqpmCnt: formatNumber(d.sprsysMltvEqpmCnt, "sprsysMltvEqpmCnt"), + sprsysMltvLnkgCnt: formatNumber(d.sprsysMltvLnkgCnt, "sprsysMltvLnkgCnt"), + sprsysMltvYetLnkgCnt: formatNumber(d.sprsysMltvYetLnkgCnt, "sprsysMltvYetLnkgCnt"), + wholFcltPrlg: formatNumber(d.wholFcltPrlg, "wholFcltPrlg"), + wholIcptgAtlfcsPrlg: formatNumber(d.wholIcptgAtlfcsPrlg, "wholIcptgAtlfcsPrlg"), + icptgPrlg: formatNumber(d.icptgPrlg, "icptgPrlg"), + atlfcsPrlg: formatNumber(d.atlfcsPrlg, "atlfcsPrlg"), + cbsysStofcbrCnt: formatNumber(d.cbsysStofcbrCnt, "cbsysStofcbrCnt"), + sprsysStofcbrCnt: formatNumber(d.sprsysStofcbrCnt, "sprsysStofcbrCnt"), + sprsysExlnOtflCnt: formatNumber(d.sprsysExlnOtflCnt, "sprsysExlnOtflCnt"), + wholBrchlnPrlg: formatNumber(d.wholBrchlnPrlg, "wholBrchlnPrlg"), + cbsysBrchlnPrlg: formatNumber(d.cbsysBrchlnPrlg, "cbsysBrchlnPrlg"), + exlnBrchlnPrlg: formatNumber(d.exlnBrchlnPrlg, "exlnBrchlnPrlg"), + drtwBrchlnPrlg: formatNumber(d.drtwBrchlnPrlg, "drtwBrchlnPrlg"), + rmrk: String(d.rmrk ?? ""), + })); - console.log(param); + const param: SeweragePipeInputFactInstallStatusSaveRequest = { + statsYr: values.year ?? "", + ctpvNm: values.sido ?? "", + sggNm: values.gugun === "ALL" ? "" : (values.gugun ?? ""), + sewerBzmnCd: values.bsnmCd ?? "", + userId: userInfo.userUnqId, + superAdmin: isAdminInfo ? "superAdmin" : "", + saveList, + }; + + const response = + await statisticsInputSewPipeApiService.saveFactInstallStatus(param); + if (response.status === "success") { + setGlobalProgress(50); + const refreshedParam = buildSewagePipeSearchParam(values); + await loadSewagePipeActiveTabData(activeTab, refreshedParam, values, true, false); + setGlobalProgress(100); + alert("저장되었습니다."); + } else { + alert(response.message || "데이터 저장 중 오류가 발생했습니다."); + } + break; + } + default: + alert("현재 탭 저장 API 연동 전입니다."); + break; + } } catch { - alert("저장 준비 중 오류가 발생했습니다."); + alert("데이터 저장 중 오류가 발생했습니다."); + } finally { + hideGlobalProgress(); } }; - const gugunOkOrNotDoList = doList.includes(lastSearchedValues?.bsnmCd || '') - ? Boolean(lastSearchedValues?.gugun && lastSearchedValues.gugun !== 'ALL') - : true; + const gugunOkOrNotDoList = buildGugunOkForToolbar(lastSearchedValues); - const showSaveInToolbar = (isEnterable || isAdminInfo) && gugunOkOrNotDoList; - const showEnterableToolbarButtons = isEnterable && gugunOkOrNotDoList; + const searchContextForToolbar = lastSearchedValues ?? searchValues; + const activeTabToolbar = + toolbarSnapshotByTab.get(activeTab) ?? + computeTabToolbarSnapshot( + tabStatusInfoMap.get(activeTab) ?? null, + tabRowDataExists(rowDataMap, activeTab), + hasSearched, + searchContextForToolbar, + inputPeriodEnterable, + isAdminInfo, + ); - /** rowDataMap — 행이 하나라도 있을 때만 툴바·엑셀 의미 있음 */ - const hasRowData = Array.from(rowDataMap.values()).some((rows) => rows.length > 0); - const excelToolbarEnabled = hasRowData && isEnterable && gugunOkOrNotDoList; - - const showActionToolbarCard = - (hasRowData || hasSearched) && - (showSaveInToolbar || - (statusInfo?.laeSbmsnSttsCd === '2' && (isAdminInfo || (isEnterable && gugunOkOrNotDoList)))); + const { + showSaveInToolbar, + showEnterableToolbarButtons, + showActionToolbarCard, + } = activeTabToolbar; return ( <> @@ -502,9 +766,11 @@ const SewagePipeInp = () => { {showActionToolbarCard && (
+ {showEnterableToolbarButtons && ( <> - void getLastYearDataFunc()} /> @@ -513,7 +779,7 @@ const SewagePipeInp = () => {
)} - {isLoading &&
+ {isLoading && !globalProgressVisible &&
@@ -534,6 +800,14 @@ const SewagePipeInp = () => { {columnInfo.map((info) => { const tabKey = info.tabKey; + const tabEnterableForGrid = computeTabEnterable( + tabStatusInfoMap.get(tabKey) ?? null, + inputPeriodEnterable, + ); + const excelToolbarEnabledForTab = + tabRowDataExists(rowDataMap, tabKey) && + tabEnterableForGrid && + gugunOkOrNotDoList; if (info.isSplitGrid && info.left && info.right) { const leftKey = info.left.tabKey || `${tabKey}_left`; @@ -544,7 +818,7 @@ const SewagePipeInp = () => { const showSplitTopButton = Boolean(splitTopButton?.text) && (splitTopButton?.visible ?? true) && - excelToolbarEnabled; + excelToolbarEnabledForTab; const leftColumnInfo: ColumnInfo = { columns: info.left.columns ?? [], columnLayout: info.left.columnLayout ?? [], @@ -563,6 +837,16 @@ const SewagePipeInp = () => { text={splitTopButton?.text ?? ""} onClick={splitTopButton?.onClick} statsYr={lastSearchedYear ?? undefined} + initialSearchValues={ + lastSearchedValues + ? { + sido: lastSearchedValues.sido, + gugun: lastSearchedValues.gugun, + bsnmCd: lastSearchedValues.bsnmCd, + } + : null + } + onSaveSuccess={refreshConnectionStatusAfterPopupSave} /> )}
@@ -618,6 +902,7 @@ const SewagePipeInp = () => { return ( { return next; }); }} - isExcelImport={excelToolbarEnabled} + isExcelImport={excelToolbarEnabledForTab} isExcelExport showHeaderCheckbox={false} fixedColumns={info.fixedColumns} enableSort={false} - getGridDataRef={tabKey === "ST00020001" ? getGridDataRef : undefined} + statusInfo={tabStatusInfoMap.get(tabKey) ?? null} + getGridDataRef={ + tabKey === "ST00020001" + ? getGridDataRef + : tabKey === "ST00020002" + ? getFactInstallGridDataRef + : undefined + } + 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"); + 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검증 - " : "일반검증 - "; + const comp = e.compVlCn ?? ""; + const now = e.nowVlCn ?? ""; + return `${prefix}${e.rsnExpln}(검증 기준값: ${comp} / 현재 입력값: ${now})`; + }) + .filter(Boolean) + .join("\n"); + } + }} /> ); })}
+ ); }; diff --git a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx index c37700dae..c31a4e060 100644 --- a/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx +++ b/apps/st/src/components/statistics/input/SeweragePenetrationRateInp.tsx @@ -24,13 +24,16 @@ import { getUserInfo } from "../../../../../../packages/common/components/src/ap import { useConfirm } from "@frontend-opensource/use-react-hooks"; import type { SearchFormValues } from "@/components/type/SearchForm.type"; -import type { - SewerageSupplyRateItem, - SewerageSupplyRateRequestParams, - SewerageSupplyRateSaveItem, - SewerageSupplyRateVerificationResult, - SewerageSupplyRateSaveRequest, - SewerageSupplyRateStatusInfo, +import { + type CheckInputPeriodItem, + type SewerageSupplyRateItem, + type SewerageSupplyRateRequestParams, + type SewerageSupplyRateSaveItem, + type SewerageSupplyRateVerificationResult, + type SewerageSupplyRateSaveRequest, + type SewerageSupplyRateStatusInfo, + getVerificationPeriodActionBlockedMessage, + isInputPeriodCurrent, } from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface"; import type { cancelSubmitRequest, @@ -100,6 +103,8 @@ const SeweragePenetrationRateInp = () => { const [lastSearchedYear, setLastSearchedYear] = useState(null); const [lastSearchedValues, setLastSearchedValues] = useState(null); const [statusInfo, setStatusInfo] = useState(null); + /** 마지막 조회 시 check-input-period 응답 (검증기간 여부 판단) */ + const [periodCheckList, setPeriodCheckList] = useState([]); const [showDailog, setShowDailog] = useState(false); const [verificationHistoryOpen, setVerificationHistoryOpen] = useState(false); const [verificationResultDialogOpen, setVerificationResultDialogOpen] = useState(false); @@ -147,6 +152,7 @@ const SeweragePenetrationRateInp = () => { const handleReset = () => { setRowData([]); setStatusInfo(null); + setPeriodCheckList([]); setLastSearchedYear(null); setLastSearchedValues(null); setFooterFromApi(null); @@ -218,8 +224,9 @@ const SeweragePenetrationRateInp = () => { const response = await statisticsInputSewSupplyRateApi.checkInputPeriod(values.year || ''); if (response.status === 'success') { - const checkEnterable = response.data; - // setIsEnterable(response.data); + const periods = Array.isArray(response.data) ? response.data : []; + setPeriodCheckList(periods); + const checkEnterable = isInputPeriodCurrent(periods); setLastSearchedYear(values.year || null); await fetchSewerageSupplyRate(getSewerageSupplyRateParams(values), checkEnterable); setLastSearchedValues(values); @@ -232,6 +239,11 @@ const SeweragePenetrationRateInp = () => { }; const SubmitExceptionFunc = async (submitInfo: StatisticsInputSubmitInfoFormData) => { + const verificationBlocked = getVerificationPeriodActionBlockedMessage(periodCheckList); + if (verificationBlocked) { + alert(verificationBlocked); + return; + } const userInfo = await getUserInfo(); const params: SubmitExceptionRequest = { @@ -309,6 +321,11 @@ const SeweragePenetrationRateInp = () => { }; const executeSubmit = async (submitInfo: StatisticsInputSubmitInfoFormData) => { + const verificationBlocked = getVerificationPeriodActionBlockedMessage(periodCheckList); + if (verificationBlocked) { + alert(verificationBlocked); + return; + } try { showGlobalProgress({ label: "제출 진행 중...", progress: 0 }); const userInfo = await getUserInfo(); @@ -388,7 +405,7 @@ const SeweragePenetrationRateInp = () => { const response = await statisticsInputSewSupplyRateApi.submitSewerageSupplyRate(requestData); if (response.status === 'success') { setGlobalProgress(50); - + if (response.data.finalStatus === '5') { const imrParam = { user_id: userInfo.userUnqId, @@ -442,6 +459,11 @@ const SeweragePenetrationRateInp = () => { const saveFunc = async () => { try { if (await confirm('저장 하시겠습니까?')) { + const verificationBlocked = getVerificationPeriodActionBlockedMessage(periodCheckList); + if (verificationBlocked) { + alert(verificationBlocked); + return; + } showGlobalProgress({ label: "저장 진행 중...", progress: 0 }); const userInfo = await getUserInfo(); @@ -520,9 +542,9 @@ const SeweragePenetrationRateInp = () => { sewerBzmnFrgnrWholPpltnCnt: footerFromApi?.sewerBzmnFrgnrWholPpltnCnt // 행정참고자료 외국인 합계 }; - // if (isAdminInfo) { - // requestData.superAdmin = 'superAdmin'; - // } + if (isAdminInfo) { + requestData.superAdmin = 'superAdmin'; + } const response = await statisticsInputSewSupplyRateApi.saveSewerageSupplyRate(requestData); @@ -547,6 +569,11 @@ const SeweragePenetrationRateInp = () => { const cnacelSubmitFunc = async () => { try { if (await confirm('제출된 내용을 취소하시겠습니까?')) { + const verificationBlocked = getVerificationPeriodActionBlockedMessage(periodCheckList); + if (verificationBlocked) { + alert(verificationBlocked); + return; + } const userInfo = await getUserInfo(); @@ -600,11 +627,11 @@ const SeweragePenetrationRateInp = () => { {showActionToolbarCard && (
+ {showEnterableToolbarButtons && ( <> - getLastYearDataFunc(searchValues ?? {})} /> submitFunc(false)} /> diff --git a/apps/st/src/components/statistics/management/LogHistory.tsx b/apps/st/src/components/statistics/management/LogHistory.tsx index e2abaf60d..fdb9a4e1a 100644 --- a/apps/st/src/components/statistics/management/LogHistory.tsx +++ b/apps/st/src/components/statistics/management/LogHistory.tsx @@ -12,7 +12,6 @@ import type { ApiResponse, SearchCondition, Sgg, - StatisticsLog, StatisticsLogRequest, } from "@/entities/statistics/model/statistics-interface"; import { diff --git a/apps/st/src/components/statistics/total/CellColorInfo.tsx b/apps/st/src/components/statistics/total/CellColorInfo.tsx new file mode 100644 index 000000000..a44ee1879 --- /dev/null +++ b/apps/st/src/components/statistics/total/CellColorInfo.tsx @@ -0,0 +1,22 @@ +const CellColorInfo = () => { + return ( +
+
+
+
+ 요약표 +
+
+
+ 합계 +
+
+
+ 소계 +
+
+
+ ); +}; + +export default CellColorInfo; \ No newline at end of file diff --git a/apps/st/src/components/statistics/total/SewagePipeMaintainTot.tsx b/apps/st/src/components/statistics/total/SewagePipeMaintainTot.tsx index 12e71151d..a061146c5 100644 --- a/apps/st/src/components/statistics/total/SewagePipeMaintainTot.tsx +++ b/apps/st/src/components/statistics/total/SewagePipeMaintainTot.tsx @@ -1,10 +1,202 @@ +import { useRef, useState } from "react"; + +import { useAlertStore } from "@/shared/lib"; +import { statisticsTotalStatusApi } from "@/entities/statistics/api/statistics-total-service"; +import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components"; + import SearchForm from "@/components/module/SearchForm"; +import DataGrid from "@/components/module/DataGrid"; +import type { ColumnInfo } from "@/components/type/DataGrid.type"; +import type { SearchFormValues } from "@/components/type/SearchForm.type"; +import { SewagePipeMaintainColumnListTot } from "@/components/util/column/total/sewagePipeMaintain/sewagePipeMaintainColumn"; +import { toGridRows } from "@/components/util/grid/gridFunction"; +import type { SewagePipeMaintainTotalRequest } from "@/entities/statistics/model/statistics-total/statistics-sewage-pipe-maintain-total-interface"; + +const firstTabKey = SewagePipeMaintainColumnListTot[0]?.tabKey ?? "ST00030001"; + +function buildSewagePipeMaintainTotSearchParam(values: SearchFormValues): SewagePipeMaintainTotalRequest { + return { + ctpvNm: values.sido === "ALL" ? "" : values.sido, + sggNm: values.gugun === "ALL" ? "" : values.gugun, + sewerBzmnCd: values.bsnmCd, + statsYr: values.year, + rowDataYn: values.isDetail, + }; +} const SewagePipeMaintainTot = () => { + const { setMessage: alert } = useAlertStore((state) => state); + const [rowDataMap, setRowDataMap] = useState[]>>(new Map()); + const [isLoading, setIsLoading] = useState(false); + const [activeTab, setActiveTab] = useState(firstTabKey); + const lastSearchedValuesRef = useRef(null); + + const columnInfo = SewagePipeMaintainColumnListTot; + + const handleReset = () => { + setRowDataMap(new Map()); + setActiveTab(firstTabKey); + lastSearchedValuesRef.current = null; + }; + + const loadSewagePipeMaintainTotTabData = async ( + tabKey: string, + values: SearchFormValues, + mergeIntoExistingMap: boolean, + ): Promise => { + const param = buildSewagePipeMaintainTotSearchParam(values); + + const applyRows = (rows: Record[]) => { + if (mergeIntoExistingMap) { + setRowDataMap((prev) => { + const next = new Map(prev); + next.set(tabKey, rows); + return next; + }); + } else { + const next = new Map[]>(); + next.set(tabKey, rows); + setRowDataMap(next); + } + }; + + if (tabKey === "ST00030001") { + const response = await statisticsTotalStatusApi.getSewagePipeMaintainRepairTotal(param); + if (response.status === "success" && response.data) { + applyRows(toGridRows(response.data as object[])); + return true; + } + alert(response.message || "데이터 조회 중 오류가 발생했습니다."); + return false; + } + + if (tabKey === "ST00030002") { + const response = await statisticsTotalStatusApi.getSewagePipeMaintainDredgingTotal(param); + if (response.status === "success" && response.data) { + applyRows(toGridRows(response.data as object[])); + return true; + } + alert(response.message || "데이터 조회 중 오류가 발생했습니다."); + return false; + } + + return false; + }; + + const handleSearch = async (values: SearchFormValues) => { + try { + setIsLoading(true); + + let ok = false; + if (activeTab === "ST00030001" || activeTab === "ST00030002") { + ok = await loadSewagePipeMaintainTotTabData(activeTab, values, true); + } + if (ok) { + lastSearchedValuesRef.current = values; + } else { + lastSearchedValuesRef.current = null; + } + } catch { + alert("데이터 조회 중 오류가 발생했습니다."); + lastSearchedValuesRef.current = null; + } finally { + setIsLoading(false); + } + }; + + const handleActiveTabChange = async (tabKey: string) => { + setActiveTab(tabKey); + + const values = lastSearchedValuesRef.current; + if (!values) return; + + const existing = rowDataMap.get(tabKey); + const hasData = Array.isArray(existing) && existing.length > 0; + if (hasData) return; + + if (tabKey === "ST00030001" || tabKey === "ST00030002") { + try { + setIsLoading(true); + await loadSewagePipeMaintainTotTabData(tabKey, values, true); + } catch { + alert("데이터 조회 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + } + }; + return ( - <> - - +
+ + {isLoading && ( +
+
+ +
+
+ )} +
+ void handleActiveTabChange(v)} + className="w-full" + variant="default" + > + + {columnInfo.map((info) => ( + + {info.tabLabel || info.tabKey} + + ))} + + {columnInfo.map((info) => { + const tabKey = info.tabKey; + 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={false} + isExcelExport + showHeaderCheckbox={false} + fixedColumns={info.fixedColumns} + enableSort={false} + cellStyleCallback={({ row }) => { + const rowType = String(row.rowType ?? ""); + if (rowType === "DETAIL") return undefined; + if (rowType === "BIZ_SUM") return "rg-cell-rowtype-biz-sum"; + return "rg-cell-rowtype-other"; + }} + /> + + ); + })} + +
+
); }; diff --git a/apps/st/src/components/statistics/total/SewagePipeTot.tsx b/apps/st/src/components/statistics/total/SewagePipeTot.tsx index cea602a35..0ee0aac2b 100644 --- a/apps/st/src/components/statistics/total/SewagePipeTot.tsx +++ b/apps/st/src/components/statistics/total/SewagePipeTot.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useRef, useState } from "react"; import { useAlertStore } from "@/shared/lib"; import { statisticsTotalStatusApi } from "@/entities/statistics/api/statistics-total-service"; @@ -6,71 +6,165 @@ import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from " import SearchForm from "@/components/module/SearchForm"; import DataGrid from "@/components/module/DataGrid"; -import type { ColumnConfig, ColumnInfo, ColumnInfoList } from "@/components/type/DataGrid.type"; +import type { ColumnInfo } from "@/components/type/DataGrid.type"; import type { SearchFormValues } from "@/components/type/SearchForm.type"; import { SewagePipeColumnInfoListTot } from "@/components/util/column/total/SewagePipeTotColumn"; import { toGridRows } from "@/components/util/grid/gridFunction"; +import type { SewagePipeTotalRequest } from "@/entities/statistics/model/statistics-total/statistics-sewage-pipe-total-interface"; const firstTabKey = SewagePipeColumnInfoListTot[0]?.tabKey ?? "ST00020001"; +function buildSewagePipeTotSearchParam(values: SearchFormValues): SewagePipeTotalRequest { + return { + ctpvNm: values.sido === "ALL" ? "" : values.sido, + sggNm: values.gugun === "ALL" ? "" : values.gugun, + sewerBzmnCd: values.bsnmCd, + statsYr: values.year, + rowDataYn: values.isDetail, + }; +} + const SewagePipeTot = () => { const { setMessage: alert } = useAlertStore((state) => state); const [rowDataMap, setRowDataMap] = useState[]>>(new Map()); const [isLoading, setIsLoading] = useState(false); const [activeTab, setActiveTab] = useState(firstTabKey); + /** 조회 성공 후 탭 전환 시 동일 조건으로 지연 조회 */ + const lastSearchedValuesRef = useRef(null); - const columnInfo = useMemo( - () => - SewagePipeColumnInfoListTot.map((tab: ColumnInfoList) => ({ - ...tab, - columns: (tab.columns ?? []).map((col: ColumnConfig) => ({ - ...col, - editable: false as const, - styleName: col.styleName ?? ("rg-cell-readonly" as const), - })), - })), - [], - ); + const columnInfo = SewagePipeColumnInfoListTot; const handleReset = () => { setRowDataMap(new Map()); setActiveTab(firstTabKey); + lastSearchedValuesRef.current = null; + }; + + /** SewagePipeInp의 `loadSewagePipeActiveTabData`와 동일 역할: 탭키별 API 호출 후 `rowDataMap` 반영 */ + const loadSewagePipeTotTabData = async ( + tabKey: string, + values: SearchFormValues, + mergeIntoExistingMap: boolean, + ): Promise => { + const param = buildSewagePipeTotSearchParam(values); + + const applyRows = (rows: Record[]) => { + if (mergeIntoExistingMap) { + setRowDataMap((prev) => { + const next = new Map(prev); + next.set(tabKey, rows); + return next; + }); + } else { + const next = new Map[]>(); + next.set(tabKey, rows); + setRowDataMap(next); + } + }; + + if (tabKey === "ST00020001") { + const response = await statisticsTotalStatusApi.getSewagePipeRegionalStatusList(param); + if (response.status === "success" && response.data) { + applyRows(toGridRows(response.data as object[])); + return true; + } + alert(response.message || "데이터 조회 중 오류가 발생했습니다."); + return false; + } + + if (tabKey === "ST00020002") { + const response = await statisticsTotalStatusApi.getSewagePipeFactInstallStatus(param); + if (response.status === "success" && response.data) { + applyRows(toGridRows(response.data as object[])); + return true; + } + alert(response.message || "데이터 조회 중 오류가 발생했습니다."); + return false; + } + + if (tabKey === "ST00020003") { + const response = await statisticsTotalStatusApi.getSewagePipePipeTypeFacilityStatus(param); + if (response.status === "success" && response.data) { + applyRows(toGridRows(response.data as object[])); + return true; + } + alert(response.message || "데이터 조회 중 오류가 발생했습니다."); + return false; + } + + if (tabKey === "ST00020004") { + const response = await statisticsTotalStatusApi.getSewagePipePipeDiamStatus(param); + if (response.status === "success" && response.data) { + applyRows(toGridRows(response.data as object[])); + return true; + } + alert(response.message || "데이터 조회 중 오류가 발생했습니다."); + return false; + } + + return false; }; const handleSearch = async (values: SearchFormValues) => { - setActiveTab(firstTabKey); - setRowDataMap(new Map()); - try { setIsLoading(true); - const param = { - ctpvNm: values.sido === "ALL" ? "" : values.sido, - sggNm: values.gugun === "ALL" ? "" : values.gugun, - sewerBzmnCd: values.bsnmCd, - statsYr: values.year, - rowDataYn: values.isDetail, - }; - - const response = await statisticsTotalStatusApi.getSewagePipeRegionalStatusList(param); - - if (response.status === "success" && response.data) { - const next = new Map[]>(); - next.set("ST00020001", toGridRows(response.data as object[])); - setRowDataMap(next); + // SewagePipeInp과 동일: 검색 시 활성 탭 데이터만 조회·맵 병합 + let ok = false; + if ( + activeTab === "ST00020001" || + activeTab === "ST00020002" || + activeTab === "ST00020003" || + activeTab === "ST00020004" + ) { + ok = await loadSewagePipeTotTabData(activeTab, values, true); + } + if (ok) { + lastSearchedValuesRef.current = values; } else { - alert(response.message || "데이터 조회 중 오류가 발생했습니다."); + lastSearchedValuesRef.current = null; } } catch { alert("데이터 조회 중 오류가 발생했습니다."); + lastSearchedValuesRef.current = null; } finally { setIsLoading(false); } }; + const handleActiveTabChange = async (tabKey: string) => { + setActiveTab(tabKey); + + const values = lastSearchedValuesRef.current; + if (!values) return; + + const existing = rowDataMap.get(tabKey); + const hasData = Array.isArray(existing) && existing.length > 0; + if (hasData) return; + + if ( + tabKey === "ST00020001" || + tabKey === "ST00020002" || + tabKey === "ST00020003" || + tabKey === "ST00020004" + ) { + try { + setIsLoading(true); + await loadSewagePipeTotTabData(tabKey, values, true); + } catch { + alert("데이터 조회 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + return; + } + + console.log("[SewagePipeTot] tabKey (API 미연결):", tabKey); + }; + return (
- + {isLoading && (
@@ -81,7 +175,7 @@ const SewagePipeTot = () => {
void handleActiveTabChange(v)} className="w-full" variant="default" > diff --git a/apps/st/src/components/statistics/total/SeweragePenetrationRateTot.tsx b/apps/st/src/components/statistics/total/SeweragePenetrationRateTot.tsx index 2ff60a726..7c5d1ab14 100644 --- a/apps/st/src/components/statistics/total/SeweragePenetrationRateTot.tsx +++ b/apps/st/src/components/statistics/total/SeweragePenetrationRateTot.tsx @@ -8,6 +8,7 @@ import { useAlertStore } from "@/shared/lib"; import SearchForm from "@/components/module/SearchForm"; import DataGrid from "@/components/module/DataGrid"; +import CellColorInfo from "./CellColorInfo"; import { LoadingWithAnimation } from "@common/components"; const SeweragePenetrationRateTot = () => { @@ -19,12 +20,17 @@ const SeweragePenetrationRateTot = () => { try { setIsLoading(true); + const isSummaryEnabled = values.isSummary !== 'ALL'; + const summaryYearCnt = isSummaryEnabled ? Number(values.isSummary) : undefined; + const param = { ctpvNm: values.sido === 'ALL' ? '' : values.sido, sggNm: values.gugun === 'ALL' ? '' : values.gugun, sewerBzmnCd: values.bsnmCd, statsYr: values.year, - rowDataYn: values.isDetail + rowDataYn: values.isDetail, + summaryYn: isSummaryEnabled, + ...(isSummaryEnabled ? { summaryYearCnt } : {}), }; const response = await statisticsTotalStatusApi.getSewerageSupplyRateList(param); @@ -35,7 +41,8 @@ const SeweragePenetrationRateTot = () => { } else { alert(response.message || '데이터 조회 중 오류가 발생했습니다.'); } - } catch { + } catch (error) { + console.log(error) alert('데이터 조회 중 오류가 발생했습니다.'); } finally { setIsLoading(false); @@ -44,12 +51,13 @@ const SeweragePenetrationRateTot = () => { return ( <> - + {isLoading &&
} + { const rowType = String(row.rowType ?? ''); if (rowType === 'DETAIL') return undefined; if (rowType === 'BIZ_SUM') return 'rg-cell-rowtype-biz-sum'; + if (rowType === 'SUMMARY') return 'rg-cell-rowtype-summary'; return 'rg-cell-rowtype-other'; }} /> diff --git a/apps/st/src/components/statistics/total/SewerageServiceLevelTot.tsx b/apps/st/src/components/statistics/total/SewerageServiceLevelTot.tsx index 275dd7a25..452419dc2 100644 --- a/apps/st/src/components/statistics/total/SewerageServiceLevelTot.tsx +++ b/apps/st/src/components/statistics/total/SewerageServiceLevelTot.tsx @@ -1,14 +1,14 @@ -import { useEffect, useMemo, useState } from "react"; +import { useRef, useState } from "react"; import { useAlertStore } from "@/shared/lib"; import { statisticsTotalStatusApi } from "@/entities/statistics/api/statistics-total-service"; import { toGridRows } from "@/components/util/grid/gridFunction"; import SearchForm from "@/components/module/SearchForm"; -import TabDataGrid from "@/components/module/TabDataGrid"; -import { LoadingWithAnimation } from "@common/components"; +import DataGrid from "@/components/module/DataGrid"; +import { LoadingWithAnimation, Tabs, TabsContent, TabsList, TabsTrigger } from "@common/components"; -import type { ColumnConfig, ColumnInfoList } from "@/components/type/DataGrid.type"; +import type { ColumnInfo } from "@/components/type/DataGrid.type"; import type { SearchFormValues } from "@/components/type/SearchForm.type"; import { ComplaintInpColumn } from "@/components/util/column/total/sewerageServiceLevel/ComplaintTotColumn"; import { ResidentFriendlyFactTotColumn } from "@/components/util/column/total/sewerageServiceLevel/ResidentFriendlyFactTotColumn"; @@ -17,81 +17,111 @@ const SewerageServiceLevelInpTabList = [ ComplaintInpColumn, ResidentFriendlyFactTotColumn, ]; +const SUPPORTED_TAB_KEYS = [ComplaintInpColumn.tabKey, ResidentFriendlyFactTotColumn.tabKey] as const; const SewerageServiceLevelTot = () => { const { setMessage: alert } = useAlertStore((state) => state); const [rowDataMap, setRowDataMap] = useState[]>>(new Map()); const [isLoading, setIsLoading] = useState(false); const [activeTab, setActiveTab] = useState(ComplaintInpColumn.tabKey); - const [lastSearchValues, setLastSearchValues] = useState(null); + const lastSearchValuesRef = useRef(null); - const columnInfo = useMemo( - () => - SewerageServiceLevelInpTabList.map((tab: ColumnInfoList) => ({ - ...tab, - columns: (tab.columns ?? []).map((col: ColumnConfig) => ({ - ...col, - editable: false as const, - styleName: col.styleName ?? ("rg-cell-readonly" as const), - })), - })), - [], - ); + const columnInfo = SewerageServiceLevelInpTabList; const handleReset = () => { setRowDataMap(new Map()); setActiveTab(ComplaintInpColumn.tabKey); - setLastSearchValues(null); + lastSearchValuesRef.current = null; }; - const fetchByTab = async (tabKey: string, values: SearchFormValues) => { - try { - setIsLoading(true); + const loadSewerageServiceLevelTotTabData = async ( + tabKey: string, + values: SearchFormValues, + mergeIntoExistingMap: boolean, + ) => { + const param = { + ctpvNm: values.sido === "ALL" ? "" : values.sido, + sggNm: values.gugun === "ALL" ? "" : values.gugun, + sewerBzmnCd: values.bsnmCd, + statsYr: values.year, + rowDataYn: values.isDetail, + }; - const param = { - ctpvNm: values.sido === "ALL" ? "" : values.sido, - sggNm: values.gugun === "ALL" ? "" : values.gugun, - sewerBzmnCd: values.bsnmCd, - statsYr: values.year, - rowDataYn: values.isDetail, - }; + const fetcherMap = { + [ComplaintInpColumn.tabKey]: statisticsTotalStatusApi.getSewerageServiceLevelComplaint.bind( + statisticsTotalStatusApi, + ), + [ResidentFriendlyFactTotColumn.tabKey]: + statisticsTotalStatusApi.getSewerageServiceLevelResidentFriendlyFact.bind( + statisticsTotalStatusApi, + ), + }; + const fetcher = fetcherMap[tabKey]; + if (!fetcher) { + return false; + } + const response = await fetcher(param); - let response; - if (tabKey === ResidentFriendlyFactTotColumn.tabKey) { - response = - await statisticsTotalStatusApi.getSewerageServiceLevelResidentFriendlyFact(param); - } else { - response = - await statisticsTotalStatusApi.getSewerageServiceLevelComplaint(param); - } - - if (response.status === "success" && response.data) { + if (response.status === "success" && response.data) { + const rows = toGridRows(response.data as object[]); + if (mergeIntoExistingMap) { setRowDataMap((prev) => { const next = new Map(prev); - next.set(tabKey, toGridRows(response.data as object[])); + next.set(tabKey, rows); return next; }); } else { - alert(response.message || "데이터 조회 중 오류가 발생했습니다."); + const next = new Map[]>(); + next.set(tabKey, rows); + setRowDataMap(next); + } + return true; + } + alert(response.message || "데이터 조회 중 오류가 발생했습니다."); + return false; + }; + + const handleSearch = async (values: SearchFormValues) => { + try { + setIsLoading(true); + let ok = false; + if (SUPPORTED_TAB_KEYS.includes(activeTab)) { + ok = await loadSewerageServiceLevelTotTabData(activeTab, values, true); + } + if (ok) { + lastSearchValuesRef.current = values; + } else { + lastSearchValuesRef.current = null; } } catch { alert("데이터 조회 중 오류가 발생했습니다."); + lastSearchValuesRef.current = null; } finally { setIsLoading(false); } }; - const handleSearch = async (values: SearchFormValues) => { - setLastSearchValues(values); - setRowDataMap(new Map()); - await fetchByTab(activeTab, values); - }; + const handleActiveTabChange = async (tabKey: string) => { + setActiveTab(tabKey); - useEffect(() => { - if (lastSearchValues == null) return; - if (rowDataMap.has(activeTab)) return; - void fetchByTab(activeTab, lastSearchValues); - }, [activeTab]); + const values = lastSearchValuesRef.current; + if (!values) return; + + const existing = rowDataMap.get(tabKey); + const hasData = Array.isArray(existing) && existing.length > 0; + if (hasData) return; + + if (SUPPORTED_TAB_KEYS.includes(tabKey)) { + try { + setIsLoading(true); + await loadSewerageServiceLevelTotTabData(tabKey, values, true); + } catch { + alert("데이터 조회 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + } + }; return (
@@ -103,20 +133,60 @@ const SewerageServiceLevelTot = () => {
)} - { - const rowType = String(row.rowType ?? ""); - if (rowType === "DETAIL") return undefined; - if (rowType === "BIZ_SUM") return "rg-cell-rowtype-biz-sum"; - return "rg-cell-rowtype-other"; - }} - /> +
+ void handleActiveTabChange(v)} className="w-full" variant="default"> + + {columnInfo.map((info) => ( + + {info.tabLabel || info.tabKey} + + ))} + + {columnInfo.map((info) => { + const tabKey = info.tabKey; + 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={false} + isExcelExport + showHeaderCheckbox={false} + fixedColumns={info.fixedColumns} + enableSort={false} + cellStyleCallback={({ row }) => { + const rowType = String(row.rowType ?? ""); + if (rowType === "DETAIL") return undefined; + if (rowType === "BIZ_SUM") return "rg-cell-rowtype-biz-sum"; + return "rg-cell-rowtype-other"; + }} + /> + + ); + })} + +
); }; diff --git a/apps/st/src/components/statistics/verification/generalVerification/DetailComponent.tsx b/apps/st/src/components/statistics/verification/generalVerification/DetailComponent.tsx index 0f9da9e01..c27387e85 100644 --- a/apps/st/src/components/statistics/verification/generalVerification/DetailComponent.tsx +++ b/apps/st/src/components/statistics/verification/generalVerification/DetailComponent.tsx @@ -3,7 +3,6 @@ import { useEffect, useRef, useState } from 'react'; import { NoData } from '@/shared/ui/no-data'; import { Button, - Input, Table, TableBody, TableCell, diff --git a/apps/st/src/components/type/DataGrid.type.ts b/apps/st/src/components/type/DataGrid.type.ts index 0ee95e977..706a7bc1d 100644 --- a/apps/st/src/components/type/DataGrid.type.ts +++ b/apps/st/src/components/type/DataGrid.type.ts @@ -3,9 +3,15 @@ import React from "react"; import { ValueType } from "realgrid"; import type { RealGridReact } from "realgrid-react"; -import { type SewerageSupplyRateStatusInfo } from "@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface"; - // RealGrid 타입 정의 + +/** DataGrid 상단 진행상황/제출일/담당자 표시에 사용 (보급률·하수관로 등 공통) */ +export type DataGridStatusBannerInfo = { + laeSbmsnSttsCd: string | null; + laeSbmsnYmd: string | null; + laePicNm: string | null; + laePicTelNo: string | null; +}; /** setColumns에 전달하는 컬럼 정보 (footer 배열/단일 객체 포함) */ export type RealGridColumnInfo = { name: string; @@ -19,30 +25,30 @@ export type RealGridColumnInfo = { mergeRule?: { criteria: "value" | string }; sortable?: boolean; footer?: - | { - text?: string; - expression?: "sum" | "avg" | "count" | "max" | "min"; - numberFormat?: string; - valueCallback?: ( - grid: GridView, - column: DataColumn, - footerIndex: number, - columnFooter: ColumnFooter, - value: number | string | undefined, - ) => number | string; - } - | Array<{ - text?: string; - expression?: "sum" | "avg" | "count" | "max" | "min"; - numberFormat?: string; - valueCallback?: ( - grid: GridView, - column: DataColumn, - footerIndex: number, - columnFooter: ColumnFooter, - value: number | string | undefined, - ) => number | string; - }>; + | { + text?: string; + expression?: "sum" | "avg" | "count" | "max" | "min"; + numberFormat?: string; + valueCallback?: ( + grid: GridView, + column: DataColumn, + footerIndex: number, + columnFooter: ColumnFooter, + value: number | string | undefined, + ) => number | string; + } + | Array<{ + text?: string; + expression?: "sum" | "avg" | "count" | "max" | "min"; + numberFormat?: string; + valueCallback?: ( + grid: GridView, + column: DataColumn, + footerIndex: number, + columnFooter: ColumnFooter, + value: number | string | undefined, + ) => number | string; + }>; }; /** setColumns 메서드가 있는 GridView */ @@ -111,21 +117,21 @@ export type DataFieldValueCallback = ( export type ComputedFieldConfig = | { fields: string[]; type: "sum" | "avg" } | { - numeratorFields: string[]; - denominatorFields: string[]; - type: "pblswrgPopltnSuprtRate"; - } + numeratorFields: string[]; + denominatorFields: string[]; + type: "pblswrgPopltnSuprtRate"; + } | { - numeratorFields: string[]; - denominatorFields: string[]; - type: "adtrtPopltnSuprtRate"; - } + numeratorFields: string[]; + denominatorFields: string[]; + type: "adtrtPopltnSuprtRate"; + } | { - numeratorFields: string[]; - denominatorFields: string[]; - thirdFields: string[]; - type: "swrgInstlrtRate"; - }; + numeratorFields: string[]; + denominatorFields: string[]; + thirdFields: string[]; + type: "swrgInstlrtRate"; + }; export type ColumnConfig = { name: string; @@ -148,31 +154,31 @@ export type ColumnConfig = { valueType?: ValueType; dataFormat?: string; footer?: - | { - text?: string; - expression?: "sum" | "avg" | "count" | "max" | "min"; - numberFormat?: string; - valueCallback?: ( - grid: GridView, - column: DataColumn, - footerIndex: number, - columnFooter: ColumnFooter, - value: number | string | undefined, - ) => number | string; - multiRow?: boolean; // 여러 행 footer 사용 여부 - } - | Array<{ - text?: string; - expression?: "sum" | "avg" | "count" | "max" | "min"; - numberFormat?: string; - valueCallback?: ( - grid: GridView, - column: DataColumn, - footerIndex: number, - columnFooter: ColumnFooter, - value: number | string | undefined, - ) => number | string; - }>; // RealGrid에서 footer를 배열로 설정할 수 있음 + | { + text?: string; + expression?: "sum" | "avg" | "count" | "max" | "min"; + numberFormat?: string; + valueCallback?: ( + grid: GridView, + column: DataColumn, + footerIndex: number, + columnFooter: ColumnFooter, + value: number | string | undefined, + ) => number | string; + multiRow?: boolean; // 여러 행 footer 사용 여부 + } + | Array<{ + text?: string; + expression?: "sum" | "avg" | "count" | "max" | "min"; + numberFormat?: string; + valueCallback?: ( + grid: GridView, + column: DataColumn, + footerIndex: number, + columnFooter: ColumnFooter, + value: number | string | undefined, + ) => number | string; + }>; // RealGrid에서 footer를 배열로 설정할 수 있음 }; export type FooterUserSpan = { @@ -184,16 +190,16 @@ export type FooterUserSpan = { export type ColumnLayoutItem = | string | { - name: string; - hideChildHeaders?: boolean; - header: { text: string }; - items: (string | ColumnLayoutItem)[]; - footerUserSpans?: FooterUserSpan[]; // footer 병합 설정 - } + name: string; + hideChildHeaders?: boolean; + header: { text: string }; + items: (string | ColumnLayoutItem)[]; + footerUserSpans?: FooterUserSpan[]; // footer 병합 설정 + } | { - column: string; // 컬럼 이름 (footerUserSpans 사용 시) - footerUserSpans?: FooterUserSpan[]; // footer 병합 설정 - }; + column: string; // 컬럼 이름 (footerUserSpans 사용 시) + footerUserSpans?: FooterUserSpan[]; // footer 병합 설정 + }; export type ColumnInfo = { columns: ColumnConfig[]; @@ -285,7 +291,7 @@ export type DataGridProps = { /** CheckBar 헤더 전체 선택/해제 */ onItemAllChecked?: (checked: boolean) => void; onScrollToBottom?: () => void; - statusInfo?: SewerageSupplyRateStatusInfo | null; + statusInfo?: DataGridStatusBannerInfo | null; /** 강조 및 스크롤할 행의 ID */ highlightedRowId?: string | number | null; /** ID로 사용할 필드명 (기본값: 'id') */ diff --git a/apps/st/src/components/type/SearchForm.type.ts b/apps/st/src/components/type/SearchForm.type.ts index 6af84e181..79ca5d118 100644 --- a/apps/st/src/components/type/SearchForm.type.ts +++ b/apps/st/src/components/type/SearchForm.type.ts @@ -6,6 +6,7 @@ export type SearchFormValues = { fctName?: string; bsnmCd?: string; isDetail?: boolean; + isSummary?: string; }; export type SearchFormProps = { @@ -17,6 +18,7 @@ export type SearchFormProps = { fctName?: boolean; userMenual?: boolean; isDetail?: boolean; + isSummary?: boolean; onSubmit?: (values: SearchFormValues) => void; onReset?: () => void; onValuesChange?: (values: SearchFormValues) => void; @@ -33,4 +35,5 @@ export const defaultSearchFormProps: Required< fctName: false, userMenual: false, isDetail: false, + isSummary: false, }; \ No newline at end of file diff --git a/apps/st/src/components/util/column/input/DoList.ts b/apps/st/src/components/util/column/input/DoList.ts index 2b58bd57b..50085a92f 100644 --- a/apps/st/src/components/util/column/input/DoList.ts +++ b/apps/st/src/components/util/column/input/DoList.ts @@ -20,4 +20,15 @@ export const needBsnmFrgnrPoplSmSidoList = [ "4700000000", "4800000000", "5000000000", +]; + +export const doNameList = [ + "경기도", + "강원특별자치도", + "충청북도", + "충청남도", + "전북특별자치도", + "전라남도", + "경상북도", + "경상남도", ]; \ No newline at end of file diff --git a/apps/st/src/components/util/column/input/sewagePipe/ConnectionStatusInpColumn.ts b/apps/st/src/components/util/column/input/sewagePipe/ConnectionStatusInpColumn.ts index 8d99e6c9e..c28f12bc7 100644 --- a/apps/st/src/components/util/column/input/sewagePipe/ConnectionStatusInpColumn.ts +++ b/apps/st/src/components/util/column/input/sewagePipe/ConnectionStatusInpColumn.ts @@ -49,18 +49,18 @@ export const AdminAreaConnectionInpColumn = { export const TreatPlantConnectionInpColumn = { columns: [ // 하수처리장 정보 - { name: 'bscFctNm', fieldName: 'bscFctNm', header: { text: '하수처리장명' }, editable: false }, - { name: 'fctCpc', fieldName: 'fctCpc', header: { text: '용량' }, valueType: ValueType.NUMBER, dataFormat: '#,##0', editable: false }, - { name: 'bscFctCd', fieldName: 'bscFctCd', header: { text: '하수처리장코드' }, editable: false }, + { name: 'fcltNm', fieldName: 'fcltNm', header: { text: '하수처리장명' }, editable: false }, + { name: 'fcltCpct', fieldName: 'fcltCpct', header: { text: '용량' }, valueType: ValueType.NUMBER, dataFormat: '#,##0', editable: false }, + { name: 'fcltSn', fieldName: 'fcltSn', header: { text: '하수처리장코드' }, editable: false }, // 연결여부 { name: 'isConnection', fieldName: 'isConnection', header: { text: '연결여부' }, editable: false }, // 연결지역 { name: 'connectionAreaList', fieldName: 'connectionAreaList', header: { text: '연결지역' }, editable: false }, ], columnLayout: [ - 'bscFctNm', - 'fctCpc', - 'bscFctCd', + 'fcltNm', + 'fcltCpct', + 'fcltSn', 'isConnection', 'connectionAreaList', ], diff --git a/apps/st/src/components/util/column/input/sewagePipe/FactInstallStatusInpColumn.ts b/apps/st/src/components/util/column/input/sewagePipe/FactInstallStatusInpColumn.ts index 549baf09a..9e6b855cf 100644 --- a/apps/st/src/components/util/column/input/sewagePipe/FactInstallStatusInpColumn.ts +++ b/apps/st/src/components/util/column/input/sewagePipe/FactInstallStatusInpColumn.ts @@ -58,7 +58,7 @@ export const FactInstallStatusInpColumn = { // 식별·키 필드 (API SeweragePipeInputFactInstallStatusItem, 그리드 비표시 — fixedColumns와 겹치지 않도록 맨 뒤) { name: 'admdstCd', fieldName: 'admdstCd', header: { text: '행정구역코드' }, visible: false, editable: false, styleName: 'rg-cell-readonly' }, - { name: 'prcsFcltSn', fieldName: 'prcsFcltSn', header: { text: '처리시설일련번호' }, visible: false, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'fcltSn', fieldName: 'fcltSn', header: { text: '처리시설일련번호' }, visible: false, editable: false, valueType: ValueType.TEXT }, { name: 'swtrmtFcltCd', fieldName: 'swtrmtFcltCd', header: { text: '하수처리시설코드' }, visible: false, editable: false, styleName: 'rg-cell-readonly' }, ], columnLayout: [ @@ -146,7 +146,7 @@ export const FactInstallStatusInpColumn = { }, 'rmrk', 'admdstCd', - 'prcsFcltSn', + 'fcltSn', 'swtrmtFcltCd', ], rowStartNum: 4, diff --git a/apps/st/src/components/util/column/total/SewagePipeTotColumn.ts b/apps/st/src/components/util/column/total/SewagePipeTotColumn.ts index fa55d7f55..9c78c5b0e 100644 --- a/apps/st/src/components/util/column/total/SewagePipeTotColumn.ts +++ b/apps/st/src/components/util/column/total/SewagePipeTotColumn.ts @@ -1,5 +1,3 @@ -import dayjs from "dayjs"; -import { headers } from "next/headers"; import { ValueType } from "realgrid"; /** @@ -7,9 +5,9 @@ import { ValueType } from "realgrid"; * 항목은 검토(SewagePipeRvwColumn)와 동일하나 footer·mergeCells·구분(displayYear)·비고(rmrk) 제외. * 표시 형식은 SeweragePentrationRateTotColumn과 같이 dataFormat + editable: false. */ -const currentYear = dayjs().year() - 1; -const startYear = 1996; -const totalCount = currentYear - startYear + 1 + 1; + +/** ST00020003 관종별: 입력 `SeweragePipeInputPipeTypeStatusItem`·합계 `SewagePipePipeTypeFacilityStatusItem`과 동일 (yy1=1996년 이전 … yy30) */ +const YY_PIPE_TYPE_FACILITY_MAX = 30; const rawSewagePipeTotList = [ { @@ -642,11 +640,11 @@ const rawSewagePipeTotList = [ dataFormat: "#,##0.0", editable: false, }, - ...Array.from({ length: totalCount }, (_, i) => { - const dtoNum = totalCount - i; - const wholField = `yy${dtoNum}PrlgWhol`; - const lyngField = `yy${dtoNum}LyngPrlg`; - const dmltnField = `yy${dtoNum}DmltnPrlg`; + ...Array.from({ length: YY_PIPE_TYPE_FACILITY_MAX }, (_, i) => { + const yyIdx = i + 1; + const wholField = `yy${yyIdx}PrlgWhol`; + const lyngField = `yy${yyIdx}LyngPrlg`; + const dmltnField = `yy${yyIdx}DmltnPrlg`; return [ { name: wholField, @@ -687,13 +685,18 @@ const rawSewagePipeTotList = [ header: { text: "총계" }, items: ["pipeByPrstWholSum", "lyngPrlgSum", "dmltnPrlgSum"], }, - ...Array.from({ length: totalCount }, (_, i) => { - const dtoNum = totalCount - i; - const yearLabel = i === 0 ? "1996년이전" : `${startYear + (i - 1)}년`; + { + name: "year1996Before", + header: { text: "1996년이전" }, + items: ["yy1PrlgWhol", "yy1LyngPrlg", "yy1DmltnPrlg"], + }, + ...Array.from({ length: YY_PIPE_TYPE_FACILITY_MAX - 1 }, (_, i) => { + const yyIdx = i + 2; + const year = 1994 + yyIdx; return { - name: `group_${yearLabel}`, - header: { text: yearLabel }, - items: [`yy${dtoNum}PrlgWhol`, `yy${dtoNum}LyngPrlg`, `yy${dtoNum}DmltnPrlg`], + name: `group_yy${yyIdx}`, + header: { text: `${year}년` }, + items: [`yy${yyIdx}PrlgWhol`, `yy${yyIdx}LyngPrlg`, `yy${yyIdx}DmltnPrlg`], }; }), ], diff --git a/apps/st/src/components/util/column/total/sewagePipeMaintain/PipeDredgingStatusTotColumn.ts b/apps/st/src/components/util/column/total/sewagePipeMaintain/PipeDredgingStatusTotColumn.ts new file mode 100644 index 000000000..aec00f860 --- /dev/null +++ b/apps/st/src/components/util/column/total/sewagePipeMaintain/PipeDredgingStatusTotColumn.ts @@ -0,0 +1,79 @@ +import { ValueType } from "realgrid"; + +// 하수도 통계 관리 > 03.하수관로 유지관리 > tab 2. 관로준설 +export const PipeDredgingStatusTotColumn = { + columns: [ + // 행정구역 + { name: 'ctpvNm', fieldName: 'ctpvNm', header: { text: '시도' } }, + { name: 'sggNm', fieldName: 'sggNm', header: { text: '시군구명' } }, + { name: 'admdstNm', fieldName: 'admdstNm', header: { text: '행정구역명' } }, + + // 하수관로 총 시설연장 + { name: 'wholFcltPrlg', fieldName: 'wholFcltPrlg', header: { text: '하수관로총시설연장(m)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.00' }, + + // 준설연장 및 개소수 > 관로(m) + { name: 'wholSwagePpl', fieldName: 'wholSwagePpl', header: { text: '합계' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'cbsysPplPrlg', fieldName: 'cbsysPplPrlg', header: { text: '합류식' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + + // 준설연장 및 개소수 > 관로(m) > 분류식 + { name: 'sprsysExlnPrlg', fieldName: 'sprsysExlnPrlg', header: { text: '우수' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'sprsysDrtwPrlg', fieldName: 'sprsysDrtwPrlg', header: { text: '오수' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + + // 준설연장 및 개소수 > 맨홀, 오·우수받이, 토실·토구 + { name: 'mnhlCnt', fieldName: 'mnhlCnt', header: { text: '맨홀(개소)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'exlnDrtwRcvprpCnt', fieldName: 'exlnDrtwRcvprpCnt', header: { text: '오·우수받이(개소)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'stofcbrOtflCnt', fieldName: 'stofcbrOtflCnt', header: { text: '토실·토구(개소)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + + // 계획 & 비율 + { name: 'drdgnPlanPrlg', fieldName: 'drdgnPlanPrlg', header: { text: '준설 계획 연장(m)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'drdgnTrgtCtrsDrdgnRt', fieldName: 'drdgnTrgtCtrsDrdgnRt', header: { text: '계획대비 준설율(%)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'wholPrlgCtrsDrdgnRt', fieldName: 'wholPrlgCtrsDrdgnRt', header: { text: '총시설연장대비 준설율(%)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + + // 준설실적 + { name: 'drdgnAmt', fieldName: 'drdgnAmt', header: { text: '금액(백만원)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'drdgnQnt', fieldName: 'drdgnQnt', header: { text: '준설량(m³)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + + // 준설토사처리 + { name: 'drdgnUntprcAmt', fieldName: 'drdgnUntprcAmt', header: { text: '준설토처리비(백만원)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'wholDrdgnPrcsQnt', fieldName: 'wholDrdgnPrcsQnt', header: { text: '처리량 계(톤)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'rclmQnt', fieldName: 'rclmQnt', header: { text: '매립' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'oceanEmsnQnt', fieldName: 'oceanEmsnQnt', header: { text: '해양투기' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'rcycQnt', fieldName: 'rcycQnt', header: { text: '재활용' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'etcQnt', fieldName: 'etcQnt', header: { text: '기타(소각, 고형화, 시멘트원료 등)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + + ].map((col) => ({ ...col, editable: false })), + columnLayout: [ + { name: 'group_admin', header: { text: '행정구역' }, items: ['ctpvNm', 'sggNm', 'admdstNm'] }, + 'wholFcltPrlg', + { + name: 'group_dredge_info', + header: { text: '준설연장 및 개소수' }, + items: [ + { + name: 'group_pipe', + header: { text: '관로(m)' }, + items: [ + 'wholSwagePpl', 'cbsysPplPrlg', + { name: 'group_sep', header: { text: '분류식' }, items: ['sprsysExlnPrlg', 'sprsysDrtwPrlg'] } + ] + }, + 'mnhlCnt', 'exlnDrtwRcvprpCnt', 'stofcbrOtflCnt' + ] + }, + 'drdgnPlanPrlg', 'drdgnTrgtCtrsDrdgnRt', 'wholPrlgCtrsDrdgnRt', + { + name: 'group_perf', + header: { text: '준설실적' }, + items: ['drdgnAmt', 'drdgnQnt'] + }, + { + name: 'group_soil_treat', + header: { text: '준설토사처리' }, + items: ['drdgnUntprcAmt', 'wholDrdgnPrcsQnt', 'rclmQnt', 'oceanEmsnQnt', 'rcycQnt', 'etcQnt'] + }, + ], + rowStartNum: 4, + fixedColumns: 1, + tabKey: 'ST00030002', + tabLabel: '관로준설' +}; \ No newline at end of file diff --git a/apps/st/src/components/util/column/total/sewagePipeMaintain/RepairStatusTotColumn.ts b/apps/st/src/components/util/column/total/sewagePipeMaintain/RepairStatusTotColumn.ts new file mode 100644 index 000000000..307253309 --- /dev/null +++ b/apps/st/src/components/util/column/total/sewagePipeMaintain/RepairStatusTotColumn.ts @@ -0,0 +1,112 @@ +import { ValueType } from "realgrid"; + +// 하수도 통계 관리 > 03.하수관로 유지관리 > tab 1. 개보수 +export const RepairStatusTotColumn = { + columns: [ + // 행정구역 + { name: 'ctpvNm', fieldName: 'ctpvNm', header: { text: '시도' }, editable: false }, + { name: 'sggNm', fieldName: 'sggNm', header: { text: '시군구명' }, editable: false }, + { name: 'admdstNm', fieldName: 'admdstNm', header: { text: '행정구역명' }, editable: false }, + + // 개·보수관로 > 전체보수(m) + { name: 'mendngWhol', fieldName: 'mendngWhol', header: { text: '계' }, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'cbsysMendngPrlg', fieldName: 'cbsysMendngPrlg', header: { text: '합류식' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + + // 개·보수관로 > 전체보수(m) > 분류식 + { name: 'sprsysExlnPrlg', fieldName: 'sprsysExlnPrlg', header: { text: '우수' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + { name: 'sprsysDrtwPrlg', fieldName: 'sprsysDrtwPrlg', header: { text: '오수' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + + // 개·보수관로 > 부분보수(개소) + { name: 'partMendngWhol', fieldName: 'partMendngWhol', header: { text: '계' }, editable: false, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'cbsysPartMendngPrlg', fieldName: 'cbsysPartMendngPrlg', header: { text: '합류식' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + + // 개·보수관로 > 부분보수(개소) > 분류식 + { name: 'sprsysExlnMendngPrlg', fieldName: 'sprsysExlnMendngPrlg', header: { text: '우수' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'sprsysDrtwMendngPrlg', fieldName: 'sprsysDrtwMendngPrlg', header: { text: '오수' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + + // 맨홀(개소) + { name: 'cbsysMnhlCnt', fieldName: 'cbsysMnhlCnt', header: { text: '합류식맨홀' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + + // 맨홀(개소) > 분류식 + { name: 'sprsysDrtwMnhlCnt', fieldName: 'sprsysDrtwMnhlCnt', header: { text: '오수맨홀' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'sprsysExlnMnhlCnt', fieldName: 'sprsysExlnMnhlCnt', header: { text: '우수맨홀' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + + // 받이 + { name: 'drtwRcvprpCnt', fieldName: 'drtwRcvprpCnt', header: { text: '오수받이(개소)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'exlnRcvprpCnt', fieldName: 'exlnRcvprpCnt', header: { text: '빗물받이(개소)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + + // 토실, 토구 + { name: 'stofcbrCnt', fieldName: 'stofcbrCnt', header: { text: '토실(개소)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + { name: 'otflCnt', fieldName: 'otflCnt', header: { text: '토구(개소)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0' }, + + // 수밀검사CCTV조사량(m) + { name: 'cctvExmnPrlg', fieldName: 'cctvExmnPrlg', header: { text: '수밀검사·CCTV조사량(m)' }, valueType: ValueType.NUMBER, dataFormat: '#,##0.0' }, + + ].map((col) => ({ ...col, editable: false })), + columnLayout: [ + { + name: 'group_admin', + header: { text: '행정구역' }, + items: ['ctpvNm', 'sggNm', 'admdstNm'] + }, + { + name: 'group_repair_main', + header: { text: '개·보수관로' }, + items: [ + { + name: 'group_full_repair', + header: { text: '전체보수(m)' }, + items: [ + 'mendngWhol', + 'cbsysMendngPrlg', + { + name: 'group_full_sep', + header: { text: '분류식' }, + items: ['sprsysExlnPrlg', 'sprsysDrtwPrlg'] + } + ] + }, + { + name: 'group_part_repair', + header: { text: '부분보수(개소)' }, + items: [ + 'partMendngWhol', + 'cbsysPartMendngPrlg', + { + name: 'group_part_sep', + header: { text: '분류식' }, + items: ['sprsysExlnMendngPrlg', 'sprsysDrtwMendngPrlg'] + } + ] + } + ] + }, + { + name: 'group_manhole', + header: { text: '맨홀(개소)' }, + items: [ + 'cbsysMnhlCnt', + { + name: 'group_mh_sep', + header: { text: '분류식' }, + items: ['sprsysDrtwMnhlCnt', 'sprsysExlnMnhlCnt'] + } + ] + }, + { + name: 'group_inlet', + header: { text: '받이' }, + items: ['drtwRcvprpCnt', 'exlnRcvprpCnt'] + }, + { + name: 'group_chamber', + header: { text: '토실,토구' }, + items: ['stofcbrCnt', 'otflCnt'] + }, + 'cctvExmnPrlg', + ], + rowStartNum: 4, + fixedColumns: 1, + tabKey: 'ST00030001', + tabLabel: '개보수' +}; \ No newline at end of file diff --git a/apps/st/src/components/util/column/total/sewagePipeMaintain/sewagePipeMaintainColumn.ts b/apps/st/src/components/util/column/total/sewagePipeMaintain/sewagePipeMaintainColumn.ts new file mode 100644 index 000000000..f8e13699c --- /dev/null +++ b/apps/st/src/components/util/column/total/sewagePipeMaintain/sewagePipeMaintainColumn.ts @@ -0,0 +1,12 @@ +import { PipeDredgingStatusTotColumn } from "./PipeDredgingStatusTotColumn" +import { RepairStatusTotColumn } from "./RepairStatusTotColumn" + +const sewagePipeMaintainColumn = [ + RepairStatusTotColumn, + PipeDredgingStatusTotColumn +]; + +export const SewagePipeMaintainColumnListTot = sewagePipeMaintainColumn.map((tab) => ({ + ...tab, + columns: tab.columns.map((col) => ({ ...col, editable: false as const })), +})); \ No newline at end of file diff --git a/apps/st/src/entities/statistics/api/input/statistics-sewage-pipe-input-service.ts b/apps/st/src/entities/statistics/api/input/statistics-sewage-pipe-input-service.ts index 02d0144c4..77572d361 100644 --- a/apps/st/src/entities/statistics/api/input/statistics-sewage-pipe-input-service.ts +++ b/apps/st/src/entities/statistics/api/input/statistics-sewage-pipe-input-service.ts @@ -3,6 +3,8 @@ import { stClient } from "@/shared/api/client"; import type { SeweragePipeFacilityAdminAreaListRequest, SeweragePipeFacilityAdminAreaListResponse, + SeweragePipeFactConnectionSaveRequest, + SeweragePipeFactConnectionSaveResponse, SeweragePipeFacilitiesListRequest, SeweragePipeFacilitiesListResponse, SeweragePipeInputConnectionStatusResponse, @@ -12,9 +14,13 @@ import type { SeweragePipeInputPipeTypeResponse, SeweragePipeInputRegionalFactStatusSaveRequest, SeweragePipeInputRegionalFactStatusSaveResponse, + SeweragePipeInputRegionalFactStatusSubmitRequest, + SeweragePipeInputRegionalFactStatusSubmitResponse, SeweragePipeInputRegionalResponse, SeweragePipeRequestParams, StatisticsInputSewPipeService, + SeweragePipeInputFactInstallStatusSaveRequest, + SeweragePipeInputFactInstallStatusSaveResponse, } from "../../model/input/statistics-sewage-pipe-input-interface"; export class StatisticsInputSewPipeApiService implements StatisticsInputSewPipeService { @@ -82,6 +88,14 @@ export class StatisticsInputSewPipeApiService implements StatisticsInputSewPipeS return response.data; } + public async saveFactConnection(param: SeweragePipeFactConnectionSaveRequest): Promise { + const response = await stClient.post( + `${ENDPOINT.ST_SERVICE.INPUT}/sewage-pipe/facility-connection-status/popup/save`, + param, + ); + return response.data; + } + public async saveRegionalFactStatus(param: SeweragePipeInputRegionalFactStatusSaveRequest): Promise { const response = await stClient.post( `${ENDPOINT.ST_SERVICE.INPUT}/sewage-pipe/regional-facility-status/save`, @@ -89,6 +103,22 @@ export class StatisticsInputSewPipeApiService implements StatisticsInputSewPipeS ); return response.data; } + + public async submitRegionalFactStatus(param: SeweragePipeInputRegionalFactStatusSubmitRequest): Promise { + const response = await stClient.post( + `${ENDPOINT.ST_SERVICE.INPUT}/sewage-pipe/regional-facility-status/submit`, + param, + ); + return response.data; + } + + public async saveFactInstallStatus(param: SeweragePipeInputFactInstallStatusSaveRequest): Promise { + const response = await stClient.post( + `${ENDPOINT.ST_SERVICE.INPUT}/sewage-pipe/facility-install-status/save`, + param, + ); + return response.data; + } }; export const statisticsInputSewPipeApiService = new StatisticsInputSewPipeApiService(); \ No newline at end of file diff --git a/apps/st/src/entities/statistics/api/statistics-total-service.ts b/apps/st/src/entities/statistics/api/statistics-total-service.ts index cc2487261..402fd016c 100644 --- a/apps/st/src/entities/statistics/api/statistics-total-service.ts +++ b/apps/st/src/entities/statistics/api/statistics-total-service.ts @@ -2,7 +2,18 @@ import { ENDPOINT } from "@/shared/config"; import { stClient } from "@/shared/api/client"; import type { SewerageSupplyRateRequest, SewerageSupplyRateResponse } from "../model/statistics-total/statistics-sewage-supply-rate-total-interface"; -import type { SewagePipeTotalRequest, SewagePipeTotalResponse } from "../model/statistics-total/statistics-sewage-pipe-total-interface"; +import type { + SewagePipeMaintainDredgingTotalResponse, + SewagePipeMaintainTotalRequest, + SewagePipeMaintainRepairTotalResponse, +} from "../model/statistics-total/statistics-sewage-pipe-maintain-total-interface"; +import type { + SewagePipeFactInstallStatusResponse, + SewagePipePipeDiamStatusResponse, + SewagePipePipeTypeFacilityStatusResponse, + SewagePipeTotalRequest, + SewagePipeTotalResponse, +} from "../model/statistics-total/statistics-sewage-pipe-total-interface"; import type { SewerageServiceLevelComplaintResponse, SewerageServiceLevelRequest, @@ -41,6 +52,60 @@ export class StatisticsTotalApiService { ); return response.data; } + + public async getSewagePipeFactInstallStatus( + params: SewagePipeTotalRequest, + ): Promise { + const response = await stClient.get( + `${ENDPOINT.ST_SERVICE.TOTAL}/sewer-pipe/facility-installation-status`, + { params }, + ); + return response.data; + } + + /** ST00020003 — 관종·관경별 시설현황(합계) */ + public async getSewagePipePipeTypeFacilityStatus( + params: SewagePipeTotalRequest, + ): Promise { + const response = await stClient.get( + `${ENDPOINT.ST_SERVICE.TOTAL}/sewer-pipe/pipe-type-facility-status`, + { params }, + ); + return response.data; + } + + /** ST00020004 — 관경별 시설현황(합계) */ + public async getSewagePipePipeDiamStatus( + params: SewagePipeTotalRequest, + ): Promise { + const response = await stClient.get( + `${ENDPOINT.ST_SERVICE.TOTAL}/sewer-pipe/pipe-diam-status`, + { params }, + ); + return response.data; + } + + /** ST00030001 — 하수관로 유지관리 > 개보수(합계) */ + public async getSewagePipeMaintainRepairTotal( + params: SewagePipeMaintainTotalRequest, + ): Promise { + const response = await stClient.get( + `${ENDPOINT.ST_SERVICE.TOTAL}/sewer-pipe-mtc/repair`, + { params }, + ); + return response.data; + } + + /** ST00030002 — 하수관로 유지관리 > 관로준설(합계) */ + public async getSewagePipeMaintainDredgingTotal( + params: SewagePipeMaintainTotalRequest, + ): Promise { + const response = await stClient.get( + `${ENDPOINT.ST_SERVICE.TOTAL}/sewer-pipe-mtc/dredging`, + { params }, + ); + return response.data; + } } export const statisticsTotalStatusApi = new StatisticsTotalApiService(); \ No newline at end of file diff --git a/apps/st/src/entities/statistics/model/input/statistics-sewage-pipe-input-interface.ts b/apps/st/src/entities/statistics/model/input/statistics-sewage-pipe-input-interface.ts index 299078872..24264cc73 100644 --- a/apps/st/src/entities/statistics/model/input/statistics-sewage-pipe-input-interface.ts +++ b/apps/st/src/entities/statistics/model/input/statistics-sewage-pipe-input-interface.ts @@ -22,6 +22,10 @@ export interface SeweragePipeInputStatusInfo { export interface VerifyResultItem { vrfcCd: string; rsnExpln: string; + /** 현재값(문자열로 내려오는 경우가 많음) */ + nowVlCn?: string; + /** 비교값 */ + compVlCn?: string; } /** 필드명 -> 검증 결과 배열 */ @@ -106,6 +110,12 @@ export type SeweragePipeInputRegionalFactStatusSaveItem = Omit< "verificationResult" >; +/** Tab2 저장 — saveList */ +export type SeweragePipeInputFactInstallStatusSaveItem = Omit< + SeweragePipeInputFactInstallStatusItem, + "verificationResult" +>; + /** 지역별 시설현황 저장 API 요청 */ export interface SeweragePipeInputRegionalFactStatusSaveRequest { statsYr: string; @@ -118,7 +128,50 @@ export interface SeweragePipeInputRegionalFactStatusSaveRequest { } export interface SeweragePipeInputRegionalFactStatusSaveResponse { - data: string; + data?: string; + message?: string; + status: string; +} + +/** 지역별 시설현황 제출 API 요청 (save item 구조 재사용) */ +export interface SeweragePipeInputRegionalFactStatusSubmitRequest { + statsYr: string; + ctpvNm: string; + sggNm: string; + sewerBzmnCd: string; + userId: string; + laePicNm: string; + laePicTelNo: string; + laeKbrdrDeptNm: string; + laeKbrdrJbgdNm: string; + laeIdfrNm: string; + laeIdfrTelNo: string; + laeIdfrDeptNm: string; + laeIdfrJbgdNm: string; + submitList: SeweragePipeInputRegionalFactStatusSaveItem[]; +} + +export interface SeweragePipeInputRegionalFactStatusSubmitResponse { + data: { + finalStatus: string; + nvpassed: boolean; + } + message?: string; + status: string; +} + +export interface SeweragePipeInputFactInstallStatusSaveRequest { + statsYr: string; + ctpvNm: string; + sggNm: string; + sewerBzmnCd: string; + userId: string; + superAdmin: string; + saveList: SeweragePipeInputFactInstallStatusSaveItem[]; +} + +export interface SeweragePipeInputFactInstallStatusSaveResponse { + data?: string; message?: string; status: string; } @@ -128,7 +181,7 @@ export interface SeweragePipeInputFactInstallStatusItem { admdstCd: string; sggNm: string; admdstNm: string; - prcsFcltSn: number; + fcltSn: string; swtrmtFcltCd: string; swtrmtFcltNm: string; prcsFcltCpct: number; @@ -425,9 +478,9 @@ export interface SeweragePipeInputManholeResponse { /** 시설별 현황 관리 - 하수처리장별 연결 현황 목록 항목 */ export interface SeweragePipeInputFacilityConnectionItem { - bscFctNm: string; - fctCpc: number; - bscFctCd: string; + fcltNm: string; + fcltCpct: number; + fcltSn: string; isConnection: string; connectionAreaList: string; } @@ -472,9 +525,10 @@ export interface SeweragePipeFacilitiesListItem { ctpvNm: string; sggNm: string; admdstCd: string; - bscFctNm: string; - fctCpc: number; - bscFctCd: string; + fcltNm: string; + fcltCpct: number; + fcltCd: string; + fcltSn: string; } /** facilitiesList API 응답 */ @@ -490,7 +544,7 @@ export interface SeweragePipeFacilityAdminAreaListRequest { swtrmtFcltCd: string; sewerBzmnCd: string; ctpvNm: string; - sggNm: string; + sggNm?: string; } /** 처리시설별 행정구역 목록 항목 */ @@ -509,6 +563,34 @@ export interface SeweragePipeFacilityAdminAreaListResponse { message?: string; } +/** 시설별 연결관계 저장 — saveList 항목 */ +export interface SeweragePipeFactConnectionSaveItem { + admdstCd: string; + admdstNm: string; +} + +/** 시설별 연결관계 저장 요청 */ +export interface SeweragePipeFactConnectionSaveRequest { + ctpvNm: string; + sggNm: string; + fcltSn: string; + statsYr: string; + swtrmtFcltCd: string; + swtrmtFcltNm: string; + prcsFcltCpct: number; + sewerBzmnCd: string; + userId: string; + saveList: SeweragePipeFactConnectionSaveItem[]; + deleteList: string[]; +} + +/** 시설별 연결관계 저장 응답 */ +export interface SeweragePipeFactConnectionSaveResponse { + status: string; + data?: string; + message?: string; +} + export interface StatisticsInputSewPipeService { getRegionalFactStatus(param: SeweragePipeRequestParams): Promise; getFactInstallStatus(param: SeweragePipeRequestParams): Promise; @@ -518,7 +600,8 @@ export interface StatisticsInputSewPipeService { getConnectionStatus(param: SeweragePipeRequestParams): Promise; getFacilitiesList(param: SeweragePipeFacilitiesListRequest): Promise; getFacilityAdminAreaList(param: SeweragePipeFacilityAdminAreaListRequest): Promise; - saveRegionalFactStatus( - param: SeweragePipeInputRegionalFactStatusSaveRequest, - ): Promise; + saveFactConnection(param: SeweragePipeFactConnectionSaveRequest): Promise; + saveRegionalFactStatus(param: SeweragePipeInputRegionalFactStatusSaveRequest): Promise; + submitRegionalFactStatus(param: SeweragePipeInputRegionalFactStatusSubmitRequest): Promise; + saveFactInstallStatus(param: SeweragePipeInputFactInstallStatusSaveRequest): Promise; } \ No newline at end of file diff --git a/apps/st/src/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface.ts b/apps/st/src/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface.ts index 1c14dfab8..f88e9a08f 100644 --- a/apps/st/src/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface.ts +++ b/apps/st/src/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface.ts @@ -167,11 +167,61 @@ export interface SewerageSupplyRateSubmitResponse { status: string; } -// 하수도 통계 입력 기간 조회 +// 하수도 통계 입력 기간 조회 (입력 INPT / 검증 VRFC 구간) +export interface CheckInputPeriodItem { + statsYr: string; + seCd: number; + seType: string; + bgngYmd: string; + endYmd: string; + current: boolean; +} + export interface CheckInputPeriodResponse { status: string; message: string; - data: boolean; + data: CheckInputPeriodItem[]; +} + +/** 입력기간(INPT) 중 현재 구간이면 true */ +export function isInputPeriodCurrent(periods: CheckInputPeriodItem[] | null | undefined): boolean { + return (periods ?? []).some((p) => p.seType === "INPT" && p.current === true); +} + +/** `YYYYMMDD` → `yyyy-mm-dd` (그 외 형식은 그대로 반환) */ +export function formatCheckInputYmdAsDash(ymd: string | null | undefined): string { + if (ymd == null || ymd === "") return ""; + if (ymd.length === 8 && /^\d{8}$/.test(ymd)) { + return `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}`; + } + return ymd; +} + +/** 검증기간(VRFC) 중 `current`인 항목 (없으면 null) */ +export function getCurrentVerificationPeriod( + periods: CheckInputPeriodItem[] | null | undefined, +): CheckInputPeriodItem | null { + return (periods ?? []).find((p) => p.seType === "VRFC" && p.current === true) ?? null; +} + +/** 검증기간(VRFC) 중 현재 구간이면 true — 저장·제출·제출 취소 불가 */ +export function isVerificationPeriodCurrent(periods: CheckInputPeriodItem[] | null | undefined): boolean { + return getCurrentVerificationPeriod(periods) !== null; +} + +/** + * 검증기간이면 차단 안내 문구(줄바꿈 + yyyy-mm-dd ~ yyyy-mm-dd), 아니면 null + */ +export function getVerificationPeriodActionBlockedMessage( + periods: CheckInputPeriodItem[] | null | undefined, +): string | null { + const v = getCurrentVerificationPeriod(periods); + if (!v) return null; + const from = formatCheckInputYmdAsDash(v.bgngYmd); + const to = formatCheckInputYmdAsDash(v.endYmd); + const range = + from && to ? `${from} ~ ${to}` : [from, to].filter(Boolean).join(" ~ "); + return `검증기간에는 저장, 제출, 제출 취소를 할 수 없습니다.\n검증기간: ${range}`; } export interface StatisticsInputSewSupplyRateService { diff --git a/apps/st/src/entities/statistics/model/statistics-total/statistics-sewage-pipe-maintain-total-interface.ts b/apps/st/src/entities/statistics/model/statistics-total/statistics-sewage-pipe-maintain-total-interface.ts new file mode 100644 index 000000000..139320868 --- /dev/null +++ b/apps/st/src/entities/statistics/model/statistics-total/statistics-sewage-pipe-maintain-total-interface.ts @@ -0,0 +1,77 @@ +export interface SewagePipeMaintainTotalRequest { + ctpvNm?: string; + sggNm?: string; + statsYr?: string; + sewerBzmnCd?: string; + rowDataYn?: boolean; +} + +export interface SewagePipeMaintainRepairTotalItem { + ctpvNm: string; + sggNm: string | null; + statsYr: string; + rowType: string; + sewerBzmnCd: string | null; + admdstNm: string; + mendngWhol: number; + cbsysMendngPrlg: number; + sprsysDrtwPrlg: number; + sprsysExlnPrlg: number; + partMendngWhol: number; + cbsysPartMendngPrlg: number; + sprsysDrtwMendngPrlg: number; + sprsysExlnMendngPrlg: number; + cbsysMnhlCnt: number; + sprsysDrtwMnhlCnt: number; + sprsysExlnMnhlCnt: number; + drtwRcvprpCnt: number; + exlnRcvprpCnt: number; + stofcbrCnt: number; + otflCnt: number; + cctvExmnPrlg: number; + rmrk: string | null; +} + +export interface SewagePipeMaintainRepairTotalResponse { + status: string; + message?: string; + data: SewagePipeMaintainRepairTotalItem[]; +} + +/** `GET .../sewer-pipe-mtc/dredging` 목록 항목 */ +export interface SewagePipeMaintainDredgingTotalItem { + ctpvNm: string; + sggNm: string | null; + admdstNm: string; + admdstCd: string; + statsYr: string; + rowType: string; + sewerBzmnCd: string | null; + wholFcltPrlg: number; + wholSwagePpl: number; + cbsysPplPrlg: number; + sprsysDrtwPrlg: number; + sprsysExlnPrlg: number; + mnhlCnt: number; + exlnDrtwRcvprpCnt: number; + stofcbrOtflCnt: number; + drdgnPlanPrlg: number; + drdgnTrgtCtrsDrdgnRt: number; + wholPrlgCtrsDrdgnRt: number; + drdgnAmt: number; + drdgnQnt: number; + drdgnUntprcAmt: number; + wholDrdgnPrcsQnt: number; + rclmQnt: number; + oceanEmsnQnt: number; + rcycQnt: number; + etcQnt: number; + rmrk: string | null; +} + +/** `GET .../sewer-pipe-mtc/dredging` 응답 */ +export interface SewagePipeMaintainDredgingTotalResponse { + status: string; + message?: string; + data: SewagePipeMaintainDredgingTotalItem[]; +} diff --git a/apps/st/src/entities/statistics/model/statistics-total/statistics-sewage-pipe-total-interface.ts b/apps/st/src/entities/statistics/model/statistics-total/statistics-sewage-pipe-total-interface.ts index f5c71974b..d9a7825cf 100644 --- a/apps/st/src/entities/statistics/model/statistics-total/statistics-sewage-pipe-total-interface.ts +++ b/apps/st/src/entities/statistics/model/statistics-total/statistics-sewage-pipe-total-interface.ts @@ -57,3 +57,180 @@ export interface SewagePipeTotalResponse { message: string; data: SewagePipeTotalItem[]; } + +export interface SewagePipeFactInstallStatusItem { + statsYr: string; + ctpvNm: string; + sggNm: string | null; + admdstCd: string; + admdstNm: string; + swtrmtFcltNm: string; + prcsFcltCpct: number | null; + rowType: string; + prcsZoneArea: number; + cbsysPrcsZoneArea: number; + sprsysPrcsZoneArea: number; + sprsysVartnRt: number; + mltvEqpmCnt: number; + cbsysMltvEqpmCnt: number; + sprsysMltvEqpmCnt: number; + sprsysMltvLnkgCnt: number; + sprsysMltvYetLnkgCnt: number; + planPrcsZoneArea: number; + cbsysPlanPrcsZoneArea: number; + sprsysPlanPrcsZoneArea: number; + wholFcltPrlg: number; + wholIcptgAtlfcPrlg: number | null; + icptgPrlg: number; + atlfcsPrlg: number; + cbsysStofcbrCnt: number; + sprsysStofcbrCnt: number; + sprsysExlnOtflCnt: number; + wholBrchlnPrlg: number; + cbsysBrchlnPrlg: number; + exlnBrchlnPrlg: number; + drtwBrchlnPrlg: number; +} + +export interface SewagePipeFactInstallStatusResponse { + status: string; + message?: string; + data: SewagePipeFactInstallStatusItem[]; +} + +/** ST00020003 합계 — `GET .../sewer-pipe/pipe-type-facility-status` */ +export interface SewagePipePipeTypeFacilityStatusItem { + pipeByPrstWhol: number; + lyngPrlg: number; + dmltnPrlg: number; + yy1PrlgWhol: number; + yy1LyngPrlg: number; + yy1DmltnPrlg: number; + yy2PrlgWhol: number; + yy2LyngPrlg: number; + yy2DmltnPrlg: number; + yy3PrlgWhol: number; + yy3LyngPrlg: number; + yy3DmltnPrlg: number; + yy4PrlgWhol: number; + yy4LyngPrlg: number; + yy4DmltnPrlg: number; + yy5PrlgWhol: number; + yy5LyngPrlg: number; + yy5DmltnPrlg: number; + yy6PrlgWhol: number; + yy6LyngPrlg: number; + yy6DmltnPrlg: number; + yy7PrlgWhol: number; + yy7LyngPrlg: number; + yy7DmltnPrlg: number; + yy8PrlgWhol: number; + yy8LyngPrlg: number; + yy8DmltnPrlg: number; + yy9PrlgWhol: number; + yy9LyngPrlg: number; + yy9DmltnPrlg: number; + yy10PrlgWhol: number; + yy10LyngPrlg: number; + yy10DmltnPrlg: number; + yy11PrlgWhol: number; + yy11LyngPrlg: number; + yy11DmltnPrlg: number; + yy12PrlgWhol: number; + yy12LyngPrlg: number; + yy12DmltnPrlg: number; + yy13PrlgWhol: number; + yy13LyngPrlg: number; + yy13DmltnPrlg: number; + yy14PrlgWhol: number; + yy14LyngPrlg: number; + yy14DmltnPrlg: number; + yy15PrlgWhol: number; + yy15LyngPrlg: number; + yy15DmltnPrlg: number; + yy16PrlgWhol: number; + yy16LyngPrlg: number; + yy16DmltnPrlg: number; + yy17PrlgWhol: number; + yy17LyngPrlg: number; + yy17DmltnPrlg: number; + yy18PrlgWhol: number; + yy18LyngPrlg: number; + yy18DmltnPrlg: number; + yy19PrlgWhol: number; + yy19LyngPrlg: number; + yy19DmltnPrlg: number; + yy20PrlgWhol: number; + yy20LyngPrlg: number; + yy20DmltnPrlg: number; + yy21PrlgWhol: number; + yy21LyngPrlg: number; + yy21DmltnPrlg: number; + yy22PrlgWhol: number; + yy22LyngPrlg: number; + yy22DmltnPrlg: number; + yy23PrlgWhol: number; + yy23LyngPrlg: number; + yy23DmltnPrlg: number; + yy24PrlgWhol: number; + yy24LyngPrlg: number; + yy24DmltnPrlg: number; + yy25PrlgWhol: number; + yy25LyngPrlg: number; + yy25DmltnPrlg: number; + yy26PrlgWhol: number; + yy26LyngPrlg: number; + yy26DmltnPrlg: number; + yy27PrlgWhol: number; + yy27LyngPrlg: number; + yy27DmltnPrlg: number; + yy28PrlgWhol: number; + yy28LyngPrlg: number; + yy28DmltnPrlg: number; + yy29PrlgWhol: number; + yy29LyngPrlg: number; + yy29DmltnPrlg: number; + yy30PrlgWhol: number; + yy30LyngPrlg: number; + yy30DmltnPrlg: number; + ctpvNm: string; + sggNm: string | null; + admdstCd: string; + admdstNm: string; + swrgPipeNm: string; + statsYr: string; + rowType: string; + rmrk: string | null; +} + +export interface SewagePipePipeTypeFacilityStatusResponse { + status: string; + message?: string; + data: SewagePipePipeTypeFacilityStatusItem[]; +} + +/** ST00020004 합계 — `GET .../sewer-pipe/pipe-diam-status` */ +export interface SewagePipePipeDiamStatusItem { + ctpvNm: string; + sggNm: string | null; + admdstCd: string; + admdstNm: string; + statsYr: string; + swrgPipeNm: string; + rowType: string; + pdmtByTsmWhol: number; + step1PdmtPrlg: number; + step2PdmtPrlg: number; + step3PdmtPrlg: number; + step4PdmtPrlg: number; + step5PdmtPrlg: number; + sqrePdmtPrlg: number; + etcPdmtPrlg: number; + rmrk: string | null; +} + +export interface SewagePipePipeDiamStatusResponse { + status: string; + message?: string; + data: SewagePipePipeDiamStatusItem[]; +} diff --git a/apps/st/src/entities/statistics/model/statistics-total/statistics-sewage-supply-rate-total-interface.ts b/apps/st/src/entities/statistics/model/statistics-total/statistics-sewage-supply-rate-total-interface.ts index 31f7b81db..faef3a3f1 100644 --- a/apps/st/src/entities/statistics/model/statistics-total/statistics-sewage-supply-rate-total-interface.ts +++ b/apps/st/src/entities/statistics/model/statistics-total/statistics-sewage-supply-rate-total-interface.ts @@ -4,6 +4,8 @@ export interface SewerageSupplyRateRequest { statsYr?: string; sewerBzmnCd?: string; rowDataYn?: boolean; + summaryYn?: boolean; + summaryYearCnt?: number; } export interface SewerageSupplyRateTotalItem { From 9fc2fc894203a9b5bb3a57d38e72f7be953bf191 Mon Sep 17 00:00:00 2001 From: bicochan Date: Wed, 29 Apr 2026 11:37:17 +0900 Subject: [PATCH 6/6] =?UTF-8?q?[st]=20feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/statistics/Dashboard.tsx | 15 +- .../pages/statistics/model/panel-config.tsx | 16 +- .../statistics/ui/panels/notice-panel.tsx | 31 +-- .../ui/panels/schedule-calendar-panel.tsx | 19 +- .../ui/panels/schedule-info-panel.tsx | 133 +++++++++---- .../ui/panels/stats-submission-panel.tsx | 185 +++++++++++++----- .../statistics/ui/panels/unsubmit-panel.tsx | 17 +- .../statistics/ui/panels/vrfc-err-panel.tsx | 8 +- .../ui/panels/vrfc-report-panel.tsx | 87 +++++--- .../pages/statistics/ui/statistics-page.tsx | 4 +- 10 files changed, 348 insertions(+), 167 deletions(-) diff --git a/apps/st/src/components/statistics/Dashboard.tsx b/apps/st/src/components/statistics/Dashboard.tsx index b3734e386..423294d2f 100644 --- a/apps/st/src/components/statistics/Dashboard.tsx +++ b/apps/st/src/components/statistics/Dashboard.tsx @@ -63,9 +63,6 @@ function Dashboard() { items={panels.map((p) => p.id)} strategy={rectSortingStrategy} > - {/* TBD: 그리드 레이아웃 비율 정의 필요 - 현재: 3열 그리드, 반응형 브레이크포인트 적용 - SPEC 레이아웃에 따라 조정 필요 */}
{panels.map((panel) => ( ) : (
- - - - - - + + + + + +
)} diff --git a/apps/st/src/pages/statistics/model/panel-config.tsx b/apps/st/src/pages/statistics/model/panel-config.tsx index 75f678a7f..8cc4f6271 100644 --- a/apps/st/src/pages/statistics/model/panel-config.tsx +++ b/apps/st/src/pages/statistics/model/panel-config.tsx @@ -6,7 +6,7 @@ import { ScheduleCalendarPanel } from "../ui/panels/schedule-calendar-panel"; import { ScheduleInfoPanel } from "../ui/panels/schedule-info-panel"; import { StatsSubmissionPanel } from "../ui/panels/stats-submission-panel"; import { UnSubmitPanel } from "../ui/panels/unsubmit-panel"; -import { VrfcErrPanel } from "../ui/panels/vrfc-err-panel"; +// import { VrfcErrPanel } from "../ui/panels/vrfc-err-panel"; import { VrfcReportPanel } from "../ui/panels/vrfc-report-panel"; import type { PanelItem } from "./types"; @@ -36,23 +36,23 @@ export function getInitialPanels({ component: , className: "", }, + // { + // id: "panel-d", + // component: , + // className: "", + // }, { id: "panel-d", - component: , - className: "", - }, - { - id: "panel-e", component: , className: "", }, { - id: "panel-f", + id: "panel-e", component: , className: "", }, { - id: "panel-g", + id: "panel-f", component: , className: "", }, diff --git a/apps/st/src/pages/statistics/ui/panels/notice-panel.tsx b/apps/st/src/pages/statistics/ui/panels/notice-panel.tsx index 8580ce2f0..2d182b741 100644 --- a/apps/st/src/pages/statistics/ui/panels/notice-panel.tsx +++ b/apps/st/src/pages/statistics/ui/panels/notice-panel.tsx @@ -17,7 +17,7 @@ export function NoticePanel() { const recentPostListQuery = useRecentPostList({ page: 0, - size: 3, + size: 4, keywordType: "bbsTypeCd", keyword: "BBSNOTICE", }); @@ -45,14 +45,18 @@ export function NoticePanel() { }); return ( -
+
-

공지사항

-
@@ -67,17 +71,16 @@ const noticeColumns = [ columnHelper.accessor("pstTtl", { header: "제목", cell: ({ row }) => ( -
-
- {row.original.pstTtl} -
-
- {row.original.wrtrNm} - {formatDate(row.original.regDt)} -
-
+
{row.original.pstTtl}
), }), + columnHelper.accessor("wrtrNm", { + header: "작성자", + }), + columnHelper.accessor("regDt", { + header: "등록일", + cell: ({ row }) => formatDate(row.original.regDt), + }), ]; function formatDate(value: string) { diff --git a/apps/st/src/pages/statistics/ui/panels/schedule-calendar-panel.tsx b/apps/st/src/pages/statistics/ui/panels/schedule-calendar-panel.tsx index 4bc29a441..8f85b1351 100644 --- a/apps/st/src/pages/statistics/ui/panels/schedule-calendar-panel.tsx +++ b/apps/st/src/pages/statistics/ui/panels/schedule-calendar-panel.tsx @@ -8,6 +8,7 @@ import dayjs from "dayjs"; import { SITE_ID } from "@/shared/config"; import { useSchedules } from "@/entities/schedule"; +import { LoadingWithAnimation } from "@common/components"; import { useDragHandle } from "../sortable-panel"; @@ -31,21 +32,24 @@ export function ScheduleCalendarPanel() { }); return ( -
+
-

일정관리

+

일정관리

-
+
{isLoading && ( -
로딩 중...
+
+
+ +
+
)} {data && ( -
- {/* TBD: FullCalendar 상세 설정 (locale, plugins, 이벤트 색상 등) */} +
diff --git a/apps/st/src/pages/statistics/ui/panels/schedule-info-panel.tsx b/apps/st/src/pages/statistics/ui/panels/schedule-info-panel.tsx index 7d2ab0eb6..8c162a436 100644 --- a/apps/st/src/pages/statistics/ui/panels/schedule-info-panel.tsx +++ b/apps/st/src/pages/statistics/ui/panels/schedule-info-panel.tsx @@ -1,7 +1,14 @@ "use client"; +import dayjs from "dayjs"; + import type { ScheduleInfoSearchParams } from "@/entities/statistics"; import { useScheduleInfo } from "@/entities/statistics"; +import { + Badge, + type BadgeProps, + LoadingWithAnimation, +} from "@common/components"; import { useDragHandle } from "../sortable-panel"; @@ -9,63 +16,121 @@ interface ScheduleInfoPanelProps { params: ScheduleInfoSearchParams | null; } +const CURRENT_STATUS_LABEL_MAP = { + INPT: "입력중", + VRFC: "검증중", + NONE: "기간외", +}; + +const CURRENT_STATUS_COLOR_MAP = { + INPT: "", + VRFC: "gray", + NONE: "lightGray", +}; + export function ScheduleInfoPanel({ params }: ScheduleInfoPanelProps) { const { dragHandleProps, isDragging } = useDragHandle(); const { data, isLoading, error } = useScheduleInfo(params); return ( -
+
-

일정안내

+

일정안내

+ {data && ( +
+ + {data.ddayLabel} + + + {data.dday} + +
+ )}
-
+
{isLoading && ( -
로딩 중...
+
+
+ +
+
)} {error && ( -
+
데이터를 불러올 수 없습니다.
)} {data && ( -
- {/* D-day 표시 */} -
-
- {data.ddayLabel} -
-
- {data.dday} -
-
- {data.currentSe} -
-
- - {/* TBD: 일정 목록 표시 형식 정의 필요 */} -
-
-
- 입력기간 -
-
- {data.inptBgngYmd} ~ {data.inptEndYmd} + <> +
+
+

+ 이번 주 주요 일정 +

+
+ + 현재 진행 상태 + + + { + CURRENT_STATUS_LABEL_MAP[ + data.currentSe as keyof typeof CURRENT_STATUS_LABEL_MAP + ] + } +
-
-
- 검증기간 +
+
+ + {data.inptSeCd}차 입력기간 + +

+ {dayjs(data.inptBgngYmd).format( + "YYYY.MM.DD", + )}{" "} + ~{" "} + {dayjs(data.inptEndYmd).format( + "YYYY.MM.DD", + )} +

-
- {data.vrfcBgngYmd} ~ {data.vrfcEndYmd} +
+ + {data.vrfcSeCd}차 검증기간 + +

+ {dayjs(data.vrfcBgngYmd).format( + "YYYY.MM.DD", + )}{" "} + ~{" "} + {dayjs(data.vrfcEndYmd).format( + "YYYY.MM.DD", + )} +

-
+ )}
diff --git a/apps/st/src/pages/statistics/ui/panels/stats-submission-panel.tsx b/apps/st/src/pages/statistics/ui/panels/stats-submission-panel.tsx index 23fe0c963..f973f4e9e 100644 --- a/apps/st/src/pages/statistics/ui/panels/stats-submission-panel.tsx +++ b/apps/st/src/pages/statistics/ui/panels/stats-submission-panel.tsx @@ -1,8 +1,14 @@ "use client"; -import { useDashboardStatsSubmissionRate } from "@/entities/statistics"; +import { Frown, Laugh, Meh, OctagonAlert, OctagonX, Smile } from "lucide-react"; + +import { + useDashboardEcptReqCount, + useDashboardStatsSubmissionRate, + useDashboardVrfcErrCount, +} from "@/entities/statistics"; import type { DashboardSearchParams } from "@/entities/statistics/model/dashboard/dashboard-interface"; -import { LoadingWithAnimation } from "@common/components"; +import { LoadingWithAnimation, Progress } from "@common/components"; import { useDragHandle } from "../sortable-panel"; @@ -10,70 +16,147 @@ interface StatsSubmissionPanelProps { params: DashboardSearchParams | null; } +const STATUS_LEVEL_TEXT_COLOR = { + GREAT: "text-blue-500", + GOOD: "text-green-500", + WARNING: "text-yellow-500", + BAD: "text-red-500", +}; + +const STATUS_LEVEL_EMOJI = { + GREAT: , + GOOD: , + WARNING: , + BAD: , +}; + export function StatsSubmissionPanel({ params }: StatsSubmissionPanelProps) { const { dragHandleProps, isDragging } = useDragHandle(); const { data, isLoading, error } = useDashboardStatsSubmissionRate(params); + const vrfcErrQuery = useDashboardVrfcErrCount(params); + const ecptReqQuery = useDashboardEcptReqCount(params); return ( -
-
+ {/*
-

통계입력현황

-
-
- {isLoading && ( -
-
- -
-
- )} - {error && ( -
- 데이터를 불러올 수 없습니다. -
- )} - {data && ( -
- {/* TBD: statusLevel별 이모지 매핑 규칙 필요 */} -
-
- {/* TBD: statusLevel 값에 따른 이모지 표시 */} - {data.statusLevel === "GOOD" - ? "😊" - : data.statusLevel === "WARNING" - ? "⚠️" - : "📊"} +

통계입력현황

+
*/} +
+
+
+

통계입력현황

+ {isLoading && ( +
+
+ +
-
- {data.statusLevel} + )} + {error && ( +
+ 데이터를 불러올 수 없습니다.
-
+ )} +
+ {data && ( +
+ {/* 통계제출률 상태 이모지 */} +
+ { + STATUS_LEVEL_EMOJI[ + data.statusLevel as keyof typeof STATUS_LEVEL_EMOJI + ] + } +
- {/* 통계제출률 프로그레스바 */} -
-
- 통계제출률 - - {data.submissionRate?.toFixed(1)}% - + {/* 통계제출률 프로그레스바 */} +
+
+ + 통계제출률 + + + {data.submissionRate?.toFixed( + 1, + )} + % + + ({data.submittedCount}/ + {data.totalCount}) + + +
+ +
+
+ )} +
+
+
+

+ 검증오류 및 예외 +

+
+ {/* 검증오류 수 카드 */} +
+
+ + 검증오류 수 +
+ {vrfcErrQuery.isLoading && ( +
-
+ )} + {vrfcErrQuery.error && ( +
+ 데이터를 불러올 수 없습니다. +
+ )} + {vrfcErrQuery.data && ( +
+ {vrfcErrQuery.data.vrfcErrCount.toLocaleString()} +
+ )}
-
-
-
-
- 제출: {data.submittedCount} - 전체: {data.totalCount} + + {/* 예외신청 수 카드 */} +
+
+ + 예외신청 수 +
+ {ecptReqQuery.isLoading && ( +
-
+ )} + {ecptReqQuery.error && ( +
+ 데이터를 불러올 수 없습니다. +
+ )} + {ecptReqQuery.data && ( +
+ {ecptReqQuery.data.ecptReqCount.toLocaleString()} +
+ )}
- )} +
); diff --git a/apps/st/src/pages/statistics/ui/panels/unsubmit-panel.tsx b/apps/st/src/pages/statistics/ui/panels/unsubmit-panel.tsx index d8c1fc832..fae349694 100644 --- a/apps/st/src/pages/statistics/ui/panels/unsubmit-panel.tsx +++ b/apps/st/src/pages/statistics/ui/panels/unsubmit-panel.tsx @@ -15,23 +15,20 @@ export function UnSubmitPanel({ params }: UnSubmitPanelProps) { const { data, isLoading, error } = useDashboardUnSubmitCount(params); return ( -
+
-

미제출 지표

+

미제출 지표

{data && ( - + {data.unSubmitCount}건 )}
-
+
{isLoading && (
@@ -51,11 +48,11 @@ export function UnSubmitPanel({ params }: UnSubmitPanelProps) { 미제출 지표가 없습니다.
) : ( -
+
{data.unSubmitList.map((item) => (

{item.statsNm} diff --git a/apps/st/src/pages/statistics/ui/panels/vrfc-err-panel.tsx b/apps/st/src/pages/statistics/ui/panels/vrfc-err-panel.tsx index 2a753196c..58fb11477 100644 --- a/apps/st/src/pages/statistics/ui/panels/vrfc-err-panel.tsx +++ b/apps/st/src/pages/statistics/ui/panels/vrfc-err-panel.tsx @@ -20,15 +20,15 @@ export function VrfcErrPanel({ params }: VrfcErrPanelProps) { const ecptReqQuery = useDashboardEcptReqCount(params); return ( -

+
-

검증오류 및 예외

+

검증오류 및 예외

-
+
{/* 검증오류 수 카드 */}
diff --git a/apps/st/src/pages/statistics/ui/panels/vrfc-report-panel.tsx b/apps/st/src/pages/statistics/ui/panels/vrfc-report-panel.tsx index 193e71a37..51834d857 100644 --- a/apps/st/src/pages/statistics/ui/panels/vrfc-report-panel.tsx +++ b/apps/st/src/pages/statistics/ui/panels/vrfc-report-panel.tsx @@ -2,6 +2,11 @@ import { useDashboardVrfcReport } from "@/entities/statistics"; import type { DashboardSearchParams } from "@/entities/statistics/model/dashboard/dashboard-interface"; +import { + Badge, + type BadgeProps, + LoadingWithAnimation, +} from "@common/components"; import { useDragHandle } from "../sortable-panel"; @@ -9,79 +14,107 @@ interface VrfcReportPanelProps { params: DashboardSearchParams | null; } +const TYPE_CODE_NAME_MAP: Record = { + NV: "일반", + IMR: "I-MR", +}; + +const TYPE_CODE_COLOR_MAP: Record = { + NV: "gray", + IMR: "red", +}; + export function VrfcReportPanel({ params }: VrfcReportPanelProps) { const { dragHandleProps, isDragging } = useDragHandle(); const { data, isLoading, error } = useDashboardVrfcReport(params); return ( -
+
-

이상치 선별보고서

+

이상치 선별보고서

-
+
{isLoading && ( -
로딩 중...
+
+
+ +
+
)} {error && ( -
+
데이터를 불러올 수 없습니다.
)} {data && ( -
+
{/* TBD: typeCounts 표시 방법 (탭? 필터?) */} {data.typeCounts && Object.keys(data.typeCounts).length > 0 && (
{Object.entries(data.typeCounts).map( ([type, count]) => ( - - {type}: {count} - + {TYPE_CODE_NAME_MAP[type]}:{" "} + {count} + ), )}
)} {/* TBD: 테이블 컬럼 정의, 증감 표시 방법 */} -
+
{data.list.length === 0 ? ( -
+
이상치 항목이 없습니다.
) : ( -
+
{data.list.map((item, index) => (
-
-
+
+ {item.statsNm} -
- {/* TBD: vrfcCd 값별 표시 레이블 */} - - {item.vrfcCd} + + { + TYPE_CODE_NAME_MAP[ + item.vrfcCd + ] + } +
-
+

{item.rsnExpln} -

-
+

+
{item.admdstNm} {/* TBD: 증감 표시 방법 (nowVlCn - compVlCn 계산?) */} - 현재: {item.nowVlCn} / 비교:{" "} - {item.compVlCn} + 현재: {item.nowVlCn ?? "-"}{" "} + / 비교:{" "} + {item.compVlCn ?? "-"}
{/* TBD: 등급/점수/단계/업데이트 시각 필드 미제공 */} diff --git a/apps/st/src/pages/statistics/ui/statistics-page.tsx b/apps/st/src/pages/statistics/ui/statistics-page.tsx index e50f90a0d..131ab95dd 100644 --- a/apps/st/src/pages/statistics/ui/statistics-page.tsx +++ b/apps/st/src/pages/statistics/ui/statistics-page.tsx @@ -37,14 +37,14 @@ const StatisticsPage = () => { // 탭이 없으면 Dashboard 표시 if (tabs.length === 0) { return ( -
+
); } return ( -
+
{tabs.map((tab) => { // 렌더링 시점에 컴포넌트가 없으면 바로 로드 if (!componentCache.current.has(tab.id)) {