diff --git a/apps/dc/.env.development b/apps/dc/.env.development new file mode 100644 index 000000000..caf72ab14 --- /dev/null +++ b/apps/dc/.env.development @@ -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 + + + + + diff --git a/apps/dc/.gitignore b/apps/dc/.gitignore new file mode 100644 index 000000000..5340dd9a1 --- /dev/null +++ b/apps/dc/.gitignore @@ -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 diff --git a/apps/dc/.prettierrc b/apps/dc/.prettierrc new file mode 100644 index 000000000..b4bfed357 --- /dev/null +++ b/apps/dc/.prettierrc @@ -0,0 +1,3 @@ +{ + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/apps/dc/app/(with-base-layout)/category/page.tsx b/apps/dc/app/(with-base-layout)/category/page.tsx new file mode 100644 index 000000000..a425636a1 --- /dev/null +++ b/apps/dc/app/(with-base-layout)/category/page.tsx @@ -0,0 +1,2 @@ +import { CategoryPage } from "@/pages/category/ui/category-page"; +export default CategoryPage; \ No newline at end of file diff --git a/apps/dc/app/(with-base-layout)/chatbot/page.tsx b/apps/dc/app/(with-base-layout)/chatbot/page.tsx new file mode 100644 index 000000000..63d822116 --- /dev/null +++ b/apps/dc/app/(with-base-layout)/chatbot/page.tsx @@ -0,0 +1,2 @@ +import { ChatBotPage } from "@/pages/chatbot/ui/chatbot-page"; +export default ChatBotPage; \ No newline at end of file diff --git a/apps/dc/app/(with-base-layout)/datainsert/page.tsx b/apps/dc/app/(with-base-layout)/datainsert/page.tsx new file mode 100644 index 000000000..9d4e90347 --- /dev/null +++ b/apps/dc/app/(with-base-layout)/datainsert/page.tsx @@ -0,0 +1,2 @@ +import { DataInsertPage } from "@/pages/datainsert/ui/datainsert-page"; +export default DataInsertPage; \ No newline at end of file diff --git a/apps/dc/app/(with-base-layout)/generalsearch/page.tsx b/apps/dc/app/(with-base-layout)/generalsearch/page.tsx new file mode 100644 index 000000000..e130c5a66 --- /dev/null +++ b/apps/dc/app/(with-base-layout)/generalsearch/page.tsx @@ -0,0 +1,2 @@ +import { GeneralSearchPage } from "@/pages/generalsearch/ui/generalsearch-page"; +export default GeneralSearchPage; \ No newline at end of file diff --git a/apps/dc/app/(with-base-layout)/layout.tsx b/apps/dc/app/(with-base-layout)/layout.tsx new file mode 100644 index 000000000..2ea3e963f --- /dev/null +++ b/apps/dc/app/(with-base-layout)/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/apps/dc/app/(with-base-layout)/mydata/page.tsx b/apps/dc/app/(with-base-layout)/mydata/page.tsx new file mode 100644 index 000000000..25c65c69d --- /dev/null +++ b/apps/dc/app/(with-base-layout)/mydata/page.tsx @@ -0,0 +1,2 @@ +import { MyDataPage } from "@/pages/mydata/ui/mydata-page"; +export default MyDataPage; \ No newline at end of file diff --git a/apps/dc/app/(with-base-layout)/mydata/register/[fileSn]/page.tsx b/apps/dc/app/(with-base-layout)/mydata/register/[fileSn]/page.tsx new file mode 100644 index 000000000..490914d0a --- /dev/null +++ b/apps/dc/app/(with-base-layout)/mydata/register/[fileSn]/page.tsx @@ -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>({ 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) => { + 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); + 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 ; +} \ No newline at end of file diff --git a/apps/dc/app/api/auth/logout/route.ts b/apps/dc/app/api/auth/logout/route.ts new file mode 100644 index 000000000..fdcc9df7c --- /dev/null +++ b/apps/dc/app/api/auth/logout/route.ts @@ -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 }); +} diff --git a/apps/dc/app/api/proxy/[...url]/route.ts b/apps/dc/app/api/proxy/[...url]/route.ts new file mode 100644 index 000000000..d3092c5c1 --- /dev/null +++ b/apps/dc/app/api/proxy/[...url]/route.ts @@ -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 = { + "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"); +} diff --git a/apps/dc/app/auth/login/page.tsx b/apps/dc/app/auth/login/page.tsx new file mode 100644 index 000000000..8f9e6adc6 --- /dev/null +++ b/apps/dc/app/auth/login/page.tsx @@ -0,0 +1,2 @@ +import { LoginPage } from "@/pages/auth/login"; +export default LoginPage; diff --git a/apps/dc/app/layout.tsx b/apps/dc/app/layout.tsx new file mode 100644 index 000000000..aa2049dcc --- /dev/null +++ b/apps/dc/app/layout.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { App } from "@/app"; + +export const metadata: Metadata = { + title: "유역하수도 통합 정보플랫폼", +}; + +export default App; diff --git a/apps/dc/app/page.tsx b/apps/dc/app/page.tsx new file mode 100644 index 000000000..e0eb5fce1 --- /dev/null +++ b/apps/dc/app/page.tsx @@ -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 ( +
+ {/*
+

+ 유역하수도 통합 정보플랫폼 +

+

로딩 중...

+
*/} +
+ ); +} \ No newline at end of file diff --git a/apps/dc/env.d.ts b/apps/dc/env.d.ts new file mode 100644 index 000000000..31ff74044 --- /dev/null +++ b/apps/dc/env.d.ts @@ -0,0 +1,16 @@ +/// + +interface ImportMetaEnv { + readonly VITE_PORT: string; + readonly VITE_PROXY_HOST: string; + readonly VITE_SERVER_API_URL: string; + readonly VITE_SITE_ID: string; + readonly VITE_CLAIM_NAME: string; + readonly VITE_ACCESS_TOKEN: string; + readonly VITE_REFRESH_TOKEN: string; + readonly VITE_AUTH_USER_ID: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/apps/dc/eslint.config.mjs b/apps/dc/eslint.config.mjs new file mode 100644 index 000000000..0dbddfc0b --- /dev/null +++ b/apps/dc/eslint.config.mjs @@ -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; diff --git a/apps/dc/middleware.ts b/apps/dc/middleware.ts new file mode 100644 index 000000000..9a27b0b78 --- /dev/null +++ b/apps/dc/middleware.ts @@ -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*", "/"], +}; diff --git a/apps/dc/next.config.ts b/apps/dc/next.config.ts new file mode 100644 index 000000000..8e70743c0 --- /dev/null +++ b/apps/dc/next.config.ts @@ -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; diff --git a/apps/dc/package.json b/apps/dc/package.json new file mode 100644 index 000000000..2aa9ad781 --- /dev/null +++ b/apps/dc/package.json @@ -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" + } +} \ No newline at end of file diff --git a/apps/dc/pages/.gitkeep b/apps/dc/pages/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/dc/postcss.config.mjs b/apps/dc/postcss.config.mjs new file mode 100644 index 000000000..1a69fd2a4 --- /dev/null +++ b/apps/dc/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + }, +}; + +export default config; diff --git a/apps/dc/public/images/bg_login.jpg b/apps/dc/public/images/bg_login.jpg new file mode 100644 index 000000000..b7793d16a Binary files /dev/null and b/apps/dc/public/images/bg_login.jpg differ diff --git a/apps/dc/public/images/loading_primary.png b/apps/dc/public/images/loading_primary.png new file mode 100644 index 000000000..17b31b642 Binary files /dev/null and b/apps/dc/public/images/loading_primary.png differ diff --git a/apps/dc/src/app/index.tsx b/apps/dc/src/app/index.tsx new file mode 100644 index 000000000..5a73f2fdd --- /dev/null +++ b/apps/dc/src/app/index.tsx @@ -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 ( + + + {children} + + + ); +}; + +export { App }; diff --git a/apps/dc/src/app/providers/app-provider.tsx b/apps/dc/src/app/providers/app-provider.tsx new file mode 100644 index 000000000..f3fe2fb1e --- /dev/null +++ b/apps/dc/src/app/providers/app-provider.tsx @@ -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 ( + + + + {children} + + + + + + ); +}; + +export { AppProvider }; diff --git a/apps/dc/src/app/providers/index.ts b/apps/dc/src/app/providers/index.ts new file mode 100644 index 000000000..15f99b7fd --- /dev/null +++ b/apps/dc/src/app/providers/index.ts @@ -0,0 +1 @@ +export { AppProvider } from "./app-provider"; diff --git a/apps/dc/src/app/styles/base.css b/apps/dc/src/app/styles/base.css new file mode 100644 index 000000000..e24111afe --- /dev/null +++ b/apps/dc/src/app/styles/base.css @@ -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; +} diff --git a/apps/dc/src/app/styles/components.css b/apps/dc/src/app/styles/components.css new file mode 100644 index 000000000..17cd78834 --- /dev/null +++ b/apps/dc/src/app/styles/components.css @@ -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; + } +} diff --git a/apps/dc/src/entities/auth/api/login-service.ts b/apps/dc/src/entities/auth/api/login-service.ts new file mode 100644 index 000000000..c1968fe36 --- /dev/null +++ b/apps/dc/src/entities/auth/api/login-service.ts @@ -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 { + try { + const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { + method: "POST", + body: JSON.stringify({ + userId: payload.userId, + password: payload.password, + }), + }); + + const data = await response.json(); + const accessToken = data[ACCESS_TOKEN]; + const tokenId = data[AUTH_USER_ID]; + + if (!accessToken || !tokenId) { + throw new Error("Missing required authentication tokens"); + } + + tokenManager.setToken(CLAIM_NAME, accessToken); + tokenManager.setToken(AUTH_USER_ID, tokenId); + tokenManager.setupRefreshTimer( + () => this.silentRefresh(), + JWT_EXPIRATION_TIME, + ); + } catch (error) { + if (error instanceof AxiosError) { + throw new Error(`Login failed: ${error.response?.data?.message}`); + } + throw new Error(`Login failed: ${error}`); + } + } + + public async silentRefresh(): Promise { + try { + const response = await fetch( + `/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, + { method: "PUT" }, + ); + + const data = await response.json(); + const accessToken = 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(); diff --git a/apps/dc/src/entities/auth/index.ts b/apps/dc/src/entities/auth/index.ts new file mode 100644 index 000000000..97e142225 --- /dev/null +++ b/apps/dc/src/entities/auth/index.ts @@ -0,0 +1,2 @@ +export { LoginApi } from "./api/login-service"; +export { AuthWrapper } from "./lib/auth-wrapper"; diff --git a/apps/dc/src/entities/auth/lib/auth-wrapper.tsx b/apps/dc/src/entities/auth/lib/auth-wrapper.tsx new file mode 100644 index 000000000..1f196ed52 --- /dev/null +++ b/apps/dc/src/entities/auth/lib/auth-wrapper.tsx @@ -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( + `${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 }; diff --git a/apps/dc/src/entities/auth/lib/token-manager.ts b/apps/dc/src/entities/auth/lib/token-manager.ts new file mode 100644 index 000000000..21ed00218 --- /dev/null +++ b/apps/dc/src/entities/auth/lib/token-manager.ts @@ -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, + expirationTime: number, + ): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + + this.refreshTimer = setTimeout(() => { + callback().catch((error) => { + console.error("Silent refresh failed:", error); + this.clearAuthToken(); + }); + }, expirationTime - 60000); + } + + public clearAuthToken(): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + + this.removeToken(CLAIM_NAME); + this.removeToken(AUTH_USER_ID); + } +} + +export const tokenManager = TokenManager.getInstance(); diff --git a/apps/dc/src/entities/auth/model/login-interface.ts b/apps/dc/src/entities/auth/model/login-interface.ts new file mode 100644 index 000000000..5aaf53044 --- /dev/null +++ b/apps/dc/src/entities/auth/model/login-interface.ts @@ -0,0 +1,9 @@ +export interface ILogin { + userId: string; + password: string; +} + +export interface LoginService { + login(payload: ILogin): Promise; + silentRefresh(): Promise; +} diff --git a/apps/dc/src/entities/datalibrary/api/category-service.ts b/apps/dc/src/entities/datalibrary/api/category-service.ts new file mode 100644 index 000000000..28186f0c3 --- /dev/null +++ b/apps/dc/src/entities/datalibrary/api/category-service.ts @@ -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 { + const response = await dcClient.get( + `${ENDPOINT.DC_SERVICE.CATEGORY}/clsf`, + { params }, + ); + return response.data; + } + + public async createCategory(data: { upClsfSystmSn: string; clsfSystmNm: string; rgtrId: string }): Promise { + await dcClient.post(`${ENDPOINT.DC_SERVICE.CATEGORY}/clsf`, data); + } + + // 분류 수정 + public async updateCategory(data: { clsfSystmSn: string; clsfSystmNm: string; mdfrId: string }): Promise { + await dcClient.put(`${ENDPOINT.DC_SERVICE.CATEGORY}/clsf`, data); + } + + // 분류 삭제 + public async deleteCategory(clsfSystmSn: string): Promise { + await dcClient.delete(`${ENDPOINT.DC_SERVICE.CATEGORY}/clsf`, { data: { clsfSystmSn } }); + } +} + +export const CategoryApi = new CategoryApiService(); \ No newline at end of file diff --git a/apps/dc/src/entities/datalibrary/api/dc-service.ts b/apps/dc/src/entities/datalibrary/api/dc-service.ts new file mode 100644 index 000000000..eea90db6f --- /dev/null +++ b/apps/dc/src/entities/datalibrary/api/dc-service.ts @@ -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"; \ No newline at end of file diff --git a/apps/dc/src/entities/datalibrary/api/file-service.ts b/apps/dc/src/entities/datalibrary/api/file-service.ts new file mode 100644 index 000000000..d2f340f26 --- /dev/null +++ b/apps/dc/src/entities/datalibrary/api/file-service.ts @@ -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 { + 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 { + 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 { + const res = await dcClient.get(`${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); +} \ No newline at end of file diff --git a/apps/dc/src/entities/datalibrary/api/flask-service.ts b/apps/dc/src/entities/datalibrary/api/flask-service.ts new file mode 100644 index 000000000..ac4bcb74b --- /dev/null +++ b/apps/dc/src/entities/datalibrary/api/flask-service.ts @@ -0,0 +1,112 @@ +import { FLASK_API_URL } from "@/shared/config/env"; + +// PDF 변환 API +export async function convertToPdf(file: File, fileSn: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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(); +} \ No newline at end of file diff --git a/apps/dc/src/entities/datalibrary/api/mydata-service.ts b/apps/dc/src/entities/datalibrary/api/mydata-service.ts new file mode 100644 index 000000000..c4e0f886f --- /dev/null +++ b/apps/dc/src/entities/datalibrary/api/mydata-service.ts @@ -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): Promise { + const res = await dcClient.get(`${ENDPOINT.DC_SERVICE.CATEGORY}/mydata`, params ? { params } : undefined); + return res.data; + }, + // 등록 + async create(data: Omit): Promise { + await dcClient.post("/api/v1/dc/mydata/datacode", data); + }, + // 수정 + async update(data: Record): Promise { + await dcClient.put(`${ENDPOINT.DC_SERVICE.CATEGORY}/mydata/datacode`, data); + }, + // 삭제 + async remove(fileSn: string): Promise { + await dcClient.delete(`${ENDPOINT.DC_SERVICE.CATEGORY}/mydata/${fileSn}`); + }, + // 단건 상세 조회 (fileSn, rgtrId) + async detail(fileSn: string, rgtrId: string): Promise { + 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; + }, +}; \ No newline at end of file diff --git a/apps/dc/src/entities/datalibrary/api/processing-service.ts b/apps/dc/src/entities/datalibrary/api/processing-service.ts new file mode 100644 index 000000000..aa8558f2e --- /dev/null +++ b/apps/dc/src/entities/datalibrary/api/processing-service.ts @@ -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 { + 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 { + await dcClient.post(`${ENDPOINT.DC_SERVICE.CATEGORY}/savePdfPath`, { + fileSn: params.fileSn, + pdfPath: params.pdfPath + }); +} + +// 임베딩 결과 저장 +export async function saveEmbeddingResult(params: { + fileSn: string; +}): Promise { + await dcClient.post(`${ENDPOINT.DC_SERVICE.CATEGORY}/saveEmbeddingResult`, { + fileSn: params.fileSn + }); +} \ No newline at end of file diff --git a/apps/dc/src/entities/datalibrary/api/search-service.ts b/apps/dc/src/entities/datalibrary/api/search-service.ts new file mode 100644 index 000000000..b8d4d10af --- /dev/null +++ b/apps/dc/src/entities/datalibrary/api/search-service.ts @@ -0,0 +1,8 @@ +import { dcClient } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +// 일반검색(자료검색) API +export async function getGeneralSearchResult(query: string): Promise { + const res = await dcClient.get(`${ENDPOINT.DC_SERVICE.CATEGORY}/data`, { params: { query } }); + return res.data; +} \ No newline at end of file diff --git a/apps/dc/src/entities/datalibrary/index.ts b/apps/dc/src/entities/datalibrary/index.ts new file mode 100644 index 000000000..0bee40fff --- /dev/null +++ b/apps/dc/src/entities/datalibrary/index.ts @@ -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"; \ No newline at end of file diff --git a/apps/dc/src/entities/datalibrary/model/dc-interface.ts b/apps/dc/src/entities/datalibrary/model/dc-interface.ts new file mode 100644 index 000000000..4e15b08d3 --- /dev/null +++ b/apps/dc/src/entities/datalibrary/model/dc-interface.ts @@ -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; +} + +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; +} \ No newline at end of file diff --git a/apps/dc/src/entities/menu-authority/@x/menu.ts b/apps/dc/src/entities/menu-authority/@x/menu.ts new file mode 100644 index 000000000..86479f859 --- /dev/null +++ b/apps/dc/src/entities/menu-authority/@x/menu.ts @@ -0,0 +1 @@ +export { MenuAuthorityApi } from "../api/menu-authority-service"; diff --git a/apps/dc/src/entities/menu-authority/@x/posting.ts b/apps/dc/src/entities/menu-authority/@x/posting.ts new file mode 100644 index 000000000..bfe84fa0d --- /dev/null +++ b/apps/dc/src/entities/menu-authority/@x/posting.ts @@ -0,0 +1 @@ +export { AuthorityButton } from "../ui/authority-button"; diff --git a/apps/dc/src/entities/menu-authority/api/menu-authority-service.ts b/apps/dc/src/entities/menu-authority/api/menu-authority-service.ts new file mode 100644 index 000000000..ec07d88ad --- /dev/null +++ b/apps/dc/src/entities/menu-authority/api/menu-authority-service.ts @@ -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 { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}`, + { params }, + ); + return response.data; + } + + public async getPageMenuAuthority( + menuCd: number, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}/pageinfo/${menuCd}`, + ); + return response.data; + } + + public async saveMenuAuthority(data: MenuAuthorityPayload[]): Promise { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}`, + data, + ); + return response.data; + } + + public async getGroupMenuAuthorityList( + params: MenuAuthorityParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}/group`, + { params }, + ); + return response.data; + } +} + +export const MenuAuthorityApi = new MenuAuthorityApiService(); diff --git a/apps/dc/src/entities/menu-authority/index.ts b/apps/dc/src/entities/menu-authority/index.ts new file mode 100644 index 000000000..5a1b11606 --- /dev/null +++ b/apps/dc/src/entities/menu-authority/index.ts @@ -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"; diff --git a/apps/dc/src/entities/menu-authority/model/menu-authority-interface.ts b/apps/dc/src/entities/menu-authority/model/menu-authority-interface.ts new file mode 100644 index 000000000..7f5401872 --- /dev/null +++ b/apps/dc/src/entities/menu-authority/model/menu-authority-interface.ts @@ -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; +export type MenuAuthorityList = PageableList; +export type MenuAuthorityPayload = z.infer; +export type MenuAuthorityFormData = z.infer; + +export interface MenuAuthorityService { + getMenuAuthorityList(params: MenuAuthorityParams): Promise; + getGroupMenuAuthorityList( + params: MenuAuthorityParams, + ): Promise; + getPageMenuAuthority(menuCd: number): Promise; + saveMenuAuthority(data: MenuAuthorityPayload[]): Promise; +} diff --git a/apps/dc/src/entities/menu-authority/model/menu-authority-table-columns.tsx b/apps/dc/src/entities/menu-authority/model/menu-authority-table-columns.tsx new file mode 100644 index 000000000..6969a85e9 --- /dev/null +++ b/apps/dc/src/entities/menu-authority/model/menu-authority-table-columns.tsx @@ -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, +): ColumnDef[] => { + 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 ( + { + 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 ( + { + if (checked) { + handleCheckedChange( + row.index, + "inqAuthrtYn", + )(checked as boolean); + } + handleCheckedChange(row.index, "regAuthrtYn")(checked as boolean); + }} + /> + ); + }, + }, + { + accessorKey: "mdfcnAuthrtYn", + header: "수정권한 ", + cell: ({ row }) => { + return ( + + ); + }, + }, + { + accessorKey: "delAuthrtYn", + header: "삭제권한", + cell: ({ row }) => { + return ( + + ); + }, + }, + { + accessorKey: "inqAuthrtYn", + header: "조회권한", + cell: ({ row }) => { + return ( + + ); + }, + }, + ]; +}; diff --git a/apps/dc/src/entities/menu-authority/ui/authority-button.tsx b/apps/dc/src/entities/menu-authority/ui/authority-button.tsx new file mode 100644 index 000000000..257ab6e5d --- /dev/null +++ b/apps/dc/src/entities/menu-authority/ui/authority-button.tsx @@ -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 ( + + ); +} + +export { AuthorityButton }; diff --git a/apps/dc/src/entities/menu-authority/ui/management-authority-dialog.tsx b/apps/dc/src/entities/menu-authority/ui/management-authority-dialog.tsx new file mode 100644 index 000000000..2d4f6a176 --- /dev/null +++ b/apps/dc/src/entities/menu-authority/ui/management-authority-dialog.tsx @@ -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; + +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({ + 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 ( + + + + + + + 그룹별 메뉴 권한 + +
{tableComponent}
+ + + + + + +
+
+ ); +} + +export { ManagementAuthorityDialog }; diff --git a/apps/dc/src/entities/user/api/user-service.ts b/apps/dc/src/entities/user/api/user-service.ts new file mode 100644 index 000000000..482d51e16 --- /dev/null +++ b/apps/dc/src/entities/user/api/user-service.ts @@ -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 { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}`, + { params }, + ); + return response.data; + } + + // 사용자 단건 조회 + public async findByUserUnqId(userUnqId: string): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`, + ); + return response.data; + } + + // 로그인한 사용자 정보 조회 + public async findByUserUnqIdByLogin(): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/info/me`, + ); + return response.data; + } + + // 사용자 회원상태 통계 조회 + public async selectUserSttsStats(): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/stts/stats`, + ); + return response.data; + } + + // 사용자 저장 + public async saveUser(data: UserSaveData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}`, + data, + ); + return response.data; + } + + // 사용자 수정 + public async updateUser( + userUnqId: string, + data: UserUpdateData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`, + data, + ); + return response.data; + } + + // 사용자 삭제 + public async deleteUser(userUnqId: string): Promise { + const response = await client.delete( + `${ENDPOINT.USER_SERVICE.USERS}/delete/${userUnqId}`, + ); + return response.data; + } + + // 사용자 계정잠금 처리 + public async lockUser(userUnqId: string): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/lock/${userUnqId}`, + ); + return response.data; + } + + // 사용자 계정잠금 해제처리 + public async unlockUser(userUnqId: string): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/unlock/${userUnqId}`, + ); + return response.data; + } + + // 리프레시 토큰 정보 재발급 + public async refreshToken(): Promise { + await client.put(`${ENDPOINT.USER_SERVICE.USERS}/token/refresh`); + } + + // 사용자 비밀번호 변경 + public async updatePassword(data: UserPasswordUpdateData): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/password/update`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 초기화(관리자용) + public async initializePassword( + userUnqId: string, + data: UserPasswordUpdateData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/password/initialize/${userUnqId}`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 찾기 변경 + public async changePassword( + data: UserFindPasswordUpdateData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/password/change`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 확인 + public async matchPassword(data: UserPasswordMatchData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/password/match`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 찾기 + public async findPasswordUser( + data: UserFindPasswordSaveData, + ): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/password/find`, + data, + ); + return response.data; + } + + // 사용자 비밀번호 찾기 유효성 확인 + public async validPassword(token: string): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/password/valid/${token}`, + ); + return response.data; + } + + // 사용자 회원정보 변경 + public async updateInfo( + userUnqId: string, + data: UserUpdateInfoData, + ): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/info/${userUnqId}`, + data, + ); + return response.data; + } + + // 사용자 승인처리 + public async applyUser(userUnqId: string): Promise { + const response = await client.put( + `${ENDPOINT.USER_SERVICE.USERS}/apply/${userUnqId}`, + ); + return response.data; + } + + // 사용자 회원탈퇴 + public async leaveUser(data: UserVerifyData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/leave`, + data, + ); + return response.data; + } + + // 사용자 이메일 중복 체크 + public async existsEmail(data: UserEmailData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/exists`, + data, + ); + return response.data; + } + + // 사용자 아이디 중복 체크 + public async existsUserId(data: UserIdData): Promise { + const response = await client.post( + `${ENDPOINT.USER_SERVICE.USERS}/existsUserId`, + data, + ); + return response.data; + } + + // 관리자 목록 조회 + public async findPageAdminUser( + params: UserSearchParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/admin/list`, + { params }, + ); + return response.data; + } +} + +export const UserApi = new UserApiService(); diff --git a/apps/dc/src/entities/user/index.ts b/apps/dc/src/entities/user/index.ts new file mode 100644 index 000000000..71bf26baa --- /dev/null +++ b/apps/dc/src/entities/user/index.ts @@ -0,0 +1,2 @@ +export * from "./api/user-service"; +export * from "./model/user-interface"; diff --git a/apps/dc/src/entities/user/model/user-interface.ts b/apps/dc/src/entities/user/model/user-interface.ts new file mode 100644 index 000000000..012fe9e92 --- /dev/null +++ b/apps/dc/src/entities/user/model/user-interface.ts @@ -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; +export type UserPasswordUpdateData = z.infer; +export type UserFindPasswordUpdateData = z.infer< + typeof userFindPasswordUpdateSchema +>; +export type UserPasswordMatchData = z.infer; +export type UserUpdateInfoData = z.infer; +export type UserSaveData = z.infer; +export type UserFindPasswordSaveData = z.infer< + typeof userFindPasswordSaveSchema +>; +export type UserVerifyData = z.infer; +export type UserEmailData = z.infer; +export type UserIdData = z.infer; + +// 페이지네이션 타입 정의 +export type UserList = PageableList; +export type UserSttsStatsList = UserSttsStatsResponse[]; +export type AdminUserList = PageableList; + +// 사용자 서비스 인터페이스 +export interface UserService { + // 사용자 목록 조회 + findPageUser: (params: UserSearchParams) => Promise; + + // 사용자 단건 조회 + findByUserUnqId: (userUnqId: string) => Promise; + + // 로그인한 사용자 정보 조회 + findByUserUnqIdByLogin: () => Promise; + + // 사용자 회원상태 통계 조회 + selectUserSttsStats: () => Promise; + + // 사용자 저장 + saveUser: (data: UserSaveData) => Promise; + + // 사용자 수정 + updateUser: (userUnqId: string, data: UserUpdateData) => Promise; + + // 사용자 삭제 + deleteUser: (userUnqId: string) => Promise; + + // 사용자 계정잠금 처리 + lockUser: (userUnqId: string) => Promise; + + // 사용자 계정잠금 해제처리 + unlockUser: (userUnqId: string) => Promise; + + // 리프레시 토큰 정보 재발급 + refreshToken: () => Promise; + + // 사용자 비밀번호 변경 + updatePassword: (data: UserPasswordUpdateData) => Promise; + + // 사용자 비밀번호 초기화(관리자용) + initializePassword: ( + userUnqId: string, + data: UserPasswordUpdateData, + ) => Promise; + + // 사용자 비밀번호 찾기 변경 + changePassword: (data: UserFindPasswordUpdateData) => Promise; + + // 사용자 비밀번호 확인 + matchPassword: (data: UserPasswordMatchData) => Promise; + + // 사용자 비밀번호 찾기 + findPasswordUser: (data: UserFindPasswordSaveData) => Promise; + + // 사용자 비밀번호 찾기 유효성 확인 + validPassword: (token: string) => Promise; + + // 사용자 회원정보 변경 + updateInfo: (userUnqId: string, data: UserUpdateInfoData) => Promise; + + // 사용자 승인처리 + applyUser: (userUnqId: string) => Promise; + + // 사용자 회원탈퇴 + leaveUser: (data: UserVerifyData) => Promise; + + // 사용자 이메일 중복 체크 + existsEmail: (data: UserEmailData) => Promise; + + // 사용자 아이디 중복 체크 + existsUserId: (data: UserIdData) => Promise; + + // 관리자 목록 조회 + findPageAdminUser: (params: UserSearchParams) => Promise; +} diff --git a/apps/dc/src/pages/auth/login/index.ts b/apps/dc/src/pages/auth/login/index.ts new file mode 100644 index 000000000..2093c3d8d --- /dev/null +++ b/apps/dc/src/pages/auth/login/index.ts @@ -0,0 +1 @@ +export { LoginPage } from "./ui/login-page"; diff --git a/apps/dc/src/pages/auth/login/ui/login-page.tsx b/apps/dc/src/pages/auth/login/ui/login-page.tsx new file mode 100644 index 000000000..92f5935ed --- /dev/null +++ b/apps/dc/src/pages/auth/login/ui/login-page.tsx @@ -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) => { + 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 ( +
+
+
+

