diff --git a/apps/common/board/.gitignore b/apps/common/board/.gitignore new file mode 100644 index 000000000..e8d8437ee --- /dev/null +++ b/apps/common/board/.gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +.env* \ No newline at end of file diff --git a/apps/common/board/.prettierrc b/apps/common/board/.prettierrc new file mode 100644 index 000000000..b4bfed357 --- /dev/null +++ b/apps/common/board/.prettierrc @@ -0,0 +1,3 @@ +{ + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/apps/common/board/app/(with-base-layout)/board/page.tsx b/apps/common/board/app/(with-base-layout)/board/page.tsx new file mode 100644 index 000000000..9f8a68c08 --- /dev/null +++ b/apps/common/board/app/(with-base-layout)/board/page.tsx @@ -0,0 +1,2 @@ +import { BoardPage } from "@/pages/board"; +export default BoardPage; diff --git a/apps/common/board/app/(with-base-layout)/layout.tsx b/apps/common/board/app/(with-base-layout)/layout.tsx new file mode 100644 index 000000000..c3254a935 --- /dev/null +++ b/apps/common/board/app/(with-base-layout)/layout.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { usePathname, useRouter } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import type { MenuItem } from "@common/components/api"; +import { BaseLayout } from "@common/components/ui"; +import { useMenuStore } from "@/shared/lib"; +import { PageLayout } from "@/shared/ui/page-layout"; + +export default function WithBaseLayout({ + children, +}: { + children: React.ReactNode; +}) { + const router = useRouter(); + const pathname = usePathname(); + const queryClient = useQueryClient(); + + const { setCurrentMenuInfo } = useMenuStore((state) => state); + + const handleMenuClick = (item: MenuItem) => { + setCurrentMenuInfo(item); + router.push(item.urlPath); + }; + + return ( + + {children} + + ); +} diff --git a/apps/common/board/app/(with-base-layout)/loading.tsx b/apps/common/board/app/(with-base-layout)/loading.tsx new file mode 100644 index 000000000..3a3b15932 --- /dev/null +++ b/apps/common/board/app/(with-base-layout)/loading.tsx @@ -0,0 +1,2 @@ +import { SkeletonPage } from "@/shared/ui/skeleton-page"; +export default SkeletonPage; diff --git a/apps/common/board/app/api/proxy/[...url]/route.ts b/apps/common/board/app/api/proxy/[...url]/route.ts new file mode 100644 index 000000000..d3092c5c1 --- /dev/null +++ b/apps/common/board/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/common/board/app/auth/login/page.tsx b/apps/common/board/app/auth/login/page.tsx new file mode 100644 index 000000000..8f9e6adc6 --- /dev/null +++ b/apps/common/board/app/auth/login/page.tsx @@ -0,0 +1,2 @@ +import { LoginPage } from "@/pages/auth/login"; +export default LoginPage; diff --git a/apps/common/board/app/layout.tsx b/apps/common/board/app/layout.tsx new file mode 100644 index 000000000..aa2049dcc --- /dev/null +++ b/apps/common/board/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/common/board/eslint.config.mjs b/apps/common/board/eslint.config.mjs new file mode 100644 index 000000000..9109635e9 --- /dev/null +++ b/apps/common/board/eslint.config.mjs @@ -0,0 +1,21 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), + { + rules: { + "react-hooks/exhaustive-deps": "off", + }, + }, +]; + +export default eslintConfig; diff --git a/apps/common/board/middleware.ts b/apps/common/board/middleware.ts new file mode 100644 index 000000000..5b38dc5a8 --- /dev/null +++ b/apps/common/board/middleware.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { CLAIM_NAME, AUTH_USER_ID } from "./src/shared/config"; + +const serverApiUrl = process.env.NEXT_PUBLIC_SERVER_API_URL; + +export function middleware(req: NextRequest) { + const pathname = req.nextUrl.pathname; + const searchParams = req.nextUrl.searchParams; + + if (pathname.startsWith("/server")) { + const queryParams = searchParams.toString(); + const newUrl = `${serverApiUrl}${pathname.replace("/server", "")}${ + queryParams ? `?${queryParams}` : "" + }`; + return NextResponse.rewrite(new URL(newUrl)); + } + + if (pathname.startsWith("/attach-service")) { + const requestHeaders = new Headers(req.headers); + const token = req.cookies.get(CLAIM_NAME)?.value; + const userId = req.cookies.get(AUTH_USER_ID)?.value; + const newUrl = `${serverApiUrl}${pathname}`; + + if (token) requestHeaders.set(CLAIM_NAME, token); + if (userId) requestHeaders.set(AUTH_USER_ID, userId); + + return NextResponse.rewrite(new URL(newUrl), { + headers: requestHeaders, + }); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/server/:path*", "/attach-service/:path*"], +}; diff --git a/apps/common/board/next.config.ts b/apps/common/board/next.config.ts new file mode 100644 index 000000000..cb651cdc0 --- /dev/null +++ b/apps/common/board/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = {}; + +export default nextConfig; diff --git a/apps/common/board/package.json b/apps/common/board/package.json new file mode 100644 index 000000000..1a8f88c44 --- /dev/null +++ b/apps/common/board/package.json @@ -0,0 +1,50 @@ +{ + "name": "cs", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "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", + "@tinymce/tinymce-react": "^6.1.0", + "axios": "^1.8.1", + "chart.js": "^4.4.8", + "dayjs": "^1.11.13", + "js-cookie": "^3.0.5", + "next": "15.1.7", + "react-chartjs-2": "^5.3.0", + "react-dnd": "^16.0.1", + "react-hook-form": "^7.54.2", + "tailwindcss-animate": "^1.0.7", + "tus-js-client": "^4.3.1", + "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.13.1", + "@types/webpack": "^5.28.5", + "eslint": "^9", + "eslint-config-next": "15.1.7", + "postcss": "^8", + "prettier": "^3.4.2", + "prettier-plugin-tailwindcss": "^0.6.11", + "steiger": "^0.5.5", + "tailwindcss": "^3.4.1", + "typescript": "^5" + } +} diff --git a/apps/common/board/pages/.gitkeep b/apps/common/board/pages/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/common/board/postcss.config.mjs b/apps/common/board/postcss.config.mjs new file mode 100644 index 000000000..1a69fd2a4 --- /dev/null +++ b/apps/common/board/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + }, +}; + +export default config; diff --git a/apps/common/board/public/images/bg_login.jpg b/apps/common/board/public/images/bg_login.jpg new file mode 100755 index 000000000..b7793d16a Binary files /dev/null and b/apps/common/board/public/images/bg_login.jpg differ diff --git a/apps/common/board/src/app/index.tsx b/apps/common/board/src/app/index.tsx new file mode 100644 index 000000000..5a73f2fdd --- /dev/null +++ b/apps/common/board/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/common/board/src/app/providers/app-provider.tsx b/apps/common/board/src/app/providers/app-provider.tsx new file mode 100644 index 000000000..6fd488b90 --- /dev/null +++ b/apps/common/board/src/app/providers/app-provider.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ConfirmProvider } from "@frontend-opensource/use-react-hooks"; +import { AuthWrapper } from "@/entities/auth"; +import { MenuWrapper } from "@/entities/menu"; +import { StoreProvider } from "@/shared/providers"; +import { AlertDialog } from "@/shared/ui/alert-dialog"; +import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; + +const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { + const queryClient = new QueryClient(); + return ( + + + + + {children} + + + + + + + ); +}; + +export { AppProvider }; diff --git a/apps/common/board/src/app/providers/index.ts b/apps/common/board/src/app/providers/index.ts new file mode 100644 index 000000000..15f99b7fd --- /dev/null +++ b/apps/common/board/src/app/providers/index.ts @@ -0,0 +1 @@ +export { AppProvider } from "./app-provider"; diff --git a/apps/common/board/src/app/styles/base.css b/apps/common/board/src/app/styles/base.css new file mode 100644 index 000000000..e24111afe --- /dev/null +++ b/apps/common/board/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/common/board/src/app/styles/components.css b/apps/common/board/src/app/styles/components.css new file mode 100644 index 000000000..5b9742c0f --- /dev/null +++ b/apps/common/board/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 { + background-color: rgba(255, 255, 255, 0.065); + border-color: #38383a; + } + .card-header { + border-bottom-color: #3f4042; + } +} diff --git a/apps/common/board/src/entities/attachment/api/attachment-service.ts b/apps/common/board/src/entities/attachment/api/attachment-service.ts new file mode 100644 index 000000000..c930b7012 --- /dev/null +++ b/apps/common/board/src/entities/attachment/api/attachment-service.ts @@ -0,0 +1,43 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + Attachment, + AttachmentService, +} from "../model/attachment-interface"; + +export class AttachmentApiService implements AttachmentService { + public async getAttachments(atflCd: string): Promise { + const response = await client.get( + `${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflCd}`, + ); + return response.data; + } + + public async deleteAttachment(atflId: string): Promise { + await client.delete(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflId}`); + } + + public async deleteAttachmentByAtflCd(atflCd: string): Promise { + await client.delete( + `${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/atflCd/${atflCd}`, + ); + } + + public async getImageUrl(atflId: string): Promise { + if (!atflId) return ""; + const response = await fetch(`${ENDPOINT.ATTACH_SERVICE.IMAGES}/${atflId}`); + const blob = await response.blob(); + return URL.createObjectURL(blob); + } + + public async getImageUrlByAtflCd(atflCd: string): Promise { + if (!atflCd) return ""; + const response = await fetch( + `${ENDPOINT.ATTACH_SERVICE.IMAGES}/atflCd/${atflCd}`, + ); + const blob = await response.blob(); + return URL.createObjectURL(blob); + } +} + +export const AttachmentApi = new AttachmentApiService(); diff --git a/apps/common/board/src/entities/attachment/index.ts b/apps/common/board/src/entities/attachment/index.ts new file mode 100644 index 000000000..182ef9fd6 --- /dev/null +++ b/apps/common/board/src/entities/attachment/index.ts @@ -0,0 +1 @@ +export { AttachmentApi } from "./api/attachment-service"; diff --git a/apps/common/board/src/entities/attachment/model/attachment-interface.ts b/apps/common/board/src/entities/attachment/model/attachment-interface.ts new file mode 100644 index 000000000..5986a96d1 --- /dev/null +++ b/apps/common/board/src/entities/attachment/model/attachment-interface.ts @@ -0,0 +1,22 @@ +export interface Attachment { + atflCd: string; + atflSn: number; + atflId: string; + physFileNm: string; + orgnlFileNm: string; + atflFlieSz: number; + atflType: string; + dwnldNocs: number; + delYn: string; + lnkgDmnId: string; + lnkgDmnNm: string; + regDt: string; +} + +export interface AttachmentService { + getAttachments: (atflCd: string) => Promise; + deleteAttachment: (atflId: string) => Promise; + deleteAttachmentByAtflCd: (atflCd: string) => Promise; + getImageUrl: (atflId: string) => Promise; + getImageUrlByAtflCd: (atflCd: string) => Promise; +} diff --git a/apps/common/board/src/entities/auth/api/login-service.ts b/apps/common/board/src/entities/auth/api/login-service.ts new file mode 100644 index 000000000..c1968fe36 --- /dev/null +++ b/apps/common/board/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/common/board/src/entities/auth/index.ts b/apps/common/board/src/entities/auth/index.ts new file mode 100644 index 000000000..97e142225 --- /dev/null +++ b/apps/common/board/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/common/board/src/entities/auth/lib/auth-wrapper.tsx b/apps/common/board/src/entities/auth/lib/auth-wrapper.tsx new file mode 100644 index 000000000..705c03231 --- /dev/null +++ b/apps/common/board/src/entities/auth/lib/auth-wrapper.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { client } from "@/shared/api"; +import type { IUserInfo } from "@/shared/store"; +import { getCookie, useUserStore } from "@/shared/lib"; +import { ENDPOINT, AUTH_USER_ID } from "@/shared/config"; +import { LoginApi } from "../api/login-service"; + +const AuthWrapper = ({ children }: { children: React.ReactNode }) => { + const router = useRouter(); + const { userInfo, setUserInfo } = useUserStore((state) => state); + + async function authCheck() { + const userId = getCookie(AUTH_USER_ID); + if (!userId) { + throw new Error("Missing required authentication tokens"); + } + + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/${userId}`, + ); + 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/common/board/src/entities/auth/lib/token-manager.ts b/apps/common/board/src/entities/auth/lib/token-manager.ts new file mode 100644 index 000000000..0e761486a --- /dev/null +++ b/apps/common/board/src/entities/auth/lib/token-manager.ts @@ -0,0 +1,59 @@ +import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config"; +import { getCookie, setCookie, removeCookie } from "@/shared/lib"; + +export class TokenManager { + private static instance: TokenManager; + private refreshTimer: NodeJS.Timeout | null = null; + + private constructor() {} + + public static getInstance(): TokenManager { + if (!TokenManager.instance) { + TokenManager.instance = new TokenManager(); + } + return TokenManager.instance; + } + + public getToken(key: string): string | undefined { + return getCookie(key); + } + + public setToken(key: string, value: string): void { + setCookie(key, value, { + secure: window.location.protocol === "https:", + sameSite: "lax", + }); + } + + public removeToken(key: string): void { + removeCookie(key); + } + + public setupRefreshTimer( + callback: () => Promise, + 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/common/board/src/entities/auth/model/login-interface.ts b/apps/common/board/src/entities/auth/model/login-interface.ts new file mode 100644 index 000000000..5aaf53044 --- /dev/null +++ b/apps/common/board/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/common/board/src/entities/board/api/board-service.ts b/apps/common/board/src/entities/board/api/board-service.ts new file mode 100644 index 000000000..9cf29599b --- /dev/null +++ b/apps/common/board/src/entities/board/api/board-service.ts @@ -0,0 +1,21 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + BoardPopupList, + BoardPopupParams, + BoardService, +} from "../model/board-interface"; + +export class BoardApiService implements BoardService { + public async getBoardPopupList( + params: BoardPopupParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.BOARD_SERVICE.BBS}`, + { params }, + ); + return response.data; + } +} + +export const BoardApi = new BoardApiService(); diff --git a/apps/common/board/src/entities/board/index.ts b/apps/common/board/src/entities/board/index.ts new file mode 100644 index 000000000..2173bb4a0 --- /dev/null +++ b/apps/common/board/src/entities/board/index.ts @@ -0,0 +1,2 @@ +export { BoardApi } from "./api/board-service"; +export { type BoardPopup } from "./model/board-interface"; diff --git a/apps/common/board/src/entities/board/model/board-interface.ts b/apps/common/board/src/entities/board/model/board-interface.ts new file mode 100644 index 000000000..942840d74 --- /dev/null +++ b/apps/common/board/src/entities/board/model/board-interface.ts @@ -0,0 +1,42 @@ +import type { PageableList, PageableParams } from "@/shared/model"; + +export const BOARD_TYPE = { + BBSNORMAL: "BBSNORMAL", + BBSFAQ: "BBSFAQ", + BBSIMAGE: "BBSIMAGE", + BBSMOVIE: "BBSMOVIE", +} as const; + +export type BoardType = (typeof BOARD_TYPE)[keyof typeof BOARD_TYPE]; + +export interface BoardPopupParams extends PageableParams { + bbsNm?: string; +} + +export interface BoardPopup { + bbsId: number; + bbsTypeCd: BoardType; + bbsTypeNm: string; + bbsNm: string; + bbsExpln: string; + rfrncUseYn: string; + atflUseYn: string; + atflMaxCnt: number; + atflMaxFileSz: number; + ansUseYn: string; + cmntUseYn: string; + dgstfnEvlUseYn: string; + smsUseYn: string; + upndFixUseYn: string; + pstgPrdUseYn: string; + popupUseYn: string; + delYn: string; + regDt: string; + pstCnt: number; +} + +export type BoardPopupList = PageableList; + +export interface BoardService { + getBoardPopupList: (params: BoardPopupParams) => Promise; +} diff --git a/apps/common/board/src/entities/code/@x/posting.ts b/apps/common/board/src/entities/code/@x/posting.ts new file mode 100644 index 000000000..0525684d9 --- /dev/null +++ b/apps/common/board/src/entities/code/@x/posting.ts @@ -0,0 +1 @@ +export { CodeSelect } from "../ui/code-select"; diff --git a/apps/common/board/src/entities/code/api/code-service.ts b/apps/common/board/src/entities/code/api/code-service.ts new file mode 100644 index 000000000..0d1055f6a --- /dev/null +++ b/apps/common/board/src/entities/code/api/code-service.ts @@ -0,0 +1,14 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { CodeService, ICode } from "../model/code-interface"; + +export class CodeApiService implements CodeService { + public async getCode(upCd: string): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.CODE}/${upCd}`, + ); + return response.data; + } +} + +export const CodeApi = new CodeApiService(); diff --git a/apps/common/board/src/entities/code/index.ts b/apps/common/board/src/entities/code/index.ts new file mode 100644 index 000000000..0b6633f4b --- /dev/null +++ b/apps/common/board/src/entities/code/index.ts @@ -0,0 +1 @@ +export { CodeSelect } from "./ui/code-select"; diff --git a/apps/common/board/src/entities/code/model/code-interface.ts b/apps/common/board/src/entities/code/model/code-interface.ts new file mode 100644 index 000000000..0d066e6da --- /dev/null +++ b/apps/common/board/src/entities/code/model/code-interface.ts @@ -0,0 +1,9 @@ +export interface ICode { + lwpoCd: string; + upCd: string; + lwpoCdNm: string; +} + +export interface CodeService { + getCode(upCd: string): Promise; +} diff --git a/apps/common/board/src/entities/code/ui/code-select.tsx b/apps/common/board/src/entities/code/ui/code-select.tsx new file mode 100644 index 000000000..bd175a8a9 --- /dev/null +++ b/apps/common/board/src/entities/code/ui/code-select.tsx @@ -0,0 +1,42 @@ +import { useQuery } from "@tanstack/react-query"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; +import { ENDPOINT } from "@/shared/config"; +import { CodeApi } from "../api/code-service"; + +type CodeSelectProps = { + upCd: string; +} & React.ComponentPropsWithoutRef; + +function CodeSelect({ upCd, defaultValue, ...props }: CodeSelectProps) { + const { data: code } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.CODE, upCd], + queryFn: () => CodeApi.getCode(upCd), + enabled: !!upCd, + }); + + const defaultValueText = + code?.find((item) => item.lwpoCd === defaultValue)?.lwpoCdNm ?? "선택"; + + return ( + + ); +} + +export { CodeSelect }; diff --git a/apps/common/board/src/entities/editor/api/editor-service.ts b/apps/common/board/src/entities/editor/api/editor-service.ts new file mode 100644 index 000000000..3e2fc75a4 --- /dev/null +++ b/apps/common/board/src/entities/editor/api/editor-service.ts @@ -0,0 +1,19 @@ +import { client } from "@/shared/api"; +import type { + EditorService, + FileInfo, + UploadFile, +} from "../model/editor-interface"; +import { ENDPOINT } from "@/shared/config"; + +export class EditorApiService implements EditorService { + async uploadImage(data: UploadFile): Promise { + const response = await client.post( + ENDPOINT.ATTACH_SERVICE.EDITOR_UPLOAD, + data, + ); + return response.data; + } +} + +export const EditorApi = new EditorApiService(); diff --git a/apps/common/board/src/entities/editor/index.ts b/apps/common/board/src/entities/editor/index.ts new file mode 100644 index 000000000..1ae82fb4f --- /dev/null +++ b/apps/common/board/src/entities/editor/index.ts @@ -0,0 +1 @@ +export { Editor } from "./ui/editor"; diff --git a/apps/common/board/src/entities/editor/lib/blob-to-base64.ts b/apps/common/board/src/entities/editor/lib/blob-to-base64.ts new file mode 100644 index 000000000..2f0e68c0f --- /dev/null +++ b/apps/common/board/src/entities/editor/lib/blob-to-base64.ts @@ -0,0 +1,10 @@ +export const blobToBase64 = (blob: Blob): Promise => { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => { + const base64 = reader.result as string; + resolve(base64.split(",")[1] || ""); + }; + reader.readAsDataURL(blob); + }); +}; diff --git a/apps/common/board/src/entities/editor/lib/generate-file-info.ts b/apps/common/board/src/entities/editor/lib/generate-file-info.ts new file mode 100644 index 000000000..c36ed3577 --- /dev/null +++ b/apps/common/board/src/entities/editor/lib/generate-file-info.ts @@ -0,0 +1,16 @@ +import { blobToBase64 } from "./blob-to-base64"; + +export const generateFileInfo = async (blobInfo: { + blob: () => Blob; + filename: () => string; +}) => { + const blob = blobInfo.blob(); + const base64 = await blobToBase64(blob); + return { + fieldName: `${blob.type.split("/")[1]}`, + fileType: blob.type, + fileBase64: base64, + originalName: blobInfo.filename(), + size: blob.size, + }; +}; diff --git a/apps/common/board/src/entities/editor/model/editor-base-config.ts b/apps/common/board/src/entities/editor/model/editor-base-config.ts new file mode 100644 index 000000000..1b97ba9a0 --- /dev/null +++ b/apps/common/board/src/entities/editor/model/editor-base-config.ts @@ -0,0 +1,37 @@ +import { SERVER_API_URL } from "@/shared/config"; + +export const editorBaseConfig = { + apiKey: process.env.NEXT_PUBLIC_TINY_API_KEY, + init: { + language: "ko_KR", + height: 500, + plugins: [ + "advlist", + "autolink", + "lists", + "link", + "image", + "charmap", + "preview", + "anchor", + "searchreplace", + "visualblocks", + "code", + "codesample", + "fullscreen", + "insertdatetime", + "media", + "table", + "code", + "help", + "wordcount", + "save", + ], + toolbar: [ + "undo redo | blocks fontfamily fontsize | bold italic underline strikethrough forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist blockquote | table", + "link image", + ], + images_upload_base_path: SERVER_API_URL, + file_picker_types: "image", + }, +}; diff --git a/apps/common/board/src/entities/editor/model/editor-interface.ts b/apps/common/board/src/entities/editor/model/editor-interface.ts new file mode 100644 index 000000000..ed6b4a987 --- /dev/null +++ b/apps/common/board/src/entities/editor/model/editor-interface.ts @@ -0,0 +1,20 @@ +export interface UploadFile { + fieldName: string; + fileType: string; + fileBase64: string; + originalName: string; + size: number; +} + +export interface FileInfo { + originalFileName: string; + message: string; + fileType: string; + size: number; + uploaded: number; + url: string; +} + +export interface EditorService { + uploadImage(data: UploadFile): Promise; +} diff --git a/apps/common/board/src/entities/editor/ui/editor.tsx b/apps/common/board/src/entities/editor/ui/editor.tsx new file mode 100644 index 000000000..b5ef02153 --- /dev/null +++ b/apps/common/board/src/entities/editor/ui/editor.tsx @@ -0,0 +1,60 @@ +import { useRef } from "react"; +import { + Editor as TinyEditor, + type Editor as TinyEditorInstance, + type IAllProps, +} from "@tinymce/tinymce-react"; +import { editorBaseConfig as baseConfig } from "../model/editor-base-config"; +import { generateFileInfo } from "../lib/generate-file-info"; +import { EditorApi } from "../api/editor-service"; + +const Editor = ({ ...props }: IAllProps) => { + const editorRef = useRef(null); + + const imagesUploadHandler = (blobInfo: { + blob: () => Blob; + filename: () => string; + }) => { + return new Promise(async (resolve) => { + const fileInfo = await generateFileInfo(blobInfo); + const data = await EditorApi.uploadImage(fileInfo); + resolve(data.url); + }); + }; + + const filePickerCallback = ( + callback: (base64: string, meta: { title: string }) => void, + ) => { + const input = document.createElement("input"); + input.setAttribute("type", "file"); + input.setAttribute("accept", "image/*"); + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = async () => { + const base64 = reader.result as string; + callback(base64, { title: file.name }); + }; + reader.readAsDataURL(file); + }; + input.click(); + }; + + return ( + { + editorRef.current = editor; + }} + init={{ + ...baseConfig.init, + images_upload_handler: imagesUploadHandler, + file_picker_callback: filePickerCallback, + }} + {...props} + /> + ); +}; + +export { Editor }; diff --git a/apps/common/board/src/entities/group/index.ts b/apps/common/board/src/entities/group/index.ts new file mode 100644 index 000000000..6bfb42bf1 --- /dev/null +++ b/apps/common/board/src/entities/group/index.ts @@ -0,0 +1,6 @@ +export { GroupApi } from "./api/group-api"; +export { + type GroupParams, + type Group, + type GroupFormData, +} from "./model/types"; diff --git a/apps/common/board/src/entities/group/model/types.ts b/apps/common/board/src/entities/group/model/types.ts new file mode 100644 index 000000000..79ba1211b --- /dev/null +++ b/apps/common/board/src/entities/group/model/types.ts @@ -0,0 +1,29 @@ +export interface Group { + groupId: string; + groupNm: string; + groupSeCd: string; + groupExpln: string; + delYn: string; + regDt: string; + rgtr: string; + mdfcnDt: string | null; + mdfr: string | null; + authrtId: string; + siteId: string | null; +} + +export interface GroupParams { + groupNm?: string; + groupSeCd?: string; + siteId?: string; +} + +export interface GroupFormData { + groupId: string; + groupNm: string; + groupSeCd: string; + groupExpln: string; + delYn: string; + authrtId: string; + siteId: string; +} \ No newline at end of file diff --git a/apps/common/board/src/entities/menu-authority/api/menu-authority-service.ts b/apps/common/board/src/entities/menu-authority/api/menu-authority-service.ts new file mode 100644 index 000000000..f9b01b4d8 --- /dev/null +++ b/apps/common/board/src/entities/menu-authority/api/menu-authority-service.ts @@ -0,0 +1,32 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + MenuAuthorityList, + MenuAuthorityParams, + MenuAuthorityPayloadData, + MenuAuthorityService, +} 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 saveMenuAuthority( + data: MenuAuthorityPayloadData[], + ): Promise { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}`, + data, + ); + return response.data; + } +} + +export const MenuAuthorityApi = new MenuAuthorityApiService(); diff --git a/apps/common/board/src/entities/menu-authority/index.ts b/apps/common/board/src/entities/menu-authority/index.ts new file mode 100644 index 000000000..07433a098 --- /dev/null +++ b/apps/common/board/src/entities/menu-authority/index.ts @@ -0,0 +1 @@ +export { ManagementAuthorityDialog } from "./ui/management-authority-dialog"; diff --git a/apps/common/board/src/entities/menu-authority/model/menu-authority-interface.ts b/apps/common/board/src/entities/menu-authority/model/menu-authority-interface.ts new file mode 100644 index 000000000..d6775838c --- /dev/null +++ b/apps/common/board/src/entities/menu-authority/model/menu-authority-interface.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; +import type { PageableList, PageableParams } from "@/shared/model"; + +export interface MenuAuthorityParams extends PageableParams { + groupId?: string; + menuCd?: number; + useYn?: 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 MenuAuthorityPayloadData = z.infer< + typeof menuAuthorityPayloadSchema +>; +export type MenuAuthorityFormData = z.infer; + +export interface MenuAuthorityService { + getMenuAuthorityList(params: MenuAuthorityParams): Promise; + saveMenuAuthority(data: MenuAuthorityPayloadData[]): Promise; +} diff --git a/apps/common/board/src/entities/menu-authority/model/menu-authority-table-columns.tsx b/apps/common/board/src/entities/menu-authority/model/menu-authority-table-columns.tsx new file mode 100644 index 000000000..90bff735d --- /dev/null +++ b/apps/common/board/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/ui"; +import type { + MenuAuthority, + MenuAuthorityFormData, + MenuAuthorityPayloadData, +} from "./menu-authority-interface"; + +export const columns = ( + form: UseFormReturn, +): ColumnDef[] => { + const getCurrentRow = (i: number) => form.watch("menuAuthority")[i]; + + const handleCheckedChange = + (i: number, key: keyof MenuAuthorityPayloadData) => (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/common/board/src/entities/menu-authority/ui/management-authority-dialog.tsx b/apps/common/board/src/entities/menu-authority/ui/management-authority-dialog.tsx new file mode 100644 index 000000000..8b940634a --- /dev/null +++ b/apps/common/board/src/entities/menu-authority/ui/management-authority-dialog.tsx @@ -0,0 +1,101 @@ +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { + Button, + DataTable, + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@common/components/ui"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; +import { + menuAuthorityFormSchema, + type MenuAuthorityFormData, + type MenuAuthorityPayloadData, +} from "../model/menu-authority-interface"; +import { columns } from "../model/menu-authority-table-columns"; +import { MenuAuthorityApi } from "../api/menu-authority-service"; + +type ManagementAuthorityDialogProps = { + menuCd?: number; +} & React.ComponentProps; + +function ManagementAuthorityDialog({ + menuCd, + ...props +}: ManagementAuthorityDialogProps) { + const { confirm } = useConfirm(); + const { setMessage: alert } = useAlertStore((state) => state); + + const { data: menuAuthorityList } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.MENU_AUTHRTS, menuCd], + queryFn: () => MenuAuthorityApi.getMenuAuthorityList({ menuCd }), + enabled: !!menuCd, + }); + + const { mutateAsync: saveMenuAuthority } = useMutation({ + mutationFn: (data: MenuAuthorityPayloadData[]) => + MenuAuthorityApi.saveMenuAuthority(data), + onSuccess: () => { + alert("저장되었습니다."); + }, + }); + + const form = useForm({ + resolver: zodResolver(menuAuthorityFormSchema), + values: { + menuAuthority: menuAuthorityList?.content ?? [], + }, + }); + + const table = useReactTable({ + data: menuAuthorityList?.content ?? [], + columns: columns(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 ( + + + + + + + 그룹별 메뉴 권한 + + + + + + + + + + + ); +} + +export { ManagementAuthorityDialog }; diff --git a/apps/common/board/src/entities/menu/api/menu-service.ts b/apps/common/board/src/entities/menu/api/menu-service.ts new file mode 100644 index 000000000..b6c1510cd --- /dev/null +++ b/apps/common/board/src/entities/menu/api/menu-service.ts @@ -0,0 +1,14 @@ +import { client } from "@common/components/api"; +import { ENDPOINT } from "@/shared/config"; +import type { MenuItem, MenuService } 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; + } +} + +export const MenuApi = new MenuApiService(); diff --git a/apps/common/board/src/entities/menu/index.ts b/apps/common/board/src/entities/menu/index.ts new file mode 100644 index 000000000..1fa9dd218 --- /dev/null +++ b/apps/common/board/src/entities/menu/index.ts @@ -0,0 +1 @@ +export { MenuWrapper } from "./lib/menu-wrapper"; diff --git a/apps/common/board/src/entities/menu/lib/menu-wrapper.tsx b/apps/common/board/src/entities/menu/lib/menu-wrapper.tsx new file mode 100644 index 000000000..d473548af --- /dev/null +++ b/apps/common/board/src/entities/menu/lib/menu-wrapper.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname } from "next/navigation"; +import { SITE_ID } from "@/shared/config"; +import { useMenuStore } from "@/shared/lib"; +import { MenuApi } from "../api/menu-service"; +import { getCurrentMenu } from "./utils"; + +const MenuWrapper = ({ children }: { children: React.ReactNode }) => { + const pathname = usePathname(); + const { setCurrentMenuInfo } = useMenuStore((state) => state); + + useEffect(() => { + (async () => { + const menuRoles = await MenuApi.getMenuRoles(SITE_ID); + if (!menuRoles) return; + + const currentMenu = getCurrentMenu(menuRoles, pathname ?? ""); + if (!currentMenu) return; + + setCurrentMenuInfo(currentMenu); + })(); + }, []); + + return <>{children}; +}; + +export { MenuWrapper }; diff --git a/apps/common/board/src/entities/menu/lib/utils.ts b/apps/common/board/src/entities/menu/lib/utils.ts new file mode 100644 index 000000000..1cf4514d9 --- /dev/null +++ b/apps/common/board/src/entities/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/common/board/src/entities/menu/model/menu-interface.ts b/apps/common/board/src/entities/menu/model/menu-interface.ts new file mode 100644 index 000000000..12c7d007a --- /dev/null +++ b/apps/common/board/src/entities/menu/model/menu-interface.ts @@ -0,0 +1,23 @@ +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 MenuService { + getMenuRoles(siteId: string): Promise; +} diff --git a/apps/common/board/src/entities/posting/api/posting-service.ts b/apps/common/board/src/entities/posting/api/posting-service.ts new file mode 100644 index 000000000..2fafd6557 --- /dev/null +++ b/apps/common/board/src/entities/posting/api/posting-service.ts @@ -0,0 +1,31 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { PageableList } from "@/shared/model"; +import type { + PostService, + PostingParams, + Post, + PostPayloadData, +} from "../model/posting-interface"; + +export class PostingApiService implements PostService { + public async getPostingHistory( + params: PostingParams, + ): Promise> { + const response = await client.get>( + `${ENDPOINT.CMS_SERVICE.POSTINGS}`, + { params }, + ); + return response.data; + } + + public async savePosting(data: PostPayloadData[]): Promise { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.POSTINGS}`, + data, + ); + return response.data; + } +} + +export const PostingApi = new PostingApiService(); diff --git a/apps/common/board/src/entities/posting/index.ts b/apps/common/board/src/entities/posting/index.ts new file mode 100644 index 000000000..c6029d757 --- /dev/null +++ b/apps/common/board/src/entities/posting/index.ts @@ -0,0 +1,7 @@ +export { PostingApi } from "./api/posting-service"; +export { + POSTING_TYPE, + type Post, + type PostPayloadData, +} from "./model/posting-interface"; +export { PostingDialog } from "./ui/posting-dialog"; diff --git a/apps/common/board/src/entities/posting/model/posting-interface.ts b/apps/common/board/src/entities/posting/model/posting-interface.ts new file mode 100644 index 000000000..b14b2ab66 --- /dev/null +++ b/apps/common/board/src/entities/posting/model/posting-interface.ts @@ -0,0 +1,50 @@ +import type { PageableList, PageableParams } from "@/shared/model"; +import { z } from "zod"; + +export const POSTING_TYPE = { + POPUP: "POPUP", + BANNER: "BANNER", + IMAGE: "IMAGE", +} as const; + +export type PostingType = (typeof POSTING_TYPE)[keyof typeof POSTING_TYPE]; + +export interface PostingParams extends PageableParams { + pstgTrgtNo: number; +} + +export const postSchema = z.object({ + siteId: z.string(), + siteNm: z.string(), + pstgTypeCd: z.string(), + pstgTypeNm: z.string(), + pstgTrgtNo: z.number(), + pstgBgngDt: z.string().optional(), + pstgEndDt: z.string().optional(), + pstgPrdSeCd: z.string(), + pstgPrdSeNm: z.string(), + delYn: z.string(), +}); + +export const postPayloadSchema = postSchema + .extend({ + mode: z.string(), + }) + .omit({ + siteNm: true, + pstgTypeNm: true, + pstgPrdSeNm: true, + }); + +export const postingFormSchema = z.object({ + pstg: z.array(postPayloadSchema), +}); + +export type Post = z.infer; +export type PostPayloadData = z.infer; +export type PostingFormData = z.infer; + +export interface PostService { + getPostingHistory: (params: PostingParams) => Promise>; + savePosting: (data: PostPayloadData[]) => Promise; +} diff --git a/apps/common/board/src/entities/posting/ui/posting-dialog.tsx b/apps/common/board/src/entities/posting/ui/posting-dialog.tsx new file mode 100644 index 000000000..9e27a4485 --- /dev/null +++ b/apps/common/board/src/entities/posting/ui/posting-dialog.tsx @@ -0,0 +1,323 @@ +import dayjs from "dayjs"; +import { useState } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useFieldArray, useForm } from "react-hook-form"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { + Dialog, + DialogTrigger, + Button, + DialogContent, + DialogHeader, + DialogTitle, + Badge, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Checkbox, + Select, + DateRangePicker, + DialogClose, + DialogFooter, +} from "@common/components/ui"; +import { cn } from "@common/components/lib"; +import { CodeSelect } from "@/entities/code/@x/posting"; +import { SiteApi, type Site } from "@/entities/site/@x/posting"; +import type { PageableList } from "@/shared/model"; +import { ENDPOINT } from "@/shared/config"; +import { PostingApi } from "../api/posting-service"; +import { + postingFormSchema, + type PostingFormData, + type PostingType, + type PostPayloadData, +} from "../model/posting-interface"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; + +type DateRange = { + from: string; + to?: string; +}; + +type PostingTarget = { + pstgTrgtNm: string; + pstgTrgtNo: number; +}; + +type PostingDialogProps = { + postingType: PostingType; + postingTargets: PostingTarget[]; +}; + +const POSTING_TYPE_LABEL = { + POPUP: "팝업", + BANNER: "배너", + IMAGE: "이미지", +} as const; + +function PostingDialog({ postingType, postingTargets }: PostingDialogProps) { + const { confirm } = useConfirm(); + const { setMessage: alert } = useAlertStore((state) => state); + const [selectedRows, setSelectedRows] = useState([]); + const [rememberPeriodCode, setRememberPeriodCode] = useState< + Record + >({}); + const [rememberPeriodDate, setRememberPeriodDate] = useState< + Record + >({}); + + const { data: siteList } = useQuery>({ + queryKey: [ENDPOINT.CMS_SERVICE.SITES], + queryFn: () => SiteApi.getSiteList({}), + }); + + const { mutateAsync: savePosting } = useMutation({ + mutationFn: (data: PostPayloadData[]) => PostingApi.savePosting(data), + onSuccess: () => { + alert("저장되었습니다."); + }, + }); + + const form = useForm({ + resolver: zodResolver(postingFormSchema), + }); + const { fields, append, replace } = useFieldArray({ + control: form.control, + name: "pstg", + }); + + const isFormValid = + fields.length && fields.every((field) => field.pstgPrdSeCd !== ""); + + const isAllSelected = + siteList && selectedRows.length === siteList.content.length; + + const postingPeriod = (siteId: string) => { + const periodCode = Number(rememberPeriodCode[siteId]); + + if (periodCode === 99) return "상시"; + + return `${dayjs().format("YYYY-MM-DD")} ~ ${dayjs() + .add(periodCode, "day") + .format("YYYY-MM-DD")}`; + }; + + const handleOpenChange = (open: boolean) => { + if (open) return true; + replace([]); + setSelectedRows([]); + setRememberPeriodCode({}); + setRememberPeriodDate({}); + }; + + const handleAppendFields = (i: number) => { + const siteId = siteList?.content[i]?.siteId ?? ""; + const newFields = postingTargets.map((target) => ({ + siteId, + pstgTypeCd: postingType, + pstgTrgtNo: target.pstgTrgtNo, + pstgBgngDt: rememberPeriodDate[siteId]?.from ?? "", + pstgEndDt: rememberPeriodDate[siteId]?.to ?? "", + pstgPrdSeCd: rememberPeriodCode[siteId] ?? "", + delYn: "N", + mode: "C", + })); + append(newFields); + }; + + const handleUpdateFields = + (siteId: string) => (obj: Record) => { + const updatedFields = fields.map((field) => + "siteId" in field && field.siteId === siteId + ? { ...field, ...obj } + : field, + ); + replace(updatedFields); + }; + + const handleRemoveFields = (siteId: string) => { + const filteredFields = fields.filter( + (field) => "siteId" in field && field.siteId !== siteId, + ); + replace(filteredFields); + }; + + const handleSelectRow = (i: number) => { + const isSelected = selectedRows.includes(i); + setSelectedRows((prev) => + isSelected ? prev.filter((j) => j !== i) : [...prev, i], + ); + + if (isSelected) { + const siteId = siteList?.content[i]?.siteId; + if (!siteId) return; + handleRemoveFields(siteId); + return; + } + + handleAppendFields(i); + }; + + const handleSelectAll = () => { + if (selectedRows.length === (siteList?.content.length ?? 0)) { + replace([]); + setSelectedRows([]); + } else { + replace([]); + setSelectedRows(siteList?.content.map((_, i) => i) ?? []); + siteList?.content.forEach((_, i) => handleAppendFields(i)); + } + }; + + const handlePeriodCodeChange = (siteId: string) => (value: string) => { + const from = dayjs().format("YYYY-MM-DD 00:00:00"); + const to = dayjs().add(Number(value), "day").format("YYYY-MM-DD 23:59:59"); + + setRememberPeriodCode((prev) => ({ + ...prev, + [siteId]: value, + })); + setRememberPeriodDate((prev) => ({ + ...prev, + [siteId]: { from, to }, + })); + handleUpdateFields(siteId)({ + pstgPrdSeCd: value, + pstgBgngDt: from, + pstgEndDt: to, + }); + }; + + const handlePeriodDateChange = + (siteId: string) => (value: DateRange | undefined) => { + const from = dayjs(value?.from).format("YYYY-MM-DD 00:00:00"); + const to = dayjs(value?.to).format("YYYY-MM-DD 23:59:59"); + setRememberPeriodDate((prev) => ({ + ...prev, + [siteId]: { from, to }, + })); + handleUpdateFields(siteId)({ + pstgBgngDt: from, + pstgEndDt: to, + }); + }; + + const handleSave = async () => { + if (await confirm("저장하시겠습니까?")) { + const data = form.getValues("pstg"); + await savePosting(data); + } + }; + + return ( + + + + + + + 사이트 반영 + +
+

+ 선택된 {POSTING_TYPE_LABEL[postingType]} +

+
+ {postingTargets.map((target) => ( + + {target.pstgTrgtNm} + + ))} +
+
+
+ + + + + + + + + + + + 사이트명 + 게시 구분 + + + + {siteList?.content.map((site, i) => ( + + + handleSelectRow(i)} + /> + + {site.siteNm} + +
+ + {!!Number(rememberPeriodCode[site.siteId]) && ( + + 게시 기간: {postingPeriod(site.siteId)} + + )} + {Number(rememberPeriodCode[site.siteId]) === 0 && ( + + handlePeriodDateChange(site.siteId)( + date as DateRange | undefined, + ) + } + disabled={!selectedRows.includes(i)} + /> + )} +
+
+
+ ))} +
+
+
+ + + + + + +
+
+ ); +} + +function PostingPeriodSelect({ + ...props +}: React.ComponentPropsWithoutRef) { + return ; +} + +export { PostingDialog }; diff --git a/apps/common/board/src/entities/site/@x/posting.ts b/apps/common/board/src/entities/site/@x/posting.ts new file mode 100644 index 000000000..6470b3cb2 --- /dev/null +++ b/apps/common/board/src/entities/site/@x/posting.ts @@ -0,0 +1,2 @@ +export { SiteApi } from "../api/site-service"; +export type { Site } from "../model/site-interface"; diff --git a/apps/common/board/src/entities/site/api/site-service.ts b/apps/common/board/src/entities/site/api/site-service.ts new file mode 100644 index 000000000..a5d9d7cd6 --- /dev/null +++ b/apps/common/board/src/entities/site/api/site-service.ts @@ -0,0 +1,51 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import { + siteFormSchema, + type SiteService, + type SiteList, + type SiteDetail, + type SiteParams, + type SiteFormData, +} from "../model/site-interface"; + +export class SiteApiService implements SiteService { + public async getSiteList(params?: SiteParams): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.SITES}`, + { params }, + ); + return response.data; + } + + public async siteDetail(siteId: string): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.SITES}/${siteId}`, + ); + return response.data; + } + + public async createSite(data: SiteFormData): Promise { + const validatedData = siteFormSchema.parse(data); + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.SITES}`, + validatedData, + ); + return response.data; + } + + public async updateSite(siteId: string, data: SiteFormData): Promise { + const validatedData = siteFormSchema.parse(data); + const response = await client.put( + `${ENDPOINT.CMS_SERVICE.SITES}/${siteId}`, + validatedData, + ); + return response.data; + } + + public async deleteSite(siteId: string): Promise { + await client.delete(`${ENDPOINT.CMS_SERVICE.SITES}/${siteId}`); + } +} + +export const SiteApi = new SiteApiService(); diff --git a/apps/common/board/src/entities/site/index.ts b/apps/common/board/src/entities/site/index.ts new file mode 100644 index 000000000..c7b437a64 --- /dev/null +++ b/apps/common/board/src/entities/site/index.ts @@ -0,0 +1,7 @@ +export { SiteApi } from "./api/site-service"; +export { + siteFormSchema, + type SiteParams, + type Site, + type SiteFormData, +} from "./model/site-interface"; diff --git a/apps/common/board/src/entities/site/model/site-interface.ts b/apps/common/board/src/entities/site/model/site-interface.ts new file mode 100644 index 000000000..8846f59b9 --- /dev/null +++ b/apps/common/board/src/entities/site/model/site-interface.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; +import type { PageableParams, PageableList } from "@/shared/model"; + +export interface SiteParams extends PageableParams { + siteNm?: string; + siteKndCd?: string; + useYn?: string; +} + +export interface Site { + siteId: string; + siteNm: string; + siteKindCode: string; + bscSiteYn: string; + siteAddr: string; + useYn: string; + regDt: string; + rgTrNm: string; + dmnAddr: string; +} + +export interface SiteDetail extends SiteFormData { + siteId: string; + siteKindNm: string; + basicSiteYn: string; + faxNumber: string; + regDt: string; + rgTr: string; + mdfcnDt: string; + mdFr: string; + rgTrNm: string; + mdfrNm: string; +} + +export const siteFormSchema = z.object({ + siteId: z.string().max(2).nonempty(), + siteNm: z.string().max(100).nonempty(), + siteExpln: z.string().max(1000).nullish(), + siteKndCd: z.string().max(10).nonempty(), + siteAddr: z.string().max(2000).nullish(), + faxNo: z.string().max(100).nullish(), + telNo: z.string().max(1000).nullish(), + lwndCn: z.string().max(2000).nullish(), + bscSiteYn: z.string(), + bkmkIcon: z.string().max(100).nullish(), + useYn: z.string(), + siteSkn: z.string().max(10).nullish(), + dmnAddr: z.string().nullish(), +}); + +export type SiteFormData = z.infer; +export type SiteList = PageableList; + +export interface SiteService { + getSiteList(params: SiteParams): Promise; + siteDetail(siteId: string): Promise; + updateSite(siteId: string, data: SiteFormData): Promise; + createSite(data: SiteFormData): Promise; +} diff --git a/apps/common/board/src/pages/auth/login/index.ts b/apps/common/board/src/pages/auth/login/index.ts new file mode 100644 index 000000000..2093c3d8d --- /dev/null +++ b/apps/common/board/src/pages/auth/login/index.ts @@ -0,0 +1 @@ +export { LoginPage } from "./ui/login-page"; diff --git a/apps/common/board/src/pages/auth/login/ui/login-page.tsx b/apps/common/board/src/pages/auth/login/ui/login-page.tsx new file mode 100644 index 000000000..4700a691b --- /dev/null +++ b/apps/common/board/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 = "/dashboard"; + }; + + 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/common/board/src/pages/auth/login/ui/login.css b/apps/common/board/src/pages/auth/login/ui/login.css new file mode 100755 index 000000000..831cc13a9 --- /dev/null +++ b/apps/common/board/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/common/board/src/pages/board/index.ts b/apps/common/board/src/pages/board/index.ts new file mode 100644 index 000000000..483a2f96b --- /dev/null +++ b/apps/common/board/src/pages/board/index.ts @@ -0,0 +1 @@ +export { BoardPage } from "./ui/board-page"; diff --git a/apps/common/board/src/pages/board/ui/board-page.tsx b/apps/common/board/src/pages/board/ui/board-page.tsx new file mode 100644 index 000000000..15960674e --- /dev/null +++ b/apps/common/board/src/pages/board/ui/board-page.tsx @@ -0,0 +1,5 @@ +function BoardPage() { + return
게시판 관리
; +} + +export { BoardPage }; diff --git a/apps/common/board/src/shared/api/client.ts b/apps/common/board/src/shared/api/client.ts new file mode 100644 index 000000000..709bb44a5 --- /dev/null +++ b/apps/common/board/src/shared/api/client.ts @@ -0,0 +1,8 @@ +import { Client } from "@common/network"; +import { BASE_URL } from "../config/api"; +import { SITE_ID } from "../config/env"; + +export const client = Client.getInstance({ + baseURL: BASE_URL, + siteId: SITE_ID, +}); diff --git a/apps/common/board/src/shared/api/index.ts b/apps/common/board/src/shared/api/index.ts new file mode 100644 index 000000000..b7b9d6121 --- /dev/null +++ b/apps/common/board/src/shared/api/index.ts @@ -0,0 +1 @@ +export { client } from "./client"; diff --git a/apps/common/board/src/shared/config/api.ts b/apps/common/board/src/shared/config/api.ts new file mode 100644 index 000000000..94d2e728d --- /dev/null +++ b/apps/common/board/src/shared/config/api.ts @@ -0,0 +1,40 @@ +import { PROXY_HOST } from "./env"; + +export const BASE_URL = `${PROXY_HOST}/server`; + +export const ENDPOINT = { + ATTACH_SERVICE: { + TUS_UPLOAD: "/attach-service/api/v1/tus/upload", + EDITOR_UPLOAD: "/attach-service/api/v1/upload/editor", + IMAGES: "/attach-service/api/v1/images", + ATTACHMENTS: "/attach-service/api/v1/atfl/attachments", + }, + USER_SERVICE: { + LOGIN: "/user-service/login", + USERS: "/user-service/api/v1/users", + SILENT_REFRESH: "/user-service/api/v1/users/token/refresh", + AUTHRTS: "/user-service/api/v1/authorizations", + ROLES_AUTHRTS: "/user-service/api/v1/role-authorizations", + }, + CMS_SERVICE: { + 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", + CONTENTS: "/cms-service/api/v1/contents", + 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", + }, + BOARD_SERVICE: { + BBS: "/board-service/api/v1/bbs", + }, +}; + +export const JWT_EXPIRATION_TIME = 1800000; diff --git a/apps/common/board/src/shared/config/env.ts b/apps/common/board/src/shared/config/env.ts new file mode 100644 index 000000000..fa6b626c4 --- /dev/null +++ b/apps/common/board/src/shared/config/env.ts @@ -0,0 +1,13 @@ +export const PORT = process.env.NEXT_PUBLIC_PORT || "3001"; +export const PROXY_HOST = + process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`; + +export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL; +export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "CS"; + +export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization"; +export const ACCESS_TOKEN = + process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token"; +export const REFRESH_TOKEN = + process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token"; +export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; diff --git a/apps/common/board/src/shared/config/index.ts b/apps/common/board/src/shared/config/index.ts new file mode 100644 index 000000000..cd83447d5 --- /dev/null +++ b/apps/common/board/src/shared/config/index.ts @@ -0,0 +1,2 @@ +export * from "./api"; +export * from "./env"; diff --git a/apps/common/board/src/shared/lib/cookie.ts b/apps/common/board/src/shared/lib/cookie.ts new file mode 100644 index 000000000..8f5d0011b --- /dev/null +++ b/apps/common/board/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/common/board/src/shared/lib/index.ts b/apps/common/board/src/shared/lib/index.ts new file mode 100644 index 000000000..bfbe51702 --- /dev/null +++ b/apps/common/board/src/shared/lib/index.ts @@ -0,0 +1,3 @@ +export { getCookie, setCookie, removeCookie } from "./cookie"; +export { useMenuStore } from "./use-menu-store"; +export { useUserStore } from "./use-user-store"; diff --git a/apps/common/board/src/shared/lib/use-alert-store.ts b/apps/common/board/src/shared/lib/use-alert-store.ts new file mode 100644 index 000000000..c21a00ed3 --- /dev/null +++ b/apps/common/board/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/common/board/src/shared/lib/use-attachment-store.ts b/apps/common/board/src/shared/lib/use-attachment-store.ts new file mode 100644 index 000000000..285699cc6 --- /dev/null +++ b/apps/common/board/src/shared/lib/use-attachment-store.ts @@ -0,0 +1,14 @@ +import { useStore } from "zustand"; +import { useContext } from "react"; +import type { AttachmentStore } from "../store/attachment-store"; +import { StoreContext } from "../providers/store-provider"; + +export const useAttachmentStore = ( + selector: (store: AttachmentStore) => T, +): T => { + const store = useContext(StoreContext); + if (!store) { + throw new Error("useAttachmentStore must be used within StoreProvider"); + } + return useStore(store.attachmentStore, selector); +}; diff --git a/apps/common/board/src/shared/lib/use-menu-store.ts b/apps/common/board/src/shared/lib/use-menu-store.ts new file mode 100644 index 000000000..66e41c76c --- /dev/null +++ b/apps/common/board/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/common/board/src/shared/lib/use-tus.ts b/apps/common/board/src/shared/lib/use-tus.ts new file mode 100644 index 000000000..fdaeb1f65 --- /dev/null +++ b/apps/common/board/src/shared/lib/use-tus.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; +import { type HttpResponse, Upload } from "tus-js-client"; +import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "../config"; +import { useAttachmentStore } from "./use-attachment-store"; +import { getCookie } from "./cookie"; + +export const useTus = () => { + const { attachmentFileCode, setAttachmentFileCode } = useAttachmentStore( + (state) => state, + ); + + const [file, setFile] = useState(null); + const [upload, setUpload] = useState(null); + const [response, setResponse] = useState(null); + const [progress, setProgress] = useState(0); + + const metadata = { + filename: file?.name ?? "", + filetype: file?.type ?? "", + atflCd: attachmentFileCode, + }; + + const createUpload = () => { + return new Upload(file!, { + endpoint: `${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`, + chunkSize: 10 * 1024 * 1024, + headers: { + [CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "", + [AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "", + }, + metadata, + onError(error) { + throw new Error(`Failed because: ${error}`); + }, + onProgress(bytesUploaded, bytesTotal) { + const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2); + setProgress(Number(percentage)); + }, + onAfterResponse(req, res) { + const fileInfo = res.getHeader("X-File-Info"); + if (fileInfo === undefined || fileInfo === null) return; + + setAttachmentFileCode(JSON.parse(fileInfo)?.atflCd ?? ""); + setResponse(res); + }, + }); + }; + + useEffect(() => { + if (!file) return; + + const upload = createUpload(); + upload.start(); + setUpload(upload); + }, [file]); + + return { + file, + upload, + response, + progress, + setFile, + }; +}; diff --git a/apps/common/board/src/shared/lib/use-user-store.ts b/apps/common/board/src/shared/lib/use-user-store.ts new file mode 100644 index 000000000..748caed4c --- /dev/null +++ b/apps/common/board/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/common/board/src/shared/model/api-interface.ts b/apps/common/board/src/shared/model/api-interface.ts new file mode 100644 index 000000000..133c0c401 --- /dev/null +++ b/apps/common/board/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/common/board/src/shared/model/index.ts b/apps/common/board/src/shared/model/index.ts new file mode 100644 index 000000000..cf589d200 --- /dev/null +++ b/apps/common/board/src/shared/model/index.ts @@ -0,0 +1 @@ +export * from "./api-interface"; diff --git a/apps/common/board/src/shared/providers/index.ts b/apps/common/board/src/shared/providers/index.ts new file mode 100644 index 000000000..f2e092170 --- /dev/null +++ b/apps/common/board/src/shared/providers/index.ts @@ -0,0 +1 @@ +export { StoreProvider } from "./store-provider"; diff --git a/apps/common/board/src/shared/providers/store-provider.tsx b/apps/common/board/src/shared/providers/store-provider.tsx new file mode 100644 index 000000000..a0ec17b61 --- /dev/null +++ b/apps/common/board/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/common/board/src/shared/store/alert-store.ts b/apps/common/board/src/shared/store/alert-store.ts new file mode 100644 index 000000000..3bba65848 --- /dev/null +++ b/apps/common/board/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/common/board/src/shared/store/attachment-store.ts b/apps/common/board/src/shared/store/attachment-store.ts new file mode 100644 index 000000000..faa700949 --- /dev/null +++ b/apps/common/board/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/common/board/src/shared/store/index.ts b/apps/common/board/src/shared/store/index.ts new file mode 100644 index 000000000..e26f6781e --- /dev/null +++ b/apps/common/board/src/shared/store/index.ts @@ -0,0 +1,4 @@ +export { createAlertStore } from "./alert-store"; +export { createAttachmentStore } from "./attachment-store"; +export { createMenuStore } from "./menu-store"; +export { createUserStore, type IUserInfo } from "./user-store"; diff --git a/apps/common/board/src/shared/store/menu-store.ts b/apps/common/board/src/shared/store/menu-store.ts new file mode 100644 index 000000000..f0c6340c9 --- /dev/null +++ b/apps/common/board/src/shared/store/menu-store.ts @@ -0,0 +1,23 @@ +import { createStore } from "zustand/vanilla"; +import type { MenuItem } from "@common/components/api"; + +export type MenuState = { + currentMenuInfo: MenuItem | null; +}; + +export type MenuActions = { + setCurrentMenuInfo: (menu: MenuItem) => void; +}; + +export type MenuStore = MenuState & MenuActions; + +export const defaultInitState: MenuState = { + currentMenuInfo: null, +}; + +export const createMenuStore = (initState: MenuState = defaultInitState) => { + return createStore()((set) => ({ + ...initState, + setCurrentMenuInfo: (menu: MenuItem) => set({ currentMenuInfo: menu }), + })); +}; diff --git a/apps/common/board/src/shared/store/user-store.ts b/apps/common/board/src/shared/store/user-store.ts new file mode 100644 index 000000000..00379fb1f --- /dev/null +++ b/apps/common/board/src/shared/store/user-store.ts @@ -0,0 +1,56 @@ +import { createStore } from "zustand/vanilla"; + +export interface IUserInfo { + regDt: string; + mdfcnDt: string; + rgtr: string; + mdfr: 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/common/board/src/shared/ui/alert-dialog.tsx b/apps/common/board/src/shared/ui/alert-dialog.tsx new file mode 100644 index 000000000..728276713 --- /dev/null +++ b/apps/common/board/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/common/board/src/shared/ui/confirm-dialog.tsx b/apps/common/board/src/shared/ui/confirm-dialog.tsx new file mode 100644 index 000000000..4223d197e --- /dev/null +++ b/apps/common/board/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, isOpen, onConfirm, onCancel } = useConfirm(); + return ( + + +
+

{message}

+
+ + + + + + + + +
+
+ ); +} + +export { ConfirmDialog }; diff --git a/apps/common/board/src/shared/ui/form.tsx b/apps/common/board/src/shared/ui/form.tsx new file mode 100644 index 000000000..bd579d263 --- /dev/null +++ b/apps/common/board/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 ( +