From ff2b79aaa619033a00529c3cc9ef289f0dba9618 Mon Sep 17 00:00:00 2001 From: bicochan Date: Mon, 9 Mar 2026 17:36:25 +0900 Subject: [PATCH] =?UTF-8?q?[st]=20feat:=20=ED=86=B5=EA=B3=84=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=83=81=EC=84=B8=20=ED=99=94=EB=A9=B4=20=EC=9E=91?= =?UTF-8?q?=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/st/package.json | 1 + apps/st/src/app/styles/components.css | 4 - .../verification/HbStatisticsVerification.tsx | 577 ++++++++++-------- .../detail/HbVerificationDetail.tsx | 87 +++ .../verification/detail/Indicators.tsx | 86 +++ .../verification/detail/RegionalGrid.tsx | 269 ++++++++ .../verification/detail/ScatterChart.tsx | 253 ++++++++ .../verification/detail/VerificationList.tsx | 141 +++++ .../api/statistics-analysis-detail-api.ts | 36 ++ .../api/statistics-analysis-result-api.ts | 37 ++ .../statistics-analysis-detail-interface.ts | 28 + .../statistics-analysis-result-interface.ts | 46 ++ apps/st/src/shared/config/api.ts | 2 + .../src/ui/date-range-picker/index.tsx | 5 +- pnpm-lock.yaml | 75 ++- 15 files changed, 1347 insertions(+), 300 deletions(-) create mode 100644 apps/st/src/components/statistics/verification/detail/HbVerificationDetail.tsx create mode 100644 apps/st/src/components/statistics/verification/detail/Indicators.tsx create mode 100644 apps/st/src/components/statistics/verification/detail/RegionalGrid.tsx create mode 100644 apps/st/src/components/statistics/verification/detail/ScatterChart.tsx create mode 100644 apps/st/src/components/statistics/verification/detail/VerificationList.tsx create mode 100644 apps/st/src/entities/statistics/api/statistics-analysis-detail-api.ts create mode 100644 apps/st/src/entities/statistics/api/statistics-analysis-result-api.ts create mode 100644 apps/st/src/entities/statistics/model/statistics-analysis-detail-interface.ts create mode 100644 apps/st/src/entities/statistics/model/statistics-analysis-result-interface.ts diff --git a/apps/st/package.json b/apps/st/package.json index c874cf19f..9880cab30 100644 --- a/apps/st/package.json +++ b/apps/st/package.json @@ -34,6 +34,7 @@ "@types/xlsx": "^0.0.36", "axios": "^1.8.1", "chart.js": "^4.4.8", + "chartjs-plugin-zoom": "^2.2.0", "dayjs": "^1.11.13", "js-cookie": "^3.0.5", "jszip": "^3.10.1", diff --git a/apps/st/src/app/styles/components.css b/apps/st/src/app/styles/components.css index 35a341131..e69de29bb 100644 --- a/apps/st/src/app/styles/components.css +++ b/apps/st/src/app/styles/components.css @@ -1,4 +0,0 @@ -.card { - margin: 0.5rem !important; - padding: 0.5rem !important; -} diff --git a/apps/st/src/components/statistics/verification/HbStatisticsVerification.tsx b/apps/st/src/components/statistics/verification/HbStatisticsVerification.tsx index 183b85468..0f44b8e4a 100644 --- a/apps/st/src/components/statistics/verification/HbStatisticsVerification.tsx +++ b/apps/st/src/components/statistics/verification/HbStatisticsVerification.tsx @@ -1,18 +1,39 @@ "use client"; -import dayjs from "dayjs"; import { useEffect, useMemo, useState } from "react"; -import { useRouter } from "next/navigation"; + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { getCoreRowModel, getPaginationRowModel, useReactTable, } from "@tanstack/react-table"; +import type { AxiosError } from "axios"; +import dayjs from "dayjs"; + +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore } from "@/shared/lib"; +import type { ApiErrorResponse } from "@/shared/model"; +import { CodeSelect } from "@/entities/code"; +import { analysisApi } from "@/entities/statistics/api/analysis-api"; +import { analysisHistoryApi } from "@/entities/statistics/api/analysis-history-api"; +import { verificationTargetManagementApi } from "@/entities/statistics/api/verification-target-management-service"; +import type { + StAnlsMstResponse, + StAnlsMstSearchParams, + StAnlsOutlierListResponse, +} from "@/entities/statistics/model/analysis-history-interface"; +import type { + AnalysisRequestPayload, + AnalysisResponseDto, +} from "@/entities/statistics/model/analysis-interface"; +import { verificationHistoryColumns } from "@/entities/statistics/model/verification-history-columns"; +import type { VerificationTargetCodeResponse } from "@/entities/statistics/model/verification-target-management-interface"; import { Button, Checkbox, DataTable, + DateRangePicker, Dialog, DialogContent, DialogHeader, @@ -29,34 +50,16 @@ import { SelectValue, Table, TableBody, + TableCell, TableHead, TableHeader, TableRow, - TableCell, - DateRangePicker, } from "@common/components"; -import { verificationHistoryColumns } from "@/entities/statistics/model/verification-history-columns"; -import { analysisApi } from "@/entities/statistics/api/analysis-api"; -import { analysisHistoryApi } from "@/entities/statistics/api/analysis-history-api"; -import type { - AnalysisRequestPayload, - AnalysisResponseDto, -} from "@/entities/statistics/model/analysis-interface"; -import type { - StAnlsMstResponse, - StAnlsMstSearchParams, - StAnlsOutlierListResponse, -} from "@/entities/statistics/model/analysis-history-interface"; -import { ENDPOINT } from "@/shared/config"; -import { useAlertStore } from "@/shared/lib"; -import { verificationTargetManagementApi } from "@/entities/statistics/api/verification-target-management-service"; -import type { VerificationTargetCodeResponse } from "@/entities/statistics/model/verification-target-management-interface"; -import { CodeSelect } from "@/entities/code"; -import type { AxiosError } from "axios"; -import type { ApiErrorResponse } from "@/shared/model"; + +import HbVerificationDetail from "./detail/HbVerificationDetail"; type DateRangeValue = { - from?: Date; + from: Date; to?: Date; }; @@ -97,7 +100,6 @@ function toNumber(value: string): number { } const HbStatisticsVerification = () => { - const router = useRouter(); const { setMessage: alert } = useAlertStore((state) => state); const queryClient = useQueryClient(); @@ -121,6 +123,7 @@ const HbStatisticsVerification = () => { anlsSn: null, }); const [selectedStatsCd, setSelectedStatsCd] = useState(""); + const [selectedAnlsSn, setSelectedAnlsSn] = useState(null); const defaultHistoryDateRange = useMemo( () => getDefaultHistoryDateRange(), @@ -330,7 +333,7 @@ const HbStatisticsVerification = () => { function handleHistoryRowClick(row: StAnlsMstResponse) { const anlsSn = Number(row.anlsSn ?? 0); if (!anlsSn) return; - router.push(`/statistics/verification/${anlsSn}`); + setSelectedAnlsSn(anlsSn); } function applyHistoryDateSearch(nextRange: DateRangeValue | undefined) { @@ -357,264 +360,298 @@ const HbStatisticsVerification = () => { return ( <> -
- {/* 검증 종류 선택 */} -
-
-
- - -
-
- - - setVerifyType(value as VerifyType) - } - className="flex items-center gap-4" - > - - - -
-
-
- - -
-
- {/* //검증 종류 선택 */} - - {verifyType === "SELECT" && ( -
- {/* 검증 대상 */} -
-

- 검증 대상 -

-
- - + {selectedAnlsSn ? ( + setSelectedAnlsSn(null)} + /> + ) : ( + <> +
+ {/* 검증 종류 선택 */} +
+
+
+ + +
+
+ + + setVerifyType(value as VerifyType) + } + className="flex items-center gap-4" + > + + + +
+
+
+
-
- {verifyType === "SELECT" && - filteredVerificationTargetCodes && - filteredVerificationTargetCodes.length > 0 ? ( -
- {filteredVerificationTargetCodes.map( - (item) => ( -
- - handleTargetToggle( - item.vrfcTrgtSn, - ) - } - /> - -
- ), +
+ {/* //검증 종류 선택 */} + + {verifyType === "SELECT" && ( +
+ {/* 검증 대상 */} +
+

+ 검증 대상 +

+
+ + + +
+
+ {verifyType === "SELECT" && + filteredVerificationTargetCodes && + filteredVerificationTargetCodes.length > + 0 ? ( +
+ {filteredVerificationTargetCodes.map( + (item) => ( +
+ + handleTargetToggle( + item.vrfcTrgtSn, + ) + } + /> + +
+ ), + )} +
+ ) : ( +
+

+ 검증 대상이 없습니다. +

+
)}
- ) : ( -
-

- 검증 대상이 없습니다. -

+
+ {/* //검증 대상 */} + + {/* 검증 변수 */} +
+

+ 검증 변수 +

+ +
+
+ + + handleFormChange( + "mu", + event.target.value, + ) + } + className="flex-1" + /> +
+ +
+ + + handleFormChange( + "percentile", + event.target.value, + ) + } + className="flex-1" + /> +
+ +
+ + + handleFormChange( + "a", + event.target.value, + ) + } + className="flex-1" + /> +
+ +
+ + + handleFormChange( + "c", + event.target.value, + ) + } + className="flex-1" + /> +
- )} +
+ {/* //검증 변수 */}
-
- {/* //검증 대상 */} - - {/* 검증 변수 */} -
-

- 검증 변수 -

- -
-
- - - handleFormChange( - "mu", - event.target.value, - ) - } - className="flex-1" - /> -
- -
- - - handleFormChange( - "percentile", - event.target.value, - ) - } - className="flex-1" - /> -
- -
- - - handleFormChange( - "a", - event.target.value, - ) - } - className="flex-1" - /> -
- -
- - - handleFormChange( - "c", - event.target.value, - ) - } - className="flex-1" - /> -
-
-
- {/* //검증 변수 */} + )}
- )} -
-
-