+ 하수도정보시스템 +
+ CMS 통합관리시스템 +

+

하수도정보시스템 CMS 통합관리시스템에 오신 것을 환영합니다.

+
+
+ logo +
+
+
+
+
    +
  • +

    ID

    +
    + + +
    +
  • +
  • +

    PASSWORD

    +
    + + +
    +
  • +
+
+
+ + +
+ {/* 비밀번호 또는 아이디를 잃어버리셨나요? */} +
+ +
+
+

copyright © 2023 KECO. All rights reserved.

+
+ +

+ + 주소 : (22689) 인천광역시 서구 환경로 42(경서동 + 종합환경연구단지) + + + 전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723, + 3722 + + 팩스 :032-590-3729 +

+
+
+ +
+ ); +} + +export { LoginPage }; diff --git a/apps/dc/src/pages/auth/login/ui/login.css b/apps/dc/src/pages/auth/login/ui/login.css new file mode 100644 index 000000000..831cc13a9 --- /dev/null +++ b/apps/dc/src/pages/auth/login/ui/login.css @@ -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 */ diff --git a/apps/dc/src/pages/category/index.ts b/apps/dc/src/pages/category/index.ts new file mode 100644 index 000000000..200388dc2 --- /dev/null +++ b/apps/dc/src/pages/category/index.ts @@ -0,0 +1 @@ +export { CategoryPage } from "./ui/category-page"; \ No newline at end of file diff --git a/apps/dc/src/pages/category/ui/category-dialog.tsx b/apps/dc/src/pages/category/ui/category-dialog.tsx new file mode 100644 index 000000000..7dd199e8c --- /dev/null +++ b/apps/dc/src/pages/category/ui/category-dialog.tsx @@ -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 ( + { if (!open) onCancel(); }}> + +
+

