Compare commits

..
Author SHA1 Message Date
leedj312 2298549a73 통계 연간 사용량 오류 수정 2025-07-25 15:10:20 +09:00
838 changed files with 15562 additions and 54259 deletions
-1
View File
@@ -1,6 +1,5 @@
# turbo
.turbo/
.vscode/**/*
# dependencies
node_modules/
-1
View File
@@ -201,4 +201,3 @@ http://dev.onasys.co.kr:8000/webjars/swagger-ui/index.html?urls.primaryName=atta
- 사이드바 메뉴 클릭 시 "menuCd" 데이터 쿠키 저장 처리
- 쿠키명 : menu-code-key
- 해당 메뉴 쿠키 데이터 가져와 페이지 권한 및 상당 Breadcrumb 데이터 전달 처리
-7
View File
@@ -1,7 +0,0 @@
# apps/cs/src/shared/config/env.ts 참고
NEXT_PUBLIC_PORT=3000
NEXT_PUBLIC_PROXY_HOST=http://localhost:3000
NEXT_PUBLIC_SITE_ID=CS
NEXT_PUBLIC_SUB_DOMAIN=http://localhost:3000
NEXT_PUBLIC_SERVER_API_URL=http://192.168.0.102:8000
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
-7
View File
@@ -1,7 +0,0 @@
# apps/cs/src/shared/config/env.ts 참고
NEXT_PUBLIC_PORT=3000
NEXT_PUBLIC_PROXY_HOST=http://localhost:3000
NEXT_PUBLIC_SITE_ID=CS
NEXT_PUBLIC_SUB_DOMAIN=http://localhost:3000
NEXT_PUBLIC_SERVER_API_URL=http://192.168.0.102:8000
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
-10
View File
@@ -1,10 +0,0 @@
# apps/cs/src/shared/config/env.ts 참고
NEXT_PUBLIC_PORT=3000
NEXT_PUBLIC_PROXY_HOST=http://localhost:3000
NEXT_PUBLIC_SITE_ID=CS
NEXT_PUBLIC_SUB_DOMAIN=http://localhost:13000
NEXT_PUBLIC_SERVER_API_URL=http://dev.onasys.co.kr:8000
NEXT_PUBLIC_LINK_BACKEND_API_HOST=http://rsone.iptime.org:10014
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
NEXT_PUBLIC_SERVER_API_URL2=http://con.mind-one.co.kr:11083/
-7
View File
@@ -1,7 +0,0 @@
# apps/cs/src/shared/config/env.ts 참고
NEXT_PUBLIC_PORT=3000
NEXT_PUBLIC_PROXY_HOST=http://localhost:3000
NEXT_PUBLIC_SITE_ID=CS
NEXT_PUBLIC_SUB_DOMAIN=http://localhost:3000
NEXT_PUBLIC_SERVER_API_URL=http://192.168.0.102:8000
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
BIN
View File
Binary file not shown.
-41
View File
@@ -1,41 +0,0 @@
# 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/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.env*
!.env.example
-3
View File
@@ -1,3 +0,0 @@
{
"plugins": ["prettier-plugin-tailwindcss"]
}
@@ -1,2 +0,0 @@
import { DashboardPage } from "@/pages/dashboard";
export default DashboardPage;
-35
View File
@@ -1,35 +0,0 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { NoLNBLayout } from "@common/components";
import type { MenuItem } from "@/entities/menu";
import { useMenuStore } from "@/shared/lib";
import { PageLayout } from "@/shared/ui/page-layout";
export default function WithBaseLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const queryClient = useQueryClient();
const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo);
const handleMenuClick = (item: MenuItem) => {
setCurrentMenuInfo(item);
router.push(item.urlPath);
};
return (
<NoLNBLayout
queryClient={queryClient}
currentPath={pathname ?? ""}
handleMenuClick={handleMenuClick}
>
<PageLayout>{children}</PageLayout>
</NoLNBLayout>
);
}
@@ -1,2 +0,0 @@
import { SkeletonPage } from "@/shared/ui/skeleton-page";
export default SkeletonPage;
-145
View File
@@ -1,145 +0,0 @@
import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
REFRESH_TOKEN,
SERVER_API_URL,
SITE_ID,
} from "@/shared/config";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
async function handler(
request: Request,
params: { url: string[] },
method: "GET" | "POST" | "PUT" | "DELETE",
) {
const cookieStore = await cookies();
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
};
let body = {};
const isRefreshHandler = params.url.includes("refresh");
console.log(`[Proxy] ${method} ${params.url.join("/")}`);
console.log(`[Proxy] Server API URL: ${SERVER_API_URL}`);
// 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();
console.log(`[Proxy] Request body:`, body);
}
const targetUrl = `${SERVER_API_URL}/${params.url.join("/")}`;
console.log(`[Proxy] Target URL: ${targetUrl}`);
const result = await fetch(targetUrl, {
method,
headers,
body: JSON.stringify(body),
});
console.log(`[Proxy] Response status: ${result.status}`);
console.log(`[Proxy] Response headers:`, Object.fromEntries(result.headers.entries()));
// 2FA 관련 엔드포인트인 경우 응답 본문도 확인
const is2FAEndpoint = params.url.includes("2fa") || params.url.includes("auth");
let responseData = {};
if (is2FAEndpoint) {
try {
responseData = await result.json();
console.log(`[Proxy] 2FA Response data:`, responseData);
} catch {
// JSON 파싱 실패 시 빈 객체 사용
console.log(`[Proxy] Failed to parse JSON response`);
}
}
const accessToken = result.headers.get(ACCESS_TOKEN) || result.headers.get('ss-token');
const refreshToken = result.headers.get(REFRESH_TOKEN);
const userId = result.headers.get(AUTH_USER_ID);
console.log(`[Proxy] Tokens - accessToken: ${!!accessToken}, userId: ${!!userId}, refreshToken: ${!!refreshToken}`);
console.log(`[Proxy] Available headers:`, Array.from(result.headers.keys()));
// 2FA 엔드포인트이고 실패한 경우 실제 오류 메시지 반환
if (!result.ok && is2FAEndpoint) {
console.log(`[Proxy] 2FA endpoint failed, returning actual error`);
return NextResponse.json(responseData, { status: result.status });
}
// 2FA 엔드포인트이고 성공한 경우 (토큰이 헤더에 없을 수 있음)
if (result.ok && is2FAEndpoint) {
console.log(`[Proxy] 2FA endpoint succeeded, returning response data`);
return NextResponse.json(responseData, { status: result.status });
}
// 일반적인 실패 처리
if (!result.ok) {
console.log(`[Proxy] General endpoint failed`);
return NextResponse.json(
{ message: "Invalid Credentials" },
{ status: 401 },
);
}
if (accessToken && userId) {
const response = NextResponse.json({
[ACCESS_TOKEN]: accessToken,
[AUTH_USER_ID]: userId,
...responseData, // 2FA 관련 추가 데이터 포함
});
if (refreshToken) {
cookieStore.set(REFRESH_TOKEN, refreshToken, {
httpOnly: true,
sameSite: "lax",
path: "/",
});
}
console.log(`[Proxy] Success response sent`);
return response;
}
console.log(`[Proxy] Missing tokens in response`);
return NextResponse.json(
{ message: "Invalid Credentials" },
{ status: 401 },
);
} catch (error) {
console.error(`[Proxy] Error:`, 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");
}
-2
View File
@@ -1,2 +0,0 @@
import { LoginPage } from "@/pages/auth/login";
export default LoginPage;
-17
View File
@@ -1,17 +0,0 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
REFRESH_TOKEN,
} from "@/shared/config";
export async function POST() {
const cookieStore = await cookies();
cookieStore.delete(ACCESS_TOKEN);
cookieStore.delete(REFRESH_TOKEN);
cookieStore.delete(AUTH_USER_ID);
cookieStore.delete(CLAIM_NAME);
return NextResponse.json({ message: "Logged out" }, { status: 200 });
}
-8
View File
@@ -1,8 +0,0 @@
import type { Metadata } from "next";
import { App } from "@/app";
export const metadata: Metadata = {
title: "유역하수도 통합 정보플랫폼",
};
export default App;
-21
View File
@@ -1,21 +0,0 @@
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"),
{
rules: {
"react-hooks/exhaustive-deps": "off",
},
},
];
export default eslintConfig;
-182
View File
@@ -1,182 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import {
CLAIM_NAME,
AUTH_USER_ID,
PORT,
PROXY_HOST,
SERVER_API_URL,
SITE_ID,
} from "./src/shared/config";
const serverApiUrl =
process.env.NEXT_PUBLIC_SERVER_API_URL ?? "http://localhost:3000";
const serverApiUrl2 =
process.env.NEXT_PUBLIC_SERVER_API_URL2 ?? serverApiUrl;
const serverApiUrl3 =
process.env.NEXT_PUBLIC_SERVER_API_URL3 ?? serverApiUrl;
const serverApiUrl4 =
process.env.NEXT_PUBLIC_SERVER_API_URL4 ?? serverApiUrl;
const serverApiUrl5 =
process.env.NEXT_PUBLIC_SERVER_API_URL5 ?? serverApiUrl;
export function middleware(req: NextRequest) {
const pathname = req.nextUrl.pathname;
const searchParams = req.nextUrl.searchParams;
const logMessages: string[] = [
`[Middleware Request] ${new Date().toISOString()}`,
`[Environment]\n Mode: ${process.env.NODE_ENV}\n Port: ${PORT}\n Proxy Host: ${PROXY_HOST}\n Server API URL: ${SERVER_API_URL}\n Site ID: ${SITE_ID}`,
`[Request]\n Original Pathname: ${pathname}`,
];
if (pathname.startsWith("/server")) {
const queryParams = searchParams.toString();
const newUrl = `${serverApiUrl}${pathname.replace("/server", "")}${
queryParams ? `?${queryParams}` : ""
}`;
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
return NextResponse.rewrite(new URL(newUrl));
}
if (pathname.startsWith("/attach-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 = `${serverApiUrl}${pathname}`;
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
return NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
});
}
if (pathname.startsWith("/sewerage-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 = `${serverApiUrl2}${pathname}`;
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
return NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
});
}
if (pathname.startsWith("/reuse-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 = `${serverApiUrl3}${pathname}`;
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
return NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
});
}
if (pathname.startsWith("/operation-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 = `${serverApiUrl4}${pathname}`;
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
return NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
});
}
if (pathname.startsWith("/ic-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 = `${serverApiUrl5}${pathname}`;
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
return NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
});
}
if (pathname.startsWith("/dc-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 = `${serverApiUrl5}${pathname}`;
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
return NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
});
}
if (pathname.startsWith("/user-service")) {
const queryParams = searchParams.toString();
const newUrl = `${serverApiUrl}${pathname}${
queryParams ? `?${queryParams}` : ""
}`;
logMessages.push(` User Service Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
return NextResponse.rewrite(new URL(newUrl));
}
if (pathname === "/") {
return NextResponse.redirect(new URL("/dashboard", req.url));
}
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
return NextResponse.next();
}
export const config = {
matcher: ["/server/:path*", "/attach-service/:path*", "/user-service/:path*", "/"],
};
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-40
View File
@@ -1,40 +0,0 @@
import type { NextConfig } from "next";
const subDomain =
process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000";
const backendUrl = process.env.NEXT_PUBLIC_SERVER_API_URL || "http://192.168.0.102:8000";
const nextConfig: NextConfig = {
output: "standalone",
async rewrites() {
return {
beforeFiles: [
{
source: "/_next/static/:path*",
destination: `${subDomain}/_next/static/:path*`,
has: [
{
type: "header",
key: "referer",
value: "(.*)/cms(.*)",
},
],
},
],
afterFiles: [
{
source: "/cms/:path*",
destination: `${subDomain}/cms/:path*`,
},
{
source: "/user-service/:path*",
destination: `${backendUrl}/user-service/:path*`,
},
],
fallback: [],
};
},
};
export default nextConfig;
-63
View File
@@ -1,63 +0,0 @@
{
"name": "cm",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@common/assets": "workspace:*",
"@common/components": "workspace:*",
"@common/config": "workspace:*",
"@fontsource-variable/noto-sans-kr": "^5.2.5",
"@frontend-opensource/use-react-hooks": "^1.1.4",
"@fullcalendar/core": "^6.1.17",
"@fullcalendar/daygrid": "^6.1.17",
"@fullcalendar/interaction": "^6.1.17",
"@fullcalendar/react": "^6.1.17",
"@fullcalendar/timegrid": "^6.1.17",
"@hookform/resolvers": "^4.1.3",
"@minoru/react-dnd-treeview": "3.5.1-alpha.1",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@tanstack/react-query": "^5.67.1",
"@tanstack/react-query-devtools": "^5.75.5",
"@tanstack/react-table": "^8.21.2",
"@tinymce/tinymce-react": "^6.1.0",
"axios": "^1.8.1",
"chart.js": "^4.4.8",
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"lucide-react": "^0.474.0",
"next": "15.3.0",
"next-qrcode": "^2.5.1",
"next-themes": "^0.4.6",
"react-chartjs-2": "^5.3.0",
"react-dnd": "^16.0.1",
"react-hook-form": "^7.54.2",
"sonner": "^2.0.1",
"tailwindcss-animate": "^1.0.7",
"tus-js-client": "^4.3.1",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@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"
}
}
View File
-8
View File
@@ -1,8 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

-63
View File
@@ -1,63 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 538 755">
<defs>
<style>
.cls-1 {
fill: #fff;
}
</style>
</defs>
<!-- Generator: Adobe Illustrator 28.7.9, SVG Export Plug-In . SVG Version: 1.2.0 Build 218) -->
<g>
<g id="Layer_1">
<g id="han" data-label-shift="dx,dy">
<path class="cls-1" d="M159.2,197l-5,5.8,5.1,3.7,13.2,9.7-7.3,3.5-8.2-1.2-9.4,1.4,1.4,4.9-3.5,7.3,3.8,2.6-.9,4.7,8.9-2.3,4.2-3.7,5.2,4.2-6.1,2.3.9,12,3,7-.9,3.3-.5,1.6,5.4,6.8,5.2,13.4h-3.6c0-.1,6.4,3.8,6.4,3.8l4.8,6h1.2l3.6-5.4,3.6-.6,13.8,5.9,19.7-1.1,4.6-6.5,3-5.2,8.2-1.7,11.7-17.8,7.1,1.9,4.9,10,13.2,9.6,3.8,13-1.4,12.2-.5,9.5,1.6,1.1-.6,1.3v1.1c.1,0-2.2,2.4-2.2,2.4l-17.5,7.4,7.6,19.2,16.3,1.1,8.4,10.8,12.6.6,4.2-5.2-4.6-7.9-9.2-6.2,12.1-7.1,6.4,5.2,1.3-5.6-6.6-6.5,17.5-5.8,4.8,4.4,4.8-2.1-4.7-6.5,4.5-8.8,6.3-.4,4.7-3,8,3,24.1,4.5,11.5-7.8-2.8-6.4,6.5-11.7,10.5-5.5,7.6-7.9,10.2-2.3,13.4,4.6,4.4-8,12.4,8.2,3.4-3.1.2-15.2.4-11.3,13.4-3.2,4.6-.4-1.9,9.9,9.8,4.6,1.6,12.8,12.2,9.2,9.8-8.4,15-8.9-3.7-19-21.8-40.6-5.7-10.6,1.2-6.8-4.9-6.1-4-4.9,1.2-5.2-26.3-33.1-5.8-12.1-7.1-14.9-11.9-20.9-2.9-9.4-2.1-6.7-1.8-5.2-8.7-25.5-8.6-14.4-.9-11-5.2-11-3-6.3-6,6.5-5.2,22.8-9.8,11-5.5,2.8-7.4,3.7-17.1.5-12.7-1.6-8.2,2.3-5.2-3.4-3.4-2.2-18.8-2.3-21.8,4.5-19-3.7-3.5,4.7-11.2-7.3-5.2,4.2-6.1-.7-11.5,3.5-14.1,10.8-3.7,10.1-12.9,9.8-4,7.3-8.5,6.8-2.3,1.9-9.1,4.5-4,18.3-9.2,11.5-14.3,1.2-2.1,30,5.6,1.4,3.3,9.2-1.4,2.7.9,2.6,2.9-.7,1.8,3.2-4.5,2.2,1.7,8.6,3.8,6.9,8.9,3.4Z"/>
</g>
<g id="yeongsan" data-label-shift="dx,dy">
<path class="cls-1" d="M57.4,578.4s.2,1.4.2,2.3.7,1.6.7,1.9.5,1.4.7,2.3c.2.9,1.2,1.2,1.2,1.2h0s.5,1.4.5,1.4c0,0-.5.7-.9,1.4-.5.7,1.6.9,1.6.9l2.8-.7s1.4-.7,1.4-1.2,0,0-.2-.7c-.2-.7-.5-.7-.9-1.2-.5-.5.5-.2.5-.2l.5.2,2.6-.5s.9-.5,1.4-.7c.5-.2-.2-.5-.2-.9s-.2-1.2-.2-1.2l.2-.9v-1.2c0-.5.7-.7.9-1.2.2-.5.5-.7.9-1.2.5-.5,0-.7,0-1.6s-.5-.5-.5-.9-.5,0-.5,0l-.9.5-.9.7s-.2.9-.5,1.2c-.2.2-1.4.2-1.4.2,0,0-.2-.5-1.2-.9-.9-.5,0,0-.5,0s-1.2-.2-1.2-.2c0,0-1.2-.7-1.6-.9-.5-.2-.5-.9-.5-.9h-1.2s-1.9,1.4-2.8,1.9c-.9.5,0,1.2,0,1.2Z"/>
<path class="cls-1" d="M45.2,585.7c.2.2,0,.2.7.9.7.7.5,1.6.9,1.6s.2-.5,1.2,0c.9.5,1.4.5,1.9.7.5.2,1.6.9,1.9.9s1.4.7,1.4.9-.2.2,0,.5c.2.2,0,.5.5,1.4.5.9,1.9,2.6,1.9,2.6,0,0-.7.7.5.5,1.2-.2,1.9-.2,1.9-.9s.2-1.2.2-1.4-.2-.2-.5-1.2c-.2-.9-.7-1.6-.7-1.6,0,0,1.2-.2,0-1.2-1.2-.9-.2-1.2-1.2-.9-.9.2-1.9.2-1.9.2-.2-1.2-.2-1.9-.2-1.9l-.5-.5.7-1.4h0c.5,0,.9-.2,1.2-.2s.9-.7.9-.7c0,0,.2-.2,0-.7-.2-.5-.2-.2-.9-.7s-1.6-.7-1.6-.7l-2.1-1.2-1.4-.9c-.7-.2-2.6.9-2.6.9,0,0,.9-.5,0,.7-.9,1.2-2.3,1.9-2.3,1.9,0,0-.2.5,0,.9.2.5,0,1.2.2,1.4Z"/>
<path class="cls-1" d="M28.6,585.5s0,.7,1.2.7h2.1c.5,0,.9.7.9.7v1.9l1.2.7s1.4,0,1.9-.5c.5-.5.5-.7.5-.7l-.5-1.4,2.3-.7s.5-.5.7-1.2c.2-.7-.2-1.2,0-2.3.2-1.2,0-2.8,0-2.8,0,0,.9-1.4.9-2.1s1.9-2.6,1.9-2.8h0v-.7h-1.4c-.2,0-1.7.5-2.1.7-.5.2,0,0-.9,1.2s-.2,1.6-.2,1.6l-1.9.5-1.4.7-.9.2s-1.2-.2-2.1-.2-.5.9-.5.9c0,0-1.2,1.4-1.9,1.6-.7.2-.7.7-.7.7,0,0,.9,2.3.9,2.8v.5Z"/>
<path class="cls-1" d="M41.7,591.4c-.5.2-.7-1.2-.5,0,.2,1.2,1.2,2.1,1.2,2.1l1.4.9s.9-.2,1.4,0c.5.2.9,0,.9,0l1.9.7v.2s.7-.2.5-1.6c-.2-1.4,0-1.4,0-1.4l-.7-.9-2.6-1.4h0s-2.8-.7-3-.5c-.2.2-.5-.2-.5.2s.5,1.4,0,1.6Z"/>
<path class="cls-1" d="M34.2,597.7s0,0,0,0c0,0,0,0,0,0Z"/>
<path class="cls-1" d="M45,598.9l-.7-.7-.5-.9s.2-1.4-.2-1.4-1.9-.5-1.9-.5l-1.4-.7h0s-1.4,0-1.4,0c0,0-.9,0-1.2.7-.2.7-.9.5-.9.9s-2.1,1.2-2.5,1.4c0,0,.2.2.4.5.5.7.5,1.4,1.2,1.4s2.1.2,2.3,0c.2-.2.9-.7,1.2-.5.2.2.5.5.5.9s-.5,1.2-.5,1.6.5,0,0,.5c-.5.5.2,1.4.5,1.6.2.2-.7,2.1-.7,2.1,0,0-.5.5-.5.7s1.4.7,1.6,0c.2-.7.7-1.2.9-1.4.2-.2.9-.5.9-.5,0,0-.5.2.7.2s2.1.2,2.1-.5v-1.9s-.2-1.2,0-1.4c.2-.2,0-2.3,0-2.3Z"/>
<path class="cls-1" d="M18.5,619.7c.7,0,1.9-.9,2.3,0,.5.9,1.9,2.6,1.9,2.6,0,0,.2-.2,1.4,0,1.2.2,1.2.5,2.1,0,.9-.5,1.4-1.4,1.4-1.4l1.2-1.2,3.3-1.4s-.2-.7-.7-2.1c-.5-1.4-.7-2.1-.7-2.1,0,0,1.2.2.5-.9-.7-1.2-.9-1.9-.9-1.9l-.5-1.6h0s-.7-.9-.7-.9l-2.1.2s-.2,1.2,0,1.9c.2.7.2.9,0,1.4-.2.5-.7-.5-1.2,0-.5.5-2.3.2-2.3.2h-1.2l-.9,1.4-1.2.9-2.3.9-2.1,1.4s-.5,0,0,.9c.5.9,2.1,1.6,2.8,1.6Z"/>
<path class="cls-1" d="M33.1,624h-.9l-1.4-.7-2.1.7s-.9,1.2-.9,1.6.7,1.9,1.4,2.3c.7.5,1.6.7,1.9,1.6.2.9.2,1.9.2,1.9,0,0,1.9.7,2.3-.7.5-1.4.2-2.3.7-2.6.5-.2,1.9-1.6,1.9-1.6,0,0-.5-.5.7-.5s1.6-.5,1.6-.7v-1.9l-.9-.9h.9l.7-.9s.2-.7,0-1.2c-.2-.5-.5-1.9-.5-1.9l-2.6.5h0l-1.4,1.4-.5.9s.2.9,0,1.2c-.2.2-1.2,1.4-1.2,1.4Z"/>
<path class="cls-1" d="M65.6,620.2l1.9-.7-2.1-2.6-3.5-1.9h0s-3.3.7-3.3.7l1.6,2.1.9,1.4s-.5.5-.9.9c-.5.5-1.6.7-1.6.7,0,0-.5.5-.5.9s-.9,0-2.1.7c-1.2.7-4.5,1.9-4.5,1.9h-.9c.9,0,1.6,2.6,2.3,2.6h3.1s-.2-.5.9-.7c1.2-.2,2.6-1.2,2.6-1.2,1.4.2,2.3-.7,3,.5.7,1.2,1.4.9,1.6,1.4.2.5,1.2,2.1,1.2,2.1l-.7,2.6s1.2.9,1.6.7c.5-.2,1.9-1.4,1.9-1.4l.9-1.9s-.5-1.4,0-1.9c.5-.5-1.6-1.4-1.6-1.4l-2.8-.7-1.9-2.6v-1.2l2.8-1.2Z"/>
<path class="cls-1" d="M64.2,635.5v-1.6s-3.3,1.4-3.3,1.6.9,1.6.9,1.6h1.6l.7-1.6Z"/>
<path class="cls-1" d="M59.8,639.2c0,1.2.5,2.8,1.2,3,.7.2,1.6,0,1.6,0v-3l-1.6-.9s-1.2-.2-1.2.9Z"/>
<path class="cls-1" d="M38.4,638.3c1.2,0,2.8-4.5,2.8-4.5v-1.4h0s-3.5,0-3.5,0l-2.1,1.4-3.5.7s-.2,1.9,1.4,1.9.7-.1.6-.2c0,0,.3,0,.6.2,1.6.5,3.3.7,3.3,1.2s-.7.7.5.7Z"/>
<path class="cls-1" d="M15.4,630.1h0s0,0,0,0h0Z"/>
<path class="cls-1" d="M7,642.7c.2-.5,1.2-.7,1.2-1.4s-1.6-2.6-.5-3.1c1.2-.5,2.6-.9,2.8-1.2.2-.2,1.2-.7,1.6-.5s.9.9.9.9l1.4-.5.5-.9s.2-.9.5-1.4c.2-.5.7-1.9.7-1.9v-1.2l-.7-1.6c-.3.2-2.6,2.1-2.8,2.3-.2.2-3,1.2-3.3,1.2s-3.1.2-3.1.2l-3.8.5-2.6,1.9,1.9,2.1s-.2,1.9,0,2.1c.2.2-.5,1.2.5,1.6.9.5,1.6.9,1.6.9,0,0,2.8.2,3-.2Z"/>
<path class="cls-1" d="M16.6,649.1c0-.7-1.2-1.6-1.2-1.6l-.7-.7-.9-.9s-.2-1.2-.2-1.4-.7-.9-.7-.9c0,0-2.3.2-2.3.5s-.7.7-.7.7c0,0-.2.7-1.6.7s-2.8-.5-2.6,0c.2.5-.2.9-.2,1.6s-.5,1.9-.5,1.9v1.2s.2,1.2.7,1.4c.5.2,1.2.5,1.4.7.2.2,1.4.9,1.2,1.4-.2.5,2.3.2,2.3.2l.5-.5c.2-.2,1.9-1.2,1.9-1.2,0,0,0-.9.5-.9s3.3-.5,3.3-.5v-1.6Z"/>
<polygon class="cls-1" points="12.9 643.4 12.9 643.4 12.9 643.4 12.9 643.4"/>
<path class="cls-1" d="M27.7,639.9c.5.5,1.4,1.6,2.1,1.9.7.2,1.2.5,1.4,1.2.2.7.2,1.4.5,2.1.2.7,1.9.7,1.9,1.2s.5-.2,1.4,0c.9.2.2-.2,1.6,1.2,1.4,1.4,2.3,2.1,2.6,2.6.2.5.9,0,.9,0l1.2-.9s-.2-1.6,0-1.6.2-2.1.2-2.3-.5-2.3-.5-2.3c0,0-1.2-1.4-1.4-1.4s-2.1-.7-2.1-.7l-1.2-1.2-.9-1.2-1.4-.5h0l-3.7-.7h-2.1l-1.2,1.6s.2.7.7,1.2Z"/>
<path class="cls-1" d="M32.6,663.4c.5.2,2.3.5,2.8.5s1.6-.2,1.9-.5c.2-.2.9-.2,1.6-.2s3.5.2,3.5.2c0,0,.5-.7.2-1.4-.2-.7-.9-1.4-.9-1.4l-.2-.7-.7-.5.7-1.2v-.9s-.7-.5-1.4-.5-1.4.2-1.4-.2-.9-1.9-.9-1.9l-1.4.7h0c-.9.5-1.9.7-2.1.9-.2.2-3.3.2-3.1.7.2.5.7,1.2.9,1.4.2.2.9,1.6.9,1.6,0,0-.5,1.6-.5,1.9s-.5,1.2,0,1.4Z"/>
<path class="cls-1" d="M21.3,670.4c0-.2,0-1.4.5-1.6.5-.2.7-.5.7-.7s-.7-1.2-.7-1.4-1.2-1.2-1.2-1.2l-.5-1.2-.2-1.2-.2-1.9h0s-.9-1.9-1.2-1.9-1.4-.2-1.4,0-.5.9-.5.9v.7c0,.2-.2.7,0,1.2.2.5-.2,1.4,0,1.6.2.2.2.7.5,1.4.2.7.7,1.2.7,1.4s-.2,1.2-.2,1.2c0,0-.5-.7-.5.5s-1.9,1.9-.5,2.1c1.4.2,2.8.5,3,.5s1.6-.2,1.6-.5Z"/>
<path class="cls-1" d="M27.4,673s-.5-.9,0-1.6c.3-.5.5-.9.7-1.2.2-.5.9-.5.9-.9s.5-2.3,0-2.8c-.5-.5-.9-1.6-1.4-2.3-.5-.7-.2-.9-1.2-1.6-.9-.7-1.9-1.2-1.9-1.2l-1.2.2h0s-.7,2.1-.7,2.3.2,2.3.7,2.6c.5.2,1.6,1.4,1.9,1.9.2.5.2,1.2.2,1.6s-.7,2.1-.9,2.1,0,0-1.2.5c-1.2.5-2.6.2-3,.9-.5.7-1.2,1.4-1.2,1.4v1.4c0,.2-.2.7.5.7s2.3-.2,2.8-.5c.5-.2,1.4-.7,1.4-.7l2.3-2.1,1.2-.7Z"/>
<path class="cls-1" d="M63.7,683.1c-.2-.5-1.4-2.1-1.4-2.1l-.5-.9s0-1.6-.9-1.9c-.9-.2-3-.2-3-.2l-1.6-2.3s-1.4-2.6-1.6-2.6-1.4,0-2.1-.2c-.7-.2-2.6-1.2-2.6-1.2l-.9.7c.2.7,1.6,2.8,1.6,2.8l1.2,2.3-.5,1.9s-1.9.5-2.3.5h-3s-.5-.7-.7.7c-.2,1.4-2.6,2.6-2.6,2.6h-3s-.9,1.6-.9,2.1-6.1,3.1-6.1,3.1l-1.9,2.6-1.9.5s-.5.7-.5.9-1.2,1.6-1.2,1.6h-.7s-2.3.5-2.3.9.5,1.6.5,2.1v1.6s0,1.6.2,1.6,1.4.7,1.4.7l.7,1.2s.5.2.5.5.7,1.2.7,1.2l1.2,1.2s-.5,1.6,0,2.1c.5.5.9.5,1.4.7.5.2,4.2.7,4.2.7,0,0,1.9.2,2.1.5.2.2,1.4.5,2.1,0,.7-.5,7-1.6,7-1.6,0,0-.7-.9,0-1.2.7-.2,1.9-.5,3-1.4,1.2-.9,3.7-1.2,3.7-1.2,0,0-.5-1.2.2-1.6.7-.5,2.6-2.1,2.8-2.1s2.1,1.4,2.1,1.4c0,0-.7.7.5.7s1.4-.5,1.9-2.1c.5-1.6,1.9-2.3,1.9-2.3l.5-.7s-.5-.5,0-1.6c.5-1.2.9-2.6.9-2.6v-3.5s-.5,0,.5-.5c.9-.5,2.1-2.3,1.4-3.1-.7-.7-1.6-1.9-1.9-2.3Z"/>
<polygon class="cls-1" points="49 672.3 49 672.3 49 672.3 49 672.3"/>
<path class="cls-1" d="M93.5,737.5c-2.3-1.2-4.2-2.3-4.2-2.3h-1.9l-1.2,3.5s1.4,0,1.2.9c-.2.9-1.6.5-1.2,1.6.5,1.2-.5.7,1.6,1.6,2.1.9,2.8,1.9,3.7.9.9-.9,1.9-2.6,1.9-2.6h0l.7-1.2s1.6-1.4-.7-2.6Z"/>
<path class="cls-1" d="M86.2,745l-2.8-1.9s-2.8,2.6-2.8,3.1-2.1,3.3-1.6,4,1.2,1.6,2.8,1.6,3.3-1.6,5.4-2.6c2.1-.9,2.8-2.3,3.7-1.9.9.5,2.1-.5,2.1-.5h0l-1.9-1.2-4.9-.7Z"/>
<path class="cls-1" d="M101.7,742h0l-3.5,1.2s-.9,1.2-.9,1.6.5,2.6.5,2.6c0,0-.5.5-.5,1.6v2.3s1.2,3.1,2.1,3.5c.9.5,2.6-.5,3.1-1.9.5-1.4-.9-5.6-.9-5.6,0,0-2.8-1.2-2.1-1.4.7-.2,2.8-.5,3.1-.5s.9-.7.9-.7l-.7-2.8h-.9Z"/>
<path class="cls-1" d="M115.1,720.4l-1.2-1.6-.5-1.6h0v-2.6l-2.1-3.3s-1.6-1.2-1.9-1.6c-.2-.5-3-2.3-3-2.1s-2.3,2.3-2.3,2.6-3.7,2.8-3.7,2.8c0,0,1.2,4,1.2,4.2s-1.9.2-.5,1.9c1.4,1.6,5.9,2.1,5.9,2.1,0,0,.2-1.4.5.5.2,1.9-.5,2.8-.5,3.1s1.2,1.2,2.6,1.2,3.3-.2,3.5,0c.2.2,3.5.5,3.5.5,0,0,.2,0,0-2.1-.2-2.1-1.4-3.8-1.4-3.8Z"/>
<path class="cls-1" d="M125.6,711.9c.5,0,1.4-1.9,1.4-1.9,0,0-.5-.7.9-.5,1.4.2.9,0,1.6.5.7.5,1.2.5,1.4.5s1.2-.7.5-1.4c-.7-.7-1.4-.9-1.9-1.6-.5-.7-.5-2.6-.5-2.6,0,0-.5-1.4-.7-1.4h-2.8l-.9.9h0c-.7,0-3.5-.7-3.5-.7l-.5,2.8-2.1,1.4s-.9.5-.5,1.2c.5.7.9,1.2.7,1.6-.2.5-1.6,0-1.4.7.2.7,1.6,1.9,1.6,1.9,0,0-.7,1.2-.7,1.6s.9,1.4,1.4,1.4,1.9-2.1,1.9-2.1c0,0,.5-.9.9-1.2.5-.2,2.1-1.2,2.1-1.2h.9Z"/>
<path class="cls-1" d="M141.5,712.4c0-.2-.9-.5-1.2-.7-.2-.2-1.6-.7-1.6-.7l-3-.9h-1.7s-.7,2.1-.7,2.3-.5,1.2,0,1.4c.5.2,2.1.7,2.1.7v1.2c0,.5-.7.7.5,1.2,1.2.5,4.2-.5,4.2-.5h2.6s.9-1.4.5-1.9c-.5-.5-1.6-1.9-1.6-2.1Z"/>
<path class="cls-1" d="M128.6,725.1s.2-.2.5,0c.2.2,1.4-.5,1.4-.5,0,0-.2-.5.5-.5s1.9.5,1.9.5c0,0,.7-.2.9-.7.2-.5-.2-.9,0-1.4.2-.5,0-1.6,0-1.6l-.7-1.6s-.9-.2-1.4-.2-1.2-.2-1.2-.2l-.5-.2-.9-.7h0s-2.3-.2-2.6.5c-.2.7-.7,1.6-.9,1.9-.2.2-1.9.9-1.9.9l1.4,1.4s.2.7,1.2,1.2c.9.5,2.3,1.4,2.3,1.4Z"/>
<path class="cls-1" d="M121.1,719.9c-.3-.3-.7-.5-1.2-.5h0c-.6,0-1.1.6-1.2,1.2,0,.4.2.8.5,1.2.1.2.3.5.7.7.5.3,1.4.6,1.6.2.2-.2,0-.5-.2-1.6,0-.7,0-.9-.2-1.2Z"/>
<path class="cls-1" d="M137.3,747.6c-.9-.5-1.9-.9-1.9-.9l-.5-1.2.9-1.2.2-2.3-4.2-.5-2.1.5h0s-1.2,2.1-1.4,2.6c-.2.5-2.1,1.9-2.1,2.1s0,.7-.2,1.4c-.2.7-.9,1.9,0,2.3.9.5,2.1.7,2.1.7,0,0,2.6-1.4,2.8-.7.2.7.2,1.2,1.2,1.9.9.7,2.1.9,2.3.9s.2-1.2.9-1.4c.7-.2,2.6-1.9,2.6-1.9,0,0,.2-1.9-.7-2.3Z"/>
<path class="cls-1" d="M267.1,679.6s.5-.5,0-1.6c-.5-1.2-.7-2.1-.7-2.1l-.7-1.2-.5-1.4s-.5-.5-.2-1.2c.2-.7,0-2.6,0-2.6l-.2-.7.7-.9h0c.2-.2.5-2.1.5-2.1l-3.3-2.3s-1.4.9-1.2,1.9c.2.9.9,2.1.9,2.6s.2,1.6,0,2.1c-.2.5-.9,1.2-.9,1.4s-.7,1.4-.9,1.9c-.2.5-2.8,1.9-2.8,1.9,0,0-1.9,1.9-1.9,2.1s-1.2.2-.7,1.6c.5,1.4.5,3.3.5,3.3,0,0-1.2.9-.5,1.6.7.7,1.4,1.6,2.8,2.1,1.4.5,4.2,1.6,4.2,1.6l3.1.5s2.3-.9,1.9-1.4c-.5-.5-1.6-1.4-1.6-1.4l-.5-1.2s0-1.2.5-1.9,1.6-2.6,1.6-2.6Z"/>
<polygon class="cls-1" points="279.4 600.4 279.4 586.1 272 579.9 262.7 571.1 261.3 561.5 254.5 559.9 251.7 557.3 244.7 562.9 243.5 566.9 235.1 563.3 234.4 553 235.1 535.7 240.9 533.6 245.9 532.1 247.1 523.8 256.9 510.3 248.3 507 239.9 515.4 232.7 509.4 238.7 493.8 231.6 486.6 229.2 475.8 217.2 472.2 206.4 484.2 205.2 493.8 195.7 486.6 186.1 495 186.1 502.2 176.5 510.6 159.8 515.4 157.4 523.8 140.6 521.4 127.4 517.8 124.5 512.7 119.4 508.3 115.3 512.5 110.2 512.4 96.4 517.8 89.3 530 84.4 535 85.6 538.3 88.3 540.1 87.2 541.1 82.3 539.9 76.8 551.3 72.3 553.6 67.3 564.8 76.6 566.5 73.6 571.4 75.3 574.4 80.6 583.9 85.4 585.9 85.4 589.7 80 596 78.4 589.4 69.9 588.5 68.8 590 71.3 593.4 75.4 595.9 74.3 598.4 69.7 600 68.7 603.3 63 605.4 61.9 611.5 67.8 615.5 70.4 613.1 73.2 605.9 74.3 613.6 75.3 626.5 69.7 634.5 69.7 639.2 68.1 643.4 67.8 644.3 80.4 650.4 92.7 661.4 89.3 661.9 86.5 659.8 85.6 664 79 654.2 69.4 650 67.8 656.3 70.6 661.9 74.3 663.3 77.1 670.4 72.7 669.9 66.1 661.5 62.9 648.1 58.5 642.1 51.9 654.2 55.1 668.3 59.6 674.6 61.9 673.9 65.8 676.6 67.1 680.5 69.9 680.2 72.7 677.9 79 686.6 77.7 698.2 82.8 699.7 79.7 706.7 76.6 709.9 77.4 712.1 82.5 712.8 82.7 721.2 93.1 719.9 97.3 713.3 97.1 709.8 112.1 701.6 110.7 695.9 117.2 694.3 120.8 688.2 120.2 673.4 122.8 671.1 124.7 675.1 124.5 684.5 122.4 694.3 125.7 699.9 130.4 702.1 134.8 704.2 136.9 700.4 143.5 702.3 145.8 697.6 149.1 698.3 148.3 688.6 152.1 687 152.9 684.2 151.7 682 155 673 162 671.1 167.4 666.2 174.9 662.4 179.6 664 183.8 662.6 187.1 655.8 192.4 654.7 194.6 657.2 202.1 657.2 202.7 663.8 200.6 669.2 196 669.8 194.8 663.8 189.6 664 187.5 671.6 189.1 675.6 185.9 681.9 181.9 681.2 183.1 676 181.4 674.3 176 692.2 189.2 694.5 191.7 701.4 195.5 706.3 203.9 704.4 207.4 697.8 213.3 694.1 214.2 689.9 209.3 687.1 210 684 218 684.7 221 686.6 224.2 683.9 222.7 674.6 209.2 661.4 209.5 657 212.8 653.7 210.5 650.9 215.4 646.2 211.9 643.7 213.5 642.5 216.5 642.5 222 642.5 227.6 640.4 228.1 635.9 230.9 638 231.2 641.7 231.8 647.4 236.2 650.7 238.4 657 234.6 663.8 232.6 672.3 234.9 676.9 238.7 676.9 243.5 679.8 244.5 675.1 243.3 671.1 245.9 662.9 249.9 659.4 255 664.1 261.6 661 262.3 655.8 265.1 651.9 267 645.5 264.6 642 261.1 642.5 257.9 642 248 646 246.3 642 239.9 634.6 244.2 626.8 246.3 631 251.6 632.8 262.5 623.5 278.8 628.6 285.4 617.2 285.4 607.6 279.4 600.4"/>
</g>
<g id="nakdong" data-label-shift="dx,dy">
<path class="cls-1" d="M405.4,649.3l-2.1-.2-1.6,1.2-.9,1.6-1.2.2-.9-.5v-1.2l.2-.9,1.4-.9.9-.7v-.9l.9-.9.7-1.2-.2-1.6-.2-1.4.5-3v-1.9l-1.6-1.6-.9-.9.2-1.9.7-2.8h0c0,0,1.6-.5,1.6-.5,0,0,1.2-.9,1.2-1.2s.2-.7.2-.7l-.2-.9-1.4-.9-1.9-.7-1.6.2-1.2.5-.9,1.2-.7,1.2-.2,1.2.5,1.9v.9l-.5.7-.7.2-1.2.2-.9.5-.5.5-.2,1.2-.2.5h-1.4l-1.4.5s-.9.2-1.2.2-.7.7-.7.7l-1.2.5h-1.4c-.2,0-.7.2-.7.2l-.5.5v.5l.2.7s.7.7.9.7,0,.7,0,.7l.2.9v.7l.2.9.5.5.7.5s.5.5.7.5.2.7.2.7c0,0,.5.7.5.9v2.1h-.2s-.7-.2-.9-.2-.5-.5-.5-.5l-.9-.7h-.9l-.9-.5-.5-.7-.7-1.2-.7-.5-.5-.2-.5.2-.7.7-.7.2h-.9l-1.4-.9-1.4-.9-1.4-.7h-1.2l-.5.2-.7.9-.5.7-1.2.7s-.2,0-.5.2c-.2.2-.7.5-.7.5l-.5.7-.7,1.2-.5,1.2-.9.9v3.3l1.2,1.4s.7,1.4.7,1.6.2,1.4.2,1.4l.7,1.2.7,1.6,1.6.2,1.4-.9.7-1.2.7-.7h2.6l1.4-1.2.9-1.6.7-.9.7-.5.5.5.5.7v2.6s.5.5.7.5,0,.7,0,.7l-.7,1.2-1.2,1.9-.5.7-.2.5-.2.5-.9.5-1.2.2-1.4.2-.7.5-.5.5-.2.7,1.4.5.5.5v.7l.5.5.7.2h1.2l2.1.2.7.5.2.7-.2.5-.7.5s-.9.7-1.2.7-.5.9-.5.9l-.2.7.2.9v.7l.9.2.9.7,1.2.2v.9l-.2.2-.9.5-1.4.5-.7.2-.5.7v.9l.5.7s.5.5.7.5,1.4.2,1.4.2c0,0,1.4.2,1.6.2s.9-.5.9-.5l1.2-.5,1.2-.7.5-.7v-.7l.2-1.2s.5-.9.5-1.2.9-.5.9-.5l1.4-.2.2-.7-.9-.9-.7-1.4.5-1.6v-1.2l.7-.7,1.6-.2,1.6-.2.7-.9.2-1.2v-2.1l.7-.7,1.9.2,1.6-.2.5,2.1,1.2,1.4,2.1.9,1.6-.7.2-1.2-.2-1.2-1.6-1.2-.7-1.4.2-1.2-1.2-.7-.9-.9.5-1.4h1.9c.2,0,.7-.5.7-.5l.7-.9v-1.6h.9c.2,0,.7-.5.7-.5l.2-1.4s.2-.5.7-.7c.5-.2,0-1.4,0-1.4v-1.4Z"/>
<path class="cls-1" d="M330.3,650.8v-1.2l-.9-.5h0s-4.2-1.2-4.2-.9-2.8,0-2.8,0l-1.4.9s-.2.7-.2.9.2,1.2.2,1.2l.5.2.9.7,2.8.5h1.9l.9-.7.9-.7.9-.2.5-.2Z"/>
<polygon class="cls-1" points="333.8 654.8 332.6 654.1 332.2 652.9 331.7 652.7 331 652.9 329.8 653.9 329.6 654.8 329.1 656 329.1 657.6 329.4 659 329.8 659.7 330.8 659.7 331.9 659.5 332.4 659 333.8 659 335 658.8 335.2 657.8 335.2 656.9 334.7 655.7 333.8 654.8"/>
<polygon class="cls-1" points="308 649.2 307.6 648.4 307.6 647.5 308 646.3 308.7 645.2 308.7 644.2 308.3 644 307.3 643.5 306.4 642.8 305.5 641.9 304.7 641.2 303.1 641.4 302.2 642.8 302.2 644.7 301.2 645.2 300.8 644.2 300.5 642.6 301 641.4 301.7 639.8 302.4 638.4 303.6 637.2 303.6 636.5 303.3 635.8 302.6 635.8 301 636 299.8 637 298.4 638.1 298.2 639.5 297.3 640 295.6 640.9 294.7 642.4 293.5 643.5 293.3 644.9 293.3 645.9 293.5 647 294.2 648.2 295.4 648.4 297 649.2 299.4 649.2 301.2 648.7 303.1 649.2 304.1 649.9 306.2 651 307.8 652 308.3 651.7 309.4 651.7 309.7 651 309.2 650.3 308 649.2"/>
<path class="cls-1" d="M305.2,653.4l-1.2-.5-.9-.7-.7-.2h-2.1l-1.6-.2-.9-.7h-1.6l-1.2,1.2-1.6,1.2-.9.7v.7l-1.4-.2-.5-1.2-.9-1.4-.2-.7-.5-.9-1.2-.5-1.2-.5-.5-.5s-.2-.5-.2-.7-.2-1.2-.2-1.2v-.5c0-.2-.7-.7-.7-.7l-.2-.7-.5-1.2-.2-1.4.7-.9.2-.9,1.6-.2.7-.5.5-.2.2-.7.2-1.2.5-.5.7-.9v-.7l-.2-1.2-.5-1.4.2-1.4-2.1-1.4h-3.1l-1.9.2-1.4,1.2-.9,1.4-.5,1.2-.5,1.4v.7l-1.6.5-.9.5-.7.9s.2.5,0,.7c-.2.2-1.2.9-1.2.9v1.4l-.5,1.2-.7.5-.9.5-.2.9v1.4l.2,1.2-.2,1.4.5,1.6.5,1.9,1.2,1.9h0v1.6l.2,1.4.7.9.9.7.2.7-.2.7-.9.9-.9.7-.5.7-.2.7,1.2.7.7.5,1.4.7v.7l.2,1.2.5.9.9.5.9-.2h1.9l1.2-.2.9-.5.9-.9v-1.4l-.5-.9-.2-1.4.2-.7.7-.5,1.9-.2,1.2-.9h2.3l.5.9v.7s-.5.7-.5.9-.2,1.2-.2,1.2l-.5,1.4v.5l-.2.9.2.5.2.5.2,1.2v1.2l.5.9,2.1.2,2.3.7,1.6-.2,1.2.2.9-.7s.5.5.7.5.5.9.5.9l1.6.7,1.6.2h.7l.2-1.2-.2-1.4-.7-.7-.2-.9v-1.2l.5-.5.9-.9.7-.2.5-.5v-2.3s-.5-2.1-.5-2.3v-.9l.2-1.2.5-1.4s-.2-.9,0-.9.7-.7.7-.7l.7-.5.2-.9-.2-.9-1.2-.5Z"/>
<polygon class="cls-1" points="533.7 451.9 529.9 457.3 516.8 465.7 509.4 457.5 516.6 447.7 513.8 417.3 513.1 408.8 521.3 392.4 522.9 355.1 523.6 353.3 531.1 333.9 526.2 315.4 526.2 282.1 519.8 263.8 504.8 272.7 495 281.2 482.8 272 481.1 259.2 471.4 254.5 473.3 244.6 468.7 245 455.3 248.2 454.9 259.5 454.7 274.8 451.4 277.9 438.9 269.7 434.5 277.6 421.1 273 410.9 275.3 403.3 283.3 392.8 288.8 386.2 300.5 389 306.9 377.6 314.7 353.5 310.3 345.4 307.2 340.7 310.3 334.5 310.7 330 319.5 334.6 326 329.8 328.1 325 323.7 307.5 329.5 314.2 336 312.9 341.5 306.5 336.4 294.4 343.5 303.6 349.6 308.1 357.5 304 362.7 314.1 371.6 316.5 383.6 314.1 388.4 326.1 392 323.3 402.6 325.2 414.4 315.5 413.8 315 423.6 307.5 439.1 298 443 298.5 447.6 301.2 454.4 296.8 462.5 286.2 472.3 276.7 473.4 269.4 480.7 267.1 483.1 264.5 485.8 256.9 505.2 256.9 510.3 247.1 523.8 245.9 532.1 240.9 533.6 235.1 535.7 234.4 553 235.1 563.3 243.5 566.9 244.7 562.9 251.7 557.3 254.5 559.9 261.3 561.5 262.7 571.1 272 579.9 279.4 586.1 279.4 600.4 285.4 607.6 285.4 617.2 278.8 628.6 282.6 628.4 288.8 622.6 289.3 620.3 291.9 621.5 292.3 624.3 296.3 623 298.7 624.3 301.6 622.6 298.9 615.9 301.2 614.9 303.4 615.5 304.7 611.2 309.9 609.7 307.1 614.2 306.9 625.2 305.5 632.3 314.8 637.2 317 641.5 318.3 642.1 322.4 640.3 325.1 641.7 326.8 641.4 329.4 634.9 335.5 635.5 338.5 640.5 342.8 638.4 346.2 633 347.4 636.3 348.6 639.8 343.1 642.3 344.4 645.9 349.4 647.4 351.4 645.9 353.5 647.7 353.3 655.5 347.7 654.1 346.2 655.7 351 659.7 353.7 666.6 356.3 667.8 358.7 665.9 359.9 659.7 358.5 655.6 361.6 654.2 365.6 646.5 363.1 643.5 360.7 647.8 358.5 646.8 361.3 632.3 361.1 630.7 361 629.5 368.3 628 371.4 624.3 365.9 619.1 357.2 620.8 355.4 623.6 351.9 624.1 354.8 618.5 361.5 616.3 367.3 614.4 366.1 610.6 379 609.8 381.7 611.6 383.5 619.4 389.9 616.8 383.5 596.4 387.2 591.5 388.9 592.4 387.7 599.2 390.1 604.4 394.7 605 398 608.8 399.5 604.8 401.5 606.5 400.5 612.2 406.2 614.7 414 613.3 414.9 615.6 420.1 616.2 424.6 617.3 428.8 614.1 432.5 616.6 438.5 610.1 436.6 622.3 438 626.7 441.3 625.8 442.5 620.6 445.7 622.8 453.5 612.9 457 616.4 461.6 616.6 461.7 613.8 459.8 610.5 463.1 606.6 469.9 608.2 480.9 594.4 480.7 591.3 485.5 581.8 491 579.8 494.6 576.1 498.5 573.7 497.1 571.8 499.6 567.6 499.9 561.3 498 558.2 504.1 554 504 546.1 505.5 548.8 506.9 554.5 511.6 551.2 514.6 544.4 514.4 537.4 515.8 532.7 513.9 528 514 525.4 524.7 493.4 527.1 486.2 527.8 477.8 535.1 466.5 538 455.6 533.7 451.9"/>
</g>
<g id="geum" data-label-shift="dx,dy">
<path class="cls-1" d="M298,443l9.5-3.9,7.5-15.6.5-9.8,9.7.6-1.9-11.8,2.8-10.6-12-3.6,2.4-4.8-2.4-12-10.1-8.9-12.6-.6-8.4-10.8-16.3-1.1-7.6-19.2,17.5-7.4,2.3-2.4v-1.1c-.1,0,.4-1.3.4-1.3l-1.6-1.1.5-9.5,1.4-12.2-3.8-13-13.2-9.6-4.9-10-7.1-1.9-11.7,17.8-8.2,1.7-3,5.2-4.6,6.5-19.7,1.1-13.8-5.9-3.6.6-3.6,5.4h-1.2l-4.8-6-6.4-3.9-5-.2-9.1-18.5-5.4,3-2.4-5.7-11.4-2.8-11.7-9.2h-3.9c0,.1-3.4,4.8-3.4,4.8l1,6.2,2.1,5.4-1.2.8-4.4-7.5-6.7-3.2-6.3-.3-5.2,3.3,5.9,3.4-3.6,2.2,5.3,4.4,2.5.8.4,3-6.5,6.1-8.7,11.2-4-1.9,6.4-13.5-1.6-1.9,1.1-7.9-2.1-3-4.7,14.5-3,1.8-3.8-5.7-4.4,2.7.5,8.4-1.5,1.7-3.3-.8-4.2,3.2-5.1,13.8.5,1.5,3.9-.2.2-6.5,5.6,2.5,2.8,5.3-7.4,2.4v3c0,0,5.9,1.2,5.9,1.2l4.3-1.5,2.1-3.3,4-.3-1.4,6,4.1,3.7-2.4,9.4,4.3,6.1,1.1,27,11.6,2.1-6.6-26.7,2.3-4.1-6.3-4.9,1.7-1.9,1-6.9,2-1.5,2.2,9,4.4,3.2,3.1-1.9,3.1-6.1,1.8,1.5-1.9,8.6,2,14.4,1.9,13.7-2.8,6.5,8.2,7.8v2.1l-6.9,2.9,2.9,21.8-1.6,1.9-5.9,6.7,1.3,2.7,3.3-2.7,8.2,2.8,6.2,7.7-1.2,2.8,5.1,13.1,5.6,3.1-19.1.5-1.8,3.3,6.4,3.1-.6,10.7,7.3,2.8,3-3.3,8.9,1.9,9.4-3.4.3,1.4.2.9-3.1,5.4-5.1-.9-9.4,3.5,6.8,6.4,4.3,1.4-.2,5.6-20.6-5.8-6.4,13.7-18.1,10.4-1.9,6.6,5.9,4.7h19.9s3.8,4.2,3.8,4.2l-.5,3.4,3,5.1,13.2,3.6,16.8,2.4,2.4-8.4,16.8-4.8,9.6-8.4v-7.2l9.6-8.4,9.6,7.2,1.2-9.6,10.8-12,12,3.6,2.4,10.8,7.2,7.2-6,15.6,7.2,6,8.4-8.4,8.6,3.3v-5.1s7.6-19.4,7.6-19.4l12.2-12.4,9.6-1.1,10.5-9.8,4.5-8.1-2.7-6.8-.6-4.6Z"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

-20
View File
@@ -1,20 +0,0 @@
import "./styles/base.css";
import "./styles/components.css";
import "@fontsource-variable/noto-sans-kr";
import { AppProvider } from "./providers";
import { cookies } from "next/headers";
const App = async ({ children }: Readonly<{ children: React.ReactNode }>) => {
const theme = (await cookies()).get("site-theme-key");
return (
<html lang="ko" data-theme={theme?.value}>
<body>
<AppProvider>{children}</AppProvider>
</body>
</html>
);
};
export { App };
@@ -1,49 +0,0 @@
"use client";
import { DndProvider } from "react-dnd";
import { MultiBackend, getBackendOptions } from "@minoru/react-dnd-treeview";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ConfirmProvider } from "@frontend-opensource/use-react-hooks";
import { AuthWrapper } from "@/entities/auth";
import { MenuWrapper } from "@/entities/menu";
import { Toaster } from "@/shared/ui/sonner";
import { AlertDialog } from "@/shared/ui/alert-dialog";
import { PromptDialog } from "@/shared/ui/prompt-dialog";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { SelectPromptDialog } from "@/shared/ui/select-prompt-dialog";
import { PromptProvider, StoreProvider } from "@/shared/providers";
const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<StoreProvider>
<PromptProvider>
<ConfirmProvider>
<AuthWrapper>
<MenuWrapper>
<DndProvider
backend={MultiBackend}
options={getBackendOptions()}
>
{children}
</DndProvider>
</MenuWrapper>
<AlertDialog />
<PromptDialog />
<ConfirmDialog />
<SelectPromptDialog />
<Toaster position="top-center" />
</AuthWrapper>
</ConfirmProvider>
</PromptProvider>
</StoreProvider>
<div className="text-2xl">
<ReactQueryDevtools initialIsOpen={false} />
</div>
</QueryClientProvider>
);
};
export { AppProvider };
-21
View File
@@ -1,21 +0,0 @@
@import "@common/assets/styles/base.css";
@import "@common/assets/styles/diveicon.css";
@import "@common/components/index.css";
@tailwind base;
@tailwind utilities;
@layer base {
:root {
--secondary-background: rgba(0, 0, 0, 0.04);
}
[data-theme="black"] {
--secondary-background: rgba(255, 255, 255, 0.09);
}
html,
body {
font-family: "Noto Sans KR", sans-serif;
font-size: 10px;
}
}
-129
View File
@@ -1,129 +0,0 @@
.card {
position: relative;
margin: 0.6rem;
box-sizing: border-box;
min-height: 2rem;
border-radius: 0.5rem;
padding: 0.6rem;
font-size: 1.3rem;
color: #666;
}
.card_width10 {
width: calc(10% - 12px);
}
.card_width20 {
width: calc(20% - 12px);
}
.card_width25 {
width: calc(25% - 12px);
}
.card_width30 {
width: calc(30% - 12px);
}
.card_width33 {
width: calc(33.333% - 12px);
}
.card_width40 {
width: calc(40% - 12px);
}
.card_width45 {
width: calc(45% - 12px);
}
.card_width50 {
width: calc(50% - 12px);
}
.card_width60 {
width: calc(60% - 12px);
}
.card_width66 {
width: calc(66.666% - 12px);
}
.card_width70 {
width: calc(70% - 12px);
}
.card_width75 {
width: calc(75% - 12px);
}
.card_width80 {
width: calc(80% - 12px);
}
.card_width90 {
width: calc(90% - 12px);
}
.card_width100 {
width: calc(100% - 12px);
}
.card_nopadding {
padding: 0 !important;
}
.card_nomargin {
width: 100%;
margin: 0 !important;
padding: 0 !important;
}
.topline {
border-top: 1px solid #ccc;
}
.bottomline {
border-bottom: 1px solid #e6eef3;
background: #fff;
}
.card-border {
border: 1px solid #dbe2e6;
background-color: rgba(255, 255, 255, 0.6);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.card-header {
margin-top: -0.6rem;
margin-left: -0.6rem;
margin-right: -0.6rem;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e1e7eb;
padding: 1rem;
}
.card-footer {
margin-left: -0.6rem;
margin-right: -0.6rem;
margin-top: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
border-top: 1px solid #e1e7eb;
padding-top: 1rem;
padding-bottom: 1rem;
}
/* 블랙 테마 */
[data-theme="black"] {
.card-border {
background-color: rgba(255, 255, 255, 0.065);
border-color: #38383a;
}
.card-header {
border-bottom-color: #3f4042;
}
}
@@ -1,85 +0,0 @@
import { AxiosError } from "axios";
import {
ENDPOINT,
JWT_EXPIRATION_TIME,
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
} from "@/shared/config";
import type { LoginService, ILogin } from "../model/login-interface";
import { tokenManager } from "../lib/token-manager";
export class LoginApiService implements LoginService {
private static instance: LoginApiService;
private constructor() {}
public static getInstance(): LoginApiService {
if (!LoginApiService.instance) {
LoginApiService.instance = new LoginApiService();
}
return LoginApiService.instance;
}
public async login(payload: ILogin): Promise<void> {
try {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
method: "POST",
body: JSON.stringify({
userId: payload.userId,
password: payload.password,
}),
});
const data = await response.json();
const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error("Missing required authentication tokens");
}
tokenManager.setToken(CLAIM_NAME, accessToken);
tokenManager.setToken(AUTH_USER_ID, tokenId);
tokenManager.setupRefreshTimer(
() => this.silentRefresh(),
JWT_EXPIRATION_TIME,
);
} catch (error) {
if (error instanceof AxiosError) {
throw new Error(`Login failed: ${error.response?.data?.message}`);
}
throw new Error(`Login failed: ${error}`);
}
}
public async silentRefresh(): Promise<void> {
try {
const response = await fetch(
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
{ method: "PUT" },
);
const data = await response.json();
const accessToken = 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);
} 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();
-2
View File
@@ -1,2 +0,0 @@
export { LoginApi } from "./api/login-service";
export { AuthWrapper } from "./lib/auth-wrapper";
@@ -1,54 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { client } from "@/shared/api";
import type { IUserInfo } from "@/shared/store";
import { getCookie, useUserStore } from "@/shared/lib";
import { ENDPOINT, AUTH_USER_ID } from "@/shared/config";
import { LoginApi } from "../api/login-service";
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
const { userInfo, setUserInfo } = useUserStore((state) => state);
async function authCheck() {
// 로그인 페이지에서는 인증 체크를 건너뜀
if (window.location.pathname === '/auth/login') {
return;
}
const userId = getCookie(AUTH_USER_ID);
if (!userId) {
throw new Error("Missing required authentication tokens");
}
const response = await client.get<IUserInfo>(
`${ENDPOINT.USER_SERVICE.USERS}/info/me`,
);
if (!userInfo) setUserInfo(response.data);
}
useEffect(() => {
(async () => {
try {
await authCheck();
} catch (error) {
console.error(error);
try {
await LoginApi.silentRefresh();
await authCheck();
} catch (refreshError) {
console.error("Token refresh failed:", refreshError);
setUserInfo(null);
router.push("/auth/login");
}
}
})();
}, []);
return <>{children}</>;
};
export { AuthWrapper };
@@ -1,59 +0,0 @@
import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config";
import { getCookie, setCookie, removeCookie } from "@/shared/lib";
export class TokenManager {
private static instance: TokenManager;
private refreshTimer: NodeJS.Timeout | null = null;
private constructor() {}
public static getInstance(): TokenManager {
if (!TokenManager.instance) {
TokenManager.instance = new TokenManager();
}
return TokenManager.instance;
}
public getToken(key: string): string | undefined {
return getCookie(key);
}
public setToken(key: string, value: string): void {
setCookie(key, value, {
secure: window.location.protocol === "https:",
sameSite: "lax",
});
}
public removeToken(key: string): void {
removeCookie(key);
}
public setupRefreshTimer(
callback: () => Promise<void>,
expirationTime: number,
): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
this.refreshTimer = setTimeout(() => {
callback().catch((error) => {
console.error("Silent refresh failed:", error);
this.clearAuthToken();
});
}, expirationTime - 60000);
}
public clearAuthToken(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
this.removeToken(CLAIM_NAME);
this.removeToken(AUTH_USER_ID);
}
}
export const tokenManager = TokenManager.getInstance();
@@ -1,9 +0,0 @@
export interface ILogin {
userId: string;
password: string;
}
export interface LoginService {
login(payload: ILogin): Promise<void>;
silentRefresh(): Promise<void>;
}
-1
View File
@@ -1 +0,0 @@
export { BoardApi } from "../api/board-service";
@@ -1,60 +0,0 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
Board,
BoardFormData,
BoardList,
BoardParams,
BoardPopupList,
BoardPopupParams,
BoardService,
} from "../model/board-interface";
export class BoardApiService implements BoardService {
public async getBoardList(params?: BoardParams): Promise<BoardList> {
const response = await client.get<BoardList>(
`${ENDPOINT.BOARD_SERVICE.BBS}`,
{ params },
);
return response.data;
}
public async getBoardPopupList(
params: BoardPopupParams,
): Promise<BoardPopupList> {
const response = await client.get<BoardPopupList>(
`${ENDPOINT.BOARD_SERVICE.BBS}`,
{ params },
);
return response.data;
}
public async getBoardInfo(bbsId: string): Promise<Board> {
const response = await client.get<Board>(
`${ENDPOINT.BOARD_SERVICE.BBS}/${bbsId}`,
);
return response.data;
}
public async createBoard(data: BoardFormData): Promise<Board> {
const response = await client.post<Board>(
`${ENDPOINT.BOARD_SERVICE.BBS}`,
data,
);
return response.data;
}
public async updateBoard(bbsId: string, data: BoardFormData): Promise<Board> {
const response = await client.put<Board>(
`${ENDPOINT.BOARD_SERVICE.BBS}/${bbsId}`,
data,
);
return response.data;
}
public async deleteBoard(bbsId: string): Promise<void> {
await client.delete(`${ENDPOINT.BOARD_SERVICE.BBS}/${bbsId}`);
}
}
export const BoardApi = new BoardApiService();
@@ -1,17 +0,0 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
BoardTemplate,
BoardTemplateService,
} from "../model/board-template-interface";
export class BoardTemplateApiService implements BoardTemplateService {
public async getBoardTemplate(bbsTypeCd: string): Promise<BoardTemplate> {
const response = await client.get<BoardTemplate>(
`${ENDPOINT.BOARD_SERVICE.BBS_TEMPLATES}/${bbsTypeCd}`,
);
return response.data;
}
}
export const BoardTemplateApi = new BoardTemplateApiService();
-11
View File
@@ -1,11 +0,0 @@
export { BoardApi } from "./api/board-service";
export {
boardFormSchema,
BOARD_POSTING_CODE,
type Board,
type BoardFormData,
type BoardParams,
type BoardPopup,
type BoardPostingCode,
} from "./model/board-interface";
export { BoardTemplateApi } from "./api/board-template-service";
@@ -1,99 +0,0 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/shared/model";
import type { BbsType } from "@/shared/store";
export const BOARD_POSTING_CODE = {
팝업게시: "01",
상단게시: "02",
기간게시: "03",
} as const;
export type BoardPostingCode =
(typeof BOARD_POSTING_CODE)[keyof typeof BOARD_POSTING_CODE];
export interface BoardParams extends PageableParams {
keywordType?: string;
keyword?: string;
bbsId?: number;
bbsTypeCd?: string;
bbsNm?: string;
bbsExpln?: string;
rfrncUseYn?: string;
atflUseYn?: string;
atflMaxCnt?: number;
atflMaxFileSz?: number;
ansUseYn?: string;
cmntUseYn?: string;
dgstfnEvlUseYn?: string;
smsUseYn?: string;
upndFixUseYn?: string;
pstgPrdUseYn?: string;
popupUseYn?: string;
delYn?: string;
}
export interface BoardPopupParams extends PageableParams {
bbsNm?: string;
}
export interface Board {
bbsId: number;
bbsTypeCd: BbsType;
bbsTypeNm: string;
bbsNm: string;
bbsExpln: string;
rfrncUseYn: string;
atflUseYn: string;
atflMaxCnt: number;
atflMaxFileSz: number;
ansUseYn: string;
ansUseAuthrt: string;
cmntUseYn: string;
dgstfnEvlUseYn: string;
smsUseYn: string;
upndFixUseYn: string;
pstgPrdUseYn: string;
popupUseYn: string;
delYn: string;
useYn: string;
prvcIndctYn: string;
mdfcnDt: string;
mdfrNm: string;
}
export interface BoardPopup extends Board {
pstCnt: number;
}
export const boardFormSchema = z.object({
bbsTypeCd: z.string(),
bbsNm: z.string(),
bbsExpln: z.string(),
rfrncUseYn: z.string(),
atflUseYn: z.string(),
atflMaxCnt: z.number().nullish(),
atflMaxFileSz: z.number().nullish(),
ansUseYn: z.string(),
ansUseAuthrt: z.string().nullish(),
cmntUseYn: z.string(),
dgstfnEvlUseYn: z.string(),
smsUseYn: z.string(),
upndFixUseYn: z.string(),
pstgPrdUseYn: z.string(),
popupUseYn: z.string(),
useYn: z.string(),
prvcIndctYn: z.string(),
});
export type BoardList = PageableList<Board>;
export type BoardPopupList = PageableList<BoardPopup>;
export type BoardFormData = z.infer<typeof boardFormSchema>;
export interface BoardService {
getBoardList: (params: BoardParams) => Promise<BoardList>;
getBoardPopupList: (params: BoardPopupParams) => Promise<BoardPopupList>;
getBoardInfo: (bbsId: string) => Promise<Board>;
createBoard: (data: BoardFormData) => Promise<Board>;
updateBoard: (bbsId: string, data: BoardFormData) => Promise<Board>;
deleteBoard: (bbsId: string) => Promise<void>;
}
@@ -1,24 +0,0 @@
export interface BoardTemplate {
bbsTypeCd: string;
rfrncUseYn: string;
atflUseYn: string;
atflMaxCnt: number;
atflMaxFileSz: number;
ansUseYn: string;
cmntUseYn: string;
dgstfnEvlUseYn: string;
smsUseYn: string;
upndFixUseYn: string;
pstgPrdUseYn: string;
popupUseYn: string;
regDt: string;
rgtr: string;
mdfcnDt: string;
mdfr: string;
rgtrNm: string;
mdfrNm: string;
}
export interface BoardTemplateService {
getBoardTemplate: (bbsTypeCd: string) => Promise<BoardTemplate>;
}
-1
View File
@@ -1 +0,0 @@
export { CodeSelect } from "../ui/code-select";
@@ -1,14 +0,0 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { CodeService, ICode } from "../model/code-interface";
export class CodeApiService implements CodeService {
public async getCode(upCd: string): Promise<ICode[]> {
const response = await client.get<ICode[]>(
`${ENDPOINT.CMS_SERVICE.CODE}/${upCd}`,
);
return response.data;
}
}
export const CodeApi = new CodeApiService();
@@ -1,73 +0,0 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import {
LwpoCodeFormSchema,
type LwpoCodeService,
type LwpoCodeList,
type LwpoCodeDetail,
type LwpoCodeParams,
type LwpoCodeFormData,
} from "../model/lwpocode-interface";
export class LwpoCodeApiService implements LwpoCodeService {
public async getLwpoCodeList(params?: LwpoCodeParams): Promise<LwpoCodeList> {
const response = await client.get<LwpoCodeList>(
`${ENDPOINT.CMS_SERVICE.LWPO_CODE}`,
{ params },
);
return response.data;
}
public async getUpCodeLwpoCodeList(upCd: string): Promise<LwpoCodeList> {
const response = await client.get<LwpoCodeList>(
`${ENDPOINT.CMS_SERVICE.LWPO_CODE}/up-cd/${upCd}`,
);
return response.data;
}
public async lwpoCodeDetail(
upCd: string,
lwpoCd: string,
): Promise<LwpoCodeDetail> {
const response = await client.get<LwpoCodeDetail>(
`${ENDPOINT.CMS_SERVICE.LWPO_CODE}/${lwpoCd}/up-cd/${upCd}`,
);
return response.data;
}
public async createLwpoCode(data: LwpoCodeFormData): Promise<void> {
const validatedData = LwpoCodeFormSchema.parse(data);
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.LWPO_CODE}`,
validatedData,
);
return response.data;
}
public async updateLwpoCode(
upCd: string,
lwpoCd: string,
data: LwpoCodeFormData,
): Promise<void> {
const validatedData = LwpoCodeFormSchema.parse(data);
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.LWPO_CODE}/${lwpoCd}/up-cd/${upCd}`,
validatedData,
);
return response.data;
}
public async deleteLwpoCode(upCd: string, lwpoCd: string): Promise<void> {
await client.delete<void>(
`${ENDPOINT.CMS_SERVICE.LWPO_CODE}/${lwpoCd}/up-cd/${upCd}`,
);
}
public async codeRefreshAll(): Promise<void> {
await client.post<void>(
`${ENDPOINT.CMS_SERVICE.LWPO_CODE}/cache/refresh-all`,
);
}
}
export const LwpoCodeApi = new LwpoCodeApiService();
@@ -1,47 +0,0 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import {
upCodeFormSchema,
type UpCodeService,
type UpCodeList,
type UpCodeDetail,
type UpCodeParams,
type UpCodeFormData,
} from "../model/upcode-interface";
export class UpCodeApiService implements UpCodeService {
public async getUpCodeList(params?: UpCodeParams): Promise<UpCodeList> {
const response = await client.get<UpCodeList>(
`${ENDPOINT.CMS_SERVICE.UP_CODE}`,
{ params },
);
return response.data;
}
public async upCodeDetail(upCd: string): Promise<UpCodeDetail> {
const response = await client.get<UpCodeDetail>(
`${ENDPOINT.CMS_SERVICE.UP_CODE}/${upCd}`,
);
return response.data;
}
public async createUpCode(data: UpCodeFormData): Promise<void> {
const validatedData = upCodeFormSchema.parse(data);
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.UP_CODE}`,
validatedData,
);
return response.data;
}
public async updateUpCode(upCd: string, data: UpCodeFormData): Promise<void> {
const validatedData = upCodeFormSchema.parse(data);
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.UP_CODE}/${upCd}`,
validatedData,
);
return response.data;
}
}
export const UpCodeApi = new UpCodeApiService();
-19
View File
@@ -1,19 +0,0 @@
export { CodeApi } from "./api/code-service";
export type { ICode } from "./model/code-interface";
export { CodeSelect } from "./ui/code-select";
export { UpCodeApi } from "./api/upcode-service";
export {
upCodeFormSchema,
type UpCode,
type UpCodeFormData,
type UpCodeParams,
} from "./model/upcode-interface";
export { LwpoCodeApi } from "./api/lwpocode-service";
export {
LwpoCodeFormSchema,
type LwpoCode,
type LwpoCodeFormData,
type LwpoCodeParams,
} from "./model/lwpocode-interface";
@@ -1,10 +0,0 @@
export interface ICode {
lwpoCd: string;
upCd: string;
lwpoCdNm: string;
unit: string;
}
export interface CodeService {
getCode(upCd: string): Promise<ICode[]>;
}
@@ -1,69 +0,0 @@
import { z } from "zod";
import type { PageableParams, PageableList } from "@/shared/model";
export interface LwpoCodeParams extends PageableParams {
upCd?: string;
lwpoCd?: string;
lwpoCdNm?: string;
useYn?: string;
}
export interface LwpoCode {
lwpoCd: string;
upCd: string;
lwpoCdNm: string;
lwpoCdExpln: string;
unit: string;
prntLwpoCd: string;
useYn: string;
sortSeq: number;
regDt: string;
rgTr: string;
rgTrNm: string;
mdfcnDt: string;
mdFr: string;
}
export interface LwpoCodeDetail extends LwpoCodeFormData {
lwpoCd: string;
upCd: string;
lwpoCdNm: string;
lwpoCdExpln: string;
unit: string;
prntLwpoCd: string;
useYn: string;
sortSeq: 0;
}
export const LwpoCodeFormSchema = z.object({
lwpoCd: z.string().max(10).nonempty(),
upCd: z.string().max(10).nonempty(),
lwpoCdNm: z.string().max(100).nonempty(),
lwpoCdExpln: z.string().max(1000).nullish(),
unit: z.string().max(20).nullish(),
prntLwpoCd: z.string().max(10).nullish(),
useYn: z.string(),
sortSeq: z.number(),
delYn: z.string(),
});
export type LwpoCodeFormData = z.infer<typeof LwpoCodeFormSchema>;
export type LwpoCodeList = PageableList<LwpoCode>;
export interface LwpoCodeService {
getLwpoCodeList(params: LwpoCodeParams): Promise<LwpoCodeList>;
getUpCodeLwpoCodeList(upCd: string): Promise<LwpoCodeList>;
lwpoCodeDetail(upCd: string, lwpoCd: string): Promise<LwpoCodeDetail>;
updateLwpoCode(
upCd: string,
lwpoCd: string,
data: LwpoCodeFormData,
): Promise<void>;
createLwpoCode(data: LwpoCodeFormData): Promise<void>;
deleteLwpoCode(
upCd: string,
lwpoCd: string,
data: LwpoCodeFormData,
): Promise<void>;
codeRefreshAll(): Promise<void>;
}
@@ -1,52 +0,0 @@
import { z } from "zod";
import type { PageableParams, PageableList } from "@/shared/model";
export interface UpCodeParams extends PageableParams {
keyword?: string;
upCd?: string;
cdSe?: string;
useYn?: string;
siteId?: string;
}
export interface UpCode {
upCd: string;
upCdNm: string;
upCdExpln: string;
cdSe: string;
siteId: string;
useYn: string;
regDt: string;
}
export interface UpCodeDetail extends UpCodeFormData {
upCd: string;
upCdNm: string;
upCdExpln: string;
cdSe: string;
siteId: string;
useYn: string;
delYn: string;
regDt: string;
rgTrNm: string;
}
export const upCodeFormSchema = z.object({
upCd: z.string().max(10).nonempty(),
upCdNm: z.string().max(100).nonempty(),
upCdExpln: z.string().max(1000).nullish(),
cdSe: z.string().max(10).nonempty(),
siteId: z.string().max(2).nullish(),
useYn: z.string(),
delYn: z.string(),
});
export type UpCodeFormData = z.infer<typeof upCodeFormSchema>;
export type UpCodeList = PageableList<UpCode>;
export interface UpCodeService {
getUpCodeList(params: UpCodeParams): Promise<UpCodeList>;
upCodeDetail(upCodeId: string): Promise<UpCodeDetail>;
updateUpCode(upCodeId: string, data: UpCodeFormData): Promise<void>;
createUpCode(data: UpCodeFormData): Promise<void>;
}
@@ -1,47 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import {
cn,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import { ENDPOINT } from "@/shared/config";
import { CodeApi } from "../api/code-service";
type CodeSelectProps = {
upCd: string;
placeholder?: string;
className?: string;
} & React.ComponentPropsWithoutRef<typeof Select>;
function CodeSelect({
upCd,
placeholder = "선택",
className,
...props
}: CodeSelectProps) {
const { data: code } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CODE, upCd],
queryFn: () => CodeApi.getCode(upCd),
enabled: !!upCd,
});
return (
<Select {...props}>
<SelectTrigger className={cn("w-[12rem]", className)}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{code?.map((item) => (
<SelectItem key={item.lwpoCd} value={item.lwpoCd}>
{item.lwpoCdNm}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export { CodeSelect };
@@ -1 +0,0 @@
export { MenuAuthorityApi } from "../api/menu-authority-service";
@@ -1 +0,0 @@
export { AuthorityButton } from "../ui/authority-button";
@@ -1,50 +0,0 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
MenuAuthorityList,
MenuAuthorityParams,
MenuAuthorityPayload,
MenuAuthorityService,
PageMenuAuthority,
} from "../model/menu-authority-interface";
export class MenuAuthorityApiService implements MenuAuthorityService {
public async getMenuAuthorityList(
params: MenuAuthorityParams,
): Promise<MenuAuthorityList> {
const response = await client.get<MenuAuthorityList>(
`${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}`,
{ params },
);
return response.data;
}
public async getPageMenuAuthority(
menuCd: number,
): Promise<PageMenuAuthority[]> {
const response = await client.get<PageMenuAuthority[]>(
`${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}/pageinfo/${menuCd}`,
);
return response.data;
}
public async saveMenuAuthority(data: MenuAuthorityPayload[]): Promise<void> {
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}`,
data,
);
return response.data;
}
public async getGroupMenuAuthorityList(
params: MenuAuthorityParams,
): Promise<MenuAuthorityList> {
const response = await client.get<MenuAuthorityList>(
`${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}/group`,
{ params },
);
return response.data;
}
}
export const MenuAuthorityApi = new MenuAuthorityApiService();
@@ -1,9 +0,0 @@
export { MenuAuthorityApi } from "./api/menu-authority-service";
export {
menuAuthorityFormSchema,
type MenuAuthority,
type MenuAuthorityPayload,
type MenuAuthorityFormData,
} from "./model/menu-authority-interface";
export { AuthorityButton } from "./ui/authority-button";
export { ManagementAuthorityDialog } from "./ui/management-authority-dialog";
@@ -1,51 +0,0 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/shared/model";
export interface MenuAuthorityParams extends PageableParams {
groupId?: string;
menuCd?: number;
useYn?: string;
}
export interface PageMenuAuthority {
mngAuthrtYn: string;
inqAuthrtYn: string;
regAuthrtYn: string;
mdfcnAuthrtYn: string;
delAuthrtYn: string;
}
export const menuAuthoritySchema = z.object({
groupId: z.string(),
groupNm: z.string(),
menuCd: z.number(),
mngAuthrtYn: z.string(),
inqAuthrtYn: z.string(),
regAuthrtYn: z.string(),
mdfcnAuthrtYn: z.string(),
delAuthrtYn: z.string(),
useYn: z.string(),
mode: z.string(),
});
export const menuAuthorityPayloadSchema = menuAuthoritySchema.omit({
groupNm: true,
});
export const menuAuthorityFormSchema = z.object({
menuAuthority: z.array(menuAuthorityPayloadSchema),
});
export type MenuAuthority = z.infer<typeof menuAuthoritySchema>;
export type MenuAuthorityList = PageableList<MenuAuthority>;
export type MenuAuthorityPayload = z.infer<typeof menuAuthorityPayloadSchema>;
export type MenuAuthorityFormData = z.infer<typeof menuAuthorityFormSchema>;
export interface MenuAuthorityService {
getMenuAuthorityList(params: MenuAuthorityParams): Promise<MenuAuthorityList>;
getGroupMenuAuthorityList(
params: MenuAuthorityParams,
): Promise<MenuAuthorityList>;
getPageMenuAuthority(menuCd: number): Promise<PageMenuAuthority[]>;
saveMenuAuthority(data: MenuAuthorityPayload[]): Promise<void>;
}
@@ -1,117 +0,0 @@
import type { ColumnDef } from "@tanstack/react-table";
import type { UseFormReturn } from "react-hook-form";
import { Checkbox } from "@common/components";
import type {
MenuAuthority,
MenuAuthorityFormData,
MenuAuthorityPayload,
} from "./menu-authority-interface";
export const createColumns = (
form: UseFormReturn<MenuAuthorityFormData>,
): ColumnDef<MenuAuthority>[] => {
const getCurrentRow = (i: number) => form.watch("menuAuthority")[i];
const handleCheckedChange =
(i: number, key: keyof MenuAuthorityPayload) => (checked: boolean) => {
if (getCurrentRow(i)?.mode === "R") {
form.setValue(`menuAuthority.${i}.mode`, "U");
}
form.setValue(`menuAuthority.${i}.${key}`, checked ? "Y" : "N");
};
return [
{
accessorKey: "groupNm",
header: "그룹명",
},
{
accessorKey: "mngAuthrtYn",
header: "관리권한",
cell: ({ row }) => {
return (
<Checkbox
checked={getCurrentRow(row.index)?.mngAuthrtYn === "Y"}
onCheckedChange={(checked) => {
if (checked) {
handleCheckedChange(
row.index,
"regAuthrtYn",
)(checked as boolean);
handleCheckedChange(
row.index,
"mdfcnAuthrtYn",
)(checked as boolean);
handleCheckedChange(
row.index,
"delAuthrtYn",
)(checked as boolean);
handleCheckedChange(
row.index,
"inqAuthrtYn",
)(checked as boolean);
}
handleCheckedChange(row.index, "mngAuthrtYn")(checked as boolean);
}}
/>
);
},
},
{
accessorKey: "regAuthrtYn",
header: "등록권한",
cell: ({ row }) => {
return (
<Checkbox
checked={getCurrentRow(row.index)?.regAuthrtYn === "Y"}
onCheckedChange={(checked) => {
if (checked) {
handleCheckedChange(
row.index,
"inqAuthrtYn",
)(checked as boolean);
}
handleCheckedChange(row.index, "regAuthrtYn")(checked as boolean);
}}
/>
);
},
},
{
accessorKey: "mdfcnAuthrtYn",
header: "수정권한 ",
cell: ({ row }) => {
return (
<Checkbox
checked={getCurrentRow(row.index)?.mdfcnAuthrtYn === "Y"}
onCheckedChange={handleCheckedChange(row.index, "mdfcnAuthrtYn")}
/>
);
},
},
{
accessorKey: "delAuthrtYn",
header: "삭제권한",
cell: ({ row }) => {
return (
<Checkbox
checked={getCurrentRow(row.index)?.delAuthrtYn === "Y"}
onCheckedChange={handleCheckedChange(row.index, "delAuthrtYn")}
/>
);
},
},
{
accessorKey: "inqAuthrtYn",
header: "조회권한",
cell: ({ row }) => {
return (
<Checkbox
checked={getCurrentRow(row.index)?.inqAuthrtYn === "Y"}
onCheckedChange={handleCheckedChange(row.index, "inqAuthrtYn")}
/>
);
},
},
];
};
@@ -1,26 +0,0 @@
import { useMenuStore } from "@/shared/lib";
import { Button, cn, type ButtonProps } from "@common/components";
import type { PageMenuAuthority } from "../model/menu-authority-interface";
type AuthorityButtonProps = {
authority: keyof PageMenuAuthority;
} & ButtonProps;
function AuthorityButton({
authority,
className,
children,
...props
}: AuthorityButtonProps) {
const pageMenuAuthority = useMenuStore((state) => state.pageMenuAuthority);
const isAuthority = pageMenuAuthority?.[authority] === "Y";
return (
<Button className={cn(className, !isAuthority && "hidden")} {...props}>
{children}
</Button>
);
}
export { AuthorityButton };
@@ -1,101 +0,0 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { getCoreRowModel } from "@tanstack/react-table";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@common/components";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useDataTable, useMutationWrapper } from "@/shared/lib";
import {
menuAuthorityFormSchema,
type MenuAuthorityFormData,
type MenuAuthorityPayload,
} from "../model/menu-authority-interface";
import { createColumns } from "../model/menu-authority-table-columns";
import { MenuAuthorityApi } from "../api/menu-authority-service";
type ManagementAuthorityDialogProps = {
menuCd?: number;
} & React.ComponentProps<typeof Dialog>;
function ManagementAuthorityDialog({ menuCd }: ManagementAuthorityDialogProps) {
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const [open, setOpen] = useState(false);
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENU_AUTHRTS, menuCd],
queryFn: () => MenuAuthorityApi.getMenuAuthorityList({ menuCd }),
enabled: !!menuCd,
});
const { mutateAsync: saveMenuAuthority } = useMutationWrapper({
mutationFn: (data: MenuAuthorityPayload[]) =>
MenuAuthorityApi.saveMenuAuthority(data),
onSuccess: () => {
alert("저장되었습니다.", () => setOpen(false));
},
});
const form = useForm<MenuAuthorityFormData>({
resolver: zodResolver(menuAuthorityFormSchema),
values: {
menuAuthority: query.data?.content ?? [],
},
});
const { tableComponent } = useDataTable({
query,
tableOptions: {
columns: createColumns(form),
getCoreRowModel: getCoreRowModel(),
},
});
const handleSave = async () => {
if (await confirm("저장하시겠습니까?")) {
const data = form.getValues("menuAuthority");
const filteredData = data.filter((item) => item.mode !== "R");
await saveMenuAuthority(filteredData);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="gap-3" disabled={!menuCd}>
<i className="diveicon di-key" />
권한관리
</Button>
</DialogTrigger>
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>그룹별 메뉴 권한</DialogTitle>
</DialogHeader>
<div className="max-h-[40rem] overflow-auto">{tableComponent}</div>
<DialogFooter className="!justify-center">
<Button size="lg" onClick={handleSave}>
저장
</Button>
<DialogClose asChild>
<Button size="lg" color="white">
취소
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { ManagementAuthorityDialog };
@@ -1,79 +0,0 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
MenuService,
Menu,
MenuItem,
MenuFormData,
MenuTemporarilyStoredData,
SiteMapFormData,
} from "../model/menu-interface";
export class MenuApiService implements MenuService {
async getMenuRoles(siteId: string): Promise<MenuItem[]> {
const response = await client.get<MenuItem[]>(
`${ENDPOINT.CMS_SERVICE.MENU_ROLES}/${siteId}`,
);
return response.data;
}
public async getMenuInfo(menuCd: number): Promise<Menu> {
const response = await client.get<Menu>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}`,
);
return response.data;
}
public async createMenu(data: MenuFormData): Promise<void> {
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.MENUS}`,
data,
);
return response.data;
}
public async updateMenu(
menuCd: number,
data: Omit<MenuFormData, "siteId">,
): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}`,
data,
);
return response.data;
}
public async deleteMenu(menuCd: number): Promise<void> {
await client.delete(`${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}`);
}
public async menuTemporaryStorage(
menuCd: number,
data: MenuFormData,
): Promise<void> {
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.MENU_TEMPORARY_STORAGE}/${menuCd}`,
data,
);
return response.data;
}
public async getMenuTemporarilyStoredData(
menuCd: number,
): Promise<MenuTemporarilyStoredData[]> {
const response = await client.get<MenuTemporarilyStoredData[]>(
`${ENDPOINT.CMS_SERVICE.MENU_TEMPORARY_STORAGE}/menu/${menuCd}/user`,
);
return response.data;
}
public async publishSiteMap(data: SiteMapFormData["siteMap"]): Promise<void> {
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.MENUS}/sitemap/publish`,
data,
);
return response.data;
}
}
export const MenuApi = new MenuApiService();
-12
View File
@@ -1,12 +0,0 @@
export { MenuApi } from "./api/menu-service";
export {
MENU_TYPE,
menuFormSchema,
siteMapFormSchema,
type MenuType,
type MenuItem,
type MenuFormData,
type SiteMapFormData,
type SiteMapPayloadData,
} from "./model/menu-interface";
export { MenuWrapper } from "./lib/menu-wrapper";
@@ -1,58 +0,0 @@
"use client";
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import { MenuAuthorityApi } from "@/entities/menu-authority/@x/menu";
import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib";
import { SITE_ID } from "@/shared/config";
import { MenuApi } from "../api/menu-service";
import { getCurrentMenu } from "./utils";
const MenuWrapper = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
const pathname = usePathname();
const alert = useAlertStore((state) => state.setMessage);
const { pageMenuAuthority, setCurrentMenuInfo, setPageMenuAuthority } =
useMenuStore((state) => state);
const getCurrentMenuInfo = async () => {
const menuRoles = await MenuApi.getMenuRoles(SITE_ID);
if (!menuRoles) return;
return getCurrentMenu(menuRoles, pathname ?? "");
};
const getPageMenuAuthority = async () => {
const menuCd = getCookie("menu-code-key");
if (!menuCd) return;
const response = await MenuAuthorityApi.getPageMenuAuthority(
parseInt(menuCd),
);
return response;
};
const checkInquiryAuthority = () => {
return pageMenuAuthority?.inqAuthrtYn === "Y";
};
useEffect(() => {
(async () => {
const currentMenu = await getCurrentMenuInfo();
if (currentMenu) setCurrentMenuInfo(currentMenu);
const response = await getPageMenuAuthority();
if (response?.length) setPageMenuAuthority(response[0]!);
})();
}, [pathname]);
useEffect(() => {
if (!pageMenuAuthority) return;
const inquiryAuthority = checkInquiryAuthority();
if (!inquiryAuthority)
alert("페이지에 대한 조회 권한이 없습니다.", () => router.push("/"));
}, [pageMenuAuthority]);
return <>{children}</>;
};
export { MenuWrapper };
-20
View File
@@ -1,20 +0,0 @@
import type { MenuItem } from "../model/menu-interface";
export const getCurrentMenu = (
menus: MenuItem[],
pathname: string,
): MenuItem | null => {
for (const menu of menus) {
if (
menu.urlPath !== "" &&
(menu.urlPath === pathname || pathname.startsWith(menu.urlPath))
)
return menu;
if (menu.children) {
const children = getCurrentMenu(menu.children, pathname);
if (children) return children;
}
}
return null;
};
@@ -1,107 +0,0 @@
import { z } from "zod";
export const MENU_TYPE = {
EMPTY: "EMPTY",
CONTENTS: "CONTENTS",
BOARD: "BOARD",
INSIDE: "INSIDE",
OUTSIDE: "OUTSIDE",
BOTTOM: "BOTTOM",
} as const;
export type MenuType = (typeof MENU_TYPE)[keyof typeof MENU_TYPE];
export interface MenuItem {
menuCd: number;
menuKornNm: string;
menuEngNm: string;
parentId: number | null;
sortSeq: number;
iconNm?: string;
children: MenuItem[] | null;
levelNo: number;
urlPath: string;
menuLnkgTypeCd: string;
menuLnkgSn: number;
menuPath: string;
qrcd: string;
useYn: string;
otptYn: string;
newWndwYn: string;
requiredUrlPath: boolean;
}
export interface MenuTemporarilyStoredData {
tmprStrgSn: number;
menuCd: number;
menuTmprStrg: string;
regDt: string;
rgtr: string;
mdfcnDt: string;
mdfr: string;
}
export const menuFormSchema = z.object({
siteId: z.string(),
menuKornNm: z.string(),
menuSubKornNm: z.string().nullish(),
menuEngNm: z.string().nullish(),
menuSubEngNm: z.string().nullish(),
levelNo: z.number(),
urlPath: z.string().nullish(),
iconNm: z.string().nullish(),
menuLnkgTypeCd: z.enum([
MENU_TYPE.EMPTY,
MENU_TYPE.CONTENTS,
MENU_TYPE.BOARD,
MENU_TYPE.INSIDE,
MENU_TYPE.OUTSIDE,
MENU_TYPE.BOTTOM,
]),
menuLnkgSn: z.number().nullish(),
otptYn: z.string().default("Y"),
prntMenuCd: z.number().nullish(),
sortSeq: z.number(),
evlUseYn: z.string().default("Y"),
qrcd: z.string().nullish(),
useYn: z.string().default("Y"),
newWndwYn: z.string().nullish(),
menuLnkgSnNm: z.string().nullish(),
menuPicUserUnqId: z.string().nullish(),
menuPicUserUnqIdNm: z.string().nullish(),
menuPicDsgnYmd: z.string().nullish(),
});
export const siteMapPayloadSchema = z.object({
menuCd: z.number(),
menuSubKornNm: z.string(),
otptYn: z.string(),
});
export const siteMapFormSchema = z.object({
siteMap: z.array(siteMapPayloadSchema),
});
export type MenuFormData = z.infer<typeof menuFormSchema>;
export type SiteMapFormData = z.infer<typeof siteMapFormSchema>;
export type SiteMapPayloadData = z.infer<typeof siteMapPayloadSchema>;
export type Menu = Omit<MenuFormData, "iconNm"> & {
menuCd: number;
iconName?: string;
};
export interface MenuService {
getMenuRoles(siteId: string): Promise<MenuItem[]>;
getMenuInfo: (menuCd: number) => Promise<Menu>;
createMenu: (data: MenuFormData) => Promise<void>;
updateMenu: (
menuCd: number,
data: Omit<MenuFormData, "siteId">,
) => Promise<void>;
deleteMenu: (menuCd: number) => Promise<void>;
menuTemporaryStorage: (menuCd: number, data: MenuFormData) => Promise<void>;
getMenuTemporarilyStoredData: (
menuCd: number,
) => Promise<MenuTemporarilyStoredData[]>;
publishSiteMap: (data: SiteMapFormData["siteMap"]) => Promise<void>;
}
@@ -1,97 +0,0 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import {
postFormSchema,
postUpdateFormSchema,
type Post,
type MovePost,
type DeletePost,
type PostInfo,
type PostList,
type MyPostList,
type PostParams,
type MyPostParams,
type PostFormData,
type PostUpdateFormData,
type PostService,
} from "../model/post-interface";
export class PostApiService implements PostService {
public async getPostList(
bbsId: number,
params: PostParams,
): Promise<PostList> {
const response = await client.get<PostList>(
`${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}`,
{ params },
);
return response.data;
}
public async getMyPostList(
params?: MyPostParams,
): Promise<MyPostList> {
const response = await client.get<MyPostList>(
`${ENDPOINT.BOARD_SERVICE.POSTS}/mypost`,
{ params },
);
return response.data;
}
public async getPostInfo(bbsId: number, pstNo: number): Promise<PostInfo> {
const response = await client.get<PostInfo>(
`${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}/${pstNo}`,
);
return response.data;
}
public async createPost(data: PostFormData): Promise<Post> {
const validatedData = postFormSchema.parse(data);
const response = await client.post<Post>(
`${ENDPOINT.BOARD_SERVICE.POSTS}`,
validatedData,
);
return response.data;
}
public async updatePost(
bbsId: number,
pstNo: number,
data: PostUpdateFormData,
): Promise<Post> {
const validatedData = postUpdateFormSchema.parse(data);
const response = await client.put<Post>(
`${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}/${pstNo}`,
validatedData,
);
return response.data;
}
public async deletePost(
bbsId: number,
pstNo: number,
delRsn?: string,
): Promise<void> {
await client.delete(`${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}/${pstNo}`, {
params: { delRsn },
});
}
public async softDeletePost(data: DeletePost[]): Promise<void> {
await client.post(`${ENDPOINT.BOARD_SERVICE.POSTS}/softDeleteSels`, data);
}
public async hardDeletePost(data: DeletePost[]): Promise<void> {
await client.post(`${ENDPOINT.BOARD_SERVICE.POSTS}/hardDeleteSels`, data);
}
public async movePost(data: MovePost[]): Promise<void> {
await client.post(`${ENDPOINT.BOARD_SERVICE.POSTS}/movePstByBbsId`, data);
}
public async restorePost(data: DeletePost[]): Promise<void> {
await client.post(`${ENDPOINT.BOARD_SERVICE.POSTS}/restoreSels`, data);
}
}
export const PostApi = new PostApiService();
-17
View File
@@ -1,17 +0,0 @@
export { PostApi } from "./api/post-service";
export {
postFormSchema,
postUpdateFormSchema,
type Post,
type MyPost,
type MovePost,
type DeletePost,
type PostList,
type PostParams,
type MyPostParams,
type PostFormData,
type PostUpdateFormData,
type PostService,
} from "./model/post-interface";
export { deleteReasonMessages } from "./model/delete-reason-messages";
export { PostPageGuard } from "./lib/post-page-guard";
@@ -1,39 +0,0 @@
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { usePathname, useRouter } from "next/navigation";
import { BoardApi } from "@/entities/board/@x/post";
import { useAlertStore, useMenuStore } from "@/shared/lib";
function PostPageGuard({
bbsId,
children,
}: {
bbsId: string;
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const alert = useAlertStore((state) => state.setMessage);
const setBbsInfo = useMenuStore((state) => state.setBbsInfo);
const { data: boardInfo, isLoading } = useQuery({
queryKey: [bbsId],
queryFn: () => BoardApi.getBoardInfo(bbsId),
});
useEffect(() => {
if (!boardInfo) return;
setBbsInfo(boardInfo);
}, [boardInfo]);
if (!isLoading && pathname && boardInfo) {
if (!pathname.includes(boardInfo.bbsTypeCd.toLowerCase())) {
alert("잘못된 경로로 접속하였습니다.", router.back);
return null;
}
}
return children;
}
export { PostPageGuard };
@@ -1,11 +0,0 @@
export const deleteReasonMessages = {
"저작권 침해":
"타인의 저작물을 허락 없이 게시하는 경우 저작권 침해로 간주되어 삭제될 수 있습니다.",
명예훼손: "타인의 명예를 훼손하는 내용의 게시물은 삭제될 수 있습니다.",
"개인정보 노출": "타인의 개인정보를 노출하는 게시물도 삭제될 수 있습니다.",
"불법 정보 유통":
"불법적인 상품이나 서비스를 판매하거나 홍보하는 게시물은 삭제될 수 있습니다.",
"욕설 및 비방":
"심한 욕설이나 비방, 모욕적인 내용이 포함된 게시물은 삭제될 수 있습니다.",
"광고성 정보": "지나치게 광고성이 짙은 게시물은 삭제될 수 있습니다.",
};
@@ -1,119 +0,0 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/shared/model";
export interface PostParams extends PageableParams {
keywordType?: string;
keyword?: string;
popupYn?: string;
upndFixYn?: string;
prdPstgYn?: string;
siteId?: string;
bbsTypeCd?: string;
ansUseYn?: string;
}
export interface MyPostParams extends PageableParams {
keywordType?: string;
keyword?: string;
bbsId?: number | null;
delYn?: string;
frDateYmd?: string;
toDateYmd?: string;
}
export interface Post {
rowNo: number;
bbsId: number;
pstNo: number;
pstTtl: string;
pstCn: string;
inqNocs: number;
rfrncUrl: string;
popupYn: string;
upndFixYn: string;
prdPstgYn: string;
rootPath: string;
pstPath: string;
ansPstn: number;
wrtrNm: string;
wrtrCplc: string;
atflCd: string;
regDt: string;
rgtrNm: string;
cmntCnt: number;
}
export interface MyPost extends Post {
bbsNm: string;
bbsTypeCd: string;
}
export interface PostInfo extends Post {
pstCn: string;
rgtr: string;
mdfcnDt: string;
mdfr: string;
}
export interface MovePost {
fromBbsId: number;
toBbsId?: number;
pstNo: number;
}
export interface DeletePost {
bbsId: number;
pstNo: number;
delRsn?: string;
}
const postEtcSchema = z.object({
bbsPstgTypeCd: z.string(),
pstgPrdSeCd: z.string(),
pstgBgngDt: z.string().nullish(),
pstgEndDt: z.string().nullish(),
});
export const postFormSchema = z.object({
bbsId: z.number(),
pstTtl: z.string().nonempty(),
pstCn: z.string().nullish(),
rfrncUrl: z.string().nullish(),
popupYn: z.string().nullish(),
popupYnEtc: z.array(postEtcSchema).nullish(),
upndFixYn: z.string().nullish(),
upndFixYnEtc: z.array(postEtcSchema).nullish(),
prdPstgYn: z.string().nullish(),
prdPstgYnEtc: z.array(postEtcSchema).nullish(),
pstPswd: z.string(),
wrtrNm: z.string().nullish(),
wrtrCplc: z.string().nullish(),
atflCd: z.string().nullish(),
parentPstNo: z.number().nullish(),
});
export const postUpdateFormSchema = postFormSchema.omit({
bbsId: true,
parentPstNo: true,
});
export type PostList = PageableList<Post>;
export type MyPostList = PageableList<MyPost>;
export type PostFormData = z.infer<typeof postFormSchema>;
export type PostUpdateFormData = z.infer<typeof postUpdateFormSchema>;
export interface PostService {
getPostList(bbsId: number, params: PostParams): Promise<PostList>;
getMyPostList(params: MyPostParams): Promise<MyPostList>;
getPostInfo(bbsId: number, pstNo: number): Promise<PostInfo>;
createPost(data: PostFormData): Promise<Post>;
updatePost(
bbsId: number,
pstNo: number,
data: PostUpdateFormData,
): Promise<Post>;
deletePost(bbsId: number, pstNo: number, delRsn?: string): Promise<void>;
softDeletePost(data: DeletePost[]): Promise<void>;
hardDeletePost(data: DeletePost[]): Promise<void>;
movePost(data: MovePost[]): Promise<void>;
}
@@ -1,225 +0,0 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
UserService,
UserSearchParams,
UserList,
UserDetailResponse,
UserSttsStatsList,
UserUpdateData,
UserPasswordUpdateData,
UserFindPasswordUpdateData,
UserPasswordMatchData,
UserUpdateInfoData,
UserSaveData,
UserFindPasswordSaveData,
UserVerifyData,
UserEmailData,
UserIdData,
AdminUserList,
} from "../model/user-interface";
export class UserApiService implements UserService {
// 사용자 목록 조회
public async findPageUser(params: UserSearchParams): Promise<UserList> {
const response = await client.get<UserList>(
`${ENDPOINT.USER_SERVICE.USERS}`,
{ params },
);
return response.data;
}
// 사용자 단건 조회
public async findByUserUnqId(userUnqId: string): Promise<UserDetailResponse> {
const response = await client.get<UserDetailResponse>(
`${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`,
);
return response.data;
}
// 로그인한 사용자 정보 조회
public async findByUserUnqIdByLogin(): Promise<UserDetailResponse> {
const response = await client.get<UserDetailResponse>(
`${ENDPOINT.USER_SERVICE.USERS}/info/me`,
);
return response.data;
}
// 사용자 회원상태 통계 조회
public async selectUserSttsStats(): Promise<UserSttsStatsList> {
const response = await client.get<UserSttsStatsList>(
`${ENDPOINT.USER_SERVICE.USERS}/stts/stats`,
);
return response.data;
}
// 사용자 저장
public async saveUser(data: UserSaveData): Promise<string> {
const response = await client.post<string>(
`${ENDPOINT.USER_SERVICE.USERS}`,
data,
);
return response.data;
}
// 사용자 수정
public async updateUser(
userUnqId: string,
data: UserUpdateData,
): Promise<string> {
const response = await client.put<string>(
`${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`,
data,
);
return response.data;
}
// 사용자 삭제
public async deleteUser(userUnqId: string): Promise<boolean> {
const response = await client.delete<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/delete/${userUnqId}`,
);
return response.data;
}
// 사용자 계정잠금 처리
public async lockUser(userUnqId: string): Promise<boolean> {
const response = await client.put<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/lock/${userUnqId}`,
);
return response.data;
}
// 사용자 계정잠금 해제처리
public async unlockUser(userUnqId: string): Promise<boolean> {
const response = await client.put<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/unlock/${userUnqId}`,
);
return response.data;
}
// 리프레시 토큰 정보 재발급
public async refreshToken(): Promise<void> {
await client.put(`${ENDPOINT.USER_SERVICE.USERS}/token/refresh`);
}
// 사용자 비밀번호 변경
public async updatePassword(data: UserPasswordUpdateData): Promise<boolean> {
const response = await client.put<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/password/update`,
data,
);
return response.data;
}
// 사용자 비밀번호 초기화(관리자용)
public async initializePassword(
userUnqId: string,
data: UserPasswordUpdateData,
): Promise<boolean> {
const response = await client.put<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/password/initialize/${userUnqId}`,
data,
);
return response.data;
}
// 사용자 비밀번호 찾기 변경
public async changePassword(
data: UserFindPasswordUpdateData,
): Promise<boolean> {
const response = await client.put<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/password/change`,
data,
);
return response.data;
}
// 사용자 비밀번호 확인
public async matchPassword(data: UserPasswordMatchData): Promise<boolean> {
const response = await client.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/password/match`,
data,
);
return response.data;
}
// 사용자 비밀번호 찾기
public async findPasswordUser(
data: UserFindPasswordSaveData,
): Promise<boolean> {
const response = await client.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/password/find`,
data,
);
return response.data;
}
// 사용자 비밀번호 찾기 유효성 확인
public async validPassword(token: string): Promise<boolean> {
const response = await client.get<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/password/valid/${token}`,
);
return response.data;
}
// 사용자 회원정보 변경
public async updateInfo(
userUnqId: string,
data: UserUpdateInfoData,
): Promise<string> {
const response = await client.put<string>(
`${ENDPOINT.USER_SERVICE.USERS}/info/${userUnqId}`,
data,
);
return response.data;
}
// 사용자 승인처리
public async applyUser(userUnqId: string): Promise<boolean> {
const response = await client.put<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/apply/${userUnqId}`,
);
return response.data;
}
// 사용자 회원탈퇴
public async leaveUser(data: UserVerifyData): Promise<boolean> {
const response = await client.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/leave`,
data,
);
return response.data;
}
// 사용자 이메일 중복 체크
public async existsEmail(data: UserEmailData): Promise<boolean> {
const response = await client.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/exists`,
data,
);
return response.data;
}
// 사용자 아이디 중복 체크
public async existsUserId(data: UserIdData): Promise<boolean> {
const response = await client.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/existsUserId`,
data,
);
return response.data;
}
// 관리자 목록 조회
public async findPageAdminUser(
params: UserSearchParams,
): Promise<AdminUserList> {
const response = await client.get<AdminUserList>(
`${ENDPOINT.USER_SERVICE.USERS}/admin/list`,
{ params },
);
return response.data;
}
}
export const UserApi = new UserApiService();
-17
View File
@@ -1,17 +0,0 @@
export { UserApi } from "./api/user-service";
export {
USER_STATE_CODE,
userSaveSchema,
userPasswordUpdateSchema,
userUpdateSchema,
type UserStateCode,
type UserSearchParams,
type UserListResponse,
type UserSaveData,
type UserDetailResponse,
type UserPasswordUpdateData,
type UserUpdateData,
type AdminUserList,
type AdminUserListResponse,
} from "./model/user-interface";
export { PasswordResetDialog } from "./ui/password-reset-dialog";
@@ -1,362 +0,0 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/shared/model";
export const USER_STATE_CODE = {
대기: "00",
정상: "01",
정지: "07",
탈퇴: "08",
삭제: "09",
} as const;
export type UserStateCode =
(typeof USER_STATE_CODE)[keyof typeof USER_STATE_CODE];
// 사용자 검색 파라미터
export interface UserSearchParams extends PageableParams {
keywordType?: string;
keyword?: string;
userStateCode?: string;
aprvYn?: string;
grpId?: string;
frDateYmd?: string;
toDateYmd?: string;
userName?: string;
}
// 사용자 목록 응답 인터페이스
export interface UserListResponse {
userId: string;
userName: string;
email: string;
roleId: string;
roleName: string;
userStateCode: UserStateCode;
userStateCodeName: string;
lastLoginDate: string;
loginFailCount: number;
userUnqId: string;
mphn: string;
telno: string;
aprvYn: string;
regDt: string;
laeSeCd: string;
laeSeNm: string;
ogdpSeCd: string;
ogdpSeNm: string;
instSeCd: string;
instSeNm: string;
aprvYmd: string;
}
// 사용자 상세 응답 인터페이스
export interface UserDetailResponse {
regDt: string;
mdfcnDt: string;
rgtr: string;
mdfr: string;
userUnqId: string;
userId: string;
userName: string;
email: string;
encryptedPassword: string;
refreshToken: string;
userStateCode: UserStateCode;
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;
twoFactorEnabled: boolean;
adminRoleYn: string;
instSeCd: string;
aprvYmd: string;
enoCd: string;
dtlInstCd: string;
laeSeNm: string;
ogdpSeNm: string;
instSeNm: string;
userStateNm: string;
pswdHntNm: string;
ecoNm: string;
dtlInstNm: string;
}
// 사용자 상태 통계 응답 인터페이스
export interface UserSttsStatsResponse {
sttsCd: string;
sttsNm: string;
cnt: number;
}
// 관리자 목록 응답 인터페이스
export interface AdminUserListResponse {
userUnqId: string;
userId: string;
userName: string;
groupId: string;
groupNm: string;
userStateCode: string;
userStateCodeName: string;
laeSeCd: string;
laeSeNm: string;
ogdpSeCd: string;
ogdpSeNm: string;
instSeCd: string;
instSeNm: string;
dtlInstCd: string;
dtlInstNm: string;
mdfr: string;
mdfrNm: string;
mdfcnDt: string;
}
// 사용자 수정 요청 스키마
export const userUpdateSchema = z.object({
userId: z.string().optional(),
userName: z.string(),
email: z.string().email().optional(),
password: z
.string()
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/,
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.",
)
.optional(),
roleId: z.string().optional(),
userStateCode: z.string(),
pswdHnt: z.string().max(10),
pswdAns: z.string().max(100).optional(),
mphn: z.string().max(11).optional(),
telno: z.string().max(11).optional(),
zip: z.string().max(5).optional(),
addr: z.string().max(200).optional(),
daddr: z.string().max(200).optional(),
laeSeCd: z.string().max(10),
ogdpSeCd: z.string().max(10),
aprvYn: z.string(),
autzrId: z.string().uuid().optional(),
expryYmd: z.string().max(8),
instSeCd: z.string().optional(),
dtlInstCd: z.string().optional(),
aprvYmd: z.string().max(8).optional(),
enoCd: z.string().optional(),
});
// 사용자 비밀번호 변경 스키마
export const userPasswordUpdateSchema = z
.object({
password: z.string().optional(),
provider: z.string().optional(),
token: z.string().optional(),
newPassword: z
.string()
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/,
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.",
),
newPasswordConfirm: z.string(),
})
.refine((data) => data.newPassword === data.newPasswordConfirm, {
message: "비밀번호가 일치하지 않습니다.",
path: ["newPasswordConfirm"],
});
// 사용자 비밀번호 찾기 변경 스키마
export const userFindPasswordUpdateSchema = z.object({
tokenValue: z.string(),
password: z
.string()
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/,
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.",
),
});
// 사용자 비밀번호 확인 스키마
export const userPasswordMatchSchema = z.object({
password: z
.string()
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/,
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.",
),
});
// 사용자 회원정보 변경 스키마
export const userUpdateInfoSchema = z.object({
password: z
.string()
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/,
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.",
)
.optional(),
provider: z.string().optional(),
token: z.string().optional(),
userName: z.string(),
email: z.string().email().optional(),
});
// 사용자 저장 스키마
export const userSaveSchema = z.object({
userId: z.string(),
userName: z.string(),
email: z.string().email().optional(),
password: z
.string()
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/,
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.",
),
pswdHnt: z.string().max(10),
pswdAns: z.string().max(100).optional(),
mphn: z.string().max(11).optional(),
telno: z.string().max(11).optional(),
zip: z.string().max(5).optional(),
addr: z.string().max(200).optional(),
daddr: z.string().max(200).optional(),
laeSeCd: z.string().max(10),
ogdpSeCd: z.string().max(10),
expryYmd: z.string().max(8),
instSeCd: z.string().optional(),
dtlInstCd: z.string().optional(),
enoCd: z.string().optional(),
});
// 사용자 비밀번호 찾기 저장 스키마
export const userFindPasswordSaveSchema = z.object({
userName: z.string(),
emailAddr: z.string().email(),
mainUrl: z.string(),
changePasswordUrl: z.string(),
});
// 사용자 인증 요청 스키마
export const userVerifySchema = z.object({
password: z
.string()
.regex(/((?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20})|()/)
.optional(),
provider: z.string().optional(),
token: z.string().optional(),
});
// 사용자 이메일 요청 스키마
export const userEmailSchema = z.object({
userId: z.string().optional(),
email: z.string().email(),
});
// 사용자 ID 요청 스키마
export const userIdSchema = z.object({
userId: z.string(),
});
// Zod 스키마 타입 정의
export type UserUpdateData = z.infer<typeof userUpdateSchema>;
export type UserPasswordUpdateData = z.infer<typeof userPasswordUpdateSchema>;
export type UserFindPasswordUpdateData = z.infer<
typeof userFindPasswordUpdateSchema
>;
export type UserPasswordMatchData = z.infer<typeof userPasswordMatchSchema>;
export type UserUpdateInfoData = z.infer<typeof userUpdateInfoSchema>;
export type UserSaveData = z.infer<typeof userSaveSchema>;
export type UserFindPasswordSaveData = z.infer<
typeof userFindPasswordSaveSchema
>;
export type UserVerifyData = z.infer<typeof userVerifySchema>;
export type UserEmailData = z.infer<typeof userEmailSchema>;
export type UserIdData = z.infer<typeof userIdSchema>;
// 페이지네이션 타입 정의
export type UserList = PageableList<UserListResponse>;
export type UserSttsStatsList = UserSttsStatsResponse[];
export type AdminUserList = PageableList<AdminUserListResponse>;
// 사용자 서비스 인터페이스
export interface UserService {
// 사용자 목록 조회
findPageUser: (params: UserSearchParams) => Promise<UserList>;
// 사용자 단건 조회
findByUserUnqId: (userUnqId: string) => Promise<UserDetailResponse>;
// 로그인한 사용자 정보 조회
findByUserUnqIdByLogin: () => Promise<UserDetailResponse>;
// 사용자 회원상태 통계 조회
selectUserSttsStats: () => Promise<UserSttsStatsList>;
// 사용자 저장
saveUser: (data: UserSaveData) => Promise<string>;
// 사용자 수정
updateUser: (userUnqId: string, data: UserUpdateData) => Promise<string>;
// 사용자 삭제
deleteUser: (userUnqId: string) => Promise<boolean>;
// 사용자 계정잠금 처리
lockUser: (userUnqId: string) => Promise<boolean>;
// 사용자 계정잠금 해제처리
unlockUser: (userUnqId: string) => Promise<boolean>;
// 리프레시 토큰 정보 재발급
refreshToken: () => Promise<void>;
// 사용자 비밀번호 변경
updatePassword: (data: UserPasswordUpdateData) => Promise<boolean>;
// 사용자 비밀번호 초기화(관리자용)
initializePassword: (
userUnqId: string,
data: UserPasswordUpdateData,
) => Promise<boolean>;
// 사용자 비밀번호 찾기 변경
changePassword: (data: UserFindPasswordUpdateData) => Promise<boolean>;
// 사용자 비밀번호 확인
matchPassword: (data: UserPasswordMatchData) => Promise<boolean>;
// 사용자 비밀번호 찾기
findPasswordUser: (data: UserFindPasswordSaveData) => Promise<boolean>;
// 사용자 비밀번호 찾기 유효성 확인
validPassword: (token: string) => Promise<boolean>;
// 사용자 회원정보 변경
updateInfo: (userUnqId: string, data: UserUpdateInfoData) => Promise<string>;
// 사용자 승인처리
applyUser: (userUnqId: string) => Promise<boolean>;
// 사용자 회원탈퇴
leaveUser: (data: UserVerifyData) => Promise<boolean>;
// 사용자 이메일 중복 체크
existsEmail: (data: UserEmailData) => Promise<boolean>;
// 사용자 아이디 중복 체크
existsUserId: (data: UserIdData) => Promise<boolean>;
// 관리자 목록 조회
findPageAdminUser: (params: UserSearchParams) => Promise<AdminUserList>;
}
@@ -1,124 +0,0 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
Button,
Input,
DialogTrigger,
} from "@common/components";
import {
UserApi,
userPasswordUpdateSchema,
type UserPasswordUpdateData,
} from "@/entities/user";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/ui/form";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
interface PasswordResetDialogProps {
userUnqId: string;
children: React.ReactNode;
}
export function PasswordResetDialog({
userUnqId,
children,
}: PasswordResetDialogProps) {
const alert = useAlertStore((state) => state.setMessage);
const [open, setOpen] = useState(false);
const form = useForm<UserPasswordUpdateData>({
resolver: zodResolver(userPasswordUpdateSchema),
defaultValues: {
newPassword: "",
newPasswordConfirm: "",
provider: "password",
},
});
const { mutate: onPasswordReset } = useMutationWrapper({
mutationFn: (data: UserPasswordUpdateData) =>
UserApi.initializePassword(userUnqId, data),
onSuccess: () => {
alert("비밀번호가 초기화되었습니다.", () => {
setOpen(false);
form.reset();
});
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>비밀번호 초기화</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => onPasswordReset(data))}
className="space-y-6"
>
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>새 비밀번호</FormLabel>
<FormControl>
<Input
{...field}
type="password"
className="!text-[1.3rem]"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="newPasswordConfirm"
render={({ field }) => (
<FormItem>
<FormLabel>새 비밀번호 확인</FormLabel>
<FormControl>
<Input
{...field}
type="password"
className="!text-[1.3rem]"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<DialogClose asChild>
<Button type="button" className="white">
취소
</Button>
</DialogClose>
<Button type="submit">확인</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
-1
View File
@@ -1 +0,0 @@
export { LoginPage } from "./ui/login-page";
@@ -1,153 +0,0 @@
import { useState } from "react";
import { ACCESS_TOKEN, AUTH_USER_ID, REFRESH_TOKEN, CLAIM_NAME } from "@/shared/config";
import { tokenManager } from "@/entities/auth/lib/token-manager";
import {
Button,
Dialog,
DialogContent,
} from "@common/components";
interface OtpModalProps {
onClose: () => void;
onSuccess: () => void;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function OtpModal({ onClose, onSuccess, open, onOpenChange }: OtpModalProps) {
const [otp, setOtp] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async () => {
try {
setIsLoading(true);
setError("");
const tempToken = localStorage.getItem("tempToken");
console.log("tempToken from localStorage:", tempToken);
if (!tempToken) {
setError("임시 토큰이 없습니다. 로그인을 다시 시도해주세요.");
return;
}
// Proxy를 통해 OTP 인증 처리
const response = await fetch(`/api/proxy/user-service/api/v1/auth/2fa/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
otpCode: otp,
tempToken
}),
});
if (!response.ok) {
const errorData = await response.json();
let errorMsg = errorData.message || 'OTP 인증 실패';
if (Array.isArray(errorData.errors) && errorData.errors.length > 0) {
errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', ');
}
throw new Error(errorMsg);
}
const data = await response.json();
console.log("OTP verification response:", data);
console.log("Response headers:", Object.fromEntries(response.headers.entries()));
console.log("Response data keys:", Object.keys(data));
console.log("Full response data:", JSON.stringify(data, null, 2));
// 2FA 응답에서는 access-token만 있고 token-id는 없을 수 있음
const accessToken = data[ACCESS_TOKEN] || data.accessToken || data.access_token || data.token || data['ss-token'];
console.log("Extracted accessToken:", !!accessToken);
console.log("Token value - accessToken:", accessToken?.substring(0, 20) + "...");
// access-token이 있는 경우 저장
if (accessToken) {
console.log("Saving access token from OTP verification");
tokenManager.setToken(CLAIM_NAME, accessToken); // Authorization으로 저장
// 리프레시 토큰도 확인
const refreshToken = data[REFRESH_TOKEN] || data.refreshToken || data.refresh_token;
if (refreshToken) {
console.log("Saving refresh token");
tokenManager.setToken(REFRESH_TOKEN, refreshToken);
}
// userId는 원래 로그인 응답에서 가져온 값 사용
const originalUserId = data.userId;
if (originalUserId) {
console.log("Saving userId from original login response");
tokenManager.setToken(AUTH_USER_ID, originalUserId);
}
console.log("All tokens saved successfully");
} else {
console.log("No access token in OTP response");
console.log("Available data keys:", Object.keys(data));
console.log("Data values:", Object.entries(data).map(([k, v]) => `${k}: ${typeof v === 'string' ? v.substring(0, 20) + '...' : v}`));
}
onSuccess();
} catch (error: unknown) {
console.error("OTP verification error:", error);
if (error instanceof Error) {
setError(error.message);
} else {
setError("OTP 인증 실패");
}
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
onOpenChange(false);
onClose();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<div className="pt-8 text-center">
<h2 className="text-[1.3rem] font-semibold mb-6">OTP 인증</h2>
<div className="space-y-4">
<input
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-lg"
placeholder="OTP 코드를 입력하세요"
value={otp}
onChange={(e) => setOtp(e.target.value)}
disabled={isLoading}
/>
{error && (
<div className="text-red-500 text-sm text-center">
{error}
</div>
)}
<div className="flex space-x-2 pt-4">
<Button
onClick={handleSubmit}
disabled={isLoading || !otp.trim()}
className="flex-1"
>
{isLoading ? "인증 중..." : "인증"}
</Button>
<Button
variant="outline"
onClick={handleClose}
disabled={isLoading}
className="flex-1"
>
닫기
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,701 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import type { UserSaveData } from "@/entities/user";
import { ENDPOINT, BASE_URL } from "@/shared/config";
import { SignupClient } from "@/shared/api/signup-client";
import "./signup-modal.css";
interface SignupModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onClose: () => void;
onSuccess: () => void;
}
function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
const [currentStep, setCurrentStep] = useState<'agreement' | 'signup'>('agreement');
const [agreed, setAgreed] = useState(false);
const [formData, setFormData] = useState({
userId: "",
userName: "",
email: "",
password: "",
confirmPassword: "",
pswdHnt: "",
pswdAns: "",
mphn: "",
telno: "",
zip: "",
addr: "",
daddr: "",
laeSeCd: "",
ogdpSeCd: "",
expryYmd: "",
instSeCd: "",
dtlInstCd: "",
enoCd: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isLoading, setIsLoading] = useState(false);
const [isCheckingId, setIsCheckingId] = useState(false);
const [isCheckingEmail, setIsCheckingEmail] = useState(false);
const [idAvailable, setIdAvailable] = useState<boolean | null>(null);
const [emailAvailable, setEmailAvailable] = useState<boolean | null>(null);
// 모달이 닫힐 때 초기화
useEffect(() => {
if (!open) {
setCurrentStep('agreement');
setAgreed(false);
setFormData({
userId: "",
userName: "",
email: "",
password: "",
confirmPassword: "",
pswdHnt: "",
pswdAns: "",
mphn: "",
telno: "",
zip: "",
addr: "",
daddr: "",
laeSeCd: "",
ogdpSeCd: "",
expryYmd: "",
instSeCd: "",
dtlInstCd: "",
enoCd: "",
});
setErrors({});
setIdAvailable(null);
setEmailAvailable(null);
}
}, [open]);
const handleAgreementChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setAgreed(e.target.checked);
};
const handleNextStep = () => {
if (agreed) {
setCurrentStep('signup');
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// 에러 초기화
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: "" }));
}
};
const validateForm = () => {
const newErrors: Record<string, string> = {};
// 필수 필드 검증
if (!formData.userId) newErrors.userId = "아이디를 입력해주세요.";
if (!formData.userName) newErrors.userName = "이름을 입력해주세요.";
if (!formData.password) newErrors.password = "비밀번호를 입력해주세요.";
if (!formData.confirmPassword) newErrors.confirmPassword = "비밀번호 확인을 입력해주세요.";
if (!formData.pswdHnt) newErrors.pswdHnt = "비밀번호 힌트를 입력해주세요.";
if (!formData.laeSeCd) newErrors.laeSeCd = "법인구분을 선택해주세요.";
if (!formData.ogdpSeCd) newErrors.ogdpSeCd = "기관구분을 선택해주세요.";
// 비밀번호 확인
if (formData.password && formData.confirmPassword && formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = "비밀번호가 일치하지 않습니다.";
}
// 비밀번호 정규식 검증
const passwordRegex = /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/;
if (formData.password && !passwordRegex.test(formData.password)) {
newErrors.password = "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.";
}
// 이메일 형식 검증
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = "올바른 이메일 형식을 입력해주세요.";
}
// 중복 체크 검증
if (idAvailable === false) newErrors.userId = "이미 사용 중인 아이디입니다.";
if (emailAvailable === false) newErrors.email = "이미 사용 중인 이메일입니다.";
setErrors(newErrors);
const isValid = Object.keys(newErrors).length === 0;
console.log("폼 검증 결과:", { isValid, errors: newErrors, formData });
return isValid;
};
const checkUserIdDuplicate = async () => {
if (!formData.userId) {
setErrors(prev => ({ ...prev, userId: "아이디를 입력해주세요." }));
return;
}
setIsCheckingId(true);
try {
// SignupClient를 통해 중복 체크 API 호출
const signupClient = SignupClient.getInstance({
baseURL: BASE_URL,
});
const response = await signupClient.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/existsUserId`,
{ userId: formData.userId }
);
const exists = response.data;
setIdAvailable(!exists);
if (exists) {
setErrors(prev => ({ ...prev, userId: "이미 사용 중인 아이디입니다." }));
} else {
setErrors(prev => ({ ...prev, userId: "" }));
}
} catch (error) {
console.error("아이디 중복 체크 오류:", error);
setErrors(prev => ({ ...prev, userId: "아이디 중복 체크 중 오류가 발생했습니다." }));
} finally {
setIsCheckingId(false);
}
};
const checkEmailDuplicate = async () => {
if (!formData.email) {
setErrors(prev => ({ ...prev, email: "이메일을 입력해주세요." }));
return;
}
setIsCheckingEmail(true);
try {
// SignupClient를 통해 중복 체크 API 호출
const signupClient = SignupClient.getInstance({
baseURL: BASE_URL,
});
const response = await signupClient.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/exists`,
{ email: formData.email }
);
const exists = response.data;
setEmailAvailable(!exists);
if (exists) {
setErrors(prev => ({ ...prev, email: "이미 사용 중인 이메일입니다." }));
} else {
setErrors(prev => ({ ...prev, email: "" }));
}
} catch (error) {
console.error("이메일 중복 체크 오류:", error);
setErrors(prev => ({ ...prev, email: "이메일 중복 체크 중 오류가 발생했습니다." }));
} finally {
setIsCheckingEmail(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
console.log("회원가입 버튼 클릭됨");
if (!validateForm()) {
console.log("폼 검증 실패");
return;
}
setIsLoading(true);
try {
const signupData: UserSaveData = {
userId: formData.userId,
userName: formData.userName,
email: formData.email || undefined,
password: formData.password,
pswdHnt: formData.pswdHnt,
pswdAns: formData.pswdAns || undefined,
mphn: formData.mphn || undefined,
telno: formData.telno || undefined,
zip: formData.zip || undefined,
addr: formData.addr || undefined,
daddr: formData.daddr || undefined,
laeSeCd: formData.laeSeCd,
ogdpSeCd: formData.ogdpSeCd,
expryYmd: "",
instSeCd: formData.instSeCd || undefined,
dtlInstCd: formData.dtlInstCd || undefined,
enoCd: formData.enoCd || undefined,
};
// SignupClient를 통해 익명사용자 회원가입 API 호출
const signupClient = SignupClient.getInstance({
baseURL: BASE_URL
});
const response = await signupClient.post(
`${ENDPOINT.USER_SERVICE.USERS}/anonymouse/users/join`,
signupData
);
console.log("회원가입 성공:", response.data);
// 성공 시 모달 닫기 및 콜백 호출
onSuccess();
onClose();
// 폼 초기화
setFormData({
userId: "",
userName: "",
email: "",
password: "",
confirmPassword: "",
pswdHnt: "",
pswdAns: "",
mphn: "",
telno: "",
zip: "",
addr: "",
daddr: "",
laeSeCd: "",
ogdpSeCd: "",
expryYmd: "",
instSeCd: "",
dtlInstCd: "",
enoCd: "",
});
setErrors({});
setIdAvailable(null);
setEmailAvailable(null);
} catch (error: unknown) {
console.error("회원가입 오류:", error);
if (error instanceof Error) {
setErrors(prev => ({ ...prev, submit: error.message }));
} else {
setErrors(prev => ({ ...prev, submit: "회원가입에 실패했습니다." }));
}
} finally {
setIsLoading(false);
}
};
if (!open) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>{currentStep === 'agreement' ? '개인정보제공동의' : '회원가입'}</h2>
<button className="close-button" onClick={onClose}>×</button>
</div>
{currentStep === 'agreement' ? (
<div className="agreement-content">
<div className="agreement-text">
<h3>개인정보 수집 및 이용 동의</h3>
<p>회원가입을 위해 다음과 같이 개인정보를 수집 및 이용합니다.</p>
<h4>1. 수집하는 개인정보 항목</h4>
<ul>
<li>필수항목: 아이디, 이름, 비밀번호, 비밀번호 힌트, 법인구분, 기관구분</li>
<li>선택항목: 이메일, 휴대폰번호, 전화번호, 주소, 기관코드, 상세기관코드, 사업자등록번호</li>
</ul>
<h4>2. 개인정보의 수집 및 이용목적</h4>
<ul>
<li>회원가입 및 관리</li>
<li>서비스 제공 및 운영</li>
<li>고객상담 및 문의응답</li>
</ul>
<h4>3. 개인정보의 보유 및 이용기간</h4>
<p>회원탈퇴 시까지 (단, 관련 법령에 따라 보존이 필요한 경우 해당 기간까지)</p>
<h4>4. 동의 거부권 및 거부 시 불이익</h4>
<p>개인정보 수집 및 이용에 대한 동의를 거부할 수 있으며, 동의 거부 시 회원가입이 제한됩니다.</p>
</div>
<div className="agreement-checkbox">
<label>
<input
type="checkbox"
checked={agreed}
onChange={handleAgreementChange}
/>
<span>개인정보 수집 및 이용에 동의합니다.</span>
</label>
</div>
<div className="agreement-actions">
<button
type="button"
onClick={onClose}
className="cancel-button"
>
취소
</button>
<button
type="button"
onClick={handleNextStep}
className="next-button"
disabled={!agreed}
>
다음
</button>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="signup-form">
<div className="form-section">
<h3>기본 정보</h3>
<div className="form-row">
<div className="form-group">
<label htmlFor="userId">아이디 *</label>
<div className="input-with-button">
<input
type="text"
id="userId"
name="userId"
value={formData.userId}
onChange={handleInputChange}
placeholder="아이디를 입력하세요"
maxLength={12}
disabled={isLoading}
/>
<button
type="button"
onClick={checkUserIdDuplicate}
disabled={isCheckingId || isLoading || !formData.userId}
className="check-button"
>
{isCheckingId ? "확인중..." : "중복확인"}
</button>
</div>
{idAvailable === true && <span className="success-message">사용 가능한 아이디입니다.</span>}
{errors.userId && <span className="error-message">{errors.userId}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="userName">이름 *</label>
<input
type="text"
id="userName"
name="userName"
value={formData.userName}
onChange={handleInputChange}
placeholder="이름을 입력하세요"
disabled={isLoading}
/>
{errors.userName && <span className="error-message">{errors.userName}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="email">이메일</label>
<div className="input-with-button">
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleInputChange}
placeholder="이메일을 입력하세요"
disabled={isLoading}
/>
<button
type="button"
onClick={checkEmailDuplicate}
disabled={isCheckingEmail || isLoading || !formData.email}
className="check-button"
>
{isCheckingEmail ? "확인중..." : "중복확인"}
</button>
</div>
{emailAvailable === true && <span className="success-message">사용 가능한 이메일입니다.</span>}
{errors.email && <span className="error-message">{errors.email}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="password">비밀번호 *</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleInputChange}
placeholder="8~20자, 영문/숫자/특수기호 포함"
maxLength={20}
disabled={isLoading}
/>
{errors.password && <span className="error-message">{errors.password}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="confirmPassword">비밀번호 확인 *</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
placeholder="비밀번호를 다시 입력하세요"
maxLength={20}
disabled={isLoading}
/>
{errors.confirmPassword && <span className="error-message">{errors.confirmPassword}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="pswdHnt">비밀번호 힌트 *</label>
<input
type="text"
id="pswdHnt"
name="pswdHnt"
value={formData.pswdHnt}
onChange={handleInputChange}
placeholder="비밀번호 힌트를 입력하세요"
maxLength={10}
disabled={isLoading}
/>
{errors.pswdHnt && <span className="error-message">{errors.pswdHnt}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="pswdAns">비밀번호 답변</label>
<input
type="text"
id="pswdAns"
name="pswdAns"
value={formData.pswdAns}
onChange={handleInputChange}
placeholder="비밀번호 답변을 입력하세요"
maxLength={100}
disabled={isLoading}
/>
{errors.pswdAns && <span className="error-message">{errors.pswdAns}</span>}
</div>
</div>
</div>
<div className="form-section">
<h3>연락처 정보</h3>
<div className="form-row">
<div className="form-group">
<label htmlFor="mphn">휴대폰 번호</label>
<input
type="tel"
id="mphn"
name="mphn"
value={formData.mphn}
onChange={handleInputChange}
placeholder="휴대폰 번호를 입력하세요"
maxLength={11}
disabled={isLoading}
/>
{errors.mphn && <span className="error-message">{errors.mphn}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="telno">전화번호</label>
<input
type="tel"
id="telno"
name="telno"
value={formData.telno}
onChange={handleInputChange}
placeholder="전화번호를 입력하세요"
maxLength={11}
disabled={isLoading}
/>
{errors.telno && <span className="error-message">{errors.telno}</span>}
</div>
</div>
</div>
<div className="form-section">
<h3>주소 정보</h3>
<div className="form-row">
<div className="form-group">
<label htmlFor="zip">우편번호</label>
<input
type="text"
id="zip"
name="zip"
value={formData.zip}
onChange={handleInputChange}
placeholder="우편번호를 입력하세요"
maxLength={5}
disabled={isLoading}
/>
{errors.zip && <span className="error-message">{errors.zip}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="addr">주소</label>
<input
type="text"
id="addr"
name="addr"
value={formData.addr}
onChange={handleInputChange}
placeholder="주소를 입력하세요"
maxLength={200}
disabled={isLoading}
/>
{errors.addr && <span className="error-message">{errors.addr}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="daddr">상세주소</label>
<input
type="text"
id="daddr"
name="daddr"
value={formData.daddr}
onChange={handleInputChange}
placeholder="상세주소를 입력하세요"
maxLength={200}
disabled={isLoading}
/>
{errors.daddr && <span className="error-message">{errors.daddr}</span>}
</div>
</div>
</div>
<div className="form-section">
<h3>기관 정보</h3>
<div className="form-row">
<div className="form-group">
<label htmlFor="laeSeCd">법인구분 *</label>
<select
id="laeSeCd"
name="laeSeCd"
value={formData.laeSeCd}
onChange={handleInputChange}
disabled={isLoading}
>
<option value="">법인구분을 선택하세요</option>
<option value="01">공공기관</option>
<option value="02">민간기업</option>
<option value="03">개인</option>
</select>
{errors.laeSeCd && <span className="error-message">{errors.laeSeCd}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="ogdpSeCd">기관구분 *</label>
<select
id="ogdpSeCd"
name="ogdpSeCd"
value={formData.ogdpSeCd}
onChange={handleInputChange}
disabled={isLoading}
>
<option value="">기관구분을 선택하세요</option>
<option value="01">환경부</option>
<option value="02">지방자치단체</option>
<option value="03">공공기관</option>
<option value="04">민간기업</option>
</select>
{errors.ogdpSeCd && <span className="error-message">{errors.ogdpSeCd}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="instSeCd">기관코드</label>
<input
type="text"
id="instSeCd"
name="instSeCd"
value={formData.instSeCd}
onChange={handleInputChange}
placeholder="기관코드를 입력하세요"
maxLength={10}
disabled={isLoading}
/>
{errors.instSeCd && <span className="error-message">{errors.instSeCd}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="dtlInstCd">상세기관코드</label>
<input
type="text"
id="dtlInstCd"
name="dtlInstCd"
value={formData.dtlInstCd}
onChange={handleInputChange}
placeholder="상세기관코드를 입력하세요"
maxLength={10}
disabled={isLoading}
/>
{errors.dtlInstCd && <span className="error-message">{errors.dtlInstCd}</span>}
</div>
</div>
</div>
{errors.submit && (
<div className="form-row">
<div className="error-message submit-error">{errors.submit}</div>
</div>
)}
<div className="form-actions">
<button
type="button"
onClick={() => setCurrentStep('agreement')}
className="back-button"
disabled={isLoading}
>
이전
</button>
<button
type="button"
onClick={onClose}
className="cancel-button"
disabled={isLoading}
>
취소
</button>
<button
type="submit"
className="submit-button"
disabled={isLoading}
>
{isLoading ? "가입 중..." : "회원가입"}
</button>
</div>
</form>
)}
</div>
</div>
);
}
export default SignupModal;
@@ -1,198 +0,0 @@
import { useEffect, useState } from "react";
import Image from "next/image";
import {
Button,
Dialog,
DialogContent,
} from "@common/components";
interface TwoFAModalProps {
onClose: () => void;
onSuccess: () => void;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function TwoFAModal({ onClose, onSuccess, open, onOpenChange }: TwoFAModalProps) {
const [qrImage, setQrImage] = useState("");
const [secret, setSecret] = useState("");
const [otp, setOtp] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isSetupLoading, setIsSetupLoading] = useState(true);
useEffect(() => {
const fetchQR = async () => {
try {
setIsSetupLoading(true);
setError("");
const tempToken = localStorage.getItem("tempToken");
if (!tempToken) {
setError("임시 토큰이 없습니다");
return;
}
// Proxy를 통해 2FA setup 처리
const response = await fetch(`/api/proxy/user-service/api/v1/auth/2fa/setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tempToken }),
});
if (!response.ok) {
const errorData = await response.json();
let errorMsg = errorData.message || 'QR코드 발급 실패';
if (Array.isArray(errorData.errors) && errorData.errors.length > 0) {
errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', ');
}
throw new Error(errorMsg);
}
const data = await response.json();
console.log("2FA setup response:", data);
console.log("Response headers:", Object.fromEntries(response.headers.entries()));
// Proxy 응답에서 QR 데이터 추출
setQrImage(data.qrImage);
setSecret(data.secret);
} catch (error: unknown) {
console.error("2FA setup error:", error);
if (error instanceof Error) {
setError(error.message);
} else {
setError("QR코드 발급 실패");
}
} finally {
setIsSetupLoading(false);
}
};
if (open) {
fetchQR();
}
}, [open]);
const handleEnable2FA = async () => {
try {
setIsLoading(true);
setError("");
const tempToken = localStorage.getItem("tempToken");
if (!tempToken) {
setError("임시 토큰이 없습니다");
return;
}
// Proxy를 통해 2FA enable 처리
const response = await fetch(`/api/proxy/user-service/api/v1/auth/2fa/enable`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': tempToken,
},
body: JSON.stringify({
otpCode: otp,
tempToken: tempToken,
secret: secret,
}),
});
if (!response.ok) {
const errorData = await response.json();
let errorMsg = errorData.message || '2FA 등록 실패';
if (Array.isArray(errorData.errors) && errorData.errors.length > 0) {
errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', ');
}
throw new Error(errorMsg);
}
const data = await response.json();
console.log("2FA enable response:", data);
console.log("Response headers:", Object.fromEntries(response.headers.entries()));
console.log("Response data keys:", Object.keys(data));
// 2FA 설정 완료 후에는 토큰을 저장하지 않음 (실제 로그인은 OTP에서 처리)
console.log("2FA setup completed successfully");
onSuccess();
} catch (error: unknown) {
console.error("2FA enable error:", error);
if (error instanceof Error) {
setError(error.message);
} else {
setError("2FA 등록 실패");
}
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
onOpenChange(false);
onClose();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<div className="pt-8 text-center">
<h2 className="text-[1.3rem] font-semibold mb-6">2FA 등록</h2>
{isSetupLoading ? (
<div className="py-8">
<div className="text-gray-500">QR코드 로딩 중...</div>
</div>
) : (
<>
{qrImage && (
<div className="flex justify-center mb-4">
<Image
src={`data:image/png;base64,${qrImage}`}
alt="QR코드"
width={200}
height={200}
/>
</div>
)}
<div className="space-y-4">
<input
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-lg"
placeholder="OTP 코드를 입력하세요"
value={otp}
onChange={(e) => setOtp(e.target.value)}
disabled={isLoading}
/>
{error && (
<div className="text-red-500 text-sm text-center">
{error}
</div>
)}
<div className="flex space-x-2 pt-4">
<Button
onClick={handleEnable2FA}
disabled={isLoading || !otp.trim()}
className="flex-1"
>
{isLoading ? "2FA 활성화 중..." : "2FA 활성화"}
</Button>
<Button
variant="outline"
onClick={handleClose}
disabled={isLoading}
className="flex-1"
>
닫기
</Button>
</div>
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,901 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { SignupClient } from "@/shared/api/signup-client";
import { BASE_URL, ENDPOINT } from "@/shared/config";
import "./user-signup-modal.css";
interface UserSignupModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
interface UserSignupData {
userId: string;
userName: string;
email?: string;
password: string;
pswdHnt: string;
pswdAns?: string;
mphn?: string;
telno?: string;
zip?: string;
addr?: string;
daddr?: string;
laeSeCd: string;
ogdpSeCd: string;
instSeCd?: string;
dtlInstCd?: string;
enoCd?: string;
}
// 익명 사용자용 CodeSelect 컴포넌트
interface AnonymousCodeSelectProps {
upCd: string;
value: string;
onValueChange: (value: string) => void;
disabled?: boolean;
placeholder?: string;
}
function AnonymousCodeSelect({
upCd,
value,
onValueChange,
disabled,
placeholder = "선택",
}: AnonymousCodeSelectProps) {
const [codes, setCodes] = useState<
Array<{ lwpoCd: string; lwpoCdNm: string }>
>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const fetchCodes = async () => {
if (!upCd) return;
setIsLoading(true);
try {
const signupClient = SignupClient.getInstance({
baseURL: BASE_URL,
});
const response = await signupClient.get(
`${ENDPOINT.CMS_SERVICE.CODE}/${upCd}`,
);
if (response.data && Array.isArray(response.data)) {
setCodes(response.data);
} else {
setCodes([]);
}
} catch (error) {
console.error(`공통 코드 로드 실패 (${upCd}):`, error);
setCodes([]);
} finally {
setIsLoading(false);
}
};
fetchCodes();
}, [upCd]);
return (
<select
value={value}
onChange={(e) => onValueChange(e.target.value)}
disabled={disabled || isLoading}
className="user-signup-select"
>
<option value="">{isLoading ? "로딩 중..." : placeholder}</option>
{codes.map((code) => (
<option key={code.lwpoCd} value={code.lwpoCd}>
{code.lwpoCdNm}
</option>
))}
</select>
);
}
function UserSignupModal({ open, onClose, onSuccess }: UserSignupModalProps) {
const [currentStep, setCurrentStep] = useState<"agreement" | "signup">(
"agreement",
);
const [agreed, setAgreed] = useState(false);
const [formData, setFormData] = useState({
userId: "",
userName: "",
email: "",
password: "",
confirmPassword: "",
pswdHnt: "",
pswdAns: "",
mphn: "",
telno: "",
zip: "",
addr: "",
daddr: "",
laeSeCd: "",
ogdpSeCd: "",
instSeCd: "",
dtlInstCd: "",
enoCd: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isLoading, setIsLoading] = useState(false);
const [isCheckingId, setIsCheckingId] = useState(false);
const [isCheckingEmail, setIsCheckingEmail] = useState(false);
const [idAvailable, setIdAvailable] = useState<boolean | null>(null);
const [emailAvailable, setEmailAvailable] = useState<boolean | null>(null);
// 모달이 닫힐 때 초기화
useEffect(() => {
if (!open) {
setCurrentStep("agreement");
setAgreed(false);
setFormData({
userId: "",
userName: "",
email: "",
password: "",
confirmPassword: "",
pswdHnt: "",
pswdAns: "",
mphn: "",
telno: "",
zip: "",
addr: "",
daddr: "",
laeSeCd: "",
ogdpSeCd: "",
instSeCd: "",
dtlInstCd: "",
enoCd: "",
});
setErrors({});
setIdAvailable(null);
setEmailAvailable(null);
}
}, [open]);
const handleAgreementChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setAgreed(e.target.checked);
};
const handleNextStep = () => {
if (agreed) {
setCurrentStep("signup");
}
};
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
// 에러 초기화
if (errors[name]) {
setErrors((prev) => ({ ...prev, [name]: "" }));
}
};
const validateForm = () => {
const newErrors: Record<string, string> = {};
console.log("폼 검증 시작:", formData);
// 필수 필드 검증
if (!formData.userId) newErrors.userId = "아이디를 입력해주세요.";
if (!formData.userName) newErrors.userName = "이름을 입력해주세요.";
if (!formData.password) newErrors.password = "비밀번호를 입력해주세요.";
if (!formData.confirmPassword)
newErrors.confirmPassword = "비밀번호 확인을 입력해주세요.";
if (!formData.pswdHnt) newErrors.pswdHnt = "비밀번호 힌트를 선택해주세요.";
if (!formData.laeSeCd) newErrors.laeSeCd = "법인구분을 선택해주세요.";
if (!formData.ogdpSeCd) newErrors.ogdpSeCd = "기관구분을 선택해주세요.";
// 비밀번호 확인
if (
formData.password &&
formData.confirmPassword &&
formData.password !== formData.confirmPassword
) {
newErrors.confirmPassword = "비밀번호가 일치하지 않습니다.";
}
// 비밀번호 정규식 검증
const passwordRegex =
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/;
if (formData.password && !passwordRegex.test(formData.password)) {
newErrors.password =
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.";
}
// 이메일 형식 검증
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = "올바른 이메일 형식을 입력해주세요.";
}
// 중복 체크 검증
if (idAvailable === false)
newErrors.userId = "이미 사용 중인 아이디입니다.";
if (emailAvailable === false)
newErrors.email = "이미 사용 중인 이메일입니다.";
setErrors(newErrors);
const isValid = Object.keys(newErrors).length === 0;
console.log("폼 검증 결과:", { isValid, errors: newErrors, formData });
return isValid;
};
const checkUserIdDuplicate = async () => {
if (!formData.userId) {
setErrors((prev) => ({ ...prev, userId: "아이디를 입력해주세요." }));
return;
}
setIsCheckingId(true);
try {
const signupClient = SignupClient.getInstance({
baseURL: BASE_URL,
});
const response = await signupClient.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/existsUserId`,
{ userId: formData.userId },
);
const exists = response.data;
setIdAvailable(!exists);
if (exists) {
setErrors((prev) => ({
...prev,
userId: "이미 사용 중인 아이디입니다.",
}));
} else {
setErrors((prev) => ({ ...prev, userId: "" }));
}
} catch (error) {
console.error("아이디 중복 체크 오류:", error);
setErrors((prev) => ({
...prev,
userId: "아이디 중복 체크 중 오류가 발생했습니다.",
}));
} finally {
setIsCheckingId(false);
}
};
const checkEmailDuplicate = async () => {
if (!formData.email) {
setErrors((prev) => ({ ...prev, email: "이메일을 입력해주세요." }));
return;
}
setIsCheckingEmail(true);
try {
const signupClient = SignupClient.getInstance({
baseURL: BASE_URL,
});
const response = await signupClient.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/exists`,
{ email: formData.email },
);
const exists = response.data;
setEmailAvailable(!exists);
if (exists) {
setErrors((prev) => ({
...prev,
email: "이미 사용 중인 이메일입니다.",
}));
} else {
setErrors((prev) => ({ ...prev, email: "" }));
}
} catch (error) {
console.error("이메일 중복 체크 오류:", error);
setErrors((prev) => ({
...prev,
email: "이메일 중복 체크 중 오류가 발생했습니다.",
}));
} finally {
setIsCheckingEmail(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
console.log("회원가입 버튼 클릭됨 - handleSubmit");
console.log("현재 폼 데이터:", formData);
if (!validateForm()) {
console.log("폼 검증 실패");
return;
}
console.log("폼 검증 성공, 회원가입 API 호출 시작");
setIsLoading(true);
try {
const signupData: UserSignupData = {
userId: formData.userId,
userName: formData.userName,
email: formData.email || undefined,
password: formData.password,
pswdHnt: formData.pswdHnt,
pswdAns: formData.pswdAns || undefined,
mphn: formData.mphn || undefined,
telno: formData.telno || undefined,
zip: formData.zip || undefined,
addr: formData.addr || undefined,
daddr: formData.daddr || undefined,
laeSeCd: formData.laeSeCd,
ogdpSeCd: formData.ogdpSeCd,
instSeCd: formData.instSeCd || undefined,
dtlInstCd: formData.dtlInstCd || undefined,
enoCd: formData.enoCd || undefined,
};
// SignupClient를 통해 익명사용자 회원가입 API 호출
console.log("SignupClient 생성 및 API 호출:", {
baseURL: BASE_URL,
endpoint: ENDPOINT.USER_SERVICE.USER_JOIN,
data: signupData,
});
const signupClient = SignupClient.getInstance({
baseURL: BASE_URL,
});
const response = await signupClient.post(
ENDPOINT.USER_SERVICE.USER_JOIN,
signupData,
);
console.log("회원가입 성공:", response.data);
// 성공 시 모달 닫기 및 콜백 호출
onSuccess();
onClose();
// 폼 초기화
setFormData({
userId: "",
userName: "",
email: "",
password: "",
confirmPassword: "",
pswdHnt: "",
pswdAns: "",
mphn: "",
telno: "",
zip: "",
addr: "",
daddr: "",
laeSeCd: "",
ogdpSeCd: "",
instSeCd: "",
dtlInstCd: "",
enoCd: "",
});
setErrors({});
setIdAvailable(null);
setEmailAvailable(null);
} catch (error: unknown) {
console.error("회원가입 오류:", error);
if (error instanceof Error) {
setErrors((prev) => ({ ...prev, submit: error.message }));
} else {
setErrors((prev) => ({ ...prev, submit: "회원가입에 실패했습니다." }));
}
} finally {
setIsLoading(false);
}
};
if (!open) return null;
return (
<div className="user-signup-modal-overlay">
<div className="user-signup-modal-content">
<div className="user-signup-modal-header">
<h2>
{currentStep === "agreement" ? "개인정보제공동의" : "회원가입"}
</h2>
<button className="user-signup-close-button" onClick={onClose}>
×
</button>
</div>
{currentStep === "agreement" ? (
<div className="user-signup-agreement-content">
<div className="user-signup-agreement-text">
<h3>개인정보 수집 및 이용 동의</h3>
<p>회원가입을 위해 다음과 같이 개인정보를 수집 및 이용합니다.</p>
<h4>1. 수집하는 개인정보 항목</h4>
<ul>
<li>
필수항목: 아이디, 이름, 비밀번호, 비밀번호 힌트, 법인구분,
기관구분
</li>
<li>
선택항목: 이메일, 휴대폰번호, 전화번호, 주소, 기관코드,
상세기관코드, 사업자등록번호
</li>
</ul>
<h4>2. 개인정보의 수집 및 이용목적</h4>
<ul>
<li>회원가입 및 관리</li>
<li>서비스 제공 및 운영</li>
<li>고객상담 및 문의응답</li>
</ul>
<h4>3. 개인정보의 보유 및 이용기간</h4>
<p>
회원탈퇴 시까지 (단, 관련 법령에 따라 보존이 필요한 경우 해당
기간까지)
</p>
<h4>4. 동의 거부권 및 거부 시 불이익</h4>
<p>
개인정보 수집 및 이용에 대한 동의를 거부할 수 있으며, 동의 거부
시 회원가입이 제한됩니다.
</p>
</div>
<div className="user-signup-agreement-checkbox">
<label>
<input
type="checkbox"
checked={agreed}
onChange={handleAgreementChange}
/>
<span>개인정보 수집 및 이용에 동의합니다.</span>
</label>
</div>
<div className="user-signup-agreement-actions">
<button
type="button"
onClick={onClose}
className="user-signup-cancel-button"
>
취소
</button>
<button
type="button"
onClick={handleNextStep}
className="user-signup-next-button"
disabled={!agreed}
>
다음
</button>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="user-signup-form">
<div className="user-signup-form-section">
<h3>기본 정보</h3>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="userId">아이디 *</label>
<div className="user-signup-input-with-button">
<input
type="text"
id="userId"
name="userId"
value={formData.userId}
onChange={handleInputChange}
placeholder="아이디를 입력하세요"
maxLength={12}
disabled={isLoading}
/>
<button
type="button"
onClick={checkUserIdDuplicate}
disabled={isCheckingId || isLoading || !formData.userId}
className="user-signup-check-button"
>
{isCheckingId ? "확인중..." : "중복확인"}
</button>
</div>
{idAvailable === true && (
<span className="user-signup-success-message">
사용 가능한 아이디입니다.
</span>
)}
{errors.userId && (
<span className="user-signup-error-message">
{errors.userId}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="userName">이름 *</label>
<input
type="text"
id="userName"
name="userName"
value={formData.userName}
onChange={handleInputChange}
placeholder="이름을 입력하세요"
disabled={isLoading}
/>
{errors.userName && (
<span className="user-signup-error-message">
{errors.userName}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="email">이메일</label>
<div className="user-signup-input-with-button">
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleInputChange}
placeholder="이메일을 입력하세요"
disabled={isLoading}
/>
<button
type="button"
onClick={checkEmailDuplicate}
disabled={isCheckingEmail || isLoading || !formData.email}
className="user-signup-check-button"
>
{isCheckingEmail ? "확인중..." : "중복확인"}
</button>
</div>
{emailAvailable === true && (
<span className="user-signup-success-message">
사용 가능한 이메일입니다.
</span>
)}
{errors.email && (
<span className="user-signup-error-message">
{errors.email}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="password">비밀번호 *</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleInputChange}
placeholder="8~20자, 영문/숫자/특수기호 포함"
maxLength={20}
disabled={isLoading}
/>
{errors.password && (
<span className="user-signup-error-message">
{errors.password}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="confirmPassword">비밀번호 확인 *</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
placeholder="비밀번호를 다시 입력하세요"
maxLength={20}
disabled={isLoading}
/>
{errors.confirmPassword && (
<span className="user-signup-error-message">
{errors.confirmPassword}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="pswdHnt">비밀번호 힌트 *</label>
<AnonymousCodeSelect
upCd="PSWDHNT"
value={formData.pswdHnt}
onValueChange={(value: string) =>
setFormData((prev) => ({ ...prev, pswdHnt: value }))
}
disabled={isLoading}
placeholder="비밀번호 힌트를 선택하세요"
/>
{errors.pswdHnt && (
<span className="user-signup-error-message">
{errors.pswdHnt}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="pswdAns">비밀번호 답변</label>
<input
type="text"
id="pswdAns"
name="pswdAns"
value={formData.pswdAns}
onChange={handleInputChange}
placeholder="비밀번호 답변을 입력하세요"
maxLength={100}
disabled={isLoading}
/>
{errors.pswdAns && (
<span className="user-signup-error-message">
{errors.pswdAns}
</span>
)}
</div>
</div>
</div>
<div className="user-signup-form-section">
<h3>연락처 정보</h3>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="mphn">휴대폰 번호</label>
<input
type="tel"
id="mphn"
name="mphn"
value={formData.mphn}
onChange={handleInputChange}
placeholder="휴대폰 번호를 입력하세요"
maxLength={11}
disabled={isLoading}
/>
{errors.mphn && (
<span className="user-signup-error-message">
{errors.mphn}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="telno">전화번호</label>
<input
type="tel"
id="telno"
name="telno"
value={formData.telno}
onChange={handleInputChange}
placeholder="전화번호를 입력하세요"
maxLength={11}
disabled={isLoading}
/>
{errors.telno && (
<span className="user-signup-error-message">
{errors.telno}
</span>
)}
</div>
</div>
</div>
<div className="user-signup-form-section">
<h3>주소 정보</h3>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="zip">우편번호</label>
<input
type="text"
id="zip"
name="zip"
value={formData.zip}
onChange={handleInputChange}
placeholder="우편번호를 입력하세요"
maxLength={5}
disabled={isLoading}
/>
{errors.zip && (
<span className="user-signup-error-message">
{errors.zip}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="addr">주소</label>
<input
type="text"
id="addr"
name="addr"
value={formData.addr}
onChange={handleInputChange}
placeholder="주소를 입력하세요"
maxLength={200}
disabled={isLoading}
/>
{errors.addr && (
<span className="user-signup-error-message">
{errors.addr}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="daddr">상세주소</label>
<input
type="text"
id="daddr"
name="daddr"
value={formData.daddr}
onChange={handleInputChange}
placeholder="상세주소를 입력하세요"
maxLength={200}
disabled={isLoading}
/>
{errors.daddr && (
<span className="user-signup-error-message">
{errors.daddr}
</span>
)}
</div>
</div>
</div>
<div className="user-signup-form-section">
<h3>기관 정보</h3>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="laeSeCd">법인구분 *</label>
<AnonymousCodeSelect
upCd="LAESECD"
value={formData.laeSeCd}
onValueChange={(value: string) =>
setFormData((prev) => ({ ...prev, laeSeCd: value }))
}
disabled={isLoading}
placeholder="법인구분을 선택하세요"
/>
{errors.laeSeCd && (
<span className="user-signup-error-message">
{errors.laeSeCd}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="ogdpSeCd">기관구분 *</label>
<AnonymousCodeSelect
upCd="OGDPSECD"
value={formData.ogdpSeCd}
onValueChange={(value: string) =>
setFormData((prev) => ({ ...prev, ogdpSeCd: value }))
}
disabled={isLoading}
placeholder="기관구분을 선택하세요"
/>
{errors.ogdpSeCd && (
<span className="user-signup-error-message">
{errors.ogdpSeCd}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="instSeCd">기관코드</label>
<input
type="text"
id="instSeCd"
name="instSeCd"
value={formData.instSeCd}
onChange={handleInputChange}
placeholder="기관코드를 입력하세요"
maxLength={10}
disabled={isLoading}
/>
{errors.instSeCd && (
<span className="user-signup-error-message">
{errors.instSeCd}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="dtlInstCd">상세기관코드</label>
<input
type="text"
id="dtlInstCd"
name="dtlInstCd"
value={formData.dtlInstCd}
onChange={handleInputChange}
placeholder="상세기관코드를 입력하세요"
maxLength={10}
disabled={isLoading}
/>
{errors.dtlInstCd && (
<span className="user-signup-error-message">
{errors.dtlInstCd}
</span>
)}
</div>
</div>
</div>
{errors.submit && (
<div className="user-signup-form-row">
<div className="user-signup-error-message user-signup-submit-error">
{errors.submit}
</div>
</div>
)}
<div className="user-signup-form-actions">
<button
type="button"
onClick={() => setCurrentStep("agreement")}
className="user-signup-back-button"
disabled={isLoading}
>
이전
</button>
<button
type="button"
onClick={onClose}
className="user-signup-cancel-button"
disabled={isLoading}
>
취소
</button>
<button
type="submit"
className="user-signup-submit-button"
disabled={isLoading}
onClick={(e) => {
e.preventDefault();
console.log("회원가입 버튼 클릭됨 - onClick");
handleSubmit(e);
}}
>
{isLoading ? "가입 중..." : "회원가입"}
</button>
</div>
</form>
)}
</div>
</div>
);
}
export default UserSignupModal;
@@ -1,321 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
import { images } from "@common/assets";
import { LoginApi } from "@/entities/auth";
import { ENDPOINT } from "@/shared/config";
import OtpModal from "./OtpModal";
import TwoFAModal from "./TwoFAModal";
import UserSignupModal from "./UserSignupModal";
import "./login.css";
import { useRouter } from "next/navigation";
function LoginPage() {
const [isOtpEnabled, setIsOtpEnabled] = useState(false);
const [showOtpModal, setShowOtpModal] = useState(false);
const [showTwoFAModal, setShowTwoFAModal] = useState(false);
const [showSignupModal, setShowSignupModal] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [successMessage, setSuccessMessage] = useState("");
const router = useRouter();
// 모달 상태 디버깅
useEffect(() => {
console.log("Modal states - OTP:", showOtpModal, "2FA:", showTwoFAModal);
}, [showOtpModal, showTwoFAModal]);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError("");
setSuccessMessage("");
try {
const formData = new FormData(e.target as HTMLFormElement);
const userId = formData.get("id") as string;
const password = formData.get("password") as string;
console.log("Login attempt - OTP enabled:", isOtpEnabled);
console.log("User ID:", userId);
if (isOtpEnabled) {
// 2FA 로그인 - Proxy를 통해 처리 (2FA 전용 엔드포인트 사용)
console.log("Starting 2FA login...");
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN_2FA}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId,
password,
provider: 'password'
}),
});
if (!response.ok) {
const errorData = await response.json();
let errorMsg = errorData.message || '로그인 실패';
if (Array.isArray(errorData.errors) && errorData.errors.length > 0) {
errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', ');
}
throw new Error(errorMsg);
}
const data = await response.json();
console.log("2FA login response:", data);
console.log("Response headers:", Object.fromEntries(response.headers.entries()));
// 2FA 로그인 응답에는 tempToken과 registered2FA만 포함됨
if (!data.tempToken) {
throw new Error("Missing tempToken in 2FA login response");
}
// 임시 토큰을 localStorage에 저장 (OTP 인증용)
localStorage.setItem('tempToken', data.tempToken);
console.log("tempToken saved:", data.tempToken);
if (data.registered2FA) {
console.log("User has 2FA registered, showing OTP modal");
setShowOtpModal(true);
} else {
console.log("User doesn't have 2FA registered, showing 2FA setup modal");
setShowTwoFAModal(true);
}
} else {
// 기본 로그인 - proxy를 통해 처리
console.log("Starting basic login...");
await LoginApi.login({ userId, password });
window.location.href = "/dashboard";
}
} catch (error: unknown) {
console.error("Login error:", error);
// 에러 처리
if (error instanceof Error) {
setError(error.message);
} else {
setError("로그인에 실패했습니다. 아이디와 비밀번호를 확인해주세요.");
}
} finally {
setIsLoading(false);
}
};
const handleOtpSuccess = () => {
console.log("OTP authentication successful, redirecting to dashboard");
localStorage.removeItem("tempToken"); // 임시 토큰 제거
router.push("/dashboard");
};
const handleTwoFASuccess = () => {
console.log("2FA setup successful, redirecting to dashboard");
localStorage.removeItem("tempToken"); // 임시 토큰 제거
router.push("/dashboard");
};
const handleOtpClose = () => {
console.log("OTP modal closed");
setShowOtpModal(false);
localStorage.removeItem("tempToken");
};
const handleTwoFAClose = () => {
console.log("2FA modal closed");
setShowTwoFAModal(false);
localStorage.removeItem("tempToken");
};
const handleSignupSuccess = () => {
console.log("회원가입 성공");
setError(""); // 에러 메시지 초기화
setSuccessMessage("회원가입이 완료되었습니다. 로그인해주세요.");
};
const handleSignupClose = () => {
console.log("회원가입 모달 닫기");
setShowSignupModal(false);
setSuccessMessage(""); // 성공 메시지 초기화
};
return (
<>
<div className="login fx">
<div className="left_box fx">
<div className="text">
<h1>
유역하수도 통합플랫폼
<br />
<span>통합관리시스템</span>
</h1>
<p>유역하수도 통합플랫폼에 오신 것을 환영합니다.</p>
</div>
<div className="img">
<Image src={images.logo} width={229} height={46} alt="logo" />
</div>
</div>
<form
id="loginForm"
name="loginForm"
method="post"
onSubmit={handleSubmit}
>
<div className="login_box fx">
<ul className="fx">
<li>
<h2>ID</h2>
<div className="input_st st1">
<i className="diveicon di-account-box" />
<input
type="text"
className="id"
title="아이디를 입력하세요."
id="id"
name="id"
maxLength={12}
disabled={isLoading}
/>
</div>
</li>
<li>
<h2>PASSWORD</h2>
<div className="input_st st2">
<i className="diveicon di-key" />
<input
type="password"
className="input_style"
maxLength={25}
title="비밀번호를 입력하세요."
id="password"
name="password"
disabled={isLoading}
/>
</div>
</li>
</ul>
<div className="bottom_box fx">
<div className="checks fx">
<input
type="checkbox"
name="checkId"
title="로그인ID 저장여부"
id="checkId"
disabled={isLoading}
/>
<label htmlFor="checkId">아이디저장</label>
</div>
<div className="checks fx">
<input
type="checkbox"
name="checkTwoFA"
title="OTP 인증 여부"
id="checkTwoFA"
checked={isOtpEnabled}
onChange={(e) => setIsOtpEnabled(e.target.checked)}
disabled={isLoading}
/>
<label htmlFor="checkTwoFA">OTP 인증</label>
</div>
</div>
<button
type="submit"
className="login_btn fx"
disabled={isLoading}
>
<span>{isLoading ? "로그인 중..." : "LOGIN"}</span>
</button>
{error && (
<div className="error-message" style={{ color: "red", textAlign: "center", marginTop: "10px" }}>
{error}
</div>
)}
{successMessage && (
<div className="success-message" style={{ color: "green", textAlign: "center", marginTop: "10px" }}>
{successMessage}
</div>
)}
<div className="signup-link" style={{ textAlign: "center", marginTop: "15px" }}>
<span style={{ color: "#666", fontSize: "14px" }}>계정이 없으신가요? </span>
<button
type="button"
onClick={() => setShowSignupModal(true)}
style={{
background: "none",
border: "none",
color: "#3b82f6",
textDecoration: "underline",
cursor: "pointer",
fontSize: "14px"
}}
>
회원가입
</button>
</div>
<div className="footer">
<div className="copy fx">
<p>copyright © 2023 KECO. All rights reserved.</p>
</div>
<div className="fot_btn fx">
<a
href="https://keco.or.kr/web/lay1/S1T6C1348/contents.do"
target="_blank"
>
개인정보처리방침
</a>
<a
href="https://keco.or.kr/web/lay1/S1T6C1349/contents.do"
target="_blank"
>
저작권정책안내
</a>
<a
href="https://keco.or.kr/web/lay1/S1T6C1350/contents.do"
target="_blank"
>
이메일무단수집거부
</a>
</div>
<p className="addr fx">
<span>
주소 : (22689) 인천광역시 서구 환경로 42(경서동
종합환경연구단지)
</span>
<span>
전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723,
3722
</span>
<span>팩스 :032-590-3729</span>
</p>
</div>
</div>
</form>
</div>
{/* OTP 인증 모달 */}
<OtpModal
onClose={handleOtpClose}
onSuccess={handleOtpSuccess}
open={showOtpModal}
onOpenChange={setShowOtpModal}
/>
{/* 2FA 등록 모달 */}
<TwoFAModal
onClose={handleTwoFAClose}
onSuccess={handleTwoFASuccess}
open={showTwoFAModal}
onOpenChange={setShowTwoFAModal}
/>
{/* 회원가입 모달 */}
<UserSignupModal
onClose={handleSignupClose}
onSuccess={handleSignupSuccess}
open={showSignupModal}
/>
</>
);
}
export { LoginPage };
-311
View File
@@ -1,311 +0,0 @@
/**
* OLYM Cascading Style Sheets
* File Name : login.css
* Description : 로그인 스타일시트
* Author : Web business Team / Choi Hyun Mi
* Email : desingtj@olym.co.kr
* Date : 2023.07.20
* Update : 2023.07.26
* Copyright(c) 2023 OLYM Communications. All Rights Reserved.
*/
html,
body {
background: #fff;
}
.fx {
display: flex;
-ms-display: flex;
-webkit-display: flex;
box-sizing: border-box;
}
.login {
width: 100%;
height: 100vh;
}
.login .left_box {
width: 100%;
background: url(../../../../../public/images/bg_login.jpg) no-repeat
center/cover;
flex-direction: column;
justify-content: space-between;
padding: 7% 5% 5% 5%;
}
.login .left_box .text h1 {
font-size: 53px;
color: #fff;
}
.login .left_box .text h1 span {
font-weight: 800;
}
.login .left_box .text p {
margin-top: 40px;
font-size: 18px;
color: #fff;
}
.login .login_box {
width: 100%;
flex-direction: column;
justify-content: center;
padding: 4% 12% 4% 6%;
}
.login .login_box > ul {
flex-direction: column;
gap: 40px;
}
.login .login_box > ul > li h2 {
font-size: 22px;
font-weight: 600;
color: #2640a5;
margin-bottom: 14px;
}
.login .login_box > ul > li .input_st {
position: relative;
}
.login .login_box > ul > li .input_st .diveicon {
position: absolute;
top: 50%;
left: 28px;
transform: translateY(-50%);
font-family: "Xeicon";
font-size: 25px;
color: #003288;
}
.login .login_box > ul > li input {
width: 100%;
padding: 20px 20px 20px 70px;
background: #f7f7f7;
box-sizing: border-box;
border: none;
border-radius: 5px;
font-size: 18px;
font-weight: 700;
color: #000;
height: auto;
}
.login .login_box > ul > li input:focus {
outline: 1px solid #3c5fe5;
}
.login .login_box > ul > li input::placeholder {
color: #afabab;
font-weight: 500;
}
.login .login_box .bottom_box {
justify-content: space-between;
gap: 20px;
padding: 50px 0 45px 0;
}
.login .login_box .bottom_box .checks {
display: flex;
align-items: center;
gap: 8px;
}
.login .login_box .bottom_box .checks input[type="checkbox"] {
width: 18px;
height: 18px;
}
.login .login_box .bottom_box .checks label {
font-size: 14px;
}
.login .login_box .bottom_box .pw_message {
align-items: center;
gap: 5px;
color: #636363;
font-weight: 500;
border-bottom: 1px solid #ccc;
padding-bottom: 5px;
}
.login .login_box .bottom_box .pw_message i {
color: #327af5;
font-size: 20px;
}
.login .login_box .login_btn {
position: relative;
border-radius: 5px;
padding: 24px 15px;
background: linear-gradient(
to left,
#1d3969 0%,
#264078 10%,
#172558 40%,
#162254 50%,
#111c43 60%,
#111c42 100%
);
overflow: hidden;
justify-content: center;
align-items: center;
gap: 10px;
color: #fff;
cursor: pointer;
}
.login .login_box .login_btn span {
font-family: "Montserrat";
font-size: 19px;
font-weight: 700;
position: relative;
}
.login .login_box .login_btn i {
font-size: 22px;
transform: translateY(-2px);
}
.login .login_box .login_btn:before {
display: block;
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
background: linear-gradient(
to right,
#1d3969 0%,
#264078 10%,
#172558 40%,
#162254 50%,
#111c43 60%,
#111c42 100%
);
transition: opacity 0.5s;
}
.login .login_box .login_btn:hover::before {
opacity: 1;
}
.login .footer .copy {
margin-top: 36px;
justify-content: space-between;
align-items: center;
gap: 3%;
}
.login .footer .copy p {
font-size: 13px;
color: #d4d4d4;
width: 44%;
text-align: center;
}
.login .footer .copy::before,
.login .login_box .copy::after {
content: "";
width: 20%;
height: 1px;
background: #d4d4d4;
}
.login .footer .fot_btn {
gap: 20px;
margin: 30px 0 10px 0;
}
.login .footer .fot_btn a {
color: #333;
font-weight: 700;
font-size: 13px;
}
.login .footer .fot_btn a:hover {
color: #003288;
}
.login .footer .addr {
color: #666;
font-size: 13px;
gap: 3px 20px;
flex-wrap: wrap;
}
/* 비밀번호 변경 팝업창 */
.login .modal .modal_container {
width: 750px;
}
.login .pw_desc {
padding: 10px 10px 30px 10px;
color: #666;
}
.login .pw_desc p {
font-size: 1.25rem;
color: #000;
font-weight: 700;
margin-bottom: 10px;
}
.login .pw_desc span {
color: #f60;
font-weight: 700;
}
.login .modal .form_write {
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
padding: 20px 50px;
}
@media (max-width: 1024px) {
.login {
flex-direction: column;
height: auto;
}
.login .left_box {
padding: 30px;
flex-direction: column-reverse;
gap: 20px;
}
.login .left_box .text h1 {
font-size: 30px;
line-height: 1.2em;
}
.login .left_box .text p {
margin-top: 20px;
font-size: 14px;
}
.login .left_box .img img {
height: 40px;
}
.login .login_box {
padding: 30px;
}
.login .login_box > ul {
gap: 20px;
}
.login .login_box > ul > li h2 {
margin-bottom: 7px;
}
.login .login_box > ul > li .input_st::after {
left: 15px;
font-size: 20px;
}
.login .login_box > ul > li input {
padding: 15px 10px 15px 50px;
font-size: 16px;
}
.login .login_box .bottom_box {
flex-direction: column;
align-items: flex-start;
padding: 30px 0;
}
.login .login_box .bottom_box .id_save label {
font-size: 14px;
}
.login .login_box .login_btn {
padding: 15px;
}
.login .login_box .login_btn span {
font-size: 16px;
}
.login .login_box .login_btn i {
font-size: 18px;
transform: translateY(-1px);
}
.login .login_box .copy p {
width: 100%;
}
.login .login_box .copy::before,
.login .login_box .copy::after {
display: none;
}
.login .footer .addr {
font-size: 11px;
gap: 3px 10px;
}
.login .modal .modal_container {
width: 90%;
}
.login .pw_desc {
padding: 0 0 20px 0;
}
.login .modal .form_write {
padding: 10px;
}
}
/* // login */
@@ -1,277 +0,0 @@
/* 모달 오버레이 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
padding: 20px;
}
/* 모달 컨텐츠 */
.modal-content {
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
max-width: 800px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
position: relative;
}
/* 모달 헤더 */
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
background-color: #f9fafb;
border-radius: 8px 8px 0 0;
}
.modal-header h2 {
margin: 0;
font-size: 1.8rem;
font-weight: 600;
color: #1f2937;
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
padding: 4px;
border-radius: 4px;
transition: color 0.2s;
}
.close-button:hover {
color: #374151;
}
/* 폼 스타일 */
.signup-form {
padding: 24px;
}
.form-section {
margin-bottom: 32px;
}
.form-section h3 {
margin: 0 0 16px 0;
font-size: 1.4rem;
font-weight: 600;
color: #374151;
padding-bottom: 8px;
border-bottom: 2px solid #e5e7eb;
}
.form-row {
margin-bottom: 16px;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
margin-bottom: 6px;
font-weight: 500;
color: #374151;
font-size: 1.1rem;
}
.form-group input,
.form-group select {
padding: 12px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1.1rem;
transition: border-color 0.2s, box-shadow 0.2s;
background-color: white;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-group input:disabled,
.form-group select:disabled {
background-color: #f3f4f6;
color: #6b7280;
cursor: not-allowed;
}
/* 입력 필드와 버튼이 함께 있는 경우 */
.input-with-button {
display: flex;
gap: 8px;
align-items: flex-end;
}
.input-with-button input {
flex: 1;
}
.check-button {
padding: 10px 16px;
background-color: #6b7280;
color: white;
border: none;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
white-space: nowrap;
min-width: 80px;
}
.check-button:hover:not(:disabled) {
background-color: #4b5563;
}
.check-button:disabled {
background-color: #d1d5db;
cursor: not-allowed;
}
/* 메시지 스타일 */
.error-message {
color: #dc2626;
font-size: 0.8rem;
margin-top: 4px;
display: block;
}
.success-message {
color: #059669;
font-size: 0.8rem;
margin-top: 4px;
display: block;
}
.submit-error {
background-color: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
padding: 12px;
border-radius: 6px;
margin-bottom: 16px;
text-align: center;
}
/* 폼 액션 버튼 */
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
margin-top: 24px;
}
.cancel-button,
.submit-button {
padding: 12px 24px;
border-radius: 6px;
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
border: none;
min-width: 100px;
}
.cancel-button {
background-color: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
}
.cancel-button:hover:not(:disabled) {
background-color: #e5e7eb;
}
.submit-button {
background-color: #3b82f6;
color: white;
}
.submit-button:hover:not(:disabled) {
background-color: #2563eb;
}
.cancel-button:disabled,
.submit-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.modal-content {
margin: 10px;
max-height: 95vh;
}
.modal-header {
padding: 16px 20px;
}
.signup-form {
padding: 20px;
}
.form-actions {
flex-direction: column;
}
.cancel-button,
.submit-button {
width: 100%;
}
.input-with-button {
flex-direction: column;
align-items: stretch;
}
.check-button {
width: 100%;
margin-top: 8px;
}
}
/* 스크롤바 스타일 */
.modal-content::-webkit-scrollbar {
width: 6px;
}
.modal-content::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.modal-content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.modal-content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
@@ -1,436 +0,0 @@
/* 신규 사용자 가입 모달 오버레이 */
.user-signup-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
padding: 20px;
}
/* 신규 사용자 가입 모달 컨텐츠 */
.user-signup-modal-content {
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
max-width: 800px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
position: relative;
}
/* 신규 사용자 가입 모달 헤더 */
.user-signup-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
background-color: #f9fafb;
border-radius: 8px 8px 0 0;
}
.user-signup-modal-header h2 {
margin: 0;
font-size: 1.8rem;
font-weight: 600;
color: #1f2937;
}
.user-signup-close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
padding: 4px;
border-radius: 4px;
transition: color 0.2s;
}
.user-signup-close-button:hover {
color: #374151;
}
/* 신규 사용자 가입 폼 스타일 */
.user-signup-form {
padding: 24px;
}
.user-signup-form-section {
margin-bottom: 32px;
}
.user-signup-form-section h3 {
margin: 0 0 16px 0;
font-size: 1.4rem;
font-weight: 600;
color: #374151;
padding-bottom: 8px;
border-bottom: 2px solid #e5e7eb;
}
.user-signup-form-row {
margin-bottom: 16px;
}
.user-signup-form-group {
display: flex;
flex-direction: column;
}
.user-signup-form-group label {
margin-bottom: 6px;
font-weight: 500;
color: #374151;
font-size: 1.1rem;
}
.user-signup-form-group input,
.user-signup-form-group select {
padding: 12px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1.1rem;
transition: border-color 0.2s, box-shadow 0.2s;
background-color: white;
}
/* CodeSelect 컴포넌트 스타일링 */
.user-signup-form-group .CodeSelect {
width: 100%;
}
.user-signup-form-group .CodeSelect select {
width: 100%;
padding: 12px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1.1rem;
transition: border-color 0.2s, box-shadow 0.2s;
background-color: white;
}
.user-signup-form-group .CodeSelect select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.user-signup-form-group .CodeSelect select:disabled {
background-color: #f3f4f6;
color: #6b7280;
cursor: not-allowed;
}
/* Select 요소 스타일링 */
.user-signup-select {
width: 100%;
padding: 12px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1.1rem;
transition: border-color 0.2s, box-shadow 0.2s;
background-color: white;
cursor: pointer;
}
.user-signup-select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.user-signup-select:disabled {
background-color: #f3f4f6;
color: #6b7280;
cursor: not-allowed;
}
.user-signup-select option {
padding: 8px;
font-size: 1rem;
}
.user-signup-form-group input:focus,
.user-signup-form-group select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.user-signup-form-group input:disabled,
.user-signup-form-group select:disabled {
background-color: #f3f4f6;
color: #6b7280;
cursor: not-allowed;
}
/* 입력 필드와 버튼이 함께 있는 경우 */
.user-signup-input-with-button {
display: flex;
gap: 8px;
align-items: flex-end;
}
.user-signup-input-with-button input {
flex: 1;
}
.user-signup-check-button {
padding: 10px 16px;
background-color: #6b7280;
color: white;
border: none;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
white-space: nowrap;
min-width: 80px;
}
.user-signup-check-button:hover:not(:disabled) {
background-color: #4b5563;
}
.user-signup-check-button:disabled {
background-color: #d1d5db;
cursor: not-allowed;
}
/* 메시지 스타일 */
.user-signup-error-message {
color: #dc2626;
font-size: 0.8rem;
margin-top: 4px;
display: block;
}
.user-signup-success-message {
color: #059669;
font-size: 0.8rem;
margin-top: 4px;
display: block;
}
.user-signup-submit-error {
background-color: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
padding: 12px;
border-radius: 6px;
margin-bottom: 16px;
text-align: center;
}
/* 폼 액션 버튼 */
.user-signup-form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
margin-top: 24px;
}
.user-signup-cancel-button,
.user-signup-submit-button,
.user-signup-back-button {
padding: 12px 24px;
border-radius: 6px;
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
border: none;
min-width: 100px;
}
.user-signup-cancel-button {
background-color: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
}
.user-signup-cancel-button:hover:not(:disabled) {
background-color: #e5e7eb;
}
.user-signup-submit-button {
background-color: #3b82f6;
color: white;
}
.user-signup-submit-button:hover:not(:disabled) {
background-color: #2563eb;
}
.user-signup-back-button {
background-color: #6b7280;
color: white;
}
.user-signup-back-button:hover:not(:disabled) {
background-color: #4b5563;
}
.user-signup-cancel-button:disabled,
.user-signup-submit-button:disabled,
.user-signup-back-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 개인정보제공동의 스타일 */
.user-signup-agreement-content {
padding: 24px;
}
.user-signup-agreement-text {
margin-bottom: 24px;
}
.user-signup-agreement-text h3 {
margin: 0 0 16px 0;
font-size: 1.4rem;
font-weight: 600;
color: #374151;
}
.user-signup-agreement-text h4 {
margin: 20px 0 8px 0;
font-size: 1.1rem;
font-weight: 600;
color: #374151;
}
.user-signup-agreement-text p {
margin: 8px 0;
color: #6b7280;
line-height: 1.5;
}
.user-signup-agreement-text ul {
margin: 8px 0;
padding-left: 20px;
color: #6b7280;
}
.user-signup-agreement-text li {
margin: 4px 0;
line-height: 1.5;
}
.user-signup-agreement-checkbox {
margin-bottom: 24px;
padding: 16px;
background-color: #f9fafb;
border-radius: 6px;
border: 1px solid #e5e7eb;
}
.user-signup-agreement-checkbox label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-weight: 500;
color: #374151;
}
.user-signup-agreement-checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
margin: 0;
}
.user-signup-agreement-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.user-signup-next-button {
padding: 12px 24px;
background-color: #3b82f6;
color: white;
border: none;
border-radius: 6px;
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s;
min-width: 100px;
}
.user-signup-next-button:hover:not(:disabled) {
background-color: #2563eb;
}
.user-signup-next-button:disabled {
background-color: #d1d5db;
cursor: not-allowed;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.user-signup-modal-content {
margin: 10px;
max-height: 95vh;
}
.user-signup-modal-header {
padding: 16px 20px;
}
.user-signup-form {
padding: 20px;
}
.user-signup-form-actions {
flex-direction: column;
}
.user-signup-cancel-button,
.user-signup-submit-button,
.user-signup-back-button {
width: 100%;
}
.user-signup-input-with-button {
flex-direction: column;
align-items: stretch;
}
.user-signup-check-button {
width: 100%;
margin-top: 8px;
}
}
/* 스크롤바 스타일 */
.user-signup-modal-content::-webkit-scrollbar {
width: 6px;
}
.user-signup-modal-content::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.user-signup-modal-content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.user-signup-modal-content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
@@ -1,14 +0,0 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { Dashboard, DashboardService } from "../model/dashboard-interface";
export class DashboardApiService implements DashboardService {
public async getDashboard(): Promise<Dashboard> {
const response = await client.get<Dashboard>(
ENDPOINT.CMS_SERVICE.DASHBOARD,
);
return response.data;
}
}
export const DashboardApi = new DashboardApiService();
-1
View File
@@ -1 +0,0 @@
export { DashboardPage } from "./ui/dashboard-page";
@@ -1,212 +0,0 @@
#mapArea {
display: flex;
align-items: center;
justify-content: center;
}
.stage {
--map-inset: 7.5%;
aspect-ratio: 0.72/1;
width: 100%;
max-height: 100%;
position: relative;
margin: auto;
}
.bgLayer {
position: absolute;
inset: var(--map-inset);
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
width: calc(100% - var(--map-inset) * 2);
height: calc(100% - var(--map-inset) * 2);
background-image: url("/images/region_map_bg.png");
background-repeat: no-repeat;
background-size: contain;
background-position: center center;
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
image-rendering: pixelated;
opacity: 0.7;
filter: drop-shadow(0 1px 0 rgba(0, 0, 0, 0.02));
pointer-events: none;
min-height: 200px;
}
.svgLayer {
position: absolute;
inset: var(--map-inset);
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
width: calc(100% - var(--map-inset) * 2);
height: calc(100% - var(--map-inset) * 2);
}
.svgLayer > svg {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
shape-rendering: geometricPrecision;
}
/* SVG 스타일을 위한 컨테이너 */
.svgContainer :global(svg) :global(path) {
fill: rgba(0, 150, 255, 0.2) !important;
stroke: #0066cc;
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.svgContainer :global(svg) :global(.cls-1) {
fill: rgba(0, 150, 255, 0.2) !important;
stroke: #0066cc;
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.svgContainer :global(svg) :global(.basin) {
opacity: 0;
pointer-events: all;
transition: opacity 0.15s ease;
cursor: pointer;
}
.svgContainer :global(svg) :global(.basin.active),
.svgContainer :global(svg) :global(.basin:hover) {
opacity: 1;
}
.svgContainer :global(svg) :global(.basin) :global(path),
.svgContainer :global(svg) :global(.basin) :global(polygon),
.svgContainer :global(svg) :global(.basin) :global(polyline) {
fill: rgba(0, 209, 209, 0.5);
stroke: #399AC3;
stroke-width: 1.3;
vector-effect: non-scaling-stroke;
}
/* 팝업 + 배지 */
.popup {
position: absolute;
width: 300px;
height: 150px;
min-width: 220px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
box-shadow:
0 12px 24px rgba(0, 0, 0, 0.18),
0 2px 0 rgba(0, 0, 0, 0.05);
opacity: 0;
visibility: hidden;
transform: translate(-50%, -8px) scale(0.98);
transition:
opacity 0.12s,
visibility 0.12s,
transform 0.12s;
pointer-events: none;
z-index: 10;
}
.popup.show {
opacity: 1 !important;
visibility: visible !important;
transform: translate(-50%, -12px) scale(1) !important;
}
.show {
opacity: 1;
visibility: visible;
}
.popup .head {
display: grid;
grid-template-columns: 1fr 1fr;
background: #d1ecfa;
border-bottom: 1px solid #c8e4f3;
font-weight: 700;
font-size: 16px;
}
.popup .head > div {
padding: 10px 12px;
text-align: center;
}
.popup .body {
padding: 6px 10px 8px;
}
.popup td {
padding: 8px 10px;
border-bottom: 1px solid #eef2f7;
font-size: 14px;
}
.popup .arrow {
position: absolute;
left: 50%;
bottom: -8px;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-top: 10px solid #fff;
filter: drop-shadow(0 1px 0 rgba(0, 0, 0, 0.1));
}
.badge {
position: absolute;
z-index: 9;
padding: 6px 10px;
border-radius: 8px;
background: #1e40af;
color: #fff;
font-weight: 700;
transform: translate(-50%, -50%);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.22);
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 0.12s, visibility 0.12s;
}
.badge.show {
opacity: 1;
visibility: visible;
}
/* 권역 라벨 */
.labelsLayer {
position: absolute;
inset: var(--map-inset);
pointer-events: none;
z-index: 3;
width: 100%;
height: 100%;
}
.basinLabel {
position: absolute;
transform: translate(-50%, -50%);
pointer-events: none;
padding: 6px 12px;
border-radius: 6px;
font-weight: 700;
font-size: 16px;
background: #ffffffee;
border: 2px solid #d0d0d0;
white-space: nowrap;
min-width: 60px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.basinLabel.active {
background: #006bf8;
color: #fff;
border-color: #96c3ff;
box-shadow: 0 4px 12px rgba(0, 107, 248, 0.3);
}
@@ -1,425 +0,0 @@
import React, { useEffect, useRef } from "react";
import styles from "./KoreaBasinMap.module.css"; // 추가
const SVG_URL = "/images/region_map.svg"; // public 폴더에 region_map.svg 위치 필요
const MANUAL_LABEL_POS = {
han: { x: 0.52, y: 0.14 },
geum: { x: 0.28, y: 0.47 },
nakdong: { x: 0.64, y: 0.47 },
yeongsan: { x: 0.18, y: 0.77 },
};
const BASIN_DATA = {
han: {
name: "한강",
colTitle: "시설수",
rows: [
["500m³ 미만", 100000000],
["500m³ 이상", 100000000],
],
},
geum: {
name: "금강",
colTitle: "시설수",
rows: [
["500m³ 미만", 100000000],
["500m³ 이상", 100000000],
],
},
nakdong: {
name: "낙동강",
colTitle: "시설수",
rows: [
["500m³ 미만", 100000000],
["500m³ 이상", 100000000],
],
},
yeongsan: {
name: "영산강",
colTitle: "시설수",
rows: [
["500m³ 미만", 100000000],
["500m³ 이상", 100000000],
],
},
};
const BASIN_IDS = [
{ key: "han", labels: ["한강", "han"] },
{ key: "geum", labels: ["금강", "geum"] },
{ key: "nakdong", labels: ["낙동강", "nakdong"] },
{ key: "yeongsan", labels: ["영산강", "yeongsan"] },
];
function fmt(n: number) {
return new Intl.NumberFormat("ko-KR").format(n);
}
export function KoreaBasinMap() {
const stageRef = useRef<HTMLDivElement>(null);
const svgLayerRef = useRef<HTMLDivElement>(null);
const labelsLayerRef = useRef<HTMLDivElement>(null);
const popupRef = useRef<HTMLDivElement>(null);
const badgeRef = useRef<HTMLDivElement>(null);
const rowsRef = useRef<HTMLTableSectionElement>(null);
const colTitleRef = useRef<HTMLTableCellElement>(null);
useEffect(() => {
let svg: SVGSVGElement | null = null;
let gs: SVGGElement[] = [];
let labels: HTMLDivElement[] = [];
console.log("Loading SVG from:", SVG_URL);
fetch(SVG_URL)
.then((r) => {
console.log("SVG fetch response:", r.status, r.statusText);
if (!r.ok) throw new Error(`HTTP error! status: ${r.status}`);
return r.text();
})
.then((txt) => {
console.log("SVG content loaded, length:", txt.length);
if (!svgLayerRef.current) {
console.error("svgLayerRef.current is null");
return;
}
svgLayerRef.current.innerHTML = txt;
svg = svgLayerRef.current.querySelector("svg");
if (!svg) {
console.error("SVG element not found in loaded content");
return;
}
console.log("SVG element found:", svg);
// viewBox/크기 세팅
if (!svg.hasAttribute("viewBox")) {
const bb = svg.getBBox();
svg.setAttribute(
"viewBox",
[bb.x, bb.y, bb.width, bb.height].join(" "),
);
}
const vb = (svg.getAttribute("viewBox") || "").split(/\s+/).map(Number);
if (vb.length === 4 && vb[3]! > 0 && stageRef.current) {
stageRef.current!.style.aspectRatio = String(vb[2]! / vb[3]!);
}
svg!.setAttribute("preserveAspectRatio", "xMidYMid meet");
svg.style.width = "100%";
svg.style.height = "100%";
// 1) 지도 그룹 수집
gs = [];
console.log("Looking for basin groups in SVG...");
BASIN_IDS.forEach((item) => {
let g: SVGGElement | null = null;
for (const id of item.labels) {
g = svg!.getElementById(id) as SVGGElement | null;
if (g) {
console.log(`Found group for ${item.key} with id '${id}':`, g);
break;
}
}
if (!g) {
console.warn(`No group found for ${item.key} with labels:`, item.labels);
return;
}
g.classList.add("basin");
(g as SVGGElement & { dataset: { key: string; label: string } }).dataset.key = item.key;
(g as SVGGElement & { dataset: { key: string; label: string } }).dataset.label = item.labels[0] || '';
// 패스에 기본 스타일 적용
const paths = g.querySelectorAll('path');
paths.forEach((path) => {
path.style.fill = 'rgba(0, 150, 255, 0.2)';
path.style.stroke = '#0066cc';
path.style.strokeWidth = '1';
path.style.vectorEffect = 'non-scaling-stroke';
});
gs.push(g);
});
console.log(`Found ${gs.length} basin groups`);
// 각 유역 그룹의 패스 정보 확인
gs.forEach((g, index) => {
const paths = g.querySelectorAll('path');
console.log(`Group ${index} (${(g as SVGGElement & { dataset: { key: string } }).dataset.key}):`, {
element: g,
pathCount: paths.length,
paths: Array.from(paths).map(p => ({
className: p.className.baseVal,
d: p.getAttribute('d')?.substring(0, 50) + '...'
}))
});
});
// 2) 함수들 먼저 정의
const clear = () => {
gs.forEach((el) => el.classList.remove("active"));
if (popupRef.current) {
if (styles.show) {
popupRef.current.classList.remove(styles.show);
}
popupRef.current.style.opacity = "0";
popupRef.current.style.visibility = "hidden";
}
if (badgeRef.current) {
if (styles.show) {
badgeRef.current.classList.remove(styles.show);
}
badgeRef.current.style.opacity = "0";
badgeRef.current.style.visibility = "hidden";
}
labels.forEach((l) => l.classList.remove("active"));
};
function showFor(g: SVGGElement) {
console.log("showFor called for:", g);
const key = (g as SVGGElement & { dataset: { key: string; label: string } }).dataset.key;
const data = (BASIN_DATA as Record<string, { name: string; colTitle: string; rows: (string | number)[][] }>)[key] || {
name: (g as SVGGElement & { dataset: { label: string } }).dataset.label,
colTitle: "",
rows: [],
};
console.log("Data for popup:", data);
if (badgeRef.current) badgeRef.current.textContent = data.name;
if (colTitleRef.current)
colTitleRef.current.textContent = data.colTitle || "???";
if (rowsRef.current)
rowsRef.current.innerHTML = data.rows
.map(
(r: (string | number)[]) =>
`<tr><td>${r[0]}</td><td style="text-align:right">${fmt(Number(r[1]) || 0)}</td></tr>`,
)
.join("");
if (!stageRef.current) return;
const sRect = stageRef.current.getBoundingClientRect(),
r = g.getBoundingClientRect();
const cx = (r.left + r.right) / 2 - sRect.left;
const ty = r.top - sRect.top;
console.log("Popup position:", { cx, ty });
if (badgeRef.current) {
badgeRef.current.style.left = cx + "px";
badgeRef.current.style.top = ty + 24 + "px";
}
if (popupRef.current) {
popupRef.current.style.left = cx + "px";
popupRef.current.style.top = ty + 6 + "px";
popupRef.current.style.opacity = "1";
popupRef.current.style.visibility = "visible";
if (styles.show) {
popupRef.current.classList.add(styles.show);
}
console.log("Added show class to popup:", popupRef.current.classList.toString());
}
if (badgeRef.current && styles.show) {
badgeRef.current.classList.add(styles.show);
}
if (popupRef.current) {
const pRect = popupRef.current.getBoundingClientRect();
const x = Math.max(
12 + pRect.width / 2,
Math.min(cx, sRect.width - 12 - pRect.width / 2),
);
popupRef.current.style.left = x + "px";
if (badgeRef.current) badgeRef.current.style.left = x + "px";
}
}
function showForWithLabel(g: SVGGElement) {
console.log("showForWithLabel called for:", g);
showFor(g);
const idx = gs.indexOf(g);
labels.forEach((l) => l.classList.remove("active"));
if (idx >= 0) labels[idx]!.classList.add("active");
}
// 3) 라벨 DOM 생성
labels = [];
if (labelsLayerRef.current) {
labelsLayerRef.current.innerHTML = "";
gs.forEach((g) => {
const el = document.createElement("div");
el.className = styles.basinLabel || '';
el.textContent = (g as SVGGElement & { dataset: { label: string } }).dataset.label || '';
labelsLayerRef.current!.appendChild(el);
labels.push(el);
});
}
// 3) 라벨 위치
function positionLabels() {
if (!stageRef.current || !svg) return;
const sRect = stageRef.current.getBoundingClientRect();
const inset =
parseFloat(
getComputedStyle(stageRef.current).getPropertyValue(
"--map-inset",
) || "0",
) / 100;
const left = sRect.width * inset,
top = sRect.height * inset;
const w = sRect.width * (1 - inset * 2),
h = sRect.height * (1 - inset * 2);
const [vx, vy, vw, vh] = (
svg!.getAttribute("viewBox") || "0 0 100 100"
)
.split(/\s+/)
.map(Number);
const svgRect = svg.getBoundingClientRect();
const stageRect = stageRef.current.getBoundingClientRect();
const offsetX = svgRect.left - stageRect.left - left;
const offsetY = svgRect.top - stageRect.top - top;
gs.forEach((g, i) => {
const key = (g as SVGGElement & { dataset: { key: string } }).dataset.key;
const manual = (MANUAL_LABEL_POS as Record<string, { x?: number; y?: number; px?: number; py?: number }>)[key];
let px, py;
if (
manual &&
(("x" in manual && "y" in manual) ||
("px" in manual && "py" in manual))
) {
if ("px" in manual) {
px = left + (manual.px || 0) + offsetX;
py = top + (manual.py || 0) + offsetY;
} else {
px = left + w * (manual.x || 0) + offsetX;
py = top + h * (manual.y || 0) + offsetY;
}
} else {
const b = g.getBBox();
const cx = b.x + b.width / 2,
cy = b.y + b.height / 2;
px = ((cx - vx!) / vw!) * w + left + offsetX;
py = ((cy - vy!) / vh!) * h + top + offsetY;
}
labels[i]!.style.left = `${px}px`;
labels[i]!.style.top = `${py + 18}px`;
});
}
// 4) 상호작용 (함수들은 이미 위에서 정의됨)
gs.forEach((g) => {
g.addEventListener("mouseenter", () => {
clear();
g.classList.add("active");
showForWithLabel(g);
});
g.addEventListener("mousemove", () => {
if (g.classList.contains("active")) showForWithLabel(g);
});
g.addEventListener("mouseleave", clear);
g.addEventListener("click", (e) => {
e.stopPropagation();
clear();
g.classList.add("active");
showForWithLabel(g);
});
});
if (stageRef.current) {
stageRef.current.addEventListener("mouseleave", clear);
stageRef.current.addEventListener("click", () => clear());
}
// 5) 실행
positionLabels();
window.addEventListener("resize", positionLabels);
new window.ResizeObserver(positionLabels).observe(stageRef.current!);
})
.catch((error) => {
console.error("Error loading SVG:", error);
if (svgLayerRef.current)
svgLayerRef.current.innerHTML =
`<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #666; font-size: 14px;">지도를 불러올 수 없습니다. (${error.message})</div>`;
});
// cleanup
return () => {
window.removeEventListener("resize", () => {});
};
}, []);
// 스타일은 필요에 따라 tailwind, css 등으로 커스터마이즈하세요.
return (
<div
ref={stageRef}
id="stage"
style={
{
width: "100%",
height: "560px",
position: "relative",
zIndex: 20,
"--map-inset": "0",
} as React.CSSProperties
}
>
<div
className={styles.bgLayer}
id="bgLayer"
/>
<div
ref={svgLayerRef}
id="svgLayer"
className={`${styles.svgLayer} ${styles.svgContainer}`}
style={{ width: "100%", height: "100%" }}
/>
<div
ref={labelsLayerRef}
id="labelsLayer"
className={styles.labelsLayer}
style={{
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
}}
/>
<div
ref={badgeRef}
id="badge"
className={styles.badge}
style={{ position: "absolute", zIndex: 10, display: "block" }}
/>
<div
ref={popupRef}
id="popup"
className={styles.popup}
style={{
position: "absolute",
zIndex: 20,
background: "#fff",
border: "1px solid #ccc",
borderRadius: 8,
padding: 12,
minWidth: 160,
boxShadow: "0 2px 8px #0002",
display: "block",
opacity: 0,
visibility: "hidden",
}}
>
<table style={{ width: "100%" }}>
<thead>
<tr>
<th>구분</th>
<th ref={colTitleRef} id="colTitle">
시설수
</th>
</tr>
</thead>
<tbody ref={rowsRef} id="rows"></tbody>
</table>
</div>
</div>
);
}
@@ -1,172 +0,0 @@
import { type ChartData, type ChartOptions } from "chart.js";
const commonData = {
labels: ["1", "2", "3", "4", "5", "6", "7"],
datasets: [
{
fill: true,
pointRadius: 0,
tension: 0.5,
},
],
};
const commonOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
},
scales: {
x: {
display: false,
},
y: {
display: false,
},
},
};
export const chartData: Record<
string,
{ data: ChartData<"line" | "bar">; options: ChartOptions<"line" | "bar"> }
> = {
memberChartData: {
data: {
...commonData,
datasets: [
{
...commonData.datasets[0],
data: [8, 10, 7, 12, 9, 13, 12],
borderColor: "rgba(255, 255, 255, 0.6)",
backgroundColor: "rgba(255, 255, 255, 0.1)",
},
],
},
options: {
...commonOptions,
scales: {
...commonOptions.scales,
y: {
...commonOptions.scales.y,
min: 5,
max: 15,
},
},
},
},
postChartData: {
data: {
...commonData,
datasets: [
{
...commonData.datasets[0],
data: [5200, 5350, 5450, 5520, 5600, 5650, 5678],
borderColor: "rgba(86, 138, 255, 1)",
backgroundColor: "rgba(86, 138, 255, 0.1)",
},
],
},
options: {
...commonOptions,
},
},
visitorChartData: {
data: {
...commonData,
datasets: [
{
...commonData.datasets[0],
data: [1800, 2100, 1950, 2300, 2150, 2250, 2345],
borderColor: "rgba(149, 131, 252)",
backgroundColor: "rgba(149, 131, 252, 0.1)",
},
],
},
options: {
...commonOptions,
},
},
apiChartData: {
data: {
...commonData,
datasets: [
{
...commonData.datasets[0],
data: [41000, 42500, 43200, 44100, 44800, 45200, 45678],
borderColor: "rgba(220, 103, 206)",
backgroundColor: "rgba(220, 103, 206, 0.1)",
},
],
},
options: {
...commonOptions,
},
},
siteAccessChartData: {
data: {
labels: Array.from({ length: 24 }, (_, i) =>
i.toString().padStart(2, "0"),
),
datasets: [],
},
options: {
...commonOptions,
plugins: {
legend: {
position: "top",
},
tooltip: {
mode: "index",
intersect: false,
},
},
scales: {
x: {
title: {
text: "시간대",
},
},
y: {
title: {
text: "접속자 수",
},
beginAtZero: true,
},
},
},
},
menuAccessChartData: {
data: {
labels: [],
datasets: [],
},
options: {
...commonOptions,
plugins: {
legend: {
position: "top",
},
tooltip: {
mode: "index",
intersect: false,
},
},
scales: {
x: {
title: {
text: "메뉴명",
},
},
y: {
title: {
text: "접속 횟수",
},
beginAtZero: true,
},
},
},
},
};
@@ -1,32 +0,0 @@
export interface SiteStatisticsContent {
siteId: string;
siteNm: string;
accessHour: string;
accessCnt: number;
}
export interface MenuStatisticsContent {
menuCd: number;
menuNm: string;
accessCnt: number;
}
export interface Dashboard {
memberTotCnt: number;
memberTodayCnt: number;
pstTotCnt: number;
pstTodayCnt: number;
accessTodayCnt: number;
accessBefDayPer: number;
apiTodayCnt: number;
waitMemberCnt: number;
waitCntnCnt: number;
notiList: [];
schdlList: [];
siteAccessList: SiteStatisticsContent[];
menuAccessList: MenuStatisticsContent[];
}
export interface DashboardService {
getDashboard(): Promise<Dashboard>;
}
@@ -1,75 +0,0 @@
import dayjs from "dayjs";
import type { ColumnDef } from "@tanstack/react-table";
import { Badge } from "@common/components";
import type { Post, MyPost } from "@/entities/post";
const noticePostTableColumns: ColumnDef<Post>[] = [
{
header: "번호",
cell: ({ row, table }) => {
if (row.original.upndFixYn === "Y") {
return (
<Badge
color="orange"
className="flex size-8 items-center justify-center"
>
<i className="diveicon di-volume-up-fill" />
</Badge>
);
}
const totalRows = table.getRowCount();
const currentPage = table.getState().pagination.pageIndex;
const pageSize = table.getState().pagination.pageSize;
return totalRows - (currentPage * pageSize + row.index);
},
},
{
accessorKey: "pstTtl",
header: "제목",
},
{
accessorKey: "wrtrNm",
header: "작성자",
},
{
accessorKey: "regDt",
header: "작성일",
cell: ({ row }) => dayjs(row.original.regDt).format("YYYY-MM-DD"),
},
{
accessorKey: "inqNocs",
header: "조회수",
},
];
const myPostTableColumns: ColumnDef<MyPost>[] = [
{
header: "번호",
cell: ({ row, table }) => {
const totalRows = table.getRowCount();
const currentPage = table.getState().pagination.pageIndex;
const pageSize = table.getState().pagination.pageSize;
return totalRows - (currentPage * pageSize + row.index);
},
},
{
accessorKey: "pstTtl",
header: "제목",
},
{
accessorKey: "wrtrNm",
header: "작성자",
},
{
accessorKey: "regDt",
header: "작성일",
cell: ({ row }) => dayjs(row.original.regDt).format("YYYY-MM-DD"),
},
{
accessorKey: "inqNocs",
header: "조회수",
},
];
export { noticePostTableColumns, myPostTableColumns };
@@ -1,303 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { tokenManager } from "@/entities/auth/lib/token-manager";
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import { KoreaBasinMap } from "../lib/KoreaBasinMap";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Filler,
Legend,
Tooltip,
ArcElement,
} from "chart.js";
import { Doughnut } from "react-chartjs-2";
// Chart.js 등록
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Filler,
Legend,
Tooltip,
ArcElement
);
interface UserInfo {
email?: string;
userId?: string;
userName?: string;
twoFactorEnabled?: boolean;
}
function DashboardPage() {
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const [error, setError] = useState("");
const router = useRouter();
useEffect(() => {
const fetchUserInfo = async () => {
console.log("fetchUserInfo called");
try {
// tokenManager를 통해 토큰 확인
const accessToken = tokenManager.getToken("Authorization");
const userId = tokenManager.getToken("token-id");
if (!accessToken || !userId) {
console.log("No tokens found, redirecting to login");
setError("로그인이 필요합니다.");
router.push("/auth/login");
return;
}
console.log("Fetching user info with userId:", userId);
// client를 사용하여 API 호출 (자동 토큰 처리)
const response = await client.get(
`${ENDPOINT.USER_SERVICE.USERS}/${userId}`,
);
if (response.data) {
console.log("User info received:", response.data);
setUserInfo(response.data);
} else {
setError("사용자 정보를 불러올 수 없습니다.");
}
} catch (error) {
console.error("Error fetching user info:", error);
if (error instanceof Error && error.message.includes("401")) {
setError("인증이 만료되었습니다. 다시 로그인해주세요.");
tokenManager.clearAuthToken();
router.push("/auth/login");
} else {
setError("네트워크 오류가 발생했습니다.");
}
}
};
fetchUserInfo();
}, [router]);
const handleLogout = () => {
console.log("Logging out...");
// localStorage 정리
localStorage.removeItem("tempToken");
// tokenManager를 통해 쿠키 정리
tokenManager.clearAuthToken();
router.push("/auth/logout");
};
const handleChangePassword = () => {
router.push("/otp?next=change-password");
};
return (
<div className="p-6">
<div className="flex flex-col gap-1">
{/* 첫번째 라인 */}
<div className="grid grid-cols-8 gap-1">
{/* 첫번쨰 열 */}
<div className="col-span-2 h-250 !p-0">
{/* 전국 하수처리시설 가동률 */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">전국 하수처리시설 가동률</h3>
</div>
<div className="h-60 p-4">
<Doughnut
data={{
datasets: [{
data: [75, 25], // 75% 가동률
backgroundColor: ['#00DFE5', '#f5f5f5'],
borderWidth: 0
}]
}}
options={{
responsive: true,
maintainAspectRatio: false,
cutout: '70%',
circumference: 180, // 반원
rotation: -90, // 왼쪽에서 시작
plugins: {
legend: {
display: false
},
tooltip: {
enabled: false
}
}
}}
plugins={[{
id: 'centerText',
afterDraw: (chart) => {
const ctx = chart.ctx;
const centerX = chart.width / 2;
const centerY = chart.height / 2;
// 가동률 텍스트
ctx.save();
ctx.font = 'bold 24px Arial';
ctx.fillStyle = '#00DFE5';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('75%', centerX, centerY + 10);
// 라벨 텍스트
ctx.font = '12px Arial';
ctx.fillStyle = '#666';
ctx.fillText('가동률', centerX, centerY + 30);
ctx.restore();
}
}]}
/>
</div>
</div>
{/* 오수관로 및 우수관로 현황 */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">오수관로 및 우수관로 현황</h3>
</div>
<div className="h-60 p-4"></div>
</div>
{/* 하수처리수 재이용 */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">하수처리수 재이용</h3>
</div>
<div className="h-60 p-4"></div>
</div>
</div>
{/* 두번쨰 열 */}
<div className="col-span-2 h-250 !p-0">
{/* 하수처리시설(개소) */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">하수처리시설(개소)</h3>
</div>
<div className="grid grid-cols-2 gap-1 h-60 !p-0">
<div className="card card-border">
<div className="card-header text-foreground text-center">
<h3 className="text-l font-medium" style={{textAlign: "center", width: "100%"}}>500㎥ 미만(개소)</h3>
</div>
<div style={{fontSize: "38px", textAlign: "center", fontWeight: "600", lineHeight: "1.2", color: "#0ec58a", padding: "2px 0"}}>3,567 <span style={{fontSize: "14px", color: "#000000"}}>개소</span></div>
</div>
<div className="card card-border">
<div className="card-header text-foreground text-center">
<h3 className="text-l font-medium" style={{textAlign: "center", width: "100%"}}>500㎥ 이상(개소)</h3>
</div>
<div style={{fontSize: "38px", textAlign: "center", fontWeight: "600", lineHeight: "1.2", color: "#0ec58a", padding: "2px 0"}}>767 <span style={{fontSize: "14px", color: "#000000"}}>개소</span></div>
</div>
<div className="col-span-2" style={{width: "100%", textAlign: "right"}}>기준년월 : 2025.09 </div>
</div>
</div>
{/* 노후하수관로현황 */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">노후하수관로현황</h3>
</div>
<div className="h-70 p-4">
</div>
</div>
{/* 재이용시설(개소) */}
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">재이용시설(개소)</h3>
</div>
<div className="h-70 p-4">
</div>
</div>
</div>
{/* 지도영역 */}
<div className="col-span-3 h-200 !p-0 relative z-10">
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">유역별 정보</h3>
</div>
<KoreaBasinMap />
</div>
</div>
{/* 사용자 정보 카드 */}
<div className="card card-border col-span-1 h-80 p-10">
<h2 className="mb-6 text-2xl">사용자 정보</h2>
{error && <div className="mb-4 text-red-500">{error}</div>}
{userInfo ? (
<div className="mb-4">
<div>
<b>아이디:</b> {userInfo.userId}
</div>
<div>
<b>이메일:</b> {userInfo.email || userInfo.userId}
</div>
<div>
<b>이름:</b> {userInfo.userName || "-"}
</div>
<div>
<b>2FA 활성화:</b>{" "}
{userInfo.twoFactorEnabled ? "예" : "아니오"}
</div>
</div>
) : (
!error && <div>사용자 정보를 불러오는 중...</div>
)}
<button className="btn mb-2 w-full" onClick={handleLogout}>
로그아웃
</button>
<button className="btn w-full" onClick={handleChangePassword}>
패스워드 변경
</button>
</div>
</div>
{/* 두번째 라인 */}
<div className="grid grid-cols-4 gap-1">
<div className="card card-border"id="grouping-statistics">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">지원현황</h3>
</div>
<div className="h-70 p-4">
</div>
</div>
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">자료관 자료현황</h3>
</div>
<div className="h-70 p-4">
</div>
</div>
<div className="card card-border">
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">공지사항</h3>
</div>
<div className="h-70 p-4">
</div>
</div>
{/* 배너 영역 */}
<div className="card card-border">
</div>
</div>
</div>
</div>
);
}
export { DashboardPage };

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