diff --git a/apps/st/src/components/statistics/verification/HbStatisticsVerification.tsx b/apps/st/src/components/statistics/verification/HbStatisticsVerification.tsx index 81fa83b2a..3fa0d990b 100644 --- a/apps/st/src/components/statistics/verification/HbStatisticsVerification.tsx +++ b/apps/st/src/components/statistics/verification/HbStatisticsVerification.tsx @@ -1,115 +1,343 @@ "use client"; -import { useState } from "react"; -// import { DataGrid } from "@rsone/components"; +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import DataGrid from "@/components/module/DataGrid"; -import SearchForm from "@/components/module/SearchForm"; import { Button, + Checkbox, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, Input, Label, + RadioGroup, + RadioGroupItem, Select, - SelectTrigger, SelectContent, SelectItem, + SelectTrigger, SelectValue, - Checkbox, } from "@common/components"; import { verificationHistoryColumnInfo } from "@/entities/statistics/model/verification-history-column"; -import { verificationHistoryMockData } from "@/entities/statistics/model/verification-history-mock"; +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, + StAnlsOutlierListResponse, +} from "@/entities/statistics/model/analysis-history-interface"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore } from "@/shared/lib"; +import { CodeApi } from "@/entities/code"; -interface VerificationFormValues { +type VerifyType = "ALL" | "SELECT"; + +interface VerificationFormState { mu: string; percentile: string; a: string; c: string; verificationYear: string; - checkboxOptions: { - sidoDistribution: boolean; - countyDistribution: boolean; - countyDistributionUserManagement: boolean; - lowerDistribution: boolean; - sidoServiceAvailability: boolean; - individualService: boolean; - legalServiceUsage: boolean; - excessUse: boolean; - lowerOrgStatistics: boolean; - studentStatistics: boolean; - developmentStatistics: boolean; - lowerManagementEnterprise: boolean; +} + +interface OutlierDialogState { + open: boolean; + anlsSn: number | null; +} + +function getVerificationYearOptions(): string[] { + const currentYear = new Date().getFullYear(); + const startYear = 2017; + const endYear = currentYear - 1; + if (endYear < startYear) return []; + return Array.from({ length: endYear - startYear + 1 }, (_, index) => + String(startYear + index), + ); +} + +function toNumber(value: string): number { + const parsed = Number(value); + return Number.isNaN(parsed) ? 0 : parsed; +} + +function mapHistoryToRow( + item: StAnlsMstResponse, +): Record { + const executedAt = item.anlsBgngDt ?? ""; + const endedAt = item.anlsEndDt ?? ""; + const verificationYear = + executedAt && executedAt.length >= 4 ? executedAt.slice(0, 4) : ""; + + return { + executedAt, + progressStatus: item.anlsSttsNm ?? "", + endedAt, + verificationYear, + mu: 0, + percentile: 0, + a: 0, + c: 0, + target: "", + normalCount: item.nmlDataNocs ?? 0, + excludedCount: item.exclDataNocs ?? 0, + abnormalCount: item.otlrDataNocs ?? 0, + anlsSn: item.anlsSn, }; } const HbStatisticsVerification = () => { - const [verificationHistoryRowData, setVerificationHistoryRowData] = - useState[]>( - verificationHistoryMockData, - ); + const { setMessage: alert } = useAlertStore((state) => state); + const queryClient = useQueryClient(); - const [formValues, setFormValues] = useState({ + const yearOptions = useMemo(() => getVerificationYearOptions(), []); + const defaultYear: string = + yearOptions.length > 0 + ? (yearOptions[yearOptions.length - 1] ?? "") + : ""; + + const [verifyType, setVerifyType] = useState("ALL"); + const [formState, setFormState] = useState({ mu: "0", percentile: "0", a: "0", c: "0", - verificationYear: "2025", - checkboxOptions: { - sidoDistribution: false, - countyDistribution: false, - countyDistributionUserManagement: false, - lowerDistribution: false, - sidoServiceAvailability: false, - individualService: false, - legalServiceUsage: false, - excessUse: false, - lowerOrgStatistics: false, - studentStatistics: false, - developmentStatistics: false, - lowerManagementEnterprise: false, + verificationYear: defaultYear, + }); + const [selectedTargets, setSelectedTargets] = useState([]); + const [outlierDialog, setOutlierDialog] = useState({ + open: false, + anlsSn: null, + }); + + const { data: statsItemList } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.CODE, "STITEM"], + queryFn: async () => { + const response = await CodeApi.getCode("STITEM"); + return response; }, }); - const handleCheckboxChange = ( - key: keyof VerificationFormValues["checkboxOptions"], - ) => { - setFormValues((prev) => ({ - ...prev, - checkboxOptions: { - ...prev.checkboxOptions, - [key]: !prev.checkboxOptions[key], - }, - })); - }; + const { data: analysisHistory } = useQuery({ + queryKey: [ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST], + queryFn: async () => { + const response = await analysisHistoryApi.getAnalysisHistoryList({ + page: 0, + size: 10, + }); + return response; + }, + }); - const handleInputChange = ( - key: keyof Omit, + const historyRowData: Record[] = useMemo( + () => + (analysisHistory?.content ?? []).map((item) => + mapHistoryToRow(item as StAnlsMstResponse), + ), + [analysisHistory], + ); + + const { data: outlierList, isLoading: isLoadingOutlier } = useQuery({ + queryKey: [ + ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST, + "outlier", + outlierDialog.anlsSn, + ], + queryFn: async () => { + if (!outlierDialog.anlsSn) return []; + const response = await analysisHistoryApi.getOutlierList( + outlierDialog.anlsSn, + ); + return response; + }, + enabled: outlierDialog.open && !!outlierDialog.anlsSn, + }); + + const analysisMutation = useMutation< + AnalysisResponseDto, + Error, + AnalysisRequestPayload + >({ + mutationFn: async (payload) => analysisApi.analyze(payload), + onSuccess: () => { + alert("검증을 요청했습니다."); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST], + }); + }, + onError: () => { + alert("검증 실행 중 오류가 발생했습니다."); + }, + }); + + function handleFormChange( + key: Key, value: string, - ) => { - setFormValues((prev) => ({ + ) { + setFormState((prev) => ({ ...prev, [key]: value, })); - }; + } - const handleSubmit = () => { - console.log("검증 실행:", formValues); - }; + function handleTargetToggle(statsCd: string) { + setSelectedTargets((prev) => + prev.includes(statsCd) + ? prev.filter((cd) => cd !== statsCd) + : [...prev, statsCd], + ); + } - const handleReset = () => { - console.log("초기화"); - }; + async function handleSubmit() { + if (!formState.verificationYear) { + alert("검증년도를 선택해 주세요."); + return; + } + + if (verifyType === "SELECT" && selectedTargets.length === 0) { + alert("검증 대상을 1개 이상 선택해 주세요."); + return; + } + + const yearNew = toNumber(formState.verificationYear); + const payload: AnalysisRequestPayload = { + dataset: + verifyType === "ALL" + ? "ALL" + : selectedTargets.length > 0 + ? selectedTargets.join(",") + : undefined, + yearNew, + yearOld: yearNew - 1, + mu: toNumber(formState.mu), + percentile: toNumber(formState.percentile), + a: toNumber(formState.a), + c: toNumber(formState.c), + vrfcMthdCd: "HB", + allVerifyYn: verifyType === "ALL" ? "Y" : "N", + }; + + analysisMutation.mutate(payload); + } + + function handleReset() { + setFormState({ + mu: "0", + percentile: "0", + a: "0", + c: "0", + verificationYear: defaultYear, + }); + setSelectedTargets([]); + } + + function handleHistoryRowClick(row: Record) { + const abnormalCount = Number(row.abnormalCount ?? 0); + const anlsSn = Number(row.anlsSn ?? 0); + if (!anlsSn || abnormalCount <= 0) return; + setOutlierDialog({ open: true, anlsSn }); + } + + function closeOutlierDialog() { + setOutlierDialog({ open: false, anlsSn: null }); + } return ( -
-
- {/* 통계 검증 섹션 */} -
-

