Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0523f6c214 |
@@ -1,6 +1,5 @@
|
||||
# turbo
|
||||
.turbo/
|
||||
.vscode/**/*
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# 디폴트 무시된 파일
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 에디터 기반 HTTP 클라이언트 요청
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
@@ -201,4 +201,3 @@ http://dev.onasys.co.kr:8000/webjars/swagger-ui/index.html?urls.primaryName=atta
|
||||
- 사이드바 메뉴 클릭 시 "menuCd" 데이터 쿠키 저장 처리
|
||||
- 쿠키명 : menu-code-key
|
||||
- 해당 메뉴 쿠키 데이터 가져와 페이지 권한 및 상당 Breadcrumb 데이터 전달 처리
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,8 +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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
import { DashboardPage } from "@/pages/dashboard";
|
||||
export default DashboardPage;
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
import { LoginPage } from "@/pages/auth/login";
|
||||
export default LoginPage;
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { App } from "@/app";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "유역하수도 통합 정보플랫폼",
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -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;
|
||||
@@ -1,79 +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";
|
||||
|
||||
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("/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*", "/"],
|
||||
};
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 +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();
|
||||
@@ -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();
|
||||
@@ -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 };
|
||||
@@ -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,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();
|
||||
@@ -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 +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 };
|
||||
@@ -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 +0,0 @@
|
||||
export { DashboardPage } from "./ui/dashboard-page";
|
||||
@@ -1,142 +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';
|
||||
|
||||
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-7 gap-1">
|
||||
{/* 총 회원수 카드 */}
|
||||
<div className="card card-border h-80 !p-0 col-span-2">
|
||||
|
||||
|
||||
</div>
|
||||
{/* 전체 게시물 카드 */}
|
||||
<div className="card card-border h-80 !p-0 col-span-2">
|
||||
|
||||
</div>
|
||||
{/* 오늘 접속자 수 카드 */}
|
||||
<div className="card card-border h-80 !p-0 col-span-2">
|
||||
|
||||
</div>
|
||||
|
||||
{/* 사용자 정보 카드 */}
|
||||
<div className="card card-border h-80 p-10 col-span-1">
|
||||
<h2 className="text-2xl mb-6">사용자 정보</h2>
|
||||
{error && <div className="text-red-500 mb-4">{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 w-full mb-2" onClick={handleLogout}>로그아웃</button>
|
||||
<button className="btn w-full" onClick={handleChangePassword}>패스워드 변경</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 두번째 라인 */}
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<div className="card card-border h-80 p-10 col-span-1">
|
||||
</div>
|
||||
<div className="card card-border h-80 p-10 col-span-1">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 세번째 라인 */}
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<div className="card card-border h-80 p-10 col-span-1">
|
||||
</div>
|
||||
<div className="card card-border h-80 p-10 col-span-1">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 네번째 라인 */}
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<div className="card card-border h-80 p-10 col-span-1">
|
||||
</div>
|
||||
<div className="card card-border h-80 p-10 col-span-1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { DashboardPage };
|
||||
@@ -1,41 +0,0 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageableList, PageableParams } from "@/shared/model";
|
||||
|
||||
export interface ZipSearchParams extends PageableParams {
|
||||
zoneNo?: string; // 구역번호
|
||||
ctpvNm?: string; // 시도명
|
||||
sggNm?: string; // 시군구명
|
||||
emdNm?: string; // 읍면동명
|
||||
roadNm?: string; // 도로명
|
||||
stdgNm?: string; // 법정동명
|
||||
dongNm?: string; // 행정동명
|
||||
lotnoMno?: number | null; // 지번본번
|
||||
lotnoSno?: number | null; // 지번부번
|
||||
}
|
||||
|
||||
export interface Zip {
|
||||
zipId: number;
|
||||
zoneNo: string;
|
||||
ctpvNm: string;
|
||||
sggNm: string;
|
||||
emdNm: string;
|
||||
roadNm: string;
|
||||
lotnoMno: number;
|
||||
lotnoSno: number;
|
||||
lotno: string;
|
||||
zipAddr: string;
|
||||
}
|
||||
|
||||
type ZipList = PageableList<Zip>;
|
||||
|
||||
export class ZipApiService {
|
||||
public async searchZip(params: ZipSearchParams): Promise<ZipList> {
|
||||
const response = await client.get<ZipList>(`${ENDPOINT.CMS_SERVICE.ZIPS}`, {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const ZipApi = new ZipApiService();
|
||||
@@ -1 +0,0 @@
|
||||
export { CreatePage } from "./ui/create-page";
|
||||
@@ -1,88 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
USER_STATE_CODE,
|
||||
type UserListResponse,
|
||||
type UserStateCode,
|
||||
} from "@/entities/user";
|
||||
import { Badge } from "@common/components";
|
||||
|
||||
export const columns: ColumnDef<UserListResponse>[] = [
|
||||
{
|
||||
id: "no",
|
||||
header: "No",
|
||||
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: "userId",
|
||||
header: "아이디",
|
||||
},
|
||||
{
|
||||
accessorKey: "userName",
|
||||
header: "이름",
|
||||
},
|
||||
{
|
||||
accessorKey: "telno",
|
||||
header: "전화번호",
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: "이메일",
|
||||
},
|
||||
{
|
||||
accessorKey: "laeSeNm",
|
||||
header: "구분",
|
||||
},
|
||||
{
|
||||
accessorKey: "ogdpSeNm",
|
||||
header: "소속",
|
||||
},
|
||||
{
|
||||
accessorKey: "instSeNm",
|
||||
header: "기관/부서",
|
||||
},
|
||||
{
|
||||
accessorKey: "userStateCode",
|
||||
header: "회원상태",
|
||||
cell: ({ row }) => {
|
||||
return getUserStateBadge(row.original.userStateCode);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "aprvYmd",
|
||||
header: "승인일자",
|
||||
},
|
||||
{
|
||||
accessorKey: "loginFailCount",
|
||||
header: "로그인실패횟수",
|
||||
},
|
||||
{
|
||||
accessorKey: "lastLoginDate",
|
||||
header: "최종접속일",
|
||||
cell: ({ row }) => {
|
||||
return row.original.lastLoginDate
|
||||
? dayjs(row.original.lastLoginDate).format("YYYY-MM-DD")
|
||||
: "";
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const getUserStateBadge = (userStateCode: UserStateCode) => {
|
||||
switch (userStateCode) {
|
||||
case USER_STATE_CODE.대기:
|
||||
return <Badge color="gray">대기</Badge>;
|
||||
case USER_STATE_CODE.정상:
|
||||
return <Badge color="green">정상</Badge>;
|
||||
case USER_STATE_CODE.정지:
|
||||
return <Badge color="yellow">정지</Badge>;
|
||||
case USER_STATE_CODE.탈퇴:
|
||||
return <Badge color="purple">탈퇴</Badge>;
|
||||
case USER_STATE_CODE.삭제:
|
||||
return <Badge color="red">삭제</Badge>;
|
||||
}
|
||||
};
|
||||
@@ -1,179 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
keepPreviousData,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
type PaginationState,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
} from "@common/components";
|
||||
import {
|
||||
LwpoCodeApi,
|
||||
type LwpoCode,
|
||||
type LwpoCodeParams,
|
||||
} from "@/entities/code";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
|
||||
interface AgencySearchDialogProps {
|
||||
onSelectAgency: (data: { lwpoCd: string; lwpoCdNm: string }) => void;
|
||||
}
|
||||
|
||||
function AgencySearchDialog({ onSelectAgency }: AgencySearchDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedAgency, setSelectedAgency] = useState<LwpoCode | null>(null);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [searchQuery, setSearchQuery] = useState<LwpoCodeParams>({
|
||||
lwpoCdNm: "",
|
||||
});
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.LWPO_CODE, pagination],
|
||||
queryFn: () =>
|
||||
LwpoCodeApi.getLwpoCodeList({
|
||||
...searchQuery,
|
||||
upCd: "AGENCYCD",
|
||||
page: pagination.pageIndex,
|
||||
size: pagination.pageSize,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const handleRowClick = (row: LwpoCode) => {
|
||||
table.setRowSelection({
|
||||
[row.lwpoCd.toString()]: true,
|
||||
});
|
||||
setSelectedAgency(row);
|
||||
};
|
||||
|
||||
const { tableComponent, tableInstance: table } = useDataTable({
|
||||
query,
|
||||
tableOptions: {
|
||||
columns: [
|
||||
{
|
||||
accessorKey: "lwpoCd",
|
||||
header: "기관코드",
|
||||
meta: {
|
||||
width: 200,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "lwpoCdNm",
|
||||
header: "기관명",
|
||||
},
|
||||
],
|
||||
state: {
|
||||
pagination,
|
||||
},
|
||||
manualPagination: true,
|
||||
enableMultiRowSelection: false,
|
||||
getRowId: (row) => row.lwpoCd.toString(),
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
},
|
||||
dataTableOptions: {
|
||||
onRowClick: handleRowClick,
|
||||
viewPagination: true,
|
||||
variant: "secondary",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSearch = async () => {
|
||||
await query.refetch();
|
||||
queryClient.removeQueries({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.LWPO_CODE],
|
||||
});
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
table.setRowSelection({});
|
||||
setSelectedAgency(null);
|
||||
};
|
||||
|
||||
const handleSelectAgency = () => {
|
||||
if (!selectedAgency) return;
|
||||
onSelectAgency({
|
||||
lwpoCd: selectedAgency.lwpoCd,
|
||||
lwpoCdNm: selectedAgency.lwpoCdNm,
|
||||
});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="px-6">기관 검색</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="max-h-[80vh] max-w-[60rem] overflow-auto"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>기관 검색</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSearch();
|
||||
}}
|
||||
>
|
||||
<div className="card card-border !mx-0 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="mr-2 whitespace-nowrap">기관명</Label>
|
||||
<Input
|
||||
value={searchQuery.lwpoCdNm ?? ""}
|
||||
onChange={(e) =>
|
||||
setSearchQuery({
|
||||
...searchQuery,
|
||||
lwpoCdNm: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="기관명을 입력하세요"
|
||||
className="!text-[1.3rem]"
|
||||
/>
|
||||
<Button type="submit">검색</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{tableComponent}
|
||||
<DialogFooter className="!justify-center">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleSelectAgency}
|
||||
disabled={!selectedAgency}
|
||||
>
|
||||
선택
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button size="lg" color="white">
|
||||
취소
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { AgencySearchDialog };
|
||||
@@ -1,45 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutationWrapper, useAlertStore } from "@/shared/lib";
|
||||
import { UserApi, userSaveSchema, type UserSaveData } from "@/entities/user";
|
||||
import { UsersForm } from "./users-form";
|
||||
|
||||
function CreatePage() {
|
||||
const router = useRouter();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
const form = useForm<UserSaveData>({
|
||||
resolver: zodResolver(userSaveSchema),
|
||||
defaultValues: {
|
||||
userId: "",
|
||||
userName: "",
|
||||
password: "",
|
||||
pswdHnt: "",
|
||||
laeSeCd: "",
|
||||
ogdpSeCd: "",
|
||||
expryYmd: "",
|
||||
},
|
||||
});
|
||||
|
||||
// 사용자 등록 mutation
|
||||
const { mutateAsync: saveUser } = useMutationWrapper({
|
||||
mutationFn: (data: UserSaveData) => UserApi.saveUser(data),
|
||||
onSuccess: () => {
|
||||
alert("회원이 등록되었습니다.", router.back);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="card card-border !mt-6">
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-medium text-label">회원 등록</h3>
|
||||
</div>
|
||||
<UsersForm form={form} handleSave={saveUser} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { CreatePage };
|
||||
@@ -1,75 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
UserApi,
|
||||
userUpdateSchema,
|
||||
type UserUpdateData,
|
||||
} from "@/entities/user";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useMutationWrapper, useAlertStore } from "@/shared/lib";
|
||||
import { UsersForm } from "./users-form";
|
||||
|
||||
interface ModifyPageProps {
|
||||
userUnqId: string;
|
||||
}
|
||||
|
||||
export function ModifyPage({ userUnqId }: ModifyPageProps) {
|
||||
const router = useRouter();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
const { data: userData } = useQuery({
|
||||
queryKey: [ENDPOINT.USER_SERVICE.USERS, userUnqId],
|
||||
queryFn: () => UserApi.findByUserUnqId(userUnqId),
|
||||
enabled: !!userUnqId,
|
||||
});
|
||||
|
||||
const form = useForm<UserUpdateData>({
|
||||
resolver: zodResolver(userUpdateSchema),
|
||||
defaultValues: {
|
||||
userId: userData?.userId ?? "",
|
||||
userName: userData?.userName ?? "",
|
||||
email: userData?.email ?? "",
|
||||
userStateCode: userData?.userStateCode ?? "",
|
||||
pswdHnt: userData?.pswdHnt ?? "",
|
||||
pswdAns: userData?.pswdAns ?? "",
|
||||
mphn: userData?.mphn ?? "",
|
||||
telno: userData?.telno ?? "",
|
||||
zip: userData?.zip ?? "",
|
||||
addr: userData?.addr ?? "",
|
||||
daddr: userData?.daddr ?? "",
|
||||
laeSeCd: userData?.laeSeCd ?? "",
|
||||
ogdpSeCd: userData?.ogdpSeCd ?? "",
|
||||
aprvYn: userData?.aprvYn ?? "",
|
||||
expryYmd: userData?.expryYmd ?? "",
|
||||
instSeCd: userData?.instSeCd ?? "",
|
||||
dtlInstCd: userData?.dtlInstCd ?? "",
|
||||
aprvYmd: userData?.aprvYmd ?? "",
|
||||
enoCd: userData?.enoCd ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: updateUser } = useMutationWrapper({
|
||||
mutationFn: (data: UserUpdateData) => UserApi.updateUser(userUnqId, data),
|
||||
onSuccess: () => {
|
||||
alert("회원 정보가 수정되었습니다.", router.back);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="card card-border !mt-6">
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-medium text-label">회원 정보 수정</h3>
|
||||
</div>
|
||||
<UsersForm
|
||||
userId={userData?.userId}
|
||||
form={form}
|
||||
handleSave={updateUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import {
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
import { USER_STATE_CODE, UserApi, PasswordResetDialog } from "@/entities/user";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
|
||||
|
||||
function UserDetail({ userUnqId }: { userUnqId: string }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { confirm } = useConfirm();
|
||||
const queryClient = useQueryClient();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
queryKey: [ENDPOINT.USER_SERVICE.USERS, userUnqId],
|
||||
queryFn: () => UserApi.findByUserUnqId(userUnqId),
|
||||
enabled: !!userUnqId,
|
||||
});
|
||||
|
||||
const { mutate: onLockUser } = useMutationWrapper({
|
||||
mutationFn: () => UserApi.lockUser(userUnqId),
|
||||
onSuccess: () => {
|
||||
alert("계정 잠금이 활성화 되었습니다.");
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: onUnlockUser } = useMutationWrapper({
|
||||
mutationFn: () => UserApi.unlockUser(userUnqId),
|
||||
onSuccess: () => {
|
||||
alert("계정 잠금이 해제되었습니다.");
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: onApplyUser } = useMutationWrapper({
|
||||
mutationFn: () => UserApi.applyUser(userUnqId),
|
||||
onSuccess: () => {
|
||||
alert("사용자 승인이 완료되었습니다.");
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: deleteUser } = useMutationWrapper({
|
||||
mutationFn: () => UserApi.deleteUser(userUnqId),
|
||||
onSuccess: () => {
|
||||
alert("회원이 삭제되었습니다.", router.back);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.USER_SERVICE.USERS],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (await confirm("삭제하시겠습니까?")) {
|
||||
await deleteUser();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card card-border !mt-6">
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-medium text-label">회원 정보</h3>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="mb-4 flex justify-end gap-2">
|
||||
{data?.userStateCode === USER_STATE_CODE.정지 ? (
|
||||
<Button color="orange" onClick={() => onUnlockUser()}>
|
||||
<i className="diveicon di-lock-open" />
|
||||
잠금해제
|
||||
</Button>
|
||||
) : (
|
||||
<Button color="orange" onClick={() => onLockUser()}>
|
||||
<i className="diveicon di-lock" />
|
||||
계정잠금
|
||||
</Button>
|
||||
)}
|
||||
<PasswordResetDialog userUnqId={userUnqId}>
|
||||
<Button color="gray">
|
||||
<i className="diveicon di-arrow-reset" /> 비밀번호 초기화
|
||||
</Button>
|
||||
</PasswordResetDialog>
|
||||
{data?.userStateCode === USER_STATE_CODE.대기 && (
|
||||
<Button className="emerald" onClick={() => onApplyUser()}>
|
||||
<i className="diveicon di-check-circle-line" /> 가입승인
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Table variant="secondary">
|
||||
<colgroup>
|
||||
<col width="20%" />
|
||||
<col width="80%" />
|
||||
</colgroup>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableHead>아이디</TableHead>
|
||||
<TableCell className="!px-4">{data?.userId}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>이름</TableHead>
|
||||
<TableCell className="!px-4">{data?.userName}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead rowSpan={2}>연락처</TableHead>
|
||||
<TableCell className="!px-4">휴대폰: {data?.mphn}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="!px-4">전화번호: {data?.telno}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>이메일</TableHead>
|
||||
<TableCell className="!px-4">{data?.email}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead rowSpan={2}>주소</TableHead>
|
||||
<TableCell className="!px-4">
|
||||
{data?.zip && `(${data?.zip})`} {data?.addr}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="!px-4">{data?.daddr}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>기관구분</TableHead>
|
||||
<TableCell className="!px-4">{data?.ogdpSeNm}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>세부기관</TableHead>
|
||||
<TableCell className="!px-4">{data?.dtlInstNm}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>소속</TableHead>
|
||||
<TableCell className="!px-4">{data?.laeSeNm}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>환경청</TableHead>
|
||||
<TableCell className="!px-4">{data?.ecoNm}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>가입일</TableHead>
|
||||
<TableCell className="!px-4">
|
||||
{data?.regDt ? dayjs(data?.regDt).format("YYYY-MM-DD") : ""}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>최근로그인</TableHead>
|
||||
<TableCell className="!px-4">
|
||||
{data?.lastLoginDate
|
||||
? dayjs(data?.lastLoginDate).format("YYYY-MM-DD HH:mm:ss")
|
||||
: ""}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>계정상태</TableHead>
|
||||
<TableCell className="!px-4">{data?.userStateNm}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex justify-center gap-4 pb-12 pt-10">
|
||||
<Button size="lg" className="white" onClick={router.back}>
|
||||
목록
|
||||
</Button>
|
||||
<Button size="lg" onClick={() => router.push(`${pathname}/modify`)}>
|
||||
수정
|
||||
</Button>
|
||||
<Button size="lg" color="red" onClick={handleDelete}>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { UserDetail };
|
||||
@@ -1,669 +0,0 @@
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import {
|
||||
useWatch,
|
||||
type Path,
|
||||
type PathValue,
|
||||
type UseFormReturn,
|
||||
} from "react-hook-form";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@common/components";
|
||||
import { FormWrapper } from "@/widgets/form-wrapper";
|
||||
import { CodeApi, CodeSelect } from "@/entities/code";
|
||||
import {
|
||||
UserApi,
|
||||
type UserSaveData,
|
||||
type UserUpdateData,
|
||||
} from "@/entities/user";
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from "@/shared/ui/form";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
|
||||
import { ZipSearchDialog } from "./zip-search-dialog";
|
||||
import { AgencySearchDialog } from "./agency-search-dialog";
|
||||
|
||||
type UsersFormProps<T extends UserSaveData | UserUpdateData> = {
|
||||
userId?: string;
|
||||
form: UseFormReturn<T>;
|
||||
handleSave: (data: T) => Promise<string>;
|
||||
handleDelete?: () => Promise<void>;
|
||||
};
|
||||
|
||||
function UsersForm<T extends UserSaveData | UserUpdateData>({
|
||||
userId,
|
||||
form,
|
||||
...props
|
||||
}: UsersFormProps<T>) {
|
||||
const isModifyMode = !!userId;
|
||||
|
||||
const [watchOgdpSeCd, watchDtlInstCd] = useWatch({
|
||||
control: form.control,
|
||||
name: ["ogdpSeCd" as Path<T>, "dtlInstCd" as Path<T>],
|
||||
});
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
const [isValidUserId, setIsValidUserId] = useState(false);
|
||||
const [isValidEmail, setIsValidEmail] = useState(false);
|
||||
const [passwordMatch, setPasswordMatch] = useState(true);
|
||||
const [instSeCdNm, setInstSeCdNm] = useState("");
|
||||
|
||||
const { data: authSeCd } = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.CODE, "AUTHSECD"],
|
||||
queryFn: () => CodeApi.getCode("AUTHSECD"),
|
||||
});
|
||||
|
||||
const { data: laeSeCd } = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.CODE, "LAESECD"],
|
||||
queryFn: () => CodeApi.getCode("LAESECD"),
|
||||
});
|
||||
|
||||
const { mutateAsync: checkDuplicateUserId } = useMutationWrapper({
|
||||
mutationFn: (userId: string) => UserApi.existsUserId({ userId }),
|
||||
onSuccess: (duplicate) => {
|
||||
if (!duplicate) {
|
||||
alert("사용가능한 아이디입니다.");
|
||||
setIsValidUserId(true);
|
||||
return;
|
||||
}
|
||||
alert("중복된 아이디입니다.");
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: checkDuplicateEmail } = useMutationWrapper({
|
||||
mutationFn: (email: string) => UserApi.existsEmail({ email }),
|
||||
onSuccess: (duplicate) => {
|
||||
if (!duplicate) {
|
||||
alert("사용가능한 이메일입니다.");
|
||||
setIsValidEmail(true);
|
||||
return;
|
||||
}
|
||||
alert("중복된 이메일입니다.");
|
||||
},
|
||||
});
|
||||
|
||||
const authSeCdSelectData = useMemo(() => {
|
||||
if (watchOgdpSeCd === "1")
|
||||
return (
|
||||
authSeCd?.filter(
|
||||
(code) =>
|
||||
code.lwpoCd === "ROLE_USER1" ||
|
||||
code.lwpoCd === "ROLE_USER2" ||
|
||||
code.lwpoCd === "ROLE_USER3",
|
||||
) ?? []
|
||||
);
|
||||
if (watchOgdpSeCd === "3" || watchOgdpSeCd === "4")
|
||||
return (
|
||||
authSeCd?.filter(
|
||||
(code) =>
|
||||
code.lwpoCd === "ROLE_USER4" || code.lwpoCd === "ROLE_USER5",
|
||||
) ?? []
|
||||
);
|
||||
return [];
|
||||
}, [authSeCd, watchOgdpSeCd]);
|
||||
|
||||
const laeSeCdSelectData = useMemo(() => {
|
||||
if (watchDtlInstCd === "ROLE_USER1")
|
||||
return laeSeCd?.filter((code) => code.lwpoCd === "G102") ?? [];
|
||||
if (watchDtlInstCd === "ROLE_USER2")
|
||||
return laeSeCd?.filter((code) => code.lwpoCd === "G101") ?? [];
|
||||
if (watchDtlInstCd === "ROLE_USER3")
|
||||
return laeSeCd?.filter((code) => code.lwpoCd.startsWith("G2")) ?? [];
|
||||
if (watchDtlInstCd === "ROLE_USER4")
|
||||
return (
|
||||
laeSeCd?.filter(
|
||||
(code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 4,
|
||||
) ?? []
|
||||
);
|
||||
if (watchDtlInstCd === "ROLE_USER5")
|
||||
return (
|
||||
laeSeCd?.filter(
|
||||
(code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 7,
|
||||
) ?? []
|
||||
);
|
||||
return [];
|
||||
}, [laeSeCd, watchDtlInstCd]);
|
||||
|
||||
const enoCdSelectData = useMemo(
|
||||
() => laeSeCd?.filter((code) => code.lwpoCd?.startsWith("G2")) ?? [],
|
||||
[laeSeCd],
|
||||
);
|
||||
|
||||
const handlePasswordConfirmChange = useCallback((value: string) => {
|
||||
const password = form.getValues("password" as Path<T>);
|
||||
setPasswordMatch(!password || value === password);
|
||||
}, []);
|
||||
|
||||
const handleAddressSelect = useCallback(
|
||||
(data: { zip: string; addr: string }) => {
|
||||
form.setValue("zip" as Path<T>, data.zip as PathValue<T, Path<T>>);
|
||||
form.setValue("addr" as Path<T>, data.addr as PathValue<T, Path<T>>);
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const handleAgencySelect = useCallback(
|
||||
(data: { lwpoCd: string; lwpoCdNm: string }) => {
|
||||
form.setValue(
|
||||
"instSeCd" as Path<T>,
|
||||
data.lwpoCd as PathValue<T, Path<T>>,
|
||||
);
|
||||
setInstSeCdNm(data.lwpoCdNm);
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormWrapper form={form} isModifyMode={isModifyMode} {...props}>
|
||||
<div className="card">
|
||||
<Table variant="secondary">
|
||||
<colgroup>
|
||||
<col width="20%" />
|
||||
<col width="80%" />
|
||||
</colgroup>
|
||||
<TableBody>
|
||||
{/* 아이디 */}
|
||||
<TableRow>
|
||||
<TableHead data-required>아이디</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"userId" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
setIsValidUserId(false);
|
||||
}}
|
||||
className="w-[24rem] !text-[1.3rem]"
|
||||
placeholder="아이디를 입력하세요"
|
||||
disabled={isModifyMode}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
{!isModifyMode && (
|
||||
<Button
|
||||
color="green"
|
||||
className="px-6"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
checkDuplicateUserId(field.value as string);
|
||||
}}
|
||||
disabled={!field.value || isValidUserId}
|
||||
>
|
||||
{isValidUserId && <i className="diveicon di-check" />}
|
||||
중복확인
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 비밀번호 */}
|
||||
<TableRow>
|
||||
<TableHead data-required>비밀번호</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"password" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
value={field.value ?? ""}
|
||||
className="w-[24rem] !text-[1.3rem]"
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 비밀번호 확인 */}
|
||||
<TableRow>
|
||||
<TableHead>비밀번호 확인</TableHead>
|
||||
<TableCell className="border">
|
||||
<div className="flex flex-col">
|
||||
<Input
|
||||
type="password"
|
||||
onChange={(e) =>
|
||||
handlePasswordConfirmChange(e.target.value)
|
||||
}
|
||||
className="w-[24rem] !text-[1.3rem]"
|
||||
placeholder="비밀번호를 다시 입력하세요"
|
||||
/>
|
||||
{!passwordMatch && (
|
||||
<p className="mt-2 text-base text-red-500">
|
||||
비밀번호가 일치하지 않습니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 비밀번호 힌트 */}
|
||||
<TableRow>
|
||||
<TableHead data-required>비밀번호 힌트</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"pswdHnt" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<CodeSelect
|
||||
{...field}
|
||||
upCd="PSWDHNT"
|
||||
className="w-[24rem]"
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 비밀번호 정답 */}
|
||||
<TableRow>
|
||||
<TableHead>비밀번호 정답</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"pswdAns" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="w-[24rem] !text-[1.3rem]"
|
||||
placeholder="비밀번호 정답을 입력하세요"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 이름 */}
|
||||
<TableRow>
|
||||
<TableHead data-required>이름</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"userName" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="w-[24rem] !text-[1.3rem]"
|
||||
placeholder="이름을 입력하세요"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 연락처 */}
|
||||
<TableRow>
|
||||
<TableHead rowSpan={2}>연락처</TableHead>
|
||||
<TableCell>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"mphn" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="w-[24rem] !text-[1.3rem]"
|
||||
placeholder="휴대폰 번호(-를 제외하고 입력)"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"telno" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="w-[24rem] !text-[1.3rem]"
|
||||
placeholder="전화번호(-를 제외하고 입력)"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 이메일 */}
|
||||
<TableRow>
|
||||
<TableHead>이메일</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"email" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
setIsValidEmail(false);
|
||||
}}
|
||||
className="w-[24rem] !text-[1.3rem]"
|
||||
placeholder="이메일을 입력하세요"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
<Button
|
||||
color="green"
|
||||
className="px-6"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (field.value) {
|
||||
checkDuplicateEmail(field.value as string);
|
||||
}
|
||||
}}
|
||||
disabled={!field.value || isValidEmail}
|
||||
>
|
||||
{isValidEmail && <i className="diveicon di-check" />}
|
||||
중복확인
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 주소 */}
|
||||
<TableRow>
|
||||
<TableHead rowSpan={2}>주소</TableHead>
|
||||
<TableCell className="border">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"zip" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="w-[10rem] !text-[1.3rem]"
|
||||
disabled
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<ZipSearchDialog onSelectAddress={handleAddressSelect} />
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"addr" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="w-[36rem] !text-[1.3rem]"
|
||||
disabled
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"daddr" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="w-[36rem] !text-[1.3rem]"
|
||||
placeholder="상세 주소를 입력하세요"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 기관구분 */}
|
||||
<TableRow>
|
||||
<TableHead data-required>기관구분</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"ogdpSeCd" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<CodeSelect
|
||||
{...field}
|
||||
upCd="OGDPSECD"
|
||||
value={field.value ?? ""}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.setValue(
|
||||
"dtlInstCd" as Path<T>,
|
||||
"" as PathValue<T, Path<T>>,
|
||||
);
|
||||
form.setValue(
|
||||
"laeSeCd" as Path<T>,
|
||||
"" as PathValue<T, Path<T>>,
|
||||
);
|
||||
form.setValue(
|
||||
"enoCd" as Path<T>,
|
||||
"" as PathValue<T, Path<T>>,
|
||||
);
|
||||
}}
|
||||
className="w-[16rem]"
|
||||
placeholder="기관 선택"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 세부기관 */}
|
||||
<TableRow>
|
||||
<TableHead>세부기관</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"dtlInstCd" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value ?? ""}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.setValue(
|
||||
"laeSeCd" as Path<T>,
|
||||
"" as PathValue<T, Path<T>>,
|
||||
);
|
||||
form.setValue(
|
||||
"enoCd" as Path<T>,
|
||||
"" as PathValue<T, Path<T>>,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[16rem]">
|
||||
<SelectValue placeholder="세부기관 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{authSeCdSelectData?.map((code) => (
|
||||
<SelectItem key={code.lwpoCd} value={code.lwpoCd}>
|
||||
{code.lwpoCdNm}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 소속 */}
|
||||
<TableRow>
|
||||
<TableHead data-required>소속</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"laeSeCd" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value ?? ""}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.setValue(
|
||||
"enoCd" as Path<T>,
|
||||
"" as PathValue<T, Path<T>>,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[16rem]">
|
||||
<SelectValue placeholder="소속 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{laeSeCdSelectData?.map((code) => (
|
||||
<SelectItem key={code.lwpoCd} value={code.lwpoCd}>
|
||||
{code.lwpoCdNm}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 환경청 */}
|
||||
<TableRow>
|
||||
<TableHead>환경청</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"enoCd" as Path<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value ?? ""}
|
||||
onValueChange={field.onChange}
|
||||
disabled={
|
||||
!(
|
||||
watchDtlInstCd === "ROLE_USER4" ||
|
||||
watchDtlInstCd === "ROLE_USER5"
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[16rem]">
|
||||
<SelectValue placeholder="환경청 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enoCdSelectData?.map((code) => (
|
||||
<SelectItem key={code.lwpoCd} value={code.lwpoCd}>
|
||||
{code.lwpoCdNm}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* 환경청 기관/업체명 */}
|
||||
<TableRow>
|
||||
<TableHead>기관/업체명</TableHead>
|
||||
<TableCell className="border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={instSeCdNm}
|
||||
className="w-[24rem] !text-[1.3rem]"
|
||||
disabled
|
||||
/>
|
||||
<AgencySearchDialog onSelectAgency={handleAgencySelect} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</FormWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export { UsersForm };
|
||||
@@ -1,396 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@common/components";
|
||||
import { CodeApi } from "@/entities/code";
|
||||
import {
|
||||
UserApi,
|
||||
type UserListResponse,
|
||||
type UserSearchParams,
|
||||
} from "@/entities/user";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker";
|
||||
import { columns } from "../model/table-columns";
|
||||
|
||||
type DateRange = {
|
||||
from?: Date | undefined;
|
||||
to?: Date | undefined;
|
||||
};
|
||||
|
||||
function UsersPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [presetDateRange, setPresetDateRange] = useState("");
|
||||
const [dateRange, setDateRange] = useState<DateRange>({
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
});
|
||||
const [keywordType, setKeywordType] = useState("all");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [userStateCode, setUserStateCode] = useState("");
|
||||
const [aprvYn, setAprvYn] = useState("");
|
||||
const [grpId, setGrpId] = useState("");
|
||||
|
||||
const [searchParams, setSearchParams] = useState<UserSearchParams>({
|
||||
keywordType: "all",
|
||||
keyword: "",
|
||||
userStateCode: "",
|
||||
aprvYn: "",
|
||||
grpId: "",
|
||||
frDateYmd: "",
|
||||
toDateYmd: "",
|
||||
sort: "regDt,desc",
|
||||
});
|
||||
|
||||
const { data: userSttsStats } = useQuery({
|
||||
queryKey: [ENDPOINT.USER_SERVICE.USERS, "userStatusStatistics"],
|
||||
queryFn: () => UserApi.selectUserSttsStats(),
|
||||
});
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [ENDPOINT.USER_SERVICE.USERS, searchParams, pagination],
|
||||
queryFn: () =>
|
||||
UserApi.findPageUser({
|
||||
...searchParams,
|
||||
page: pagination.pageIndex,
|
||||
size: pagination.pageSize,
|
||||
}),
|
||||
});
|
||||
|
||||
const { data: userStateCodes } = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.CODE],
|
||||
queryFn: () => CodeApi.getCode("USERSTTS"),
|
||||
});
|
||||
|
||||
const handleRowClick = (row: UserListResponse) => {
|
||||
router.push(`/users/${row.userUnqId}`);
|
||||
};
|
||||
|
||||
const { tableComponent, tableInstance: table } = useDataTable({
|
||||
query,
|
||||
tableOptions: {
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
},
|
||||
manualPagination: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
},
|
||||
dataTableOptions: {
|
||||
onRowClick: handleRowClick,
|
||||
viewPagination: true,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setPagination({
|
||||
...pagination,
|
||||
pageIndex: 0,
|
||||
});
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
keywordType,
|
||||
keyword,
|
||||
userStateCode,
|
||||
aprvYn,
|
||||
grpId,
|
||||
frDateYmd: dateRange?.from
|
||||
? dayjs(dateRange.from).format("YYYYMMDD")
|
||||
: "",
|
||||
toDateYmd: dateRange?.to ? dayjs(dateRange.to).format("YYYYMMDD") : "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setKeywordType("all");
|
||||
setKeyword("");
|
||||
setUserStateCode("");
|
||||
setAprvYn("");
|
||||
setGrpId("");
|
||||
setDateRange({
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
});
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
keywordType: "all",
|
||||
keyword: "",
|
||||
userStateCode: "",
|
||||
aprvYn: "",
|
||||
grpId: "",
|
||||
frDateYmd: "",
|
||||
toDateYmd: "",
|
||||
});
|
||||
};
|
||||
|
||||
const handlePresetDateRange = (value: string) => {
|
||||
let presetDateRange: DateRange;
|
||||
switch (value) {
|
||||
case "1M":
|
||||
presetDateRange = {
|
||||
from: dayjs().subtract(1, "month").toDate(),
|
||||
to: dayjs().toDate(),
|
||||
};
|
||||
break;
|
||||
case "3M":
|
||||
presetDateRange = {
|
||||
from: dayjs().subtract(3, "month").toDate(),
|
||||
to: dayjs().toDate(),
|
||||
};
|
||||
break;
|
||||
case "6M":
|
||||
presetDateRange = {
|
||||
from: dayjs().subtract(6, "month").toDate(),
|
||||
to: dayjs().toDate(),
|
||||
};
|
||||
break;
|
||||
default:
|
||||
presetDateRange = {
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
};
|
||||
}
|
||||
setPresetDateRange(value);
|
||||
setDateRange(presetDateRange);
|
||||
};
|
||||
|
||||
const handleDateRangeChange = (dateRange: DateRange) => {
|
||||
setDateRange(dateRange);
|
||||
setPresetDateRange("");
|
||||
};
|
||||
|
||||
const getUserSttsStatsCnt = (code: string): string => {
|
||||
if (!Array.isArray(userSttsStats)) return "0";
|
||||
const count = userSttsStats.find((item) => item.sttsCd === code)?.cnt ?? 0;
|
||||
return count.toLocaleString();
|
||||
};
|
||||
|
||||
const handleUserStateCodeChange = (code: string, checked: boolean) => {
|
||||
let newUserStateCode = userStateCode;
|
||||
|
||||
if (checked) {
|
||||
newUserStateCode = userStateCode ? `${userStateCode},${code}` : code;
|
||||
} else {
|
||||
newUserStateCode = userStateCode
|
||||
.split(",")
|
||||
.filter((c) => c !== code)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
setUserStateCode(newUserStateCode);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card card-border !mt-6">
|
||||
<div className="card card-border flex items-center justify-center gap-10 !bg-accent !py-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-[rgba(153,153,153,1)]">
|
||||
<i className="diveicon di-person text-xl text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-medium text-label">
|
||||
승인대기{" "}
|
||||
<em className="not-italic text-primary">
|
||||
{getUserSttsStatsCnt("00")}
|
||||
</em>
|
||||
명
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-primary">
|
||||
<i className="diveicon di-person-fill text-xl text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-medium text-label">
|
||||
전체{" "}
|
||||
<em className="not-italic text-primary">
|
||||
{getUserSttsStatsCnt("TOTAL")}
|
||||
</em>
|
||||
명
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-[rgba(255,149,0,1)]">
|
||||
<i className="diveicon di-lock text-xl text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-medium text-label">
|
||||
잠금{" "}
|
||||
<em className="not-italic text-primary">
|
||||
{getUserSttsStatsCnt("07")}
|
||||
</em>
|
||||
명
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-destructive">
|
||||
<i className="diveicon di-exclamation-triangle-fill text-xl text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-medium text-label">
|
||||
탈퇴{" "}
|
||||
<em className="not-italic text-primary">
|
||||
{getUserSttsStatsCnt("08")}
|
||||
</em>
|
||||
명
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card card-border">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6 p-2">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="mr-2 whitespace-nowrap text-label">
|
||||
회원정보
|
||||
</Label>
|
||||
<Select
|
||||
value={keywordType}
|
||||
onValueChange={(value) => setKeywordType(value)}
|
||||
>
|
||||
<SelectTrigger className="w-[18rem]">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="userId">아이디</SelectItem>
|
||||
<SelectItem value="userName">이름</SelectItem>
|
||||
<SelectItem value="email">이메일</SelectItem>
|
||||
<SelectItem value="mphn">휴대폰번호</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="!text-[1.3rem]"
|
||||
placeholder="검색어를 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="mr-2 text-label">그룹</Label>
|
||||
{/* Select */}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="mr-2 text-label">기간</Label>
|
||||
<Select
|
||||
value={presetDateRange}
|
||||
onValueChange={handlePresetDateRange}
|
||||
>
|
||||
<SelectTrigger className="w-[12rem]">
|
||||
<SelectValue placeholder="기간 선택" className="w-[24rem]" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1M">1개월</SelectItem>
|
||||
<SelectItem value="3M">3개월</SelectItem>
|
||||
<SelectItem value="6M">6개월</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputDateRangePicker
|
||||
dateRange={dateRange}
|
||||
setDateRange={handleDateRangeChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="mr-2 text-label">회원상태</Label>
|
||||
<div className="flex gap-4">
|
||||
{userStateCodes?.map((item) => (
|
||||
<div key={item.lwpoCd} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={item.lwpoCd}
|
||||
value={item.lwpoCd}
|
||||
checked={userStateCode?.includes(item.lwpoCd)}
|
||||
onCheckedChange={(checked) => {
|
||||
handleUserStateCodeChange(item.lwpoCd, !!checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={item.lwpoCd}>{item.lwpoCdNm}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button type="submit" className="w-[16rem]">
|
||||
<i className="diveicon di-search" />
|
||||
검색
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
className="w-[16rem]"
|
||||
onClick={handleReset}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="card card-border">
|
||||
<div className="card-header">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h3 className="text-2xl font-medium text-label">회원 목록</h3>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
defaultValue="regDt,desc"
|
||||
onValueChange={(value) => {
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
sort: value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="aprvYmd,desc">승인일순</SelectItem>
|
||||
<SelectItem value="regDt,desc">등록일순</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={table.getState().pagination.pageSize.toString()}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(parseInt(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-[3.2rem] w-[12rem]">
|
||||
<SelectValue placeholder="페이지 크기" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={pageSize.toString()}>
|
||||
{pageSize}개씩 보기
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => router.push("/users/register")}>
|
||||
등록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card !m-[1.2rem] !p-0">{tableComponent}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { UsersPage };
|
||||
@@ -1,224 +0,0 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
keepPreviousData,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
type PaginationState,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
} from "@common/components";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { ZipApi, type Zip, type ZipSearchParams } from "../api/zip-service";
|
||||
|
||||
interface ZipSearchDialogProps {
|
||||
onSelectAddress: (data: { zip: string; addr: string }) => void;
|
||||
}
|
||||
|
||||
function ZipSearchDialog({ onSelectAddress }: ZipSearchDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedAddress, setSelectedAddress] = useState<Zip | null>(null);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [searchQuery, setSearchQuery] = useState<ZipSearchParams>({
|
||||
roadNm: "",
|
||||
lotnoMno: null,
|
||||
lotnoSno: null,
|
||||
});
|
||||
const hasFetchedOnce = useRef(false);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.ZIPS, pagination],
|
||||
queryFn: () =>
|
||||
ZipApi.searchZip({
|
||||
...searchQuery,
|
||||
page: pagination.pageIndex,
|
||||
size: pagination.pageSize,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const handleRowClick = (row: Zip) => {
|
||||
table.setRowSelection({
|
||||
[row.zipId.toString()]: true,
|
||||
});
|
||||
setSelectedAddress(row);
|
||||
};
|
||||
|
||||
const { tableComponent, tableInstance: table } = useDataTable({
|
||||
query,
|
||||
tableOptions: {
|
||||
columns: [
|
||||
{
|
||||
accessorKey: "zoneNo",
|
||||
header: "우편번호",
|
||||
},
|
||||
{
|
||||
accessorKey: "zipAddr",
|
||||
header: "주소",
|
||||
},
|
||||
],
|
||||
state: {
|
||||
pagination,
|
||||
},
|
||||
manualPagination: true,
|
||||
enableMultiRowSelection: false,
|
||||
getRowId: (row) => row.zipId.toString(),
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
},
|
||||
dataTableOptions: {
|
||||
onRowClick: handleRowClick,
|
||||
viewPagination: true,
|
||||
variant: "secondary",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSearch = async () => {
|
||||
await query.refetch();
|
||||
queryClient.removeQueries({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.ZIPS],
|
||||
});
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
table.setRowSelection({});
|
||||
setSelectedAddress(null);
|
||||
hasFetchedOnce.current = true;
|
||||
};
|
||||
|
||||
const handleSelectAddress = () => {
|
||||
if (!selectedAddress) return;
|
||||
onSelectAddress({
|
||||
zip: selectedAddress.zoneNo,
|
||||
addr: selectedAddress.zipAddr,
|
||||
});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hasFetchedOnce.current) query.refetch();
|
||||
}, [pagination]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="px-6">우편번호 검색</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>주소 검색</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSearch();
|
||||
}}
|
||||
className="mb-4 space-y-2"
|
||||
>
|
||||
<div className="card card-border !m-0 flex flex-col gap-4 p-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="flex flex-col">
|
||||
<Label className="mb-1 text-base">도로명</Label>
|
||||
<Input
|
||||
value={searchQuery.roadNm || ""}
|
||||
onChange={(e) =>
|
||||
setSearchQuery({
|
||||
...searchQuery,
|
||||
roadNm: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="예: 테헤란로"
|
||||
className="!text-[1.3rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Label className="mb-1 text-base">지번본번</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={searchQuery.lotnoMno ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: null;
|
||||
setSearchQuery({
|
||||
...searchQuery,
|
||||
lotnoMno: value,
|
||||
});
|
||||
}}
|
||||
placeholder="예: 123"
|
||||
className="!text-[1.3rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Label className="mb-1 text-base">지번부번</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={searchQuery.lotnoSno ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
? parseInt(e.target.value)
|
||||
: null;
|
||||
setSearchQuery({
|
||||
...searchQuery,
|
||||
lotnoSno: value,
|
||||
});
|
||||
}}
|
||||
placeholder="예: 45"
|
||||
className="!text-[1.3rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit">검색</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{query.data?.content.length && tableComponent}
|
||||
|
||||
<DialogFooter className="!justify-center">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleSelectAddress}
|
||||
disabled={!selectedAddress}
|
||||
>
|
||||
선택
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button size="lg" color="white">
|
||||
취소
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { ZipSearchDialog };
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Client } from "@common/network";
|
||||
import { BASE_URL } from "../config/api";
|
||||
import { SITE_ID } from "../config/env";
|
||||
import { setCookie } from "../lib";
|
||||
|
||||
setCookie("site-id-key", SITE_ID);
|
||||
|
||||
export const client = Client.getInstance({
|
||||
baseURL: BASE_URL,
|
||||
siteId: SITE_ID,
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { client } from "./client";
|
||||
@@ -1,71 +0,0 @@
|
||||
import axios from "axios";
|
||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
|
||||
|
||||
interface SignupClientConfig {
|
||||
baseURL: string;
|
||||
}
|
||||
|
||||
class SignupClient {
|
||||
private static instance: SignupClient;
|
||||
private axiosInstance: AxiosInstance;
|
||||
|
||||
private constructor(config: SignupClientConfig) {
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL: config.baseURL,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// 인증 토큰 없이도 요청할 수 있도록 인터셉터 설정
|
||||
this.axiosInstance.interceptors.request.use((config) => {
|
||||
// 인증 토큰을 자동으로 추가하지 않음
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
public static getInstance(config: SignupClientConfig): SignupClient {
|
||||
if (!SignupClient.instance) {
|
||||
SignupClient.instance = new SignupClient(config);
|
||||
}
|
||||
return SignupClient.instance;
|
||||
}
|
||||
|
||||
public async get<T = unknown, R = AxiosResponse<T>>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<R> {
|
||||
const response = await this.axiosInstance.get<T, R>(url, config);
|
||||
return response;
|
||||
}
|
||||
|
||||
public async post<T = unknown, R = AxiosResponse<T>, D = unknown>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig<D>
|
||||
): Promise<R> {
|
||||
const response = await this.axiosInstance.post<T, R>(url, data, config);
|
||||
return response;
|
||||
}
|
||||
|
||||
public async put<T = unknown, R = AxiosResponse<T>, D = unknown>(
|
||||
url: string,
|
||||
data?: D,
|
||||
config?: AxiosRequestConfig<D>
|
||||
): Promise<R> {
|
||||
const response = await this.axiosInstance.put<T, R>(url, data, config);
|
||||
return response;
|
||||
}
|
||||
|
||||
public async delete<T = unknown, R = AxiosResponse<T>>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<R> {
|
||||
const response = await this.axiosInstance.delete<T, R>(url, config);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export type { SignupClientConfig };
|
||||
export { SignupClient };
|
||||
@@ -1,74 +0,0 @@
|
||||
import { PROXY_HOST } from "./env";
|
||||
|
||||
export const BASE_URL = `${PROXY_HOST}/server`;
|
||||
|
||||
export const ENDPOINT = {
|
||||
ATTACH_SERVICE: {
|
||||
TUS_UPLOAD: "/attach-service/api/v1/tus/upload",
|
||||
EDITOR_UPLOAD: "/attach-service/api/v1/upload/editor",
|
||||
IMAGES: "/attach-service/api/v1/images",
|
||||
MOVIE_THUMBNAIL: "/attach-service/api/v1/movie/thumbnail",
|
||||
ATTACHMENTS: "/attach-service/api/v1/atfl/attachments",
|
||||
DOWNLOAD: "/attach-service/api/v1/atflcd/download",
|
||||
STREAM_VIDEO: "/attach-service/api/v1/stream/video",
|
||||
},
|
||||
USER_SERVICE: {
|
||||
LOGIN: "/user-service/login",
|
||||
LOGIN_2FA: "/user-service/api/v1/auth/login",
|
||||
USERS: "/user-service/api/v1/users",
|
||||
SILENT_REFRESH: "/user-service/api/v1/users/token/refresh",
|
||||
AUTHORIZATIONS: "/user-service/api/v1/authorizations",
|
||||
ROLE_AUTHORIZATIONS: "/user-service/api/v1/role-authorizations",
|
||||
ROLES: "/user-service/api/v1/roles",
|
||||
LOGINLOG: "/user-service/api/v1/login-logs",
|
||||
PRIVACYLOG: "/user-service//api/v1/prvc-inq-logs",
|
||||
OTP_VERIFY: "/user-service/api/v1/auth/2fa/verify",
|
||||
TWOFA_SETUP: "/user-service/api/v1/auth/2fa/setup",
|
||||
TWOFA_ENABLE: "/user-service/api/v1/auth/2fa/enable",
|
||||
USER_JOIN: "/user-service/api/v1/anonymouse/users/join",
|
||||
},
|
||||
CMS_SERVICE: {
|
||||
DASHBOARD: "/cms-service/api/v1/dashboard",
|
||||
SITES: "/cms-service/api/v1/sites",
|
||||
IPS: "/cms-service/api/v1/site-ips",
|
||||
DOMAINS: "/cms-service/api/v1/site-dmns",
|
||||
POSTINGS: "/cms-service/api/v1/site-pstgs",
|
||||
CODE: "/cms-service/api/v1/code/cache",
|
||||
MENUS: "/cms-service/api/v1/menus",
|
||||
MENU_ROLES: "/cms-service/api/v1/menu-roles",
|
||||
MENU_AUTHRTS: "/cms-service/api/v1/menu-authrts",
|
||||
MENU_TEMPORARY_STORAGE: "/cms-service/api/v1/menu-tmpr-strg",
|
||||
MENU_HISTORY: "/cms-service/api/v1/menu-hstry",
|
||||
CONTENTS: "/cms-service/api/v1/contents",
|
||||
CONTENTS_DETAILS: "/cms-service/api/v1/contents-details",
|
||||
ORGANIZATIONS: "/cms-service/api/v1/ognzs",
|
||||
USER_ORGANIZATION: "/cms-service/api/v1/user-ognz",
|
||||
IMAGES: "/cms-service/api/v1/images",
|
||||
BANNERS: "/cms-service/api/v1/bnrs",
|
||||
TEMPLATE: "/cms-service/api/v1/tmplt",
|
||||
TERMS: "/cms-service/api/v1/trms",
|
||||
LWPO_CODE: "/cms-service/api/v1/lwpo-cds",
|
||||
UP_CODE: "/cms-service/api/v1/up-codes",
|
||||
POPUPS: "/cms-service/api/v1/popups",
|
||||
SCHEDULES: "/cms-service/api/v1/schdls",
|
||||
SURVEY: "/cms-service/api/v1/srvy",
|
||||
SURVEY_QUESTION: "/cms-service/api/v1/srvy-qitem",
|
||||
SURVEY_ARTICLE: "/cms-service/api/v1/srvy-artcl",
|
||||
ZIPS: "/cms-service/api/v1/zips",
|
||||
GROUPS: "/cms-service/api/v1/groups",
|
||||
GROUP_USERS: "/cms-service/api/v1/group-users",
|
||||
MANAGER_IP: "/cms-service/api/v1/mgrip",
|
||||
APILOG: "/cms-service/api/v1/api-logs",
|
||||
SITE_AUTHORITY: "/cms-service/api/v1/site-authrt",
|
||||
CONNECTION_LOG: "/cms-service/api/v1/cntnlog",
|
||||
},
|
||||
BOARD_SERVICE: {
|
||||
BBS: "/board-service/api/v1/bbs",
|
||||
BBS_TEMPLATES: "/board-service/api/v1/bbsTmplt",
|
||||
POSTS: "/board-service/api/v1/posts",
|
||||
POSTS_COMMENTS: "/board-service/api/v1/posts-cmnts",
|
||||
POST_EVALUATION: "/board-service/api/v1/pstEvl",
|
||||
},
|
||||
};
|
||||
|
||||
export const JWT_EXPIRATION_TIME = 1800000;
|
||||
@@ -1,11 +0,0 @@
|
||||
export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
|
||||
export const PROXY_HOST =
|
||||
process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
|
||||
export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL;
|
||||
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "CS";
|
||||
export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
|
||||
export const ACCESS_TOKEN =
|
||||
process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
|
||||
export const REFRESH_TOKEN =
|
||||
process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
|
||||
export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./api";
|
||||
export * from "./env";
|
||||
@@ -1,29 +0,0 @@
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
export function getCookie(name: string): string | undefined {
|
||||
if (typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
return Cookies.get(name);
|
||||
}
|
||||
|
||||
export function setCookie(
|
||||
name: string,
|
||||
value: string,
|
||||
options?: Cookies.CookieAttributes,
|
||||
): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
Cookies.set(name, value, options);
|
||||
}
|
||||
|
||||
export function removeCookie(
|
||||
name: string,
|
||||
options?: Cookies.CookieAttributes,
|
||||
): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
Cookies.remove(name, options);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export { getCookie, setCookie, removeCookie } from "./cookie";
|
||||
export { useMutationWrapper } from "./use-mutation-wrapper";
|
||||
export { useAlertStore } from "./use-alert-store";
|
||||
export { useDataTable } from "./use-data-table";
|
||||
export { useMenuStore } from "./use-menu-store";
|
||||
export { useUserStore } from "./use-user-store";
|
||||
export { usePrompt } from "./use-prompt";
|
||||
export { useSelectPrompt } from "./use-select-prompt";
|
||||