Files
frontend/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx
T

366 lines
11 KiB
TypeScript

"use client";
import React, { useEffect, useMemo, useState } from "react";
import moment from "moment";
import {
CHART_FORM,
type ChartConfig,
MainChart,
StandardFormField,
StandardFormSection,
StandardPopupLayout,
} from "@common/business_components";
import { SelectBox } from "@common/business_components/ui";
import { Button, Input } from "@common/components";
import type { TInftwtAnls, TInftwtAnlsRslt, TOilqData } from "./aplcnYms";
import * as fn from "./aplcnYms";
type ModalOpenProp = {
pplMngNo?: string;
inftwtAnlsSn?: number;
aplcnYm?: string;
analysisInfo?: string;
resultOnly?: boolean;
};
type DataType = {
label: string;
val: number;
};
const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: "선택" }];
const InfilChart = ({
pplMngNo = "",
inftwtAnlsSn = 0,
aplcnYm = "",
analysisInfo = "",
resultOnly = false,
}: ModalOpenProp) => {
const [selectedAplcnYm, setSelectedAplcnYm] = useState("");
const [inftwtAnlss, setInftwtAnlss] = useState<TInftwtAnls[]>([]);
const [inftwtAnlsInfos, setInftwtAnlsInfos] = useState<
{ text: string; val: number; hidden?: boolean }[]
>(INITIAL_ANALYSIS_OPTIONS);
const [inftwtAnls, setInftwtAnls] = useState<TInftwtAnls | null>(null);
const [selectedInftwtAnls, setSelectedInftwtAnls] = useState<number>(0);
const [chartData, setChartData] = useState<{
chartData1: DataType[];
chartData2: DataType[];
}>({
chartData1: [],
chartData2: [],
});
const [inftwtQty, setInftwtQty] = useState<string>("0");
const aplcnYms = fn.useAplcnYms("inftwtAnlsRslt");
const normalizedPplMngNo = pplMngNo ? `L${pplMngNo}` : "";
const isResultOnlyMode =
resultOnly && inftwtAnlsSn > 0 && Boolean(normalizedPplMngNo);
const chartForm = CHART_FORM.LINE;
const chartConfig = {
chartData1: { label: "순오수량", color: "red" },
chartData2: { label: "실측하수량", color: "orange" },
} as ChartConfig;
const hasChartData = useMemo(() => {
return chartData.chartData1.length > 0 || chartData.chartData2.length > 0;
}, [chartData]);
const calculateFallbackInftwtQty = (
inftwtAnlsRslt: TInftwtAnlsRslt[],
oilqRows: TOilqData[],
) => {
const predcValues = inftwtAnlsRslt
.map((item) => Number(item.predcOilq))
.filter((value) => Number.isFinite(value));
const oilqValues = oilqRows
.map((item) => Number(item.oilq))
.filter((value) => Number.isFinite(value));
if (predcValues.length === 0 || oilqValues.length === 0) {
return 0;
}
const avgPredc =
predcValues.reduce((sum, value) => sum + value, 0) / predcValues.length;
const avgOilq =
oilqValues.reduce((sum, value) => sum + value, 0) / oilqValues.length;
const dailyQty = (avgOilq - avgPredc) * 24;
return Math.round(dailyQty / 10) * 10;
};
const resetState = () => {
setSelectedAplcnYm("");
setInftwtAnlss([]);
setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
setInftwtAnls(null);
setSelectedInftwtAnls(0);
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty("0");
};
const loadResultChart = async (targetInftwtAnlsSn: number) => {
if (!targetInftwtAnlsSn || !normalizedPplMngNo) {
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty("0");
return;
}
try {
const [inftwtAnlsRslt, oilq, qty] = await Promise.all([
fn.selectInftwtAnlsRslt(targetInftwtAnlsSn, normalizedPplMngNo),
fn.selectOilq(targetInftwtAnlsSn, normalizedPplMngNo).catch((err) => {
console.error("selectOilq 에러:", err);
return [];
}),
fn
.selectPplInftwtQty(targetInftwtAnlsSn, normalizedPplMngNo)
.catch((err) => {
console.error("selectPplInftwtQty 에러:", err);
return 0;
}),
]);
setChartData({
chartData1: fn.getChartDataInftwtAnlsRslt(inftwtAnlsRslt ?? []),
chartData2: fn.getChartDataOilq(oilq ?? []),
});
const fallbackQty = calculateFallbackInftwtQty(
inftwtAnlsRslt ?? [],
oilq ?? [],
);
const normalizedQty = Number(qty ?? Number.NaN);
const resolvedQty =
Number.isFinite(normalizedQty) &&
!(normalizedQty === 0 && fallbackQty !== 0)
? normalizedQty
: fallbackQty;
if (
!Number.isFinite(normalizedQty) ||
(normalizedQty === 0 && fallbackQty !== 0)
) {
console.warn(
"Inftwt qty API returned empty or zero; using chart-data fallback qty.",
{
apiQty: qty,
fallbackQty,
resultCount: inftwtAnlsRslt?.length ?? 0,
oilqCount: oilq?.length ?? 0,
},
);
}
setInftwtQty(resolvedQty.toLocaleString("ko-KR"));
} catch (error) {
console.error("침입수 분석결과 차트 조회 중 오류:", error);
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty("0");
}
};
useEffect(() => {
if (!isResultOnlyMode) return;
setSelectedAplcnYm(aplcnYm);
setSelectedInftwtAnls(inftwtAnlsSn);
setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
void loadResultChart(inftwtAnlsSn);
}, [aplcnYm, inftwtAnlsSn, isResultOnlyMode, normalizedPplMngNo]);
const handleAplcnYmChange = async (val: string | number) => {
const selectedValue = String(val);
setSelectedAplcnYm(selectedValue);
setInftwtAnlss([]);
setInftwtAnls(null);
setSelectedInftwtAnls(0);
setInftwtQty("0");
setChartData({ chartData1: [], chartData2: [] });
if (!selectedValue || selectedValue === "0") {
setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
return;
}
try {
const data = await fn.selectInftwtAnls(selectedValue);
const list = data ?? [];
setInftwtAnlss(list);
setInftwtAnlsInfos(fn.getOptionsInftwtAnlsInfo(list));
} catch (error) {
console.error("selectInftwtAnls 에러:", error);
setInftwtAnlss([]);
setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
}
};
const handleInftwtAnlsSnChange = (val: string | number) => {
const selectedValue = Number(val);
setSelectedInftwtAnls(selectedValue);
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty("0");
if (selectedValue === 0) {
setInftwtAnls(null);
return;
}
const selectedAnls =
inftwtAnlss.find((anls) => anls.inftwtAnlsSn === selectedValue) ?? null;
setInftwtAnls(selectedAnls);
};
const infilRsltChart = async () => {
if (!selectedAplcnYm || selectedInftwtAnls === 0 || !normalizedPplMngNo) {
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty("0");
return;
}
await loadResultChart(selectedInftwtAnls);
};
/** 분석 메타데이터를 공통 표형 폼의 읽기 전용 셀로 표시한다. */
const renderInfoField = (
label: string,
value?: string | number | null,
span: 1 | 2 = 1,
) => (
<StandardFormField label={label} columns={2} span={span}>
<Input className="w-full" value={value ?? ""} readOnly />
</StandardFormField>
);
const handleReset = () => {
resetState();
};
const renderResultPanel = () => (
<section className="card card-border !m-0 min-w-0 overflow-hidden !p-[1.2rem]">
<StandardFormSection title="침입수 분석결과" columns={2}>
<StandardFormField label="상수사용량 적용연월" columns={2}>
<div className="flex min-h-[3.2rem] items-center justify-end text-[1.35rem] font-semibold tabular-nums text-foreground">
{selectedAplcnYm
? moment(selectedAplcnYm, "YYYYMM").format("YYYY-MM")
: "-"}
</div>
</StandardFormField>
<StandardFormField label="침입수량 산정결과" columns={2}>
<div className="flex min-h-[3.2rem] items-center justify-end gap-3 whitespace-nowrap text-foreground">
<span className="text-[1.15rem] text-muted-foreground">
침입수량
</span>
<div className="flex items-baseline gap-2">
<strong className="text-[1.5rem] font-semibold tabular-nums">
{inftwtQty}
</strong>
<span className="text-[1.1rem]">m³/d</span>
</div>
</div>
</StandardFormField>
</StandardFormSection>
<section className="mt-[1.2rem] overflow-hidden rounded-sm border border-border bg-background">
<h4 className="border-b border-border bg-container px-4 py-3 text-[1.4rem] font-semibold text-label">
침입수량 그래프
</h4>
<div className="min-h-[30rem] p-3">
{hasChartData ? (
<MainChart
chartConfig={chartConfig}
chartForm={chartForm}
chartData={chartData}
chartTitle=""
yFieldText="유량(m³/h)"
yFieldText2=""
/>
) : (
<div
className="flex min-h-[30rem] items-center justify-center text-[1.3rem] text-muted-foreground"
role="status"
>
조회된 데이터가 없습니다.
</div>
)}
</div>
</section>
</section>
);
const renderDefaultBody = () => (
<div className="flex min-h-0 flex-1 flex-col gap-[1.2rem]">
<StandardFormSection title="침입수 분석 조건" columns={2}>
<StandardFormField label="상수사용월" columns={2}>
<SelectBox
className="w-full"
selectClass="w-full"
label=""
selectArray={aplcnYms}
value={selectedAplcnYm}
onSelectValue={handleAplcnYmChange}
/>
</StandardFormField>
<StandardFormField label="분석정보" columns={2}>
<SelectBox
className="w-full"
selectClass="w-full"
label=""
selectArray={inftwtAnlsInfos}
value={selectedInftwtAnls}
onSelectValue={handleInftwtAnlsSnChange}
/>
</StandardFormField>
{renderInfoField("오수전환율", inftwtAnls?.drtwSwtcRt ?? "")}
{renderInfoField(
"시간패턴 파일명",
inftwtAnls?.atflHrPttnNm ?? "",
)}
{renderInfoField("수용가-맨홀 매칭", inftwtAnls?.atflMpngNm ?? "")}
{renderInfoField("관망도 파일명", inftwtAnls?.atflInpNm ?? "")}
{renderInfoField("유량 파일명", inftwtAnls?.atflOilqNm ?? "", 2)}
</StandardFormSection>
{renderResultPanel()}
</div>
);
/** 조회·초기화 뒤에 창 닫기 성격의 취소 액션을 항상 마지막에 배치한다. */
const renderFooterActions = () => (
<div className="flex w-full items-center justify-center gap-2">
{!isResultOnlyMode ? (
<>
<Button type="button" onClick={infilRsltChart}>
조회
</Button>
<Button type="button" color="white" onClick={handleReset}>
초기화
</Button>
</>
) : null}
<Button type="button" color="white" onClick={() => window.close()}>
취소
</Button>
</div>
);
return (
<StandardPopupLayout
title={isResultOnlyMode ? "침입수 분석결과" : "침입수 분석"}
bodyClassName="overflow-x-hidden"
footer={renderFooterActions()}
showDefaultCancelAction={false}
>
{isResultOnlyMode ? renderResultPanel() : renderDefaultBody()}
</StandardPopupLayout>
);
};
export default React.memo(InfilChart);