- 통계 검증 -

+ <> +
+ {/* 검증 종류 선택 */} +
+
+ + 검증 방식 + + + setVerifyType(value as VerifyType) + } + className="flex items-center gap-4" + > + + + +
+
+ + +
+
+ {/* //검증 종류 선택 */} -
- {/* 왼쪽: 검증 변수 */} -
+ {verifyType === "SELECT" && ( +
+ {/* 검증 대상 */} +
+

+ 검증 대상 +

+ +
+ {verifyType === "SELECT" && + statsItemList && + statsItemList.length > 0 && ( +
+ {statsItemList.map((item) => ( +
+ + handleTargetToggle( + item.lwpoCd, + ) + } + /> + +
+ ))} +
+ )} +
+
+ {/* //검증 대상 */} + + {/* 검증 변수 */} +

검증 변수

@@ -119,11 +347,11 @@ const HbStatisticsVerification = () => { - handleInputChange( + value={formState.mu} + onChange={(event) => + handleFormChange( "mu", - e.target.value, + event.target.value, ) } className="flex-1" @@ -136,11 +364,11 @@ const HbStatisticsVerification = () => { - handleInputChange( + value={formState.percentile} + onChange={(event) => + handleFormChange( "percentile", - e.target.value, + event.target.value, ) } className="flex-1" @@ -151,11 +379,11 @@ const HbStatisticsVerification = () => { - handleInputChange( + value={formState.a} + onChange={(event) => + handleFormChange( "a", - e.target.value, + event.target.value, ) } className="flex-1" @@ -166,11 +394,11 @@ const HbStatisticsVerification = () => { - handleInputChange( + value={formState.c} + onChange={(event) => + handleFormChange( "c", - e.target.value, + event.target.value, ) } className="flex-1" @@ -182,9 +410,9 @@ const HbStatisticsVerification = () => { 검증 년도
- - {/* 오른쪽: 검증 대상 */} -
-

- 검증 대상 -

- -
-
-
- - handleCheckboxChange( - "sidoDistribution", - ) - } - /> - -
- -
- - handleCheckboxChange( - "countyDistribution", - ) - } - /> - -
- -
- - handleCheckboxChange( - "countyDistributionUserManagement", - ) - } - /> - -
- -
- - handleCheckboxChange( - "lowerDistribution", - ) - } - /> - -
-
- -
-
- - handleCheckboxChange( - "sidoServiceAvailability", - ) - } - /> - -
- -
- - handleCheckboxChange( - "individualService", - ) - } - /> - -
- -
- - handleCheckboxChange( - "sidoServiceAvailability", - ) - } - /> - -
- -
- - handleCheckboxChange( - "individualService", - ) - } - /> - -
-
- -
-
- - handleCheckboxChange( - "lowerOrgStatistics", - ) - } - /> - -
- -
- - handleCheckboxChange( - "studentStatistics", - ) - } - /> - -
- -
- - handleCheckboxChange( - "lowerOrgStatistics", - ) - } - /> - -
- -
- - handleCheckboxChange( - "studentStatistics", - ) - } - /> - -
-
- -
-
- - handleCheckboxChange( - "lowerManagementEnterprise", - ) - } - /> - -
-
-
-
+ {/* //검증 변수 */}
-
- - {/* 버튼 */} -
- - -
- - {/* 검증 이력 섹션 */} -
-