+ {message} +

+
+ + + + + + +
+
+ ); +} \ No newline at end of file diff --git a/apps/dc/src/pages/category/ui/category-page.tsx b/apps/dc/src/pages/category/ui/category-page.tsx new file mode 100644 index 000000000..29f8ef71b --- /dev/null +++ b/apps/dc/src/pages/category/ui/category-page.tsx @@ -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[]>([]); + const [selectedParentNode, setSelectedParentNode] = useState | null>(null); + const [selectedSubNode, setSelectedSubNode] = useState | null>(null); + const [categoryQuery, setSearchText] = useState({ + clsfSystmNm: "", + }); + const [newCategoryName, setNewCategoryName] = useState(""); + const [loading, setLoading] = useState(false); + const { setMessage: showAlert } = useAlertStore((state) => state); + const { confirm } = useConfirm(); + const [dialog, setDialog] = useState(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(null); + const [showCategoryNameError, setShowCategoryNameError] = useState(false); + const [isAddingMode, setIsAddingMode] = useState(false); + const [addingParentNode, setAddingParentNode] = useState | 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[]) => { + 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) => { + 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(); + 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) => { + 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(); + + 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[]) => { + return nodes.reduce((acc: NodeModel[], 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 ( + +
+
+
+ + setSearchTerm(e.target.value)} + /> + + +
+
+
+
+
+

자료관 분류 트리

+
+
{ + // 파일 드래그는 방지하되, 트리 내부 드래그는 허용 + 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(); + } + }} + > + + 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 ( +
{ + 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 && ( + { + // 파일 드래그는 방지하되, 트리 내부 드래그는 허용 + 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 ? ( + + ) : ( + + )} + + )} + {node.text} +
+ ); + }} + /> +
+
+
+
+
+

분류 추가

+
+
+
+
+ + +
+ +
+ + { + setNewCategoryName(e.target.value); + if (showCategoryNameError && e.target.value.trim()) { + setShowCategoryNameError(false); + } + }} + readOnly={false} + /> + {showCategoryNameError && ( +

+ )} +
+
+
+
+ {isAddingMode ? ( + <> + + + + ) : ( + <> + + + + + )} +
+
+
+
+
+
+ +
+ ); +} + +export { CategoryPage }; \ No newline at end of file diff --git a/apps/dc/src/pages/chatbot/index.ts b/apps/dc/src/pages/chatbot/index.ts new file mode 100644 index 000000000..c9dc843f7 --- /dev/null +++ b/apps/dc/src/pages/chatbot/index.ts @@ -0,0 +1 @@ +export { ChatBotPage } from "./ui/chatbot-page"; \ No newline at end of file diff --git a/apps/dc/src/pages/chatbot/ui/chatbot-page.tsx b/apps/dc/src/pages/chatbot/ui/chatbot-page.tsx new file mode 100644 index 000000000..60e014173 --- /dev/null +++ b/apps/dc/src/pages/chatbot/ui/chatbot-page.tsx @@ -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([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const [sessionId, setSessionId] = useState(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) => { + e.preventDefault(); + if (!input.trim() || loading) return; + + const question = input.trim(); + setMessages(prev => [...prev, { role: "user", text: question }]); + setInput(""); + + // RAG 요청 + handleRagRequest(question); + }; + + return ( + +
+
+

하수도 GPT

+
+ +
+ {messages.map((msg, index) => ( +
+ + {msg.role === "user" ? ( + + ) : ( + + )} + + + {msg.text} + {/* 출처 표시: bot 답변에만 */} + {msg.role === "bot" && msg.sources && msg.sources.length > 0 && ( +
+
+ 🔗 출처 +
+
    + {msg.sources.map((src, i) => ( +
  • + +
  • + ))} +
+
+ )} +
+
+ ))} + + {/* 로딩 상태 표시 - 실시간 답변 생성 중에는 표시하지 않음 */} + {loading && messages.length === 0 && ( +
+ + + + + 답변을 생성하고 있습니다... + +
+ )} +
+ +
+ setInput(e.target.value)} + disabled={loading} + /> + +
+
+
+ ); +} + +export { ChatBotPage }; \ No newline at end of file diff --git a/apps/dc/src/pages/datainsert/index.ts b/apps/dc/src/pages/datainsert/index.ts new file mode 100644 index 000000000..ba99d58d9 --- /dev/null +++ b/apps/dc/src/pages/datainsert/index.ts @@ -0,0 +1 @@ +export { DataInsertPage } from "./ui/datainsert-page"; \ No newline at end of file diff --git a/apps/dc/src/pages/datainsert/ui/datainsert-dialog.tsx b/apps/dc/src/pages/datainsert/ui/datainsert-dialog.tsx new file mode 100644 index 000000000..2e7ca817d --- /dev/null +++ b/apps/dc/src/pages/datainsert/ui/datainsert-dialog.tsx @@ -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 ( + { if (!open) onCancel(); }}> + +
+

+ {message} +

+
+ + + + + + +
+
+ ); +} \ No newline at end of file diff --git a/apps/dc/src/pages/datainsert/ui/datainsert-page.tsx b/apps/dc/src/pages/datainsert/ui/datainsert-page.tsx new file mode 100644 index 000000000..c1e6024a0 --- /dev/null +++ b/apps/dc/src/pages/datainsert/ui/datainsert-page.tsx @@ -0,0 +1,1050 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { PageLayout } from "@/shared/ui/page-layout"; +import { Button } from "@common/components"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; +import { Folder, FileText, Info, Monitor, Download, Filter, ChevronDown, ChevronRight, ChevronLeft, HardDrive, FileDigit, AlertCircle, X } from "lucide-react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { CategoryApi } from "@/entities/datalibrary"; +import { useUserStore } from "@/shared/lib"; +import { uploadFile, listFiles, getFileDetail, downloadFile } from "@/entities/datalibrary/api/file-service"; +import { saveImagePath, savePdfPath, saveEmbeddingResult } from "@/entities/datalibrary/api/processing-service"; +import { convertToPdf, pdfToImages, embedDocuments, getImageList, getImagePreviewUrl } from "@/entities/datalibrary/api/flask-service"; + +import { DataInsertDialog } from "./datainsert-dialog"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; +import { useUploadStore } from "@/shared/lib/use-upload-store"; +import type { FileItem, FileDetail } from "@/entities/datalibrary/model/dc-interface"; +import { getUserByUnqId } from "@/shared/api"; +import QRCode from "react-qr-code"; +import { ENDPOINT } from "@/shared/config"; + +function DataInsertPage() { + const [files, setFiles] = useState([]); + const [sortOption, setSortOption] = useState<"latest" | "oldest">("latest"); + const [uploadedFiles, setUploadedFiles] = useState([]); + const [uploadSuccess, setUploadSuccess] = useState(false); + + // 트리 데이터 및 선택 상태 추가 + const [treeNodes, setTreeNodes] = useState([]); + const [selectedCategory, setSelectedCategory] = useState(null); + + // 파일 input ref + const fileInputRef = useRef(null); + + // files 상태 변화 모니터링 + useEffect(() => { + console.log('=== files 상태 변화 감지 ==='); + console.log('변경된 시간:', new Date().toISOString()); + console.log('현재 files 길이:', files.length); + console.log('files 내용:', files.map(f => ({ name: f.name, size: f.size }))); + console.log('등록 버튼 활성화 상태:', files.length > 0 ? '활성화' : '비활성화'); + console.log('========================'); + }, [files]); + + // 펼침 상태 관리 + const [expandedIds, setExpandedIds] = useState([]); + + // 상태 추가 + const [prodDept, setProdDept] = useState(""); + const [prodSystem, setProdSystem] = useState(""); + const userUnqId = useUserStore((state) => state.userInfo?.userUnqId || ""); + const userId = useUserStore((state) => state.userInfo?.userId || ""); + + // 다이얼로그 상태 + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogLoading, setDialogLoading] = useState(false); + const { setMessage: showAlert } = useAlertStore((state) => state); + + // 로딩 오버레이 상태 + const [loadingOverlay, setLoadingOverlay] = useState(false); + const [resultMessage, setResultMessage] = useState(null); + + // 파일 목록 상태 + const [selectedFileSn, setSelectedFileSn] = useState(null); + + // 등록자 UUID → userId 매핑 상태 + const [userIdMap, setUserIdMap] = useState>({}); + + // 필수값 에러 상태 + const [showProdDeptError, setShowProdDeptError] = useState(false); + const [showCategoryError, setShowCategoryError] = useState(false); + + // 전역 업로드 스토어 사용 + const { + isUploading, + uploadProgress, + showProgressModal, + startUpload, + updateProgress, + setShowProgressModal, + completeUpload + } = useUploadStore(); + + // 파일 목록 API + const { + data: fileList, + isLoading: fileListLoading, + error: fileListError, + refetch: refetchFileList, + } = useQuery({ + queryKey: ["fileList", sortOption, selectedCategory?.clsfSystmSn], + queryFn: () => selectedCategory?.clsfSystmSn ? listFiles(sortOption, selectedCategory.clsfSystmSn) : Promise.resolve([]), + enabled: !!selectedCategory?.clsfSystmSn, + }); + + // 파일 상세 API + const { + data: fileDetail, + isLoading: fileDetailLoading, + error: fileDetailError, + refetch: refetchFileDetail, + } = useQuery({ + queryKey: ["fileDetail", selectedFileSn], + queryFn: () => selectedFileSn ? getFileDetail(selectedFileSn) : Promise.resolve(null), + enabled: !!selectedFileSn, + }); + + // 파일 선택 핸들러 (드래그&드랍 없이 클릭만 허용) + const handleFileChange = (e: React.ChangeEvent) => { + console.log('=== 파일 선택 시작 ==='); + console.log('이벤트 발생 시간:', new Date().toISOString()); + + if (e.target.files) { + console.log('선택된 파일 개수:', e.target.files.length); + console.log('선택된 파일들:', Array.from(e.target.files).map(f => ({ name: f.name, size: f.size }))); + + const startTime = performance.now(); + const fileArray = Array.from(e.target.files); + const endTime = performance.now(); + + console.log('Array.from 변환 소요 시간:', (endTime - startTime).toFixed(2), 'ms'); + console.log('변환된 파일 배열:', fileArray); + + setFiles(fileArray); + setUploadSuccess(false); + + console.log('setFiles 호출 완료'); + console.log('현재 files 상태 길이:', fileArray.length); + } else { + console.log('선택된 파일이 없습니다.'); + } + }; + + // 드래그&드랍 핸들러 + const [isDragOver, setIsDragOver] = useState(false); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(true); + }; + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + // 드래그가 실제로 영역을 벗어났는지 확인 + const rect = e.currentTarget.getBoundingClientRect(); + const x = e.clientX; + const y = e.clientY; + + if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) { + setIsDragOver(false); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + + console.log('=== 드래그&드롭 파일 처리 ==='); + console.log('드롭 시간:', new Date().toISOString()); + + const droppedFiles = Array.from(e.dataTransfer.files); + console.log('드롭된 파일 개수:', droppedFiles.length); + console.log('드롭된 파일들:', droppedFiles.map(f => ({ name: f.name, size: f.size }))); + + if (droppedFiles.length > 0) { + setFiles(droppedFiles); + setUploadSuccess(false); + console.log('드롭 파일 setFiles 호출 완료'); + } + }; + + // 카테고리 트리 데이터 패치 + const { data: categoryList } = useQuery({ + queryKey: ["categoryList"], + queryFn: () => CategoryApi.getCategoryList({}), + }); + + // 트리 구조 정렬 함수 (영문순서, 한글순서 정렬 적용) + function sortTreeNodes(nodes: any[]): any[] { + if (!Array.isArray(nodes)) return nodes; + + // 영문순서, 한글순서로 정렬 + const sortedNodes = nodes.sort((a, b) => { + const textA = a.clsfSystmNm || ""; + const textB = b.clsfSystmNm || ""; + + // 영문이 먼저 오도록 정렬 + 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']); + }); + + // 각 노드의 자식들도 재귀적으로 정렬 + return sortedNodes.map((node) => ({ + ...node, + children: node.children && node.children.length > 0 ? sortTreeNodes(node.children) : [] + })); + } + + useEffect(() => { + if (Array.isArray(categoryList)) { + const sortedTreeNodes = sortTreeNodes(categoryList); + setTreeNodes(sortedTreeNodes); + } + }, [categoryList]); + + // 펼침/접기 토글 함수 + const handleToggle = (id: string) => { + setExpandedIds(prev => + prev.includes(id) ? prev.filter(eid => eid !== id) : [...prev, id] + ); + }; + + // 단순 트리 컴포넌트 (펼침/접기 지원) + function SimpleTree({ nodes, selectedId, onSelect, depth = 0 }: { nodes: any[]; selectedId: string; onSelect: (node: any) => void; depth?: number }): JSX.Element { + return ( +
    e.preventDefault()} + onDrop={(e) => e.preventDefault()} + onDragEnter={(e) => e.preventDefault()} + onDragLeave={(e) => e.preventDefault()} + > + {nodes.map((node: any) => { + const hasChildren = node.children && node.children.length > 0; + const isExpanded = expandedIds.includes(node.clsfSystmSn); + const isSelected = selectedId === node.clsfSystmSn; + + return ( +
  • +
    onSelect(node)} + > + {hasChildren && ( + + )} + + {node.clsfSystmNm} +
    + {hasChildren && isExpanded && ( + + )} +
  • + ); + })} +
