[st] feat: 통계 검증 상세 화면 작업

This commit is contained in:
2026-03-09 17:36:25 +09:00
parent 64e7a1f43f
commit ff2b79aaa6
15 changed files with 1347 additions and 300 deletions
@@ -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<string>("");
const [selectedAnlsSn, setSelectedAnlsSn] = useState<number | null>(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 (
<>
<div className="card card-border flex flex-col gap-6">
{/* 검증 종류 선택 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-6">
<div className="flex items-center gap-4 pl-2">
<Label className="text-[1.3rem] font-medium text-label">
검증 년도
</Label>
<Select
value={formState.verificationYear}
onValueChange={(value) =>
handleFormChange("verificationYear", value)
}
>
<SelectTrigger className="w-[10rem] flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{yearOptions.map((year) => (
<SelectItem key={year} value={year}>
{year}년
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-4 pl-2">
<Label className="text-[1.3rem] font-medium text-label">
검증 방식
</Label>
<RadioGroup
value={verifyType}
onValueChange={(value) =>
setVerifyType(value as VerifyType)
}
className="flex items-center gap-4"
>
<label className="flex cursor-pointer items-center gap-2 text-label">
<RadioGroupItem value="ALL" />
<span>일괄 검증</span>
</label>
<label className="flex cursor-pointer items-center gap-2 text-label">
<RadioGroupItem value="SELECT" />
<span>선택 검증</span>
</label>
</RadioGroup>
</div>
</div>
<div className="flex gap-2">
<Button
color="green"
className="h-[3.6rem] w-[120px]"
onClick={handleSubmit}
disabled={analysisMutation.isPending}
>
검증 실행
</Button>
<Button
color="gray"
className="h-[3.6rem] w-[120px]"
type="button"
onClick={handleReset}
>
초기화
</Button>
</div>
</div>
{/* //검증 종류 선택 */}
{verifyType === "SELECT" && (
<div className="flex gap-4">
{/* 검증 대상 */}
<div className="flex-1">
<h4 className="mb-3 rounded bg-gray-100 px-4 py-2 text-[1.4rem] font-medium">
검증 대상
</h4>
<div className="mb-3 flex items-center gap-4 px-4">
<Label className="text-[1.3rem] font-medium text-label">
통계항목
</Label>
<CodeSelect
upCd="STITEM"
onValueChange={setSelectedStatsCd}
className="w-[24rem]"
/>
{selectedAnlsSn ? (
<HbVerificationDetail
anlsSn={selectedAnlsSn}
onBack={() => setSelectedAnlsSn(null)}
/>
) : (
<>
<div className="card card-border flex flex-col gap-6">
{/* 검증 종류 선택 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-6">
<div className="flex items-center gap-4 pl-2">
<Label className="text-[1.3rem] font-medium text-label">
검증 년도
</Label>
<Select
value={formState.verificationYear}
onValueChange={(value) =>
handleFormChange(
"verificationYear",
value,
)
}
>
<SelectTrigger className="w-[10rem] flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{yearOptions.map((year) => (
<SelectItem
key={year}
value={year}
>
{year}년
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-4 pl-2">
<Label className="text-[1.3rem] font-medium text-label">
검증 방식
</Label>
<RadioGroup
value={verifyType}
onValueChange={(value) =>
setVerifyType(value as VerifyType)
}
className="flex items-center gap-4"
>
<label className="flex cursor-pointer items-center gap-2 text-label">
<RadioGroupItem value="ALL" />
<span>일괄 검증</span>
</label>
<label className="flex cursor-pointer items-center gap-2 text-label">
<RadioGroupItem value="SELECT" />
<span>선택 검증</span>
</label>
</RadioGroup>
</div>
</div>
<div className="flex gap-2">
<Button
color="green"
className="h-[3.6rem] w-[120px]"
onClick={handleSubmit}
disabled={analysisMutation.isPending}
>
검증 실행
</Button>
<Button
type="button"
color="gray"
className="h-[3.2rem] px-4 text-[1.3rem]"
onClick={() => setSelectedStatsCd("")}
className="h-[3.6rem] w-[120px]"
type="button"
onClick={handleReset}
>
초기화
</Button>
</div>
<div className="max-h-[16rem] space-y-4 overflow-auto rounded-md border p-4">
{verifyType === "SELECT" &&
filteredVerificationTargetCodes &&
filteredVerificationTargetCodes.length > 0 ? (
<div className="grid grid-cols-3 gap-4">
{filteredVerificationTargetCodes.map(
(item) => (
<div
key={item.vrfcTrgtSn}
className="flex items-center gap-2"
>
<Checkbox
id={item.vrfcTrgtSn}
checked={selectedTargets.includes(
item.vrfcTrgtSn,
)}
onCheckedChange={() =>
handleTargetToggle(
item.vrfcTrgtSn,
)
}
/>
<Label
htmlFor={
item.vrfcTrgtSn
}
className="cursor-pointer text-label"
>
{item.vrfcTrgtNm}
</Label>
</div>
),
</div>
{/* //검증 종류 선택 */}
{verifyType === "SELECT" && (
<div className="flex gap-4">
{/* 검증 대상 */}
<div className="flex-1">
<h4 className="mb-3 rounded bg-gray-100 px-4 py-2 text-[1.4rem] font-medium">
검증 대상
</h4>
<div className="mb-3 flex items-center gap-4 px-4">
<Label className="text-[1.3rem] font-medium text-label">
통계항목
</Label>
<CodeSelect
upCd="STITEM"
onValueChange={setSelectedStatsCd}
className="w-[24rem]"
/>
<Button
type="button"
color="gray"
className="h-[3.2rem] px-4 text-[1.3rem]"
onClick={() =>
setSelectedStatsCd("")
}
>
초기화
</Button>
</div>
<div className="max-h-[16rem] space-y-4 overflow-auto rounded-md border p-4">
{verifyType === "SELECT" &&
filteredVerificationTargetCodes &&
filteredVerificationTargetCodes.length >
0 ? (
<div className="grid grid-cols-3 gap-4">
{filteredVerificationTargetCodes.map(
(item) => (
<div
key={
item.vrfcTrgtSn
}
className="flex items-center gap-2"
>
<Checkbox
id={
item.vrfcTrgtSn
}
checked={selectedTargets.includes(
item.vrfcTrgtSn,
)}
onCheckedChange={() =>
handleTargetToggle(
item.vrfcTrgtSn,
)
}
/>
<Label
htmlFor={
item.vrfcTrgtSn
}
className="cursor-pointer text-label"
>
{
item.vrfcTrgtNm
}
</Label>
</div>
),
)}
</div>
) : (
<div className="flex items-center justify-center py-4">
<p className="text-[1.3rem] text-muted-foreground">
검증 대상이 없습니다.
</p>
</div>
)}
</div>
) : (
<div className="flex items-center justify-center py-4">
<p className="text-[1.3rem] text-muted-foreground">
검증 대상이 없습니다.
</p>
</div>
{/* //검증 대상 */}
{/* 검증 변수 */}
<div className="flex-[0.5]">
<h4 className="mb-3 rounded bg-gray-100 px-4 py-2 text-[1.4rem] font-medium">
검증 변수
</h4>
<div className="space-y-3">
<div className="flex items-center gap-4">
<Label className="w-32 text-label">
μ
</Label>
<Input
type="number"
value={formState.mu}
onChange={(event) =>
handleFormChange(
"mu",
event.target.value,
)
}
className="flex-1"
/>
</div>
<div className="flex items-center gap-4">
<Label className="w-32 text-label">
Percentile
</Label>
<Input
type="number"
value={formState.percentile}
onChange={(event) =>
handleFormChange(
"percentile",
event.target.value,
)
}
className="flex-1"
/>
</div>
<div className="flex items-center gap-4">
<Label className="w-32 text-label">
A
</Label>
<Input
type="number"
value={formState.a}
onChange={(event) =>
handleFormChange(
"a",
event.target.value,
)
}
className="flex-1"
/>
</div>
<div className="flex items-center gap-4">
<Label className="w-32 text-label">
C
</Label>
<Input
type="number"
value={formState.c}
onChange={(event) =>
handleFormChange(
"c",
event.target.value,
)
}
className="flex-1"
/>
</div>
</div>
)}
</div>
{/* //검증 변수 */}
</div>
</div>
{/* //검증 대상 */}
{/* 검증 변수 */}
<div className="flex-[0.5]">
<h4 className="mb-3 rounded bg-gray-100 px-4 py-2 text-[1.4rem] font-medium">
검증 변수
</h4>
<div className="space-y-3">
<div className="flex items-center gap-4">
<Label className="w-32 text-label">μ</Label>
<Input
type="number"
value={formState.mu}
onChange={(event) =>
handleFormChange(
"mu",
event.target.value,
)
}
className="flex-1"
/>
</div>
<div className="flex items-center gap-4">
<Label className="w-32 text-label">
Percentile
</Label>
<Input
type="number"
value={formState.percentile}
onChange={(event) =>
handleFormChange(
"percentile",
event.target.value,
)
}
className="flex-1"
/>
</div>
<div className="flex items-center gap-4">
<Label className="w-32 text-label">A</Label>
<Input
type="number"
value={formState.a}
onChange={(event) =>
handleFormChange(
"a",
event.target.value,
)
}
className="flex-1"
/>
</div>
<div className="flex items-center gap-4">
<Label className="w-32 text-label">C</Label>
<Input
type="number"
value={formState.c}
onChange={(event) =>
handleFormChange(
"c",
event.target.value,
)
}
className="flex-1"
/>
</div>
</div>
</div>
{/* //검증 변수 */}
)}
</div>
)}
</div>
<div className="mt-10 px-2 pb-8">
<h3 className="mb-4 text-[1.6rem] font-semibold text-title">
검증 이력
</h3>
<div className="card card-border !m-0 !mb-2 flex justify-between">
<div className="flex items-center gap-4 px-2">
<span className="text-[1.3rem] font-medium text-label">
검증일시
</span>
<DateRangePicker
value={historyDateRange as any}
defaultValue={defaultHistoryDateRange as any}
onValueChange={(value) =>
setHistoryDateRange((value as any) ?? undefined)
}
className="max-w-[26rem]"
<div className="mt-10 px-2 pb-8">
<h3 className="mb-4 text-[1.6rem] font-semibold text-title">
검증 이력
</h3>
<div className="card card-border !m-0 !mb-2 flex justify-between">
<div className="flex items-center gap-4 px-2">
<span className="text-[1.3rem] font-medium text-label">
검증일시
</span>
<DateRangePicker
value={historyDateRange}
defaultValue={defaultHistoryDateRange}
onValueChange={(value) =>
setHistoryDateRange(
value?.from
? (value as DateRangeValue)
: undefined,
)
}
className="max-w-[26rem]"
/>
</div>
<div className="flex gap-2">
<Button
type="button"
className="h-[3.2rem] px-4 text-[1.3rem]"
onClick={handleHistoryDateSearch}
>
조회
</Button>
<Button
type="button"
color="gray"
className="h-[3.2rem] px-4 text-[1.3rem]"
onClick={handleHistoryDateReset}
>
초기화
</Button>
</div>
</div>
<DataTable
table={table}
variant="secondary"
onRowClick={handleHistoryRowClick}
viewPagination={true}
className="[&_td]:!p-0"
/>
</div>
<div className="flex gap-2">
<Button
type="button"
className="h-[3.2rem] px-4 text-[1.3rem]"
onClick={handleHistoryDateSearch}
>
조회
</Button>
<Button
type="button"
color="gray"
className="h-[3.2rem] px-4 text-[1.3rem]"
onClick={handleHistoryDateReset}
>
초기화
</Button>
</div>
</div>
<DataTable
table={table}
variant="secondary"
onRowClick={handleHistoryRowClick}
viewPagination={true}
className="[&_td]:!p-0"
/>
</div>
</>
)}
<Dialog open={outlierDialog.open} onOpenChange={closeOutlierDialog}>
<DialogContent className="max-w-[800px]">
@@ -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<HbVerificationDetailProps> = ({
anlsSn,
onBack,
}) => {
const [selectedVrfcTrgtSn, setSelectedVrfcTrgtSn] = useState<string | null>(
null,
);
const [selectedSido, setSelectedSido] = useState<string | null>(null);
return (
<div className="flex flex-col">
<div className="flex items-center gap-4 p-4">
<Button
variant="ghost"
size="icon"
color="white"
onClick={onBack}
className=""
>
<i className="diveicon di-arrow-back" />
<span className="sr-only">목록으로 돌아가기</span>
</Button>
<h2 className="text-[1.8rem] font-bold text-title">
검증 상세
</h2>
</div>
<div className="flex flex-col gap-4">
<div className="h-[20rem] overflow-y-auto">
<AreaAVerificationList
anlsSn={anlsSn}
selectedVrfcTrgtSn={selectedVrfcTrgtSn}
onSelect={setSelectedVrfcTrgtSn}
/>
</div>
<div className="card card-border !m-4">
<h3 className="p-4 pb-0 text-[1.8rem] font-bold text-title">
검증 결과 상세
</h3>
<div className="flex">
<div className="flex-[0.5] p-4">
<AreaBRegionalGrid
anlsSn={anlsSn}
vrfcTrgtSn={selectedVrfcTrgtSn}
selectedSido={selectedSido}
onSidoSelect={setSelectedSido}
/>
</div>
<div className="flex flex-[0.5] flex-col gap-4 p-4 pt-[4.8rem]">
<div className="flex-[0.5]">
<AreaDIndicators
anlsSn={anlsSn}
vrfcTrgtSn={selectedVrfcTrgtSn}
/>
</div>
<div className="flex-[0.5]">
<AreaCScatterChart
anlsSn={anlsSn}
vrfcTrgtSn={selectedVrfcTrgtSn}
selectedSido={selectedSido}
/>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default HbVerificationDetail;
@@ -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<IndicatorsProps> = ({ 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 (
<div className="card card-border flex min-h-[10rem] items-center justify-center gap-6 text-[1.4rem] text-muted-foreground">
항목을 선택해주세요
</div>
);
}
if (isLoading) {
return (
<div className="card card-border flex min-h-[10rem] items-center justify-center gap-6 text-[1.4rem] text-muted-foreground">
지표 데이터를 불러오는 중...
</div>
);
}
const IndicatorItem = ({
label,
value,
}: {
label: string;
value: number | null | undefined;
}) => (
<div className="flex flex-1 flex-col gap-2 overflow-hidden rounded border bg-gray-50 p-2">
<h4
className="truncate text-[1.2rem] font-medium text-label"
title={label}
>
{label}
</h4>
<div className="truncate text-[1.6rem] font-bold">
{value != null ? Number(value).toFixed(4) : "-"}
</div>
</div>
);
return (
<div className="card card-border !m-0 flex min-h-[10rem] flex-wrap gap-2">
<IndicatorItem label="r 중위수" value={dtl?.rtCnterVl} />
<IndicatorItem
label="(E_m)E 중위수"
value={dtl?.lastTrsfScrCnterVl}
/>
<IndicatorItem
label="(E_l)E 하한값"
value={dtl?.lastTrsfScrLlmtVl}
/>
<IndicatorItem
label="(E_u)E 상한값"
value={dtl?.lastTrsfScrUplmtVl}
/>
<IndicatorItem label="E_D_L" value={dtl?.llmtVl} />
<IndicatorItem label="E_D_U" value={dtl?.uplmtVl} />
<IndicatorItem label="이상치 하한선" value={dtl?.otlrLlmtLine} />
<IndicatorItem label="이상치 상한선" value={dtl?.otlrUplmtLine} />
</div>
);
};
export default Indicators;
@@ -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<RegionalGridProps> = ({
anlsSn,
vrfcTrgtSn,
selectedSido,
onSidoSelect,
}) => {
const [selectedGugun, setSelectedGugun] = useState<string>("ALL");
const [selectedStatus, setSelectedStatus] = useState<string>("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<StAnlsRsltResponse>[] = 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 (
<span className="font-medium text-green-600">
정상
</span>
);
if (val === "n")
return (
<span className="font-medium text-red-500">
이상치
</span>
);
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 (
<div className="flex flex-col gap-2">
<div className="flex justify-between">
<div className="flex items-center gap-2">
<Select
value={selectedSido || "ALL"}
onValueChange={handleSidoChange}
>
<SelectTrigger className="w-[12rem] bg-white">
<SelectValue placeholder="시도 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체 (시도)</SelectItem>
{sidoOptions.map((opt) => (
<SelectItem key={opt.lwpoCd} value={opt.lwpoCd}>
{opt.lwpoCdNm}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={selectedGugun}
onValueChange={setSelectedGugun}
disabled={!selectedSido || selectedSido === "ALL"}
>
<SelectTrigger className="w-[12rem] bg-white">
<SelectValue placeholder="시군구 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체 (시군구)</SelectItem>
{gugunOptions.map((opt) => (
<SelectItem key={opt.lwpoCd} value={opt.lwpoCd}>
{opt.lwpoCdNm}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={selectedStatus}
onValueChange={setSelectedStatus}
>
<SelectTrigger className="w-[10rem] bg-white">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체 상태</SelectItem>
<SelectItem value="NORMAL">정상</SelectItem>
<SelectItem value="OUTLIER">이상치</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<Button onClick={() => refetch()}>조회</Button>
<Button color="gray">다운로드</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
<DataTable
table={table}
variant="secondary"
viewPagination={true}
className="whitespace-nowrap text-[1.2rem] [&_td]:!px-2 [&_th]:!px-2"
/>
{isLoading && (
<div className="p-4 text-center text-[1.3rem] text-muted-foreground">
데이터를 불러오는 중...
</div>
)}
{!isLoading && (!vrfcTrgtSn || rowData.length === 0) && (
<div className="p-4 text-center text-[1.3rem] text-muted-foreground">
{vrfcTrgtSn
? "표시할 항목이 없습니다."
: "항목을 선택해주세요"}
</div>
)}
</div>
</div>
);
};
export default RegionalGrid;
@@ -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<ScatterChartProps> = ({
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 (
<div className="relative flex-1 p-4">
{!vrfcTrgtSn ? (
<div className="flex h-full w-full items-center justify-center text-[1.4rem] text-muted-foreground">
항목을 선택해주세요
</div>
) : isLoading ? (
<div className="flex h-full w-full items-center justify-center text-[1.4rem] text-muted-foreground">
차트를 불러오는 중...
</div>
) : chartConfig ? (
<div className="h-[40rem] w-full">
<Scatter
data={chartConfig.data as any}
options={chartConfig.options as any}
/>
</div>
) : (
<div className="flex h-full w-full items-center justify-center text-[1.4rem] text-muted-foreground">
표시할 데이터가 없습니다.
</div>
)}
</div>
);
};
export default ScatterChart;
@@ -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<VerificationListProps> = ({
anlsSn,
selectedVrfcTrgtSn,
onSelect,
}) => {
const { data: verificationTargetCodes } = useQuery({
queryKey: [ENDPOINT.STATVERIFY_SERVICE.VRFCTGT_MNG, "codes"],
queryFn: () =>
verificationTargetManagementApi.getVerificationTargetCodes(),
});
const targetCodeMap = useMemo(() => {
const map = new Map<string, string>();
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 (
<div className="flex-1 px-4 py-2">
{isLoading && (
<div className="p-4 text-center text-[1.3rem] text-muted-foreground">
데이터를 불러오는 중...
</div>
)}
{!isLoading && rowData.length === 0 && (
<div className="p-4 text-center text-[1.3rem] text-muted-foreground">
표시할 항목이 없습니다.
</div>
)}
{!isLoading && rowData.length > 0 && (
<DataTable
table={table}
variant="secondary"
onRowClick={handleRowClick}
viewPagination={false}
className="whitespace-nowrap text-[1.2rem] [&_td]:!px-2 [&_th]:!px-2"
/>
)}
</div>
);
};
export default VerificationList;