- 검증 이력 -

- -
+ )}
-
+ +
+

+ 검증 이력 +

+ {}} + isExcelImport={false} + isExcelExport={false} + showHeaderCheckbox={false} + onRowClick={handleHistoryRowClick} + cellStyleCallback={({ row, fieldName }) => { + if (fieldName === "abnormalCount") { + const abnormal = Number(row.abnormalCount ?? 0); + if (abnormal > 0) return "rg-cell-verify-error"; + } + return ""; + }} + /> +
+ + + + + 이상건수 상세 + + {isLoadingOutlier && ( +

+ 이상치 데이터를 불러오는 중입니다. +

+ )} + {!isLoadingOutlier && (outlierList?.length ?? 0) === 0 && ( +

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

+ )} + {!isLoadingOutlier && (outlierList?.length ?? 0) > 0 && ( +
+ {outlierList?.map( + (item: StAnlsOutlierListResponse) => ( +
+
+ + {item.vrfcTrgtNm} + + + 이상건수:{" "} + {item.otlrDataNocs ?? 0} + +
+
+ ), + )} +
+ )} +
+
+ ); }; diff --git a/apps/st/src/entities/statistics/api/analysis-api.ts b/apps/st/src/entities/statistics/api/analysis-api.ts new file mode 100644 index 000000000..0b607b299 --- /dev/null +++ b/apps/st/src/entities/statistics/api/analysis-api.ts @@ -0,0 +1,41 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import type { + AnalysisRequestPayload, + AnalysisResponseDto, + JobStatusDto, + AnalysisResultDto, +} from "../model/analysis-interface"; + +export class AnalysisApiService { + public async analyze( + payload: AnalysisRequestPayload, + ): Promise { + const response = await client.post( + `${ENDPOINT.STATVERIFY_SERVICE.ANALYSIS}/analyze`, + payload, + ); + + return response.data; + } + + public async getJobStatus(jobId: string): Promise { + const response = await client.get( + `${ENDPOINT.STATVERIFY_SERVICE.ANALYSIS}/status/${jobId}`, + ); + + return response.data; + } + + public async getResult(jobId: string): Promise { + const response = await client.get( + `${ENDPOINT.STATVERIFY_SERVICE.ANALYSIS}/result/${jobId}`, + ); + + return response.data; + } +} + +export const analysisApi = new AnalysisApiService(); + diff --git a/apps/st/src/entities/statistics/api/analysis-history-api.ts b/apps/st/src/entities/statistics/api/analysis-history-api.ts new file mode 100644 index 000000000..56375bf5f --- /dev/null +++ b/apps/st/src/entities/statistics/api/analysis-history-api.ts @@ -0,0 +1,36 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import type { + StAnlsMstListApiResponse, + StAnlsMstSearchParams, + StAnlsOutlierListResponse, +} from "../model/analysis-history-interface"; + +export class AnalysisHistoryApiService { + public async getAnalysisHistoryList( + params: StAnlsMstSearchParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST}`, + { + params, + }, + ); + + return response.data; + } + + public async getOutlierList( + anlsSn: number, + ): Promise { + const response = await client.get( + `${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST}/outlierList/${anlsSn}`, + ); + + return response.data; + } +} + +export const analysisHistoryApi = new AnalysisHistoryApiService(); + diff --git a/apps/st/src/entities/statistics/model/analysis-history-interface.ts b/apps/st/src/entities/statistics/model/analysis-history-interface.ts new file mode 100644 index 000000000..c37d4fe5c --- /dev/null +++ b/apps/st/src/entities/statistics/model/analysis-history-interface.ts @@ -0,0 +1,49 @@ +import type { PageableList } from "@/shared/model"; + +export interface StAnlsMstResponse { + anlsSn: number; + anlsBgngDt: string; + anlsEndDt: string | null; + anlsSttsCd: string; + anlsSttsNm: string; + nmlDataNocs: number; + exclDataNocs: number; + otlrDataNocs: number; + jobUuid: string; + regDt: string; + rgtr: string; + rgtrNm: string; +} + +export interface StAnlsMstSearchParams { + anlsSttsCd?: string; + jobUuid?: string; + page?: number; + size?: number; + sort?: string; +} + +export interface StAnlsOutlierDataResponse { + rsltSn: number; + vrfcTrgtSn: number; + admdstCd: string; + sido: string; + gugun: string; + adstDong: string; + addr: string; + cryrVl: number; + otlrTrsfRt: number; +} + +export interface StAnlsOutlierListResponse { + anlsSn: number; + vrfcTrgtSn: number; + vrfcTrgtNm: string; + nmlDataNocs: number; + exclDataNocs: number; + otlrDataNocs: number; + outlierDataList: StAnlsOutlierDataResponse[]; +} + +export type StAnlsMstListApiResponse = PageableList; + diff --git a/apps/st/src/entities/statistics/model/analysis-interface.ts b/apps/st/src/entities/statistics/model/analysis-interface.ts new file mode 100644 index 000000000..96ef8a6cc --- /dev/null +++ b/apps/st/src/entities/statistics/model/analysis-interface.ts @@ -0,0 +1,64 @@ +import type { PageableList } from "@/shared/model"; + +export interface AnalysisRequestPayload { + dataset?: string; + yearOld: number; + yearNew: number; + mu: number; + a: number; + c: number; + percentile: number; + sido?: string; + sigungu?: string; + vrfcMthdCd?: string; + allVerifyYn?: "Y" | "N"; +} + +export interface AnalysisResponseDto { + jobId: string; + status: string; + message: string; + queuePosition: number; + createdAt: string; + anlsSn: number; +} + +export interface JobStatusDto { + jobId: string; + status: string; + message: string; + queuePosition: number; + progress: number; + startedAt: string | null; + completedAt: string | null; + error: string | null; + anlsSn: number | null; + processingTimeMs: number | null; +} + +export interface AnalysisSummaryDto { + // 요약 필드는 Python 분석 서비스 스키마에 따라 확장 + [key: string]: unknown; +} + +export interface OutlierRecordDto { + // 상세 구조는 Python 분석 서비스 응답에 맞춰 필요 시 확장 + [key: string]: unknown; +} + +export interface AnalysisResultDto { + jobId: string; + status: string; + createdAt: string; + completedAt: string | null; + summary: AnalysisSummaryDto | null; + multiSummaries: Record | null; + outliers: OutlierRecordDto[]; + results: OutlierRecordDto[]; + totalCount: number; + cached: boolean; + processingTimeMs: number; + anlsSn: number; +} + +export type AnalysisHistoryListApiResponse = PageableList; diff --git a/apps/st/src/shared/config/api.ts b/apps/st/src/shared/config/api.ts index 1b9cf74ec..cc38d4c34 100644 --- a/apps/st/src/shared/config/api.ts +++ b/apps/st/src/shared/config/api.ts @@ -72,6 +72,8 @@ export const ENDPOINT = { }, STATVERIFY_SERVICE: { 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", }, };