+ ); + } + + // 백그라운드 업로드 처리 함수 + const processBackgroundUpload = async (filesToUpload: File[]) => { + startUpload(filesToUpload.length); + + try { + // 1. 파일 업로드 및 file_sn 배열 반환 + updateProgress({ + currentStep: "파일 업로드 중...", + fileName: filesToUpload[0]?.name || "", + currentStepIndex: 0 + }); + + const fileSnList = await uploadFile({ + files: filesToUpload, + clsfSystmSn: selectedCategory.clsfSystmSn, + prodDept, + prodSystem, + userUnqId, + userId, + }); + + // 2. 각 파일별 처리 (PDF 변환, 이미지 변환, 임베딩) + for (let i = 0; i < filesToUpload.length; i++) { + const fileSn = fileSnList[i]; + const originFile = filesToUpload[i]; + + if (!fileSn || !originFile) continue; + + updateProgress({ + currentFile: i + 1, + currentStep: "PDF 변환 중...", + fileName: originFile.name, + currentStepIndex: 1 + }); + + try { + // 1) PDF 변환 요청 + const pdfData = await convertToPdf(originFile, fileSn); + + if (!pdfData.converted_files || pdfData.converted_files.length === 0) continue; + const convertedFile = pdfData.converted_files[0]; + if (!convertedFile.pdf_path) continue; + + // PDF 경로를 데이터베이스에 저장 + await savePdfPath({ + fileSn: fileSn, + pdfPath: convertedFile.pdf_path + }); + + updateProgress({ + currentStep: "이미지 변환 중...", + fileName: originFile.name, + currentStepIndex: 2 + }); + + // 2) 변환된 PDF를 이미지 변환 API로 전송 + const flaskData = await pdfToImages(fileSn, convertedFile.pdf_path); + + if (flaskData.converted_files && flaskData.converted_files.length > 0) { + const convertedFile = flaskData.converted_files[0]; + if (convertedFile.images_dir && convertedFile.total_pages) { + await saveImagePath({ + fileSn: fileSn, + folderPath: convertedFile.images_dir, + totalPages: convertedFile.total_pages + }); + + updateProgress({ + currentStep: "임베딩 처리 중...", + fileName: originFile.name, + currentStepIndex: 3 + }); + + // 3) PDF 문서 임베딩 요청 + try { + await embedDocuments(convertedFile.pdf_path, originFile.name); + await saveEmbeddingResult({ fileSn }); + + // 임베딩 완료 후 진행률 업데이트 + updateProgress({ + currentStep: "임베딩 완료", + fileName: originFile.name, + currentStepIndex: 4 + }); + } catch (embedError) { + console.error('PDF 임베딩 실패:', embedError); + } + } + } + } catch (fileError) { + console.error(`파일 처리 실패: ${originFile.name}`, fileError); + } + } + + // 업로드 완료 + setFiles([]); + setUploadedFiles(filesToUpload); + setUploadSuccess(true); + setResultMessage("업로드 성공!"); + setProdDept(""); + setProdSystem(""); + await refetchFileList(); + setShowProgressModal(false); // 모달 자동 닫기 + + } catch (error) { + console.error('백그라운드 업로드 실패:', error); + setResultMessage("업로드 실패"); + } finally { + completeUpload(); + } + }; + + // 등록자 UUID → userId 매핑 + useEffect(() => { + async function fetchUserIds() { + if (!Array.isArray(fileList) || fileList.length === 0) return; + + const entries = await Promise.all( + fileList.map(async (file: any) => { + const rgtr = file.rgtr || file.userUnqId; + if (!rgtr || userIdMap[rgtr]) return [rgtr, userIdMap[rgtr]]; + + try { + const user = await getUserByUnqId(rgtr); + return [rgtr, (user as any)?.userId ?? rgtr]; + } catch (error) { + console.error('사용자 정보 조회 실패:', error); + return [rgtr, rgtr]; + } + }) + ); + setUserIdMap(Object.fromEntries(entries)); + } + fetchUserIds(); + }, [fileList]); + + return ( +
+
+

