Merge remote-tracking branch 'origin/iipbs/sn/dev'
@@ -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
|
||||
@@ -14,6 +14,8 @@ dist
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
apps/wr/.next/**/*
|
||||
apps/fo/.next/**/*
|
||||
|
||||
# env
|
||||
.env*
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 >
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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: '등록자' }
|
||||
]
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './map'
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { useUserStore, type IUserInfo } from "./user-store";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 })),
|
||||
}));
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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*",
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 961 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 970 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1016 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 468 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 757 B |
|
After Width: | Height: | Size: 785 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 791 B |
|
After Width: | Height: | Size: 782 B |
|
After Width: | Height: | Size: 782 B |
|
After Width: | Height: | Size: 1.2 KiB |