Files
frontend/apps/sn/app/(with-base-layout)/ii/rdii/page.tsx
T
2026-09-15 10:00:42 +09:00

674 lines
25 KiB
TypeScript

/**
* ➡️ 경로 : I/I 분석 - RDII 분석
*
* 📄 RDII Page
* RDII 분석 페이지 컴포넌트
*
* - 오수관망도, 수용가-맨홀 매칭결과, 강우자료 등 파일 업로드 기능
* - RTK계수 출처 및 RDII 기여면적 입력 기능
* - 단위도 계수 및 초기 차단 깊이 테이블 편집 기능
* - 엑셀 파일 데이터 파싱 및 JSON 변환 처리
* - 분석 데이터 서버 제출 및 폼 초기화 기능
* - 파일 확장자 및 입력 값 검증 기능
* - 로딩 오버레이 및 에러 핸들링 지원
*
* 🔗 사용 API
* - RDII분석 등록 첨부파일INP 포함: POST /api/v1/rdiiAnls/saveWithFile (sewerage-service)
* - 로그인된 사용자의 사용자 정보 조회: GET /api/v1/users/info/me (user-service)
*/
"use client";
import { postMutiPart } from "@/components/lib/api";
import FileUploadForm from "@/components/ui/fileUploadForm";
import BaseLayout from "../BaseLayout";
import { useAnalysisCompletionWatcher } from "../useAnalysisCompletionWatcher";
import { openAnalysisResultMapView } from "../analysisResultView";
import { DateMonthPicker, filePicked } from "@common/business_components/ui";
import {
StandardFormField,
StandardFormSection,
} from "@common/business_components";
import { Input } from "@common/components/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { z } from "zod";
import {
excelToMatchingResultData,
excelToRainData,
} from "@/components/lib/utils";
import {
EditableFormTable,
type EditableTableHeadItem,
} from "../../../components/ui/EditableFormTable";
import {
validateMultipleFiles,
FILE_EXTENSIONS,
type FileValidationConfig,
} from "../../fileValidation";
import { validateBasicSelections } from "../../selectionValidation";
import { tokenManager } from "@/entities/auth/lib/token-manager";
import { AUTH_USER_ID } from "@/shared/config";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@common/components/config";
import { getUserInfo } from "@common/components/api";
import LoadingOverlay from "@common/business_components/ui/loadingBar";
import { useAlertStore } from "@/shared/lib";
// zod 스키마 정의
const formSchema = z.object({
ctpvCd: z.string(), // 시도코드
sggCd: z.string(), // 시군구코드
swtrmtFcltNm: z.string(), // 하수처리시설명
rtkSource: z.string(), // RTK계수 출처
rdiiArea: z.string(), // RDII 기여면적
unitFactors: z.array(
z.object({
// 단위도 계수
response: z.string(),
R: z.string(),
T: z.string(),
K: z.string(),
}),
),
initialDepths: z.array(
z.object({
// 초기 차단 깊이
response: z.string(),
Dmax: z.string(),
Drec: z.string(),
Do: z.string(),
}),
),
// osumap: z.instanceof(File).optional(), // 오수관망도
// matching: z.instanceof(File).optional(), // 수용가-맨홀 매칭결과
aplcnYm: z.string(), // 상수사용량 적용월 (YYYYMM 형식)
rate: z.string(), // 오수전환율
// rain: z.instanceof(File).optional(), // 강우자료
analysisInfo: z.string().optional(), // 분석 정보
date: z.string(), // Date 객체에서 string(yyyyMM 형식)으로 변경
// date: z.coerce.date(),
});
// zod 스키마 타입 추론
type FormSchemaType = z.infer<typeof formSchema>;
// 기본 폼 값 정의 (테스트 편의를 위해 초기값 설정, 추후 삭제 필요)
const defaultFormValues: FormSchemaType = {
ctpvCd: "41",
sggCd: "41830",
swtrmtFcltNm: "\uC591\uD3C9",
rtkSource: "",
rdiiArea: "", // 문자열로 기본값 설정
unitFactors: [
{ response: "Short-Term", R: "0.040", T: "0.5", K: "1" }, // 문자열로 기본값 설정
{ response: "Medium-Term", R: "0.005", T: "1", K: "2" },
{ response: "Long-Term", R: "0.005", T: "2", K: "3" },
],
initialDepths: [
{ response: "Short-Term", Dmax: "0", Drec: "0", Do: "0" }, // 문자열로 기본값 설정
{ response: "Medium-Term", Dmax: "0", Drec: "0", Do: "0" },
{ response: "Long-Term", Dmax: "0", Drec: "0", Do: "0" },
],
// osumap: undefined,
// matchingResultFile: undefined,
aplcnYm: "",
rate: "0.8", // 문자열로 기본값 설정
// rainFile: undefined,
analysisInfo: "",
date: (() => {
// 현재 연월을 yyyyMM 형식으로 설정
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
return `${year}${month}`;
})(),
};
// 각 파일 업로드 폼의 데이터를 저장할 인터페이스 정의
interface UploadedFileInfo {
file: File | null;
atflCd: string | null;
atflId: string | null;
}
// 초기 파일 정보 상태
const initialFileInfo: UploadedFileInfo = {
file: null,
atflCd: null,
atflId: null,
};
const DEFAULT_CTPV_CD = "41";
const DEFAULT_SGG_CD = "41830";
const DEFAULT_SGG_NAME = "\uC591\uD3C9\uAD70";
const DEFAULT_SWTRMT_FCLT_NM = "\uC591\uD3C9";
export default function RTKForm() {
const alert = useAlertStore((state) => state.setMessage);
const [formResetKey, setFormResetKey] = useState(0); // FileUploadForm 리셋을 위한 key 상태
const [isLoadingResult, setIsLoadingResult] = useState<boolean>(false); // 로딩 상태
// FileUploadForm에서 전달받은 파일 정보를 저장할 상태 배열
// 각 인덱스는 FileUploadForm의 fileIdx와 매칭됨
const [uploadedFilesData, setUploadedFilesData] = useState<
UploadedFileInfo[]
>([
initialFileInfo, // 0: 오수관망도
initialFileInfo, // 1: 수용가-맨홀 매칭결과
initialFileInfo, // 2: 강우자료
]);
const [select1, setSelect1] = useState<string>("0"); // 하수처리장 (swtrmtFcltCd)
const [ctpvCd, setCtpvCd] = useState<string>(DEFAULT_CTPV_CD); // 시도 코드 상태
const [sggCd, setSggCd] = useState<string>(DEFAULT_SGG_CD); // 시군구 코드 상태
const [sggName, setSggName] = useState<string>(DEFAULT_SGG_NAME); // 시군구 이름 상태
const [swtrmtFcltNm, setSwtrmtFcltNm] = useState<string>(
DEFAULT_SWTRMT_FCLT_NM,
); // 하수처리시설명 상태
const { data: userInfo } = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.USERS],
queryFn: () => getUserInfo(),
enabled: true,
});
const { startWatching } = useAnalysisCompletionWatcher({
buildStatusUrl: (analysisSn) => `v1/rdiiAnls/${analysisSn}/status`,
successMessage: "RDII 분석이 완료되었습니다.",
failureMessage: "RDII 분석이 실패했습니다. 로그를 확인해주세요.",
analysisType: "RDII",
analysisName: "RDII 분석",
initialProgressPercent: 10,
initialProgressStep: "REQUEST_ACCEPTED",
initialProgressMessage: "요청 접수 완료",
});
// form 인스턴스 생성
const form = useForm<FormSchemaType>({
resolver: zodResolver(formSchema),
defaultValues: defaultFormValues,
});
// 폼 초기화 함수
const resetAllForms = () => {
form.reset(defaultFormValues); // 폼 값 초기화
setUploadedFilesData([
// 파일 업로드 상태 초기화 (3개 파일)
initialFileInfo,
initialFileInfo,
initialFileInfo,
]);
setFormResetKey((prevKey) => prevKey + 1); // FileUploadForm 재마운트를 위해 key 변경
setSelect1("0");
setCtpvCd(DEFAULT_CTPV_CD);
setSggCd(DEFAULT_SGG_CD);
setSggName(DEFAULT_SGG_NAME);
setSwtrmtFcltNm(DEFAULT_SWTRMT_FCLT_NM);
};
// FileUploadForm의 onFileSelect prop으로 전달될 콜백 함수
const handleFileSelection = (
index: number, // FileUploadForm의 fileIdx 값
selectedFile: File | null,
cd: string | null,
id: string | null,
) => {
setUploadedFilesData((prevData) => {
const newData = [...prevData];
newData[index] = {
file: selectedFile,
atflCd: cd,
atflId: id,
};
return newData;
});
};
const registFile = async (analysisData: any) => {
const data = await postMutiPart("v1/rdiiAnls/saveWithFile", analysisData);
if (!data) {
throw new Error("분석 요청 접수 응답이 비어 있습니다.");
}
const nextAnalysisSn = Number(data?.rainInfwtAnlsSn ?? 0);
const canWatchCompletion =
Number.isFinite(nextAnalysisSn) && nextAnalysisSn > 0;
setIsLoadingResult(false);
await new Promise((resolve) => setTimeout(resolve, 0));
alert(
canWatchCompletion
? "분석 요청이 접수되었습니다. 완료 시 안내됩니다."
: "분석 요청이 접수되었습니다. 결과 목록에서 진행 상태를 확인해주세요.",
() => {
if (canWatchCompletion) {
startWatching(nextAnalysisSn);
}
resetAllForms();
},
);
};
// 침입수 분석 실행 버튼 클릭 핸들러
const handleAnalysisClick = async () => {
setIsLoadingResult(true); // 로딩 상태 시작
// 기본 선택 항목들 검증 (환경청, 시도, 시군구, 하수처리장)
const selectionValidation = validateBasicSelections(select1, {
requireEnv: true,
requireSido: true,
requireSigungu: true,
requireTreatmentPlant: true,
});
if (!selectionValidation.isValid) {
setIsLoadingResult(false);
alert(
selectionValidation.errorMessage ?? "필수 선택 항목을 확인해주세요.",
);
return;
}
const formData = form.getValues();
const file1 = uploadedFilesData[0]; // 오수관망도
const file2 = uploadedFilesData[1]; // 수용가-맨홀 매칭결과
const file3 = uploadedFilesData[2]; // 강우자료
// 모든 필수 파일이 선택되었는지 확인
if (!file1?.file || !file2?.file || !file3?.file) {
setIsLoadingResult(false);
alert("모든 분석용 파일을 선택해주세요.");
return;
}
// 파일 확장자 검증 (분리된 유틸리티 사용)
const validationConfigs: FileValidationConfig[] = [
{
file: file1.file,
allowedExtensions: FILE_EXTENSIONS.INP,
fileDescription: "오수관망도",
},
{
file: file2.file,
allowedExtensions: FILE_EXTENSIONS.EXCEL,
fileDescription: "수용가-맨홀 매칭결과",
},
{
file: file3.file,
allowedExtensions: FILE_EXTENSIONS.EXCEL,
fileDescription: "오수량 시간패턴",
},
];
// 파일 확장자 검증 실행
const validationResult = validateMultipleFiles(validationConfigs);
if (!validationResult.isValid) {
setIsLoadingResult(false);
alert(validationResult.errorMessage ?? "파일 형식을 확인해주세요.");
return;
}
// 오수전환율 값 0 ~ 1 사이인지 검증
const rateValue = form.getValues().rate;
if (rateValue === "" || rateValue === null || rateValue === undefined) {
setIsLoadingResult(false);
console.error("오수전환율은 0에서 1 사이의 값으로 입력해야 합니다.");
alert("오수전환율을 입력해주세요.");
return;
}
const numericInput1 = parseFloat(rateValue);
if (isNaN(numericInput1) || numericInput1 < 0 || numericInput1 > 1) {
setIsLoadingResult(false);
console.error("오수전환율은 0에서 1 사이의 값으로 입력해야 합니다.");
alert("오수전환율은 0에서 1 사이의 값으로 입력해야 합니다.");
return;
}
const filePickers = [
{ file: file2, parser: excelToMatchingResultData },
{ file: file3, parser: excelToRainData },
];
try {
const parsedResults = await Promise.all(
filePickers.map(async ({ file, parser }) => {
if (!file || !file.file) return null; // fileInfo 객체 및 file 객체 null 체크
// filePicked 함수에 File 객체와 headerNum 전달
const ExcelToJsonData = await filePicked(file.file, 0);
if (ExcelToJsonData === undefined) {
console.warn(
`Warning: filePicked returned undefined for ${file.file.name}`,
);
return {
fileName: file.file.name,
atflCd: file.atflCd,
atflId: file.atflId,
parsedData: null, // 또는 적절한 기본값
};
}
// ExcelToJsonData를 각 parser 함수로 처리
// parser 함수가 filePicked가 반환하는 JSON 형식을 직접 처리한다고 가정
const parsedDataBySpecificFunction = parser(ExcelToJsonData);
return {
fileName: file.file.name,
atflCd: file.atflCd,
atflId: file.atflId,
parsedData: parsedDataBySpecificFunction,
};
}),
);
// 각 파싱 결과 접근
const matchingResultData =
parsedResults.find((r) => r && r.fileName === file2.file?.name)
?.parsedData ?? [];
const rainData =
parsedResults.find((r) => r && r.fileName === file3.file?.name)
?.parsedData ?? [];
// 상수사용량 적용월과 강우자료의 날짜 일치 여부 검증
let dateValidated = true;
if (rainData) {
rainData
.filter((data) => data && "mtrngDt" in data)
.map((data) => {
if (formData.date !== data.mtrngDt.replace(/-/g, "").slice(0, 6))
dateValidated = false;
});
}
if (!dateValidated) {
setIsLoadingResult(false);
alert("'상수사용량 적용월'과 강우자료의 '날짜'가 일치하지 않습니다.");
return;
}
// 새로운 JSON 데이터 생성
const jsonData = {
aplcnYm: formData.date, // 적용연월 (YYYYMM 형식)
ctpvCd: ctpvCd !== "0" ? String(ctpvCd).trim() : null, // 시도코드 (선택값)
sggCd: sggCd !== "0" ? String(sggCd) : null, // 시군구코드 (선택값)
swtrmtFcltNm: swtrmtFcltNm ?? null,
atflCd: file1?.atflCd, // 첨부파일CD (오수관망도)
atflId: file1?.atflId, // 첨부파일ID (오수관망도)
atflInpNm: file1?.file?.name || "", // 오수관망도 파일명
atflMpngNm: file2?.file?.name || "", // 수용가-맨홀 매칭결과 파일명
atflRainNm: file3?.file?.name || "", // 강우자료 파일명
cntbArea: parseFloat(formData.rdiiArea) || null, // RDII 기여면적(ha)
srcNm: formData.rtkSource, // 출처이름 (RTK계수 출처)
shortPrdR: parseFloat(formData.unitFactors[0]?.R || "0"),
shortPrdT: parseFloat(formData.unitFactors[0]?.T || "0"),
shortPrdK: parseFloat(formData.unitFactors[0]?.K || "0"),
mdPrdR: parseFloat(formData.unitFactors[1]?.R || "0"),
mdPrdT: parseFloat(formData.unitFactors[1]?.T || "0"),
mdPrdK: parseFloat(formData.unitFactors[1]?.K || "0"),
longPrdR: parseFloat(formData.unitFactors[2]?.R || "0"),
longPrdT: parseFloat(formData.unitFactors[2]?.T || "0"),
longPrdK: parseFloat(formData.unitFactors[2]?.K || "0"),
shortPrdMaxDpth: parseFloat(formData.initialDepths[0]?.Dmax || "0"),
shortPrdRcvryRt: parseFloat(formData.initialDepths[0]?.Drec || "0"),
shortPrdBgngDpth: parseFloat(formData.initialDepths[0]?.Do || "0"),
mdPrdMaxDpth: parseFloat(formData.initialDepths[1]?.Dmax || "0"),
mdPrdRcvryRt: parseFloat(formData.initialDepths[1]?.Drec || "0"),
mdPrdBgngDpth: parseFloat(formData.initialDepths[1]?.Do || "0"),
longPrdMaxDpth: parseFloat(formData.initialDepths[2]?.Dmax || "0"),
longPrdRcvryRt: parseFloat(formData.initialDepths[2]?.Drec || "0"),
longPrdBgngDpth: parseFloat(formData.initialDepths[2]?.Do || "0"),
drtwSwtcRt: parseFloat(formData.rate || "0"), // 오수전환율
anlsInfo: formData.analysisInfo, // 분석정보
rgtr: tokenManager.getToken(AUTH_USER_ID) || null, // 등록자 (사용자 ID)
rgtrNm: userInfo?.userName, // 등록자 (사용자 ID)
tbSnRcptrMhlMpng: matchingResultData, // 수용가-맨홀 매칭결과
tbSnRainData: rainData, // 강우자료
};
const jsonBlob = new Blob([JSON.stringify(jsonData)], {
type: "application/json",
});
const combinedAnalysisData = new FormData();
combinedAnalysisData.append("data", jsonBlob);
if (file1?.file) combinedAnalysisData.append("inpFile", file1.file);
await registFile(combinedAnalysisData);
setIsLoadingResult(false);
} catch (error) {
setIsLoadingResult(false);
console.error("RDII 분석 요청 처리 중 오류 발생:", error);
alert(
error instanceof Error && error.message.trim()
? error.message
: "파일 처리 중 오류가 발생했습니다.",
);
return;
}
};
const { fields: unitFactorFields } = useFieldArray({
control: form.control,
name: "unitFactors",
});
const { fields: initialDepthFields } = useFieldArray({
control: form.control,
name: "initialDepths",
});
// 단위도 계수 테이블 설정 (EditableTableHeadItem 타입 사용 및 id 수정)
const unitFactorHeadlist: EditableTableHeadItem<
FormSchemaType["unitFactors"][number]
>[] = [
{ id: "response", title: "Response", editable: false, width: "150px" }, // width 속성 추가
{ id: "R", title: "R", editable: true, inputProps: { type: "text" } },
{ id: "T", title: "T", editable: true, inputProps: { type: "text" } },
{ id: "K", title: "K", editable: true, inputProps: { type: "text" } },
];
// 초기 차단 깊이 테이블 설정 (EditableTableHeadItem 타입 사용)
const initialDepthHeadlist: EditableTableHeadItem<
FormSchemaType["initialDepths"][number]
>[] = [
{ id: "response", title: "Response", editable: false, width: "150px" }, // width 속성 추가
{ id: "Dmax", title: "Dmax", editable: true, inputProps: { type: "text" } },
{ id: "Drec", title: "Drec", editable: true, inputProps: { type: "text" } },
{ id: "Do", title: "Do", editable: true, inputProps: { type: "text" } },
];
return (
<>
<LoadingOverlay isLoading={isLoadingResult} />
<BaseLayout
selectBox={{ title: "분석 대상지역 선택" }}
titleName={{
mainTitle: "RDII 분석",
firstCardTitle: "RDII 분석자료 입력",
secondCardTitle: "실측자료 등록",
}}
form={form} // react-hook-form 인스턴스 전달
onSubmitEvent={() => {}} // BaseLayout의 form 태그에 연결될 onSubmit
graphBtn={false}
resultViewBtn={"결과보기"}
analysisBtn={"RDII분석 실행"}
ctpvCd={ctpvCd}
sggCd={sggCd}
sggName={sggName}
selectedSwagePrcsFclt={select1}
onSwagePrcsFcltChange={setSelect1}
setCtpvCd={setCtpvCd}
setSggCd={setSggCd}
setSggName={setSggName}
setSwtrmtFcltNm={setSwtrmtFcltNm}
defaultSwtrmtFcltNm={swtrmtFcltNm}
setIsLoading={setIsLoadingResult}
onResultView={() => openAnalysisResultMapView("rdiiResultPopup", alert)}
onAnalysis={handleAnalysisClick}
isRdii={true}
>
{[
<StandardFormSection
key="form-section"
title="분석 조건 및 자료"
columns={2}
wideLabels
>
<StandardFormField label="RTK계수 출처" required columns={2}>
<Input {...form.register("rtkSource")} className="w-full" />
</StandardFormField>
<StandardFormField label="RDII 기여면적(ha)" required columns={2}>
<Input {...form.register("rdiiArea")} className="w-full" />
</StandardFormField>
<StandardFormField label="단위도 계수" required columns={2}>
<div className="overflow-x-auto">
<EditableFormTable
form={form}
name="unitFactors"
fields={unitFactorFields}
headlist={unitFactorHeadlist}
tableClassName="min-w-[40rem] w-full"
/>
</div>
</StandardFormField>
<StandardFormField label="초기 차단 깊이" required columns={2}>
<div className="overflow-x-auto">
<EditableFormTable
form={form}
name="initialDepths"
fields={initialDepthFields}
headlist={initialDepthHeadlist}
tableClassName="min-w-[40rem] w-full"
/>
</div>
</StandardFormField>
<StandardFormField
label="오수관망도"
required
columns={2}
className="[&_.card]:!m-0 [&_.card]:!border-0 [&_.card]:!p-0"
>
<div className="w-full">
<FileUploadForm
key={`file-upload-0-${formResetKey}`} // 폼 초기화를 위한 key
onFileSelect={(file, cd, id) =>
handleFileSelection(0, file, cd, id)
}
showButton={false}
showPreview={true}
accept={".inp"}
fileIdx={0}
/>
</div>
</StandardFormField>
<StandardFormField
label="수용가-맨홀 매칭결과"
required
columns={2}
className="[&_.card]:!m-0 [&_.card]:!border-0 [&_.card]:!p-0"
>
<div className="flex items-center">
<FileUploadForm
key={`file-upload-1-${formResetKey}`} // 폼 초기화를 위한 key
onFileSelect={(file, cd, id) =>
handleFileSelection(1, file, cd, id)
}
showButton={false}
showPreview={true}
accept={".xls, .xlsx, .xlsm, .xltx"}
fileIdx={1}
/>
</div>
</StandardFormField>
<StandardFormField
label="강우자료"
required
columns={2}
className="[&_.card]:!m-0 [&_.card]:!border-0 [&_.card]:!p-0"
>
<div className="w-full">
<FileUploadForm
key={`file-upload-2-${formResetKey}`} // 폼 초기화를 위한 key
onFileSelect={(file, cd, id) =>
handleFileSelection(2, file, cd, id)
}
showButton={false}
showPreview={true}
accept={".xls, .xlsx, .xlsm, .xltx"}
fileIdx={2}
/>
</div>
</StandardFormField>
<StandardFormField label="오수전환율" required columns={2}>
<Input
value={form.watch("rate")} // form의 rate 값을 가져옴
onChange={(e) => {
const newValue = e.target.value;
if (newValue === "") {
form.setValue("rate", newValue); // form의 rate 필드에 설정
return;
}
// 숫자와 '.'만 허용하고, '.'은 최대 하나인지 확인
const isValidChars = /^[0-9]*\.?[0-9]*$/.test(newValue);
if (!isValidChars) return; // 유효하지 않은 문자 또는 여러 개의 '.' 포함
// '.' 기호가 처음에 오면 안 됨
if (newValue.startsWith(".")) return;
// 완성된 숫자인 경우에만 범위 체크 (입력 중인 경우 허용)
if (!newValue.endsWith(".")) {
const numericValue = parseFloat(newValue);
if (
isNaN(numericValue) ||
numericValue < 0 ||
numericValue > 1
)
return;
}
form.setValue("rate", newValue, { shouldValidate: true }); // form의 rate 필드에 설정
}}
className="w-full"
placeholder="0 ~ 1 사이의 값 입력"
/>
</StandardFormField>
<StandardFormField label="상수사용량 적용월" required columns={2}>
<DateMonthPicker
field={{
value: form.watch("date"), // form의 date 값을 직접 사용
onChange: (value: string) => {
form.setValue("date", value, { shouldValidate: true }); // form의 date 필드에 직접 설정
},
}}
selectClass="w-full"
startYear={1900} // 시작 연도 설정
/>
</StandardFormField>
<StandardFormField label="분석 정보" required columns={2}>
<Input
{...form.register("analysisInfo")}
className="w-full"
placeholder="세부 분석정보 입력"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // Enter 키 기본 동작 방지
handleAnalysisClick();
}
}}
/>
</StandardFormField>
</StandardFormSection>,
]}
</BaseLayout>
</>
);
}