+ 자료관리 등록 +

+ {/* 기존 업로드 진행 상황 버튼 제거됨 */} +
+ {/* 백그라운드 업로드 진행 상황 모달 */} + {showProgressModal && isUploading && uploadProgress && ( +
+
+
+
+ 로딩중 +

파일 업로드 중...

+
+ +
+ +
+ {/* 진행률 바 */} +
+
+
+ + {/* 진행 상황 텍스트 */} +
+

+ {uploadProgress.currentFile} / {uploadProgress.totalFiles} 파일 처리 중 +

+

+ {uploadProgress.currentStep} +

+ {uploadProgress.fileName && ( +

+ 현재 파일: {uploadProgress.fileName} +

+ )} +
+ + {/* 진행률 퍼센트 */} +
+ + {Math.max(0, Math.round(((uploadProgress.currentFile - 1) * 5 + uploadProgress.currentStepIndex) / (uploadProgress.totalFiles * 5) * 100))}% + +
+
+ +
+

+ 업로드가 완료될 때까지 잠시 기다려주세요. +
+ 다른 작업을 계속 진행하실 수 있습니다. +

+
+
+
+ )} + {/* 결과 메시지 오버레이 */} + {resultMessage && ( +
+
+ + {resultMessage} + +
+
+ )} +
+ {/* 왼쪽: 등록 패널 */} +
+
+ {/* 상위항목/분류구조 셀렉트 → 단순 트리로 교체 */} +
+ +
e.preventDefault()} + onDrop={(e) => e.preventDefault()} + onDragEnter={(e) => e.preventDefault()} + onDragLeave={(e) => e.preventDefault()} + > + { + // 같은 노드를 다시 클릭하면 선택 해제 + if (selectedCategory?.clsfSystmSn === node.clsfSystmSn) { + setSelectedCategory(null); + } else { + setSelectedCategory(node); + if (showCategoryError && node) { + setShowCategoryError(false); + } + } + }} + /> +
+ + {showCategoryError && ( +

+ )} +
+ + {/* 생산부서코드, 생산시스템코드 입력 */} +
+
+ + { + setProdDept(e.target.value); + if (showProdDeptError && e.target.value.trim()) { + setShowProdDeptError(false); + } + }} + /> + {showProdDeptError && ( +

+ )} +
+
+ + setProdSystem(e.target.value)} + /> +
+
+ + {/* 파일 업로드: 클릭만 허용, 드래그&드랍 불가 */} +
+ +
+ + + {isDragOver ? ( +
+

파일을 여기에 놓으세요

+

드래그한 파일을 이 영역에 놓으면 자동으로 선택됩니다

+
+ ) : files.length === 0 ? ( +

파일을 선택하거나 여기에 드래그하세요

+ ) : ( +
+

선택된 파일

+
+ {files.map((file, idx) => ( +
+ {file.name} +
+ ))} +
+
+ )} +
+ + {/* 등록 버튼 - 항상 보이도록 고정 */} +
+ {(() => { + const buttonDisabled = files.length === 0; + console.log('=== 등록 버튼 렌더링 ==='); + console.log('렌더링 시간:', new Date().toISOString()); + console.log('files.length:', files.length); + console.log('buttonDisabled:', buttonDisabled); + console.log('========================'); + + return ( + + ); + })()} + { + setDialogLoading(true); + setDialogOpen(false); // 다이얼로그 닫기 + // 백그라운드에서 업로드 처리 시작 + processBackgroundUpload(files); + setDialogLoading(false); + }} + onCancel={() => setDialogOpen(false)} + /> +
+
+
+ + {/* 업로드 결과 영역 - 별도 섹션으로 분리 */} +
+ {uploadSuccess && ( +
+ 업로드 완료 +
+ )} + {isUploading && ( +
+ 로딩중 + 백그라운드에서 업로드 중... +
+ )} +
+ {uploadedFiles.length > 0 ? ( + uploadedFiles.map((file, idx) => ( +
+ {file.name} +
+ )) + ) : ( + 업로드된 파일이 없습니다. + )} +
+
+
+ + {/* 오른쪽 파일 목록 패널 */} +
+
+

파일 목록

+ +
+
+ {/* 파일 목록: 40% */} +
+ + + + + + + + + + + {!selectedCategory?.clsfSystmSn ? ( + + + + ) : fileListLoading ? ( + + ) : fileListError ? ( + + ) : Array.isArray(fileList) && fileList.length > 0 ? ( + fileList.map((file: any) => ( + { + if (selectedFileSn === String(file.fileSn)) { + setSelectedFileSn(null); + } else { + setSelectedFileSn(String(file.fileSn)); + } + }} + > + + + + + + )) + ) : ( + + )} + +
번호파일명등록자등록일자
+
+ + 분류구조를 선택하세요. +
+
파일 목록을 불러오는 중...
파일 목록을 불러오지 못했습니다.
{file.no}{file.fileTitle}-{file.regDt || file.createdAt || "-"}
업로드된 파일이 없습니다.
+
+ {/* 파일 상세정보: 60% */} +
+
+

파일 상세정보

+ {/* 다운로드 버튼: 파일 상세정보가 있을 때만 노출 */} + {(() => { + const detail = Array.isArray(fileDetail) ? fileDetail[0] : fileDetail; + if (!detail?.filePath) return null; + const fileName = detail.fileTitle || detail.fileName || "downloaded_file"; + return ( + + ); + })()} +
+ {fileDetailLoading ? ( +
+
상세정보를 불러오는 중...
+
+ ) : fileDetailError ? ( +
상세정보를 불러오지 못했습니다.
+ ) : !fileDetail ? ( +
+ +
파일을 선택하세요.
+
+ ) : ( + (() => { + const detail = Array.isArray(fileDetail) ? fileDetail[0] : fileDetail; + return ( +
+ {/* 왼쪽: 미리보기 영역 (60%) */} +
+ {selectedFileSn && } +
+ {/* 오른쪽: 파일 정보 영역 (40%) */} +
+
+
    +
  • + +
    + 파일명 +
    {detail?.fileTitle || "-"}
    +
    +
  • +
  • + +
    + 생산부서 +
    {detail?.prdctnDeptCd || "-"}
    +
    +
  • +
  • + +
    + 생산시스템 +
    {detail?.prdctnSysCd || "-"}
    +
    +
  • +
  • + +
    + 파일 크기 +
    {detail?.fileSz ? `${(detail.fileSz / (1024 * 1024)).toFixed(2)} MB` : "-"}
    +
    +
  • +
  • + +
    + 페이지 수 +
    {detail?.pageCnt || "-"}
    +
    +
  • +
+
+ {/* QR코드: 오른쪽 하단 고정 */} + {detail?.filePath && detail?.fileTitle && ( +
+
+ +
+
+ )} +
+
+ ); + })() + )} +
+
+
+
+
+ ); +} + +// PDF 이미지 미리보기 슬라이드 컴포넌트 +function PdfImagePreview({ fileSn }: { fileSn: string }) { + const [images, setImages] = useState([]); + 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
미리보기 이미지가 없습니다.
; + + return ( +
+ {/* 메인 미리보기 이미지 */} +
+ {`page_${current setModalOpen(true)} + /> +
+ + {/* 썸네일 및 컨트롤 */} +
+ {/* 썸네일 */} +
+ {images.map((img, idx) => ( + {`thumb_${idx setCurrent(idx)} + /> + ))} +
+ + {/* 페이지 네비게이션 */} +
+ + + {current + 1} / {images.length} + + +
+
+ + {/* 확대 모달 */} + {modalOpen && ( +
setModalOpen(false)} + > +
+ {/* 이전 버튼 */} + {current > 0 && ( + + )} + + {/* 이미지 컨테이너 */} +
+ 확대 이미지 e.stopPropagation()} + /> +
+ + {/* 다음 버튼 */} + {current < images.length - 1 && ( + + )} + + {/* 페이지 정보 */} +
+ {current + 1} / {images.length} +
+ + {/* 닫기 버튼 */} + +
+
+ )} +
+ ); +} + +export { DataInsertPage }; \ No newline at end of file diff --git a/apps/dc/src/pages/generalsearch/index.ts b/apps/dc/src/pages/generalsearch/index.ts new file mode 100644 index 000000000..8aa1081a5 --- /dev/null +++ b/apps/dc/src/pages/generalsearch/index.ts @@ -0,0 +1 @@ +export { GeneralSearchPage } from "./ui/generalsearch-page"; \ No newline at end of file diff --git a/apps/dc/src/pages/generalsearch/ui/generalsearch-page.tsx b/apps/dc/src/pages/generalsearch/ui/generalsearch-page.tsx new file mode 100644 index 000000000..78186438f --- /dev/null +++ b/apps/dc/src/pages/generalsearch/ui/generalsearch-page.tsx @@ -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("default"); + const [activeTab, setActiveTab] = useState<"all"|"category"|"file">("all"); + const [searchText, setSearchText] = useState(""); + const [results, setResults] = useState(null); + const [loading, setLoading] = useState(false); + const [searched, setSearched] = useState(false); + // 파일 상세 + const [selectedFileSn, setSelectedFileSn] = useState(null); + const [fileDetail, setFileDetail] = useState(null); + const [fileDetailLoading, setFileDetailLoading] = useState(false); + const [showDetail, setShowDetail] = useState(false); + // 분류별 파일 리스트 + const [categoryFiles, setCategoryFiles] = useState>({}); + const [expandedCategories, setExpandedCategories] = useState>(new Set()); + const [categoryFilesLoading, setCategoryFilesLoading] = useState>(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 ( + +
+ {/* 검색 영역 */} +
+
+ + setSearchText(e.target.value)} + onKeyDown={e => { if (e.key === "Enter") handleSearch(); }} + /> + +
+
+ + {/* 검색 결과 영역 */} +
+ {/* 오른쪽 상단에 상세보기 버튼 */} + {searched && ( + + )} + {!searched && ( +
+ 검색어를 입력해주세요. +
+ )} + + + + {/* 일반 검색 결과 */} + {searched && searchType === "default" && loading && ( +
검색 중...
+ )} + {searched && searchType === "default" && !loading && results && ( + <> +
+

+ {searchText ? ( + <>{searchText} 으로 검색한 결과입니다. + ) : ( + <>전체 자료 목록입니다. + )} +

+
+
+
setActiveTab("all")} + >전체
+
setActiveTab("category")} + >분류
+
setActiveTab("file")} + >파일
+
+ {/* 전체 탭: 분류와 파일을 상하로 분할 */} + {activeTab === "all" && ( +
+ {/* 위쪽: 분류 영역 (50%) */} +
+
분류
+
+ {results?.data?.categoryList && results.data.categoryList.length > 0 ? ( +
    + {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 ( +
  • + {/* 분류 항목 */} +
    handleCategoryClick(cat)} + > +
    + {isExpanded ? ( + + ) : ( + + )} +
    + + {cat.path || getCategoryPath(results.data.categoryList, cat.clsfSystmSn)} +
    + + {/* 펼쳐진 파일 목록 */} + {isExpanded && ( +
    + {isLoading ? ( +
    + 파일 목록을 불러오는 중... +
    + ) : files.length > 0 ? ( +
      + {files.map((file: any) => ( +
    • { + const fileSn = String(file.fileSn || file.file_sn); + setSelectedFileSn(prev => prev === fileSn ? null : fileSn); + }} + > + + {file.fileTitle || file.file_title} +
    • + ))} +
    + ) : ( +
    파일이 없습니다.
    + )} +
    + )} +
  • + ); + })} +
