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

This commit is contained in:
2026-03-11 14:35:57 +09:00
parent c8a66d2e53
commit 8c384a51ce
6 changed files with 162 additions and 107 deletions
@@ -62,20 +62,16 @@ 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]">
<Indicators
anlsSn={anlsSn}
vrfcTrgtSn={selectedVrfcTrgtSn}
/>
</div>
<Indicators
anlsSn={anlsSn}
vrfcTrgtSn={selectedVrfcTrgtSn}
/>
<div className="flex-[0.5]">
<ScatterChart
anlsSn={anlsSn}
vrfcTrgtSn={selectedVrfcTrgtSn}
selectedSido={selectedSido}
/>
</div>
<ScatterChart
anlsSn={anlsSn}
vrfcTrgtSn={selectedVrfcTrgtSn}
selectedSido={selectedSido}
/>
</div>
</div>
</div>
@@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { stAnlsRsltApi } from "@/entities/statistics/api/statistics-analysis-result-api";
import { LoadingWithAnimation } from "@common/components";
interface IndicatorsProps {
anlsSn: number;
@@ -34,8 +35,10 @@ const Indicators: React.FC<IndicatorsProps> = ({ anlsSn, vrfcTrgtSn }) => {
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 className="flex w-full items-center justify-center py-40">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
);
}
@@ -61,7 +64,7 @@ const Indicators: React.FC<IndicatorsProps> = ({ anlsSn, vrfcTrgtSn }) => {
);
return (
<div className="card card-border !m-0 flex min-h-[10rem] flex-wrap gap-2">
<div className="card card-border !m-0 grid min-h-[10rem] grid-cols-4 gap-2">
<IndicatorItem label="r 중위수" value={dtl?.rtCnterVl} />
<IndicatorItem
label="(E_m)E 중위수"
@@ -1,12 +1,7 @@
import React, { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
type ColumnDef,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table";
import { ValueType } from "realgrid";
import { ENDPOINT } from "@/shared/config";
import { CodeApi } from "@/entities/code/api/code-service";
@@ -14,7 +9,6 @@ import { stAnlsRsltApi } from "@/entities/statistics/api/statistics-analysis-res
import type { StAnlsRsltResponse } from "@/entities/statistics/model/statistics-analysis-result-interface";
import {
Button,
DataTable,
LoadingWithAnimation,
Select,
SelectContent,
@@ -23,6 +17,9 @@ import {
SelectValue,
} from "@common/components";
import DataGrid from "@/components/module/DataGrid";
import type { ColumnInfo } from "@/components/type/DataGrid.type";
interface RegionalGridProps {
anlsSn: number;
vrfcTrgtSn: string | null;
@@ -52,6 +49,10 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
status: "",
});
const [gridRowData, setGridRowData] = useState<
Record<string, string | number>[]
>([]);
const { data: regionCodes } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CODE, "LAESECD"],
queryFn: () => CodeApi.getCode("LAESECD"),
@@ -99,34 +100,45 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
!appliedFilter.gugun &&
!appliedFilter.status
) {
return showAllOnEmptyFilter ? data : [];
}
data = showAllOnEmptyFilter ? data : [];
} else {
if (appliedFilter.sido) {
const sidoName = sidoOptions.find(
(opt) => opt.lwpoCd === appliedFilter.sido,
)?.lwpoCdNm;
if (sidoName) {
data = data.filter((item) => item.sido === sidoName);
}
}
if (appliedFilter.sido) {
const sidoName = sidoOptions.find(
(opt) => opt.lwpoCd === appliedFilter.sido,
)?.lwpoCdNm;
if (sidoName) {
data = data.filter((item) => item.sido === sidoName);
if (appliedFilter.gugun) {
const gugunName = gugunOptions.find(
(opt) => opt.lwpoCd === appliedFilter.gugun,
)?.lwpoCdNm;
if (gugunName) {
data = data.filter((item) =>
gugunName.includes(item.gugun),
);
}
}
if (appliedFilter.status) {
data = data.filter(
(item) =>
item.otlrYn.toLowerCase() === appliedFilter.status,
);
}
}
if (appliedFilter.gugun) {
const gugunName = gugunOptions.find(
(opt) => opt.lwpoCd === appliedFilter.gugun,
)?.lwpoCdNm;
if (gugunName) {
data = data.filter((item) => gugunName.includes(item.gugun));
}
}
const mappedData = data.map((item) => ({
...item,
otlrYn: item.otlrYn === "0" ? "정상" : "이상치",
}));
if (appliedFilter.status) {
data = data.filter(
(item) => item.otlrYn.toLowerCase() === appliedFilter.status,
);
}
return data;
setGridRowData(
mappedData as unknown as Record<string, string | number>[],
);
return mappedData;
}, [
resultData,
appliedFilter,
@@ -135,60 +147,89 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
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();
return (
<span
className={`font-medium ${val === "0" ? "text-green-600" : "text-red-500"}`}
>
{val === "0" ? "정상" : "이상치"}
</span>
);
const columnInfo: ColumnInfo = useMemo(
() => ({
columns: [
{
name: "sido",
fieldName: "sido",
header: { text: "시도" },
width: 80,
},
},
],
{
name: "gugun",
fieldName: "gugun",
header: { text: "구군" },
width: 80,
},
{
name: "admdstNm",
fieldName: "admdstNm",
header: { text: "행정구역" },
width: 100,
},
{
name: "prvyrVl",
fieldName: "prvyrVl",
header: { text: "이전년도" },
width: 80,
valueType: ValueType.NUMBER,
dataFormat: "#,##0",
},
{
name: "cryrVl",
fieldName: "cryrVl",
header: { text: "당해년도" },
width: 80,
valueType: ValueType.NUMBER,
dataFormat: "#,##0",
},
{
name: "chgRt",
fieldName: "chgRt",
header: { text: "R" },
width: 60,
valueType: ValueType.NUMBER,
dataFormat: "#,##0.00",
},
{
name: "trsfRt",
fieldName: "trsfRt",
header: { text: "S" },
width: 60,
valueType: ValueType.NUMBER,
dataFormat: "#,##0.00",
},
{
name: "otlrTrsfRt",
fieldName: "otlrTrsfRt",
header: { text: "E" },
width: 60,
valueType: ValueType.NUMBER,
dataFormat: "#,##0.00",
},
{
name: "otlrYn",
fieldName: "otlrYn",
header: { text: "상태" },
width: 60,
},
],
columnLayout: [
"sido",
"gugun",
"admdstNm",
"prvyrVl",
"cryrVl",
"chgRt",
"trsfRt",
"otlrTrsfRt",
"otlrYn",
],
}),
[],
);
const table = useReactTable({
data: rowData,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: {
pagination: { pageSize: 10 },
},
});
const handleSidoChange = (val: string) => {
onSidoSelect(!val ? null : val);
setSelectedGugun("");
@@ -258,8 +299,8 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="y">정상</SelectItem>
<SelectItem value="n">이상치</SelectItem>
<SelectItem value="0">정상</SelectItem>
<SelectItem value="1">이상치</SelectItem>
</SelectContent>
</Select>
</div>
@@ -269,7 +310,9 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
초기화
</Button>
<Button onClick={handleSearch}>조회</Button>
<Button color="green">다운로드</Button>
<Button color="green" onClick={() => {}}>
다운로드
</Button>
</div>
</div>
@@ -289,11 +332,12 @@ const RegionalGrid: React.FC<RegionalGridProps> = ({
</div>
)}
{!isLoading && rowData.length > 0 && (
<DataTable
table={table}
variant="secondary"
viewPagination={true}
className="whitespace-nowrap text-[1.2rem] [&_td]:!px-2 [&_th]:!px-2"
<DataGrid
columnInfo={columnInfo}
rowData={gridRowData}
setRowData={setGridRowData}
isExcelExport={false}
isExcelImport={false}
/>
)}
</div>
@@ -17,6 +17,7 @@ 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 { LoadingWithAnimation } from "@common/components";
ChartJS.register(
LinearScale,
@@ -231,11 +232,13 @@ const ScatterChart: React.FC<ScatterChartProps> = ({
항목을 선택해주세요
</div>
) : isLoading ? (
<div className="flex h-full w-full items-center justify-center text-[1.4rem] text-muted-foreground">
차트를 불러오는 중...
<div className="flex w-full items-center justify-center py-40">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
) : chartConfig ? (
<div className="h-[40rem] w-full">
<div className="h-[48rem] w-full">
<Scatter
data={chartConfig.data as any}
options={chartConfig.options as any}
@@ -100,6 +100,11 @@ const VerificationList: React.FC<VerificationListProps> = ({
const table = useReactTable({
data: rowData,
columns,
state: {
rowSelection: selectedVrfcTrgtSn ? { [selectedVrfcTrgtSn]: true } : {},
},
enableMultiRowSelection: false,
getRowId: (row) => String(row.vrfcTrgtSn),
getCoreRowModel: getCoreRowModel(),
});
@@ -18,6 +18,10 @@ export interface StAnlsDtlResponse {
nmlDataNocs: number; // 정상 데이터 건수
exclDataNocs: number; // 제외 데이터 건수
otlrDataNocs: number; // 이상치 데이터 건수
statsCd: string; // 통계 코드
statsNm: string; // 통계 항목 명
vrfcTrgtNm: string; // 검증대상명
statsYr: string; // 통계년도
}
export interface StAnlsDtlSearchParams extends PageableParams {