diff --git a/.github/workflows/sn_build.yml b/.github/workflows/sn_build.yml new file mode 100644 index 000000000..51d9f5d98 --- /dev/null +++ b/.github/workflows/sn_build.yml @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index c40b4dc6e..d0bb49da9 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ dist # misc .DS_Store +apps/wr/.next/**/* +apps/fo/.next/**/* # env .env* diff --git a/apps/fo/next-env.d.ts b/apps/fo/next-env.d.ts new file mode 100644 index 000000000..1b3be0840 --- /dev/null +++ b/apps/fo/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/sn/.gitignore b/apps/sn/.gitignore new file mode 100644 index 000000000..364691fc4 --- /dev/null +++ b/apps/sn/.gitignore @@ -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 \ No newline at end of file diff --git a/apps/sn/.prettierrc b/apps/sn/.prettierrc new file mode 100644 index 000000000..b4bfed357 --- /dev/null +++ b/apps/sn/.prettierrc @@ -0,0 +1,3 @@ +{ + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/apps/sn/app/api/proxy/[...url]/route.ts b/apps/sn/app/api/proxy/[...url]/route.ts new file mode 100644 index 000000000..791562e27 --- /dev/null +++ b/apps/sn/app/api/proxy/[...url]/route.ts @@ -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 = { + "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"); +} diff --git a/apps/sn/app/clientLayout.tsx b/apps/sn/app/clientLayout.tsx new file mode 100644 index 000000000..e8fdbe032 --- /dev/null +++ b/apps/sn/app/clientLayout.tsx @@ -0,0 +1,7 @@ +'use client' + +import { BaseLayout } from "@common/components"; + +export function ClientLayout({ children }: { children: React.ReactNode }) { + return {children}; +} \ No newline at end of file diff --git a/apps/sn/app/components/lib/api/attachment/attachment-interface.tsx b/apps/sn/app/components/lib/api/attachment/attachment-interface.tsx new file mode 100644 index 000000000..351e76fcb --- /dev/null +++ b/apps/sn/app/components/lib/api/attachment/attachment-interface.tsx @@ -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 { + 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; + +export interface AttachmentService { + getAttachmentsList: (params: AttachmentParams) => Promise; + getAttachmentsInfo: (atflCd: string) => Promise; + deleteAttachmentByAtflCd: (atflCd: string) => Promise; +} diff --git a/apps/sn/app/components/lib/api/attachment/attachment-service.tsx b/apps/sn/app/components/lib/api/attachment/attachment-service.tsx new file mode 100644 index 000000000..36390c0fb --- /dev/null +++ b/apps/sn/app/components/lib/api/attachment/attachment-service.tsx @@ -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 { + const response = await getData(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}`); + return response.data; + } + + public async getAttachmentsInfo(atflCd: string): Promise { + const response = await getData( + `${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflCd}`, + ); + return response.data; + } + + public async deleteAttachmentByAtflCd(atflCd: string): Promise { + await deleteData(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/atflCd/${atflCd}`); + } +} + +export const AttachmentApi = new AttachmentApiService(); diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts new file mode 100644 index 000000000..84f38002f --- /dev/null +++ b/apps/sn/app/components/lib/api/index.ts @@ -0,0 +1,68 @@ +const BASE_URL = process.env.NEXT_PUBLIC_SEWERAGE_API_URL || ''; + +export const postData = async (url: string, data: object): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { + 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; +}; \ No newline at end of file diff --git a/apps/sn/app/components/lib/auth/api/login-service.tsx b/apps/sn/app/components/lib/auth/api/login-service.tsx new file mode 100644 index 000000000..44b7da488 --- /dev/null +++ b/apps/sn/app/components/lib/auth/api/login-service.tsx @@ -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; + silentRefresh(): Promise; +} + +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 { + 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 { + 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(); diff --git a/apps/sn/app/components/lib/auth/index.tsx b/apps/sn/app/components/lib/auth/index.tsx new file mode 100644 index 000000000..97e142225 --- /dev/null +++ b/apps/sn/app/components/lib/auth/index.tsx @@ -0,0 +1,2 @@ +export { LoginApi } from "./api/login-service"; +export { AuthWrapper } from "./lib/auth-wrapper"; diff --git a/apps/sn/app/components/lib/auth/lib/auth-wrapper.tsx b/apps/sn/app/components/lib/auth/lib/auth-wrapper.tsx new file mode 100644 index 000000000..0b72cc768 --- /dev/null +++ b/apps/sn/app/components/lib/auth/lib/auth-wrapper.tsx @@ -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( + `${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 }; diff --git a/apps/sn/app/components/lib/auth/lib/token-manager.tsx b/apps/sn/app/components/lib/auth/lib/token-manager.tsx new file mode 100644 index 000000000..9feab4af0 --- /dev/null +++ b/apps/sn/app/components/lib/auth/lib/token-manager.tsx @@ -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, + 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(); diff --git a/apps/sn/app/components/lib/auth/model/login-interface.tsx b/apps/sn/app/components/lib/auth/model/login-interface.tsx new file mode 100644 index 000000000..5aaf53044 --- /dev/null +++ b/apps/sn/app/components/lib/auth/model/login-interface.tsx @@ -0,0 +1,9 @@ +export interface ILogin { + userId: string; + password: string; +} + +export interface LoginService { + login(payload: ILogin): Promise; + silentRefresh(): Promise; +} diff --git a/apps/sn/app/components/lib/common/apiHelpers.ts b/apps/sn/app/components/lib/common/apiHelpers.ts new file mode 100644 index 000000000..caaa3b050 --- /dev/null +++ b/apps/sn/app/components/lib/common/apiHelpers.ts @@ -0,0 +1,24 @@ +import { getData } from "../api"; + +export const searchData = async>(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) + ); + 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; +} \ No newline at end of file diff --git a/apps/sn/app/components/lib/utils/cookie.tsx b/apps/sn/app/components/lib/utils/cookie.tsx new file mode 100644 index 000000000..8f5d0011b --- /dev/null +++ b/apps/sn/app/components/lib/utils/cookie.tsx @@ -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); +} diff --git a/apps/sn/app/components/lib/utils/date.ts b/apps/sn/app/components/lib/utils/date.ts new file mode 100644 index 000000000..75e3cbf32 --- /dev/null +++ b/apps/sn/app/components/lib/utils/date.ts @@ -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() }; + }); diff --git a/apps/sn/app/components/lib/utils/excelToJson.ts b/apps/sn/app/components/lib/utils/excelToJson.ts new file mode 100644 index 000000000..837b099cc --- /dev/null +++ b/apps/sn/app/components/lib/utils/excelToJson.ts @@ -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; +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; +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; +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; +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; +}; \ No newline at end of file diff --git a/apps/sn/app/components/lib/utils/fileDownload.ts b/apps/sn/app/components/lib/utils/fileDownload.ts new file mode 100644 index 000000000..681203196 --- /dev/null +++ b/apps/sn/app/components/lib/utils/fileDownload.ts @@ -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); + + // 다운로드를 트리거할 가짜 태그 생성 + 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); + } +}; \ No newline at end of file diff --git a/apps/sn/app/components/lib/utils/fileUploadUtil.tsx b/apps/sn/app/components/lib/utils/fileUploadUtil.tsx new file mode 100644 index 000000000..9b3721cd0 --- /dev/null +++ b/apps/sn/app/components/lib/utils/fileUploadUtil.tsx @@ -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"); +}; diff --git a/apps/sn/app/components/lib/utils/index.tsx b/apps/sn/app/components/lib/utils/index.tsx new file mode 100644 index 000000000..0b9999c87 --- /dev/null +++ b/apps/sn/app/components/lib/utils/index.tsx @@ -0,0 +1,4 @@ +export { getCookie, setCookie, removeCookie } from "./cookie"; +export { handleUpload } from "./fileUploadUtil"; +export * from "./fileDownload"; +export * from './date' diff --git a/apps/sn/app/components/ui/EditableFormTable.tsx b/apps/sn/app/components/ui/EditableFormTable.tsx new file mode 100644 index 000000000..89fd8f005 --- /dev/null +++ b/apps/sn/app/components/ui/EditableFormTable.tsx @@ -0,0 +1,131 @@ +import { Input, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components/ui"; +import type { UseFormReturn, FieldArrayWithId, Path, FieldValues } from "react-hook-form"; +import { Controller } from "react-hook-form"; + +// 편집 가능한 테이블 헤더 항목 인터페이스 +export interface EditableTableHeadItem { + id: keyof TItem | 'response'; // 'response'는 직접적인 키가 아닐 수 있지만 특별히 처리될 수 있음 + title: string; + editable?: boolean; // 편집 가능 여부 + width?: string; // 컬럼 너비 추가 + align?: 'left' | 'center' | 'right'; // 정렬 + cellClassName?: string; // 셀 클래스 이름 + inputClassName?: string; // 입력 필드 클래스 이름 + inputProps?: React.ComponentProps; // Input 컴포넌트에 전달할 props (예: type="number") +} + +// EditableFormTable 컴포넌트 props 타입 정의 +type EditableFormTableProps< + TFormValues extends FieldValues, + TFieldArrayName extends Path, + TFieldArrayItem extends FieldValues = TFormValues[TFieldArrayName] extends (infer U)[] ? U extends FieldValues ? U : never : never +> = { + form: UseFormReturn; // react-hook-form의 form 객체 + name: TFieldArrayName; // 필드 배열 이름 (예: "unitFactors") + fields: FieldArrayWithId[]; // useFieldArray에서 반환된 필드 배열 + headlist: EditableTableHeadItem[]; // 테이블 헤더 설정 + tableClassName?: string; // 테이블 클래스 이름 + headerClassName?: string; // 헤더 클래스 이름 + bodyClassName?: string; // 바디 클래스 이름 + rowClassName?: string; // 행 클래스 이름 +}; + +// EditableFormTable 컴포넌트 +export function EditableFormTable< + TFormValues extends FieldValues, + TFieldArrayName extends Path, + TFieldArrayItem extends FieldValues = TFormValues[TFieldArrayName] extends (infer U)[] ? U extends FieldValues ? U : never : never +>({ + form, + name, + fields, + headlist, + tableClassName = "w-full", + headerClassName = "font-bold", + bodyClassName = 'text-center', + rowClassName = "", +}: EditableFormTableProps) { + return ( +
+ + + + {headlist.map((head) => ( + + {head.title} + + ))} + + + + {fields.map((rowItem, rowIndex) => ( + + {headlist.map((head) => { + const fieldName = `${name}.${rowIndex}.${String(head.id)}` as Path; + const cellValue = (rowItem as any)[head.id as string]; + + return ( + + {head.editable ? ( + { + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + + if (newValue === "") { + field.onChange(""); // 빈 문자열 허용 + return; + } + + // 숫자와 '.'만 허용 (단일 '.' 허용) + const isValidChars = /^[0-9]*\.?[0-9]*$/.test(newValue); + if (!isValidChars) { + return; // 유효하지 않은 문자 또는 여러 개의 '.' 포함 시 변경 무시 + } + + // '.' 기호가 포함된 경우, 처음에는 올 수 없음 (예: '.5' 방지, '0.5' 유도) + // 이 조건은 사용자의 요구사항에 따라 조정될 수 있습니다. + // 현재 로직은 '.5'와 같은 입력을 허용하지 않습니다. + if (newValue.includes('.') && newValue.startsWith('.')) { + // 만약 '.'만 입력된 경우 허용하려면 아래 주석 해제 + // if (newValue === '.') { + // field.onChange(newValue); + // return; + // } + return; + } + + field.onChange(newValue); + }; + + return ( + + ); + }} + /> + ) : ( + {String(cellValue ?? '')} + )} + + ); + })} + + ))} + +
+
+ ); +} \ No newline at end of file diff --git a/apps/sn/app/components/ui/LoadingBar/index.tsx b/apps/sn/app/components/ui/LoadingBar/index.tsx new file mode 100644 index 000000000..37991d1d2 --- /dev/null +++ b/apps/sn/app/components/ui/LoadingBar/index.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { LoadingWithAnimation } from '@common/components/ui/loading'; + +interface LoadingOverlayProps { + isLoading: boolean; +} + +const LoadingOverlay: React.FC = ({ isLoading }) => { + if (!isLoading) return null; + + return ( +
+
+ +
+
+ ); +}; + +export default LoadingOverlay; \ No newline at end of file diff --git a/apps/sn/app/components/ui/SidoSigunguSelectBox.tsx b/apps/sn/app/components/ui/SidoSigunguSelectBox.tsx new file mode 100644 index 000000000..0ea8bb510 --- /dev/null +++ b/apps/sn/app/components/ui/SidoSigunguSelectBox.tsx @@ -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 = { + 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 ( +
+ ({ val: opt.id, text: opt.name }))} + value={sido} + onSelectValue={val => onSidoChange(Number(val))} + /> + ({ val: opt.id, text: opt.name }))} + value={sigungu} + onSelectValue={val => onSigunguChange(Number(val))} + /> +
+ ); +} \ No newline at end of file diff --git a/apps/sn/app/components/ui/YearRangeSelectBox.tsx b/apps/sn/app/components/ui/YearRangeSelectBox.tsx new file mode 100644 index 000000000..3dc8818fc --- /dev/null +++ b/apps/sn/app/components/ui/YearRangeSelectBox.tsx @@ -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 ( +
+ onChange({ start: String(val), end })} + value={start} + /> + ~ + onChange({ start, end: String(val) })} + value={end} + /> +
+ ); +} \ No newline at end of file diff --git a/apps/sn/app/components/ui/contentLayout.tsx b/apps/sn/app/components/ui/contentLayout.tsx new file mode 100644 index 000000000..75d61235f --- /dev/null +++ b/apps/sn/app/components/ui/contentLayout.tsx @@ -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 ( + + + <ContentsDiv> + <LoadingOverlay isLoading={isLoading ?? false}/> + {children} + </ContentsDiv> + </MainDiv> + ) +} + +export default ContentLayout \ No newline at end of file diff --git a/apps/sn/app/components/ui/fileUploadForm.tsx b/apps/sn/app/components/ui/fileUploadForm.tsx new file mode 100644 index 000000000..0760b3252 --- /dev/null +++ b/apps/sn/app/components/ui/fileUploadForm.tsx @@ -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 \ No newline at end of file diff --git a/apps/sn/app/components/ui/formSection.tsx b/apps/sn/app/components/ui/formSection.tsx new file mode 100644 index 000000000..90bc8f735 --- /dev/null +++ b/apps/sn/app/components/ui/formSection.tsx @@ -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 \ No newline at end of file diff --git a/apps/sn/app/components/ui/govSidoSigunguSearchForm.tsx b/apps/sn/app/components/ui/govSidoSigunguSearchForm.tsx new file mode 100644 index 000000000..b46f2b481 --- /dev/null +++ b/apps/sn/app/components/ui/govSidoSigunguSearchForm.tsx @@ -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 }; \ No newline at end of file diff --git a/apps/sn/app/components/ui/modal.tsx b/apps/sn/app/components/ui/modal.tsx new file mode 100644 index 000000000..f1dda48a6 --- /dev/null +++ b/apps/sn/app/components/ui/modal.tsx @@ -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 \ No newline at end of file diff --git a/apps/sn/app/components/ui/operFileUploadForm.tsx b/apps/sn/app/components/ui/operFileUploadForm.tsx new file mode 100644 index 000000000..bd1c67996 --- /dev/null +++ b/apps/sn/app/components/ui/operFileUploadForm.tsx @@ -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 \ No newline at end of file diff --git a/apps/sn/app/components/ui/verticalTable.tsx b/apps/sn/app/components/ui/verticalTable.tsx new file mode 100644 index 000000000..ae3068a67 --- /dev/null +++ b/apps/sn/app/components/ui/verticalTable.tsx @@ -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> + </> + ); +} \ No newline at end of file diff --git a/apps/sn/app/ii/BaseLayout.tsx b/apps/sn/app/ii/BaseLayout.tsx new file mode 100644 index 000000000..3974b2407 --- /dev/null +++ b/apps/sn/app/ii/BaseLayout.tsx @@ -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 \ No newline at end of file diff --git a/apps/sn/app/ii/irsetting/page.tsx b/apps/sn/app/ii/irsetting/page.tsx new file mode 100644 index 000000000..be24229a0 --- /dev/null +++ b/apps/sn/app/ii/irsetting/page.tsx @@ -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 \ No newline at end of file diff --git a/apps/sn/app/ii/rdii/page.tsx b/apps/sn/app/ii/rdii/page.tsx new file mode 100644 index 000000000..5f87c52b0 --- /dev/null +++ b/apps/sn/app/ii/rdii/page.tsx @@ -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 > + ) +} diff --git a/apps/sn/app/ii/result/page.tsx b/apps/sn/app/ii/result/page.tsx new file mode 100644 index 000000000..6e1d1b067 --- /dev/null +++ b/apps/sn/app/ii/result/page.tsx @@ -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 \ No newline at end of file diff --git a/apps/sn/app/layout.tsx b/apps/sn/app/layout.tsx new file mode 100644 index 000000000..f983b20bc --- /dev/null +++ b/apps/sn/app/layout.tsx @@ -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> + ); +} diff --git a/apps/sn/app/map/SearchModal.tsx b/apps/sn/app/map/SearchModal.tsx new file mode 100644 index 000000000..e1c2bd0de --- /dev/null +++ b/apps/sn/app/map/SearchModal.tsx @@ -0,0 +1,1128 @@ +import FeatureClickEventPopup from './featureClickEventPopup'; +import React, { useState } from 'react'; +import axios from 'axios'; +import { UiModal, UiTable } from '@common/business_components/ui'; +import { Button, Input, Label } from '@common/components'; +import { UiTable2 } from '@common/business_components/ui/table/index-2'; +import { UITab2 } from '@common/business_components/ui/tab/index-2'; +import { SelectBox2 } from '@common/business_components/ui/select/index-2'; +import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; +import { SidoSigunguSelectBox } from '@/components/ui/SidoSigunguSelectBox'; +import { SwtrmtFcltCol, SwtrmtPrcsZoneCol } from './components/common/table'; +import LoadingOverlay from '@/components/ui/LoadingBar'; + +const API_URL = `${process.env.NEXT_PUBLIC_SEWERAGE_API_URL}`; + +// 탭 +const TABS = [ + { val: "basic", text: "기본정보" }, + { val: "operation", text: "운영정보" }, + { val: "report", text: "하수도정비기본계획보고서" }, +]; +// 기본정보 '구분' 선택 목록 +const BASIC_TYPES = [ + { val: "tb_ms_swage_ppl", text: "하수관로" }, + { val: "tb_il_swage_prcs_fclt", text: "하수처리시설" }, + { val: "tb_ms_swage_prcs_zone", text: "하수처리구역" }, + { val: "tb_ms_swage_mh", text: "하수맨홀" }, + { val: "tb_ms_swage_psmtn_1", text: "오수펌프" }, + { val: "tb_ms_swage_psmtn_2", text: "빗물펌프" }, + { val: "tb_ms_otfl", text: "우수토실" }, +]; +// 운영정보 '구분' 선택 목록 +const WORK_TYPES = [ + { val: "maintenance", text: "보수 및 갱신" }, + { val: "cleaning", text: "청소 및 준설" }, + { val: "inspection", text: "점검 및 진단" }, +]; + +const YEARS = Array.from({ length: 100 }, (_, i) => `${2025 - i}`); +// 테이블 헤더 리스트 +// 하수관로 +const swagePplTableHeadlist = [ + { id: 'pipeNumber', title: '관로번호' }, + { id: 'gisManageNumber', title: '관리번호(GIS)' }, + { id: 'managingInstitution', title: '관리기관' }, + { id: 'drawingNumber', title: '도엽번호' }, + { id: 'installationLocation', title: '설치위치' }, + { id: 'usage', title: '용도' }, + { id: 'pipeDiameter', title: '관경' }, + { id: 'pipeLength', title: '관로연장' }, + { id: 'pipeType', title: '관종' }, + { id: 'burialDepth', title: '매설심도' }, + { id: 'constructionYear', title: '개보수 년도' }, + { id: 'installationSubject', title: '설치 주체' }, + { id: 'area', title: '구역' }, + { id: 'division', title: '분구' }, + { id: 'alignmentType', title: '지선/간선 구분' }, + { id: 'flowType', title: '흐름 방식' }, + { id: 'boxCount', title: '박스 열수' }, + { id: 'height', title: '높이' }, + { id: 'width', title: '폭' }, + { id: 'startManholeNumber', title: '시점 맨홀번호' }, + { id: 'endManholeNumber', title: '종점 맨홀번호' }, + { id: 'startElevation', title: '시점관저고' }, + { id: 'endElevation', title: '종점관저고' }, +] + +// 하수맨홀 (추후 작성 예정) +const swageMhTableHeadlist = [ + { id: "swtrmtFcltCd", title: "하수처리시설코드" }, +]; +// 운영정보 +const operationTableHeadlist = [ + { id: "no", title: "NO" }, + { id: "manageNumber", title: "관리번호" }, + { id: "diagnosisYear", title: "진단년도" }, + { id: "null1", title: "..." }, + { id: "null2", title: "..." }, + { id: "installationLocation", title: "매설위치" }, +]; +// 하수도정비 기본계획 보고서 +const reportTableHeadlist = [ + { id: "no", title: "NO" }, + { id: "approvalYear", title: "승인년도" }, + { id: "sido", title: "시도" }, + { id: "sigungu", title: "시군구" }, + { id: "treatmentPlant", title: "하수처리장" }, + { id: "treatmentZone", title: "하수처리구역" }, + { id: "reportCount", title: "보고서(00건)" }, + { id: "planMapCount", title: "하수도대장 평면도(00건)" }, + { id: "regDate", title: "등록일자" }, +]; + +const 처리구역List = [ + { val: "0", text: "선택" }, + { val: "a", text: "A" }, + { val: "b", text: "B" }, +]; + +interface SearchModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onFeatureSelect: (row: any) => void; +} + +// 날짜 조합 함수 +const getDateString = ( + year: string, + month: string, + day: string, + defaultDate: string, +) => { + if (!year && !month && !day) return defaultDate; + const y = year || defaultDate.slice(0, 4); + const m = month || defaultDate.slice(4, 6); + const d = day || defaultDate.slice(6, 8); + return `${y}${m}${d}`; +}; + +const SearchModal: React.FC<SearchModalProps> = ({ + open, + onOpenChange, + onFeatureSelect, +}) => { + const [activeTab, setActiveTab] = useState(TABS[0]?.val ?? "basic"); + const [workType, setWorkType] = useState(WORK_TYPES[0]?.val ?? ""); + const [showTable, setShowTable] = useState(false); + const [tableData, setTableData] = useState<any[]>([]); + const [tableCount, setTableCount] = useState<number>(0); + const [curPage, setCurPage] = useState(1); + const [basicType, setBasicType] = useState(BASIC_TYPES[0]?.val ?? ""); + const [시도, set시도] = useState(0); + const [시군구, set시군구] = useState(0); + const [처리구역, set처리구역] = useState(""); + // 날짜 상태 선언 + const [시작1, set시작1] = useState(""); + const [시작2, set시작2] = useState(""); + const [종료1, set종료1] = useState(""); + const [종료2, set종료2] = useState(""); + const [변수1, set변수1] = useState(""); + const [변수2, set변수2] = useState(""); + const [변수3, set변수3] = useState(""); + const [변수4, set변수4] = useState(""); + // 시작, 종료, 경과년도 + const [yearFilter, setYearFilter] = useState({ + 시작: "", + 종료: "", + 경과년도: "", + }); + + const [가동개시_시작_월, set가동개시_시작_월] = useState(''); + const [가동개시_종료_월, set가동개시_종료_월] = useState(''); + const [가동개시_시작_일, set가동개시_시작_일] = useState(''); + const [가동개시_종료_일, set가동개시_종료_일] = useState(''); + + // 로딩바 + const [isLoading, setIsLoading] = useState(false); + // 상세 정보 조회를 위한 상태 추가 + const [popupFeature, setPopupFeature] = useState<any>(null); + + // 오늘 날짜 구하기 + const today = new Date(); + const todayYear = today.getFullYear().toString(); + const todayMonth = (today.getMonth() + 1).toString().padStart(2, "0"); + const todayDate = today.getDate().toString().padStart(2, "0"); + + // UiTable2에 넘길 headlist를 BASIC_TYPES 값에 따라 분기 처리 + let dynamicHeadlist = SwtrmtPrcsZoneCol; // default 값 + if (activeTab === 'operation') dynamicHeadlist = operationTableHeadlist; // 운영정보 + else if (activeTab === 'report') dynamicHeadlist = reportTableHeadlist; // 하수도정비기본계획보고서 + else if (basicType === 'tb_ms_swage_ppl') dynamicHeadlist = swagePplTableHeadlist; // 하수관로 + else if (basicType === 'tb_il_swage_prcs_fclt') dynamicHeadlist = SwtrmtFcltCol; // 하수처리시설 + else if (basicType === 'tb_ms_swage_prcs_zone') dynamicHeadlist = SwtrmtPrcsZoneCol; // 하수처리구역 + else if (basicType === 'tb_ms_swage_mh') dynamicHeadlist = swageMhTableHeadlist; // 하수맨홀 + + // 조회 버튼 클릭 시 1페이지로 이동하도록 수정 + const handleSearch = () => { + setCurPage(1); // 페이지를 1로 초기화 + fetchFilteredData(); + setShowTable(true); + }; + + // cellClick 함수 타입 지정 + const handleCellClick = (index?: number, headId?: string) => { + if (typeof index === "number") { + onFeatureSelect(tableData[index % 10]); // 10은 page size 하드 코딩한 숫자 + onOpenChange(false); // SearchModal 닫기 + } + }; + + // 구분(basicType)이 바뀔 때마다 queryParams, filterParams 초기화 + React.useEffect(() => { + // filterParams 초기화 + // setFilterParams({}); + set시작1(""); + set시작2(""); + set종료1(""); + set종료2(""); + set변수1(""); + set변수2(""); + set변수3(""); + set변수4(""); + set가동개시_시작_월(""); + set가동개시_종료_월(""); + set가동개시_시작_일(""); + set가동개시_종료_일(""); + set시도(0); + set시군구(0); + set처리구역(""); + setYearFilter({ + 시작: "", + 종료: "", + 경과년도: "", + }); + setShowTable(false); + setTableCount(0); + setTableData([]); + // queryParams는 fetchFilteredData 내에서 매번 새로 생성되므로 별도 상태 초기화는 필요 없음 + }, [activeTab, basicType]); + + // 페이지 변경 시마다 데이터 fetch + React.useEffect(() => { + // showTable이 true일 때만 fetch (최초 렌더 방지) + if (showTable) { + fetchFilteredData(); // 실제 API 사용 시 + // fetchDummyData(); // 더미 데이터 사용 시 + } + }, [curPage]); + + // 하수관로용 CQL 필터 생성 + const pplCqlFilters = () => { + const cqlFilters: string[] = []; + // 추후 작성 예정 + if (변수1) cqlFilters.push(`용도코드='${변수1}'`); + if (변수2) cqlFilters.push(`재질코드='${변수2}'`); + if (변수3) cqlFilters.push(`행정읍면동코드='${변수3}'`); + if (변수4) cqlFilters.push(`경과년도='${변수4}'`); + // 설치년도 범위 + const startDate = getDateString( + yearFilter.시작, + 가동개시_시작_월, + 가동개시_시작_일, + "19000101", + ); + const endDate = getDateString( + yearFilter.종료, + 가동개시_종료_월, + 가동개시_종료_일, + `${todayYear}${todayMonth}${todayDate}`, + ); + cqlFilters.push( + `설치시작년도 >= '${startDate}' and 설치시작년도 <= '${endDate}'`, + ); + // 관경(mm) 범위 + if (시작1 && 종료1) cqlFilters.push(`관경 BETWEEN ${시작1} AND ${종료1}`); + else if (시작1) cqlFilters.push(`관경 >= ${시작1}`); + else if (종료1) cqlFilters.push(`관경 <= ${종료1}`); + // 연장(m) 범위 + if (시작2 && 종료2) cqlFilters.push(`연장 BETWEEN ${시작2} AND ${종료2}`); + else if (시작2) cqlFilters.push(`연장 >= ${시작2}`); + else if (종료2) cqlFilters.push(`연장 <= ${종료2}`); + + return cqlFilters; + }; + // 하수처리시설용 CQL 필터 생성 + const facilityCqlFilters = () => { + const cqlFilters: string[] = []; + // 가동개시일자 범위 + const startDate = getDateString( + yearFilter.시작, + 가동개시_시작_월, + 가동개시_시작_일, + "19000101", + ); + const endDate = getDateString( + yearFilter.종료, + 가동개시_종료_월, + 가동개시_종료_일, + `${todayYear}${todayMonth}${todayDate}`, + ); + cqlFilters.push( + `oprtng_strt_ymd >= '${startDate}' and oprtng_strt_ymd <= '${endDate}'`, + ); + // 시설용량 범위 + if (시작1 && 종료1) + cqlFilters.push(`fclt_cpct BETWEEN ${시작1} AND ${종료1}`); + else if (시작1) cqlFilters.push(`fclt_cpct >= ${시작1}`); + else if (종료1) cqlFilters.push(`fclt_cpct <= ${종료1}`); + // 하수처리시설명 + if (변수1) cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${변수1}%'`); + return cqlFilters; + }; + // 하수처리구역용 CQL 필터 생성 + const zoneCqlFilters = () => { + const cqlFilters: string[] = []; + if (시도) cqlFilters.push(`ctpv_cd='${시도}'`); + if (시군구) cqlFilters.push(`sgg_cd='${시군구}'`); + if (변수1) cqlFilters.push(`swtrmt_zone_nm ILIKE '%${변수1}%'`); + if (변수2) cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${변수2}%'`); + return cqlFilters; + }; + // 하수맨홀용 CQL 필터 생성 + const mhCqlFilters = () => { + const cqlFilters: string[] = []; + // 설치년도 범위 + const startDate = getDateString( + yearFilter.시작, + 가동개시_시작_월, + 가동개시_시작_일, + "19000101", + ); + const endDate = getDateString( + yearFilter.종료, + 가동개시_종료_월, + 가동개시_종료_일, + `${todayYear}${todayMonth}${todayDate}`, + ); + cqlFilters.push( + `설치시작년도 >= '${startDate}' and 설치시작년도 <= '${endDate}'`, + ); + if (변수1) cqlFilters.push(`경과년도='${변수1}'`); + if (변수2) cqlFilters.push(`용도코드='${변수2}'`); + // 구경(mm) 범위 + if (시작1 && 종료1) cqlFilters.push(`구경 BETWEEN ${시작1} AND ${종료1}`); + else if (시작1) cqlFilters.push(`구경 >= ${시작1}`); + else if (종료1) cqlFilters.push(`구경 <= ${종료1}`); + + return cqlFilters; + }; + // 하수펌프장용 CQL 필터 생성 + const psmtnCqlFilters = () => { + const cqlFilters: string[] = []; + // 설치년도 범위 + const startDate = getDateString( + yearFilter.시작, + 가동개시_시작_월, + 가동개시_시작_일, + "19000101", + ); + const endDate = getDateString( + yearFilter.종료, + 가동개시_종료_월, + 가동개시_종료_일, + `${todayYear}${todayMonth}${todayDate}`, + ); + cqlFilters.push( + `설치시작년도 >= '${startDate}' and 설치시작년도 <= '${endDate}'`, + ); + if (변수1) cqlFilters.push(`경과년도='${변수1}'`); + // 일일처리용량 범위 + if (시작1 && 종료1) + cqlFilters.push(`일일처리용량 BETWEEN ${시작1} AND ${종료1}`); + else if (시작1) cqlFilters.push(`일일처리용량 >= ${시작1}`); + else if (종료1) cqlFilters.push(`일일처리용량 <= ${종료1}`); + // 우수양수능력 범위 + if (시작2 && 종료2) + cqlFilters.push(`우수양수능력 BETWEEN ${시작2} AND ${종료2}`); + else if (시작2) cqlFilters.push(`우수양수능력 >= ${시작2}`); + else if (종료2) cqlFilters.push(`우수양수능력 <= ${종료2}`); + + return cqlFilters; + }; + // 우수토실용 CQL 필터 생성 + const otflCqlFilters = () => { + const cqlFilters: string[] = []; + // 설치년도 범위 + const startDate = getDateString( + yearFilter.시작, + 가동개시_시작_월, + 가동개시_시작_일, + "19000101", + ); + const endDate = getDateString( + yearFilter.종료, + 가동개시_종료_월, + 가동개시_종료_일, + `${todayYear}${todayMonth}${todayDate}`, + ); + cqlFilters.push( + `설치시작년도 >= '${startDate}' and 설치시작년도 <= '${endDate}'`, + ); + if (변수1) cqlFilters.push(`경과년도='${변수1}'`); + if (변수2) cqlFilters.push(`하천명 ILIKE '%${변수1}%'`); + // 계획방류량 범위 + if (시작1 && 종료1) + cqlFilters.push(`계획방류량 BETWEEN ${시작1} AND ${종료1}`); + else if (시작1) cqlFilters.push(`계획방류량 >= ${시작1}`); + else if (종료1) cqlFilters.push(`계획방류량 <= ${종료1}`); + + return cqlFilters; + }; + // 운영정보용 CQL 필터 생성 + const operationCqlFilters = () => { + const cqlFilters: string[] = []; + // 설치년도 범위 + const startDate = getDateString( + yearFilter.시작, + 가동개시_시작_월, + 가동개시_시작_일, + "19000101", + ); + const endDate = getDateString( + yearFilter.종료, + 가동개시_종료_월, + 가동개시_종료_일, + `${todayYear}${todayMonth}${todayDate}`, + ); + cqlFilters.push( + `설치시작년도 >= '${startDate}' and 설치시작년도 <= '${endDate}'`, + ); + return cqlFilters; + }; + // 하수도정비기본계획보고서용 CQL 필터 생성 + const reportCqlFilters = () => { + const cqlFilters: string[] = []; + // 설치년도 범위 + if (시도) cqlFilters.push(`ctpv_cd='${시도}'`); + if (시군구) cqlFilters.push(`sgg_cd='${시군구}'`); + if (처리구역) cqlFilters.push(`처리구역='${처리구역}'`); + if (변수1) cqlFilters.push(`처리구역코드='${변수1}'`); + const startDate = getDateString( + yearFilter.시작, + 가동개시_시작_월, + 가동개시_시작_일, + "19000101", + ); + const endDate = getDateString( + yearFilter.종료, + 가동개시_종료_월, + 가동개시_종료_일, + `${todayYear}${todayMonth}${todayDate}`, + ); + cqlFilters.push( + `설치시작년도 >= '${startDate}' and 설치시작년도 <= '${endDate}'`, + ); + return cqlFilters; + }; + + // 검색 API 호출 함수 + const fetchFilteredData = async () => { + setIsLoading(true); // 로딩 시작 + try { + const queryParams = new URLSearchParams({ + lyrTypeNm: basicType, + page: `${curPage - 1}`, + size: '10', + sort: 'string', + }); + + let cqlFilters: string[] = []; + if (activeTab === 'basic') { + if (basicType === 'tb_ms_swage_prcs_zone') cqlFilters = zoneCqlFilters(); + else if (basicType === 'tb_il_swage_prcs_fclt') cqlFilters = facilityCqlFilters(); + else if (basicType === 'tb_ms_swage_ppl') cqlFilters = pplCqlFilters(); + } else if (activeTab === 'operation') { + cqlFilters = zoneCqlFilters(); + } else { + cqlFilters = reportCqlFilters(); + } + + if (cqlFilters.length > 0) queryParams.append('cqlFiter', cqlFilters.join(' and ')); + + console.log('API URL:', `${API_URL}/v1/fclts?${queryParams.toString()}`); + const res = await axios.get(`${API_URL}/v1/fclts?${queryParams.toString()}`); + console.log('필터링된 API 결과(개수):', res.data.count); + console.log('필터링된 API 결과(데이터):', res.data.data); + + setTableData(res.data.data || []); + setTableCount(res.data.count || []); + console.log('tableCount 상태 업데이트 후:', res.data.count); // tableCount 로그 추가 + setShowTable(true); + } catch (err) { + console.error('API ERROR:', err); + setTableData([]); + } finally { + setIsLoading(false); // 로딩 종료 + } + }; + + const renderBody = () => ( + <> + {/* 탭 영역 */} + <UITab2 + defaultValue={TABS[0]?.val} + tabList={TABS.map((tab) => ({ + value: tab.val, + label: tab.text, + onClick: () => setActiveTab(tab.val), + }))} + > + {/* 각각의 탭 영역 */} + {/* 기본정보 탭 */} + {activeTab === 'basic' && ( + <div className="relative border rounded-lg p-6 mt-4 w-full h-[23vh]"> + {/* 구분 */} + <div className="mb-3 flex items-center justify-between gap-4"> + <div className="flex items-center"> + <SelectBox2 + className="w-[150px] mr-3" + selectClass="rounded-lg bg-white" + label="구분" + selectArray={BASIC_TYPES} + onSelectValue={(value: string | number) => + setBasicType(String(value)) + } + value={basicType} + /> + </div> + </div> + {/* 분기처리: 하수관로 */} + {basicType === "tb_ms_swage_ppl" && ( + <div className="mb-6"> + <div className="mb-2 flex items-center"> + {/* 설치년도 */} + <YearRangeSelectBox + start={yearFilter.시작} + end={yearFilter.종료} + onChange={(range) => + setYearFilter({ + ...yearFilter, + 시작: range.start, + 종료: range.end, + }) + } + startLabel="설치년도" + endLabel="" + /> + + {/* 경과년도 */} + <div className="flex items-center"> + <Label className="mr-2 w-auto text-xl text-gray-600"> + 경과년도 + </Label> + <Input + className='!text-xl w-[70px] placeholder:text-center mr-8' + placeholder="입력" + value={yearFilter.경과년도} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + setYearFilter({ ...yearFilter, 경과년도: val }); + }} + /> + </div> + {/* 용도코드 */} + <Label className="mr-2 w-auto text-xl text-gray-600"> + 용도코드 + </Label> + <Input + className="mr-8 w-[100px] !text-xl placeholder:text-center" + placeholder="입력" + value={변수1} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + set변수1(val); + }} + /> + {/* 재질코드 */} + <Label className="mr-2 w-auto text-xl text-gray-600"> + 재질코드 + </Label> + <Input + className="mr-8 w-[100px] !text-xl placeholder:text-center" + placeholder="입력" + value={변수2} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + set변수2(val); + }} + /> + </div> + <div className="flex items-center mb-2"> + {/* 행정읍면동코드 */} + <Label className="mr-2 w-auto text-xl text-gray-600"> + 행정읍면동코드 + </Label> + <Input + className="mr-8 w-[100px] !text-xl placeholder:text-center" + placeholder="입력" + value={변수3} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + set변수3(val); + }} + /> + {/* 관경 */} + <Label className="mr-2 w-auto text-xl text-gray-600"> + 관경(mm) + </Label> + <Input + className="w-[100px] !text-xl placeholder:text-center" + placeholder="입력" + value={시작1} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + set시작1(val); + }} + /> + <span className="mx-2 text-xl font-semibold">~</span> + <Input + className="mr-8 w-[100px] !text-xl placeholder:text-center" + placeholder="입력" + value={종료1} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + set종료1(val); + }} + /> + {/* 연장 */} + <Label className="mr-2 w-auto text-xl text-gray-600"> + 연장(m) + </Label> + <Input + className="w-[100px] !text-xl placeholder:text-center" + placeholder="입력" + value={시작2} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + set시작2(val); + }} + /> + <span className="mx-2 text-xl font-semibold">~</span> + <Input + className="mr-8 w-[100px] !text-xl placeholder:text-center" + placeholder="입력" + value={종료2} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + set종료2(val); + }} + /> + </div> + </div> + )} + {/* 분기처리: 하수처리시설 */} + {basicType === "tb_il_swage_prcs_fclt" && ( + <div className="mb-6"> + {/* 가동개시일자 */} + <div className="mb-2 flex items-center"> + <YearRangeSelectBox + start={yearFilter.시작} + end={yearFilter.종료} + onChange={(range) => + setYearFilter({ + ...yearFilter, + 시작: range.start, + 종료: range.end, + }) + } + startLabel="가동개시일자" + endLabel=" " + /> + {/* 시설용량 */} + <Label className="mr-2 w-auto text-xl text-gray-600"> + 시설용량 + </Label> + <Input + className="w-[100px] !text-xl placeholder:text-center" + placeholder="입력" + value={시작1} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + set시작1(val); + }} + /> + <span className="mx-2 text-xl font-semibold">~</span> + <Input + className="mr-8 w-[100px] !text-xl placeholder:text-center" + placeholder="입력" + value={종료1} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + set종료1(val); + }} + /> + {/* 하수처리시설명 */} + <Label className="mr-2 w-auto text-xl text-gray-600"> + 하수처리시설명 + </Label> + <Input + className="mr-8 w-[100px] !text-xl placeholder:text-center" + placeholder="입력" + value={변수1} + onChange={(e) => { + set변수1(e.target.value); + }} + /> + </div> + </div> + )} + {/* 분기처리: 하수처리구역 */} + {basicType === "tb_ms_swage_prcs_zone" && ( + <div className="mb-3"> + <div className="mb-2 flex items-center"> + <SidoSigunguSelectBox + sido={시도} + sigungu={시군구} + onSidoChange={(sidoId) => { + set시도(sidoId); + set시군구(0); + }} + onSigunguChange={set시군구} + /> + <Label className="mr-2 w-auto text-xl text-gray-600"> + 하수처리구역명 + </Label> + <Input + className="mr-8 w-[100px] !text-xl placeholder:text-center" + placeholder="입력" + value={변수1} + onChange={(e) => { + set변수1(e.target.value); + }} + /> + <Label className="mr-2 w-auto text-xl text-gray-600"> + 하수처리시설명 + </Label> + <Input + className='!text-xl w-[120px] placeholder:text-center mr-8' + placeholder="입력" + value={변수2} + onChange={(e) => { + set변수2(e.target.value); + }} + /> + </div> + </div> + )} + {/* 분기처리: 하수맨홀 */} + {basicType === "tb_ms_swage_mh" && ( + <div className="mb-3"> + <div className="mb-2 flex items-center"> + {/* 설치년도 */} + <YearRangeSelectBox + start={yearFilter.시작} + end={yearFilter.종료} + onChange={(range) => + setYearFilter({ + ...yearFilter, + 시작: range.start, + 종료: range.end, + }) + } + startLabel="설치년도" + endLabel="" + /> + {/* 경과년도 */} + <Label className="mr-2 w-auto text-xl text-gray-600"> + 경과년도 + </Label> + <Input + className="mr-8 w-[100px] !text-xl placeholder:text-center" + placeholder="입력" + value={yearFilter.경과년도} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + setYearFilter({ ...yearFilter, 경과년도: val }); + }} + /> + </div> + <div className='flex items-center mb-2'> + {/* 용도코드 */} + <Label className="mr-2 w-auto text-xl text-gray-600"> + 용도코드 + </Label> + <Input + className="mr-8 w-[100px] !text-xl placeholder:text-center" + placeholder="입력" + value={변수1} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + set변수1(val); + }} + /> + {/* 하수맨홀 구경 */} + <Label className="mr-2 w-auto text-xl text-gray-600"> + 구경(mm) + </Label> + <Input + className="w-[100px] !text-xl placeholder:text-center" + placeholder="입력" + value={시작1} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + set시작1(val); + }} + /> + <span className="mx-2 text-xl font-semibold">~</span> + <Input + className="mr-8 w-[100px] !text-xl placeholder:text-center" + placeholder="입력" + value={종료1} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + set종료1(val); + }} + /> + </div> + </div> + )} + {/* 분기처리: 하수펌프장 */} + {(basicType === "tb_ms_swage_psmtn_1" || + basicType === "tb_ms_swage_psmtn_2") && ( + <div className="mb-3"> + {/* 설치년도 */} + <div className="flex items-center mb-2"> + <YearRangeSelectBox + start={yearFilter.시작} + end={yearFilter.종료} + onChange={range => setYearFilter({ ...yearFilter, 시작: range.start, 종료: range.end })} + startLabel="설치년도" + endLabel="" + /> + {/* 경과년도 */} + <Label className="w-auto text-xl text-gray-600 mr-2">경과년도</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' + placeholder="입력" + value={yearFilter.경과년도} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + setYearFilter({ ...yearFilter, 경과년도: val }); + }} + /> + </div> + <div className="flex items-center mb-2"> + {/* 일일처리용량 - 오수맨홀 */} + <Label className="mr-2 w-auto text-xl text-gray-600"> + 일일처리용량 + </Label> + <Input + className="w-[100px] !text-xl placeholder:text-center" + placeholder="입력" + value={시작1} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + set시작1(val); + }} + /> + <span className="mx-2 text-xl font-semibold">~</span> + <Input + className="mr-8 w-[100px] !text-xl placeholder:text-center" + placeholder="입력" + value={종료1} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + set종료1(val); + }} + /> + {/* 우수양수능력 - 우수맨홀 */} + <Label className="mr-2 w-auto text-xl text-gray-600"> + 우수양수능력 + </Label> + <Input + className="w-[100px] !text-xl placeholder:text-center" + placeholder="입력" + value={시작2} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + set시작2(val); + }} + /> + <span className="mx-2 text-xl font-semibold">~</span> + <Input + className="mr-8 w-[100px] !text-xl placeholder:text-center" + placeholder="입력" + value={종료2} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + set종료2(val); + }} + /> + </div> + </div> + )} + {/* 분기처리: 우수토실? 토구? */} + {basicType === "tb_ms_otfl" && ( + <div className="mb-3"> + {/* 설치년도 */} + <div className="flex items-center mb-2"> + <YearRangeSelectBox + start={yearFilter.시작} + end={yearFilter.종료} + onChange={range => setYearFilter({ ...yearFilter, 시작: range.start, 종료: range.end })} + startLabel="설치년도" + endLabel="" + /> + {/* 경과년도 */} + <Label className="w-auto text-xl text-gray-600 mr-2">경과년도</Label> + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' + placeholder="입력" + value={yearFilter.경과년도} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + setYearFilter({ ...yearFilter, 경과년도: val }); + }} + /> + </div> + <div className="flex items-center mb-2"> + {/* 하천명 */} + <Label className="mr-2 w-auto text-xl text-gray-600"> + 하천명 + </Label> + <Input + className="mr-8 w-[100px] !text-xl placeholder:text-center" + placeholder="입력" + value={변수1} + onChange={(e) => { + set변수1(e.target.value); + }} + /> + {/* 계획방류량 */} + <Label className="mr-2 w-auto text-xl text-gray-600"> + 계획방류량 + </Label> + <Input + className="w-[100px] !text-xl placeholder:text-center" + placeholder="입력" + value={시작1} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + set시작1(val); + }} + /> + <span className="mx-2 text-xl font-semibold">~</span> + <Input + className="mr-8 w-[100px] !text-xl placeholder:text-center" + placeholder="입력" + value={종료1} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + set종료1(val); + }} + /> + </div> + </div> + )} + + {/* 조회 버튼 중앙 정렬 */} + <div className="absolute bottom-4 right-4 mr-2"> + <Button className="text-base py-2" onClick={handleSearch}>조회</Button> + </div> + </div> + )} + {/* 운영정보 탭 */} + {activeTab === 'operation' && ( + <div className="relative flex flex-col border rounded-lg p-6 mb-4 w-full h-[23vh]"> + <div className="flex items-center mb-6"> + <SelectBox2 + className="w-60 mr-8" + selectClass="rounded-lg bg-white text-xl" + label="업무구분" + selectArray={WORK_TYPES.map((w) => ({ + val: w.val, + text: w.text, + }))} + onSelectValue={(val) => setWorkType(String(val))} + value={workType} + /> + + {/* 설치년도 */} + <YearRangeSelectBox + start={yearFilter.시작} + end={yearFilter.종료} + onChange={(range) => + setYearFilter({ + ...yearFilter, + 시작: range.start, + 종료: range.end, + }) + } + startLabel="설치년도" + endLabel="" + /> + <div className="flex items-center"> + {/* 경과년도 */} + <Label className="mr-2 w-auto text-xl text-gray-600"> + 경과년도 + </Label> + <Input + className="mr-8 w-[100px] !text-xl placeholder:text-center" + placeholder="입력" + value={yearFilter.경과년도} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ""); + setYearFilter({ ...yearFilter, 경과년도: val }); + }} + /> + </div> + </div> + {/* 조회 버튼 중앙 정렬 */} + <div className="absolute bottom-4 right-4 mr-2"> + <Button className="text-base py-2" onClick={handleSearch}>조회</Button> + </div> + </div> + )} + {/* 하수도정비기본계획보고서 탭 */} + {activeTab === 'report' && ( + <div className="relative flex flex-col border rounded-lg p-6 mb-4 w-full h-[23vh]"> + {/* 시도, 시군구, 하수처리구역 한 줄 */} + <div className="mb-6 flex items-center"> + <SidoSigunguSelectBox + sido={시도} + sigungu={시군구} + onSidoChange={(sidoId) => { + set시도(sidoId); + set시군구(0); + }} + onSigunguChange={set시군구} + /> + </div> + <div className="flex justify-between items-center mb-6"> + <div className="flex items-center gap-4"> + <SelectBox2 + className="w-auto mr-2" + selectClass="rounded-lg bg-white text-xl" + label="하수처리구역" + selectArray={처리구역List.map(opt => ({ val: opt.val, text: opt.text }))} + onSelectValue={val => set처리구역(String(val))} + value={처리구역} + /> + <Input + className='!text-xl w-[100px] placeholder:text-center mr-8' + placeholder="입력" + value={변수1} + onChange={(e) => { + // 숫자만 입력 허용 + const val = e.target.value.replace(/[^0-9]/g, ''); + set변수1(val); + }} + /> + <YearRangeSelectBox + start={yearFilter.시작} + end={yearFilter.종료} + onChange={range => setYearFilter({ ...yearFilter, 시작: range.start, 종료: range.end })} + startLabel="승인년도" + endLabel="" + /> + </div> + {/* 조회 버튼 중앙 정렬 */} + <div className="absolute bottom-4 right-4 mr-2"> + <Button className="text-base py-2" onClick={handleSearch}>조회</Button> + </div> + </div> + </div> + )} + </UITab2> + + {/* 📋 결과 리스트 */} + <div className="h-[60vh] w-full space-y-2 overflow-hidden rounded-lg border"> + {/* 테이블 영역 (가로/세로 스크롤 가능) */} + <div className="flex-1 px-2 pb-2"> + <LoadingOverlay isLoading={isLoading}/> + <UiTable2 + headName={ + (() => { + const resultCount = tableData.length; + const label = + activeTab === "basic" + ? BASIC_TYPES.find((type) => type.val === basicType)?.text + : activeTab === "operation" + ? WORK_TYPES.find((type) => type.val === workType)?.text + : activeTab === "report" + ? "하수도정비기본계획보고서" + : TABS.find((tab) => tab.val === activeTab)?.text; + return ( + <> + {label} 조회 결과 ({tableCount})건 + </> + ); + })() + } + tableData={tableData} + headlist={dynamicHeadlist} + pageSize={tableData.length < 10 ? tableData.length : 10} + total={tableCount} + children={undefined} + cellClick={handleCellClick} + onPageChange={setCurPage} + resetPageNo={false} + cur={curPage} + /> + </div> + </div> + </> + ); + + return ( + <UiModal + open={open} + onOpenChange={onOpenChange} + bodyClass={` + h-[100vh] + w-[60vw] + max-w-none + max-h-none + overflow-visible + z-[101] + `} + body={renderBody()} + /> + ); +}; + +export default React.memo(SearchModal); diff --git a/apps/sn/app/map/components/InputSection.tsx b/apps/sn/app/map/components/InputSection.tsx new file mode 100644 index 000000000..6c411e2cf --- /dev/null +++ b/apps/sn/app/map/components/InputSection.tsx @@ -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; \ No newline at end of file diff --git a/apps/sn/app/map/components/common/index.ts b/apps/sn/app/map/components/common/index.ts new file mode 100644 index 000000000..c3e1a4a37 --- /dev/null +++ b/apps/sn/app/map/components/common/index.ts @@ -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); +}; \ No newline at end of file diff --git a/apps/sn/app/map/components/common/table.ts b/apps/sn/app/map/components/common/table.ts new file mode 100644 index 000000000..148f5ef0f --- /dev/null +++ b/apps/sn/app/map/components/common/table.ts @@ -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:'기관관리번호'}] diff --git a/apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx b/apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx new file mode 100644 index 000000000..1db284c8e --- /dev/null +++ b/apps/sn/app/map/components/popupComponent/MsSwagePpl.tsx @@ -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 \ No newline at end of file diff --git a/apps/sn/app/map/components/popupComponent/Ssc.tsx b/apps/sn/app/map/components/popupComponent/Ssc.tsx new file mode 100644 index 000000000..06b975739 --- /dev/null +++ b/apps/sn/app/map/components/popupComponent/Ssc.tsx @@ -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 \ No newline at end of file diff --git a/apps/sn/app/map/components/popupComponent/SwtrmtFclt.tsx b/apps/sn/app/map/components/popupComponent/SwtrmtFclt.tsx new file mode 100644 index 000000000..eef14ea5e --- /dev/null +++ b/apps/sn/app/map/components/popupComponent/SwtrmtFclt.tsx @@ -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 \ No newline at end of file diff --git a/apps/sn/app/map/components/popupComponent/chart/infilChart.tsx b/apps/sn/app/map/components/popupComponent/chart/infilChart.tsx new file mode 100644 index 000000000..ba0b70455 --- /dev/null +++ b/apps/sn/app/map/components/popupComponent/chart/infilChart.tsx @@ -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 \ No newline at end of file diff --git a/apps/sn/app/map/components/popupComponent/sewagePump.tsx b/apps/sn/app/map/components/popupComponent/sewagePump.tsx new file mode 100644 index 000000000..5776df5b2 --- /dev/null +++ b/apps/sn/app/map/components/popupComponent/sewagePump.tsx @@ -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 diff --git a/apps/sn/app/map/components/popupComponent/smh.tsx b/apps/sn/app/map/components/popupComponent/smh.tsx new file mode 100644 index 000000000..e693a28ce --- /dev/null +++ b/apps/sn/app/map/components/popupComponent/smh.tsx @@ -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; \ No newline at end of file diff --git a/apps/sn/app/map/components/popupComponent/stormWaterPump.tsx b/apps/sn/app/map/components/popupComponent/stormWaterPump.tsx new file mode 100644 index 000000000..eefef8b3b --- /dev/null +++ b/apps/sn/app/map/components/popupComponent/stormWaterPump.tsx @@ -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 \ No newline at end of file diff --git a/apps/sn/app/map/featureClickEventPopup.tsx b/apps/sn/app/map/featureClickEventPopup.tsx new file mode 100644 index 000000000..2f70adbc3 --- /dev/null +++ b/apps/sn/app/map/featureClickEventPopup.tsx @@ -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; \ No newline at end of file diff --git a/apps/sn/app/map/page.tsx b/apps/sn/app/map/page.tsx new file mode 100644 index 000000000..b547fb7e4 --- /dev/null +++ b/apps/sn/app/map/page.tsx @@ -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; diff --git a/apps/sn/app/oper/floodPre/page.tsx b/apps/sn/app/oper/floodPre/page.tsx new file mode 100644 index 000000000..013b1a36c --- /dev/null +++ b/apps/sn/app/oper/floodPre/page.tsx @@ -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 \ No newline at end of file diff --git a/apps/sn/app/oper/index.ts b/apps/sn/app/oper/index.ts new file mode 100644 index 000000000..54ba00374 --- /dev/null +++ b/apps/sn/app/oper/index.ts @@ -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: '등록자' } +] \ No newline at end of file diff --git a/apps/sn/app/oper/oldPp/page.tsx b/apps/sn/app/oper/oldPp/page.tsx new file mode 100644 index 000000000..92d7fe6aa --- /dev/null +++ b/apps/sn/app/oper/oldPp/page.tsx @@ -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 \ No newline at end of file diff --git a/apps/sn/app/page.tsx b/apps/sn/app/page.tsx new file mode 100644 index 000000000..fbf788346 --- /dev/null +++ b/apps/sn/app/page.tsx @@ -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> + ); +} diff --git a/apps/sn/app/service/index.ts b/apps/sn/app/service/index.ts new file mode 100644 index 000000000..c6bfcff72 --- /dev/null +++ b/apps/sn/app/service/index.ts @@ -0,0 +1 @@ +export * from './map' diff --git a/apps/sn/app/service/map.ts b/apps/sn/app/service/map.ts new file mode 100644 index 000000000..062cfeedb --- /dev/null +++ b/apps/sn/app/service/map.ts @@ -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`) + } +} diff --git a/apps/sn/app/store/index.tsx b/apps/sn/app/store/index.tsx new file mode 100644 index 000000000..a9fc1288d --- /dev/null +++ b/apps/sn/app/store/index.tsx @@ -0,0 +1 @@ +export { useUserStore, type IUserInfo } from "./user-store"; diff --git a/apps/sn/app/store/store-provider.tsx b/apps/sn/app/store/store-provider.tsx new file mode 100644 index 000000000..8a2e8347e --- /dev/null +++ b/apps/sn/app/store/store-provider.tsx @@ -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> + ); +}; diff --git a/apps/sn/app/store/user-store.tsx b/apps/sn/app/store/user-store.tsx new file mode 100644 index 000000000..8719dc842 --- /dev/null +++ b/apps/sn/app/store/user-store.tsx @@ -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 })), + })); +}; diff --git a/apps/sn/app/styles/base.css b/apps/sn/app/styles/base.css new file mode 100644 index 000000000..8453ff9e8 --- /dev/null +++ b/apps/sn/app/styles/base.css @@ -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; +} diff --git a/apps/sn/app/styles/components.css b/apps/sn/app/styles/components.css new file mode 100644 index 000000000..4dc30bafd --- /dev/null +++ b/apps/sn/app/styles/components.css @@ -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; + } +} diff --git a/apps/sn/components.json b/apps/sn/components.json new file mode 100644 index 000000000..65412202e --- /dev/null +++ b/apps/sn/components.json @@ -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" +} \ No newline at end of file diff --git a/apps/sn/env.d.ts b/apps/sn/env.d.ts new file mode 100644 index 000000000..31ff74044 --- /dev/null +++ b/apps/sn/env.d.ts @@ -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; +} diff --git a/apps/sn/eslint.config.mjs b/apps/sn/eslint.config.mjs new file mode 100644 index 000000000..c85fb67c4 --- /dev/null +++ b/apps/sn/eslint.config.mjs @@ -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; diff --git a/apps/sn/middleware.ts b/apps/sn/middleware.ts new file mode 100644 index 000000000..f76696f53 --- /dev/null +++ b/apps/sn/middleware.ts @@ -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*", + ], +}; diff --git a/apps/sn/next.config.ts b/apps/sn/next.config.ts new file mode 100644 index 000000000..f149ae9b8 --- /dev/null +++ b/apps/sn/next.config.ts @@ -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; diff --git a/apps/sn/package.json b/apps/sn/package.json new file mode 100644 index 000000000..42c19afa7 --- /dev/null +++ b/apps/sn/package.json @@ -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" + } +} diff --git a/apps/sn/postcss.config.mjs b/apps/sn/postcss.config.mjs new file mode 100644 index 000000000..1a69fd2a4 --- /dev/null +++ b/apps/sn/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + }, +}; + +export default config; diff --git a/apps/sn/public/favicon.ico b/apps/sn/public/favicon.ico new file mode 100644 index 000000000..d304b6981 Binary files /dev/null and b/apps/sn/public/favicon.ico differ diff --git a/apps/sn/public/images/map/layericon/BML_CBAS_PS.png b/apps/sn/public/images/map/layericon/BML_CBAS_PS.png new file mode 100644 index 000000000..5ff4add3e Binary files /dev/null and b/apps/sn/public/images/map/layericon/BML_CBAS_PS.png differ diff --git a/apps/sn/public/images/map/layericon/BML_ELEV_PS.png b/apps/sn/public/images/map/layericon/BML_ELEV_PS.png new file mode 100644 index 000000000..83970a331 Binary files /dev/null and b/apps/sn/public/images/map/layericon/BML_ELEV_PS.png differ diff --git a/apps/sn/public/images/map/layericon/BML_ETCP_PS.png b/apps/sn/public/images/map/layericon/BML_ETCP_PS.png new file mode 100644 index 000000000..df9e4957b Binary files /dev/null and b/apps/sn/public/images/map/layericon/BML_ETCP_PS.png differ diff --git a/apps/sn/public/images/map/layericon/BML_STMS_PS.png b/apps/sn/public/images/map/layericon/BML_STMS_PS.png new file mode 100644 index 000000000..423f2e8fc Binary files /dev/null and b/apps/sn/public/images/map/layericon/BML_STMS_PS.png differ diff --git a/apps/sn/public/images/map/layericon/BML_SYMB_PS.png b/apps/sn/public/images/map/layericon/BML_SYMB_PS.png new file mode 100644 index 000000000..497589bea Binary files /dev/null and b/apps/sn/public/images/map/layericon/BML_SYMB_PS.png differ diff --git a/apps/sn/public/images/map/layericon/RDL_BYST_PS.png b/apps/sn/public/images/map/layericon/RDL_BYST_PS.png new file mode 100644 index 000000000..1e78cba68 Binary files /dev/null and b/apps/sn/public/images/map/layericon/RDL_BYST_PS.png differ diff --git a/apps/sn/public/images/map/layericon/RDL_CROS_PS.png b/apps/sn/public/images/map/layericon/RDL_CROS_PS.png new file mode 100644 index 000000000..57b5f2ca5 Binary files /dev/null and b/apps/sn/public/images/map/layericon/RDL_CROS_PS.png differ diff --git a/apps/sn/public/images/map/layericon/RDL_ETCT_PS.png b/apps/sn/public/images/map/layericon/RDL_ETCT_PS.png new file mode 100644 index 000000000..29c0b77b4 Binary files /dev/null and b/apps/sn/public/images/map/layericon/RDL_ETCT_PS.png differ diff --git a/apps/sn/public/images/map/layericon/RDL_OCUP_PS.png b/apps/sn/public/images/map/layericon/RDL_OCUP_PS.png new file mode 100644 index 000000000..8a1ac0074 Binary files /dev/null and b/apps/sn/public/images/map/layericon/RDL_OCUP_PS.png differ diff --git a/apps/sn/public/images/map/layericon/RDL_RDSN_PS.png b/apps/sn/public/images/map/layericon/RDL_RDSN_PS.png new file mode 100644 index 000000000..5629de9fb Binary files /dev/null and b/apps/sn/public/images/map/layericon/RDL_RDSN_PS.png differ diff --git a/apps/sn/public/images/map/layericon/RDL_SCLT_PS.png b/apps/sn/public/images/map/layericon/RDL_SCLT_PS.png new file mode 100644 index 000000000..7281dc61d Binary files /dev/null and b/apps/sn/public/images/map/layericon/RDL_SCLT_PS.png differ diff --git a/apps/sn/public/images/map/layericon/RDL_SEPC_PS.png b/apps/sn/public/images/map/layericon/RDL_SEPC_PS.png new file mode 100644 index 000000000..087b4e941 Binary files /dev/null and b/apps/sn/public/images/map/layericon/RDL_SEPC_PS.png differ diff --git a/apps/sn/public/images/map/layericon/RDL_STAT_PS.png b/apps/sn/public/images/map/layericon/RDL_STAT_PS.png new file mode 100644 index 000000000..13d8ea689 Binary files /dev/null and b/apps/sn/public/images/map/layericon/RDL_STAT_PS.png differ diff --git a/apps/sn/public/images/map/layericon/RDL_STLT_PS.png b/apps/sn/public/images/map/layericon/RDL_STLT_PS.png new file mode 100644 index 000000000..489524b5c Binary files /dev/null and b/apps/sn/public/images/map/layericon/RDL_STLT_PS.png differ diff --git a/apps/sn/public/images/map/layericon/RDL_TFSN_PS.png b/apps/sn/public/images/map/layericon/RDL_TFSN_PS.png new file mode 100644 index 000000000..6dfff6696 Binary files /dev/null and b/apps/sn/public/images/map/layericon/RDL_TFSN_PS.png differ diff --git a/apps/sn/public/images/map/layericon/RDL_TRCT_PS.png b/apps/sn/public/images/map/layericon/RDL_TRCT_PS.png new file mode 100644 index 000000000..ed1a72d8a Binary files /dev/null and b/apps/sn/public/images/map/layericon/RDL_TRCT_PS.png differ diff --git a/apps/sn/public/images/map/layericon/RDL_TREE_PS.png b/apps/sn/public/images/map/layericon/RDL_TREE_PS.png new file mode 100644 index 000000000..0ee84dedc Binary files /dev/null and b/apps/sn/public/images/map/layericon/RDL_TREE_PS.png differ diff --git a/apps/sn/public/images/map/layericon/RDL_TRSN_PS.png b/apps/sn/public/images/map/layericon/RDL_TRSN_PS.png new file mode 100644 index 000000000..15675cb31 Binary files /dev/null and b/apps/sn/public/images/map/layericon/RDL_TRSN_PS.png differ diff --git a/apps/sn/public/images/map/layericon/RDL_TSCT_PS.png b/apps/sn/public/images/map/layericon/RDL_TSCT_PS.png new file mode 100644 index 000000000..ffc225159 Binary files /dev/null and b/apps/sn/public/images/map/layericon/RDL_TSCT_PS.png differ diff --git a/apps/sn/public/images/map/layericon/SWL_CLAY_PS.png b/apps/sn/public/images/map/layericon/SWL_CLAY_PS.png new file mode 100644 index 000000000..e603ffa9c Binary files /dev/null and b/apps/sn/public/images/map/layericon/SWL_CLAY_PS.png differ diff --git a/apps/sn/public/images/map/layericon/SWL_CLAY_PS_RED.png b/apps/sn/public/images/map/layericon/SWL_CLAY_PS_RED.png new file mode 100644 index 000000000..0e5450688 Binary files /dev/null and b/apps/sn/public/images/map/layericon/SWL_CLAY_PS_RED.png differ diff --git a/apps/sn/public/images/map/layericon/SWL_DEPT_PS.png b/apps/sn/public/images/map/layericon/SWL_DEPT_PS.png new file mode 100644 index 000000000..fede6c304 Binary files /dev/null and b/apps/sn/public/images/map/layericon/SWL_DEPT_PS.png differ diff --git a/apps/sn/public/images/map/layericon/SWL_MANH_PS.png b/apps/sn/public/images/map/layericon/SWL_MANH_PS.png new file mode 100644 index 000000000..75217f494 Binary files /dev/null and b/apps/sn/public/images/map/layericon/SWL_MANH_PS.png differ diff --git a/apps/sn/public/images/map/layericon/SWL_MANH_PS_BLACK.png b/apps/sn/public/images/map/layericon/SWL_MANH_PS_BLACK.png new file mode 100644 index 000000000..5d2e8a70a Binary files /dev/null and b/apps/sn/public/images/map/layericon/SWL_MANH_PS_BLACK.png differ diff --git a/apps/sn/public/images/map/layericon/SWL_MANH_PS_BLUE.png b/apps/sn/public/images/map/layericon/SWL_MANH_PS_BLUE.png new file mode 100644 index 000000000..e521cb84a Binary files /dev/null and b/apps/sn/public/images/map/layericon/SWL_MANH_PS_BLUE.png differ diff --git a/apps/sn/public/images/map/layericon/SWL_MANH_PS_GRAY.png b/apps/sn/public/images/map/layericon/SWL_MANH_PS_GRAY.png new file mode 100644 index 000000000..e19f3d8b3 Binary files /dev/null and b/apps/sn/public/images/map/layericon/SWL_MANH_PS_GRAY.png differ diff --git a/apps/sn/public/images/map/layericon/SWL_MANH_PS_GREEN.png b/apps/sn/public/images/map/layericon/SWL_MANH_PS_GREEN.png new file mode 100644 index 000000000..4503c13c6 Binary files /dev/null and b/apps/sn/public/images/map/layericon/SWL_MANH_PS_GREEN.png differ diff --git a/apps/sn/public/images/map/layericon/SWL_MANH_PS_RED.png b/apps/sn/public/images/map/layericon/SWL_MANH_PS_RED.png new file mode 100644 index 000000000..b8fa45f9c Binary files /dev/null and b/apps/sn/public/images/map/layericon/SWL_MANH_PS_RED.png differ diff --git a/apps/sn/public/images/map/layericon/SWL_MANH_PS_YELLOW.png b/apps/sn/public/images/map/layericon/SWL_MANH_PS_YELLOW.png new file mode 100644 index 000000000..b7de1fbfd Binary files /dev/null and b/apps/sn/public/images/map/layericon/SWL_MANH_PS_YELLOW.png differ diff --git a/apps/sn/public/images/map/layericon/SWL_SPEW_PS.png b/apps/sn/public/images/map/layericon/SWL_SPEW_PS.png new file mode 100644 index 000000000..d260ea3a6 Binary files /dev/null and b/apps/sn/public/images/map/layericon/SWL_SPEW_PS.png differ diff --git a/apps/sn/public/images/map/layericon/SWL_SPOT_PS.png b/apps/sn/public/images/map/layericon/SWL_SPOT_PS.png new file mode 100644 index 000000000..84c7992e4 Binary files /dev/null and b/apps/sn/public/images/map/layericon/SWL_SPOT_PS.png differ diff --git a/apps/sn/public/images/map/layericon/SWL_SPOT_PS_BLACK.png b/apps/sn/public/images/map/layericon/SWL_SPOT_PS_BLACK.png new file mode 100644 index 000000000..fe67f8f98 Binary files /dev/null and b/apps/sn/public/images/map/layericon/SWL_SPOT_PS_BLACK.png differ diff --git a/apps/sn/public/images/map/layericon/SWL_SPOT_PS_BLUE.png b/apps/sn/public/images/map/layericon/SWL_SPOT_PS_BLUE.png new file mode 100644 index 000000000..691f2f45e Binary files /dev/null and b/apps/sn/public/images/map/layericon/SWL_SPOT_PS_BLUE.png differ diff --git a/apps/sn/public/images/map/layericon/SWL_SPOT_PS_GREEN.png b/apps/sn/public/images/map/layericon/SWL_SPOT_PS_GREEN.png new file mode 100644 index 000000000..d09c4bd97 Binary files /dev/null and b/apps/sn/public/images/map/layericon/SWL_SPOT_PS_GREEN.png differ diff --git a/apps/sn/public/images/map/layericon/SWL_SPOT_PS_GREY.png b/apps/sn/public/images/map/layericon/SWL_SPOT_PS_GREY.png new file mode 100644 index 000000000..32d0bf519 Binary files /dev/null and b/apps/sn/public/images/map/layericon/SWL_SPOT_PS_GREY.png differ diff --git a/apps/sn/public/images/map/layericon/SWL_SPOT_PS_RED.png b/apps/sn/public/images/map/layericon/SWL_SPOT_PS_RED.png new file mode 100644 index 000000000..a2da36f9b Binary files /dev/null and b/apps/sn/public/images/map/layericon/SWL_SPOT_PS_RED.png differ diff --git a/apps/sn/public/images/map/layericon/SWL_VENT_PS.png b/apps/sn/public/images/map/layericon/SWL_VENT_PS.png new file mode 100644 index 000000000..834eeec21 Binary files /dev/null and b/apps/sn/public/images/map/layericon/SWL_VENT_PS.png differ diff --git a/apps/sn/public/images/map/layericon/TL_SPBD_ENTRC.png b/apps/sn/public/images/map/layericon/TL_SPBD_ENTRC.png new file mode 100644 index 000000000..8e7de8593 Binary files /dev/null and b/apps/sn/public/images/map/layericon/TL_SPBD_ENTRC.png differ diff --git a/apps/sn/public/images/map/layericon/TL_SPGF_LOC.png b/apps/sn/public/images/map/layericon/TL_SPGF_LOC.png new file mode 100644 index 000000000..74b0adac3 Binary files /dev/null and b/apps/sn/public/images/map/layericon/TL_SPGF_LOC.png differ diff --git a/apps/sn/public/images/map/layericon/TL_SPOT_NOIN.png b/apps/sn/public/images/map/layericon/TL_SPOT_NOIN.png new file mode 100644 index 000000000..4800beaeb Binary files /dev/null and b/apps/sn/public/images/map/layericon/TL_SPOT_NOIN.png differ diff --git a/apps/sn/public/images/map/layericon/TL_SPRD_BANO.png b/apps/sn/public/images/map/layericon/TL_SPRD_BANO.png new file mode 100644 index 000000000..8d08dca29 Binary files /dev/null and b/apps/sn/public/images/map/layericon/TL_SPRD_BANO.png differ diff --git a/apps/sn/public/images/map/layericon/TL_SPRD_CRSRD.png b/apps/sn/public/images/map/layericon/TL_SPRD_CRSRD.png new file mode 100644 index 000000000..57b5f2ca5 Binary files /dev/null and b/apps/sn/public/images/map/layericon/TL_SPRD_CRSRD.png differ diff --git a/apps/sn/public/images/map/layericon/TL_SPRD_NONM.png b/apps/sn/public/images/map/layericon/TL_SPRD_NONM.png new file mode 100644 index 000000000..e68c05ab6 Binary files /dev/null and b/apps/sn/public/images/map/layericon/TL_SPRD_NONM.png differ diff --git a/apps/sn/public/images/map/layericon/TL_SPRD_RENM.png b/apps/sn/public/images/map/layericon/TL_SPRD_RENM.png new file mode 100644 index 000000000..4800beaeb Binary files /dev/null and b/apps/sn/public/images/map/layericon/TL_SPRD_RENM.png differ diff --git a/apps/sn/public/images/map/layericon/TL_SPRD_RWNM.png b/apps/sn/public/images/map/layericon/TL_SPRD_RWNM.png new file mode 100644 index 000000000..e68c05ab6 Binary files /dev/null and b/apps/sn/public/images/map/layericon/TL_SPRD_RWNM.png differ diff --git a/apps/sn/public/images/map/layericon/UFL_BMAN_PS.png b/apps/sn/public/images/map/layericon/UFL_BMAN_PS.png new file mode 100644 index 000000000..f2f64a641 Binary files /dev/null and b/apps/sn/public/images/map/layericon/UFL_BMAN_PS.png differ diff --git a/apps/sn/public/images/map/layericon/UFL_BOPC_PS.png b/apps/sn/public/images/map/layericon/UFL_BOPC_PS.png new file mode 100644 index 000000000..0f8e99f45 Binary files /dev/null and b/apps/sn/public/images/map/layericon/UFL_BOPC_PS.png differ diff --git a/apps/sn/public/images/map/layericon/UFL_BPIP_PS.png b/apps/sn/public/images/map/layericon/UFL_BPIP_PS.png new file mode 100644 index 000000000..dcc3622a7 Binary files /dev/null and b/apps/sn/public/images/map/layericon/UFL_BPIP_PS.png differ diff --git a/apps/sn/public/images/map/layericon/UFL_BPOL_PS.png b/apps/sn/public/images/map/layericon/UFL_BPOL_PS.png new file mode 100644 index 000000000..4f1102989 Binary files /dev/null and b/apps/sn/public/images/map/layericon/UFL_BPOL_PS.png differ diff --git a/apps/sn/public/images/map/layericon/UFL_BTRS_PS.png b/apps/sn/public/images/map/layericon/UFL_BTRS_PS.png new file mode 100644 index 000000000..557c17089 Binary files /dev/null and b/apps/sn/public/images/map/layericon/UFL_BTRS_PS.png differ diff --git a/apps/sn/public/images/map/layericon/UFL_GMNT_PS.png b/apps/sn/public/images/map/layericon/UFL_GMNT_PS.png new file mode 100644 index 000000000..6081d8b00 Binary files /dev/null and b/apps/sn/public/images/map/layericon/UFL_GMNT_PS.png differ diff --git a/apps/sn/public/images/map/layericon/UFL_GPIP_PS.png b/apps/sn/public/images/map/layericon/UFL_GPIP_PS.png new file mode 100644 index 000000000..5d7e86fd2 Binary files /dev/null and b/apps/sn/public/images/map/layericon/UFL_GPIP_PS.png differ diff --git a/apps/sn/public/images/map/layericon/UFL_GPRS_PS.png b/apps/sn/public/images/map/layericon/UFL_GPRS_PS.png new file mode 100644 index 000000000..79c120f20 Binary files /dev/null and b/apps/sn/public/images/map/layericon/UFL_GPRS_PS.png differ diff --git a/apps/sn/public/images/map/layericon/UFL_GVAL_PS.png b/apps/sn/public/images/map/layericon/UFL_GVAL_PS.png new file mode 100644 index 000000000..e8f7cde2e Binary files /dev/null and b/apps/sn/public/images/map/layericon/UFL_GVAL_PS.png differ diff --git a/apps/sn/public/images/map/layericon/UFL_KMAN_PS.png b/apps/sn/public/images/map/layericon/UFL_KMAN_PS.png new file mode 100644 index 000000000..764ce8ba5 Binary files /dev/null and b/apps/sn/public/images/map/layericon/UFL_KMAN_PS.png differ diff --git a/apps/sn/public/images/map/layericon/UFL_KPOL_PS.png b/apps/sn/public/images/map/layericon/UFL_KPOL_PS.png new file mode 100644 index 000000000..d9fa712cf Binary files /dev/null and b/apps/sn/public/images/map/layericon/UFL_KPOL_PS.png differ diff --git a/apps/sn/public/images/map/layericon/UFL_VALV_PS.png b/apps/sn/public/images/map/layericon/UFL_VALV_PS.png new file mode 100644 index 000000000..1b5d70178 Binary files /dev/null and b/apps/sn/public/images/map/layericon/UFL_VALV_PS.png differ diff --git a/apps/sn/public/images/map/layericon/WTL_FIRE_PS.png b/apps/sn/public/images/map/layericon/WTL_FIRE_PS.png new file mode 100644 index 000000000..4137a3f74 Binary files /dev/null and b/apps/sn/public/images/map/layericon/WTL_FIRE_PS.png differ diff --git a/apps/sn/public/images/map/layericon/WTL_FLOW_PS.png b/apps/sn/public/images/map/layericon/WTL_FLOW_PS.png new file mode 100644 index 000000000..285f7edfa Binary files /dev/null and b/apps/sn/public/images/map/layericon/WTL_FLOW_PS.png differ diff --git a/apps/sn/public/images/map/layericon/WTL_MANH_PS.png b/apps/sn/public/images/map/layericon/WTL_MANH_PS.png new file mode 100644 index 000000000..5f2a57cfe Binary files /dev/null and b/apps/sn/public/images/map/layericon/WTL_MANH_PS.png differ diff --git a/apps/sn/public/images/map/layericon/WTL_META_PS.png b/apps/sn/public/images/map/layericon/WTL_META_PS.png new file mode 100644 index 000000000..22c7af31b Binary files /dev/null and b/apps/sn/public/images/map/layericon/WTL_META_PS.png differ diff --git a/apps/sn/public/images/map/layericon/WTL_PIPE_PS.png b/apps/sn/public/images/map/layericon/WTL_PIPE_PS.png new file mode 100644 index 000000000..c9c6863f6 Binary files /dev/null and b/apps/sn/public/images/map/layericon/WTL_PIPE_PS.png differ diff --git a/apps/sn/public/images/map/layericon/WTL_RSRV_PS.png b/apps/sn/public/images/map/layericon/WTL_RSRV_PS.png new file mode 100644 index 000000000..d7b307a61 Binary files /dev/null and b/apps/sn/public/images/map/layericon/WTL_RSRV_PS.png differ diff --git a/apps/sn/public/images/map/layericon/WTL_RSRV_PS_GRAY.png b/apps/sn/public/images/map/layericon/WTL_RSRV_PS_GRAY.png new file mode 100644 index 000000000..33a667a1e Binary files /dev/null and b/apps/sn/public/images/map/layericon/WTL_RSRV_PS_GRAY.png differ diff --git a/apps/sn/public/images/map/layericon/WTL_VALV_PS.png b/apps/sn/public/images/map/layericon/WTL_VALV_PS.png new file mode 100644 index 000000000..ca2b4788b Binary files /dev/null and b/apps/sn/public/images/map/layericon/WTL_VALV_PS.png differ diff --git a/apps/sn/public/images/map/layericon/default.png b/apps/sn/public/images/map/layericon/default.png new file mode 100644 index 000000000..92bf1f587 Binary files /dev/null and b/apps/sn/public/images/map/layericon/default.png differ diff --git a/apps/sn/public/images/map/layericon/reset.png b/apps/sn/public/images/map/layericon/reset.png new file mode 100644 index 000000000..45f889206 Binary files /dev/null and b/apps/sn/public/images/map/layericon/reset.png differ diff --git a/apps/sn/public/images/map/toolbar/arrow_down.png b/apps/sn/public/images/map/toolbar/arrow_down.png new file mode 100644 index 000000000..88590ecaf Binary files /dev/null and b/apps/sn/public/images/map/toolbar/arrow_down.png differ diff --git a/apps/sn/public/images/map/toolbar/arrow_right.png b/apps/sn/public/images/map/toolbar/arrow_right.png new file mode 100644 index 000000000..7090c37dd Binary files /dev/null and b/apps/sn/public/images/map/toolbar/arrow_right.png differ diff --git a/apps/sn/public/images/map/toolbar/backward_icon.png b/apps/sn/public/images/map/toolbar/backward_icon.png new file mode 100644 index 000000000..3a853475e Binary files /dev/null and b/apps/sn/public/images/map/toolbar/backward_icon.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_cont_area.png b/apps/sn/public/images/map/toolbar/btn_cont_area.png new file mode 100644 index 000000000..044562b55 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_cont_area.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_cont_area_on.png b/apps/sn/public/images/map/toolbar/btn_cont_area_on.png new file mode 100644 index 000000000..0129ff1bd Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_cont_area_on.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_cont_next.png b/apps/sn/public/images/map/toolbar/btn_cont_next.png new file mode 100644 index 000000000..075111dcf Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_cont_next.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_cont_next_on.png b/apps/sn/public/images/map/toolbar/btn_cont_next_on.png new file mode 100644 index 000000000..4fdbe3a00 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_cont_next_on.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_cont_prev.png b/apps/sn/public/images/map/toolbar/btn_cont_prev.png new file mode 100644 index 000000000..7e3fae7d2 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_cont_prev.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_cont_prev_on.png b/apps/sn/public/images/map/toolbar/btn_cont_prev_on.png new file mode 100644 index 000000000..fb2d55cfd Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_cont_prev_on.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_cont_print.png b/apps/sn/public/images/map/toolbar/btn_cont_print.png new file mode 100644 index 000000000..82596b83d Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_cont_print.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_cont_print_on.png b/apps/sn/public/images/map/toolbar/btn_cont_print_on.png new file mode 100644 index 000000000..bb7f27334 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_cont_print_on.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_cont_ruler.png b/apps/sn/public/images/map/toolbar/btn_cont_ruler.png new file mode 100644 index 000000000..501e460a4 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_cont_ruler.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_cont_ruler_on.png b/apps/sn/public/images/map/toolbar/btn_cont_ruler_on.png new file mode 100644 index 000000000..676a81bcc Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_cont_ruler_on.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_cont_save.png b/apps/sn/public/images/map/toolbar/btn_cont_save.png new file mode 100644 index 000000000..c057b9377 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_cont_save.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_cont_save_on.png b/apps/sn/public/images/map/toolbar/btn_cont_save_on.png new file mode 100644 index 000000000..bf8e6d623 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_cont_save_on.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_cont_sketch.png b/apps/sn/public/images/map/toolbar/btn_cont_sketch.png new file mode 100644 index 000000000..38a5f507f Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_cont_sketch.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_cont_sketch_on.png b/apps/sn/public/images/map/toolbar/btn_cont_sketch_on.png new file mode 100644 index 000000000..79fb54e03 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_cont_sketch_on.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_cont_zoomin.png b/apps/sn/public/images/map/toolbar/btn_cont_zoomin.png new file mode 100644 index 000000000..ac32fbab0 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_cont_zoomin.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_cont_zoomin_on.png b/apps/sn/public/images/map/toolbar/btn_cont_zoomin_on.png new file mode 100644 index 000000000..f258a965f Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_cont_zoomin_on.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_cont_zoomout.png b/apps/sn/public/images/map/toolbar/btn_cont_zoomout.png new file mode 100644 index 000000000..2ee0b5161 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_cont_zoomout.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_cont_zoomout_on.png b/apps/sn/public/images/map/toolbar/btn_cont_zoomout_on.png new file mode 100644 index 000000000..58dcbfdae Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_cont_zoomout_on.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_edit_add.png b/apps/sn/public/images/map/toolbar/btn_edit_add.png new file mode 100644 index 000000000..fe4f0993f Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_edit_add.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_edit_add_on.png b/apps/sn/public/images/map/toolbar/btn_edit_add_on.png new file mode 100644 index 000000000..37a836298 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_edit_add_on.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_edit_del.png b/apps/sn/public/images/map/toolbar/btn_edit_del.png new file mode 100644 index 000000000..3bb372ab9 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_edit_del.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_edit_del_on.png b/apps/sn/public/images/map/toolbar/btn_edit_del_on.png new file mode 100644 index 000000000..89e558739 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_edit_del_on.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_edit_next.png b/apps/sn/public/images/map/toolbar/btn_edit_next.png new file mode 100644 index 000000000..df4913c90 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_edit_next.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_edit_next_on.png b/apps/sn/public/images/map/toolbar/btn_edit_next_on.png new file mode 100644 index 000000000..4e5540b55 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_edit_next_on.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_edit_prev.png b/apps/sn/public/images/map/toolbar/btn_edit_prev.png new file mode 100644 index 000000000..e94bf6d42 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_edit_prev.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_edit_prev_on.png b/apps/sn/public/images/map/toolbar/btn_edit_prev_on.png new file mode 100644 index 000000000..8645dedfa Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_edit_prev_on.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_edit_save.png b/apps/sn/public/images/map/toolbar/btn_edit_save.png new file mode 100644 index 000000000..5f01dd55c Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_edit_save.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_edit_save_on.png b/apps/sn/public/images/map/toolbar/btn_edit_save_on.png new file mode 100644 index 000000000..b035687d6 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_edit_save_on.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_edit_slt.png b/apps/sn/public/images/map/toolbar/btn_edit_slt.png new file mode 100644 index 000000000..aaf2bbd08 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_edit_slt.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_edit_slt_on.png b/apps/sn/public/images/map/toolbar/btn_edit_slt_on.png new file mode 100644 index 000000000..02d035a74 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_edit_slt_on.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_edit_snap.png b/apps/sn/public/images/map/toolbar/btn_edit_snap.png new file mode 100644 index 000000000..fc91d4304 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_edit_snap.png differ diff --git a/apps/sn/public/images/map/toolbar/btn_edit_snap_on.png b/apps/sn/public/images/map/toolbar/btn_edit_snap_on.png new file mode 100644 index 000000000..f856d8fbe Binary files /dev/null and b/apps/sn/public/images/map/toolbar/btn_edit_snap_on.png differ diff --git a/apps/sn/public/images/map/toolbar/close_icon.png b/apps/sn/public/images/map/toolbar/close_icon.png new file mode 100644 index 000000000..e8e76d5d7 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/close_icon.png differ diff --git a/apps/sn/public/images/map/toolbar/ico_gis_sprite.png b/apps/sn/public/images/map/toolbar/ico_gis_sprite.png new file mode 100644 index 000000000..0ee4b5985 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/ico_gis_sprite.png differ diff --git a/apps/sn/public/images/map/toolbar/icon-viewclose.png b/apps/sn/public/images/map/toolbar/icon-viewclose.png new file mode 100644 index 000000000..d93ce9c05 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/icon-viewclose.png differ diff --git a/apps/sn/public/images/map/toolbar/img_base_map.png b/apps/sn/public/images/map/toolbar/img_base_map.png new file mode 100644 index 000000000..7f96fcf5e Binary files /dev/null and b/apps/sn/public/images/map/toolbar/img_base_map.png differ diff --git a/apps/sn/public/images/map/toolbar/img_network_map.png b/apps/sn/public/images/map/toolbar/img_network_map.png new file mode 100644 index 000000000..df80af6dc Binary files /dev/null and b/apps/sn/public/images/map/toolbar/img_network_map.png differ diff --git a/apps/sn/public/images/map/toolbar/img_road_map.png b/apps/sn/public/images/map/toolbar/img_road_map.png new file mode 100644 index 000000000..02d015d12 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/img_road_map.png differ diff --git a/apps/sn/public/images/map/toolbar/img_settle_map.png b/apps/sn/public/images/map/toolbar/img_settle_map.png new file mode 100644 index 000000000..1a93f50e8 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/img_settle_map.png differ diff --git a/apps/sn/public/images/map/toolbar/loading.gif b/apps/sn/public/images/map/toolbar/loading.gif new file mode 100644 index 000000000..7c3683bb4 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/loading.gif differ diff --git a/apps/sn/public/images/map/toolbar/map_pin.png b/apps/sn/public/images/map/toolbar/map_pin.png new file mode 100644 index 000000000..b6cc2f6aa Binary files /dev/null and b/apps/sn/public/images/map/toolbar/map_pin.png differ diff --git a/apps/sn/public/images/map/toolbar/toggle_off.png b/apps/sn/public/images/map/toolbar/toggle_off.png new file mode 100644 index 000000000..d5dea9f67 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/toggle_off.png differ diff --git a/apps/sn/public/images/map/toolbar/toggle_on.png b/apps/sn/public/images/map/toolbar/toggle_on.png new file mode 100644 index 000000000..9cb573875 Binary files /dev/null and b/apps/sn/public/images/map/toolbar/toggle_on.png differ diff --git a/apps/sn/tailwind.config.ts b/apps/sn/tailwind.config.ts new file mode 100644 index 000000000..5a1819bab --- /dev/null +++ b/apps/sn/tailwind.config.ts @@ -0,0 +1,19 @@ +import type { Config } from "tailwindcss"; +import presetConfig from "../../tailwind.config.preset"; + +export default { + presets: [presetConfig], + darkMode: ["class", "[data-mode='dark']"], + content: [ + "./app/**/*.{js,ts,jsx,tsx,mdx}", + "../../packages/common/components/src/ui/**/*.{js,ts,jsx,tsx,mdx}", + "../../packages/common/business_components/src/ui/**/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + boxShadow: { + blur: "0 0 10px -1px rgba(0, 0, 0, 0.06)", + }, + }, + }, +} satisfies Config; diff --git a/apps/sn/tsconfig.json b/apps/sn/tsconfig.json new file mode 100644 index 000000000..5ed74c59e --- /dev/null +++ b/apps/sn/tsconfig.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "strictNullChecks": true, + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "baseUrl": "app", + "paths": { + "@/*": ["./*"], + "@common/components/*": ["../../../packages/common/components/src/*"], + "@common/components": ["../../../packages/common/components/src"], + "@common/business_components/*": ["../../../packages/common/business_components/src/*"], + "@common/business_components": ["../../../packages/common/business_components/src"], + "@common/network/*": ["../../../packages/common/network/*"], + "@common/network": ["../../../packages/common/network/"], + "@store": ["./store"], + "@service": ["./service"], + "@utils": ["./components/lib/utils"] + } + }, + "include": [ + ".eslintrc.*", + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + "**/*.mts", + "**/*.js", + "**/*.cjs", + "**/*.mjs", + "**/*.jsx", + "**/*.json", + ".next/types/**/*.ts" + ], + "exclude": ["**/node_modules", "**/.*/*"] +} diff --git a/package.json b/package.json index 159f30a50..5375511be 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,10 @@ "packages/*" ], "scripts": { - "build": "turbo run build --filter=!wr", + "build": "turbo run build", "dev": "turbo run dev", "dev:cs": "turbo run dev --filter=cs", + "dev:sn": "turbo run dev --filter=sn", "dev:wr": "turbo run dev --filter=wr", "preview:components": "turbo run storybook --filter=@common/components", "preinstall": "npx only-allow pnpm" diff --git a/packages/common/business_components/package.json b/packages/common/business_components/package.json index ce78d2a6b..e7aae9fc5 100644 --- a/packages/common/business_components/package.json +++ b/packages/common/business_components/package.json @@ -31,6 +31,7 @@ "@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-slot": "^1.1.2", + "axios": "1.8.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", @@ -42,6 +43,8 @@ "recharts": "^2.15.1", "sonner": "^2.0.1", "tailwind-merge": "^3.0.1", + "xlsx": "^0.18.5", + "xlsx-populate": "^1.21.0", "zod": "^3.24.2" }, "devDependencies": { @@ -57,13 +60,14 @@ "@storybook/test": "^8.5.3", "@storybook/theming": "^8.5.5", "@vitejs/plugin-react": "^4.3.4", + "@xlsx/xlsx-populate": "^0.0.8", "autoprefixer": "^10.4.20", "eslint": "^9.17.0", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.16", "eslint-plugin-storybook": "^0.11.2", "globals": "^15.14.0", - "postcss": "^8.5.1", + "postcss": "^8.5.3", "prettier": "^3.4.2", "prettier-plugin-tailwindcss": "^0.6.11", "storybook": "^8.5.3", diff --git a/packages/common/business_components/src/base.css b/packages/common/business_components/src/base.css index 6b56f5eff..ab1424be8 100644 --- a/packages/common/business_components/src/base.css +++ b/packages/common/business_components/src/base.css @@ -1,65 +1,10 @@ +@import "@common/assets/styles/base.css"; +@import "@common/assets/styles/diveicon.css"; + @tailwind base; @tailwind components; @tailwind utilities; -@layer base { - :root { - --background: #ffffff; - --foreground: #0a0a0a; - --card: #ffffff; - --card-foreground: #0a0a0a; - --popover: #ffffff; - --popover-foreground: #0a0a0a; - --primary: #2094fa; - --primary-foreground: #fafafa; - --secondary: #f5f5f5; - --secondary-foreground: #0f1e33; - --muted: #f5f5f5; - --muted-foreground: #737373; - --accent: #f5f5f5; - --accent-foreground: #0f1e33; - --destructive: #ff4141; - --destructive-foreground: #fafafa; - --border: #e5e5e5; - --input: #e5e5e5; - --label: #666666; - --ring: #0a0a0a; - --chart-1: #d95b43; - --chart-2: #299392; - --chart-3: #263e4d; - --chart-4: #dfb938; - --chart-5: #f08634; - --radius: 1rem; - } - [data-mode="dark"] { - --background: #0a0a0a; - --foreground: #fafafa; - --card: #0a0a0a; - --card-foreground: #fafafa; - --popover: #0a0a0a; - --popover-foreground: #fafafa; - --primary: #2094fa; - --primary-foreground: #171717; - --secondary: #262626; - --secondary-foreground: #fafafa; - --muted: #262626; - --muted-foreground: #a2a2a2; - --accent: #262626; - --accent-foreground: #fafafa; - --destructive: #b22222; - --destructive-foreground: #fafafa; - --border: #262626; - --input: #262626; - --label: #f1f1f1; - --ring: #d4d4d4; - --chart-1: #3399ff; - --chart-2: #339966; - --chart-3: #ff9933; - --chart-4: #cc66ff; - --chart-5: #ff3399; - } -} - @layer base { * { @apply border-border; diff --git a/packages/common/business_components/src/diveicon.css b/packages/common/business_components/src/diveicon.css deleted file mode 100644 index cce8cdaef..000000000 --- a/packages/common/business_components/src/diveicon.css +++ /dev/null @@ -1,1799 +0,0 @@ -@font-face { - font-family: "diveicon"; - src: url("../public/assets/fonts/diveicon.eot?stp8eo"); - src: - url("../public/assets/fonts/diveicon.eot?stp8eo#iefix") - format("embedded-opentype"), - url("../public/assets/fonts/diveicon.ttf?stp8eo") format("truetype"), - url("../public/assets/fonts/diveicon.woff?stp8eo") format("woff"), - url("../public/assets/fonts/diveicon.svg?stp8eo#diveicon") format("svg"); - font-weight: normal; - font-style: normal; - font-display: block; -} - -i.diveicon { - /* use !important to prevent issues with browser extensions that change fonts */ - font-family: "diveicon" !important; - speak: never; - font-style: normal; - font-weight: normal; - font-variant: normal; - text-transform: none; - line-height: 1; - - /* Better Font Rendering =========== */ - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -.di-account-box:before { - content: "\e8f3"; -} -.di-account-circle:before { - content: "\e901"; -} -.di-add-image:before { - content: "\e902"; -} -.di-add-Note:before { - content: "\e903"; -} -.di-alarm-add:before { - content: "\e904"; -} -.di-alarm-clock:before { - content: "\e905"; -} -.di-alarm-off:before { - content: "\e906"; -} -.di-alarm-on:before { - content: "\e908"; -} -.di-alarm:before { - content: "\e909"; -} -.di-alternate:before { - content: "\e90a"; -} -.di-analytics:before { - content: "\e90b"; -} -.di-android:before { - content: "\e90c"; -} -.di-announcement:before { - content: "\e90d"; -} -.di-app-indicator:before { - content: "\e90e"; -} -.di-app-list:before { - content: "\e90f"; -} -.di-app-square:before { - content: "\e910"; -} -.di-apple:before { - content: "\e911"; -} -.di-apps-square:before { - content: "\e912"; -} -.di-apps:before { - content: "\e913"; -} -.di-archive-fill:before { - content: "\e914"; -} -.di-archive:before { - content: "\e915"; -} -.di-arrow-90left-down:before { - content: "\e916"; -} -.di-arrow-90left-up:before { - content: "\e917"; -} -.di-arrow-90left:before { - content: "\e918"; -} -.di-arrow-90right-down:before { - content: "\e919"; -} -.di-arrow-90right-up:before { - content: "\e91a"; -} -.di-arrow-90right:before { - content: "\e91b"; -} -.di-arrow-back-square-fill:before { - content: "\e91c"; -} -.di-arrow-back:before { - content: "\e91d"; -} -.di-arrow-down-square-fill:before { - content: "\e91e"; -} -.di-arrow-down-square:before { - content: "\e91f"; -} -.di-arrow-down:before { - content: "\e920"; -} -.di-arrow-forward-square-fill:before { - content: "\e921"; -} -.di-arrow-forward:before { - content: "\e922"; -} -.di-arrow-left-right:before { - content: "\e923"; -} -.di-arrow-left-square-fill:before { - content: "\e924"; -} -.di-arrow-left-square:before { - content: "\e925"; -} -.di-arrow-left:before { - content: "\e926"; -} -.di-arrow-lr-up-down:before { - content: "\e927"; -} -.di-arrow-reflash:before { - content: "\e928"; -} -.di-arrow-reset:before { - content: "\e929"; -} -.di-arrow-right-square-fill:before { - content: "\e92a"; -} -.di-arrow-right-square:before { - content: "\e92b"; -} -.di-arrow-right:before { - content: "\e92c"; -} -.di-arrow-up-down:before { - content: "\e92d"; -} -.di-arrow-up-square-fill:before { - content: "\e92e"; -} -.di-arrow-up-square:before { - content: "\e92f"; -} -.di-arrow-up:before { - content: "\e930"; -} -.di-arrows-angle-contract:before { - content: "\e931"; -} -.di-arrows-angle-expand:before { - content: "\e932"; -} -.di-arrows-fullscreen:before { - content: "\e933"; -} -.di-aspect-ratio-fill:before { - content: "\e934"; -} -.di-aspect-ratio:before { - content: "\e935"; -} -.di-assignment-ind:before { - content: "\e936"; -} -.di-assignment-turned-in:before { - content: "\e937"; -} -.di-assignment:before { - content: "\e938"; -} -.di-asterisk:before { - content: "\e939"; -} -.di-atm:before { - content: "\e93a"; -} -.di-badge-4k-fill:before { - content: "\e93b"; -} -.di-badge-4k:before { - content: "\e93c"; -} -.di-badge-8k-fill:before { - content: "\e93d"; -} -.di-badge-8k:before { - content: "\e93e"; -} -.di-badge-ad-fill:before { - content: "\e93f"; -} -.di-badge-ad:before { - content: "\e940"; -} -.di-badge-cc-fill:before { - content: "\e941"; -} -.di-badge-cc:before { - content: "\e942"; -} -.di-badge-hd-fill:before { - content: "\e943"; -} -.di-badge-hd:before { - content: "\e944"; -} -.di-badge-tm-fill:before { - content: "\e945"; -} -.di-badge-tm:before { - content: "\e946"; -} -.di-bag-check-fill:before { - content: "\e947"; -} -.di-bag-check:before { - content: "\e948"; -} -.di-bag-fill:before { - content: "\e949"; -} -.di-bag:before { - content: "\e94a"; -} -.di-bags:before { - content: "\e94b"; -} -.di-bar-chart-fill:before { - content: "\e94c"; -} -.di-bar-chart:before { - content: "\e94d"; -} -.di-battery-charging:before { - content: "\e94e"; -} -.di-battery-emty:before { - content: "\e94f"; -} -.di-battery-full:before { - content: "\e950"; -} -.di-battery-harf:before { - content: "\e951"; -} -.di-bell-alert-box-fill:before { - content: "\e952"; -} -.di-bell-alert-box:before { - content: "\e953"; -} -.di-bell-alert-fill:before { - content: "\e954"; -} -.di-bell-alert:before { - content: "\e955"; -} -.di-bell-notification-fill:before { - content: "\e956"; -} -.di-bell-notification-on-fill:before { - content: "\e957"; -} -.di-bell-notification-on:before { - content: "\e958"; -} -.di-bell-notification:before { - content: "\e959"; -} -.di-bike:before { - content: "\e95a"; -} -.di-bluetooth:before { - content: "\e95b"; -} -.di-boat:before { - content: "\e95c"; -} -.di-book:before { - content: "\e95d"; -} -.di-bookmark-check-fill:before { - content: "\e95e"; -} -.di-bookmark-check:before { - content: "\e95f"; -} -.di-bookmark-fill:before { - content: "\e960"; -} -.di-bookmark-new-fill:before { - content: "\e961"; -} -.di-bookmark-new:before { - content: "\e962"; -} -.di-bookmark-star-fill:before { - content: "\e963"; -} -.di-bookmark-star:before { - content: "\e964"; -} -.di-bookmark:before { - content: "\e965"; -} -.di-boostzone:before { - content: "\e966"; -} -.di-box-arrow-backup:before { - content: "\e967"; -} -.di-box-arrow-download:before { - content: "\e968"; -} -.di-box-arrow-link-blank:before { - content: "\e969"; -} -.di-box-arrow-upload:before { - content: "\e96a"; -} -.di-box-seam:before { - content: "\e96b"; -} -.di-box:before { - content: "\e96c"; -} -.di-braces:before { - content: "\e96d"; -} -.di-briefcase-fill:before { - content: "\e96e"; -} -.di-briefcase:before { - content: "\e96f"; -} -.di-brightness-fill:before { - content: "\e970"; -} -.di-brightness:before { - content: "\e971"; -} -.di-broadcast-pin:before { - content: "\e972"; -} -.di-broadcast:before { - content: "\e973"; -} -.di-bug-fill:before { - content: "\e974"; -} -.di-bug:before { - content: "\e975"; -} -.di-building:before { - content: "\e976"; -} -.di-bus:before { - content: "\e977"; -} -.di-calculator-fill:before { - content: "\e978"; -} -.di-calculator:before { - content: "\e979"; -} -.di-calendar-check-fill:before { - content: "\e97a"; -} -.di-calendar-check:before { - content: "\e97b"; -} -.di-calendar-day-fill:before { - content: "\e97c"; -} -.di-calendar-day:before { - content: "\e97d"; -} -.di-calendar-event-fill:before { - content: "\e97e"; -} -.di-calendar-event:before { - content: "\e97f"; -} -.di-calendar-fill:before { - content: "\e980"; -} -.di-calendar-week-fill:before { - content: "\e981"; -} -.di-calendar-week:before { - content: "\e982"; -} -.di-calendar:before { - content: "\e983"; -} -.di-call-end:before { - content: "\e984"; -} -.di-call:before { - content: "\e985"; -} -.di-camera-fill:before { - content: "\e986"; -} -.di-camera-video-fill:before { - content: "\e987"; -} -.di-camera-video:before { - content: "\e988"; -} -.di-camera:before { - content: "\e989"; -} -.di-car:before { - content: "\e98a"; -} -.di-card-fill:before { - content: "\e98b"; -} -.di-card:before { - content: "\e98c"; -} -.di-caret-down-fill:before { - content: "\e98d"; -} -.di-caret-down-square-fill:before { - content: "\e98e"; -} -.di-caret-down-square:before { - content: "\e98f"; -} -.di-caret-left-fill:before { - content: "\e990"; -} -.di-caret-left-square-fill:before { - content: "\e991"; -} -.di-caret-left-square:before { - content: "\e992"; -} -.di-caret-right-fill:before { - content: "\e993"; -} -.di-caret-right-square-fill:before { - content: "\e994"; -} -.di-caret-right-square:before { - content: "\e995"; -} -.di-caret-up-fill:before { - content: "\e996"; -} -.di-caret-up-square-fill:before { - content: "\e997"; -} -.di-caret-up-square:before { - content: "\e998"; -} -.di-cart-fill:before { - content: "\e999"; -} -.di-cart:before { - content: "\e99a"; -} -.di-cash-stack:before { - content: "\e99b"; -} -.di-cash:before { - content: "\e99c"; -} -.di-cast:before { - content: "\e99d"; -} -.di-cctv:before { - content: "\e99e"; -} -.di-chat-box-left-dots-fill:before { - content: "\e99f"; -} -.di-chat-box-left-dots:before { - content: "\e9a0"; -} -.di-chat-box-left-fill:before { - content: "\e9a1"; -} -.di-chat-box-left-text-fill:before { - content: "\e9a2"; -} -.di-chat-box-left-text:before { - content: "\e9a3"; -} -.di-chat-box-left:before { - content: "\e9a4"; -} -.di-chat-box-right-dots-fill:before { - content: "\e9a5"; -} -.di-chat-box-right-dots:before { - content: "\e9a6"; -} -.di-chat-box-right-fill:before { - content: "\e9a7"; -} -.di-chat-box-right-text-fill:before { - content: "\e9a8"; -} -.di-chat-box-right-text:before { - content: "\e9a9"; -} -.di-chat-box-right:before { - content: "\e9aa"; -} -.di-chat-circle-left-dot-fill:before { - content: "\e9ab"; -} -.di-chat-circle-left-dot:before { - content: "\e9ac"; -} -.di-chat-circle-left-fill:before { - content: "\e9ad"; -} -.di-chat-circle-left-text-fill:before { - content: "\e9ae"; -} -.di-chat-circle-left-text:before { - content: "\e9af"; -} -.di-chat-circle-left:before { - content: "\e9b0"; -} -.di-chat-circle-right-dot-fill:before { - content: "\e9b1"; -} -.di-chat-circle-right-dot:before { - content: "\e9b2"; -} -.di-chat-circle-right-fill:before { - content: "\e9b3"; -} -.di-chat-circle-right-text-fill:before { - content: "\e9b4"; -} -.di-chat-circle-right-text:before { - content: "\e9b5"; -} -.di-chat-circle-right:before { - content: "\e9b6"; -} -.di-check-box:before { - content: "\e9b7"; -} -.di-check-circle-fill:before { - content: "\e9b8"; -} -.di-check-circle-line:before { - content: "\e9b9"; -} -.di-check-circle:before { - content: "\e9ba"; -} -.di-check-square-fill:before { - content: "\e9bb"; -} -.di-check-square-line:before { - content: "\e9bc"; -} -.di-check-square:before { - content: "\e9bd"; -} -.di-check:before { - content: "\e9be"; -} -.di-chevron-bar-contract:before { - content: "\e9bf"; -} -.di-chevron-bar-down:before { - content: "\e9c0"; -} -.di-chevron-bar-expand:before { - content: "\e9c1"; -} -.di-chevron-bar-left:before { - content: "\e9c2"; -} -.di-chevron-bar-right:before { - content: "\e9c3"; -} -.di-chevron-bar-up:before { - content: "\e9c4"; -} -.di-chevron-contract:before { - content: "\e9c5"; -} -.di-chevron-double-down:before { - content: "\e9c6"; -} -.di-chevron-double-left:before { - content: "\e9c7"; -} -.di-chevron-double-right:before { - content: "\e9c8"; -} -.di-chevron-double-up:before { - content: "\e9c9"; -} -.di-chevron-down:before { - content: "\e9ca"; -} -.di-chevron-expand:before { - content: "\e9cb"; -} -.di-chevron-left:before { - content: "\e9cc"; -} -.di-chevron-right:before { - content: "\e9cd"; -} -.di-chevron-up:before { - content: "\e9ce"; -} -.di-clipboard-check:before { - content: "\e9cf"; -} -.di-clipboard-data:before { - content: "\e9d0"; -} -.di-clipboard-text:before { - content: "\e9d1"; -} -.di-clipboard:before { - content: "\e9d2"; -} -.di-clock-fill:before { - content: "\e9d3"; -} -.di-clock-history:before { - content: "\e9d4"; -} -.di-clock:before { - content: "\e9d5"; -} -.di-cloud-arrow-down-fill:before { - content: "\e9d6"; -} -.di-cloud-arrow-down:before { - content: "\e9d7"; -} -.di-cloud-arrow-up-fill:before { - content: "\e9d8"; -} -.di-cloud-arrow-up:before { - content: "\e9d9"; -} -.di-cloud-check-fill:before { - content: "\e9da"; -} -.di-cloud-check:before { - content: "\e9db"; -} -.di-cloud-fill:before { - content: "\e9dc"; -} -.di-cloud:before { - content: "\e9dd"; -} -.di-code-square:before { - content: "\e9de"; -} -.di-code:before { - content: "\e9df"; -} -.di-collection-fill:before { - content: "\e9e0"; -} -.di-collection:before { - content: "\e9e1"; -} -.di-compass-fill:before { - content: "\e9e2"; -} -.di-compass:before { - content: "\e9e3"; -} -.di-cone:before { - content: "\e9e4"; -} -.di-controller-fill:before { - content: "\e9e5"; -} -.di-controller:before { - content: "\e9e6"; -} -.di-copyright-sign:before { - content: "\e9e7"; -} -.di-copyright-symbol:before { - content: "\e9e8"; -} -.di-COVID-19-fill:before { - content: "\e9e9"; -} -.di-COVID-19:before { - content: "\e9ea"; -} -.di-cpu-fill:before { - content: "\e9eb"; -} -.di-cpu:before { - content: "\e9ec"; -} -.di-crop:before { - content: "\e9ed"; -} -.di-cup-fill:before { - content: "\e9ee"; -} -.di-cup-straw:before { - content: "\e9ef"; -} -.di-cup:before { - content: "\e9f0"; -} -.di-cursor-fill:before { - content: "\e9f1"; -} -.di-cursor-text:before { - content: "\e9f2"; -} -.di-cursor:before { - content: "\e9f3"; -} -.di-dash-circle-fill:before { - content: "\e9f4"; -} -.di-dash-circle:before { - content: "\e9f5"; -} -.di-dash-square-fill:before { - content: "\e9f6"; -} -.di-dash-square:before { - content: "\e9f7"; -} -.di-dash:before { - content: "\e9f8"; -} -.di-dashboard:before { - content: "\e9f9"; -} -.di-diagram:before { - content: "\e9fa"; -} -.di-diamond-fill:before { - content: "\e9fb"; -} -.di-diamond:before { - content: "\e9fc"; -} -.di-diamond2:before { - content: "\e9fd"; -} -.di-display-fill:before { - content: "\e9fe"; -} -.di-display:before { - content: "\e9ff"; -} -.di-dns:before { - content: "\ea00"; -} -.di-door-closed-fill:before { - content: "\ea01"; -} -.di-door-closed:before { - content: "\ea02"; -} -.di-download_app:before { - content: "\ea03"; -} -.di-download:before { - content: "\ea04"; -} -.di-email-fill-notification:before { - content: "\ea05"; -} -.di-email-fill-open:before { - content: "\ea06"; -} -.di-email-fill:before { - content: "\ea07"; -} -.di-email-notification:before { - content: "\ea08"; -} -.di-email-open:before { - content: "\ea09"; -} -.di-email:before { - content: "\ea0a"; -} -.di-emergenc_call:before { - content: "\ea0b"; -} -.di-emergenc:before { - content: "\ea0c"; -} -.di-exclamation-chat-fill:before { - content: "\ea0d"; -} -.di-exclamation-chat:before { - content: "\ea0e"; -} -.di-exclamation-circle-fill:before { - content: "\ea0f"; -} -.di-exclamation-circle:before { - content: "\ea10"; -} -.di-exclamation-diamond-fill:before { - content: "\ea11"; -} -.di-exclamation-diamond:before { - content: "\ea12"; -} -.di-exclamation-square-circle:before { - content: "\ea13"; -} -.di-exclamation-square-fill:before { - content: "\ea14"; -} -.di-exclamation-square:before { - content: "\ea15"; -} -.di-exclamation-triangle-fill:before { - content: "\ea16"; -} -.di-exclamation-triangle:before { - content: "\ea17"; -} -.di-exclamation:before { - content: "\ea18"; -} -.di-exclude:before { - content: "\ea19"; -} -.di-face-id:before { - content: "\ea1a"; -} -.di-facebook-circle:before { - content: "\ea1b"; -} -.di-facebook-square:before { - content: "\ea1c"; -} -.di-favorite-hart:before { - content: "\ea1d"; -} -.di-favorite-star:before { - content: "\ea1e"; -} -.di-file-copy:before { - content: "\ea1f"; -} -.di-file-excel:before { - content: "\ea20"; -} -.di-file-image:before { - content: "\ea21"; -} -.di-file-keynote:before { - content: "\ea22"; -} -.di-file-numbers:before { - content: "\ea23"; -} -.di-file-powerpoint:before { - content: "\ea24"; -} -.di-file-text:before { - content: "\ea25"; -} -.di-file-word:before { - content: "\ea26"; -} -.di-file:before { - content: "\ea27"; -} -.di-filter-circle-fill:before { - content: "\ea28"; -} -.di-filter-circle:before { - content: "\ea29"; -} -.di-filter-left:before { - content: "\ea2a"; -} -.di-filter-right:before { - content: "\ea2b"; -} -.di-filter-square-fill:before { - content: "\ea2c"; -} -.di-filter-square:before { - content: "\ea2d"; -} -.di-filter:before { - content: "\ea2e"; -} -.di-finder-folder:before { - content: "\ea2f"; -} -.di-finder:before { - content: "\ea30"; -} -.di-fingerprint:before { - content: "\ea31"; -} -.di-flag-fill:before { - content: "\ea32"; -} -.di-flag:before { - content: "\ea33"; -} -.di-flashlight:before { - content: "\ea34"; -} -.di-folder-check:before { - content: "\ea35"; -} -.di-folder-delet:before { - content: "\ea36"; -} -.di-folder-fill:before { - content: "\ea37"; -} -.di-folder-open-fill:before { - content: "\ea38"; -} -.di-folder-open:before { - content: "\ea39"; -} -.di-folder-plus:before { - content: "\ea3a"; -} -.di-folder-share:before { - content: "\ea3b"; -} -.di-folder-x:before { - content: "\ea3c"; -} -.di-folder:before { - content: "\ea3d"; -} -.di-font:before { - content: "\ea3e"; -} -.di-fullscreen-exit:before { - content: "\ea3f"; -} -.di-fullscreen:before { - content: "\ea40"; -} -.di-gear-fill:before { - content: "\ea41"; -} -.di-gear-square-fill:before { - content: "\ea42"; -} -.di-gear-system-update:before { - content: "\ea43"; -} -.di-gear:before { - content: "\ea44"; -} -.di-gem:before { - content: "\ea45"; -} -.di-geo-alt:before { - content: "\ea46"; -} -.di-geo-location:before { - content: "\ea47"; -} -.di-geo-my-location:before { - content: "\ea48"; -} -.di-gift-fill:before { - content: "\ea49"; -} -.di-gift:before { - content: "\ea4a"; -} -.di-graph-down:before { - content: "\ea4b"; -} -.di-graph-up:before { - content: "\ea4c"; -} -.di-grid-2-fill:before { - content: "\ea4d"; -} -.di-grid-3-fill:before { - content: "\ea4e"; -} -.di-grid:before { - content: "\ea4f"; -} -.di-grip-horizontal:before { - content: "\ea50"; -} -.di-grip-vertical:before { - content: "\ea51"; -} -.di-group:before { - content: "\ea52"; -} -.di-group2:before { - content: "\ea53"; -} -.di-hand-index:before { - content: "\ea54"; -} -.di-harf:before { - content: "\ea55"; -} -.di-hart-fill:before { - content: "\ea56"; -} -.di-hart-half:before { - content: "\ea57"; -} -.di-hash:before { - content: "\ea58"; -} -.di-hdd-network:before { - content: "\ea59"; -} -.di-hdd:before { - content: "\ea5a"; -} -.di-hexagon-fill:before { - content: "\ea5b"; -} -.di-hexagon-harf:before { - content: "\ea5c"; -} -.di-hexagon:before { - content: "\ea5d"; -} -.di-history:before { - content: "\ea5e"; -} -.di-home-fill:before { - content: "\ea5f"; -} -.di-home:before { - content: "\ea60"; -} -.di-hotel_bed:before { - content: "\ea61"; -} -.di-hotel:before { - content: "\ea62"; -} -.di-image-fill:before { - content: "\ea63"; -} -.di-image:before { - content: "\ea64"; -} -.di-images:before { - content: "\ea65"; -} -.di-info-box:before { - content: "\ea66"; -} -.di-info-circle:before { - content: "\ea67"; -} -.di-info-fill:before { - content: "\ea68"; -} -.di-info:before { - content: "\ea69"; -} -.di-instagram-circle:before { - content: "\ea6a"; -} -.di-instagram-fill:before { - content: "\ea6b"; -} -.di-instagram:before { - content: "\ea6c"; -} -.di-intersect:before { - content: "\ea6d"; -} -.di-justify-left:before { - content: "\ea6e"; -} -.di-justify-right:before { - content: "\ea6f"; -} -.di-justify:before { - content: "\ea70"; -} -.di-kakao-alim-talk:before { - content: "\ea71"; -} -.di-kakao-circle:before { - content: "\ea72"; -} -.di-kakao-firend-talk:before { - content: "\ea73"; -} -.di-kakao-story-circle:before { - content: "\ea74"; -} -.di-kakao-story:before { - content: "\ea75"; -} -.di-kakao-talk:before { - content: "\ea76"; -} -.di-key:before { - content: "\ea77"; -} -.di-keyboard:before { - content: "\ea78"; -} -.di-label:before { - content: "\ea79"; -} -.di-language:before { - content: "\ea7a"; -} -.di-laptop-fill:before { - content: "\ea7b"; -} -.di-laptop:before { - content: "\ea7c"; -} -.di-layer-back:before { - content: "\ea7d"; -} -.di-layer-front:before { - content: "\ea7e"; -} -.di-layer:before { - content: "\ea7f"; -} -.di-lightning-fill:before { - content: "\ea80"; -} -.di-lightning:before { - content: "\ea81"; -} -.di-list-check:before { - content: "\ea82"; -} -.di-list:before { - content: "\ea83"; -} -.di-local-airport-land:before { - content: "\ea84"; -} -.di-local-airport:before { - content: "\ea85"; -} -.di-local-atm:before { - content: "\ea86"; -} -.di-local-bar:before { - content: "\ea87"; -} -.di-local-cafe:before { - content: "\ea88"; -} -.di-local-dining:before { - content: "\ea89"; -} -.di-local-ev-station:before { - content: "\ea8a"; -} -.di-local-fastfood:before { - content: "\ea8b"; -} -.di-local-gas-station:before { - content: "\ea8c"; -} -.di-local-hospital:before { - content: "\ea8d"; -} -.di-local-laundry:before { - content: "\ea8e"; -} -.di-local-library:before { - content: "\ea8f"; -} -.di-local-museum:before { - content: "\ea90"; -} -.di-local-restaurant:before { - content: "\ea91"; -} -.di-local-store:before { - content: "\ea92"; -} -.di-lock-open:before { - content: "\ea93"; -} -.di-lock:before { - content: "\ea94"; -} -.di-map-fill:before { - content: "\ea95"; -} -.di-map:before { - content: "\ea96"; -} -.di-menu-close-fill:before { - content: "\ea97"; -} -.di-menu-close:before { - content: "\ea98"; -} -.di-menu-open-fill:before { - content: "\ea99"; -} -.di-menu-open:before { - content: "\ea9a"; -} -.di-mic-fill:before { - content: "\ea9b"; -} -.di-mic-mute-fill:before { - content: "\ea9c"; -} -.di-mic-mute:before { - content: "\ea9d"; -} -.di-mic:before { - content: "\ea9e"; -} -.di-minus:before { - content: "\ea9f"; -} -.di-mobile-android:before { - content: "\eaa0"; -} -.di-mobile-flip:before { - content: "\eaa1"; -} -.di-mobile-fold:before { - content: "\eaa2"; -} -.di-mobile-ios:before { - content: "\eaa3"; -} -.di-mobile-pad:before { - content: "\eaa4"; -} -.di-mobile-store:before { - content: "\eaa5"; -} -.di-mobile:before { - content: "\eaa6"; -} -.di-moon-crescent:before { - content: "\eaa7"; -} -.di-moon-half:before { - content: "\eaa8"; -} -.di-moon:before { - content: "\eaa9"; -} -.di-more-horiz:before { - content: "\eaaa"; -} -.di-more-vert:before { - content: "\eaab"; -} -.di-movie-fill:before { - content: "\eaac"; -} -.di-movie:before { - content: "\eaad"; -} -.di-music-backward-fill:before { - content: "\eaae"; -} -.di-music-backward:before { - content: "\eaaf"; -} -.di-music-forward-fill:before { - content: "\eab0"; -} -.di-music-forward:before { - content: "\eab1"; -} -.di-music-list:before { - content: "\eab2"; -} -.di-music-pause-fill:before { - content: "\eab3"; -} -.di-music-pause:before { - content: "\eab4"; -} -.di-music-play-fill:before { - content: "\eab5"; -} -.di-music-play:before { - content: "\eab6"; -} -.di-music-stop-fill:before { - content: "\eab7"; -} -.di-music-stop:before { - content: "\eab8"; -} -.di-music:before { - content: "\eab9"; -} -.di-naver-blog:before { - content: "\eaba"; -} -.di-naver-circle:before { - content: "\eabb"; -} -.di-naver:before { - content: "\eabc"; -} -.di-news:before { - content: "\eabd"; -} -.di-option:before { - content: "\eabe"; -} -.di-paperclip:before { - content: "\eabf"; -} -.di-payment:before { - content: "\eac0"; -} -.di-pen-fill:before { - content: "\eac1"; -} -.di-pen:before { - content: "\eac2"; -} -.di-pencil-fill:before { - content: "\eac3"; -} -.di-pencil-square:before { - content: "\eac4"; -} -.di-pencil:before { - content: "\eac5"; -} -.di-person-fill:before { - content: "\eac6"; -} -.di-person:before { - content: "\eac7"; -} -.di-personal-information:before { - content: "\eac8"; -} -.di-pet:before { - content: "\eac9"; -} -.di-power:before { - content: "\eaca"; -} -.di-print:before { - content: "\eacb"; -} -.di-problem:before { - content: "\eacc"; -} -.di-profile-smile:before { - content: "\eacd"; -} -.di-profile:before { - content: "\eace"; -} -.di-push-assemble:before { - content: "\eacf"; -} -.di-push-fill:before { - content: "\ead0"; -} -.di-push-notification:before { - content: "\ead1"; -} -.di-push:before { - content: "\ead2"; -} -.di-question-answer:before { - content: "\ead3"; -} -.di-question-circle-fill:before { - content: "\ead4"; -} -.di-question-circle:before { - content: "\ead5"; -} -.di-question-diamond-fill:before { - content: "\ead6"; -} -.di-question-diamond:before { - content: "\ead7"; -} -.di-question-square-fill:before { - content: "\ead8"; -} -.di-question-square:before { - content: "\ead9"; -} -.di-question:before { - content: "\eada"; -} -.di-radio:before { - content: "\eadb"; -} -.di-receipt:before { - content: "\eadc"; -} -.di-refresh-fill:before { - content: "\eadd"; -} -.di-refresh:before { - content: "\eade"; -} -.di-reset-fill:before { - content: "\eadf"; -} -.di-reset:before { - content: "\eae0"; -} -.di-rotation-lock:before { - content: "\eae1"; -} -.di-rotation:before { - content: "\eae2"; -} -.di-rwis:before { - content: "\eae3"; -} -.di-search:before { - content: "\eae4"; -} -.di-server:before { - content: "\eae5"; -} -.di-share:before { - content: "\eae6"; -} -.di-shield:before { - content: "\eae7"; -} -.di-shift-fill:before { - content: "\eae8"; -} -.di-shift:before { - content: "\eae9"; -} -.di-shuffle:before { - content: "\eaea"; -} -.di-silent:before { - content: "\eaeb"; -} -.di-sim:before { - content: "\eaec"; -} -.di-slash:before { - content: "\eaed"; -} -.di-sliders:before { - content: "\eaee"; -} -.di-smart-watch:before { - content: "\eaef"; -} -.di-sort-alpha-down-alt:before { - content: "\eaf0"; -} -.di-sort-alpha-down:before { - content: "\eaf1"; -} -.di-sort-alpha-up-alt:before { - content: "\eaf2"; -} -.di-sort-alpha-up:before { - content: "\eaf3"; -} -.di-sort-down-alt:before { - content: "\eaf4"; -} -.di-sort-down:before { - content: "\eaf5"; -} -.di-sort-numeric-down-alt:before { - content: "\eaf6"; -} -.di-sort-numeric-down:before { - content: "\eaf7"; -} -.di-sort-numeric-up-alt:before { - content: "\eaf8"; -} -.di-sort-numeric-up:before { - content: "\eaf9"; -} -.di-sort-up-alt:before { - content: "\eafa"; -} -.di-sort-up:before { - content: "\eafb"; -} -.di-sound:before { - content: "\eafc"; -} -.di-soundwave:before { - content: "\eafd"; -} -.di-square-fill:before { - content: "\eafe"; -} -.di-square:before { - content: "\eaff"; -} -.di-star-circle:before { - content: "\eb00"; -} -.di-star-fill:before { - content: "\eb01"; -} -.di-star:before { - content: "\eb02"; -} -.di-statistics-circle:before { - content: "\eb03"; -} -.di-statistics:before { - content: "\eb04"; -} -.di-sticky-fill:before { - content: "\eb05"; -} -.di-sticky:before { - content: "\eb06"; -} -.di-stoplights-fill:before { - content: "\eb07"; -} -.di-stoplights:before { - content: "\eb08"; -} -.di-sunny:before { - content: "\eb09"; -} -.di-tab:before { - content: "\eb0a"; -} -.di-table:before { - content: "\eb0b"; -} -.di-terminal-fill:before { - content: "\eb0c"; -} -.di-terminal:before { - content: "\eb0d"; -} -.di-ternnel:before { - content: "\eb0e"; -} -.di-textarea-t:before { - content: "\eb0f"; -} -.di-textarea:before { - content: "\eb10"; -} -.di-thermometer-full:before { - content: "\eb11"; -} -.di-thermometer-half:before { - content: "\eb12"; -} -.di-thermometer:before { - content: "\eb13"; -} -.di-time-end:before { - content: "\eb14"; -} -.di-time-full:before { - content: "\eb15"; -} -.di-time-start:before { - content: "\eb16"; -} -.di-toggle-off:before { - content: "\eb17"; -} -.di-toggle-on:before { - content: "\eb18"; -} -.di-toggle:before { - content: "\eb19"; -} -.di-train:before { - content: "\eb1a"; -} -.di-trash-fill:before { - content: "\eb1b"; -} -.di-trash:before { - content: "\eb1c"; -} -.di-truck-fill:before { - content: "\eb1d"; -} -.di-truck:before { - content: "\eb1e"; -} -.di-ui-checks-grid:before { - content: "\eb1f"; -} -.di-ui-radios-grid:before { - content: "\eb20"; -} -.di-upload:before { - content: "\eb21"; -} -.di-vibration:before { - content: "\eb22"; -} -.di-vms:before { - content: "\eb23"; -} -.di-volume-down-fill:before { - content: "\eb24"; -} -.di-volume-down:before { - content: "\eb25"; -} -.di-volume-mute-fill:before { - content: "\eb26"; -} -.di-volume-mute:before { - content: "\eb27"; -} -.di-volume-off-fill:before { - content: "\eb28"; -} -.di-volume-off:before { - content: "\eb29"; -} -.di-volume-up-fill:before { - content: "\eb2a"; -} -.di-volume-up:before { - content: "\eb2b"; -} -.di-weather-cloud-fill:before { - content: "\eb2c"; -} -.di-weather-cloud:before { - content: "\eb2d"; -} -.di-weather-cloudy-gusts:before { - content: "\eb2e"; -} -.di-weather-cloudy-windy:before { - content: "\eb2f"; -} -.di-weather-cloudy:before { - content: "\eb30"; -} -.di-weather-fahrenheit:before { - content: "\eb31"; -} -.di-weather-fog:before { - content: "\eb32"; -} -.di-weather-hail:before { - content: "\eb33"; -} -.di-weather-humidity:before { - content: "\eb34"; -} -.di-weather-hurricane:before { - content: "\eb35"; -} -.di-weather-lightning:before { - content: "\eb36"; -} -.di-weather-rain:before { - content: "\eb37"; -} -.di-weather-rainny:before { - content: "\eb38"; -} -.di-weather-snow:before { - content: "\eb39"; -} -.di-weather-snowflake:before { - content: "\eb3a"; -} -.di-weather-sunny-overcast:before { - content: "\eb3b"; -} -.di-weather-sunny:before { - content: "\eb3c"; -} -.di-weather-thunderstorm:before { - content: "\eb3d"; -} -.di-weather-umbrella:before { - content: "\eb3e"; -} -.di-weather-windy:before { - content: "\eb3f"; -} -.di-whater:before { - content: "\eb40"; -} -.di-whather-celsius:before { - content: "\eb41"; -} -.di-wifi-1:before { - content: "\eb42"; -} -.di-wifi-2:before { - content: "\eb43"; -} -.di-wifi-off:before { - content: "\eb44"; -} -.di-wifi:before { - content: "\eb45"; -} -.di-window:before { - content: "\eb46"; -} -.di-x-circle-fill:before { - content: "\eb47"; -} -.di-x-circle:before { - content: "\eb48"; -} -.di-x-square-fill:before { - content: "\eb49"; -} -.di-x-square:before { - content: "\eb4a"; -} -.di-x:before { - content: "\eb4b"; -} -.di-zoom-in:before { - content: "\eb4c"; -} -.di-zoom-out:before { - content: "\eb4d"; -} -.di-server-line:before { - content: "\e907"; -} diff --git a/packages/common/business_components/src/index.css b/packages/common/business_components/src/index.css deleted file mode 100644 index 46896e552..000000000 --- a/packages/common/business_components/src/index.css +++ /dev/null @@ -1,11 +0,0 @@ - -@layer base {} - -@layer base { - * { - @apply border-border outline-ring/50; - } - body { - @apply bg-background text-foreground; - } -} \ No newline at end of file diff --git a/packages/common/business_components/src/ui/alert_dialog_ui/index.tsx b/packages/common/business_components/src/ui/alert_dialog_ui/index.tsx new file mode 100644 index 000000000..8437c87a8 --- /dev/null +++ b/packages/common/business_components/src/ui/alert_dialog_ui/index.tsx @@ -0,0 +1,53 @@ +import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@common/components"; +import React from "react"; + +type ModalType = { + bodyClass: string; + butNm: string; + title: string; + body: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function UiModal({ bodyClass, butNm, title, body, open, onOpenChange }: ModalType) { + const dialogProps = open !== undefined && onOpenChange !== undefined + ? { open, onOpenChange } + : {}; + + return ( + <> + {!open && ( + <Dialog> + <DialogTrigger asChild> + <Button className="mr-4 rounded-lg">{butNm}</Button> + </DialogTrigger> + <DialogContent className={bodyClass}> + <DialogHeader> + <DialogTitle>{title}</DialogTitle> + <DialogDescription> + </DialogDescription> + </DialogHeader> + {body} + <DialogFooter className="sm:justify-start"> + </DialogFooter> + </DialogContent> + </Dialog> + )} + {open && ( + <Dialog {...dialogProps}> + <DialogContent className={bodyClass}> + <DialogHeader> + <DialogTitle>{title}</DialogTitle> + <DialogDescription> + </DialogDescription> + </DialogHeader> + {body} + <DialogFooter className="sm:justify-start"> + </DialogFooter> + </DialogContent> + </Dialog> + )} + </> + ); +} diff --git a/packages/common/business_components/src/ui/chart/BarChart.tsx b/packages/common/business_components/src/ui/chart/BarChart.tsx index 82499306c..0802a72e4 100644 --- a/packages/common/business_components/src/ui/chart/BarChart.tsx +++ b/packages/common/business_components/src/ui/chart/BarChart.tsx @@ -1,42 +1,136 @@ "use client" -import type { CSSProperties } from "react"; -import { - Card, - CardContent -} from "./lib/card"; +import { Bar, BarChart, CartesianGrid, Legend, XAxis, YAxis } from "recharts"; import { ChartContainer, ChartTooltip, - ChartTooltipContent, type ChartConfig } from "./lib/chart"; -import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"; +import type { CSSProperties } from "react"; -export default function Chart(props: { chartConfig: ChartConfig; chartData: any[] | undefined; chartTitle: string}) { +const customStyles = { + legendContainer: { + display: "flex", + justifyContent: "center", + gap: "20px", + fontSize: "9px", + fontWeight : "bold", + marginTop: "10px", + } as CSSProperties, +}; + +type BarChartProps = { + chartConfig: ChartConfig | undefined; + chartData: any[] | undefined; + chartTitle: string; + chartClassName?: string; + graphSize?: number; + yFieldText?: string; + showLegend?: boolean; +} + +// 커스텀 툴팁 +const CustomTooltip = ({ + active, + payload, + label +}: { + active?: boolean; + payload?: Array<{ + name: string; + value: number; + color: string; + }>; + label?: string; +}) => { + if (active && payload && payload.length) { + return ( + <div className="w-[180px] rounded bg-white p-2.5 shadow-[0px_2px_10px_rgba(0,0,0,0.2)]"> + <p className="text-[12px] font-bold m-3">{label}</p> + {payload.map((item, index) => ( + <div key={index} style={{ marginTop: "10px", display: "flex", alignItems: "center", gap: "8px" }}> + {/* 색상 박스 추가 (범례 색상과 동일) */} + <div style={{ width: "10px", height: "10px", backgroundColor: item.color, borderRadius: "50%" }}></div> + <span className="text-[11px] font-bold">{` ${item.name} : ${item.value}`}</span> + </div> + ))} + </div> + ); + } + return null; +}; + +// 커스텀 범례 (글씨 크기 키우기 + 스타일 추가) +const CustomLegend = (props: any) => { + const { payload } = props; return ( - <Card className="h-full"> - <CardContent className="h-full"> - {props.chartTitle && props.chartTitle !== "" && props.chartTitle !== "undefined" ? ( - <div style={{ textAlign: "left", fontSize: "13px", fontWeight: "bold", marginTop:"13px", marginBottom: "10px" }}> - {props.chartTitle} - </div>): null} - <div className="h-[calc(100%-42px)] flex justify-center"> - <ChartContainer config={props.chartConfig} className="h-full w-full"> - <BarChart accessibilityLayer data={props.chartData} className="h-full"> - <CartesianGrid vertical={false} /> - <XAxis - dataKey="label" - tickLine={false} - tickMargin={10} - axisLine={false} - tickFormatter={(value) => value.slice(0, 3)} - /> - <ChartTooltip cursor={false} content={<ChartTooltipContent indicator="dashed" />}/> - <Bar dataKey="val" radius={10} /> - </BarChart> - </ChartContainer> + <div style={customStyles.legendContainer}> + {payload.map((entry: unknown, index: number) => ( + <div key={`legend-${index}`} className="flex items-center gap-2"> + <div className="w-[14px] h-[14px] ml-10" style={{ backgroundColor: (entry as {color: string}).color }}></div> + <span className="text-[12px]">{(entry as {value: string}).value}</span> </div> - </CardContent> - </Card> + ))} + </div> + ); +}; + +export default function Chart({chartConfig=undefined, chartData=[], chartTitle='차트', chartClassName='w-full h-full', graphSize=40, yFieldText='', showLegend=false} : BarChartProps) { + const colors = ["#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEEAD", "#D4A5A5"]; + const getMaxValue = (data: any[], keyPrefix: string) => { + return Math.max(...data.map(item => + Object.keys(item) + .filter(key => key.includes(keyPrefix)) + .map(key => item[key]) + ).flat()); + }; + + const barMaxValue = getMaxValue(chartData, 'barVal'); + return ( + <> + {chartTitle && chartTitle !== "" && chartTitle !== "undefined" ? ( + <div className="!mt-3 text-[2.5rem] font-bold"> + {chartTitle} + </div>): null} + <div className="flex justify-center"> + <ChartContainer config={chartConfig ?? {}} className={chartClassName + " !w-[90%]"}> + <BarChart accessibilityLayer data={chartData}> + <CartesianGrid vertical={false} /> + <XAxis + dataKey="label" + tickLine={true} + tickMargin={10} + axisLine={true} + fontSize={13} + fontWeight={500} + /> + <YAxis + tickLine={true} + tickMargin={10} + tickCount={6} + axisLine={true} + fontSize={12} + fontWeight={500} + domain={[0, Math.ceil(barMaxValue * 1)]} + label={yFieldText ? { value: yFieldText, className:"text-[13px] font-bold", position: 'insideLeft', angle: -90, offset: 20 } : undefined} + /> + <ChartTooltip cursor={false} content={<CustomTooltip active={undefined} payload={undefined} label={undefined} />}/> + {chartData && chartData.length > 0 && ( + Object.keys(chartData[0]) + .filter(key => key !== "label") + .map((key, idx) => ( + <Bar + dataKey={key} + radius={5} + key={key} + barSize={graphSize} + fill={colors[idx % colors.length]} + /> + )) + )} + {showLegend && <Legend content={CustomLegend}/>} + </BarChart> + </ChartContainer> + </div> + </> ) } \ No newline at end of file diff --git a/packages/common/business_components/src/ui/chart/LineBarChart.tsx b/packages/common/business_components/src/ui/chart/LineBarChart.tsx index 5f2ae4715..2e83458d8 100644 --- a/packages/common/business_components/src/ui/chart/LineBarChart.tsx +++ b/packages/common/business_components/src/ui/chart/LineBarChart.tsx @@ -1,37 +1,15 @@ "use client" import type { CSSProperties } from "react"; import { Bar, CartesianGrid, ComposedChart, Legend, Line, Tooltip, XAxis, YAxis } from "recharts"; -import { - Card, - CardContent -} from "./lib/card"; import { ChartContainer, type ChartConfig } from "./lib/chart"; -import React from "react"; +import type { ContentType } from "recharts/types/component/DefaultLegendContent"; -// 툴팁 커스텀 css +// 툴크 커스텀 css /** 차트 기본 스타일 */ -const styles = { - tooltipDiv: { - borderRadius: "5px", - backgroundColor: "white", - padding: "10px", - boxShadow: "0px 2px 10px rgba(0,0,0,0.2)", - width: "180px", // 적절한 크기로 설정 - } as React.CSSProperties, - tooltipLabel: { - fontWeight: "bold", - fontSize: "12px", - marginBottom: "5px", - borderBottom: "1px solid #ddd", - paddingBottom: "3px", - } as React.CSSProperties, - tooltipText: { - fontSize: "11px", - fontWeight:"bold", - } as React.CSSProperties, +const customStyles = { legendContainer: { display: "flex", justifyContent: "center", @@ -42,17 +20,39 @@ const styles = { } as CSSProperties, }; +type LineBarChartProps = { + chartConfig: ChartConfig | undefined; + chartData: any[] | undefined; + chartTitle: string; + chartClassName?: string; + graphSize?: number; + yFieldText?: string | undefined; + yFieldText2?: string | undefined; + showLegend?: boolean; +} + +type LegendEntry = { + value: string; + color: string; + type?: string; + payload?: unknown; +}; + +type LegendProps = { + payload?: LegendEntry[]; +}; + // 커스텀 툴팁 -const CustomTooltip = ({ active, payload, label }) => { +const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: number; color: string }>; label?: string }) => { if (active && payload && payload.length) { return ( - <div style={styles.tooltipDiv}> - <p style={styles.tooltipLabel}>{label}</p> + <div className="w-[180px] rounded bg-white p-2.5 shadow-[0px_2px_10px_rgba(0,0,0,0.2)]"> + <p className="text-[12px] font-bold m-3">{label}</p> {payload.map((item, index) => ( <div key={index} style={{ marginTop: "10px", display: "flex", alignItems: "center", gap: "8px" }}> {/* 색상 박스 추가 (범례 색상과 동일) */} <div style={{ width: "10px", height: "10px", backgroundColor: item.color, borderRadius: "50%" }}></div> - <span style={styles.tooltipText}>{`${item.name}: ${item.value}`}</span> + <span className="text-[11px] font-bold">{` ${item.name} : ${item.value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`}</span> </div> ))} </div> @@ -62,47 +62,128 @@ const CustomTooltip = ({ active, payload, label }) => { }; // 커스텀 범례 (글씨 크기 키우기 + 스타일 추가) -const CustomLegend = (props: any) => { +const CustomLegend = (props: LegendProps) => { const { payload } = props; + if (!payload) return null; + return ( - <div style={styles.legendContainer}> - {payload.map((entry, index) => ( - <div key={`legend-${index}`} style={{ display: "flex", alignItems: "center", gap: "8px" }}> - <div style={{ width: "12px", height: "12px", backgroundColor: entry.color, borderRadius: "50%" }}></div> - <span>{entry.value}</span> + <div style={customStyles.legendContainer}> + {payload.map((entry: LegendEntry, index: number) => ( + <div key={`legend-${index}`} className="flex items-center gap-2"> + <div className="w-[14px] h-[14px] ml-10" style={{ backgroundColor: entry.color }}></div> + <span className="text-[12px]">{entry.value}</span> </div> ))} </div> ); }; -export default function LineBarchart(props: { chartConfig: ChartConfig; chartData: any[] | undefined; chartTitle:string }) { +export default function LineBarchart({ + chartConfig, + chartData, + chartTitle, + chartClassName = 'w-full h-full', + graphSize = 40, + yFieldText, + yFieldText2, + showLegend = true +}: LineBarChartProps) { + if (!chartData || chartData.length === 0) { + return <div>데이터가 없습니다.</div>; + } + + const barConfig = chartConfig?.barVal; + const lineConfig = chartConfig?.lineVal; + + // 데이터의 최대값 계산 + const getMaxValue = (data: any[], keyPrefix: string) => { + return Math.max(...data.map(item => + Object.keys(item) + .filter(key => key.includes(keyPrefix)) + .map(key => item[key]) + ).flat()); + }; + + const lineMaxValue = getMaxValue(chartData, 'lineVal'); + const barMaxValue = getMaxValue(chartData, 'barVal'); + return ( - <Card className="h-full"> - <CardContent className="h-full"> - {props.chartTitle && props.chartTitle !== "" && props.chartTitle !== "undefined" ? ( + <div className={chartClassName}> + {chartTitle && chartTitle !== "" && chartTitle !== "undefined" ? ( <div style={{ textAlign: "left", fontSize: "13px", fontWeight: "bold", marginTop:"13px", marginBottom: "10px" }}> - {props.chartTitle} - </div>): null} - <div className="h-[calc(100%-42px)] flex justify-center"> - <ChartContainer config={props.chartConfig} className="h-full w-full" > - <ComposedChart data={props.chartData} className="h-full "> - <CartesianGrid strokeDasharray="3 3" /> - <XAxis dataKey="label" fontSize="8px" fontWeight="bold"/> - {/* 왼쪽 Y축: lineVal (정상) */} - <YAxis yAxisId="left" orientation="left" /> - {/* 오른쪽 Y축: barVal (위에서 아래로 반전) */} - <YAxis yAxisId="right" orientation="right" reversed /> - <Tooltip content={<CustomTooltip active={undefined} payload={undefined} label={undefined} />} /> - <Legend content={CustomLegend}/> - {/* 바 차트 (오른쪽 Y축 사용, 위에서 아래로) */} - <Bar yAxisId="right" dataKey="barVal" fill="#8884d8" name="강수량(mm/일)"/> - {/* 라인 차트 (왼쪽 Y축 사용) */} - <Line yAxisId="left" type="monotone" dataKey="lineVal" stroke="orange" strokeWidth={2} dot={{r: 0}} name="BOD(mg/L)"/> - </ComposedChart> - </ChartContainer> + {chartTitle} </div> - </CardContent> - </Card> - ) + ) : null} + <div className="h-[calc(100%-42px)] flex justify-center"> + <ChartContainer config={chartConfig ?? {}} className="h-full w-full"> + <ComposedChart + data={chartData} + height={graphSize * 7.5} + width={graphSize * 12.5} + margin={{ top: 5, right: 30, left: 20, bottom: 5 }} + > + <CartesianGrid strokeDasharray="3 3" /> + <XAxis dataKey="label" fontSize="8px" fontWeight="bold"/> + {/* 왼쪽 Y축: lineVal */} + {yFieldText && <YAxis + yAxisId="left" + className="text-[12px] font-bold" + tickCount={6} + orientation="left" + domain={[0, Math.ceil(lineMaxValue * 1)]} + label={{ value: yFieldText, angle: -90, position: 'insideLeft' }} + />} + {/* 오른쪽 Y축: barVal */} + {yFieldText2 && <YAxis + yAxisId="right" + className="text-[12px] font-bold" + tickCount={6} + orientation="right" + domain={[0, Math.ceil(barMaxValue * 1)]} + label={{ value: yFieldText2, angle: -90, position: 'insideRight' }} + reversed={true} + />} + <Tooltip content={<CustomTooltip active={undefined} payload={undefined} label={undefined} />} /> + {showLegend && <Legend content={CustomLegend as ContentType}/>} + {/* 바 차트 (오른쪽 Y축 사용) */} + {Object.keys(chartData[0]).map((key, index) => { + const config = chartConfig?.[key]; + if(key.includes("barVal")) { + return ( + <Bar + key={`bar-${key}-${index}`} + yAxisId="right" + dataKey={key} + fill={config?.color || "black"} + name={String(config?.label || "Y축")} + maxBarSize={80} + /> + ); + } + return null; + })} + {/* 라인 차트 (왼쪽 Y축 사용) */} + {Object.keys(chartData[0]).map((key, index) => { + const config = chartConfig?.[key]; + if(key.includes("lineVal")) { + return ( + <Line + key={`line-${key}-${index}`} + yAxisId="left" + type="linear" + dataKey={key} + stroke={config?.color || "black"} + strokeWidth={2} + dot={{r: 0}} + name={String(config?.label || "X축")} + /> + ); + } + return null; + })} + </ComposedChart> + </ChartContainer> + </div> + </div> + ); } \ No newline at end of file diff --git a/packages/common/business_components/src/ui/chart/LineChart.tsx b/packages/common/business_components/src/ui/chart/LineChart.tsx index 136034e00..4014702b6 100644 --- a/packages/common/business_components/src/ui/chart/LineChart.tsx +++ b/packages/common/business_components/src/ui/chart/LineChart.tsx @@ -1,37 +1,24 @@ "use client" import type { CSSProperties } from "react"; -import { - Card, - CardContent -} from "./lib/card"; +import { CartesianGrid, Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts"; import { ChartContainer, - ChartTooltip, - ChartTooltipContent, type ChartConfig } from "./lib/chart"; -import { CartesianGrid, Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts"; +import type { ContentType } from "recharts/types/component/DefaultLegendContent"; + +type LineChartProps = { + chartConfig: ChartConfig | undefined; + chartData: ChartDataType | undefined; + chartTitle: string; + chartClassName?: string; + graphSize?: number; + yFieldText?: string; + showLegend?: boolean; +} /** 차트 기본 스타일 */ -const styles = { - tooltipDiv: { - borderRadius: "5px", - backgroundColor: "white", - padding: "10px", - boxShadow: "0px 2px 10px rgba(0,0,0,0.2)", - width: "180px", // 적절한 크기로 설정 - } as React.CSSProperties, - tooltipLabel: { - fontWeight: "bold", - fontSize: "12px", - marginBottom: "5px", - borderBottom: "1px solid #ddd", - paddingBottom: "3px", - } as React.CSSProperties, - tooltipText: { - fontSize: "11px", - fontWeight:"bold", - } as React.CSSProperties, +const customStyles = { legendContainer: { display: "flex", justifyContent: "center", @@ -42,17 +29,28 @@ const styles = { } as CSSProperties, }; +type LegendEntry = { + value: string; + color: string; + type?: string; + payload?: unknown; +}; + +type LegendProps = { + payload?: LegendEntry[]; +}; + // 커스텀 툴팁 -const CustomTooltip = ({ active, payload, label }) => { +const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: number; color: string }>; label?: string }) => { if (active && payload && payload.length) { return ( - <div style={styles.tooltipDiv}> - <p style={styles.tooltipLabel}>{label}</p> + <div className="w-[180px] rounded bg-white p-2.5 shadow-[0px_2px_10px_rgba(0,0,0,0.2)]"> + <p className="text-[12px] font-bold m-3">{label}</p> {payload.map((item, index) => ( <div key={index} style={{ marginTop: "10px", display: "flex", alignItems: "center", gap: "8px" }}> {/* 색상 박스 추가 (범례 색상과 동일) */} <div style={{ width: "10px", height: "10px", backgroundColor: item.color, borderRadius: "50%" }}></div> - <span style={styles.tooltipText}>{`${item.name}: ${item.value}`}</span> + <span className="text-[11px] font-bold">{` ${item.name} : ${item.value}`}</span> </div> ))} </div> @@ -62,60 +60,108 @@ const CustomTooltip = ({ active, payload, label }) => { }; // 커스텀 범례 (글씨 크기 키우기 + 스타일 추가) -const CustomLegend = (props: any) => { +const CustomLegend = (props: LegendProps) => { const { payload } = props; + if (!payload) return null; + return ( - <div style={styles.legendContainer}> - {payload.map((entry, index) => ( - <div key={`legend-${index}`} style={{ display: "flex", alignItems: "center", gap: "8px" }}> - <div style={{ width: "12px", height: "12px", backgroundColor: entry.color, borderRadius: "50%" }}></div> - <span>{entry.value}</span> + <div style={customStyles.legendContainer}> + {payload.map((entry: LegendEntry, index: number) => ( + <div key={`legend-${index}`} className="flex items-center gap-2"> + <div className="w-[14px] h-[14px] ml-10" style={{ backgroundColor: entry.color }}></div> + <span className="text-[12px]">{entry.value}</span> </div> ))} </div> ); }; -type DataType = {label : string; val:number}; +type DataType = {label: string; val: string}; + type ChartDataType = { - chartData1: DataType[]; - chartData2: DataType[]; + [key: string]: DataType[]; }; -export default function Linechart(props: { chartConfig: ChartConfig; chartData: ChartDataType; chartTitle:string}) { - const mergedData = Object.values(props.chartData).reduce((acc, dataset) => { +export default function Linechart({ + chartConfig, + chartData, + chartTitle, + chartClassName = 'w-full h-full', + graphSize = 40, + yFieldText, + showLegend = true +}: LineChartProps) { + if (!chartData || Object.keys(chartData).length === 0) { + return <div>데이터가 없습니다.</div>; + } + + // 모든 데이터셋이 비어있는지 확인 + const hasNoData = Object.values(chartData).every(dataset => !dataset || dataset.length === 0); + if (hasNoData) { + return <div>데이터가 없습니다.</div>; + } + + // 데이터 병합 및 변환 + const mergedData = Object.entries(chartData).reduce<Array<{[key: string]: string | number}>>((acc, [key, dataset]) => { dataset.forEach((entry, index) => { if (!acc[index]) { - acc[index] = { label: entry.label }; // label 추가 + acc[index] = { label: entry.label }; } - const key = Object.keys(props.chartData).find(k => props.chartData[k] === dataset); - if (key) acc[index][key] = entry.val; + acc[index][key] = parseFloat(entry.val); }); return acc; - }, [] as any[]); + }, []); + + // 데이터의 최대값 계산 + const getMaxValue = (data: ChartDataType): number => { + return Math.max(...Object.values(data).flatMap(dataset => + dataset.map(entry => { + const val = parseFloat(entry.val); + return isNaN(val) ? 0 : val; + }) + )); + }; + + const lineMaxValue = getMaxValue(chartData); return ( - <Card className="h-full"> - <CardContent className="h-full"> - {props.chartTitle && props.chartTitle !== "" && props.chartTitle !== "undefined" ? ( - <div style={{ textAlign: "left", fontSize: "13px", fontWeight: "bold", marginTop:"13px", marginBottom: "10px" }}> - {props.chartTitle} - </div>): null} - <div className="h-[calc(100%-42px)] flex justify-center"> - <ChartContainer config={props.chartConfig} className="h-full w-full"> - <LineChart data={mergedData} className="h-full"> - <CartesianGrid vertical={false} /> - <XAxis dataKey="label" fontSize="8px" fontWeight="bold"/> - <YAxis yAxisId="left" orientation="left" /> - <Tooltip content={<CustomTooltip active={undefined} payload={undefined} label={undefined} />} /> - <Legend content={CustomLegend}/> - {Object.keys(props.chartData).map((key, index) => ( - <Line key={key} yAxisId="left" dataKey={key} type="linear" strokeWidth={2} dot={false} name={key} /> - ))} - </LineChart> - </ChartContainer> + <div className={chartClassName}> + {chartTitle && chartTitle !== "" && chartTitle !== "undefined" ? ( + <div className="text-left text-[13px] font-bold mt-[13px] mb-[10px]"> + {chartTitle} </div> - </CardContent> - </Card> - ) + ) : null} + <div className="h-[calc(100%-42px)] flex justify-center"> + <ChartContainer config={chartConfig ?? {}} className="h-full w-full"> + <LineChart + data={mergedData} + height={graphSize * 7.5} + width={graphSize * 12.5} + margin={{ top: 5, right: 30, left: 20, bottom: 5 }} + > + <CartesianGrid vertical={false} /> + <XAxis dataKey="label" fontSize="8px" fontWeight="bold" /> + <YAxis yAxisId="left" className="text-[12px] font-bold" domain={[0, Math.ceil(lineMaxValue * 1.1)]} tickCount={6} orientation="left" label={{ value: yFieldText, className:"text-[12px] font-bold", angle: -90, position: 'insideLeft' }} /> + <Tooltip content={<CustomTooltip />} /> + {showLegend && <Legend content={CustomLegend as ContentType}/>} + {Object.keys(chartData).map((key) => { + const config = chartConfig?.[key]; + return ( + <Line + key={key} + yAxisId="left" + dataKey={key} + type="linear" + strokeWidth={2} + dot={false} + name={String(config?.label || "X축")} + stroke={config?.color} + /> + ); + })} + </LineChart> + </ChartContainer> + </div> + </div> + ); } \ No newline at end of file diff --git a/packages/common/business_components/src/ui/chart/index.tsx b/packages/common/business_components/src/ui/chart/index.tsx index fc5747f3a..b30eef3c3 100644 --- a/packages/common/business_components/src/ui/chart/index.tsx +++ b/packages/common/business_components/src/ui/chart/index.tsx @@ -18,15 +18,41 @@ const CHART_FORM = { LINEBAR : 'linebar' }; -function MainChart(props: { chartForm: string; chartConfig: any; chartData: any; chartTitle:string; }) { +/** + * 차트 컴포넌트 + * @param chartForm 차트 형태 + * @param chartConfig 차트 설정 + * @param chartData 차트 데이터 + * @param chartTitle 차트 제목 + * @param chartClassName 차트 클래스명 + * @param graphSize 차트 크기 + * @param yFieldText 차트 Y축 텍스트 + * @param yFieldText2 차트 Y축 텍스트2(라인바 차트때 사용) + * @param showLegend 차트 범례 표시 여부 + * @returns + */ + +type MainChartProps = { + chartForm: string; + chartConfig: any; + chartData: any; + chartTitle: string; + chartClassName?: string; + graphSize?: number; + yFieldText? : string | undefined; + yFieldText2? : string | undefined; + showLegend? : boolean; +} + +function MainChart(props: MainChartProps) { if( props.chartForm == CHART_FORM.BAR ){ return ( - <BarChart chartConfig={props.chartConfig} chartData={props.chartData} chartTitle={props.chartTitle}> + <BarChart chartConfig={props.chartConfig} chartData={props.chartData} chartTitle={props.chartTitle} chartClassName={props.chartClassName} graphSize={props.graphSize} yFieldText={props.yFieldText} showLegend={props.showLegend}> </BarChart> ); } else if( props.chartForm == CHART_FORM.LINE ){ return ( - <LineChart chartConfig={props.chartConfig} chartData={props.chartData} chartTitle={props.chartTitle}> + <LineChart chartConfig={props.chartConfig} chartData={props.chartData} chartTitle={props.chartTitle} chartClassName={props.chartClassName} graphSize={props.graphSize} yFieldText={props.yFieldText} showLegend={props.showLegend}> </LineChart> ); } else if( props.chartForm == CHART_FORM.DN ){ @@ -36,7 +62,7 @@ function MainChart(props: { chartForm: string; chartConfig: any; chartData: any; ); } else if (props.chartForm == CHART_FORM.LINEBAR){ return ( - <LineBarchart chartConfig={props.chartConfig} chartData={props.chartData} chartTitle={props.chartTitle}> + <LineBarchart chartConfig={props.chartConfig} chartData={props.chartData} chartTitle={props.chartTitle} chartClassName={props.chartClassName} graphSize={props.graphSize} yFieldText={props.yFieldText} yFieldText2={props.yFieldText2} showLegend={props.showLegend}> </LineBarchart> ); } diff --git a/packages/common/business_components/src/ui/date_picker/index.tsx b/packages/common/business_components/src/ui/date_picker/index.tsx index 16aa2c075..d3a6a05c1 100644 --- a/packages/common/business_components/src/ui/date_picker/index.tsx +++ b/packages/common/business_components/src/ui/date_picker/index.tsx @@ -9,12 +9,23 @@ import type { DayPickerSingleProps } from "react-day-picker"; import { Button, Calendar, Popover, PopoverContent, PopoverTrigger } from "@common/components/ui"; import { cn } from "../../lib/utils"; import { FormControl } from "../form"; +import { ko } from "date-fns/locale"; +import { useState } from "react"; type DatePickerProps = DayPickerSingleProps & { field: { value: Date; onChange: (date: Date | undefined) => void } ,selectClass:string|undefined }; + export function DatePicker({ field, selectClass }: DatePickerProps) { + + + const [open, setOpen] = useState(false); + + const handleSelect = (date: Date | undefined) => { + field.onChange(date); // React Hook Form 업데이트 + setOpen(false); + }; return ( - <Popover> + <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> <FormControl> <Button @@ -27,7 +38,12 @@ export function DatePicker({ field, selectClass }: DatePickerProps) { ):selectClass} > {field?.value ? ( - format(field?.value, "PPP") + format( + field.value instanceof Date && !isNaN(field.value.getTime()) + ? field.value + : new Date() + + , "PPP", { locale: ko }) ) : ( <span>Pick a date</span> )} @@ -39,8 +55,9 @@ export function DatePicker({ field, selectClass }: DatePickerProps) { <Calendar mode="single" selected={field?.value} - onSelect={field?.onChange} + onSelect={handleSelect} initialFocus + locale={ko} /> </PopoverContent> </Popover> diff --git a/packages/common/business_components/src/ui/dialog_ui/index.tsx b/packages/common/business_components/src/ui/dialog_ui/index.tsx new file mode 100644 index 000000000..b9e341279 --- /dev/null +++ b/packages/common/business_components/src/ui/dialog_ui/index.tsx @@ -0,0 +1,46 @@ +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from "@common/components"; + +type ModalType = { + bodyClass: string; + body: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; + noHeaderYn?: 'N' | 'Y'; + noFooterYn?: 'N' | 'Y'; + headerTitle?: string; + footerContent?: React.ReactNode; +} + +export function UiModal({ + bodyClass, + body, + open, + onOpenChange, + noHeaderYn = 'N', + noFooterYn = 'N', + headerTitle, + footerContent +}: ModalType) { + + return ( + <> + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className={bodyClass}> + {noHeaderYn === 'N' && ( + <DialogHeader> + <DialogDescription> + {headerTitle} + </DialogDescription> + </DialogHeader> + )} + {(open && body)} + {noFooterYn === 'N' && ( + <DialogFooter className="sm:justify-start"> + {footerContent} + </DialogFooter> + )} + </DialogContent> + </Dialog> + </> + ); +} diff --git a/packages/common/business_components/src/ui/excel/excelStyle.tsx b/packages/common/business_components/src/ui/excel/excelStyle.tsx new file mode 100644 index 000000000..3c8f8d219 --- /dev/null +++ b/packages/common/business_components/src/ui/excel/excelStyle.tsx @@ -0,0 +1,65 @@ +// import type { sheetParameter } from '@/index'; +import * as XlsxPopulate from '@xlsx/xlsx-populate'; +import type { SheetParameter } from './index'; + + + +const makeCellStyle =(styleQ: XlsxPopulate.StyleQuery )=>{ + styleQ.fill({ type: "solid", color: {rgb:"9bc1e7"} }); + styleQ.borderEach( { style: "thin", color: { rgb: "FFFFFF" } } ); + styleQ.wrapText(true); + // styleQ.fontColor({rgb:"FFFFFF"}); + styleQ.fontSize(13); + styleQ.horizontalAlignment("center"); + styleQ.verticalAlignment("center"); + styleQ.bold(true); +} + +const makeExcelFile = async(workbook: XlsxPopulate.Workbook,fileNm:string)=>{ + const outputData = await workbook.output("array-buffer"); + const blob = new Blob([outputData], { type: "application/octet-stream" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = fileNm+".xlsx"; + link.click(); + +} + + +export const excelStyle = async (data: ArrayBuffer, range: string = "A1:V3", fileNm:string) => { + const workbook = await XlsxPopulate.Workbook.fromData(data); + const rangeCells = workbook.activeSheet(0)?.sheet(0)?.range(range); + console.log(rangeCells); + rangeCells?.style((styleQ)=>{ + makeCellStyle(styleQ) + }); + makeExcelFile(workbook, fileNm) + + +} + + +export const excelArrayStyle = async (data: ArrayBuffer, range: SheetParameter[], fileNm: string) => { + const workbook = await XlsxPopulate.Workbook.fromData(data); + if (range) { + range.forEach((item, index) => { + const rangeCells = workbook.activeSheet(index)?.sheet(index)?.range(item.headerRange); + console.log(rangeCells); + rangeCells?.style((styleQ) => { + makeCellStyle(styleQ) + }); + + // 모든 데이터 셀에 세로 가운데 정렬 적용 + if (item.data && item.data.length > 0) { + const numCols = item.data[0]?.length || 0; + const numRows = item.data.length; + const dataRange = workbook.activeSheet(index)?.sheet(index)?.range(`A1:${String.fromCharCode(65 + numCols - 1)}${numRows}`); + dataRange?.style((styleQ) => { + styleQ.verticalAlignment("center"); + }); + } + }); + } + makeExcelFile(workbook, fileNm) + +} \ No newline at end of file diff --git a/packages/common/business_components/src/ui/excel/index.tsx b/packages/common/business_components/src/ui/excel/index.tsx new file mode 100644 index 000000000..96f3b3e5d --- /dev/null +++ b/packages/common/business_components/src/ui/excel/index.tsx @@ -0,0 +1,167 @@ +import * as XLSX from 'xlsx'; +import { excelArrayStyle, excelStyle } from './excelStyle'; + +type ExcelDownloadProps = { + data: JSON | JSON[]; + fileName: string; + sheetNameArray: string[]; + headerRange?: string; +} + +export const downloadExcel = ({ data, fileName = new Date().getTime().toString() + '.xlsx', sheetNameArray = ["Sheet1"], headerRange='A1:V3'}: ExcelDownloadProps) => { + const worksheet = XLSX.utils.json_to_sheet(Array.isArray(data) ? data : [data]); + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, sheetNameArray[0]); + excelStyle(XLSX.write(workbook, { bookType: "xlsx", type: "buffer" }), headerRange, fileName); +}; + +export type SheetParameter = { + data: any[][]; + headerNum: number; + headerRange: string; + sheetName: string; + startCol?: string; +}; + +export const downloadMultipleExcel = (fileName = new Date().getTime().toString() + '.xlsx', sheetArray: SheetParameter[]): Promise<JSON | undefined> => { + const workbook = XLSX.utils.book_new(); + for (let i = 0; i < sheetArray.length; i++) { + // Create a new worksheet for each sheet + const worksheet = XLSX.utils.aoa_to_sheet(sheetArray[i]?.data || [[]]); + // Add data to the worksheet starting from the specified header number + if (sheetArray && sheetArray[i]?.headerNum) { + XLSX.utils.sheet_add_aoa(worksheet, sheetArray[i]?.data || [[]], { origin: `A${sheetArray[i]?.headerNum}` }); + } + // Append the worksheet to the workbook with the specified sheet name + XLSX.utils.book_append_sheet(workbook, worksheet, sheetArray[i]?.sheetName); + } + if (sheetArray.length > 0) { + excelArrayStyle(XLSX.write(workbook, { bookType: "xlsx", type: "buffer" }), sheetArray, fileName); + } + return Promise.resolve(undefined); +}; + +export const filePicked=(oFile: File, headerNum:number=0, dateType="yyyymmdd"):Promise<JSON | undefined> =>{ + // Create A File Reader HTML5 + const reader = new FileReader(); + return new Promise<JSON | undefined>((resolve, reject) => { + // Ready The Event For When A File Gets Selected + reader.onload = function (e) { + const data = e.target?.result; + if (!data) { + console.error("Failed to read file: e.target.result is null"); + resolve(undefined); + return; + } + //셀데이터에서 데이트 형식으로 입력하면 yyyymmdd 형식으로 변환 + const wb = XLSX.read(data, { type: 'binary', cellDates: true, dateNF: dateType }); + let jsonXls: JSON | undefined; + // Loop Over Each Sheet + wb.SheetNames.forEach(function (sheetName,index ) { + // Obtain The Current Row As CSV + // 일단 첫번째 시트만 읽을 수 있도록 수정 + if(index ===0){ + const sheet = wb.Sheets[sheetName]; + + if (sheet) { + jsonXls = XLSX.utils.sheet_to_json(sheet, { range: headerNum, raw:false }) as unknown as JSON; + } else { + console.error(`Sheet "${sheetName}" is undefined.`); + } + } + }); + resolve(jsonXls); + }; + + reader.onerror = function () { + console.error("Error reading file"); + reject(undefined); + }; + + // Tell JS To Start Reading The File.. You could delay this if desired + reader.readAsBinaryString(oFile); + }); +} + +export const filePickedArray=(oFile: File, headerNum:number=0):Promise<JSON[] | undefined> =>{ + // Create A File Reader HTML5 + const reader = new FileReader(); + return new Promise<JSON[] | undefined>((resolve, reject) => { + // Ready The Event For When A File Gets Selected + reader.onload = function (e) { + const data = e.target?.result; + if (!data) { + console.error("Failed to read file: e.target.result is null"); + resolve(undefined); + return; + } + //셀데이터에서 데이트 형식으로 입력하면 yyyymmdd 형식으로 변환 + const wb = XLSX.read(data, { type: 'binary', cellDates: true, dateNF: "yyyymmdd" }); + const arrayXls: JSON[] = [] + let jsonXls: JSON | undefined; + // Loop Over Each Sheet + wb.SheetNames.forEach(function (sheetName,index ) { + const sheet = wb.Sheets[sheetName]; + if (sheet) { + console.log(sheet) + jsonXls = XLSX.utils.sheet_to_json(sheet, { range: headerNum, raw:false }) as unknown as JSON; + arrayXls.push(jsonXls) + } else { + console.error(`Sheet "${sheetName}" is undefined.`); + } + }); + resolve(arrayXls); + + }; + + reader.onerror = function () { + console.error("Error reading file"); + reject(undefined); + }; + + // Tell JS To Start Reading The File.. You could delay this if desired + reader.readAsBinaryString(oFile); + }); +} + +export const filePickedBlob=(oFile: ArrayBuffer, data:[][],fileNm:string, headerNum:number=0, headerRange='A1:V3', startCol?:number):Promise<JSON | undefined> =>{ // Create A File Reader HTML5 + return new Promise<JSON | undefined>(() => { + // Ready The Event For When A File Gets Selected + const wb = XLSX.read(oFile, { type: 'binary' }); + // Loop Over Each Sheet + wb.SheetNames.forEach(async function (sheetName, index) { + // Obtain The Current Row As CSV + if(index==0){ + const sheet = wb.Sheets[sheetName]; + if (sheet) { + //오리진 확인 test 필요 + // const sheet = XLSX.utils.json_to_sheet(data); + const excelStartCol = startCol?startCol.toString():'A'; + XLSX.utils.sheet_add_aoa(sheet, data, { origin: excelStartCol+headerNum }); + // XLSX.utils.book_append_sheet(wb, sheet, 'test'); + await excelStyle(XLSX.write(wb, { bookType: "xlsx", type: "buffer" }), headerRange, fileNm) + } else { + console.error(`Sheet "${sheetName}" is undefined.`); + } + } + }); + }); + } + +export const filePickedBlobArray = (oFile: ArrayBuffer, fileNm: string, sheetArray: SheetParameter[]): Promise<JSON | undefined> => { + return new Promise<JSON | undefined>(async () => { + const wb = XLSX.read(oFile, { type: 'binary' }); + wb.SheetNames.forEach(function (sheetName, index) { + if (sheetArray[index]) { + const sheet = wb.Sheets[sheetName]; + if (sheet) { + const startCol = sheetArray[index].startCol ? sheetArray[index].startCol : 'A'; + XLSX.utils.sheet_add_aoa(sheet, sheetArray[index].data, { origin: startCol + sheetArray[index].headerNum }); + } else { + console.error(`Sheet "${sheetName}" is undefined.`); + } + } + }); + await excelArrayStyle(XLSX.write(wb, { bookType: "xlsx", type: "buffer" }), sheetArray, fileNm) + }); +} \ No newline at end of file diff --git a/packages/common/business_components/src/ui/file-upload/index.tsx b/packages/common/business_components/src/ui/file-upload/index.tsx index 3d581ed05..22ef56820 100644 --- a/packages/common/business_components/src/ui/file-upload/index.tsx +++ b/packages/common/business_components/src/ui/file-upload/index.tsx @@ -6,41 +6,51 @@ const dropZoneConfig = { maxSize: 1024 * 1024 * 4, multiple: true, }; + + + interface FileUploadProps { + files: File[]; // 파일 배열 + setFiles: (files: File[] | null) => void; // 파일 배열 세터 + fileTypes?: string; // 옵션 (파일 확장자 문자열) + onFileClick?:(index:number)=> void; // 파일 클릭 핸들러 + + } + export const FileUpload = -(({ ...props }) => { +({ files, setFiles, fileTypes ,onFileClick}: FileUploadProps) => { return ( <FileUploader - value={props.files} - onValueChange={props.setFiles} + value={files} + onValueChange={setFiles} dropzoneOptions={dropZoneConfig} - acceptName={props?.fileTypes} + acceptName={fileTypes} className="relative bg-background rounded-lg p-2" > <FileInput id="fileInput" className="outline-dashed outline-1 outline-slate-500" > - <div className="flex items-center justify-center flex-col p-8 w-full "> - <CloudUpload className='text-gray-500 w-10 h-10' /> + <div className="flex items-center justify-center flex-col p-1 w-full "> + <CloudUpload className='text-gray-500 w-5 h-4' /> <p className="mb-1 text-sm text-gray-500 dark:text-gray-400"> - <span className="font-semibold">Click to upload</span> -   or drag and drop + <span className="font-semibold">클릭 후 업로드 </span> +   혹은 파일을 드래그 해주세요 </p> <p className="text-xs text-gray-500 dark:text-gray-400"> - {!props?.fileTypes?"SVG, PNG, JPG, GIF":props?.fileTypes} + {!fileTypes?"SVG, PNG, JPG, GIF":fileTypes} </p> </div> </FileInput> <FileUploaderContent> - {props.files && - props.files.length > 0 && - props.files.map((file:File, i:number) => ( - <FileUploaderItem key={i} index={i}> + {files&& + files.length > 0 && + files.map((file:File, i:number) => ( + <FileUploaderItem key={i} index={i} onFileClick={onFileClick }> <Paperclip className="h-4 w-4 stroke-current" /> - <span>{file.name}</span> + <span className="w-4/5 overflow-hidden">{file.name}</span> </FileUploaderItem> ))} </FileUploaderContent> </FileUploader> ) -}) \ No newline at end of file +} \ No newline at end of file diff --git a/packages/common/business_components/src/ui/file-upload/lib/file_upload.tsx b/packages/common/business_components/src/ui/file-upload/lib/file_upload.tsx index 3db4bcceb..3fe1aafd8 100644 --- a/packages/common/business_components/src/ui/file-upload/lib/file_upload.tsx +++ b/packages/common/business_components/src/ui/file-upload/lib/file_upload.tsx @@ -286,9 +286,9 @@ FileUploaderContent.displayName = "FileUploaderContent"; export const FileUploaderItem = forwardRef< HTMLDivElement, - { index: number } & React.HTMLAttributes<HTMLDivElement> ->(({ className, index, children, ...props }, ref) => { - const { removeFileFromSet, activeIndex, direction } = useFileUpload(); + { index: number } & React.HTMLAttributes<HTMLDivElement> &{onFileClick?:(index:number)=>void} +>(({ className, index, children, onFileClick, ...props }, ref) => { + const { removeFileFromSet , activeIndex, direction } = useFileUpload(); const isSelected = index === activeIndex; return ( <div @@ -300,8 +300,13 @@ export const FileUploaderItem = forwardRef< isSelected ? "bg-muted" : "", )} {...props} + > - <div className="font-medium leading-none tracking-tight flex items-center gap-1.5 h-full w-full"> + <div className="font-medium leading-none tracking-tight flex items-center gap-1.5 h-full !w-4/5 overflow-hidden " + onClick={() =>{ + + + }}> {children} </div> <button @@ -309,8 +314,10 @@ export const FileUploaderItem = forwardRef< className={cn( "absolute", direction === "rtl" ? "top-1 left-1" : "top-1 right-1", + )} - onClick={() => removeFileFromSet(index)} + onClick={() =>{ + removeFileFromSet(index)}} > <span className="sr-only">remove item {index}</span> <RemoveIcon className="w-4 h-4 hover:stroke-destructive duration-200 ease-in-out" /> diff --git a/packages/common/business_components/src/ui/form_ui/index.tsx b/packages/common/business_components/src/ui/form_ui/index.tsx index bd9930c3c..b834d5515 100644 --- a/packages/common/business_components/src/ui/form_ui/index.tsx +++ b/packages/common/business_components/src/ui/form_ui/index.tsx @@ -1,19 +1,66 @@ -import React from "react"; +import { Checkbox } from "@common/components/ui"; +import React, { useCallback, useState } from "react"; import { FormControl, FormItem, FormLabel, FormMessage } from "../form"; -const FormUi = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> & {label:string|undefined} ->(({children, label},ref)=>{ +export const BZNM_TOT_NO = '종사업자등록번호'; +export const CANDIDATE = '공급처 후보지 선택(필수아님)'; + +interface FormUiProps extends React.HTMLAttributes<HTMLDivElement> { + label?: string; + onCheckboxChange?: (checked: boolean) => void; + isDisabled?: boolean; +} + +interface FormElementProps extends React.HTMLAttributes<HTMLElement> { + disabled?: boolean; +} + +const FormUi = React.forwardRef<HTMLDivElement, FormUiProps>( + ({ children, label, onCheckboxChange, isDisabled }, ref) => { + const [isChecked, setIsChecked] = useState(false); + + const handleCheckboxChange = useCallback((checked: boolean) => { + setIsChecked(checked); + onCheckboxChange?.(checked); + }, [onCheckboxChange]); return ( - <FormItem ref={ref}> - {label?<FormLabel className="text-xl">{label}</FormLabel>:<></>} - <FormControl> - {children} - </FormControl> - <FormMessage /> - </FormItem> - ) -}) + <FormItem ref={ref}> + {label && <FormLabel className="text-xl">{label}</FormLabel>} + {label === BZNM_TOT_NO && ( + <> + <div className="float-right"> + <div className="flex items-center gap-2"> + <Checkbox + id="check" + checked={isChecked} + onCheckedChange={handleCheckboxChange} + /> + <label className="w-20 text-xl text-gray-600" htmlFor="check">해당없음</label> + </div> + </div> + {children} + </> + )} + {label === CANDIDATE && ( + <FormControl> + {children} + </FormControl> + )} + {label !== BZNM_TOT_NO && label !== CANDIDATE && ( + <FormControl> + {React.isValidElement(children) && isDisabled + ? React.cloneElement(children, { + disabled: true, + ...(children.props as FormElementProps) + }) + : children} + </FormControl> + )} + <FormMessage /> + </FormItem> + ); + } +); + FormUi.displayName = 'FormUi'; export { FormUi }; diff --git a/packages/common/business_components/src/ui/index.ts b/packages/common/business_components/src/ui/index.ts index 795fffa2a..90bf029c0 100644 --- a/packages/common/business_components/src/ui/index.ts +++ b/packages/common/business_components/src/ui/index.ts @@ -1,12 +1,22 @@ export * from "./chart"; export * from "./date_picker"; +export * from "./dialog_ui"; +export * from "./excel"; export * from "./file-upload"; export * from "./form"; export * from "./form_ui"; +export * from "./list_div"; +export * from "./main_content_div"; export * from "./scroll"; +export * from "./search_base_form"; export * from "./search_form"; +export * from "./search_form_left"; +export * from "./search_form_right"; export * from "./search_input"; export * from "./search_input_select"; export * from "./select"; +export * from "./tab"; export * from "./table"; +export * from "./table_div"; +export * from "./title"; diff --git a/packages/common/business_components/src/ui/list_div/index.tsx b/packages/common/business_components/src/ui/list_div/index.tsx new file mode 100644 index 000000000..abe480c7a --- /dev/null +++ b/packages/common/business_components/src/ui/list_div/index.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +function ListDiv(props: {children: React.ReactNode, className?: string|undefined}) { + return ( + <div className={`card card-border ${props.className}`}> + {props.children} + </div> + ) +} + +export default ListDiv \ No newline at end of file diff --git a/packages/common/business_components/src/ui/main_content_div/index.tsx b/packages/common/business_components/src/ui/main_content_div/index.tsx new file mode 100644 index 000000000..ea95cd2ab --- /dev/null +++ b/packages/common/business_components/src/ui/main_content_div/index.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +export default function MainDiv(props: {children: React.ReactNode}) { + return ( + <div className="min-h-[89vh] bg-container"> + {props.children} + </div> + ) +} + +export { MainDiv }; \ No newline at end of file diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js new file mode 100644 index 000000000..fd86a25b0 --- /dev/null +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -0,0 +1,234 @@ +import './lib/css/gis.common.css'; +import React, { createContext, useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import OlMap from 'ol/Map'; +import View from 'ol/View'; +import { defaults as defaultControls, OverviewMap } from 'ol/control'; +import { defaults } from 'ol/interaction'; +import * as olProj from 'ol/proj'; +import proj4 from 'proj4'; +import { register } from 'ol/proj/proj4'; +import GisApp from './lib/gis.core'; +import GisContent from './GisContent'; +import FeatureClickEventPopup from '@/map/featureClickEventPopup'; +import GisTree from './GisTree'; +import SearchModal from '@/map/SearchModal'; + +export const GisContext = createContext({ gisCore: null, gisMap: null, treeData: null, checkedLayerIds: null }); +export const useGisContext = () => useContext(GisContext); + +function Gis({ treeData = [], isSearch = false }) { + const mapRef = useRef(); + const [mapObject, setMapObject] = useState({}); + const [tabVisible, setTabVisible] = useState(true); + const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태 + const [featureData, setFeatureData] = useState(null) // 선택된 피쳐 데이터 + const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); // SearchModal 열림 상태 + const gisCore = useMemo(() => new GisApp.core({ targetId: mapRef.current }), []); + const mapInstanceRef = useRef(null); // 맵 생성 여부를 추적할 ref + const checkedLayerIds = useRef([]); + let gisMap = null; + const closePopup = () => { + setIsOpen(false); // 팝업 닫기 + setFeatureData(null) + }; + + // SearchModal, GisContent 등에서 호출 + const handleFeatureSelect = useCallback((data) => { + gisMap = mapInstanceRef.current; + + if (!gisMap) { + console.warn('지도가 초기화되지 않았습니다.'); + return; + } + + // 항상 같은 select 객체 사용 + const select = GisApp.Module.select; + if (!select) return; + + // 1. 기존 선택 초기화 + select.resetSelectedFeature(); + + // 2. data가 OpenLayers Feature 인스턴스인지 확인 + let foundFeature = null; + if (data && typeof data.getGeometry === 'function' && typeof data.setStyle === 'function') { + // 이미 Feature 인스턴스라면 그대로 사용 + foundFeature = data; + } else { + // 아니면 featureId로 맵에서 Feature 인스턴스를 찾아서 사용 + const featureId = data.fid || data.ftrIdn || data.id; + gisMap.getLayers().getArray().forEach(layer => { + const source = layer.getSource?.(); + if (source?.getFeatureById) { + const feature = source.getFeatureById(String(featureId).trim()); + if (feature) foundFeature = feature; + } + }); + } + + // 3. 피처 선택 + if (foundFeature) { + let geometry = foundFeature.getGeometry(); + if (geometry) { + let extent = geometry.getExtent(); + + // // EPSG:4326 좌표계로 추정되는 경우(값이 위도/경도 범위) + // console.log('before extent', extent); + // const is4326 = + // extent[0] >= -90 && extent[0] <= 90 && + // extent[1] >= -180 && extent[1] <= 180 && + // extent[2] >= -90 && extent[2] <= 90 && + // extent[3] >= -180 && extent[3] <= 180; + // if (is4326) { + // // extent: [위도, 경도, 위도, 경도] → [경도, 위도, 경도, 위도]로 순서 변경 + // const reorderedExtent = [extent[1], extent[0], extent[3], extent[2]]; + // // EPSG:4326 extent를 EPSG:3857로 변환 + // extent = olProj.transformExtent(reorderedExtent, 'EPSG:4326', 'EPSG:3857'); + // console.log('after extent', extent); + // } + + gisMap.getView().fit(extent, { + duration: 300, + padding: [50, 50, 50, 50], + maxZoom: 16, + constrainResolution: true, + nearest: true, + }); + } + select.selectFeature(foundFeature, true); + } else { + console.warn('OpenLayers Feature를 찾을 수 없습니다:', data); + } + + setFeatureData(data); + setIsOpen(true); + setIsSearchModalOpen(false); + }, [mapInstanceRef]); + + useEffect(() => { + if (!mapRef.current) return; + if (mapInstanceRef.current) return; // 이미 생성된 맵이 있다면 중단 + + // EPSG:5179 등록 + proj4.defs([ + [ + 'EPSG:5179', + '+proj=tmerc +lat_0=38 +lon_0=127.5 +k=0.9996 +x_0=1000000 +y_0=2000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs', + ], + ]); + register(proj4); + + //맵 생성 + const map = new OlMap({ + layers: [gisCore.baseLayers['base'], gisCore.baseLayers['satellite']], + renderer: 'webgl', + target: mapRef.current, + interactions: defaults({ doubleClickZoom: false }), + controls: defaultControls({ + zoom: false, + rotate: false, + attributionOptions: { + collapsible: false, + }, + }).extend([]), + view: new View({ + projection: GisApp.TO_PROJ, + zoom: gisCore.defaults.zoom, + center: olProj.transform( + [126.6757, 37.5585], + GisApp.FROM_PROJ, + GisApp.TO_PROJ + ), + minZoom: gisCore.defaults.minZoom, + maxZoom: gisCore.defaults.maxZoom, + }), + }); + + //미니맵 + const overviewMap = new OverviewMap({ + layers: [GisApp.Module.core.baseMiniMapLayers['base']], + view: new View({ + projection: GisApp.TO_PROJ, + minZoom: 9, + }), + collapsed: false, + collapseLabel: '\u00BB', + label: '\u00AB', + }); + + map.addControl(overviewMap); + gisCore.__proto__.map = map; + + gisCore.scaleControl(); + gisCore.changeBaseLayer('base'); + setMapObject({ map: map }); + mapInstanceRef.current = map; // 생성 완료 플래그 설정 + }, []); + + useEffect(() => { + if (!treeData || treeData.length === 0) return; + + gisCore.deleteAllOverlay(); + gisCore.deleteAllLayer(); + + const layerCode = gisCore.convertLayerInfosToLayerCode(treeData, 'lyrSn'); + GisApp.layerCode = layerCode; + + //setTreeData(data); + const layerItems = getLayerItems(layerCode); + //setEditLayerItems(prev => [...layerItems]) + }, [treeData]); + + const getLayerItems = (layerCode) => { + const layerItems = Object.entries(layerCode) + .filter(([key, obj], idx) => obj?.updtPsblYn === 'Y') + .map(([key, obj]) => ({ + ...obj, + name: obj.lyrNm, + label: obj.lyrNm, + value: obj.lyrSn + '', + })) + .sort((a, b) => { + if (a.sortOrdr > b.sortOrdr) return 1; + if (a.sortOrdr < b.sortOrdr) return -1; + return 0; + }); + return layerItems; + }; + + if (mapObject.map) { + try { + gisMap = mapObject.map; + } catch (e) { + console.error('오류 발생:', e); // 시큐어코딩 관련 수정 : 오류 정보를 로그로 출력 + } + } + + return ( + <> + <GisContext.Provider value={{ gisCore, gisMap: mapInstanceRef.current, treeData, checkedLayerIds }}> + <div className="h-[calc(100vh-10rem)] flex relative"> + <GisTree visible={tabVisible} setVisible={setTabVisible} /> + <GisContent onFeatureSelect={handleFeatureSelect} isSearch={isSearch} isSearchModalOpen={isSearchModalOpen} setIsSearchModalOpen={setIsSearchModalOpen} /> + {/* isSearchModalOpen이 true면 visible=false로 팝업 숨김 */} + {isOpen && featureData && !isSearchModalOpen && ( + <FeatureClickEventPopup + featureData={featureData} + onClose={closePopup} // 닫기 콜백 전달 + /> + )} + <SearchModal + open={isSearchModalOpen} + onOpenChange={setIsSearchModalOpen} + onFeatureSelect={handleFeatureSelect} + /> + <div ref={mapRef} className="gis-map flex-1 relative" style={{ height: '100%', padding: 0 }} /> + {/* <GisLonLat/> + <GisToolBar/> + <MapToggleBtn/> */} + </div> + </GisContext.Provider> + </> + ); +} + +export default React.memo(Gis); \ No newline at end of file diff --git a/packages/common/business_components/src/ui/map/GisContent.js b/packages/common/business_components/src/ui/map/GisContent.js new file mode 100644 index 000000000..ad8a53605 --- /dev/null +++ b/packages/common/business_components/src/ui/map/GisContent.js @@ -0,0 +1,79 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import GisApp from './lib/gis.core'; +import { useGisContext } from './Gis'; +import GisLonLat from './GisLonLat'; +import GisSelect from './GisSelect'; +import GisToolBar from './GisToolBar'; +import './lib/css/gis.edit.css'; +import MapToggleBtn from './MapToggleBtn'; +import { Search } from 'lucide-react'; + +export default function GisContent({ edit = false, onFeatureSelect, isSearch, setIsSearchModalOpen }) { + const { gisMap, gisCore } = useGisContext(); + const [visibleComponents, setVisibleComponents] = useState({}); + const addressResultLayer = useRef(null); + const [playShow, setPlayShow] = useState({ show: false }); + + //셀렉트 버튼 이벤트 + // const clickFeature = useCallback(feature => { + // if (feature) { + // const layer = feature.getLayer(); + // setVisibleComponents(prevState => ({ + // ...prevState, + // })); + // } + // }, []); + + const selectRslt = useCallback(feature => { + GisApp.Module.layer.highLightFeature( + [feature], + { + id: GisApp.hlLayerId, + zIndex: 10, + opacity: 1, + selectable: true, + }, + { width: 30 } + ); + clickFeature(feature); + }, []); + + useEffect(() => { + if (gisMap) { + setVisibleComponents(prevState => ({ + ...prevState, + })); + } + }, [gisMap]); + + if (!gisMap) return null; + + return ( + <> + {/*<GisTree />*/} + <MapToggleBtn /> + <GisLonLat /> + <GisToolBar /> + <GisSelect onFeatureSelect={onFeatureSelect} /> + + {/* 검색 모달 */} + {isSearch && ( + <div className="absolute right-8 top-[254px] z-20"> + <button + type="button" + onClick={() => setIsSearchModalOpen(true)} + className="overflow-hidden w-[40px] h-[40px] bg-white + border border-[#9aa7b8] + rounded + shadow-[0_0_5px_#999] + flex items-center justify-center + p-0 + " + > + <Search size={24} className="text-gray-700" /> + </button> + </div> + )} + </> + ); +} \ No newline at end of file diff --git a/packages/common/business_components/src/ui/map/GisEdit.js b/packages/common/business_components/src/ui/map/GisEdit.js new file mode 100644 index 000000000..dcf51545e --- /dev/null +++ b/packages/common/business_components/src/ui/map/GisEdit.js @@ -0,0 +1,173 @@ +import GisApp from './lib/gis.core'; +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { GisContext, setTree } from './Gis'; +import { Button } from "@common/components"; +import { gAlert, gConfirm } from './lib/util'; +// import { fclApi } from '../../js/apis/fcl/fclApi'; + +export default React.memo(function GisEdit() { + const { editLayerItems = [], siteSn, locgovCd, checkedLayerIds } = useContext(GisContext); + const editModule = GisApp.Module.edit; + const ftrIdn = useRef(null); + const selectedLyrId = useRef(null); + if (editLayerItems.length > 0 && !selectedLyrId.current) { + selectedLyrId.current = editLayerItems[0].value; + } + const [btnEvt, setBtnEvt] = useState({ edit: false, modify: false, add: false, snap: false }); + const edit = () => { + const edit = !btnEvt.edit; + if (edit) { + setBtnEvt({ ...btnEvt, edit: edit }); + } else { + setBtnEvt({ edit: edit, modify: false, add: false, snap: false }); + editModule.clearUndoRedoFeature(); + } + editModule.removeInteraction(); + }; + + const modify = () => { + const modify = !btnEvt.modify; + setBtnEvt({ ...btnEvt, ...{ add: false }, modify }); + if (modify) { + editModule.setModify(); + } else { + editModule.removeModify(); + } + }; + + const add = () => { + let add = !btnEvt.add; + if (add) { + if (!checkedLayerIds?.current?.includes(selectedLyrId.current)) { + alert('트리에서 레이어 체크 후 추가 가능합니다.'); + return; + } + const errMsg = editModule.setDraw(selectedLyrId.current); + if (errMsg) { + alert(errMsg); + return; + } + } else { + editModule.removeDraw(); + } + setBtnEvt({ ...btnEvt, ...{ modify: false }, add }); + }; + + const del = () => { + editModule.deleteFeature(); + }; + + const undo = () => { + GisApp.Module.edit.undo(); + }; + + const redo = () => { + GisApp.Module.edit.redo(); + }; + + const snap = () => { + const snap = !btnEvt.snap; + setBtnEvt({ ...btnEvt, snap }); + if (snap) { + editModule.setSnap(); + } else { + editModule.removeSnap(); + } + }; + + const validateEditFeatures = editFeatures => { + if (editFeatures.length === 0) { + //gAlert('INFO', 'NO_CHANGED_DATA'); + return false; + } + return true; + }; + + const save = async () => { + const editFeatureInfo = editModule.getEditFeatureInfo(); + const editFeatures = Object.values(editFeatureInfo).map(value => ({ + siteSn, + locgovCd, + geom: value.geom, + flag: value.flag, + lyrId: value.lyr_id, + })); + if (!validateEditFeatures(editFeatures)) { + return; + } + if (!gConfirm('CONFIRM', 'SAVE_QUEST')) return false; + // const result = await fclApi.saveEditFeatures(editFeatures); + // if (result.code === 200) { + // editModule.clearUndoRedo(); + // } + }; + + useEffect(() => { + //setBtnEvt({ edit: false, modify: false, add: false, snap: false }); + editModule.setUndoInteraction(); + editModule.removeInteraction(); + }, [locgovCd]); + if (editLayerItems.length === 0) return; + return ( + <> + <div className="edit_control map_control_div ol-unselectable ol-control" style={{ pointerEvents: 'auto', top: '28px', left: '120px', zIndex: '1' }}> + <label htmlFor={'selectLyrId'}></label> + <select + id={'selectLyrId'} + onChange={evt => { + const lyrId = evt.target.value; + selectedLyrId.current = lyrId + ''; + setBtnEvt({ ...btnEvt, ...{ add: false } }); + editModule.setInteractionActive(false); + }} + > + {editLayerItems.map(layerItem => ( + <option value={layerItem?.lyrSn} key={layerItem?.lyrSn}> + {layerItem?.lyrNm} + </option> + ))} + </select> + <Button btn={{ type: `${btnEvt.edit ? 'primary' : 'secondary'}`, fnc: edit }}>{`${btnEvt.edit ? ' 편집종료' : '편집하기'}`}</Button> + {btnEvt.edit && ( + <ul> + <li> + <div className={`btn_edit_slt${btnEvt.modify ? ' active' : ''}`} title="수정" onClick={modify}> + <span>수정</span> + </div> + </li> + <li> + <div className={`btn_edit_add${btnEvt.add ? ' active' : ''}`} title="추가" onClick={add}> + <span>추가</span> + </div> + </li> + <li> + <div className="btn_edit_del" title="삭제" onClick={del}> + <span>삭제</span> + </div> + </li> + <li> + <div className="btn_edit_prev" title="이전" onClick={undo}> + <span>이전</span> + </div> + </li> + <li> + <div className="btn_edit_next" title="다음" onClick={redo}> + <span>다음</span> + </div> + </li> + <li> + <div className={`btn_edit_snap${btnEvt.snap ? ' active' : ''}`} title="스냅" onClick={snap}> + <span>스냅</span> + </div> + </li> + <li> + <div className="btn_edit_save" title="저장" onClick={save}> + <span>저장</span> + </div> + </li> + </ul> + )} + </div> + </> + ); +}); diff --git a/packages/common/business_components/src/ui/map/GisLonLat.js b/packages/common/business_components/src/ui/map/GisLonLat.js new file mode 100644 index 000000000..2cee2a9c8 --- /dev/null +++ b/packages/common/business_components/src/ui/map/GisLonLat.js @@ -0,0 +1,44 @@ +import React from 'react'; +import GisApp from './lib/gis.core'; +import { MousePosition } from 'ol/control'; +import { useContext, useEffect, useRef } from 'react'; +import { GisContext } from './Gis'; +export default React.memo(function GisLonLat() { + const { gisCore } = useContext(GisContext); + const labelLonLatRef = useRef(null); + + useEffect(() => { + if (!gisCore?.map || !labelLonLatRef.current) return; + + // 기존 MousePosition 제거를 위한 컨트롤 추적 + let existingMouseControls = gisCore.map + .getControls() + .getArray() + .filter(control => control instanceof MousePosition); + + // 기존 MousePosition 컨트롤 제거 + existingMouseControls.forEach(control => { + gisCore.map.removeControl(control); + }); + + // 새로운 MousePosition 컨트롤 추가 + const mousePosition = new MousePosition({ + coordinateFormat: function (coordinate) { + let lng = coordinate[0]; + let lat = coordinate[1]; + return `경도 : ${lng.toFixed(3)}   위도 : ${lat.toFixed(3)}`; + }, + projection: GisApp.FROM_PROJ, + className: 'custom-mouse-position', + target: labelLonLatRef.current, + }); + gisCore.map.addControl(mousePosition); + }, [gisCore.map]); + return ( + <> + <ul className="map-scale-area"> + <li id="label_lonlat" ref={labelLonLatRef} /> + </ul> + </> + ); +}); diff --git a/packages/common/business_components/src/ui/map/GisSelect.js b/packages/common/business_components/src/ui/map/GisSelect.js new file mode 100644 index 000000000..a9116e626 --- /dev/null +++ b/packages/common/business_components/src/ui/map/GisSelect.js @@ -0,0 +1,60 @@ +import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; +import GisApp from './lib/gis.core'; +import GisOlSelect from './lib/gis.select'; +import { GisContext } from './Gis'; + +export default React.memo(function GisSelect({ onFeatureSelect }) { + + const { gisMap } = useContext(GisContext); + + + useEffect(() => { + if (!gisMap) return; // 아직 초기화 안 됐으면 아무것도 안 함 + + // 이미 select가 있으면 재생성하지 않음 + if (!GisApp.Module.select) { + const select = new GisOlSelect(gisMap); + GisApp.Module.select = select; + + // 피처 선택 시 콜백 등록 + select.getSelectedInfo(function (features) { + if (features?.[0]) { + console.log('GisSelect features', features); + onFeatureSelect(features[0]); + } + }); + } + }, [gisMap, onFeatureSelect]); + + // const select = new GisOlSelect(gisMap); + // GisApp.Module.select = select; + + // select.getSelectedInfo(function (features) { + // //clickFeature(features[0]); + // if(features.length > 0) { + // const layer = features[0]; + // // console.log('GisSelect layer', layer); + // onFeatureSelect(layer); //피쳐 속성데이터 상위컴포넌트로 전달 + // } + // }); + + return ( + <> + {/* <div className="select_control map_control_div ol-unselectable ol-control" style={{ pointerEvents: 'auto', zIndex: '1' }}> + <h3>선,면,다각형을 이용한 시설물 선택 영역</h3> + <ul> + <li> + <div className="sprite_gis icon1 btn_select_one" title="단일선택" onClick={reset}> + <span>단일선택</span> + </div> + </li> + <li> + <div className="sprite_gis icon4 btn_select_cancel" title="새로고침" onClick={reset}> + <span>새로고침</span> + </div> + </li> + </ul> + </div> */} + </> + ); +}); diff --git a/packages/common/business_components/src/ui/map/GisTab.js b/packages/common/business_components/src/ui/map/GisTab.js new file mode 100644 index 000000000..27dd36e2b --- /dev/null +++ b/packages/common/business_components/src/ui/map/GisTab.js @@ -0,0 +1,85 @@ +// import React, { useState } from 'react'; +// import { Tabs, TabsList, TabsTrigger } from '@common/components/ui/tabs'; +// import GisTree from './GisTree'; +// import GisSearch from './GisSearch'; +// import { ChevronLeft, ChevronRight, Search } from 'lucide-react'; +// import SearchModal from '@/map/SearchModal'; + + +// function GisTab({ visible, setVisible, isSearch, isSearchModalOpen, setIsSearchModalOpen, onFeatureSelect }) { +// const [activeTab, setActiveTab] = useState('layers'); + +// return ( +// <> +// <div +// className={`absolute top-0 left-0 w-[250px] h-[calc(100vh-10rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-40 +// ${visible ? 'translate-x-0 opacity-100' : '-translate-x-full opacity-0 pointer-events-none'}`} +// > +// <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden"> +// <TabsList className="flex-none mt-4 border-none rounded-none"> +// <TabsTrigger value="layers" className="flex-1 h-10">레이어</TabsTrigger> +// {/* {isSearch && ( +// <button +// type="button" +// onClick={() => setIsSearchModalOpen(true)} +// className="h-10 w-10 flex items-center justify-center px-0" +// > +// <Search size={18} className="text-gray-700" /> +// </button> +// // <TabsTrigger value="search" className="flex-1 h-10 flex items-center justify-center"> +// // <Search size={18} /> +// // </TabsTrigger> +// )} */} +// </TabsList> + +// {/* GisTree는 항상 마운트되며, 탭 상태에 따라 보여주기만 함 */} +// <div +// className={`grow overflow-y-auto p-2 pt-0 border-none shadow-none ${activeTab === 'layers' ? 'block' : 'hidden' +// }`} +// > +// <GisTree /> +// </div> + +// {/* GisSearch도 동일하게 처리 */} +// {/* {isSearch && ( +// <div +// className={`grow overflow-y-auto p-2 pt-0 border-none shadow-none ${ +// activeTab === 'search' ? 'block' : 'hidden' +// }`} +// > +// <GisSearch /> +// </div> +// )} */} + + +// {/* 🔍 검색 모달 */} +// {/* <SearchModal +// open={isSearchModalOpen} +// onOpenChange={setIsSearchModalOpen} +// onFeatureSelect={onFeatureSelect} +// /> */} +// </Tabs> + +// {/* 접기 버튼 */} +// <button +// onClick={() => setVisible(false)} +// className="absolute top-1/2 right-[-3px] transform -translate-y-1/2 z-50 bg-white border border-l border-gray-300 rounded-l-none rounded-r px-1 py-2 shadow-sm" +// > +// <ChevronLeft size={16} /> +// </button> +// </div> + +// {/* 열기 버튼 */} +// {!visible && ( +// <button +// onClick={() => setVisible(true)} +// className="absolute top-1/2 left-[-3px] transform -translate-y-1/2 z-50 bg-white border border-r border-gray-300 rounded-r px-1 py-2 shadow-sm" +// > +// <ChevronRight size={16} /> +// </button> +// )} +// </> +// ); +// } + +// export default React.memo(GisTab); diff --git a/packages/common/business_components/src/ui/map/GisToolBar.js b/packages/common/business_components/src/ui/map/GisToolBar.js new file mode 100644 index 000000000..c5c613cc9 --- /dev/null +++ b/packages/common/business_components/src/ui/map/GisToolBar.js @@ -0,0 +1,71 @@ +import React from 'react'; +import GisApp from './lib/gis.core'; +import GisOlMeasure from './lib/gis.measure'; +import { useContext } from 'react'; +import { useGisContext } from './Gis'; +export default React.memo(function GisToolBar() { + const { gisMap } = useGisContext(); + if (!gisMap) return <></>; + const gisMeasuer = new GisOlMeasure(gisMap); + GisApp.Module.measure = gisMeasuer; + const zoomIn = () => { + if (gisMap.getView().getMaxZoom() > gisMap.getView().getZoom()) { + gisMap.getView().animate({ + zoom: gisMap.getView().getZoom() + 1, + duration: 250, + }); + } + }; + const zoomOut = () => { + if (gisMap.getView().getMaxZoom() > gisMap.getView().getZoom()) { + gisMap.getView().animate({ + zoom: gisMap.getView().getZoom() - 1, + duration: 250, + }); + } + }; + + const measure = type => { + gisMeasuer.addInteraction(type); + }; + + const reset = () => { + gisMeasuer.removeFeature(); + }; + return ( + <> + <div className="custom-toolbar"> + <div className="tool-bar"> + <h3>toolbar 영역</h3> + <ul> + <li> + <div className="btn_control_zoomin" title="확대" onClick={zoomIn}> + <span>확대</span> + </div> + </li> + <li> + <div className="btn_control_zoomout" title="축소" onClick={zoomOut}> + <span>축소</span> + </div> + </li> + <li> + <div className="btn_control_ruler btn_control_measure" title="거리 측정" onClick={() => measure(GisApp.measure.LineString)}> + <span>거리측정</span> + </div> + </li> + <li> + <div className="btn_control_area btn_control_measure" title="면적 측정" onClick={() => measure(GisApp.measure.Polygon)}> + <span>면적측정</span> + </div> + </li> + <li> + <div className="sprite_gis icon4 btn_select_cancel" title="리셋" onClick={() => reset()}> + <span>리셋</span> + </div> + </li> + </ul> + </div> + </div> + </> + ); +}); diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js new file mode 100644 index 000000000..6a2e8face --- /dev/null +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -0,0 +1,235 @@ +import GisApp from './lib/gis.core'; +import React, { useEffect, useState } from 'react'; +import { useGisContext } from './Gis'; +import { DndProvider } from "react-dnd"; +import { + Tree, + MultiBackend, + getBackendOptions +} from "@minoru/react-dnd-treeview"; +import { ChevronLeft, ChevronRight } from 'lucide-react'; + +export default React.memo(function GisTree({ checkedNodes = [], setCheckedNodes = () => { }, visible, setVisible }) { + const { gisCore, treeData } = useGisContext(); + const [checked, setChecked] = useState([]); + const [expanded, setExpanded] = useState([]); + const [nodes, setNodes] = useState([]); + const [activeTab, setActiveTab] = useState('layers'); + + useEffect(() => { + if (treeData && treeData.length > 0) { + const nodeData = setNodeValues(treeData); + setNodes(nodeData); + gisCore.setAllLayersOnMap(false); + } + }, [treeData]); + + useEffect(() => { + if (nodes.length > 0) { + const defaultChecked = getDefaultCheckedNodes(nodes); + setChecked(defaultChecked); + const defaultExpanded = Array.from(new Set([ + ...defaultChecked, + ...defaultChecked.flatMap((id) => getAncestors(id, nodes)) + ])); + setExpanded(defaultExpanded); + gisCore.setLayerOnMap(defaultChecked, true); + } + }, [nodes]); + + const getDefaultCheckedNodes = (nodes, values = []) => { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (node.data.initEprssYn === 'Y') { + values.push(node.data.lyrSn); + } + } + return values; + }; + + const getDescendants = (id, all) => { + const children = all.filter((n) => n.parent === id); + return children.reduce( + (acc, child) => acc.concat(child.id, ...getDescendants(child.id, all)), + [] + ); + }; + + const getAncestors = (id, all) => { + const node = all.find((n) => n.id === id); + if (!node || node.parent == null) return []; + return [node.parent, ...getAncestors(node.parent, all)]; + }; + + const isEveryChildChecked = (parentId, all, checked) => { + const children = all.filter((n) => n.parent === parentId); + return children.every((c) => checked.includes(c.id)); + }; + + const handleCheck = (id) => { + setChecked((prevChecked) => { + const isChecked = prevChecked.includes(id); + let newChecked = [...prevChecked]; + if (isChecked) { + const descendants = getDescendants(id, nodes); + newChecked = newChecked.filter((cid) => cid !== id && !descendants.includes(cid)); + const ancestors = getAncestors(id, nodes); + ancestors.forEach((p) => { + if (newChecked.includes(p)) { + newChecked = newChecked.filter((cid) => cid !== p); + } + }); + } else { + const descendants = getDescendants(id, nodes); + newChecked = Array.from(new Set([...newChecked, id, ...descendants])); + const ancestors = getAncestors(id, nodes); + ancestors.forEach((p) => { + if (isEveryChildChecked(p, nodes, newChecked)) { + newChecked.push(p); + } + }); + // setExpanded((prev) => Array.from(new Set([...prev, ...ancestors]))); + } + const allIds = nodes.map((n) => n.id); + const unchecked = allIds.filter((nid) => !newChecked.includes(nid)); + gisCore.setLayerOnMap(newChecked, true); + gisCore.setLayerOnMap(unchecked, false); + return newChecked; + }); + }; + + const setNodeValues = (result) => { + return result.map((item) => { + const src = makeLegendSrc(item); + let label = item.lyrNm; + return { + id: item.lyrSn, + text: label, + parent: item.upperLyrSn, + droppable: !!item.children, + data: item, + icon: src ? <img src={src} alt={item.lyrNm} /> : '', + }; + }); + }; + + const makeLegendSrc = layerinfo => { + if (!layerinfo.gobjTy) return null; + + let src; + if (layerinfo.iconNm) { + src = GisApp.CONTEXT_LAYER_PATH + layerinfo.iconNm; + } else { + const ss = makeLegendImage(layerinfo); + src = ss.src; + } + return src; + }; + + const makeLegendImage = layerinfo => { + let image = document.createElement('img'); + let canvas = document.createElement('canvas'); + canvas.width = 16; + canvas.height = 16; + let ctx = canvas.getContext('2d'); + ctx.beginPath(); + + if (layerinfo.gobjTy === 'lineLabelPoligon') { + ctx.moveTo(1, 1); + ctx.lineTo(15, 1); + ctx.lineTo(15, 15); + ctx.lineTo(1, 15); + ctx.lineTo(1, 1); + } else if (layerinfo.gobjTy === 'line') { + ctx.moveTo(1, 15); + ctx.lineTo(5, 1); + ctx.lineTo(9, 15); + ctx.lineTo(15, 1); + } else { + ctx.moveTo(1, 1); + ctx.lineTo(15, 1); + ctx.lineTo(15, 15); + ctx.lineTo(1, 15); + ctx.lineTo(1, 1); + } + + if (layerinfo.gobjTy === 'lineLabelPoligon') { + ctx.fillStyle = layerinfo.polgnColor; + ctx.fill(); + ctx.lineWidth = 1; + ctx.strokeStyle = layerinfo.lnColor; + ctx.stroke(); + } + + if (layerinfo.gobjTy === 'line') { + ctx.lineWidth = 1; + ctx.strokeStyle = layerinfo.lnColor; + ctx.stroke(); + } + + image.src = canvas.toDataURL('image/png'); + return image; + }; + + if (!treeData || treeData.length === 0) return null; + + return ( + <> + <div + className={`absolute top-0 left-0 w-[250px] h-[calc(100vh-10rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-40 + ${visible ? 'translate-x-0 opacity-100' : '-translate-x-full opacity-0 pointer-events-none'}`} + > + <div + className={`grow overflow-y-auto p-2 pt-0 border-none shadow-none ${activeTab === 'layers' ? 'block' : 'hidden'}`} + > + <h3 className="text-3xl text-gray-600 text-bold font-bold mx-2 my-4 ">레이어</h3> + <hr className="mb-4" /> + <DndProvider backend={MultiBackend} options={getBackendOptions()}> + <Tree + tree={nodes} + rootId={null} + initialOpen={expanded} + onOpenChange={setExpanded} + render={(node, { depth, isOpen, onToggle }) => ( + <div + className="flex items-center gap-2 py-1 px-2 hover:bg-gray-100 rounded" + style={{ marginInlineStart: depth * 16 + (!node.droppable ? 16 : 0) }} + > + {node.droppable && ( + <i onClick={onToggle} className={`diveicon di-arrow-forward mr-2 text-base text-label ${isOpen ? 'rotate-90' : ''}`} + title={isOpen ? '닫기' : '열기'}> + </i> + )} + <input type="checkbox" checked={checked.includes(node.id)} onChange={() => handleCheck(node.id)} className="cursor-pointer" /> + {node.icon && <span>{node.icon}</span>} + <span className="text-[1.3rem] font-medium text-label">{node.text}</span> + </div> + )} + sort={(a, b) => { + const aOrder = parseInt(a.data?.sortOrdr ?? '0'); + const bOrder = parseInt(b.data?.sortOrdr ?? '0'); + return aOrder - bOrder; + }} + /> + </DndProvider> + </div> + {/* 접기 버튼 */} + <button + onClick={() => setVisible(false)} + className="absolute top-1/2 right-[-3px] transform -translate-y-1/2 z-50 bg-white border border-l border-gray-300 rounded-l-none rounded-r px-1 py-2 shadow-sm" + > + <ChevronLeft size={16} /> + </button> + </div> + {/* 열기 버튼 */} + {!visible && ( + <button + onClick={() => setVisible(true)} + className="absolute top-1/2 left-[-3px] transform -translate-y-1/2 z-50 bg-white border border-r border-gray-300 rounded-r px-1 py-2 shadow-sm" + > + <ChevronRight size={16} /> + </button> + )} + </> + ); +}); diff --git a/packages/common/business_components/src/ui/map/Google3DMap.js b/packages/common/business_components/src/ui/map/Google3DMap.js new file mode 100644 index 000000000..2ae6547dd --- /dev/null +++ b/packages/common/business_components/src/ui/map/Google3DMap.js @@ -0,0 +1,60 @@ +import React, { useEffect, useRef, useState } from 'react'; +import OLCesium from 'olcs/OLCesium'; +import olView from 'ol/View.js'; +import olSourceImageWMS from 'ol/source/ImageWMS.js'; +import olSourceOSM from 'ol/source/OSM.js'; +import olLayerImage from 'ol/layer/Image.js'; +import olLayerTile from 'ol/layer/Tile.js'; +import olMap from 'ol/Map.js'; +import { GLOBAL } from '../../js/consts/global'; + +const Google3DMap = () => { + const mapRef = useRef(null); + const [ol3d, setOl3d] = useState(null); + + useEffect(() => { + if (!mapRef.current) return; + + const customProjSource = new olSourceImageWMS({ + attributions: '© <a href="http://www.geo.admin.ch/internet/geoportal/en/home.html">National parks / geo.admin.ch</a>', + crossOrigin: 'anonymous', + params: { LAYERS: 'ch.bafu.schutzgebiete-paerke_nationaler_bedeutung' }, + projection: 'EPSG:21781', + url: 'https://wms.geo.admin.ch/', + }); + + const { Cesium } = window; + Cesium.Ion.defaultAccessToken = GLOBAL.CESIUM_ION_TOKEN; + + const ol2d = new olMap({ + layers: [new olLayerTile({ source: new olSourceOSM() }), new olLayerImage({ source: customProjSource })], + target: mapRef.current, + view: new olView({ + center: [860434.6266531206, 6029479.0044273855], + zoom: 6, + }), + }); + + const ol3dInstance = new OLCesium({ + map: ol2d, + time: () => Cesium.JulianDate.now(), + }); + + const scene = ol3dInstance.getCesiumScene(); + Cesium.createWorldTerrainAsync().then(tp => { + scene.terrainProvider = tp; + }); + + ol3dInstance.setEnabled(true); + setOl3d(ol3dInstance); + + return () => { + ol3dInstance.setEnabled(false); + ol2d.setTarget(null); + }; + }, []); + + return <div ref={mapRef} style={{ width: '100%', height: '100vh' }} />; +}; + +export default Google3DMap; diff --git a/packages/common/business_components/src/ui/map/MapToggleBtn.js b/packages/common/business_components/src/ui/map/MapToggleBtn.js new file mode 100644 index 000000000..b157dc141 --- /dev/null +++ b/packages/common/business_components/src/ui/map/MapToggleBtn.js @@ -0,0 +1,32 @@ +import React, { useContext, useState } from 'react'; +import { Button } from "@common/components"; +import { GisContext } from './Gis'; +import GisApp from './lib/gis.core'; + +export default React.memo(function MapToggleBtn() { + const { gisCore } = useContext(GisContext); + const [activeMap, setActiveMap] = useState({ label: '일반', type: 'base' }); // 기본 선택값 + + const handleSelect = mapObj => { + setActiveMap(mapObj); + gisCore.changeBaseLayer(mapObj.type); + }; + + return ( + <div className="mapbtn"> + <button className="sprite_gis viewer" title="지도선택">지도 선택</button> + <ul className="choice_map"> + <li className={activeMap.type === 'base' ? 'on' : ''}> + <button className="map1" id="base" title="일반지도" onClick={() => handleSelect({ label: '일반', type: 'base' })}> + <div>일반지도</div> + </button> + </li> + <li className={activeMap.type === 'satellite' ? 'on' : ''}> + <button className="map2" id="satelite" title="위성지도" onClick={() => handleSelect({ label: '위성', type: 'satellite' })}> + <div>위성지도</div> + </button> + </li> + </ul> + </div> + ); +}); diff --git a/packages/common/business_components/src/ui/map/lib/css/gis.common.css b/packages/common/business_components/src/ui/map/lib/css/gis.common.css new file mode 100644 index 000000000..0a8a045e7 --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/css/gis.common.css @@ -0,0 +1,1036 @@ +@charset "UTF-8"; + +.custom-toolbar { + position: absolute; + top: -20px; + right: 0px; +} +.mapbtn { + position: absolute; + top: 10px; + right: 20px; + z-index: 2; +} +.mapbtn h3 { + overflow: hidden; + font-size: 0; + line-height: 0; + text-indent: -9999px; +} +.mapbtn > button { + overflow: hidden; + width: 40px; + height: 38px; + background: url(../images/toolbar/ico_gis_sprite.png) #fff no-repeat; + border: 1px solid #9aa7b8; + border-radius: 3px; + box-shadow: 0px 0px 5px #999; +} +.mapbtn .map1 { + background-image: url('../images/toolbar/img_map1.png'); + background-size: contain; + background-repeat: no-repeat; + background-position: center; + width: 60px; + height: 40px; +} +.mapbtn .map2 { + background-image: url('../images/toolbar/img_map2.png'); + background-size: contain; + background-repeat: no-repeat; + background-position: center; + width: 60px; + height: 40px; +} +.mapbtn > button > div { + display: none; + overflow: hidden; + text-indent: -9999px; +} +.mapbtn .viewer { + background-position: 0 -120px; +} +.mapbtn > a.danmyun { + background: url(../images/toolbar/ico_gis_sprite.png) #fff 0 -400px; +} +.mapbtn > a:hover { + margin: -1px; + border: 2px solid #1357ce; +} +.mapbtn .choice_map { + display: none; + position: absolute; + top: 0; + right: 20px; + width: 150px; +} +.mapbtn:hover ul.choice_map { + display: block; +} +.mapbtn ul.choice_map { + font-size: 0; +} +.mapbtn ul.choice_map li { + float: left; + margin-right: 5px; + background: #fff; + width: 60px; + text-align: center; + border: 2px solid transparent; + border-radius: 3px; + box-shadow: 0px 0px 5px #999; + overflow: hidden; +} +.mapbtn ul.choice_map li:last-child { + margin-right: 0; +} +.mapbtn ul.choice_map > li:hover { + border: 2px solid #1357ce; +} +.mapbtn ul.choice_map > li.on { + border: 2px solid #1357ce; +} +.mapbtn ul.choice_map li span { + font-size: 12px; + color: #999; + text-align: center; +} +.mapbtn ul.choice_map li.on span { + font-size: 12px; + color: #333; + text-align: center; +} + +.tool-bar { + position: absolute; + top: 70px; + right: 20px; + z-index: 2; +} +.tool-bar h3 { + overflow: hidden; + font-size: 0; + line-height: 0; + text-indent: -9999px; +} +.tool-bar .status { + margin-bottom: 10px; + overflow: hidden; + width: 40px; + height: 40px; + background: #fff; + border: 1px solid #9aa7b8; + border-radius: 3px; + box-shadow: 0px 0px 5px #999; + box-sizing: border-box; +} +.tool-bar .status div { + display: block; + width: 40px; + height: 40px; + background: url(../images/toolbar/ico_gis_sprite.png) no-repeat; + background-position: 0 -160px; + text-indent: -9999px; +} +.tool-bar .status div:hover { + background-position: -40px -160px; +} +.tool-bar .status div.on { + background: url(../images/toolbar/ico_gis_sprite.png) no-repeat; + background-position: 0 -200px; +} +.tool-bar .status div.on:hover { + background-position: -40px -200px; +} +.tool-bar ul { + display: inline-block; + overflow: hidden; + width: 40px; + background: #fff; + border: 1px solid #9aa7b8; + border-radius: 3px; + box-shadow: 0px 0px 5px #999; + font-size: 0; + box-sizing: border-box; + padding: 0px; +} +.tool-bar ul li { + height: 40px; + border-bottom: 1px solid #eeeeee; + box-sizing: border-box; +} +.tool-bar ul li:last-child { + border-bottom: 0 none; +} +.tool-bar ul li div { + width: 40px; + height: 40px; + display: inline-block; +} +.tool-bar ul li div span { + display: none; + overflow: hidden; + text-indent: -9999px; +} + +.ol-scale-line { + background: rgba(0, 60, 136, 0.3); + border-radius: 4px; + padding: 2px; + position: absolute; + bottom: 10px; + right: 200px; +} + +/* 로드뷰 관련 */ +#roadview-container { + width: 100%; + height: 100%; + z-index: 500; + display: block; + position: unset; + left: 50%; + top: 0px; +} +#roadview-container .roadview-container-1p { + width: 50%; + height: 100%; + float: left; + position: relative; +} +#roadview-container .roadview-container-2p { + width: 50%; + height: 100%; + float: left; + position: relative; +} +#roadview-container .roadview { + position: absolute; + top: 0; + right: 0; + left: 0; + bottom: 0; + z-index: 0; + background: #999; +} + +/* Map Contents: Map Control Bar */ +.map_control { + position: absolute; + right: 20px; + top: 200px; + display: block; +} +.control_icons { + background: rgba(255, 255, 255, 0.9); + border-radius: 4px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); + padding: 10px; + display: block; +} +.control_icons li a { + display: block; + width: 40px; + height: 40px; + font-size: 0; +} +.control_icons li a:hover { + border-radius: 100%; + box-shadow: 0 3px 5px rgba(0, 0, 0, 0.1); +} +.btn_control_zoomin { + background: url(../images/toolbar/btn_cont_zoomin.png) no-repeat center; +} +.btn_control_zoomin:hover { + background: url(../images/toolbar/btn_cont_zoomin_on.png) no-repeat center #67aee3; +} +.btn_control_zoomout { + background: url(../images/toolbar/btn_cont_zoomout.png) no-repeat center; +} +.btn_control_zoomout:hover { + background: url(../images/toolbar/btn_cont_zoomout_on.png) no-repeat center #67aee3; +} +.btn_control_prev { + background: url(../images/toolbar/btn_cont_prev.png) no-repeat center; +} +.btn_control_prev:hover { + background: url(../images/toolbar/btn_cont_prev_on.png) no-repeat center #67aee3; +} +.btn_control_next { + background: url(../images/toolbar/btn_cont_next.png) no-repeat center; +} +.btn_control_next:hover { + background: url(../images/toolbar/btn_cont_next_on.png) no-repeat center #67aee3; +} +.btn_control_ruler { + background: url(../images/toolbar/btn_cont_ruler.png) no-repeat center center; +} +.btn_control_ruler:hover { + background: url(../images/toolbar/btn_cont_ruler_on.png) no-repeat center #67aee3; +} +.btn_control_ruler.active { + background: url(../images/toolbar/btn_cont_ruler_on.png) no-repeat center #67aee3; +} +.btn_control_area { + background: url(../images/toolbar/btn_cont_area.png) no-repeat center; +} +.btn_control_area:hover { + background: url(../images/toolbar/btn_cont_area_on.png) no-repeat center #67aee3; +} +.btn_control_area.active { + background: url(../images/toolbar/btn_cont_area_on.png) no-repeat center #67aee3; +} +.btn_control_save { + background: url(../images/toolbar/btn_cont_save.png) no-repeat center; +} +.btn_control_save:hover { + background: url(../images/toolbar/btn_cont_save_on.png) no-repeat center #67aee3; +} +.btn_control_sketch { + background: url(../images/toolbar/btn_cont_sketch.png) no-repeat center; +} +.btn_control_sketch:hover { + background: url(../images/toolbar/btn_cont_sketch_on.png) no-repeat center #67aee3; +} +.btn_control_print { + background: url(../images/toolbar/btn_cont_print.png) no-repeat center; +} +.btn_control_print:hover { + background: url(../images/toolbar/btn_cont_print_on.png) no-repeat center #67aee3; +} + +/*하단 */ +.map-scale-area { + position: absolute; + bottom: 40px; + right: 200px; + display: inline-block; + z-index: 2; + list-style: none; +} +.map-scale-area li { + float: left; + margin-right: 15px; + font-size: 12px; + font-weight: 600; + color: #333; + text-shadow: -2px 0 #fff, 0 2px #fff, 2px 0 #fff, 0 -2px #fff; + -moz-text-shadow: -2px 0 #fff, 0 2px #fff, 2px 0 #fff, 0 -2px #fff; + -webkit-text-shadow: -2px 0 #fff, 0 2px #fff, 2px 0 #fff, 0 -2px #fff; +} +.map-scale-area li:last-child { + margin-right: 0; +} +.map-scale-area .scale-bar { + margin-right: 12px; + margin-top: 5px; + padding: 0 10px; + border: 2px solid #333; + border-top: 0 none; + height: 9px; + position: relative; + z-index: 999; +} +.map-scale-area .scale-bar .line-text { + position: relative; + top: -11px; +} + +/* sprite */ +.sprite_gis { + display: inline-block; /* overflow: hidden; */ + background: url(../images/toolbar/ico_gis_sprite.png) 0 0 no-repeat; + line-height: 999px; + vertical-align: bottom; +} + +.select_control { + position: absolute; + top: 80px; + left: 450px; +} +.select_control h3 { + overflow: hidden; + font-size: 0; + line-height: 0; + text-indent: -9999px; +} +.select_control ul { + display: inline-block; + overflow: hidden; + height: 40px; + font-size: 0; + background: #fff; + border: 1px solid #9aa7b8; + border-radius: 3px; + box-shadow: 0px 0px 5px #999; + box-sizing: border-box; + padding: 0px; + margin: 0px; +} +.select_control ul li { + float: left; + width: 40px; + border-right: 1px solid #eeeeee; +} +.select_control ul li:last-child { + border-right: 0 none; +} +.select_control ul li div { + overflow: hidden; + width: 40px; + height: 40px; + background: url(../images/toolbar/ico_gis_sprite.png) no-repeat; +} +.select_control ul li div span { + display: none; + overflow: hidden; + text-indent: -9999px; +} +.select_control .sprite_gis.icon1 { + background-position: 0 0; +} +.select_control .sprite_gis.icon1:hover { + background-position: -40px 0; +} +.select_control .sprite_gis.icon2 { + background-position: 0 -40px; +} +.select_control .sprite_gis.icon2:hover { + background-position: -40px -40px; +} +.select_control .sprite_gis.icon3 { + background-position: 0 -80px; +} +.select_control .sprite_gis.icon3:hover { + background-position: -40px -80px; +} +.select_control .sprite_gis.icon4 { + background-position: 0 -360px; +} +.select_control .sprite_gis.icon4:hover { + background-position: -40px -360px; +} +.select_control .sprite_gis.icon4.active { + background-position: -40px -360px; +} + +/* 20230516 하수도 자산관리용 아이콘*/ +.sprite_gis.icon4 { + background-position: 0 -360px; +} +.sprite_gis.icon4:hover { + background-position: -40px -360px !important; + background-color: #67aee3; +} + +.select_control select { + float: right; + min-width: 100px; + height: 40px; + line-height: 40px; + margin-left: 10px; + padding-left: 10px; + font-size: 13px; + color: #666; + border: 1px solid #fff; + border-radius: 3px; + box-shadow: 0px 0px 5px #999; +} + +.select_control-left0 { + left: 0px; +} + +/*** result_conts ***/ +.result_conts { + position: absolute; + top: 92px; + left: -320px; + height: 540px; + bottom: 0; + width: 320px; + border-right: 1px solid #bdbdbd; + box-shadow: 0px 0px 10px #999; + background: #fff; + box-sizing: border-box; + z-index: 500; + -webkit-transition: left 0.2s ease-out, opacity 0.3s ease-out; + -moz-transition: left 0.2s ease-out, opacity 0.3s ease-out; + transition: left 0.2s ease-out, opacity 0.3s ease-out; +} +.result_conts button.toggle { + display: block; + width: 30px; + height: 50px; + position: absolute; + top: 50%; + right: -30px; + margin-top: -28px; + background: url(../images/toolbar/toggle_off.png); + outline: none; + border: none; +} +.result_conts.on button.toggle { + background: url(../images/toolbar/toggle_on.png); +} +.result_conts.on { + left: 0; +} + +.result_conts .cheader { + padding: 16px 20px; + border-bottom: 1px solid #dddddd; + font-size: 14px; + line-height: 18px; + box-sizing: border-box; +} +.result_conts .cheader span.title { + color: #666; +} +.result_conts .cheader span.title em { + padding-right: 5px; + color: #152346; + font-weight: bold; +} +.result_conts .cbody { + position: absolute; + top: 51px; + bottom: 0; + left: 0; + right: 0; + padding: 15px 10px 0; + overflow: hidden; + overflow-y: auto; +} +.result_conts .cbody ul.list { + position: absolute; + top: 54px; + bottom: 41px; + left: 10px; + right: 10px; + overflow: hidden; + overflow-y: auto; + color: #999; + list-style: none; + padding: 0px; +} +.result_conts .cbody ul.list li { + border-bottom: 1px dotted #bbbbbb; +} +.result_conts .cbody .list li a { + color: rgb(98, 89, 202); + display: block; + width: 100%; + padding: 19px 0 15px 10px; +} +.result_conts .cbody .list li a .zip { + display: inline-block; + padding: 0 5px; + border: 1px solid #152346; + font-size: 12px; + color: #152346; + margin-right: 10px; +} +.result_conts .cbody .list li a .zip2 { + display: inline-block; + padding: 0 5px; + border: 1px solid #cc33cc; + font-size: 12px; + color: #cc33cc; +} +.result_conts .cbody .list li a .addr { + font-size: 0.875rem; + display: block; + padding-top: 5px; + color: #989898; +} +.t_count { + font-size: 0.875rem; +} + +/** 시설물정보 검색결과 하수관용도별 색상구분 : 하수관로 **/ +.result_conts .cbody .list li a .addr .SBA000_facility_init { + color: #999999; +} +.result_conts .cbody .list li a .addr .SBA001_facility_init { + color: #cc9933; +} +.result_conts .cbody .list li a .addr .SBA002_facility_init { + color: #ff9900; +} +.result_conts .cbody .list li a .addr .SBA003_facility_init { + color: #0000ff; +} +.result_conts .cbody .list li a .addr .SBA004_facility_init { + color: #cc0000; +} +.result_conts .cbody .list li a .addr .SBA999_facility_init { + color: #222222; +} + +/** 시설물정보 검색결과 하수관용도별 색상구분 : 하수맨홀 **/ +.result_conts .cbody .list li a .addr .SMU000_facility_init { + color: #999999; +} +.result_conts .cbody .list li a .addr .SMU001_facility_init { + color: #0000ff; +} +.result_conts .cbody .list li a .addr .SMU002_facility_init { + color: #cc0000; +} +.result_conts .cbody .list li a .addr .SMU003_facility_init { + color: #ff9900; +} +.result_conts .cbody .list li a .addr .SMU004_facility_init { + color: #cc9933; +} +.result_conts .cbody .list li a .addr .SMU999_facility_init { + color: #222222; +} + +/** 시설물정보 검색결과 하수관용도별 색상구분 : 물받이 **/ +.result_conts .cbody .list li a .addr .SBD000_facility_init { + color: #999999; +} +.result_conts .cbody .list li a .addr .SBD001_facility_init { + color: #0000ff; +} +.result_conts .cbody .list li a .addr .SBD002_facility_init { + color: #cc0000; +} +.result_conts .cbody .list li a .addr .SBD003_facility_init { + color: #ff9900; +} +.result_conts .cbody .list li a .addr .SBD999_facility_init { + color: #222222; +} + +.result_conts .cbody .list li.on, +.result_conts .cbody .list li:hover { + background-color: #ebebeb; +} +.result_conts .cbody .list li.on a .zip, +.result_conts .cbody .list li:hover a .zip { + color: #fff; + background-color: #152346; +} +.result_conts .cbody .list li.on a .addr, +.result_conts .cbody .list li:hover a .addr { + color: #000; +} + +.result_conts .cbody .paginate { + position: absolute; + bottom: 0; + left: 20px; + right: 0; + padding: 10px 0; + font-size: 11px; + line-height: normal; + text-align: center; +} + +/*** 페이징 **/ +.paginate { + padding: 10px 0; + font-size: 11px; + line-height: normal; + text-align: center; +} +.result_conts .cbody .paginate .pagination .page-link { + font-size: 11px !important; +} + +/*** 검색결과 더보기 **/ +.result_facility { + position: absolute; + left: 320px; + bottom: 0; + width: 1280px; + height: 200px; + border-radius: 10px; + border-right: 1px solid #bdbdbd; + box-shadow: 0px 0px 10px #999; + background: #fff; + box-sizing: border-box; + z-index: 500; + -webkit-transition: left 0.2s ease-out, opacity 0.3s ease-out; + -moz-transition: left 0.2s ease-out, opacity 0.3s ease-out; + transition: left 0.2s ease-out, opacity 0.3s ease-out; +} + +.result_facility_popup { + width: 1130px; +} + +.result_facility .cheader { + padding: 16px 20px; + border-bottom: 1px solid #dddddd; + font-size: 35px; + line-height: 18px; + box-sizing: border-box; + color: #666; + text-align: center; +} + +/*** 상수도 시설현황 **/ +.fcltyLayerSmmyDiv { + position: absolute; + top: 10px; + left: 500px; + bottom: 0; + background: #fff; + width: 130px; + overflow-x: hidden; + height: 40px; + overflow-y: hidden; + border-radius: 5px; +} +.fcltyLayerSmmyHeaderArea { + background: #ffffff; + color: black; +} +.fcltyLayerSmmyHeaderArea span { + color: #000000; + font-size: 16px; + padding-left: 10px; + line-height: 40px; +} + +.assetsSmmyDiv { + position: absolute; + top: 55px; + left: 320px; + bottom: 0; + background: #fff; + width: 310px; + overflow-x: hidden; + height: 690px; + overflow-y: auto; + border-radius: 5px; +} +.assetsNotMappingDiv { + position: absolute; + top: 10px; + right: 20px; + bottom: 0; + background: #fff; + width: 180px; + overflow-x: hidden; + height: 70px; + overflow-y: hidden; + border-radius: 5px; +} +.assetsNotAcceptDiv { + position: absolute; + top: 80px; + right: 20px; + bottom: 0; + background: #fff; + width: 180px; + overflow-x: hidden; + height: 70px; + overflow-y: hidden; + border-radius: 5px; +} + +.puDiv { + position: relative; + width: 390px; + height: 180px; + background: #fff; + margin: 4px auto 10px; + padding: 0; + border-bottom: 0px; +} +.puDiv .puHeader { + display: block; + position: relative; + margin-left: 10px; +} +.puDiv .puContent { + display: block; + position: relative; + width: 395px; + padding: 2px; + text-align: center; + margin-left: 10px; +} +.puGrayTb { + position: relative; + width: 98.3%; + margin-top: 5px; + table-layout: fixed; + font-family: Verdana; +} +.puGrayTb th { + text-align: center; + height: 29px; + background: #b9d5ff; + color: #454545; + border: 1px solid #9d9d9d; + font-size: 12px; + font-weight: bold; +} +.puGrayTb td { + text-align: center; + height: 15px; + background: #fff; + color: #454545; + padding-left: 5px; + font-size: 12px; + border: 1px solid #cbcbcb; + font-weight: bold; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.puHeaderArea { + position: relative; + height: 40px; +} +.assetsSmmyHeaderArea { + background: #00c73c; + margin-bottom: 7px; +} +.assetsSmmyHeaderArea span { + color: #fff; + font-size: 16px; + padding-left: 10px; + line-height: 40px; +} +.puHeaderArea .close { + position: relative; + top: 5px; + float: right; + color: #fff; + background: url('../images/toolbar/icon-viewclose.png') no-repeat; + text-indent: -9999px; + width: 30px; + height: 30px; +} +.header h3:after { + content: ''; + display: block; + background: #00a0ea; + width: 20px; + height: 3px; + font-size: 0; + margin-top: 5px; +} + +.switch { + position: relative; + display: inline-block; + width: 40px; + height: 22px; + left: 10px; + bottom: 2px; +} +.switch input { + opacity: 0; + width: 0; + height: 0; +} +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + -webkit-transition: 0.4s; + transition: 0.4s; + border-radius: 34px; +} +.slider:before { + position: absolute; + content: ''; + height: 15px; + width: 16px; + left: 4px; + bottom: 3.55px; + background-color: white; + -webkit-transition: 0.4s; + transition: 0.4s; + border-radius: 50%; +} +input:checked + .slider { + background-color: #7e7d7d; +} +input:focus + .slider { + box-shadow: 0 0 1px #7e7d7d; +} +input:checked + .slider:before { + -webkit-transform: translateX(26px); + -ms-transform: translateX(26px); + transform: translateX(16px); +} +.value { + position: absolute; + top: -11px; + left: -44px; +} + +table.board-table th { + text-align: center; + padding: 4px 0 !important; + border: 1px solid #bdbdbd52; + color: #000000; + font-size: 11px; + width: 50px; + background: #ffffff; +} +table.board-table td { + text-align: center; + padding: 4px 0 !important; + border: 1px solid #f1eded; + color: #000000; + font-size: 11px; + width: 50px; + background: #ffffff; +} +table.board-table thead .red { + background: #ff0000; + color: #ffffff; + font-size: 12px; + text-align: center; + padding: 5px; + font-family: Verdana; +} +table.board-table thead .blue { + background: #2a63dd; + color: #ffffff; + font-size: 12px; + text-align: center; + padding: 5px; + font-family: Verdana; +} +table.board-table thead .green { + background: green; + color: #ffffff; + font-size: 12px; + text-align: center; + padding: 5px; + font-family: Verdana; +} +table.board-table thead .gray { + background: #c0c0c0; + color: #ffffff; + font-size: 12px; + text-align: center; + padding: 5px; + font-family: Verdana; +} +table.board-table tbody th td { + font-size: 13px; + font-weight: bold; + font-family: Verdana; +} + +.ol-custom-popup { + position: absolute; + background-color: #fff; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); + padding: 15px; + border-radius: 10px; + border: 1px solid #ccc; + bottom: 12px; + left: -50px; + min-width: 280px; + font-size: 15; +} +.ol-custom-popup:after, +.ol-popup:before { + top: 100%; + border: solid transparent; + content: ' '; + height: 0; + width: 0; + position: absolute; + pointer-events: none; +} +.ol-custom-popup:after { + border-top-color: #fff; + border-width: 10px; + left: 48px; + margin-left: -10px; +} +.ol-custom-popup:before { + border-top-color: #ccc; + border-width: 11px; + left: 48px; + margin-left: -11px; +} +.ol-custom-popup-closer { + text-decoration: none; + position: absolute; + top: 2px; + right: 8px; +} +.ol-custom-popup-closer:after { + content: '✖'; +} +.ol-overviewmap { + width: 150px; + position: absolute; + z-index: 1; + right: 0px; + bottom: 0px; +} +.ol-overviewmap .ol-overviewmap-map { + border: 1px solid #7b98bc; + height: 100px; + margin: 10px; +} +.ol-overviewmap-box { + border: 2px dotted rgba(0, 60, 136, 0.7); +} +.ol-overviewmap button { + display: none; +} +.ol-overviewmap-map canvas { + height: 100%; +} + +.gis-map canvas { + transform: matrix(1, 0, 0, 1, 0, 0) !important; + width: inherit; + height: inherit; +} + +.gis-content .evl-legends{ + bottom: -20px !important; + left: 1350px; + z-index: 2; +} + + +/* cesium-viewer-toolbar 숨기기 */ +.cesium-viewer-toolbar { + display: none; +} + +/* cesium-viewer-animationContainer 숨기기 */ +.cesium-viewer-animationContainer { + display: none; +} +.cesium-viewer-bottom { + display: none; +} +.cesium-viewer-timelineContainer { + display: none; +} +.cesium-credit-logoContainer{ + display: none !important; +} +.cesium-credit-wrapper{ + display: none !important; +} +.cesium-credit-expand-link{ + display: none !important; +} + + diff --git a/packages/common/business_components/src/ui/map/lib/css/gis.edit.css b/packages/common/business_components/src/ui/map/lib/css/gis.edit.css new file mode 100644 index 000000000..c2531e4b0 --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/css/gis.edit.css @@ -0,0 +1,192 @@ +@charset 'utf-8'; + +.edit_control { + position: absolute; + top: 20px; + left: 610px; +} +.edit_control h3 { + overflow: hidden; + font-size: 0; + line-height: 0; + text-indent: -9999px; +} +.edit_control ul { + float: left; + overflow: hidden; + height: 40px; + margin-left: 10px; + background: #fff; + font-size: 0; + border: 1px solid #9aa7b8; + border-radius: 3px; + box-shadow: 0px 0px 5px #999; + box-sizing: border-box; + padding: 0; + cursor: pointer; +} +.edit_control ul li { + display: inline-block; + width: 40px; + height: 40px; + border-right: 1px solid #eeeeee; +} +.edit_control ul li:last-child { + border-right: 0 none; +} +/*background 삭제*/ +/* .edit_control ul li a {overflow:hidden;width: 40px;height: 40px;background: url(../../images/om/ico_gis_sprite.png) no-repeat;} */ +.edit_control ul li a { + overflow: hidden; + width: 40px; + height: 40px; + display: inline-block; + padding: 0; +} +.edit_control ul li a span { + display: none; + overflow: hidden; + text-indent: -9999px; +} +.edit_control select { + float: left; + min-width: 150px; + height: 40px; + line-height: 40px; + margin-left: 10px; + padding-left: 10px; + font-size: 13px; + color: #666; + border: 1px solid #fff; + border-radius: 3px; + box-shadow: 0px 0px 5px #999; +} + +.edit_control input { + float: left; + min-width: 150px; + height: 40px; + line-height: 40px; + margin-left: 10px; + padding-left: 10px; + font-size: 13px; + color: #666; + border: 1px solid #fff; + border-radius: 3px; + box-shadow: 0px 0px 5px #999; +} + +.edit_control button { + float: left; + min-width: 80px; + height: 40px; + margin-left: 5px; + font-size: 13px; + color: #fff; + text-align: center; + border-radius: 3px; + cursor: pointer; +} +.edit_control button:hover { + background: #1357ce; +} + +.edit_toolbar { + position: absolute; + left: 280px; + top: 52px; +} +.btn_edit_slt { + background: url(../images/toolbar/btn_edit_slt.png) no-repeat center; + height: 40px; +} +.btn_edit_slt:hover { + background: url(../images/toolbar/btn_edit_slt_on.png) no-repeat center; + height: 40px; +} +.btn_edit_slt.active { + background: url(../images/toolbar/btn_edit_slt_on.png) no-repeat center; + height: 40px; +} +.btn_edit_add { + background: url(../images/toolbar/btn_edit_add.png) no-repeat center; + height: 40px; +} +.btn_edit_add:hover { + background: url(../images/toolbar/btn_edit_add_on.png) no-repeat center; + height: 40px; +} +.btn_edit_add.active { + background: url(../images/toolbar/btn_edit_add_on.png) no-repeat center; + height: 40px; +} + +.btn_edit_del { + background: url(../images/toolbar/btn_edit_del.png) no-repeat center; + height: 40px; +} + +.btn_edit_del:hover { + background: url(../images/toolbar/btn_edit_del_on.png) no-repeat center; + height: 40px; +} + +.btn_edit_prev { + background: url(../images/toolbar/btn_edit_prev.png) no-repeat center; + height: 40px; +} + +.btn_edit_prev:hover { + background: url(../images/toolbar/btn_edit_prev_on.png) no-repeat center; + height: 40px; +} + +.btn_edit_next { + background: url(../images/toolbar/btn_edit_next.png) no-repeat center; + height: 40px; +} + +.btn_edit_next:hover { + background: url(../images/toolbar/btn_edit_next_on.png) no-repeat center; + height: 40px; +} + +.btn_edit_snap { + background: url(../images/toolbar/btn_edit_snap.png) no-repeat center; + height: 40px; +} + +.btn_edit_snap:hover { + background: url(../images/toolbar/btn_edit_snap_on.png) no-repeat center; + height: 40px; +} + +.btn_edit_snap.active { + background: url(../images/toolbar/btn_edit_snap_on.png) no-repeat center; + height: 40px; +} + +.btn_edit_save { + background: url(../images/toolbar/btn_edit_save.png) no-repeat center; + height: 40px; +} + +.btn_edit_save:hover { + background: url(../images/toolbar/btn_edit_save_on.png) no-repeat center; + height: 40px; +} + +.btn_edit_transform { + background: url(../images/toolbar/btn_edit_slt.png) no-repeat center; + height: 40px; +} + +.btn_edit_transform:hover { + background: url(../images/toolbar/btn_edit_slt_on.png) no-repeat center; + height: 40px; +} + +.btn_edit_transform.active { + background: url(../images/toolbar/btn_edit_slt_on.png) no-repeat center; + height: 40px; +} diff --git a/packages/common/business_components/src/ui/map/lib/css/gis.roadview.css b/packages/common/business_components/src/ui/map/lib/css/gis.roadview.css new file mode 100644 index 000000000..917d1b778 --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/css/gis.roadview.css @@ -0,0 +1,7 @@ +@charset 'utf-8'; +/* 로드뷰 관련 */ +.roadview-container {width: 100%; height: 100%; z-index:500; display:block;position: absolute; left: 0px;top: 0px;} +.roadview-container .roadview-container-1p {width: 310px; height: 190px; bottom: 15px; left: 15px; top: auto; border-radius: 2px; overflow: hidden; z-index: 1000; position: absolute;} +.roadview-container .roadview-container-2p {width: 100%; height:100%; float:left; position:relative;} +.roadview-container .roadview {position:absolute; top:0; right:0; left:0; bottom:0; z-index:0; background:#999;} + diff --git a/packages/common/business_components/src/ui/map/lib/css/gis.sketch.css b/packages/common/business_components/src/ui/map/lib/css/gis.sketch.css new file mode 100644 index 000000000..e69c02a7a --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/css/gis.sketch.css @@ -0,0 +1,206 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, +body, +div, +span, +applet, +object, +iframe, +h1, +h2, +h3, +h4, +h5, +h6, +p, +blockquote, +pre, +a, +abbr, +acronym, +address, +big, +cite, +code, +del, +dfn, +em, +img, +ins, +kbd, +q, +s, +samp, +small, +strike, +strong, +sub, +sup, +tt, +var, +b, +u, +i, +center, +dl, +dt, +dd, +ol, +ul, +li, +fieldset, +form, +label, +legend, +table, +caption, +tbody, +tfoot, +thead, +tr, +th, +td, +article, +aside, +canvas, +details, +embed, +figure, +figcaption, +footer, +header, +hgroup, +menu, +nav, +output, +ruby, +section, +summary, +time, +mark, +audio, +video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} +/* HTML5 display-role reset for older browsers */ +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +menu, +nav, +section { + display: block; +} +body { + line-height: 1; +} +ol, +ul { + list-style: none; +} +blockquote, +q { + quotes: none; +} +blockquote:before, +blockquote:after, +q:before, +q:after { + content: ""; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} + +body { + background-color: #e6e6fc; + display: flex; + flex-direction: column; + align-items: center; + padding: 50px 0px; +} + +.canvas { + width: 800px; + height: 500px; + background-color: white; + border-radius: 15px; + box-shadow: 6px 6px 5px grey; +} + +.controls .controls__colors { + display: flex; +} +.controls__colors .controls__color { + width: 50px; + height: 50px; + border-radius: 25px; + margin-right: 5px; + cursor: pointer; + box-shadow: 3px 3px 5px grey; +} + +.jscolor { + color: transparent; + font-size: 0; + line-height: 0; + + width: 50px; + height: 50px; + border-radius: 25px; + border-width: 0px; + margin-right: 5px; + cursor: pointer; + box-shadow: 3px 3px 5px grey; +} + +.controls { + display: flex; + flex-direction: column; + align-items: center; +} + +.controls .controls__btns { + margin-top: 20px; + margin-bottom: 30px; +} + +.controls__btns button:active { + transform: scale(0.98); +} + +.controls__btns button { + all: unset; + cursor: pointer; + background-color: white; + padding: 5px 0px; + width: 80px; + text-align: center; + border-radius: 10px; + box-shadow: 3px 3px 3px grey; + border: 2px solid rgba(0, 0, 0, 0.2); + color: rgba(0, 0, 0, 0.8); + text-transform: uppercase; + font-weight: 600; + font-size: 12px; +} + +.controls .controls__range { + margin-top: 20px; +} diff --git a/packages/common/business_components/src/ui/map/lib/css/gis.tooltip.css b/packages/common/business_components/src/ui/map/lib/css/gis.tooltip.css new file mode 100644 index 000000000..043d842e2 --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/css/gis.tooltip.css @@ -0,0 +1,35 @@ +@charset 'utf-8'; + +.ol-tooltip { + position: relative; + background: rgba(0, 0, 0, 0.5); + border-radius: 4px; + color: white; + padding: 4px 8px; + opacity: 0.7; + white-space: nowrap; + font-size: 12px; +} +.ol-tooltip-measure { + opacity: 1; + font-weight: bold; +} +.ol-tooltip-static { + background-color: #ffcc33; + color: black; + border: 1px solid white; +} +.ol-tooltip-measure:before, +.ol-tooltip-static:before { + border-top: 6px solid rgba(0, 0, 0, 0.5); + border-right: 6px solid transparent; + border-left: 6px solid transparent; + content: ""; + position: absolute; + bottom: -6px; + margin-left: -7px; + left: 50%; +} +.ol-tooltip-static:before { + border-top-color: #ffcc33; +} \ No newline at end of file diff --git a/packages/common/business_components/src/ui/map/lib/gis.core.js b/packages/common/business_components/src/ui/map/lib/gis.core.js new file mode 100644 index 000000000..caa068f76 --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/gis.core.js @@ -0,0 +1,978 @@ +import MousePosition from 'ol/control/MousePosition'; +import ScaleLine from 'ol/control/ScaleLine'; +import Feature from 'ol/Feature'; +import WKT from 'ol/format/WKT'; +import LinearRing from 'ol/geom/LinearRing'; +import Draw from 'ol/interaction/Draw'; +import TileLayer from 'ol/layer/Tile'; +import * as olProj from 'ol/proj'; +import VectorSource from 'ol/source/Vector'; +import XYZ from 'ol/source/XYZ'; +import GeoSJON from 'ol/format/GeoJSON'; +import { Fill, Stroke, Style } from 'ol/style'; +import CircleStyle from 'ol/style/Circle'; +import VectorLayer from 'ol/layer/Vector'; +import { Point } from 'ol/geom'; +import { boundingExtent } from 'ol/extent'; +import TileWMTS from 'ol/source/WMTS'; +import WMTS from 'ol/tilegrid/WMTS'; +import { get as getProjection } from 'ol/proj'; +import olSourceImageWMS from 'ol/source/ImageWMS'; +import { GeoJSON } from 'ol/format'; +import GisOlEditLayer from './gis.edit'; +import GisOlLayer from './gis.layer'; + +const GisApp = {}; +/** + * GisApp 전역변수 생성 + */ +//레이어 정보 +GisApp.layerInfos = []; +//기본정보 +//GisApp.GEOSERVER_URL = 'http://rsone.iptime.org:10010/geoserver/hasudo/ows'; +//GisApp.GEOSERVER_WMS_URL = 'http://rsone.iptime.org:10010/geoserver/hasudo/wms'; +GisApp.GEOSERVER_URL = '/geoserver/hasudo/ows'; +GisApp.SN_GEOSERVER_URL = '/sewerage-service/api/v1/map/geometry'; +GisApp.GEOSERVER_WMS_URL = '/geoserver/hasudo/wms'; + +//맵 정보 +GisApp.GOOGLE_API_KEY = 'AIzaSyCf1sgeELFalfPuqlykRKBMj6X9u_DQH6M'; +GisApp.GOOGLE_ROADMAP_URL = `https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}&key=${GisApp.GOOGLE_API_KEY}`; +GisApp.GOOGLE_SATELLITE_URL = `https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}&key=${GisApp.GOOGLE_API_KEY}`; +GisApp.GOOGLE_HYBRID_URL = `https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}&key=${GisApp.GOOGLE_API_KEY}`; +GisApp.GOOGLE_3D_URL = `https://tile.googleapis.com/v1/3dtiles/root.json?key=${GisApp.GOOGLE_API_KEY}`; + +// TODO: NGII 정보 +GisApp.NGII_API_KEY = '305CC242F2920F46BB93BCC13A52DD8A'; // NGII에서 발급받은 API 키 +GisApp.NGII_WMTS_URL = `http://map.ngii.go.kr/openapi/wmts_ngiiMap.js?apikey=${GisApp.NGII_API_KEY}`; + +GisApp.EMAP_URL = '/emap/'; +GisApp.EMAP_SATELLITE_URL = '/emap/satellite/'; +GisApp.VWORLD_URL = 'https://xdworld.vworld.kr/2d/Base/service/{z}/{x}/{y}.png'; +GisApp.VWORLD_SATELLITE_URL = 'https://xdworld.vworld.kr/2d/Satellite/service/{z}/{x}/{y}.jpeg'; +GisApp.OPENSTREET_URL = '//tile.openstreetmap.org/{z}/{x}/{y}.png'; +GisApp.BASE_MAP_TYPES = []; + +GisApp.FROM_PROJ = 'EPSG:4326'; +// GisApp.TO_PROJ = 'EPSG:3857'; +GisApp.TO_PROJ = 'EPSG:5179'; //ngii 좌표계 +GisApp.TO_DATA_PROJ = 'EPSG:4326'; +GisApp.CONTEXT_LAYER_PATH = '/images/map/layericon/'; +GisApp.layerCode = {}; +GisApp.hlLayerId = 'LightFeatureList'; +GisApp.hLayerId = 'highLightFeature'; +GisApp.wtlkgAnal = 'wtlkgAnal'; + +GisApp.Module = { + core: null, + select: null, + measure: null, + layer: null, + style: null, + edit: null, +}; + +/** + * GisApp.layerInfos Set + */ +GisApp.setLayerInfos = function (layerInfos) { + GisApp.layerInfos = layerInfos; +}; + +/** + * GisApp core + * @requires gis.layer.js + */ +GisApp.core = function (options) { + GisApp.core.prototype.initialize(options); +}; + +GisApp.core.prototype = { + defaults: { + target: '', + centerX: 37.2806094, + centerY: 127.0032502, + zoom: 11, + minZoom: 5, + maxZoom: 22, + }, + + options: { + /*1: { zoom: 16, minZoom: 10, maxZoom: 22, centerX: 35.3227711, centerY: 129.1836202, dataProj: 'EPSG:5186' }, + 4: { zoom: 16, minZoom: 10, maxZoom: 22, centerX: 35.0140499, centerY: 126.7110757, dataProj: 'EPSG:5186' }, + 14: { zoom: 16, minZoom: 10, maxZoom: 22, centerX: 34.9390499, centerY: 127.6960757, dataProj: 'EPSG:5186' },*/ + }, + epsg_5179: null, + map: null, // ol.Map + view: {}, // ol.view + interactions: {}, // interaction 모음 객체 + selectedFeature: null, // 현재 선택된 feature + baseLayers: {}, + baseMiniMapLayers: {}, + layers: {}, + initialize: function (options) { + let option = this.options['1']; + if (option) { + Object.assign(this.defaults, option); + GisApp.TO_DATA_PROJ = option.dataProj; + } + Object.assign(this.defaults, options); + GisApp.Module.core = this; + GisApp.Module.layer = new GisOlLayer(this.defaults); + GisApp.Module.edit = new GisOlEditLayer(this.defaults); + // TODO: vworld layer 정보 (getNGIIbaseMap)활성화 하면 ngii base map으로 변경됨 + this.baseMiniMapLayers['base'] = this.getBaseMap('minimap-base'); + this.baseLayers['base'] = this.getBaseMap('base'); + this.baseLayers['satellite'] = this.getBaseMap('satellite'); + this.baseLayers['3d'] = this.getBaseMap('3d'); + // this.baseLayers['3d'] = this.getNGIIBaseMap('3d'); + // this.baseMiniMapLayers['base'] = this.getNGIIBaseMap('minimap-base'); + // this.baseLayers['base'] = this.getNGIIBaseMap('base'); + // this.baseLayers['satellite'] = this.getNGIIBaseMap('satellite'); + }, + layerType: { + point: 'POINT', + line: 'LINE', + poligon: 'POLIGON', + }, + chgMngFcltOptions: function (fcltCd) { + let option = this.options[fcltCd]; + Object.assign(this.defaults, option); + this.setCenter(Number(this.defaults.centerX), Number(this.defaults.centerY)); + this.setZoom(15); + }, + // NGII Base Map + getNGIIBaseMap: function (_id) { + const layers = { + base: 'korean_map', // 일반지도 + satellite: 'satellite_map', // 위성지도 + night: 'night_map', // 야간지도 + }; + // 선택된 레이어 + const layerName = layers[_id] || layers.base; + // EPSG:5179 좌표계 설정 + const projection = getProjection('EPSG:5179'); + const extent = [705680.0000, 1349270.0000,1388291.0000, 2581448.0000]; // NGII 좌표계 범위 + const resolutions = [ + 2088.96, 1044.48, 522.24, 261.12, 130.56, 65.28, 32.64, 16.32, 8.16, 4.08, 2.04, 1.02, 0.51, 0.255, + ]; + const tileGrid = new WMTS({ + extent: extent, + resolutions: resolutions, + matrixIds: resolutions.map((_, index) => index.toString()), // WMTS matrix IDs + }); + + // WMTS 소스 생성 + const wmtsSource = new TileWMTS({ + url: GisApp.NGII_WMTS_URL, + layer: layerName, + matrixSet: 'EPSG:5179', + format: 'image/png', + projection: projection, + tileGrid: tileGrid, + style: 'default', + crossOrigin: 'anonymous', + }); + + // TileLayer 생성 + return new TileLayer({ + source: wmtsSource, + visible: true, + zIndex: 0, + }); + }, + getBaseMap: function (_id) { + let url = GisApp.VWORLD_URL; + if (_id === 'satellite') { + url = GisApp.VWORLD_SATELLITE_URL; + } + let baseMap = new TileLayer({ + id: _id, + visible: true, + type: 'base', + source: new XYZ({ + url: url, + crossOrigin: 'anonymous', + }), + zIndex: 0, + }); + return baseMap; + }, + /** + * [사용]테이블명으로 레이어 아이디 조회 + */ + getLayerCodeByTbNm: function (_tableNm) { + let returnLayerCode = null; + for (let i in GisApp.layerCode) { + let layerCode = GisApp.layerCode[i]; + if (layerCode.tableName === _tableNm) { + returnLayerCode = layerCode; + } + } + return returnLayerCode; + }, + /** + * [사용]베이스 레이어 변경 + * Parameters: + * _title - {String} 베이스 레이어 이름 + * + * Returns: + */ + changeBaseLayer: function (_id) { + this.map.getLayers().forEach(function (layer) { + let type = layer.get('type'); + let id = layer.get('id'); + if (type == 'base') { + if (id == _id) { + layer.setVisible(true); + } else { + layer.setVisible(false); + } + } + }); + }, + /** + * [사용]지도 중심좌표 설정 + */ + setCenter: function (x, y, transYn) { + if (!x || !y) return; + let view = this.map.getView(); + + if (!transYn) { + // 값이 없으면 + let center = olProj.transform([Number(y), Number(x)], GisApp.FROM_PROJ, GisApp.TO_PROJ); + const style = new Style({ + image: new CircleStyle({ + radius: 5, + fill: new Fill({ + color: 'rgba(255,0,0,1)', + }), + stroke: new Stroke({ + color: 'rgba(255,0,0,1)', + width: 2, + }), + }), + }); + + // 빨간색 점 추가 (스타일 적용) + let redPoint = new Feature({ + geometry: new Point(center), + }); + + let redPointLayer = new VectorLayer({ + source: new VectorSource({ + features: [redPoint], + }), + style: style, + }); + redPointLayer.set('id', 'redPointLayer'); + // 이전에 만들었던 빨간색 점 레이어 삭제 + this.deleteLayer('redPointLayer'); + + // 새로운 빨간색 점 레이어를 맵에 추가 + this.map.addLayer(redPointLayer); + view.setCenter(center); + } else { + view.setCenter([Number(x), Number(y)]); + } + }, + + /** + * [사용]주소검색 결과 지도이동 + */ + setAddressResult: function (x, y, geom, addressName) { + if (!x || !y) return; + let view = this.map.getView(); + let center = olProj.transform([Number(y), Number(x)], GisApp.FROM_PROJ, GisApp.TO_PROJ); + + const style = [ + new Style({ + image: new CircleStyle({ + radius: 5, + fill: new Fill({ + color: 'rgba(255,0,0,1)', + }), + stroke: new Stroke({ + color: 'rgba(255,0,0,1)', + width: 2, + }), + }), + }), + new Style({ + stroke: new Stroke({ + color: 'blue', + width: 3, + }), + fill: new Fill({ + color: 'rgba(0, 0, 255, 0.1)', + }), + }), + ]; + + // 빨간색 점 추가 (스타일 적용) + let redPoint = new Feature({ + geometry: new Point(center), + }); + let redArea = new WKT().readFeature(geom, { + dataProjection: GisApp.FROM_PROJ, + featureProjection: GisApp.TO_PROJ, + }); + + let redPointLayer = new VectorLayer({ + source: new VectorSource({ + features: [redPoint, redArea], + }), + style: style, + }); + + redPointLayer.set('id', 'redPointLayer'); + // 이전에 만들었던 빨간색 점 레이어 삭제 + this.deleteLayer('redPointLayer'); + + // 새로운 빨간색 점 레이어를 맵에 추가 + this.map.addLayer(redPointLayer); + + view.animate({ center: center, zoom: 19 }); + return redPointLayer; + }, + /* + * [사용]zoom 설정 + */ + setZoom: function (zoom) { + if (!zoom) return; + this.map.getView().setZoom(zoom); + }, + /* + * [사용]zoom 정보 반환 + */ + getZoom: function () { + return this.map.getView().getZoom(); + }, + /** + * [사용]레이어 맵에 추가(외부에서 생성된 layer를 파라미터로 받아 맵에 추가) + */ + setWfsLayerOnMap: function (layer) { + this.deleteLayer(layer.get('id')); + this.map.addLayer(layer); + return layer; + }, + /** + * [사용]레이어 생성(지오서버에서 WFS 방식으로 레이어정보 가져와 레이어 생성) + */ + getWfsLayer: function (layerCodeObj) { + return GisApp.Module.layer.getWfsLayer(layerCodeObj); + }, + /** + * [사용]레이어 맵에 추가(getWfsLayer 사용하여 레이어 생성) + */ + addWfsLayer: function (layerCodeObj) { + let layer = this.getWfsLayer(layerCodeObj); + this.deleteLayer(layer.get('id')); + this.map.addLayer(layer); + return layer; + }, + /** + * [사용]레이어 맵에 추가(getWmsLayer 사용하여 레이어 생성) + */ + addWmsLayer: function (layerCodeObj) { + let layer = GisApp.Module.layer.getWmsLayer(layerCodeObj); + this.deleteLayer(layer.get('id')); + this.map.addLayer(layer); + return layer; + }, + /** + * [사용]레이어 삭제 + */ + deleteLayer: function (layerId) { + const map = this.map; + if (!layerId) return; + map.getLayers().forEach(function (layer) { + let deleteLayer = null; + if (layer != null && layer.get('id') != null) { + let vlayerId = layer.get('id'); + if (vlayerId) { + if (vlayerId === layerId) { + deleteLayer = layer; + } else if (`${vlayerId}`.includes(`${layerId}`)) { + deleteLayer = layer; + } + if (deleteLayer) { + map.removeLayer(deleteLayer); + } + } + } + }); + }, + /** + * [사용]레이어 전체 삭제 + */ + deleteAllLayer: function () { + const map = this.map; + map.getLayers().forEach(function (layer) { + if (layer?.get('type') !== 'base') { + map.removeLayer(layer); + } + }); + //맵에 보이지 않는 레이어 삭제 + GisApp.Module.layer?.layers.forEach(function (layer) { + map.removeLayer(layer); + }); + }, + /** + * [사용]레이어이름으로 레이어 조회 반환 + */ + getLayerById: function (layerId) { + if (!layerId) return; + let layer = null; + this.map.getAllLayers().forEach(function (el) { + if (el != null && el?.get('id') != null) { + let vlayerId = el?.get('id'); + if (Number(vlayerId) === Number(layerId)) { + layer = el; + } + } + }); + if (layer != null) { + GisApp.Module.layer.layers.forEach(function (el) { + if (el != null && el?.get('id') != null) { + let vlayerId = el?.get('id'); + if (Number(vlayerId) === Number(layerId)) { + layer = el; + } + } + }); + } + return layer; + }, + /** + * [사용]오버레이 전체 삭제 + */ + deleteAllOverlay: function () { + let map = this.map; + let overlays = map.getOverlays(); + if (overlays != null) { + let overlayArray = overlays.getArray(); + let overlaySize = overlayArray.length; + if (overlaySize > 0) { + for (let i = overlaySize - 1; i >= 0; i--) { + let overlay = overlayArray[i]; + map.removeOverlay(overlay); + } + } + } + Object.values(GisApp.Module.style.overlays).forEach(overlay => { + if (overlay) { + map.removeOverlay(overlay); + } + }); + GisApp.Module.style.overlays = {}; + }, + /** + * [사용]오버레이 아이디로 삭제 + */ + deleteOverlay: function (_id) { + let map = this.map; + let overlays = map.getOverlays(); + if (overlays != null) { + let overlayArray = overlays.getArray(); + let overlaySize = overlayArray.length; + if (overlaySize > 0) { + for (let i = overlaySize - 1; i >= 0; i--) { + let overlay = overlayArray[i]; + let id = overlay.getId(); + if (id === _id) { + map.removeOverlay(overlay); + delete GisApp.Module.style.overlays[_id]; + } + } + } + } + }, + /** + * [사용]맵 이벤트 삭제 + */ + removeMapEventFnNm: function (handlerNm, fnNm) { + let map = this.map; + for (let i in map.listeners_[handlerNm]) { + let listener = map.listeners_[handlerNm][i]; + if (listener.name == fnNm) { + map.listeners_[handlerNm].splice(i, 1); + } + } + }, + /** + * [사용]위경도 -> 시분초 변환 + */ + coordTrans: function (coord) { + let str = String(coord); /* String 변환 */ + let split = str.split('.'); /* 소수점 split */ + let hour = split[0]; /* 시간 */ + let minute = parseFloat((coord - parseInt(split[0]).toFixed(6)) * 60).toFixed(6); + let min = String(minute).split('.')[0]; + let second = parseFloat((minute - parseInt(min)) * 60).toFixed(2); + return hour + '도 ' + min + '분 ' + second + '초'; + }, + /** + * [사용]시분초 -> 위경도 변환 + */ + coordTransrate: function (degree, minute, second) { + let min = (Number(minute) / 60).toFixed(2); + let sec = (Number(second) / 3600).toFixed(4); + return degree + Number(min) + Number(sec); + }, + /** + * [사용]마우스 좌표 위치 + */ + mousePositionControl: function () { + let element = document.getElementById('label_lonlat'); + let me = this; + let mousePosition = new MousePosition({ + coordinateFormat: function (coordinate) { + let text = ''; + if (element) { + let lng = coordinate[0]; + let lat = coordinate[1]; + text += '경도 : ' + lng.toFixed(7) + ' ( ' + me.coordTrans(lng) + ' ) '; + text += '위도 : ' + lat.toFixed(7) + '( ' + me.coordTrans(lat) + ' )'; + } + return text; + }, + projection: GisApp.FROM_PROJ, + className: 'custom-mouse-position', + target: document.getElementById('label_lonlat'), + }); + this.map.addControl(mousePosition); + }, + /** + * [사용]스케일 표시 + */ + scaleControl: function () { + let scale = new ScaleLine({ + minWidth: 100, + units: 'metric', + }); + this.map.addControl(scale); + }, + /** + * [사용]레이어 맵에 셋팅 + * + * Parameters: + * layerList - {Array} [layerNm : GisApp.layerCode 키] + * visible - {Boolean} 레이어 visible 여부 + * + * Returns: + */ + setLayerOnMap: function (layerIds, visible, addQurryFlter) { + for (let i in layerIds) { + let id = layerIds[i]; + let layer = this.getLayerById(id); + if (layer) { + layer.setVisible(visible); + } else if (visible) { + const layerCode = GisApp.layerCode[id]; + const queryFlter = layerCode?.flterSql || '' + ' ' + (addQurryFlter || ''); + layerCode['flterSql'] = queryFlter; + //layer = this.addWmsLayer(layerCode); + layer = this.addWfsLayer(layerCode); + } + } + if (GisApp.Module.select) { + this.setSelectInteraction(); + } + if (GisApp.Module.edit.snap) { + GisApp.Module.edit.setSnap(); + } + }, + /** + * [사용]shp 파일 다운로드 + */ + getShapeLayer: function (option) { + GisApp.Module.layer.getShapeLayer(option); + }, + /** + * [사용]레이어 셀렉트 가능하게 셋팅 + */ + setSelectInteraction: function () { + let layers = this.map.getLayers(); + if (layers) { + for (let i in layers.array_) { + let layer = layers.array_[i]; + if (layer.get('chcYn') === 'Y') { + GisApp.Module.select.addLayer(layer); + } + } + } + }, + /** + * [사용]layerInfos(array) To layerCode(Object) + */ + convertLayerInfosToLayerCode: function (array, key, parentName) { + let initialValue = {}; + + for (let i = 0; i < array.length; i++) { + let item = {}; + Object.assign(item, array[i]); + if (parentName) { + item.parentName = parentName; + } + initialValue[item[key]] = item; + + if (item.children && item.children.length > 0) { + let child = this.convertLayerInfosToLayerCode(item.children, key, item.name); + Object.assign(initialValue, child); + } + if (initialValue[item[key]].children) { + delete initialValue[item[key]].children; + } + } + return initialValue; + }, + /** + * [사용]피처 좌표값 WKT포맷으로 변경 + */ + convertFeatureGeomToWKT: function (feature) { + let geometry = feature.getGeometry(); + const wktGeom = this.convertGeomToWkt(geometry); + return wktGeom; + }, + /** + * [사용]WKT포맷 Geometry로 변경 + */ + convertWktToGeom: function (wtkGeom) { + let format = new WKT(); + let geom = format.readGeometry(wtkGeom); + return geom; + }, + /** + * [사용]좌표와 properties의 키값으로 피처 검색 + */ + getFeatureGeomProp: function (geometry, ftrIds, ftrCde) { + let returnFeature; + this.map.getLayers().forEach(function (layer) { + let type = layer.get('type'); + if (type != 'base') { + let source = layer.getSource(); + + let type = geometry.getType(); + let coordinates = geometry.getCoordinates(); + if (type.toUpperCase() === 'POINT') { + coordinates = [coordinates]; + } else { + coordinates = coordinates[0]; + } + let featureFtrIds; + let featureFtrCde; + for (let i in coordinates) { + let coordinate = coordinates[i]; + let feature = source.getClosestFeatureToCoordinate(coordinate); + if (feature) { + let properties = feature.getProperties(); + featureFtrIds = properties['FTR_IDS']; + featureFtrCde = properties['FTR_CDE']; + if (ftrIds == featureFtrIds && ftrCde == featureFtrCde) { + returnFeature = feature; + } + } + } + } + }); + return returnFeature; + }, + /** + * [사용]관리번호로 피처검색 + */ + getFeatureByFtrIdn: function (typeName, ftrIdn) { + let returnFeature; + this.map.getLayers().forEach(function (layer) { + let layerTypeName = layer.get('typeName'); + if (layerTypeName === typeName) { + layer + .getSource() + .getFeatures() + .forEach(feature => { + let featureFtrIdn = feature.get('ftrIdn'); + if (featureFtrIdn === ftrIdn) { + returnFeature = feature; + } + }); + } + }); + return returnFeature; + }, + /** + * [사용]맵 이벤트 추가 + */ + addMapEvent: function (evtNm, id, callback) { + if (id) { + let evt = this.map.on(evtNm, callback); + evt.listener.id = id; + } + }, + /** + * [사용]맵 이벤트 삭제 + */ + removeMapEvent: function (evtNm, id) { + if (id) { + let listeners = this.map.listeners_[evtNm]; + for (let i in listeners) { + let listenter = listeners[i]; + if (listenter.id == id) { + listeners.splice(i, 1); + } + } + } + }, + /** + * [사용]마커 그리기 추가 + */ + drawMarker: function (layer, callback) { + let map = this.map; + let drawSource = layer.getSource(); + let draw = new Draw({ + type: 'Point', + source: drawSource, + condition: function (evt) { + return true; + }, + }); + map.addInteraction(draw); + draw.on('drawend', function (event) { + callback(event); + map.removeInteraction(draw); + }); + }, + /** + * [사용]피처 삭제 + */ + deleteFeature: function (layerId, featureId) { + if (!layerId || !featureId) return; + let layer = this.getLayerById(layerId); + if (layer != null) { + let feature = layer.getSource().getFeatureById(featureId); + if (feature != null) { + layer.getSource().removeFeature(feature); + } + } + }, + /** + * [사용]레이어 + */ + fitExtent: function (extent, zoom) { + let map = this.map; + let mapSize = map.getSize(); + if (!zoom) { + zoom = this.getZoom(); + } + if (extent) { + //map.getView().fit(extent, { size: [mapSize[0], mapSize[1]] }); + this.map.getView().fit(extent, { + maxZoom: 18, // 너무 확대되지 않도록 최대 줌 제한 + duration: 500, // 부드러운 애니메이션 효과 (밀리초 단위) + }); + } + this.setZoom(zoom); + }, + getGeomLineOfFeature: function (feature) { + if (!feature) { + return; + } + let geometry = feature.getGeometry(); + let type = geometry.getType(); + if (!(type == 'LineString' || type == 'MultiLineString')) { + return; + } + let geomLienString = geometry; + if (type != 'LineString') { + //lienString = geometry.getLineString(); + } + return geomLienString; + }, + getCenterGeomLineOfFeature: function (feature) { + if (!feature) { + return; + } + let center = []; + let geomLienString = this.getGeomLineOfFeature(feature); + let coords = geomLienString.getCoordinates(); + let coordCnt = coords.length; + if (coordCnt == 1) { + coords = coords[0]; + } + //라인 한개 + if (coordCnt == 2) { + return geomLienString; + } + //멀티라인 + let centerCnt = Math.floor(coordCnt / 2); + let lineCoordinates = []; + lineCoordinates.push(coords[centerCnt - 1]); + lineCoordinates.push(coords[centerCnt]); + geomLienString = new LinearRing(lineCoordinates); + return geomLienString; + }, + getCenterOfLineGeom: function (geom) { + let center = []; + let extent = geom.getExtent(); + let x = extent[0] + (extent[2] - extent[0]) / 2; + let y = extent[1] + (extent[3] - extent[1]) / 2; + center.push(x); + center.push(y); + return center; + }, + /** + * 1 -> 01 두자리수로 변환 + */ + fillZeroToZoomLevel: function (n, digits) { + let zero = ''; + n = n.toString(); + if (digits > n.length) { + for (let i = 0; digits - n.length > i; i++) { + zero += '0'; + } + } + return zero + n; + }, + convertGeoJsonToFeature: function (data, dataProjection) { + let format = new GeoSJON(); + let feature = format.readFeature(data, { + dataProjection: dataProjection || GisApp.TO_DATA_PROJ, + featureProjection: GisApp.TO_PROJ, + }); + return feature; + }, + convertGeoJsonToFeatures: function (data, dataProjection) { + let format = new GeoSJON(); + const features = []; + for (let obj of data) { + let feature = null; + try { + feature = format.readFeature(obj, { + dataProjection: dataProjection || GisApp.TO_DATA_PROJ, + featureProjection: GisApp.TO_PROJ, + }); + } catch (error) { + console.log(obj); + } + features.push(feature); + } + return features; + }, + + /** + * [사용]WKT포맷 Geometry로 feature 생성 + */ + convertWktToFeature: function (wtkGeom, dataProjection) { + let format = new WKT(); + let feature = format.readFeature(wtkGeom, { + dataProjection: dataProjection || GisApp.TO_DATA_PROJ, + featureProjection: GisApp.TO_PROJ, + }); + return feature; + }, + + /** + * [사용]geometry를 WKT포맷으로 변경 + */ + convertGeomToWkt: function (geometry) { + let format = new WKT(); + let wktGeom = format.writeGeometry(geometry, { + dataProjection: GisApp.TO_DATA_PROJ, + featureProjection: GisApp.TO_PROJ, + }); + return wktGeom; + }, + /** + * [사용]레이어 전체 표시 컨트롤 + */ + setAllLayersOnMap: function (visible) { + this.map.getLayers().forEach(function (el) { + if (el !== null && el.get('id') !== null) { + let layerId = el.get('id'); + if ('base' !== layerId && 'satellite' !== layerId && 'minimap-base' !== layerId) { + el.setVisible(visible); + } + } + }); + }, + fitExtentZoomFeatures: function (features, zoom, paddingX = 0, paddingY = 0) { + if (!features.length) return; + // Feature들의 경계를 계산하여 extent 생성 + const extent = this.getExtentFeatures(features); + if (zoom) { + //const paddingX = 0; // X축 이동량 (양수는 오른쪽, 음수는 왼쪽) + //const paddingY = -10000; // Y축 이동량 (양수는 아래쪽, 음수는 위쪽) + extent[0] -= paddingX; + extent[1] -= paddingY; + this.fitExtent(extent, zoom); + } else { + this.map.getView().fit(extent, { + maxZoom: 18, // 너무 확대되지 않도록 최대 줌 제한 + duration: 500, // 부드러운 애니메이션 효과 (밀리초 단위) + }); + } + }, + getExtentFeatures: function (features) { + if (!features.length) return; + let extent = []; + const allCoordinates = features + .map(feature => { + const geometry = feature.getGeometry(); + if (!geometry || typeof geometry.getCoordinates !== 'function') { + return null; // geometry가 없거나 getCoordinates() 메서드가 없으면 null 반환 + } + return geometry.getCoordinates(); + }) + .filter(coords => coords !== null); + function extractCoordinatePairs(arr) { + let result = []; + + function recursiveFlatten(subArray) { + if (!Array.isArray(subArray)) return; + + for (let item of subArray) { + if (Array.isArray(item) && item.length === 2 && typeof item[0] === 'number' && typeof item[1] === 'number') { + // [숫자, 숫자] 형태의 배열이면 추가 + result.push(item); + } else { + // 그렇지 않다면 재귀 호출 + recursiveFlatten(item); + } + } + } + + recursiveFlatten(arr); + return result; + } + + const flattenCoord = extractCoordinatePairs(allCoordinates); + extent = boundingExtent(flattenCoord); + return extent; + }, +}; + +/** + * [사용]피처 레이어 검색 + */ +Feature.prototype.getLayer = function () { + let map = GisApp.Module.core.map; + let this_ = this, + layer_; + let sameFeature = function (feature) { + return this_ === feature ? true : false; + }; + map.getLayers().forEach(function (layer) { + let source = layer.getSource(); + if (source instanceof VectorSource) { + let features = source.getFeatures(); + if (features.length > 0) { + let found = features.some(sameFeature); + if (found) { + layer_ = layer; + } + } + } + }); + return layer_; +}; + +export default GisApp; diff --git a/packages/common/business_components/src/ui/map/lib/gis.edit.js b/packages/common/business_components/src/ui/map/lib/gis.edit.js new file mode 100644 index 000000000..ce3fae555 --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/gis.edit.js @@ -0,0 +1,521 @@ +import GisApp from './gis.core'; +import Draw from 'ol/interaction/Draw.js'; +import Modify from 'ol/interaction/Modify.js'; +import Collection from 'ol/Collection.js'; +import Snap from 'ol/interaction/Snap.js'; +import { click, always } from 'ol/events/condition'; +import UndoRedo from 'ol-ext/interaction/UndoRedo.js'; +import Transform from 'ol-ext/interaction/Transform.js'; +import { gAlert } from './util'; +import VectorSource from 'ol/source/Vector.js'; +import VectorLayer from 'ol/layer/Vector'; + +const GisOlEditLayer = function (options) { + this.initialize(options); +}; + +GisOlEditLayer.prototype = { + defaults: {}, + editFlag: false, + draw: null, + modify: null, + snap: null, + undoredo: null, + transform: null, + drawType: { + point: 'Point', + line: 'MultiLineString', + poligon: 'MultiPolygon', + }, + selectedLayerId: null, //셀렉트 박스에서 선택된 레이어 ID + targetElement: null, + editFeatureInfo: {}, + addFeatureNo: 1, + initialize: function (options) { + Object.assign(this.defaults, options); + }, + /** + * [공통] Interaction 추가 + */ + addInteration: function () { + this.setUndoInteraction(); + }, + /** + * [공통] Interaction 삭제 + */ + removeInteraction: function () { + this.removeDraw(); + this.removeModify(); + this.removeSnap(); + this.clearUndoRedoFeature(); + }, + /** + * [공통] Interaction Active 컨트롤 + */ + setInteractionActive: function (active) { + this.setDrawActive(active); + this.setModifyActive(active); + this.setActiveUndo(active); + }, + /** + * [그리기] 기능 추가 + */ + setDraw: function (lyrId) { + if (!lyrId) return '레이어를 선택해주십시오.'; + const core = GisApp.Module.core; + const layer = core.getLayerById(lyrId); + const layerCode = GisApp.layerCode[lyrId]; + if (!layer) return '트리에서 레이어 체크 후 추가 가능합니다.'; + const lyrTy = layerCode.gobjTy; + + //초기화 + this.removeModify(); + this.removeTransform(); + if (GisApp.Module.select) { + GisApp.Module.select.setActive(false); + } + + const originalSource = layer.getSource(); + const drawSource = new VectorSource({ + features: originalSource.getFeatures(), // 기존 feature들 복사 + }); + + // 새로운 임시 레이어 추가 (draw 전용) + const drawLayer = new VectorLayer({ + source: drawSource, + minZoom: 1, // 이 레이어만 minZoom을 1로 설정 + style: layer.getStyle(), + }); + core.map.addLayer(drawLayer); + + //그리기 기능 추가 + const me = this; + + if (me.draw) { + core.map.removeInteraction(me.draw); + } + const drawType = this.drawType[lyrTy]; + this.draw = new Draw({ + type: drawType, + source: drawSource, + condition: function (evt) { + return true; + }, + }); + core.map.addInteraction(this.draw); + + //그리기 기능 이벤트 + this.draw.on('drawstart', function (event) {}); + + const drawFeatureCallback = function (event) { + me.setActiveUndo(true); + const drawFeature = event.feature; + drawFeature.setId(lyrId + '_feature_' + me.addFeatureNo++); + + const properties = {}; + properties['lyr_id'] = lyrId; + drawFeature.setProperties(properties); + + me.setAddFeatureUndo(layer, drawFeature); + me.setActiveUndo(false); + }; + + this.draw.on('drawend', function (event) { + drawFeatureCallback(event); + }); + + /*drawSource.listeners_.addfeature = null; + let addfeatureCallback = function(event){ + } + drawSource.on('addfeature', addfeatureCallback);*/ + }, + /** + * [그리기] 기능 제거 + */ + removeDraw: function () { + let core = GisApp.Module.core; + core.map.removeInteraction(this.draw); + if (GisApp.Module.select) { + GisApp.Module.select.setActive(true); + } + }, + /** + * [그리기] 기능 활성화 + */ + setDrawActive: function (active) { + if (this.draw) { + this.draw.setActive(active); + if (!active) { + /* $('#selectEditLayer').css('display', 'none'); + $('.btn_edit_add').removeClass('active'); */ + } + } + }, + /** + * [히스토리] UndoInteraction 추가 + */ + setUndoInteraction: function () { + let core = GisApp.Module.core; + if (!this.undoredo) { + this.undoredo = new UndoRedo(); + core.map.addInteraction(this.undoredo); + } + }, + /** + * [히스토리] UndoInteraction 제거 + */ + removeUndoInteraction: function () { + let core = GisApp.Module.core; + if (this.undoredo) { + core.map.removeInteraction(this.undoredo); + this.undoredo = null; + } + }, + /** + * [히스토리] UndoInteraction 활성화 + */ + setActiveUndo: function (active) { + if (this.undoredo) { + this.undoredo.setActive(active); + } + }, + /** + * [히스토리] 피처 추가 + */ + setAddFeatureUndo: function (layer, addFeature) { + if (this.undoredo) { + let obj = {}; + obj.target = layer.getSource(); + obj.feature = addFeature; + obj.type = 'addfeature'; + obj.feature.set('uploadType', 'addFeature'); + this.undoredo._onAddRemove(obj); + } + }, + /** + * [히스토리] 피처 삭제 + */ + setRemoveFeatureUndo: function (layer, removeFeature) { + if (this.undoredo) { + let obj = {}; + obj.target = layer.getSource(); + obj.feature = removeFeature; + obj.type = 'removefeature'; + if (obj.feature.get('uploadType') !== 'addFeature') { + obj.feature.set('uploadType', 'removeFeature'); + } + this.undoredo._onAddRemove(obj); + } + }, + /** + * [히스토리] 이전 + */ + undo: function () { + if (this.undoredo) { + if (this.undoredo.hasUndo() === 0) { + gAlert('INFO', 'NO_PREV_HISTORY'); + return; + } + this.setActiveUndo(true); + this.undoredo.undo(); + this.setActiveUndo(false); + } + }, + /** + * [히스토리] 다음 + */ + redo: function () { + if (this.undoredo) { + if (this.undoredo.hasRedo() === 0) { + gAlert('INFO', 'NO_NEXT_HISTORY'); + return; + } + this.setActiveUndo(true); + this.undoredo.redo(); + this.setActiveUndo(false); + } + }, + /** + * [히스토리] 초기화 + */ + clearUndoRedo: function () { + if (this.undoredo) { + const undoStack = this.undoredo._undoStack.array_; + for (let i in undoStack) { + const undo = undoStack[i]; + if (undo.feature) { + undo.feature.set('uploadType', null); + } + } + this.undoredo.clear(); + } + }, + /** + * [히스토리] 피처 초기화 + */ + clearUndoRedoFeature: function () { + if (this.undoredo) { + this.setActiveUndo(true); + const undoStack = [...this.undoredo._undoStack.array_]; + for (let i in undoStack) { + const undo = undoStack[i]; + if (undo.feature) { + this.undoredo.undo(); + } + } + this.setActiveUndo(false); + this.undoredo.clear(); + } + }, + /** + * [삭제] 피처 삭제 + */ + deleteFeature: function () { + //초기화 + this.removeDraw(); + this.removeModify(); + this.removeTransform(); + + //삭제 기능 실행 + let me = this; + let core = GisApp.Module.core; + let select = GisApp.Module.select; + select.getSelectedFeatures().forEach(function (selectedFeature) { + let selectedFeatureId = selectedFeature.getId(); + let layers = core.map.getLayers(); + for (let i in layers.array_) { + let layer = layers.array_[i]; + if (layer.get('mdfcnYn') === 'Y' && select.getVisible(layer)) { + layer + .getSource() + .getFeatures() + .forEach(function (feature) { + let featureId = feature.getId(); + if (selectedFeatureId === featureId) { + me.setActiveUndo(true); + feature.setProperties({ lyr_id: layer.get('id') }); + layer.getSource().removeFeature(feature); + me.setRemoveFeatureUndo(layer, feature); + me.setActiveUndo(false); + } + }); + } + } + }); + }, + /** + * [수정] 피처 수정 기능 셋팅 + */ + setModify: function () { + //초기화 + this.removeDraw(); + this.removeTransform(); + + //수정기능 추가 + let me = this; + let core = GisApp.Module.core; + let select = GisApp.Module.select; + let selectedFeatures = select.getSelectedFeatures(); + + this.modify = new Modify({ + features: selectedFeatures, + }); + core.map.addInteraction(this.modify); + + select.selectInteraction.on('change:active', function () { + selectedFeatures.forEach(function (each) { + selectedFeatures.remove(each); + }); + }); + + this.modify.on('modifystart', function (event) {}); + + this.modify.on('modifyend', function (event) { + let features = event.features.getArray(); + for (let i = 0; i < features.length; i++) { + let feature = features[i]; + let layer = select.selectInteraction.getLayer(feature); + feature.setProperties({ lyr_id: layer.get('id') }); + } + }); + }, + /** + * [수정] 피처 수정 기능 제거 + */ + removeModify: function () { + if (this.modify) { + let core = GisApp.Module.core; + core.map.removeInteraction(this.modify); + this.modify = null; + } + }, + /** + * [수정] 기능 활성화 + */ + setModifyActive: function (active) { + if (this.modify) { + this.modify.setActive(active); + } + }, + /** + * [크기조절] 피처 크기조절 기능 셋팅 + */ + setTransform: function () { + //초기화 + this.removeDraw(); + this.removeModify(); + + let me = this; + let core = GisApp.Module.core; + let select = GisApp.Module.select; + + this.transform = new Transform({ + enableRotatedTransform: false, + addCondition: click, + filter: function (f, l) { + return f.getGeometry().getType() !== 'Point'; + }, + // layers: [vector], + hitTolerance: select.defaults.hitTolerance, + translateFeature: true, + scale: true, + rotate: true, + keepAspectRatio: always, + translate: true, + stretch: true, + }); + this.transform.on(['rotateend', 'translateend', 'scaleend'], function (event) { + let features = event.features.getArray(); + for (let i = 0; i < features.length; i++) { + let feature = features[i]; + let layer = feature.getLayer(); + feature.setProperties({ lyr_id: layer.get('id') }); + } + }); + + core.map.addInteraction(this.transform); + }, + /** + * [크기조절] 피처 크기조절 기능 제거 + */ + removeTransform: function () { + if (this.transform) { + let core = GisApp.Module.core; + core.map.removeInteraction(this.transform); + this.transform = null; + } + }, + /** + * [크기조절] 피처 크기조절 기능 활성화 + */ + setTransformActive: function (active) { + if (this.transform) { + this.transform.setActive(active); + } + }, + /** + * [스냅] 기능 추가 + */ + setSnap: function (active) { + //초기화 + this.removeSnap(); + + //스냅 기능 추가 + let core = GisApp.Module.core; + let select = GisApp.Module.select; + core.map.removeInteraction(this.snap); + let features = []; + let layers = core.map.getLayers(); + for (let i in layers.array_) { + let layer = layers.array_[i]; + if (select.getVisible(layer) && layer.get('type') !== 'base') { + if (layer.getSource().getFeatures().length > 0) { + features = features.concat(layer.getSource().getFeatures()); + } + } + } + let snapCollection = new Collection(features); + this.snap = new Snap({ + features: snapCollection, + pixelTolerance: 30, + }); + core.map.addInteraction(this.snap); + }, + /** + * [스냅] 기능 제거 + */ + removeSnap: function () { + if (this.snap) { + let core = GisApp.Module.core; + core.map.removeInteraction(this.snap); + this.snap = null; + } + }, + /** + * [스냅] 스냅에 피처 추가 + */ + addSnap: function (layers) { + let me = this; + if (me.snap) { + for (let i in layers) { + let source = layers[i].getSource(); + source.listeners_.addfeature = null; + source.on('addfeature', function (event) { + me.snap.addFeature(event.feature); + }); + } + } + }, + /** + * [스냅] 기능 활성화 + */ + setSnapActive: function (active) { + if (this.snap) { + this.snap.setActive(active); + } + }, + + /** + * [저장] 피처 수정 정보 셋팅 + */ + setEditFeatureInfo: function (flag, feature) { + const featureId = feature.getId(); + const properties = feature.getProperties(); + + //신규 추가 후 삭제시에 editFeatureInfo에서 제거 + if (properties.uploadType === 'addFeature' && flag === 'delete') { + delete this.editFeatureInfo[featureId]; + return; + } + + //좌표값 WKT포멧으로 변경 + const pGeom = GisApp.Module.core.convertFeatureGeomToWKT(feature); + const param = { + flag, + ...properties, + geom: pGeom, + }; + this.editFeatureInfo[featureId] = param; + }, + getEditFeatureInfo: function () { + const undoStack = this.undoredo._undoStack.array_; + this.editFeatureInfo = {}; + for (let i in undoStack) { + const undo = undoStack[i]; + if (undo.feature) { + const feature = undo.feature; + const properties = feature.getProperties(); + if (properties.uploadType === 'addFeature') { + this.setEditFeatureInfo('insert', feature); + } else if (properties.uploadType === 'removeFeature') { + this.setEditFeatureInfo('delete', feature); + } else if (undo.type === 'changegeometry') { + this.setEditFeatureInfo('update', feature); + } + } + } + return this.editFeatureInfo; + }, +}; + +export default GisOlEditLayer; diff --git a/packages/common/business_components/src/ui/map/lib/gis.layer.js b/packages/common/business_components/src/ui/map/lib/gis.layer.js new file mode 100644 index 000000000..d9657c313 --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/gis.layer.js @@ -0,0 +1,564 @@ +import Feature from 'ol/Feature.js'; +import GeoJSON from 'ol/format/GeoJSON.js'; +import Point from 'ol/geom/Point.js'; +import VectorLayer from 'ol/layer/Vector'; +import VectorImageLayer from 'ol/layer/VectorImage.js'; +import { all, bbox } from 'ol/loadingstrategy'; +import { transform } from 'ol/proj'; +import Cluster from 'ol/source/Cluster.js'; +import VectorSource from 'ol/source/Vector.js'; +import VectorTile from 'ol/source/VectorTile.js'; +import CircleStyle from 'ol/style/Circle.js'; +import Fill from 'ol/style/Fill.js'; +import Icon from 'ol/style/Icon.js'; +import Stroke from 'ol/style/Stroke.js'; +import Style from 'ol/style/Style.js'; +import Text from 'ol/style/Text.js'; +import { createXYZ } from 'ol/tilegrid'; +import GisApp from './gis.core.js'; +import GisOlStyle from './gis.style.js'; +import Tile from 'ol/layer/Tile.js'; +import TileWMS from 'ol/source/TileWMS.js'; +import gisCore from './gis.core.js'; + +/** + * GisApp.layer + * @requires gis.core.js + * @requires gis.style.js + */ +const GisOlLayer = function (options) { + this.initialize(options); +}; + +GisOlLayer.prototype = { + layers: [], + cesiumLayers: [], + defaults: {}, + storageNm: 'hasudo:', + defaultLayerOptions: { + name: '', + zIndex: 2, + opacity: 1, + flterSql: null, + minZoom: '15', + }, + initialize: function (options) { + Object.assign(this.defaults, options); + this.gisStyle = new GisOlStyle(); + GisApp.Module.style = this.gisStyle; + }, + gisStyle: null, + /** + * [사용]빈 레이어 생성 + */ + makeLayer: function (options) { + let layerOptions = { + ...this.defaultLayerOptions, + ...options, + minZoom: options?.minViewSize ? options?.minViewSize : options?.minZoom, + zIndex: options?.drawOrdr ? options?.drawOrdr : 1, + }; + let returnLayer = new VectorImageLayer(layerOptions); + this.layers.push(returnLayer); + return returnLayer; + }, + /** + * gisStyle 반환 + */ + getGisStyle: function () { + return this.gisStyle; + }, + /** + * [사용]WFS 벡터 소스 반환 + */ + getVectorSourceGeoserver: function (options) { + let url = GisApp.GEOSERVER_URL; + let toProj = GisApp.TO_PROJ; + let lyrTyNm = options.lyrTyNm; + let typename = this.storageNm + lyrTyNm; + + if(options.lyrTyNm == "om_dt_untrt_swg_rain"){ //중점우수토실실 + url = GisApp.SN_GEOSERVER_URL; + typename = lyrTyNm; + } + + const params = {}; + params['TYPENAME'] = typename; + params['SERVICE'] = 'WFS'; + params['VERSION'] = '1.1.0'; + params['REQUEST'] = 'GetFeature'; + params['SRS'] = toProj; + params['OUTPUTFORMAT'] = 'application/json'; + if (options?.flterSql && options?.flterSql?.trim() !== 'null') { + params['CQL_FILTER'] = options?.flterSql; + } + const strParams = new URLSearchParams(params).toString(); + let source = new VectorSource({ + format: new GeoJSON(), + url: function (extent) { + return url + '?' + strParams; + }, + strategy: all, + }); + return source; + }, + /** + * [사용]WFS 벡터 소스 반환 + */ + getVectorTileSource: function (options) { + let url = GisApp.GEOSERVER_URL; + let toProj = GisApp.TO_PROJ; + let params = {}; + params['TYPENAME'] = this.storageNm + options.typeName; + params['SERVICE'] = 'WFS'; + params['VERSION'] = '1.1.0'; + params['REQUEST'] = 'GetFeature'; + params['SRS'] = toProj; + params['OUTPUTFORMAT'] = 'application/json'; + let strParams = new URLSearchParams(params).toString(); + + let source = new VectorTile({ + format: new GeoJSON(), + + tileUrlFunction: function (tileCoord, pixelRatio, projection) { + let filterStr = '&CQL_FILTER=bbox(GEOM,' + source.getTileGrid().getTileCoordExtent(tileCoord).join(',') + ",'" + projection.getCode() + "')"; + if (options.flterSql) { + filterStr += ' AND ' + options.flterSql; + } + let paramWithUrl = url + '?' + strParams + filterStr; + return paramWithUrl; + }, + tileGrid: createXYZ, + }); + + return source; + }, + /** + * [사용]WFS 레이어 생성 + */ + getWfsLayer: function (option) { + option = option || {}; + //레이어 생성 + let returnLayer = this.makeLayer(option); + //레이어 소스 생성 + let source = this.getVectorSourceGeoserver(option); + returnLayer.setSource(source); + //스타일 추가 + returnLayer.setStyle(this.gisStyle.getLayerStyleFunction(returnLayer, option)); + returnLayer.set('id', option.id + ''); + return returnLayer; + }, + /** + * [사용]WMS 레이어 생성 + */ + getWmsLayer: function (options) { + let layerOptions = Object.assign({}, this.defaultLayerOptions, options); + let url = GisApp.GEOSERVER_WMS_URL; + let toProj = GisApp.TO_PROJ; + const lyrTyNm = options.lyrTyNm; + let params = {}; + params['LAYERS'] = this.storageNm + lyrTyNm; + params['SERVICE'] = 'WMS'; + params['VERSION'] = '1.1.1'; + params['REQUEST'] = 'GetMap'; + params['SRS'] = toProj; + params['FORMAT'] = 'image/png'; + params['TILED'] = true; + const wmsLayer = new Tile({ + ...layerOptions, + id: layerOptions?.id, + source: new TileWMS({ + url, + params, + serverType: 'geoserver', + }), + }); + wmsLayer.setZIndex(options.draw0rdr); + wmsLayer.setMinZoom(options.minViewSize); + this.layers.push(wmsLayer); + return wmsLayer; + }, + /** + * [사용]WFS 레이어 생성 + */ + getWfsEmptyLayer: function (option) { + option = option || {}; + //레이어 생성 + let returnLayer = this.makeLayer(option); + let vectorSource = new VectorSource({ + strategy: all, + }); + returnLayer.setSource(vectorSource); + //스타일 추가 + returnLayer.setStyle(this.gisStyle.getLayerStyle(returnLayer, option)); + returnLayer.set('id', option.id + ''); + let map = GisApp.Module.core.map; + map.addLayer(returnLayer); + return returnLayer; + }, + /** + * 피처 리스트로 레이어 생성 + */ + getLayerByFeature: function (feature, layerOptions, layerStyle) { + if (!layerOptions || !layerOptions.id) { + return null; + } + let vectorSource = new VectorSource({ + strategy: all, + }); + if (feature) { + if (Object.prototype.toString.call(feature).slice(8, -1).indexOf('Object') > -1) { + vectorSource.addFeature(feature); + } else { + vectorSource.addFeatures(feature); + } + } + GisApp.Module.core.deleteLayer(layerOptions.id); + layerOptions.source = vectorSource; + let layer = new VectorImageLayer(layerOptions); + layer.setStyle(layerStyle); + layer.set('id', layerOptions.id + ''); + return layer; + }, + /** + * 피처 하이라이트 + */ + highLightFeature: function (hlFeature, layerOptions, styleOptions, customStyleFunction) { + const color = styleOptions.color || 'rgba(135, 116, 243, 0.4)'; + const width = styleOptions.width || 10; + const featureKey = styleOptions.featureKey || ''; + const fitExtent = layerOptions.fitExtent || 'true'; + + const styleFunction = function (feature, resolution) { + const featureStyle = feature.getStyle(); + if (featureStyle) return null; + const style = new Style({ + image: new CircleStyle({ + fill: new Fill({ + color: color, + }), + radius: width, + }), + stroke: new Stroke({ + color: color, + width: width, + }), + }); + + const getFeatureText = function (feature) { + if (featureKey) { + let text = feature.get(featureKey); + if (text) return text; + } + return ''; + }; + const text = new Text({ + stroke: new Stroke({ + color: 'white', + width: 2, + }), + fill: new Fill({ + color: 'black', + }), + font: '12px Verdana', + text: getFeatureText(feature), + }); + style.setText(text); + return style; + }; + let actualStyleFunction = customStyleFunction || styleFunction; + GisApp.Module.core.deleteLayer(layerOptions.id); + let layer = this.getLayerByFeature(hlFeature, layerOptions, actualStyleFunction); + if (hlFeature && hlFeature.length > 0) { + let map = GisApp.Module.core.map; + map.addLayer(layer); + if (fitExtent === 'true') { + GisApp.Module.core.fitExtent(layer.getSource().getExtent()); + } + } + return layer; + }, + /** + * 마커 레이어 맵에 추가 + */ + addMarkerLayer: function (lat, lon, fromProj, layerOptions) { + let p = transform([Number(lon), Number(lat)], fromProj, GisApp.TO_PROJ); + let feature = new Feature({ + geometry: new Point(p), + }); + let layer = this.getMarkerLayer(layerOptions); + layer.getSource().addFeature(feature); + let map = GisApp.Module.core.map; + map.addLayer(layer); + GisApp.Module.core.fitExtent(feature.getGeometry().getExtent()); + return layer; + }, + /** + * 마커 레이어 생성 + */ + getMarkerLayer: function (layerOptions) { + let imgSrc = GisApp.CONTEXT_PATH + 'images/marker/marker-icon.png'; + let style = new Style({ + image: new Icon({ + anchor: [0.5, 46], + anchorXUnits: 'fraction', + anchorYUnits: 'pixels', + src: imgSrc, + }), + }); + let layer = this.getLayerByFeature(null, layerOptions, style); + return layer; + }, + /** + * 피처 생성 + */ + getFeatureCoord: function (coord) { + let feature = new Feature({ + geometry: new Point(coord), + }); + return feature; + }, + /** + * 피처 생성(lat,lon) + */ + getFeatureLonLat: function (lat, lon) { + let p = transform([Number(lon), Number(lat)], GisApp.FROM_PROJ, GisApp.TO_PROJ); + let feature = new Feature({ + geometry: new Point(p), + }); + return feature; + }, + addPolygonLayer: function (features, option) { + const map = GisApp.Module.core.map; + option = Object.assign( + { + gobjTy: 'lineLabelPoligon', + minZoom: option?.minZoom || '10', + lblAtrbNm: option?.lblAtrbNm || 'fclty_nm', + lnWdth: option?.lnWdth || '2', + lnColor: option?.lnColor || 'rgba(0, 0, 0, 1)', + polgnColor: option?.polgnColor || 'rgba(255, 0, 0, 0.2)', + clickPsblYn: option?.clickPsblYn || 'N', + fondNm: '18px "맑은 고딕",Malgun Gothic', + }, + option + ); + features[0].set('overLayId', option?.overlayId || null); + features[0].set('html', option?.html || null); + //레이어 생성 + let vectorLayer = this.makeLayer(option); + //레이어 소스 생성 + const vectorSource = new VectorSource({}); + vectorSource.addFeatures(features); + vectorLayer.setSource(vectorSource); + //스타일 추가 + vectorLayer.setStyle(this.gisStyle.getLayerStyleFunction(vectorLayer, option)); + vectorLayer.set('id', option?.id + ''); + GisApp.Module.core.deleteLayer(option?.id); + map.addLayer(vectorLayer); + }, + addLayerByLyrTyNm: function (id, lyrTyNm, addQurryFlter) { + const map = GisApp.Module.core.map; + const layerCode = GisApp.layerCode; + const matched = Object.values(layerCode).find(obj => obj.lyrTyNm === lyrTyNm); + matched['id'] = id; + let layer = GisApp.Module.core.getLayerById(matched?.id); + if (layer) { + layer.setVisible(true); + } else { + const queryFlter = layerCode?.flterSql || '' + ' ' + (addQurryFlter || ''); + matched['flterSql'] = queryFlter; + layer = this.getWfsLayer(matched); + GisApp.Module.core.deleteLayer(id); + map.addLayer(layer); + } + }, + /** + * 클러스터 레이어 맵에 추가 + */ + addClusterLayer: function (features, { layerId, color }) { + const map = GisApp.Module.core.map; + const vectorSource = new VectorSource({}); + vectorSource.addFeatures(features); + const clusterLayerId = layerId + 'Cluster'; + const vectorLayerId = layerId + 'Vector'; + // 클러스터 레이어 생성 + const clusterLayer = new VectorLayer({ + id: clusterLayerId, + source: new Cluster({ + distance: 100, // 클러스터링 거리 + source: vectorSource, + geometryFunction: function (feature) { + let geometry = feature.getGeometry(); + let type = geometry.getType(); + let coordinates; + if (type === 'Point') { + coordinates = geometry.getCoordinates(); + } else if (type === 'LineString' || type === 'MultiLineString') { + let totalX = 0; + let totalY = 0; + let totalPoints = 0; + geometry.getCoordinates().forEach(line => { + line.forEach(point => { + totalX += point[0]; + totalY += point[1]; + totalPoints++; + }); + }); + coordinates = [totalX / totalPoints, totalY / totalPoints]; + } + return new Point(coordinates); + }, + }), + style: feature => { + const clusterSize = feature.get('features').length; + return new Style({ + image: new CircleStyle({ + radius: 10, + fill: new Fill({ color }), + }), + stroke: new Stroke({ + color, + width: 10, + }), + text: new Text({ + text: clusterSize.toString(), + fill: new Fill({ + color: 'white', + }), + stroke: new Stroke({ + color: 'black', + width: 4, + }), + }), + }); + }, + }); + + const vectorLayer = new VectorLayer({ + id: vectorLayerId, + zIndex: 1, + source: vectorSource, + style: new Style({ + image: new CircleStyle({ + radius: 10, + fill: new Fill({ color }), + }), + stroke: new Stroke({ + color, + width: 10, + }), + }), + }); + GisApp.Module.core.deleteLayer(clusterLayerId); + GisApp.Module.core.deleteLayer(vectorLayerId); + map.addLayer(clusterLayer); + map.addLayer(vectorLayer); + function handleZoomEnd() { + const findClusterLayer = map + ?.getLayers() + ?.getArray() + .find(function (layer) { + return layer.get('id') === clusterLayerId; + }); + const findVectorLayer = map + ?.getLayers() + ?.getArray() + .find(function (layer) { + return layer.get('id') === vectorLayerId; + }); + if (!findClusterLayer) { + GisApp.Module.core.deleteLayer(vectorLayerId); + } + if (!findVectorLayer) { + GisApp.Module.core.deleteLayer(vectorLayerId); + } + const zoomLevel = map.getView().getZoom(); + if (zoomLevel >= 18) { + vectorLayer.setVisible(true); + clusterLayer.setVisible(false); + } else { + clusterLayer.setVisible(true); + vectorLayer.setVisible(false); + } + if (!findClusterLayer && !findVectorLayer) { + map.getView().un('change:resolution', handleZoomEnd); + } + } + // 줌 이벤트 핸들러 등록 + map.getView().un('change:resolution', handleZoomEnd); + map.getView().on('change:resolution', handleZoomEnd); + handleZoomEnd(); + }, + setLayersToCesium: function (ol3dInstance) { + let me = this; + //const { Cesium } = window; + const cesiumScene = ol3dInstance.getCesiumScene(); // Cesium Scene 가져오기 + me.cesiumLayers.forEach(cesiumLayer => { + cesiumScene.imageryLayers.remove(cesiumLayer); + }); + const layers = GisApp.Module.core.map + .getAllLayers() + .filter(layer => layer.get('type') !== 'base') + .sort((a, b) => { + const orderA = a.getProperties().drawOrdr || 0; + const orderB = b.getProperties().drawOrdr || 0; + return orderA - orderB; // 낮은 값이 먼저 오도록 정렬 + }); + + layers.forEach(layer => { + const properties = layer.getProperties(); + let cesiumLayer = null; + if (properties?.lyrTyNm) { + const visible = layer.getVisible(); + const lyrTyNm = properties.lyrTyNm; + // OpenLayers에서 레이어의 가시성 확인 + const { Cesium } = window; + const wmsUrl = GisApp.GEOSERVER_WMS_URL; + const wmsProvider = new Cesium.WebMapServiceImageryProvider({ + url: wmsUrl, + layers: GisApp.Module.layer.storageNm + lyrTyNm, // WMS 레이어 이름 + parameters: { + service: 'WMS', + version: '1.1.1', + request: 'GetMap', + format: 'image/png', + transparent: true, + styles: lyrTyNm, + }, + }); + cesiumLayer = cesiumScene.imageryLayers.addImageryProvider(wmsProvider); + cesiumLayer['properties'] = properties; + me.cesiumLayers.push(cesiumLayer); + if (properties?.visible) { + const zoomLevel = getOpenLayersZoom(cesiumScene); + cesiumLayer.show = zoomLevel >= Number(properties?.minZoom); + } else { + cesiumLayer.show = false; + } + } + }); + if (me.cesiumLayers.length > 0) { + cesiumScene._moveEndListener = () => { + const zoomLevel = getOpenLayersZoom(cesiumScene); // 대략적인 줌 계산 + me.cesiumLayers.forEach(cesiumLayer => { + const properties = cesiumLayer.properties; + if (properties?.visible) { + console.log('zoomLevel >= minZoom==', properties?.lyrTyNm, zoomLevel >= properties?.minZoom); + cesiumLayer.show = zoomLevel >= Number(properties?.minZoom); + } + }); + }; + cesiumScene.camera.moveEnd.addEventListener(cesiumScene._moveEndListener); + } + }, +}; + +function getOpenLayersZoom(cesiumScene) { + const cameraHeight = cesiumScene.camera.positionCartographic.height; + const maxResolution = 156543.03392804097; // OpenLayers 기본 해상도 (Google Mercator 기준) + const tileSize = 256; // 기본 타일 크기 + return Math.ceil(Math.log2(maxResolution / (cameraHeight / tileSize))); +} + +export default GisOlLayer; diff --git a/packages/common/business_components/src/ui/map/lib/gis.measure.js b/packages/common/business_components/src/ui/map/lib/gis.measure.js new file mode 100644 index 000000000..6fc427bed --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/gis.measure.js @@ -0,0 +1,204 @@ +import Style from 'ol/style/Style'; +import VectorLayer from 'ol/layer/Vector'; +import VectorSource from 'ol/source/Vector'; +import Fill from 'ol/style/Fill.js'; +import Stroke from 'ol/style/Stroke'; +import CircleStyle from 'ol/style/Circle'; +import { getLength, getArea } from 'ol/sphere'; +import Draw from 'ol/interaction/Draw'; +import Polygon from 'ol/geom/Polygon'; +import LineString from 'ol/geom/LineString'; +import { unByKey } from 'ol/Observable'; +import Overlay from 'ol/Overlay'; +import GisApp from './gis.core'; +//import DOMPurify from 'dompurify'; +const GisOlMeasure = function (options) { + GisApp.measure = {}; + GisApp.measure.helpTooltipElement = null; + GisApp.measure.helpTooltip = null; + GisApp.measure.sketch = null; + GisApp.measure.measureTooltipElement = null; + GisApp.measure.measureTooltip = null; + GisApp.measure.continueLineMsg = '선 그리기 종료를 위해 마우스를 더블클릭하세요.'; + GisApp.measure.Polygon = 'Polygon'; + GisApp.measure.LineString = 'LineString'; + + this.initialize(options); +}; + +GisOlMeasure.prototype = { + map: null, + draw: null, + vector: null, + type: null, + initialize: function (map) { + let me = this; + let source = new VectorSource(); + let vector = new VectorLayer({ + source: source, + style: new Style({ + fill: new Fill({ + color: 'rgba(255, 255, 255, 0.2)', + }), + stroke: new Stroke({ + color: '#ffcc33', + width: 2, + }), + image: new CircleStyle({ + radius: 7, + fill: new Fill({ + color: '#ffcc33', + }), + }), + }), + }); + me.map = map; + me.vector = vector; + me.map.addLayer(vector); + me.map.removeInteraction(this.draw); + }, + pointerMoveHandler: function (evt) { + if (evt.dragging) { + return; + } + let helpMsg = '시작점을 선택하세요'; + if (GisApp.measure.sketch) { + helpMsg = GisApp.measure.continueLineMsg; + } + if (GisApp.measure.helpTooltipElement) { + //GisApp.measure.helpTooltipElement.innerHTML = DOMPurify.sanitize(helpMsg); + GisApp.measure.helpTooltipElement.innerHTML = helpMsg; + GisApp.measure.helpTooltip.setPosition(evt.coordinate); + GisApp.measure.helpTooltipElement.classList.remove('hidden'); + } + }, + formatLength: function (line) { + let length = getLength(line); + let output; + if (length > 1000) { + output = Math.round((length / 1000) * 100) / 100 + ' ' + 'km'; + } else { + output = Math.round(length * 100) / 100 + ' ' + 'm'; + } + return output; + }, + formatArea: function (polygon) { + let area = getArea(polygon); + let output; + if (area > 10000) { + output = Math.round((area / 1000000) * 100) / 100 + ' ' + 'km<sup>2</sup>'; + } else { + output = Math.round(area * 100) / 100 + ' ' + 'm<sup>2</sup>'; + } + return output; + }, + addInteraction: function (type) { + let me = this; + me.map.removeInteraction(me.draw); + me.map.on('pointermove', me.pointerMoveHandler); + me.type = type; + + let source = me.vector.getSource(); + me.draw = new Draw({ + source: source, + type: type, + style: new Style({ + fill: new Fill({ + color: 'rgba(255, 255, 255, 0.2)', + }), + stroke: new Stroke({ + color: 'rgba(0, 0, 0, 0.5)', + lineDash: [10, 10], + width: 2, + }), + image: new CircleStyle({ + radius: 5, + stroke: new Stroke({ + color: 'rgba(0, 0, 0, 0.7)', + }), + fill: new Fill({ + color: 'rgba(255, 255, 255, 0.2)', + }), + }), + }), + }); + + me.map.addInteraction(me.draw); + me.createMeasureTooltip(); + me.createHelpTooltip(); + + let listener; + me.draw.on('drawstart', function (evt) { + let tooltipCoord = evt.coordinate; + GisApp.measure.sketch = evt.feature; + listener = GisApp.measure.sketch.getGeometry().on('change', function (evt) { + let geom = evt.target; + let output; + if (geom instanceof Polygon) { + output = me.formatArea(geom); + tooltipCoord = geom.getInteriorPoint().getCoordinates(); + } else if (geom instanceof LineString) { + output = me.formatLength(geom); + tooltipCoord = geom.getLastCoordinate(); + } + //GisApp.measure.measureTooltipElement.innerHTML = DOMPurify.sanitize(output); + GisApp.measure.measureTooltipElement.innerHTML = output; + GisApp.measure.measureTooltip.setPosition(tooltipCoord); + }); + }); + + me.draw.on('drawend', function () { + GisApp.measure.measureTooltipElement.className = 'ol-tooltip ol-tooltip-static'; + GisApp.measure.measureTooltip.setOffset([0, -7]); + GisApp.measure.sketch = null; + // unset tooltip so that a new one can be created + GisApp.measure.measureTooltipElement = null; + me.createMeasureTooltip(); + unByKey(listener); + me.removeInteraction(); + }); + }, + removeInteraction: function () { + this.map.removeInteraction(this.draw); + this.map.removeOverlay(GisApp.measure.helpTooltip); + }, + removeFeature: function () { + let source = this.vector.getSource(); + let features = this.vector.getSource().getFeatures(); + for (let i in features) { + let feature = features[i]; + source.removeFeature(feature); + } + }, + createHelpTooltip: function () { + let me = this; + if (GisApp.measure.helpTooltipElement) { + GisApp.measure.helpTooltipElement.parentNode.removeChild(GisApp.measure.helpTooltipElement); + } + GisApp.measure.helpTooltipElement = document.createElement('div'); + GisApp.measure.helpTooltipElement.className = 'ol-tooltip hidden'; + GisApp.measure.helpTooltip = new Overlay({ + element: GisApp.measure.helpTooltipElement, + offset: [15, 0], + positioning: 'center-left', + }); + me.map.addOverlay(GisApp.measure.helpTooltip); + }, + createMeasureTooltip: function () { + let me = this; + if (GisApp.measure.measureTooltipElement) { + GisApp.measure.measureTooltipElement.parentNode.removeChild(GisApp.measure.measureTooltipElement); + } + GisApp.measure.measureTooltipElement = document.createElement('div'); + GisApp.measure.measureTooltipElement.className = 'ol-tooltip ol-tooltip-measure'; + GisApp.measure.measureTooltip = new Overlay({ + id: 'measure', + element: GisApp.measure.measureTooltipElement, + offset: [0, -15], + positioning: 'bottom-center', + }); + me.map.addOverlay(GisApp.measure.measureTooltip); + }, +}; + +export default GisOlMeasure; diff --git a/packages/common/business_components/src/ui/map/lib/gis.select.js b/packages/common/business_components/src/ui/map/lib/gis.select.js new file mode 100644 index 000000000..7f52ab2a3 --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/gis.select.js @@ -0,0 +1,436 @@ +import GisApp from './gis.core'; +import _ from 'lodash'; +import Style from 'ol/style/Style.js'; +import { click, mouseOnly, never } from 'ol/events/condition'; +import DragBox from 'ol/interaction/DragBox.js'; +import Draw from 'ol/interaction/Draw.js'; +import Select from 'ol/interaction/Select.js'; +import VectorLayer from 'ol/layer/Vector.js'; +import Overlay from 'ol/Overlay.js'; +import VectorSource from 'ol/source/Vector.js'; +import CircleStyle from 'ol/style/Circle.js'; +import Fill from 'ol/style/Fill.js'; +import Stroke from 'ol/style/Stroke.js'; +//import DOMPurify from 'dompurify'; + +const GisOlSelect = function (map, options) { + GisApp.select = {}; + GisApp.select.helpTooltipElement = null; + GisApp.select.helpTooltip = null; + GisApp.select.helpMsg = null; + this.initialize(map, options); +}; + +GisOlSelect.prototype = { + map: null, + defaults: { + hitTolerance: 10, + useAreaControl: false, + }, + selectInteraction: null, + dragBox: null, + dragPolygon: null, + selectedFeatures: [], + selectFeatureFlag: false, + initialize: function (map, options) { + this.map = map; + _.extend(this.defaults, options); + this.setInteraction(); + this.pointerMoveEventHandler(); + }, + /** + * SELECT 이벤트 등록 + */ + setInteraction: function () { + let me = this; + let map = this.map; + if (this.selectInteraction) { + this.removeInteration(); + } + let baseStyle = new Style({ + image : new CircleStyle({ + fill : new Fill({ + color : 'rgba(135, 116, 243, 0.4)', + }), + radius : 30 + }), + stroke : new Stroke({ + color : 'rgba(135, 116, 243, 0.4)', + width : 10, + }) + }); + + this.selectInteraction = new Select({ + style: function (feature, resolution) { + let styles = []; + let layer = feature.getLayer(); + let styleFn = GisApp.Module.style.getLayerStyleFunction(layer, null); + + if (styleFn) { + const style = styleFn(feature, resolution); + if (Array.isArray(style)) { + styles.push(...style); + } else { + styles.push(style); + } + } + styles.push(baseStyle); + return styles; + }, + layers: function (layer) { + let clickPsblYn = layer?.get('clickPsblYn'); + return clickPsblYn === 'Y'; + }, + condition: click, + hitTolerance: me.defaults.hitTolerance, + }); + + map.addInteraction(this.selectInteraction); + this.selectedFeatures = this.selectInteraction.getFeatures(); + + // 선택된 피처 중심으로 지도 이동 + this.selectInteraction.on('select', function (e) { + const feature = e.selected[0]; + if (feature) { + const geometry = feature.getGeometry(); + if (geometry) { + const extent = geometry.getExtent(); + map.getView().fit(extent, { + duration: 300, + padding: [50, 50, 50, 50], + maxZoom: 16 + }); + } + } + }); + }, + /** + * SELECT 피처 초기화 + */ + reset: function () { + let me = this; + if (GisApp.Module.edit) { + GisApp.Module.edit.setDrawActive(false); + } + if (GisApp.Module.draw) { + GisApp.Module.draw.setDrawActive(false); + } + me.setActive(true); + me.removeHelpTooltip(); + + let map = this.map; + map.removeInteraction(me.dragBox); + map.removeInteraction(me.dragPolygon); + + this.selectInteraction.addCondition_ = never; + this.selectFeatureFlag = false; + + if (GisApp.Module.measure) { + GisApp.Module.measure.removeFeature(); + GisApp.Module.core.deleteOverlay('measure'); + } + + GisApp.Module.core.deleteLayer(GisApp.hLayerId); + GisApp.Module.core.deleteLayer(GisApp.hlLayerId); + + this.resetSelectedFeature(); + }, + /** + * 단일 SELECT 이벤트 등록 + */ + setSingleInteration: function () { + this.reset(); + this.selectInteraction.addCondition_ = click; + this.selectFeatureFlag = true; + }, + /** + * 박스 SELECT 이벤트 등록 + */ + setBoxInteration: function () { + //초기화 + this.reset(); + this.selectFeatureFlag = true; + + let me = this; + let map = this.map; + me.dragBox = new DragBox({ + condition: mouseOnly, + }); + map.addInteraction(this.dragBox); + GisApp.select.helpMsg = '직사각형 시작점을 클릭하고 드래그하세요.'; + me.createHelpTooltip(); + me.dragBox.on('boxend', function () { + let rotation = map.getView().getRotation(); + let oblique = rotation % (Math.PI / 2) !== 0; + let candidateFeatures = oblique ? [] : me.selectedFeatures; + let extent = me.dragBox.getGeometry().getExtent(); + let layers = map.getLayers(); + for (let i in layers.array_) { + let layer = layers.array_[i]; + if (layer?.get('clickPsblYn') == 'Y' && me.getVisible(layer)) { + layer.getSource().forEachFeatureIntersectingExtent(extent, function (feature) { + candidateFeatures.push(feature); + }); + } + } + + if (oblique) { + let anchor = [0, 0]; + let geometry = me.dragBox.getGeometry().clone(); + geometry.rotate(-rotation, anchor); + let extent$1 = geometry.getExtent(); + candidateFeatures.forEach(function (feature) { + let geometry = feature.getGeometry().clone(); + geometry.rotate(-rotation, anchor); + if (geometry.intersectsExtent(extent$1)) { + me.selectedFeatures.push(feature); + } + }); + } + me.selectInteraction.dispatchEvent({ + type: 'select', + selected: me.selectedFeatures, + deselected: [], + }); + map.removeInteraction(me.dragBox); + }); + + me.dragBox.on('boxstart', function () { + me.removeHelpTooltip(); + me.selectedFeatures.clear(); + }); + }, + /** + * 폴리곤 SELECT 이벤트 등록 + */ + setPolygonInteration: function () { + this.reset(); + this.selectFeatureFlag = true; + + let me = this; + let map = this.map; + + //폴리곤 레이어 생성 + let polygonSource = new VectorSource({ + useSpatialIndex: false, + }); + let polygonLayer = new VectorLayer({ + source: polygonSource, + }); + map.addLayer(polygonLayer); + + //폴리곤 draw 객체 생성 + me.dragPolygon = new Draw({ + source: polygonSource, + type: 'Polygon', + condition: mouseOnly, + }); + map.addInteraction(me.dragPolygon); + + //툴팁 추가 + GisApp.select.helpMsg = '시작점을 클릭하세요.'; + me.createHelpTooltip(); + + me.dragPolygon.on('drawstart', function () { + me.removeHelpTooltip(); + me.selectedFeatures.clear(); + }); + + me.dragPolygon.on('drawend', function (evt) { + setTimeout(function () { + let polygon = evt.feature.getGeometry(); + let layers = map.getLayers(); + for (let i in layers.array_) { + let layer = layers.array_[i]; + if (layer?.get('clickPsblYn') == 'Y' && me.getVisible(layer)) { + let features = layer.getSource().getFeatures(); + for (let j = 0; j < features.length; j++) { + if (polygon.intersectsExtent(features[j].getGeometry().getExtent())) { + me.selectedFeatures.push(features[j]); + } + } + } + } + me.selectInteraction.dispatchEvent({ + type: 'select', + selected: me.selectedFeatures, + deselected: [], + }); + map.removeInteraction(me.dragPolygon); + map.removeLayer(polygonLayer); + }, 300); + }); + return me.dragPolygon; + }, + /** + * 레이어 추가 + */ + addLayer: function (layer) { + if (layer) { + layer.set('clickPsblYn', 'Y'); + } + }, + /** + * 레이어 삭제 + */ + removeLayer: function (layer) { + if (layer) { + layer.set('clickPsblYn', 'N'); + } + }, + /** + * 선택된 객체 초기화 + */ + resetSelectedFeature: function () { + const selectedFeatures = this.selectedFeatures; + selectedFeatures.forEach(feature => { + selectedFeatures.remove(feature); + }); + }, + /** + * SELECT 이벤트 활성화 + */ + setActive: function (active) { + this.selectInteraction.setActive(active); + }, + /** + * SELECT 이벤트 삭제 + */ + removeInteration: function () { + let map = this.map; + map.removeInteraction(this.selectInteraction); + this.selectInteraction = null; + + //포인터 변경 삭제 + this.removePointerMoveEventHandler('pointerMoveForSelect'); + }, + /** + * 피처 위에 마우스 오버시 포인터 변경 + */ + pointerMoveEventHandler: function () { + let me = this; + let map = this.map; + let pointerMoveForSelect = function (evt) { + let hit = this.forEachFeatureAtPixel( + evt.pixel, + function (feature, layer) { + let returnFlag = true; + if (layer?.get('clickPsblYn') === 'N') { + returnFlag = false; + } + return returnFlag; + }, + { hitTolerance: me.defaults.hitTolerance } + ); + + if (hit && me.selectInteraction.getActive()) { + this.getTargetElement().style.cursor = 'pointer'; + } else { + this.getTargetElement().style.cursor = ''; + } + }; + map.on('pointermove', pointerMoveForSelect); + }, + /** + * 포인터 이벤트 삭제 + */ + removePointerMoveEventHandler: function (fnNm) { + GisApp.Module.core.removeMapEventFnNm('pointermove', fnNm); + }, + /** + * 선택된 피처 정보 반환(callback) + */ + getSelectedInfo: function (callback) { + let me = this; + this.selectInteraction.on('select', function (e) { + if (!e.auto) { + let features = me.selectInteraction.getFeatures(); + if(features.getArray().length == 0) return; + let layers = []; + for (let i in features.array_) { + let feature = features.array_[0]; + let layer = me.selectInteraction.getLayer(feature); + layers.push(layer); + } + callback(features.array_, layers); + } + }); + }, + /** + * 선택된 피처 정보 반환 + */ + getSelectedFeatures: function () { + return this.selectInteraction.getFeatures(); + }, + /** + * 마지막으로 선택된 피처 정보 반환 + */ + getLastSelectedFeatures: function () { + return this.selectInteraction.getFeatures(); + }, + /** + * 툴팁 생성 + */ + createHelpTooltip: function () { + let me = this; + let map = this.map; + GisApp.select.helpTooltipElement = document.createElement('div'); + GisApp.select.helpTooltipElement.className = 'ol-tooltip hidden'; + GisApp.select.helpTooltip = new Overlay({ + element: GisApp.select.helpTooltipElement, + offset: [15, 0], + positioning: 'center-left', + }); + map.addOverlay(GisApp.select.helpTooltip); + map.on('pointermove', me.pointerMoveTooltipHandler); + }, + /** + * 툴팁 삭제 + */ + removeHelpTooltip: function () { + if (GisApp.select.helpTooltipElement) { + let map = this.map; + this.removePointerMoveEventHandler('pointerMoveTooltipHandler'); + map.removeOverlay(GisApp.measure.helpTooltip); + GisApp.select.helpTooltipElement.remove(); + } + }, + /** + * 툴팁용 포인터 move 이벤트 + */ + pointerMoveTooltipHandler: function (evt) { + if (evt.dragging) { + return; + } + //GisApp.select.helpTooltipElement.innerHTML = DOMPurify.sanitize(GisApp.select.helpMsg); + GisApp.select.helpTooltipElement.innerHTML = GisApp.select.helpMsg; + GisApp.select.helpTooltip.setPosition(evt.coordinate); + GisApp.select.helpTooltipElement.classList.remove('hidden'); + }, + getVisible: function (layer) { + let maxZoom = layer.getMaxZoom(); + let minZoom = layer.getMinZoom(); + let map = this.map; + let curZoom = map.getView().getZoom(); + if (layer.getVisible() && maxZoom > curZoom && minZoom < curZoom) { + return true; + } + return false; + }, + /** + * 피처 셀렉트 + */ + selectFeature: function (feature, auto) { + let me = this; + me.selectedFeatures.clear(); + me.selectedFeatures.push(feature); + me.selectInteraction.dispatchEvent({ + type: 'select', + selected: me.selectedFeatures, + deselected: [], + auto: auto, + }); + }, +}; + +export default GisOlSelect; diff --git a/packages/common/business_components/src/ui/map/lib/gis.style.js b/packages/common/business_components/src/ui/map/lib/gis.style.js new file mode 100644 index 000000000..f791592cf --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/gis.style.js @@ -0,0 +1,402 @@ +import GisApp from './gis.core.js'; +import Style from 'ol/style/Style.js'; +import Fill from 'ol/style/Fill.js'; +import Icon from 'ol/style/Icon.js'; +import Circle from 'ol/style/Circle.js'; +import Stroke from 'ol/style/Stroke.js'; +import Text from 'ol/style/Text.js'; +import Point from 'ol/geom/Point.js'; +import RegularShape from 'ol/style/RegularShape.js'; +import Overlay from 'ol/Overlay'; +import _ from 'lodash'; +//import DOMPurify from 'dompurify'; +import { createPortal } from 'react-dom'; +import ReactDOM from 'react-dom/client'; +import React from 'react'; + +const GisOlStyle = function () { + this.initialize(); +}; + +GisOlStyle.prototype = { + overlays: {}, + hiddenOverlays: {}, + initialize: function () { + this.layericonPath = GisApp.CONTEXT_LAYER_PATH; + }, + getRGBAToString: function (r, g, b, a) { + let rgba = 'rgba({r}, {g}, {b}, {a})'; + return rgba.replace('{r}', r).replace('{g}', g).replace('{b}', b).replace('{a}', a); + }, + getFillColor: function (layer) { + let r = layer.get('fillColorR'); + let g = layer.get('fillColorG'); + let b = layer.get('fillColorB'); + let a = layer.get('fillColorA'); + return this.getRGBAToString(r, g, b, a); + }, + getFontLineColor: function (layer) { + let r = layer.get('fontOutlineColorR'); + let g = layer.get('fontOutlineColorG'); + let b = layer.get('fontOutlineColorB'); + let a = layer.get('fontOutlineColorA'); + return this.getRGBAToString(r, g, b, a); + }, + getFontFillColor: function (layer) { + let r = layer.get('fontColorR'); + let g = layer.get('fontColorG'); + let b = layer.get('fontColorB'); + let a = layer.get('fontColorA'); + return this.getRGBAToString(r, g, b, a); + }, + point: function (layer, options, feature, resolution) { + let symbol = layer.get('iconNm'); + let maticKey = layer.get('maticKey'); + let maticVal1 = layer.get('maticVal1'); + let image; + let style = new Style({ + fill: new Fill(), + }); + + let text = this.getText(layer, feature, resolution); + style.setText(text); + + if (symbol) { + let rotation = 0; + if (feature) { + let angDir = feature.get('angDir'); + if (angDir) { + rotation = (angDir * Math.PI) / 180; + } + } + image = new Icon({ + crossOrigin: 'anonymous', + scale: 1.2, + src: this.layericonPath + symbol, + rotation: rotation, + }); + } else { + image = new Circle({ + fill: new Fill({ + color: '#4848B4', + }), + radius: 5, + }); + } + + const overlayHtml = feature.get('html'); + if (overlayHtml) { + this.setOverlay(layer, feature, overlayHtml); + } + + let baseResolution = 20; + let minScale = 0.4; + let maxScale = 1.5; + let scale = baseResolution / resolution > maxScale ? maxScale : baseResolution / resolution; + scale = Math.max(minScale, Math.min(maxScale, scale)); + image.setScale(scale); + style.setImage(image); + _.extend(style, options); + return style; + }, + line: function (layer, options, feature, resolution) { + let linewidth = layer.get('lnWdth'); + let lineColor = layer.get('lnColor'); + let style = new Style({ + stroke: new Stroke({ + color: lineColor, + width: linewidth, + }), + text: new Text(), + }); + + if (GisApp.Module.toolbar && GisApp.Module.toolbar.defaults.blockDiagramVisible) { + let blockDiagramLayerId = GisApp.Module.toolbar.getBlockDiagramLayerId(layer.get('lyrId')); + if (blockDiagramLayerId) { + style = this.getArrowStyle(feature, lineColor, linewidth); + } + } + return style; + }, + poligon: function (layer, options, feature, resolution) { + const style = new Style(); + const color = layer.get('polgnColor'); + const fill = new Fill({ + color, + }); + style.setFill(fill); + _.extend(style, options); + return style; + }, + lineLabelPoligon: function (layer, options, feature, resolution) { + let style = new Style(); + const polgnColor = layer.get('polgnColor'); + let fill = new Fill({ + color: polgnColor, + }); + let lnWdth = layer.get('lnWdth'); + let lineColor = layer.get('lnColor'); + let lineDash = layer.get('lineDash'); + let stroke = new Stroke({ + color: lineColor || '#000000', + width: lnWdth, + lineDash: lineDash ? [10, 5] : null, + }); + let text = this.getText(layer, feature, resolution); + if (text) { + style.setText(text); + } + const overlayHtml = feature.get('html'); + if (overlayHtml) { + this.setOverlay(layer, feature, overlayHtml); + } + style.setFill(fill); + style.setStroke(stroke); + _.extend(style, options); + return style; + }, + imagePoligon: function (layer, options, feature, resolution) { + let style = new Style(); + let symbol = layer.get('iconNm'); + let retGeom = feature.getGeometry().getInteriorPoint(); + let image = new Icon({ + crossOrigin: 'anonymous', + imgSize: [20, 20], + src: this.layericonPath + symbol, + }); + + let scale = 0.5 / resolution > 1 ? 1 : 0.5 / resolution; + image.setScale(1); + style.setGeometry(retGeom); + style.setImage(image); + + _.extend(style, options); + return style; + }, + setOverlay: function (layer, feature, overlayHtml) { + const layerFeature = layer.getSource().getFeatures()[0]; + const layerCoordinate = layerFeature ? layerFeature.getGeometry().getCoordinates() : null; + + const properties = feature.getProperties(); + const overLayId = feature?.get('overLayId'); + const overLayMinZoom = layer.get('overLayMinZoom') || '12'; + const overLayOffset = layer?.get('overLayOffset'); + let findoOverlay = this.overlays[overLayId]; + if (findoOverlay) { + const zoom = GisApp.Module.core.map.getView().getZoom(); + if (Number(overLayMinZoom) < zoom) { + findoOverlay.options.element.style.display = 'block'; + } else { + findoOverlay.options.element.style.display = 'none'; + } + return findoOverlay; + } + let overlayContainer = document.createElement('div'); + document.body.appendChild(overlayContainer); + + if (typeof overlayHtml === 'string') { + //const sanitizedHtml = DOMPurify.sanitize(overlayHtml); + overlayContainer.innerHTML = sanitizedHtml; + } + // React 컴포넌트일 경우 createPortal() 사용 + else if (React.isValidElement(overlayHtml)) { + ReactDOM.createRoot(overlayContainer).render(createPortal(overlayHtml, overlayContainer)); + } + + // 오버레이 생성 + let overlay = new Overlay({ + element: overlayContainer, + positioning: 'bottom-center', + stopEvent: false, + offset: overLayOffset ? overLayOffset : [0, 0], // 오프셋 설정 (위치 조정) + }); + this.overlays[overLayId] = overlay; + + // feature의 좌표 가져오기 + let geometry = feature.getGeometry(); + let coordinate; + // Point인 경우 + if (geometry.getType() === 'Point') { + coordinate = geometry.getCoordinates(); + } + // LineString 또는 MultiLineString인 경우 + else if (geometry.getType() === 'LineString' || geometry.getType() === 'MultiLineString') { + coordinate = geometry.getFlatMidpoint(); // 선의 중간 좌표를 가져옴 + } + // Polygon 또는 MultiPolygon인 경우 + else if (geometry.getType() === 'Polygon' || geometry.getType() === 'MultiPolygon') { + coordinate = geometry.getExtent(); + const center = [(coordinate[0] + coordinate[2]) / 2, (coordinate[1] + coordinate[3]) / 2]; + coordinate = center; + } else { + console.error('Unsupported geometry type: ' + geometry.getType()); + return; + } + overlay.setPosition(coordinate); + GisApp.Module.core.map.addOverlay(overlay); + const zoom = GisApp.Module.core.map.getView().getZoom(); + if (Number(overLayMinZoom) < zoom) { + overlay.options.element.style.display = 'block'; + } else { + overlay.options.element.style.display = 'none'; + } + }, + label: function (layer, options, feature, resolution) { + let textBaseline = layer.get('textBaseline') || 'bottom'; + let textAlign = layer.get('textAlign') || 'center'; + let fontExtnColor = layer.get('fontExtnColor'); + let fontExtnWdth = Number(layer.get('fontExtnWdth') || '3.5'); + let fontIntnColor = layer.get('fontIntnColor'); + let style = new Style(); + let prop = feature.getProperties(); + let label = prop[layer.get('lblAtrbNm')]; + + let text = new Text({ + stroke: new Stroke({ + width: fontExtnWdth, + color: fontExtnColor, + }), + fill: new Fill({ + color: fontIntnColor, + }), + font: layer.get('font'), + textBaseline: textBaseline, + textAlign: textAlign, + text: label, + }); + style.setStroke(null); + style.setFill(null); + style.setText(text); + _.extend(style, options); + + return style; + }, + getText: function (layer, feature, resolution) { + let textBaseline = layer.get('textBaseline') || 'middle'; + let textAlign = layer.get('textAlign') || 'center'; + let fontExtnColor = layer.get('fontExtnColor'); + let fontExtnWdth = Number(layer.get('fontExtnWdth') || '1'); + let fontIntnColor = layer.get('fontIntnColor'); + let minViewSize = layer.get('minViewSize'); + if (feature == null) { + return; + } + let prop = feature.getProperties(); + let label = prop[layer.get('lblAtrbNm')]; + let scaleFactor = 1 / resolution; // 해상도가 낮아질수록 글자 크기 증가 + const zoom = GisApp.Module.core.map.getView().getZoom(); + if (zoom < minViewSize - 3) { + label = ''; + } + let text = new Text({ + stroke: new Stroke({ + color: '#FFFFFF', // 흰색 테두리 + width: fontExtnWdth, + }), + fill: new Fill({ + color: fontIntnColor, + width: fontExtnWdth, + }), + font: `${layer.get('fontNm')}`, // 동적으로 폰트 크기 적용 + textBaseline: textBaseline, + textAlign: textAlign, + overflow: true, + text: label, + offsetY: 20, + }); + + return text; + }, + getArrowStyle: function (feature, lineColor, linewidth) { + let styles = [ + new Style({ + stroke: new Stroke({ + color: lineColor, + width: linewidth, + }), + }), + ]; + let geomLienString = GisApp.Module.core.getGeomLineOfFeature(feature); + if (geomLienString) { + let startCoord = geomLienString.getFirstCoordinate(); + let endCoord = geomLienString.getLastCoordinate(); + + let dx = endCoord[0] - startCoord[0]; + let dy = endCoord[1] - startCoord[1]; + let rotation = Math.atan2(dy, dx); + + let centerCoord = GisApp.Module.core.getCenterOfLineFeature(feature); + styles.push( + new Style({ + geometry: new Point(centerCoord), + image: new RegularShape({ + fill: new Fill({ color: lineColor }), + points: 3, + radius: 6, + rotation: -rotation, + angle: Math.PI / 2, + }), + }) + ); + } + return styles; + }, + //레이어 스타일 반환 + getLayerStyle: function (layer, options) { + let gobjTy = layer.get('gobjTy'); + if (!gobjTy) return; + let style = this[gobjTy](layer, options); + return style; + }, + getLayerStyleFunction: function (layer, options) { + let gobjTy = layer.get('gobjTy'); + let styleFunction = function (feature, resolution) { + return this[gobjTy](layer, options, feature, resolution); + }.bind(this); + return styleFunction; + }, + adjustOverlayPosition: function (overlay, coordinate) { + const overlays = this.overlays; + let adjustedCoordinate = [...coordinate]; // 초기 좌표 설정 + const minDistance = 100000; // 최소 거리 (필요시 조정) + let offset = overlay.getOffset() || [0, 0]; + + let closestOverlay = null; + let closestDistance = Infinity; + + // 모든 오버레이의 위치를 비교하여 가장 가까운 오버레이 찾기 + Object.values(overlays).forEach(existingOverlay => { + if (existingOverlay) { + const existingCoordinate = existingOverlay.getPosition(); + const existingOffset = existingOverlay.getOffset() || [0, 0]; + + // 현재 오버레이의 좌표 가져오기 + const overlayCoordinate = overlay.getPosition(); + if (!overlayCoordinate || !existingCoordinate) return; + + // 거리 계산 (유클리드 거리) + const distance = Math.sqrt(Math.pow(overlayCoordinate[0] - existingCoordinate[0], 2) + Math.pow(overlayCoordinate[1] - existingCoordinate[1], 2)); + + // 가장 가까운 오버레이 업데이트 + if (distance > 0 && distance < closestDistance) { + closestDistance = distance; + closestOverlay = existingOverlay; + } else { + offset = [50, 50]; + } + } + }); + + // 가장 가까운 오버레이가 있으면 offset 조정 + if (closestOverlay && closestDistance < minDistance) { + const closestOffset = closestOverlay.getOffset() || [0, 0]; + // X, Y 좌표를 기존 offset 값의 반대로 설정 + offset = [-closestOffset[0], -closestOffset[1]]; + } + + // 최종 offset 적용 + overlay.setOffset(offset); + }, +}; + +export default GisOlStyle; diff --git a/packages/common/business_components/src/ui/map/lib/images/marker/marker-icon-boon.png b/packages/common/business_components/src/ui/map/lib/images/marker/marker-icon-boon.png new file mode 100644 index 000000000..f65ce99f2 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/marker/marker-icon-boon.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/marker/marker-icon-hab.png b/packages/common/business_components/src/ui/map/lib/images/marker/marker-icon-hab.png new file mode 100644 index 000000000..2ef70877e Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/marker/marker-icon-hab.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/marker/marker-icon.png b/packages/common/business_components/src/ui/map/lib/images/marker/marker-icon.png new file mode 100644 index 000000000..950edf246 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/marker/marker-icon.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_area.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_area.png new file mode 100644 index 000000000..044562b55 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_area.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_area_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_area_on.png new file mode 100644 index 000000000..0129ff1bd Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_area_on.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_next.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_next.png new file mode 100644 index 000000000..075111dcf Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_next.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_next_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_next_on.png new file mode 100644 index 000000000..4fdbe3a00 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_next_on.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_prev.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_prev.png new file mode 100644 index 000000000..7e3fae7d2 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_prev.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_prev_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_prev_on.png new file mode 100644 index 000000000..fb2d55cfd Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_prev_on.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_print.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_print.png new file mode 100644 index 000000000..82596b83d Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_print.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_print_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_print_on.png new file mode 100644 index 000000000..bb7f27334 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_print_on.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_ruler.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_ruler.png new file mode 100644 index 000000000..501e460a4 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_ruler.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_ruler_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_ruler_on.png new file mode 100644 index 000000000..676a81bcc Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_ruler_on.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_save.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_save.png new file mode 100644 index 000000000..c057b9377 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_save.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_save_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_save_on.png new file mode 100644 index 000000000..bf8e6d623 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_save_on.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_sketch.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_sketch.png new file mode 100644 index 000000000..38a5f507f Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_sketch.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_sketch_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_sketch_on.png new file mode 100644 index 000000000..79fb54e03 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_sketch_on.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomin.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomin.png new file mode 100644 index 000000000..ac32fbab0 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomin.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomin_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomin_on.png new file mode 100644 index 000000000..f258a965f Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomin_on.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomout.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomout.png new file mode 100644 index 000000000..2ee0b5161 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomout.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomout_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomout_on.png new file mode 100644 index 000000000..58dcbfdae Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_cont_zoomout_on.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_add.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_add.png new file mode 100644 index 000000000..fe4f0993f Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_add.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_add_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_add_on.png new file mode 100644 index 000000000..37a836298 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_add_on.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_del.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_del.png new file mode 100644 index 000000000..3bb372ab9 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_del.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_del_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_del_on.png new file mode 100644 index 000000000..89e558739 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_del_on.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_next.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_next.png new file mode 100644 index 000000000..df4913c90 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_next.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_next_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_next_on.png new file mode 100644 index 000000000..4e5540b55 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_next_on.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_prev.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_prev.png new file mode 100644 index 000000000..e94bf6d42 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_prev.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_prev_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_prev_on.png new file mode 100644 index 000000000..8645dedfa Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_prev_on.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_save.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_save.png new file mode 100644 index 000000000..5f01dd55c Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_save.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_save_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_save_on.png new file mode 100644 index 000000000..b035687d6 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_save_on.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_slt.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_slt.png new file mode 100644 index 000000000..aaf2bbd08 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_slt.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_slt_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_slt_on.png new file mode 100644 index 000000000..02d035a74 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_slt_on.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_snap.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_snap.png new file mode 100644 index 000000000..fc91d4304 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_snap.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_snap_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_snap_on.png new file mode 100644 index 000000000..f856d8fbe Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/btn_edit_snap_on.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/ico_gis_sprite.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/ico_gis_sprite.png new file mode 100644 index 000000000..3567cc6e4 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/ico_gis_sprite.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/icon-viewclose.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/icon-viewclose.png new file mode 100644 index 000000000..d93ce9c05 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/icon-viewclose.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/img_map1.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/img_map1.png new file mode 100644 index 000000000..7f96fcf5e Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/img_map1.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/img_map2.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/img_map2.png new file mode 100644 index 000000000..1a93f50e8 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/img_map2.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/img_map3.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/img_map3.png new file mode 100644 index 000000000..df80af6dc Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/img_map3.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/img_map4.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/img_map4.png new file mode 100644 index 000000000..02d015d12 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/img_map4.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/loading.gif b/packages/common/business_components/src/ui/map/lib/images/toolbar/loading.gif new file mode 100644 index 000000000..7c3683bb4 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/loading.gif differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/map_pin.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/map_pin.png new file mode 100644 index 000000000..b6cc2f6aa Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/map_pin.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/toggle_off.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/toggle_off.png new file mode 100644 index 000000000..d5dea9f67 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/toggle_off.png differ diff --git a/packages/common/business_components/src/ui/map/lib/images/toolbar/toggle_on.png b/packages/common/business_components/src/ui/map/lib/images/toolbar/toggle_on.png new file mode 100644 index 000000000..9cb573875 Binary files /dev/null and b/packages/common/business_components/src/ui/map/lib/images/toolbar/toggle_on.png differ diff --git a/packages/common/business_components/src/ui/map/lib/util.js b/packages/common/business_components/src/ui/map/lib/util.js new file mode 100644 index 000000000..9fe55869b --- /dev/null +++ b/packages/common/business_components/src/ui/map/lib/util.js @@ -0,0 +1,596 @@ + +//공통 +export const util = { + getOneToArrayByObjectKey: function (arr, key, value) { + let result = {}; + arr.forEach(item => { + if (item[key] === value) { + result = item; + } + }); + return result; + }, + groupByProperty: (arr, prop, parentProp, group) => { + const tree = []; + const mappedArr = {}; + let arrElem; + let mappedElem; + // 먼저 배열의 노드를 개체에 매핑하고 -> 해시 테이블을 생성 + for (let i = 0; i < arr.length; i++) { + arrElem = arr[i]; + if (group && arrElem.mnuGroup !== group) continue; + mappedArr[arrElem[prop]] = arrElem; + mappedArr[arrElem[prop]].children = []; + } + + for (const mnuCd in mappedArr) { + if (mappedArr.hasOwnProperty(mnuCd)) { + mappedElem = mappedArr[mnuCd]; + // 요소가 루트 수준에 없으면 자식의 부모 배열에 추가. + if (mappedElem[parentProp] && mappedArr[mappedElem[parentProp]]?.children) { + mappedArr[mappedElem[parentProp]].children.push(mappedElem); + } + // 요소가 루트 수준에 있는 경우 첫 번째 수준 요소 배열에 추가 + else { + tree.push(mappedElem); + } + } + } + return tree; + }, + getLowestChildren: function (obj) { + if (!obj.children || obj.children.length === 0) { + return obj; + } else { + return this.getLowestChildren(obj.children[0]); + } + }, + hasClass: (e, clazz) => { + return e.target.classList.contains(clazz); + }, + isNull: function (str) { + if (typeof str === 'undefined' || str === null || str === 'null' || str === '' || str === 'undefined') { + return true; + } + return false; + }, + nvl: function (str, returnVal) { + if (this.isNull(str)) { + return returnVal; + } + return str; + }, + isNumeric: function (str) { + const pattern = /\d/; + return pattern.test(str); + }, + isAlpha: function (str) { + const pattern = /[a-zA-Z]/; + return pattern.test(str); + }, + isAlphaNumeric: function (str) { + const pattern = /^[a-zA-Z0-9]+$/; + return pattern.test(str); + }, + isSpecialChar: function (str) { + const pattern = /[~!@#$%^&*()_+|<>?:{}]/; + return pattern.test(str); + }, + isKoreanAplhaNumeric: function (str) { + const pattern = /^[ㄱ-ㅎ|ㅏ-ㅣ|가-힣|a-zA-Z0-9]+$/; + return pattern.test(str); + }, + isValidPassword: function (str) { + const pattern = /^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[!@#$%^&*(),.?":{}|<>])[a-zA-Z0-9!@#$%^&*(),.?":{}|<>]{8,20}$/; + return pattern.test(str); + }, + isValidExt: function (fileName, allowedExtensions) { + const fileExtension = fileName.split('.').pop().toLowerCase(); + return allowedExtensions.includes(fileExtension); + }, + isValidAllDate: function (dateString) { + const formats = ['YYYY', 'YYYYMM', 'YYYYMMDD']; + for (let format of formats) { + if (moment(dateString, format, true).isValid()) { + return true; + } + } + return false; + }, + isEmptyObject: function (param) { + return Object.keys(param).length === 0 && param.constructor === Object; + }, + castArrayObjectToList: function (array, key) { + var newArray = []; + for (var i = 0; i < array.length; i++) { + newArray.push(array[i][key]); + } + return newArray; + }, + arraysMatch: function (arr1, arr2) { + // 길이가 같은지 확인 + if (arr1.length !== arr2.length) return false; + + // 각 요소가 일치하는지 확인 + return arr1.every((element, index) => element === arr2[index]); + }, + //상위뎁스 객체들 조회 + findObjectByKeyAndValue: function ({ array, find: { key: findKey, value: findValue }, result: { key: resultKey }, parents = [] }) { + for (let i = 0; i < array.length; i++) { + let obj = array[i]; + let siblings = [...array]; + //siblings.splice(i, 1); // 현재 메뉴를 제거하여 형제 메뉴들만 남김 + let currentParents = [...parents, { [resultKey]: obj, siblings: siblings }]; + + if (obj[findKey] === findValue) { + let formattedResult = currentParents.reduce((acc, cur, idx) => { + acc[`${resultKey}${idx + 1}`] = { active: cur[resultKey], list: cur.siblings }; + return acc; + }, {}); + return formattedResult; + } + if (obj.children) { + let result = this.findObjectByKeyAndValue({ array: obj.children, find: { key: findKey, value: findValue }, result: { key: resultKey }, parents: currentParents }); + if (result !== null) { + return result; + } + } + } + return null; + }, + autoComma(val, returnVal) { + if (!val && val !== 0) return returnVal ? returnVal : ''; + + // 문자열로 변환하고, 숫자와 소수점만을 허용 + val = String(val).replace(/[^0-9.-]/g, ''); + + // 소수점을 기준으로 숫자를 분리 + let [integerPart, decimalPart] = val.split('.'); + + // 정수 부분에 대해 쉼표 추가 + integerPart = integerPart.replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,'); + + // 소수점 이하 숫자가 있다면 다시 합치기 + return decimalPart ? `${integerPart}.${decimalPart}` : integerPart; + }, + removeComma: function (str) { + str = String(str); + return str.replace(/[^\d]+/g, ''); + }, + removeSpaceAndTrim: function (str) { + if (str) { + str = String(str); + return str.replace(/\s/g, ''); + } else { + return str; + } + }, + isNotWhiteSpace: function (str) { + const hasWhitespace = /\s/.test(str); + return !hasWhitespace; + }, + //숫자만 남기고 모두 제거해서 반환 + retainOnlyNumbers: function (str) { + str = String(str); + return str.replace(/\D+/g, ''); + }, + proxyToObject: function (proxy) { + return JSON.parse(JSON.stringify(Object.assign({}, proxy))); + }, + dateFormatter: function ({ input, isHan = false }) { + const value = this.retainOnlyNumbers(input); + + if (value.length === 6) { + const date = moment(value, 'YYYYMM'); + return date.format(isHan ? 'YYYY년 MM월' : 'YYYY-MM'); + } else if (value.length === 8) { + const date = moment(value, 'YYYYMMDD'); + return date.format(isHan ? 'YYYY년 MM월 DD일' : 'YYYY-MM-DD'); + } else if (value.length === 14) { + const date = moment(value, 'YYYYMMDDHHmmss'); + return date.format(isHan ? 'YYYY년 MM월 DD일 HH시 mm분 ss초' : 'YYYY-MM-DD HH:MM:SS'); + } else { + return input; + } + }, + generateYearArray: function (num) { + const currentYear = moment().year(); + if (num <= 1 || isNaN(num)) { + return [(currentYear - 2).toString(), (currentYear - 1).toString(), currentYear.toString()]; + } + + const years = []; + for (let i = num - 1; i >= 0; i--) { + years.push((currentYear - i).toString()); + } + + return years; + }, + getFileId: function () { + let timeStamp = moment().format('YYYYMMDDHHmmssSSS'); // YYYYMMDDHHmmssSSS 형태로 타임스탬프를 생성 + return 'CFM_' + timeStamp.slice(-12); // slice(-12)는 문자열의 끝에서 12자리를 추출합니다. + }, + lpad: function (number, pad, length) { + let str = number.toString(); + while (str.length < length) { + str = pad + str; + } + return str; + }, + convertKeysToCamelCase: function (obj) { + if (obj === null || typeof obj !== 'object') { + return obj; + } + return Object.keys(obj).reduce((acc, key) => { + const camelCaseKey = key.replace(/([-_]\w)/g, match => match.replace(/[-_]/, '').toUpperCase()); + acc[camelCaseKey] = typeof obj[key] === 'object' ? this.convertKeysToCamelCase(obj[key]) : obj[key]; + return acc; + }, {}); + }, + blobUrlToBlob: async blobUrl => { + try { + const response = await fetch(blobUrl); + return await response.blob(); + } catch (error) { + console.error(error); + return new Blob(); + } + }, + hexToRgba: hex => { + const r = parseInt(hex.substring(1, 3), 16); + const g = parseInt(hex.substring(3, 5), 16); + const b = parseInt(hex.substring(5, 7), 16); + return `rgba(${r}, ${g}, ${b} , 0.5)`; + }, + rgbaToHex: rgba => { + /*rgba값을 가져와서 rgb 로 추출 후 hex 값 반환 */ + if (!rgba) return false; + const rgbArray = rgba.match(/\d+/g).slice(0, 3).map(Number); + return `#${((1 << 24) | (rgbArray[0] << 16) | (rgbArray[1] << 8) | rgbArray[2]).toString(16).slice(1).toUpperCase()}`; + }, + rgbAddOpacity: (rgb, opacity) => { + const rgbValues = rgb.match(/\d+/g); // rgb 값을 배열로 추출 + const colorWithOpacity = `rgba(${rgbValues[0]}, ${rgbValues[1]}, ${rgbValues[2]}, ${opacity})`; // rgba로 변환 + return colorWithOpacity; + }, + generateMonthArray: (pSmonth, pEmonth) => { + let startYear = Math.floor(pSmonth / 100); + let startMonth = pSmonth % 100; + let endYear = Math.floor(pEmonth / 100); + let endMonth = pEmonth % 100; + + let months = []; + + for (let year = startYear; year <= endYear; year++) { + let monthStart = year === startYear ? startMonth : 1; + let monthEnd = year === endYear ? endMonth : 12; + + for (let month = monthStart; month <= monthEnd; month++) { + let monthString = month < 10 ? `0${month}` : `${month}`; + months.push(`${year}${monthString}`); + } + } + + return months; + }, + findDuplicatesForArr: (arr, key) => { + const map = new Map(); + const duplicates = []; + + arr.forEach(item => { + const keyValue = item[key]; + if (map.has(keyValue)) { + if (!duplicates.includes(keyValue)) { + duplicates.push(keyValue); + } + } else { + map.set(keyValue, true); + } + }); + + return duplicates; + }, + createHtmlFromJson: (html, data) => { + if (!html || util.isEmptyObject(html)) return null; + const { classNm, headerKey, rows } = JSON.parse(html); + if (!rows) return null; + const tableRows = rows + .map(row => { + const value = data?.[row.key] ?? null; + let displayValue; + if (typeof value === 'number') { + displayValue = util.autoComma(value, 0); + } else if (typeof value === 'string') { + displayValue = util.nvl(value, '-'); + } else { + displayValue = '-'; + } + return ` + <tr> + <th>${row.th}</th> + <td>${displayValue}</td> + </tr> + `; + }) + .join(''); + return ` + <div class="${classNm}"> + <table> + <tr> + <th colspan="2" style="position: relative; text-align: center; background-color: #DEEAF3;"> + ${data?.[headerKey] ?? '-'} + </th> + </tr> + ${tableRows} + </table> + </div> + `; + }, +}; + +/** + * + * 다이렉트로 import하여 사용하기 위해 export로 수정중 + * + * */ + +//객체 배열에서 특정 key의 value인 것을 추출 +export const getArrayByObjectKey = function (arr, key, value) { + return arr.filter(item => item[key] === value); +}; + +//소수점 1자리를 기준으로 점을 언더스코어로 변경 +export const numberDotToUnderscore = function (num) { + if (Number.isInteger(num)) { + return num.toString(); + } else { + return num.toFixed(1).replace('.', '_'); + } +}; + +export const stripHTML = function (htmlString) { + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlString, 'text/html'); + return doc.body.textContent || ''; +}; + +//URL에서 매개변수 추출 +export function getParameterByName(name, url = window.location.search) { + name = name.replace(/[\[\]]/g, '\\$&'); + var regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'), + results = regex.exec(url); + if (!results) return null; + if (!results[2]) return ''; + return decodeURIComponent(results[2].replace(/\+/g, ' ')); +} + +export function dateFormatForDB({ date: isoString, format = 'YYYY-MM-DD HH:MI:SS' }) { + if (!isoString) return ''; + + let date = new Date(isoString); + + let year = date.getFullYear(); + let month = (date.getMonth() + 1).toString().padStart(2, '0'); + let day = date.getDate().toString().padStart(2, '0'); + + let hours = date.getHours().toString().padStart(2, '0'); + let minutes = date.getMinutes().toString().padStart(2, '0'); + let seconds = date.getSeconds().toString().padStart(2, '0'); + + return ( + format + // + .replace('YYYY', year) + .replace('MM', month) + .replace('DD', day) + .replace('HH', hours) + .replace('MI', minutes) + .replace('SS', seconds) + ); +} + +//blob 다운로드 +//blob 다운로드 +export function downloadFromBlob(res, data) { + const url = URL.createObjectURL(new Blob([res.data], { type: res.data.type })); + const link = document.createElement('a'); + + const contentDisposition = res.headers?.['content-disposition']; // 파일 이름 + let fileName = data?.fileName || 'export'; //기본 파일 이름 + + if (contentDisposition) { + const [fileNameMatch] = contentDisposition.split(';').filter(str => str.includes('filename')); + if (fileNameMatch) { + [, fileName] = fileNameMatch.split('='); + // 파일명이 "UTF-8''" 등으로 인코딩되어 있을 수 있으니 decodeURIComponent 추가 + fileName = decodeURIComponent(fileName); + } + } + + link.href = url; + // 브라우저 인코딩 관련 이슈 방지를 위해 파일명에 대한 추가 decodeURI 처리 + link.download = decodeURIComponent(fileName).replace(/"/g, '').trim(); + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + + link.remove(); + URL.revokeObjectURL(url); +} + +export function downloadBlobAsFile(blob, filename = 'export') { + // 브라우저에 따라 인코딩 처리 햔재는 파이어폭스만 + const url = URL.createObjectURL(blob); + const encodedFilename = encodeURIComponent(filename); + const downloadLink = document.createElement('a'); + + downloadLink.href = url; + downloadLink.download = decodeURIComponent(encodedFilename); + + document.body.appendChild(downloadLink); + downloadLink.click(); + + document.body.removeChild(downloadLink); + URL.revokeObjectURL(url); +} + +export function viewFile(res, data) { + // Blob URL 생성 + const url = URL.createObjectURL(new Blob([res.data], { type: res.data.type })); + + let fileType = null; + let fileName = data?.fileName || 'export'; // 기본 파일 이름 + + // PDF 및 이미지 타입의 MIME 타입 확인 + if (res.data.type === 'application/pdf') { + fileType = 'pdf'; + } else if (res.data.type.startsWith('image/')) { + fileType = 'img'; + } else if (res.data.type === 'application/octet-stream') { + // Content-Disposition 헤더에서 파일 이름 추출 + const contentDisposition = res.headers?.['content-disposition']; + if (contentDisposition) { + const fileNameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); + if (fileNameMatch && fileNameMatch[1]) { + fileName = fileNameMatch[1].replace(/['"]/g, ''); // 파일 이름 추출 + fileName = decodeURIComponent(fileName); // URL 인코딩된 파일 이름을 디코딩 + } + } + + // 파일 확장자 추출 + const fileExtension = fileName.split('.').pop().toLowerCase(); + + // 확장자를 통해 파일 타입 결정 + if (fileExtension === 'pdf') { + fileType = 'pdf'; + } else if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp'].includes(fileExtension)) { + fileType = 'img'; + } else { + console.error('지원하지 않는 파일 형식입니다.'); + } + } + + // URL과 파일 타입 반환 + return { url, fileType, fileName }; +} + +export function getToday() { + return getDate('today'); +} + +export function getDate(type, diff) { + const date = new Date(); + + switch (type) { + case 'year': + date.setFullYear(date.getFullYear() + diff); + break; + case 'month': + date.setMonth(date.getMonth() + diff); + break; + case 'day': + date.setDate(date.getDate() + diff); + break; + case 'today': + date.setDate(date.getDate()); + break; + default: + console.log('잘못된 타입입니다. 올바른 타입: 년, 월, 일'); + return; + } + + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + + return `${year}${month}${day}`; +} + +//promise 처리 +export const byPromise = function (actions) { + return actions.reduce((promise, action) => { + return promise.then(async () => { + if (typeof action === 'function') { + return await action(); + } else { + return await action; + } + }); + }, Promise.resolve()); +}; + +export const getMessage = (msgGroup, msgKey, innerText, fix) => { + let resultString = GLOBAL.MESSAGE[msgGroup][msgKey]; + const prefix = fix?.prefix || ''; + const suffix = fix?.suffix || ''; + + if (innerText) { + for (const [key, value] of Object.entries(innerText)) { + const regex = new RegExp(`\\$\{${key}\}`, 'g'); + resultString = resultString.replace(regex, value); + } + } + + if (resultString) resultString = resultString.replace(/\\n/g, '\n'); + return resultString ? `${prefix}${resultString}${suffix}` : '안내 메시지 로딩 오류 - 관리자에게 문의하세요.'; +}; + +// global.js의 message를 읽어오는 alert +export const gAlert = (msgGroup, msgKey, innerText, fix) => { + alert(getMessage(msgGroup, msgKey, innerText, fix)); +}; + +// message를 confirm +export const gConfirm = (msgGroup, msgKey, innerText, fix) => { + return window.confirm(getMessage(msgGroup, msgKey, innerText, fix)); +}; + +// message를 함수 리턴 +export const gMessage = getMessage; + +// message를 save후 응답까지 처리 +export const gSaveCommonConfirm = async ({ message, api = async () => {}, param = {}, success, fail, prefix }) => { + try { + if (!gConfirm('CONFIRM', `${message}_QUEST`, null, { prefix })) return; + + const result = await api(param, { isAlert: false }); + + if (result?.code !== 200) return; + + const rep = message === 'UPLOAD' ? result.data.data : result.data; + + if (rep > 0) { + gAlert('INFO', `${message}_COMPLETE`); + await success?.(); + } else { + gAlert('ERROR', `${message}_FAIL`); + await fail?.(); + } + } catch (error) { + console.error('오류 발생:', error); // 시큐어코딩 관련 수정 : 오류 정보를 로그로 출력 + } +}; + +export const nullToValue = (text, { value = '-', prefix = '', suffix = '' } = {}) => { + if (!text || text === value) { + return value; + } + return `${prefix}${text}${suffix}`; +}; + +export const filterArrayOfObjectsByKeys = (arrayOfObjects, keys) => { + return arrayOfObjects.map(obj => { + return keys.reduce((filteredObj, key) => { + if (obj.hasOwnProperty(key)) { + filteredObj[key] = obj[key]; + } + return filteredObj; + }, {}); + }); +}; + +export const isMobile = () => { + const width = window.innerWidth > 0 ? window.innerWidth : window.screen.width; + + return width < GLOBAL.MOBILE_SIZE; +}; diff --git a/packages/common/business_components/src/ui/search_base_form/index.tsx b/packages/common/business_components/src/ui/search_base_form/index.tsx new file mode 100644 index 000000000..b8d8355ae --- /dev/null +++ b/packages/common/business_components/src/ui/search_base_form/index.tsx @@ -0,0 +1,144 @@ +"use client" + +import { Button } from "@common/components/ui"; +import { useCallback, useEffect, useState } from "react"; +import { SearchFormLeft, type EnvPartSelectDataType } from "../search_form_left"; +import { SearchFormRight } from "../search_form_right"; +import type { SelectDataType } from "../search_input_select"; +type SearchBaseType = { + leftChildren:React.ReactNode; + rightChildren:React.ReactNode; + selectSidoData?: SelectDataType[] | []; + selectSigunData?: SelectDataType[] | []; + selectRegionData?: SelectDataType[] | []; + selectUnitWaterShedData?: SelectDataType[] | []; + selectMiddleRegionData?: SelectDataType[] | []; + selectEnvPartData?: EnvPartSelectDataType[] | []; + queryClick:(returnType:ReturnType) => void; + excelName?:string; + excelClick:(returnType:ReturnType) => void; + initClick:() => void; + btnClass:string; + ref:React.RefObject<ReturnType>; +} +export type ReturnType = { + partValue: string; + sidoValue: string; + sigunValue: string; + regionValue: string; + unitWaterShedValue: string; + middleRegionValue: string; + envPartValue: string; +} + + +function SearchBaseForm ({ + leftChildren, + rightChildren, + selectSidoData, + selectSigunData, + selectRegionData, + selectUnitWaterShedData, + selectMiddleRegionData, + selectEnvPartData, + btnClass, + excelName, + queryClick, + excelClick, + initClick, + ref + }: SearchBaseType) { + const [partValue, setPartValue] = useState<string>("A"); + const [sidoValue, setSidoValue] = useState<string>("0"); + const [sigunValue, setSigunValue] = useState<string>("0"); + const [regionValue, setRegionValue] = useState<string>("0"); + const [untitWaterShedValue, setUntitWaterShedValue] = useState<string>("0"); + const [middleRegionValue, setMiddleRegionValue] = useState<string>("0"); + const [envPartValue, setEnvPartValue] = useState<string>("0"); + const [resetTrigger, setResetTrigger] = useState(false); + const queryVal = () => { + console.log("조회버튼 클릭"); + queryClick({ + partValue: partValue, + sidoValue: sidoValue, + sigunValue: sigunValue, + regionValue: regionValue, + unitWaterShedValue: untitWaterShedValue, + middleRegionValue: middleRegionValue, + envPartValue: envPartValue, + }); + } + const initVal = useCallback(() => { + setResetTrigger(true); + initClick(); + }, [setResetTrigger, initClick]); + useEffect(() => { + if (resetTrigger) { + setResetTrigger(false); + } + }, [resetTrigger]); + const excelDownload = () => { + excelClick({ + partValue: partValue, + sidoValue: sidoValue, + sigunValue: sigunValue, + regionValue: regionValue, + unitWaterShedValue: untitWaterShedValue, + middleRegionValue: middleRegionValue, + envPartValue: envPartValue, + }) + }; + useEffect(() => { + if (ref && ref.current) { + const customRef = ref.current; + customRef.partValue = partValue; + customRef.sidoValue = sidoValue; + customRef.sigunValue = sigunValue; + customRef.regionValue = regionValue; + customRef.unitWaterShedValue = untitWaterShedValue; + customRef.middleRegionValue = middleRegionValue; + customRef.envPartValue = envPartValue; + } + }, [partValue, sidoValue, sigunValue, regionValue, untitWaterShedValue, middleRegionValue, envPartValue, ref]); + return ( + <div className="flex card card-border !mt-6"> + <SearchFormLeft isShowPart={true} + selectSidoData={selectSidoData} + selectSigunData={selectSigunData} + selectRegionData={selectRegionData} + selectUnitWaterShedData={selectUnitWaterShedData} + selectMiddleRegionData={selectMiddleRegionData} + selectEnvPartData={selectEnvPartData} + setRtnPartValue={setPartValue} + setRtnSidoValue={setSidoValue} + setRtnSigunValue={setSigunValue} + setRtnRegionValue={setRegionValue} + setRtnUnitWaterShedValue={setUntitWaterShedValue} + setRtnMiddleRegionValue={setMiddleRegionValue} + setRtnEnvPartValue={setEnvPartValue} + setRtnEnvPartSidoValue={setSidoValue} + setRtnEnvPartSigunValue={setSigunValue} + resetTrigger={resetTrigger} + > + {leftChildren} + </SearchFormLeft> + <SearchFormRight> + <Button className={btnClass} color="green" onClick={excelDownload}> + {excelName?excelName:"엑셀다운로드"} + </Button> + <div> + <Button className={btnClass} onClick={initVal}> + 초기화 + </Button> + <Button className={btnClass} onClick={queryVal}> + 조회 + </Button> + </div> + {rightChildren} + </SearchFormRight> + </div> + ) + } + + export { SearchBaseForm }; + \ No newline at end of file diff --git a/packages/common/business_components/src/ui/search_form/index.tsx b/packages/common/business_components/src/ui/search_form/index.tsx index 7c6024748..860c99c69 100644 --- a/packages/common/business_components/src/ui/search_form/index.tsx +++ b/packages/common/business_components/src/ui/search_form/index.tsx @@ -1,9 +1,9 @@ function SearchDiv ({children}:{ children: React.ReactNode; }) { return ( - <div className="flex border border-black m-4 rounded-lg min-h-[80px]"> - {children} + <div className="flex card card-border !mt-6"> + {children} </div> ) } - + export { SearchDiv }; diff --git a/packages/common/business_components/src/ui/search_form_left/index.tsx b/packages/common/business_components/src/ui/search_form_left/index.tsx new file mode 100644 index 000000000..02112f084 --- /dev/null +++ b/packages/common/business_components/src/ui/search_form_left/index.tsx @@ -0,0 +1,317 @@ +"use client" + +import React, { useEffect, useState } from 'react'; +import type { SelectDataType } from '../search_input_select'; +import { SelectBox } from '../select'; + +/** + * 검색 폼 왼쪽 컴포넌트 타입 + * + * @param isShowPart 구분 조회 여부 + * @param children 자식 컴포넌트 + * @param selectSidoData 시도 데이터 + * @param selectSigunData 시군구 데이터 + * @param selectRegionData 유역 데이터 + * @param selectUnitWaterShedData 단위유역 데이터 + * @param selectMiddleRegionData 중권역 데이터 + * @param selectEnvPartData 환경청 데이터 + * @param selectEnvPartSidoData 환경청 시도 데이터 + * @param selectEnvPartSigunData 환경청 시군구 데이터 + * @param setRtnPartValue 부분 값 변경 핸들러 + */ +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: string) => void; + resetTrigger?: boolean; +} + +export type EnvPartSelectDataType = SelectDataType & { + adstDliStrCd: string, +} + +const selectPartData:SelectDataType[] = [ + { text: "행정별", val: 'A' }, + { text: "유역별", val: 'R' }, + { text: "환경청별", val: 'G' }, +]; + +function SearchFormLeft({ isShowPart = false, children, selectSidoData = [], selectSigunData = [], selectRegionData = [], selectUnitWaterShedData = [], selectMiddleRegionData = [], selectEnvPartData = [], setRtnPartValue, setRtnSidoValue, setRtnSigunValue, setRtnRegionValue, setRtnUnitWaterShedValue, setRtnMiddleRegionValue, setRtnEnvPartValue, setRtnEnvPartSidoValue, setRtnEnvPartSigunValue, resetTrigger = false }: SearchFormLeftProps) { + /******************선택된 데이터******************/ + const [partValue, setPartValue] = useState<string>('A'); + //행정별 시도 시군구 + const [sidoValue, setSidoValue] = useState<string>('0'); + const [sigunValue, setSigunValue] = useState<string>('0'); + //유역별 + const [regionValue, setRegionValue] = useState<string>('0'); + const [unitWaterShedValue, setUnitWaterShedValue] = useState<string>('0'); + const [middleRegionValue, setMiddleRegionValue] = useState<string>('0'); + //환경청별 시도 시군구 + const [envPartValue, setEnvPartValue] = useState<string>('0'); + // 환경청 + const [filteredEnvPartSigunDataSelected, setFilteredEnvPartSigunDataSelected] = useState<SelectDataType[]>([]); + + /****************** 셀렉트 리스트 데이터. ******************/ + // 조회된 조건에 맞는 시도 시군 유역 + const [filteredSigunData, setFilteredSigunData] = useState<SelectDataType[]>([{ text: "전체", val: '0' }]); + const [filteredUnitWaterShedData, setFilteredUnitWaterShedData] = useState<SelectDataType[]>([{ text: "전체", val: '0' }]); + const [filteredMiddleRegionData, setFilteredMiddleRegionData] = useState<SelectDataType[]>([{ text: "전체", val: '0' }]); + // 조회된 조건에 맞는 환경청 시도 시군 + const [filteredEnvPartData, setFilteredEnvPartData] = useState<EnvPartSelectDataType[]>([{ text: "전체", val: '0', adstDliStrCd:'' }]); + const [filteredEnvPartSidoData, setFilteredEnvPartSidoData] = useState<EnvPartSelectDataType[]>([{ text: "전체", val: '0', adstDliStrCd:'' }]); + const [filteredEnvPartSigunData, setFilteredEnvPartSigunData] = useState<SelectDataType[]>([{ text: "전체", val: '0' }]); + + // 시군구 세팅 + const initializeSigunData = (sidoCd: string) => { + if (sidoCd === '0') { + setSigunValue('0'); + setFilteredSigunData([{ text: "전체", val: '0' }]); + } else { + const newFilteredData = [ + { text: "전체", val: '0' }, + ...selectSigunData.filter((item: SelectDataType) => item.val.substring(0, 2) === sidoCd) + ]; + setSigunValue('0'); + setFilteredSigunData(newFilteredData); + } + } + + // 단위유역 세팅 + const initializeUnitWaterShedData = (regionCd: string) => { + if(regionCd === '0') { + setUnitWaterShedValue('0'); + setFilteredUnitWaterShedData([{ text: "전체", val: '0' }]); + } else { + const newFilteredData = [ + { text: "전체", val: '0' }, + ...selectUnitWaterShedData.filter((item: SelectDataType) => item.val.substring(0, 1) === regionCd) + ]; + setUnitWaterShedValue('0'); + setFilteredUnitWaterShedData(newFilteredData); + } + } + + // 중권역 세팅 + const initializeMiddleRegionData = (unitWaterShedCd: string) => { + if(unitWaterShedCd === '0') { + setMiddleRegionValue('0'); + setFilteredMiddleRegionData([{ text: "전체", val: '0' }]); + } else { + const newFilteredData = [ + { text: "전체", val: '0' }, + ...selectMiddleRegionData.filter((item: SelectDataType) => item.val.substring(0, 2) === unitWaterShedCd) + ]; + setMiddleRegionValue('0'); + setFilteredMiddleRegionData(newFilteredData); + } + } + + // 환경청 세팅 - 시도 + const initializeCityDataByEnvPartSido = (envPartCd: string) => { + const foundEnvPart = selectEnvPartData.find((item: EnvPartSelectDataType) => item.val === envPartCd); + const cityArray = foundEnvPart?.adstDliStrCd.toString().split('|').join('').split(','); + + // 시군구 데이터 + const envSigunData = selectSigunData.filter((item: SelectDataType) => cityArray?.includes(item.val)); + + // 환경청 시도 데이터 + const sidoArray = [...new Set(envSigunData.map((item: SelectDataType) => item.val.substring(0, 2)))]; + const envSidoData = selectSidoData.filter((item: SelectDataType) => sidoArray?.includes(item.val)); + setFilteredEnvPartSigunDataSelected(envSigunData); + + setFilteredEnvPartSidoData([ + { text: "전체", val: '0', adstDliStrCd:'' }, + ...envSidoData.map((item: SelectDataType) => ({ text: item.text, val: item.val, adstDliStrCd:'' })) + ]); + } + + // 환경청 세팅 - 시군구 + const initializeCityDataByEnvPartSigun = (sidoCd: string) => { + const envSigunData = filteredEnvPartSigunDataSelected.filter((item: SelectDataType) => item.val.substring(0, 2) === sidoCd); + setFilteredEnvPartSigunData([ + { text: "전체", val: '0'}, + ...envSigunData.map((item: SelectDataType) => ({ text: item.text, val: item.val })) + ]); + } + + const onSelectValue = (val: string, type: string) => { + switch(type) { + case 'part': + setPartValue(val); + if (setRtnPartValue) { + setRtnPartValue(val); + } + // 구분 + if(val == 'R') { + initializeMiddleRegionData('0'); + initializeUnitWaterShedData('0'); + } else if(val == 'G') { + setFilteredEnvPartData([ + { text: "전체", val: '0', adstDliStrCd:'' }, + ...selectEnvPartData.filter((item: EnvPartSelectDataType) => item.val !== null && item.adstDliStrCd !== '') + ]); + setEnvPartValue('0'); + initializeCityDataByEnvPartSido('0'); + } else { + initializeSigunData('0'); + onSelectValue(selectSigunData[0]?.val || '0', 'sigun'); + } + + // 다른 selectbox 전체로 초기화 + onSelectValue(selectSidoData[0]?.val || '0', 'sido'); + onSelectValue(selectSigunData[0]?.val || '0', 'sigun'); + break; + case 'sido': + setSidoValue(val); + if (setRtnSidoValue) { + setRtnSidoValue(val); + } + initializeSigunData(val); + onSelectValue(selectSigunData[0]?.val || '0', 'sigun'); + break; + case 'sigun': + if (setRtnSigunValue) { + setRtnSigunValue(val); + } + setSigunValue(val); + break; + + // 유역 세팅 + case 'region' : + setRegionValue(val); + if (setRtnRegionValue) { + setRtnRegionValue(val); + } + initializeUnitWaterShedData(val); + onSelectValue(selectUnitWaterShedData[0]?.val || '0', 'unitWaterShed'); + break; + case 'unitWaterShed' : + setUnitWaterShedValue(val); + if (setRtnUnitWaterShedValue) { + setRtnUnitWaterShedValue(val); + } + initializeMiddleRegionData(val); + onSelectValue(selectMiddleRegionData[0]?.val || '0', 'middleRegion'); + break; + case 'middleRegion' : + setMiddleRegionValue(val); + if (setRtnMiddleRegionValue) { + setRtnMiddleRegionValue(val); + } + break; + + // 환경청 세팅 + case 'envPart' : + setEnvPartValue(val); + if (setRtnEnvPartValue) { + setRtnEnvPartValue(val); + } + initializeCityDataByEnvPartSido(val); + onSelectValue(filteredEnvPartSidoData[0]?.val || '0', 'envPartSido'); + break; + case 'envPartSido' : + setSidoValue(val); + if (setRtnEnvPartSidoValue) { + setRtnEnvPartSidoValue(val); + } + initializeCityDataByEnvPartSigun(val); + onSelectValue(filteredEnvPartSigunData[0]?.val || '0', 'envPartSigun'); + break; + case 'envPartSigun' : + setSigunValue(val); + if (setRtnEnvPartSigunValue) { + setRtnEnvPartSigunValue(val); + } + break; + } + } + + // 리셋 함수 + const resetAllValues = () => { + setPartValue('A'); + setSidoValue('0'); + setSigunValue('0'); + setRegionValue('0'); + setUnitWaterShedValue('0'); + setMiddleRegionValue('0'); + setEnvPartValue('0'); + + setFilteredSigunData([{ text: "전체", val: '0' }]); + setFilteredUnitWaterShedData([{ text: "전체", val: '0' }]); + setFilteredMiddleRegionData([{ text: "전체", val: '0' }]); + setFilteredEnvPartData([{ text: "전체", val: '0', adstDliStrCd: '' }]); + setFilteredEnvPartSidoData([{ text: "전체", val: '0', adstDliStrCd: '' }]); + setFilteredEnvPartSigunData([{ text: "전체", val: '0' }]); + + if (setRtnPartValue) setRtnPartValue('A'); + if (setRtnSidoValue) setRtnSidoValue('0'); + if (setRtnSigunValue) setRtnSigunValue('0'); + if (setRtnRegionValue) setRtnRegionValue('0'); + if (setRtnUnitWaterShedValue) setRtnUnitWaterShedValue('0'); + if (setRtnMiddleRegionValue) setRtnMiddleRegionValue('0'); + if (setRtnEnvPartValue) setRtnEnvPartValue('0'); + if (setRtnEnvPartSidoValue) setRtnEnvPartSidoValue('0'); + if (setRtnEnvPartSigunValue) setRtnEnvPartSigunValue('0'); + }; + + // 리셋 트리거 변경 감지 + useEffect(() => { + if (resetTrigger) { + resetAllValues(); + // 리셋 후 트리거를 false로 설정 + if (setRtnPartValue) { + setRtnPartValue('0'); + } + } + }, [resetTrigger]); + + return ( + isShowPart ? ( + <div className="grid grid-cols-12 gap-5 w-11/12 m-4"> + <SelectBox className="col-span-3" label={'구분'} selectArray={selectPartData} onSelectValue={(val) => onSelectValue(val.toString(), 'part')} value={partValue} selectClass={undefined}/> + {partValue === 'R' ? ( + <> + <SelectBox className="col-span-3" label={'유역'} selectArray={selectRegionData} onSelectValue={(val) => onSelectValue(val.toString(), 'region')} value={regionValue} selectClass={undefined}/> + <SelectBox className="col-span-3" label={'단위유역'} selectArray={filteredUnitWaterShedData} onSelectValue={(val) => onSelectValue(val.toString(), 'unitWaterShed')} value={unitWaterShedValue} selectClass={undefined}/> + <SelectBox className="col-span-3" label={'중권역'} selectArray={filteredMiddleRegionData} onSelectValue={(val) => onSelectValue(val.toString(), 'middleRegion')} value={middleRegionValue} selectClass={undefined}/> + </> + ) : partValue === 'G' ? ( + <> + <SelectBox className="col-span-3" label={'환경청'} selectArray={filteredEnvPartData} onSelectValue={(val) => onSelectValue(val.toString(), 'envPart')} value={envPartValue} selectClass={undefined}/> + <SelectBox className="col-span-3" label={'시도'} selectArray={filteredEnvPartSidoData} onSelectValue={(val) => onSelectValue(val.toString(), 'envPartSido')} value={sidoValue} selectClass={undefined}/> + <SelectBox className="col-span-3" label={'시군구'} selectArray={filteredEnvPartSigunData} onSelectValue={(val) => onSelectValue(val.toString(), 'envPartSigun')} value={sigunValue} selectClass={undefined}/> + </> + ) : ( + <> + <SelectBox className="col-span-3" label={'시도'} selectArray={selectSidoData} onSelectValue={(val) => onSelectValue(val.toString(), 'sido')} value={sidoValue} selectClass={undefined}/> + <SelectBox className="col-span-3" label={'시군구'} selectArray={filteredSigunData} onSelectValue={(val) => onSelectValue(val.toString(), 'sigun')} value={sigunValue} selectClass={undefined}/> + </> + )} + {children} + </div> + ) : ( + <div className="grid grid-cols-12 gap-5 w-11/12 m-4"> + {children} + </div> + ) + ) +} + +export { SearchFormLeft }; diff --git a/packages/common/business_components/src/ui/search_form_left/searchFormLeft.md b/packages/common/business_components/src/ui/search_form_left/searchFormLeft.md new file mode 100644 index 000000000..04531c27f --- /dev/null +++ b/packages/common/business_components/src/ui/search_form_left/searchFormLeft.md @@ -0,0 +1,375 @@ +## SearchFormLeft 모듈 +#### 검색영역 좌측 모듈로 행정별, 유역별, 환경청을 선택했을 때 검색 영역을 세팅해주고 childnode를 붙여서 출력해주는 모듈 +#### 1. 컴포넌트 호출방법 +```js +import { SearchFormLeft } from "@common/business_components"; + +..... + +// state 지정 +const [partValue, setPartValue] = useState<string>("0"); +const [sidoValue, setSidoValue] = useState<string>("0"); +const [sigunValue, setSigunValue] = useState<string>("0"); +const [regionValue, setRegionValue] = useState<string>("0"); +const [untitWaterShedValue, setUntitWaterShedValue] = useState<string>("0"); +const [middleRegionValue, setMiddleRegionValue] = useState<string>("0"); +const [envPartValue, setEnvPartValue] = useState<string>("0"); +const [envPartSidoValue, setEnvPartSidoValue] = useState<string>("0"); +const [envPartSigunValue, setEnvPartSigunValue] = useState<string>("0"); +const [resetTrigger, setResetTrigger] = useState<boolean>(false); +..... + +<SearchFormLeft isShowPart={true} + selectSidoData={selectSidoData} + selectSigunData={selectSigunData} + selectRegionData={selectRegionData} + selectUnitWaterShedData={selectUnitWaterShedData} + selectMiddleRegionData={selectMiddleRegionData} + selectEnvPartData={selectEnvPartData} + setRtnPartValue={setPartValue} + setRtnSidoValue={setSidoValue} + setRtnSigunValue={setSigunValue} + setRtnRegionValue={setRegionValue} + setRtnUnitWaterShedValue={setUntitWaterShedValue} + setRtnMiddleRegionValue={setMiddleRegionValue} + setRtnEnvPartValue={setEnvPartValue} + setRtnEnvPartSidoValue={setEnvPartSidoValue} + setRtnEnvPartSigunValue={setEnvPartSigunValue} + resetTrigger={resetTrigger} +> + +``` +  ㆍisShowPart : 검색단에서 구분(행정별,유역별,환경청별) 세팅 여부(보일 경우 : Y, 아닐경우 : N 혹은 미첨부) + +  ㆍselectSidoData : 전체 시도 데이터 + +  ㆍselectSigunData : 전체 시군구구 데이터 + +  ㆍselectRegionData : 전체 유역역 데이터 + +  ㆍselectUnitWaterShedData : 전체 단위유역 데이터 + +  ㆍselectMiddleRegionData : 전체 중권역 데이터 + +  ㆍselectEnvPartData : 환경청 전체 데이터 + +  ㆍsetRtnPartValue : 선택한 구분 set함수(조회시 페이지에서 사용) + +  ㆍsetRtnSidoValue : 선택한 시도 set함수(조회시 페이지에서 사용) + +  ㆍsetRtnSigunValue : 선택한 시군구 set함수(조회시 페이지에서 사용) + +  ㆍsetRtnRegionValue : 선택한 유역 set함수(조회시 페이지에서 사용) + +  ㆍsetRtnUnitWaterShedValue : 선택한 단위유역 set함수(조회시 페이지에서 사용) + +  ㆍsetRtnMiddleRegionValue : 선택한 중유역 set함수(조회시 페이지에서 사용) + +  ㆍsetRtnEnvPartValue : 선택한 환경청 set함수(조회시 페이지에서 사용) + +  ㆍsetRtnEnvPartSidoValue : 선택한 환경청의 시도 set함수(조회시 페이지에서 사용) + +  ㆍsetRtnEnvPartSigunValue : 선택한 환경청 시도의 시군구 set함수(조회시 페이지에서 사용) + +  ㆍresetTrigger : 초기화 trigger 변수 + + + +#### 2. 세팅방법 +```js +/** + * store - index.js + * ※ 업무파트별로 common/폴더에 스토어를 만들어놓는걸 추천! + * ※ fetch 구문은 각자가 개발 후 return 된 데이터 타입만 맞추면 된다. + */ + +// 시도 API 호출 함수 추가 +const fetchSidoData = async (): Promise<SelectDataType[]> => { + try { + const res = await getFetch('fo', '/addr/city', null); + const data = res.data; + selectSidoData = [ + { text: "전체", val: '0'}, + ...data.map((item: sidoDataType) => ({ + text: item.sido || '', + val: String(item.adstDstrCd) || '' + })) + ]; + return selectSidoData; + } catch (error) { + console.error('시도 데이터를 가져오는데 실패했습니다:', error); + return selectSidoData; + } +}; + +// 시군구 API 호출 함수 추가 +const fetchSigunData = async (): Promise<SelectDataType[]> => { + try { + const res = await getFetch('fo', '/addr/sgg', null); + const data = res.data; + selectSigunData = [ + { text: "전체", val: '0' }, + ...data + .map((item: sigunDataType) => ({ + text: item.gugun || '', + val: String(item.adstDstrCd) || '' + })) + ]; + return selectSigunData; + } catch (error) { + console.error('시군구 데이터를 가져오는데 실패했습니다:', error); + return selectSigunData; + } +}; + +// 권역 API 호출 함수 추가 +const fetchRegionData = async (): Promise<SelectDataType[]> => { + try { + const res = await getFetch('fo', '/addr/sarea', null); + const data = res.data; + return [ + { text: "전체", val: '0'}, + ...data.map((item: regionDataType) => ({ + text: item.rvbsn || '', + val: String(item.rvbsnCd) || '' + })) + ]; + } catch (error) { + console.error('권역 데이터를 가져오는데 실패했습니다:', error); + return selectRegionData; + } +}; + +// 중권역 API 호출 함수 추가 +const fetchMiddleRegionData = async (): Promise<SelectDataType[]> => { + try { + const res = await getFetch('fo', '/addr/midArea', null); + const data = res.data; + return [ + { text: "전체", val: '0' }, + ...data + .map((item: middleRegionDataType) => ({ + text: item.mdmbsn || '', + val: String(item.rvbsnCd) || '' + })) + ]; + } catch (error) { + console.error('중권역 데이터를 가져오는데 실패했습니다:', error); + return selectMiddleRegionData; + } +}; + +// 단위유역 API 호출 함수 추가 +const fetchUnitWaterShedData = async (): Promise<SelectDataType[]> => { + try { + const res = await getFetch('fo', '/addr/largeArea', null); + const data = res.data; + return [ + { text: "전체", val: '0' }, + ...data + .map((item: unitWaterShedDataType) => ({ + text: item.lgbsn || '', + val: String(item.rvbsnCd) || '' + })) + ]; + } catch (error) { + console.error('단위유역 데이터를 가져오는데 실패했습니다:', error); + return selectUnitWaterShedData; + } +}; + +// 환경청 데이터 +const fetchEnvPartData = async (): Promise<envPartSelectDataType[]> => { + try { + const res = await getFetch('fo', '/addr/govArea', null); + const data = res.data; + selectEnvPartData = [ + { text: "전체", val: '0', adstDliStrCd: '' }, + ...data + .map((item: envPartDataType) => ({ + text: item.lclgvrnmntNm || '', + val: String(item.lclgvrnmntCd) || '', + adstDliStrCd: item.adstDliStrCd || '' + })) + ]; + return selectEnvPartData; + } catch (error) { + console.error('환경청 데이터를 가져오는데 실패했습니다:', error); + return selectEnvPartData; + } +}; + +....... + +// 타입 및 state 반환 +export type CommonType = { + Common: { + isInit: boolean; + selectSidoData: SelectDataType[]; + selectSigunData: SelectDataType[]; + selectPartData: SelectDataType[]; + selectUpdownData: SelectDataType[]; + selectOperationData: SelectDataType[]; + selectFacilityPartData: SelectDataType[]; + selectSearchYear: SelectDataType[]; + selectUnitWaterShedData: SelectDataType[]; + selectRegionData: SelectDataType[]; + selectMiddleRegionData: SelectDataType[]; + selectEnvPartSidoData: SelectDataType[]; + selectEnvPartSigunData: SelectDataType[]; + selectFlowTerm: SelectDataType[]; + selectFlowPanel: SelectDataType[]; + selectEnvPartData: envPartSelectDataType[]; + } +} + +export const commonState:CommonType = { + Common: { + isInit: false, + selectSidoData: selectSidoData, + selectSigunData: selectSigunData, + selectPartData: selectPartData, + selectUpdownData: selectUpdownData, + selectOperationData: selectOperationData, + selectFacilityPartData: selectFacilityPartData, + selectSearchYear: selectSearchYear, + selectRegionData: selectRegionData, + selectUnitWaterShedData: selectUnitWaterShedData, + selectMiddleRegionData: selectMiddleRegionData, + selectEnvPartSidoData : selectEnvPartSidoData, + selectEnvPartSigunData : selectEnvPartSigunData, + selectFlowTerm: selectFlowTerm, + selectFlowPanel: selectFlowPanel, + selectEnvPartData : selectEnvPartData, + } +} + +..... + +// action export +export type CommonAction = { + initializeCommonData: () => Promise<void>; +} + +....... + +// reducer 세팅 +export const CommonReducer:(set:any)=>CommonAction=(set: any) => { + return { + // 시도 변경 이벤트 + initializeCommonData: async () => { + const promiseSet = await Promise.all([fetchSidoData(), fetchSigunData(), fetchRegionData(), fetchUnitWaterShedData(), fetchMiddleRegionData(), fetchEnvPartData()]); + const [sidoData, sggData, regionData, unitData, midData, envData] = promiseSet; + set((state: CommonType) => ({ + Common: { + ...state.Common, + selectSidoData: sidoData, + selectSigunData: sggData, + selectRegionData: regionData, + selectMiddleRegionData: midData, + selectUnitWaterShedData: unitData, + selectEnvPartData: envData + } + }) + )}, + } +} +``` +```js +/** + * page.tsx + */ + +....... + +// store에서 액션 호출 (각 페이지 유형별로 세팅) +const { + .... + ,initializeCommonData + .... +} = useFoStore((state) => state); + +....... + +// 데이터 반환 +const { + isInit, + selectPartData, + selectUpdownData, + selectOperationData, + selectFacilityPartData, + selectSidoData, + selectSigunData, + selectRegionData, + selectUnitWaterShedData, + selectMiddleRegionData, + selectEnvPartData, +} = Common; + +....... + +// 페이지 내에서 사용할 수 있게 state 설정 +const [partValue, setPartValue] = useState<string>("0"); +const [sidoValue, setSidoValue] = useState<string>("0"); +const [sigunValue, setSigunValue] = useState<string>("0"); +const [regionValue, setRegionValue] = useState<string>("0"); +const [untitWaterShedValue, setUntitWaterShedValue] = useState<string>("0"); +const [middleRegionValue, setMiddleRegionValue] = useState<string>("0"); +const [envPartValue, setEnvPartValue] = useState<string>("0"); +const [envPartSidoValue, setEnvPartSidoValue] = useState<string>("0"); +const [envPartSigunValue, setEnvPartSigunValue] = useState<string>("0"); +const [resetTrigger, setResetTrigger] = useState<boolean>(false); + +<!-- 초기화 기능 --> +const resetVal = () => { + console.log('=== 초기화 ==='); + console.log(selectSidoData); + + // SelectBox 초기화 - 각 selectArray의 첫 번째 옵션 값 사용 + const initialPartValue = selectPartData[0]?.val || "0"; + const initialSidoValue = selectSidoData[0]?.val || "0"; + const initialSigunValue = selectSigunData[0]?.val || "0"; + const initialRegionValue = selectRegionData[0]?.val || "0"; + const initialUnitWaterShedValue = selectUnitWaterShedData[0]?.val || "0"; + const initialMiddleRegionValue = selectMiddleRegionData[0]?.val || "0"; + const initialEnvPartValue = selectEnvPartData[0]?.val || "0"; + const initialEnvPartSidoValue = selectEnvPartData[0]?.val || "0"; + const initialEnvPartSigunValue = selectEnvPartData[0]?.val || "0"; + + // 상태값 설정 + setPartValue(initialPartValue); + setSidoValue(initialSidoValue); + setSigunValue(initialSigunValue); + setRegionValue(initialRegionValue); + setUntitWaterShedValue(initialUnitWaterShedValue); + setMiddleRegionValue(initialMiddleRegionValue); + setEnvPartValue(initialEnvPartValue); + setEnvPartSidoValue(initialEnvPartSidoValue); + setEnvPartSigunValue(initialEnvPartSigunValue); + + // 리셋 후 트리거를 false로 설정 + setTimeout(() => { + setResetTrigger(false); + }, 0); + } + +// 조회 함수 +const chkVal = useCallback(() => { + setResetPage(true); + // 조회 funciton + searchFcltyQltyList(1); +}, [searchFcltyQltyList]); + +// 그리드 page 넘어갈 때 필요한 함수 +const handlePageChange = useCallback((page: number) => { + setResetPage(false); + searchFcltyQltyList(page); +}, [searchFcltyQltyList]); + +// 테이블 호출 컴포넌트 +<UiTable cellClick={(index: number | undefined) => index !== undefined && cellClick(index)} headName={"시설별 수질현황"} tableData={mainList} headlist={upHeadList2} pageSize={10} total={total} resetPageNo={resetPage} onPageChange={handlePageChange}> + <HeadMakeColSpan upHeadList={upHeadList2} layoutNum={layoutNum}/> +</UiTable> +``` + + diff --git a/packages/common/business_components/src/ui/search_form_right/index.tsx b/packages/common/business_components/src/ui/search_form_right/index.tsx new file mode 100644 index 000000000..f5a92dde7 --- /dev/null +++ b/packages/common/business_components/src/ui/search_form_right/index.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +function SearchFormRight(props: {children: React.ReactNode, className?: string}) { + return ( + <div className={props.className?props.className:`w-1/12 mx-5 flex flex-col items-center justify-center space-x-2 `}> + {props.children} + </div> + ) +} + +export { SearchFormRight }; diff --git a/packages/common/business_components/src/ui/search_icon/index.tsx b/packages/common/business_components/src/ui/search_icon/index.tsx new file mode 100644 index 000000000..0ef0e17e3 --- /dev/null +++ b/packages/common/business_components/src/ui/search_icon/index.tsx @@ -0,0 +1,18 @@ +export default function SearchIcon() { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + strokeWidth={1.5} + stroke="currentColor" + className="w-6 h-6 text-gray-700" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + d="M21 21l-4.35-4.35M17 11A6 6 0 105 11a6 6 0 0012 0z" + /> + </svg> + ); + } \ No newline at end of file diff --git a/packages/common/business_components/src/ui/search_input/index.tsx b/packages/common/business_components/src/ui/search_input/index.tsx index 61ff8ec3f..06ffce4ff 100644 --- a/packages/common/business_components/src/ui/search_input/index.tsx +++ b/packages/common/business_components/src/ui/search_input/index.tsx @@ -1,14 +1,28 @@ import { Input, Label } from "@common/components"; import React from "react"; -const SearchInput = React.forwardRef< - HTMLDivElement& HTMLInputElement, - React.HTMLAttributes<HTMLDivElement> & {className:string, label:string}> -(({className, label}, ref) => { +const SearchInput = React.forwardRef<HTMLInputElement, {className:string, label:string, textValue:string, setText:(val:string)=>void}>(({className, label, textValue, setText}, ref) => { + const changeText=(e:React.ChangeEvent<HTMLInputElement>)=>{ + + if(e?.target?.value){ + + setText(e.target.value) + } + + + + } + + const keyUp=(e:any)=>{ + if(e.keyCode===8 && e.target.value.length==1){ + setText('') + } + } + return ( - <div className={className+" flex"} > - {label && <Label className="m-4 text-2xl w-1/4">{label}</Label>} - <Input className="ml-8 w-3/4 text-lg" ref={ref}></Input> + <div className={className+" flex items-center"} > + {label && <Label className="w-20 text-xl text-gray-600">{label}</Label>} + <Input className="!text-lg flex-1 w-3/4 h-14 border rounded" value={textValue} onChange={changeText} onKeyUp={keyUp}></Input> </div> ) }); diff --git a/packages/common/business_components/src/ui/search_input_select/index.tsx b/packages/common/business_components/src/ui/search_input_select/index.tsx index c6face023..bf8787356 100644 --- a/packages/common/business_components/src/ui/search_input_select/index.tsx +++ b/packages/common/business_components/src/ui/search_input_select/index.tsx @@ -11,21 +11,25 @@ export type SelectDataType = { * @param label - string label * @param selectData - SelectDataType[] 선택 데이터 * @param onSelectValue - (val:string)=>void 선택 값 콜백 + * @param textValue - string 입력 값 + * @param setText - (val:string)=>void 입력 값 설정 콜백 + * @param selectValue - string select 선택 값 * @param refs - React.forwardRef<HTMLInputElement> | null 입력값 ref * @returns React.FC */ -const SearchInputSelect = React.forwardRef<HTMLInputElement, {className:string, label:string, selectData: SelectDataType[], onSelectValue:(val:string)=>void, textValue:string, setText:(val:string)=>void}>(({className, label, selectData, onSelectValue, textValue, setText}) => { +const SearchInputSelect = React.forwardRef<HTMLInputElement, {className:string, label:string, selectData: SelectDataType[], onSelectValue:(val:string)=>void, textValue:string, setText:(val:string)=>void, selectValue:string}>(({className, label, selectData, onSelectValue, textValue, setText, selectValue}, ref) => { const changeText=(e:React.ChangeEvent<HTMLInputElement>)=>{ if(e?.target?.value) setText(e.target.value) } + return ( <div className={className+" flex items-center"} > - {label && <Label className="m-4 text-2xl w-1/4">{label}</Label>} + {label && <Label className="w-20 text-xl text-gray-600">{label}</Label>} <div className="flex w-full items-center"> - <Input className="w-2/3 text-lg" value={textValue} onChange={changeText} /> - <Select onValueChange={onSelectValue}> - <SelectTrigger className="ml-2 w-1/3 text-lg" > - <SelectValue placeholder={selectData?.[0]?.text ?? ""} /> + <Input className="w-2/3 text-xl h-14 p-2 border rounded" value={textValue} onChange={changeText} /> + <Select onValueChange={onSelectValue} value={selectValue}> + <SelectTrigger className="ml-2 w-1/3" > + <SelectValue placeholder={selectData?.[0]?.text ?? ""} /> </SelectTrigger> {/* <SelectContent ref={refs ? (refs as unknown as RefType)?.selectValue : null}> */} <SelectContent > diff --git a/packages/common/business_components/src/ui/select/index-2.tsx b/packages/common/business_components/src/ui/select/index-2.tsx new file mode 100644 index 000000000..8ff1f4582 --- /dev/null +++ b/packages/common/business_components/src/ui/select/index-2.tsx @@ -0,0 +1,45 @@ +/** + * @remarks + * 이 버전은 SearchModal에서 사용하기 위해 `SelectContent`의 z-index를 `z-[101]`로 수정하였습니다. + */ +import { Label } from "@common/components"; +import React from "react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./lib/selectBox"; + +/** 차트 기본 스타일 */ +interface SelectBox1 { + text:string, + val:string|number +} + +const SelectBox2 = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> & { + label:string, + selectClass:string|undefined, + className:string, + selectArray:SelectBox1[], + onSelectValue:(val:string|number)=>void, + value?: string|number + } +>(({ label, className, selectClass, selectArray, onSelectValue, value }, ref)=>{ + const placeHolder = selectArray?.[0]?.text ?? ""; + + return ( + <div className={className+" flex items-center flex-nowrap"} ref={ref}> + {label && <Label className="inline-block w-auto whitespace-nowrap text-xl text-gray-600 mr-2">{label}</Label>} + <Select onValueChange={onSelectValue} value={value !== undefined ? String(value) : undefined}> + <SelectTrigger className={selectClass?selectClass:"flex-1 p-2 border rounded"}> + <SelectValue placeholder={placeHolder} /> + </SelectTrigger> + <SelectContent className="z-[101]"> + {selectArray.map((selectArray) => ( + <SelectItem key={selectArray.val} value={String(selectArray.val)}>{selectArray.text}</SelectItem> + ))} + </SelectContent> + </Select> + </div> + ) +}) + +export { SelectBox2 }; diff --git a/packages/common/business_components/src/ui/select/index.tsx b/packages/common/business_components/src/ui/select/index.tsx index a3b397606..ee6296677 100644 --- a/packages/common/business_components/src/ui/select/index.tsx +++ b/packages/common/business_components/src/ui/select/index.tsx @@ -5,26 +5,32 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ". /** 차트 기본 스타일 */ interface SelectBox1 { text:string, - val:string + val:string|number } - const SelectBox = React.forwardRef< HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> & {label:string, selectClass:string|undefined, className:string, selectArray:SelectBox1[], onSelectValue:(val:string)=>void} ->(({ label, className, selectClass, selectArray, onSelectValue }, ref)=>{ + React.HTMLAttributes<HTMLDivElement> & { + label:string, + selectClass:string|undefined, + className:string, + selectArray:SelectBox1[], + onSelectValue:(val:string|number)=>void, + value?: string|number + } +>(({ label, className, selectClass, selectArray, onSelectValue, value }, ref)=>{ const placeHolder = selectArray?.[0]?.text ?? ""; return ( <div className={className+" flex items-center"} ref={ref}> - {label && <Label className="m-4 text-2xl w-1/4">{label}</Label>} - <Select onValueChange={onSelectValue}> - <SelectTrigger className={selectClass?selectClass:"ml-8 w-3/4 text-2xl"}> + {label && <Label className="w-20 text-xl text-gray-600">{label}</Label>} + <Select onValueChange={onSelectValue} value={value !== undefined ? String(value) : undefined}> + <SelectTrigger className={selectClass?selectClass:"flex-1 p-2 border rounded"}> <SelectValue placeholder={placeHolder} /> </SelectTrigger> <SelectContent> {selectArray.map((selectArray) => ( - <SelectItem key={selectArray.val} value={selectArray.val}>{selectArray.text}</SelectItem> + <SelectItem key={selectArray.val} value={String(selectArray.val)}>{selectArray.text}</SelectItem> ))} </SelectContent> </Select> @@ -32,4 +38,4 @@ const SelectBox = React.forwardRef< ) }) -export { SelectBox }; \ No newline at end of file +export { SelectBox }; diff --git a/packages/common/business_components/src/ui/select/lib/selectBox.tsx b/packages/common/business_components/src/ui/select/lib/selectBox.tsx index 08e3abed3..cc4bac4d1 100644 --- a/packages/common/business_components/src/ui/select/lib/selectBox.tsx +++ b/packages/common/business_components/src/ui/select/lib/selectBox.tsx @@ -1,6 +1,6 @@ -import * as React from "react" import * as SelectPrimitive from "@radix-ui/react-select" import { Check, ChevronDown, ChevronUp } from "lucide-react" +import * as React from "react" import { cn } from "../../../lib/utils" @@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef< <SelectPrimitive.Trigger ref={ref} className={cn( - "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", + "flex h-13 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-xl ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", className )} {...props} @@ -103,7 +103,7 @@ const SelectLabel = React.forwardRef< >(({ className, ...props }, ref) => ( <SelectPrimitive.Label ref={ref} - className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} + className={cn("py-1.5 pl-8 pr-2 text-lg font-semibold", className)} {...props} /> )) @@ -116,7 +116,7 @@ const SelectItem = React.forwardRef< <SelectPrimitive.Item ref={ref} className={cn( - "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-lg outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className )} {...props} @@ -145,14 +145,6 @@ const SelectSeparator = React.forwardRef< SelectSeparator.displayName = SelectPrimitive.Separator.displayName export { - Select, - SelectGroup, - SelectValue, - SelectTrigger, - SelectContent, - SelectLabel, - SelectItem, - SelectSeparator, - SelectScrollUpButton, - SelectScrollDownButton, + Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue } + diff --git a/packages/common/business_components/src/ui/tab/index-2.tsx b/packages/common/business_components/src/ui/tab/index-2.tsx new file mode 100644 index 000000000..71c103835 --- /dev/null +++ b/packages/common/business_components/src/ui/tab/index-2.tsx @@ -0,0 +1,38 @@ +import { Tabs, TabsList, TabsTrigger } from '@common/components/ui'; +import React from 'react'; + +interface TabItem { + value: string; + label: string; + onClick?: () => void; +} + +interface UITabProps extends React.HTMLAttributes<HTMLDivElement> { + defaultValue?: string; + tabList: TabItem[]; + children: React.ReactNode; +} + +const UITab2 = React.forwardRef< + HTMLDivElement, + UITabProps +>(({defaultValue, tabList, children}, ref)=>{ + return ( + <Tabs defaultValue={defaultValue || tabList[0]?.value} className={"w-full"}> + <TabsList className="flex"> + {tabList.map((tab) => ( + <TabsTrigger + key={tab.value} + value={tab.value} + onClick={tab.onClick} + className="py-2 px-4 text-sm font-medium border-b-2 focus:outline-none data-[state=active]:border-blue-500 data-[state=active]:text-blue-600 data-[state=inactive]:border-transparent data-[state=inactive]:text-gray-500 hover:text-gray-700 hover:border-gray-300" + > + {tab.label} + </TabsTrigger> + ))} + </TabsList> + {children} + </Tabs> + ) +}) +export { UITab2 }; diff --git a/packages/common/business_components/src/ui/tab/index.tsx b/packages/common/business_components/src/ui/tab/index.tsx new file mode 100644 index 000000000..aee969460 --- /dev/null +++ b/packages/common/business_components/src/ui/tab/index.tsx @@ -0,0 +1,46 @@ +import { Tabs, TabsList, TabsTrigger } from '@common/components/ui'; +import React from 'react'; + +interface TabItem { + value: string; + label: string; +} + +interface UITabProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'dir'> { + defaultValue?: string; + tabList: TabItem[]; + children: React.ReactNode; + onValueChange?: (value: string) => void; +} + +const UITab = React.forwardRef< + HTMLDivElement, + UITabProps +>(({defaultValue, tabList, children, onValueChange, ...props}, ref) => { + return ( + <Tabs + defaultValue={defaultValue || tabList[0]?.value} + className={"w-full space-x-1 border-b border-gray-200"} + onValueChange={onValueChange} + ref={ref} + {...props} + > + <TabsList className="flex space-x-1"> + {tabList.map((tab) => ( + <TabsTrigger + key={tab.value} + value={tab.value} + className="py-2 px-4 text-sm font-medium border-b-2 focus:outline-none data-[state=active]:border-blue-500 data-[state=active]:text-blue-600 data-[state=inactive]:border-transparent data-[state=inactive]:text-gray-500 hover:text-gray-700 hover:border-gray-300" + > + {tab.label} + </TabsTrigger> + ))} + </TabsList> + {children} + </Tabs> + ); +}); + +UITab.displayName = 'UITab'; + +export { UITab }; diff --git a/packages/common/business_components/src/ui/table/HeadMakeColSpan.tsx b/packages/common/business_components/src/ui/table/HeadMakeColSpan.tsx new file mode 100644 index 000000000..ebd611c1d --- /dev/null +++ b/packages/common/business_components/src/ui/table/HeadMakeColSpan.tsx @@ -0,0 +1,70 @@ +import { TableHead, TableRow } from "@common/components/ui"; + +interface TableUpperProps { + id: string; + title: string; + upName?: string; + upSequnce?: number; + row? : number; + rowSpan?: number; + colSpan?: number; + visible?:boolean; +} + +const HeadMakeColSpan = ({ upHeadList }: { upHeadList: TableUpperProps[] }) => { + const maxRow = upHeadList?.reduce((max, head) => Math.max(max, head.row || 0), 0); + //upHeadList.forEach((item) => item.visible ?item.visible:true) + + if(maxRow == 0){ + return ( + <> + <TableRow className="bg-gray-50"> + {upHeadList + .filter((item) => item.visible != false) + .map((head: TableUpperProps) => { + return ( + <TableHead + key={head.id} + colSpan={head.colSpan || 1} + rowSpan={head.rowSpan || 1} + style={{ whiteSpace: 'pre-line' }} + className="bg-gray-50" + > + {head.title} + </TableHead> + ); + })} + </TableRow> + </> + ); + }else{ + return ( + <> + {Array.from({ length: maxRow + 1 }, (_, rowIndex) => ( + <TableRow key={rowIndex+ " headerRow"} className="bg-gray-50"> + {upHeadList + .filter((item) => item.visible != false) + .map((head: TableUpperProps) => { + if (head.row === rowIndex) { + return ( + <TableHead + key={head.id} + colSpan={head.colSpan || 1} + rowSpan={head.rowSpan || 1} + style={{ whiteSpace: 'pre-line' }} + className="bg-gray-50" + > + {head.title} + </TableHead> + ); + } + })} + </TableRow> + ))} + </> + ); + } + +} + +export { HeadMakeColSpan }; diff --git a/packages/common/business_components/src/ui/table/index-2.tsx b/packages/common/business_components/src/ui/table/index-2.tsx new file mode 100644 index 000000000..b6a9750b0 --- /dev/null +++ b/packages/common/business_components/src/ui/table/index-2.tsx @@ -0,0 +1,157 @@ +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components"; +import { useEffect, useState } from "react"; +import UiPageNations from "./uiPageNations"; + +type UiTableProps = { + headName: React.ReactNode | undefined; + tableData: { [key: string]: string | number | boolean | object }[]; + headlist: UiHeadProps[]; + pageSize: number; + total: number; + children: React.ReactNode | undefined; + cellClick: ((index: number | undefined, headId: string) => void) | undefined; + onPageChange: (page: number) => void; + className?: string; + resetPageNo?: boolean; + cur?: number; + height?: string; // ✅ 추가된 prop +}; + +type UiHeadProps = { + id: string; + title: string; + align?: string; +}; + +export function UiTable2({ + tableData, + headlist, + pageSize, + total, + children, + headName, + cellClick, + onPageChange, + className = '', + resetPageNo = false, + cur, + height = '45vh', // ✅ 기본값 설정 +}: UiTableProps) { + const [idx, setIdx] = useState<number>(-1); + const [curState, setCurState] = useState<number>(cur ?? 1); + + useEffect(() => { + setCurState(cur ?? 1); + }, [cur]); + + useEffect(() => { + if (resetPageNo) { + setCurState(1); + onPageChange(1); + } + }, [onPageChange, resetPageNo]); + + const handlePageChange = (page: number) => { + setCurState(page); + onPageChange(page); + }; + + const handleCellClick = (index: number, headId: string) => { + if (cellClick) { + cellClick(index, headId); + setIdx(index); + } + }; + + const startIdx = (curState - 1) * pageSize; + + return ( + <> + {headName && ( + <div className="card-header"> + <div className="flex w-full items-center justify-between"> + <h3 className="text-2xl font-medium text-label">{headName}</h3> + </div> + </div> + )} + + <div className={`card !m-[1.2rem] !p-0 relative ${className}`}> + {/* ✅ 외부에서 조절 가능한 높이 적용 */} + <div className="overflow-auto" style={{ height }}> + <Table variant="secondary" className="w-full"> + <TableHeader className="bg-white"> + {children ? ( + <>{children}</> + ) : ( + <TableRow> + {headlist.map((head) => ( + <TableHead + key={head.id} + className="min-w-[120px] max-w-[180px] w-[150px] + text-center whitespace-nowrap overflow-hidden + text-ellipsis bg-white" + > + {head.title} + </TableHead> + ))} + </TableRow> + )} + </TableHeader> + + <TableBody className="text-center"> + {tableData.length > 0 ? ( + tableData.map((el, index) => ( + <TableRow key={index + "row"}> + {headlist.map((head) => ( + <TableCell + key={head.id + index} + className={` + ${head.align === 'right' + ? 'text-right' + : head.align === 'center' + ? 'text-center' + : typeof el[head.id] === 'number' + ? 'text-right' + : 'text-center' + } + ${cellClick && idx === index ? '!font-[1000] cursor-pointer' : + cellClick ? 'cursor-pointer' : ''} + `} + onClick={() => handleCellClick(startIdx + index, head.id)} + > + {typeof el[head.id] === 'number' + ? String(el[head.id]).replace(/\B(?=(\d{3})+(?!\d))/g, ",") + : String(el[head.id] ?? '')} + </TableCell> + ))} + </TableRow> + )) + ) : ( + <TableRow> + <TableCell colSpan={headlist.length} className="p-0"> + {/* ✅ 높이 보장 + 가운데 정렬용 div */} + <div className="flex justify-center items-center text-gray-500" style={{ height: `calc(${height} - 70px)` }}> + <span className="text-xl">🔍 검색 결과가 없습니다.</span> + </div> + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </div> + + {/* 페이지네이션 하단 고정 */} + { ( + <div className="sticky left-0 right-0 bottom-0 bg-white z-20 mt-5"> + <UiPageNations + total={total} + cur={curState} + pageSize={pageSize} + onPageChange={handlePageChange} + /> + </div> + )} + </div> + </> + ); +} diff --git a/packages/common/business_components/src/ui/table/index.tsx b/packages/common/business_components/src/ui/table/index.tsx index cbb37b273..48f4deb62 100644 --- a/packages/common/business_components/src/ui/table/index.tsx +++ b/packages/common/business_components/src/ui/table/index.tsx @@ -1,47 +1,107 @@ -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components"; +"use client" + +import { Table, TableBody, TableCell, TableHeader, TableRow } from "@common/components"; +import { useEffect, useState } from "react"; +import { HeadMakeColSpan } from "./HeadMakeColSpan"; import UiPageNations from "./uiPageNations"; type UiTableProps = { headName: React.ReactNode | undefined; - tableData: { [key: string]: string | number | boolean }[]; + tableData: { [key: string]:any}[]; headlist: UiHeadProps[]; pageSize: number; total: number; - children: React.ReactNode | undefined; - cellClick : (index:number)=>void | undefined; + cellClick : ((index:number|undefined, headId:string)=>void )| undefined; + onPageChange: (page: number) => void; + className?: string; + resetPageNo?: boolean; + children?: React.ReactNode | undefined; + boldArray?: number[]; } type UiHeadProps = { id: string; title: string; + align?: string; + visible?:boolean; } -export function UiTable({ tableData, headlist, pageSize, total, children, headName, cellClick }: UiTableProps) { - const cur = 1; +export function UiTable({ tableData, headlist, pageSize, total, headName, children, cellClick, onPageChange, className = '', resetPageNo = false, boldArray = [] }: UiTableProps) { + const [cur, setCur] = useState<number>(1); + const [idx, setIdx] = useState<number>(-1); + useEffect(() => { + if (resetPageNo) { + setCur(1); + onPageChange(1); + } + }, [onPageChange, resetPageNo]); + + const handlePageChange = (page: number) => { + setCur(page); + onPageChange(page); + } + + const handleCellClick = (index: number,headId:string )=> { + if (cellClick) { + cellClick(index,headId); + setIdx(index); + } + }; + return ( <> - {headName ? headName : <></>} - <Table> - <TableHeader> - {children ? <>{children}</> : <TableRow> - {headlist?.map((head) => ( - <TableHead key={head.id}>{head.title}</TableHead> - ))} - </TableRow>} - - </TableHeader> - <TableBody className='text-center'> - {tableData?.map((el: { [key: string]: string | number | boolean }, index) => ( - <TableRow key={index + "row"} > - {headlist.map((head) => ( - <TableCell onClick={()=>cellClick(index)} key={head.id + index}>{el[head.id]}</TableCell> - ))} - </TableRow> - ))} - </TableBody> - </Table> - {total <= 0 ? <></> : <UiPageNations total={total} cur={cur} pageSize={pageSize} />} + {headName ? <div className="card-header"> + <h3 className="text-2xl font-medium text-label">{headName}</h3> + </div> : <></>} + <div className={`card !m-[1.2rem] !p-0r ${className}`}> + <Table variant="secondary"> + <TableHeader className="font-bold"> + {children ? children : <HeadMakeColSpan upHeadList={headlist} />} + </TableHeader> + <TableBody className='text-center'> + {tableData.length > 0 ? tableData?.map((el: { [key: string]: any}, index) => ( + <TableRow key={`row-${index}`}> + {headlist + .filter((item) => item.visible != false) + .map((head) => ( + head.id && head.id.length > 1 && ( + boldArray.includes(index) ? <TableCell + className={`${head.align === 'right' ? 'text-right' : head.align === 'center' ? 'text-center' : typeof el[head.id] === 'number' ? 'text-right' : 'text-center'} ${ + cellClick && idx === index ? '!font-[1000] cursor-pointer' : + cellClick ? 'cursor-pointer' : '' + } !font-bold`} + onClick={() => handleCellClick(index, head.id)} + key={`cell-${head.id}-${index}`} + > + {typeof el[head.id] !== 'number'?String(el[head.id] ?? ''): String(el[head.id]).includes('.') + ? String(el[head.id]).split('.').map((part, index) => + index === 0 ? part.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : part + ).join('.') + : String(el[head.id] ?? '').replace(/\B(?=(\d{3})+(?!\d))/g, ",")} + </TableCell>: + <TableCell + className={`${head.align === 'right' ? 'text-right' : head.align === 'center' ? 'text-center' : typeof el[head.id] === 'number' ? 'text-right' : 'text-center'} ${ + cellClick && idx === index ? '!font-[1000] cursor-pointer' : + cellClick ? 'cursor-pointer' : '' + }`} + onClick={() => handleCellClick(index, head.id)} + key={`cell-${head.id}-${index}`} + > + {typeof el[head.id] !== 'number'?String(el[head.id] ?? ''): String(el[head.id]).includes('.') + ? String(el[head.id]).split('.').map((part, index) => + index === 0 ? part.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : part + ).join('.') + : String(el[head.id] ?? '').replace(/\B(?=(\d{3})+(?!\d))/g, ",")} + </TableCell> + ) + ))} + </TableRow> + )) : <></>} + </TableBody> + </Table> + {total <= 0 ? <></> : <UiPageNations total={total} cur={cur} pageSize={pageSize} onPageChange={handlePageChange} />} + </div> </> ); -} +} \ No newline at end of file diff --git a/packages/common/business_components/src/ui/table/lib/pagination.tsx b/packages/common/business_components/src/ui/table/lib/pagination.tsx index 2c17f4b45..76e3b4578 100644 --- a/packages/common/business_components/src/ui/table/lib/pagination.tsx +++ b/packages/common/business_components/src/ui/table/lib/pagination.tsx @@ -8,7 +8,7 @@ const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( <nav role="navigation" aria-label="pagination" - className={cn("mx-auto flex w-full justify-center", className)} + className={cn("mx-auto flex w-full justify-center text-lg mt-2", className)} {...props} /> ) @@ -42,7 +42,7 @@ type PaginationLinkProps = { const PaginationLink = ({ className, isActive, - size = "icon", + size = "default", ...props }: PaginationLinkProps) => ( <a @@ -52,6 +52,7 @@ const PaginationLink = ({ variant: isActive ? "outline" : "ghost", size, }), + "px-4 py-2 text-lg", className )} {...props} @@ -66,10 +67,10 @@ const PaginationPrevious = ({ <PaginationLink aria-label="Go to previous page" size="default" - className={cn("gap-1 pl-2.5", className)} + className={cn("gap-2 pl-4 text-lg", className)} {...props} > - <ChevronLeft className="h-4 w-4" /> + <ChevronLeft className="h-6 w-6" /> <span>Previous</span> </PaginationLink> ) @@ -82,11 +83,11 @@ const PaginationNext = ({ <PaginationLink aria-label="Go to next page" size="default" - className={cn("gap-1 pr-2.5", className)} + className={cn("gap-2 pr-4 text-lg", className)} {...props} > <span>Next</span> - <ChevronRight className="h-4 w-4" /> + <ChevronRight className="h-6 w-6" /> </PaginationLink> ) PaginationNext.displayName = "PaginationNext" @@ -97,10 +98,10 @@ const PaginationEllipsis = ({ }: React.ComponentProps<"span">) => ( <span aria-hidden - className={cn("flex h-9 w-9 items-center justify-center", className)} + className={cn("flex h-12 w-12 items-center justify-center text-lg", className)} {...props} > - <MoreHorizontal className="h-4 w-4" /> + <MoreHorizontal className="h-6 w-6" /> <span className="sr-only">More pages</span> </span> ) diff --git a/packages/common/business_components/src/ui/table/uiPageNations.tsx b/packages/common/business_components/src/ui/table/uiPageNations.tsx index a7b788d71..891fc4c81 100644 --- a/packages/common/business_components/src/ui/table/uiPageNations.tsx +++ b/packages/common/business_components/src/ui/table/uiPageNations.tsx @@ -1,46 +1,77 @@ -'use client' - +'use client'; import { useEffect, useState } from "react"; -import { Pagination, PaginationContent, PaginationItem, PaginationPrevious, PaginationLink, PaginationNext } from "./lib/pagination"; - +import { PaginationNext } from "./lib/pagination"; +import { PaginationLink } from "./lib/pagination"; +import { PaginationPrevious } from "./lib/pagination"; +import { PaginationItem } from "./lib/pagination"; +import { PaginationContent } from "./lib/pagination"; +import { Pagination } from "./lib/pagination"; interface CommonPageNationsProps { - total: number; - cur: number; - pageSize: number; + total: number; + cur: number; + pageSize: number; + onPageChange: (page: number) => void; } export default function UiPageNations(commonPageNationsProps: CommonPageNationsProps) { - - const [pageProps, setPageProps] = useState(commonPageNationsProps); + const [pageProps, setPageProps] = useState(commonPageNationsProps); + useEffect(() => { + setPageProps(commonPageNationsProps); + }, [commonPageNationsProps]); - useEffect(() => { - setPageProps(commonPageNationsProps); - } ,[commonPageNationsProps]); + const totalPages = Math.ceil(pageProps.total / pageProps.pageSize); + const pagesPerGroup = 10; + const currentGroup = Math.floor((pageProps.cur - 1) / pagesPerGroup); + const startPage = currentGroup * pagesPerGroup + 1; + const endPage = Math.min(startPage + pagesPerGroup - 1, totalPages); + const handlePageChange = (page: number) => { + pageProps.onPageChange(page); + }; - return( - <Pagination> - <PaginationContent> - <PaginationItem> - <PaginationPrevious href="#" /> - </PaginationItem> + return ( + <Pagination> + <PaginationContent> + + {/* 이전 그룹으로 이동 */} + <PaginationItem> + <PaginationPrevious + href="#" + onClick={() => { + if (startPage > 1) handlePageChange(startPage - 1); + }} + /> + </PaginationItem> + + {/* 현재 그룹 페이지들 표시 */} { - Array.from({length:Math.ceil(commonPageNationsProps.total/commonPageNationsProps.pageSize)},(_,i)=>i+1).map((el,index)=>( - <PaginationItem key={index}> - <PaginationLink href= '#'>{el}</PaginationLink> - </PaginationItem> - )) - } - <PaginationItem> - <PaginationNext href="#" /> + Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i).map((pageNum) => ( + <PaginationItem key={pageNum}> + <PaginationLink + href="#" + isActive={pageNum === pageProps.cur} + onClick={() => handlePageChange(pageNum)} + > + {pageNum} + </PaginationLink> </PaginationItem> - - - - </PaginationContent> - </Pagination> - ); + )) + } + + {/* 다음 그룹으로 이동 */} + <PaginationItem> + <PaginationNext + href="#" + onClick={() => { + if (endPage < totalPages) handlePageChange(endPage + 1); + }} + /> + </PaginationItem> + + </PaginationContent> + </Pagination> + ); } \ No newline at end of file diff --git a/packages/common/business_components/src/ui/table_div/index.tsx b/packages/common/business_components/src/ui/table_div/index.tsx new file mode 100644 index 000000000..b0e580b9c --- /dev/null +++ b/packages/common/business_components/src/ui/table_div/index.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +function ContentsDiv(props: {children: React.ReactNode}) { + return ( + <div className="card card-border !mt-6"> + {props.children} + </div> + ) +} + +export { ContentsDiv }; \ No newline at end of file diff --git a/packages/common/business_components/src/ui/title/index.tsx b/packages/common/business_components/src/ui/title/index.tsx new file mode 100644 index 000000000..5b6d9bff4 --- /dev/null +++ b/packages/common/business_components/src/ui/title/index.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +function Title(props: {title: string}) { + return ( + <h2 className="px-[1.8rem] pt-[2rem] text-[2.4rem] font-semibold text-title"> + {props.title} + </h2> + ) +} + +export { Title }; \ No newline at end of file diff --git a/packages/common/business_components/tailwind.config.js b/packages/common/business_components/tailwind.config.js index f30c9f77a..9ce3117a0 100644 --- a/packages/common/business_components/tailwind.config.js +++ b/packages/common/business_components/tailwind.config.js @@ -1,93 +1,8 @@ +import presetConfig from "../../../tailwind.config.preset"; + /** @type {import('tailwindcss').Config} */ export default { - darkMode: ["class", "[data-mode='dark']"], - content: ["./src/**/*.{ts,tsx,js,jsx}", "./.storybook/**/*.{ts,tsx,js,jsx}"], - theme: { - extend: { - borderRadius: { - lg: "var(--radius)", - md: "calc(var(--radius) - 2px)", - sm: "calc(var(--radius) - 4px)", - }, - colors: { - background: "var(--background)", - foreground: "var(--foreground)", - card: { - DEFAULT: "var(--card)", - foreground: "var(--card-foreground)", - }, - popover: { - DEFAULT: "var(--popover)", - foreground: "var(--popover-foreground)", - }, - primary: { - DEFAULT: "var(--primary)", - foreground: "var(--primary-foreground)", - }, - secondary: { - DEFAULT: "var(--secondary)", - foreground: "var(--secondary-foreground)", - }, - muted: { - DEFAULT: "var(--muted)", - foreground: "var(--muted-foreground)", - }, - accent: { - DEFAULT: "var(--accent)", - foreground: "var(--accent-foreground)", - }, - destructive: { - DEFAULT: "var(--destructive)", - foreground: "var(--destructive-foreground)", - }, - border: "var(--border)", - input: "var(--input)", - label: "var(--label)", - ring: "var(--ring)", - chart: { - 1: "var(--chart-1)", - 2: "var(--chart-2)", - 3: "var(--chart-3)", - 4: "var(--chart-4)", - 5: "var(--chart-5)", - }, - }, - keyframes: { - "accordion-down": { - from: { height: "0" }, - to: { height: "var(--radix-accordion-content-height)" }, - }, - "accordion-up": { - from: { height: "var(--radix-accordion-content-height)" }, - to: { height: "0" }, - }, - "spin-parent": { - from: { transform: "translate(-50%,-50%) rotate(0deg)" }, - to: { transform: "translate(-50%,-50%) rotate(360deg)" }, - }, - "spin-child": { - from: { transform: "translate(0,0) rotate(0deg)" }, - to: { transform: "translate(0,0) rotate(360deg)" }, - }, - }, - animation: { - "accordion-down": "accordion-down 0.2s ease-out", - "accordion-up": "accordion-up 0.2s ease-out", - "spin-parent": "spin-parent 6s linear infinite", - "spin-child": "spin-child 1s ease-in-out infinite", - }, - backgroundImage: { - "gradient-blue": "linear-gradient(.25turn, #2094fa, #00e5dd)", - "gradient-blue-hover": - "linear-gradient(.25turn, #2298ff, #01dcda, #00e8b4)", - "gradient-green": "linear-gradient(.25turn, #00bad0, #00eb6f)", - "gradient-green-hover": "linear-gradient(.25turn, #00bad0, #12fe81)", - "gradient-red": "linear-gradient(.25turn, #ff487c, #ffc425)", - "gradient-red-hover": "linear-gradient(.25turn, #ff487c, #ffea3a)", - "gradient-purple": "linear-gradient(.25turn, #be30d7, #ff9264)", - "gradient-purple-hover": "linear-gradient(.25turn, #be30d7, #ffaf5a)", - }, - }, - }, + presets: [presetConfig], + content: ["./src/**/*.{ts,tsx,js,jsx}"], plugins: [require("tailwindcss-animate")], }; diff --git a/packages/common/business_components/tsconfig.json b/packages/common/business_components/tsconfig.json index 8b065d297..20f5a6648 100644 --- a/packages/common/business_components/tsconfig.json +++ b/packages/common/business_components/tsconfig.json @@ -30,5 +30,5 @@ "**/*.mjs", "**/*.jsx", "**/*.json" - ] +, "../../../apps/sn/app/map/SearchModal.tsx" ] } diff --git a/packages/common/components/src/ui/input/index.tsx b/packages/common/components/src/ui/input/index.tsx index 1922ca859..f3abe2d82 100644 --- a/packages/common/components/src/ui/input/index.tsx +++ b/packages/common/components/src/ui/input/index.tsx @@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>( return ( <input className={cn( - "flex h-[3.2rem] w-full rounded-md border border-input bg-form px-3 py-2 text-base text-foreground ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-placeholder focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "flex h-[3.2rem] w-full rounded-md border border-input bg-form px-3 py-2 text-xl ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-placeholder focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-xl", className, )} ref={ref} diff --git a/packages/common/components/src/ui/table/index.tsx b/packages/common/components/src/ui/table/index.tsx index ba50e9d75..8ced24202 100644 --- a/packages/common/components/src/ui/table/index.tsx +++ b/packages/common/components/src/ui/table/index.tsx @@ -29,7 +29,7 @@ const TableHeader = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement> >(({ className, ...props }, ref) => ( - <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} /> + <thead ref={ref} className={cn("sticky top-0 bg-secondary [&_tr]:border-b", className)} {...props} /> )); TableHeader.displayName = "TableHeader"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a5b358f7..ade6a3d0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,9 +172,12 @@ importers: '@common/config': specifier: workspace:* version: link:../../packages/common/config +<<<<<<< HEAD +======= '@fontsource-variable/noto-sans-kr': specifier: ^5.2.5 version: 5.2.5 +>>>>>>> origin/main '@frontend-opensource/use-react-hooks': specifier: ^1.1.4 version: 1.1.4 @@ -279,6 +282,115 @@ importers: specifier: ^5 version: 5.6.3 + apps/sn: + dependencies: + '@common/assets': + specifier: workspace:* + version: link:../../packages/common/assets + '@common/business_components': + specifier: workspace:* + version: link:../../packages/common/business_components + '@common/components': + specifier: workspace:* + version: link:../../packages/common/components + '@frontend-opensource/use-react-hooks': + specifier: ^1.1.4 + version: 1.1.4 + '@hookform/resolvers': + specifier: ^4.1.3 + version: 4.1.3(react-hook-form@7.54.2) + '@radix-ui/react-label': + specifier: ^2.1.2 + version: 2.1.2(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-slot': + specifier: ^1.1.2 + version: 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@tanstack/react-query': + specifier: ^5.67.1 + version: 5.67.1(react@18.3.1) + '@tanstack/react-table': + specifier: ^8.21.2 + version: 8.21.2(react-dom@18.3.1)(react@18.3.1) + axios: + specifier: ^1.8.1 + version: 1.8.1 + chart.js: + specifier: ^4.4.8 + version: 4.4.8 + dayjs: + specifier: ^1.11.13 + version: 1.11.13 + js-cookie: + specifier: ^3.0.5 + version: 3.0.5 + next: + specifier: 15.1.7 + version: 15.1.7(react-dom@18.3.1)(react@18.3.1) + ol: + specifier: ^7.3.0 + version: 7.5.2 + ol-ext: + specifier: ^4.0.11 + version: 4.0.31(ol@7.5.2) + proj4: + specifier: ^2.9.0 + version: 2.15.0 + react-chartjs-2: + specifier: ^5.3.0 + version: 5.3.0(chart.js@4.4.8)(react@18.3.1) + react-hook-form: + specifier: ^7.54.2 + version: 7.54.2(react@18.3.1) + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@3.4.17) + zod: + specifier: ^3.24.2 + version: 3.24.2 + zustand: + specifier: ^5.0.3 + version: 5.0.3(@types/react@18.3.18)(react@18.3.1) + devDependencies: + '@eslint/eslintrc': + specifier: ^3 + version: 3.3.0 + '@faker-js/faker': + specifier: ^9.5.0 + version: 9.5.0 + '@types/js-cookie': + specifier: ^3.0.6 + version: 3.0.6 + '@types/node': + specifier: ^22.13.1 + version: 22.13.9 + '@types/webpack': + specifier: ^5.28.5 + version: 5.28.5 + eslint: + specifier: ^9 + version: 9.21.0 + eslint-config-next: + specifier: 15.1.7 + version: 15.1.7(eslint@9.21.0)(typescript@5.6.3) + postcss: + specifier: ^8 + version: 8.5.3 + prettier: + specifier: ^3.4.2 + version: 3.5.2 + prettier-plugin-tailwindcss: + specifier: ^0.6.11 + version: 0.6.11(prettier@3.5.2) + steiger: + specifier: ^0.5.5 + version: 0.5.5(typescript@5.6.3) + tailwindcss: + specifier: ^3.4.1 + version: 3.4.17 + typescript: + specifier: ^5 + version: 5.6.3 + apps/wr: dependencies: '@common/business_components': @@ -372,6 +484,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.1.2 version: 1.1.2(@types/react@18.3.18)(react@18.3.1) + axios: + specifier: 1.8.1 + version: 1.8.1 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -405,6 +520,12 @@ importers: tailwind-merge: specifier: ^3.0.1 version: 3.0.2 + xlsx: + specifier: ^0.18.5 + version: 0.18.5 + xlsx-populate: + specifier: ^1.21.0 + version: 1.21.0 zod: specifier: ^3.24.2 version: 3.24.2 @@ -445,6 +566,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.3.4(vite@6.2.0) + '@xlsx/xlsx-populate': + specifier: ^0.0.8 + version: 0.0.8 autoprefixer: specifier: ^10.4.20 version: 10.4.20(postcss@8.5.3) @@ -464,7 +588,7 @@ importers: specifier: ^15.14.0 version: 15.15.0 postcss: - specifier: ^8.5.1 + specifier: ^8.5.3 version: 8.5.3 prettier: specifier: ^3.4.2 @@ -1693,6 +1817,7 @@ packages: /@faker-js/faker@9.5.0: resolution: {integrity: sha512-3qbjLv+fzuuCg3umxc9/7YjrEXNaKwHgmig949nfyaTx8eL4FAsvFbu+1JcFUj1YAXofhaDn6JdEUBTYuk0Ssw==} engines: {node: '>=18.0.0', npm: '>=9.0.0'} + deprecated: Please update to a newer version dev: true /@feature-sliced/filesystem@2.4.0: @@ -2228,6 +2353,33 @@ packages: resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} dev: false + /@mapbox/jsonlint-lines-primitives@2.0.2: + resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==} + engines: {node: '>= 0.6'} + dev: false + + /@mapbox/mapbox-gl-style-spec@13.28.0: + resolution: {integrity: sha512-B8xM7Fp1nh5kejfIl4SWeY0gtIeewbuRencqO3cJDrCHZpaPg7uY+V8abuR+esMeuOjRl5cLhVTP40v+1ywxbg==} + hasBin: true + dependencies: + '@mapbox/jsonlint-lines-primitives': 2.0.2 + '@mapbox/point-geometry': 0.1.0 + '@mapbox/unitbezier': 0.0.0 + csscolorparser: 1.0.3 + json-stringify-pretty-compact: 2.0.0 + minimist: 1.2.8 + rw: 1.3.3 + sort-object: 0.3.2 + dev: false + + /@mapbox/point-geometry@0.1.0: + resolution: {integrity: sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==} + dev: false + + /@mapbox/unitbezier@0.0.0: + resolution: {integrity: sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==} + dev: false + /@mdx-js/react@3.1.0(@types/react@18.3.18)(react@18.3.1): resolution: {integrity: sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ==} peerDependencies: @@ -2462,6 +2614,10 @@ packages: engines: {node: '>=12.4.0'} dev: true + /@petamoriken/float16@3.9.2: + resolution: {integrity: sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==} + dev: false + /@pkgjs/parseargs@0.11.0: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -4551,6 +4707,11 @@ packages: '@xtuc/long': 4.2.2 dev: true + /@xlsx/xlsx-populate@0.0.8: + resolution: {integrity: sha512-6yu24ePaO3cnAuti77FhVPZvKkzUt6s6x3zJ9wiuBSjR0ImWu7l/SgePelZaf8Oty4SP4rg4vznQVCGQtfzu2A==} + engines: {node: '>=16'} + dev: true + /@xtuc/ieee754@1.2.0: resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} dev: true @@ -4573,6 +4734,11 @@ packages: hasBin: true dev: true + /adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + dev: false + /ajv-formats@2.1.1(ajv@8.17.1): resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -4955,6 +5121,14 @@ packages: /caniuse-lite@1.0.30001701: resolution: {integrity: sha512-faRs/AW3jA9nTwmJBSO1PQ6L/EOgsB5HMQQq4iCu5zhPgVVgO/pZRHlmatwijZKetFw8/Pr4q6dEN8sJuq8qTw==} + /cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + dev: false + /chai@5.2.0: resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} engines: {node: '>=12'} @@ -5044,6 +5218,11 @@ packages: engines: {node: '>=6'} dev: false + /codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + dev: false + /color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -5112,6 +5291,10 @@ packages: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} dev: true + /core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + dev: false + /cosmiconfig@9.0.0(typescript@5.6.3): resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} engines: {node: '>=14'} @@ -5128,6 +5311,12 @@ packages: typescript: 5.6.3 dev: true + /crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + dev: false + /cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -5140,6 +5329,10 @@ packages: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} dev: true + /csscolorparser@1.0.3: + resolution: {integrity: sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==} + dev: false + /cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -5491,6 +5684,10 @@ packages: es-errors: 1.3.0 gopd: 1.2.0 + /earcut@2.2.4: + resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} + dev: false + /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -6299,6 +6496,11 @@ packages: mime-types: 2.1.35 dev: false + /frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + dev: false + /fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} dev: true @@ -6355,6 +6557,20 @@ packages: engines: {node: '>=6.9.0'} dev: true + /geotiff@2.1.3: + resolution: {integrity: sha512-PT6uoF5a1+kbC3tHmZSUsLHBp2QJlHasxxxxPW47QIY1VBKpFB+FcDvX+MxER6UzgLQZ0xDzJ9s48B9JbOCTqA==} + engines: {node: '>=10.19'} + dependencies: + '@petamoriken/float16': 3.9.2 + lerc: 3.0.0 + pako: 2.1.0 + parse-headers: 2.0.6 + quick-lru: 6.1.2 + web-worker: 1.5.0 + xml-utils: 1.10.2 + zstddec: 0.1.0 + dev: false + /get-amd-module-type@6.0.1: resolution: {integrity: sha512-MtjsmYiCXcYDDrGqtNbeIYdAl85n+5mSv2r3FbzER/YV3ZILw4HNNIw34HuV5pyl0jzs6GFYU1VHVEefhgcNHQ==} engines: {node: '>=18'} @@ -6548,6 +6764,10 @@ packages: react-is: 16.13.1 dev: false + /ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + dev: false + /ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -6558,6 +6778,10 @@ packages: engines: {node: '>= 4'} dev: true + /immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + dev: false + /immer@10.1.1: resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==} dev: true @@ -6586,7 +6810,6 @@ packages: /inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - dev: true /internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} @@ -6840,6 +7063,10 @@ packages: is-docker: 2.2.1 dev: true + /isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + dev: false + /isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} dev: true @@ -6934,6 +7161,10 @@ packages: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} dev: true + /json-stringify-pretty-compact@2.0.0: + resolution: {integrity: sha512-WRitRfs6BGq4q8gTgOy4ek7iPFXjbra0H3PmDLKm2xnZ+Gh1HUhiKGgCZkSPNULlP7mvfu6FV/mOLhCarspADQ==} + dev: false + /json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true @@ -6957,6 +7188,15 @@ packages: object.values: 1.2.1 dev: true + /jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + dev: false + /keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} dependencies: @@ -6974,6 +7214,10 @@ packages: language-subtag-registry: 0.3.23 dev: true + /lerc@3.0.0: + resolution: {integrity: sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==} + dev: false + /levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -6982,6 +7226,12 @@ packages: type-check: 0.4.0 dev: true + /lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + dependencies: + immediate: 3.0.6 + dev: false + /lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -7133,6 +7383,10 @@ packages: resolution: {integrity: sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==} dev: true + /mapbox-to-css-font@2.4.5: + resolution: {integrity: sha512-VJ6nB8emkO9VODI0Fk+TQ/0zKBTqmf/Pkt8Xv0kHstoc0iXRajA00DAid4Kc3K5xeFIOoiZrVxijEzj0GLVO2w==} + dev: false + /math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -7151,6 +7405,10 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + /mgrs@1.0.0: + resolution: {integrity: sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==} + dev: false + /micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -7187,7 +7445,6 @@ packages: /minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - dev: true /minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} @@ -7414,6 +7671,32 @@ packages: es-object-atoms: 1.1.1 dev: true + /ol-ext@4.0.31(ol@7.5.2): + resolution: {integrity: sha512-byh26JcXZggkLUY79+c29cxf2ovkkwz5niWJzOIN+dLPDJ+ogSn0y4Wl8fIS9O2JtQff5/cInHRImgZBvl+N1g==} + peerDependencies: + ol: '>= 5.3.0' + dependencies: + ol: 7.5.2 + dev: false + + /ol-mapbox-style@10.7.0: + resolution: {integrity: sha512-S/UdYBuOjrotcR95Iq9AejGYbifKeZE85D9VtH11ryJLQPTZXZSW1J5bIXcr4AlAH6tyjPPHTK34AdkwB32Myw==} + dependencies: + '@mapbox/mapbox-gl-style-spec': 13.28.0 + mapbox-to-css-font: 2.4.5 + ol: 7.5.2 + dev: false + + /ol@7.5.2: + resolution: {integrity: sha512-HJbb3CxXrksM6ct367LsP3N+uh+iBBMdP3DeGGipdV9YAYTP0vTJzqGnoqQ6C2IW4qf8krw9yuyQbc9fjOIaOQ==} + dependencies: + earcut: 2.2.4 + geotiff: 2.1.3 + ol-mapbox-style: 10.7.0 + pbf: 3.2.1 + rbush: 3.0.1 + dev: false + /open@8.4.2: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} @@ -7461,6 +7744,14 @@ packages: /package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + /pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + dev: false + + /pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + dev: false + /parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -7468,6 +7759,10 @@ packages: callsites: 3.1.0 dev: true + /parse-headers@2.0.6: + resolution: {integrity: sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==} + dev: false + /parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -7522,6 +7817,14 @@ packages: effector: 23.3.0 dev: true + /pbf@3.2.1: + resolution: {integrity: sha512-ClrV7pNOn7rtmoQVF4TS1vyU0WhYRnP92fzbfF75jAIwpnzdJXf8iTd4CMEqO4yUenH6NDqLiwjqlh6QgZzgLQ==} + hasBin: true + dependencies: + ieee754: 1.2.1 + resolve-protobuf-schema: 2.1.0 + dev: false + /picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -7772,10 +8075,21 @@ packages: engines: {node: '>=12'} dev: true + /process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + dev: false + /process@0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} + /proj4@2.15.0: + resolution: {integrity: sha512-LqCNEcPdI03BrCHxPLj29vsd5afsm+0sV1H/O3nTDKrv8/LA01ea1z4QADDMjUqxSXWnrmmQDjqFm1J/uZ5RLw==} + dependencies: + mgrs: 1.0.0 + wkt-parser: 1.4.0 + dev: false + /prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} dependencies: @@ -7791,6 +8105,10 @@ packages: signal-exit: 3.0.7 dev: false + /protocol-buffers-schema@3.6.0: + resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==} + dev: false + /proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} dev: false @@ -7807,6 +8125,15 @@ packages: /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + /quick-lru@6.1.2: + resolution: {integrity: sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==} + engines: {node: '>=12'} + dev: false + + /quickselect@2.0.0: + resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==} + dev: false + /quote-unquote@1.0.0: resolution: {integrity: sha512-twwRO/ilhlG/FIgYeKGFqyHhoEhqgnKVkcmqMKi2r524gz3ZbDTcyFt38E9xjJI2vT+KbRNHVbnJ/e0I25Azwg==} dev: true @@ -7817,6 +8144,12 @@ packages: safe-buffer: 5.2.1 dev: true + /rbush@3.0.1: + resolution: {integrity: sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==} + dependencies: + quickselect: 2.0.0 + dev: false + /react-chartjs-2@5.3.0(chart.js@4.4.8)(react@18.3.1): resolution: {integrity: sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==} peerDependencies: @@ -8087,6 +8420,18 @@ packages: dependencies: pify: 2.3.0 + /readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + dev: false + /readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -8205,6 +8550,12 @@ packages: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} dev: true + /resolve-protobuf-schema@2.1.0: + resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} + dependencies: + protocol-buffers-schema: 3.6.0 + dev: false + /resolve@1.22.10: resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} engines: {node: '>= 0.4'} @@ -8266,6 +8617,10 @@ packages: dependencies: queue-microtask: 1.2.3 + /rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + dev: false + /safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -8277,6 +8632,10 @@ packages: isarray: 2.0.5 dev: true + /safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + dev: false + /safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} dev: true @@ -8298,6 +8657,10 @@ packages: is-regex: 1.2.1 dev: true + /sax@1.4.1: + resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + dev: false + /scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} dependencies: @@ -8360,6 +8723,10 @@ packages: es-object-atoms: 1.1.1 dev: true + /setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + dev: false + /sharp@0.33.5: resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -8508,6 +8875,24 @@ packages: react-dom: 18.3.1(react@18.3.1) dev: false + /sort-asc@0.1.0: + resolution: {integrity: sha512-jBgdDd+rQ+HkZF2/OHCmace5dvpos/aWQpcxuyRs9QUbPRnkEJmYVo81PIGpjIdpOcsnJ4rGjStfDHsbn+UVyw==} + engines: {node: '>=0.10.0'} + dev: false + + /sort-desc@0.1.1: + resolution: {integrity: sha512-jfZacW5SKOP97BF5rX5kQfJmRVZP5/adDUTY8fCSPvNcXDVpUEe2pr/iKGlcyZzchRJZrswnp68fgk3qBXgkJw==} + engines: {node: '>=0.10.0'} + dev: false + + /sort-object@0.3.2: + resolution: {integrity: sha512-aAQiEdqFTTdsvUFxXm3umdo04J7MRljoVGbBlkH7BgNsMvVNAJyGj7C/wV1A8wHWAJj/YikeZbfuCKqhggNWGA==} + engines: {node: '>=0.10.0'} + dependencies: + sort-asc: 0.1.0 + sort-desc: 0.1.1 + dev: false + /source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -8531,6 +8916,13 @@ packages: whatwg-url: 7.1.0 dev: true + /ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + dependencies: + frac: 1.1.2 + dev: false + /stable-hash@0.0.4: resolution: {integrity: sha512-LjdcbuBeLcdETCrPn9i8AYAZ1eCtu4ECAWtP7UleOiZ9LzVxRzzUZEoZ8zB24nhkQnDWyET0I+3sWokSDS3E7g==} dev: true @@ -8666,6 +9058,12 @@ packages: es-object-atoms: 1.1.1 dev: true + /string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + dependencies: + safe-buffer: 5.1.2 + dev: false + /strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -9435,6 +9833,10 @@ packages: graceful-fs: 4.2.11 dev: true + /web-worker@1.5.0: + resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} + dev: false + /webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} dev: true @@ -9554,11 +9956,25 @@ packages: dependencies: isexe: 2.0.0 + /wkt-parser@1.4.0: + resolution: {integrity: sha512-qpwO7Ihds/YYDTi1aADFTI1Sm9YC/tTe3SHD24EeIlZxy7Ik6a1b4HOz7jAi0xdUAw487duqpo8OGu+Tf4nwlQ==} + dev: false + + /wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + dev: false + /word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} dev: true + /word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + dev: false + /wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -9588,6 +10004,33 @@ packages: optional: true dev: true + /xlsx-populate@1.21.0: + resolution: {integrity: sha512-8v2Gm8BehXo6LU7KT802QoXTPkYY1SKk5V8g/UuYZnNB3JzXqud/P99Pxr2yXeKyt+sKlCatmidz6jQNie1hRw==} + dependencies: + cfb: 1.2.2 + jszip: 3.10.1 + lodash: 4.17.21 + sax: 1.4.1 + dev: false + + /xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + dev: false + + /xml-utils@1.10.2: + resolution: {integrity: sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==} + dev: false + /y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -9637,6 +10080,10 @@ packages: /zod@3.24.2: resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} + /zstddec@0.1.0: + resolution: {integrity: sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==} + dev: false + /zustand@5.0.3(@types/react@18.3.18)(react@18.3.1): resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==} engines: {node: '>=12.20.0'}