+ ) : ( +
분류 결과가 없습니다.
+ )} +
+
+ + {/* 아래쪽: 파일 영역 (50%) */} +
+
파일
+
+ {results?.data?.fileList && results.data.fileList.length > 0 ? ( +
    + {results.data.fileList.map((file: any) => ( +
  • { + const fileSn = String(file.fileSn || file.file_sn); + setSelectedFileSn(prev => prev === fileSn ? null : fileSn); + }} + > + +
    + {file.fileTitle || file.file_title} + {file.path && ( + + {file.path} {'>'} {file.fileTitle || file.file_title} + + )} +
    +
  • + ))} +
+ ) : ( +
파일 결과가 없습니다.
+ )} +
+
+
+ )} + + {/* 분류 탭 */} + {activeTab === "category" && ( +
+
분류
+
+ {results?.data?.categoryList && results.data.categoryList.length > 0 ? ( +
    + {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 ( +
  • + {/* 분류 항목 */} +
    handleCategoryClick(cat)} + > +
    + {isExpanded ? ( + + ) : ( + + )} +
    + + {cat.path || getCategoryPath(results.data.categoryList, cat.clsfSystmSn)} +
    + + {/* 펼쳐진 파일 목록 */} + {isExpanded && ( +
    + {isLoading ? ( +
    + 파일 목록을 불러오는 중... +
    + ) : files.length > 0 ? ( +
      + {files.map((file: any) => ( +
    • { + const fileSn = String(file.fileSn || file.file_sn); + setSelectedFileSn(prev => prev === fileSn ? null : fileSn); + }} + > + +
      + {file.fileTitle || file.file_title} + {file.path && ( + + {file.path} {'>'} {file.fileTitle || file.file_title} + + )} +
      +
    • + ))} +
    + ) : ( +
    파일이 없습니다.
    + )} +
    + )} +
  • + ); + })} +
