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

This commit is contained in:
2026-03-09 17:36:25 +09:00
parent 64e7a1f43f
commit ff2b79aaa6
15 changed files with 1347 additions and 300 deletions
+1
View File
@@ -34,6 +34,7 @@
"@types/xlsx": "^0.0.36",
"axios": "^1.8.1",
"chart.js": "^4.4.8",
"chartjs-plugin-zoom": "^2.2.0",
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"jszip": "^3.10.1",
-4
View File
@@ -1,4 +0,0 @@
.card {
margin: 0.5rem !important;
padding: 0.5rem !important;
}
@@ -1,18 +1,39 @@
"use client";
import dayjs from "dayjs";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
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,
@@ -29,34 +50,16 @@ import {
SelectValue,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableCell,
DateRangePicker,
} from "@common/components";
import { verificationHistoryColumns } from "@/entities/statistics/model/verification-history-columns";
import { analysisApi } from "@/entities/statistics/api/analysis-api";
import { analysisHistoryApi } from "@/entities/statistics/api/analysis-history-api";
import type {
AnalysisRequestPayload,
AnalysisResponseDto,
} from "@/entities/statistics/model/analysis-interface";
import type {
StAnlsMstResponse,
StAnlsMstSearchParams,
StAnlsOutlierListResponse,
} from "@/entities/statistics/model/analysis-history-interface";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore } from "@/shared/lib";
import { verificationTargetManagementApi } from "@/entities/statistics/api/verification-target-management-service";
import type { VerificationTargetCodeResponse } from "@/entities/statistics/model/verification-target-management-interface";
import { CodeSelect } from "@/entities/code";
import type { AxiosError } from "axios";
import type { ApiErrorResponse } from "@/shared/model";
import HbVerificationDetail from "./detail/HbVerificationDetail";
type DateRangeValue = {
from?: Date;
from: Date;
to?: Date;
};
@@ -97,7 +100,6 @@ function toNumber(value: string): number {
}
const HbStatisticsVerification = () => {
const router = useRouter();
const { setMessage: alert } = useAlertStore((state) => state);
const queryClient = useQueryClient();
@@ -121,6 +123,7 @@ const HbStatisticsVerification = () => {
anlsSn: null,
});
const [selectedStatsCd, setSelectedStatsCd] = useState<string>("");
const [selectedAnlsSn, setSelectedAnlsSn] = useState<number | null>(null);
const defaultHistoryDateRange = useMemo(
() => getDefaultHistoryDateRange(),
@@ -330,7 +333,7 @@ const HbStatisticsVerification = () => {
function handleHistoryRowClick(row: StAnlsMstResponse) {
const anlsSn = Number(row.anlsSn ?? 0);
if (!anlsSn) return;
router.push(`/statistics/verification/${anlsSn}`);
setSelectedAnlsSn(anlsSn);
}
function applyHistoryDateSearch(nextRange: DateRangeValue | undefined) {
@@ -357,264 +360,298 @@ const HbStatisticsVerification = () => {
return (
<>
<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]"
/>
{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
type="button"
color="gray"
className="h-[3.2rem] px-4 text-[1.3rem]"
onClick={() => setSelectedStatsCd("")}
className="h-[3.6rem] w-[120px]"
type="button"
onClick={handleReset}
>
초기화
</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>
{/* //검증 종류 선택 */}
{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 className="flex items-center justify-center py-4">
<p className="text-[1.3rem] text-muted-foreground">
검증 대상이 없습니다.
</p>
</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="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 as any}
defaultValue={defaultHistoryDateRange as any}
onValueChange={(value) =>
setHistoryDateRange((value as any) ?? undefined)
}
className="max-w-[26rem]"
<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>
<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]">
@@ -0,0 +1,87 @@
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";
interface HbVerificationDetailProps {
anlsSn: number;
onBack: () => void;
}
const HbVerificationDetail: React.FC<HbVerificationDetailProps> = ({
anlsSn,
onBack,
}) => {
const [selectedVrfcTrgtSn, setSelectedVrfcTrgtSn] = useState<string | null>(
null,
);
const [selectedSido, setSelectedSido] = useState<string | null>(null);
return (
<div className="flex flex-col">
<div className="flex items-center gap-4 p-4">
<Button
variant="ghost"
size="icon"
color="white"
onClick={onBack}
className=""
>
<i className="diveicon di-arrow-back" />
<span className="sr-only">목록으로 돌아가기</span>
</Button>
<h2 className="text-[1.8rem] font-bold text-title">
검증 상세
</h2>
</div>
<div className="flex flex-col gap-4">
<div className="h-[20rem] overflow-y-auto">
<AreaAVerificationList
anlsSn={anlsSn}
selectedVrfcTrgtSn={selectedVrfcTrgtSn}
onSelect={setSelectedVrfcTrgtSn}
/>
</div>
<div className="card card-border !m-4">
<h3 className="p-4 pb-0 text-[1.8rem] font-bold text-title">
검증 결과 상세
</h3>
<div className="flex">
<div className="flex-[0.5] p-4">
<AreaBRegionalGrid
anlsSn={anlsSn}
vrfcTrgtSn={selectedVrfcTrgtSn}
selectedSido={selectedSido}
onSidoSelect={setSelectedSido}
/>
</div>
<div className="flex flex-[0.5] flex-col gap-4 p-4 pt-[4.8rem]">
<div className="flex-[0.5]">
<AreaDIndicators
anlsSn={anlsSn}
vrfcTrgtSn={selectedVrfcTrgtSn}
/>
</div>
<div className="flex-[0.5]">
<AreaCScatterChart
anlsSn={anlsSn}
vrfcTrgtSn={selectedVrfcTrgtSn}
selectedSido={selectedSido}
/>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default HbVerificationDetail;
@@ -0,0 +1,86 @@
import React from "react";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { stAnlsRsltApi } from "@/entities/statistics/api/statistics-analysis-result-api";
interface IndicatorsProps {
anlsSn: number;
vrfcTrgtSn: string | null;
}
const Indicators: React.FC<IndicatorsProps> = ({ anlsSn, vrfcTrgtSn }) => {
const { data: chartData, isLoading } = useQuery({
queryKey: [
ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_RSLT,
"chart",
anlsSn,
vrfcTrgtSn,
],
queryFn: () => stAnlsRsltApi.getChartData(anlsSn, Number(vrfcTrgtSn)),
enabled: !!anlsSn && !!vrfcTrgtSn,
});
const dtl = chartData?.dtl;
if (!vrfcTrgtSn) {
return (
<div className="card card-border flex min-h-[10rem] items-center justify-center gap-6 text-[1.4rem] text-muted-foreground">
항목을 선택해주세요
</div>
);
}
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>
);
}
const IndicatorItem = ({
label,
value,
}: {
label: string;
value: number | null | undefined;
}) => (
<div className="flex flex-1 flex-col gap-2 overflow-hidden rounded border bg-gray-50 p-2">
<h4
className="truncate text-[1.2rem] font-medium text-label"
title={label}
>
{label}
</h4>
<div className="truncate text-[1.6rem] font-bold">
{value != null ? Number(value).toFixed(4) : "-"}
</div>
</div>
);
return (
<div className="card card-border !m-0 flex min-h-[10rem] flex-wrap gap-2">
<IndicatorItem label="r 중위수" value={dtl?.rtCnterVl} />
<IndicatorItem
label="(E_m)E 중위수"
value={dtl?.lastTrsfScrCnterVl}
/>
<IndicatorItem
label="(E_l)E 하한값"
value={dtl?.lastTrsfScrLlmtVl}
/>
<IndicatorItem
label="(E_u)E 상한값"
value={dtl?.lastTrsfScrUplmtVl}
/>
<IndicatorItem label="E_D_L" value={dtl?.llmtVl} />
<IndicatorItem label="E_D_U" value={dtl?.uplmtVl} />
<IndicatorItem label="이상치 하한선" value={dtl?.otlrLlmtLine} />
<IndicatorItem label="이상치 상한선" value={dtl?.otlrUplmtLine} />
</div>
);
};
export default Indicators;
@@ -0,0 +1,269 @@
import React, { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
type ColumnDef,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table";
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 {
Button,
DataTable,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
interface RegionalGridProps {
anlsSn: number;
vrfcTrgtSn: string | null;
selectedSido: string | null;
onSidoSelect: (sido: string | null) => void;
}
const RegionalGrid: React.FC<RegionalGridProps> = ({
anlsSn,
vrfcTrgtSn,
selectedSido,
onSidoSelect,
}) => {
const [selectedGugun, setSelectedGugun] = useState<string>("ALL");
const [selectedStatus, setSelectedStatus] = useState<string>("ALL");
const { data: regionCodes } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CODE, "LAESECD"],
queryFn: () => CodeApi.getCode("LAESECD"),
});
const sidoOptions = useMemo(() => {
if (!regionCodes) return [];
return regionCodes.filter(
(code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 4,
);
}, [regionCodes]);
const gugunOptions = useMemo(() => {
if (!regionCodes || !selectedSido || selectedSido === "ALL") return [];
const sidoPrefix = selectedSido;
return regionCodes.filter(
(code) =>
code.lwpoCd.startsWith(sidoPrefix) && code.lwpoCd.length === 7,
);
}, [regionCodes, selectedSido]);
const {
data: resultData,
isLoading,
refetch,
} = useQuery({
queryKey: [
ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_RSLT,
"list",
anlsSn,
vrfcTrgtSn,
],
queryFn: () =>
stAnlsRsltApi.getResultList({
anlsSn,
vrfcTrgtSn: Number(vrfcTrgtSn),
page: 0,
size: 1000,
}),
enabled: false,
});
const rowData = useMemo(() => {
let data = (resultData?.content ?? []) as StAnlsRsltResponse[];
if (selectedSido && selectedSido !== "ALL") {
const sidoName = sidoOptions.find(
(opt) => opt.lwpoCd === selectedSido,
)?.lwpoCdNm;
if (sidoName) {
data = data.filter((item) => item.sido === sidoName);
}
}
if (selectedGugun && selectedGugun !== "ALL") {
const gugunName = gugunOptions.find(
(opt) => opt.lwpoCd === selectedGugun,
)?.lwpoCdNm;
if (gugunName) {
data = data.filter((item) => item.gugun === gugunName);
}
}
if (selectedStatus && selectedStatus !== "ALL") {
const statusTarget = selectedStatus === "NORMAL" ? "y" : "n";
data = data.filter(
(item) => item.otlrYn.toLowerCase() === statusTarget,
);
}
return data;
}, [
resultData,
selectedSido,
selectedGugun,
selectedStatus,
sidoOptions,
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();
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 "-";
},
},
],
[],
);
const table = useReactTable({
data: rowData,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: {
pagination: { pageSize: 10 },
},
});
const handleSidoChange = (val: string) => {
onSidoSelect(val === "ALL" ? null : val);
setSelectedGugun("ALL");
};
return (
<div className="flex flex-col gap-2">
<div className="flex justify-between">
<div className="flex items-center gap-2">
<Select
value={selectedSido || "ALL"}
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}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={selectedGugun}
onValueChange={setSelectedGugun}
disabled={!selectedSido || selectedSido === "ALL"}
>
<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}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={selectedStatus}
onValueChange={setSelectedStatus}
>
<SelectTrigger className="w-[10rem] bg-white">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체 상태</SelectItem>
<SelectItem value="NORMAL">정상</SelectItem>
<SelectItem value="OUTLIER">이상치</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<Button onClick={() => refetch()}>조회</Button>
<Button color="gray">다운로드</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>
)}
{!isLoading && (!vrfcTrgtSn || rowData.length === 0) && (
<div className="p-4 text-center text-[1.3rem] text-muted-foreground">
{vrfcTrgtSn
? "표시할 항목이 없습니다."
: "항목을 선택해주세요"}
</div>
)}
</div>
</div>
);
};
export default RegionalGrid;
@@ -0,0 +1,253 @@
import React, { useMemo } from "react";
import { Scatter } from "react-chartjs-2";
import { useQuery } from "@tanstack/react-query";
import {
Chart as ChartJS,
Legend,
LinearScale,
LineElement,
PointElement,
ScatterController,
Tooltip,
} from "chart.js";
import zoomPlugin from "chartjs-plugin-zoom";
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";
ChartJS.register(
LinearScale,
PointElement,
LineElement,
Tooltip,
Legend,
ScatterController,
zoomPlugin,
);
interface ScatterChartProps {
anlsSn: number;
vrfcTrgtSn: string | null;
selectedSido: string | null;
}
const ScatterChart: React.FC<ScatterChartProps> = ({
anlsSn,
vrfcTrgtSn,
selectedSido,
}) => {
const { data: regionCodes } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CODE, "LAESECD"],
queryFn: () => CodeApi.getCode("LAESECD"),
});
const selectedSidoName = useMemo(() => {
if (!selectedSido || !regionCodes) return null;
return (
regionCodes.find((c) => c.lwpoCd === selectedSido)?.lwpoCdNm || null
);
}, [regionCodes, selectedSido]);
const { data: resultData, isLoading: isLoadingGrid } = useQuery({
queryKey: [
ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_RSLT,
"list",
anlsSn,
vrfcTrgtSn,
],
queryFn: () =>
stAnlsRsltApi.getResultList({
anlsSn,
vrfcTrgtSn: Number(vrfcTrgtSn),
page: 0,
size: 1000,
}),
enabled: !!anlsSn && !!vrfcTrgtSn,
});
const { data: chartData, isLoading: isLoadingChart } = useQuery({
queryKey: [
ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_RSLT,
"chart",
anlsSn,
vrfcTrgtSn,
],
queryFn: () => stAnlsRsltApi.getChartData(anlsSn, Number(vrfcTrgtSn)),
enabled: !!anlsSn && !!vrfcTrgtSn,
});
const isLoading = isLoadingGrid || isLoadingChart;
const chartConfig = useMemo(() => {
if (!resultData || !chartData) return null;
const rawPoints = (resultData.content ?? []) as StAnlsRsltResponse[];
const boundaries = chartData.list ?? [];
const sortedBoundaries = [...boundaries].sort(
(a, b) => a.xPrev - b.xPrev,
);
const upperLineData = sortedBoundaries.map((b) => ({
x: b.xPrev,
y: b.yUpper,
}));
const lowerLineData = sortedBoundaries.map((b) => ({
x: b.xPrev,
y: b.yLower,
}));
const normalPoints: {
x: number;
y: number;
item: StAnlsRsltResponse;
}[] = [];
const outlierPoints: {
x: number;
y: number;
item: StAnlsRsltResponse;
}[] = [];
rawPoints.forEach((item) => {
if (selectedSidoName && item.sido !== selectedSidoName) {
return;
}
const pt = {
x: Number(item.prvyrVl ?? 0),
y: Number(item.cryrVl ?? 0),
item,
};
if (item.otlrYn.toLowerCase() === "y") {
normalPoints.push(pt);
} else {
outlierPoints.push(pt);
}
});
const data = {
datasets: [
{
label: "상한선",
data: upperLineData,
type: "line" as const,
borderColor: "rgba(255, 99, 132, 0.8)",
borderWidth: 2,
fill: false,
pointRadius: 0,
tension: 0.1,
},
{
label: "하한선",
data: lowerLineData,
type: "line" as const,
borderColor: "rgba(54, 162, 235, 0.8)",
borderWidth: 2,
fill: false,
pointRadius: 0,
tension: 0.1,
},
{
label: "이상치",
data: outlierPoints,
backgroundColor: "rgba(255, 99, 132, 0.7)",
pointRadius: 5,
pointHoverRadius: 8,
},
{
label: "정상",
data: normalPoints,
backgroundColor: "rgba(75, 192, 192, 0.7)",
pointRadius: 4,
pointHoverRadius: 7,
},
],
};
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
zoom: {
pan: {
enabled: true,
mode: "xy" as const,
},
zoom: {
wheel: {
enabled: true,
},
pinch: {
enabled: true,
},
mode: "xy" as const,
},
},
tooltip: {
callbacks: {
label: (context: any) => {
if (context.dataset.type === "line") {
return `${context.dataset.label}: (${context.parsed.x.toFixed(2)}, ${context.parsed.y.toFixed(2)})`;
}
const pt = context.raw as {
x: number;
y: number;
item: StAnlsRsltResponse;
};
return `${pt.item.sido} ${pt.item.gugun}: 이전(${pt.x}), 당해(${pt.y})`;
},
},
},
},
scales: {
x: {
type: "linear" as const,
position: "bottom" as const,
title: {
display: true,
text: "이전년도 값",
},
},
y: {
type: "linear" as const,
title: {
display: true,
text: "당해년도 값",
},
},
},
};
return { data, options };
}, [resultData, chartData, selectedSidoName]);
return (
<div className="relative flex-1 p-4">
{!vrfcTrgtSn ? (
<div className="flex h-full w-full items-center justify-center text-[1.4rem] text-muted-foreground">
항목을 선택해주세요
</div>
) : isLoading ? (
<div className="flex h-full w-full items-center justify-center text-[1.4rem] text-muted-foreground">
차트를 불러오는 중...
</div>
) : chartConfig ? (
<div className="h-[40rem] w-full">
<Scatter
data={chartConfig.data as any}
options={chartConfig.options as any}
/>
</div>
) : (
<div className="flex h-full w-full items-center justify-center text-[1.4rem] text-muted-foreground">
표시할 데이터가 없습니다.
</div>
)}
</div>
);
};
export default ScatterChart;
@@ -0,0 +1,141 @@
import React, { useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { ENDPOINT } from "@/shared/config";
import { stAnlsDtlApi } from "@/entities/statistics/api/statistics-analysis-detail-api";
import { verificationTargetManagementApi } from "@/entities/statistics/api/verification-target-management-service";
import type { StAnlsDtlResponse } from "@/entities/statistics/model/statistics-analysis-detail-interface";
import { DataTable } from "@common/components";
interface VerificationListProps {
anlsSn: number;
selectedVrfcTrgtSn: string | null;
onSelect: (vrfcTrgtSn: string) => void;
}
const VerificationList: React.FC<VerificationListProps> = ({
anlsSn,
selectedVrfcTrgtSn,
onSelect,
}) => {
const { data: verificationTargetCodes } = useQuery({
queryKey: [ENDPOINT.STATVERIFY_SERVICE.VRFCTGT_MNG, "codes"],
queryFn: () =>
verificationTargetManagementApi.getVerificationTargetCodes(),
});
const targetCodeMap = useMemo(() => {
const map = new Map<string, string>();
if (verificationTargetCodes) {
verificationTargetCodes.forEach((item) => {
map.set(item.vrfcTrgtSn, item.vrfcTrgtNm);
});
}
return map;
}, [verificationTargetCodes]);
const { data: detailData, isLoading } = useQuery({
queryKey: [ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_DTL, "list", anlsSn],
queryFn: () =>
stAnlsDtlApi.getDetailList({ anlsSn, page: 0, size: 100 }),
enabled: !!anlsSn,
});
const rowData = useMemo(() => {
return (detailData?.content ?? []) as StAnlsDtlResponse[];
}, [detailData]);
const columns = useMemo(
() => [
{
header: "검증 항목",
accessorFn: (row: StAnlsDtlResponse) =>
targetCodeMap.get(String(row.vrfcTrgtSn)) ||
String(row.vrfcTrgtSn),
id: "vrfcTrgtNm",
size: 150,
},
{
header: "μ",
accessorKey: "ipcrCntVl",
size: 60,
},
{
header: "Percentile",
accessorKey: "pctlCrtrVl",
size: 60,
},
{
header: "A",
accessorKey: "scopAjmtCntVl",
size: 60,
},
{
header: "C",
accessorKey: "thrsCntVl",
size: 60,
},
{
header: "정상",
accessorKey: "nmlDataNocs",
size: 70,
},
{
header: "제외",
accessorKey: "exclDataNocs",
size: 70,
},
{
header: "이상",
accessorKey: "otlrDataNocs",
size: 70,
},
],
[targetCodeMap],
);
const table = useReactTable({
data: rowData,
columns,
getCoreRowModel: getCoreRowModel(),
});
const handleRowClick = (row: StAnlsDtlResponse) => {
onSelect(String(row.vrfcTrgtSn));
};
useEffect(() => {
if (!selectedVrfcTrgtSn && rowData.length > 0) {
const firstTargetSn = rowData[0]?.vrfcTrgtSn;
if (firstTargetSn) onSelect(String(firstTargetSn));
}
}, [selectedVrfcTrgtSn, rowData, onSelect]);
return (
<div className="flex-1 px-4 py-2">
{isLoading && (
<div className="p-4 text-center text-[1.3rem] text-muted-foreground">
데이터를 불러오는 중...
</div>
)}
{!isLoading && rowData.length === 0 && (
<div className="p-4 text-center text-[1.3rem] text-muted-foreground">
표시할 항목이 없습니다.
</div>
)}
{!isLoading && rowData.length > 0 && (
<DataTable
table={table}
variant="secondary"
onRowClick={handleRowClick}
viewPagination={false}
className="whitespace-nowrap text-[1.2rem] [&_td]:!px-2 [&_th]:!px-2"
/>
)}
</div>
);
};
export default VerificationList;
@@ -0,0 +1,36 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
StAnlsDtlListApiResponse,
StAnlsDtlResponse,
StAnlsDtlSearchParams,
} from "../model/statistics-analysis-detail-interface";
export class StAnlsDtlApiService {
public async getDetailList(
params: StAnlsDtlSearchParams,
): Promise<StAnlsDtlListApiResponse> {
const response = await client.get<StAnlsDtlListApiResponse>(
`${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_DTL}`,
{
params,
},
);
return response.data;
}
public async getDetail(
anlsSn: number,
vrfcTrgtSn: number,
): Promise<StAnlsDtlResponse> {
const response = await client.get<StAnlsDtlResponse>(
`${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_DTL}/${anlsSn}/${vrfcTrgtSn}`,
);
return response.data;
}
}
export const stAnlsDtlApi = new StAnlsDtlApiService();
@@ -0,0 +1,37 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
ChartRetDto,
StAnlsRsltListApiResponse,
StAnlsRsltSearchParams,
} from "../model/statistics-analysis-result-interface";
export class StAnlsRsltApiService {
public async getResultList(
params: StAnlsRsltSearchParams,
): Promise<StAnlsRsltListApiResponse> {
const response = await client.get<StAnlsRsltListApiResponse>(
`${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_RSLT}`,
{
params,
},
);
return response.data;
}
public async getChartData(
anlsSn: number,
vrfcTrgtSn: number,
): Promise<ChartRetDto> {
const response = await client.get<ChartRetDto>(
`${ENDPOINT.STATVERIFY_SERVICE.ST_ANLS_RSLT}/chartList/${anlsSn}/${vrfcTrgtSn}`,
);
return response.data;
}
}
export const stAnlsRsltApi = new StAnlsRsltApiService();
@@ -0,0 +1,28 @@
import type { PageableList, PageableParams } from "@/shared/model";
export interface StAnlsDtlResponse {
anlsSn: number; // 분석 일련번호
vrfcTrgtSn: number; // 검증 대상 일련번호
ipcrCntVl: number | null; // 중요도 개수 값 (mu)
pctlCrtrVl: number | null; // 백분위 기준 값 (percentile)
scopAjmtCntVl: number | null; // 범위 조정 개수 값 (A)
thrsCntVl: number | null; // 임계 개수 값 (C)
rtCnterVl: number | null; // 비율 중앙값
lastTrsfScrCnterVl: number | null; // 최종 변환 점수 중앙값
lastTrsfScrLlmtVl: number | null; // 최종 변환 점수 하한값
lastTrsfScrUplmtVl: number | null; // 최종 변환 점수 상한값
uplmtVl: number | null; // 상한값
llmtVl: number | null; // 하한값
otlrUplmtLine: number | null; // 이상치 상한선
otlrLlmtLine: number | null; // 이상치 하한선
nmlDataNocs: number; // 정상 데이터 건수
exclDataNocs: number; // 제외 데이터 건수
otlrDataNocs: number; // 이상치 데이터 건수
}
export interface StAnlsDtlSearchParams extends PageableParams {
anlsSn?: number; // 분석 일련번호
vrfcTrgtSn?: number; // 검증 대상 일련번호
}
export type StAnlsDtlListApiResponse = PageableList<StAnlsDtlResponse>;
@@ -0,0 +1,46 @@
import type { PageableList, PageableParams } from "@/shared/model";
import type { StAnlsDtlResponse } from "./statistics-analysis-detail-interface";
export interface StAnlsRsltResponse {
rsltSn: number; // 결과 일련번호
anlsSn: number; // 분석 일련번호
vrfcTrgtSn: number; // 검증 대상 일련번호
admdstCd: string; // 행정구역 코드
admdstNm: string; // 행정구역 명칭
sido: string; // 시도
gugun: string; // 시군구
adstDong: string; // 행정동
drngBzmnCd: string; // 하수도 사업자 코드
pipeSeCd: string; // 파이프 구분 코드
fcltNm: string; // 시설명
addr: string; // 주소
prvyrVl: number | null; // 전년도 값
cryrVl: number | null; // 당해년도 값
chgRt: number | null; // 변경율 (r_i)
trsfRt: number | null; // 변환 비율 (s_i)
otlrTrsfRt: number | null; // 이상치 변환 비율 (E_i)
otlrYn: string; // 이상치 여부 (y/n)
}
export interface StAnlsRsltSearchParams extends PageableParams {
anlsSn?: number; // 분석 일련번호
vrfcTrgtSn?: number; // 검증 대상 일련번호
admdstCd?: string; // 행정구역 코드
admdstNm?: string; // 행정구역 명칭
fcltNm?: string; // 시설명
otlrYn?: string; // 이상치 여부 (y/n)
}
export type StAnlsRsltListApiResponse = PageableList<StAnlsRsltResponse>;
export interface ChartBoundaryPointDto {
xPrev: number; // 전년도 값 (x축)
yLower: number; // 이상치 하한 당년값 (y축)
yUpper: number; // 이상치 상한 당년값 (y축)
}
export interface ChartRetDto {
dtl: StAnlsDtlResponse; // 분석 상세 파라미터 (mu, r_mid, T_L, T_U 등)
list: ChartBoundaryPointDto[]; // HBR 경계선 곡선 데이터 리스트 (x_prev, y_lower, y_upper)
}
+2
View File
@@ -76,6 +76,8 @@ export const ENDPOINT = {
VRFCTGT_MNG: "/statverify-service/api/v1/vrfc-trgt-mng",
ANALYSIS: "/statverify-service/api/v1/stat-verify/analysis",
ST_ANLS_MST: "/statverify-service/api/v1/st-anls-mst",
ST_ANLS_DTL: "/statverify-service/api/v1/st-anls-dtl",
ST_ANLS_RSLT: "/statverify-service/api/v1/st-anls-rslt",
},
};
@@ -10,7 +10,10 @@ import { Button } from "../button";
import { Calendar } from "../calendar";
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
type DateRangePickerProps = React.HTMLAttributes<HTMLDivElement> &
type DateRangePickerProps = Omit<
React.HTMLAttributes<HTMLDivElement>,
"defaultValue"
> &
React.ComponentProps<typeof DayPicker> & {
value?: DateRange;
defaultValue?: DateRange;
+50 -25
View File
@@ -101,10 +101,10 @@ importers:
devDependencies:
eslint:
specifier: ^9.0.0
version: 9.39.2(jiti@1.21.7)
version: 9.39.2(jiti@2.6.1)
eslint-config-next:
specifier: 15.4.0
version: 15.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
version: 15.4.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
postcss:
specifier: ^8.4.49
version: 8.5.6
@@ -1112,6 +1112,9 @@ importers:
chart.js:
specifier: ^4.4.8
version: 4.5.1
chartjs-plugin-zoom:
specifier: ^2.2.0
version: 2.2.0(chart.js@4.5.1)
dayjs:
specifier: ^1.11.13
version: 1.11.19
@@ -1187,13 +1190,13 @@ importers:
version: 5.28.5
eslint:
specifier: ^9
version: 9.39.2(jiti@2.6.1)
version: 9.39.2(jiti@1.21.7)
eslint-config-next:
specifier: 15.1.7
version: 15.1.7(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
version: 15.1.7(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint-plugin-react-hooks:
specifier: ^5.0.0
version: 5.2.0(eslint@9.39.2(jiti@2.6.1))
version: 5.2.0(eslint@9.39.2(jiti@1.21.7))
postcss:
specifier: ^8
version: 8.5.6
@@ -3841,6 +3844,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/hammerjs@2.0.46':
resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==}
'@types/js-cookie@3.0.6':
resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
@@ -4444,6 +4450,11 @@ packages:
resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==}
engines: {pnpm: '>=8'}
chartjs-plugin-zoom@2.2.0:
resolution: {integrity: sha512-in6kcdiTlP6npIVLMd4zXZ08PDUXC52gZ4FAy5oyjk1zX3gKarXMAof7B9eFiisf9WOC3bh2saHg+J5WtLXZeA==}
peerDependencies:
chart.js: '>=3.2.0'
check-error@2.1.1:
resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==}
engines: {node: '>= 16'}
@@ -5314,6 +5325,10 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
hammerjs@2.0.8:
resolution: {integrity: sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==}
engines: {node: '>=0.8.0'}
has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'}
@@ -9305,6 +9320,8 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/hammerjs@2.0.46': {}
'@types/js-cookie@3.0.6': {}
'@types/json-schema@7.0.15': {}
@@ -10134,6 +10151,12 @@ snapshots:
dependencies:
'@kurkle/color': 0.3.4
chartjs-plugin-zoom@2.2.0(chart.js@4.5.1):
dependencies:
'@types/hammerjs': 2.0.46
chart.js: 4.5.1
hammerjs: 2.0.8
check-error@2.1.1: {}
chokidar@3.6.0:
@@ -10706,6 +10729,26 @@ snapshots:
optionalDependencies:
source-map: 0.7.6
eslint-config-next@15.1.7(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3):
dependencies:
'@next/eslint-plugin-next': 15.1.7
'@rushstack/eslint-patch': 1.15.0
'@typescript-eslint/eslint-plugin': 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/parser': 8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-react-hooks: 5.2.0(eslint@9.39.2(jiti@1.21.7))
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- eslint-import-resolver-webpack
- eslint-plugin-import-x
- supports-color
eslint-config-next@15.1.7(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
dependencies:
'@next/eslint-plugin-next': 15.1.7
@@ -10726,26 +10769,6 @@ snapshots:
- eslint-plugin-import-x
- supports-color
eslint-config-next@15.4.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3):
dependencies:
'@next/eslint-plugin-next': 15.4.0
'@rushstack/eslint-patch': 1.15.0
'@typescript-eslint/eslint-plugin': 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/parser': 8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-react-hooks: 5.2.0(eslint@9.39.2(jiti@1.21.7))
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- eslint-import-resolver-webpack
- eslint-plugin-import-x
- supports-color
eslint-config-next@15.4.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
dependencies:
'@next/eslint-plugin-next': 15.4.0
@@ -11344,6 +11367,8 @@ snapshots:
graceful-fs@4.2.11: {}
hammerjs@2.0.8: {}
has-bigints@1.1.0: {}
has-flag@4.0.0: {}