Merge remote-tracking branch 'origin/iipbs/sn/dev'

This commit is contained in:
yschoi
2025-06-04 19:54:48 +09:00
297 changed files with 15108 additions and 2272 deletions
+19
View File
@@ -0,0 +1,19 @@
name: iipbs_sn
on:
push:
branches: [iipbs/sn/dev]
jobs:
build:
runs-on: ubuntu-20.04
steps:
- uses: appleboy/ssh-action@master
with:
port: 11022
host: con.mind-one.co.kr
username: mindone
password: mindone!23
script: |
cd ./iipbs/front
sh ./frontStartSn.sh
+2
View File
@@ -14,6 +14,8 @@ dist
# misc
.DS_Store
apps/wr/.next/**/*
apps/fo/.next/**/*
# env
.env*
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+44
View File
@@ -0,0 +1,44 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
.next/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
#.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
pnpm-lock.yaml
+3
View File
@@ -0,0 +1,3 @@
{
"plugins": ["prettier-plugin-tailwindcss"]
}
+100
View File
@@ -0,0 +1,100 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
const REFRESH_TOKEN = process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL || "";
const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "SN";
async function handler(
request: Request,
params: { url: string[] },
method: "GET" | "POST" | "PUT" | "DELETE",
) {
const cookieStore = await cookies();
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
};
let body = {};
const isRefreshHandler = params.url.includes("refresh");
// silent refresh
if (isRefreshHandler) {
const refreshToken = cookieStore.get(REFRESH_TOKEN);
headers[CLAIM_NAME] = refreshToken?.value ?? "";
if (!headers[CLAIM_NAME]) {
return NextResponse.json({ message: "Can't refresh" }, { status: 401 });
}
}
try {
if (!isRefreshHandler) {
body = await request.json();
}
const result = await fetch(`${SERVER_API_URL}/${params.url.join("/")}`, {
method,
headers,
body: JSON.stringify(body),
});
if (!result.ok) {
console.log("Invalid Credentials ");
return NextResponse.json(
{ message: "Invalid Credentials" },
{ status: 401 },
);
}
const accessToken = result.headers.get(ACCESS_TOKEN);
const refreshToken = result.headers.get(REFRESH_TOKEN);
const userId = result.headers.get(AUTH_USER_ID);
if (accessToken && userId) {
const response = NextResponse.json({
[ACCESS_TOKEN]: accessToken,
[AUTH_USER_ID]: userId,
});
if (refreshToken) {
cookieStore.set(REFRESH_TOKEN, refreshToken, {
httpOnly: true,
sameSite: "lax",
path: "/",
});
}
return response;
}
return NextResponse.json(
{ message: "Invalid Credentials" },
{ status: 401 },
);
} catch (error) {
return NextResponse.json(
{ message: "Server Error", error },
{ status: 500 },
);
}
}
export async function POST(
request: Request,
props: { params: Promise<{ url: string[] }> },
) {
const params = await props.params;
return handler(request, params, "POST");
}
export async function PUT(
request: Request,
props: { params: Promise<{ url: string[] }> },
) {
const params = await props.params;
return handler(request, params, "PUT");
}
+7
View File
@@ -0,0 +1,7 @@
'use client'
import { BaseLayout } from "@common/components";
export function ClientLayout({ children }: { children: React.ReactNode }) {
return <BaseLayout>{children}</BaseLayout>;
}
@@ -0,0 +1,74 @@
import type { AxiosResponse } from "axios";
interface SortConfig {
empty: boolean;
sorted: boolean;
unsorted: boolean;
}
interface PageableConfig {
sort: SortConfig;
offset: number;
pageNumber: number;
pageSize: number;
paged: boolean;
unpaged: boolean;
}
export interface PageableList<T> {
content: T[];
pageable: PageableConfig;
sort: SortConfig;
totalPages: number;
totalElements: number;
number: number;
numberOfElements: number;
size: number;
first: boolean;
last: boolean;
empty: boolean;
}
export interface PageableParams {
page?: number;
size?: number;
sort?: string[];
}
export interface AttachmentParams extends PageableParams {
keywordType?: string;
keyword?: string;
atflCd?: string;
atflSn?: number;
atflId?: string;
orgnlFileNm?: string;
atflFlieSz?: number;
atflType?: string;
dwnldNocs?: number;
delYn?: string;
lnkgDmnId?: string;
lnkgDmnNm?: string;
}
export interface Attachment {
atflCd: string;
atflSn: number;
atflId: string;
physFileNm: string;
orgnlFileNm: string;
atflFlieSz: number;
atflType: string;
dwnldNocs: number;
delYn: string;
lnkgDmnId: string;
lnkgDmnNm: string;
regDt: string;
}
export type AttachmentList = PageableList<Attachment>;
export interface AttachmentService {
getAttachmentsList: (params: AttachmentParams) => Promise<AttachmentList>;
getAttachmentsInfo: (atflCd: string) => Promise<Attachment[]>;
deleteAttachmentByAtflCd: (atflCd: string) => Promise<void>;
}
@@ -0,0 +1,42 @@
import type { AxiosResponse } from "axios";
import type {
Attachment,
AttachmentList,
AttachmentParams,
AttachmentService,
} from "../attachment/attachment-interface";
import { getData, deleteData } from "../index";
export const ENDPOINT = {
ATTACH_SERVICE: {
TUS_UPLOAD: "/attach-service/api/v1/tus/upload",
EDITOR_UPLOAD: "/attach-service/api/v1/upload/editor",
IMAGES: "/attach-service/api/v1/images",
MOVIE_THUMBNAIL: "/attach-service/api/v1/movie/thumbnail",
ATTACHMENTS: "/attach-service/api/v1/atfl/attachments",
DOWNLOAD: "/attach-service/api/v1/atflcd/download",
STREAM_VIDEO: "/attach-service/api/v1/stream/video",
},
};
export class AttachmentApiService implements AttachmentService {
public async getAttachmentsList(
params: AttachmentParams,
): Promise<AttachmentList> {
const response = await getData(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}`);
return response.data;
}
public async getAttachmentsInfo(atflCd: string): Promise<Attachment[]> {
const response = await getData(
`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflCd}`,
);
return response.data;
}
public async deleteAttachmentByAtflCd(atflCd: string): Promise<void> {
await deleteData(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/atflCd/${atflCd}`);
}
}
export const AttachmentApi = new AttachmentApiService();
+68
View File
@@ -0,0 +1,68 @@
const BASE_URL = process.env.NEXT_PUBLIC_SEWERAGE_API_URL || '';
export const postData = async (url: string, data: object): Promise<any> => {
const options: RequestInit = {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
const res = await fetch(`${BASE_URL}/${url}`, options);
if (res.ok) {
const resData = await res.json();
return resData;
// const text = await res.text();
// if (text) {
// return JSON.parse(text); // JSON 데이터가 있으면 파싱하여 반환
// }
// return true; // 응답 본문이 비어 있으면 true 반환
}
};
export const postMutiPart = async (url: string, data: FormData): Promise<any> => {
const options: RequestInit = {
method: "POST",
body: data,
};
const res = await fetch(`${BASE_URL}/${url}`, options);
const resData = await res.json();
return resData;
};
export const putData = async (url: string, data: object): Promise<any> => {
const options: RequestInit = {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
};
const res = await fetch(`${BASE_URL}/${url}`, options);
const resData = await res.json();
return resData;
};
export const getData = async (url: string): Promise<any> => {
const res = await fetch(url.startsWith('/') ? `${url}` : `${BASE_URL}/${url}`, {
method: "GET",
});
if (res.ok) {
const data = await res.json();
return data;
}
throw new Error('Failed to fetch data');
};
export const deleteData = async (url: string, data?: object): Promise<any> => {
const options: RequestInit = {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: data ? JSON.stringify(data) : undefined,
};
const res = await fetch(url.startsWith('/') ? `${url}` : `${BASE_URL}/${url}`, options);
if (res.status === 204) return true
return res;
};
@@ -0,0 +1,105 @@
import { AxiosError } from "axios";
import { tokenManager } from "../lib/token-manager";
const ENDPOINT = {
USER_SERVICE: {
LOGIN: "/user-service/login",
USERS: "/user-service/api/v1/users",
SILENT_REFRESH: "/user-service/api/v1/users/token/refresh",
AUTHORIZATIONS: "/user-service/api/v1/authorizations",
ROLE_AUTHORIZATIONS: "/user-service/api/v1/role-authorizations",
},
};
const JWT_EXPIRATION_TIME = 1800000;
const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
export interface ILogin {
userId: string;
password: string;
}
export interface LoginService {
login(payload: ILogin): Promise<void>;
silentRefresh(): Promise<void>;
}
export class LoginApiService implements LoginService {
private static instance: LoginApiService;
private constructor() {}
public static getInstance(): LoginApiService {
if (!LoginApiService.instance) {
LoginApiService.instance = new LoginApiService();
}
return LoginApiService.instance;
}
public async login(payload: ILogin): Promise<void> {
try {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
method: "POST",
body: JSON.stringify({
userId: payload.userId,
password: payload.password,
}),
});
const data = await response.json();
const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error("Missing required authentication tokens");
}
tokenManager.setToken(CLAIM_NAME, accessToken);
tokenManager.setToken(AUTH_USER_ID, tokenId);
tokenManager.setupRefreshTimer(
() => this.silentRefresh(),
JWT_EXPIRATION_TIME,
);
} catch (error) {
if (error instanceof AxiosError) {
throw new Error(`Login failed: ${error.response?.data?.message}`);
}
throw new Error(`Login failed: ${error}`);
}
}
public async silentRefresh(): Promise<void> {
try {
const response = await fetch(
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
{
method: "PUT",
},
);
const data = await response.json();
const accessToken = response.headers.get("access-token");
const tokenId = response.headers.get("token-id");
if (!accessToken || !tokenId) {
throw new Error("Missing required authentication tokens");
}
tokenManager.setToken(CLAIM_NAME, accessToken);
tokenManager.setToken(AUTH_USER_ID, tokenId);
} catch (error) {
tokenManager.clearAuthToken();
if (error instanceof AxiosError) {
throw new Error(
`Silent refresh failed: ${error.response?.data?.message}`,
);
}
throw new Error(`Silent refresh failed: ${error}`);
}
}
}
export const LoginApi = LoginApiService.getInstance();
@@ -0,0 +1,2 @@
export { LoginApi } from "./api/login-service";
export { AuthWrapper } from "./lib/auth-wrapper";
@@ -0,0 +1,87 @@
"use client";
import { useEffect } from "react";
import { useRouter, usePathname } from "next/navigation";
import axios from "axios";
import Cookies from "js-cookie";
import { useUserStore, type IUserInfo } from "@store";
import { getCookie } from "@utils";
import { LoginApi } from "../api/login-service";
import { tokenManager } from "../lib/token-manager";
const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
const ENDPOINT = {
USER_SERVICE: {
LOGIN: "/user-service/login",
USERS: "/user-service/api/v1/users",
SILENT_REFRESH: "/user-service/api/v1/users/token/refresh",
AUTHORIZATIONS: "/user-service/api/v1/authorizations",
ROLE_AUTHORIZATIONS: "/user-service/api/v1/role-authorizations",
},
};
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
const pathname = usePathname(); // 현재 경로
const { userInfo, setUserInfo } = useUserStore((state) => state);
async function authCheck() {
const checkToken = getCookie(AUTH_USER_ID);
if (!checkToken) {
//TODO 나중에 수정해야....
const userId = "admin1gmail";
const password = "test1234!";
await LoginApi.login({ userId, password });
// window.location.href = "http://dev.onasys.co.kr/auth/login";
// throw new Error("Missing required authentication tokens");
}
const accessToken = tokenManager.getToken(CLAIM_NAME);
const tokenId = tokenManager.getToken(AUTH_USER_ID);
const response = await axios.get<IUserInfo>(
`${ENDPOINT.USER_SERVICE.USERS}/info/me`,
{
withCredentials: true,
headers: {
[CLAIM_NAME]: accessToken,
[AUTH_USER_ID]: tokenId ?? "",
},
},
);
if (!userInfo) setUserInfo(response.data);
}
useEffect(() => {
(async () => {
try {
await authCheck();
} catch (error) {
console.error("Auth check failed:", error);
try {
await LoginApi.silentRefresh();
await authCheck();
} catch (refreshError) {
console.error("Token refresh failed:", refreshError);
setUserInfo(null);
// TODO 운영에 반영 할때 수정해야.....
// 현재 경로가 로그인 페이지가 아닐 때만 redirect
// if (!pathname.startsWith("/auth/login")) {
// window.location.href = "http://dev.onasys.co.kr/auth/login";
// }
}
}
})();
}, [pathname]); // pathname도 의존성에 포함
return <>{children}</>;
};
export { AuthWrapper };
@@ -0,0 +1,61 @@
import { getCookie, setCookie, removeCookie } from "@utils";
const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
export class TokenManager {
private static instance: TokenManager;
private refreshTimer: NodeJS.Timeout | null = null;
private constructor() {}
public static getInstance(): TokenManager {
if (!TokenManager.instance) {
TokenManager.instance = new TokenManager();
}
return TokenManager.instance;
}
public getToken(key: string): string | undefined {
return getCookie(key);
}
public setToken(key: string, value: string): void {
setCookie(key, value, {
secure: window.location.protocol === "https:",
sameSite: "lax",
});
}
public removeToken(key: string): void {
removeCookie(key);
}
public setupRefreshTimer(
callback: () => Promise<void>,
expirationTime: number,
): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
this.refreshTimer = setTimeout(() => {
callback().catch((error) => {
console.error("Silent refresh failed:", error);
this.clearAuthToken();
});
}, expirationTime - 60000);
}
public clearAuthToken(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
this.removeToken(CLAIM_NAME);
this.removeToken(AUTH_USER_ID);
}
}
export const tokenManager = TokenManager.getInstance();
@@ -0,0 +1,9 @@
export interface ILogin {
userId: string;
password: string;
}
export interface LoginService {
login(payload: ILogin): Promise<void>;
silentRefresh(): Promise<void>;
}
@@ -0,0 +1,24 @@
import { getData } from "../api";
export const searchData = async<T extends Record<string, any>>(url: string, paramObj: T) => {
const params = new URLSearchParams(
Object.entries(paramObj)
.filter(([_, value]) => value !== undefined && value !== '') // 값이 있는 항목만 포함
.reduce((acc, [key, value]) => {
acc[key] = String(value);
return acc;
}, {} as Record<string, string>)
);
const data = await getData(`${url}?${params.toString()}`);
return data && { data: data.content, total: data.totalElements }
}
// 상위코드로 하위코드 조회
export const searchCmmnCd = async (upCd: string) => {
const data = await getData(`/cms-service/api/v1/code/cache/${upCd}`);
const filteredData = data.map((item: any) => ({
lwpoCd: item.lwpoCd,
lwpoCdNm: item.lwpoCdNm,
}));
return filteredData;
}
@@ -0,0 +1,29 @@
import Cookies from "js-cookie";
export function getCookie(name: string): string | undefined {
if (typeof window === "undefined") {
return undefined;
}
return Cookies.get(name);
}
export function setCookie(
name: string,
value: string,
options?: Cookies.CookieAttributes,
): void {
if (typeof window === "undefined") {
return;
}
Cookies.set(name, value, options);
}
export function removeCookie(
name: string,
options?: Cookies.CookieAttributes,
): void {
if (typeof window === "undefined") {
return;
}
Cookies.remove(name, options);
}
+13
View File
@@ -0,0 +1,13 @@
export const formatDate = (date: Date): string => {
if (!date) return '';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); // 월은 0부터 시작하므로 +1
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
// 현재 년도부터 10년 전까지의 년도 옵션 생성
export const yearOptions = Array.from({ length: 10 }, (_, i) => {
const year = new Date().getFullYear() - i;
return { text: `${year}년`, val: year.toString() };
});
@@ -0,0 +1,182 @@
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' | 'M/D/YY_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;
}
month = parseInt(dateParts[0] ?? '', 10);
day = parseInt(dateParts[1] ?? '', 10);
year = parseInt(dateParts[2] ?? '', 10);
} else if (inputType === 'M/D/YY_H:MM') { // "M/D/YY 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] ?? ""; // "M/D/YY"
timePartStr = parts[1] ?? ""; // "H:MM"
const dateParts = datePartStr.split('/');
if (dateParts.length !== 3) {
console.warn(`Invalid date format for ${inputType} (datePart):`, datePartStr);
return null;
}
month = parseInt(dateParts[0] ?? '', 10);
day = parseInt(dateParts[1] ?? '', 10);
year = parseInt(dateParts[2] ?? '', 10);
// YY to YYYY conversion (assuming 2000s)
if (year < 100) {
year += 2000;
}
} 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-DD HH: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');
return `${formattedYear}-${formattedMonth}-${formattedDay} ${formattedHours}:${formattedMinutes}:00`;
}
// 수용가-맨홀 매칭결과 (수정 필요)
export const MatchingResultZod = z.object({
anlsSn: z.number(), // 분석 일련번호
mhlMngNo: z.string(), // 맨홀 번호 (임시값이라 추후 수정 필요)
rcptrNo: z.string(), // 수용가 번호
});
export type MatchingResultZodType = z.infer<typeof MatchingResultZod>;
export const excelToMatchingResultData = (excelData: any) => {
const MatchingResultData: MatchingResultZodType[] = excelData.map((item: any) => {
return {
anlsSn: Number(item["NO"]),
mhlMngNo: String(item["FTR_IDN"]), // (임시값이라 추후 수정 필요)
rcptrNo: String(item["수용가"]),
};
}).filter((el: any) => el !== undefined);
return MatchingResultData;
};
// 시간패턴
export const timePatternZod = z.object({
hrId: z.number(), // 시간
rt: z.number().nullish(), // 비율
});
export type timePatternZodType = z.infer<typeof timePatternZod>;
export const excelToTimePatternData = (excelData: any) => {
const timePatternData: timePatternZodType[] = excelData.map((item: any) => {
return {
hrId: Number(item["시간"]),
rt: Number(item["비율"]),
};
}).filter((el: any) => el !== undefined);
return timePatternData;
};
// 유량자료
export const flowRateZod = z.object({
pplMngNo: z.string(), // 관로번호
mtrngDt: z.string(), // 시간
oilq: z.number().nullish(), // 유량(㎥/h)
});
export type flowRateZodType = z.infer<typeof flowRateZod>;
export const excelToFlowRateData = (excelData: any) => {
const flowRateData: flowRateZodType[] = excelData.map((item: any) => {
const dateTimeStr = String(item["시간"]); // "M/D/YY H:MM" 형식 (예: "2/23/25 0:10")
const formattedDateTime = parseAndFormatDateTimeToISOCompatibleString(dateTimeStr, null, 'M/D/YY_H:MM');
if (!formattedDateTime) return undefined;
return {
pplMngNo: String(item["관로번호"]),
mtrngDt: formattedDateTime,
oilq: Number(item["유량(㎥/h)"]),
};
}).filter((el: any) => el !== undefined);
return flowRateData;
};
// 강우자료 (수정 필요)
export const rainZod = z.object({
mtrngDt: z.string(), // 날짜 + 시간
amrf: z.number().nullish(), // 강우량(mm)
});
export type rainZodType = z.infer<typeof rainZod>;
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 {
mtrngDt: formattedForRainZod,
amrf: Number(item["강우량(mm)"]),
};
}).filter((el: any) => el !== undefined);
return rainData;
};
@@ -0,0 +1,26 @@
export const handleDownload = async (api:string, fileNm:string) => {
try {
const response = await fetch(api, {
method: 'GET',
});
if (!response.ok) {
throw new Error('파일 다운로드 실패');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
// 다운로드를 트리거할 가짜 <a> 태그 생성
const link = document.createElement('a');
link.href = url;
link.download = fileNm; // 원하는 파일명 지정
document.body.appendChild(link);
link.click();
// 메모리 해제
link.remove();
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('다운로드 중 오류 발생:', error);
}
};
@@ -0,0 +1,56 @@
import { Upload, type HttpResponse } from "tus-js-client";
import { getCookie, setCookie } from "@utils";
import { ENDPOINT } from "../api/attachment/attachment-service";
const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
type UploadOptions = {
handleUploadStart?: (upload: Upload) => void;
handleProgress?: (percentage: string) => void;
handleSuccess?: (upload: Upload) => void;
handleResponse?: (res: HttpResponse) => void;
};
export const handleUpload = (files: FileList, options?: UploadOptions) => {
let metadata = {};
const file = files.item(0);
if (!file) return;
metadata = {
filename: file.name,
filetype: file.type,
};
const upload = new Upload(file, {
endpoint: ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD,
chunkSize: 10 * 1024 * 1024,
headers: {
[CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "",
[AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "",
},
metadata,
onError(error) {
console.error(`Failed because: ${error}`);
},
onProgress(bytesUploaded, bytesTotal) {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
options?.handleProgress?.(percentage);
},
onAfterResponse(req, res) {
const fileInfo = res.getHeader("X-File-Info");
if (fileInfo === undefined || fileInfo === null) return;
setCookie("atfl-cd-key", JSON.parse(fileInfo)?.atflCd ?? "");
options?.handleResponse?.(res);
},
onSuccess() {
options?.handleSuccess?.(upload);
},
});
upload.start();
options?.handleUploadStart?.(upload);
console.log("upload start");
};
@@ -0,0 +1,4 @@
export { getCookie, setCookie, removeCookie } from "./cookie";
export { handleUpload } from "./fileUploadUtil";
export * from "./fileDownload";
export * from './date'
@@ -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<TItem extends FieldValues> {
id: keyof TItem | 'response'; // 'response'는 직접적인 키가 아닐 수 있지만 특별히 처리될 수 있음
title: string;
editable?: boolean; // 편집 가능 여부
width?: string; // 컬럼 너비 추가
align?: 'left' | 'center' | 'right'; // 정렬
cellClassName?: string; // 셀 클래스 이름
inputClassName?: string; // 입력 필드 클래스 이름
inputProps?: React.ComponentProps<typeof Input>; // Input 컴포넌트에 전달할 props (예: type="number")
}
// EditableFormTable 컴포넌트 props 타입 정의
type EditableFormTableProps<
TFormValues extends FieldValues,
TFieldArrayName extends Path<TFormValues>,
TFieldArrayItem extends FieldValues = TFormValues[TFieldArrayName] extends (infer U)[] ? U extends FieldValues ? U : never : never
> = {
form: UseFormReturn<TFormValues>; // react-hook-form의 form 객체
name: TFieldArrayName; // 필드 배열 이름 (예: "unitFactors")
fields: FieldArrayWithId<TFieldArrayItem, any, "id">[]; // useFieldArray에서 반환된 필드 배열
headlist: EditableTableHeadItem<TFieldArrayItem>[]; // 테이블 헤더 설정
tableClassName?: string; // 테이블 클래스 이름
headerClassName?: string; // 헤더 클래스 이름
bodyClassName?: string; // 바디 클래스 이름
rowClassName?: string; // 행 클래스 이름
};
// EditableFormTable 컴포넌트
export function EditableFormTable<
TFormValues extends FieldValues,
TFieldArrayName extends Path<TFormValues>,
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<TFormValues, TFieldArrayName, TFieldArrayItem>) {
return (
<div className={`card !m-[1.2rem] !p-0r`}>
<Table variant="secondary" className={tableClassName}>
<TableHeader className={headerClassName}>
<TableRow>
{headlist.map((head) => (
<TableHead
key={String(head.id)}
className={head.align ? `text-${head.align}` : 'text-center'}
style={{ width: head.width }} // head.width 스타일 적용
>
{head.title}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody className={bodyClassName}>
{fields.map((rowItem, rowIndex) => (
<TableRow key={rowItem.id} className={rowClassName}>
{headlist.map((head) => {
const fieldName = `${name}.${rowIndex}.${String(head.id)}` as Path<TFormValues>;
const cellValue = (rowItem as any)[head.id as string];
return (
<TableCell key={`${rowItem.id}-${String(head.id)}`} className={`${head.align ? `text-${head.align}` : 'text-center'} ${head.cellClassName || ''}`}>
{head.editable ? (
<Controller
name={fieldName}
control={form.control}
render={({ field }) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<Input
{...field} // field의 value, onBlur, name, ref 등을 전달
onChange={handleChange} // 커스텀 handleChange 사용
className={`h-8 w-full text-center ${head.inputClassName || ''}`}
{...head.inputProps} // 기존 inputProps (예: type="text")
// type="text"를 명시적으로 사용하거나 head.inputProps에서 설정하는 것이 좋습니다.
// type="number"와 함께 사용 시 브라우저 기본 동작과 충돌할 수 있습니다.
/>
);
}}
/>
) : (
<span>{String(cellValue ?? '')}</span>
)}
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,20 @@
import React from 'react';
import { LoadingWithAnimation } from '@common/components/ui/loading';
interface LoadingOverlayProps {
isLoading: boolean;
}
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ isLoading }) => {
if (!isLoading) return null;
return (
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50">
<div className="relative w-16 h-16">
<LoadingWithAnimation color="primary" />
</div>
</div>
);
};
export default LoadingOverlay;
@@ -0,0 +1,159 @@
import React, { useMemo } from "react";
import { SelectBox2 } from "@common/business_components/ui/select/index-2";
interface Option {
id: number;
name: string;
}
interface SidoSigunguSelectBoxProps {
sido: number;
sigungu: number;
onSidoChange: (id: number) => void;
onSigunguChange: (id: number) => void;
className?: string;
}
const sidoList: { id: number, name: string }[] = [
{ id: 0, name: '전체' },
{ id: 26, name: '부산광역시' },
{ id: 28, name: '인천광역시' },
{ id: 30, name: '대전광역시' },
{ id: 41, name: '경기도' },
{ id: 42, name: '강원도' },
{ id: 44, name: '충청남도' },
{ id: 45, name: '전라북도' },
{ id: 46, name: '전라남도' },
{ id: 47, name: '경상북도' },
{ id: 48, name: '경상남도' },
];
const sigunguMap: Record<number, { id: number; name: string }[]> = {
26: [
{ id: 0, name: '전체' },
// ...부산산광역시의 시군구 추가
],
28: [
{ id: 0, name: '전체' },
// ...인천광역시의 시군구 추가
],
30: [
{ id: 0, name: '전체' },
// ...대전광역시의 시군구 추가
],
41: [
{ id: 0, name: '전체' },
{ id: 41820, name: '가평군' },
{ id: 41550, name: '안성시' },
{ id: 41830, name: '양평군' },
{ id: 41670, name: '여주시' },
{ id: 41500, name: '이천시' },
{ id: 41430, name: '의왕시' },
{ id: 41150, name: '의정부시' },
{ id: 41480, name: '파주시' },
{ id: 41650, name: '포천시' },
// ...경기도의 시군구 추가
],
42: [
{ id: 0, name: '전체' },
{ id: 42170, name: '동해시' },
{ id: 42110, name: '춘천시' },
{ id: 42720, name: '홍천군' },
// ...강원도의 시군구 추가
],
44: [
{ id: 0, name: '전체' },
{ id: 44770, name: '서천군' },
// ...충청남도의 시군구 추가
],
45: [
{ id: 0, name: '전체' },
{ id: 45130, name: '군산시' },
{ id: 45190, name: '남원시' },
{ id: 45730, name: '무주군' },
{ id: 45800, name: '부안군' },
{ id: 45770, name: '순창군' },
{ id: 45140, name: '익산시' },
{ id: 45750, name: '임실군' },
{ id: 45710, name: '완주군' },
{ id: 45110, name: '전주시' },
// ...전라북도의 시군구 추가
],
46: [
{ id: 0, name: '전체' },
{ id: 46810, name: "강진군" },
{ id: 46770, name: "고흥군" },
{ id: 46720, name: "곡성군" },
{ id: 46730, name: "구례군" },
{ id: 46230, name: "광양시" },
{ id: 46170, name: "나주시" },
{ id: 46110, name: "목포시" },
{ id: 46840, name: "무안군" },
{ id: 46150, name: "순천시" },
{ id: 46910, name: "신안군" },
{ id: 46130, name: "여수시" },
{ id: 46890, name: "완도군" },
// ...전라남도의 시군구 추가
],
47: [
{ id: 0, name: '전체' },
{ id: 47130, name: "경주시" },
{ id: 47830, name: "고령군" },
{ id: 47190, name: "구미시" },
{ id: 47720, name: "군위군" },
{ id: 47150, name: "김천시" },
{ id: 47280, name: "문경시" },
{ id: 47920, name: "봉화군" },
{ id: 47250, name: "상주시" },
{ id: 47770, name: "영덕군" },
{ id: 47760, name: "영양군" },
{ id: 47210, name: "영주시" },
{ id: 47900, name: "예천군" },
{ id: 47930, name: "울진군" },
{ id: 47730, name: "의성군" },
{ id: 47750, name: "청송군" },
{ id: 47850, name: "칠곡군" },
{ id: 47110, name: "포항시" },
// ...경상북도의 시군구 추가
],
48: [
{ id: 0, name: '전체' },
{ id: 48310, name: "거제시" },
{ id: 48250, name: "김해시" },
{ id: 48270, name: "밀양시" },
{ id: 48220, name: "통영시" },
// ...경상남도의 시군구 추가
],
// ...나머지 시도별 시군구 추가
};
export function SidoSigunguSelectBox({
sido,
sigungu,
onSidoChange,
onSigunguChange,
className = "",
}: SidoSigunguSelectBoxProps) {
// 선택된 시도에 따라 시군구 리스트를 가져옴
const sigunguList = useMemo(() => sigunguMap[sido] || [{ id: 0, name: '전체' }], [sido]);
return (
<div className={`flex items-center justify-center ${className} mr-8`}>
<SelectBox2
className="w-[150px] mr-8"
selectClass="rounded-lg bg-white"
label="시도"
selectArray={sidoList.map(opt => ({ val: opt.id, text: opt.name }))}
value={sido}
onSelectValue={val => onSidoChange(Number(val))}
/>
<SelectBox2
className="w-48"
selectClass="w-48 rounded-lg bg-white truncate"
label="시군구"
selectArray={sigunguList.map(opt => ({ val: opt.id, text: opt.name }))}
value={sigungu}
onSelectValue={val => onSigunguChange(Number(val))}
/>
</div>
);
}
@@ -0,0 +1,111 @@
import React from "react";
import { SelectBox2 } from "@common/business_components/ui/select/index-2";
interface YearRangeSelectBoxProps {
start: string;
end: string;
onChange: (range: { start: string; end: string }) => void;
startLabel?: string;
endLabel?: string;
minYear?: number;
maxYear?: number;
className?: string;
selectClass?: string;
}
// 시작년도 selectArray 생성 함수
export function getStartYearArray({
minYear = 1900,
maxYear = new Date().getFullYear(),
placeholder = "년",
endYear,
}: {
minYear?: number;
maxYear?: number;
placeholder?: string;
endYear?: string;
} = {}) {
const years = Array.from({ length: maxYear - minYear + 1 }, (_, i) =>
(maxYear - i).toString()
).filter(y => !endYear || parseInt(y) <= parseInt(endYear));
return [
{ val: "0", text: placeholder },
...years.map(y => ({ val: y, text: y })),
];
}
// 종료년도 selectArray 생성 함수
export function getEndYearArray({
minYear = 1900,
maxYear = new Date().getFullYear(),
placeholder = "년",
startYear,
}: {
minYear?: number;
maxYear?: number;
placeholder?: string;
startYear?: string;
} = {}) {
const years = Array.from({ length: maxYear - minYear + 1 }, (_, i) =>
(maxYear - i).toString()
).filter(y => !startYear || parseInt(y) >= parseInt(startYear));
return [
{ val: "9999", text: placeholder },
...years.map(y => ({ val: y, text: y })),
];
}
export function YearRangeSelectBox({
start,
end,
onChange,
startLabel = "시작년도",
endLabel = "",
minYear = 1900,
maxYear = new Date().getFullYear(),
className = "",
selectClass = "rounded-lg bg-white flex-shrink-0 w-[70px]"
}: YearRangeSelectBoxProps) {
// 시작년도 배열
const getStartYearArray = () => {
const years = Array.from({ length: maxYear - minYear + 1 }, (_, i) =>
(maxYear - i).toString()
).filter(y => !end || parseInt(y) <= parseInt(end));
return [
{ val: "0", text: "년" },
...years.map(y => ({ val: y, text: y })),
];
};
// 종료년도 배열
const getEndYearArray = () => {
const years = Array.from({ length: maxYear - minYear + 1 }, (_, i) =>
(maxYear - i).toString()
).filter(y => !start || parseInt(y) >= parseInt(start));
return [
{ val: "9999", text: "년" },
...years.map(y => ({ val: y, text: y })),
];
};
return (
<div className={`flex items-center ${className}`}>
<SelectBox2
className="flex-1 mr-3 min-w-[120px] max-w-[170px]"
selectClass={selectClass}
label={startLabel}
selectArray={getStartYearArray()}
onSelectValue={val => onChange({ start: String(val), end })}
value={start}
/>
<span className="font-semibold text-xl mx-2">~</span>
<SelectBox2
className="flex-1 ml-1 mr-6 min-w-[70px] max-w-[150px]"
selectClass={selectClass}
label={endLabel}
selectArray={getEndYearArray()}
onSelectValue={val => onChange({ start, end: String(val) })}
value={end}
/>
</div>
);
}
@@ -0,0 +1,21 @@
import { ContentsDiv, MainDiv, Title } from '@common/business_components/ui'
import React, { type ReactNode } from 'react'
import LoadingOverlay from './LoadingBar'
type LayoutNode = {
children : ReactNode
title : string
isLoading?: boolean
}
const ContentLayout = ({children, title, isLoading} : LayoutNode) => {
return (
<MainDiv>
<Title title={title} />
<ContentsDiv>
<LoadingOverlay isLoading={isLoading ?? false}/>
{children}
</ContentsDiv>
</MainDiv>
)
}
export default ContentLayout
@@ -0,0 +1,148 @@
"use client"
import { FileUploader } from "@common/components";
import { Button } from '@common/components/ui';
import { handleUpload } from "@utils";
import { useId, useState } from 'react';
import type { HttpResponse, Upload } from "tus-js-client";
interface FileUploadFormProps {
onSubmit?: (files: string, year: string, quarter: string, code: string, id: string) => void;
onFileSelect?: (file: File | null, atflCd: string | null, atflId: string | null) => void; // 파일 선택 시 호출될 콜백 추가
showButton?: boolean; // 버튼 표시 여부 (기본값 true)
showPreview?: boolean; // 파일명 프리뷰 표시 여부
accept?: string; // 파일 형식 지정
fileIdx?: number; // 부모가 어떤 FileUploadForm인지 구분하기 위한 인덱스
}
const FileUploadForm = ({
onSubmit,
onFileSelect,
showButton = true,
showPreview = false,
accept = ".jpeg, .jpg, .png",
fileIdx // props에서 fileIdx 받기
}: FileUploadFormProps) => {
const [internalAtflCd, setInternalAtflCd] = useState<string | null>(null);
const [internalAtflId, setInternalAtflId] = useState<string | null>(null);
const [currentFile, setCurrentFile] = useState<File | null>(null); // 현재 선택된 파일을 저장할 상태
const [fileName, setFileName] = useState<string>('');
const [files, setFiles] = useState<FileList | null>(null);
const [upload, setUpload] = useState<Upload | null>(null);
const uniqueId = useId(); // 각 FileUploadForm 인스턴스에 대한 고유 ID 생성
const quater = [{ text: '1분기', val: '1' }, { text: '2분기', val: '2' }, { text: '3분기', val: '3' }, { text: '4분기', val: '4' }];
// 현재 년도부터 10년 전까지의 년도 옵션 생성
const yearOptions = Array.from({ length: 10 }, (_, i) => {
const year = new Date().getFullYear() - i;
return { text: `${year}년`, val: year.toString() };
});
const [selectedYear, setSelectedYear] = useState<string>(yearOptions[0]?.val || '');
const [selectedQuarter, setSelectedQuarter] = useState<string>(quater[0]?.val || '');
const internalHandleUploadResponse = (file: File) => async (res: HttpResponse) => {
const fileInfo = res.getHeader("X-File-Info");
if (!fileInfo) {
if (onFileSelect) onFileSelect(file, null, null);
return;
}
const { atflCd: newAtflCd, atflId: newAtflId } = JSON.parse(fileInfo) ?? {};
const orgnlFileNm = file.name;
console.log(`FileUploadForm (idx: ${fileIdx ?? 'N/A'}) - orgnlFileNm:`, orgnlFileNm, 'New AtflCd:', newAtflCd, 'New AtflId:', newAtflId);
setFileName(orgnlFileNm);
setInternalAtflCd(newAtflCd);
setInternalAtflId(newAtflId);
if (onFileSelect) {
onFileSelect(file, newAtflCd, newAtflId);
}
};
return (
<div> {/* 컴포넌트 루트 div */}
<div className="card p-6"> {/* 카드 스타일링 */}
<div className='flex items-center gap-2'> {/* FileUploader와 Button을 정렬하는 메인 flex 컨테이너 */}
<div className="w-[300px] mr-4"> {/* FileUploader의 너비와 오른쪽 여백을 제어하는 컨테이너 */}
<FileUploader
id={uniqueId}
className="!h-auto !max-w-none flex-row items-center gap-2"
accept={accept}
onUpload={(e) => {
const selectedFiles = e.target.files;
if (!selectedFiles || selectedFiles.length === 0) {
setFiles(null);
setCurrentFile(null);
setInternalAtflCd(null);
setInternalAtflId(null);
if (onFileSelect) {
onFileSelect(null, null, null); // 파일 선택 취소 시 null 전달
}
return;
}
const firstFile = selectedFiles[0];
if (!firstFile) return; // 타입스크립트 undefined 가능성 제거
setFiles(selectedFiles);
setCurrentFile(firstFile);
if (onFileSelect) {
onFileSelect(firstFile, internalAtflCd, internalAtflId);
}
handleUpload(selectedFiles, {
handleUploadStart: setUpload,
handleSuccess: setUpload,
// handleResponse는 tus-js-client의 Upload 인스턴스 콜백과 연동 필요
// 여기서는 handleUpload 유틸리티가 응답을 직접 처리한다고 가정
handleResponse: internalHandleUploadResponse(firstFile),
});
}}
customPreview={
showPreview ? (
<div className="file-uploader-preview">
{files && files.length > 0
? fileName
: "파일을 선택하세요."}
</div>
) : null
}
/>
</div>
{showButton ? ( // showButton이 true일 때만 버튼 렌더링
<Button
className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700"
size="sm"
onClick={() => {
console.log(files?.[0]?.name)
if (!currentFile) {
alert('파일을 선택해주세요.');
return;
}
if (!selectedYear) {
alert('등록년도를 선택해주세요.');
return;
}
if (!selectedQuarter) {
alert('분기를 선택해주세요.');
return;
}
const fileNm = currentFile.name;
if (onSubmit) {
// 부모에게 전달할 때는 내부 상태인 internalAtflCd, internalAtflId 사용
onSubmit(fileNm, selectedYear, selectedQuarter, internalAtflCd || '', internalAtflId || '');
setFiles(null); // 파일 업로드 후 파일 목록 초기화
setFileName(''); // 파일명 초기화
}
}}
>
등록
</Button>
) : null}
</div>
</div>
</div>
)
}
export default FileUploadForm
+24
View File
@@ -0,0 +1,24 @@
import { FormField } from '@common/business_components/ui'
import React, { type ReactElement } from 'react'
interface FormSectionProps {
children: ReactElement
titleNm: string
form: any
name: string
}
const FormSection = ({ children, titleNm, form, name }: FormSectionProps) => {
return (
<div className="grid grid-cols-[200px_1fr] items-center">
<div className="text-xl font-medium">{titleNm}</div>
<FormField
control={form.control}
name={name}
render={({ field }) => children}
/>
</div>
)
}
export default FormSection
@@ -0,0 +1,335 @@
import React, { useEffect, useState } from 'react';
import { SelectBox } from '@common/business_components/ui/select';
export type comCodeState = {
code: {
isInit: boolean;
chBuCd: SelectDataType[]
rainFcltCd: SelectDataType[];
bdstUsgCd: SelectDataType[];
cityCd: SelectDataType[];
sggCd: SelectDataType[];
rvbsnCd: SelectDataType[];
mdmbsnCd: SelectDataType[];
lgbsnCd: SelectDataType[];
lclgvrnmntCd: EnvPartSelectDataType[];
orgwtKndCd: SelectDataType[];
yn: SelectDataType[];
buildingType: {
RST: string,
RNB: string,
HTB: string
}
};
};
type SelectDataType = {
text: string;
val: string;
sido?: string; // 시도 코드 (선택 사항)
}
type EnvPartSelectDataType = SelectDataType & {
adstDliStrCd: string,
}
type SearchFormLeftProps = {
isShowPart?: boolean | undefined;
children: React.ReactNode;
selectSidoData?: SelectDataType[] | [];
selectSigunData?: SelectDataType[] | [];
selectRegionData?: SelectDataType[] | [];
selectUnitWaterShedData?: SelectDataType[] | [];
selectMiddleRegionData?: SelectDataType[] | [];
selectEnvPartData?: EnvPartSelectDataType[] | [];
selectEnvPartSidoData?: SelectDataType[] | [];
selectEnvPartSigunData?: SelectDataType[] | [];
setRtnPartValue?: (value: string) => void;
setRtnSidoValue?: (value: string) => void;
setRtnSigunValue?: (value: string) => void;
setRtnRegionValue?: (value: string) => void;
setRtnUnitWaterShedValue?: (value: string) => void;
setRtnMiddleRegionValue?: (value: string) => void;
setRtnEnvPartValue?: (value: string) => void;
setRtnEnvPartSidoValue?: (value: string) => void;
setRtnEnvPartSigunValue?: (value: { val: string, text: string }) => void; // setRtnEnvPartSigunValue prop 타입을 객체로 변경
resetTrigger?: boolean;
}
function GovSidoSigunguSearchForm({ isShowPart = false, children, selectSidoData = [], selectSigunData = [], selectRegionData = [], selectUnitWaterShedData = [], selectMiddleRegionData = [], selectEnvPartData = [], setRtnPartValue, setRtnSidoValue, setRtnSigunValue, setRtnRegionValue, setRtnUnitWaterShedValue, setRtnMiddleRegionValue, setRtnEnvPartValue, setRtnEnvPartSidoValue, setRtnEnvPartSigunValue, resetTrigger = false }: SearchFormLeftProps) {
//환경청별 시도 시군구
const [envPartValue, setEnvPartValue] = useState<string>('0');
const [envPartSidoValue, setEnvPartSidoValue] = useState<string>('0'); // 환경청 선택 시 시/도 값
const [envPartSigunValue, setEnvPartSigunValue] = useState<string>('0'); // 환경청 선택 시 시/군/구 값
const [filteredEnvPartSigunDataSelected, setFilteredEnvPartSigunDataSelected] = useState<SelectDataType[]>([]);
// API로부터 가져온 전체 데이터 목록 (필터링 전)
const [allEnvPartOptions, setAllEnvPartOptions] = useState<EnvPartSelectDataType[]>([]);
const [allSidoOptions, setAllSidoOptions] = useState<SelectDataType[]>([]);
const [allSigunOptions, setAllSigunOptions] = useState<SelectDataType[]>([]);
// 조회된 조건에 맞는 환경청 시도 시군 (SelectBox에 실제 사용될 데이터)
const [filteredEnvPartData, setFilteredEnvPartData] = useState<EnvPartSelectDataType[]>([{ text: "전체", val: '0', adstDliStrCd: '', sido: '0' }]);
const [filteredEnvPartSidoData, setFilteredEnvPartSidoData] = useState<EnvPartSelectDataType[]>([{ text: "전체", val: '0', adstDliStrCd: '', sido: '0' }]);
const [filteredEnvPartSigunData, setFilteredEnvPartSigunData] = useState<SelectDataType[]>([{ text: "전체", val: '0', sido: '0' }]);
// 시군구 데이터
const getSggCd = async (): Promise<SelectDataType[]> => {
try {
const response = await fetch('http://con.mind-one.co.kr:11082/api/v1/addr/sgg', { method: "GET", headers: { accept: "application/json" } });
if (!response.ok) throw new Error(`시군구 데이터 오류: ${response.status}`);
const res = await response.json();
const sgg = res.data;
const sggCd = sgg.map((item: any) => ({
val: item.adstDstrCd,
sido: item.sido,
text: item.gugun,
}));
sggCd.unshift({ val: '0', text: '전체' }); // "전체" 옵션 추가
return sggCd;
} catch (error) {
console.error('시군구 데이터를 가져오는데 실패했습니다:', error);
return [{ val: '0', text: '전체', sido: '0' }];
}
};
// 시도 데이터
const getCityCd = async (): Promise<SelectDataType[]> => {
try {
const response = await fetch('http://con.mind-one.co.kr:11082/api/v1/addr/city', { method: "GET", headers: { accept: "application/json" } });
if (!response.ok) throw new Error(`시도 데이터 오류: ${response.status}`);
const res = await response.json();
const city = res.data;
const cityCd = city.map((item: any) => ({
val: item.adstDstrCd,
text: item.sido,
}));
cityCd.unshift({ val: '0', text: '전체' });
return cityCd;
} catch (error) {
console.error('시도 데이터를 가져오는데 실패했습니다:', error);
return [{ val: '0', text: '전체', sido: '0' }];
}
};
// 환경청 데이터
const getEnvPartCd = async (): Promise<EnvPartSelectDataType[]> => {
try {
const response = await fetch('http://con.mind-one.co.kr:11082/api/v1/addr/govArea', { method: "GET", headers: { accept: "application/json" } });
if (!response.ok) throw new Error(`환경청 데이터 오류: ${response.status}`);
const res = await response.json();
const lclgvrnmnt = res.data.filter((item: { lclgvrnmntNm: string; }) => {
// item.lclgvrnmntNm이 null이나 undefined가 아닌지 확인 후 endsWith 사용
return item.lclgvrnmntNm && item.lclgvrnmntNm.endsWith("환경청");
});
const lclgvrnmntCd = lclgvrnmnt.map((item: any) => ({
val: item.lclgvrnmntCd,
text: item.lclgvrnmntNm,
adstDliStrCd: item.adstDliStrCd || ''
}));
lclgvrnmntCd.unshift({ val: '0', text: '전체', adstDliStrCd: '' });
return lclgvrnmntCd;
} catch (error) {
console.error('환경청 데이터를 가져오는데 실패했습니다:', error);
return [{ val: '0', text: '전체', adstDliStrCd: '', sido: '0' }];
}
};
useEffect(() => {
const loadInitialData = async () => {
const [envParts, sidos, siguns] = await Promise.all([
getEnvPartCd(),
getCityCd(),
getSggCd()
]);
setAllEnvPartOptions(envParts.filter(item => item.val !== '0'));
setFilteredEnvPartData(envParts);
setAllSidoOptions(sidos.filter(item => item.val !== '0'));
// 초기 filteredEnvPartSidoData는 모든 시/도를 포함 (환경청 "전체" 선택 시)
setFilteredEnvPartSidoData(sidos.map(s => ({ ...s, adstDliStrCd: '' })));
setAllSigunOptions(siguns.filter(item => item.val !== '0'));
// 초기 filteredEnvPartSigunData는 모든 시/군/구를 포함 (환경청 "전체", 시/도 "전체" 선택 시)
setFilteredEnvPartSigunData(siguns);
// 만약 isShowPart가 true이고, partValue가 'G'(환경청별)로 기본 설정되어 있다면,
// 초기 envPartValue ('0')에 대한 시도/시군구 목록을 설정합니다.
initializeCityDataByEnvPartSido(envPartValue); // envPartValue 기본값 '0'
};
if (isShowPart) {
loadInitialData();
}
}, [isShowPart]); // isShowPart가 변경될 때 데이터를 다시 로드
// 환경청 세팅 - 시도
const initializeCityDataByEnvPartSido = (envPartCd: string) => {
const foundEnvPart = allEnvPartOptions.find((item: EnvPartSelectDataType) => item.val === envPartCd);
console.log('foundEnvPart:', foundEnvPart);
let relevantSidos: SelectDataType[] = []; // 기본값을 빈 배열로 설정
let relevantSigunsForSidoDerivation: SelectDataType[] = []; // 기본값을 빈 배열로 설정
if (envPartCd === '0') { // "전체" 환경청 선택 시
relevantSidos = [...allSidoOptions];
relevantSigunsForSidoDerivation = [...allSigunOptions];
} else if (foundEnvPart && foundEnvPart.adstDliStrCd) { // 특정 환경청 선택 & 해당 환경청에 adstDliStrCd 정보가 있는 경우
// adstDliStrCd가 시도 코드를 포함한다고 가정 (주석 참고)
const cityCodesInEnvPart = foundEnvPart.adstDliStrCd.toString().split('|').join('').split(',').filter(c => c);
console.log('cityCodesInEnvPart:', cityCodesInEnvPart);
// 1. allSigunOptions에서 val이 cityCodesInEnvPart에 포함된 데이터의 sido만 추출
const relevantSigunSidos = Array.from(
new Set(
allSigunOptions
.filter(sigungu => cityCodesInEnvPart.includes(sigungu.val))
.map(sigungu => sigungu.sido)
)
);
console.log('relevantSigunTexts:', relevantSigunSidos);
// 2. relevantSigunsForSidoDerivation은 해당 sido만을 가진 배열로 반환
relevantSidos = allSidoOptions.filter(sido => relevantSigunSidos.includes(sido.text));
console.log('relevantSidos:', relevantSidos);
// 해당 시도들에 속하는 시군구들 (가나다순 오름차순 정렬)
relevantSigunsForSidoDerivation = allSigunOptions
.filter(sigun => {
const sigunSidoPrefix = sigun.val.substring(0, 2);
return relevantSidos.some(s => s.val === sigunSidoPrefix);
})
.sort((a, b) => a.text.localeCompare(b.text, 'ko'));
}
// 특정 환경청이 선택되었으나, foundEnvPart가 없거나 adstDliStrCd 정보가 없는 경우,
// relevantSidos와 relevantSigunsForSidoDerivation은 초기 빈 배열 상태를 유지합니다.
setFilteredEnvPartSidoData([
{ text: "전체", val: '0', adstDliStrCd: '', sido: '0' },
...relevantSidos.map((item: SelectDataType) => ({ text: item.text, val: item.val, adstDliStrCd: '', sido: '0' }))
]);
setFilteredEnvPartSigunDataSelected(relevantSigunsForSidoDerivation);
// 시/도 선택이 "전체"('0')로 초기화되므로, 해당 시/군/구 목록도 업데이트
initializeCityDataByEnvPartSigun('0');
}
// 환경청 세팅 - 시군구
const initializeCityDataByEnvPartSigun = (selectedSidoInEnvPart: string) => {
let sigunsToList: SelectDataType[];
if (selectedSidoInEnvPart === '0') { // 선택된 시도가 "전체"이면
// filteredEnvPartSigunDataSelected는 이미 해당 환경청에 맞는 시군구들로 필터링 되어있음
sigunsToList = [...filteredEnvPartSigunDataSelected];
} else {
sigunsToList = filteredEnvPartSigunDataSelected.filter((item: SelectDataType) => item.val.substring(0, 2) === selectedSidoInEnvPart);
}
setFilteredEnvPartSigunData([
{ text: "전체", val: '0' },
...sigunsToList
]);
}
const onSelectValue = (val: string, type: string) => {
switch (type) {
// 환경청 세팅
case 'envPart':
setEnvPartValue(val);
setRtnEnvPartValue?.(val);
initializeCityDataByEnvPartSido(val);
setEnvPartSidoValue('0'); // 시도 "전체"로 초기화
setEnvPartSigunValue('0'); // 시군구 "전체"로 초기화
// 부모에게도 초기화된 값 전달 (text 포함)
if (setRtnEnvPartSidoValue) {
const sidoDefault = filteredEnvPartSidoData.find(item => item.val === '0') || { val: '0', text: '전체' };
// setRtnEnvPartSidoValue(sidoDefault.val); // 이전 방식: 값만 전달
// TODO: 만약 시도 값도 객체로 전달해야 한다면 아래와 같이 수정
// setRtnEnvPartSidoValue({ val: sidoDefault.val, text: sidoDefault.text });
}
if (setRtnEnvPartSigunValue) {
const sigunDefault = filteredEnvPartSigunData.find(item => item.val === '0') || { val: '0', text: '전체' };
setRtnEnvPartSigunValue({ val: sigunDefault.val, text: sigunDefault.text });
}
break;
case 'envPartSido': // 환경청별 - 시도 선택
setEnvPartSidoValue(val);
setRtnEnvPartSidoValue?.(val);
initializeCityDataByEnvPartSigun(val);
setEnvPartSigunValue('0'); // 시군구 "전체"로 초기화
if (setRtnEnvPartSigunValue) {
const sigunDefault = filteredEnvPartSigunData.find(item => item.val === '0') || { val: '0', text: '전체' };
setRtnEnvPartSigunValue({ val: sigunDefault.val, text: sigunDefault.text });
}
break;
case 'envPartSigun': // 환경청별 - 시군구 선택
setEnvPartSigunValue(val);
const selectedSigun = filteredEnvPartSigunData.find(item => item.val === val);
if (setRtnEnvPartSigunValue && selectedSigun) {
setRtnEnvPartSigunValue({ val: selectedSigun.val, text: selectedSigun.text });
} else if (setRtnEnvPartSigunValue && val === '0') { // "전체"를 명시적으로 처리 (find 실패 대비)
setRtnEnvPartSigunValue({ val: '0', text: '전체' });
}
break;
}
}
// 리셋 함수
const resetAllValues = () => {
setEnvPartValue('0');
setEnvPartSidoValue('0');
setEnvPartSigunValue('0');
// 필터링된 목록들도 초기 "전체" 또는 전체 목록으로 리셋
setFilteredEnvPartSigunData([{ text: "전체", val: '0', sido: '0' }, ...allSigunOptions]);
setFilteredEnvPartData([{ text: "전체", val: '0', adstDliStrCd: '', sido: '0' }, ...allEnvPartOptions]);
setFilteredEnvPartSidoData([{ text: "전체", val: '0', adstDliStrCd: '', sido: '0' }, ...allSidoOptions.map(s => ({ ...s, adstDliStrCd: '' }))]);
setFilteredEnvPartSigunData([{ text: "전체", val: '0', sido: '0' }, ...allSigunOptions]);
setFilteredEnvPartSigunDataSelected(allSigunOptions);
setRtnPartValue?.('A');
setRtnSidoValue?.('0');
setRtnSigunValue?.('0');
setRtnRegionValue?.('0');
setRtnUnitWaterShedValue?.('0');
setRtnMiddleRegionValue?.('0');
setRtnEnvPartValue?.('0');
setRtnEnvPartSidoValue?.('0');
setRtnEnvPartSigunValue?.({ val: '0', text: '전체' });
};
// 리셋 트리거 변경 감지
useEffect(() => {
if (resetTrigger) {
resetAllValues();
// 리셋 후 트리거를 false로 설정하는 로직은 부모 컴포넌트에서 처리해야 할 수 있습니다.
// 여기서는 resetTrigger props가 true일 때 리셋을 수행합니다.
}
}, [resetTrigger]);
return (
isShowPart ? (
<div className="grid grid-cols-12 gap-5 w-11/12 m-4">
<>
<SelectBox className="col-span-2" label={'환경청'} selectArray={filteredEnvPartData} onSelectValue={(val) => onSelectValue(val.toString(), 'envPart')} value={envPartValue} selectClass={'rounded-lg bg-white truncate'} />
<SelectBox className="col-span-2" label={'시도'} selectArray={filteredEnvPartSidoData} onSelectValue={(val) => onSelectValue(val.toString(), 'envPartSido')} value={envPartSidoValue} selectClass={'rounded-lg bg-white truncate'} />
<SelectBox className="col-span-2" label={'시군구'} selectArray={filteredEnvPartSigunData} onSelectValue={(val) => onSelectValue(val.toString(), 'envPartSigun')} value={envPartSigunValue} selectClass={'rounded-lg bg-white truncate'} />
</>
{children}
</div>
) : (
<div className="grid grid-cols-12 gap-5 w-11/12 m-4">
{children}
</div>
)
)
}
export { GovSidoSigunguSearchForm };
+35
View File
@@ -0,0 +1,35 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose, DialogOverlay } from '@common/components/ui'
import React from 'react'
interface ModalProps {
isModalOpen: boolean;
setIsModalOpen: (open: boolean) => void;
title?: string;
children: React.ReactNode;
closeModal: () => void;
saveModal?: () => void
}
const Modal: React.FC<ModalProps> = ({ isModalOpen, setIsModalOpen, title, children, closeModal,saveModal }) => {
return (
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogOverlay className='z-[200]' />
<DialogContent className="w-auto max-w-[1200px] min-w-[400px] min-h-[150px] h-auto max-h-[450px] overflow-auto z-[1000]">
{title ? <DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader> : null}
{children}
<DialogFooter>
<DialogClose>
<div className='flex justify-end gap-2'>
{saveModal ? <button className="px-4 py-2 bg-primary text-white rounded" onClick={saveModal}>저장</button> : null}
<button className="px-4 py-2 bg-gray-600 text-white rounded" onClick={closeModal}>닫기</button>
</div>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default Modal
@@ -0,0 +1,51 @@
"use client"
import { SelectBox } from '@common/business_components/ui';
import React, { useState } from 'react'
import FileUploadForm from './fileUploadForm';
import { yearOptions } from '../lib/utils';
const operFileUploadForm = ({ title, onSubmit }: { title: string, onSubmit: (files: string, year: string, quarter: string, code:string, id:string) => void }) => {
const [atflCd, setAtflCd] = useState<string | null>(null);
const [atflId, setAtflId] = useState<string | null>(null);
const quater = [{ text: '1분기', val: '1' }, { text: '2분기', val: '2' }, { text: '3분기', val: '3' }, { text: '4분기', val: '4' }]
const [selectedYear, setSelectedYear] = useState<string>(yearOptions[0]?.val || '');
const [selectedQuarter, setSelectedQuarter] = useState<string>(quater[0]?.val || '');
return (
<div>
<div className='card-header'>
<h3 className="text-2xl font-medium">{title}</h3>
</div>
<div className="card p-6">
<div className="grid grid-col-2 items-center space-x-4">
<div className='flex'>
<SelectBox
className="w-50 mr-3 ml-4"
selectClass="rounded-lg bg-white"
label="년도"
selectArray={yearOptions}
onSelectValue={(value: string | number) => setSelectedYear(String(value))}
/><SelectBox
className="w-50"
selectClass="rounded-lg bg-white"
label="분기"
selectArray={quater}
onSelectValue={(value: string | number) => setSelectedQuarter(String(value))}
/>
<FileUploadForm
onSubmit={(files, year, quarter, code, id) => {
onSubmit(files, (selectedYear ?? year) || '', (selectedQuarter ?? quarter) || '', code, id);
}}
showButton={true}
showPreview={true}
accept={""} />
</div>
</div>
</div>
</div>
)
}
export default operFileUploadForm
+129
View File
@@ -0,0 +1,129 @@
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
import { Table, TableBody, TableCell, TableRow } from "@common/components";
import { useEffect, useState } from "react";
type UiTableProps = {
headName: React.ReactNode | undefined;
tableData: { [key: string]: string | number | boolean | object }[];
headlist: UiHeadProps[];
pageSize: number;
total: number;
children: Record<string, React.ReactNode> | undefined;
cellClick: ((index: number | undefined, headId: string) => void) | undefined;
onPageChange: (page: number) => void;
className?: string;
resetPageNo?: boolean;
isEditable?: boolean;
};
type UiHeadProps = {
id: string;
title: string;
align?: string;
type?: string;
options?: string[];
};
export function VerticalTable({
tableData,
headlist,
pageSize,
total,
children,
headName,
cellClick,
onPageChange,
className = "",
resetPageNo = false,
isEditable = false,
}: UiTableProps) {
const [cur, setCur] = useState<number>(1);
const [formData, setFormData] = useState<{ [key: string]: string | number | boolean }>({});
useEffect(() => {
if (resetPageNo) {
setCur(1);
onPageChange(1);
}
}, [onPageChange, resetPageNo]);
useEffect(() => {
if (tableData.length > 0 && tableData[0]) {
setFormData(tableData[0] as { [key: string]: string | number | boolean });
}
}, [tableData]);
const handleInputChange = (id: string, value: string | number | boolean) => {
setFormData((prev) => ({
...prev,
[id]: value,
}));
};
const handlePageChange = (page: number) => {
setCur(page);
onPageChange(page);
};
return (
<>
{headName ? (
<div className="card-header">
<h3 className="text-2xl mx-3 font-medium text-label">{headName}</h3>
</div>
) : null}
<div className={`card !m-[1.2rem] !p-0r ${className}`}>
<Table variant="secondary">
<TableBody className="text-center">
{headlist?.map((head) => (
<TableRow key={head.id}>
{/* 컬럼 제목 */}
<TableCell className="font-bold bg-blue-100 text-xs">
{head.title}
</TableCell>
{/* 데이터 값 또는 입력 필드 */}
<TableCell className="text-xs">
{children && children[head.id] ? children[head.id] : isEditable ? (
head.type === "select" ? (
<select
id={head.id}
value={formData[head.id] ?? "-"}
onChange={(e) => handleInputChange(head.id, e.target.value)}
className="w-full border border-gray-300 rounded-sm px-2 py-1 text-xl border-radius-0"
>
{head.options?.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
) : head.type === "input" ? (
<input
id={head.id}
type="text"
value={formData[head.id] ?? ""}
onChange={(e) => handleInputChange(head.id, e.target.value)}
className="w-full text-xl border-radius-0 border border-gray-300 rounded-sm px-2 py-1"
/>
) : (
<span>{formData[head.id] ?? "-"}</span>
)
) : (
<span>{formData[head.id] ?? "-"}</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{total > 0 && (
<UiPageNations
total={total}
cur={cur}
pageSize={pageSize}
onPageChange={handlePageChange}
/>
)}
</div>
</>
);
}
+225
View File
@@ -0,0 +1,225 @@
'use client'
import { getData } from '@/components/lib/api'
import ContentLayout from '@/components/ui/contentLayout'
import { GovSidoSigunguSearchForm } from '@/components/ui/govSidoSigunguSearchForm'
import { Form, SearchFormRight, SelectBox } from '@common/business_components/ui'
import { SidoSigunguSelectBox } from '../components/ui/SidoSigunguSelectBox'
import { SelectBox2 } from '@common/business_components/ui/select/index-2'
import ListDiv from '@common/business_components/ui/list_div'
import { Button, Label } from '@common/components/ui'
import { useEffect, useState, type ReactNode } from 'react'
type TitleName = {
mainTitle: string
firstCardTitle: string
secondCardTitle?: string
}
type SelectBox = {
labelNm?: Label
searchChk?: boolean
title?: string // SearchDiv의 제목 (선택 사항)
}
type Label = {
first: string
second: string
third: string
fourth: string
}
// API로부터 받아올 데이터 타입을 정의 (SelectBox에 사용될 형태)
interface ApiSelectOption {
text: string;
val: string;
}
type BaseLayout = {
children?: ReactNode[]
titleName: TitleName
form: any
onSubmitEvent: Function
graphBtn?: boolean
firstDivBtn?: boolean
searchBox?: SelectBox
enoCd?: string | number
setEnoCd?: (enoCd: string) => void
ctpvCd?: string | number
setCtpvCd?: (ctpvCd: string) => void
sigungu?: ApiSelectOption
setSigungu?: (sigungu: ApiSelectOption) => void
selectedSwageTreatmentPlant?: string | number;
onSwageTreatmentPlantChange?: (value: string | number) => void;
selectedSwageProcessingZone?: string | number;
onSwageProcessingZoneChange?: (value: string | number) => void;
}
// TODO: 나중에 이벤트 추가되면 파라미터 추가 필요 ?
/**
*
* @param children
* @param titleName : 메인 타이틀, 첫번째 요소 타이틀, 두번째 요소 타이틀
* @param form : zod 스키마
* @param onSubmitEvent : 폼 이벤트 처리
* @param graphBtn : 하단에 그래프 버튼 유무 처리
* @returns
*/
const BaseLayout = ({
children,
titleName,
form,
onSubmitEvent,
graphBtn = false,
searchBox,
enoCd,
setEnoCd,
ctpvCd,
setCtpvCd,
sigungu,
setSigungu,
selectedSwageTreatmentPlant,
onSwageTreatmentPlantChange,
selectedSwageProcessingZone,
onSwageProcessingZoneChange,
}: BaseLayout) => {
const defaultOption: ApiSelectOption = { val: '0', text: '선택' };
const [processingZoneOptions, setProcessingZoneOptions] = useState<ApiSelectOption[]>([]);
const [treatmentFacilityOptions, setTreatmentFacilityOptions] = useState<ApiSelectOption[]>([]);
const [isLoadingAreaData, setIsLoadingAreaData] = useState<boolean>(false);
useEffect(() => {
const fetchData = async () => {
if (!sigungu || sigungu.val === '0' || !sigungu.val) {
console.log("BaseLayout: Skipping fetchData because sigungu.val is:", sigungu?.val);
setProcessingZoneOptions([defaultOption]);
setTreatmentFacilityOptions([defaultOption]);
setIsLoadingAreaData(false);
return;
}
setIsLoadingAreaData(true);
try {
const queryParamsZone = new URLSearchParams({
lyrTypeNm: 'tb_ms_swage_prcs_zone',
page: '0',
size: '100000',
sort: 'string',
});
const cqlFiltersZone: string[] = [];
if (sigungu.val && sigungu.val !== '0') {
cqlFiltersZone.push(`sgg_cd='${sigungu.val}'`);
}
if (cqlFiltersZone.length > 0) {
queryParamsZone.append('cqlFiter', cqlFiltersZone.join(' and '));
}
const queryParamsFacility = new URLSearchParams({
lyrTypeNm: 'tb_il_swage_prcs_fclt',
page: '0',
size: '100000', // 요청하신 URL에 맞게 50으로 변경 가능
sort: 'string',
});
const cqlFiltersFacility: string[] = [];
if (sigungu.text && sigungu.val !== '0') {
cqlFiltersFacility.push(`sgg_nm ILIKE '%${sigungu.text}%'`);
}
if (cqlFiltersFacility.length > 0) {
queryParamsFacility.append('cqlFiter', cqlFiltersFacility.join(' and ')); // API 명세에 따라 cqlFilter 또는 cqlFiter
}
console.log("BaseLayout API URL for Zones:", `v1/fclts?${queryParamsZone.toString()}`);
console.log("BaseLayout API URL for Facilities:", `v1/fclts?${queryParamsFacility.toString()}`);
const [zoneRes, facilityRes] = await Promise.all([
getData(`v1/fclts?${queryParamsZone.toString()}`),
getData(`v1/fclts?${queryParamsFacility.toString()}`)
]);
const zones = (zoneRes.data || []).map((item: any) => ({
text: item.swtrmtZoneNm || item.name || `구역 ${item.id || item.gid || item.objectid}`,
val: item.fid || item.id || item.gid || item.objectid
}));
setProcessingZoneOptions([defaultOption, ...zones]);
console.log("BaseLayout Fetched Processing Zones:", [defaultOption, ...zones]);
const facilities = (facilityRes.data || []).map((item: any) => ({
text: item.swtrmtFcltNm || item.name || `시설 ${item.id || item.gid || item.objectid}`,
val: item.fid || item.id || item.gid || item.objectid
}));
setTreatmentFacilityOptions([defaultOption, ...facilities]);
console.log("BaseLayout Fetched Treatment Facilities:", [defaultOption, ...facilities]);
} catch (error) {
console.error("BaseLayout: 지역/시설 데이터 조회 중 오류 발생:", error);
setProcessingZoneOptions([defaultOption]);
setTreatmentFacilityOptions([defaultOption]);
} finally {
setIsLoadingAreaData(false);
}
};
fetchData();
}, [sigungu]); // sigungu 상태가 변경될 때마다 실행
return (
<ContentLayout title={titleName.mainTitle}>
<ListDiv>
{/* searchBox.title이 있으면 h3 태그로 제목을 표시합니다. */}
{searchBox?.title && (
<div className="card-header">
<h3 className="text-2xl font-medium">{searchBox.title}</h3>
<div>
{graphBtn ? <Button className="!mr-[1.2rem]">다운로드</Button> : null}
{/* 그래프 있을 시 */}
{graphBtn ? <Button className="!mr-[1.2rem]">삭제</Button> : null}
</div>
</div>
)}
<div className="flex items-center w-full">
<GovSidoSigunguSearchForm
isShowPart={true}
setRtnEnvPartValue={(val) => setEnoCd?.(val)}
setRtnEnvPartSidoValue={(val) => setCtpvCd?.(val)}
setRtnEnvPartSigunValue={setSigungu} // 선택된 시군구 값을 받을 콜백 함수 전달
>
<SelectBox2 label={"하수처리장"} value={selectedSwageTreatmentPlant} selectArray={treatmentFacilityOptions} className="col-span-3" selectClass="rounded-lg bg-white truncate" onSelectValue={(val) => onSwageTreatmentPlantChange?.(val)} />
<SelectBox2 label={"하수처리구역"} value={selectedSwageProcessingZone} selectArray={processingZoneOptions} className="col-span-3" selectClass="rounded-lg bg-white truncate" onSelectValue={(val) => onSwageProcessingZoneChange?.(val)} />
</GovSidoSigunguSearchForm>
{searchBox?.searchChk ? <SearchFormRight><Button>조회</Button></SearchFormRight> : null}
</div>
</ListDiv>
<ListDiv>
<div className="card-header">
<h3 className="text-2xl font-medium">{titleName.firstCardTitle}</h3>
<div>
{graphBtn ? <Button className="px-4 py-2 !mr-[1.2rem] bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size='sm'>다운로드</Button> : null}
{/* 그래프 있을 시 */}
{graphBtn ? <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size='sm'>삭제</Button> : null}
</div>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmitEvent)}>
<div className="card !m-[1.2rem]">
{/* 폼 정보 입력 칸 */}
{children?.[0]}
</div>
</form>
</Form>
</ListDiv>
{
children?.[1] != null ? <ListDiv>
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-medium">{titleName?.secondCardTitle}</h3>
<div>
{graphBtn ? <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size='sm'>다운로드</Button> : null}
{/* 그래프 있을 시 */}
{graphBtn ? <Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size='sm'>삭제</Button> : null}
</div>
</div>
<div className="card !m-[1.2rem]">{children?.[1]}</div>
</ListDiv> : null
}
</ContentLayout >
)
}
export default BaseLayout
+344
View File
@@ -0,0 +1,344 @@
'use client'
import { postData } from '@/components/lib/api'
import FileUploadForm from '@/components/ui/fileUploadForm'
import FormSection from '@/components/ui/formSection'
import { DatePicker, filePicked } from '@common/business_components/ui'
import { Button, 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 } from '../../components/lib/utils/excelToJson'
import BaseLayout from '../BaseLayout'
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 };
interface ApiSelectOption {
text: string;
val: string;
}
const IrSetting = () => {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
managenm: "",
region: "",
suyeonga: 0,
inputdate: new Date(),
rtkSource: "",
suyeongaPlace: "",
pattern: ""
}
})
const [uploadedFilesData, setUploadedFilesData] = useState<UploadedFileInfo[]>([
initialFileInfo, // 0: 오수관망도
initialFileInfo, // 1: 수용가-맨홀 매칭결과
initialFileInfo, // 2: 오수량 시간패턴
initialFileInfo // 3: 유량자료 등록
]);
const [selectedDate, setSelectedDate] = useState(new Date());
const [input1, setInput1] = useState('');
const [input2, setInput2] = useState('');
const [select1, setSelect1] = useState('0'); // 환경청코드
const [select2, setSelect2] = useState('0'); // 시도코드
const [select3, setSelect3] = useState<ApiSelectOption>({ val: '0', text: '전체' }); // 시군구 상태를 객체로 변경
const [select4, setSelect4] = useState<string | number>('0'); // 하수처리장코드
const [select5, setSelect5] = useState<string | number>('0'); // 하수처리구역코드
const [formResetKey, setFormResetKey] = useState(0); // FileUploadForm 리셋을 위한 key 상태
// 폼 초기화 함수
const resetAllForms = () => {
form.reset({ // react-hook-form 초기화
managenm: "",
region: "",
suyeonga: 0,
inputdate: new Date(),
rtkSource: "",
suyeongaPlace: "",
pattern: ""
});
setUploadedFilesData([ // 파일 업로드 상태 초기화
initialFileInfo,
initialFileInfo,
initialFileInfo,
initialFileInfo
]);
setSelectedDate(new Date()); // 날짜 선택 초기화
setInput1(''); // 입력 필드 초기화
setInput2(''); // 입력 필드 초기화
setSelect1('0'); // 선택 필드 초기화
setSelect2('0');
setSelect3({ val: '0', text: '전체' });
setSelect4(0);
setSelect5(0);
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:", analysisData);
// 추후 API 엔드포인트에 맞게 URL 수정 필요
const data = await postData('v1/inftwtAnls', analysisData);
console.log("Response from backend:", data);
if (data) {
alert("분석 데이터가 성공적으로 제출되었습니다.");
resetAllForms(); // 성공 시 폼 초기화 함수 호출
} else alert("분석 데이터 제출 중 오류가 발생했습니다.");
};
// 침입수 분석 실행 버튼 클릭 핸들러
const handleAnalysisClick = async () => {
console.log(uploadedFilesData);
const file1 = uploadedFilesData[0]; // 오수관망도
const file2 = uploadedFilesData[1]; // 수용가-맨홀 매칭결과
const file3 = uploadedFilesData[2]; // 오수량 시간패턴
const file4 = uploadedFilesData[3]; // 유량자료
console.log("오수관망도 정보:", file1?.file);
console.log("수용가-맨홀 매칭결과 정보:", file2);
console.log("오수량 시간패턴 정보:", file3);
console.log("유량자료 정보:", file4);
// 모든 필수 파일이 선택되었는지 확인 (UploadedFileInfo 객체와 그 안의 file 객체 모두 확인)
if (!file1?.file || !file2?.file || !file3?.file || !file4?.file) {
alert('모든 분석용 파일을 선택해주세요.');
return;
}
const filePickers = [
// filePicked 유틸리티가 File 객체를 직접 받는지, ArrayBuffer 등을 받는지 확인 필요
// 여기서는 File 객체를 받고, 내부적으로 ArrayBuffer로 변환하여 parser에 전달한다고 가정
{ file: file2, parser: excelToMatchingResultData },
{ file: file3, parser: excelToTimePatternData },
{ file: file4, parser: excelToFlowRateData }
];
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 = parsedResults.find(r => r && r.fileName === file4.file?.name)?.parsedData;
// 새로운 JSON 데이터 생성
const combinedAnalysisData = {
enoCd: select1.toString(), // 환경청코드
ctpvCd: select2.toString(), // 시도코드
sggCd: select3.val.toString(), // 시군구코드
swageTmplntCd: select4.toString(), // 하수처리장코드
swtrmtZoneCd: select5.toString(), // 하수처리구역코드
aplcnYm: new Date(selectedDate).toISOString().substring(0, 7).replace("-", ""), // 적용연월
dtrwSwtcRt: parseFloat(input1) || null, // 오수전환율 (숫자로 변환)
anlsInfo: input2, // 분석정보
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, // 유량자료
};
console.log("통합 분석 데이터 (JSON):", combinedAnalysisData);
// 수정된 registFile 함수를 호출하여 combinedAnalysisData를 전송합니다.
await registFile(combinedAnalysisData);
} catch (error) {
console.error("파일 파싱 중 오류 발생:", error);
alert("파일 처리 중 오류가 발생했습니다.");
return;
}
}
return (
<>
<BaseLayout
titleName={{ mainTitle: '침입수 분석', firstCardTitle: '침입수 분석 자료 입력', secondCardTitle: '실측자료 등록' }}
form={form}
onSubmitEvent={() => { }}
graphBtn={false}
searchBox={{ title: '분석 대상지역 선택' }}
enoCd={select1}
setEnoCd={setSelect1}
ctpvCd={select2}
setCtpvCd={setSelect2}
sigungu={select3}
setSigungu={setSelect3}
selectedSwageTreatmentPlant={select4}
onSwageTreatmentPlantChange={setSelect4}
selectedSwageProcessingZone={select5}
onSwageProcessingZoneChange={setSelect5}>
<div className="grid gap-4">
<FormSection name='suyeongaPlace' form={form} titleNm='▷ 오수관망도'>
<FileUploadForm
key={`file-upload-0-${formResetKey}`} // 폼 초기화를 위한 key
onFileSelect={(file, cd, id) => handleFileSelection(0, file, cd, id)}
showButton={false}
showPreview={true}
accept={""}
fileIdx={0} />
</FormSection>
<FormSection name='suyeonga' form={form} titleNm='▷ 수용가-맨홀 매칭결과'>
<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} />
</FormSection>
<FormSection name='pattern' form={form} titleNm='▷ 오수량 시간패턴'>
<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} />
</FormSection>
<FormSection
name='inputdate'
form={form}
titleNm='▷ 상수사용량 적용기간'>
<DatePicker
mode={"single"}
field={{
value: selectedDate, // 현재 페이지의 selectedDate 상태 사용
onChange: (date: Date | undefined) => {
if (date) {
setSelectedDate(date);
form.setValue('inputdate', date, { shouldValidate: true });
}
}
}}
selectClass="ml-5 date-picker-trigger" // date-picker.css의 스타일 적용
/>
</FormSection>
<FormSection
name='rtkSource'
form={form}
titleNm='▷ 오수전환율'>
<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; // '.'이 처음에는 올 수 없음
setInput1(newValue);
}}
className="ml-5 h-[3.2rem] text-center text-xl w-[150px]"
placeholder="예: 0.75" />
</FormSection>
<FormSection
name='analysis information'
form={form}
titleNm='▷ 분석 정보'>
<Input
value={input2}
onChange={(e) => setInput2(e.target.value)}
className="ml-5 h-[3.2rem] text-center text-xl w-[150px]" />
</FormSection>
</div>
<div className="flex items-center gap-4">
<FormSection
name='region' form={form} titleNm='▷ 유량자료 등록'>
<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} />
</FormSection>
<div className="flex justify-center mt-1">
<Button type="submit" onClick={handleAnalysisClick}>침입수분석 실행</Button>
</div>
</div>
</BaseLayout>
</>
)
}
export default IrSetting
+431
View File
@@ -0,0 +1,431 @@
"use client"
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, DatePicker } from "@common/business_components/ui"
import { Button, 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'
// zod 스키마 정의
const formSchema = 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({ // 초기 차단 깊이
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.date(),
// date: z.coerce.date(),
})
// zod 스키마 타입 추론
type FormSchemaType = z.infer<typeof formSchema>;
// 기본 폼 값 정의
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 };
interface ApiSelectOption {
text: string;
val: string;
}
export default function RTKForm() {
const [formResetKey, setFormResetKey] = useState(0); // FileUploadForm 리셋을 위한 key 상태
// FileUploadForm에서 전달받은 파일 정보를 저장할 상태 배열
// 각 인덱스는 FileUploadForm의 fileIdx와 매칭됨
const [uploadedFilesData, setUploadedFilesData] = useState<UploadedFileInfo[]>([
initialFileInfo, // 0: 오수관망도
initialFileInfo, // 1: 수용가-맨홀 매칭결과
initialFileInfo, // 2: 강우자료
]);
const [select1, setSelect1] = useState('0'); // 환경청코드 (enoCd)
const [select2, setSelect2] = useState('0'); // 시도코드 (ctpvCd)
const [select3, setSelect3] = useState<ApiSelectOption>({ val: '0', text: '전체' }); // 시군구 (sggCd)
const [select4, setSelect4] = useState<string | number>('0'); // 하수처리장 (swageTmplntCd)
const [select5, setSelect5] = useState<string | number>('0'); // 하수처리구역 (swtrmtZoneCd)
// 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');
setSelect2('0');
setSelect3({ val: '0', text: '전체' });
setSelect4('0');
setSelect5('0');
};
// 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) {
alert("분석 데이터가 성공적으로 제출되었습니다.");
resetAllForms(); // 성공 시 폼 초기화 함수 호출
} else alert("분석 데이터 제출 중 오류가 발생했습니다.");
};
// 침입수 분석 실행 버튼 클릭 핸들러
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: select1.toString(), // 환경청코드
aplcnYm: new Date(formData.date).toISOString().substring(0, 7).replace("-", ""), // 적용연월 (YYYYMM 형식)
ctpvCd: select2.toString(), // 시도코드
sggCd: select3.val.toString(), // 시군구코드
swageTmplntCd: select4.toString(), // 하수처리장코드
swtrmtZoneCd: select5.toString(), // 하수처리구역코드
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 !== 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: formData.analysisInfo, // 분석정보
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,
name: "unitFactors"
})
const { fields: initialDepthFields } = useFieldArray({
control: form.control,
name: "initialDepths"
})
const onSubmit = (data: z.infer<typeof formSchema>) => {
console.log("✅ 제출 데이터:", data)
}
// 단위도 계수 테이블 설정 (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' } }
];
// 테이블 페이지 변경 핸들러
const handlePageChange = (page: number) => {
console.log('Page changed:', page)
}
return (
// <BaseLayout searchBox={{ title: '분석 대상지역 선택' }} titleName={{ mainTitle: 'RDII 분석', firstCardTitle: 'RDII 분석자료 입력', secondCardTitle: '실측자료 등록' }} form={form} onSubmitEvent={() => { }} graphBtn={false} >
<BaseLayout
searchBox={{ title: '분석 대상지역 선택' }}
titleName={{ mainTitle: 'RDII 분석', firstCardTitle: 'RDII 분석자료 입력', secondCardTitle: '실측자료 등록' }}
form={form} // react-hook-form 인스턴스 전달
onSubmitEvent={() => { }} // BaseLayout의 form 태그에 연결될 onSubmit
graphBtn={false}
// select 상태 및 핸들러 전달
enoCd={select1}
setEnoCd={setSelect1}
ctpvCd={select2}
setCtpvCd={setSelect2}
sigungu={select3}
setSigungu={setSelect3}
selectedSwageTreatmentPlant={select4}
onSwageTreatmentPlantChange={setSelect4}
selectedSwageProcessingZone={select5}
onSwageProcessingZoneChange={setSelect5}
>
{[<div className="space-y-[1.2rem] overflow-x-auto" key="form-section">
<div className="grid grid-cols-2 gap-4">
<FormSection name="rtkSource" form={form} titleNm="○ RTK계수 출처">
<Input {...form.register("rtkSource")} className="h-12 text-center text-xl w-[225px]" />
</FormSection>
<FormSection name="rdiiArea" form={form} titleNm="○ RDII 기여면적(ha)">
<Input {...form.register("rdiiArea")} className="h-12 text-center text-xl w-[225px]" />
</FormSection></div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="mb-2 text-xl font-medium">○ 단위도 계수</Label>
<EditableFormTable
form={form}
name="unitFactors"
fields={unitFactorFields}
headlist={unitFactorHeadlist}
tableClassName="w-[400px]"
/>
</div>
<div>
<Label className="mb-[1.2rem] text-xl font-medium">○ 초기 차단 깊이</Label>
<EditableFormTable
form={form}
name="initialDepths"
fields={initialDepthFields}
headlist={initialDepthHeadlist}
tableClassName="w-[400px]"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<FormSection name="suyeonga" form={form} titleNm="○ 오수관망도"><div className="w-[350px]">
<FileUploadForm
key={`file-upload-0-${formResetKey}`} // 폼 초기화를 위한 key
onFileSelect={(file, cd, id) => handleFileSelection(0, file, cd, id)}
showButton={false}
showPreview={true}
accept={""}
fileIdx={0} />
</div></FormSection>
<FormSection name="regopm" form={form} titleNm="○ 수용가-맨홀 매칭결과"><div className="w-[350px]">
<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></FormSection>
<FormSection name="rain" form={form} titleNm="○ 강우자료"><div className="w-[350px]">
<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></FormSection>
<FormSection name='rate' form={form} titleNm='○ 오수전환율'>
<Input {...form.register("rate")} className="h-12 text-center text-xl w-[225px] ml-5" />
</FormSection>
<FormSection name='date' form={form} titleNm='○ 상수사용량 적용기간'>
{/* <DatePicker
selected={form.watch("date")}
onSelect={(date: Date | undefined) => {
if (date) {
form.setValue("date", date, { shouldValidate: true });
}
}}
mode={"single"}
className="ml-5"
/> */}
<DatePicker
mode={"single"} // mode는 DayPickerSingleProps에서 오므로 그대로 사용 가능
field={{
value: form.watch("date"), // react-hook-form의 "date" 필드 값 사용
onChange: (date: Date | undefined) => {
if (date) {
form.setValue("date", date, { shouldValidate: true });
}
}
}}
// className을 직접 사용하거나, DatePicker 내부에서 selectClass를 처리하도록 수정 필요
// 여기서는 DatePicker의 selectClass prop을 사용하고, date-picker.css의 스타일을 적용
selectClass="ml-5 date-picker-trigger"
/>
</FormSection>
<FormSection name='rate' form={form} titleNm='○ 분석 정보'>
<Input {...form.register("analysisInfo")} className="h-12 text-center text-xl w-[225px] ml-5" />
</FormSection>
</div>
<div className="flex justify-end mt-1">
<Button type="submit" onClick={handleAnalysisClick}>RDII분석 실행</Button>
</div>
</div>
]}
</BaseLayout >
)
}
+91
View File
@@ -0,0 +1,91 @@
'use client'
import React, { useState } from 'react'
import { FormField, SelectBox, UiTable } from '@common/business_components/ui'
import { Button, DatePicker, Input } from '@common/components/ui'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import FormSection from '@/components/ui/formSection'
import BaseLayout from '../BaseLayout'
import { title } from 'process'
const formSchema = z.object({
managenm: z.string(),
region: z.string(),
suyeonga: z.number(),
inputdate: z.coerce.date(),
rtkSource: z.string(),
suyeongaPlace: z.string()
})
// TODO: 분석구분에 따라서 테이블 컬럼 변경필요
const tableColumn = [
{ id: 'no', title: '번호' },
{ id: 'hasu', title: '하수처리장' },
{ id: 'guyeok', title: '처리구역' },
{ id: 'sangsu', title: '상수 사용량' },
{ id: 'rate', title: '오수 전환율' },
{ id: 'water', title: '순오수량' },
{ id: 'measureWater', title: '측정하수량' },
{ id: 'ii', title: '침입수량' },
{ id: 'date', title: '분석일자' },
{ id: 'usr', title: '분석자' }
]
const tableColumn2 = [
{ id: 'no', title: '번호' },
{ id: 'hasu', title: '하수처리장' },
{ id: 'guyeok', title: '처리구역' },
{ id: 'sangsu', title: '상수 사용량' },
{ id: 'rate', title: '오수 전환율' },
{ id: 'rtx', title: 'RTX계수출처' },
{ id: 'rdii', title: 'RDII 기여면적' },
{ id: 'water', title: '순오수량' },
{ id: 'measureWater', title: '강우유출량' },
{ id: 'ii', title: 'RDII(m2/d)' },
{ id: 'date', title: '분석일자' },
{ id: 'usr', title: '분석자' }
]
const tableInfoData = [{ 'no': '1', 'hasu': '양평하수처리장', 'guyeok': '양평', 'sangsu': '1000', 'rate': '0.5', 'water': '500', 'measureWater': '1000', 'ii': '500', 'date': '2023-10-01', 'usr': '홍길동' }]
const Result = () => {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
managenm: "",
region: "",
suyeonga: 0,
inputdate: new Date(),
rtkSource: "",
suyeongaPlace: ""
}
})
return (
<BaseLayout searchBox={{labelNm: {first: "분석기간",second: "분석구분",third: "하수처리장",fourth: "하수처리구역",},searchChk: true,}} titleName={{ mainTitle: '분석결과', firstCardTitle: '분석이력', secondCardTitle:'RDII 산정결과 현황'}} form={form} onSubmitEvent={() => { }} graphBtn={true}>
{[<div key='table1'>
<div className="mb-[1.2rem] text-xl font-medium">○ 침입수 산정결과 현황</div>
<UiTable
headName=''
headlist={tableColumn}
tableData={tableInfoData}
total={0}
pageSize={10}
children={undefined}
cellClick={(e) => { }}
onPageChange={(e) => { }}
/>
</div>,
<div key='table2'>
<UiTable
headName=''
headlist={tableColumn2}
tableData={[]}
total={0}
pageSize={10}
children={undefined}
cellClick={(e) => { }}
onPageChange={(e) => { }}
/>
</div>
]}
</BaseLayout>
)
}
export default Result
+37
View File
@@ -0,0 +1,37 @@
import "./styles/base.css";
import "./styles/components.css";
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { StoreProvider } from "./store/store-provider";
import { AuthWrapper } from "./components/lib/auth";
import { ClientLayout } from "./clientLayout";
export const metadata: Metadata = {
title: "디지털 하수관로",
description: "디지털 하수관로 시스템입니다",
keywords: ["하수관로"],
openGraph: {
title: "디지털 하수관로",
description: "디지털 하수관로 시스템입니다"
}
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const theme = (await cookies()).get("site-theme-key");
return (
<html lang="en" data-theme={theme?.value}>
<body>
<StoreProvider>
<AuthWrapper>
<ClientLayout>{children}</ClientLayout>
</AuthWrapper>
</StoreProvider>
</body>
</html>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@common/components/ui";
import React from "react";
interface InputSectionProps {
title: string;
data: { year: string; type: string }[];
sectionKey: string;
onInputChange: (section: string, index: number, field: string, value: string) => void;
}
const InputSection: React.FC<InputSectionProps> = ({ title, data, sectionKey, onInputChange }) => {
return (
<div>
{/* 섹션 제목 */}
<div className="card-header">
<h3 className="text-2xl mx-3 font-medium text-label">{title}</h3>
</div>
{/* 테이블 */}
<div className="card !m-[1.2rem] !p-0r">
<Table variant="secondary" className="w-full">
<TableHeader>
<TableRow>
<TableHead className="border border-gray-300 px-2 py-2 text-sm">년도</TableHead>
<TableHead className="border border-gray-300 px-2 py-2 text-sm">구분</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((row, index) => (
<TableRow key={index}>
<TableCell className="border border-gray-300 px-2 py-2 text-sm">
<input
type="text"
value={row.year}
onChange={(e) => onInputChange(sectionKey, index, "year", e.target.value)}
className="w-full text-xl border-radius-0 px-3 py-2 text-base text-center leading-normal"
/>
</TableCell>
<TableCell className="border border-gray-300 px-2 py-2 text-sm text-center leading-normal">
{row.type}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
};
export default InputSection;
@@ -0,0 +1,60 @@
import MsSwagePpl from "../popupComponent/MsSwagePpl";
import sewagePump from "../popupComponent/sewagePump";
import smh from "../popupComponent/smh";
import Ssc from "../popupComponent/Ssc";
import StormWaterPump from "../popupComponent/stormWaterPump";
import SwtrmtFclt from "../popupComponent/SwtrmtFclt";
type LayerComponentProps = { featureData: any; chkMainSsc?: boolean };
type LayerContentMap = {
[key: string]: {
component?: React.FC<LayerComponentProps>;
props?: any;
};
};
// 레이어 이름과 컴포넌트를 매핑
// 레이어 선택시 변경되는 팝업 컨텐츠 동적으로 변경하도록 작업
const layerContentMap: LayerContentMap = {
// TODO: gis데이터 추가되면 추가 작업 필요
"하수처리시설": {
component: SwtrmtFclt,
},
"하수처리구역": { },
"우수토실":{component: Ssc,},
"중점우수토실":{component: Ssc, }, // 중점우수토실 여부 설정
"하수관로":{component: MsSwagePpl,},
"하수맨홀":{component: smh,},
"빗물펌프":{component:StormWaterPump},
"오수펌프":{component:sewagePump},
};
// 동적 컴포넌트 반환 함수
export const getLayerContentComponent = (layerNm: string): React.FC<LayerComponentProps> | null => {
const component = layerContentMap[layerNm];
if (!component || !component.component) {
// component가 없으면 null 반환
return () => null;
}
return component.component
};
// 사용자 입력 필드 (보수 및 갱신, 청소 및 준설, 점검 및 진단)
export const handleInputChange = (section: string,
index: number,
field: string,
value: string,
dataMap: {
[key: string]: {
data: { year: string; type: string }[];
setData: React.Dispatch<React.SetStateAction<{ year: string; type: string }[]>>;
};
}
) => {
const target = dataMap[section];
if (!target) return;
const updated = [...target.data];
if (updated[index]) {
updated[index][field as keyof typeof updated[number]] = value;
}
target.setData(updated);
};
@@ -0,0 +1,73 @@
// 하수처리시설
export const SwtrmtFcltCol = [
{ id: 'swtrmtFcltNm', title: '하수처리시설명' },
{ id: 'fcltCpct', title: '시설용량' },
{ id: 'rgnExpln', title: '지역설명' },
{ id: 'cmcnYmd', title: '준공일자' },
{ id: 'oprtngStrtYmd', title: '가동개시일자' },
{ id: 'clsgYmd', title: '폐쇄일자' },
{ id: 'prcsMsrcCd', title: '처리공법코드' },
// { id: 'laeCd', title: '지자체코드' },
{ id: 'laeNm', title: '지자체명' },
{ id: 'ctpvNm', title: '시도명' },
{ id: 'sggNm', title: '시군구명' },
// { id: 'sareaCd', title: '권역코드' },
{ id: 'sareaNm', title: '권역명' },
{ id: 'lrgeSareaNm', title: '대권역명' },
{ id: 'rvrNm', title: '하천명' },
{ id: 'addr', title: '주소' },
{ id: 'daddr', title: '상세주소' },
{ id: 'rdnmAddr', title: '도로명주소' },
{ id: 'dtlRdnmAddr', title: '상세도로명주소' },
{ id: 'dschgWtstNm', title: '방류수계명' },
{ id: 'pllnArea', title: '부지면적' },
{ id: 'mngRprtvBzentyNm', title: '관리대행업제명' },
{ id: 'swtrmtPpltnCnt', title: '하수처리인구수' },
{ id: 'prcsZoneAreaSum', title: '처리구역면적합계' },
{ id: 'xcrd', title: 'X좌표' },
{ id: 'ycrd', title: 'Y좌표' },
// { id: 'oprtngSttsCd', title: '가동상태코드' },
{ id: 'rprsMsrcCn', title: '대표공법내용' },
{ id: 'sttyCrtrBod', title: '법정기준생물화학적산소요구량' },
{ id: 'sttyCrtrToc', title: '법정기준TOC' },
{ id: 'sttyCrtrSs', title: '법정기준SS' },
{ id: 'sttyCrtrTn', title: '법정기준총질소' },
{ id: 'sttyCrtrTph', title: '법정기준총인' },
{ id: 'sttyCrtrClbclCnt', title: '법정기준대장균수' },
{ id: 'bsnNm', title: '유역명' },
// { id: 'obsrvnSptCd', title: '관측지점코드' },
];
// 하수처리구역
export const SwtrmtPrcsZoneCol = [
{ id: 'swtrmtZoneSn', title: '하수처리구역 일련번호' },
{ id: 'swtrmtZoneNm', title: '하수처리구역명' },
// { id: 'swtrmtFcltCd', title: '하수처리시설코드' },
// { id: 'ctpvCd', title: '시도코드' },
{ id: 'ctpvNm', title: '시도명' },
// { id: 'sggCd', title: '시군구코드' },
{ id: 'sggNm', title: '시군구명' },
{ id: 'swtrmtFcltNm', title: '하수처리시설명' },
]
// 하수맨홀
export const MsSwageMhlCol = [
{ id: 'fid', title: 'FID' },
{ id: 'geom', title: 'geometry' },
{ id: 'ftreSmblCd', title: '지형지물부호코드' },
{ id: 'mngNo', title: '관리번호' },
// { id: 'pbadmsEmdCd', title: '행정읍면동코드' },
{ id: 'mapshtNo', title: '도엽번호' },
// { id: 'mngInstCd', title: '관리기관코드' },
{ id: 'instlYmd', title: '설치일자' },
{ id: 'lastDrdgnYmd', title: '최종준설일자' },
{ id: 'usgCd', title: '용도코드' },
// { id: 'fcltyShpCd', title: '시설물형태코드' },
{ id: 'mhlKndCd', title: '맨홀종류코드' },
{ id: 'swageMhDiam', title: '하수맨홀구경' },
{ id: 'wdthLen', title: '가로길이' },
{ id: 'vrtcLen', title: '세로길이' },
// { id: 'mqltCd', title: '재질코드' },
{ id: 'altdVl', title: '표고값' },
{ id: 'dpthlVl', title: '심도값' },
{ id: 'instMngNo', title:'기관관리번호'}]
@@ -0,0 +1,87 @@
import { VerticalTable } from '@/components/ui/verticalTable';
import React, { useState } from 'react'
import InputSection from '../InputSection';
import { handleInputChange } from '../common';
import { Button } from '@common/components/ui';
import InfilChart from './chart/infilChart';
// 하수관로
// TODO: RDII 버튼인가 그거 팝업 두개 구현 필요
const inputColumn = [
{ id: "title", title: "설치주제", type: "select", options: ["BTL", "재정사업"] },
{ id: "swtrmt_zone_cd", title: "처리구역", type: "select", options: ["구역1", "구역2"] },
{ id: "basu", title: "배수구역", type: "input" },
{ id: "mltv_dtdv_nm", title: "배수분구", type: "input" },
{ id: "wltjs", title: "지선/간선", type: "select", options: ["지선", "간선"] },
{ id: "flow", title: "흐름방식", type: "select", options: ["자연유하", "압송"] },
{ id: "place", title: "매설위치", type: "select", options: ["도로", "하상"] },
];
const SwtrmtFclt = ({ featureData }: { featureData: any }) => {
console.log("featureData", featureData);
const [isModalOpen, setIsModalOpen] = React.useState(false);
const [btnTxt, setBtnTxt] = useState<string>("");
const openModal = (e: React.MouseEvent<HTMLButtonElement>) => {
e.currentTarget.textContent && setBtnTxt(e.currentTarget.textContent)
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
};
const [repairData, setRepairData] = useState([
{ year: "2025", type: "개대체" },
{ year: "2025", type: "보수" },
]);
const [cleaningData, setCleaningData] = useState([
{ year: "2025", type: "청소" },
{ year: "2025", type: "준설" },
]);
const [inspectionData, setInspectionData] = useState([
{ year: "2025", type: "관로기술진단" },
{ year: "2025", type: "관로기술진단" },
]);
const dataMap = {
repair: { data: repairData, setData: setRepairData },
cleaning: { data: cleaningData, setData: setCleaningData },
inspection: { data: inspectionData, setData: setInspectionData },
};
const keysToExtract = [
"swtrmt_zone_cd",
"mltv_dtdv_nm",
"fid",
"lyrTypeNm",
"reg_dt",
"mdfcn_dt",
"rgtr",
"mdfr",
];
// const filteredData = Object.entries(featureData)
// .filter(([key]) => keysToExtract.includes(key)) // 필요한 키만 필터링
// .map(([key, value]) => ({ key, value: value ?? "N/A" })); // 테이블 데이터 형식으로 변환
return (
<div>
<VerticalTable headName='자체 입력정보' tableData={[]} headlist={inputColumn} pageSize={20} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void {
throw new Error("Function not implemented.");
}}></VerticalTable>
<InputSection title="보수 및 갱신" data={repairData} sectionKey="repair" onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)} />
<InputSection title="청소 및 준설" data={cleaningData} sectionKey="cleaning" onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)} />
<InputSection title="점검 및 진단" data={inspectionData} sectionKey="inspection" onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)} />
<div className="absolute bottom-0 left-0 w-full flex justify-start space-x-2 p-4">
<Button
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
onClick={openModal}
>
Infil
</Button>
<Button
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
onClick={openModal}
>
RDII
</Button>
</div>
<InfilChart btnTxt={btnTxt} isModalOpen={isModalOpen} setIsModalOpen={setIsModalOpen} closeModal={closeModal} />
</div>
)
}
export default SwtrmtFclt
@@ -0,0 +1,66 @@
// 우수토실
import Modal from '@/components/ui/modal';
import { VerticalTable } from '@/components/ui/verticalTable';
import { UiTable } from '@common/business_components/ui';
import { Button, TableHead, TableRow } from '@common/components/ui';
import React from 'react'
const inputColumn = [
{ id: "swtrmt_zone_cd", title: "처리구역", type: "input" },
{ id: "mainSscYn", title: "중점우수토실 대상여부", type: "select", options: ['Y', 'N'] },
];
const headlist = [
{ id: 'startrain', title: '강우시작일시' }, { id: 'endrain', title: '강우종료일시' }, { id: '선행건기', title: '선행건기(h)' }, { id: '강우정보', title: '강우정보' }, { id: '누적강우량', title: '누적강우량(mm)' }, { id: '지속시간', title: '지속시간(h)' }, { id: '강우강도', title: '강우강도(mm/h)' },
]
const headlist3 = [{ id: 'nm', title: '회차' }, { id: 'date', title: '측정일자' }, { id: 'measure', title: '수량(m)' }, { id: 'bod', title: 'BOD(mg/L)' }, { id: 'toc', title: 'TOC(mg/L)' }, { id: '오염부하량', title: '오염부하량(kg)' }]
const Ssc = ({ featureData, chkMainSsc }: { featureData: any, chkMainSsc?: boolean}) => {
const [isModalOpen, setIsModalOpen] = React.useState(false);
const openModal = () => {
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
};
return (
<div><VerticalTable headName='자체 입력정보' tableData={[]} headlist={inputColumn} pageSize={0} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void {
throw new Error('Function not implemented.');
}} />{!chkMainSsc ? null :
<div>
<div className="card-header">
<h3 className="text-2xl mx-3 font-medium text-label">중점우수토실 운영정보</h3>
</div>
<Button className='mx-auto block w-64 mt-3 py-2 text-white rounded-sm text-sm font-medium hover:bg-blue-700 bg-blue-600' onClick={openModal}>상세보기</Button>
<Modal isModalOpen={isModalOpen} setIsModalOpen={setIsModalOpen} closeModal={closeModal}>
<div className='grid grid-col-3'><UiTable headName='강우정보' tableData={[]} headlist={headlist} pageSize={0} total={0} children={undefined} cellClick={undefined} onPageChange={function (page: number): void {
throw new Error('Function not implemented.');
}}></UiTable>
<UiTable headName='측정정보' tableData={[]} headlist={[]} pageSize={0} total={0} cellClick={undefined} onPageChange={function (page: number): void {
throw new Error('Function not implemented.');
}}> {/* 커스텀 헤더 */}
<TableRow>
<TableHead colSpan={5} className="text-center bg-blue-100 font-bold">
수량
</TableHead>
<TableHead colSpan={3} className="text-center bg-blue-100 font-bold">
수질
</TableHead>
</TableRow>
<TableRow>
<TableHead className="text-center">측정시작일시</TableHead>
<TableHead className="text-center">측정종료일시</TableHead>
<TableHead className="text-center">측정간격(분)</TableHead>
<TableHead className="text-center">측정기기설치위치</TableHead>
<TableHead className="text-center">유량계 종류 및 모델명</TableHead>
<TableHead className="text-center">채수시작일시</TableHead>
<TableHead className="text-center">채수종료일시</TableHead>
<TableHead className="text-center">채수횟수</TableHead>
</TableRow></UiTable>
<UiTable headName='수량/수질측정 정보' tableData={[]} headlist={headlist3} pageSize={0} total={0} children={undefined} cellClick={undefined} onPageChange={function (page: number): void {
throw new Error('Function not implemented.');
}}></UiTable></div>
</Modal>
</div>}
</div>
)
}
export default Ssc
@@ -0,0 +1,49 @@
import Modal from '@/components/ui/modal';
import { VerticalTable } from '@/components/ui/verticalTable';
import { UiTable } from '@common/business_components/ui';
import { Button, TableCell } from '@common/components/ui';
import React, { useState } from 'react'
// 하수처리시설
const headList = [
{ id: "title", title: "하수도정비 기본계획보고서" },
{ id: "cad", title: "하수도대장 평면도" },]
const modalHeadList = [
{ id: "no", title: "번호" },
{ id: "year", title: "승인연도" },
{ id: "cad", title: '평면도'},
{ id: "fileNm", title: "파일명" },
{ id: "date", title: "입력시간" },
]
const SwtrmtFclt = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalTitle, setModalTitle] = useState<string>('');
const openModal = (title:string) => {
setIsModalOpen(true);
setModalTitle(title)
};
const closeModal = () => {
setIsModalOpen(false);
};
return (
<div>
<VerticalTable children={{
title: <Button onClick={() => openModal('하수도정비 기본계획보고서')}>보고서</Button>,
cad: <Button onClick={() => openModal('하수도대장 평면도')}>평면도</Button>,
}} headName='디지털 자료관 연계정보' tableData={[]} headlist={headList} pageSize={20} total={0} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void {
throw new Error("Function not implemented.");
}}></VerticalTable>
<Modal isModalOpen={isModalOpen} setIsModalOpen={setIsModalOpen} closeModal={closeModal}>
<div className='grid grid-col-2'>
<UiTable headName={modalTitle} tableData={[]} headlist={modalHeadList} pageSize={20} total={0} cellClick={undefined} children={undefined} onPageChange={function (page: number): void {
throw new Error("Function not implemented.");
}
}></UiTable>
</div>
</Modal>
</div>
)
}
export default SwtrmtFclt
@@ -0,0 +1,140 @@
import React, { useState } from 'react'
import { type ChartConfig, MainChart, CHART_FORM } from "@common/business_components";
import Modal from '@/components/ui/modal';
import { Button } from '@common/components/ui';
type ModalOpenProp = {
isModalOpen: boolean,
setIsModalOpen: (open: boolean) => void,
closeModal: () => void,
btnTxt?: string,
}
const InfilChart = ({ btnTxt, isModalOpen, setIsModalOpen, closeModal }: ModalOpenProp) => {
const [popupOpen, setPopupOpen] = React.useState(false);
const buttonRef = React.useRef<HTMLButtonElement>(null); // 버튼 참조
const [popupPosition, setPopupPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); // 팝업 위치
const openPopup = () => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect(); // 버튼 위치 계산
setPopupPosition({
top: rect.bottom + window.scrollY, // 버튼 아래쪽
left: rect.left + window.scrollX, // 버튼 왼쪽
});
}
setPopupOpen(true); // 팝업 열기
};
console.log("btnTxt", btnTxt);
const closePopup = () => {setPopupOpen(false);}; // 팝업 닫기
const chartForm = CHART_FORM.LINE;
const chartData = {
chartData1: [
{ label: "1", val: 20 },
{ label: "3", val: 40 },
{ label: "5", val: 60 },
],
chartData2: [
{ label: "1", val: 15 },
{ label: "3", val: 35 },
{ label: "5", val: 55 },
],
}
const chartConfig = Object.values(chartData).flat().reduce((config, element) => {
config[element.label] = {
label: element.label,
color: "hsl(var(--chart-1))",
};
return config;
}, {} as Record<string, { label: string; color: string }>) satisfies ChartConfig;
const getCurrentDate = () => {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, "0"); // 월은 0부터 시작하므로 +1
return `${year}-${month}`;
};
return (
<Modal isModalOpen={isModalOpen} setIsModalOpen={setIsModalOpen} closeModal={closeModal}>
{/* 분석 입력현황 조회 */}
<div className='grid grid-cols-4 gap-4'>
<div className="grid grid-rows-4 gap-4 mb-4">
<Button className="bg-white text-white w-[130px] font-bold px-4 mt-2 py-2" onClick={openPopup}>
분석 입력현황 조회
</Button>
{/* 침입수량 분석결과 */}
<div className="bg-white text-red-600 font-bold px-4 py-2 ">
침입수량 분석결과
<div className="flex items-center mt-2">
<span className="text-black text-xl font-bold">120</span>
<span className="ml-2 text-black">m³/d</span>
</div>
</div>
</div>
{/* 차트 */}
<div className="w-[500px] h-[300px] bg-white p-4">
<MainChart chartConfig={chartConfig} chartForm={chartForm} chartData={chartData} chartTitle={'관로유량'} />
</div>
</div>
{popupOpen && (
<div
className="absolute bg-gray-100 border border-gray-300 w-[320px] shadow-lg p-4 rounded"
style={{
top: popupPosition.top,
left: popupPosition.left,
zIndex: 1000,
}}
>
<div className="text-lg font-bold mb-2">분석 입력현황</div>
<div className="grid grid-cols-2 gap-2">
<div>상수사용량 정보:</div>
<div>
<input type='month' defaultValue={getCurrentDate()} className="border border-gray-300 rounded px-2 py-1"/>
</div>
<div>오수 전환율:</div>
<div>
<select className="border border-gray-300 rounded px-2 py-1">
<option>0.85</option>
<option>0.90</option>
</select>
</div>
<div>수용가-맨홀 매칭:</div>
<div>
<select className="border border-gray-300 rounded px-2 py-1">
<option>매칭(양평).shp</option>
<option>매칭(서울).shp</option>
</select>
</div>
<div>관망도 자료:</div>
<div>
<select className="border border-gray-300 rounded px-2 py-1">
<option>양평.inp</option>
<option>서울.inp</option>
</select>
</div>
{btnTxt === 'RDII' ? <><div>강우 자료:</div>
<div>
<select className="border border-gray-300 rounded px-2 py-1">
<option>R20240815.xlsx</option>
</select>
</div>
<div>RTX 계수 출처:</div>
<div>
<select className="border border-gray-300 rounded px-2 py-1">
<option>2015년 하수도정비기본계획</option>
</select>
</div>
</> : null}
</div>
<div className="flex justify-end mt-4 space-x-2">
<button className="px-4 py-2 bg-gray-600 text-white rounded" onClick={closePopup}>
저장
</button>
<button className="px-4 py-2 bg-gray-600 text-white rounded" onClick={closePopup}>
종료
</button>
</div>
</div>
)}
</Modal>
)
}
export default InfilChart
@@ -0,0 +1,18 @@
import { VerticalTable } from '@/components/ui/verticalTable';
import React from 'react'
const inputColumn = [
{ id: "swtrmt_zone_cd", title: "처리구역", type: "select", options: ["구역1", "구역2"] },
{ id: "mltv_dtdv_nm", title: "배수분구", type: "input" },
{ id: "freq", title: "설계빈도", type: "input" },
]
// 오수펌프
const sewagePump = ({ featureData }: { featureData: any }) => {
return (
<div><VerticalTable headName='자체 입력정보' tableData={[]} headlist={inputColumn} pageSize={0} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void {
throw new Error('Function not implemented.');
}} />
</div>
)
}
export default sewagePump
@@ -0,0 +1,21 @@
// 하수맨홀
import { VerticalTable } from '@/components/ui/verticalTable';
import React from 'react'
const inputColumn = [
{ id: "guyeok", title: "처리구역", type: "select", options: ["구역1", "구역2"] },
{ id: "basu", title: "배수구역", type: "input" },
{ id: "bungu", title: "배수분구", type: "input" },
{ id: "place", title: "매설위치", type: "select", options: ["도로", "하상"] },
{id:'spec', title:'규격', type:'input'},
];
const Smh = () => {
return (
<div>
<VerticalTable headName='자체 입력정보' tableData={[]} headlist={inputColumn} pageSize={20} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void {
throw new Error("Function not implemented.");
}}></VerticalTable>
</div>
)
}
export default Smh;
@@ -0,0 +1,19 @@
import { VerticalTable } from '@/components/ui/verticalTable';
import React from 'react'
const inputColumn = [
{ id: "swtrmt_zone_cd", title: "처리구역", type: "input" },
{ id: "mltv_dtdv_nm", title: "배수분구", type: "input" },
]
// 빗물펌프
const StormWaterPump = ({ featureData }: { featureData: Record<string, any> }) => {
const validKey = inputColumn.map((item) => item.id);
const filteredData = featureData && Object.entries(featureData).filter(([key]) => validKey.includes(key)).reduce((obj: Record<string, any>, [key, value]) => {obj[key] = value; return obj;}, {});
return (
<div><VerticalTable headName='자체 입력정보' tableData={[filteredData]} headlist={inputColumn} pageSize={0} total={0} children={undefined} cellClick={undefined} isEditable={true} onPageChange={function (page: number): void {
throw new Error('Function not implemented.');
}} />
</div>
)
}
export default StormWaterPump
+165
View File
@@ -0,0 +1,165 @@
'use client'
import { VerticalTable } from "@/components/ui/verticalTable";
import { Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components/ui";
import { useEffect, useRef, useState } from "react";
import { SwtrmtFcltCol, SwtrmtPrcsZoneCol } from "./components/common/table";
import { useGisContext } from "@common/business_components/ui/map/Gis";
import { getData, putData } from "@/components/lib/api";
import { getLayerContentComponent } from "./components/common";
import Modal from "@/components/ui/modal";
// TODO:
const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData: any }) => {
const { gisMap } = useGisContext() as unknown as { checkedLayerIds: React.MutableRefObject<number[]>; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; };
// const featureId = featureData.getId(); // 피처의 id 가져오기
const featureId = typeof featureData.getId === 'function'
? featureData.getId()
: featureData.fid;
const [layerNm, setLayerNm] = useState<string>(""); // 레이어 이름 상태
const [tableData, setTableData] = useState<any>(null); // 피처 데이터 상태
const [headlist, setHeadlist] = useState<{ id: string; title: string; }[]>([]); // 컬럼리스트 변경상태
const [buttonChk, setButtonChk] = useState(true)
const [fetchData, setfetchData] = useState<any>(); // api 호출여부
const contentRef = useRef<HTMLDivElement>(null);
const [isModalOpen, setIsModalOpen] = useState(false); // 모달 열기 상태
const [chkMainSsc, setChkMainSsc] = useState(false); // 중점우수토실 여부
// 피쳐데이터의 현재 레이어 이름 가져오는 함수
const getSelectedLayers = () => {
let result = null;
const layers = gisMap.getLayers().getArray(); // gisMap에서 모든 레이어 가져오기
let featureLayer: { get: (key: string) => any } = { get: (key: string) => null }; // 피처가 속한 레이어를 찾기 위한 변수
layers.forEach((layer: any) => {
const source = layer.getSource();
if (source && source.hasFeature && featureData
&& typeof featureData.getId === 'function' && source.hasFeature(featureData)) {
featureLayer = layer; // 피처가 속한 레이어 찾기
}
});
// SearchModal 클릭 이벤트와 Gis 클릭 이벤트 구분하기
if (featureLayer.get('lyrNm') === null) {
if (featureData.lyrTypeNm === 'tb_il_swage_prcs_fclt') result = '하수처리시설';
else if (featureData.lyrTypeNm === 'tb_ms_swage_prcs_zone') result = '하수처리구역';
}
else result = featureLayer.get('lyrNm');
return result;
// return featureLayer.get('lyrNm'); // 레이어 이름 출력
};
useEffect(() => {
const fetchData = async () => {
if (featureId) {
const selectedLayerName = getSelectedLayers(); // 레이어 이름 가져오기
setLayerNm(selectedLayerName); // 상태 업데이트
const layerId = featureId.split('.')[0]
const data = await getData(`v1/fclts/${featureId}?lyrTypeNm=${layerId}`);
setfetchData(data)
switch (selectedLayerName) {
case '하수처리시설':
setHeadlist(SwtrmtFcltCol);
setButtonChk(false)
break;
case '하수처리구역':
setHeadlist(SwtrmtPrcsZoneCol)
setButtonChk(false)
break;
case '중점우수토실':
setButtonChk(false)
setChkMainSsc(true)
break;
case '우수토실':
setButtonChk(true)
break;
case '하수관로':
setButtonChk(true)
break;
case '오수펌프':
setButtonChk(true)
break;
case '빗물펌프':
setButtonChk(true)
break;
case '하수맨홀':
setButtonChk(true)
break;
default:
break;
}
setTableData(data);
};
}
fetchData();
}, [featureId]);
// 저장 시 모달 띄우기
const handleSaveClick = () => {
setIsModalOpen(true); // 모달 열기
};
const handleModalSave = () => {
setIsModalOpen(false); // 모달 닫기
btnClickEvent(); // 저장 이벤트 실행
};
const handleModalClose = () => {
setIsModalOpen(false); // 모달 닫기
};
const btnClickEvent = async() => {
const bodyData: Record<string, any> = {}
if (buttonChk && contentRef.current) {
const inputElements = contentRef.current.querySelectorAll('input');
const selectElements = contentRef.current.querySelectorAll('select');
// 카멜케이스 변환 함수
const toCamelCase = (str: string) =>
str.replace(/_([a-z])/g, (_, char) => char.toUpperCase());
// select 태그의 값 가져오기
selectElements.forEach((select) => {
const camelCaseId = toCamelCase(select.id);
bodyData[camelCaseId] = select.value;
});
// input 태그의 값 가져오기
inputElements.forEach((input) => {
const camelCaseId = toCamelCase(input.id);
bodyData[camelCaseId] = input.value;
});
if (layerNm === '하수관로') bodyData['lyrTypeNm'] = 'tb_ms_swage_ppl'
else if (layerNm === '우수토실') bodyData['lyrTypeNm'] = 'tb_ms_stofcbr'
else if (layerNm === '오수펌프') bodyData['lyrTypeNm'] = 'tb_ms_swage_pmstn'
else if (layerNm === '빗물펌프') bodyData['lyrTypeNm'] = 'tb_ms_swage_pmstn'
else if(layerNm === '하수맨홀')bodyData['lyrTypeNm'] = 'tb_ms_swage_mnhl'
bodyData['fid'] = featureId
const data = await putData(`v1/fclts/${featureId}`, bodyData);
if(data.status==200){
alert('저장되었습니다')
setTableData(data)
setfetchData(data)
}
}
}
const ContentComponent = layerNm ? getLayerContentComponent(layerNm) : null;
return (
<div id="popup" className="absolute top-0 right-0 w-[350px] h-[calc(100vh-10rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-[200]">
<div className="card-header bg-primary flex items-center justify-between p-4">
<div className="flex mx-3 flex-col">
<h3 className="text-2xl font-medium text-white text-label">{layerNm} 정보</h3>
<div className="font-medium text-white text-label">(00구 00리 하수관로)</div>
</div>
<button
onClick={onClose}
className="text-white hover:text-gray-700 focus:outline-none"
aria-label="닫기"
>
✕
</button>
</div>
<div className="overflow-y-auto overflow-x-hidden flex-1" ref={contentRef}>
<VerticalTable headName='LX 연계정보' tableData={[tableData]} headlist={headlist} pageSize={20} total={0} children={undefined} cellClick={undefined} onPageChange={function (page: number): void {
throw new Error("Function not implemented.");
}}></VerticalTable>
<div className="w-full mx-5 flex items-center justify-end space-x-2">
{buttonChk ? <Button onClick={handleSaveClick} className="px-4 mr-5 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700">
저장
</Button> : null}
</div>
<Modal isModalOpen={isModalOpen} title="자체입력정보 저장" setIsModalOpen={setIsModalOpen} closeModal={handleModalClose} saveModal={handleModalSave}><div className="text-lg">정보를 저장하시겠습니까?</div></Modal>
{ContentComponent && <ContentComponent featureData={fetchData} chkMainSsc={chkMainSsc} />}
</div>
</div>
)
}
export default FeatureClickEventPopup;
+36
View File
@@ -0,0 +1,36 @@
'use client'
import Gis from '@common/business_components/ui/map/Gis';
import { SearchDiv, SelectBox, UiTable, MainDiv, Title, ContentsDiv, SearchFormLeft, SearchFormRight } from '@common/business_components';
import ListDiv from '@common/business_components/ui/list_div';
import { Button, Input } from '@common/components';
import { useEffect, useState } from "react";
import { mapService } from "@service";
const Map = () => {
const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정
const [selectedFeature,setSelectedFeature] = useState(null); // 선택된 피처 상태
const fetchData = async () => {
try {
const response = await mapService.getLyrStngs();
setTreeData(response.data); // API 응답 저장
} catch (error) {
console.error("데이터 불러오기 실패:", error);
}
};
// 컴포넌트 마운트 시 1회 실행
useEffect(() => {
fetchData();
}, []);
return (
<div className="bg-container">
<Gis treeData={treeData} isSearch={true} />
</div>
);
};
export default Map;
+162
View File
@@ -0,0 +1,162 @@
'use client'
import { SearchDiv, SearchFormLeft, SearchFormRight, SelectBox, UiTable } from '@common/business_components/ui'
import { Button, DatePicker, Input, Label, LoadingWithAnimation } from '@common/components/ui'
import React, { useEffect, useState } from 'react'
import OperFileUploadForm from '../../components/ui/operFileUploadForm'
import ListDiv from '@common/business_components/ui/list_div'
import ContentLayout from '@/components/ui/contentLayout'
import { searchCmmnCd, searchData } from '@/components/lib/common/apiHelpers'
import { tableHeadlist } from '..'
import { handleDownload } from '@/components/lib/utils/fileDownload'
import { formatDate, yearOptions } from '@/components/lib/utils/date'
import { deleteData, postData } from '@/components/lib/api'
import LoadingOverlay from '@/components/ui/LoadingBar'
const Page = () => {
// 조회조건
const [fromDate, setFromDate] = useState(() => { const date = new Date(); date.setDate(date.getDate() - 7); return date })
const [toDate, setToDate] = useState(new Date())
const [manageYear, setManageYear] = useState(yearOptions[0]?.val || '');
const [mngQu, setMngQu] = useState(yearOptions[0]?.val || '');
const [usr, setUsr] = useState('')
const [pjNm, setpjNm] = useState('')
// 파일업로드 관련 상태
const [fileNm, setFileNm] = useState('') //파일명
const [atflId, setAtflId] = useState('') //파일 ID
const [sbmrgPrevntBizSn, setSbmrgPrevntBizSn] = useState('') //파일 일련번호
const [tableData, setTableData] = useState<any>([{}]) //테이블 데이터
const [curPage, setCurPage] = useState(1) //현재 페이지
const [totalData, setTotalData] = useState(0) //총 데이터 수
const [isLoading, setIsLoading] = useState(true) //로딩 상태
const fetchData = async () => {
setIsLoading(true)
searchCmmnCd('SITEKNDCD')
try {
const { data, total } = await searchData('v1/sbmrgPrevnt', {
fromRegDt: formatDate(fromDate),
fromMngYr: manageYear,
toMngYr: mngQu,
toRegDt: formatDate(toDate),
atflNm: pjNm,
rgtrNm: usr,
page: curPage - 1,
size: '10'
})
setTableData(data)
setTotalData(total)
}
catch (error) { console.error('Error fetching data:', error) }
finally { setIsLoading(false); }
}
useEffect(() => {
setCurPage(1)
fetchData()
}, [])
// 파일 다운로드
const isChckFile = () => {
if (fileNm === '' || fileNm === undefined) {
alert('파일을 선택해주세요')
return false
}
handleDownload(`/attach-service/api/v1/download/${atflId}`, fileNm)
}
// 선택 파일 삭제
const isDeleteFile = async () => {
if (sbmrgPrevntBizSn === '' || sbmrgPrevntBizSn === undefined) {
alert('삭제할 파일을 선택해주세요')
return false
}
const data = await deleteData(`/attach-service/api/v1/atfl/attachments/${atflId}`)
if (data) {
const delData = await deleteData(`v1/sbmrgPrevnt/${sbmrgPrevntBizSn}`)
if (delData.status === 200) {
alert('삭제되었습니다.')
fetchData()
}
}
}
// 현황 등록
const registFile = async (fileName: string, year: string, quater: string, cd: string, id: string) => {
const data = await postData('v1/sbmrgPrevnt', {
atflNm: fileName,
mngYr: year,
mngQu: quater,
atflCd: cd,
atflId: id,
sbmrgPrevntBizNm: fileName
})
if (data) { alert('파일이 등록되었습니다.'); fetchData() }
}
return (
<ContentLayout title='도시침수 예방사업' isLoading={isLoading}>
<SearchDiv>
<SearchFormLeft>
<div className='flex items-center gap-5'>
<div className='flex items-center gap-2'>
<Label className="w-20 text-xl text-gray-600">등록기간</Label>
<DatePicker mode={'single'}
selected={fromDate}
onSelect={(date: Date | undefined) => { if (date) setFromDate(date) }} />
<span>~</span>
<DatePicker mode={'single'}
selected={toDate}
onSelect={(date: Date | undefined) => { if (date) setToDate(date) }} />
</div>
<div className='flex items-center gap-2'>
<Label className="w-20 text-xl text-gray-600">관리년도</Label>
<SelectBox
className="w-[90px] mr-3 ml-4"
selectClass="rounded-lg bg-white"
label=""
selectArray={yearOptions}
onSelectValue={(value: string | number) => setManageYear(String(value))}
/>
<span>~</span>
<SelectBox
className="w-[90px] mr-3 ml-4"
selectClass="rounded-lg bg-white"
label=""
selectArray={yearOptions}
onSelectValue={(value: string | number) => setMngQu(String(value))}
/>
</div>
<div className='flex items-center'>
<Label className="w-20 text-xl text-gray-600">등록자</Label>
<Input
className='w-[100px]'
value={usr}
onChange={(e) => setUsr(e.target.value)}
/>
</div>
<div className='flex items-center'>
<Label className="w-20 text-xl text-gray-600">파일명</Label>
<Input
className='w-[100px]'
value={pjNm}
onChange={(e) => setpjNm(e.target.value)}
/>
</div>
</div>
</SearchFormLeft>
<SearchFormRight>
<Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" onClick={async () => await fetchData()} >조회</Button>
</SearchFormRight>
</SearchDiv>
<ListDiv className="mt-4">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-medium">사업정보 등록현황</h3>
<div>
<Button className="px-4 py-2 !mr-[1.2rem] bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" onClick={isChckFile} >파일 다운로드</Button>
<Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size="sm" onClick={isDeleteFile} >삭제</Button>
</div>
</div>
<UiTable headName='' tableData={tableData} headlist={tableHeadlist} pageSize={10} total={totalData} cellClick={(index) => { if (index != null) { const fileName = tableData[index]?.sbmrgPrevntBizNm ?? ""; setFileNm(fileName); const id = tableData[index]?.atflId ?? ''; setAtflId(id); setSbmrgPrevntBizSn(tableData[index]?.sbmrgPrevntBizSn); } }} onPageChange={async (page: number) => { setCurPage(page); await fetchData() }}></UiTable>
</ListDiv>
<ListDiv className="mt-4">
<OperFileUploadForm title='도시침수 예방사업 사업추진현황 등록' onSubmit={async (file, year, quater, cd, id) => { await registFile(file, year, quater, cd, id) }} />
</ListDiv>
</ContentLayout>
)
}
export default Page
+17
View File
@@ -0,0 +1,17 @@
export const tableHeadlist = [
{ id: 'sbmrgPrevntBizSn', title: '일련번호' },
{ id: 'sbmrgPrevntBizNm', title: '파일명' },
{ id: 'mngYr', title: '년도' },
{ id: 'mngQu', title: '분기' },
{ id: 'regDt', title: '등록 일자' },
{ id: 'rgtr', title: '등록자' }
]
export const tableHeadlistOldPp = [
{ id: 'dcrpPplMtncBizSn', title: '일련번호' },
{ id: 'dcrpPplMtncBizNm', title: '파일명' },
{ id: 'mngYr', title: '년도' },
{ id: 'mngQu', title: '분기' },
{ id: 'regDt', title: '등록 일자' },
{ id: 'rgtr', title: '등록자' }
]
+161
View File
@@ -0,0 +1,161 @@
'use client'
import { SearchDiv, SearchFormLeft, SearchFormRight, SelectBox, UiTable } from '@common/business_components/ui'
import { Button, DatePicker, Input, Label } from '@common/components/ui'
import React, { useEffect, useState } from 'react'
import OperFileUploadForm from '../../components/ui/operFileUploadForm'
import ListDiv from '@common/business_components/ui/list_div'
import ContentLayout from '@/components/ui/contentLayout'
import { tableHeadlistOldPp } from '..'
import { handleDownload } from '@/components/lib/utils/fileDownload'
import { searchData } from '@/components/lib/common/apiHelpers'
import { formatDate, yearOptions } from '@/components/lib/utils/date'
import { deleteData, postData } from '@/components/lib/api'
const Page = () => {
const [fromDate, setFromDate] = useState<Date>(()=>{const date = new Date(); date.setDate(date.getDate()-7); return date});
const [toDate, setToDate] = useState<Date>(new Date());
const [manageYear, setManageYear] = useState(yearOptions[0]?.val || '');
const [mngQu, setMngQu] = useState(yearOptions[0]?.val || '');
const [usr, setUsr] = useState('')
const [pjNm, setpjNm] = useState('')
const [fileNm, setFileNm] = useState('')
const [atflId, setAtflId] = useState('')
const [dcrpPplMtncBizSn, setDcrpPplMtncBizSn] = useState('')
const [tableData, setTableData] = useState<any>([{}])
const [curPage, setCurPage] = useState(1)
const [totalData, setTotalData] = useState(0) //총 데이터 수
const [isLoading, setIsLoading] = useState(true) //로딩 상태
// 데이터 조회
const fetchData = async () => {
setIsLoading(true)
try {
const {data, total} = await searchData('v1/dcrpPpl', {
fromRegDt: formatDate(fromDate),
fromMngYr: manageYear,
toMngYr: mngQu,
toRegDt: formatDate(toDate),
dcrpPplMtncBizNm: pjNm,
rgtrNm: usr,
page: curPage - 1,
size: '10'
})
setTableData(data)
setTotalData(total)
console.log(data, total)
}catch (error) {
console.error('Error fetching data:', error)}finally{setIsLoading(false)}
}
useEffect(() => {
setCurPage(1)
fetchData()
}, [])
// 파일 다운로드
const isChckFile = () => {
if (fileNm === '' || fileNm === undefined) {
alert('파일을 선택해주세요')
return false
}
handleDownload(`/attach-service/api/v1/download/${atflId}`, fileNm)
return true
}
// 파일 삭제
const isDeleteFile = async () => {
if (dcrpPplMtncBizSn === '' || dcrpPplMtncBizSn === undefined) {
alert('삭제할 파일을 선택해주세요')
return false
}
const data = await deleteData(`/attach-service/api/v1/atfl/attachments/${atflId}`)
if (data) {
const delData = await deleteData(`v1/dcrpPpl/${dcrpPplMtncBizSn}`)
if (delData.status == 200) {
alert('삭제되었습니다.')
fetchData()
} else {
alert('삭제에 실패했습니다.')
}
}
}
// 파일 등록
const registFile = async (fileNm: string, year: string, quater: string, cd: string, id: string) => {
const data = await postData('v1/dcrpPpl', {
atflNm: fileNm,
mngYr: year,
mngQu: quater,
atflCd: cd,
atflId: id,
dcrpPplMtncBizNm: fileNm
})
if (data) {alert('파일이 등록되었습니다.');fetchData() }
}
return (
<ContentLayout title='노후 하수관로 정비사업' isLoading={isLoading}>
<SearchDiv>
<SearchFormLeft>
<div className='flex items-center gap-4'>
<div className='flex items-center space-x-2'>
<Label className="w-20 text-xl text-gray-600">등록기간</Label>
<DatePicker mode={'single'}
selected={fromDate}
onSelect={(date: Date | undefined) => { if (date) setFromDate(date) }} />
<span>~</span>
<DatePicker mode={'single'}
selected={toDate}
onSelect={(date: Date | undefined) => { if (date) setToDate(date) }} />
<div className='flex items-center gap-2'>
<Label className="w-20 text-xl text-gray-600">관리년도</Label>
<SelectBox
className="w-[90px] mr-3 ml-4"
selectClass="rounded-lg bg-white"
label=""
selectArray={yearOptions}
onSelectValue={(value: string | number) => setManageYear(String(value))}
/>
<span>~</span>
<SelectBox
className="w-[90px] mr-3 ml-4"
selectClass="rounded-lg bg-white"
label=""
selectArray={yearOptions}
onSelectValue={(value: string | number) => setMngQu(String(value))}
/>
</div>
<div className='flex items-center'>
<Label className="w-20 text-xl text-gray-600">등록자</Label>
<Input
className='w-[100px] !text-2xl'
value={usr}
onChange={(e) => setUsr(e.target.value)}
/>
</div>
<div className='flex items-center'>
<Label className="w-20 text-xl text-gray-600">파일명</Label>
<Input
className='w-[100px] !text-2xl'
value={pjNm}
onChange={(e) => {setpjNm(e.target.value)}}
/>
</div>
</div>
</div>
</SearchFormLeft>
<SearchFormRight>
<Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 mr-2" size="sm" onClick={async () => {await fetchData()}}>조회</Button>
</SearchFormRight>
</SearchDiv>
<ListDiv className="mt-4">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-medium">사업정보 등록현황</h3>
<div>
<Button className="px-4 py-2 !mr-[1.2rem] bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" onClick={isChckFile}>파일 다운로드</Button>
<Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size="sm" onClick={isDeleteFile}>삭제</Button>
</div>
</div>
<UiTable headName='' tableData={tableData} headlist={tableHeadlistOldPp} pageSize={10} total={totalData} children={undefined} cellClick={(index) => { if (index != null) { const fileName = tableData[index]?.dcrpPplMtncBizNm ?? ""; setFileNm(fileName); const id = tableData[index]?.atflId ?? ''; setAtflId(id); setDcrpPplMtncBizSn(tableData[index]?.dcrpPplMtncBizSn) } }} onPageChange={async (page:number)=>{setCurPage(page); await fetchData()}}></UiTable>
</ListDiv>
<ListDiv className="mt-4">
<OperFileUploadForm title='노후 하수관로 정비사업 주진현황 등록' onSubmit={async (file, year, quater, cd, id) => { await registFile(file, year, quater, cd, id) }} />
</ListDiv>
</ContentLayout>
)
}
export default Page
+28
View File
@@ -0,0 +1,28 @@
'use client'
import Link from "next/link";
import { Button } from "@common/components/ui";
export default function Home() {
return (
<div>
<div><h3 className="text-2xl text-bold gap-4 mx-4">하수관망도</h3>
<div className="flex flex-row gap-4 p-4">
<Link href={"/map"}><Button>하수관망도</Button></Link>
</div>
</div>
<div><h3 className="text-2xl text-bold gap-4 mx-4">운영정보 관리</h3>
<div className="flex flex-row gap-4 p-4">
<Link href={"/oper/floodPre"}><Button>도시침수 예방사업</Button></Link>
<Link href={"/oper/oldPp"}><Button>노후 하수관로 정비사업</Button></Link>
</div>
</div>
<div><h3 className="text-2xl text-bold gap-4 mx-4">I/I 분석(기초정보 설정)</h3>
<div className="flex flex-row gap-4 p-4">
<Link href={"/ii/irsetting"}><Button>침입수 분석</Button></Link>
<Link href={"/ii/rdii"}><Button>RDII 분석</Button></Link>
<Link href={"/ii/result"}><Button>분석결과</Button></Link>
</div>
</div>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export * from './map'
+37
View File
@@ -0,0 +1,37 @@
import axios from 'axios'
/**
* 관망도 API
*/
const API_URL = `${process.env.NEXT_PUBLIC_SEWERAGE_API_URL}/v1/map`
export type LyrStngProps = {
lyr_sn: string
lyr_nm: string
upper_lyr_sn: string
drawOrdr: string
flterSql: string
gobjTy: string
lyrTpNm: string
useYn: string
minViewSize: string
iconNm: string
lblAtrbNm: string
fontNm: string
fontIntnColor: string
fontExtnColor: string
fontExtnWdth: string
lnWdth: string
lnColor: string
polgnColor: string
initEprssYn: string
clickPsblYn: string
}
export const mapService = {
//레이어 설정 조회
getLyrStngs: () => {
return axios.get(`${API_URL}/lyrStngs`)
}
}
+1
View File
@@ -0,0 +1 @@
export { useUserStore, type IUserInfo } from "./user-store";
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { createContext, useRef } from "react";
import { createUserStore } from "../store/user-store";
type StoreContextType = {
userStore: ReturnType<typeof createUserStore>;
};
export const StoreContext = createContext<StoreContextType | undefined>(
undefined,
);
export const StoreProvider = ({ children }: { children: React.ReactNode }) => {
const storeRef = useRef<StoreContextType | null>(null);
if (storeRef.current === null) {
storeRef.current = {
userStore: createUserStore(),
};
}
return (
<StoreContext.Provider value={storeRef.current}>
{children}
</StoreContext.Provider>
);
};
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { createStore } from "zustand/vanilla";
export interface IUserInfo {
regDt: string;
mdfcnDt: string;
rgtr: string;
mdfr: string;
adminRoleYn: string;
userUnqId: string;
userId: string;
userName: string;
email: string;
encryptedPassword: string;
refreshToken: string;
userStateCode: string;
lastLoginDate: string;
loginFailCount: number;
pswdHnt: string;
pswdAns: string;
mphn: string;
telno: string;
zip: string;
addr: string;
daddr: string;
laeSeCd: string;
ogdpSeCd: string;
aprvYn: string;
autzrId: string;
expryYmd: string;
lastPswdChgDt: string;
googleId: string;
kakaoId: string;
naverId: string;
isSocialUser: boolean;
hasPassword: boolean;
}
export type UserState = {
userInfo: IUserInfo | null;
};
type UserStore = {
userInfo: IUserInfo | null;
setUserInfo: (userInfo: IUserInfo | null) => void;
};
// ✅ zustand 기본 create + persist 적용
export const useUserStore = create<UserStore>()(
persist(
(set) => ({
userInfo: null,
setUserInfo: (userInfo) => set({ userInfo }),
}),
{
name: "user-store", // localStorage 키
partialize: (state) => ({ userInfo: state.userInfo }), // 선택적 저장
},
),
);
export const defaultInitState: UserState = {
userInfo: null,
};
export const createUserStore = (initState: UserState = defaultInitState) => {
return createStore<UserStore>()((set) => ({
...initState,
setUserInfo: (userInfo: IUserInfo | null) => set(() => ({ userInfo })),
}));
};
+12
View File
@@ -0,0 +1,12 @@
@import "@common/assets/styles/base.css";
@import "@common/assets/styles/diveicon.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
font-family: "Noto Sans KR", sans-serif;
font-size: 10px;
}
+29
View File
@@ -0,0 +1,29 @@
@tailwind components;
@layer components {
.card {
@apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#666];
}
.card-border {
@apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur;
}
.card-header {
@apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4;
}
.card-footer {
@apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4;
}
[data-theme="black"] {
.card-border {
@apply border-[#3f4042] bg-[rgba(255,255,255,0.065)];
}
.card-header {
@apply border-b-[#3f4042];
}
}
input[type="file"] {
@apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer;
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_PORT: string;
readonly VITE_PROXY_HOST: string;
readonly VITE_SERVER_API_URL: string;
readonly VITE_SITE_ID: string;
readonly VITE_CLAIM_NAME: string;
readonly VITE_ACCESS_TOKEN: string;
readonly VITE_REFRESH_TOKEN: string;
readonly VITE_AUTH_USER_ID: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+16
View File
@@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;
+82
View File
@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from "next/server";
const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL || "";
const GEOSERVER_URL = process.env.NEXT_PUBLIC_GEOSERVER_URL || "";
const SEWERAGE_API_URL = process.env.NEXT_PUBLIC_SEWERAGE_API_URL || "";
export function middleware(req: NextRequest) {
console.log("Middleware 진입:", req.nextUrl.pathname);
const pathname = req.nextUrl.pathname;
const searchParams = req.nextUrl.searchParams;
if (pathname.startsWith("/server")) {
const queryParams = searchParams.toString();
const newUrl = `${SERVER_API_URL}${pathname.replace("/server", "")}${
queryParams ? `?${queryParams}` : ""
}`;
console.log(`[Middleware] Rewriting from '/server' path to: ${newUrl}`);
return NextResponse.rewrite(new URL(newUrl));
}
if (pathname.startsWith("/geoserver")) {
const queryParams = searchParams.toString();
const newUrl = `${GEOSERVER_URL}${pathname.replace("/geoserver", "")}${
queryParams ? `?${queryParams}` : ""
}`;
console.log(`[Middleware] Rewriting from '/geoserver' path to: ${newUrl}`);
return NextResponse.rewrite(new URL(newUrl));
}
if (pathname.startsWith("/sewerage-service/api/v1/map/geometry")) {
const queryParams = searchParams.toString();
const newUrl = `${SEWERAGE_API_URL}${pathname.replace("/sewerage-service/api", "")}${
queryParams ? `?${queryParams}` : ""
}`;
console.log(`[Middleware] Rewriting from '/geoserver' path to: ${newUrl}`);
return NextResponse.rewrite(new URL(newUrl));
}
if (pathname.startsWith("/attach-service") || pathname.startsWith("/cms-service")) {
const requestHeaders = new Headers(req.headers);
const token = req.cookies.get(CLAIM_NAME)?.value;
const userId = req.cookies.get(AUTH_USER_ID)?.value;
const newUrl = `${SERVER_API_URL}${pathname}`;
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
console.log(
`[Middleware] Rewriting from '/attach-service' path to: ${newUrl}`,
);
return NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
});
}
if (pathname.startsWith("/user-service")) {
const requestHeaders = new Headers(req.headers);
const newUrl = `${SERVER_API_URL}${pathname}`;
console.log(
`[Middleware] Rewriting from '/user-service' path to: ${newUrl}`,
);
return NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
});
}
return NextResponse.next();
}
export const config = {
matcher: [
"/server/:path*",
"/geoserver/:path*",
"/attach-service/:path*",
"/user-service/:path*",
"/cms-service/:path*",
"/sewerage-service/api/v1/map/geometry/:path*",
],
};
+61
View File
@@ -0,0 +1,61 @@
import type { NextConfig } from "next";
import webpack from "webpack";
const geoServerUrl = process.env.NEXT_PUBLIC_GEOSERVER_URL?.replace(/\/$/, '') ?? '';
const cnServerUrl = process.env.NEXT_PUBLIC_SERVER_API_URL?.replace(/\/$/, '') ?? '';
const nextConfig: NextConfig = {
// basePath: "/sn",
typescript: {
ignoreBuildErrors: true, // 이 줄 추가
},
eslint: {
ignoreDuringBuilds: true,
},
webpack: (config) => {
config.plugins.push(
new webpack.DefinePlugin({
"import.meta.env.VITE_PORT": JSON.stringify(
process.env.NEXT_PUBLIC_PORT
),
"import.meta.env.VITE_PROXY_HOST": JSON.stringify(
process.env.NEXT_PUBLIC_PROXY_HOST
),
"import.meta.env.VITE_SERVER_API_URL": JSON.stringify(
process.env.NEXT_PUBLIC_SERVER_API_URL
),
"import.meta.env.VITE_SITE_ID": JSON.stringify(process.env.SITE_ID),
"import.meta.env.VITE_CLAIM_NAME": JSON.stringify(
process.env.NEXT_PUBLIC_CLAIM_NAME
),
"import.meta.env.VITE_ACCESS_TOKEN": JSON.stringify(
process.env.NEXT_PUBLIC_ACCESS_TOKEN
),
"import.meta.env.VITE_REFRESH_TOKEN": JSON.stringify(
process.env.NEXT_PUBLIC_REFRESH_TOKEN
),
"import.meta.env.VITE_AUTH_USER_ID": JSON.stringify(
process.env.NEXT_PUBLIC_AUTH_USER_ID
),
"process.env.SITE_ID": JSON.stringify(process.env.NEXT_PUBLIC_SITE_ID),
})
);
return config;
},
// async rewrites() {
// return [
// {
// source: '/geoserver/:hasudo*',
// destination: `${geoServerUrl}/:hasudo*`,
// },
// {
// source: '/attach-service/:path*',
// destination: `${cnServerUrl}/attach-service/:path*`,
// },
// ];
// },
};
export default nextConfig;
+51
View File
@@ -0,0 +1,51 @@
{
"name": "sn",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3002",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@common/assets": "workspace:*",
"@common/business_components": "workspace:*",
"@common/components": "workspace:*",
"@frontend-opensource/use-react-hooks": "^1.1.4",
"@hookform/resolvers": "^4.1.3",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@tanstack/react-query": "^5.67.1",
"@tanstack/react-table": "^8.21.2",
"axios": "^1.8.1",
"chart.js": "^4.4.8",
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"next": "15.1.7",
"react-chartjs-2": "^5.3.0",
"react-hook-form": "^7.54.2",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.2",
"zustand": "^5.0.3",
"ol": "^7.3.0",
"ol-ext": "^4.0.11",
"proj4": "^2.9.0",
"tus-js-client": "^4.3.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@faker-js/faker": "^9.5.0",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.13.1",
"@types/webpack": "^5.28.5",
"eslint": "^9",
"eslint-config-next": "15.1.7",
"postcss": "^8",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"steiger": "^0.5.5",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1016 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 785 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 791 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Some files were not shown because too many files have changed in this diff Show More