+ ) : ( +
분류 결과가 없습니다.
+ )} +
+
+ )} + + {/* 파일 탭 */} + {activeTab === "file" && ( +
+
파일
+
+ {results?.data?.fileList && results.data.fileList.length > 0 ? ( +
    + {results.data.fileList.map((file: any) => ( +
  • { + const fileSn = String(file.fileSn || file.file_sn); + setSelectedFileSn(prev => prev === fileSn ? null : fileSn); + }} + > + +
    + {file.fileTitle || file.file_title} + {file.path && ( + + {file.path} {'>'} {file.fileTitle || file.file_title} + + )} +
    +
  • + ))} +
+ ) : ( +
파일 결과가 없습니다.
+ )} +
+
+ )} + + )} +
+
+ {/* 파일 상세정보 패널 (모달 또는 하단 고정 등 원하는 위치에 배치) */} + {showDetail && ( +
+
+ +

+ 파일 상세정보 +

+ {/* fileDetail 구조에 맞게 매핑 */} + {fileDetailLoading ? ( +
+
상세정보를 불러오는 중...
+
+ ) : fileDetail ? ( + (() => { + const detail = Array.isArray(fileDetail) ? fileDetail[0] : fileDetail; + return ( +
+ {/* 왼쪽: 미리보기 영역 (65%) */} +
+ {selectedFileSn && } +
+ {/* 오른쪽: 파일 정보 영역 (35%) */} +
+
+
    +
  • + +
    + 파일명 +
    {detail?.fileTitle || "-"}
    +
    +
  • +
  • + +
    + 생산부서 +
    {detail?.prdctnDeptCd || "-"}
    +
    +
  • +
  • + +
    + 생산시스템 +
    {detail?.prdctnSysCd || "-"}
    +
    +
  • +
  • + +
    + 파일 크기 +
    {detail?.fileSz ? `${(detail.fileSz / (1024 * 1024)).toFixed(2)} MB` : "-"}
    +
    +
  • +
  • + +
    + 페이지 수 +
    {detail?.pageCnt || "-"}
    +
    +
  • +
  • + +
    + 마지막 저장일시 +
    {detail?.lastStrgDt || "-"}
    +
    +
  • + {/* 다운로드 버튼 */} + {(() => { + const detail = Array.isArray(fileDetail) ? fileDetail[0] : fileDetail; + if (!detail?.filePath) return null; + const fileName = detail.fileTitle || detail.fileName || "downloaded_file"; + return ( +
  • + +
  • + ); + })()} +
+
+ {/* 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 ( +
+
+ +
+
+ ); + })()} +
+
+ ); + })() + ) : ( +
상세정보를 불러오지 못했습니다.
+ )} +
+
+ )} +
+ ); +} + +// [추가] PDF 이미지 미리보기 슬라이드 컴포넌트 +function PdfImagePreview({ fileSn }: { fileSn: string }) { + const [images, setImages] = useState([]); + 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
미리보기 이미지가 없습니다.
; + + return ( +
+ {/* 메인 미리보기 이미지 */} +
+ {`page_${current setModalOpen(true)} + /> +
+ + {/* 썸네일 및 컨트롤 */} +
+ {/* 썸네일 */} +
+ {images.map((img, idx) => ( + {`thumb_${idx setCurrent(idx)} + /> + ))} +
+ + {/* 페이지 네비게이션 */} +
+ + + {current + 1} / {images.length} + + +
+
+ + {/* 확대 모달 */} + {modalOpen && ( +
setModalOpen(false)} + > +
+ {/* 이전 버튼 */} + {current > 0 && ( + + )} + + {/* 이미지 컨테이너 */} +
+ 확대 이미지 e.stopPropagation()} + /> +
+ + {/* 다음 버튼 */} + {current < images.length - 1 && ( + + )} + + {/* 페이지 정보 */} +
+ {current + 1} / {images.length} +
+ + {/* 닫기 버튼 */} + +
+
+ )} +
+ ); +} + +export { GeneralSearchPage }; + +// util: 분류 경로 추적 함수 추가 +function getCategoryPath(categories: any[], targetId: string) { + const map: Record = {}; + 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(" > "); +} \ No newline at end of file diff --git a/apps/dc/src/pages/menu/api/menu-service.ts b/apps/dc/src/pages/menu/api/menu-service.ts new file mode 100644 index 000000000..33ad06837 --- /dev/null +++ b/apps/dc/src/pages/menu/api/menu-service.ts @@ -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 { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.MENU_ROLES}/${siteId}`, + ); + return response.data; + } + + public async getMenuInfo(menuCd: number): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}`, + ); + return response.data; + } + + public async createMenu(data: MenuFormData): Promise { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.MENUS}`, + data, + ); + return response.data; + } + + public async updateMenu( + menuCd: number, + data: Omit, + ): Promise { + const response = await client.put( + `${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}`, + data, + ); + return response.data; + } + + public async deleteMenu(menuCd: number): Promise { + await client.delete(`${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}`); + } + + public async menuTemporaryStorage( + menuCd: number, + data: MenuFormData, + ): Promise { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.MENU_TEMPORARY_STORAGE}/${menuCd}`, + data, + ); + return response.data; + } + + public async getMenuTemporarilyStoredData( + menuCd: number, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.MENU_TEMPORARY_STORAGE}/menu/${menuCd}/user`, + ); + return response.data; + } +} + +export const MenuApi = new MenuApiService(); diff --git a/apps/dc/src/pages/menu/index.ts b/apps/dc/src/pages/menu/index.ts new file mode 100644 index 000000000..e4a9cec40 --- /dev/null +++ b/apps/dc/src/pages/menu/index.ts @@ -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"; diff --git a/apps/dc/src/pages/menu/lib/menu-wrapper.tsx b/apps/dc/src/pages/menu/lib/menu-wrapper.tsx new file mode 100644 index 000000000..1f1080cac --- /dev/null +++ b/apps/dc/src/pages/menu/lib/menu-wrapper.tsx @@ -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 }; diff --git a/apps/dc/src/pages/menu/lib/utils.ts b/apps/dc/src/pages/menu/lib/utils.ts new file mode 100644 index 000000000..1cf4514d9 --- /dev/null +++ b/apps/dc/src/pages/menu/lib/utils.ts @@ -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; +}; diff --git a/apps/dc/src/pages/menu/model/menu-interface.ts b/apps/dc/src/pages/menu/model/menu-interface.ts new file mode 100644 index 000000000..b1693f616 --- /dev/null +++ b/apps/dc/src/pages/menu/model/menu-interface.ts @@ -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; + +export type Menu = Omit & { + menuCd: number; + iconName?: string; +}; + +export interface MenuService { + getMenuRoles(siteId: string): Promise; + getMenuInfo: (menuCd: number) => Promise; + createMenu: (data: MenuFormData) => Promise; + updateMenu: ( + menuCd: number, + data: Omit, + ) => Promise; + deleteMenu: (menuCd: number) => Promise; + menuTemporaryStorage: (menuCd: number, data: MenuFormData) => Promise; + getMenuTemporarilyStoredData: ( + menuCd: number, + ) => Promise; +} diff --git a/apps/dc/src/pages/mydata/index.ts b/apps/dc/src/pages/mydata/index.ts new file mode 100644 index 000000000..f98c5975c --- /dev/null +++ b/apps/dc/src/pages/mydata/index.ts @@ -0,0 +1 @@ +export { MyDataPage } from "./ui/mydata-page"; \ No newline at end of file diff --git a/apps/dc/src/pages/mydata/ui/mydata-dialog.tsx b/apps/dc/src/pages/mydata/ui/mydata-dialog.tsx new file mode 100644 index 000000000..d2c8ec391 --- /dev/null +++ b/apps/dc/src/pages/mydata/ui/mydata-dialog.tsx @@ -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 ( + { if (!open) onCancel(); }}> + +
+

+ {message} +

+
+ + + + + + +
+
+ ); +} \ No newline at end of file diff --git a/apps/dc/src/pages/mydata/ui/mydata-page.tsx b/apps/dc/src/pages/mydata/ui/mydata-page.tsx new file mode 100644 index 000000000..d0c146a4b --- /dev/null +++ b/apps/dc/src/pages/mydata/ui/mydata-page.tsx @@ -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(null); + const [data, setData] = useState([]); + const [selectedRow, setSelectedRow] = useState(null); + const [searchField, setSearchField] = useState("ALL"); + const [searchValue, setSearchValue] = useState(""); + 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) => { + const params: Record = { + 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(`/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(`/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(`/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 = {}; + // 검색필드가 "전체"가 아닌 경우에만 검색 조건 추가 + 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 ( + +
+ {/* 메인 카드 */} +
+ {/* ① 검색 + 필터 */} +
+
+ {/* 조건 셀렉트 */} + + + {/* 값 셀렉트 */} + + +
+ {/* ③④⑤ 버튼들 */} +
+ +
+
+ + {/* 데이터 테이블 - 스크롤 가능한 영역 */} +
+
+ + + + + + + + + + + + + + + {/* userId, shrnSeCd, dataSttsCd 등 숨김 */} + + + + {Array.isArray(data) && data.map((item, idx) => ( + handleRowClick(item)} + > + + {/* 공유구분: 코드명으로 표시 */} + + + + + + + + + + {/* 자료상태: 코드명으로 표시 */} + + + ))} + +
관심자료공유구분공유부서공유아이디이관자료여부이관자료사용자ID제목생산부서생산시스템생산년도자료상태
+ {item.itrstYn === 'Y' ? ⭐ : ☆} + {item.shrnSeCdNm ?? shrnSeCdMap[item.shrnSeCd ?? ''] ?? ''}{item.shrnDeptCd}{item.shrnUserId}{item.trnsfDataYn}{item.dataTrnsfUserId}{item.fileNm}{item.prdctnDeptCd}{item.prdctnSysCd}{item.regDt}{item.dataSttsCdNm ?? dataSttsCdMap[item.dataSttsCd ?? ''] ?? ''}
+
+
+ + {/* 페이징 UI - 고정 위치 */} +
+ {/* 페이지 정보 - 왼쪽 */} +
+ 총 {totalCount}개 중 {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, totalCount)}개 +
+ + {/* 페이징 버튼 - 가운데 */} +
+ + + + {/* 페이지 번호 버튼들 */} + {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 ( + + ); + })} + + + +
+ + {/* 페이지 크기 선택 - 오른쪽 */} +
+ 페이지당 행 수 + +
+
+
+
+ + {/* 삭제 확인 모달 */} + {showConfirm && selectedRow && ( +
+
+

+ ❗ 선택한 자료를 삭제 하시겠습니까? +

+
+ + +
+
+
+ )} +
+ ); +} + +export { MyDataPage }; \ No newline at end of file diff --git a/apps/dc/src/pages/mydata/ui/mydata-register.tsx b/apps/dc/src/pages/mydata/ui/mydata-register.tsx new file mode 100644 index 000000000..8c4606ab1 --- /dev/null +++ b/apps/dc/src/pages/mydata/ui/mydata-register.tsx @@ -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>; + mydataId?: string; + handleSave: (data: Partial) => Promise; + handleDelete?: () => Promise; +}; + +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([]); + const [selectedUser, setSelectedUser] = useState(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 }>({ 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(`/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(`/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) => { + // 필수값 검증 + 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 ( +
+ +
+
+

내자료 {isModifyMode ? "수정" : "등록"}

+
+
+ + + + + + + + userId + + ( + + + + + + + )} + /> + + + + rgtrId + + ( + + + + + + + )} + /> + + + + * 관심자료 여부 + + ( + + + 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' : ''}`} + /> + + + + )} + /> + + + + * 공유구분 + + ( + + + {shrnSeCdLoading ? ( +
+ 로딩 중... +
+ ) : ( + field.onChange(value === "__SELECT__" ? "" : value)} + options={shrnSeCdOptions} + placeholder="선택" + className={`w-[160px] h-[3.2rem] text-xl ${showShrnSeCdError ? 'border-red-500' : ''}`} + /> + )} +
+ +
+ )} + /> +
+
+ + 생산부서 + + ( + + + { + const trimmedValue = e.target.value.trim(); + field.onChange(trimmedValue); + }} + /> + + + + )} + /> + + + + 생산시스템 + + ( + + + { + const trimmedValue = e.target.value.trim(); + field.onChange(trimmedValue); + }} + /> + + + + )} + /> + + + + * 자료상태 + + ( + + + {dataSttsLoading ? ( +
+ 로딩 중... +
+ ) : ( + field.onChange(value === "__SELECT__" ? "" : value)} + options={dataSttsOptions} + placeholder="선택" + className={`w-[120px] h-[3.2rem] text-xl ${showDataSttsCdError ? 'border-red-500' : ''}`} + /> + )} +
+ +
+ )} + /> +
+
+ + 자료이관사용자ID + +
+ ( + + + + + + + )} + /> + +
+
+
+
+
+
+
+ + + {isModifyMode && handleDelete && ( + + )} +
+
+ {/* 사용자 검색 모달 */} + {userModalOpen && ( +
+
+
사용자 검색
+
+ setUserSearch(e.target.value)} className="h-[3.2rem] px-3 !text-[1.3rem] flex-1" placeholder="이름 입력" /> + +
+
+ {userLoading ? ( +
검색 중...
+ ) : userResults.length === 0 ? ( +
검색 결과 없음
+ ) : ( + userResults.map(user => ( +
setSelectedUser(user)} + > + +
+ {user.userName} + {user.userId} +
+
+ )) + )} +
+
+ + +
+
+
+ )} +
+ + + ); +} \ No newline at end of file diff --git a/apps/dc/src/shared/api/client.ts b/apps/dc/src/shared/api/client.ts new file mode 100644 index 000000000..ba2dbe99f --- /dev/null +++ b/apps/dc/src/shared/api/client.ts @@ -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, +}); diff --git a/apps/dc/src/shared/api/index.ts b/apps/dc/src/shared/api/index.ts new file mode 100644 index 000000000..18a584c4b --- /dev/null +++ b/apps/dc/src/shared/api/index.ts @@ -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 }; \ No newline at end of file diff --git a/apps/dc/src/shared/config/api.ts b/apps/dc/src/shared/config/api.ts new file mode 100644 index 000000000..12f5f70b6 --- /dev/null +++ b/apps/dc/src/shared/config/api.ts @@ -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; diff --git a/apps/dc/src/shared/config/env.ts b/apps/dc/src/shared/config/env.ts new file mode 100644 index 000000000..89d2c039c --- /dev/null +++ b/apps/dc/src/shared/config/env.ts @@ -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"; diff --git a/apps/dc/src/shared/config/index.ts b/apps/dc/src/shared/config/index.ts new file mode 100644 index 000000000..cd83447d5 --- /dev/null +++ b/apps/dc/src/shared/config/index.ts @@ -0,0 +1,2 @@ +export * from "./api"; +export * from "./env"; diff --git a/apps/dc/src/shared/lib/cookie.ts b/apps/dc/src/shared/lib/cookie.ts new file mode 100644 index 000000000..8f5d0011b --- /dev/null +++ b/apps/dc/src/shared/lib/cookie.ts @@ -0,0 +1,29 @@ +import Cookies from "js-cookie"; + +export function getCookie(name: string): string | undefined { + if (typeof window === "undefined") { + return undefined; + } + return Cookies.get(name); +} + +export function setCookie( + name: string, + value: string, + options?: Cookies.CookieAttributes, +): void { + if (typeof window === "undefined") { + return; + } + Cookies.set(name, value, options); +} + +export function removeCookie( + name: string, + options?: Cookies.CookieAttributes, +): void { + if (typeof window === "undefined") { + return; + } + Cookies.remove(name, options); +} diff --git a/apps/dc/src/shared/lib/index.ts b/apps/dc/src/shared/lib/index.ts new file mode 100644 index 000000000..58b4c76ba --- /dev/null +++ b/apps/dc/src/shared/lib/index.ts @@ -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"; \ No newline at end of file diff --git a/apps/dc/src/shared/lib/match-query-status.ts b/apps/dc/src/shared/lib/match-query-status.ts new file mode 100644 index 000000000..0e5f45829 --- /dev/null +++ b/apps/dc/src/shared/lib/match-query-status.ts @@ -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( + query: UseQueryResult>, + options: { + Loading?: JSX.Element; + Errored?: JSX.Element | ((error: unknown) => JSX.Element); + Empty?: JSX.Element; + Success: ( + data: UseQueryResult> & { + data: NonNullable["data"]>; + }, + ) => JSX.Element; + }, +): JSX.Element; +export function matchQueryStatus( + query: UseQueryResult>, + options: { + Loading?: JSX.Element; + Errored?: JSX.Element | ((error: unknown) => JSX.Element); + Success: (data: UseQueryResult>) => JSX.Element; + }, +): JSX.Element; +export function matchQueryStatus( + query: UseQueryResult>, + { + Loading, + Errored, + Empty, + Success, + }: { + Loading?: JSX.Element; + Errored?: JSX.Element | ((error: unknown) => JSX.Element); + Empty?: JSX.Element; + Success: (data: UseQueryResult>) => 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); +} diff --git a/apps/dc/src/shared/lib/use-alert-store.ts b/apps/dc/src/shared/lib/use-alert-store.ts new file mode 100644 index 000000000..c21a00ed3 --- /dev/null +++ b/apps/dc/src/shared/lib/use-alert-store.ts @@ -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 = (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); +}; diff --git a/apps/dc/src/shared/lib/use-data-table.tsx b/apps/dc/src/shared/lib/use-data-table.tsx new file mode 100644 index 000000000..51638f746 --- /dev/null +++ b/apps/dc/src/shared/lib/use-data-table.tsx @@ -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 = { + query: UseQueryResult>; + tableOptions: Omit, "data" | "rowCount">; + dataTableOptions?: DataTableOptions; +}; + +type DataTableOptions = { + variant?: "default" | "secondary" | "line"; + className?: HTMLAttributes["className"]; + viewPagination?: boolean; + onRowClick?: (row: T) => void; +}; + +export const useDataTable = ({ + query, + tableOptions, + dataTableOptions, +}: UseDataTableOptions) => { + const table = useReactTable({ + ...tableOptions, + data: query.data?.content ?? [], + rowCount: query.data?.totalElements ?? 0, + }); + + return { + tableComponent: matchQueryStatus(query, { + Empty: , + Success: () => { + return ; + }, + }), + tableInstance: table, + }; +}; diff --git a/apps/dc/src/shared/lib/use-menu-store.ts b/apps/dc/src/shared/lib/use-menu-store.ts new file mode 100644 index 000000000..66e41c76c --- /dev/null +++ b/apps/dc/src/shared/lib/use-menu-store.ts @@ -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 = (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); +}; diff --git a/apps/dc/src/shared/lib/use-mutation-wrapper copy.tsx b/apps/dc/src/shared/lib/use-mutation-wrapper copy.tsx new file mode 100644 index 000000000..cea1a8f83 --- /dev/null +++ b/apps/dc/src/shared/lib/use-mutation-wrapper copy.tsx @@ -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, +) => { + 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}`); + }, + }); +}; diff --git a/apps/dc/src/shared/lib/use-mutation-wrapper.tsx b/apps/dc/src/shared/lib/use-mutation-wrapper.tsx new file mode 100644 index 000000000..cea1a8f83 --- /dev/null +++ b/apps/dc/src/shared/lib/use-mutation-wrapper.tsx @@ -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, +) => { + 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}`); + }, + }); +}; diff --git a/apps/dc/src/shared/lib/use-upload-store.ts b/apps/dc/src/shared/lib/use-upload-store.ts new file mode 100644 index 000000000..0dff04202 --- /dev/null +++ b/apps/dc/src/shared/lib/use-upload-store.ts @@ -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); +}; \ No newline at end of file diff --git a/apps/dc/src/shared/lib/use-user-store.ts b/apps/dc/src/shared/lib/use-user-store.ts new file mode 100644 index 000000000..748caed4c --- /dev/null +++ b/apps/dc/src/shared/lib/use-user-store.ts @@ -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 = (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); +}; diff --git a/apps/dc/src/shared/model/api-interface.ts b/apps/dc/src/shared/model/api-interface.ts new file mode 100644 index 000000000..133c0c401 --- /dev/null +++ b/apps/dc/src/shared/model/api-interface.ts @@ -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 { + 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[]; +} diff --git a/apps/dc/src/shared/model/index.ts b/apps/dc/src/shared/model/index.ts new file mode 100644 index 000000000..cf589d200 --- /dev/null +++ b/apps/dc/src/shared/model/index.ts @@ -0,0 +1 @@ +export * from "./api-interface"; diff --git a/apps/dc/src/shared/providers/index.ts b/apps/dc/src/shared/providers/index.ts new file mode 100644 index 000000000..f2e092170 --- /dev/null +++ b/apps/dc/src/shared/providers/index.ts @@ -0,0 +1 @@ +export { StoreProvider } from "./store-provider"; diff --git a/apps/dc/src/shared/providers/store-provider.tsx b/apps/dc/src/shared/providers/store-provider.tsx new file mode 100644 index 000000000..a0ec17b61 --- /dev/null +++ b/apps/dc/src/shared/providers/store-provider.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { createContext, useRef } from "react"; +import { + createAlertStore, + createAttachmentStore, + createMenuStore, + createUserStore, +} from "../store"; + +type StoreContextType = { + alertStore: ReturnType; + attachmentStore: ReturnType; + menuStore: ReturnType; + userStore: ReturnType; +}; + +export const StoreContext = createContext( + undefined, +); + +export const StoreProvider = ({ children }: { children: React.ReactNode }) => { + const storeRef = useRef(null); + if (storeRef.current === null) { + storeRef.current = { + alertStore: createAlertStore(), + attachmentStore: createAttachmentStore(), + menuStore: createMenuStore(), + userStore: createUserStore(), + }; + } + + return ( + + {children} + + ); +}; diff --git a/apps/dc/src/shared/store/alert-store.ts b/apps/dc/src/shared/store/alert-store.ts new file mode 100644 index 000000000..3bba65848 --- /dev/null +++ b/apps/dc/src/shared/store/alert-store.ts @@ -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()((set) => ({ + ...initState, + setMessage: (message: string, callback?: () => void) => + set(() => ({ message, callback })), + })); +}; diff --git a/apps/dc/src/shared/store/attachment-store.ts b/apps/dc/src/shared/store/attachment-store.ts new file mode 100644 index 000000000..faa700949 --- /dev/null +++ b/apps/dc/src/shared/store/attachment-store.ts @@ -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()((set) => ({ + ...initState, + setAttachmentFileCode: (attachmentFileCode: string) => + set({ attachmentFileCode }), + })); +}; diff --git a/apps/dc/src/shared/store/index.ts b/apps/dc/src/shared/store/index.ts new file mode 100644 index 000000000..ce111a1e7 --- /dev/null +++ b/apps/dc/src/shared/store/index.ts @@ -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"; \ No newline at end of file diff --git a/apps/dc/src/shared/store/menu-store.ts b/apps/dc/src/shared/store/menu-store.ts new file mode 100644 index 000000000..8536011ee --- /dev/null +++ b/apps/dc/src/shared/store/menu-store.ts @@ -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()((set) => ({ + ...initState, + setBbsInfo: (bbs: IBbs) => set({ bbsInfo: bbs }), + setCurrentMenuInfo: (menu: IMenu) => set({ currentMenuInfo: menu }), + setPageMenuAuthority: (authority: IAuthority) => + set({ pageMenuAuthority: authority }), + })); +}; diff --git a/apps/dc/src/shared/store/upload-store-instance.ts b/apps/dc/src/shared/store/upload-store-instance.ts new file mode 100644 index 000000000..1c75b60b1 --- /dev/null +++ b/apps/dc/src/shared/store/upload-store-instance.ts @@ -0,0 +1,3 @@ +import { createUploadStore } from "./upload-store"; + +export const uploadStore = createUploadStore(); \ No newline at end of file diff --git a/apps/dc/src/shared/store/upload-store.ts b/apps/dc/src/shared/store/upload-store.ts new file mode 100644 index 000000000..bed86eaf8 --- /dev/null +++ b/apps/dc/src/shared/store/upload-store.ts @@ -0,0 +1,67 @@ +import { createStore } from "zustand/vanilla"; + +export interface UploadProgress { + currentFile: number; + totalFiles: number; + currentStep: string; + fileName: string; + currentStepIndex: number; // 현재 단계 인덱스 (0: 파일업로드, 1: PDF변환, 2: 이미지변환, 3: 임베딩, 4: 완료) +} + +export type UploadState = { + isUploading: boolean; + uploadProgress: UploadProgress | null; + showProgressModal: boolean; +}; + +export type UploadActions = { + startUpload: (totalFiles: number) => void; + updateProgress: (progress: Partial) => void; + setShowProgressModal: (show: boolean) => void; + completeUpload: () => void; + resetUpload: () => void; +}; + +export type UploadStore = UploadState & UploadActions; + +export const defaultUploadState: UploadState = { + isUploading: false, + uploadProgress: null, + showProgressModal: false, +}; + +export const createUploadStore = (initState: UploadState = defaultUploadState) => { + return createStore()((set, get) => ({ + ...initState, + startUpload: (totalFiles: number) => set(() => ({ + isUploading: true, + uploadProgress: { + currentFile: 1, + totalFiles, + currentStep: "파일 업로드 중...", + fileName: "", + currentStepIndex: 0 + }, + showProgressModal: false + })), + updateProgress: (progress: Partial) => set((state) => ({ + uploadProgress: state.uploadProgress ? { + ...state.uploadProgress, + ...progress + } : null + })), + setShowProgressModal: (show: boolean) => set(() => ({ + showProgressModal: show + })), + completeUpload: () => set(() => ({ + isUploading: false, + uploadProgress: null, + showProgressModal: false + })), + resetUpload: () => set(() => ({ + isUploading: false, + uploadProgress: null, + showProgressModal: false + })) + })); +}; \ No newline at end of file diff --git a/apps/dc/src/shared/store/user-store.ts b/apps/dc/src/shared/store/user-store.ts new file mode 100644 index 000000000..65d897cf1 --- /dev/null +++ b/apps/dc/src/shared/store/user-store.ts @@ -0,0 +1,57 @@ +import { createStore } from "zustand/vanilla"; + +export interface IUserInfo { + regDt: string; + mdfcnDt: string; + rgtr: string; + mdfr: string; + adminRoleYn: string; + userUnqId: string; + userId: string; + userName: string; + email: string; + encryptedPassword: string; + refreshToken: string; + userStateCode: string; + lastLoginDate: string; + loginFailCount: number; + pswdHnt: string; + pswdAns: string; + mphn: string; + telno: string; + zip: string; + addr: string; + daddr: string; + laeSeCd: string; + ogdpSeCd: string; + aprvYn: string; + autzrId: string; + expryYmd: string; + lastPswdChgDt: string; + googleId: string; + kakaoId: string; + naverId: string; + isSocialUser: boolean; + hasPassword: boolean; +} + +export type UserState = { + userInfo: IUserInfo | null; +}; + +export type UserActions = { + setUserInfo: (userInfo: IUserInfo | null) => void; +}; + +export type UserStore = UserState & UserActions; + +export const defaultInitState: UserState = { + userInfo: null, +}; + +export const createUserStore = (initState: UserState = defaultInitState) => { + return createStore()((set) => ({ + ...initState, + setUserInfo: (userInfo: IUserInfo | null) => set(() => ({ userInfo })), + })); +}; diff --git a/apps/dc/src/shared/ui/alert-dialog.tsx b/apps/dc/src/shared/ui/alert-dialog.tsx new file mode 100644 index 000000000..728276713 --- /dev/null +++ b/apps/dc/src/shared/ui/alert-dialog.tsx @@ -0,0 +1,32 @@ +import { + Dialog, + DialogContent, + DialogFooter, + DialogClose, + Button, +} from "@common/components/ui"; +import { useAlertStore } from "../lib/use-alert-store"; + +function AlertDialog() { + const { message, callback, setMessage } = useAlertStore((state) => state); + const handleOpenChange = () => { + setMessage(""); + callback?.(); + }; + return ( + + +
+

