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

This commit is contained in:
2026-03-11 12:51:31 +09:00
parent 9d7ceb69b1
commit 42a99708a3
9 changed files with 147 additions and 108 deletions
+4
View File
@@ -26,5 +26,9 @@ pnpm-lock.yaml
settings.json
.npmrc
.cursor
.agent
apps/**/tsconfig.tsbuildinfo
# next-agents-md
.next-docs/
+1
View File
File diff suppressed because one or more lines are too long
@@ -180,6 +180,7 @@ const HbStatisticsVerification = () => {
const params: StAnlsMstSearchParams = {
page: historyPagination.pageIndex,
size: historyPagination.pageSize,
sort: "anlsSn,desc",
};
if (appliedHistoryDateRange?.from) {
@@ -455,6 +456,7 @@ const HbStatisticsVerification = () => {
</Label>
<CodeSelect
upCd="STITEM"
value={selectedStatsCd}
onValueChange={setSelectedStatsCd}
className="w-[24rem]"
/>
@@ -2,10 +2,10 @@ 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";
import Indicators from "./Indicators";
import RegionalGrid from "./RegionalGrid";
import ScatterChart from "./ScatterChart";
import VerificationList from "./VerificationList";
interface HbVerificationDetailProps {
anlsSn: number;
@@ -41,7 +41,7 @@ const HbVerificationDetail: React.FC<HbVerificationDetailProps> = ({
<div className="flex flex-col gap-4">
<div className="h-[20rem] overflow-y-auto">
<AreaAVerificationList
<VerificationList
anlsSn={anlsSn}
selectedVrfcTrgtSn={selectedVrfcTrgtSn}
onSelect={setSelectedVrfcTrgtSn}
@@ -54,7 +54,7 @@ const HbVerificationDetail: React.FC<HbVerificationDetailProps> = ({
</h3>
<div className="flex">
<div className="flex-[0.5] p-4">
<AreaBRegionalGrid
<RegionalGrid
anlsSn={anlsSn}
vrfcTrgtSn={selectedVrfcTrgtSn}
selectedSido={selectedSido}
@@ -63,14 +63,14 @@ const HbVerificationDetail: React.FC<HbVerificationDetailProps> = ({
</div>
<div className="flex flex-[0.5] flex-col gap-4 p-4 pt-[4.8rem]">
<div className="flex-[0.5]">
<AreaDIndicators
<Indicators
anlsSn={anlsSn}
vrfcTrgtSn={selectedVrfcTrgtSn}
/>
</div>
<div className="flex-[0.5]">
<AreaCScatterChart
<ScatterChart
anlsSn={anlsSn}
vrfcTrgtSn={selectedVrfcTrgtSn}
selectedSido={selectedSido}
@@ -15,6 +15,7 @@ import type { StAnlsRsltResponse } from "@/entities/statistics/model/statistics-
import {
Button,
DataTable,
LoadingWithAnimation,
Select,
SelectContent,
SelectItem,
@@ -29,14 +30,27 @@ interface RegionalGridProps {
onSidoSelect: (sido: string | null) => void;
}
interface AppliedFilter {
sido: string | null;
gugun: string;
status: string;
}
const RegionalGrid: React.FC<RegionalGridProps> = ({
anlsSn,
vrfcTrgtSn,
selectedSido,
onSidoSelect,
}) => {
const [selectedGugun, setSelectedGugun] = useState<string>("ALL");
const [selectedStatus, setSelectedStatus] = useState<string>("ALL");
const [selectedGugun, setSelectedGugun] = useState<string>("");
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [showAllOnEmptyFilter, setShowAllOnEmptyFilter] =
useState<boolean>(true);
const [appliedFilter, setAppliedFilter] = useState<AppliedFilter>({
sido: null,
gugun: "",
status: "",
});
const { data: regionCodes } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CODE, "LAESECD"],
@@ -51,7 +65,7 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
}, [regionCodes]);
const gugunOptions = useMemo(() => {
if (!regionCodes || !selectedSido || selectedSido === "ALL") return [];
if (!regionCodes || !selectedSido) return [];
const sidoPrefix = selectedSido;
return regionCodes.filter(
@@ -60,11 +74,7 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
);
}, [regionCodes, selectedSido]);
const {
data: resultData,
isLoading,
refetch,
} = useQuery({
const { data: resultData, isLoading } = useQuery({
queryKey: [
ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_RSLT,
"list",
@@ -78,43 +88,49 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
page: 0,
size: 1000,
}),
enabled: false,
enabled: !!vrfcTrgtSn,
});
const rowData = useMemo(() => {
let data = (resultData?.content ?? []) as StAnlsRsltResponse[];
if (selectedSido && selectedSido !== "ALL") {
if (
!appliedFilter.sido &&
!appliedFilter.gugun &&
!appliedFilter.status
) {
return showAllOnEmptyFilter ? data : [];
}
if (appliedFilter.sido) {
const sidoName = sidoOptions.find(
(opt) => opt.lwpoCd === selectedSido,
(opt) => opt.lwpoCd === appliedFilter.sido,
)?.lwpoCdNm;
if (sidoName) {
data = data.filter((item) => item.sido === sidoName);
}
}
if (selectedGugun && selectedGugun !== "ALL") {
if (appliedFilter.gugun) {
const gugunName = gugunOptions.find(
(opt) => opt.lwpoCd === selectedGugun,
(opt) => opt.lwpoCd === appliedFilter.gugun,
)?.lwpoCdNm;
if (gugunName) {
data = data.filter((item) => item.gugun === gugunName);
data = data.filter((item) => gugunName.includes(item.gugun));
}
}
if (selectedStatus && selectedStatus !== "ALL") {
const statusTarget = selectedStatus === "NORMAL" ? "y" : "n";
if (appliedFilter.status) {
data = data.filter(
(item) => item.otlrYn.toLowerCase() === statusTarget,
(item) => item.otlrYn.toLowerCase() === appliedFilter.status,
);
}
return data;
}, [
resultData,
selectedSido,
selectedGugun,
selectedStatus,
appliedFilter,
showAllOnEmptyFilter,
sidoOptions,
gugunOptions,
]);
@@ -150,19 +166,13 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
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 "-";
return (
<span
className={`font-medium ${val === "0" ? "text-green-600" : "text-red-500"}`}
>
{val === "0" ? "정상" : "이상치"}
</span>
);
},
},
],
@@ -180,8 +190,29 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
});
const handleSidoChange = (val: string) => {
onSidoSelect(val === "ALL" ? null : val);
setSelectedGugun("ALL");
onSidoSelect(!val ? null : val);
setSelectedGugun("");
};
const handleSearch = () => {
setAppliedFilter({
sido: selectedSido,
gugun: selectedGugun,
status: selectedStatus,
});
setShowAllOnEmptyFilter(false);
};
const handleReset = () => {
onSidoSelect(null);
setSelectedGugun("");
setSelectedStatus("");
setAppliedFilter({
sido: null,
gugun: "",
status: "",
});
setShowAllOnEmptyFilter(true);
};
return (
@@ -189,14 +220,13 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
<div className="flex justify-between">
<div className="flex items-center gap-2">
<Select
value={selectedSido || "ALL"}
value={selectedSido || ""}
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}
@@ -207,13 +237,12 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
<Select
value={selectedGugun}
onValueChange={setSelectedGugun}
disabled={!selectedSido || selectedSido === "ALL"}
disabled={!selectedSido}
>
<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}
@@ -226,41 +255,47 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
onValueChange={setSelectedStatus}
>
<SelectTrigger className="w-[10rem] bg-white">
<SelectValue placeholder="상태" />
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체 상태</SelectItem>
<SelectItem value="NORMAL">정상</SelectItem>
<SelectItem value="OUTLIER">이상치</SelectItem>
<SelectItem value="y">정상</SelectItem>
<SelectItem value="n">이상치</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<Button onClick={() => refetch()}>조회</Button>
<Button color="gray">다운로드</Button>
<Button color="white" onClick={handleReset}>
초기화
</Button>
<Button onClick={handleSearch}>조회</Button>
<Button color="green">다운로드</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 className="flex w-full items-center justify-center py-40">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
{!isLoading && (!vrfcTrgtSn || rowData.length === 0) && (
<div className="p-4 text-center text-[1.3rem] text-muted-foreground">
{vrfcTrgtSn
? "표시할 항목이 없습니다."
: "항목을 선택해주세요"}
: "항목을 선택해주세요."}
</div>
)}
{!isLoading && rowData.length > 0 && (
<DataTable
table={table}
variant="secondary"
viewPagination={true}
className="whitespace-nowrap text-[1.2rem] [&_td]:!px-2 [&_th]:!px-2"
/>
)}
</div>
</div>
);
@@ -1,7 +1,11 @@
import React, { useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import {
type ColumnDef,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { ENDPOINT } from "@/shared/config";
import { stAnlsDtlApi } from "@/entities/statistics/api/statistics-analysis-detail-api";
@@ -47,50 +51,47 @@ const VerificationList: React.FC<VerificationListProps> = ({
return (detailData?.content ?? []) as StAnlsDtlResponse[];
}, [detailData]);
const columns = useMemo(
const columns: ColumnDef<StAnlsDtlResponse>[] = useMemo(
() => [
{
header: "통계 년도",
accessorKey: "statsYr",
},
{
header: "통계 항목",
accessorKey: "statsNm",
},
{
header: "검증 항목",
accessorFn: (row: StAnlsDtlResponse) =>
targetCodeMap.get(String(row.vrfcTrgtSn)) ||
String(row.vrfcTrgtSn),
id: "vrfcTrgtNm",
size: 150,
accessorKey: "vrfcTrgtNm",
},
{
header: "μ",
accessorKey: "ipcrCntVl",
size: 60,
},
{
header: "Percentile",
accessorKey: "pctlCrtrVl",
size: 60,
},
{
header: "A",
accessorKey: "scopAjmtCntVl",
size: 60,
},
{
header: "C",
accessorKey: "thrsCntVl",
size: 60,
},
{
header: "정상",
header: "정상건수",
accessorKey: "nmlDataNocs",
size: 70,
},
{
header: "제외",
header: "제외건수",
accessorKey: "exclDataNocs",
size: 70,
},
{
header: "이상",
header: "이상건수",
accessorKey: "otlrDataNocs",
size: 70,
},
],
[targetCodeMap],
@@ -2,35 +2,34 @@ import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
StAnlsMstListApiResponse,
StAnlsMstSearchParams,
StAnlsOutlierListResponse,
StAnlsMstListApiResponse,
StAnlsMstSearchParams,
StAnlsOutlierListResponse,
} from "../model/analysis-history-interface";
export class AnalysisHistoryApiService {
public async getAnalysisHistoryList(
params: StAnlsMstSearchParams,
): Promise<StAnlsMstListApiResponse> {
const response = await client.get<StAnlsMstListApiResponse>(
`${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST}`,
{
params,
},
);
public async getAnalysisHistoryList(
params: StAnlsMstSearchParams,
): Promise<StAnlsMstListApiResponse> {
const response = await client.get<StAnlsMstListApiResponse>(
`${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST}`,
{
params,
},
);
return response.data;
}
return response.data;
}
public async getOutlierList(
anlsSn: number,
): Promise<StAnlsOutlierListResponse[]> {
const response = await client.get<StAnlsOutlierListResponse[]>(
`${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST}/outlierList/${anlsSn}`,
);
public async getOutlierList(
anlsSn: number,
): Promise<StAnlsOutlierListResponse[]> {
const response = await client.get<StAnlsOutlierListResponse[]>(
`${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST}/outlierList/${anlsSn}`,
);
return response.data;
}
return response.data;
}
}
export const analysisHistoryApi = new AnalysisHistoryApiService();
@@ -1,4 +1,4 @@
import type { PageableList } from "@/shared/model";
import type { PageableList, PageableParams } from "@/shared/model";
export interface StAnlsMstResponse {
anlsSn: number;
@@ -16,12 +16,9 @@ export interface StAnlsMstResponse {
rgtrNm: string;
}
export interface StAnlsMstSearchParams {
export interface StAnlsMstSearchParams extends PageableParams {
anlsSttsCd?: string;
jobUuid?: string;
page?: number;
size?: number;
sort?: string;
verifyDtStart?: string;
verifyDtEnd?: string;
}
@@ -20,7 +20,7 @@ export interface StAnlsRsltResponse {
chgRt: number | null; // 변경율 (r_i)
trsfRt: number | null; // 변환 비율 (s_i)
otlrTrsfRt: number | null; // 이상치 변환 비율 (E_i)
otlrYn: string; // 이상치 여부 (y/n)
otlrYn: string; // 이상치 여부 (0: 정상, 1: 이상치)
}
export interface StAnlsRsltSearchParams extends PageableParams {