Files
frontend/apps/sn/app/(with-base-layout)/ii/irsetting/page.tsx
T

643 lines
23 KiB
TypeScript

/**
* ➡️ 경로 : I/I 분석 - 침입수 분석
*
* 📄 IrSetting Page
* 침입수 분석 설정 페이지 컴포넌트
*
* - 오수관망도, 수용가-맨홀 매칭결과, 오수량 시간패턴 등 파일 업로드 기능
* - 오수전환율 및 분석 정보 입력 기능
* - 엑셀 파일 데이터 파싱 및 JSON 변환 처리
* - 분석 데이터 서버 제출 및 폼 초기화 기능
* - 파일 확장자 및 입력 값 검증 기능
* - 로딩 오버레이 및 에러 핸들링 지원
*
* 🔗 사용 API
* - 침입수분석 등록 첨부파일INP 포함: POST /api/v1/inftwtAnls/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 {
StandardFormField,
StandardFormSection,
} from "@common/business_components";
import { DateMonthPicker, filePicked } from "@common/business_components/ui";
import { Input } from "@common/components/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
excelToFlowRateData,
excelToMatchingResultData,
excelToTimePatternData,
excelToRcptrInfoData,
} from "@/components/lib/utils";
import BaseLayout from "../BaseLayout";
import { useAnalysisCompletionWatcher } from "../useAnalysisCompletionWatcher";
import { openAnalysisResultMapView } from "../analysisResultView";
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";
const formSchema = z.object({
managenm: z.string(),
region: z.string(),
suyeonga: z.number(),
inputdate: z.coerce.date(),
rtkSource: z.string(),
suyeongaPlace: z.string(),
pattern: z.string(),
});
// 각 파일 업로드 폼의 데이터를 저장할 인터페이스 정의
interface UploadedFileInfo {
file: File | null;
atflCd: string | null;
atflId: string | null;
// 필요하다면 파일 이름 등 추가 정보 저장 가능
// fileName: string;
}
// 초기 파일 정보 상태
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";
const IrSetting = () => {
const alert = useAlertStore((state) => state.setMessage);
const [input1, setInput1] = useState("0.8"); // 오수전환율 초기값
const [input2, setInput2] = useState("");
const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] =
useState<string>("0"); // ?????? ?? ??
const [formResetKey, setFormResetKey] = useState(0); // FileUploadForm 리셋을 위한 key 상태
const [isLoadingResult, setIsLoadingResult] = useState<boolean>(false); // 로딩 상태
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 [uploadedFilesData, setUploadedFilesData] = useState<
UploadedFileInfo[]
>([
initialFileInfo, // 0: 오수관망도
initialFileInfo, // 1: 수용가-맨홀 매칭결과
initialFileInfo, // 2: 오수량 시간패턴
initialFileInfo, // 3: 유량자료 등록
]);
// selectedDate를 yyyyMM 문자열 형식으로 변경
const [selectedDate, setSelectedDate] = useState(() => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
return `${year}${month}`;
});
const { data: userInfo } = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.USERS],
queryFn: () => getUserInfo(),
enabled: true,
});
const { startWatching } = useAnalysisCompletionWatcher({
buildStatusUrl: (analysisSn) => `v1/inftwtAnls/${analysisSn}/status`,
successMessage: "침입수 분석이 완료되었습니다.",
failureMessage: "침입수 분석이 실패했습니다. 로그를 확인해주세요.",
analysisType: "INFTWT",
analysisName: "침입수 분석",
initialProgressPercent: 10,
initialProgressStep: "REQUEST_ACCEPTED",
initialProgressMessage: "요청 접수 완료",
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
managenm: "",
region: "",
suyeonga: 0,
inputdate: new Date(),
rtkSource: "",
suyeongaPlace: "",
pattern: "",
},
});
// 폼 초기화 함수
const resetAllForms = () => {
form.reset({
// react-hook-form 초기화
managenm: "",
region: "",
suyeonga: 0,
inputdate: new Date(),
rtkSource: "",
suyeongaPlace: "",
pattern: "",
});
setUploadedFilesData([
// 파일 업로드 상태 초기화
initialFileInfo,
initialFileInfo,
initialFileInfo,
initialFileInfo,
]);
// selectedDate를 현재 연월로 초기화
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
setSelectedDate(`${year}${month}`);
setInput1(""); // 입력 필드 초기화
setInput2(""); // 입력 필드 초기화
setSelectedSwagePrcsFclt("0");
setCtpvCd(DEFAULT_CTPV_CD);
setSggCd(DEFAULT_SGG_CD);
setSggName(DEFAULT_SGG_NAME);
setSwtrmtFcltNm(DEFAULT_SWTRMT_FCLT_NM);
setFormResetKey((prevKey) => prevKey + 1); // FileUploadForm 재마운트를 위해 key 변경
};
// 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/inftwtAnls/savewithfile", analysisData);
if (!data) {
throw new Error("분석 요청 접수 응답이 비어 있습니다.");
}
const nextAnalysisSn = Number(data?.inftwtAnlsSn ?? 0);
setIsLoadingResult(false);
await new Promise((resolve) => setTimeout(resolve, 0));
alert("분석 데이터가 성공적으로 제출되었습니다.", () => {
if (Number.isFinite(nextAnalysisSn) && nextAnalysisSn > 0) {
startWatching(nextAnalysisSn);
}
resetAllForms();
});
};
// 침입수 분석 실행 버튼 클릭 핸들러
const handleAnalysisClick = async () => {
setIsLoadingResult(true); // 로딩 상태 시작
// 기본 선택 항목들 검증 (환경청, 시도, 시군구, 하수처리장)
const selectionValidation = validateBasicSelections(selectedSwagePrcsFclt, {
requireEnv: true,
requireSido: true,
requireSigungu: true,
requireTreatmentPlant: true,
treatmentPlantLabel: "하수처리시설",
});
if (!selectionValidation.isValid) {
setIsLoadingResult(false);
alert(
selectionValidation.errorMessage ?? "필수 선택 항목을 확인해주세요.",
);
return;
}
const file1 = uploadedFilesData[0]; // 오수관망도
const file2 = uploadedFilesData[1]; // 수용가-맨홀 매칭결과
const file3 = uploadedFilesData[2]; // 오수량 시간패턴
const file4 = uploadedFilesData[3]; // 유량자료
const file5 = uploadedFilesData[4]; // 수용가 상수사용량
// 모든 필수 파일이 선택되었는지 확인 (UploadedFileInfo 객체와 그 안의 file 객체 모두 확인)
// 유량자료는 선택값
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: "오수량 시간패턴",
},
];
// 유량자료 파일이 있는 경우 검증 목록에 추가
if (file4?.file) {
validationConfigs.push({
file: file4.file,
allowedExtensions: FILE_EXTENSIONS.EXCEL,
fileDescription: "유량자료",
});
}
// 수용가 상수사용량 파일이 있는 경우 검증 목록에 추가
if (file5?.file) {
validationConfigs.push({
file: file5.file,
allowedExtensions: FILE_EXTENSIONS.EXCEL,
fileDescription: "수용가 상수사용량",
});
}
// 파일 확장자 검증 실행
const validationResult = validateMultipleFiles(validationConfigs);
if (!validationResult.isValid) {
setIsLoadingResult(false);
alert(validationResult.errorMessage ?? "파일 형식을 확인해주세요.");
return;
}
// 오수전환율 값 0 ~ 1 사이인지 검증
if (input1 === "" || input1 === null || input1 === undefined) {
setIsLoadingResult(false);
console.error("오수전환율은 0에서 1 사이의 값으로 입력해야 합니다.");
alert("오수전환율을 입력해주세요.");
return;
}
const numericInput1 = parseFloat(input1);
if (isNaN(numericInput1) || numericInput1 < 0 || numericInput1 > 1) {
setIsLoadingResult(false);
console.error("오수전환율은 0에서 1 사이의 값으로 입력해야 합니다.");
alert("오수전환율은 0에서 1 사이의 값으로 입력해야 합니다.");
return;
}
type MatchingResult = { anlsSn: number; mhlMngNo: string; rcptrNo: string };
type TimePattern = { hrId: number; rt?: number | null | undefined };
type FlowRate = {
pplMngNo: string;
mtrngDt: string;
oilq?: number | null | undefined;
};
type rcptrInfo = {
rcptrNo: string;
useYm: string;
usqty?: number | null | undefined;
};
const filePickers: {
file: UploadedFileInfo;
parser: (
excelData: any,
) => MatchingResult[] | TimePattern[] | FlowRate[] | rcptrInfo[];
}[] = [
{ file: file2, parser: excelToMatchingResultData },
{ file: file3, parser: excelToTimePatternData },
];
if (file4?.file) {
// 유량자료 파일이 있는 경우
filePickers.push({ file: file4, parser: excelToFlowRateData });
}
if (file5?.file) {
// 수용가 상수사용량 파일이 있는 경우
filePickers.push({ file: file5, parser: excelToRcptrInfoData });
}
try {
const parsedResults = await Promise.all(
filePickers.map(async ({ file, parser }) => {
if (!file || !file.file) return null;
const jsonData = await filePicked(file.file, 0);
if (jsonData === undefined) {
console.warn(
`Warning: filePicked returned undefined for ${file.file.name}`,
);
return {
fileName: file.file.name,
atflCd: file.atflCd,
atflId: file.atflId,
parsedData: null,
};
}
// jsonData를 각 parser 함수로 처리
const parsedDataBySpecificFunction = parser(jsonData);
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 timePatternData =
parsedResults.find((r) => r && r.fileName === file3.file?.name)
?.parsedData ?? [];
const flowRateData = file4?.file
? parsedResults.find((r) => r && r.fileName === file4.file?.name)
?.parsedData
: null;
const rcptrInfoData = file5?.file
? parsedResults.find((r) => r && r.fileName === file5.file?.name)
?.parsedData
: null;
if (!swtrmtFcltNm || !swtrmtFcltNm.trim()) {
setIsLoadingResult(false);
alert("하수처리시설명이 비어 있습니다. 시설을 다시 선택해주세요.");
return;
}
if (
!Array.isArray(matchingResultData) ||
matchingResultData.length === 0
) {
setIsLoadingResult(false);
alert(
"수용가-맨홀 매칭결과 파일에서 저장할 데이터를 읽지 못했습니다. 엑셀 헤더를 확인해주세요.",
);
return;
}
if (!Array.isArray(timePatternData) || timePatternData.length === 0) {
setIsLoadingResult(false);
alert(
"오수량 시간패턴 파일에서 저장할 데이터를 읽지 못했습니다. 엑셀 헤더를 확인해주세요.",
);
return;
}
// 새로운 JSON 데이터 생성
const jsonData = {
ctpvCd: ctpvCd !== "0" ? String(ctpvCd).trim() : null,
sggCd: sggCd !== "0" ? String(sggCd) : null, // 시군구코드 (선택값)
swtrmtFcltNm: swtrmtFcltNm ?? null,
aplcnYm: selectedDate, // 적용연월
drtwSwtcRt: parseFloat(input1) || null, // 오수전환율 (숫자로 변환)
anlsInfo: input2, // 분석정보
rgtr: tokenManager.getToken(AUTH_USER_ID) || null, // 등록자 (사용자 ID)
rgtrNm: userInfo?.userName, // 등록자 (사용자 ID)
//atflCd: file1?.atflCd, // 첨부파일CD (오수관망도)
//atflId: file1?.atflId, // 첨부파일ID (오수관망도)
atflInpNm: file1?.file?.name, // 오수관망도 파일명
atflHrPttnNm: file3?.file?.name, // 오수량 시간패턴 파일명
atflMpngNm: file2?.file?.name, // 수용가-맨홀 매칭결과 파일명
atflOilqNm: file4?.file?.name || null, // 유량자료 파일명 (없을 수 있음)
tbSnRcptrMhlMpng: matchingResultData, // 수용가-맨홀 매칭결과
tbSnHrPttn: timePatternData, // 오수량 시간패턴
tbSnOilqData: flowRateData, // 유량자료 (없을 수 있음)
tbSnRcptrInfo: rcptrInfoData, // 수용가 상수사용량 (없을 수 있음)
};
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("침입수 분석 요청 처리 중 오류 발생:", error);
alert(
error instanceof Error && error.message.trim()
? error.message
: "파일 처리 중 오류가 발생했습니다.",
);
return;
}
};
return (
<>
<LoadingOverlay isLoading={isLoadingResult} />
<BaseLayout
titleName={{
mainTitle: "침입수 분석",
firstCardTitle: "침입수 분석 자료 입력",
}}
form={form}
onSubmitEvent={() => {}}
graphBtn={false}
resultViewBtn={"결과보기"}
analysisBtn={"침입수분석 실행"}
selectBox={{ title: "분석 대상지역 선택" }}
ctpvCd={ctpvCd}
sggCd={sggCd}
sggName={sggName}
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
onSwagePrcsFcltChange={setSelectedSwagePrcsFclt}
setCtpvCd={setCtpvCd}
setSggCd={setSggCd}
setSggName={setSggName}
setSwtrmtFcltNm={setSwtrmtFcltNm}
defaultSwtrmtFcltNm={swtrmtFcltNm}
setIsLoading={setIsLoadingResult}
onResultView={() =>
openAnalysisResultMapView("inftwtResultPopup", alert)
}
onAnalysis={handleAnalysisClick}
isIr={true}
>
{[
<StandardFormSection
key="irsetting-layout-key"
title="분석 조건 및 자료"
columns={2}
>
<StandardFormField label="상수사용량 적용월" required columns={2}>
{/* 연월 선택 라이브러리로 수정 필요 */}
<DateMonthPicker
field={{
value: selectedDate, // yyyyMM 형식
onChange: (value: string) => {
setSelectedDate(value);
},
}}
selectClass="w-full"
startYear={1900} // 시작 연도 설정
/>
</StandardFormField>
<StandardFormField
label="수용가 상수사용량"
columns={2}
className="[&_.card]:!m-0 [&_.card]:!border-0 [&_.card]:!p-0"
>
<FileUploadForm
key={`file-upload-4-${formResetKey}`} // 폼 초기화를 위한 key
onFileSelect={(file, cd, id) =>
handleFileSelection(4, file, cd, id)
}
showButton={false}
showPreview={true}
accept={".xls, .xlsx, .xlsm, .xltx"}
fileIdx={4}
/>
</StandardFormField>
<StandardFormField
label="오수관망도"
required
columns={2}
className="[&_.card]:!m-0 [&_.card]:!border-0 [&_.card]:!p-0"
>
<FileUploadForm
key={`file-upload-0-${formResetKey}`} // 폼 초기화를 위한 key
onFileSelect={(file, cd, id) =>
handleFileSelection(0, file, cd, id)
}
showButton={false}
showPreview={true}
accept={".inp"}
fileIdx={0}
/>
</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"
>
<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}
/>
</StandardFormField>
<StandardFormField label="오수전환율" required columns={2}>
<Input
value={input1}
onChange={(e) => {
const newValue = e.target.value;
if (newValue === "") {
setInput1(newValue);
return;
}
// 숫자와 '.'만 허용하고, '.'은 최대 하나인지 확인
const isValidChars = /^[0-9]*\.?[0-9]*$/.test(newValue);
if (!isValidChars) return; // 유효하지 않은 문자 또는 여러 개의 '.' 포함
// '.' 기호가 포함된 경우, 처음이나 끝에 오면 안 됨
if (newValue.includes(".") && newValue.startsWith("."))
return; // '.'이 처음에는 올 수 없음
// 숫자 값으로 변환하여 범위 체크 (0 이상 1 이하)
const numericValue = parseFloat(newValue);
if (
isNaN(numericValue) ||
numericValue < 0 ||
numericValue > 1
)
return;
setInput1(newValue);
}}
className="w-full"
placeholder="예: 0.75"
/>
</StandardFormField>
<StandardFormField label="분석 정보" required columns={2}>
<Input
value={input2}
onChange={(e) => setInput2(e.target.value)}
className="w-full"
placeholder="세부 분석정보 입력"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // Enter 키 기본 동작 방지
handleAnalysisClick();
}
}}
/>
</StandardFormField>
<StandardFormField
label="유량자료"
columns={2}
className="[&_.card]:!m-0 [&_.card]:!border-0 [&_.card]:!p-0"
>
<FileUploadForm
key={`file-upload-3-${formResetKey}`} // 폼 초기화를 위한 key
onFileSelect={(file, cd, id) =>
handleFileSelection(3, file, cd, id)
}
showButton={false}
showPreview={true}
accept={".xls, .xlsx, .xlsm, .xltx"}
fileIdx={3}
/>
</StandardFormField>
</StandardFormSection>,
]}
</BaseLayout>
</>
);
};
export default IrSetting;