Files
frontend/apps/st/src/components/statistics/verification/HbStatisticsVerification.tsx
T

750 lines
33 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
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,
DialogTitle,
Input,
Label,
LoadingWithAnimation,
RadioGroup,
RadioGroupItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import HbVerificationDetail from "./detail/HbVerificationDetail";
type DateRangeValue = {
from: Date;
to?: Date;
};
type VerifyType = "ALL" | "SELECT";
interface VerificationFormState {
mu: string;
percentile: string;
a: string;
c: string;
verificationYear: string;
}
interface OutlierDialogState {
open: boolean;
anlsSn: number | null;
}
function getDefaultHistoryDateRange(): DateRangeValue {
const to = dayjs().endOf("day").toDate();
const from = dayjs().subtract(7, "day").startOf("day").toDate();
return { from, to };
}
function getVerificationYearOptions(): string[] {
const currentYear = dayjs().year();
const startYear = 2017;
const endYear = currentYear - 1;
if (endYear < startYear) return [];
return Array.from({ length: endYear - startYear + 1 }, (_, index) =>
String(startYear + index),
);
}
function toNumber(value: string): number {
const parsed = Number(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
const HbStatisticsVerification = () => {
const { setMessage: alert } = useAlertStore((state) => state);
const queryClient = useQueryClient();
const yearOptions = useMemo(() => getVerificationYearOptions(), []);
const defaultYear: string =
yearOptions.length > 0
? (yearOptions[yearOptions.length - 1] ?? "")
: "";
const [verifyType, setVerifyType] = useState<VerifyType>("ALL");
const [formState, setFormState] = useState<VerificationFormState>({
mu: "0",
percentile: "0",
a: "0",
c: "0",
verificationYear: defaultYear,
});
const [selectedTargets, setSelectedTargets] = useState<string[]>([]);
const [outlierDialog, setOutlierDialog] = useState<OutlierDialogState>({
open: false,
anlsSn: null,
});
const [selectedStatsCd, setSelectedStatsCd] = useState<string>("");
const [selectedAnlsSn, setSelectedAnlsSn] = useState<number | null>(null);
const defaultHistoryDateRange = useMemo(
() => getDefaultHistoryDateRange(),
[],
);
const [historyDateRange, setHistoryDateRange] = useState<
DateRangeValue | undefined
>(defaultHistoryDateRange);
const [appliedHistoryDateRange, setAppliedHistoryDateRange] = useState<
DateRangeValue | undefined
>(defaultHistoryDateRange);
const [historyPagination, setHistoryPagination] = useState({
pageIndex: 0,
pageSize: 10,
});
const { data: verificationTargetCodes } = useQuery<
VerificationTargetCodeResponse[]
>({
queryKey: [ENDPOINT.STATVERIFY_SERVICE.VRFCTGT_MNG, "codes"],
queryFn: () =>
verificationTargetManagementApi.getVerificationTargetCodes(),
});
const filteredVerificationTargetCodes = useMemo(() => {
return verificationTargetCodes?.filter((item) =>
selectedStatsCd
? item.vrfcTrgtSn.startsWith(selectedStatsCd)
: true,
);
}, [verificationTargetCodes, selectedStatsCd]);
const allVerificationTargetIds = useMemo(
() =>
(filteredVerificationTargetCodes ?? []).map(
(item) => item.vrfcTrgtSn,
),
[filteredVerificationTargetCodes],
);
useEffect(() => {
if (!allVerificationTargetIds.length) return;
setSelectedTargets(allVerificationTargetIds);
}, [allVerificationTargetIds]);
const { data: analysisHistory } = useQuery({
queryKey: [
ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST,
historyPagination,
appliedHistoryDateRange,
],
queryFn: () => {
const params: StAnlsMstSearchParams = {
page: historyPagination.pageIndex,
size: historyPagination.pageSize,
};
if (appliedHistoryDateRange?.from) {
params.verifyDtStart = dayjs(
appliedHistoryDateRange.from,
).format("YYYY-MM-DD");
}
if (appliedHistoryDateRange?.to || appliedHistoryDateRange?.from) {
const to =
appliedHistoryDateRange.to ?? appliedHistoryDateRange.from!;
params.verifyDtEnd = dayjs(to).format("YYYY-MM-DD");
}
return analysisHistoryApi.getAnalysisHistoryList(params);
},
refetchInterval: (query) => {
const data = query.state.data;
if (!data?.content?.length) return false;
const hasProcessing = (data.content as StAnlsMstResponse[]).some(
(item) => item.anlsSttsCd === "PROCESSING",
);
return hasProcessing ? 3000 : false;
},
});
const historyRowData: StAnlsMstResponse[] = (analysisHistory?.content ??
[]) as StAnlsMstResponse[];
const table = useReactTable({
data: historyRowData,
columns: verificationHistoryColumns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: true,
pageCount: analysisHistory?.totalPages ?? 0,
state: {
pagination: historyPagination,
},
onPaginationChange: (updater) => {
setHistoryPagination((prev) =>
typeof updater === "function" ? updater(prev) : updater,
);
},
meta: {
onOutlierCellClick: (row: StAnlsMstResponse) => {
setOutlierDialog({ open: true, anlsSn: row.anlsSn });
},
},
});
const { data: outlierList, isLoading: isLoadingOutlier } = useQuery({
queryKey: [
ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST,
"outlier",
outlierDialog.anlsSn,
],
queryFn: async () => {
if (!outlierDialog.anlsSn) return [];
const response = await analysisHistoryApi.getOutlierList(
outlierDialog.anlsSn,
);
return response;
},
enabled: outlierDialog.open && !!outlierDialog.anlsSn,
});
const analysisMutation = useMutation<
AnalysisResponseDto,
AxiosError<ApiErrorResponse>,
AnalysisRequestPayload
>({
mutationFn: async (payload) => analysisApi.analyze(payload),
onSuccess: () => {
alert("검증을 요청했습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_MST],
});
},
onError: (error) => {
alert(
error.response?.data?.message ??
"검증 실행 중 오류가 발생했습니다.",
);
},
});
function handleFormChange<Key extends keyof VerificationFormState>(
key: Key,
value: string,
) {
setFormState((prev) => ({
...prev,
[key]: value,
}));
}
function handleTargetToggle(statsCd: string) {
setSelectedTargets((prev) =>
prev.includes(statsCd)
? prev.filter((cd) => cd !== statsCd)
: [...prev, statsCd],
);
}
async function handleSubmit() {
if (!formState.verificationYear) {
alert("검증년도를 선택해 주세요.");
return;
}
if (verifyType === "SELECT" && selectedTargets.length === 0) {
alert("검증 대상을 1개 이상 선택해 주세요.");
return;
}
const yearNew = toNumber(formState.verificationYear);
const payload: AnalysisRequestPayload = {
dataset:
verifyType === "ALL"
? allVerificationTargetIds.length > 0
? allVerificationTargetIds.join(",")
: undefined
: selectedTargets.length > 0
? selectedTargets.join(",")
: undefined,
yearNew,
yearOld: yearNew - 1,
mu: toNumber(formState.mu),
percentile: toNumber(formState.percentile),
a: toNumber(formState.a),
c: toNumber(formState.c),
vrfcMthdCd: "HB",
allVerifyYn: verifyType === "ALL" ? "Y" : "N",
};
analysisMutation.mutate(payload);
}
function handleReset() {
setFormState({
mu: "0",
percentile: "0",
a: "0",
c: "0",
verificationYear: defaultYear,
});
setSelectedTargets([]);
}
function handleHistoryRowClick(row: StAnlsMstResponse) {
const anlsSn = Number(row.anlsSn ?? 0);
if (!anlsSn) return;
setSelectedAnlsSn(anlsSn);
}
function applyHistoryDateSearch(nextRange: DateRangeValue | undefined) {
setAppliedHistoryDateRange(nextRange);
setHistoryPagination((prev) => ({
...prev,
pageIndex: 0,
}));
}
function handleHistoryDateSearch() {
applyHistoryDateSearch(historyDateRange);
}
function handleHistoryDateReset() {
const resetRange = getDefaultHistoryDateRange();
setHistoryDateRange(resetRange);
applyHistoryDateSearch(resetRange);
}
function closeOutlierDialog() {
setOutlierDialog({ open: false, anlsSn: null });
}
return (
<>
{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
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]"
/>
<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>
{/* //검증 대상 */}
{/* 검증 변수 */}
<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}
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>
</>
)}
<Dialog open={outlierDialog.open} onOpenChange={closeOutlierDialog}>
<DialogContent className="max-w-[800px]">
<DialogHeader>
<DialogTitle>이상건수 상세</DialogTitle>
</DialogHeader>
{isLoadingOutlier && (
<div className="flex w-full items-center justify-center py-40">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
{!isLoadingOutlier && (outlierList?.length ?? 0) === 0 && (
<p className="px-2 py-4 text-[1.3rem] text-muted-foreground">
표시할 이상치 데이터가 없습니다.
</p>
)}
{!isLoadingOutlier && (outlierList?.length ?? 0) > 0 && (
<div className="max-h-[40rem] space-y-4 overflow-auto px-1 py-2 text-[1.25rem]">
{outlierList?.map(
(item: StAnlsOutlierListResponse) => (
<Table
key={`${item.anlsSn}-${item.vrfcTrgtSn}`}
variant="secondary"
className="table-fixed"
>
<TableHeader>
<TableRow>
<TableHead
colSpan={5}
className="bg-muted text-left text-[1.3rem] font-semibold"
>
{item.vrfcTrgtNm}
</TableHead>
</TableRow>
<TableRow>
<TableHead className="bg-muted font-semibold">
시도
</TableHead>
<TableHead className="bg-muted font-semibold">
시군구
</TableHead>
<TableHead className="bg-muted font-semibold">
행정구역
</TableHead>
<TableHead className="bg-muted font-semibold">
시설명
</TableHead>
<TableHead className="bg-muted text-center font-semibold">
입력값
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{item.outlierDataList.map(
(data) => (
<TableRow key={data.rsltSn}>
<TableCell className="text-center">
{data.sido ?? "-"}
</TableCell>
<TableCell className="text-center">
{data.gugun ?? "-"}
</TableCell>
<TableCell className="text-center">
{data.adstDong ??
"-"}
</TableCell>
<TableCell className="text-center">
{/* 시설명: DTO에 해당 필드 없음 */}
-
</TableCell>
<TableCell className="text-right">
{data.cryrVl != null
? String(
data.cryrVl,
)
: "-"}
</TableCell>
</TableRow>
),
)}
</TableBody>
</Table>
),
)}
</div>
)}
</DialogContent>
</Dialog>
</>
);
};
export default HbStatisticsVerification;