{message}

+
+ + + + + +
+
+ ); +} + +export { AlertDialog }; diff --git a/apps/dc/src/shared/ui/code-select.tsx b/apps/dc/src/shared/ui/code-select.tsx new file mode 100644 index 000000000..31a547e38 --- /dev/null +++ b/apps/dc/src/shared/ui/code-select.tsx @@ -0,0 +1,41 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; + +type CodeSelectProps = { + value?: string; + onValueChange?: (value: string) => void; + options: { value: string; label: string }[]; + placeholder?: string; + className?: string; + disabled?: boolean; +}; + +export function CodeSelect({ + value, + onValueChange, + options, + placeholder = "선택", + className, + disabled = false, +}: CodeSelectProps) { + return ( + + ); +} \ No newline at end of file diff --git a/apps/dc/src/shared/ui/confirm-dialog.tsx b/apps/dc/src/shared/ui/confirm-dialog.tsx new file mode 100644 index 000000000..8f9ce2264 --- /dev/null +++ b/apps/dc/src/shared/ui/confirm-dialog.tsx @@ -0,0 +1,46 @@ +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogClose, + Button, + type ButtonProps, +} from "@common/components/ui"; + +type ConfirmDialogProps = { + open: boolean; + confirmButtonProps?: ButtonProps & { label?: string }; + cancelButtonProps?: ButtonProps & { label?: string }; +}; + +function ConfirmDialog({ + open, + confirmButtonProps, + cancelButtonProps, +}: ConfirmDialogProps) { + const { message, onConfirm, onCancel } = useConfirm(); + return ( + + +
+

{message}

+
+ + + + + + + + +
+
+ ); +} + +export { ConfirmDialog }; diff --git a/apps/dc/src/shared/ui/form.tsx b/apps/dc/src/shared/ui/form.tsx new file mode 100644 index 000000000..bd579d263 --- /dev/null +++ b/apps/dc/src/shared/ui/form.tsx @@ -0,0 +1,175 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + FormProvider, + useFormContext, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { cn, Label } from "@common/components"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +