diff --git a/apps/sn/app/components/lib/auth/lib/auth-wrapper.tsx b/apps/sn/app/components/lib/auth/lib/auth-wrapper.tsx index 0b72cc768..5b5e18143 100644 --- a/apps/sn/app/components/lib/auth/lib/auth-wrapper.tsx +++ b/apps/sn/app/components/lib/auth/lib/auth-wrapper.tsx @@ -43,6 +43,9 @@ const AuthWrapper = ({ children }: { children: React.ReactNode }) => { const accessToken = tokenManager.getToken(CLAIM_NAME); const tokenId = tokenManager.getToken(AUTH_USER_ID); + //TODO 삭제 + console.log(accessToken) + const response = await axios.get( `${ENDPOINT.USER_SERVICE.USERS}/info/me`, { diff --git a/apps/sn/app/components/lib/utils/excelToJson.ts b/apps/sn/app/components/lib/utils/excelToJson.ts index c4a3a226d..2ed697139 100644 --- a/apps/sn/app/components/lib/utils/excelToJson.ts +++ b/apps/sn/app/components/lib/utils/excelToJson.ts @@ -1,35 +1,106 @@ export const TEST_UUID= '2d4b151d-1828-41e0-91d9-4cd28ea1cc7e' import { z } from "zod"; -// 수용가-맨홀 매칭결과 (아직 안함) +// "YYYY-MM-DDTHH:MM:SS" 형식으로 포맷팅 하는 함수 +function parseAndFormatDateTimeToISOCompatibleString( + dateInput: string, + timeInputForMDY: string | null, + inputType: 'YYYY-MM-DD_H:MM' | 'M/D/YYYY_H:MM' +): string | null { + let year: number, month: number, day: number, hours: number, minutes: number; + let datePartStr: string; + let timePartStr: string; + + if (inputType === 'YYYY-MM-DD_H:MM') { + const parts = String(dateInput).split(' '); + if (parts.length !== 2) { + console.warn(`Invalid dateTime format for ${inputType} (split space):`, dateInput); + return null; + } + datePartStr = parts[0] ?? ""; // "YYYY-MM-DD" + timePartStr = parts[1] ?? ""; // "H:MM" + + const dateParts = datePartStr.split('-'); + if (dateParts.length !== 3) { + console.warn(`Invalid date format for ${inputType} (datePart):`, datePartStr); + return null; + } + year = parseInt(dateParts[0] ?? '', 10); + month = parseInt(dateParts[1] ?? '', 10); + day = parseInt(dateParts[2] ?? '', 10); + } else if (inputType === 'M/D/YYYY_H:MM') { + datePartStr = String(dateInput); // "M/D/YYYY" + if (!timeInputForMDY) { + console.warn(`Time input is missing for ${inputType}:`, dateInput); + return null; + } + timePartStr = String(timeInputForMDY); // "H:MM" + + const dateParts = datePartStr.split('/'); + if (dateParts.length !== 3) { + console.warn(`Invalid date format for ${inputType} (datePart):`, datePartStr); + return null; + } + // Note: Order for M/D/YYYY is month, day, year + month = parseInt(dateParts[0] ?? '', 10); + day = parseInt(dateParts[1] ?? '', 10); + year = parseInt(dateParts[2] ?? '', 10); + } else { + console.warn("Invalid inputType for date parsing."); + return null; + } + + // 시간 파싱 (H:MM) - 공통 로직 + const timeParts = timePartStr.split(':'); + if (timeParts.length !== 2) { + console.warn(`Invalid time format (timePart):`, timePartStr); + return null; + } + hours = parseInt(timeParts[0] ?? '', 10); + minutes = parseInt(timeParts[1] ?? '', 10); + + if (isNaN(year) || isNaN(month) || isNaN(day) || isNaN(hours) || isNaN(minutes)) { + console.warn("Invalid parsed date or time numeric values:", { year, month, day, hours, minutes }); + return null; + } + + // "YYYY-MM-DDTHH:MM:SS" 형식으로 포맷팅 - 공통 로직 + const formattedYear = String(year).padStart(4, '0'); // 연도는 4자리로 보장 (M/D/YYYY의 경우) + const formattedMonth = String(month).padStart(2, '0'); + const formattedDay = String(day).padStart(2, '0'); + const formattedHours = String(hours).padStart(2, '0'); + const formattedMinutes = String(minutes).padStart(2, '0'); + // 초는 00으로 고정, 'T'를 사용하여 Date 생성자 호환성 확보 + return `${formattedYear}-${formattedMonth}-${formattedDay} ${formattedHours}:${formattedMinutes}:00`; +} + + +// 수용가-맨홀 매칭결과 (수정 필요) export const MatchingResultZod = z.object({ - anls_sn: z.number(), - mhl_mng_no: z.string(), // 일련번호 0 포함인지? number? string? - rcptr_no: z.string(), + anls_sn: z.number(), // 분석 일련번호 + mhl_mng_no: z.string(), // 맨홀 번호 (임시값이라 추후 수정 필요) + rcptr_no: z.string(), // 수용가 번호 }); - export type MatchingResultZodType = z.infer; - export const excelToMatchingResultData = (excelData: any) => { const MatchingResultData: MatchingResultZodType[] = excelData.map((item: any) => { return { - anls_sn: Number(item["NO"]), - mhl_mng_no: String(item["FTR_IDN"]), // 임시값이라 추후 수정 필요 + anls_sn: Number(item["NO"]), + mhl_mng_no: String(item["FTR_IDN"]), // (임시값이라 추후 수정 필요) rcptr_no: String(item["수용가"]), }; }).filter((el: any) => el !== undefined); return MatchingResultData; }; + // 시간패턴 export const timePatternZod = z.object({ - hr_id: z.number(), - rt: z.number().nullish(), + hr_id: z.number(), // 시간 + rt: z.number().nullish(), // 비율 }); - export type timePatternZodType = z.infer; - export const excelToTimePatternData = (excelData: any) => { const timePatternData: timePatternZodType[] = excelData.map((item: any) => { @@ -41,23 +112,59 @@ export const excelToTimePatternData = (excelData: any) => { return timePatternData; }; + // 유량자료 export const flowRateZod = z.object({ - ppl_mng_no: z.string(), - mtrng_dt: z.number(), // type 뭘로 해야할지 Date? timeStamp임 - oilq: z.number().nullish(), + ppl_mng_no: z.string(), // 관로번호 + mtrng_dt: z.string(), // 시간 + oilq: z.number().nullish(), // 유량(㎥/h) }); - export type flowRateZodType = z.infer; - -export const excelToflowRateData = (excelData: any) => { +export const excelToFlowRateData = (excelData: any) => { const flowRateData: flowRateZodType[] = excelData.map((item: any) => { + const dateTimeStr = String(item["시간"]); // "YYYY-MM-DD H:MM" 형식 (예: "2025-02-23 8:50") + + const formattedDateTime = parseAndFormatDateTimeToISOCompatibleString(dateTimeStr, null, 'YYYY-MM-DD_H:MM'); + + if (!formattedDateTime) return undefined; + + const dateObject = new Date(formattedDateTime); + const mtrngDtValue = !isNaN(dateObject.getTime()) ? dateObject.toISOString() : null; + + if (!mtrngDtValue) { + console.warn("Failed to create Date object for flowRateData:", formattedDateTime); + return undefined; + } return { ppl_mng_no: String(item["관로번호"]), - mtrng_dt: new Date(item["시간"]).toISOString(), // type 뭘로 해야할지 Date? timeStamp임 + mtrng_dt: mtrngDtValue, oilq: Number(item["유량(㎥/h)"]), }; }).filter((el: any) => el !== undefined); return flowRateData; +}; + + +// 강우자료 (수정 필요) +export const rainZod = z.object({ + mtrng_dt: z.string(), // 날짜 + 시간 + amrf: z.number().nullish(), // 강우량(mm) +}); +export type rainZodType = z.infer; +export const excelToRainData = (excelData: any) => { + const rainData: rainZodType[] = excelData.map((item: any) => { + const datePartStr = String(item["날짜"]); // "M/D/YYYY" 형식 (예: "5/9/2021") + const timePartStr = String(item["시간"]); // "H:MM" 형식 (예: "8:00") + + const formattedForRainZod = parseAndFormatDateTimeToISOCompatibleString(datePartStr, timePartStr, 'M/D/YYYY_H:MM'); + + if (!formattedForRainZod) return undefined; + + return { + mtrng_dt: formattedForRainZod, + amrf: Number(item["강우량(mm)"]), + }; + }).filter((el: any) => el !== undefined); + return rainData; }; \ No newline at end of file diff --git a/apps/sn/app/components/ui/EditableFormTable.tsx b/apps/sn/app/components/ui/EditableFormTable.tsx new file mode 100644 index 000000000..89fd8f005 --- /dev/null +++ b/apps/sn/app/components/ui/EditableFormTable.tsx @@ -0,0 +1,131 @@ +import { Input, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components/ui"; +import type { UseFormReturn, FieldArrayWithId, Path, FieldValues } from "react-hook-form"; +import { Controller } from "react-hook-form"; + +// 편집 가능한 테이블 헤더 항목 인터페이스 +export interface EditableTableHeadItem { + id: keyof TItem | 'response'; // 'response'는 직접적인 키가 아닐 수 있지만 특별히 처리될 수 있음 + title: string; + editable?: boolean; // 편집 가능 여부 + width?: string; // 컬럼 너비 추가 + align?: 'left' | 'center' | 'right'; // 정렬 + cellClassName?: string; // 셀 클래스 이름 + inputClassName?: string; // 입력 필드 클래스 이름 + inputProps?: React.ComponentProps; // Input 컴포넌트에 전달할 props (예: type="number") +} + +// EditableFormTable 컴포넌트 props 타입 정의 +type EditableFormTableProps< + TFormValues extends FieldValues, + TFieldArrayName extends Path, + TFieldArrayItem extends FieldValues = TFormValues[TFieldArrayName] extends (infer U)[] ? U extends FieldValues ? U : never : never +> = { + form: UseFormReturn; // react-hook-form의 form 객체 + name: TFieldArrayName; // 필드 배열 이름 (예: "unitFactors") + fields: FieldArrayWithId[]; // useFieldArray에서 반환된 필드 배열 + headlist: EditableTableHeadItem[]; // 테이블 헤더 설정 + tableClassName?: string; // 테이블 클래스 이름 + headerClassName?: string; // 헤더 클래스 이름 + bodyClassName?: string; // 바디 클래스 이름 + rowClassName?: string; // 행 클래스 이름 +}; + +// EditableFormTable 컴포넌트 +export function EditableFormTable< + TFormValues extends FieldValues, + TFieldArrayName extends Path, + TFieldArrayItem extends FieldValues = TFormValues[TFieldArrayName] extends (infer U)[] ? U extends FieldValues ? U : never : never +>({ + form, + name, + fields, + headlist, + tableClassName = "w-full", + headerClassName = "font-bold", + bodyClassName = 'text-center', + rowClassName = "", +}: EditableFormTableProps) { + return ( +
+ + + + {headlist.map((head) => ( + + {head.title} + + ))} + + + + {fields.map((rowItem, rowIndex) => ( + + {headlist.map((head) => { + const fieldName = `${name}.${rowIndex}.${String(head.id)}` as Path; + const cellValue = (rowItem as any)[head.id as string]; + + return ( + + {head.editable ? ( + { + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + + if (newValue === "") { + field.onChange(""); // 빈 문자열 허용 + return; + } + + // 숫자와 '.'만 허용 (단일 '.' 허용) + const isValidChars = /^[0-9]*\.?[0-9]*$/.test(newValue); + if (!isValidChars) { + return; // 유효하지 않은 문자 또는 여러 개의 '.' 포함 시 변경 무시 + } + + // '.' 기호가 포함된 경우, 처음에는 올 수 없음 (예: '.5' 방지, '0.5' 유도) + // 이 조건은 사용자의 요구사항에 따라 조정될 수 있습니다. + // 현재 로직은 '.5'와 같은 입력을 허용하지 않습니다. + if (newValue.includes('.') && newValue.startsWith('.')) { + // 만약 '.'만 입력된 경우 허용하려면 아래 주석 해제 + // if (newValue === '.') { + // field.onChange(newValue); + // return; + // } + return; + } + + field.onChange(newValue); + }; + + return ( + + ); + }} + /> + ) : ( + {String(cellValue ?? '')} + )} + + ); + })} + + ))} + +
+
+ ); +} \ No newline at end of file diff --git a/apps/sn/app/components/ui/SidoSigunguSelectBox.tsx b/apps/sn/app/components/ui/SidoSigunguSelectBox.tsx index cf70f7e22..d1b907757 100644 --- a/apps/sn/app/components/ui/SidoSigunguSelectBox.tsx +++ b/apps/sn/app/components/ui/SidoSigunguSelectBox.tsx @@ -15,7 +15,7 @@ interface SidoSigunguSelectBoxProps { } const sidoList: { id: number, name: string }[] = [ - { id: 0, name: '선택' }, + { id: 0, name: '전체' }, { id: 26, name: '부산광역시' }, { id: 28, name: '인천광역시' }, { id: 30, name: '대전광역시' }, @@ -29,19 +29,19 @@ const sidoList: { id: number, name: string }[] = [ ]; const sigunguMap: Record = { 26: [ - { id: 0, name: '선택' }, + { id: 0, name: '전체' }, // ...부산산광역시의 시군구 추가 ], 28: [ - { id: 0, name: '선택' }, + { id: 0, name: '전체' }, // ...인천광역시의 시군구 추가 ], 30: [ - { id: 0, name: '선택' }, + { id: 0, name: '전체' }, // ...대전광역시의 시군구 추가 ], 41: [ - { id: 0, name: '선택' }, + { id: 0, name: '전체' }, { id: 41820, name: '가평군' }, { id: 41550, name: '안성시' }, { id: 41830, name: '양평군' }, @@ -54,19 +54,19 @@ const sigunguMap: Record = { // ...경기도의 시군구 추가 ], 42: [ - { id: 0, name: '선택' }, + { id: 0, name: '전체' }, { id: 42170, name: '동해시' }, { id: 42110, name: '춘천시' }, { id: 42720, name: '홍천군' }, // ...강원도의 시군구 추가 ], 44: [ - { id: 0, name: '선택' }, + { id: 0, name: '전체' }, { id: 44770, name: '서천군' }, // ...충청남도의 시군구 추가 ], 45: [ - { id: 0, name: '선택' }, + { id: 0, name: '전체' }, { id: 45130, name: '군산시' }, { id: 45190, name: '남원시' }, { id: 45730, name: '무주군' }, @@ -79,7 +79,7 @@ const sigunguMap: Record = { // ...전라북도의 시군구 추가 ], 46: [ - { id: 0, name: '선택' }, + { id: 0, name: '전체' }, { id: 46810, name: "강진군" }, { id: 46770, name: "고흥군" }, { id: 46720, name: "곡성군" }, @@ -95,7 +95,7 @@ const sigunguMap: Record = { // ...전라남도의 시군구 추가 ], 47: [ - { id: 0, name: '선택' }, + { id: 0, name: '전체' }, { id: 47130, name: "경주시" }, { id: 47830, name: "고령군" }, { id: 47190, name: "구미시" }, @@ -116,7 +116,7 @@ const sigunguMap: Record = { // ...경상북도의 시군구 추가 ], 48: [ - { id: 0, name: '선택' }, + { id: 0, name: '전체' }, { id: 48310, name: "거제시" }, { id: 48250, name: "김해시" }, { id: 48270, name: "밀양시" }, @@ -134,7 +134,7 @@ export function SidoSigunguSelectBox({ className = "", }: SidoSigunguSelectBoxProps) { // 선택된 시도에 따라 시군구 리스트를 가져옴 - const sigunguList = useMemo(() => sigunguMap[sido] || [{ id: 0, name: '선택' }], [sido]); + const sigunguList = useMemo(() => sigunguMap[sido] || [{ id: 0, name: '전체' }], [sido]); return (
@@ -150,7 +150,7 @@ export function SidoSigunguSelectBox({ className="w-[150px] mr-8" selectClass="rounded-lg bg-white" label="시군구" - selectArray={sidoList.map(opt => ({ val: opt.id, text: opt.name }))} + selectArray={sigunguList.map(opt => ({ val: opt.id, text: opt.name }))} value={sigungu} onSelectValue={val => onSigunguChange(Number(val))} /> diff --git a/apps/sn/app/components/ui/formSection.tsx b/apps/sn/app/components/ui/formSection.tsx index 28e4618f7..90bc8f735 100644 --- a/apps/sn/app/components/ui/formSection.tsx +++ b/apps/sn/app/components/ui/formSection.tsx @@ -10,7 +10,7 @@ interface FormSectionProps { const FormSection = ({ children, titleNm, form, name }: FormSectionProps) => { return ( -
+
{titleNm}
{ }} selectClass='' />
- {searchBox?.searchChk ? : null} + {searchBox?.searchChk ? : null}

{titleName.firstCardTitle}

- {graphBtn ? : null} + {graphBtn ? : null} {/* 그래프 있을 시 */} - {graphBtn ? : null} + {graphBtn ? : null}
@@ -91,9 +91,9 @@ const BaseLayout = ({ children, titleName, form, onSubmitEvent, graphBtn = false

{titleName?.secondCardTitle}

- {graphBtn ? : null} + {graphBtn ? : null} {/* 그래프 있을 시 */} - {graphBtn ? : null} + {graphBtn ? : null}
{children?.[1]}
diff --git a/apps/sn/app/ii/irsetting/page.tsx b/apps/sn/app/ii/irsetting/page.tsx index a299e9337..4238fe8e6 100644 --- a/apps/sn/app/ii/irsetting/page.tsx +++ b/apps/sn/app/ii/irsetting/page.tsx @@ -8,7 +8,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useState } from 'react' import { useForm } from 'react-hook-form' import { z } from 'zod' -import { excelToflowRateData, excelToMatchingResultData, excelToTimePatternData } from '../../components/lib/utils/excelToJson' +import { excelToFlowRateData, excelToMatchingResultData, excelToTimePatternData } from '../../components/lib/utils/excelToJson' import BaseLayout from '../BaseLayout' const formSchema = z.object({ @@ -95,11 +95,6 @@ const IrSetting = () => { setSelect4(''); setSelect5(''); setFormResetKey(prevKey => prevKey + 1); // FileUploadForm 재마운트를 위해 key 변경 - // FileUploadForm 내부의 파일명 표시 등을 초기화하려면 - // FileUploadForm 컴포넌트 자체에 reset 로직 또는 key 변경을 통한 재마운트 전략이 필요할 수 있습니다. - // 현재 구조에서는 FileUploadForm의 내부 상태를 직접 초기화하기 어렵습니다. - // 가장 간단한 방법은 FileUploadForm에 key prop을 주고, 초기화 시 key를 변경하여 강제로 재렌더링하는 것입니다. - // 예: setFormResetKey(prev => prev + 1) }; // FileUploadForm의 onFileSelect prop으로 전달될 콜백 함수 @@ -135,7 +130,7 @@ const IrSetting = () => { } }; - // 버튼 클릭 핸들러 + // 침입수 분석 실행 버튼 클릭 핸들러 const handleAnalysisClick = async () => { console.log(uploadedFilesData); const file1 = uploadedFilesData[0]; // 오수관망도 @@ -160,7 +155,7 @@ const IrSetting = () => { // 여기서는 File 객체를 받고, 내부적으로 ArrayBuffer로 변환하여 parser에 전달한다고 가정 { file: file2, parser: excelToMatchingResultData }, { file: file3, parser: excelToTimePatternData }, - { file: file4, parser: excelToflowRateData } + { file: file4, parser: excelToFlowRateData } // 오수관망도 파일은 파싱 대상이 아닐 수 있으므로, 필요에 따라 추가/제외 ]; @@ -207,11 +202,15 @@ const IrSetting = () => { sggCd: select3, // 시군구코드 swageTmplntCd: select4, // 하수처리장코드 swtrmtZoneCd: select5, // 하수처리구역코드 - aplcnYmd: selectedDate.toISOString(), // 적용연월 - dtrwSwtcRt: input1, // 오수전환율 + aplcnYmd: selectedDate.toISOString().substring(0, 7).replace("-", ""), // 적용연월 + dtrwSwtcRt: parseFloat(input1) || null, // 오수전환율 (숫자로 변환) anlsInfo: input2, // 분석정보 - atflCd: file1?.atflCd, // 첨부파일CD - atflId: file1?.atflId, // 첨부파일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, // 유량자료 파일명 tbSnRcptrMhlMpng: matchingResultData, // 수용가-맨홀 매칭결과 tbSnHrPttn: timePatternData, // 오수량 시간패턴 tbSnOilqData: flowRateData, // 유량자료 @@ -255,7 +254,6 @@ const IrSetting = () => { showPreview={true} accept={".xls, .xlsx, .xlsm, .xltx"} fileIdx={1} /> - {/* */} { showPreview={true} accept={".xls, .xlsx, .xlsm, .xltx"} fileIdx={2} /> - {/* */} { showPreview={true} accept={".xls, .xlsx, .xlsm, .xltx"} fileIdx={3} /> - {/* */}
diff --git a/apps/sn/app/ii/rdii/page.tsx b/apps/sn/app/ii/rdii/page.tsx index 94cb94f40..b51219f71 100644 --- a/apps/sn/app/ii/rdii/page.tsx +++ b/apps/sn/app/ii/rdii/page.tsx @@ -1,57 +1,266 @@ "use client" -import { date, z } from "zod" -import { useForm, useFieldArray } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Input, Button, DatePicker } from "@common/components/ui" -import { UiTable } from "@common/business_components/ui" -import React, { useState } from "react" -import BaseLayout from "@/ii/BaseLayout" +import { postData } from '@/components/lib/api' +import FileUploadForm from "@/components/ui/fileUploadForm" import FormSection from "@/components/ui/formSection" +import BaseLayout from "@/ii/BaseLayout" +import { filePicked } from "@common/business_components/ui" +import { Button, DatePicker, Input } from "@common/components/ui" +import { zodResolver } from "@hookform/resolvers/zod" +import { Label } from '@radix-ui/react-label' +import { useState } from "react" +import { useFieldArray, useForm } from "react-hook-form" +import { z } from "zod" +import { excelToMatchingResultData, excelToRainData } from '../../components/lib/utils/excelToJson' +import { EditableFormTable, type EditableTableHeadItem } from '../../components/ui/EditableFormTable' + -// TODO: 컴포넌트 호출 ~ 필요 코드 수정해야댐 // zod 스키마 정의 const formSchema = z.object({ - region: z.string(), - rdiiArea: z.string(), - rtkSource: z.string(), - unitFactors: z.array(z.object({ + enoCd: z.string(), // 환경청코드 + ctpvCd: z.string(), // 시도코드 + sggCd: z.string(), // 시군구코드 + swageTmplntCd: z.string(), // 하수처리장코드 + swtrmtZoneCd: 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({ + initialDepths: z.array(z.object({ // 초기 차단 깊이 response: z.string(), Dmax: z.string(), Drec: z.string(), Do: z.string(), })), - suyeonga: z.string(), - rate: z.string(), - date: z.coerce.date(), - rain: 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.date(), + // date: z.coerce.date(), }) +// zod 스키마 타입 추론 +type FormSchemaType = z.infer; +// 기본 폼 값 정의 +const defaultFormValues: FormSchemaType = { + enoCd: "", + ctpvCd: "", + sggCd: "", + swageTmplntCd: "", + swtrmtZoneCd: "", + rtkSource: "", + rdiiArea: "", // 문자열로 기본값 설정 + unitFactors: [ + { response: "Short-Term", R: "", T: "", K: "" }, // 문자열로 기본값 설정 + { response: "Medium-Term", R: "", T: "", K: "" }, + { response: "Long-Term", R: "", T: "", K: "" }, + ], + initialDepths: [ + { response: "Short-Term", Dmax: "", Drec: "", Do: "" }, // 문자열로 기본값 설정 + { response: "Medium-Term", Dmax: "", Drec: "", Do: "" }, + { response: "Long-Term", Dmax: "", Drec: "", Do: "" }, + ], + // osumap: undefined, + // matchingResultFile: undefined, + aplcnYm: "", + rate: "", // 문자열로 기본값 설정 + // rainFile: undefined, + analysisInfo: "", + date: new Date(), +}; + +// 각 파일 업로드 폼의 데이터를 저장할 인터페이스 정의 +interface UploadedFileInfo { + file: File | null; + atflCd: string | null; + atflId: string | null; +} + +// 초기 파일 정보 상태 +const initialFileInfo: UploadedFileInfo = { file: null, atflCd: null, atflId: null }; export default function RTKForm() { - const form = useForm>({ + const [formResetKey, setFormResetKey] = useState(0); // FileUploadForm 리셋을 위한 key 상태 + + // FileUploadForm에서 전달받은 파일 정보를 저장할 상태 배열 + // 각 인덱스는 FileUploadForm의 fileIdx와 매칭됨 + const [uploadedFilesData, setUploadedFilesData] = useState([ + initialFileInfo, // 0: 오수관망도 + initialFileInfo, // 1: 수용가-맨홀 매칭결과 + initialFileInfo, // 2: 강우자료 + ]); + + // form 인스턴스 생성 + const form = useForm({ resolver: zodResolver(formSchema), - defaultValues: { - region: "", - rdiiArea: "", - rtkSource: "", - unitFactors: [ - { response: "Short-Term", R: "", T: "", K: "" }, - { response: "Medium-Term", R: "", T: "", K: "" }, - { response: "Long-Term", R: "", T: "", K: "" }, - ], - initialDepths: [ - { response: "Short-Term", Dmax: "", Drec: "", Do: "" }, - { response: "Medium-Term", Dmax: "", Drec: "", Do: "" }, - { response: "Long-Term", Dmax: "", Drec: "", Do: "" }, - ], + defaultValues: defaultFormValues + }); + + // 폼 초기화 함수 + const resetAllForms = () => { + form.reset(defaultFormValues); // 폼 값 초기화 + setUploadedFilesData([ // 파일 업로드 상태 초기화 (3개 파일) + initialFileInfo, + initialFileInfo, + initialFileInfo, + ]); + 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; + }); + console.log(`Page: File data received for form index ${index}`, { fileName: selectedFile?.name, cd, id }); + }; + + const registFile = async (analysisData: any) => { + console.log("Submitting combined analysis data to backend"); + // 추후 API 엔드포인트에 맞게 URL 수정 필요 + const data = await postData('v1/rdiiAnls', analysisData); + if (data.ok) { + alert("분석 데이터가 성공적으로 제출되었습니다."); + resetAllForms(); // 성공 시 폼 초기화 함수 호출 + } else { + alert("분석 데이터 제출 중 오류가 발생했습니다."); + // resetAllForms(); } - }) + }; + + // 침입수 분석 실행 버튼 클릭 핸들러 + const handleAnalysisClick = async () => { + const formData = form.getValues(); + console.log(uploadedFilesData); + const file1 = uploadedFilesData[0]; // 오수관망도 + const file2 = uploadedFilesData[1]; // 수용가-맨홀 매칭결과 + const file3 = uploadedFilesData[2]; // 강우자료 + + console.log("오수관망도 정보:", file1?.file); + console.log("수용가-맨홀 매칭결과 정보:", file2); + console.log("강우자료:", file3); + + // 모든 필수 파일이 선택되었는지 확인 + if (!file1?.file || !file2?.file || !file3?.file) { + alert('모든 분석용 파일을 선택해주세요.'); + 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 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 함수로 처리 + // parser 함수가 filePicked가 반환하는 JSON 형식을 직접 처리한다고 가정 + 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 rainData = parsedResults.find(r => r && r.fileName === file3.file?.name)?.parsedData; + + // 새로운 JSON 데이터 생성 + const combinedAnalysisData = { + enoCd: "", // 환경청코드 + aplcnYm: formData.date.toISOString().substring(0, 7).replace("-", ""), // 적용연월 (YYYYMM 형식) + ctpvCd: "", // 시도코드 + sggCd: "", // 시군구코드 + swageTmplntCd: "", // 하수처리장코드 + swtrmtZoneCd: "", // 하수처리구역코드 + atflCd: file1?.atflCd, // 첨부파일CD (오수관망도) + atflId: file1?.atflId, // 첨부파일ID (오수관망도) + atflInpNm: file1?.file?.name || "", // 오수관망도 파일명 + atflMpngNm: file2?.file?.name || "", // 수용가-맨홀 매칭결과 파일명 + atflRainNm: file3?.file?.name || "", // 강우자료 파일명 + cntbArea: parseFloat("rdiiArea") || null, // RDII 기여면적(ha) + srcNm: "", // 출처이름 (RTK계수 출처) + + shortPrdR: parseFloat(formData.unitFactors[0]?.R !== undefined ? formData.unitFactors[0]?.R : "0") || null, // 단기간R + shortPrdT: parseFloat(formData.unitFactors[0]?.T !== undefined ? formData.unitFactors[0]?.T : "0") || null, // 단기간T + shortPrdK: parseFloat(formData.unitFactors[0]?.K !== undefined ? formData.unitFactors[0]?.K : "0") || null, // 단기간K + + mdPrdR: parseFloat(formData.unitFactors[1]?.R !== undefined ? formData.unitFactors[1]?.R : "0") || null, // 중간기간R + mdPrdT: parseFloat(formData.unitFactors[1]?.T !== undefined ? formData.unitFactors[1]?.T : "0") || null, // 중간기간T + mdPrdK: parseFloat(formData.unitFactors[1]?.K !== undefined ? formData.unitFactors[1]?.K : "0") || null, // 중간기간K + + longPrdR: parseFloat(formData.unitFactors[2]?.R !== undefined ? formData.unitFactors[2]?.R : "0") || null, // 장기간R + longPrdT: parseFloat(formData.unitFactors[2]?.T !== undefined ? formData.unitFactors[2]?.T : "0") || null, // 장기간T + longPrdK: parseFloat(formData.unitFactors[2]?.K !== undefined ? formData.unitFactors[2]?.K : "0") || null, // 장기간K + + shortPrdMaxDpth: parseFloat(formData.initialDepths[0]?.Dmax !== undefined ? formData.initialDepths[0]?.Dmax : "0") || null, // 단기간최대깊이 + shortPrdRcvryRt: parseFloat(formData.initialDepths[0]?.Drec !== undefined ? formData.initialDepths[0]?.Drec : "0") || null, // 단기간회복비율 + shortPrdBgngDpth: parseFloat(formData.initialDepths[0]?.Do !== undefined ? formData.initialDepths[0]?.Do : "0") || null, // 단기간시작깊이 + + mdPrdMaxDpth: parseFloat(formData.initialDepths[1]?.Dmax !== undefined ? formData.initialDepths[1]?.Dmax : "0") || null, // 중간기간최대깊이 + mdPrdRcvryRt: parseFloat(formData.initialDepths[1]?.Drec !== undefined ? formData.initialDepths[1]?.Drec : "0") || null, // 중간기간회복비율 + mdPrdBgngDpth: parseFloat(formData.initialDepths[1]?.Do !== undefined ? formData.initialDepths[1]?.Do : "0") || null, // 중간기간시작깊이 + + longTermMaxDpth: parseFloat(formData.initialDepths[2]?.Dmax !== undefined ? formData.initialDepths[2]?.Dmax : "0") || null, // 장기간최대깊이 + longTermRcvryRt: parseFloat(formData.initialDepths[2]?.Drec !== undefined ? formData.initialDepths[2]?.Drec : "0") || null, // 장기간회복비율 + longTermBgngDpth: parseFloat(formData.initialDepths[2]?.Do !== undefined ? formData.initialDepths[2]?.Do : "0") || null, // 장기간시작깊이 + + dtrwSwtcRt: parseFloat(formData.rate !== undefined ? formData.rate : "0") || null, // 오수전환율 + anlsInfo: "", // 분석정보 + + tbSnRcptrMhlMpng: matchingResultData, // 수용가-맨홀 매칭결과 + tbSnRainData: rainData, // 강우자료 + }; + + console.log("통합 분석 데이터 (JSON):", combinedAnalysisData); + + // 수정된 registFile 함수를 호출하여 combinedAnalysisData를 전송합니다. + await registFile(combinedAnalysisData); + + } catch (error) { + console.error("파일 파싱 중 오류 발생:", error); + alert("파일 처리 중 오류가 발생했습니다."); + return; + } + } const { fields: unitFactorFields } = useFieldArray({ control: form.control, @@ -67,88 +276,108 @@ export default function RTKForm() { console.log("✅ 제출 데이터:", data) } - // 단위도 계수 테이블 설정 - const unitFactorHeadlist = [ - { id: 'response', title: 'Response' }, - { id: 'R', title: 'R' }, - { id: 'T', title: 'T' }, - { id: 'K', title: 'K' } - ] + // 단위도 계수 테이블 설정 (EditableTableHeadItem 타입 사용 및 id 수정) + const unitFactorHeadlist: EditableTableHeadItem[] = [ + { 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[] = [ + { 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' } } + ]; - // 초기 차단 깊이 테이블 설정 - const initialDepthHeadlist = [ - { id: 'response', title: 'Response' }, - { id: 'Dmax', title: 'Dmax' }, - { id: 'Drec', title: 'Drec' }, - { id: 'Do', title: 'Do' } - ] // 테이블 페이지 변경 핸들러 const handlePageChange = (page: number) => { console.log('Page changed:', page) } - const [selectedDate, setSelectedDate] = useState(new Date()) + return ( { }} graphBtn={false} > - {[
+ {[
+
+ + + + + +
- -
-
-
○ 단위도 계수
- ○ 단위도 계수 + - {null} - + tableClassName="w-[400px]" + />
-
○ 초기 차단 깊이
- ○ 초기 차단 깊이 + - {null} - + tableClassName="w-[400px]" + />
-
- +
+ handleFileSelection(0, file, cd, id)} + showButton={false} + showPreview={true} + accept={""} + fileIdx={0} />
-
- +
+ handleFileSelection(1, file, cd, id)} + showButton={false} + showPreview={true} + accept={".xls, .xlsx, .xlsm, .xltx"} + fileIdx={1} />
- - { if (date) setSelectedDate(date) }} mode={"single"} /> +
+ handleFileSelection(2, file, cd, id)} + showButton={false} + showPreview={true} + accept={".xls, .xlsx, .xlsm, .xltx"} + fileIdx={2} /> +
+ + + + + { + if (date) { + form.setValue("date", date, { shouldValidate: true }); + } + }} + mode={"single"} + className="ml-5" + /> + + + - -
- -
-
- +
]} diff --git a/apps/sn/app/map/SearchModal.tsx b/apps/sn/app/map/SearchModal.tsx index e6ea49c69..5e4449b3c 100644 --- a/apps/sn/app/map/SearchModal.tsx +++ b/apps/sn/app/map/SearchModal.tsx @@ -372,6 +372,7 @@ const SearchModal: React.FC = ({ open, onOpenChange, onFeature console.log('필터링된 API 결과(데이터):', res.data.data); setTableData(res.data.data || []); setTableCount(res.data.count || []); + console.log('tableCount 상태 업데이트 후:', res.data.count); // tableCount 로그 추가 setShowTable(true); }) .catch(err => { @@ -574,7 +575,10 @@ const SearchModal: React.FC = ({ open, onOpenChange, onFeature { + set시도(sidoId); + set시군구(0); + }} onSigunguChange={set시군구} /> @@ -811,7 +815,7 @@ const SearchModal: React.FC = ({ open, onOpenChange, onFeature
({ val: w.val, text: w.text }))} @@ -856,7 +860,10 @@ const SearchModal: React.FC = ({ open, onOpenChange, onFeature { + set시도(sidoId); + set시군구(0); + }} onSigunguChange={set시군구} />
@@ -975,7 +982,7 @@ const SearchModal: React.FC = ({ open, onOpenChange, onFeature z-[101] `} body={renderBody()} - headerTitle="검색" + // headerTitle="검색" // 필요시 footerContent 등 추가 /> ); diff --git a/apps/sn/public/favicon.ico b/apps/sn/public/favicon.ico new file mode 100644 index 000000000..d304b6981 Binary files /dev/null and b/apps/sn/public/favicon.ico differ diff --git a/packages/common/business_components/src/ui/map/lib/gis.core.js b/packages/common/business_components/src/ui/map/lib/gis.core.js index 4085e2907..1869b766a 100644 --- a/packages/common/business_components/src/ui/map/lib/gis.core.js +++ b/packages/common/business_components/src/ui/map/lib/gis.core.js @@ -14,6 +14,9 @@ import CircleStyle from 'ol/style/Circle'; import VectorLayer from 'ol/layer/Vector'; import { Point } from 'ol/geom'; import { boundingExtent } from 'ol/extent'; +import TileWMTS from 'ol/source/WMTS'; +import WMTS from 'ol/tilegrid/WMTS'; +import { get as getProjection } from 'ol/proj'; import olSourceImageWMS from 'ol/source/ImageWMS'; import { GeoJSON } from 'ol/format'; import GisOlEditLayer from './gis.edit'; @@ -32,20 +35,26 @@ GisApp.GEOSERVER_URL = '/geoserver/hasudo/ows'; GisApp.GEOSERVER_WMS_URL = '/geoserver/hasudo/wms'; //맵 정보 -GisApp.EMAP_URL = '/emap/'; -GisApp.EMAP_SATELLITE_URL = '/emap/satellite/'; -GisApp.VWORLD_URL = 'https://xdworld.vworld.kr/2d/Base/service/{z}/{x}/{y}.png'; -GisApp.VWORLD_SATELLITE_URL = 'https://xdworld.vworld.kr/2d/Satellite/service/{z}/{x}/{y}.jpeg'; GisApp.GOOGLE_API_KEY = 'AIzaSyCf1sgeELFalfPuqlykRKBMj6X9u_DQH6M'; GisApp.GOOGLE_ROADMAP_URL = `https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}&key=${GisApp.GOOGLE_API_KEY}`; GisApp.GOOGLE_SATELLITE_URL = `https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}&key=${GisApp.GOOGLE_API_KEY}`; GisApp.GOOGLE_HYBRID_URL = `https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}&key=${GisApp.GOOGLE_API_KEY}`; GisApp.GOOGLE_3D_URL = `https://tile.googleapis.com/v1/3dtiles/root.json?key=${GisApp.GOOGLE_API_KEY}`; + +// TODO: NGII 정보 +GisApp.NGII_API_KEY = '305CC242F2920F46BB93BCC13A52DD8A'; // NGII에서 발급받은 API 키 +GisApp.NGII_WMTS_URL = `http://map.ngii.go.kr/openapi/wmts_ngiiMap.js?apikey=${GisApp.NGII_API_KEY}`; + +GisApp.EMAP_URL = '/emap/'; +GisApp.EMAP_SATELLITE_URL = '/emap/satellite/'; +GisApp.VWORLD_URL = 'https://xdworld.vworld.kr/2d/Base/service/{z}/{x}/{y}.png'; +GisApp.VWORLD_SATELLITE_URL = 'https://xdworld.vworld.kr/2d/Satellite/service/{z}/{x}/{y}.jpeg'; GisApp.OPENSTREET_URL = '//tile.openstreetmap.org/{z}/{x}/{y}.png'; GisApp.BASE_MAP_TYPES = []; GisApp.FROM_PROJ = 'EPSG:4326'; -GisApp.TO_PROJ = 'EPSG:3857'; +// GisApp.TO_PROJ = 'EPSG:3857'; +GisApp.TO_PROJ = 'EPSG:5179'; //ngii 좌표계 GisApp.TO_DATA_PROJ = 'EPSG:4326'; GisApp.CONTEXT_LAYER_PATH = '/images/map/layericon/'; GisApp.layerCode = {}; @@ -86,6 +95,7 @@ GisApp.core.prototype = { minZoom: 5, maxZoom: 22, }, + options: { /*1: { zoom: 16, minZoom: 10, maxZoom: 22, centerX: 35.3227711, centerY: 129.1836202, dataProj: 'EPSG:5186' }, 4: { zoom: 16, minZoom: 10, maxZoom: 22, centerX: 35.0140499, centerY: 126.7110757, dataProj: 'EPSG:5186' }, @@ -109,11 +119,15 @@ GisApp.core.prototype = { GisApp.Module.core = this; GisApp.Module.layer = new GisOlLayer(this.defaults); GisApp.Module.edit = new GisOlEditLayer(this.defaults); - + // TODO: vworld layer 정보 (getNGIIbaseMap)활성화 하면 ngii base map으로 변경됨 this.baseMiniMapLayers['base'] = this.getBaseMap('minimap-base'); this.baseLayers['base'] = this.getBaseMap('base'); this.baseLayers['satellite'] = this.getBaseMap('satellite'); this.baseLayers['3d'] = this.getBaseMap('3d'); + // this.baseLayers['3d'] = this.getNGIIBaseMap('3d'); + // this.baseMiniMapLayers['base'] = this.getNGIIBaseMap('minimap-base'); + // this.baseLayers['base'] = this.getNGIIBaseMap('base'); + // this.baseLayers['satellite'] = this.getNGIIBaseMap('satellite'); }, layerType: { point: 'POINT', @@ -126,6 +140,46 @@ GisApp.core.prototype = { this.setCenter(Number(this.defaults.centerX), Number(this.defaults.centerY)); this.setZoom(15); }, + // NGII Base Map + getNGIIBaseMap: function (_id) { + const layers = { + base: 'korean_map', // 일반지도 + satellite: 'satellite_map', // 위성지도 + night: 'night_map', // 야간지도 + }; + // 선택된 레이어 + const layerName = layers[_id] || layers.base; + // EPSG:5179 좌표계 설정 + const projection = getProjection('EPSG:5179'); + const extent = [705680.0000, 1349270.0000,1388291.0000, 2581448.0000]; // NGII 좌표계 범위 + const resolutions = [ + 2088.96, 1044.48, 522.24, 261.12, 130.56, 65.28, 32.64, 16.32, 8.16, 4.08, 2.04, 1.02, 0.51, 0.255, + ]; + const tileGrid = new WMTS({ + extent: extent, + resolutions: resolutions, + matrixIds: resolutions.map((_, index) => index.toString()), // WMTS matrix IDs + }); + + // WMTS 소스 생성 + const wmtsSource = new TileWMTS({ + url: GisApp.NGII_WMTS_URL, + layer: layerName, + matrixSet: 'EPSG:5179', + format: 'image/png', + projection: projection, + tileGrid: tileGrid, + style: 'default', + crossOrigin: 'anonymous', + }); + + // TileLayer 생성 + return new TileLayer({ + source: wmtsSource, + visible: true, + zIndex: 0, + }); + }, getBaseMap: function (_id) { let url = GisApp.VWORLD_URL; if (_id === 'satellite') { @@ -182,7 +236,7 @@ GisApp.core.prototype = { setCenter: function (x, y, transYn) { if (!x || !y) return; let view = this.map.getView(); - + if (!transYn) { // 값이 없으면 let center = olProj.transform([Number(y), Number(x)], GisApp.FROM_PROJ, GisApp.TO_PROJ); @@ -553,7 +607,7 @@ GisApp.core.prototype = { */ convertLayerInfosToLayerCode: function (array, key, parentName) { let initialValue = {}; - + for (let i = 0; i < array.length; i++) { let item = {}; Object.assign(item, array[i]); diff --git a/packages/common/business_components/src/ui/table/index-2.tsx b/packages/common/business_components/src/ui/table/index-2.tsx index 58ae1bbd0..3a8caa90e 100644 --- a/packages/common/business_components/src/ui/table/index-2.tsx +++ b/packages/common/business_components/src/ui/table/index-2.tsx @@ -59,44 +59,54 @@ export function UiTable2({ tableData, headlist, pageSize, total, children, headN

{headName}

) : null} -
-
- - {/* 스크롤 관련 div 제거 */} - - {children ? ( - <>{children} - ) : ( - - {headlist?.map((head) => ( - + {(!tableData || tableData.length === 0) ? ( + // "데이터 없음" 메시지를 flex를 이용해 수직/수평 중앙 정렬 +
+ 조회된 데이터가 없습니다. +
+ ) : ( +
+ {/* 스크롤 관련 div 제거 */} + + {children ? ( + <>{children} + ) : ( + + {headlist?.map((head) => ( + - {head.title} - - ))} - - )} - - - {tableData.map((el: { [key: string]: string | number | boolean | object }, index) => ( - - {headlist?.map((head) => ( - handleCellClick(startIdx + index, head.id)} - key={head.id + index} - > - {typeof el[head.id] === 'number' ? String(el[head.id]).replace(/\B(?=(\d{3})+(?!\d))/g, ",") : String(el[head.id] ?? '')} - - ))} - - ))} - -
+ {head.title} + + ))} + + )} + + + {tableData.map((el: { [key: string]: string | number | boolean | object }, index) => ( + + {headlist?.map((head) => ( + handleCellClick(startIdx + index, head.id)} + key={head.id + index} + > + {typeof el[head.id] === 'number' ? String(el[head.id]).replace(/\B(?=(\d{3})+(?!\d))/g, ",") : String(el[head.id] ?? '')} + + ))} + + ))} + + + )}
{/* 페이지네이션을 sticky 하단에 고정 */} {total > 0 && (