Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
NEXT_PUBLIC_PORT=3001
|
||||
NEXT_PUBLIC_DC_SERVICE_API_URL=https://apigateway.apps.rsonesoft.com
|
||||
NEXT_PUBLIC_SERVER_API_URL=http://dev.onasys.co.kr:8000
|
||||
NEXT_PUBLIC_FLASK_API_URL=http://1.243.42.193:5000
|
||||
NEXT_PUBLIC_PROXY_HOST=http://localhost:3001
|
||||
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
|
||||
#next dev -H 192.168.45.10 -p 3000
|
||||
|
||||
|
||||
|
||||
# NEXT_PUBLIC_PORT=3000
|
||||
#NEXT_PUBLIC_PROXY_HOST=http://dev.onasys.co.kr:3001
|
||||
#NEXT_PUBLIC_DC_SERVICE_API_URL=http://localhost:8080
|
||||
NEXT_PUBLIC_SITE_ID=DC
|
||||
#NEXT_PUBLIC_SUB_DOMAIN=http://localhost:3000
|
||||
#NEXT_PUBLIC_SERVER_API_URL=http://192.168.0.102:8000
|
||||
# NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# 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
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { CategoryPage } from "@/pages/category/ui/category-page";
|
||||
export default CategoryPage;
|
||||
@@ -0,0 +1,2 @@
|
||||
import { ChatBotPage } from "@/pages/chatbot/ui/chatbot-page";
|
||||
export default ChatBotPage;
|
||||
@@ -0,0 +1,2 @@
|
||||
import { DataInsertPage } from "@/pages/datainsert/ui/datainsert-page";
|
||||
export default DataInsertPage;
|
||||
@@ -0,0 +1,2 @@
|
||||
import { GeneralSearchPage } from "@/pages/generalsearch/ui/generalsearch-page";
|
||||
export default GeneralSearchPage;
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { BaseLayout } from "@common/components/ui";
|
||||
|
||||
export default function WithBaseLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const handleMenuClick = (item: any) => {
|
||||
if (item.urlPath) {
|
||||
router.push(item.urlPath);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseLayout currentPath={pathname ?? ""} handleMenuClick={handleMenuClick}>
|
||||
{children}
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { MyDataPage } from "@/pages/mydata/ui/mydata-page";
|
||||
export default MyDataPage;
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { MyDataApi } from "@/entities/datalibrary";
|
||||
import type { MyData } from "@/entities/datalibrary";
|
||||
import { MyDataRegister } from "@/pages/mydata/ui/mydata-register";
|
||||
import { useUserStore } from "@/shared/lib";
|
||||
import { useAlertStore } from "@/shared/lib/use-alert-store";
|
||||
|
||||
export default function MyDataRegisterPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const fileSn = params?.fileSn as string;
|
||||
const form = useForm<Partial<MyData>>({ defaultValues: {} });
|
||||
const userUnqId = useUserStore((state) => state.userInfo?.userUnqId || "");
|
||||
const userId = useUserStore((state) => state.userInfo?.userId || "");
|
||||
const { setMessage: alert } = useAlertStore((state) => state);
|
||||
|
||||
useEffect(() => {
|
||||
if (fileSn) {
|
||||
MyDataApi.detail(fileSn, userUnqId).then(found => {
|
||||
if (found) {
|
||||
// 모든 필드를 빈 문자열로 보정 + userId, rgtr도 포함
|
||||
const foundAny = found as any;
|
||||
const allKeys = [
|
||||
"fileSn", "shrnSeCd", "shrnDeptCd", "shrnUserId", "fileNm", "dataSttsCd", "prdctnDeptCd", "prdctnSysCd", "regDt", "itrstYn", "trnsfUserId", "userId", "rgtr",
|
||||
// 명칭 필드도 바인딩
|
||||
"shrnSeCdNm", "dataSttsCdNm",
|
||||
// 추가 필드들
|
||||
"trnsfDataYn", "dataTrnsfUserId"
|
||||
];
|
||||
const normalized = allKeys.reduce((acc, key) => {
|
||||
// select박스 값은 공백/undefined/null/공백문자열이면 ''로, 값이 있으면 trim해서 문자열로 변환
|
||||
if (["shrnSeCd", "dataSttsCd", "itrstYn"].includes(key)) {
|
||||
const v = foundAny[key];
|
||||
acc[key] = v && String(v).trim() ? String(v).trim() : '';
|
||||
} else if (key === "userId") {
|
||||
acc[key] = foundAny[key] ?? userId;
|
||||
} else if (["prdctnDeptCd", "prdctnSysCd"].includes(key)) {
|
||||
// 생산부서, 생산시스템 필드는 공백 제거
|
||||
const v = foundAny[key];
|
||||
acc[key] = v ? String(v).trim() : "";
|
||||
} else {
|
||||
acc[key] = foundAny[key] ?? "";
|
||||
}
|
||||
return acc;
|
||||
}, {} as any);
|
||||
form.reset(normalized);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [fileSn, userId, userUnqId]);
|
||||
|
||||
const handleSave = async (data: Partial<MyData>) => {
|
||||
if (!fileSn) return;
|
||||
// key 그대로 map 변환 + fileSn, mdfr, userId, rgtr도 포함
|
||||
const mapData = Object.entries(data).reduce((acc, [key, value]) => {
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
mapData['fileSn'] = fileSn;
|
||||
mapData['mdfrId'] = userUnqId;
|
||||
const dataAny = data as any;
|
||||
mapData['userId'] = dataAny.userId ?? '';
|
||||
mapData['rgtrId'] = dataAny.rgtr ?? '';
|
||||
try {
|
||||
await MyDataApi.update(mapData);
|
||||
alert("수정이 완료되었습니다.", () => {
|
||||
router.push("/mydata");
|
||||
});
|
||||
} catch (e: any) {
|
||||
alert("수정 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!fileSn) return;
|
||||
//if (!window.confirm("삭제하시겠습니까?")) return;
|
||||
try {
|
||||
await MyDataApi.remove(fileSn);
|
||||
alert("삭제가 완료되었습니다.", () => {
|
||||
router.push("/mydata");
|
||||
});
|
||||
} catch (e: any) {
|
||||
alert("삭제 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
return <MyDataRegister form={form} mydataId={fileSn} handleSave={handleSave} handleDelete={handleDelete} />;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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 });
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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");
|
||||
|
||||
// silent refresh
|
||||
if (isRefreshHandler) {
|
||||
const refreshToken = cookieStore.get(REFRESH_TOKEN);
|
||||
headers[CLAIM_NAME] = refreshToken?.value ?? "";
|
||||
|
||||
if (!headers[CLAIM_NAME]) {
|
||||
return NextResponse.json({ message: "Can't refresh" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isRefreshHandler) {
|
||||
body = await request.json();
|
||||
}
|
||||
|
||||
const result = await fetch(`${SERVER_API_URL}/${params.url.join("/")}`, {
|
||||
method,
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
{ message: "Invalid Credentials" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const accessToken = result.headers.get(ACCESS_TOKEN);
|
||||
const refreshToken = result.headers.get(REFRESH_TOKEN);
|
||||
const userId = result.headers.get(AUTH_USER_ID);
|
||||
|
||||
if (accessToken && userId) {
|
||||
const response = NextResponse.json({
|
||||
[ACCESS_TOKEN]: accessToken,
|
||||
[AUTH_USER_ID]: userId,
|
||||
});
|
||||
|
||||
if (refreshToken) {
|
||||
cookieStore.set(REFRESH_TOKEN, refreshToken, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "Invalid Credentials" },
|
||||
{ status: 401 },
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ message: "Server Error", error },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
props: { params: Promise<{ url: string[] }> },
|
||||
) {
|
||||
const params = await props.params;
|
||||
return handler(request, params, "POST");
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
props: { params: Promise<{ url: string[] }> },
|
||||
) {
|
||||
const params = await props.params;
|
||||
return handler(request, params, "PUT");
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { LoginPage } from "@/pages/auth/login";
|
||||
export default LoginPage;
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import { App } from "@/app";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "유역하수도 통합 정보플랫폼",
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getCookie } from "@/shared/lib";
|
||||
import { AUTH_USER_ID } from "@/shared/config";
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const userId = getCookie(AUTH_USER_ID);
|
||||
|
||||
if (!userId) {
|
||||
// 인증되지 않은 사용자는 일반검색 페이지로 이동
|
||||
router.push("/auth/login");
|
||||
} else {
|
||||
// 인증된 사용자는 내 데이터 페이지로 이동
|
||||
router.push("/generalsearch");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
{/* <div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
유역하수도 통합 정보플랫폼
|
||||
</h1>
|
||||
<p className="text-gray-600">로딩 중...</p>
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_PORT: string;
|
||||
readonly VITE_PROXY_HOST: string;
|
||||
readonly VITE_SERVER_API_URL: string;
|
||||
readonly VITE_SITE_ID: string;
|
||||
readonly VITE_CLAIM_NAME: string;
|
||||
readonly VITE_ACCESS_TOKEN: string;
|
||||
readonly VITE_REFRESH_TOKEN: string;
|
||||
readonly VITE_AUTH_USER_ID: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-unused-vars": "warn",
|
||||
"@next/next/no-img-element": "warn",
|
||||
"prefer-const": "warn",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,175 @@
|
||||
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 dcServiceUrl =
|
||||
process.env.NEXT_PUBLIC_DC_SERVICE_API_URL ?? "http://localhost:3000";
|
||||
|
||||
const serverApiUrl =
|
||||
process.env.NEXT_PUBLIC_SERVER_API_URL ?? "http://localhost:3000";
|
||||
|
||||
const flaskApiUrl =
|
||||
process.env.NEXT_PUBLIC_FLASK_API_URL ?? "http://localhost:5000";
|
||||
|
||||
const linkBackendApiHost = process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST ?? "http://localhost:8080";
|
||||
|
||||
// CSP 헤더 설정 함수 (img-src만)
|
||||
function addSecurityHeaders(response: NextResponse): NextResponse {
|
||||
response.headers.set("Content-Security-Policy", "img-src 'self' data: https: blob:;");
|
||||
return response;
|
||||
}
|
||||
|
||||
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}\n DC Service URL: ${dcServiceUrl}\n Flask API URL: ${flaskApiUrl}\n Link Backend API Host: ${linkBackendApiHost}`,
|
||||
`[Request]\n Original Pathname: ${pathname}`,
|
||||
];
|
||||
|
||||
// DC Service 처리 (인증 토큰 포함)
|
||||
if (pathname.startsWith("/server/dc-service")) {
|
||||
const requestHeaders = new Headers(req.headers);
|
||||
const token = req.cookies.get(CLAIM_NAME)?.value;
|
||||
const userId = req.cookies.get(AUTH_USER_ID)?.value;
|
||||
|
||||
const queryParams = searchParams.toString();
|
||||
const newUrl = `${dcServiceUrl}${pathname.replace("/server", "")}${
|
||||
queryParams ? `?${queryParams}` : ""
|
||||
}`;
|
||||
|
||||
if (token) requestHeaders.set(CLAIM_NAME, token);
|
||||
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
|
||||
|
||||
// 디버깅을 위한 토큰 정보 로그
|
||||
logMessages.push(` Token Debug: ${token ? 'EXISTS' : 'MISSING'}`);
|
||||
logMessages.push(` UserId Debug: ${userId ? 'EXISTS' : 'MISSING'}`);
|
||||
logMessages.push(` DC Service Rewritten URL: ${newUrl}`);
|
||||
logMessages.push("\n--------------------------------\n");
|
||||
console.log(logMessages.join("\n"));
|
||||
|
||||
const response = NextResponse.rewrite(new URL(newUrl), {
|
||||
headers: requestHeaders,
|
||||
});
|
||||
return addSecurityHeaders(response);
|
||||
}
|
||||
|
||||
// Flask Service 처리 (인증 토큰 포함)
|
||||
if (pathname.startsWith("/server/flask-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 queryParams = searchParams.toString();
|
||||
const newUrl = `${flaskApiUrl}${pathname.replace("/server/flask-service", "")}${
|
||||
queryParams ? `?${queryParams}` : ""
|
||||
}`;
|
||||
|
||||
if (token) requestHeaders.set(CLAIM_NAME, token);
|
||||
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
|
||||
|
||||
logMessages.push(` Flask Service Rewritten URL: ${newUrl}`);
|
||||
logMessages.push("\n--------------------------------\n");
|
||||
console.log(logMessages.join("\n"));
|
||||
|
||||
const response = NextResponse.rewrite(new URL(newUrl), {
|
||||
headers: requestHeaders,
|
||||
});
|
||||
return addSecurityHeaders(response);
|
||||
}
|
||||
|
||||
// 일반 Server API 처리 (인증 토큰 포함)
|
||||
if (pathname.startsWith("/server") && !pathname.startsWith("/server/dc-service") && !pathname.startsWith("/server/flask-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 queryParams = searchParams.toString();
|
||||
const newUrl = `${serverApiUrl}${pathname.replace("/server", "")}${
|
||||
queryParams ? `?${queryParams}` : ""
|
||||
}`;
|
||||
|
||||
if (token) requestHeaders.set(CLAIM_NAME, token);
|
||||
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
|
||||
|
||||
logMessages.push(` Server API Rewritten URL: ${newUrl}`);
|
||||
logMessages.push("\n--------------------------------\n");
|
||||
console.log(logMessages.join("\n"));
|
||||
|
||||
const response = NextResponse.rewrite(new URL(newUrl), {
|
||||
headers: requestHeaders,
|
||||
});
|
||||
return addSecurityHeaders(response);
|
||||
}
|
||||
|
||||
// Attach Service 처리 (인증 토큰 포함)
|
||||
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(` Attach Service Rewritten URL: ${newUrl}`);
|
||||
logMessages.push("\n--------------------------------\n");
|
||||
console.log(logMessages.join("\n"));
|
||||
|
||||
const response = NextResponse.rewrite(new URL(newUrl), {
|
||||
headers: requestHeaders,
|
||||
});
|
||||
return addSecurityHeaders(response);
|
||||
}
|
||||
|
||||
// Link Backend API 처리 (인증 토큰 포함)
|
||||
if (pathname.startsWith("/api/link")) {
|
||||
const requestHeaders = new Headers(req.headers);
|
||||
const token = req.cookies.get(CLAIM_NAME)?.value;
|
||||
const userId = req.cookies.get(AUTH_USER_ID)?.value;
|
||||
|
||||
// /api/link 경로를 제거하고 백엔드 API 경로로 변환
|
||||
const apiPath = pathname.replace("/api/link", "");
|
||||
const queryParams = searchParams.toString();
|
||||
const newUrl = `${linkBackendApiHost}${apiPath}${
|
||||
queryParams ? `?${queryParams}` : ""
|
||||
}`;
|
||||
|
||||
if (token) requestHeaders.set(CLAIM_NAME, token);
|
||||
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
|
||||
|
||||
logMessages.push(` Link API Rewritten URL: ${newUrl}`);
|
||||
logMessages.push("\n--------------------------------\n");
|
||||
console.log(logMessages.join("\n"));
|
||||
|
||||
const response = NextResponse.rewrite(new URL(newUrl), {
|
||||
headers: requestHeaders,
|
||||
});
|
||||
return addSecurityHeaders(response);
|
||||
}
|
||||
|
||||
// 루트 경로 리다이렉트
|
||||
if (pathname === "/") {
|
||||
const response = NextResponse.redirect(new URL("/category", req.url));
|
||||
return addSecurityHeaders(response);
|
||||
}
|
||||
|
||||
logMessages.push("\n--------------------------------\n");
|
||||
console.log(logMessages.join("\n"));
|
||||
|
||||
const response = NextResponse.next();
|
||||
return addSecurityHeaders(response);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/server/:path*", "/attach-service/:path*", "/api/link/:path*", "/"],
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const subDomain =
|
||||
process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
experimental: {
|
||||
proxyTimeout: 600000,
|
||||
},
|
||||
// output: "standalone", // Windows symlink 권한 문제로 비활성화
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
logging: {
|
||||
fetches: {
|
||||
fullUrl: true,
|
||||
},
|
||||
},
|
||||
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*`,
|
||||
},
|
||||
],
|
||||
fallback: [],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "dc",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@common/assets": "workspace:*",
|
||||
"@common/components": "workspace:*",
|
||||
"@frontend-opensource/use-react-hooks": "^1.1.4",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@minoru/react-dnd-treeview": "^3.5.0",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@tanstack/react-query": "^5.67.1",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"axios": "^1.8.1",
|
||||
"chart.js": "^4.4.8",
|
||||
"dayjs": "^1.11.13",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.474.0",
|
||||
"next": "15.2.3",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.24.2",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@faker-js/faker": "^9.5.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^22.17.0",
|
||||
"@types/webpack": "^5.28.5",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.2.3",
|
||||
"postcss": "^8",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"steiger": "^0.5.5",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,18 @@
|
||||
import "./styles/base.css";
|
||||
import "./styles/components.css";
|
||||
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 };
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { ConfirmProvider } from "@frontend-opensource/use-react-hooks";
|
||||
import { AuthWrapper } from "@/entities/auth";
|
||||
import { StoreProvider } from "@/shared/providers";
|
||||
import { AlertDialog } from "@/shared/ui/alert-dialog";
|
||||
import { UploadProgressIndicator } from "@/shared/ui/upload-progress-indicator";
|
||||
|
||||
const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||
return (
|
||||
<StoreProvider>
|
||||
<ConfirmProvider>
|
||||
<AuthWrapper>
|
||||
{children}
|
||||
<AlertDialog />
|
||||
<UploadProgressIndicator />
|
||||
</AuthWrapper>
|
||||
</ConfirmProvider>
|
||||
</StoreProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export { AppProvider };
|
||||
@@ -0,0 +1 @@
|
||||
export { AppProvider } from "./app-provider";
|
||||
@@ -0,0 +1,11 @@
|
||||
@import "@common/assets/styles/base.css";
|
||||
@import "@common/assets/styles/diveicon.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family: "Noto Sans KR", sans-serif;
|
||||
font-size: 10px;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
.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 {
|
||||
border-color: #3f4042;
|
||||
background-color: rgba(255, 255, 255, 0.065);
|
||||
}
|
||||
.card-header {
|
||||
border-bottom-color: #3f4042;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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();
|
||||
@@ -0,0 +1,2 @@
|
||||
export { LoginApi } from "./api/login-service";
|
||||
export { AuthWrapper } from "./lib/auth-wrapper";
|
||||
@@ -0,0 +1,51 @@
|
||||
"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 CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
|
||||
|
||||
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
const router = useRouter();
|
||||
const { userInfo, setUserInfo } = useUserStore((state) => state);
|
||||
|
||||
async function authCheck() {
|
||||
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 };
|
||||
@@ -0,0 +1,82 @@
|
||||
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 {
|
||||
// 환경에 따른 도메인 설정
|
||||
const domain = this.getCookieDomain();
|
||||
|
||||
setCookie(key, value, {
|
||||
secure: window.location.protocol === "https:",
|
||||
sameSite: "lax",
|
||||
domain: domain,
|
||||
path: "/", // 모든 경로에서 접근 가능
|
||||
});
|
||||
}
|
||||
|
||||
private getCookieDomain(): string | undefined {
|
||||
const hostname = window.location.hostname;
|
||||
|
||||
// 개발 환경에서는 localhost 도메인 사용
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1") {
|
||||
return undefined; // localhost에서는 도메인 설정하지 않음
|
||||
}
|
||||
|
||||
// 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용
|
||||
if (hostname.includes("hasuon.or.kr")) {
|
||||
return ".hasuon.or.kr";
|
||||
}
|
||||
|
||||
// 기타 환경에서는 현재 호스트명 사용
|
||||
return hostname;
|
||||
}
|
||||
|
||||
public removeToken(key: string): void {
|
||||
const domain = this.getCookieDomain();
|
||||
removeCookie(key, { domain, path: "/" });
|
||||
}
|
||||
|
||||
public setupRefreshTimer(
|
||||
callback: () => Promise<void>,
|
||||
expirationTime: number,
|
||||
): void {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
}
|
||||
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
callback().catch((error) => {
|
||||
console.error("Silent refresh failed:", error);
|
||||
this.clearAuthToken();
|
||||
});
|
||||
}, expirationTime - 60000);
|
||||
}
|
||||
|
||||
public clearAuthToken(): void {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
|
||||
this.removeToken(CLAIM_NAME);
|
||||
this.removeToken(AUTH_USER_ID);
|
||||
}
|
||||
}
|
||||
|
||||
export const tokenManager = TokenManager.getInstance();
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface ILogin {
|
||||
userId: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginService {
|
||||
login(payload: ILogin): Promise<void>;
|
||||
silentRefresh(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { dcClient } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import {
|
||||
type CategoryService,
|
||||
type CategoryList,
|
||||
type CategoryParams,
|
||||
type Category,
|
||||
} from "../model/dc-interface";
|
||||
|
||||
export class CategoryApiService implements CategoryService {
|
||||
public async getCategoryList(params: CategoryParams): Promise<CategoryList> {
|
||||
const response = await dcClient.get<CategoryList>(
|
||||
`${ENDPOINT.DC_SERVICE.CATEGORY}/clsf`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async createCategory(data: { upClsfSystmSn: string; clsfSystmNm: string; rgtrId: string }): Promise<void> {
|
||||
await dcClient.post(`${ENDPOINT.DC_SERVICE.CATEGORY}/clsf`, data);
|
||||
}
|
||||
|
||||
// 분류 수정
|
||||
public async updateCategory(data: { clsfSystmSn: string; clsfSystmNm: string; mdfrId: string }): Promise<void> {
|
||||
await dcClient.put(`${ENDPOINT.DC_SERVICE.CATEGORY}/clsf`, data);
|
||||
}
|
||||
|
||||
// 분류 삭제
|
||||
public async deleteCategory(clsfSystmSn: string): Promise<void> {
|
||||
await dcClient.delete(`${ENDPOINT.DC_SERVICE.CATEGORY}/clsf`, { data: { clsfSystmSn } });
|
||||
}
|
||||
}
|
||||
|
||||
export const CategoryApi = new CategoryApiService();
|
||||
@@ -0,0 +1,25 @@
|
||||
// 카테고리 관리 서비스
|
||||
export { CategoryApi } from "./category-service";
|
||||
|
||||
// 내 자료 관리 서비스
|
||||
export { MyDataApi } from "./mydata-service";
|
||||
|
||||
// 파일 관리 서비스
|
||||
export { uploadFile, listFiles, getFileDetail, downloadFile } from "./file-service";
|
||||
|
||||
// 검색 서비스
|
||||
export { getGeneralSearchResult } from "./search-service";
|
||||
|
||||
// 파일 처리 서비스
|
||||
export { saveImagePath, savePdfPath, saveEmbeddingResult } from "./processing-service";
|
||||
|
||||
// Flask API 서비스
|
||||
export {
|
||||
convertToPdf,
|
||||
pdfToImages,
|
||||
embedDocuments,
|
||||
getImageList,
|
||||
getImagePreviewUrl,
|
||||
downloadPdf,
|
||||
askQuestion
|
||||
} from "./flask-service";
|
||||
@@ -0,0 +1,71 @@
|
||||
import { dcClient } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { FileItem, FileDetail } from "../model/dc-interface";
|
||||
|
||||
// 파일 업로드
|
||||
export async function uploadFile(params: {
|
||||
files: File[];
|
||||
clsfSystmSn: string;
|
||||
prodDept: string;
|
||||
prodSystem: string;
|
||||
userUnqId: string;
|
||||
userId: string;
|
||||
}): Promise<string[]> {
|
||||
const formData = new FormData();
|
||||
params.files.forEach(file => {
|
||||
formData.append("files", file); // key를 'files'로 변경
|
||||
formData.append("fileName", file.name);
|
||||
formData.append("fileExt", file.name.split('.').pop() || "");
|
||||
formData.append("fileSize", file.size.toString());
|
||||
});
|
||||
formData.append("clsfSystmSn", params.clsfSystmSn);
|
||||
formData.append("prodDept", params.prodDept);
|
||||
formData.append("prodSystem", params.prodSystem);
|
||||
formData.append("userUnqId", params.userUnqId);
|
||||
formData.append("userId", params.userId);
|
||||
|
||||
const res = await dcClient.post(`${ENDPOINT.DC_SERVICE.CATEGORY}/file`, formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" }
|
||||
});
|
||||
// file_sn 추출 (여러 파일일 경우 배열)
|
||||
if (Array.isArray(res.data)) {
|
||||
return res.data.map((item: any) => (item as any).file_sn || (item as any).fileSn || (item as any).fileId || "");
|
||||
} else if (res.data && ((res.data as any).file_sn || (res.data as any).fileSn || (res.data as any).fileId)) {
|
||||
return [(res.data as any).file_sn || (res.data as any).fileSn || (res.data as any).fileId];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// 파일 목록 조회
|
||||
export async function listFiles(sort: "latest" | "oldest" = "latest", clsfSystmSn?: string): Promise<FileItem[]> {
|
||||
const params: any = { sort };
|
||||
if (clsfSystmSn) params.clsfSystmSn = clsfSystmSn;
|
||||
const res = await dcClient.get<{ data: FileItem[] }>(
|
||||
`${ENDPOINT.DC_SERVICE.CATEGORY}/file`,
|
||||
{ params }
|
||||
);
|
||||
return res.data.data || res.data;
|
||||
}
|
||||
|
||||
// 파일 상세 조회
|
||||
export async function getFileDetail(fileSn: string | number): Promise<FileDetail> {
|
||||
const res = await dcClient.get<FileDetail>(`${ENDPOINT.DC_SERVICE.CATEGORY}/fileprcs`, { params: { fileSn } });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// 파일 다운로드 (window.open 방식)
|
||||
export async function downloadFile(filePath: string, fileName?: string) {
|
||||
const url = `${ENDPOINT.DC_SERVICE.CATEGORY}/file/download?filePath=${encodeURIComponent(filePath)}${fileName ? `&fileName=${encodeURIComponent(fileName)}` : ""}`;
|
||||
const response = await dcClient.get(url, {
|
||||
responseType: "blob",
|
||||
});
|
||||
const downloadName = fileName || "downloaded_file";
|
||||
const blob = response.data as Blob;
|
||||
const link = document.createElement("a");
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = downloadName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(link.href);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { FLASK_API_URL } from "@/shared/config/env";
|
||||
|
||||
// PDF 변환 API
|
||||
export async function convertToPdf(file: File, fileSn: string): Promise<any> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('file_sn', fileSn);
|
||||
|
||||
const response = await fetch(`/server/flask-service/convert-to-pdf`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`PDF 변환 실패: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// PDF를 이미지로 변환 API
|
||||
export async function pdfToImages(fileSn: string, pdfPath: string): Promise<any> {
|
||||
const formData = new FormData();
|
||||
formData.append('file_sn', fileSn);
|
||||
formData.append('pdf_path', pdfPath);
|
||||
|
||||
const response = await fetch(`/server/flask-service/pdf-to-images`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`이미지 변환 실패: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 문서 임베딩 API
|
||||
export async function embedDocuments(pdfPath: string, originalFilename: string): Promise<any> {
|
||||
const formData = new FormData();
|
||||
formData.append('pdf_path', pdfPath);
|
||||
formData.append('original_filename', originalFilename);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 600000); // 10분 타임아웃
|
||||
|
||||
const response = await fetch(`/server/flask-service/embed-documents`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`임베딩 실패: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 이미지 목록 조회 API
|
||||
export async function getImageList(fileSn: string): Promise<{ files: string[] }> {
|
||||
const response = await fetch(`/server/flask-service/image-list/${fileSn}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`이미지 목록 조회 실패: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 이미지 미리보기 URL 생성
|
||||
export function getImagePreviewUrl(fileSn: string, imageName: string): string {
|
||||
return `/server/flask-service/image-preview/${fileSn}/${imageName}`;
|
||||
}
|
||||
|
||||
// PDF 다운로드 API
|
||||
export async function downloadPdf(pdfPath: string, fileName: string): Promise<Blob> {
|
||||
const response = await fetch(`/server/flask-service/download-pdf?pdf_path=${encodeURIComponent(pdfPath)}&file_name=${encodeURIComponent(fileName)}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`PDF 다운로드 실패: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
// 질문 요청 API (챗봇용)
|
||||
export async function askQuestion(question: string, sessionId?: string | null): Promise<any> {
|
||||
const formData = new FormData();
|
||||
formData.append('question', question);
|
||||
|
||||
// session_id가 있으면 파라미터로 추가
|
||||
if (sessionId) {
|
||||
formData.append('session_id', sessionId);
|
||||
}
|
||||
|
||||
const response = await fetch(`/server/flask-service/ask-question`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`질문 요청 실패: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { dcClient } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { MyData } from "../model/dc-interface";
|
||||
|
||||
export const MyDataApi = {
|
||||
// 목록 조회
|
||||
async list(params?: Record<string, string>): Promise<MyData[]> {
|
||||
const res = await dcClient.get<MyData[]>(`${ENDPOINT.DC_SERVICE.CATEGORY}/mydata`, params ? { params } : undefined);
|
||||
return res.data;
|
||||
},
|
||||
// 등록
|
||||
async create(data: Omit<MyData, "fileSn">): Promise<void> {
|
||||
await dcClient.post("/api/v1/dc/mydata/datacode", data);
|
||||
},
|
||||
// 수정
|
||||
async update(data: Record<string, any>): Promise<void> {
|
||||
await dcClient.put(`${ENDPOINT.DC_SERVICE.CATEGORY}/mydata/datacode`, data);
|
||||
},
|
||||
// 삭제
|
||||
async remove(fileSn: string): Promise<void> {
|
||||
await dcClient.delete(`${ENDPOINT.DC_SERVICE.CATEGORY}/mydata/${fileSn}`);
|
||||
},
|
||||
// 단건 상세 조회 (fileSn, rgtrId)
|
||||
async detail(fileSn: string, rgtrId: string): Promise<MyData | undefined> {
|
||||
const res = await dcClient.get<{ list: MyData[] }>(`${ENDPOINT.DC_SERVICE.CATEGORY}/mydata`, { params: { fileSn, rgtrId } });
|
||||
return res.data.list && res.data.list.length > 0 ? res.data.list[0] : undefined;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { dcClient } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
// 이미지 경로 저장
|
||||
export async function saveImagePath(params: {
|
||||
fileSn: string;
|
||||
folderPath: string;
|
||||
totalPages: number;
|
||||
}): Promise<void> {
|
||||
await dcClient.post(`${ENDPOINT.DC_SERVICE.CATEGORY}/saveImgPath`, {
|
||||
fileSn: params.fileSn,
|
||||
folderPath: params.folderPath,
|
||||
totalPages: params.totalPages
|
||||
});
|
||||
}
|
||||
|
||||
// PDF 경로 저장
|
||||
export async function savePdfPath(params: {
|
||||
fileSn: string;
|
||||
pdfPath: string;
|
||||
}): Promise<void> {
|
||||
await dcClient.post(`${ENDPOINT.DC_SERVICE.CATEGORY}/savePdfPath`, {
|
||||
fileSn: params.fileSn,
|
||||
pdfPath: params.pdfPath
|
||||
});
|
||||
}
|
||||
|
||||
// 임베딩 결과 저장
|
||||
export async function saveEmbeddingResult(params: {
|
||||
fileSn: string;
|
||||
}): Promise<void> {
|
||||
await dcClient.post(`${ENDPOINT.DC_SERVICE.CATEGORY}/saveEmbeddingResult`, {
|
||||
fileSn: params.fileSn
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { dcClient } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
// 일반검색(자료검색) API
|
||||
export async function getGeneralSearchResult(query: string): Promise<any> {
|
||||
const res = await dcClient.get(`${ENDPOINT.DC_SERVICE.CATEGORY}/data`, { params: { query } });
|
||||
return res.data;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { CategoryApi } from "./api/category-service";
|
||||
export { MyDataApi } from "./api/mydata-service";
|
||||
export {
|
||||
type CategoryParams,
|
||||
type Category,
|
||||
type MyData,
|
||||
} from "./model/dc-interface";
|
||||
@@ -0,0 +1,69 @@
|
||||
export interface CategoryParams {
|
||||
clsfSystmNm?: string;
|
||||
useYn?: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
clsfSystmSn: string;
|
||||
upClsfSystmSn: string;
|
||||
clsfSystmNm: string;
|
||||
sortSeq: string;
|
||||
useYn: string;
|
||||
mdfrNm: string;
|
||||
mdfcnDt: string;
|
||||
regDt: string;
|
||||
rgtrNm: string;
|
||||
}
|
||||
|
||||
export type CategoryList = Category[];
|
||||
|
||||
export interface CategoryService {
|
||||
getCategoryList(params: CategoryParams): Promise<CategoryList>;
|
||||
}
|
||||
|
||||
export interface MyData {
|
||||
fileSn: string;
|
||||
shrnSeCd: string | null;
|
||||
shrnDeptCd: string | null;
|
||||
shrnUserId: string | null;
|
||||
fileNm: string;
|
||||
dataSttsCd: string | null;
|
||||
prdctnDeptCd: string | null;
|
||||
prdctnSysCd: string | null;
|
||||
regDt: string | null;
|
||||
itrstYn?: string; // 관심자료 여부 (Y/N)
|
||||
trnsfUserId?: string | null; // 자료이관사용자ID
|
||||
// 추가: 이관자료여부, 이관자료사용자ID
|
||||
trnsfDataYn?: string | null; // 이관자료여부
|
||||
dataTrnsfUserId?: string | null; // 이관자료사용자ID
|
||||
// 추가: 코드명 필드(옵셔널)
|
||||
shrnSeCdNm?: string;
|
||||
dataSttsCdNm?: string;
|
||||
// 추가: 사용자 관련 필드
|
||||
userId?: string;
|
||||
rgtr?: string;
|
||||
}
|
||||
|
||||
// 파일 목록 아이템
|
||||
export interface FileItem {
|
||||
fileSn: string | number;
|
||||
/** @deprecated fileTitle을 사용하세요 */
|
||||
fileName: string;
|
||||
no: number;
|
||||
fileTitle: string;
|
||||
rgtrNm: string;
|
||||
regDt?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
// 파일 상세 정보
|
||||
export interface FileDetail {
|
||||
fileSn: string | number;
|
||||
fileName: string;
|
||||
path?: string;
|
||||
categoryPath?: string;
|
||||
prodDept?: string;
|
||||
prodSystem?: string;
|
||||
qrUrl?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { MenuAuthorityApi } from "../api/menu-authority-service";
|
||||
@@ -0,0 +1 @@
|
||||
export { AuthorityButton } from "../ui/authority-button";
|
||||
@@ -0,0 +1,50 @@
|
||||
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();
|
||||
@@ -0,0 +1,9 @@
|
||||
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";
|
||||
@@ -0,0 +1,51 @@
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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")}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
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 };
|
||||
@@ -0,0 +1,101 @@
|
||||
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 };
|
||||
@@ -0,0 +1,225 @@
|
||||
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();
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./api/user-service";
|
||||
export * from "./model/user-interface";
|
||||
@@ -0,0 +1,362 @@
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { LoginPage } from "./ui/login-page";
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { images } from "@common/assets";
|
||||
import { LoginApi } from "@/entities/auth";
|
||||
import "./login.css";
|
||||
|
||||
function LoginPage() {
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(e.target as HTMLFormElement);
|
||||
const userId = formData.get("id") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
await LoginApi.login({ userId, password });
|
||||
window.location.href = "/category";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login fx">
|
||||
<div className="left_box fx">
|
||||
<div className="text">
|
||||
<h1>
|
||||
하수도정보시스템
|
||||
<br />
|
||||
<span>CMS 통합관리시스템</span>
|
||||
</h1>
|
||||
<p>하수도정보시스템 CMS 통합관리시스템에 오신 것을 환영합니다.</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}
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="bottom_box fx">
|
||||
<div className="checks fx">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="checkId"
|
||||
title="로그인ID 저장여부"
|
||||
id="checkId"
|
||||
/>
|
||||
<label htmlFor="checkId">아이디저장</label>
|
||||
</div>
|
||||
{/* <a href="javascript:fnUserSearch();" className=" pw_message fx"><i className="xi-external-link"></i>비밀번호 또는 아이디를 잃어버리셨나요?</a> */}
|
||||
</div>
|
||||
<button type="submit" className="login_btn fx">
|
||||
<span>LOGIN</span>
|
||||
</button>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export { LoginPage };
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* 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 */
|
||||
@@ -0,0 +1 @@
|
||||
export { CategoryPage } from "./ui/category-page";
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Dialog, DialogContent, DialogFooter, DialogClose } from "@common/components/ui";
|
||||
|
||||
interface CategoryDialogProps {
|
||||
open: boolean;
|
||||
message: string;
|
||||
loading?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CategoryDialog({ open, message, loading, onConfirm, onCancel, onOpenChange }: CategoryDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange ? onOpenChange : open => { if (!open) onCancel(); }}>
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<div className="py-10">
|
||||
<p className="text-center text-[1.4rem] text-foreground">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter className="!justify-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-primary text-white px-10 py-3 rounded text-xl hover:bg-primary/80"
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-red-600 text-white hover:bg-red-700 px-10 py-3 rounded text-xl"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,823 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useState, useMemo, useRef } from "react";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { CategoryApi, type CategoryParams } from "@/entities/datalibrary";
|
||||
import { Button, Input } from "@common/components";
|
||||
import { PageLayout } from "@/shared/ui/page-layout";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@common/components";
|
||||
import {
|
||||
Tree,
|
||||
getBackendOptions,
|
||||
MultiBackend,
|
||||
} from "@minoru/react-dnd-treeview";
|
||||
import type { NodeModel } from "@minoru/react-dnd-treeview";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { ChevronRight, ChevronDown, RotateCcw } from "lucide-react";
|
||||
import { useAlertStore } from "@/shared/lib/use-alert-store";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
|
||||
import { CategoryDialog } from "./category-dialog";
|
||||
import { useUserStore } from "@/shared/lib/use-user-store";
|
||||
import axios from "axios";
|
||||
|
||||
type TreeNode = {
|
||||
id: string;
|
||||
parent: string;
|
||||
droppable: boolean;
|
||||
text: string;
|
||||
};
|
||||
|
||||
function CategoryPage() {
|
||||
const router = useRouter();
|
||||
const [treeData, setTreeData] = useState<NodeModel<TreeNode>[]>([]);
|
||||
const [selectedParentNode, setSelectedParentNode] = useState<NodeModel<TreeNode> | null>(null);
|
||||
const [selectedSubNode, setSelectedSubNode] = useState<NodeModel<TreeNode> | null>(null);
|
||||
const [categoryQuery, setSearchText] = useState<CategoryParams>({
|
||||
clsfSystmNm: "",
|
||||
});
|
||||
const [newCategoryName, setNewCategoryName] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { setMessage: showAlert } = useAlertStore((state) => state);
|
||||
const { confirm } = useConfirm();
|
||||
const [dialog, setDialog] = useState<null | { type: "add" | "edit" | "delete" }>(null);
|
||||
const userUnqId = useUserStore((state) => state.userInfo?.userUnqId || "");
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const [expandedIds, setExpandedIds] = useState<(string | number)[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const searchResultRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const categoryNameInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showCategoryNameError, setShowCategoryNameError] = useState(false);
|
||||
const [isAddingMode, setIsAddingMode] = useState(false);
|
||||
const [addingParentNode, setAddingParentNode] = useState<NodeModel<TreeNode> | null>(null);
|
||||
|
||||
const { data: categoryList, refetch } = useQuery({
|
||||
queryKey: [ENDPOINT.DC_SERVICE.CATEGORY],
|
||||
queryFn: () => CategoryApi.getCategoryList(categoryQuery),
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
// 페이지 진입 시 데이터 조회
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
|
||||
|
||||
const handleDrop = (newTreeData: NodeModel<TreeNode>[]) => {
|
||||
setTreeData(newTreeData);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const treeElement = document.querySelector('.tree-container');
|
||||
if (treeElement) {
|
||||
expandedIds.forEach(id => {
|
||||
const nodeElement = treeElement.querySelector(`[data-id="${id}"]`);
|
||||
if (nodeElement) {
|
||||
const toggleButton = nodeElement.querySelector('.toggle-button');
|
||||
if (toggleButton && !nodeElement.classList.contains('expanded')) {
|
||||
(toggleButton as HTMLElement).click();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [expandedIds]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!searchTerm.trim()) {
|
||||
showAlert("검색어를 입력해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
const searchResults = treeData.filter((node) =>
|
||||
(node.text ?? "").toLowerCase().includes((searchTerm ?? "").toLowerCase())
|
||||
);
|
||||
|
||||
if (searchResults.length === 0) {
|
||||
showAlert("검색 결과가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const newExpandedIds = new Set<number | string>();
|
||||
searchResults.forEach((node) => {
|
||||
let parentId = node.parent;
|
||||
while (parentId !== 0) {
|
||||
newExpandedIds.add(parentId);
|
||||
const parentNode = treeData.find((n) => n.id === parentId);
|
||||
if (parentNode) {
|
||||
parentId = parentNode.parent;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setExpandedIds(Array.from(newExpandedIds));
|
||||
|
||||
setTimeout(() => {
|
||||
const firstResultRef = searchResultRefs.current[0];
|
||||
if (firstResultRef) {
|
||||
firstResultRef.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleSelect = (node: NodeModel<TreeNode>) => {
|
||||
if (selectedSubNode?.id === node.id) {
|
||||
setSelectedSubNode(null);
|
||||
setSelectedParentNode(null);
|
||||
setNewCategoryName("");
|
||||
} else {
|
||||
setSelectedSubNode(node);
|
||||
const parentNode = treeData.find(n => n.id === node.parent) || null;
|
||||
setSelectedParentNode(parentNode);
|
||||
setNewCategoryName(node.text || "");
|
||||
|
||||
// 선택된 노드와 부모까지 모두 펼치기
|
||||
let parentId = node.parent;
|
||||
const newExpandedIds = new Set(expandedIds);
|
||||
while (parentId && parentId !== 0 && parentId !== "0") {
|
||||
newExpandedIds.add(parentId);
|
||||
const parentNode = treeData.find(n => n.id === parentId);
|
||||
if (parentNode) {
|
||||
parentId = parentNode.parent;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
setExpandedIds(Array.from(newExpandedIds));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCategory = () => {
|
||||
if (!selectedSubNode) {
|
||||
showAlert("분류 구조를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!newCategoryName.trim()) {
|
||||
setShowCategoryNameError(true);
|
||||
showAlert("분류 이름을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
setShowCategoryNameError(false);
|
||||
setIsAddingMode(true);
|
||||
setAddingParentNode(selectedSubNode);
|
||||
setNewCategoryName("");
|
||||
};
|
||||
|
||||
const handleEditCategory = () => {
|
||||
if (!selectedSubNode) {
|
||||
showAlert("수정할 분류 구조를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!newCategoryName.trim()) {
|
||||
setShowCategoryNameError(true);
|
||||
showAlert("분류 이름을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
setShowCategoryNameError(false);
|
||||
setDialog({ type: "edit" });
|
||||
};
|
||||
|
||||
const handleDeleteCategory = () => {
|
||||
setDialog({ type: "delete" });
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (dialog && dialog.type === "add") {
|
||||
if (!newCategoryName.trim()) {
|
||||
setShowCategoryNameError(true);
|
||||
showAlert("분류 이름을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!addingParentNode) {
|
||||
showAlert("상위 분류를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm("분류를 추가하시겠습니까?");
|
||||
if (!ok) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await CategoryApi.createCategory({
|
||||
upClsfSystmSn: String(addingParentNode.id),
|
||||
clsfSystmNm: newCategoryName,
|
||||
rgtrId: userUnqId,
|
||||
});
|
||||
showAlert("분류가 추가되었습니다.");
|
||||
setNewCategoryName("");
|
||||
setSelectedParentNode(null);
|
||||
setSelectedSubNode(null);
|
||||
setIsAddingMode(false);
|
||||
setAddingParentNode(null);
|
||||
setDialog(null);
|
||||
refetch();
|
||||
} catch (e) {
|
||||
showAlert("작업에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else if (dialog && dialog.type === "edit") {
|
||||
if (!selectedSubNode) {
|
||||
showAlert("수정할 분류 구조를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await CategoryApi.updateCategory({
|
||||
clsfSystmSn: String(selectedSubNode.id),
|
||||
clsfSystmNm: newCategoryName,
|
||||
mdfrId: userUnqId,
|
||||
});
|
||||
showAlert("분류 이름이 수정되었습니다.");
|
||||
setNewCategoryName("");
|
||||
setSelectedParentNode(null);
|
||||
setSelectedSubNode(null);
|
||||
setDialog(null);
|
||||
refetch();
|
||||
} catch (e) {
|
||||
showAlert("작업에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else if (dialog && dialog.type === "delete") {
|
||||
if (!selectedSubNode) {
|
||||
showAlert("삭제할 분류 구조를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await CategoryApi.deleteCategory(String(selectedSubNode.id));
|
||||
showAlert("분류가 삭제되었습니다.");
|
||||
setNewCategoryName("");
|
||||
setSelectedParentNode(null);
|
||||
setSelectedSubNode(null);
|
||||
setDialog(null);
|
||||
refetch();
|
||||
} catch (e) {
|
||||
showAlert("분류 삭제에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDialogConfirm = async () => {
|
||||
if (!dialog) return;
|
||||
if (dialog.type === "add") {
|
||||
if (!newCategoryName.trim()) {
|
||||
setShowCategoryNameError(true);
|
||||
showAlert("분류 이름을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!addingParentNode) {
|
||||
showAlert("상위 분류를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await CategoryApi.createCategory({
|
||||
upClsfSystmSn: String(addingParentNode.id),
|
||||
clsfSystmNm: newCategoryName,
|
||||
rgtrId: userUnqId,
|
||||
});
|
||||
showAlert("분류가 추가되었습니다.");
|
||||
setNewCategoryName("");
|
||||
setSelectedParentNode(null);
|
||||
setSelectedSubNode(null);
|
||||
setIsAddingMode(false);
|
||||
setAddingParentNode(null);
|
||||
setDialog(null);
|
||||
refetch();
|
||||
} catch (e) {
|
||||
showAlert("작업에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else if (dialog.type === "edit") {
|
||||
if (!selectedSubNode) {
|
||||
showAlert("수정할 분류 구조를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await CategoryApi.updateCategory({
|
||||
clsfSystmSn: String(selectedSubNode.id),
|
||||
clsfSystmNm: newCategoryName,
|
||||
mdfrId: userUnqId,
|
||||
});
|
||||
showAlert("분류 이름이 수정되었습니다.");
|
||||
setNewCategoryName("");
|
||||
setSelectedParentNode(null);
|
||||
setSelectedSubNode(null);
|
||||
setDialog(null);
|
||||
refetch();
|
||||
} catch (e) {
|
||||
showAlert("작업에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else if (dialog.type === "delete") {
|
||||
if (!selectedSubNode) {
|
||||
showAlert("삭제할 분류 구조를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await CategoryApi.deleteCategory(String(selectedSubNode.id));
|
||||
showAlert("분류가 삭제되었습니다.");
|
||||
setNewCategoryName("");
|
||||
setSelectedParentNode(null);
|
||||
setSelectedSubNode(null);
|
||||
setDialog(null);
|
||||
refetch();
|
||||
} catch (e) {
|
||||
showAlert("분류 삭제에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDialogCancel = () => {
|
||||
setDialog(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (Array.isArray(categoryList)) {
|
||||
// 백엔드에서 트리 구조로 반환하므로 flat 구조로 변환
|
||||
const flattenTree = (nodes: any[], parentId = "0"): any[] => {
|
||||
const result: any[] = [];
|
||||
|
||||
nodes.forEach((node) => {
|
||||
// 현재 노드를 flat 구조로 추가
|
||||
result.push({
|
||||
id: String(node.clsfSystmSn),
|
||||
parent: String(parentId),
|
||||
droppable: node.droppAble,
|
||||
text: node.clsfSystmNm,
|
||||
});
|
||||
|
||||
// 자식 노드들이 있으면 재귀적으로 처리
|
||||
if (node.children && node.children.length > 0) {
|
||||
result.push(...flattenTree(node.children, node.clsfSystmSn));
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const mappedData = flattenTree(categoryList);
|
||||
|
||||
// 트리 데이터를 영문순서, 한글순서로 정렬 (각 레벨별로)
|
||||
const sortTreeData = (nodes: typeof mappedData): typeof mappedData => {
|
||||
// 부모별로 그룹화
|
||||
const groupedByParent = new Map<string, typeof mappedData>();
|
||||
|
||||
nodes.forEach(node => {
|
||||
const parent = node.parent;
|
||||
if (!groupedByParent.has(parent)) {
|
||||
groupedByParent.set(parent, []);
|
||||
}
|
||||
groupedByParent.get(parent)!.push(node);
|
||||
});
|
||||
|
||||
// 각 그룹 내에서 정렬 (최상위 부모부터)
|
||||
const sortedGroups = Array.from(groupedByParent.entries()).sort(([parentA], [parentB]) => {
|
||||
// 최상위 부모("0")를 먼저 정렬
|
||||
if (parentA === "0" && parentB !== "0") return -1;
|
||||
if (parentA !== "0" && parentB === "0") return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
sortedGroups.forEach(([parent, children]) => {
|
||||
children.sort((a, b) => {
|
||||
const textA = a.text || "";
|
||||
const textB = b.text || "";
|
||||
|
||||
// 영문이 먼저 오도록 정렬
|
||||
const isEnglishA = /^[a-zA-Z]/.test(textA);
|
||||
const isEnglishB = /^[a-zA-Z]/.test(textB);
|
||||
|
||||
if (isEnglishA && !isEnglishB) return -1;
|
||||
if (!isEnglishA && isEnglishB) return 1;
|
||||
|
||||
// 같은 언어 내에서는 알파벳/가나다순 정렬
|
||||
return textA.localeCompare(textB, ['ko', 'en']);
|
||||
});
|
||||
});
|
||||
|
||||
// 정렬된 결과를 다시 합치기 (순서 보장)
|
||||
const sortedData: typeof mappedData = [];
|
||||
sortedGroups.forEach(([parent, children]) => {
|
||||
sortedData.push(...children);
|
||||
});
|
||||
|
||||
return sortedData;
|
||||
};
|
||||
|
||||
// 각 레벨별로 정렬
|
||||
const sortedData = sortTreeData(mappedData);
|
||||
setTreeData(sortedData);
|
||||
} else {
|
||||
//console.warn("categoryList is not array:", categoryList);
|
||||
}
|
||||
}, [categoryList]);
|
||||
|
||||
const filteredTreeData = useMemo(() => {
|
||||
if (!searchTerm) return treeData;
|
||||
|
||||
const filterTree = (nodes: NodeModel<TreeNode>[]) => {
|
||||
return nodes.reduce((acc: NodeModel<TreeNode>[], node) => {
|
||||
const nodeMatches = (node.text ?? "").toLowerCase().includes((searchTerm ?? "").toLowerCase());
|
||||
const filteredChildren = node.droppable ? filterTree(treeData.filter(n => n.parent === node.id)) : [];
|
||||
|
||||
if (nodeMatches || filteredChildren.length > 0) {
|
||||
acc.push(node);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
return filterTree(treeData);
|
||||
}, [treeData, searchTerm]);
|
||||
|
||||
const handleReset = () => {
|
||||
setSearchTerm("");
|
||||
setExpandedIds([]);
|
||||
searchResultRefs.current = [];
|
||||
setSelectedSubNode(null);
|
||||
setSelectedParentNode(null);
|
||||
setNewCategoryName("");
|
||||
setIsAddingMode(false);
|
||||
setAddingParentNode(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
}, [userInfo, userUnqId]);
|
||||
|
||||
return (
|
||||
<PageLayout pageTitle="자료관 분류 관리">
|
||||
<div className="card !mb-0 flex flex-col !min-h-[calc(100vh-16.8rem)]">
|
||||
<div className="card card-border">
|
||||
<form onSubmit={handleSearch} className="flex items-center gap-2 p-2 mb-2">
|
||||
<label htmlFor="category-search" className="mr-2 text-label">
|
||||
분류명
|
||||
</label>
|
||||
<Input
|
||||
id="category-search"
|
||||
className="h-[3.6rem] w-[50rem] rounded-sm !text-[1.3rem]"
|
||||
placeholder="검색어 입력"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<Button className="h-[3.6rem] rounded-sm text-[1.3rem]" type="submit">
|
||||
검색
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-[3.6rem] !bg-gray-600 hover:!bg-gray-600 flex items-center gap-2"
|
||||
onClick={handleReset}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
초기화
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="flex flex-1 gap-2 min-h-0">
|
||||
<div className="card card-border w-[60%] flex flex-col">
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-medium text-label">자료관 분류 트리</h3>
|
||||
</div>
|
||||
<div
|
||||
className="p-4 flex-1 min-h-0 max-h-[60vh] overflow-y-auto tree-container"
|
||||
onDragOver={(e) => {
|
||||
// 파일 드래그는 방지하되, 트리 내부 드래그는 허용
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
// 파일 드롭은 방지하되, 트리 내부 드롭은 허용
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onDragEnter={(e) => {
|
||||
// 파일 드래그는 방지하되, 트리 내부 드래그는 허용
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
// 파일 드래그는 방지하되, 트리 내부 드래그는 허용
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DndProvider backend={MultiBackend} options={getBackendOptions()}>
|
||||
<Tree
|
||||
tree={treeData}
|
||||
rootId="0"
|
||||
onDrop={handleDrop}
|
||||
initialOpen={expandedIds.map(String)}
|
||||
sort={false}
|
||||
insertDroppableFirst={false}
|
||||
canDrop={() => false}
|
||||
render={(node, { depth, isOpen, onToggle }) => {
|
||||
const isExpanded = expandedIds.map(String).includes(String(node.id));
|
||||
const isSearchResult = searchTerm && (node.text ?? "").toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={el => {
|
||||
if (isSearchResult) {
|
||||
searchResultRefs.current = [...searchResultRefs.current, el];
|
||||
}
|
||||
}}
|
||||
data-id={node.id}
|
||||
onDragOver={(e) => {
|
||||
// 파일 드래그는 방지하되, 트리 내부 드래그는 허용
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
// 파일 드롭은 방지하되, 트리 내부 드롭은 허용
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onDragEnter={(e) => {
|
||||
// 파일 드래그는 방지하되, 트리 내부 드래그는 허용
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
// 파일 드래그는 방지하되, 트리 내부 드래그는 허용
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
handleSelect(node);
|
||||
if (node.droppable) {
|
||||
onToggle();
|
||||
setExpandedIds(prev => {
|
||||
const idStr = String(node.id);
|
||||
const prevStr = prev.map(String);
|
||||
const isCurrentlyExpanded = prevStr.includes(idStr);
|
||||
if (isCurrentlyExpanded) {
|
||||
// 닫는 동작: 하위 노드에 선택된 노드가 있으면 선택 해제
|
||||
// 하위 노드 id 목록 구하기
|
||||
const getDescendantIds = (parentId: string | number): (string | number)[] => {
|
||||
const children = treeData.filter(n => n.parent === parentId);
|
||||
let all = children.map(n => n.id);
|
||||
children.forEach(child => {
|
||||
all = all.concat(getDescendantIds(child.id));
|
||||
});
|
||||
return all;
|
||||
};
|
||||
const descendants = getDescendantIds(node.id);
|
||||
if (
|
||||
selectedSubNode &&
|
||||
(descendants.includes(selectedSubNode.id) || selectedSubNode.id === node.id)
|
||||
) {
|
||||
setSelectedSubNode(null);
|
||||
setSelectedParentNode(null);
|
||||
setNewCategoryName("");
|
||||
}
|
||||
return prevStr.filter(id => id !== idStr);
|
||||
} else {
|
||||
// 여는 동작: 자동 선택
|
||||
setSelectedSubNode(node);
|
||||
setSelectedParentNode(treeData.find(n => n.id === node.parent) || null);
|
||||
setNewCategoryName(node.text || "");
|
||||
return [...prevStr, idStr];
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
style={{ paddingLeft: `${depth * 16}px` }}
|
||||
className={`cursor-pointer py-3 border-b border-gray-200 flex items-center transition-colors
|
||||
${String(selectedSubNode?.id) === String(node.id)
|
||||
? "bg-blue-100"
|
||||
: isSearchResult
|
||||
? "bg-sky-100"
|
||||
: "hover:bg-gray-100"}
|
||||
${isExpanded ? 'expanded' : ''}
|
||||
`}
|
||||
>
|
||||
{node.droppable && (
|
||||
<span
|
||||
onDragOver={(e) => {
|
||||
// 파일 드래그는 방지하되, 트리 내부 드래그는 허용
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
// 파일 드롭은 방지하되, 트리 내부 드롭은 허용
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onDragEnter={(e) => {
|
||||
// 파일 드래그는 방지하되, 트리 내부 드래그는 허용
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
// 파일 드래그는 방지하되, 트리 내부 드래그는 허용
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
setExpandedIds(prev => {
|
||||
const idStr = String(node.id);
|
||||
const prevStr = prev.map(String);
|
||||
const isCurrentlyExpanded = prevStr.includes(idStr);
|
||||
if (isCurrentlyExpanded) {
|
||||
return prevStr.filter(id => id !== idStr);
|
||||
} else {
|
||||
setSelectedSubNode(node);
|
||||
setSelectedParentNode(treeData.find(n => n.id === node.parent) || null);
|
||||
setNewCategoryName(node.text || "");
|
||||
return [...prevStr, idStr];
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="mr-2 flex items-center justify-center cursor-pointer toggle-button"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{node.text}</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</DndProvider>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card card-border flex-1 flex flex-col w-[60%] overflow-y-auto">
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-medium text-label">분류 추가</h3>
|
||||
</div>
|
||||
<div className="p-4 flex-1 flex flex-col">
|
||||
<div>
|
||||
<div className="mb-[10px]">
|
||||
<label className="block text-md font-medium text-label mb-1">
|
||||
상위 항목
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded border px-3 py-2 h-[3.2rem] !text-[1.3rem] bg-gray-200 cursor-not-allowed"
|
||||
readOnly
|
||||
placeholder="상위 분류를 선택해주세요"
|
||||
value={isAddingMode ? (addingParentNode?.text || "") : (selectedParentNode?.text || "")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-[10px]">
|
||||
<label className="block text-md font-medium text-label mb-1 flex items-center">
|
||||
<span className="text-red-500">*</span> 분류 이름
|
||||
</label>
|
||||
<input
|
||||
ref={categoryNameInputRef}
|
||||
type="text"
|
||||
className={`w-full rounded border px-3 py-2 h-[3.2rem] !text-[1.3rem] ${
|
||||
showCategoryNameError ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
|
||||
}`}
|
||||
placeholder={isAddingMode ? "새로 추가할 분류 이름을 입력해주세요" : "분류 이름을 입력해주세요"}
|
||||
value={newCategoryName}
|
||||
onChange={(e) => {
|
||||
setNewCategoryName(e.target.value);
|
||||
if (showCategoryNameError && e.target.value.trim()) {
|
||||
setShowCategoryNameError(false);
|
||||
}
|
||||
}}
|
||||
readOnly={false}
|
||||
/>
|
||||
{showCategoryNameError && (
|
||||
<p className="text-red-600 text-sm mt-2"></p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col justify-end">
|
||||
<div className="flex justify-end mt-10 gap-2">
|
||||
{isAddingMode ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-primary text-white px-8 py-2 rounded-sm hover:bg-primary/80 text-md h-[3.2rem]"
|
||||
onClick={() => setDialog({ type: "add" })}
|
||||
disabled={loading}
|
||||
>
|
||||
등록
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-gray-500 text-white px-8 py-2 rounded-sm hover:bg-gray-600 text-md h-[3.2rem]"
|
||||
onClick={() => {
|
||||
setIsAddingMode(false);
|
||||
setAddingParentNode(null);
|
||||
setNewCategoryName("");
|
||||
setSelectedSubNode(null);
|
||||
setSelectedParentNode(null);
|
||||
setExpandedIds([]);
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-primary text-white px-8 py-2 rounded-sm hover:bg-primary/80 text-md h-[3.2rem]"
|
||||
onClick={handleAddCategory}
|
||||
disabled={loading}
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-gray-600 text-white px-8 py-2 rounded-sm hover:bg-gray-700 text-md h-[3.2rem]"
|
||||
onClick={handleEditCategory}
|
||||
disabled={loading}
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-red-600 text-white px-8 py-2 rounded-sm hover:bg-red-700 text-md h-[3.2rem] cursor-pointer"
|
||||
onClick={handleDeleteCategory}
|
||||
disabled={!selectedSubNode || loading}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CategoryDialog
|
||||
open={!!dialog}
|
||||
message={
|
||||
dialog?.type === "add"
|
||||
? "분류를 추가하시겠습니까?"
|
||||
: dialog?.type === "edit"
|
||||
? "분류를 수정하시겠습니까?"
|
||||
: dialog?.type === "delete"
|
||||
? "분류를 삭제하시겠습니까?"
|
||||
: ""
|
||||
}
|
||||
loading={loading}
|
||||
onConfirm={handleDialogConfirm}
|
||||
onCancel={handleDialogCancel}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export { CategoryPage };
|
||||
@@ -0,0 +1 @@
|
||||
export { ChatBotPage } from "./ui/chatbot-page";
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Button, Input } from "@common/components";
|
||||
import { PageLayout } from "@/shared/ui/page-layout";
|
||||
import { UserCircle, Bot } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { askQuestion, downloadPdf } from "@/entities/datalibrary/api/flask-service";
|
||||
|
||||
interface Message {
|
||||
role: "user" | "bot";
|
||||
text: string;
|
||||
sources?: Array<{
|
||||
pdf_path: string;
|
||||
original_filename: string;
|
||||
page: number;
|
||||
type: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function ChatBotPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const initialQ = searchParams?.get("q") || "";
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const hasProcessedInitialQRef = useRef(false);
|
||||
|
||||
// 초기 질문이 있으면 자동으로 RAG 요청 (한 번만)
|
||||
useEffect(() => {
|
||||
if (initialQ && !hasProcessedInitialQRef.current) {
|
||||
hasProcessedInitialQRef.current = true;
|
||||
setMessages([{ role: "user", text: initialQ }]);
|
||||
handleRagRequest(initialQ);
|
||||
}
|
||||
}, [initialQ]);
|
||||
|
||||
// RAG 요청 함수
|
||||
const handleRagRequest = async (question: string) => {
|
||||
setLoading(true);
|
||||
|
||||
// "봇이 답변을 생성중입니다..." 메시지를 먼저 추가
|
||||
setMessages(prev => [...prev, { role: "bot", text: "봇이 답변을 생성중입니다...", sources: [] }]);
|
||||
|
||||
try {
|
||||
const data = await askQuestion(question, sessionId);
|
||||
const answer = data.answer || data.response || '답변을 찾을 수 없습니다.';
|
||||
const sources = data.sources || [];
|
||||
|
||||
// session_id가 응답에 포함되어 있으면 저장
|
||||
if (data.session_id) {
|
||||
setSessionId(data.session_id);
|
||||
}
|
||||
|
||||
// 답변을 한 글자씩 실시간으로 표시
|
||||
for (let i = 0; i <= answer.length; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 30)); // 30ms 딜레이
|
||||
setMessages(prev => {
|
||||
const newMessages = [...prev];
|
||||
// 마지막 메시지(봇 메시지)를 실제 답변으로 교체
|
||||
newMessages[newMessages.length - 1] = {
|
||||
role: "bot",
|
||||
text: answer.substring(0, i),
|
||||
sources: sources // 모든 출처 표시
|
||||
};
|
||||
return newMessages;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('RAG 요청 오류:', error);
|
||||
setMessages(prev => {
|
||||
const newMessages = [...prev];
|
||||
newMessages[newMessages.length - 1] = { role: "bot", text: '질문에 대한 답변을 생성하는 중 오류가 발생했습니다.' };
|
||||
return newMessages;
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 출처 파일 다운로드 함수
|
||||
const handleSourceDownload = async (pdfPath: string, fileName: string) => {
|
||||
try {
|
||||
const blob = await downloadPdf(pdfPath, fileName);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('파일 다운로드 오류:', error);
|
||||
alert('파일 다운로드 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || loading) return;
|
||||
|
||||
const question = input.trim();
|
||||
setMessages(prev => [...prev, { role: "user", text: question }]);
|
||||
setInput("");
|
||||
|
||||
// RAG 요청
|
||||
handleRagRequest(question);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout pageTitle="하수도 GPT">
|
||||
<div className="card card-border mt-6 flex flex-col h-[calc(100vh-16.8rem)]">
|
||||
<div className="card card-header flex-shrink-0">
|
||||
<h3 className="text-2xl font-medium text-label">하수도 GPT</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3 min-h-0">
|
||||
{messages.map((msg, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-end gap-2 max-w-[75%] ${
|
||||
msg.role === "user"
|
||||
? "flex-row-reverse self-end ml-auto"
|
||||
: "self-start mr-auto"
|
||||
}`}
|
||||
>
|
||||
<span className="shrink-0 flex items-center">
|
||||
{msg.role === "user" ? (
|
||||
<UserCircle className="w-8 h-8 text-blue-400" />
|
||||
) : (
|
||||
<Bot className="w-8 h-8 text-gray-400" />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`p-3 rounded-md text-2xl whitespace-pre-wrap ${
|
||||
msg.role === "user"
|
||||
? "bg-blue-100"
|
||||
: "bg-gray-100"
|
||||
}`}
|
||||
style={{ maxWidth: "calc(100% - 2.5rem)" }}
|
||||
>
|
||||
{msg.text}
|
||||
{/* 출처 표시: bot 답변에만 */}
|
||||
{msg.role === "bot" && msg.sources && msg.sources.length > 0 && (
|
||||
<div className="mt-2 text-lg text-gray-500 space-y-1">
|
||||
<div className="font-semibold flex items-center gap-1">
|
||||
<span role="img" aria-label="출처">🔗</span> 출처
|
||||
</div>
|
||||
<ul className="list-disc list-inside ml-2">
|
||||
{msg.sources.map((src, i) => (
|
||||
<li key={i}>
|
||||
<button
|
||||
onClick={() => handleSourceDownload(src.pdf_path, src.original_filename)}
|
||||
className="underline hover:text-blue-600 text-left"
|
||||
>
|
||||
{src.original_filename} (페이지 {src.page})
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 로딩 상태 표시 - 실시간 답변 생성 중에는 표시하지 않음 */}
|
||||
{loading && messages.length === 0 && (
|
||||
<div className="flex items-end gap-2 max-w-[75%] self-start mr-auto">
|
||||
<span className="shrink-0 flex items-center">
|
||||
<Bot className="w-8 h-8 text-gray-400" />
|
||||
</span>
|
||||
<span className="p-3 rounded-md text-2xl bg-gray-100" style={{ maxWidth: "calc(100% - 2.5rem)" }}>
|
||||
답변을 생성하고 있습니다...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex items-center gap-2 p-4 border-t flex-shrink-0"
|
||||
>
|
||||
<Input
|
||||
className="flex-1 h-[3.2rem] !text-[1.5rem]"
|
||||
placeholder="질문을 입력하세요..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button type="submit" className="h-[3.2rem]" disabled={loading}>
|
||||
{loading ? "처리중..." : "전송"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export { ChatBotPage };
|
||||
@@ -0,0 +1 @@
|
||||
export { DataInsertPage } from "./ui/datainsert-page";
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Dialog, DialogContent, DialogFooter, DialogClose } from "@common/components/ui";
|
||||
|
||||
interface DataInsertDialogProps {
|
||||
open: boolean;
|
||||
message: string;
|
||||
loading?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function DataInsertDialog({ open, message, loading, onConfirm, onCancel, onOpenChange }: DataInsertDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange ? onOpenChange : open => { if (!open) onCancel(); }}>
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<div className="py-10">
|
||||
<p className="text-center text-[1.6rem] text-foreground">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter className="!justify-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-primary text-white px-10 py-3 rounded text-xl hover:bg-primary/80"
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-red-600 text-white hover:bg-red-700 px-10 py-3 rounded text-xl"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
export { GeneralSearchPage } from "./ui/generalsearch-page";
|
||||
@@ -0,0 +1,701 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getGeneralSearchResult } from "@/entities/datalibrary/api/search-service";
|
||||
import { getFileDetail, downloadFile, listFiles } from "@/entities/datalibrary/api/file-service";
|
||||
import { getImageList, getImagePreviewUrl } from "@/entities/datalibrary/api/flask-service";
|
||||
import { PageLayout } from "@/shared/ui/page-layout";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Button,
|
||||
Input,
|
||||
} from "@common/components";
|
||||
import { Folder, FileText, Info, Monitor, UserCircle, Bot, ChevronDown, ChevronRight, ChevronLeft, HardDrive, FileDigit, Clock, X } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import QRCode from "react-qr-code";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
function GeneralSearchPage() {
|
||||
const router = useRouter();
|
||||
const [searchType, setSearchType] = useState<string>("default");
|
||||
const [activeTab, setActiveTab] = useState<"all"|"category"|"file">("all");
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [results, setResults] = useState<any|null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searched, setSearched] = useState(false);
|
||||
// 파일 상세
|
||||
const [selectedFileSn, setSelectedFileSn] = useState<string|null>(null);
|
||||
const [fileDetail, setFileDetail] = useState<any|null>(null);
|
||||
const [fileDetailLoading, setFileDetailLoading] = useState(false);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
// 분류별 파일 리스트
|
||||
const [categoryFiles, setCategoryFiles] = useState<Record<string, any[]>>({});
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||
const [categoryFilesLoading, setCategoryFilesLoading] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (searchType === "natural") {
|
||||
// 자연어 검색 시 chatbot-page로 이동
|
||||
router.push(`/chatbot?q=${encodeURIComponent(searchText)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 일반 검색 - 상태 초기화
|
||||
setLoading(true);
|
||||
setSearched(true);
|
||||
setSelectedFileSn(null); // 선택된 파일 초기화
|
||||
setExpandedCategories(new Set()); // 펼쳐진 분류 초기화
|
||||
setCategoryFiles({}); // 분류별 파일 목록 초기화
|
||||
setCategoryFilesLoading(new Set()); // 로딩 상태 초기화
|
||||
setFileDetail(null); // 파일 상세정보 초기화
|
||||
setShowDetail(false); // 상세정보 패널 닫기
|
||||
|
||||
try {
|
||||
// dc-service의 getGeneralSearchResult를 통해 요청
|
||||
// 빈 검색어로도 검색 가능하도록 수정
|
||||
const data = await getGeneralSearchResult(searchText || "");
|
||||
setResults(data);
|
||||
} catch (e) {
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 파일 상세정보 요청
|
||||
const handleShowDetail = async () => {
|
||||
if (!selectedFileSn) return;
|
||||
setFileDetailLoading(true);
|
||||
setShowDetail(true);
|
||||
try {
|
||||
const detail = await getFileDetail(selectedFileSn);
|
||||
setFileDetail(detail);
|
||||
} finally {
|
||||
setFileDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 분류 클릭 시 해당 분류의 파일 리스트 가져오기 (펼침/접기)
|
||||
const handleCategoryClick = async (category: any) => {
|
||||
const categoryId = category.clsfSystmSn;
|
||||
|
||||
if (expandedCategories.has(categoryId)) {
|
||||
// 접기
|
||||
setExpandedCategories(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(categoryId);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
// 펼치기
|
||||
setExpandedCategories(prev => new Set([...prev, categoryId]));
|
||||
|
||||
// 이미 파일 목록이 있으면 로딩하지 않음
|
||||
if (categoryFiles[categoryId]) return;
|
||||
|
||||
// 로딩 상태 설정
|
||||
setCategoryFilesLoading(prev => new Set([...prev, categoryId]));
|
||||
|
||||
try {
|
||||
const files = await listFiles("latest", categoryId);
|
||||
setCategoryFiles(prev => ({
|
||||
...prev,
|
||||
[categoryId]: files
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('분류별 파일 조회 오류:', error);
|
||||
setCategoryFiles(prev => ({
|
||||
...prev,
|
||||
[categoryId]: []
|
||||
}));
|
||||
} finally {
|
||||
setCategoryFilesLoading(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(categoryId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout pageTitle="자료검색">
|
||||
<div className="card card-border !mt-6 flex flex-col !min-h-[calc(100vh-17.8rem)] overflow-hidden">
|
||||
{/* 검색 영역 */}
|
||||
<div className="card card-border">
|
||||
<div className="flex items-center gap-4 p-4">
|
||||
<Select value={searchType} onValueChange={setSearchType}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="검색조건" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">일반검색</SelectItem>
|
||||
<SelectItem value="natural">자연어 검색</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
className="w-[30rem] !text-[1.3rem]"
|
||||
placeholder="검색어를 입력해주세요."
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === "Enter") handleSearch(); }}
|
||||
/>
|
||||
<Button onClick={handleSearch} disabled={loading} className="rounded-sm">검색</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 결과 영역 */}
|
||||
<div className={`card card-border flex flex-col p-8 ${searched ? 'h-[calc(100vh-300px)] overflow-hidden' : 'flex-1'} relative min-h-0`}>
|
||||
{/* 오른쪽 상단에 상세보기 버튼 */}
|
||||
{searched && (
|
||||
<Button
|
||||
className="absolute top-6 right-8 z-10"
|
||||
disabled={!selectedFileSn}
|
||||
onClick={handleShowDetail}
|
||||
>
|
||||
상세 정보 보기
|
||||
</Button>
|
||||
)}
|
||||
{!searched && (
|
||||
<div className="flex justify-center items-center flex-1 text-2xl text-gray-400">
|
||||
검색어를 입력해주세요.
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* 일반 검색 결과 */}
|
||||
{searched && searchType === "default" && loading && (
|
||||
<div className="flex justify-center items-center flex-1 text-xl text-gray-400">검색 중...</div>
|
||||
)}
|
||||
{searched && searchType === "default" && !loading && results && (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<p className="text-lg font-bold">
|
||||
{searchText ? (
|
||||
<><span className="text-blue-500">{searchText}</span> 으로 검색한 결과입니다.</>
|
||||
) : (
|
||||
<>전체 자료 목록입니다.</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-6 border-b mb-4 text-lg">
|
||||
<div
|
||||
className={`pb-1 px-8 cursor-pointer font-medium ${activeTab === "all" ? "border-b-2 border-blue-500 text-blue-600" : "text-gray-600"}`}
|
||||
onClick={() => setActiveTab("all")}
|
||||
>전체</div>
|
||||
<div
|
||||
className={`pb-1 px-8 cursor-pointer font-medium ${activeTab === "category" ? "border-b-2 border-blue-500 text-blue-600" : "text-gray-600"}`}
|
||||
onClick={() => setActiveTab("category")}
|
||||
>분류</div>
|
||||
<div
|
||||
className={`pb-1 px-8 cursor-pointer font-medium ${activeTab === "file" ? "border-b-2 border-blue-500 text-blue-600" : "text-gray-600"}`}
|
||||
onClick={() => setActiveTab("file")}
|
||||
>파일</div>
|
||||
</div>
|
||||
{/* 전체 탭: 분류와 파일을 상하로 분할 */}
|
||||
{activeTab === "all" && (
|
||||
<div className={`flex flex-col gap-4 ${searched ? 'h-full' : ''}`}>
|
||||
{/* 위쪽: 분류 영역 (50%) */}
|
||||
<div className={`${searched ? 'h-[50%]' : 'flex-1'} flex flex-col`}>
|
||||
<div className="font-semibold text-blue-700 mb-2">분류</div>
|
||||
<div className="flex-1 overflow-y-auto border border-gray-200 rounded-lg p-4 min-h-0">
|
||||
{results?.data?.categoryList && results.data.categoryList.length > 0 ? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{results.data.categoryList
|
||||
.sort((a: any, b: any) => {
|
||||
const pathA = getCategoryPath(results.data.categoryList, a.clsfSystmSn);
|
||||
const pathB = getCategoryPath(results.data.categoryList, b.clsfSystmSn);
|
||||
return pathA.localeCompare(pathB, 'ko');
|
||||
})
|
||||
.map((cat: any, idx: number) => {
|
||||
const isExpanded = expandedCategories.has(cat.clsfSystmSn);
|
||||
const isLoading = categoryFilesLoading.has(cat.clsfSystmSn);
|
||||
const files = categoryFiles[cat.clsfSystmSn] || [];
|
||||
|
||||
return (
|
||||
<li key={cat.clsfSystmSn} className="flex flex-col">
|
||||
{/* 분류 항목 */}
|
||||
<div
|
||||
className={`flex items-center bg-blue-50 border border-blue-200 rounded-lg px-4 py-3 text-blue-800 text-base font-semibold shadow-sm hover:bg-blue-100 transition-all w-full cursor-pointer ${
|
||||
isExpanded ? "bg-blue-200 border-blue-400" : ""
|
||||
}`}
|
||||
onClick={() => handleCategoryClick(cat)}
|
||||
>
|
||||
<div className="flex items-center mr-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-blue-600" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
<Folder className="w-5 h-5 mr-2 text-blue-400" />
|
||||
<span className="truncate font-bold">{cat.path || getCategoryPath(results.data.categoryList, cat.clsfSystmSn)}</span>
|
||||
</div>
|
||||
|
||||
{/* 펼쳐진 파일 목록 */}
|
||||
{isExpanded && (
|
||||
<div className="ml-8 mt-2 border-l-2 border-blue-200 pl-4">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center h-12 text-gray-400">
|
||||
파일 목록을 불러오는 중...
|
||||
</div>
|
||||
) : files.length > 0 ? (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{files.map((file: any) => (
|
||||
<li
|
||||
key={file.fileSn || file.file_sn}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded cursor-pointer transition-all border border-transparent hover:border-blue-400 hover:bg-blue-50 ${
|
||||
selectedFileSn === String(file.fileSn || file.file_sn) ? "bg-blue-100 border-blue-400" : "bg-white"
|
||||
}`}
|
||||
onClick={() => {
|
||||
const fileSn = String(file.fileSn || file.file_sn);
|
||||
setSelectedFileSn(prev => prev === fileSn ? null : fileSn);
|
||||
}}
|
||||
>
|
||||
<FileText className="w-4 h-4 text-blue-400" />
|
||||
<span className="truncate font-medium text-base">{file.fileTitle || file.file_title}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-gray-400 text-sm py-2">파일이 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-gray-400">분류 결과가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 아래쪽: 파일 영역 (50%) */}
|
||||
<div className={`${searched ? 'h-[40%]' : 'flex-1'} flex flex-col`}>
|
||||
<div className="font-semibold text-blue-700 mb-2">파일</div>
|
||||
<div className="flex-1 overflow-y-auto border border-gray-200 rounded-lg p-4 min-h-0">
|
||||
{results?.data?.fileList && results.data.fileList.length > 0 ? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{results.data.fileList.map((file: any) => (
|
||||
<li
|
||||
key={file.fileSn || file.file_sn}
|
||||
className={`flex items-center gap-3 px-4 py-2 rounded cursor-pointer transition-all border border-transparent hover:border-blue-400 ${
|
||||
selectedFileSn === String(file.fileSn || file.file_sn) ? "bg-blue-100 border-blue-400" : "bg-white"
|
||||
}`}
|
||||
onClick={() => {
|
||||
const fileSn = String(file.fileSn || file.file_sn);
|
||||
setSelectedFileSn(prev => prev === fileSn ? null : fileSn);
|
||||
}}
|
||||
>
|
||||
<FileText className="w-5 h-5 text-blue-400" />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="truncate font-semibold text-lg">{file.fileTitle || file.file_title}</span>
|
||||
{file.path && (
|
||||
<span className="text-sm text-gray-500 truncate">
|
||||
{file.path} {'>'} {file.fileTitle || file.file_title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-gray-400">파일 결과가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 분류 탭 */}
|
||||
{activeTab === "category" && (
|
||||
<div className={`flex flex-col ${searched ? 'h-[90%]' : 'flex-1'}`}>
|
||||
<div className="font-semibold text-blue-700 mb-2">분류</div>
|
||||
<div className="flex-1 overflow-y-auto border border-gray-200 rounded-lg p-4 min-h-0">
|
||||
{results?.data?.categoryList && results.data.categoryList.length > 0 ? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{results.data.categoryList
|
||||
.sort((a: any, b: any) => {
|
||||
const pathA = getCategoryPath(results.data.categoryList, a.clsfSystmSn);
|
||||
const pathB = getCategoryPath(results.data.categoryList, b.clsfSystmSn);
|
||||
return pathA.localeCompare(pathB, 'ko');
|
||||
})
|
||||
.map((cat: any, idx: number) => {
|
||||
const isExpanded = expandedCategories.has(cat.clsfSystmSn);
|
||||
const isLoading = categoryFilesLoading.has(cat.clsfSystmSn);
|
||||
const files = categoryFiles[cat.clsfSystmSn] || [];
|
||||
|
||||
return (
|
||||
<li key={cat.clsfSystmSn} className="flex flex-col">
|
||||
{/* 분류 항목 */}
|
||||
<div
|
||||
className={`flex items-center bg-blue-50 border border-blue-200 rounded-lg px-4 py-3 text-blue-800 text-base font-semibold shadow-sm hover:bg-blue-100 transition-all w-full cursor-pointer ${
|
||||
isExpanded ? "bg-blue-200 border-blue-400" : ""
|
||||
}`}
|
||||
onClick={() => handleCategoryClick(cat)}
|
||||
>
|
||||
<div className="flex items-center mr-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-blue-600" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
<Folder className="w-5 h-5 mr-2 text-blue-400" />
|
||||
<span className="truncate font-bold">{cat.path || getCategoryPath(results.data.categoryList, cat.clsfSystmSn)}</span>
|
||||
</div>
|
||||
|
||||
{/* 펼쳐진 파일 목록 */}
|
||||
{isExpanded && (
|
||||
<div className="ml-8 mt-2 border-l-2 border-blue-200 pl-4">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center h-12 text-gray-400">
|
||||
파일 목록을 불러오는 중...
|
||||
</div>
|
||||
) : files.length > 0 ? (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{files.map((file: any) => (
|
||||
<li
|
||||
key={file.fileSn || file.file_sn}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded cursor-pointer transition-all border border-transparent hover:border-blue-400 hover:bg-blue-50 ${
|
||||
selectedFileSn === String(file.fileSn || file.file_sn) ? "bg-blue-100 border-blue-400" : "bg-white"
|
||||
}`}
|
||||
onClick={() => {
|
||||
const fileSn = String(file.fileSn || file.file_sn);
|
||||
setSelectedFileSn(prev => prev === fileSn ? null : fileSn);
|
||||
}}
|
||||
>
|
||||
<FileText className="w-4 h-4 text-blue-400" />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="truncate font-medium text-base">{file.fileTitle || file.file_title}</span>
|
||||
{file.path && (
|
||||
<span className="text-sm text-gray-500 truncate">
|
||||
{file.path} {'>'} {file.fileTitle || file.file_title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-gray-400 text-sm py-2">파일이 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-gray-400">분류 결과가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일 탭 */}
|
||||
{activeTab === "file" && (
|
||||
<div className={`flex flex-col ${searched ? 'h-[90%]' : 'flex-1'}`}>
|
||||
<div className="font-semibold text-blue-700 mb-2">파일</div>
|
||||
<div className="flex-1 overflow-y-auto border border-gray-200 rounded-lg p-4 min-h-0">
|
||||
{results?.data?.fileList && results.data.fileList.length > 0 ? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{results.data.fileList.map((file: any) => (
|
||||
<li
|
||||
key={file.fileSn || file.file_sn}
|
||||
className={`flex items-center gap-3 px-4 py-2 rounded cursor-pointer transition-all border border-transparent hover:border-blue-400 ${selectedFileSn === String(file.fileSn || file.file_sn) ? "bg-blue-100 border-blue-400" : "bg-white"}`}
|
||||
onClick={() => {
|
||||
const fileSn = String(file.fileSn || file.file_sn);
|
||||
setSelectedFileSn(prev => prev === fileSn ? null : fileSn);
|
||||
}}
|
||||
>
|
||||
<FileText className="w-5 h-5 text-blue-400" />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="truncate font-semibold text-lg">{file.fileTitle || file.file_title}</span>
|
||||
{file.path && (
|
||||
<span className="text-sm text-gray-500 truncate">
|
||||
{file.path} {'>'} {file.fileTitle || file.file_title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-gray-400">파일 결과가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 파일 상세정보 패널 (모달 또는 하단 고정 등 원하는 위치에 배치) */}
|
||||
{showDetail && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-lg shadow-lg w-[1000px] max-w-[90vw] h-[700px] max-h-[85vh] p-6 relative">
|
||||
<button
|
||||
className="absolute top-4 right-4 text-2xl hover:bg-gray-100 rounded-full w-8 h-8 flex items-center justify-center"
|
||||
onClick={() => setShowDetail(false)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2 mb-4">
|
||||
<Info className="w-5 h-5 text-blue-500" />파일 상세정보
|
||||
</h3>
|
||||
{/* fileDetail 구조에 맞게 매핑 */}
|
||||
{fileDetailLoading ? (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<div className="text-center text-2xl text-blue-400 font-semibold">상세정보를 불러오는 중...</div>
|
||||
</div>
|
||||
) : fileDetail ? (
|
||||
(() => {
|
||||
const detail = Array.isArray(fileDetail) ? fileDetail[0] : fileDetail;
|
||||
return (
|
||||
<div className="flex gap-6 h-[95%] min-h-0">
|
||||
{/* 왼쪽: 미리보기 영역 (65%) */}
|
||||
<div className="w-[65%] bg-gray-100 rounded-lg p-4 flex flex-col min-h-0">
|
||||
{selectedFileSn && <PdfImagePreview fileSn={selectedFileSn} />}
|
||||
</div>
|
||||
{/* 오른쪽: 파일 정보 영역 (35%) */}
|
||||
<div className="w-[35%] flex flex-col min-h-0">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ul className="text-lg text-gray-700 space-y-5">
|
||||
<li className="p-5 flex items-center gap-5 bg-gray-50 rounded">
|
||||
<FileText className="w-10 h-10 text-gray-400 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-semibold text-gray-600 text-lg block">파일명</span>
|
||||
<div className="text-gray-800 text-xl truncate">{detail?.fileTitle || "-"}</div>
|
||||
</div>
|
||||
</li>
|
||||
<li className="p-5 flex items-center gap-5 bg-gray-50 rounded">
|
||||
<Monitor className="w-10 h-10 text-gray-400 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-semibold text-gray-600 text-lg block">생산부서</span>
|
||||
<div className="text-gray-800 text-xl truncate">{detail?.prdctnDeptCd || "-"}</div>
|
||||
</div>
|
||||
</li>
|
||||
<li className="p-5 flex items-center gap-5 bg-gray-50 rounded">
|
||||
<Monitor className="w-10 h-10 text-gray-400 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-semibold text-gray-600 text-lg block">생산시스템</span>
|
||||
<div className="text-gray-800 text-xl truncate">{detail?.prdctnSysCd || "-"}</div>
|
||||
</div>
|
||||
</li>
|
||||
<li className="p-5 flex items-center gap-5 bg-gray-50 rounded">
|
||||
<HardDrive className="w-10 h-10 text-gray-400 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-semibold text-gray-600 text-lg block">파일 크기</span>
|
||||
<div className="text-gray-800 text-xl">{detail?.fileSz ? `${(detail.fileSz / (1024 * 1024)).toFixed(2)} MB` : "-"}</div>
|
||||
</div>
|
||||
</li>
|
||||
<li className="p-5 flex items-center gap-5 bg-gray-50 rounded">
|
||||
<FileDigit className="w-10 h-10 text-gray-400 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-semibold text-gray-600 text-lg block">페이지 수</span>
|
||||
<div className="text-gray-800 text-xl">{detail?.pageCnt || "-"}</div>
|
||||
</div>
|
||||
</li>
|
||||
<li className="p-5 flex items-center gap-5 bg-gray-50 rounded">
|
||||
<Clock className="w-10 h-10 text-gray-400 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-semibold text-gray-600 text-lg block">마지막 저장일시</span>
|
||||
<div className="text-gray-800 text-xl">{detail?.lastStrgDt || "-"}</div>
|
||||
</div>
|
||||
</li>
|
||||
{/* 다운로드 버튼 */}
|
||||
{(() => {
|
||||
const detail = Array.isArray(fileDetail) ? fileDetail[0] : fileDetail;
|
||||
if (!detail?.filePath) return null;
|
||||
const fileName = detail.fileTitle || detail.fileName || "downloaded_file";
|
||||
return (
|
||||
<li className="p-5 flex items-center justify-end bg-gray-50 rounded">
|
||||
<button
|
||||
className="px-6 py-3 rounded bg-blue-600 text-white font-semibold hover:bg-blue-700 transition-colors w-1/3 text-lg"
|
||||
onClick={() => downloadFile(detail.filePath, fileName)}
|
||||
>
|
||||
다운로드
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})()}
|
||||
</ul>
|
||||
</div>
|
||||
{/* QR코드: 오른쪽 하단 고정 */}
|
||||
{(() => {
|
||||
const detail = Array.isArray(fileDetail) ? fileDetail[0] : fileDetail;
|
||||
if (!detail?.filePath) return null;
|
||||
const fileName = detail.fileTitle || detail.fileName || "downloaded_file";
|
||||
const qrUrl = `https://apigateway.apps.rsonesoft.com${ENDPOINT.DC_SERVICE.CATEGORY}/file/download?filePath=${encodeURIComponent(detail.filePath)}&fileName=${encodeURIComponent(fileName)}`;
|
||||
return (
|
||||
<div className="flex-shrink-0 mt-3 flex justify-end">
|
||||
<div className="w-[120px] h-[120px] bg-white border p-1 rounded-lg shadow-sm">
|
||||
<QRCode value={qrUrl} size={110} title="파일 다운로드 QR코드" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<div className="text-center text-gray-400">상세정보를 불러오지 못했습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// [추가] PDF 이미지 미리보기 슬라이드 컴포넌트
|
||||
function PdfImagePreview({ fileSn }: { fileSn: string }) {
|
||||
const [images, setImages] = useState<string[]>([]);
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileSn) return;
|
||||
getImageList(fileSn)
|
||||
.then(data => setImages(data.files || []))
|
||||
.catch(error => console.error('이미지 목록 조회 실패:', error));
|
||||
}, [fileSn]);
|
||||
|
||||
if (!images.length) return <div className="text-gray-400 text-base">미리보기 이미지가 없습니다.</div>;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col min-h-0">
|
||||
{/* 메인 미리보기 이미지 */}
|
||||
<div className="flex-1 bg-white rounded-lg overflow-hidden shadow-sm border min-h-0">
|
||||
<img
|
||||
src={getImagePreviewUrl(fileSn, images[current] || '')}
|
||||
alt={`page_${current + 1}`}
|
||||
className="w-full h-full object-contain cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => setModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 썸네일 및 컨트롤 */}
|
||||
<div className="flex-shrink-0 mt-2 space-y-2">
|
||||
{/* 썸네일 */}
|
||||
<div className="flex gap-1 justify-center overflow-x-auto pb-1">
|
||||
{images.map((img, idx) => (
|
||||
<img
|
||||
key={img}
|
||||
src={getImagePreviewUrl(fileSn, img)}
|
||||
alt={`thumb_${idx + 1}`}
|
||||
className={`w-8 h-10 border cursor-pointer object-cover rounded transition-all ${
|
||||
current === idx ? 'border-blue-500 shadow-sm' : 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
onClick={() => setCurrent(idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 페이지 네비게이션 */}
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<button
|
||||
onClick={() => setCurrent(c => Math.max(0, c - 1))}
|
||||
disabled={current === 0}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-base font-semibold"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<span className="text-lg font-semibold text-gray-700">
|
||||
{current + 1} / {images.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrent(c => Math.min(images.length - 1, c + 1))}
|
||||
disabled={current === images.length - 1}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-base font-semibold"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 확대 모달 */}
|
||||
{modalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80"
|
||||
onClick={() => setModalOpen(false)}
|
||||
>
|
||||
<div className="relative w-full h-full flex items-center justify-center p-8">
|
||||
{/* 이전 버튼 */}
|
||||
{current > 0 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCurrent(c => c - 1);
|
||||
}}
|
||||
className="absolute left-8 top-1/2 transform -translate-y-1/2 bg-white/90 hover:bg-white text-gray-800 p-4 rounded-full shadow-lg z-10 transition-all"
|
||||
>
|
||||
<ChevronLeft className="w-8 h-8" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 이미지 컨테이너 */}
|
||||
<div className="relative w-[40vw] max-w-[40vw] max-h-[40vh] flex items-center justify-center">
|
||||
<img
|
||||
src={getImagePreviewUrl(fileSn, images[current] || '')}
|
||||
alt="확대 이미지"
|
||||
className="max-w-full max-h-full object-contain rounded shadow-2xl bg-white"
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 다음 버튼 */}
|
||||
{current < images.length - 1 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCurrent(c => c + 1);
|
||||
}}
|
||||
className="absolute right-8 top-1/2 transform -translate-y-1/2 bg-white/90 hover:bg-white text-gray-800 p-4 rounded-full shadow-lg z-10 transition-all"
|
||||
>
|
||||
<ChevronRight className="w-8 h-8" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 페이지 정보 */}
|
||||
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 bg-black/70 text-white px-6 py-3 rounded-full text-lg font-medium">
|
||||
{current + 1} / {images.length}
|
||||
</div>
|
||||
|
||||
{/* 닫기 버튼 */}
|
||||
<button
|
||||
onClick={() => setModalOpen(false)}
|
||||
className="absolute top-8 right-8 bg-black/70 hover:bg-black text-white p-3 rounded-full shadow-lg z-10 transition-all"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { GeneralSearchPage };
|
||||
|
||||
// util: 분류 경로 추적 함수 추가
|
||||
function getCategoryPath(categories: any[], targetId: string) {
|
||||
const map: Record<string, any> = {};
|
||||
categories.forEach((c: any) => { map[c.clsfSystmSn] = c; });
|
||||
let path: string[] = [];
|
||||
let current = map[targetId];
|
||||
while (current) {
|
||||
path.push(current.clsfSystmNm);
|
||||
current = map[current.upClsfSystmSn];
|
||||
}
|
||||
return path.reverse().join(" > ");
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type {
|
||||
MenuService,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuFormData,
|
||||
MenuTemporarilyStoredData,
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
export const MenuApi = new MenuApiService();
|
||||
@@ -0,0 +1,9 @@
|
||||
export { MenuApi } from "./api/menu-service";
|
||||
export {
|
||||
MENU_TYPE,
|
||||
menuFormSchema,
|
||||
type MenuType,
|
||||
type MenuItem,
|
||||
type MenuFormData,
|
||||
} from "./model/menu-interface";
|
||||
export { MenuWrapper } from "./lib/menu-wrapper";
|
||||
@@ -0,0 +1,58 @@
|
||||
"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 };
|
||||
@@ -0,0 +1,20 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
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 type MenuFormData = z.infer<typeof menuFormSchema>;
|
||||
|
||||
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[]>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { MyDataPage } from "./ui/mydata-page";
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Dialog, DialogContent, DialogFooter, DialogClose } from "@common/components/ui";
|
||||
|
||||
interface MyDataDialogProps {
|
||||
open: boolean;
|
||||
message: string;
|
||||
loading?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function MyDataDialog({ open, message, loading, onConfirm, onCancel, onOpenChange }: MyDataDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange ? onOpenChange : open => { if (!open) onCancel(); }}>
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<div className="py-10">
|
||||
<p className="text-center text-[1.4rem] text-foreground">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter className="!justify-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-primary text-white px-10 py-3 rounded text-xl hover:bg-primary/80"
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-red-600 text-white hover:bg-red-700 px-10 py-3 rounded text-xl"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { PageLayout } from "@/shared/ui/page-layout";
|
||||
import {
|
||||
Button, Input, Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@common/components";
|
||||
import { Filter } from "lucide-react";
|
||||
import { MyDataApi } from "@/entities/datalibrary";
|
||||
import type { MyData } from "@/entities/datalibrary";
|
||||
import { client } from "@/shared/api";
|
||||
import { useUserStore } from "@/shared/lib";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAlertStore } from "@/shared/lib/use-alert-store";
|
||||
|
||||
|
||||
function MyDataPage() {
|
||||
const router = useRouter();
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [favoriteRow, setFavoriteRow] = useState<number | null>(null);
|
||||
const [data, setData] = useState<MyData[]>([]);
|
||||
const [selectedRow, setSelectedRow] = useState<MyData | null>(null);
|
||||
const [searchField, setSearchField] = useState<string>("ALL");
|
||||
const [searchValue, setSearchValue] = useState<string>("");
|
||||
const [optionList, setOptionList] = useState<{ value: string; label: string }[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const userUnqId = useUserStore((state) => state.userInfo?.userUnqId || "");
|
||||
const userId = useUserStore((state) => state.userInfo?.userId || "");
|
||||
const { setMessage: alert } = useAlertStore((state) => state);
|
||||
// 공유구분/자료상태 코드명 매핑 테이블 상태 추가
|
||||
const [shrnSeCdMap, setShrnSeCdMap] = useState<{ [key: string]: string }>({});
|
||||
const [dataSttsCdMap, setDataSttsCdMap] = useState<{ [key: string]: string }>({});
|
||||
// 페이징 관련 상태
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
|
||||
// 데이터 로딩 함수
|
||||
const loadData = async (page: number = 1, searchParams?: Record<string, string>) => {
|
||||
const params: Record<string, string> = {
|
||||
rgtrId: userUnqId,
|
||||
userId: userId,
|
||||
nowPage: String(page),
|
||||
rowCnt: String(pageSize),
|
||||
...searchParams
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await MyDataApi.list(params);
|
||||
// 새로운 API 응답 구조: {list: [...], totCnt: number}
|
||||
if (response && typeof response === 'object') {
|
||||
const responseData = response as any;
|
||||
setData(responseData.list || []);
|
||||
setTotalCount(responseData.totCnt || 0);
|
||||
setTotalPages(Math.ceil((responseData.totCnt || 0) / pageSize));
|
||||
} else {
|
||||
// 기존 API 응답 구조 (배열) - fallback
|
||||
const responseArray = Array.isArray(response) ? response : [];
|
||||
setData(responseArray);
|
||||
setTotalCount(responseArray.length);
|
||||
setTotalPages(Math.ceil(responseArray.length / pageSize));
|
||||
}
|
||||
setCurrentPage(page);
|
||||
} catch (error) {
|
||||
console.error('데이터 로딩 오류:', error);
|
||||
setData([]);
|
||||
setTotalCount(0);
|
||||
setTotalPages(0);
|
||||
}
|
||||
};
|
||||
|
||||
// fetch list (최초 마운트 시 rgtr, userId 전달)
|
||||
useEffect(() => {
|
||||
loadData(1);
|
||||
}, [userUnqId, userId, pageSize]);
|
||||
|
||||
// 페이지 진입 시 데이터 재조회
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
loadData(currentPage);
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleFocus);
|
||||
return () => window.removeEventListener('focus', handleFocus);
|
||||
}, [userUnqId, userId, currentPage, pageSize]);
|
||||
|
||||
|
||||
|
||||
// 코드값 옵션 조회 util
|
||||
async function getCodeOptions(upCd: string) {
|
||||
const res = await client.get<any[]>(`/cms-service/api/v1/lwpo-cds/up-cd/${upCd}`);
|
||||
return (res.data ?? []).map((item: any) => ({
|
||||
value: String(item.lwpoCd),
|
||||
label: item.lwpoCdNm
|
||||
}));
|
||||
}
|
||||
|
||||
// 조건별 옵션 불러오기
|
||||
useEffect(() => {
|
||||
async function fetchOptions() {
|
||||
let opts: { value: string; label: string }[] = [];
|
||||
if (searchField === "ALL") {
|
||||
// 검색필드가 "전체"일 때는 오른쪽 검색조건도 "전체"만 표시
|
||||
opts = [{ value: "ALL", label: "전체" }];
|
||||
} else if (searchField === "a.shrn_se_cd") {
|
||||
opts = await getCodeOptions("DC001");
|
||||
} else if (searchField === "a.data_stts_cd") {
|
||||
opts = await getCodeOptions("DC003");
|
||||
// "전체" 옵션을 맨 앞에 추가
|
||||
opts.unshift({ value: "ALL", label: "전체" });
|
||||
} else if (searchField === "a.trnsf_data_yn") {
|
||||
opts = [
|
||||
{ value: "Y", label: "Y" },
|
||||
{ value: "N", label: "N" },
|
||||
];
|
||||
// "전체" 옵션을 맨 앞에 추가
|
||||
opts.unshift({ value: "ALL", label: "전체" });
|
||||
}
|
||||
setOptionList(opts);
|
||||
}
|
||||
fetchOptions();
|
||||
}, [searchField]);
|
||||
|
||||
useEffect(() => {
|
||||
if (optionList.length > 0) {
|
||||
// 검색필드가 "전체"일 때는 "전체" 선택, 아니면 첫 번째 옵션 선택
|
||||
if (searchField === "ALL") {
|
||||
setSearchValue("ALL");
|
||||
} else {
|
||||
setSearchValue(optionList[0]?.value ?? "");
|
||||
}
|
||||
} else {
|
||||
setSearchValue("");
|
||||
}
|
||||
}, [optionList, searchField]);
|
||||
|
||||
useEffect(() => {
|
||||
}, [optionList]);
|
||||
|
||||
// 공유구분/자료상태 코드명 매핑 useEffect
|
||||
useEffect(() => {
|
||||
async function fetchCodeMaps() {
|
||||
const shrnRes = await client.get<any[]>(`/cms-service/api/v1/lwpo-cds/up-cd/DC001`);
|
||||
const shrnMap: { [key: string]: string } = {};
|
||||
(shrnRes.data ?? []).forEach((item: any) => { shrnMap[item.lwpoCd] = item.lwpoCdNm; });
|
||||
setShrnSeCdMap(shrnMap);
|
||||
const sttsRes = await client.get<any[]>(`/cms-service/api/v1/lwpo-cds/up-cd/DC003`);
|
||||
const sttsMap: { [key: string]: string } = {};
|
||||
(sttsRes.data ?? []).forEach((item: any) => { sttsMap[item.lwpoCd] = item.lwpoCdNm; });
|
||||
setDataSttsCdMap(sttsMap);
|
||||
}
|
||||
fetchCodeMaps();
|
||||
}, []);
|
||||
|
||||
// 검색 실행 함수
|
||||
const handleSearch = async () => {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
// 검색 조건에 따라 API 호출 파라미터 구성
|
||||
const searchParams: Record<string, string> = {};
|
||||
// 검색필드가 "전체"가 아닌 경우에만 검색 조건 추가
|
||||
if (searchField && searchField !== "ALL") {
|
||||
searchParams.col_nam = searchField;
|
||||
// "전체"가 아닌 경우에만 검색값 추가
|
||||
if (searchValue && searchValue !== "ALL") {
|
||||
searchParams.col_val = searchValue;
|
||||
}
|
||||
}
|
||||
// 검색필드가 "전체"가 아니고 검색값이 "전체"인 경우에도 파라미터를 전송하지 않음
|
||||
if (searchField && searchField !== "ALL" && searchValue === "ALL") {
|
||||
delete searchParams.col_nam;
|
||||
delete searchParams.col_val;
|
||||
}
|
||||
await loadData(1, searchParams);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 삭제
|
||||
const handleDelete = async () => {
|
||||
if (!selectedRow) return;
|
||||
try {
|
||||
await MyDataApi.remove(selectedRow.fileSn);
|
||||
alert("삭제가 완료되었습니다.");
|
||||
await loadData(currentPage);
|
||||
setShowConfirm(false);
|
||||
setSelectedRow(null);
|
||||
} catch (e: any) {
|
||||
alert("삭제 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
// 테이블 행 클릭 시 선택 및 수정 화면 이동
|
||||
const handleRowClick = (item: MyData) => {
|
||||
setSelectedRow(item);
|
||||
router.push(`/mydata/register/${item.fileSn}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout pageTitle="내자료 정보 관리">
|
||||
<div className="card card-border !mt-6 flex flex-col h-[calc(100vh-16rem)]">
|
||||
{/* 메인 카드 */}
|
||||
<div className="card card-border flex-grow flex flex-col">
|
||||
{/* ① 검색 + 필터 */}
|
||||
<div className="flex justify-between items-center mb-4 border-b border-gray-200 p-4 flex-shrink-0">
|
||||
<div className="flex gap-2 items-center text-xl">
|
||||
{/* 조건 셀렉트 */}
|
||||
<label className="mr-1 text-xl font-semibold">검색조건</label>
|
||||
<Select value={searchField} onValueChange={setSearchField}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="검색조건" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">전체</SelectItem>
|
||||
<SelectItem value="a.shrn_se_cd">공유구분</SelectItem>
|
||||
<SelectItem value="a.trnsf_data_yn">이관자료여부</SelectItem>
|
||||
<SelectItem value="a.data_stts_cd">자료상태</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 값 셀렉트 */}
|
||||
<Select value={searchValue} onValueChange={setSearchValue} disabled={optionList.length === 0}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{optionList.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
className="ml-4 px-9 py-2 rounded-sm text-white text-md h-[3.2rem] bg-bg-primary/70 hover:bg-primary/80"
|
||||
onClick={handleSearch}
|
||||
disabled={!searchField || isSearching}
|
||||
type="button"
|
||||
>
|
||||
{isSearching ? "검색중..." : "검색"}
|
||||
</Button>
|
||||
</div>
|
||||
{/* ③④⑤ 버튼들 */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-primary text-white px-9 py-2 rounded-sm hover:bg-primary/80 text-md h-[3.2rem]"
|
||||
onClick={() => router.push('/datainsert')}
|
||||
>
|
||||
등록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 테이블 - 스크롤 가능한 영역 */}
|
||||
<div className="flex-1 overflow-hidden mx-4">
|
||||
<div className="h-full overflow-auto">
|
||||
<table className="w-full text-md border-t table-ui">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="border-b text-gray-700 text-center bg-[rgb(89,135,191)] px-4 py-3 text-xl font-medium leading-[1.2] text-white">
|
||||
<th className="py-4">관심자료</th>
|
||||
<th>공유구분</th>
|
||||
<th>공유부서</th>
|
||||
<th>공유아이디</th>
|
||||
<th>이관자료여부</th>
|
||||
<th>이관자료사용자ID</th>
|
||||
<th>제목</th>
|
||||
<th>생산부서</th>
|
||||
<th>생산시스템</th>
|
||||
<th>생산년도</th>
|
||||
<th>자료상태</th>
|
||||
{/* userId, shrnSeCd, dataSttsCd 등 숨김 */}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.isArray(data) && data.map((item, idx) => (
|
||||
<tr
|
||||
key={item.fileSn}
|
||||
className={`border-b text-center border-[var(--border)] px-2 py-3 text-xl font-normal leading-[1.2] text-[var(--label)] ${selectedRow?.fileSn === item.fileSn ? "bg-blue-50" : ""} hover:bg-blue-100 cursor-pointer`}
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<td className="cursor-pointer py-4">
|
||||
{item.itrstYn === 'Y' ? <span style={{color: '#FFD600'}}>⭐</span> : <span style={{color: '#bbb'}}>☆</span>}
|
||||
</td>
|
||||
{/* 공유구분: 코드명으로 표시 */}
|
||||
<td>{item.shrnSeCdNm ?? shrnSeCdMap[item.shrnSeCd ?? ''] ?? ''}</td>
|
||||
<td>{item.shrnDeptCd}</td>
|
||||
<td>{item.shrnUserId}</td>
|
||||
<td>{item.trnsfDataYn}</td>
|
||||
<td>{item.dataTrnsfUserId}</td>
|
||||
<td>{item.fileNm}</td>
|
||||
<td>{item.prdctnDeptCd}</td>
|
||||
<td>{item.prdctnSysCd}</td>
|
||||
<td>{item.regDt}</td>
|
||||
{/* 자료상태: 코드명으로 표시 */}
|
||||
<td>{item.dataSttsCdNm ?? dataSttsCdMap[item.dataSttsCd ?? ''] ?? ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 페이징 UI - 고정 위치 */}
|
||||
<div className="flex justify-between items-center p-4 border-t border-gray-200 flex-shrink-0">
|
||||
{/* 페이지 정보 - 왼쪽 */}
|
||||
<div className="text-xl font-semibold text-gray-700">
|
||||
총 {totalCount}개 중 {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, totalCount)}개
|
||||
</div>
|
||||
|
||||
{/* 페이징 버튼 - 가운데 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`px-4 py-2 rounded-sm text-xl font-semibold h-[3.2rem] ${
|
||||
currentPage === 1
|
||||
? "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||
: "bg-primary text-white hover:bg-primary/80"
|
||||
}`}
|
||||
onClick={() => loadData(1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
처음
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-4 py-2 rounded-sm text-xl font-semibold h-[3.2rem] ${
|
||||
currentPage === 1
|
||||
? "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||
: "bg-primary text-white hover:bg-primary/80"
|
||||
}`}
|
||||
onClick={() => loadData(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
|
||||
{/* 페이지 번호 버튼들 */}
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let pageNum;
|
||||
if (totalPages <= 5) {
|
||||
pageNum = i + 1;
|
||||
} else if (currentPage <= 3) {
|
||||
pageNum = i + 1;
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pageNum = totalPages - 4 + i;
|
||||
} else {
|
||||
pageNum = currentPage - 2 + i;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
type="button"
|
||||
className={`w-12 h-[3.2rem] rounded-sm text-xl font-semibold ${
|
||||
currentPage === pageNum
|
||||
? "bg-[rgb(89,135,191)] text-white"
|
||||
: "bg-white text-gray-700 border border-gray-300 hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => loadData(pageNum)}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`px-4 py-2 rounded-sm text-xl font-semibold h-[3.2rem] ${
|
||||
currentPage === totalPages
|
||||
? "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||
: "bg-primary text-white hover:bg-primary/80"
|
||||
}`}
|
||||
onClick={() => loadData(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-4 py-2 rounded-sm text-xl font-semibold h-[3.2rem] ${
|
||||
currentPage === totalPages
|
||||
? "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||
: "bg-primary text-white hover:bg-primary/80"
|
||||
}`}
|
||||
onClick={() => loadData(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
마지막
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 페이지 크기 선택 - 오른쪽 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl font-semibold text-gray-700">페이지당 행 수</span>
|
||||
<Select value={String(pageSize)} onValueChange={(value) => setPageSize(Number(value))}>
|
||||
<SelectTrigger className="w-[100px] h-[3.2rem] text-xl">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
{showConfirm && selectedRow && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-30 flex justify-center items-center z-50">
|
||||
<div className="bg-white p-6 rounded shadow-md text-center w-[30rem]">
|
||||
<p className="mb-4 text-lg font-semibold text-yellow-600">
|
||||
❗ 선택한 자료를 삭제 하시겠습니까?
|
||||
</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Button className="text-white" onClick={handleDelete}>확인</Button>
|
||||
<Button variant="outline" className="text-white" onClick={() => setShowConfirm(false)}>취소</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export { MyDataPage };
|
||||
@@ -0,0 +1,438 @@
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { Button, Input, Table, TableBody, TableCell, TableHead, TableRow, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@common/components";
|
||||
import { Form, FormField, FormItem, FormControl, FormMessage } from "@/shared/ui/form";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { MyData } from "@/entities/datalibrary";
|
||||
import { client } from "@/shared/api";
|
||||
import { User } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
|
||||
import { MyDataDialog } from "./mydata-dialog";
|
||||
import { useUserStore } from "@/shared/lib";
|
||||
import { useAlertStore } from "@/shared/lib/use-alert-store";
|
||||
import { CodeSelect } from "@/shared/ui/code-select";
|
||||
|
||||
|
||||
type MyDataRegisterProps = {
|
||||
form: UseFormReturn<Partial<MyData>>;
|
||||
mydataId?: string;
|
||||
handleSave: (data: Partial<MyData>) => Promise<void>;
|
||||
handleDelete?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function MyDataRegister({ form, mydataId, handleSave, handleDelete }: MyDataRegisterProps) {
|
||||
const isModifyMode = !!mydataId;
|
||||
const { confirm } = useConfirm();
|
||||
const [onConfirm, setOnConfirm] = useState(false);
|
||||
// 사용자 검색 모달 상태
|
||||
const [userModalOpen, setUserModalOpen] = useState(false);
|
||||
const [userSearch, setUserSearch] = useState("");
|
||||
const [userResults, setUserResults] = useState<any[]>([]);
|
||||
const [selectedUser, setSelectedUser] = useState<any>(null);
|
||||
const [userLoading, setUserLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const [shrnSeCdOptions, setShrnSeCdOptions] = useState<{ value: string; label: string }[]>([]);
|
||||
const [dataSttsOptions, setDataSttsOptions] = useState<{ value: string; label: string }[]>([]);
|
||||
const [dialog, setDialog] = useState<{ open: boolean; type: 'save' | 'delete' | null; data?: Partial<MyData> }>({ open: false, type: null });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [shrnSeCdLoading, setShrnSeCdLoading] = useState(true);
|
||||
const [dataSttsLoading, setDataSttsLoading] = useState(true);
|
||||
const userUnqId = useUserStore((state) => state.userInfo?.userUnqId || "");
|
||||
const { setMessage: showAlert } = useAlertStore((state) => state);
|
||||
|
||||
// 필수값 에러 상태
|
||||
const [showItrstYnError, setShowItrstYnError] = useState(false);
|
||||
const [showShrnSeCdError, setShowShrnSeCdError] = useState(false);
|
||||
const [showDataSttsCdError, setShowDataSttsCdError] = useState(false);
|
||||
|
||||
// userId, rgtrId를 항상 폼에 세팅
|
||||
useEffect(() => {
|
||||
form.setValue("rgtrId" as any, userUnqId);
|
||||
}, [userUnqId]);
|
||||
|
||||
// 공유구분 옵션 불러오기
|
||||
useEffect(() => {
|
||||
async function fetchShrnSeCdOptions() {
|
||||
try {
|
||||
setShrnSeCdLoading(true);
|
||||
const res = await client.get<any[]>(`/cms-service/api/v1/lwpo-cds/up-cd/DC001`);
|
||||
setShrnSeCdOptions((res.data ?? []).map((item: any) => ({ value: String(item.lwpoCd), label: item.lwpoCdNm })));
|
||||
} catch (error) {
|
||||
console.error('공유구분 옵션 로딩 실패:', error);
|
||||
} finally {
|
||||
setShrnSeCdLoading(false);
|
||||
}
|
||||
}
|
||||
fetchShrnSeCdOptions();
|
||||
}, []);
|
||||
|
||||
// 자료상태 코드값 옵션 불러오기
|
||||
useEffect(() => {
|
||||
async function fetchDataSttsOptions() {
|
||||
try {
|
||||
setDataSttsLoading(true);
|
||||
const res = await client.get<any[]>(`/cms-service/api/v1/lwpo-cds/up-cd/DC003`);
|
||||
setDataSttsOptions((res.data ?? []).map((item: any) => ({ value: String(item.lwpoCd), label: item.lwpoCdNm })));
|
||||
} catch (error) {
|
||||
console.error('자료상태 옵션 로딩 실패:', error);
|
||||
} finally {
|
||||
setDataSttsLoading(false);
|
||||
}
|
||||
}
|
||||
fetchDataSttsOptions();
|
||||
}, []);
|
||||
|
||||
const onSubmit = (data: Partial<MyData>) => {
|
||||
// 필수값 검증
|
||||
let hasError = false;
|
||||
let errorMessages: string[] = [];
|
||||
|
||||
if (!data.itrstYn || data.itrstYn === "") {
|
||||
setShowItrstYnError(true);
|
||||
hasError = true;
|
||||
errorMessages.push("관심자료 여부를 선택하세요.");
|
||||
} else {
|
||||
setShowItrstYnError(false);
|
||||
}
|
||||
|
||||
if (!data.shrnSeCd || data.shrnSeCd === "") {
|
||||
setShowShrnSeCdError(true);
|
||||
hasError = true;
|
||||
errorMessages.push("공유구분을 선택하세요.");
|
||||
} else {
|
||||
setShowShrnSeCdError(false);
|
||||
}
|
||||
|
||||
if (!data.dataSttsCd || data.dataSttsCd === "") {
|
||||
setShowDataSttsCdError(true);
|
||||
hasError = true;
|
||||
errorMessages.push("자료상태를 선택하세요.");
|
||||
} else {
|
||||
setShowDataSttsCdError(false);
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
showAlert(errorMessages[0] || "필수값을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 모든 MyData 필드를 undefined라도 반드시 포함
|
||||
const allKeys: string[] = [
|
||||
"fileSn", "shrnSeCd", "shrnDeptCd", "shrnUserId", "fileNm", "dataSttsCd", "prdctnDeptCd", "prdctnSysCd", "regDt", "itrstYn", "dataTrnsfUserId", "userId", "rgtrId"
|
||||
];
|
||||
const fullData = allKeys.reduce((acc, key) => {
|
||||
acc[key] = (data as any)[key] ?? "";
|
||||
return acc;
|
||||
}, {} as any);
|
||||
fullData.rgtrId = userUnqId; // 항상 최신 uuid로 덮어씀
|
||||
setDialog({ open: true, type: 'save', data: fullData });
|
||||
};
|
||||
|
||||
const onDelete = () => {
|
||||
setDialog({ open: true, type: 'delete' });
|
||||
};
|
||||
|
||||
const handleDialogConfirm = async () => {
|
||||
setLoading(true);
|
||||
if (dialog.type === 'save' && dialog.data) {
|
||||
await handleSave(dialog.data);
|
||||
} else if (dialog.type === 'delete') {
|
||||
await handleDelete?.();
|
||||
}
|
||||
setLoading(false);
|
||||
setDialog({ open: false, type: null });
|
||||
};
|
||||
|
||||
const handleDialogCancel = () => {
|
||||
setDialog({ open: false, type: null });
|
||||
};
|
||||
|
||||
// 사용자 검색 함수
|
||||
const handleUserSearch = async () => {
|
||||
setUserLoading(true);
|
||||
try {
|
||||
const res = await client.get("/user-service/api/v1/users", { params: { userName: userSearch } });
|
||||
const content = (res.data as any)?.content;
|
||||
setUserResults(Array.isArray(content) ? content : []);
|
||||
} finally {
|
||||
setUserLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 사용자 선택 함수 (확인 버튼에서 사용)
|
||||
const handleUserConfirm = () => {
|
||||
if (selectedUser) {
|
||||
form.setValue("dataTrnsfUserId", selectedUser.userId);
|
||||
setUserModalOpen(false);
|
||||
setSelectedUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="card card-border !mt-6">
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-medium text-label">내자료 {isModifyMode ? "수정" : "등록"}</h3>
|
||||
</div>
|
||||
<div className="card">
|
||||
<Table variant="secondary">
|
||||
<colgroup>
|
||||
<col width="20%" />
|
||||
<col width="80%" />
|
||||
</colgroup>
|
||||
<TableBody>
|
||||
<TableRow style={{ display: 'none' }}>
|
||||
<TableHead>userId</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"userId" as any}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input {...field} type="hidden" value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow style={{ display: 'none' }}>
|
||||
<TableHead>rgtrId</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"rgtrId" as any}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input {...field} type="hidden" value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead><span className="text-red-500">*</span> 관심자료 여부</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="itrstYn"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<CodeSelect
|
||||
value={field.value ?? ""}
|
||||
onValueChange={(value) => field.onChange(value === "__SELECT__" ? "" : value)}
|
||||
options={[
|
||||
{ value: "Y", label: "Y" },
|
||||
{ value: "N", label: "N" }
|
||||
]}
|
||||
placeholder="선택"
|
||||
className={`w-[120px] h-[3.2rem] text-xl ${showItrstYnError ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead><span className="text-red-500">*</span> 공유구분</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shrnSeCd"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
{shrnSeCdLoading ? (
|
||||
<div className="w-[160px] h-[3.2rem] text-xl border rounded-md bg-gray-100 flex items-center px-3">
|
||||
로딩 중...
|
||||
</div>
|
||||
) : (
|
||||
<CodeSelect
|
||||
value={field.value ?? ""}
|
||||
onValueChange={(value) => field.onChange(value === "__SELECT__" ? "" : value)}
|
||||
options={shrnSeCdOptions}
|
||||
placeholder="선택"
|
||||
className={`w-[160px] h-[3.2rem] text-xl ${showShrnSeCdError ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>생산부서</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="prdctnDeptCd"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="border rounded h-[3.2rem] px-3 !text-[1.3rem]"
|
||||
value={field.value ?? ''}
|
||||
onBlur={(e) => {
|
||||
const trimmedValue = e.target.value.trim();
|
||||
field.onChange(trimmedValue);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>생산시스템</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="prdctnSysCd"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="border rounded h-[3.2rem] px-3 !text-[1.3rem]"
|
||||
value={field.value ?? ''}
|
||||
onBlur={(e) => {
|
||||
const trimmedValue = e.target.value.trim();
|
||||
field.onChange(trimmedValue);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead><span className="text-red-500">*</span> 자료상태</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dataSttsCd"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
{dataSttsLoading ? (
|
||||
<div className="w-[120px] h-[3.2rem] text-xl border rounded-md bg-gray-100 flex items-center px-3">
|
||||
로딩 중...
|
||||
</div>
|
||||
) : (
|
||||
<CodeSelect
|
||||
value={field.value ?? ""}
|
||||
onValueChange={(value) => field.onChange(value === "__SELECT__" ? "" : value)}
|
||||
options={dataSttsOptions}
|
||||
placeholder="선택"
|
||||
className={`w-[120px] h-[3.2rem] text-xl ${showDataSttsCdError ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>자료이관사용자ID</TableHead>
|
||||
<TableCell className="border">
|
||||
<div className="flex gap-2 items-center">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dataTrnsfUserId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input {...field} className="bg-gray-200 border-gray-300 rounded h-[3.2rem] px-3 !text-[1.3rem] w-[180px]" value={field.value ?? ''} readOnly />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<button type="button" className="h-[3.2rem] px-4 text-xl bg-primary text-white hover:bg-primary/80 rounded-sm" onClick={() => setUserModalOpen(true)}>검색</button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex justify-center gap-4 pb-12 pt-10">
|
||||
<Button size="lg" color="gray" type="button" className="h-[3.2rem] px-8 text-xl" onClick={() => router.back()}>
|
||||
목록
|
||||
</Button>
|
||||
<Button size="lg" type="submit" className="h-[3.2rem] px-8 text-xl bg-primary text-white hover:bg-primary/80">
|
||||
저장
|
||||
</Button>
|
||||
{isModifyMode && handleDelete && (
|
||||
<Button size="lg" color="red" type="button" className="h-[3.2rem] px-8 text-xl" onClick={form.handleSubmit(() => onDelete())}>
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 사용자 검색 모달 */}
|
||||
{userModalOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-30 flex justify-center items-center z-50">
|
||||
<div className="bg-white rounded shadow-md w-[420px] p-6">
|
||||
<div className="mb-4 text-xl font-semibold">사용자 검색</div>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Input value={userSearch} onChange={e => setUserSearch(e.target.value)} className="h-[3.2rem] px-3 !text-[1.3rem] flex-1" placeholder="이름 입력" />
|
||||
<button type="button" className="h-[3.2rem] px-4 text-xl bg-primary text-white hover:bg-primary/80 rounded-sm" onClick={handleUserSearch} disabled={userLoading}>검색</button>
|
||||
</div>
|
||||
<div className="max-h-[200px] overflow-y-auto border rounded">
|
||||
{userLoading ? (
|
||||
<div className="p-4 text-center text-gray-400 !text-[1.3rem]">검색 중...</div>
|
||||
) : userResults.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-400 !text-[1.3rem]">검색 결과 없음</div>
|
||||
) : (
|
||||
userResults.map(user => (
|
||||
<div
|
||||
key={user.userId}
|
||||
className={`flex items-center gap-3 p-3 my-2 rounded border shadow-sm transition-all cursor-pointer bg-white hover:bg-blue-50 ${selectedUser?.userId === user.userId ? 'ring-2 ring-blue-400 bg-blue-100' : ''}`}
|
||||
onClick={() => setSelectedUser(user)}
|
||||
>
|
||||
<User className="w-6 h-6 text-blue-400" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold !text-[1.3rem] text-gray-800">{user.userName}</span>
|
||||
<span className="text-lg text-gray-500">{user.userId}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end mt-4 gap-2">
|
||||
<button type="button" className="h-[3.2rem] px-4 text-xl bg-primary text-white hover:bg-primary/80 rounded-sm" onClick={handleUserConfirm} disabled={!selectedUser}>확인</button>
|
||||
<button type="button" className="h-[3.2rem] px-4 text-xl bg-red-600 text-white hover:bg-red-700 rounded-sm" onClick={() => { setUserModalOpen(false); setSelectedUser(null); }}>닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
<MyDataDialog
|
||||
open={dialog.open}
|
||||
message={dialog.type === 'save' ? (isModifyMode ? '수정하시겠습니까?' : '저장하시겠습니까?') : '삭제하시겠습니까?'}
|
||||
loading={loading}
|
||||
onConfirm={handleDialogConfirm}
|
||||
onCancel={handleDialogCancel}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Client } from "@common/network";
|
||||
import { BASE_URL, DC_SERVICE_BASE_URL } from "../config/api";
|
||||
import { SITE_ID } from "../config/env";
|
||||
import { setCookie } from "../lib";
|
||||
|
||||
// SITE_ID를 쿠키에 저장 (공통 모듈 사용을 위한 필수 사항)
|
||||
setCookie("site-id-key", SITE_ID);
|
||||
|
||||
export const client = Client.getInstance({
|
||||
baseURL: BASE_URL,
|
||||
siteId: SITE_ID,
|
||||
});
|
||||
|
||||
// DC 서비스 전용
|
||||
export const dcClient = Client.getInstance({
|
||||
baseURL: DC_SERVICE_BASE_URL,
|
||||
siteId: SITE_ID,
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { client, dcClient } from "./client";
|
||||
import { ENDPOINT } from "../config/api";
|
||||
|
||||
export async function getUserByUnqId(userUnqId: string) {
|
||||
const res = await client.get(`${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export { client };
|
||||
export { dcClient };
|
||||
@@ -0,0 +1,72 @@
|
||||
import { PROXY_HOST, NEXT_PUBLIC_DC_SERVICE_API_URL } from "./env";
|
||||
|
||||
export const BASE_URL = `${PROXY_HOST}/server`;
|
||||
export const DC_SERVICE_BASE_URL = `${PROXY_HOST}/server/dc-service`;
|
||||
|
||||
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",
|
||||
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",
|
||||
},
|
||||
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",
|
||||
},
|
||||
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",
|
||||
},
|
||||
DC_SERVICE: {
|
||||
CATEGORY: "/dc-service/api/v1/dc"
|
||||
}
|
||||
};
|
||||
|
||||
export const JWT_EXPIRATION_TIME = 1800000;
|
||||
@@ -0,0 +1,18 @@
|
||||
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 NEXT_PUBLIC_DC_SERVICE_API_URL = process.env.NEXT_PUBLIC_DC_SERVICE_API_URL || `http://localhost:${PORT}`;
|
||||
|
||||
// Flask API URL
|
||||
export const FLASK_API_URL = process.env.NEXT_PUBLIC_FLASK_API_URL || "http://localhost:5000";
|
||||
|
||||
//export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "DC";
|
||||
export const SITE_ID = "DC";
|
||||
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";
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./api";
|
||||
export * from "./env";
|
||||
@@ -0,0 +1,29 @@
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
export function getCookie(name: string): string | undefined {
|
||||
if (typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
return Cookies.get(name);
|
||||
}
|
||||
|
||||
export function setCookie(
|
||||
name: string,
|
||||
value: string,
|
||||
options?: Cookies.CookieAttributes,
|
||||
): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
Cookies.set(name, value, options);
|
||||
}
|
||||
|
||||
export function removeCookie(
|
||||
name: string,
|
||||
options?: Cookies.CookieAttributes,
|
||||
): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
Cookies.remove(name, options);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { getCookie, setCookie, removeCookie } from "./cookie";
|
||||
export { useMutationWrapper } from "./use-mutation-wrapper";
|
||||
export { useDataTable } from "./use-data-table";
|
||||
export { useAlertStore } from "./use-alert-store";
|
||||
export { useMenuStore } from "./use-menu-store";
|
||||
export { useUserStore } from "./use-user-store";
|
||||
@@ -0,0 +1,65 @@
|
||||
import { type UseQueryResult } from "@tanstack/react-query";
|
||||
import type { PageableList } from "../model";
|
||||
|
||||
/**
|
||||
* Match the state of a query to a set of components.
|
||||
*
|
||||
* Useful for rendering different UI based on the state of a query.
|
||||
*/
|
||||
export function matchQueryStatus<T>(
|
||||
query: UseQueryResult<PageableList<T>>,
|
||||
options: {
|
||||
Loading?: JSX.Element;
|
||||
Errored?: JSX.Element | ((error: unknown) => JSX.Element);
|
||||
Empty?: JSX.Element;
|
||||
Success: (
|
||||
data: UseQueryResult<PageableList<T>> & {
|
||||
data: NonNullable<UseQueryResult<T>["data"]>;
|
||||
},
|
||||
) => JSX.Element;
|
||||
},
|
||||
): JSX.Element;
|
||||
export function matchQueryStatus<T>(
|
||||
query: UseQueryResult<PageableList<T>>,
|
||||
options: {
|
||||
Loading?: JSX.Element;
|
||||
Errored?: JSX.Element | ((error: unknown) => JSX.Element);
|
||||
Success: (data: UseQueryResult<PageableList<T>>) => JSX.Element;
|
||||
},
|
||||
): JSX.Element;
|
||||
export function matchQueryStatus<T>(
|
||||
query: UseQueryResult<PageableList<T>>,
|
||||
{
|
||||
Loading,
|
||||
Errored,
|
||||
Empty,
|
||||
Success,
|
||||
}: {
|
||||
Loading?: JSX.Element;
|
||||
Errored?: JSX.Element | ((error: unknown) => JSX.Element);
|
||||
Empty?: JSX.Element;
|
||||
Success: (data: UseQueryResult<PageableList<T>>) => JSX.Element;
|
||||
},
|
||||
): JSX.Element {
|
||||
if (query.isLoading && Loading) {
|
||||
return Loading;
|
||||
}
|
||||
|
||||
if (query.isError && Errored) {
|
||||
if (typeof Errored === "function") {
|
||||
return Errored(query.error);
|
||||
}
|
||||
return Errored;
|
||||
}
|
||||
|
||||
const isEmpty =
|
||||
query.data?.content === undefined ||
|
||||
query.data?.content === null ||
|
||||
(Array.isArray(query.data?.content) && query.data?.content.length === 0);
|
||||
|
||||
if (isEmpty && Empty) {
|
||||
return Empty;
|
||||
}
|
||||
|
||||
return Success(query);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useStore } from "zustand";
|
||||
import { useContext } from "react";
|
||||
import type { AlertStore } from "../store/alert-store";
|
||||
import { StoreContext } from "../providers/store-provider";
|
||||
|
||||
export const useAlertStore = <T>(selector: (store: AlertStore) => T): T => {
|
||||
const storeContext = useContext(StoreContext);
|
||||
|
||||
if (!storeContext) {
|
||||
throw new Error("useAlertStore must be used within StoreProvider");
|
||||
}
|
||||
|
||||
return useStore(storeContext.alertStore, selector);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
import { useReactTable, type TableOptions } from "@tanstack/react-table";
|
||||
import { DataTable } from "@common/components";
|
||||
import { NoData } from "../ui/no-data";
|
||||
import type { PageableList } from "../model";
|
||||
import { matchQueryStatus } from "./match-query-status";
|
||||
|
||||
type UseDataTableOptions<T> = {
|
||||
query: UseQueryResult<PageableList<T>>;
|
||||
tableOptions: Omit<TableOptions<T>, "data" | "rowCount">;
|
||||
dataTableOptions?: DataTableOptions<T>;
|
||||
};
|
||||
|
||||
type DataTableOptions<T> = {
|
||||
variant?: "default" | "secondary" | "line";
|
||||
className?: HTMLAttributes<HTMLTableElement>["className"];
|
||||
viewPagination?: boolean;
|
||||
onRowClick?: (row: T) => void;
|
||||
};
|
||||
|
||||
export const useDataTable = <T,>({
|
||||
query,
|
||||
tableOptions,
|
||||
dataTableOptions,
|
||||
}: UseDataTableOptions<T>) => {
|
||||
const table = useReactTable({
|
||||
...tableOptions,
|
||||
data: query.data?.content ?? [],
|
||||
rowCount: query.data?.totalElements ?? 0,
|
||||
});
|
||||
|
||||
return {
|
||||
tableComponent: matchQueryStatus(query, {
|
||||
Empty: <NoData />,
|
||||
Success: () => {
|
||||
return <DataTable table={table} {...dataTableOptions} />;
|
||||
},
|
||||
}),
|
||||
tableInstance: table,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useStore } from "zustand";
|
||||
import { useContext } from "react";
|
||||
import type { MenuStore } from "../store/menu-store";
|
||||
import { StoreContext } from "../providers/store-provider";
|
||||
|
||||
export const useMenuStore = <T>(selector: (store: MenuStore) => T): T => {
|
||||
const storeContext = useContext(StoreContext);
|
||||
|
||||
if (!storeContext) {
|
||||
throw new Error("useMenuStore must be used within StoreProvider");
|
||||
}
|
||||
|
||||
return useStore(storeContext.menuStore, selector);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { AxiosError } from "axios";
|
||||
import { useMutation, type UseMutationOptions } from "@tanstack/react-query";
|
||||
import { useAlertStore } from "./use-alert-store";
|
||||
|
||||
export const useMutationWrapper = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
TVariables = void,
|
||||
TContext = unknown,
|
||||
>(
|
||||
mutationOptions: UseMutationOptions<TData, TError, TVariables, TContext>,
|
||||
) => {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
return useMutation({
|
||||
...mutationOptions,
|
||||
onError: (error) => {
|
||||
let errorMessage = "";
|
||||
|
||||
if (error instanceof AxiosError) {
|
||||
if (error.response?.data?.message)
|
||||
errorMessage += `${error.response.data.message}\n`;
|
||||
if (error.response?.data?.errors[0]?.message)
|
||||
errorMessage += `${error.response.data.errors[0].message}\n`;
|
||||
}
|
||||
|
||||
alert(`${errorMessage}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { AxiosError } from "axios";
|
||||
import { useMutation, type UseMutationOptions } from "@tanstack/react-query";
|
||||
import { useAlertStore } from "./use-alert-store";
|
||||
|
||||
export const useMutationWrapper = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
TVariables = void,
|
||||
TContext = unknown,
|
||||
>(
|
||||
mutationOptions: UseMutationOptions<TData, TError, TVariables, TContext>,
|
||||
) => {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
return useMutation({
|
||||
...mutationOptions,
|
||||
onError: (error) => {
|
||||
let errorMessage = "";
|
||||
|
||||
if (error instanceof AxiosError) {
|
||||
if (error.response?.data?.message)
|
||||
errorMessage += `${error.response.data.message}\n`;
|
||||
if (error.response?.data?.errors[0]?.message)
|
||||
errorMessage += `${error.response.data.errors[0].message}\n`;
|
||||
}
|
||||
|
||||
alert(`${errorMessage}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useStore } from "zustand";
|
||||
import { uploadStore } from "@/shared/store/upload-store-instance";
|
||||
import type { UploadStore } from "@/shared/store/upload-store";
|
||||
|
||||
export const useUploadStore = () => {
|
||||
return useStore(uploadStore, (state: UploadStore) => state);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useStore } from "zustand";
|
||||
import { useContext } from "react";
|
||||
import type { UserStore } from "../store/user-store";
|
||||
import { StoreContext } from "../providers/store-provider";
|
||||
|
||||
export const useUserStore = <T>(selector: (store: UserStore) => T): T => {
|
||||
const storeContext = useContext(StoreContext);
|
||||
|
||||
if (!storeContext) {
|
||||
throw new Error("useUserStore must be used within StoreProvider");
|
||||
}
|
||||
|
||||
return useStore(storeContext.userStore, selector);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
interface SortConfig {
|
||||
empty: boolean;
|
||||
sorted: boolean;
|
||||
unsorted: boolean;
|
||||
}
|
||||
|
||||
interface PageableConfig {
|
||||
sort: SortConfig;
|
||||
offset: number;
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
paged: boolean;
|
||||
unpaged: boolean;
|
||||
}
|
||||
|
||||
export interface PageableList<T> {
|
||||
content: T[];
|
||||
pageable: PageableConfig;
|
||||
sort: SortConfig;
|
||||
totalPages: number;
|
||||
totalElements: number;
|
||||
number: number;
|
||||
numberOfElements: number;
|
||||
size: number;
|
||||
first: boolean;
|
||||
last: boolean;
|
||||
empty: boolean;
|
||||
}
|
||||
|
||||
export interface PageableParams {
|
||||
page?: number;
|
||||
size?: number;
|
||||
sort?: string[];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./api-interface";
|
||||
@@ -0,0 +1 @@
|
||||
export { StoreProvider } from "./store-provider";
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useRef } from "react";
|
||||
import {
|
||||
createAlertStore,
|
||||
createAttachmentStore,
|
||||
createMenuStore,
|
||||
createUserStore,
|
||||
} from "../store";
|
||||
|
||||
type StoreContextType = {
|
||||
alertStore: ReturnType<typeof createAlertStore>;
|
||||
attachmentStore: ReturnType<typeof createAttachmentStore>;
|
||||
menuStore: ReturnType<typeof createMenuStore>;
|
||||
userStore: ReturnType<typeof createUserStore>;
|
||||
};
|
||||
|
||||
export const StoreContext = createContext<StoreContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const StoreProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const storeRef = useRef<StoreContextType | null>(null);
|
||||
if (storeRef.current === null) {
|
||||
storeRef.current = {
|
||||
alertStore: createAlertStore(),
|
||||
attachmentStore: createAttachmentStore(),
|
||||
menuStore: createMenuStore(),
|
||||
userStore: createUserStore(),
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<StoreContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</StoreContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createStore } from "zustand/vanilla";
|
||||
|
||||
export type AlertState = {
|
||||
message: string;
|
||||
callback?: () => void;
|
||||
};
|
||||
|
||||
export type AlertActions = {
|
||||
setMessage: (message: string, callback?: () => void) => void;
|
||||
};
|
||||
|
||||
export type AlertStore = AlertState & AlertActions;
|
||||
|
||||
export const defaultInitState: AlertState = {
|
||||
message: "",
|
||||
callback: undefined,
|
||||
};
|
||||
|
||||
export const createAlertStore = (initState: AlertState = defaultInitState) => {
|
||||
return createStore<AlertStore>()((set) => ({
|
||||
...initState,
|
||||
setMessage: (message: string, callback?: () => void) =>
|
||||
set(() => ({ message, callback })),
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createStore } from "zustand/vanilla";
|
||||
|
||||
export type AttachmentState = {
|
||||
attachmentFileCode: string;
|
||||
};
|
||||
|
||||
export type AttachmentActions = {
|
||||
setAttachmentFileCode: (attachmentFileCode: string) => void;
|
||||
};
|
||||
|
||||
export type AttachmentStore = AttachmentState & AttachmentActions;
|
||||
|
||||
export const defaultInitState: AttachmentState = {
|
||||
attachmentFileCode: "",
|
||||
};
|
||||
|
||||
export const createAttachmentStore = (
|
||||
initState: AttachmentState = defaultInitState,
|
||||
) => {
|
||||
return createStore<AttachmentStore>()((set) => ({
|
||||
...initState,
|
||||
setAttachmentFileCode: (attachmentFileCode: string) =>
|
||||
set({ attachmentFileCode }),
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export { createUploadStore, type UploadProgress } from "./upload-store";
|
||||
export { createAlertStore } from "./alert-store";
|
||||
export { createAttachmentStore } from "./attachment-store";
|
||||
export { createMenuStore, type BbsType } from "./menu-store";
|
||||
export { createUserStore, type IUserInfo } from "./user-store";
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createStore } from "zustand/vanilla";
|
||||
|
||||
export const BBS_TYPE = {
|
||||
BBSNOTICE: "BBSNOTICE",
|
||||
BBSNORMAL: "BBSNORMAL",
|
||||
BBSFAQ: "BBSFAQ",
|
||||
BBSIMAGE: "BBSIMAGE",
|
||||
BBSMOVIE: "BBSMOVIE",
|
||||
} as const;
|
||||
|
||||
export type BbsType = (typeof BBS_TYPE)[keyof typeof BBS_TYPE];
|
||||
|
||||
export interface IBbs {
|
||||
bbsId: number;
|
||||
bbsTypeCd: BbsType;
|
||||
bbsTypeNm: string;
|
||||
bbsNm: string;
|
||||
bbsExpln: string;
|
||||
rfrncUseYn: string;
|
||||
atflUseYn: string;
|
||||
atflMaxCnt: number;
|
||||
atflMaxFileSz: number;
|
||||
ansUseYn: string;
|
||||
ansUseAuthrt: string;
|
||||
cmntUseYn: string;
|
||||
dgstfnEvlUseYn: string;
|
||||
smsUseYn: string;
|
||||
upndFixUseYn: string;
|
||||
pstgPrdUseYn: string;
|
||||
popupUseYn: string;
|
||||
delYn: string;
|
||||
useYn: string;
|
||||
mdfcnDt: string;
|
||||
mdfrNm: string;
|
||||
}
|
||||
|
||||
interface IMenu {
|
||||
menuCd: number;
|
||||
menuKornNm: string;
|
||||
menuEngNm: string;
|
||||
parentId: number | null;
|
||||
sortSeq: number;
|
||||
iconNm?: string;
|
||||
children: IMenu[] | null;
|
||||
levelNo: number;
|
||||
urlPath: string;
|
||||
menuLnkgTypeCd: string;
|
||||
menuLnkgSn: number;
|
||||
menuPath: string;
|
||||
qrcd: string;
|
||||
useYn: string;
|
||||
otptYn: string;
|
||||
newWndwYn: string;
|
||||
requiredUrlPath: boolean;
|
||||
}
|
||||
|
||||
interface IAuthority {
|
||||
mngAuthrtYn: string;
|
||||
inqAuthrtYn: string;
|
||||
regAuthrtYn: string;
|
||||
mdfcnAuthrtYn: string;
|
||||
delAuthrtYn: string;
|
||||
}
|
||||
|
||||
export type MenuState = {
|
||||
bbsInfo: IBbs | null;
|
||||
currentMenuInfo: IMenu | null;
|
||||
pageMenuAuthority: IAuthority | null;
|
||||
};
|
||||
|
||||
export type MenuActions = {
|
||||
setBbsInfo: (bbs: IBbs) => void;
|
||||
setCurrentMenuInfo: (menu: IMenu) => void;
|
||||
setPageMenuAuthority: (authority: IAuthority) => void;
|
||||
};
|
||||
|
||||
export type MenuStore = MenuState & MenuActions;
|
||||
|
||||
export const defaultInitState: MenuState = {
|
||||
currentMenuInfo: null,
|
||||
bbsInfo: null,
|
||||
pageMenuAuthority: null,
|
||||
};
|
||||
|
||||
export const createMenuStore = (initState: MenuState = defaultInitState) => {
|
||||
return createStore<MenuStore>()((set) => ({
|
||||
...initState,
|
||||
setBbsInfo: (bbs: IBbs) => set({ bbsInfo: bbs }),
|
||||
setCurrentMenuInfo: (menu: IMenu) => set({ currentMenuInfo: menu }),
|
||||
setPageMenuAuthority: (authority: IAuthority) =>
|
||||
set({ pageMenuAuthority: authority }),
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createUploadStore } from "./upload-store";
|
||||
|
||||
export const uploadStore = createUploadStore();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user