- 검증 이력 -

-
-
- - 검증일시 - - - setHistoryDateRange((value as any) ?? undefined) - } - className="max-w-[26rem]" +
+

+ 검증 이력 +

+
+
+ + 검증일시 + + + setHistoryDateRange( + value?.from + ? (value as DateRangeValue) + : undefined, + ) + } + className="max-w-[26rem]" + /> +
+
+ + +
+
+
-
- - -
-
- -
+ + )} diff --git a/apps/st/src/components/statistics/verification/detail/HbVerificationDetail.tsx b/apps/st/src/components/statistics/verification/detail/HbVerificationDetail.tsx new file mode 100644 index 000000000..11eb6c8fd --- /dev/null +++ b/apps/st/src/components/statistics/verification/detail/HbVerificationDetail.tsx @@ -0,0 +1,87 @@ +import React, { useState } from "react"; + +import { Button } from "@common/components"; + +import AreaDIndicators from "./Indicators"; +import AreaBRegionalGrid from "./RegionalGrid"; +import AreaCScatterChart from "./ScatterChart"; +import AreaAVerificationList from "./VerificationList"; + +interface HbVerificationDetailProps { + anlsSn: number; + onBack: () => void; +} + +const HbVerificationDetail: React.FC = ({ + anlsSn, + onBack, +}) => { + const [selectedVrfcTrgtSn, setSelectedVrfcTrgtSn] = useState( + null, + ); + const [selectedSido, setSelectedSido] = useState(null); + + return ( +
+
+ +

+ 검증 상세 +

+
+ +
+
+ +
+ +
+

+ 검증 결과 상세 +

+
+
+ +
+
+
+ +
+ +
+ +
+
+
+
+
+
+ ); +}; + +export default HbVerificationDetail; diff --git a/apps/st/src/components/statistics/verification/detail/Indicators.tsx b/apps/st/src/components/statistics/verification/detail/Indicators.tsx new file mode 100644 index 000000000..eaabf7849 --- /dev/null +++ b/apps/st/src/components/statistics/verification/detail/Indicators.tsx @@ -0,0 +1,86 @@ +import React from "react"; + +import { useQuery } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; +import { stAnlsRsltApi } from "@/entities/statistics/api/statistics-analysis-result-api"; + +interface IndicatorsProps { + anlsSn: number; + vrfcTrgtSn: string | null; +} + +const Indicators: React.FC = ({ anlsSn, vrfcTrgtSn }) => { + const { data: chartData, isLoading } = useQuery({ + queryKey: [ + ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_RSLT, + "chart", + anlsSn, + vrfcTrgtSn, + ], + queryFn: () => stAnlsRsltApi.getChartData(anlsSn, Number(vrfcTrgtSn)), + enabled: !!anlsSn && !!vrfcTrgtSn, + }); + + const dtl = chartData?.dtl; + + if (!vrfcTrgtSn) { + return ( +
+ 항목을 선택해주세요 +
+ ); + } + + if (isLoading) { + return ( +
+ 지표 데이터를 불러오는 중... +
+ ); + } + + const IndicatorItem = ({ + label, + value, + }: { + label: string; + value: number | null | undefined; + }) => ( +
+

+ {label} +

+
+ {value != null ? Number(value).toFixed(4) : "-"} +
+
+ ); + + return ( +
+ + + + + + + + +
+ ); +}; + +export default Indicators; diff --git a/apps/st/src/components/statistics/verification/detail/RegionalGrid.tsx b/apps/st/src/components/statistics/verification/detail/RegionalGrid.tsx new file mode 100644 index 000000000..940d328d9 --- /dev/null +++ b/apps/st/src/components/statistics/verification/detail/RegionalGrid.tsx @@ -0,0 +1,269 @@ +import React, { useMemo, useState } from "react"; + +import { useQuery } from "@tanstack/react-query"; +import { + type ColumnDef, + getCoreRowModel, + getPaginationRowModel, + useReactTable, +} from "@tanstack/react-table"; + +import { ENDPOINT } from "@/shared/config"; +import { CodeApi } from "@/entities/code/api/code-service"; +import { stAnlsRsltApi } from "@/entities/statistics/api/statistics-analysis-result-api"; +import type { StAnlsRsltResponse } from "@/entities/statistics/model/statistics-analysis-result-interface"; +import { + Button, + DataTable, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; + +interface RegionalGridProps { + anlsSn: number; + vrfcTrgtSn: string | null; + selectedSido: string | null; + onSidoSelect: (sido: string | null) => void; +} + +const RegionalGrid: React.FC = ({ + anlsSn, + vrfcTrgtSn, + selectedSido, + onSidoSelect, +}) => { + const [selectedGugun, setSelectedGugun] = useState("ALL"); + const [selectedStatus, setSelectedStatus] = useState("ALL"); + + const { data: regionCodes } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.CODE, "LAESECD"], + queryFn: () => CodeApi.getCode("LAESECD"), + }); + + const sidoOptions = useMemo(() => { + if (!regionCodes) return []; + return regionCodes.filter( + (code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 4, + ); + }, [regionCodes]); + + const gugunOptions = useMemo(() => { + if (!regionCodes || !selectedSido || selectedSido === "ALL") return []; + + const sidoPrefix = selectedSido; + return regionCodes.filter( + (code) => + code.lwpoCd.startsWith(sidoPrefix) && code.lwpoCd.length === 7, + ); + }, [regionCodes, selectedSido]); + + const { + data: resultData, + isLoading, + refetch, + } = useQuery({ + queryKey: [ + ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_RSLT, + "list", + anlsSn, + vrfcTrgtSn, + ], + queryFn: () => + stAnlsRsltApi.getResultList({ + anlsSn, + vrfcTrgtSn: Number(vrfcTrgtSn), + page: 0, + size: 1000, + }), + enabled: false, + }); + + const rowData = useMemo(() => { + let data = (resultData?.content ?? []) as StAnlsRsltResponse[]; + + if (selectedSido && selectedSido !== "ALL") { + const sidoName = sidoOptions.find( + (opt) => opt.lwpoCd === selectedSido, + )?.lwpoCdNm; + if (sidoName) { + data = data.filter((item) => item.sido === sidoName); + } + } + + if (selectedGugun && selectedGugun !== "ALL") { + const gugunName = gugunOptions.find( + (opt) => opt.lwpoCd === selectedGugun, + )?.lwpoCdNm; + if (gugunName) { + data = data.filter((item) => item.gugun === gugunName); + } + } + + if (selectedStatus && selectedStatus !== "ALL") { + const statusTarget = selectedStatus === "NORMAL" ? "y" : "n"; + data = data.filter( + (item) => item.otlrYn.toLowerCase() === statusTarget, + ); + } + + return data; + }, [ + resultData, + selectedSido, + selectedGugun, + selectedStatus, + sidoOptions, + gugunOptions, + ]); + + const columns: ColumnDef[] = useMemo( + () => [ + { header: "시도", accessorKey: "sido", size: 80 }, + { header: "구군", accessorKey: "gugun", size: 80 }, + { header: "행정구역", accessorKey: "admdstNm", size: 100 }, + { header: "이전년도", accessorKey: "prvyrVl", size: 80 }, + { header: "당해년도", accessorKey: "cryrVl", size: 80 }, + { + header: "R", + accessorKey: "chgRt", + size: 60, + cell: (info) => Number(info.getValue() ?? 0).toFixed(2), + }, + { + header: "S", + accessorKey: "trsfRt", + size: 60, + cell: (info) => Number(info.getValue() ?? 0).toFixed(2), + }, + { + header: "E", + accessorKey: "otlrTrsfRt", + size: 60, + cell: (info) => Number(info.getValue() ?? 0).toFixed(2), + }, + { + header: "상태", + accessorKey: "otlrYn", + size: 60, + cell: (info) => { + const val = info.getValue(); + if (val === "y") + return ( + + 정상 + + ); + if (val === "n") + return ( + + 이상치 + + ); + return "-"; + }, + }, + ], + [], + ); + + const table = useReactTable({ + data: rowData, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + initialState: { + pagination: { pageSize: 10 }, + }, + }); + + const handleSidoChange = (val: string) => { + onSidoSelect(val === "ALL" ? null : val); + setSelectedGugun("ALL"); + }; + + return ( +
+
+
+ + + +
+ +
+ + +
+
+ +
+ + {isLoading && ( +
+ 데이터를 불러오는 중... +
+ )} + {!isLoading && (!vrfcTrgtSn || rowData.length === 0) && ( +
+ {vrfcTrgtSn + ? "표시할 항목이 없습니다." + : "항목을 선택해주세요"} +
+ )} +
+
+ ); +}; + +export default RegionalGrid; diff --git a/apps/st/src/components/statistics/verification/detail/ScatterChart.tsx b/apps/st/src/components/statistics/verification/detail/ScatterChart.tsx new file mode 100644 index 000000000..97eef64c3 --- /dev/null +++ b/apps/st/src/components/statistics/verification/detail/ScatterChart.tsx @@ -0,0 +1,253 @@ +import React, { useMemo } from "react"; +import { Scatter } from "react-chartjs-2"; + +import { useQuery } from "@tanstack/react-query"; +import { + Chart as ChartJS, + Legend, + LinearScale, + LineElement, + PointElement, + ScatterController, + Tooltip, +} from "chart.js"; +import zoomPlugin from "chartjs-plugin-zoom"; + +import { ENDPOINT } from "@/shared/config"; +import { CodeApi } from "@/entities/code/api/code-service"; +import { stAnlsRsltApi } from "@/entities/statistics/api/statistics-analysis-result-api"; +import type { StAnlsRsltResponse } from "@/entities/statistics/model/statistics-analysis-result-interface"; + +ChartJS.register( + LinearScale, + PointElement, + LineElement, + Tooltip, + Legend, + ScatterController, + zoomPlugin, +); + +interface ScatterChartProps { + anlsSn: number; + vrfcTrgtSn: string | null; + selectedSido: string | null; +} + +const ScatterChart: React.FC = ({ + anlsSn, + vrfcTrgtSn, + selectedSido, +}) => { + const { data: regionCodes } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.CODE, "LAESECD"], + queryFn: () => CodeApi.getCode("LAESECD"), + }); + + const selectedSidoName = useMemo(() => { + if (!selectedSido || !regionCodes) return null; + return ( + regionCodes.find((c) => c.lwpoCd === selectedSido)?.lwpoCdNm || null + ); + }, [regionCodes, selectedSido]); + + const { data: resultData, isLoading: isLoadingGrid } = useQuery({ + queryKey: [ + ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_RSLT, + "list", + anlsSn, + vrfcTrgtSn, + ], + queryFn: () => + stAnlsRsltApi.getResultList({ + anlsSn, + vrfcTrgtSn: Number(vrfcTrgtSn), + page: 0, + size: 1000, + }), + enabled: !!anlsSn && !!vrfcTrgtSn, + }); + + const { data: chartData, isLoading: isLoadingChart } = useQuery({ + queryKey: [ + ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_RSLT, + "chart", + anlsSn, + vrfcTrgtSn, + ], + queryFn: () => stAnlsRsltApi.getChartData(anlsSn, Number(vrfcTrgtSn)), + enabled: !!anlsSn && !!vrfcTrgtSn, + }); + + const isLoading = isLoadingGrid || isLoadingChart; + + const chartConfig = useMemo(() => { + if (!resultData || !chartData) return null; + + const rawPoints = (resultData.content ?? []) as StAnlsRsltResponse[]; + const boundaries = chartData.list ?? []; + + const sortedBoundaries = [...boundaries].sort( + (a, b) => a.xPrev - b.xPrev, + ); + const upperLineData = sortedBoundaries.map((b) => ({ + x: b.xPrev, + y: b.yUpper, + })); + const lowerLineData = sortedBoundaries.map((b) => ({ + x: b.xPrev, + y: b.yLower, + })); + + const normalPoints: { + x: number; + y: number; + item: StAnlsRsltResponse; + }[] = []; + const outlierPoints: { + x: number; + y: number; + item: StAnlsRsltResponse; + }[] = []; + + rawPoints.forEach((item) => { + if (selectedSidoName && item.sido !== selectedSidoName) { + return; + } + + const pt = { + x: Number(item.prvyrVl ?? 0), + y: Number(item.cryrVl ?? 0), + item, + }; + + if (item.otlrYn.toLowerCase() === "y") { + normalPoints.push(pt); + } else { + outlierPoints.push(pt); + } + }); + + const data = { + datasets: [ + { + label: "상한선", + data: upperLineData, + type: "line" as const, + borderColor: "rgba(255, 99, 132, 0.8)", + borderWidth: 2, + fill: false, + pointRadius: 0, + tension: 0.1, + }, + { + label: "하한선", + data: lowerLineData, + type: "line" as const, + borderColor: "rgba(54, 162, 235, 0.8)", + borderWidth: 2, + fill: false, + pointRadius: 0, + tension: 0.1, + }, + { + label: "이상치", + data: outlierPoints, + backgroundColor: "rgba(255, 99, 132, 0.7)", + pointRadius: 5, + pointHoverRadius: 8, + }, + { + label: "정상", + data: normalPoints, + backgroundColor: "rgba(75, 192, 192, 0.7)", + pointRadius: 4, + pointHoverRadius: 7, + }, + ], + }; + + const options = { + responsive: true, + maintainAspectRatio: false, + plugins: { + zoom: { + pan: { + enabled: true, + mode: "xy" as const, + }, + zoom: { + wheel: { + enabled: true, + }, + pinch: { + enabled: true, + }, + mode: "xy" as const, + }, + }, + tooltip: { + callbacks: { + label: (context: any) => { + if (context.dataset.type === "line") { + return `${context.dataset.label}: (${context.parsed.x.toFixed(2)}, ${context.parsed.y.toFixed(2)})`; + } + const pt = context.raw as { + x: number; + y: number; + item: StAnlsRsltResponse; + }; + return `${pt.item.sido} ${pt.item.gugun}: 이전(${pt.x}), 당해(${pt.y})`; + }, + }, + }, + }, + scales: { + x: { + type: "linear" as const, + position: "bottom" as const, + title: { + display: true, + text: "이전년도 값", + }, + }, + y: { + type: "linear" as const, + title: { + display: true, + text: "당해년도 값", + }, + }, + }, + }; + + return { data, options }; + }, [resultData, chartData, selectedSidoName]); + + return ( +
+ {!vrfcTrgtSn ? ( +
+ 항목을 선택해주세요 +
+ ) : isLoading ? ( +
+ 차트를 불러오는 중... +
+ ) : chartConfig ? ( +
+ +
+ ) : ( +
+ 표시할 데이터가 없습니다. +
+ )} +
+ ); +}; + +export default ScatterChart; diff --git a/apps/st/src/components/statistics/verification/detail/VerificationList.tsx b/apps/st/src/components/statistics/verification/detail/VerificationList.tsx new file mode 100644 index 000000000..62463b036 --- /dev/null +++ b/apps/st/src/components/statistics/verification/detail/VerificationList.tsx @@ -0,0 +1,141 @@ +import React, { useEffect, useMemo } from "react"; + +import { useQuery } from "@tanstack/react-query"; +import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; + +import { ENDPOINT } from "@/shared/config"; +import { stAnlsDtlApi } from "@/entities/statistics/api/statistics-analysis-detail-api"; +import { verificationTargetManagementApi } from "@/entities/statistics/api/verification-target-management-service"; +import type { StAnlsDtlResponse } from "@/entities/statistics/model/statistics-analysis-detail-interface"; +import { DataTable } from "@common/components"; + +interface VerificationListProps { + anlsSn: number; + selectedVrfcTrgtSn: string | null; + onSelect: (vrfcTrgtSn: string) => void; +} + +const VerificationList: React.FC = ({ + anlsSn, + selectedVrfcTrgtSn, + onSelect, +}) => { + const { data: verificationTargetCodes } = useQuery({ + queryKey: [ENDPOINT.STATVERIFY_SERVICE.VRFCTGT_MNG, "codes"], + queryFn: () => + verificationTargetManagementApi.getVerificationTargetCodes(), + }); + + const targetCodeMap = useMemo(() => { + const map = new Map(); + if (verificationTargetCodes) { + verificationTargetCodes.forEach((item) => { + map.set(item.vrfcTrgtSn, item.vrfcTrgtNm); + }); + } + return map; + }, [verificationTargetCodes]); + + const { data: detailData, isLoading } = useQuery({ + queryKey: [ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_DTL, "list", anlsSn], + queryFn: () => + stAnlsDtlApi.getDetailList({ anlsSn, page: 0, size: 100 }), + enabled: !!anlsSn, + }); + + const rowData = useMemo(() => { + return (detailData?.content ?? []) as StAnlsDtlResponse[]; + }, [detailData]); + + const columns = useMemo( + () => [ + { + header: "검증 항목", + accessorFn: (row: StAnlsDtlResponse) => + targetCodeMap.get(String(row.vrfcTrgtSn)) || + String(row.vrfcTrgtSn), + id: "vrfcTrgtNm", + size: 150, + }, + { + header: "μ", + accessorKey: "ipcrCntVl", + size: 60, + }, + { + header: "Percentile", + accessorKey: "pctlCrtrVl", + size: 60, + }, + { + header: "A", + accessorKey: "scopAjmtCntVl", + size: 60, + }, + { + header: "C", + accessorKey: "thrsCntVl", + size: 60, + }, + { + header: "정상", + accessorKey: "nmlDataNocs", + size: 70, + }, + { + header: "제외", + accessorKey: "exclDataNocs", + size: 70, + }, + { + header: "이상", + accessorKey: "otlrDataNocs", + size: 70, + }, + ], + [targetCodeMap], + ); + + const table = useReactTable({ + data: rowData, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + const handleRowClick = (row: StAnlsDtlResponse) => { + onSelect(String(row.vrfcTrgtSn)); + }; + + useEffect(() => { + if (!selectedVrfcTrgtSn && rowData.length > 0) { + const firstTargetSn = rowData[0]?.vrfcTrgtSn; + if (firstTargetSn) onSelect(String(firstTargetSn)); + } + }, [selectedVrfcTrgtSn, rowData, onSelect]); + + return ( +
+ {isLoading && ( +
+ 데이터를 불러오는 중... +
+ )} + {!isLoading && rowData.length === 0 && ( +
+ 표시할 항목이 없습니다. +
+ )} + {!isLoading && rowData.length > 0 && ( + + )} +
+ ); +}; + +export default VerificationList; diff --git a/apps/st/src/entities/statistics/api/statistics-analysis-detail-api.ts b/apps/st/src/entities/statistics/api/statistics-analysis-detail-api.ts new file mode 100644 index 000000000..4e7a6e476 --- /dev/null +++ b/apps/st/src/entities/statistics/api/statistics-analysis-detail-api.ts @@ -0,0 +1,36 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import type { + StAnlsDtlListApiResponse, + StAnlsDtlResponse, + StAnlsDtlSearchParams, +} from "../model/statistics-analysis-detail-interface"; + +export class StAnlsDtlApiService { + public async getDetailList( + params: StAnlsDtlSearchParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_DTL}`, + { + params, + }, + ); + + return response.data; + } + + public async getDetail( + anlsSn: number, + vrfcTrgtSn: number, + ): Promise { + const response = await client.get( + `${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_DTL}/${anlsSn}/${vrfcTrgtSn}`, + ); + + return response.data; + } +} + +export const stAnlsDtlApi = new StAnlsDtlApiService(); diff --git a/apps/st/src/entities/statistics/api/statistics-analysis-result-api.ts b/apps/st/src/entities/statistics/api/statistics-analysis-result-api.ts new file mode 100644 index 000000000..604e4144a --- /dev/null +++ b/apps/st/src/entities/statistics/api/statistics-analysis-result-api.ts @@ -0,0 +1,37 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import type { + ChartRetDto, + StAnlsRsltListApiResponse, + StAnlsRsltSearchParams, +} from "../model/statistics-analysis-result-interface"; + +export class StAnlsRsltApiService { + public async getResultList( + params: StAnlsRsltSearchParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_RSLT}`, + { + params, + }, + ); + + return response.data; + } + + public async getChartData( + anlsSn: number, + vrfcTrgtSn: number, + ): Promise { + const response = await client.get( + `${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_RSLT}/chartList/${anlsSn}/${vrfcTrgtSn}`, + ); + + return response.data; + } +} + +export const stAnlsRsltApi = new StAnlsRsltApiService(); + diff --git a/apps/st/src/entities/statistics/model/statistics-analysis-detail-interface.ts b/apps/st/src/entities/statistics/model/statistics-analysis-detail-interface.ts new file mode 100644 index 000000000..3f6eb9003 --- /dev/null +++ b/apps/st/src/entities/statistics/model/statistics-analysis-detail-interface.ts @@ -0,0 +1,28 @@ +import type { PageableList, PageableParams } from "@/shared/model"; + +export interface StAnlsDtlResponse { + anlsSn: number; // 분석 일련번호 + vrfcTrgtSn: number; // 검증 대상 일련번호 + ipcrCntVl: number | null; // 중요도 개수 값 (mu) + pctlCrtrVl: number | null; // 백분위 기준 값 (percentile) + scopAjmtCntVl: number | null; // 범위 조정 개수 값 (A) + thrsCntVl: number | null; // 임계 개수 값 (C) + rtCnterVl: number | null; // 비율 중앙값 + lastTrsfScrCnterVl: number | null; // 최종 변환 점수 중앙값 + lastTrsfScrLlmtVl: number | null; // 최종 변환 점수 하한값 + lastTrsfScrUplmtVl: number | null; // 최종 변환 점수 상한값 + uplmtVl: number | null; // 상한값 + llmtVl: number | null; // 하한값 + otlrUplmtLine: number | null; // 이상치 상한선 + otlrLlmtLine: number | null; // 이상치 하한선 + nmlDataNocs: number; // 정상 데이터 건수 + exclDataNocs: number; // 제외 데이터 건수 + otlrDataNocs: number; // 이상치 데이터 건수 +} + +export interface StAnlsDtlSearchParams extends PageableParams { + anlsSn?: number; // 분석 일련번호 + vrfcTrgtSn?: number; // 검증 대상 일련번호 +} + +export type StAnlsDtlListApiResponse = PageableList; diff --git a/apps/st/src/entities/statistics/model/statistics-analysis-result-interface.ts b/apps/st/src/entities/statistics/model/statistics-analysis-result-interface.ts new file mode 100644 index 000000000..38c44b7a9 --- /dev/null +++ b/apps/st/src/entities/statistics/model/statistics-analysis-result-interface.ts @@ -0,0 +1,46 @@ +import type { PageableList, PageableParams } from "@/shared/model"; + +import type { StAnlsDtlResponse } from "./statistics-analysis-detail-interface"; + +export interface StAnlsRsltResponse { + rsltSn: number; // 결과 일련번호 + anlsSn: number; // 분석 일련번호 + vrfcTrgtSn: number; // 검증 대상 일련번호 + admdstCd: string; // 행정구역 코드 + admdstNm: string; // 행정구역 명칭 + sido: string; // 시도 + gugun: string; // 시군구 + adstDong: string; // 행정동 + drngBzmnCd: string; // 하수도 사업자 코드 + pipeSeCd: string; // 파이프 구분 코드 + fcltNm: string; // 시설명 + addr: string; // 주소 + prvyrVl: number | null; // 전년도 값 + cryrVl: number | null; // 당해년도 값 + chgRt: number | null; // 변경율 (r_i) + trsfRt: number | null; // 변환 비율 (s_i) + otlrTrsfRt: number | null; // 이상치 변환 비율 (E_i) + otlrYn: string; // 이상치 여부 (y/n) +} + +export interface StAnlsRsltSearchParams extends PageableParams { + anlsSn?: number; // 분석 일련번호 + vrfcTrgtSn?: number; // 검증 대상 일련번호 + admdstCd?: string; // 행정구역 코드 + admdstNm?: string; // 행정구역 명칭 + fcltNm?: string; // 시설명 + otlrYn?: string; // 이상치 여부 (y/n) +} + +export type StAnlsRsltListApiResponse = PageableList; + +export interface ChartBoundaryPointDto { + xPrev: number; // 전년도 값 (x축) + yLower: number; // 이상치 하한 당년값 (y축) + yUpper: number; // 이상치 상한 당년값 (y축) +} + +export interface ChartRetDto { + dtl: StAnlsDtlResponse; // 분석 상세 파라미터 (mu, r_mid, T_L, T_U 등) + list: ChartBoundaryPointDto[]; // HBR 경계선 곡선 데이터 리스트 (x_prev, y_lower, y_upper) +} diff --git a/apps/st/src/shared/config/api.ts b/apps/st/src/shared/config/api.ts index 6624c3f8a..8fa5dce05 100644 --- a/apps/st/src/shared/config/api.ts +++ b/apps/st/src/shared/config/api.ts @@ -76,6 +76,8 @@ export const ENDPOINT = { VRFCTGT_MNG: "/statverify-service/api/v1/vrfc-trgt-mng", ANALYSIS: "/statverify-service/api/v1/stat-verify/analysis", ST_ANLS_MST: "/statverify-service/api/v1/st-anls-mst", + ST_ANLS_DTL: "/statverify-service/api/v1/st-anls-dtl", + ST_ANLS_RSLT: "/statverify-service/api/v1/st-anls-rslt", }, }; diff --git a/packages/common/components/src/ui/date-range-picker/index.tsx b/packages/common/components/src/ui/date-range-picker/index.tsx index 1e8cdfe4c..58fa324d7 100644 --- a/packages/common/components/src/ui/date-range-picker/index.tsx +++ b/packages/common/components/src/ui/date-range-picker/index.tsx @@ -10,7 +10,10 @@ import { Button } from "../button"; import { Calendar } from "../calendar"; import { Popover, PopoverContent, PopoverTrigger } from "../popover"; -type DateRangePickerProps = React.HTMLAttributes & +type DateRangePickerProps = Omit< + React.HTMLAttributes, + "defaultValue" +> & React.ComponentProps & { value?: DateRange; defaultValue?: DateRange; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1bb15fae..e39d4b41f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,10 +101,10 @@ importers: devDependencies: eslint: specifier: ^9.0.0 - version: 9.39.2(jiti@1.21.7) + version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: 15.4.0 - version: 15.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + version: 15.4.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) postcss: specifier: ^8.4.49 version: 8.5.6 @@ -1112,6 +1112,9 @@ importers: chart.js: specifier: ^4.4.8 version: 4.5.1 + chartjs-plugin-zoom: + specifier: ^2.2.0 + version: 2.2.0(chart.js@4.5.1) dayjs: specifier: ^1.11.13 version: 1.11.19 @@ -1187,13 +1190,13 @@ importers: version: 5.28.5 eslint: specifier: ^9 - version: 9.39.2(jiti@2.6.1) + version: 9.39.2(jiti@1.21.7) eslint-config-next: specifier: 15.1.7 - version: 15.1.7(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 15.1.7(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint-plugin-react-hooks: specifier: ^5.0.0 - version: 5.2.0(eslint@9.39.2(jiti@2.6.1)) + version: 5.2.0(eslint@9.39.2(jiti@1.21.7)) postcss: specifier: ^8 version: 8.5.6 @@ -3841,6 +3844,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/hammerjs@2.0.46': + resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} + '@types/js-cookie@3.0.6': resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} @@ -4444,6 +4450,11 @@ packages: resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} engines: {pnpm: '>=8'} + chartjs-plugin-zoom@2.2.0: + resolution: {integrity: sha512-in6kcdiTlP6npIVLMd4zXZ08PDUXC52gZ4FAy5oyjk1zX3gKarXMAof7B9eFiisf9WOC3bh2saHg+J5WtLXZeA==} + peerDependencies: + chart.js: '>=3.2.0' + check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} @@ -5314,6 +5325,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hammerjs@2.0.8: + resolution: {integrity: sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==} + engines: {node: '>=0.8.0'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -9305,6 +9320,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/hammerjs@2.0.46': {} + '@types/js-cookie@3.0.6': {} '@types/json-schema@7.0.15': {} @@ -10134,6 +10151,12 @@ snapshots: dependencies: '@kurkle/color': 0.3.4 + chartjs-plugin-zoom@2.2.0(chart.js@4.5.1): + dependencies: + '@types/hammerjs': 2.0.46 + chart.js: 4.5.1 + hammerjs: 2.0.8 + check-error@2.1.1: {} chokidar@3.6.0: @@ -10706,6 +10729,26 @@ snapshots: optionalDependencies: source-map: 0.7.6 + eslint-config-next@15.1.7(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): + dependencies: + '@next/eslint-plugin-next': 15.1.7 + '@rushstack/eslint-patch': 1.15.0 + '@typescript-eslint/eslint-plugin': 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-react-hooks: 5.2.0(eslint@9.39.2(jiti@1.21.7)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + eslint-config-next@15.1.7(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 15.1.7 @@ -10726,26 +10769,6 @@ snapshots: - eslint-plugin-import-x - supports-color - eslint-config-next@15.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): - dependencies: - '@next/eslint-plugin-next': 15.4.0 - '@rushstack/eslint-patch': 1.15.0 - '@typescript-eslint/eslint-plugin': 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/parser': 8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - eslint: 9.39.2(jiti@1.21.7) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@1.21.7)) - eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@1.21.7)) - eslint-plugin-react-hooks: 5.2.0(eslint@9.39.2(jiti@1.21.7)) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - eslint-import-resolver-webpack - - eslint-plugin-import-x - - supports-color - eslint-config-next@15.4.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 15.4.0 @@ -11344,6 +11367,8 @@ snapshots: graceful-fs@4.2.11: {} + hammerjs@2.0.8: {} + has-bigints@1.1.0: {} has-flag@4.0.0: {}