555 lines
20 KiB
TypeScript
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;
|