Files
frontend/apps/st/src/components/statistics/verification/ImrVerificationHistory.tsx
T
chanii8 a606afef2f
Deploy Frontend / deploy (push) Successful in 1m0s
[st] feat: IMR API 수정
2026-04-02 18:00:45 +09:00

555 lines
20 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { Line } from "react-chartjs-2";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table";
import type { ChartOptions } from "chart.js";
import {
CategoryScale,
Chart as ChartJS,
Legend,
LinearScale,
LineElement,
PointElement,
Title,
Tooltip as ChartTooltip,
} from "chart.js";
import dayjs from "dayjs";
import { ENDPOINT } from "@/shared/config";
import { CodeSelect } from "@/entities/code";
import type {
AdstZoneResponseDto,
StAnlsMstResponse,
StAnlsMstSearchParams,
StAnlsRsltResponse,
} from "@/entities/statistics";
import {
IMR_DETAIL_VISIBLE_COLUMNS_BY_STATS_CD,
IMR_DETAIL_VISIBLE_COLUMNS_DEFAULT,
imrDetailGridColumnInfo,
imrVerificationHistoryColumns,
} from "@/entities/statistics";
import { imrApi } from "@/entities/statistics/api/imr-api";
import {
useStAnlsSidoCombo,
useStAnlsSigunguCombo,
} from "@/entities/statistics/api/queries";
import { stAnlsMstApi } from "@/entities/statistics/api/statistics-analysis-master-api";
import { stAnlsRsltApi } from "@/entities/statistics/api/statistics-analysis-result-api";
import {
Button,
DataTable,
DateRangePicker,
Label,
LoadingWithAnimation,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import DataGrid from "@/components/module/DataGrid";
import type { ColumnInfo } from "@/components/type/DataGrid.type";
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
ChartTooltip,
Legend,
);
type DateRangeValue = {
from: Date;
to?: Date;
};
const EMPTY_DATA: never[] = [];
const EMPTY_ADST_LIST: AdstZoneResponseDto[] = [];
function getImrDetailGridColumnInfo(statsCd: string): ColumnInfo {
const visible =
IMR_DETAIL_VISIBLE_COLUMNS_BY_STATS_CD[statsCd] ??
IMR_DETAIL_VISIBLE_COLUMNS_DEFAULT;
const visibleSet = new Set(visible);
return {
columns: imrDetailGridColumnInfo.columns.map((col) =>
visibleSet.has(col.name) ? col : { ...col, visible: false },
),
columnLayout: imrDetailGridColumnInfo.columnLayout,
};
}
const ImrVerificationHistory = () => {
// Area A Search States
const [searchSido, setSearchSido] = useState<string>("");
const [searchSigungu, setSearchSigungu] = useState<string>("");
const [searchStatsCd, setSearchStatsCd] = useState<string>("");
const [dateRange, setDateRange] = useState<DateRangeValue | undefined>({
from: dayjs().subtract(1, "month").toDate(),
to: dayjs().toDate(),
});
const [appliedSearchParams, setAppliedSearchParams] =
useState<StAnlsMstSearchParams>({
page: 0,
size: 10,
vrfcMthdCd: "IMR",
sort: "anlsSn,desc",
});
// Selected States
const [selectedHistory, setSelectedHistory] =
useState<StAnlsMstResponse | null>(null);
const [selectedDetail, setSelectedDetail] =
useState<StAnlsRsltResponse | null>(null);
const [isDownloading, setIsDownloading] = useState<boolean>(false);
// Area B Grid State
const [gridRowData, setGridRowData] = useState<
Record<string, string | number>[]
>([]);
// Area A Pagination
const [historyPagination, setHistoryPagination] = useState({
pageIndex: 0,
pageSize: 5,
});
const { data: sidoData } = useStAnlsSidoCombo();
const { data: sigunguData } = useStAnlsSigunguCombo(searchSido);
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const sigunguOptions = sigunguData ?? EMPTY_ADST_LIST;
const imrDetailColumnInfo = useMemo(
() => getImrDetailGridColumnInfo(selectedHistory?.statsCd ?? ""),
[selectedHistory?.statsCd],
);
// Area A Data Fetching
const { data: historyData } = useQuery({
queryKey: [
ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST,
"imr",
appliedSearchParams,
historyPagination,
],
queryFn: () =>
stAnlsMstApi.getImrAnalysisMasterList({
...appliedSearchParams,
page: historyPagination.pageIndex,
size: historyPagination.pageSize,
}),
placeholderData: keepPreviousData,
});
const historyTable = useReactTable({
data: historyData?.content ?? EMPTY_DATA,
columns: imrVerificationHistoryColumns,
getRowId: (row) => String(row.anlsSn),
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: true,
pageCount: historyData?.totalPages ?? 0,
state: {
pagination: historyPagination,
rowSelection: selectedHistory
? { [selectedHistory.anlsSn]: true }
: {},
},
onPaginationChange: setHistoryPagination,
});
// Area B Data Fetching
const { data: detailData, isLoading: isDetailLoading } = useQuery({
queryKey: [
ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_RSLT,
selectedHistory?.anlsSn,
],
queryFn: () =>
stAnlsRsltApi.getImrResultList(selectedHistory!.anlsSn, {
page: 0,
size: 100,
sort: "admdstCd,asc",
}),
enabled: !!selectedHistory,
});
useEffect(() => {
if (!detailData || !detailData.content) {
setGridRowData([]);
return;
}
const mapped = detailData.content.map((item) => ({
...item,
statsNm: selectedHistory?.vrfcTrgtNm ?? "-",
}));
setGridRowData(mapped as Record<string, string | number>[]);
}, [detailData, selectedHistory]);
// Area C Data Fetching
const { data: chartData, isLoading: isChartLoading } = useQuery({
queryKey: [
ENDPOINT.STATVERIFY_SERVICE.IMR,
selectedHistory?.anlsSn,
selectedDetail?.vrfcTrgtSn,
selectedDetail?.admdstCd,
],
queryFn: () =>
imrApi.getOutliersLog({
jobUuid: selectedHistory!.jobUuid,
anlsSn: selectedDetail!.anlsSn,
vrfcTrgtSn: selectedDetail!.vrfcTrgtSn,
admdstCd: selectedDetail!.admdstCd,
swtrmtFcltCd: selectedDetail!.swtrmtFcltCd,
pipeSeCd: selectedDetail!.pipeSeCd,
fcltNm: selectedDetail!.fcltNm,
fcltPstnCn: selectedDetail!.fcltPstnCn,
fcltSn: selectedDetail!.fcltSn,
ognzNm: selectedDetail!.ognzNm,
emphsMngSeCd: selectedDetail!.emphsMngSeCd,
}),
enabled: !!selectedHistory && !!selectedDetail,
});
const handleSearch = () => {
const params: StAnlsMstSearchParams = {
vrfcMthdCd: "IMR",
sort: "anlsSn,desc",
};
if (searchSido !== "")
params.sewerBzmnCd =
searchSigungu !== "" ? searchSigungu : searchSido;
if (searchStatsCd) params.statsCd = searchStatsCd;
if (dateRange?.from)
params.verifyDtStart = dayjs(dateRange.from).format("YYYY-MM-DD");
if (dateRange?.to)
params.verifyDtEnd = dayjs(dateRange.to).format("YYYY-MM-DD");
setAppliedSearchParams(params);
setHistoryPagination((prev) => ({ ...prev, pageIndex: 0 }));
setSelectedHistory(null);
setSelectedDetail(null);
};
const handleReset = () => {
setSearchSido("");
setSearchSigungu("");
setSearchStatsCd("");
const defaultRange = {
from: dayjs().subtract(1, "month").toDate(),
to: dayjs().toDate(),
};
setDateRange(defaultRange);
};
const handleDownload = async () => {
if (!selectedHistory || isDownloading) return;
setIsDownloading(true);
try {
const blob = await stAnlsRsltApi.downloadExcelImr(
selectedHistory.anlsSn,
);
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `IMR_분석결과.xlsx`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error("Failed to download IMR excel", error);
} finally {
setIsDownloading(false);
}
};
const chartOptions: ChartOptions<"line"> = useMemo(
() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "top" as const,
labels: {
font: {
size: 12,
},
},
},
title: {
display: true,
text: "I-MR 잔차 관리도",
font: {
size: 16,
weight: "bold",
},
},
},
scales: {
y: {
beginAtZero: false,
title: {
display: true,
text: "수치",
},
},
x: {
title: {
display: true,
text: "연도",
},
},
},
}),
[selectedDetail],
);
const lineChartData = useMemo(() => {
if (!chartData || !chartData.history) return null;
const labels = chartData.history.map((h) => String(h.year));
const actualData = chartData.history.map((h) => h.x);
const uclData = chartData.history.map((h) => h.ucl);
const lclData = chartData.history.map((h) => h.lcl);
const predictedData = chartData.history.map((h) => h.predicted);
return {
labels,
datasets: [
{
label: "실측치 (X)",
data: actualData,
borderColor: "rgb(75, 192, 192)",
backgroundColor: "rgba(75, 192, 192, 0.5)",
tension: 0.1,
pointRadius: 4,
pointHoverRadius: 6,
},
{
label: "상한선 (UCL)",
data: uclData,
borderColor: "rgb(255, 99, 132)",
backgroundColor: "rgba(255, 99, 132, 0.2)",
borderDash: [5, 5],
pointStyle: "false",
fill: false,
},
{
label: "하한선 (LCL)",
data: lclData,
borderColor: "rgb(54, 162, 235)",
backgroundColor: "rgba(54, 162, 235, 0.2)",
borderDash: [5, 5],
pointStyle: "false",
fill: false,
},
{
label: "예측치 (Predicted)",
data: predictedData,
borderColor: "rgb(153, 102, 255)",
backgroundColor: "rgba(153, 102, 255, 0.5)",
borderDash: [2, 2],
pointStyle: "false",
fill: false,
},
],
};
}, [chartData]);
return (
<div className="flex flex-col gap-4">
{/* 영역 A: 검증 이력 그리드 + 검색 필터 */}
<div className="card card-border bg-white p-6">
<div className="flex flex-wrap items-end gap-6 p-2">
<div className="flex items-center gap-2">
<Label className="text-[1.3rem] font-medium text-label">
시도
</Label>
<Select
value={searchSido}
onValueChange={(val) => {
setSearchSido(val);
setSearchSigungu("");
}}
>
<SelectTrigger className="w-[14rem]">
<SelectValue placeholder="시도 선택" />
</SelectTrigger>
<SelectContent>
{sidoOptions.map((opt) => (
<SelectItem
key={opt.adstCd}
value={opt.adstCd}
>
{opt.adstNm}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="text-[1.3rem] font-medium text-label">
시군구
</Label>
<Select
value={searchSigungu}
onValueChange={setSearchSigungu}
disabled={searchSido === ""}
>
<SelectTrigger className="w-[14rem]">
<SelectValue placeholder="시군구 선택" />
</SelectTrigger>
<SelectContent>
{sigunguOptions.map((opt) => (
<SelectItem
key={opt.adstCd}
value={opt.adstCd}
>
{opt.adstNm}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="text-[1.3rem] font-medium text-label">
검증일시
</Label>
<DateRangePicker
value={dateRange}
onValueChange={(val) =>
setDateRange(
val
? { from: val.from!, to: val.to }
: undefined,
)
}
className="w-[20rem]"
/>
</div>
<div className="flex items-center gap-2">
<Label className="text-[1.3rem] font-medium text-label">
통계항목
</Label>
<CodeSelect
upCd="STITEM"
value={searchStatsCd}
onValueChange={setSearchStatsCd}
placeholder="통계항목 선택"
className="w-[22rem]"
/>
</div>
<div className="ml-auto flex gap-2">
<Button color="gray" onClick={handleReset}>
초기화
</Button>
<Button onClick={handleSearch}>검색</Button>
</div>
</div>
<div className="mt-2 pb-4">
<DataTable
table={historyTable}
variant="secondary"
onRowClick={(row) => {
setSelectedHistory(row);
setSelectedDetail(null);
}}
viewPagination={true}
className="[&_td]:!p-0"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* 영역 B: 상세 데이터 그리드 */}
<div className="card card-border bg-white p-4">
<div className="flex items-center justify-between">
<h2 className="pl-2 text-[1.6rem] font-bold text-title">
이상치 데이터
</h2>
<Button
color="green"
disabled={
!detailData ||
detailData.content.length === 0 ||
isDownloading
}
onClick={handleDownload}
>
다운로드
</Button>
</div>
<div className="mt-2">
{isDetailLoading ? (
<div className="flex h-[39rem] w-full items-center justify-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
) : !selectedHistory || gridRowData.length === 0 ? (
<div className="flex h-[39rem] w-full items-center justify-center text-[1.4rem] text-muted-foreground">
{!selectedHistory
? "항목을 선택해주세요."
: "표시할 항목이 없습니다."}
</div>
) : (
<DataGrid
columnInfo={imrDetailColumnInfo}
rowData={gridRowData}
setRowData={setGridRowData}
idFieldName="rsltSn"
isExcelExport={false}
isExcelImport={false}
highlightedRowId={selectedDetail?.rsltSn}
onRowClick={(e) => {
if (!e.anlsSn) return;
const row = detailData?.content?.find(
(item) =>
String(item.rsltSn) ===
String(e.rsltSn),
);
if (row) setSelectedDetail(row);
}}
/>
)}
</div>
</div>
{/* 영역 C: Line Chart */}
<div className="m-2 flex h-[45rem] items-center justify-center rounded-sm border bg-white p-4">
{isChartLoading ? (
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
) : lineChartData ? (
<Line options={chartOptions} data={lineChartData} />
) : (
<div className="text-[1.4rem]">
이상치 데이터를 선택하면 차트가 표시됩니다.
</div>
)}
</div>
</div>
</div>
);
};
export default ImrVerificationHistory;