diff --git a/apps/common/features/.gitignore b/apps/common/features/.gitignore new file mode 100644 index 000000000..e8d8437ee --- /dev/null +++ b/apps/common/features/.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/features/.prettierrc b/apps/common/features/.prettierrc new file mode 100644 index 000000000..b4bfed357 --- /dev/null +++ b/apps/common/features/.prettierrc @@ -0,0 +1,3 @@ +{ + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/apps/common/features/app/(with-base-layout)/banner/page.tsx b/apps/common/features/app/(with-base-layout)/banner/page.tsx new file mode 100644 index 000000000..23f63defe --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/banner/page.tsx @@ -0,0 +1,2 @@ +import { BannerPage } from "@/pages/banner"; +export default BannerPage; diff --git a/apps/common/features/app/(with-base-layout)/banner/register/[sequential]/page.tsx b/apps/common/features/app/(with-base-layout)/banner/register/[sequential]/page.tsx new file mode 100644 index 000000000..bc19150cb --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/banner/register/[sequential]/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage as VModifyPage } from "@/pages/banner"; + +export default async function ModifyPage({ + params, +}: { + params: Promise<{ sequential: string }>; +}) { + const { sequential } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/banner/register/page.tsx b/apps/common/features/app/(with-base-layout)/banner/register/page.tsx new file mode 100644 index 000000000..46eb12c1c --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/banner/register/page.tsx @@ -0,0 +1,2 @@ +import { CreatePage } from "@/pages/banner"; +export default CreatePage; diff --git a/apps/common/features/app/(with-base-layout)/bbs/page.tsx b/apps/common/features/app/(with-base-layout)/bbs/page.tsx new file mode 100644 index 000000000..d9aab376a --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/bbs/page.tsx @@ -0,0 +1,2 @@ +import { BbsPage } from "@/pages/bbs"; +export default BbsPage; diff --git a/apps/common/features/app/(with-base-layout)/bbs/register/[id]/page.tsx b/apps/common/features/app/(with-base-layout)/bbs/register/[id]/page.tsx new file mode 100644 index 000000000..07cc78b6f --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/bbs/register/[id]/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage as VModifyPage } from "@/pages/bbs"; + +export default async function ModifyPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/bbs/register/page.tsx b/apps/common/features/app/(with-base-layout)/bbs/register/page.tsx new file mode 100644 index 000000000..3e74aef97 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/bbs/register/page.tsx @@ -0,0 +1,2 @@ +import { CreatePage } from "@/pages/bbs"; +export default CreatePage; diff --git a/apps/common/features/app/(with-base-layout)/contents/page.tsx b/apps/common/features/app/(with-base-layout)/contents/page.tsx new file mode 100644 index 000000000..ba2a6a21d --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/contents/page.tsx @@ -0,0 +1,2 @@ +import { ContentsPage } from "@/pages/contents"; +export default ContentsPage; diff --git a/apps/common/features/app/(with-base-layout)/contents/register/[sequential]/page.tsx b/apps/common/features/app/(with-base-layout)/contents/register/[sequential]/page.tsx new file mode 100644 index 000000000..aa9320b8f --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/contents/register/[sequential]/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage as VModifyPage } from "@/pages/contents"; + +export default async function ModifyPage({ + params, +}: { + params: Promise<{ sequential: string }>; +}) { + const { sequential } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/contents/register/page.tsx b/apps/common/features/app/(with-base-layout)/contents/register/page.tsx new file mode 100644 index 000000000..fc33228e2 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/contents/register/page.tsx @@ -0,0 +1,2 @@ +import { CreatePage } from "@/pages/contents"; +export default CreatePage; diff --git a/apps/common/features/app/(with-base-layout)/dashboard/page.tsx b/apps/common/features/app/(with-base-layout)/dashboard/page.tsx new file mode 100644 index 000000000..53d564ac6 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/dashboard/page.tsx @@ -0,0 +1,2 @@ +import { DashboardPage } from "@/pages/dashboard"; +export default DashboardPage; diff --git a/apps/common/features/app/(with-base-layout)/file/page.tsx b/apps/common/features/app/(with-base-layout)/file/page.tsx new file mode 100644 index 000000000..6b03fe11d --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/file/page.tsx @@ -0,0 +1,2 @@ +import { FilePage } from "@/pages/file"; +export default FilePage; diff --git a/apps/common/features/app/(with-base-layout)/image/page.tsx b/apps/common/features/app/(with-base-layout)/image/page.tsx new file mode 100644 index 000000000..89ca9eeb1 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/image/page.tsx @@ -0,0 +1,2 @@ +import { ImagePage } from "@/pages/image"; +export default ImagePage; diff --git a/apps/common/features/app/(with-base-layout)/image/register/[sequential]/page.tsx b/apps/common/features/app/(with-base-layout)/image/register/[sequential]/page.tsx new file mode 100644 index 000000000..800f0b382 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/image/register/[sequential]/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage as VModifyPage } from "@/pages/image"; + +export default async function ModifyPage({ + params, +}: { + params: Promise<{ sequential: string }>; +}) { + const { sequential } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/image/register/page.tsx b/apps/common/features/app/(with-base-layout)/image/register/page.tsx new file mode 100644 index 000000000..3c29f2bfe --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/image/register/page.tsx @@ -0,0 +1,2 @@ +import { CreatePage } from "@/pages/image"; +export default CreatePage; diff --git a/apps/common/features/app/(with-base-layout)/layout.tsx b/apps/common/features/app/(with-base-layout)/layout.tsx new file mode 100644 index 000000000..c3254a935 --- /dev/null +++ b/apps/common/features/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/features/app/(with-base-layout)/loading.tsx b/apps/common/features/app/(with-base-layout)/loading.tsx new file mode 100644 index 000000000..3a3b15932 --- /dev/null +++ b/apps/common/features/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/features/app/(with-base-layout)/menu/page.tsx b/apps/common/features/app/(with-base-layout)/menu/page.tsx new file mode 100644 index 000000000..bbac0670c --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/menu/page.tsx @@ -0,0 +1,2 @@ +import { MenuPage } from "@/pages/menu"; +export default MenuPage; diff --git a/apps/common/features/app/(with-base-layout)/module/page.tsx b/apps/common/features/app/(with-base-layout)/module/page.tsx new file mode 100644 index 000000000..c395e9a79 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/module/page.tsx @@ -0,0 +1,2 @@ +import { ModulePage } from "@/pages/module"; +export default ModulePage; diff --git a/apps/common/features/app/(with-base-layout)/module/register/[sequential]/page.tsx b/apps/common/features/app/(with-base-layout)/module/register/[sequential]/page.tsx new file mode 100644 index 000000000..9f1c32ff5 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/module/register/[sequential]/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage as VModifyPage } from "@/pages/module"; + +export default async function ModifyPage({ + params, +}: { + params: Promise<{ sequential: string }>; +}) { + const { sequential } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/module/register/page.tsx b/apps/common/features/app/(with-base-layout)/module/register/page.tsx new file mode 100644 index 000000000..6354dc8ab --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/module/register/page.tsx @@ -0,0 +1,2 @@ +import { CreatePage } from "@/pages/module"; +export default CreatePage; diff --git a/apps/common/features/app/(with-base-layout)/organization/page.tsx b/apps/common/features/app/(with-base-layout)/organization/page.tsx new file mode 100644 index 000000000..39a15cf83 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/organization/page.tsx @@ -0,0 +1,2 @@ +import { OrganizationPage } from "@/pages/organization"; +export default OrganizationPage; diff --git a/apps/common/features/app/(with-base-layout)/site/page.tsx b/apps/common/features/app/(with-base-layout)/site/page.tsx new file mode 100644 index 000000000..0cf120630 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/site/page.tsx @@ -0,0 +1,2 @@ +import { SitePage } from "@/pages/site"; +export default SitePage; diff --git a/apps/common/features/app/(with-base-layout)/site/register/[id]/page.tsx b/apps/common/features/app/(with-base-layout)/site/register/[id]/page.tsx new file mode 100644 index 000000000..685d77172 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/site/register/[id]/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage as VModifyPage } from "@/pages/site"; + +export default async function ModifyPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/site/register/page.tsx b/apps/common/features/app/(with-base-layout)/site/register/page.tsx new file mode 100644 index 000000000..01e25cc43 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/site/register/page.tsx @@ -0,0 +1,2 @@ +import { CreatePage } from "@/pages/site"; +export default CreatePage; diff --git a/apps/common/features/app/(with-base-layout)/template/page.tsx b/apps/common/features/app/(with-base-layout)/template/page.tsx new file mode 100644 index 000000000..f5eab5953 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/template/page.tsx @@ -0,0 +1,2 @@ +import { TemplatePage } from "@/pages/template"; +export default TemplatePage; diff --git a/apps/common/features/app/(with-base-layout)/template/register/[sequential]/page.tsx b/apps/common/features/app/(with-base-layout)/template/register/[sequential]/page.tsx new file mode 100644 index 000000000..3878a4e41 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/template/register/[sequential]/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage as VModifyPage } from "@/pages/template"; + +export default async function ModifyPage({ + params, +}: { + params: Promise<{ sequential: string }>; +}) { + const { sequential } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/template/register/page.tsx b/apps/common/features/app/(with-base-layout)/template/register/page.tsx new file mode 100644 index 000000000..9be3f975a --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/template/register/page.tsx @@ -0,0 +1,2 @@ +import { CreatePage } from "@/pages/template"; +export default CreatePage; diff --git a/apps/common/features/app/(with-base-layout)/terms/page.tsx b/apps/common/features/app/(with-base-layout)/terms/page.tsx new file mode 100644 index 000000000..47f4c8e02 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/terms/page.tsx @@ -0,0 +1,2 @@ +import { TermsPage } from "@/pages/terms"; +export default TermsPage; diff --git a/apps/common/features/app/(with-base-layout)/terms/register/[sequential]/page.tsx b/apps/common/features/app/(with-base-layout)/terms/register/[sequential]/page.tsx new file mode 100644 index 000000000..8ff8b2010 --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/terms/register/[sequential]/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage as VModifyPage } from "@/pages/terms"; + +export default async function ModifyPage({ + params, +}: { + params: Promise<{ sequential: string }>; +}) { + const { sequential } = await params; + return ; +} diff --git a/apps/common/features/app/(with-base-layout)/terms/register/page.tsx b/apps/common/features/app/(with-base-layout)/terms/register/page.tsx new file mode 100644 index 000000000..997fc119a --- /dev/null +++ b/apps/common/features/app/(with-base-layout)/terms/register/page.tsx @@ -0,0 +1,2 @@ +import { CreatePage } from "@/pages/terms"; +export default CreatePage; diff --git a/apps/common/features/app/api/proxy/[...url]/route.ts b/apps/common/features/app/api/proxy/[...url]/route.ts new file mode 100644 index 000000000..d3092c5c1 --- /dev/null +++ b/apps/common/features/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/features/app/auth/login/page.tsx b/apps/common/features/app/auth/login/page.tsx new file mode 100644 index 000000000..8f9e6adc6 --- /dev/null +++ b/apps/common/features/app/auth/login/page.tsx @@ -0,0 +1,2 @@ +import { LoginPage } from "@/pages/auth/login"; +export default LoginPage; diff --git a/apps/common/features/app/layout.tsx b/apps/common/features/app/layout.tsx new file mode 100644 index 000000000..aa2049dcc --- /dev/null +++ b/apps/common/features/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/features/eslint.config.mjs b/apps/common/features/eslint.config.mjs new file mode 100644 index 000000000..9109635e9 --- /dev/null +++ b/apps/common/features/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/features/middleware.ts b/apps/common/features/middleware.ts new file mode 100644 index 000000000..d56c1f8ab --- /dev/null +++ b/apps/common/features/middleware.ts @@ -0,0 +1,42 @@ +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 ?? "http://localhost:3000"; + +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}` : "" + }`; + console.log(`[Middleware] Rewriting from '/server' path to: ${newUrl}`); + 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); + + console.log( + `[Middleware] Rewriting from '/attach-service' path to: ${newUrl}`, + ); + 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/features/next.config.ts b/apps/common/features/next.config.ts new file mode 100644 index 000000000..7cbf8ee2e --- /dev/null +++ b/apps/common/features/next.config.ts @@ -0,0 +1,32 @@ +import type { NextConfig } from "next"; + +const subDomain = + process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000"; + +const nextConfig: NextConfig = { + async rewrites() { + return { + beforeFiles: [ + { + source: "/_next/static/:path*", + destination: `${subDomain}/_next/static/:path*`, + has: [ + { + type: "header", + key: "x-rewrite-me", + }, + ], + }, + ], + afterFiles: [ + { + source: "/board/:path*", + destination: `${subDomain}/board/:path*`, + }, + ], + fallback: [], + }; + }, +}; + +export default nextConfig; diff --git a/apps/common/features/package.json b/apps/common/features/package.json new file mode 100644 index 000000000..b2b5f2355 --- /dev/null +++ b/apps/common/features/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.3.0", + "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/features/pages/.gitkeep b/apps/common/features/pages/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/common/features/postcss.config.mjs b/apps/common/features/postcss.config.mjs new file mode 100644 index 000000000..1a69fd2a4 --- /dev/null +++ b/apps/common/features/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/features/public/images/bg_login.jpg b/apps/common/features/public/images/bg_login.jpg new file mode 100755 index 000000000..b7793d16a Binary files /dev/null and b/apps/common/features/public/images/bg_login.jpg differ diff --git a/apps/common/features/public/images/img_cc_by.png b/apps/common/features/public/images/img_cc_by.png new file mode 100755 index 000000000..628cc2bfd Binary files /dev/null and b/apps/common/features/public/images/img_cc_by.png differ diff --git a/apps/common/features/public/images/img_cc_by_nc.png b/apps/common/features/public/images/img_cc_by_nc.png new file mode 100755 index 000000000..86f60c5db Binary files /dev/null and b/apps/common/features/public/images/img_cc_by_nc.png differ diff --git a/apps/common/features/public/images/img_cc_by_nc_nd.png b/apps/common/features/public/images/img_cc_by_nc_nd.png new file mode 100755 index 000000000..9d8ccb18d Binary files /dev/null and b/apps/common/features/public/images/img_cc_by_nc_nd.png differ diff --git a/apps/common/features/public/images/img_cc_by_nc_sa.png b/apps/common/features/public/images/img_cc_by_nc_sa.png new file mode 100755 index 000000000..00f402c9e Binary files /dev/null and b/apps/common/features/public/images/img_cc_by_nc_sa.png differ diff --git a/apps/common/features/public/images/img_cc_by_nd.png b/apps/common/features/public/images/img_cc_by_nd.png new file mode 100755 index 000000000..0da64acd8 Binary files /dev/null and b/apps/common/features/public/images/img_cc_by_nd.png differ diff --git a/apps/common/features/public/images/img_cc_by_sa.png b/apps/common/features/public/images/img_cc_by_sa.png new file mode 100755 index 000000000..6d94e60b9 Binary files /dev/null and b/apps/common/features/public/images/img_cc_by_sa.png differ diff --git a/apps/common/features/public/images/img_opentype01.png b/apps/common/features/public/images/img_opentype01.png new file mode 100755 index 000000000..456c7c6bb Binary files /dev/null and b/apps/common/features/public/images/img_opentype01.png differ diff --git a/apps/common/features/public/images/img_opentype02.png b/apps/common/features/public/images/img_opentype02.png new file mode 100755 index 000000000..a6c5bfb32 Binary files /dev/null and b/apps/common/features/public/images/img_opentype02.png differ diff --git a/apps/common/features/public/images/img_opentype03.png b/apps/common/features/public/images/img_opentype03.png new file mode 100755 index 000000000..970a6a0d4 Binary files /dev/null and b/apps/common/features/public/images/img_opentype03.png differ diff --git a/apps/common/features/public/images/img_opentype04.png b/apps/common/features/public/images/img_opentype04.png new file mode 100755 index 000000000..41d284cab Binary files /dev/null and b/apps/common/features/public/images/img_opentype04.png differ diff --git a/apps/common/features/src/app/index.tsx b/apps/common/features/src/app/index.tsx new file mode 100644 index 000000000..5a73f2fdd --- /dev/null +++ b/apps/common/features/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/features/src/app/providers/app-provider.tsx b/apps/common/features/src/app/providers/app-provider.tsx new file mode 100644 index 000000000..6fd488b90 --- /dev/null +++ b/apps/common/features/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/features/src/app/providers/index.ts b/apps/common/features/src/app/providers/index.ts new file mode 100644 index 000000000..15f99b7fd --- /dev/null +++ b/apps/common/features/src/app/providers/index.ts @@ -0,0 +1 @@ +export { AppProvider } from "./app-provider"; diff --git a/apps/common/features/src/app/styles/base.css b/apps/common/features/src/app/styles/base.css new file mode 100644 index 000000000..e24111afe --- /dev/null +++ b/apps/common/features/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/features/src/app/styles/components.css b/apps/common/features/src/app/styles/components.css new file mode 100644 index 000000000..5b9742c0f --- /dev/null +++ b/apps/common/features/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/features/src/entities/attachment/api/attachment-service.ts b/apps/common/features/src/entities/attachment/api/attachment-service.ts new file mode 100644 index 000000000..f956f6580 --- /dev/null +++ b/apps/common/features/src/entities/attachment/api/attachment-service.ts @@ -0,0 +1,55 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + Attachment, + AttachmentList, + AttachmentParams, + AttachmentService, +} from "../model/attachment-interface"; + +export class AttachmentApiService implements AttachmentService { + public async getAttachmentsList( + params: AttachmentParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}`, + { params }, + ); + return response.data; + } + + public async getAttachmentsInfo(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/features/src/entities/attachment/index.ts b/apps/common/features/src/entities/attachment/index.ts new file mode 100644 index 000000000..385425537 --- /dev/null +++ b/apps/common/features/src/entities/attachment/index.ts @@ -0,0 +1,5 @@ +export { AttachmentApi } from "./api/attachment-service"; +export type { + AttachmentParams, + Attachment, +} from "./model/attachment-interface"; diff --git a/apps/common/features/src/entities/attachment/model/attachment-interface.ts b/apps/common/features/src/entities/attachment/model/attachment-interface.ts new file mode 100644 index 000000000..5133e4a72 --- /dev/null +++ b/apps/common/features/src/entities/attachment/model/attachment-interface.ts @@ -0,0 +1,42 @@ +import type { PageableList, PageableParams } from "@/shared/model"; + +export interface AttachmentParams extends PageableParams { + keywordType?: string; + keyword?: string; + atflCd?: string; + atflSn?: number; + atflId?: string; + orgnlFileNm?: string; + atflFlieSz?: number; + atflType?: string; + dwnldNocs?: number; + delYn?: string; + lnkgDmnId?: string; + lnkgDmnNm?: string; +} + +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 type AttachmentList = PageableList; + +export interface AttachmentService { + getAttachmentsList: (params: AttachmentParams) => Promise; + getAttachmentsInfo: (atflCd: string) => Promise; + deleteAttachment: (atflId: string) => Promise; + deleteAttachmentByAtflCd: (atflCd: string) => Promise; + getImageUrl: (atflId: string) => Promise; + getImageUrlByAtflCd: (atflCd: string) => Promise; +} diff --git a/apps/common/features/src/entities/auth/api/login-service.ts b/apps/common/features/src/entities/auth/api/login-service.ts new file mode 100644 index 000000000..c1968fe36 --- /dev/null +++ b/apps/common/features/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/features/src/entities/auth/index.ts b/apps/common/features/src/entities/auth/index.ts new file mode 100644 index 000000000..97e142225 --- /dev/null +++ b/apps/common/features/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/features/src/entities/auth/lib/auth-wrapper.tsx b/apps/common/features/src/entities/auth/lib/auth-wrapper.tsx new file mode 100644 index 000000000..705c03231 --- /dev/null +++ b/apps/common/features/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/features/src/entities/auth/lib/token-manager.ts b/apps/common/features/src/entities/auth/lib/token-manager.ts new file mode 100644 index 000000000..0e761486a --- /dev/null +++ b/apps/common/features/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/features/src/entities/auth/model/login-interface.ts b/apps/common/features/src/entities/auth/model/login-interface.ts new file mode 100644 index 000000000..5aaf53044 --- /dev/null +++ b/apps/common/features/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/features/src/entities/board/api/board-service.ts b/apps/common/features/src/entities/board/api/board-service.ts new file mode 100644 index 000000000..150ff677a --- /dev/null +++ b/apps/common/features/src/entities/board/api/board-service.ts @@ -0,0 +1,60 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + Board, + BoardFormData, + BoardList, + BoardParams, + BoardPopupList, + BoardPopupParams, + BoardService, +} from "../model/board-interface"; + +export class BoardApiService implements BoardService { + public async getBoardList(params: BoardParams): Promise { + const response = await client.get( + `${ENDPOINT.BOARD_SERVICE.BBS}`, + { params }, + ); + return response.data; + } + + public async getBoardPopupList( + params: BoardPopupParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.BOARD_SERVICE.BBS}`, + { params }, + ); + return response.data; + } + + public async getBoardInfo(bbsId: string): Promise { + const response = await client.get( + `${ENDPOINT.BOARD_SERVICE.BBS}/${bbsId}`, + ); + return response.data; + } + + public async createBoard(data: BoardFormData): Promise { + const response = await client.post( + `${ENDPOINT.BOARD_SERVICE.BBS}`, + data, + ); + return response.data; + } + + public async updateBoard(bbsId: string, data: BoardFormData): Promise { + const response = await client.put( + `${ENDPOINT.BOARD_SERVICE.BBS}/${bbsId}`, + data, + ); + return response.data; + } + + public async deleteBoard(bbsId: string): Promise { + await client.delete(`${ENDPOINT.BOARD_SERVICE.BBS}/${bbsId}`); + } +} + +export const BoardApi = new BoardApiService(); diff --git a/apps/common/features/src/entities/board/index.ts b/apps/common/features/src/entities/board/index.ts new file mode 100644 index 000000000..f9ef1b610 --- /dev/null +++ b/apps/common/features/src/entities/board/index.ts @@ -0,0 +1,8 @@ +export { BoardApi } from "./api/board-service"; +export { + boardFormSchema, + type Board, + type BoardFormData, + type BoardParams, + type BoardPopup, +} from "./model/board-interface"; diff --git a/apps/common/features/src/entities/board/model/board-interface.ts b/apps/common/features/src/entities/board/model/board-interface.ts new file mode 100644 index 000000000..1ed8f1d9f --- /dev/null +++ b/apps/common/features/src/entities/board/model/board-interface.ts @@ -0,0 +1,96 @@ +import { z } from "zod"; +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 BoardParams extends PageableParams { + keywordType?: string; + keyword?: string; + bbsId?: number; + bbsTypeCd?: string; + bbsNm?: string; + bbsExpln?: string; + rfrncUseYn?: string; + atflUseYn?: string; + atflMaxCnt?: number; + atflMaxFileSz?: number; + ansUseYn?: string; + cmntUseYn?: string; + dgstfnEvlUseYn?: string; + smsUseYn?: string; + upndFixUseYn?: string; + pstgPrdUseYn?: string; + popupUseYn?: string; + delYn?: string; +} + +export interface BoardPopupParams extends PageableParams { + bbsNm?: string; +} + +export interface Board { + bbsId: number; + bbsTypeCd: string; + 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; +} + +export interface BoardPopup extends Board { + pstCnt: number; +} + +export const boardFormSchema = z.object({ + bbsTypeCd: z.string(), + bbsNm: z.string(), + bbsExpln: z.string(), + rfrncUseYn: z.string(), + atflUseYn: z.string(), + atflMaxCnt: z.number().nullish(), + atflMaxFileSz: z.number().nullish(), + ansUseYn: z.string(), + ansUseAuthrt: z.string().nullish(), + cmntUseYn: z.string(), + dgstfnEvlUseYn: z.string(), + smsUseYn: z.string(), + upndFixUseYn: z.string(), + pstgPrdUseYn: z.string(), + popupUseYn: z.string(), + useYn: z.string(), +}); + +export type BoardList = PageableList; +export type BoardPopupList = PageableList; +export type BoardFormData = z.infer; + +export interface BoardService { + getBoardList: (params: BoardParams) => Promise; + getBoardPopupList: (params: BoardPopupParams) => Promise; + getBoardInfo: (bbsId: string) => Promise; + createBoard: (data: BoardFormData) => Promise; + updateBoard: (bbsId: string, data: BoardFormData) => Promise; + deleteBoard: (bbsId: string) => Promise; +} diff --git a/apps/common/features/src/entities/code/@x/posting.ts b/apps/common/features/src/entities/code/@x/posting.ts new file mode 100644 index 000000000..0525684d9 --- /dev/null +++ b/apps/common/features/src/entities/code/@x/posting.ts @@ -0,0 +1 @@ +export { CodeSelect } from "../ui/code-select"; diff --git a/apps/common/features/src/entities/code/api/code-service.ts b/apps/common/features/src/entities/code/api/code-service.ts new file mode 100644 index 000000000..0d1055f6a --- /dev/null +++ b/apps/common/features/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/features/src/entities/code/index.ts b/apps/common/features/src/entities/code/index.ts new file mode 100644 index 000000000..dc74bbc47 --- /dev/null +++ b/apps/common/features/src/entities/code/index.ts @@ -0,0 +1,3 @@ +export { CodeApi } from "./api/code-service"; +export type { ICode } from "./model/code-interface"; +export { CodeSelect } from "./ui/code-select"; diff --git a/apps/common/features/src/entities/code/model/code-interface.ts b/apps/common/features/src/entities/code/model/code-interface.ts new file mode 100644 index 000000000..4562e2559 --- /dev/null +++ b/apps/common/features/src/entities/code/model/code-interface.ts @@ -0,0 +1,10 @@ +export interface ICode { + lwpoCd: string; + upCd: string; + lwpoCdNm: string; + unit: string; +} + +export interface CodeService { + getCode(upCd: string): Promise; +} diff --git a/apps/common/features/src/entities/code/ui/code-select.tsx b/apps/common/features/src/entities/code/ui/code-select.tsx new file mode 100644 index 000000000..bd175a8a9 --- /dev/null +++ b/apps/common/features/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 ( + + + + + + {code?.map((item) => ( + + {item.lwpoCdNm} + + ))} + + + ); +} + +export { CodeSelect }; diff --git a/apps/common/features/src/entities/content/api/contents-service.ts b/apps/common/features/src/entities/content/api/contents-service.ts new file mode 100644 index 000000000..00d2a8c67 --- /dev/null +++ b/apps/common/features/src/entities/content/api/contents-service.ts @@ -0,0 +1,90 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + ContentsService, + ContentsParams, + ContentsList, + ContentsFormData, + ContentsVersionParams, + ContentsVersionList, + ContentsDetail, +} from "../model/contents-interface"; + +export class ContentsApiService implements ContentsService { + public async getContentsList(params: ContentsParams): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.CONTENTS}`, + { params }, + ); + return response.data; + } + + public async getContentsVersionList( + params: ContentsVersionParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}`, + { params }, + ); + return response.data; + } + + public async getLatestContentsInfo( + contsSn: number, + contsVer: number, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`, + ); + return response.data; + } + + public async createContents(data: ContentsFormData): Promise { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}`, + data, + ); + return response.data; + } + + public async updateContents( + contsSn: number, + contsVer: number, + data: ContentsFormData, + ): Promise { + const response = await client.put( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`, + data, + ); + return response.data; + } + + public async deleteContents( + contsSn: number, + contsVer: number, + ): Promise { + await client.delete( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`, + ); + } + + public async requestApprove( + contsSn: number, + contsVer: number, + ): Promise { + await client.put( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}/approveRequest`, + ); + } + + public async approveContents( + contsSn: number, + contsVer: number, + ): Promise { + await client.put( + `${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}/approve`, + ); + } +} + +export const ContentsApi = new ContentsApiService(); diff --git a/apps/common/features/src/entities/content/index.ts b/apps/common/features/src/entities/content/index.ts new file mode 100644 index 000000000..2446dd716 --- /dev/null +++ b/apps/common/features/src/entities/content/index.ts @@ -0,0 +1,8 @@ +export { ContentsApi } from "./api/contents-service"; +export { + CONTENTS_APPROVE_STATUS, + contentsFormSchema, + type Contents, + type ContentsParams, + type ContentsFormData, +} from "./model/contents-interface"; diff --git a/apps/common/features/src/entities/content/model/contents-interface.ts b/apps/common/features/src/entities/content/model/contents-interface.ts new file mode 100644 index 000000000..72456149b --- /dev/null +++ b/apps/common/features/src/entities/content/model/contents-interface.ts @@ -0,0 +1,100 @@ +import { z } from "zod"; +import type { PageableList, PageableParams } from "@/shared/model"; + +export const CONTENTS_APPROVE_STATUS = { + DRAFT: "01", + REQUESTED: "02", + APPROVED: "99", +} as const; + +export type ContentsApproveStatus = + (typeof CONTENTS_APPROVE_STATUS)[keyof typeof CONTENTS_APPROVE_STATUS]; + +export interface ContentsParams extends PageableParams { + contsTtl?: string; + aprvContsVer?: number; + useYn?: string; +} + +export interface ContentsVersionParams extends PageableParams { + contsSn?: number; + contsCn?: string; + aprvCd?: ContentsApproveStatus; + cclCd?: string; + koglCd?: string; + tag?: string; + useYn?: string; +} + +export interface ContentsVersion { + contsSn: number; + contsVer: number; + contsCn: string; + useYn: string; +} + +export const contentsSchema = z.object({ + contsSn: z.number(), + contsTtl: z.string(), + aprvContsVer: z.number(), + useYn: z.string(), + mdfcnDt: z.string(), + mdfr: z.string(), + mdfrNm: z.string(), + aprvDt: z.string(), + autzrNm: z.string(), + workAprvCd: z.string(), + workAprvNm: z.string(), + workContsVer: z.number(), +}); + +export const contentsDetailSchema = z.object({ + contsSn: z.number(), + contsVer: z.number(), + contsTtl: z.string(), + contsCn: z.string(), + contsRmrk: z.string().nullish(), + aprvCd: z.enum([ + CONTENTS_APPROVE_STATUS.DRAFT, + CONTENTS_APPROVE_STATUS.REQUESTED, + CONTENTS_APPROVE_STATUS.APPROVED, + ]), + cclCd: z.string().nullish(), + koglCd: z.string().nullish(), + tag: z.string().nullish(), + aprvDt: z.string(), + autzr: z.string(), + useYn: z.string(), +}); + +export const contentsFormSchema = contentsDetailSchema.omit({ + contsVer: true, + aprvDt: true, + autzr: true, +}); + +export type Contents = z.infer; +export type ContentsDetail = z.infer; +export type ContentsFormData = z.infer; +export type ContentsList = PageableList; +export type ContentsVersionList = PageableList; + +export interface ContentsService { + getContentsList(params: ContentsParams): Promise; + getContentsVersionList( + params: ContentsVersionParams, + ): Promise; + getLatestContentsInfo( + contsSn: number, + contsVer: number, + ): Promise; + createContents(data: ContentsFormData): Promise; + updateContents( + contsSn: number, + contsVer: number, + data: ContentsFormData, + ): Promise; + deleteContents(contsSn: number, contsVer: number): Promise; + requestApprove(contsSn: number, contsVer: number): Promise; + approveContents(contsSn: number, contsVer: number): Promise; +} diff --git a/apps/common/features/src/entities/editor/api/editor-service.ts b/apps/common/features/src/entities/editor/api/editor-service.ts new file mode 100644 index 000000000..3e2fc75a4 --- /dev/null +++ b/apps/common/features/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/features/src/entities/editor/index.ts b/apps/common/features/src/entities/editor/index.ts new file mode 100644 index 000000000..1ae82fb4f --- /dev/null +++ b/apps/common/features/src/entities/editor/index.ts @@ -0,0 +1 @@ +export { Editor } from "./ui/editor"; diff --git a/apps/common/features/src/entities/editor/lib/blob-to-base64.ts b/apps/common/features/src/entities/editor/lib/blob-to-base64.ts new file mode 100644 index 000000000..2f0e68c0f --- /dev/null +++ b/apps/common/features/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/features/src/entities/editor/lib/generate-file-info.ts b/apps/common/features/src/entities/editor/lib/generate-file-info.ts new file mode 100644 index 000000000..c36ed3577 --- /dev/null +++ b/apps/common/features/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/features/src/entities/editor/model/editor-base-config.ts b/apps/common/features/src/entities/editor/model/editor-base-config.ts new file mode 100644 index 000000000..1b97ba9a0 --- /dev/null +++ b/apps/common/features/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/features/src/entities/editor/model/editor-interface.ts b/apps/common/features/src/entities/editor/model/editor-interface.ts new file mode 100644 index 000000000..ed6b4a987 --- /dev/null +++ b/apps/common/features/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/features/src/entities/editor/ui/editor.tsx b/apps/common/features/src/entities/editor/ui/editor.tsx new file mode 100644 index 000000000..797a6c1bf --- /dev/null +++ b/apps/common/features/src/entities/editor/ui/editor.tsx @@ -0,0 +1,61 @@ +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, + disabled: props.disabled, + images_upload_handler: imagesUploadHandler, + file_picker_callback: filePickerCallback, + }} + {...props} + /> + ); +}; + +export { Editor }; diff --git a/apps/common/features/src/entities/menu-authority/api/menu-authority-service.ts b/apps/common/features/src/entities/menu-authority/api/menu-authority-service.ts new file mode 100644 index 000000000..22e3dcefa --- /dev/null +++ b/apps/common/features/src/entities/menu-authority/api/menu-authority-service.ts @@ -0,0 +1,30 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + MenuAuthorityList, + MenuAuthorityParams, + MenuAuthorityPayload, + 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: MenuAuthorityPayload[]): 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/features/src/entities/menu-authority/index.ts b/apps/common/features/src/entities/menu-authority/index.ts new file mode 100644 index 000000000..07433a098 --- /dev/null +++ b/apps/common/features/src/entities/menu-authority/index.ts @@ -0,0 +1 @@ +export { ManagementAuthorityDialog } from "./ui/management-authority-dialog"; diff --git a/apps/common/features/src/entities/menu-authority/model/menu-authority-interface.ts b/apps/common/features/src/entities/menu-authority/model/menu-authority-interface.ts new file mode 100644 index 000000000..7467728fe --- /dev/null +++ b/apps/common/features/src/entities/menu-authority/model/menu-authority-interface.ts @@ -0,0 +1,39 @@ +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 MenuAuthorityPayload = z.infer; +export type MenuAuthorityFormData = z.infer; + +export interface MenuAuthorityService { + getMenuAuthorityList(params: MenuAuthorityParams): Promise; + saveMenuAuthority(data: MenuAuthorityPayload[]): Promise; +} diff --git a/apps/common/features/src/entities/menu-authority/model/menu-authority-table-columns.tsx b/apps/common/features/src/entities/menu-authority/model/menu-authority-table-columns.tsx new file mode 100644 index 000000000..b70731542 --- /dev/null +++ b/apps/common/features/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, + 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/common/features/src/entities/menu-authority/ui/management-authority-dialog.tsx b/apps/common/features/src/entities/menu-authority/ui/management-authority-dialog.tsx new file mode 100644 index 000000000..ea5bbb788 --- /dev/null +++ b/apps/common/features/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 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, + ...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: MenuAuthorityPayload[]) => + MenuAuthorityApi.saveMenuAuthority(data), + onSuccess: () => { + alert("저장되었습니다."); + }, + }); + + const form = useForm({ + resolver: zodResolver(menuAuthorityFormSchema), + values: { + menuAuthority: menuAuthorityList?.content ?? [], + }, + }); + + const table = useReactTable({ + data: menuAuthorityList?.content ?? [], + 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 ( + + + + + 권한관리 + + + + + 그룹별 메뉴 권한 + + + + + 저장 + + + + 취소 + + + + + + ); +} + +export { ManagementAuthorityDialog }; diff --git a/apps/common/features/src/entities/menu/api/menu-service.ts b/apps/common/features/src/entities/menu/api/menu-service.ts new file mode 100644 index 000000000..b6c1510cd --- /dev/null +++ b/apps/common/features/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/features/src/entities/menu/index.ts b/apps/common/features/src/entities/menu/index.ts new file mode 100644 index 000000000..1fa9dd218 --- /dev/null +++ b/apps/common/features/src/entities/menu/index.ts @@ -0,0 +1 @@ +export { MenuWrapper } from "./lib/menu-wrapper"; diff --git a/apps/common/features/src/entities/menu/lib/menu-wrapper.tsx b/apps/common/features/src/entities/menu/lib/menu-wrapper.tsx new file mode 100644 index 000000000..d473548af --- /dev/null +++ b/apps/common/features/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/features/src/entities/menu/lib/utils.ts b/apps/common/features/src/entities/menu/lib/utils.ts new file mode 100644 index 000000000..1cf4514d9 --- /dev/null +++ b/apps/common/features/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/features/src/entities/menu/model/menu-interface.ts b/apps/common/features/src/entities/menu/model/menu-interface.ts new file mode 100644 index 000000000..12c7d007a --- /dev/null +++ b/apps/common/features/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/features/src/entities/organization/api/organization-service.ts b/apps/common/features/src/entities/organization/api/organization-service.ts new file mode 100644 index 000000000..ae7fc44ac --- /dev/null +++ b/apps/common/features/src/entities/organization/api/organization-service.ts @@ -0,0 +1,66 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import { + type Organization, + type OrganizationFormData, + type OrganizationService, + type OrganizationTree, + type OrganizationTreeForDnd, +} from "../model/organization-interface"; + +export class OrganizationApiService implements OrganizationService { + public async getOrganizationsTreeList(): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}/tree`, + ); + return response.data; + } + + public async getOrganizationInfo(ognzSn: number): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}/${ognzSn}`, + ); + return response.data; + } + + public async createOrganization(data: OrganizationFormData): Promise { + await client.post(`${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}`, data); + } + + public async deleteOrganization(ognzSn: number): Promise { + await client.delete( + `${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}/${ognzSn}`, + ); + } + + public async updateOrganizationName( + ognzSn: number, + name: string, + ): Promise { + const response = await client.put( + `${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}/${ognzSn}/${name}`, + ); + return response.data; + } + + public async updateOrganization( + ognzSn: number, + data: OrganizationFormData, + ): Promise { + await client.put( + `${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}/${ognzSn}`, + data, + ); + } + + public async updateOrganizationByDnd( + data: OrganizationTreeForDnd[], + ): Promise { + await client.put( + `${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}/dnd/tree`, + data, + ); + } +} + +export const OrganizationApi = new OrganizationApiService(); diff --git a/apps/common/features/src/entities/organization/index.ts b/apps/common/features/src/entities/organization/index.ts new file mode 100644 index 000000000..6b4cb0357 --- /dev/null +++ b/apps/common/features/src/entities/organization/index.ts @@ -0,0 +1,8 @@ +export { OrganizationApi } from "./api/organization-service"; +export { + organizationFormSchema, + type OrganizationTree, + type OrganizationFormData, + type OrganizationTreeForDnd, +} from "./model/organization-interface"; +export { generateOrganizationTreeData, handleDropNode } from "./lib/utils"; diff --git a/apps/common/features/src/entities/organization/lib/utils.ts b/apps/common/features/src/entities/organization/lib/utils.ts new file mode 100644 index 000000000..45591fc9e --- /dev/null +++ b/apps/common/features/src/entities/organization/lib/utils.ts @@ -0,0 +1,78 @@ +import type { DropOptions, NodeModel } from "@minoru/react-dnd-treeview"; +import type { + OrganizationTree, + OrganizationTreeForDnd, +} from "../model/organization-interface"; + +export const generateOrganizationTreeData = ( + data: OrganizationTree[], +): NodeModel[] => { + return data.reduce[]>((acc, menu) => { + const node: NodeModel = { + id: menu.ognzSn ?? 0, + parent: menu.upOgnzSn ?? 0, + text: menu.ognzNm, + droppable: true, + data: menu, + }; + acc.push(node); + + if (menu.children?.length) { + acc.push(...generateOrganizationTreeData(menu.children)); + } + + return acc; + }, []); +}; + +export const generateDndOrganizationTreeData = ( + treeData: NodeModel[], + options: DropOptions, +): OrganizationTreeForDnd | null => { + const { dragSourceId, dragSource, dropTargetId } = options; + if (!dragSource?.data) return null; + + const siblings = treeData.filter((node) => node.parent === dropTargetId); + const dragSourceIndex = siblings.findIndex( + (node) => node.id === dragSourceId, + ); + const prevSibling = siblings[dragSourceIndex - 1]; + const newSortSeq = + dragSourceIndex === 0 ? 1 : (prevSibling?.data?.sortSeq ?? 0) + 1; + + const treeNode: OrganizationTreeForDnd = { + ognzSn: dragSource.data.ognzSn, + ognzNm: dragSource.data.ognzNm, + ognzExpln: dragSource.data.ognzExpln, + upOgnzSn: dropTargetId === 0 ? null : (dropTargetId as number), + sortSeq: newSortSeq, + }; + + const childNodes = treeData.filter( + (node) => node.parent === dragSourceId && node.data, + ); + const result: OrganizationTreeForDnd = { + ...treeNode, + children: + childNodes?.map((child) => ({ + ognzSn: child.data?.ognzSn, + ognzNm: child.data?.ognzNm ?? "", + ognzExpln: child.data?.ognzExpln, + upOgnzSn: child.data?.upOgnzSn === 0 ? null : (dragSourceId as number), + sortSeq: child.data?.sortSeq ?? 1, + })) ?? [], + }; + + return result; +}; + +export const handleDropNode = + (callback: (newTree: OrganizationTreeForDnd) => void) => + ( + newTree: NodeModel[], + options: DropOptions, + ) => { + const newTreeData = generateDndOrganizationTreeData(newTree, options); + if (!newTreeData) return; + callback(newTreeData); + }; diff --git a/apps/common/features/src/entities/organization/model/organization-interface.ts b/apps/common/features/src/entities/organization/model/organization-interface.ts new file mode 100644 index 000000000..a63b85571 --- /dev/null +++ b/apps/common/features/src/entities/organization/model/organization-interface.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +export const organizationSchema = z.object({ + ognzSn: z.number().nullish(), + ognzNm: z.string(), + ognzExpln: z.string().nullish(), + upOgnzSn: z.number().nullish(), + sortSeq: z.number(), + useYn: z.string().nullish(), +}); + +export const organizationFormSchema = organizationSchema.omit({ + ognzSn: true, +}); + +export type Organization = z.infer; +export type OrganizationFormData = z.infer; +export type OrganizationTree = Organization & { + children?: OrganizationTree[]; +}; +export type OrganizationTreeForDnd = Omit; + +export interface OrganizationService { + getOrganizationsTreeList(): Promise; + getOrganizationInfo(ognzSn: number): Promise; + createOrganization(data: OrganizationFormData): Promise; + deleteOrganization(ognzSn: number): Promise; + updateOrganizationName(ognzSn: number, name: string): Promise; + updateOrganization(ognzSn: number, data: OrganizationFormData): Promise; + updateOrganizationByDnd(data: OrganizationTreeForDnd[]): Promise; +} diff --git a/apps/common/features/src/entities/posting/api/posting-service.ts b/apps/common/features/src/entities/posting/api/posting-service.ts new file mode 100644 index 000000000..d1360b46a --- /dev/null +++ b/apps/common/features/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, + PostPayload, +} 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: PostPayload[]): Promise { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.POSTINGS}`, + data, + ); + return response.data; + } +} + +export const PostingApi = new PostingApiService(); diff --git a/apps/common/features/src/entities/posting/index.ts b/apps/common/features/src/entities/posting/index.ts new file mode 100644 index 000000000..75c5c69ed --- /dev/null +++ b/apps/common/features/src/entities/posting/index.ts @@ -0,0 +1,7 @@ +export { PostingApi } from "./api/posting-service"; +export { + POSTING_TYPE, + type Post, + type PostPayload, +} from "./model/posting-interface"; +export { PostingDialog } from "./ui/posting-dialog"; diff --git a/apps/common/features/src/entities/posting/model/posting-interface.ts b/apps/common/features/src/entities/posting/model/posting-interface.ts new file mode 100644 index 000000000..72c49bb03 --- /dev/null +++ b/apps/common/features/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 PostPayload = z.infer; +export type PostingFormData = z.infer; + +export interface PostService { + getPostingHistory: (params: PostingParams) => Promise>; + savePosting: (data: PostPayload[]) => Promise; +} diff --git a/apps/common/features/src/entities/posting/ui/posting-dialog.tsx b/apps/common/features/src/entities/posting/ui/posting-dialog.tsx new file mode 100644 index 000000000..c461bcda9 --- /dev/null +++ b/apps/common/features/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 PostPayload, +} 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: PostPayload[]) => 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/features/src/entities/site/@x/posting.ts b/apps/common/features/src/entities/site/@x/posting.ts new file mode 100644 index 000000000..6470b3cb2 --- /dev/null +++ b/apps/common/features/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/features/src/entities/site/api/site-service.ts b/apps/common/features/src/entities/site/api/site-service.ts new file mode 100644 index 000000000..a5d9d7cd6 --- /dev/null +++ b/apps/common/features/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/features/src/entities/site/index.ts b/apps/common/features/src/entities/site/index.ts new file mode 100644 index 000000000..c7b437a64 --- /dev/null +++ b/apps/common/features/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/features/src/entities/site/model/site-interface.ts b/apps/common/features/src/entities/site/model/site-interface.ts new file mode 100644 index 000000000..8846f59b9 --- /dev/null +++ b/apps/common/features/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/features/src/entities/user-organization/api/user-organization-service.ts b/apps/common/features/src/entities/user-organization/api/user-organization-service.ts new file mode 100644 index 000000000..a4e588310 --- /dev/null +++ b/apps/common/features/src/entities/user-organization/api/user-organization-service.ts @@ -0,0 +1,62 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + TreeUserOrganization, + UserOrganizationParams, + UserOrganizationList, + UserOrganizationService, + UserOrganizationPayload, + SearchUserParams, + SearchUserList, + User, + MoveUserOrganizationPayload, +} from "../model/user-organization-interface"; + +export class UserOrganizationApiService implements UserOrganizationService { + public async getUserOrganizationList( + params: UserOrganizationParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.USER_ORGANIZATION}`, + { params }, + ); + return response.data; + } + + public async getUserOrganizationTreeList(): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.USER_ORGANIZATION}/treeuser`, + ); + return response.data; + } + + public async searchUser(params: SearchUserParams): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.USER_ORGANIZATION}/user-popup`, + { params }, + ); + return response.data; + } + + public async saveUserOrganization( + data: UserOrganizationPayload[], + ): Promise { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.USER_ORGANIZATION}`, + data, + ); + return response.data; + } + + public async moveUserOrganization( + data: MoveUserOrganizationPayload[], + ): Promise { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.USER_ORGANIZATION}/move-ognz`, + data, + ); + return response.data; + } +} + +export const UserOrganizationApi = new UserOrganizationApiService(); diff --git a/apps/common/features/src/entities/user-organization/index.ts b/apps/common/features/src/entities/user-organization/index.ts new file mode 100644 index 000000000..87317f1da --- /dev/null +++ b/apps/common/features/src/entities/user-organization/index.ts @@ -0,0 +1,11 @@ +export { UserOrganizationApi } from "./api/user-organization-service"; +export { + userOrganizationFormSchema, + type TreeUserOrganization, + type TreeUser, + type UserOrganizationFormData, + type UserOrganizationPayload, + type User, + type SearchUser, + type MoveUserOrganizationPayload, +} from "./model/user-organization-interface"; diff --git a/apps/common/features/src/entities/user-organization/model/user-organization-interface.ts b/apps/common/features/src/entities/user-organization/model/user-organization-interface.ts new file mode 100644 index 000000000..25d7c7032 --- /dev/null +++ b/apps/common/features/src/entities/user-organization/model/user-organization-interface.ts @@ -0,0 +1,92 @@ +import { z } from "zod"; +import type { PageableList, PageableParams } from "@/shared/model"; + +export interface UserOrganizationParams extends PageableParams { + keywordType?: string; + keyword?: string; + ognzSn?: number; + userUnqId?: string; + jbpsSeCd?: string; + jbgdSeCd?: string; +} + +export interface SearchUserParams extends PageableParams { + userNm?: string; + ogdpSeCd?: string; +} + +export interface User { + mode: string; + ognzSn: number; + userUnqId: string; + userUnqNm?: string; + jbpsSeCd: string; + jbpsSeNm?: string; + jbgdSeCd: string; + jbgdSeNm?: string; + sortSeq: number; + mdfcnDt?: string; + mdfr?: string; + mdfrNm?: string; +} + +export interface SearchUser { + userUnqId: string; + userNm: string; + ogdpSeCd: string; + ogdpSeNm: string; + regDt: string; +} + +export interface TreeUser { + userUnqId: string; + userNm: string; + jbpsSeCd: string; + jbgdSeCd: string; +} + +export interface TreeUserOrganization { + ognzSn: number; + upOgnzSn: number; + ognzNm: string; + users: TreeUser[]; + children: TreeUserOrganization[]; +} + +export interface MoveUserOrganizationPayload { + ognzSn: number; + toOgnzSn: number; + userUnqId: string; +} + +export const userOrganizationPayloadSchema = z.object({ + mode: z.string(), + ognzSn: z.number(), + userUnqId: z.string(), + jbpsSeCd: z.string(), + jbgdSeCd: z.string(), + sortSeq: z.number(), +}); + +export const userOrganizationFormSchema = z.object({ + userOrganization: z.array(userOrganizationPayloadSchema), +}); + +export type UserOrganizationList = PageableList; +export type SearchUserList = PageableList; +export type UserOrganizationPayload = z.infer< + typeof userOrganizationPayloadSchema +>; +export type UserOrganizationFormData = z.infer< + typeof userOrganizationFormSchema +>; + +export interface UserOrganizationService { + getUserOrganizationList( + params: UserOrganizationParams, + ): Promise; + getUserOrganizationTreeList(): Promise; + searchUser(params: SearchUserParams): Promise; + saveUserOrganization(data: UserOrganizationPayload[]): Promise; + moveUserOrganization(data: MoveUserOrganizationPayload[]): Promise; +} diff --git a/apps/common/features/src/pages/auth/login/index.ts b/apps/common/features/src/pages/auth/login/index.ts new file mode 100644 index 000000000..2093c3d8d --- /dev/null +++ b/apps/common/features/src/pages/auth/login/index.ts @@ -0,0 +1 @@ +export { LoginPage } from "./ui/login-page"; diff --git a/apps/common/features/src/pages/auth/login/ui/login-page.tsx b/apps/common/features/src/pages/auth/login/ui/login-page.tsx new file mode 100644 index 000000000..4700a691b --- /dev/null +++ b/apps/common/features/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 통합관리시스템에 오신 것을 환영합니다. + + + + + + + + + + ID + + + + + + + PASSWORD + + + + + + + + + + 아이디저장 + + {/* 비밀번호 또는 아이디를 잃어버리셨나요? */} + + + LOGIN + + + + 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/features/src/pages/auth/login/ui/login.css b/apps/common/features/src/pages/auth/login/ui/login.css new file mode 100755 index 000000000..831cc13a9 --- /dev/null +++ b/apps/common/features/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/features/src/pages/banner/api/banner-service.ts b/apps/common/features/src/pages/banner/api/banner-service.ts new file mode 100644 index 000000000..93646c770 --- /dev/null +++ b/apps/common/features/src/pages/banner/api/banner-service.ts @@ -0,0 +1,51 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + BannerFormData, + BannerInfo, + BannerList, + BannerParams, + BannerService, +} from "../model/banner-interface"; + +export class BannerApiService implements BannerService { + public async getBannerList(params: BannerParams): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.BANNERS}`, + { params }, + ); + return response.data; + } + + public async getBannerInfo(bnrSn: string): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.BANNERS}/${bnrSn}`, + ); + return response.data; + } + + public async createBanner(data: BannerFormData): Promise { + const response = await client.post( + ENDPOINT.CMS_SERVICE.BANNERS, + data, + ); + return response.data; + } + + public async updateBanner( + bnrSn: string, + data: BannerFormData, + ): Promise { + const response = await client.put( + `${ENDPOINT.CMS_SERVICE.BANNERS}/${bnrSn}`, + data, + ); + return response.data; + } + + public async deleteBanner(bnrSn: string): Promise { + await client.delete(`${ENDPOINT.CMS_SERVICE.BANNERS}/${bnrSn}`); + } +} + +export const BannerApi = new BannerApiService(); diff --git a/apps/common/features/src/pages/banner/index.ts b/apps/common/features/src/pages/banner/index.ts new file mode 100644 index 000000000..b2f0c6a55 --- /dev/null +++ b/apps/common/features/src/pages/banner/index.ts @@ -0,0 +1,3 @@ +export { CreatePage } from "./ui/create-page"; +export { ModifyPage } from "./ui/modify-page"; +export { BannerPage } from "./ui/banner-page"; diff --git a/apps/common/features/src/pages/banner/lib/utils.ts b/apps/common/features/src/pages/banner/lib/utils.ts new file mode 100644 index 000000000..c6c8ca101 --- /dev/null +++ b/apps/common/features/src/pages/banner/lib/utils.ts @@ -0,0 +1,61 @@ +import { Upload, type HttpResponse } from "tus-js-client"; +import { getCookie, setCookie } from "@/shared/lib"; +import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config"; + +type UploadOptions = { + handleUploadStart?: (upload: Upload) => void; + handleProgress?: (percentage: string) => void; + handleSuccess?: (upload: Upload) => void; + handleResponse?: (res: HttpResponse) => void; +}; + +export const handleUpload = (files: FileList, options?: UploadOptions) => { + let metadata = {}; + + const file = files.item(0); + if (!file) return; + metadata = { + filename: file.name, + filetype: file.type, + }; + + const atflCd = getCookie("atfl-cd-key"); + if (atflCd) { + metadata = { + ...metadata, + atflCd, + }; + } + + const upload = 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) { + console.error(`Failed because: ${error}`); + }, + onProgress(bytesUploaded, bytesTotal) { + const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2); + options?.handleProgress?.(percentage); + }, + onAfterResponse(req, res) { + const fileInfo = res.getHeader("X-File-Info"); + if (fileInfo === undefined || fileInfo === null) return; + + setCookie("atfl-cd-key", JSON.parse(fileInfo)?.atflCd ?? ""); + options?.handleResponse?.(res); + }, + onSuccess() { + options?.handleSuccess?.(upload); + }, + }); + + upload.start(); + options?.handleUploadStart?.(upload); + + console.log("upload start >>>>>", upload); +}; diff --git a/apps/common/features/src/pages/banner/model/banner-interface.ts b/apps/common/features/src/pages/banner/model/banner-interface.ts new file mode 100644 index 000000000..1226f63e6 --- /dev/null +++ b/apps/common/features/src/pages/banner/model/banner-interface.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; +import type { PageableList, PageableParams } from "@/shared/model"; + +export const BANNER_TYPE = { + BNRMAIN: "BNRMAIN", + BNRFTER: "BNRFTER", +} as const; + +export type BannerType = (typeof BANNER_TYPE)[keyof typeof BANNER_TYPE]; + +export interface BannerParams extends PageableParams { + bnrTypeCd?: BannerType; + bnrTtl?: string; + useYn?: string; +} + +export interface Banner { + bnrSn: number; + bnrTypeCd: BannerType; + bnrTypeNm: string; + bnrTtl: string; + lnkgUrlAddr: string; + useYn: string; + atflCd: string; + mdfcnDt: string; + mdfr: string; + mdfrNm: string; +} + +export interface BannerInfo extends Omit { + bnrExpln: string; + newWndwYn: string; + atflCd: string; + regDt: string; + rgTrNm: string; +} + +export const bannerFormSchema = z.object({ + bnrTypeCd: z.string(), + bnrTtl: z.string(), + bnrExpln: z.string().nullish(), + lnkgUrlAddr: z.string().nullish(), + newWndwYn: z.string(), + atflCd: z.string().nullish(), + useYn: z.string(), +}); + +export type BannerFormData = z.infer; +export type BannerList = PageableList; + +export interface BannerService { + getBannerList: (params: BannerParams) => Promise; + getBannerInfo: (bnrSn: string) => Promise; + createBanner: (data: BannerFormData) => Promise; + updateBanner: (bnrSn: string, data: BannerFormData) => Promise; + deleteBanner: (bnrSn: string) => Promise; +} diff --git a/apps/common/features/src/pages/banner/model/banner-table-columns.tsx b/apps/common/features/src/pages/banner/model/banner-table-columns.tsx new file mode 100644 index 000000000..74da7c31c --- /dev/null +++ b/apps/common/features/src/pages/banner/model/banner-table-columns.tsx @@ -0,0 +1,100 @@ +"use client"; + +import dayjs from "dayjs"; +import Image from "next/image"; +import { useQuery } from "@tanstack/react-query"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Checkbox, Skeleton } from "@common/components/ui"; +import { AttachmentApi } from "@/entities/attachment"; +import { ENDPOINT } from "@/shared/config"; +import type { Banner } from "./banner-interface"; + +export const createColumns = ( + imageRefs: React.MutableRefObject>, +): ColumnDef[] => [ + { + id: "select", + header: ({ table }) => ( + e.stopPropagation()} + onCheckedChange={(checked) => + table.toggleAllPageRowsSelected(!!checked) + } + className="h-4 w-4 rounded border-gray-300" + /> + ), + cell: ({ row }) => ( + e.stopPropagation()} + onCheckedChange={(checked) => row.toggleSelected(!!checked)} + className="h-4 w-4 rounded border-gray-300" + /> + ), + }, + { + header: "배너 이미지", + cell: ({ row }) => ( + + ), + }, + { + accessorKey: "bnrTtl", + header: "배너명", + }, + { + accessorKey: "bnrTypeNm", + header: "배너구분", + }, + { + accessorKey: "useYn", + header: "사용여부", + }, + { + accessorKey: "mdfrNm", + header: "최종수정자", + }, + { + accessorKey: "mdfcnDt", + header: "최종수정일", + cell: ({ row }) => { + const date = row.getValue("mdfcnDt") as string; + return date ? dayjs(date).format("YYYY-MM-DD") : ""; + }, + }, +]; + +type BannerImageProps = { + atflCd: string; + bnrSn: number; + imageRefs: React.MutableRefObject>; +}; + +const BannerImage = ({ atflCd, bnrSn, imageRefs }: BannerImageProps) => { + const prevImage = imageRefs.current.get(bnrSn); + + const { data: imageUrl, isLoading } = useQuery({ + queryKey: [ENDPOINT.ATTACH_SERVICE.IMAGES, atflCd], + queryFn: () => AttachmentApi.getImageUrlByAtflCd(atflCd), + select: (url) => { + if (url) imageRefs.current.set(bnrSn, url); + return url; + }, + enabled: !prevImage && !!atflCd, + }); + + if (isLoading) return ; + + if (!imageUrl) return null; + + return ( + + + + ); +}; diff --git a/apps/common/features/src/pages/banner/model/posting-table-columns.tsx b/apps/common/features/src/pages/banner/model/posting-table-columns.tsx new file mode 100644 index 000000000..a43cf8693 --- /dev/null +++ b/apps/common/features/src/pages/banner/model/posting-table-columns.tsx @@ -0,0 +1,44 @@ +"use client"; + +import dayjs from "dayjs"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Checkbox } from "@common/components/ui"; +import type { Post } from "@/entities/posting"; + +export const columns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( + e.stopPropagation()} + onCheckedChange={(checked) => + table.toggleAllPageRowsSelected(!!checked) + } + className="h-4 w-4 rounded border-gray-300" + /> + ), + cell: ({ row }) => ( + e.stopPropagation()} + onCheckedChange={(checked) => row.toggleSelected(!!checked)} + className="h-4 w-4 rounded border-gray-300" + /> + ), + }, + { + accessorKey: "siteNm", + header: "사이트", + }, + { + accessorKey: "pstgPrdSeNm", + header: "게시기간", + cell: ({ row }) => { + const pstgPrdSeNm = row.original.pstgPrdSeNm; + const pstgBgngDt = row.original.pstgBgngDt; + const pstgEndDt = row.original.pstgEndDt; + return `${pstgPrdSeNm} (${dayjs(pstgBgngDt).format("YYYY-MM-DD")} ~ ${dayjs(pstgEndDt).format("YYYY-MM-DD")})`; + }, + }, +]; diff --git a/apps/common/features/src/pages/banner/ui/banner-form.tsx b/apps/common/features/src/pages/banner/ui/banner-form.tsx new file mode 100644 index 000000000..f4af75ffe --- /dev/null +++ b/apps/common/features/src/pages/banner/ui/banner-form.tsx @@ -0,0 +1,296 @@ +"use client"; + +import Image from "next/image"; +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { UseFormReturn } from "react-hook-form"; +import type { HttpResponse, Upload } from "tus-js-client"; +import { + FileUploader, + Input, + Label, + Progress, + RadioGroup, + RadioGroupItem, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Textarea, +} from "@common/components"; +import { FormWrapper } from "@/widgets/form-wrapper"; +import { CodeSelect } from "@/entities/code"; +import { AttachmentApi } from "@/entities/attachment"; +import { ENDPOINT } from "@/shared/config"; +import { setCookie } from "@/shared/lib/cookie"; +import { FormControl, FormField, FormItem } from "@/shared/ui/form"; +import type { BannerFormData } from "../model/banner-interface"; +import { handleUpload } from "../lib/utils"; + +type BannerFormProps = { + form: UseFormReturn; + bnrSn?: number; + handleSave: (data: BannerFormData) => Promise; + handleDelete?: () => Promise; +}; + +function BannerForm({ form, bnrSn, ...props }: BannerFormProps) { + const atflCd = form.watch("atflCd"); + + const [imageUrl, setImageUrl] = useState(""); + const [upload, setUpload] = useState(null); + const [progress, setProgress] = useState(0); + + const { data: attachmentData } = useQuery({ + queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS, atflCd], + queryFn: () => AttachmentApi.getAttachmentsInfo(atflCd!), + select: (data) => data?.[data.length - 1], + enabled: !!atflCd, + }); + + const handleProgress = (percentage: string) => { + setProgress(Number(percentage)); + }; + + const handleUploadResponse = async (res: HttpResponse) => { + const fileInfo = res.getHeader("X-File-Info"); + if (fileInfo === undefined || fileInfo === null) return; + + const { atflCd, atflId } = JSON.parse(fileInfo) ?? {}; + if (atflCd) form.setValue("atflCd", atflCd); + if (atflId) setImageUrl(await AttachmentApi.getImageUrl(atflId)); + if (attachmentData) + await AttachmentApi.deleteAttachmentByAtflCd(attachmentData.atflCd); + }; + + useEffect(() => { + if (!attachmentData) return; + + (async () => { + setImageUrl(await AttachmentApi.getImageUrl(attachmentData.atflId)); + })(); + }, [attachmentData]); + + return ( + + + + + + + + + + 배너명 + + ( + + + + + + )} + /> + + + + 배너구분 + + ( + + + + + + )} + /> + + + + 배너 이미지 + + ( + + + { + const files = e.target.files; + if (!files) return; + setCookie("atfl-cd-key", ""); + handleUpload(files, { + handleUploadStart: setUpload, + handleProgress, + handleSuccess: setUpload, + handleResponse: handleUploadResponse, + }); + }} + customPreview={ + <> + + + + {imageUrl && ( + + )} + + + {(upload?.file as File)?.name} + + + + + + + + 이미지 파일(jpg, jpeg, png, gif)만 업로드 + 가능합니다. + + + + > + } + /> + + + )} + /> + + + + 링크 URL + + ( + + + + + + )} + /> + + + + 링크 열기 + + ( + + + + + + 현재창 + + + + 새창 + + + + + )} + /> + + + + 배너 설명 + + ( + + + + + + )} + /> + + + + 사용여부 + + ( + + + + + + 사용 + + + + 미사용 + + + + + )} + /> + + + + + + + ); +} + +export { BannerForm }; diff --git a/apps/common/features/src/pages/banner/ui/banner-page.tsx b/apps/common/features/src/pages/banner/ui/banner-page.tsx new file mode 100644 index 000000000..baa372b5f --- /dev/null +++ b/apps/common/features/src/pages/banner/ui/banner-page.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useEffect, useState, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { + getCoreRowModel, + useReactTable, + type PaginationState, + type RowSelectionState, +} from "@tanstack/react-table"; +import { Button, DataTable, Input, Label } from "@common/components"; +import { POSTING_TYPE, PostingDialog } from "@/entities/posting"; +import { ENDPOINT } from "@/shared/config"; +import { BannerApi } from "../api/banner-service"; +import { createColumns } from "../model/banner-table-columns"; +import { type Banner, type BannerParams } from "../model/banner-interface"; +import { PreviewBannerDialog } from "./preview-banner-dialog"; + +type PostingTarget = { + pstgTrgtNm: string; + pstgTrgtNo: number; +}; + +function BannerPage() { + const router = useRouter(); + const imageRefs = useRef>(new Map()); + + const [rowSelection, setRowSelection] = useState({}); + const [postingTargets, setPostingTargets] = useState([]); + const [bannerQuery, setBannerQuery] = useState({ bnrTtl: "" }); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 5, + }); + + const { data: bannerList, refetch } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.BANNERS], + queryFn: () => + BannerApi.getBannerList({ + ...bannerQuery, + page: pagination.pageIndex, + size: pagination.pageSize, + }), + enabled: false, + }); + + const table = useReactTable({ + data: bannerList?.content ?? [], + columns: createColumns(imageRefs), + state: { + pagination, + rowSelection, + }, + rowCount: bannerList?.totalElements ?? 0, + manualPagination: true, + enableMultiRowSelection: true, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + onRowSelectionChange: setRowSelection, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + refetch(); + }; + + const handleRowClick = (row: Banner) => { + router.push(`/banner/register/${row.bnrSn}`); + }; + + const getSelectedBannerImages = () => { + const selectedImages: string[] = []; + table.getFilteredSelectedRowModel().rows.forEach((row) => { + const imageUrl = imageRefs.current.get(row.original.bnrSn); + if (imageUrl) selectedImages.push(imageUrl); + }); + return selectedImages; + }; + + useEffect(() => { + const postingTargets = table + .getFilteredSelectedRowModel() + .rows.map((row) => ({ + pstgTrgtNm: row.original.bnrTtl, + pstgTrgtNo: row.original.bnrSn, + })); + + if (!postingTargets.length) { + setPostingTargets([]); + return; + } + + setPostingTargets(postingTargets); + }, [rowSelection]); + + useEffect(() => { + refetch(); + }, [pagination]); + + return ( + + + + + 배너명 + + + setBannerQuery({ ...bannerQuery, bnrTtl: e.target.value }) + } + /> + + 조회 + + + + + + + 배너 목록 + + + + router.push("/banner/register")}> + 등록 + + + + + + + + + + ); +} + +export { BannerPage }; diff --git a/apps/common/features/src/pages/banner/ui/create-page.tsx b/apps/common/features/src/pages/banner/ui/create-page.tsx new file mode 100644 index 000000000..23d082c1d --- /dev/null +++ b/apps/common/features/src/pages/banner/ui/create-page.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; +import { + bannerFormSchema, + type BannerFormData, +} from "../model/banner-interface"; +import { BannerApi } from "../api/banner-service"; +import { BannerForm } from "./banner-form"; + +function CreatePage() { + const router = useRouter(); + const { setMessage: alert } = useAlertStore((state) => state); + + const { mutateAsync: createBanner } = useMutation({ + mutationFn: (data: BannerFormData) => BannerApi.createBanner(data), + onSuccess: () => { + alert("등록되었습니다.", router.back); + }, + }); + + const form = useForm({ + resolver: zodResolver(bannerFormSchema), + defaultValues: { + newWndwYn: "N", + useYn: "Y", + }, + }); + + console.log(form.formState.errors); + + return ( + + + 배너 등록 + + + + ); +} + +export { CreatePage }; diff --git a/apps/common/features/src/pages/banner/ui/modify-page.tsx b/apps/common/features/src/pages/banner/ui/modify-page.tsx new file mode 100644 index 000000000..f0f679529 --- /dev/null +++ b/apps/common/features/src/pages/banner/ui/modify-page.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + getCoreRowModel, + useReactTable, + type RowSelectionState, +} from "@tanstack/react-table"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { Button, DataTable } from "@common/components/ui"; +import { PostingApi, type PostPayload } from "@/entities/posting"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; +import { ENDPOINT } from "@/shared/config"; +import { + bannerFormSchema, + type BannerFormData, +} from "../model/banner-interface"; +import { columns } from "../model/posting-table-columns"; +import { BannerApi } from "../api/banner-service"; +import { BannerForm } from "./banner-form"; + +function ModifyPage({ sequential }: { sequential: string }) { + const router = useRouter(); + const { confirm } = useConfirm(); + const queryClient = useQueryClient(); + const { setMessage: alert } = useAlertStore((state) => state); + + const [rowSelection, setRowSelection] = useState({}); + + const { data: bannerDetail } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.BANNERS, sequential], + queryFn: () => BannerApi.getBannerInfo(sequential), + }); + + const { data: postHistory } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.POSTINGS, sequential], + queryFn: () => + PostingApi.getPostingHistory({ pstgTrgtNo: Number(sequential) }), + }); + + const { mutateAsync: updateBanner } = useMutation({ + mutationFn: (data: BannerFormData) => + BannerApi.updateBanner(sequential, data), + onSuccess: () => { + alert("수정되었습니다."); + }, + }); + + const { mutateAsync: deleteBanner } = useMutation({ + mutationFn: () => BannerApi.deleteBanner(sequential), + onSuccess: () => { + alert("삭제되었습니다.", router.back); + }, + }); + + const { mutateAsync: savePosting } = useMutation({ + mutationFn: (data: PostPayload[]) => PostingApi.savePosting(data), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.CMS_SERVICE.POSTINGS, sequential], + }); + alert("삭제되었습니다."); + }, + }); + + const form = useForm({ + resolver: zodResolver(bannerFormSchema), + values: bannerDetail, + }); + + const table = useReactTable({ + data: postHistory?.content ?? [], + columns, + state: { + rowSelection, + }, + enableMultiRowSelection: true, + getCoreRowModel: getCoreRowModel(), + onRowSelectionChange: setRowSelection, + }); + + const handleDeletePostingHistory = async () => { + const data = table.getFilteredSelectedRowModel().rows.map((row) => ({ + ...row.original, + mode: "D", + })); + + if (await confirm("삭제하시겠습니까?")) { + savePosting(data); + } + }; + + return ( + + + + 배너 수정 + + + + + + 사이트 게시 이력 + + + + + 이력삭제 + + + + + + + ); +} + +export { ModifyPage }; diff --git a/apps/common/features/src/pages/banner/ui/preview-banner-dialog.tsx b/apps/common/features/src/pages/banner/ui/preview-banner-dialog.tsx new file mode 100644 index 000000000..74be653dd --- /dev/null +++ b/apps/common/features/src/pages/banner/ui/preview-banner-dialog.tsx @@ -0,0 +1,60 @@ +"use client"; + +import Image from "next/image"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + Button, + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from "@common/components"; + +type PreviewBannerDialogProps = { + images: string[]; +}; + +function PreviewBannerDialog({ images }: PreviewBannerDialogProps) { + return ( + + + + + 미리보기 + + + + + 배너 이미지 미리보기 + + + + + {images.map((image, i) => ( + + + + + + ))} + + + + + + + + ); +} + +export { PreviewBannerDialog }; diff --git a/apps/common/features/src/pages/bbs/index.ts b/apps/common/features/src/pages/bbs/index.ts new file mode 100644 index 000000000..bed839012 --- /dev/null +++ b/apps/common/features/src/pages/bbs/index.ts @@ -0,0 +1,3 @@ +export { BbsPage } from "./ui/bbs-page"; +export { CreatePage } from "./ui/create-page"; +export { ModifyPage } from "./ui/modify-page"; diff --git a/apps/common/features/src/pages/bbs/model/table-columns.tsx b/apps/common/features/src/pages/bbs/model/table-columns.tsx new file mode 100644 index 000000000..697f953a7 --- /dev/null +++ b/apps/common/features/src/pages/bbs/model/table-columns.tsx @@ -0,0 +1,41 @@ +import dayjs from "dayjs"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Button } from "@common/components/ui"; +import type { Board } from "@/entities/board/model/board-interface"; + +export const columns: ColumnDef[] = [ + { + id: "no", + header: "NO", + cell: ({ row }) => row.index + 1, + }, + { + accessorKey: "bbsNm", + header: "게시판명", + }, + { + accessorKey: "bbsTypeNm", + header: "게시판유형", + }, + { + accessorKey: "mdfrNm", + header: "최종수정자", + }, + { + accessorKey: "mdfcnDt", + header: "최종수정일", + cell: ({ row }) => + row.original.mdfcnDt + ? dayjs(row.original.mdfcnDt).format("YYYY-MM-DD") + : "", + }, + { + accessorKey: "useYn", + header: "사용여부", + }, + { + id: "actions", + header: "관리", + cell: () => 관리, + }, +]; diff --git a/apps/common/features/src/pages/bbs/ui/bbs-form.tsx b/apps/common/features/src/pages/bbs/ui/bbs-form.tsx new file mode 100644 index 000000000..ac2b5ebd5 --- /dev/null +++ b/apps/common/features/src/pages/bbs/ui/bbs-form.tsx @@ -0,0 +1,447 @@ +"use client"; + +import type { UseFormReturn } from "react-hook-form"; +import { + Input, + Label, + RadioGroup, + RadioGroupItem, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@common/components"; +import { FormWrapper } from "@/widgets/form-wrapper"; +import { CodeSelect } from "@/entities/code"; +import type { Board, BoardFormData } from "@/entities/board"; +import { FormControl, FormField, FormItem } from "@/shared/ui/form"; + +type BbsFormProps = { + form: UseFormReturn; + bbsId?: string; + handleSave: (data: BoardFormData) => Promise; +}; + +function BbsForm({ form, bbsId, ...props }: BbsFormProps) { + return ( + + + + + + + + + + 게시판명 + + ( + + + + + + )} + /> + + + + 게시판 설명 + + ( + + + + + + )} + /> + + + + 게시판 유형 + + ( + + + + + + )} + /> + + + + 참조 사용 여부 + + ( + + + + + + 사용 + + + + 사용안함 + + + + + )} + /> + + + + 첨부파일 사용 여부 + + ( + + + + + + 사용 + + + + 사용안함 + + + + + )} + /> + + + + 첨부파일 최대 개수 + + ( + + + { + const value = e.target.value; + if (isNaN(Number(value))) return; + field.onChange(value === "" ? null : Number(value)); + }} + className="w-[12rem] !text-[1.3rem]" + placeholder="최대 개수 입력" + disabled={form.watch("atflUseYn") === "N"} + /> + + + )} + /> + + + + 첨부파일 최대 크기 + + ( + + + { + const value = e.target.value; + if (isNaN(Number(value))) return; + field.onChange(value === "" ? null : Number(value)); + }} + className="w-[12rem] !text-[1.3rem]" + placeholder="최대 크기 입력" + disabled={form.watch("atflUseYn") === "N"} + /> + + + )} + /> + + + + 답글 사용 여부 + + ( + + + + + + 사용 + + + + 사용안함 + + + + + )} + /> + + + + 답글 사용 권한 + + ( + + + + + + + + 관리자 + 회원 + 일반 + + + + + )} + /> + + + + 댓글 사용 여부 + + ( + + + + + + 사용 + + + + 사용안함 + + + + + )} + /> + + + + 게시물평가 사용 여부 + + ( + + + + + + 사용 + + + + 사용안함 + + + + + )} + /> + + + + 팝업 사용 여부 + + ( + + + + + + 사용 + + + + 사용안함 + + + + + )} + /> + + + + SMS알림 사용 여부 + + ( + + + + + + 사용 + + + + 사용안함 + + + + + )} + /> + + + + 상단고정 사용 여부 + + ( + + + + + + 사용 + + + + 사용안함 + + + + + )} + /> + + + + 게시기간 사용 여부 + + ( + + + + + + 사용 + + + + 사용안함 + + + + + )} + /> + + + + 사용 여부 + + ( + + + + + + 사용 + + + + 사용안함 + + + + + )} + /> + + + + + + + ); +} + +export { BbsForm }; diff --git a/apps/common/features/src/pages/bbs/ui/bbs-page.tsx b/apps/common/features/src/pages/bbs/ui/bbs-page.tsx new file mode 100644 index 000000000..89562cb9c --- /dev/null +++ b/apps/common/features/src/pages/bbs/ui/bbs-page.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { + getCoreRowModel, + useReactTable, + type PaginationState, + type RowSelectionState, +} from "@tanstack/react-table"; +import { + Button, + DataTable, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; +import { CodeSelect } from "@/entities/code"; +import { BoardApi, type BoardParams } from "@/entities/board"; +import { ENDPOINT } from "@/shared/config"; +import { type Board } from "@/entities/board"; +import { columns } from "../model/table-columns"; + +function BbsPage() { + const router = useRouter(); + + const [rowSelection, setRowSelection] = useState({}); + const [boardParams, setBoardParams] = useState({ + bbsTypeCd: "", + bbsNm: "", + }); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + + const { data: boardList, refetch } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.BBS], + queryFn: () => + BoardApi.getBoardList({ + ...boardParams, + page: pagination.pageIndex, + size: pagination.pageSize, + }), + enabled: false, + }); + + const table = useReactTable({ + data: boardList?.content ?? [], + columns, + state: { + pagination, + rowSelection, + }, + rowCount: boardList?.totalElements ?? 0, + manualPagination: true, + enableMultiRowSelection: true, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + onRowSelectionChange: setRowSelection, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + refetch(); + }; + + const handleRowClick = (row: Board) => { + router.push(`/bbs/register/${row.bbsId}`); + }; + + useEffect(() => { + refetch(); + }, [pagination]); + + return ( + + + + + 게시판 유형 + + + setBoardParams({ ...boardParams, bbsTypeCd: value }) + } + /> + + 게시판명 + + + setBoardParams({ ...boardParams, bbsNm: e.target.value }) + } + /> + + 조회 + + + + + + + 게시판 목록 + + router.push("/bbs/register")}>등록 + { + table.setPageSize(Number(value)); + }} + > + + + + + {[10, 20, 30, 40, 50].map((pageSize) => ( + + {pageSize}개씩 보기 + + ))} + + + + + + + + + + + ); +} + +export { BbsPage }; diff --git a/apps/common/features/src/pages/bbs/ui/create-page.tsx b/apps/common/features/src/pages/bbs/ui/create-page.tsx new file mode 100644 index 000000000..bb710c33b --- /dev/null +++ b/apps/common/features/src/pages/bbs/ui/create-page.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; +import { + BoardApi, + boardFormSchema, + type BoardFormData, +} from "@/entities/board"; +import { BbsForm } from "./bbs-form"; + +function CreatePage() { + const router = useRouter(); + const { setMessage: alert } = useAlertStore((state) => state); + + const { mutateAsync: createBoard } = useMutation({ + mutationFn: (data: BoardFormData) => BoardApi.createBoard(data), + onSuccess: () => { + alert("등록되었습니다.", router.back); + }, + }); + + const form = useForm({ + resolver: zodResolver(boardFormSchema), + defaultValues: { + bbsTypeCd: "", + bbsNm: "", + bbsExpln: "", + rfrncUseYn: "Y", + atflUseYn: "Y", + ansUseYn: "Y", + cmntUseYn: "Y", + dgstfnEvlUseYn: "Y", + smsUseYn: "Y", + upndFixUseYn: "Y", + pstgPrdUseYn: "Y", + popupUseYn: "Y", + useYn: "Y", + }, + }); + + return ( + + + 게시판 등록 + + + + ); +} + +export { CreatePage }; diff --git a/apps/common/features/src/pages/bbs/ui/modify-page.tsx b/apps/common/features/src/pages/bbs/ui/modify-page.tsx new file mode 100644 index 000000000..f411cbd5c --- /dev/null +++ b/apps/common/features/src/pages/bbs/ui/modify-page.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; +import { ENDPOINT } from "@/shared/config"; +import { + BoardApi, + boardFormSchema, + type BoardFormData, +} from "@/entities/board"; +import { BbsForm } from "./bbs-form"; + +function ModifyPage({ bbsId }: { bbsId: string }) { + const { setMessage: alert } = useAlertStore((state) => state); + + const { data: boardInfo } = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.BBS, bbsId], + queryFn: () => BoardApi.getBoardInfo(bbsId), + }); + + const { mutateAsync: updateBoard } = useMutation({ + mutationFn: (data: BoardFormData) => BoardApi.updateBoard(bbsId, data), + onSuccess: () => { + alert("수정되었습니다."); + }, + }); + + const form = useForm({ + resolver: zodResolver(boardFormSchema), + values: boardInfo, + }); + + return ( + + + 게시판 수정 + + + + ); +} + +export { ModifyPage }; diff --git a/apps/common/features/src/pages/contents/index.ts b/apps/common/features/src/pages/contents/index.ts new file mode 100644 index 000000000..2bb0d4fb2 --- /dev/null +++ b/apps/common/features/src/pages/contents/index.ts @@ -0,0 +1,3 @@ +export { ContentsPage } from "./ui/contents-page"; +export { CreatePage } from "./ui/create-page"; +export { ModifyPage } from "./ui/modify-page"; diff --git a/apps/common/features/src/pages/contents/model/table-columns.tsx b/apps/common/features/src/pages/contents/model/table-columns.tsx new file mode 100644 index 000000000..19d98a48b --- /dev/null +++ b/apps/common/features/src/pages/contents/model/table-columns.tsx @@ -0,0 +1,68 @@ +import dayjs from "dayjs"; +import { createColumnHelper } from "@tanstack/react-table"; +import type { Contents } from "@/entities/content"; + +const columnHelper = createColumnHelper(); + +export const columns = [ + columnHelper.display({ + id: "no", + header: "NO", + cell: ({ row }) => row.index + 1, + }), + columnHelper.accessor("contsTtl", { + id: "contsTtl", + header: "콘텐츠명", + }), + columnHelper.group({ + id: "workInfo", + header: "작성정보", + columns: [ + columnHelper.accessor("workContsVer", { + header: "작성버전", + cell: ({ row }) => + row.original.workContsVer ? `rev. ${row.original.workContsVer}` : "", + }), + columnHelper.accessor("workAprvNm", { + header: "작성상태", + }), + ], + }), + columnHelper.group({ + id: "aprvInfo", + header: "승인정보", + columns: [ + columnHelper.accessor("aprvContsVer", { + header: "승인버전", + cell: ({ row }) => + row.original.aprvContsVer ? `rev. ${row.original.aprvContsVer}` : "", + }), + columnHelper.accessor("autzrNm", { + header: "승인자", + }), + columnHelper.accessor("aprvDt", { + header: "승인일자", + cell: ({ row }) => + row.original.aprvDt + ? dayjs(row.original.aprvDt).format("YYYY-MM-DD") + : "", + }), + ], + }), + columnHelper.accessor("useYn", { + id: "useYn", + header: "사용여부", + }), + columnHelper.accessor("mdfrNm", { + id: "mdfrNm", + header: "최종수정자", + }), + columnHelper.accessor("mdfcnDt", { + id: "mdfcnDt", + header: "최종수정일", + cell: ({ row }) => + row.original.mdfcnDt + ? dayjs(row.original.mdfcnDt).format("YYYY-MM-DD") + : "", + }), +]; diff --git a/apps/common/features/src/pages/contents/ui/contents-form.tsx b/apps/common/features/src/pages/contents/ui/contents-form.tsx new file mode 100644 index 000000000..823d30173 --- /dev/null +++ b/apps/common/features/src/pages/contents/ui/contents-form.tsx @@ -0,0 +1,268 @@ +import dynamic from "next/dynamic"; +import { type UseFormReturn } from "react-hook-form"; +import { + Input, + Label, + RadioGroup, + RadioGroupItem, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@common/components"; +import { FormWrapper } from "@/widgets/form-wrapper"; +import { FormItem, FormControl, FormField } from "@/shared/ui/form"; +import type { ContentsFormData } from "@/entities/content"; +import { useQuery } from "@tanstack/react-query"; +import { ENDPOINT } from "@/shared/config"; +import { CodeApi } from "@/entities/code/api/code-service"; +import Image from "next/image"; + +const Editor = dynamic( + () => import("@/entities/editor").then((module) => module.Editor), + { ssr: false }, +); + +interface ContentsFormProps { + form: UseFormReturn; + contsSn?: number; + isEditable?: boolean; + isContsTtlDisabled?: boolean; + handleSave: (data: ContentsFormData) => Promise; + handleDelete?: () => Promise; +} + +function ContentsForm({ + form, + contsSn, + isEditable, + isContsTtlDisabled, + ...props +}: ContentsFormProps) { + const { data: cclCode } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.CODE, "CCLCD"], + queryFn: () => CodeApi.getCode("CCLCD"), + }); + + const { data: koglCode } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.CODE, "KOGLCD"], + queryFn: () => CodeApi.getCode("KOGLCD"), + }); + + return ( + + + + + + + + + + 콘텐츠 제목 + + ( + + + + + + )} + /> + + + + 콘텐츠 내용 + + ( + + + + + + )} + /> + + + + CCL + + ( + + + + + + + 해당없음 + + + {cclCode?.map((code) => ( + + + + + + + ))} + + + + )} + /> + + + + 공공누리 + + ( + + + + + + + 해당없음 + + + {koglCode?.map((code) => ( + + + + + + + + + ))} + + + + )} + /> + + + + 태그 + + ( + + + + + + )} + /> + + + + 사용여부 + + ( + + + + + + 사용 + + + + 사용안함 + + + + + )} + /> + + + + + + + ); +} + +export { ContentsForm }; diff --git a/apps/common/features/src/pages/contents/ui/contents-page.tsx b/apps/common/features/src/pages/contents/ui/contents-page.tsx new file mode 100644 index 000000000..dd47313e7 --- /dev/null +++ b/apps/common/features/src/pages/contents/ui/contents-page.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { + getCoreRowModel, + useReactTable, + type PaginationState, +} from "@tanstack/react-table"; +import { Button, DataTable, Input, Label } from "@common/components"; +import { + ContentsApi, + type Contents, + type ContentsParams, +} from "@/entities/content"; +import { ENDPOINT } from "@/shared/config"; +import { columns } from "../model/table-columns"; + +function ContentsPage() { + const router = useRouter(); + + const [contentsQuery, setContentsQuery] = useState({ + contsTtl: "", + }); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 5, + }); + + const { data: contentsList, refetch } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS], + queryFn: () => + ContentsApi.getContentsList({ + ...contentsQuery, + page: pagination.pageIndex, + size: pagination.pageSize, + }), + placeholderData: keepPreviousData, + enabled: false, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + refetch(); + }; + + const handleRowClick = (row: Contents) => { + router.push(`/contents/register/${row.contsSn}`); + }; + + const table = useReactTable({ + data: contentsList?.content ?? [], + columns, + rowCount: contentsList?.totalElements ?? 0, + state: { + pagination, + }, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + }); + + useEffect(() => { + refetch(); + }, [pagination]); + + return ( + + + + + 콘텐츠명 + + setContentsQuery({ contsTtl: e.target.value })} + /> + + 조회 + + + + + + 콘텐츠 목록 + router.push("/contents/register")}> + 등록 + + + + + + + + ); +} + +export { ContentsPage }; diff --git a/apps/common/features/src/pages/contents/ui/create-page.tsx b/apps/common/features/src/pages/contents/ui/create-page.tsx new file mode 100644 index 000000000..8f7c662f3 --- /dev/null +++ b/apps/common/features/src/pages/contents/ui/create-page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { useRouter } from "next/navigation"; +import { useMutation } from "@tanstack/react-query"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + ContentsApi, + contentsFormSchema, + type ContentsFormData, +} from "@/entities/content"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; +import { ContentsForm } from "./contents-form"; + +function CreatePage() { + const router = useRouter(); + const { setMessage: alert } = useAlertStore((state) => state); + + const { mutateAsync: createContents } = useMutation({ + mutationFn: (data: ContentsFormData) => ContentsApi.createContents(data), + onSuccess: () => { + alert("등록되었습니다.", router.back); + }, + }); + + const form = useForm({ + resolver: zodResolver(contentsFormSchema), + defaultValues: { + useYn: "Y", + }, + }); + + return ( + + + 콘텐츠 등록 + + + + ); +} + +export { CreatePage }; diff --git a/apps/common/features/src/pages/contents/ui/modify-page.tsx b/apps/common/features/src/pages/contents/ui/modify-page.tsx new file mode 100644 index 000000000..e4b765f24 --- /dev/null +++ b/apps/common/features/src/pages/contents/ui/modify-page.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; +import { + CONTENTS_APPROVE_STATUS, + ContentsApi, + contentsFormSchema, + type ContentsFormData, +} from "@/entities/content"; +import { ContentsForm } from "./contents-form"; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, + Button, +} from "@common/components/ui"; + +function ModifyPage({ sequential }: { sequential: string }) { + const { confirm } = useConfirm(); + const queryClient = useQueryClient(); + const { setMessage: alert } = useAlertStore((state) => state); + + const contsSn = Number(sequential); + const [contsVer, setContsVer] = useState(0); + + const { data: contentsVersionList } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS, contsSn], + queryFn: () => + ContentsApi.getContentsVersionList({ + contsSn: contsSn, + page: 0, + size: 100, + }), + }); + + const { data: contentsDetail } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS, contsVer], + queryFn: () => ContentsApi.getLatestContentsInfo(contsSn, contsVer), + enabled: !!contsVer, + }); + + const { mutateAsync: createContents } = useMutation({ + mutationFn: (data: ContentsFormData) => ContentsApi.createContents(data), + onSuccess: () => { + setContsVer(0); + alert("수정되었습니다."); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS, contsSn], + }); + }, + }); + + const { mutateAsync: updateContents } = useMutation({ + mutationFn: (data: ContentsFormData) => + ContentsApi.updateContents(contsSn, contsVer, data), + onSuccess: () => { + alert("수정되었습니다."); + }, + }); + + const { mutateAsync: deleteContents } = useMutation({ + mutationFn: () => ContentsApi.deleteContents(contsSn, contsVer), + onSuccess: () => { + setContsVer(0); + alert("삭제되었습니다."); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS, contsSn], + }); + }, + }); + + const { mutateAsync: requestApprove } = useMutation({ + mutationFn: () => ContentsApi.requestApprove(contsSn, contsVer), + onSuccess: () => { + alert("승인 요청이 완료되었습니다."); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS], + }); + }, + }); + + const { mutateAsync: approveContents } = useMutation({ + mutationFn: () => ContentsApi.approveContents(contsSn, contsVer), + onSuccess: () => { + alert("승인되었습니다."); + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS], + }); + }, + }); + + const form = useForm({ + resolver: zodResolver(contentsFormSchema), + values: contentsDetail, + }); + + const approveStatus = contentsDetail?.aprvCd; + const isContsTtlDisabled = + (contentsVersionList?.content?.[0]?.contsVer ?? 0) > 1; + + const onRequestApprove = async () => { + const message = + "승인 요청하면 더이상 내용을 수정할 수 없습니다.\n요청하시겠습니까?"; + if (await confirm(message)) requestApprove(); + }; + + const onApprove = async () => { + const message = "승인하시겠습니까?"; + if (await confirm(message)) approveContents(); + }; + + useEffect(() => { + if (contsVer) return; + if (!contentsVersionList?.content?.length) return; + + const latestContent = contentsVersionList?.content[0]; + if (!latestContent) return; + + setContsVer(latestContent?.contsVer ?? 0); + }, [contentsVersionList?.content]); + + return ( + + + 템플릿 수정 + + { + setContsVer(Number(value)); + }} + > + + + + + {contentsVersionList?.content?.map((item) => ( + + {item.contsCn} + + ))} + + + {approveStatus === CONTENTS_APPROVE_STATUS.DRAFT && ( + 승인요청 + )} + {approveStatus === CONTENTS_APPROVE_STATUS.REQUESTED && ( + <> + 승인 + 반려 + > + )} + + + + + ); +} + +export { ModifyPage }; diff --git a/apps/common/features/src/pages/dashboard/index.ts b/apps/common/features/src/pages/dashboard/index.ts new file mode 100644 index 000000000..5b787c2e2 --- /dev/null +++ b/apps/common/features/src/pages/dashboard/index.ts @@ -0,0 +1 @@ +export { DashboardPage } from "./ui/dashboard-page"; diff --git a/apps/common/features/src/pages/dashboard/ui/dashboard-page.tsx b/apps/common/features/src/pages/dashboard/ui/dashboard-page.tsx new file mode 100644 index 000000000..4dc693361 --- /dev/null +++ b/apps/common/features/src/pages/dashboard/ui/dashboard-page.tsx @@ -0,0 +1,5 @@ +function DashboardPage() { + return <>>; +} + +export { DashboardPage }; diff --git a/apps/common/features/src/pages/file/index.ts b/apps/common/features/src/pages/file/index.ts new file mode 100644 index 000000000..4c0093cfc --- /dev/null +++ b/apps/common/features/src/pages/file/index.ts @@ -0,0 +1 @@ +export { FilePage } from "./ui/file-page"; diff --git a/apps/common/features/src/pages/file/model/table-columns.tsx b/apps/common/features/src/pages/file/model/table-columns.tsx new file mode 100644 index 000000000..2606397c9 --- /dev/null +++ b/apps/common/features/src/pages/file/model/table-columns.tsx @@ -0,0 +1,77 @@ +"use client"; + +import dayjs from "dayjs"; +import { type ColumnDef } from "@tanstack/react-table"; +import { Button } from "@common/components"; +import type { Attachment } from "@/entities/attachment"; + +export const createColumns = ( + handleDeleteAttachment: (atflId: string) => void, +): ColumnDef[] => [ + { + id: "no", + header: "NO", + cell: (info) => info.row.index + 1, + }, + { + accessorKey: "atflId", + header: "파일ID", + }, + { + accessorKey: "atflSn", + header: "파일순번", + }, + { + accessorKey: "orgnlFileNm", + header: "파일명", + }, + { + accessorKey: "atflFlieSz", + header: "파일크기", + cell: ({ row }) => { + const size = row.getValue("atflFlieSz"); + return size ? formatFileSize(Number(size)) : "0 Bytes"; + }, + }, + + { + accessorKey: "dwnldNocs", + header: "다운로드수", + }, + { + accessorKey: "regDt", + header: "등록일시", + cell: ({ row }) => { + const date = row.getValue("regDt") as string; + return date ? dayjs(date).format("YYYY-MM-DD") : ""; + }, + }, + { + accessorKey: "delYn", + header: "삭제여부", + }, + { + id: "delete", + header: "삭제", + cell: ({ row }) => ( + handleDeleteAttachment(row.original.atflId)} + > + 삭제 + + ), + }, +]; + +const formatFileSize = (bytes: number): string => { + console.log("bytes: ", bytes); + if (bytes === 0) return "0 Bytes"; + + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; +}; diff --git a/apps/common/features/src/pages/file/ui/file-page.tsx b/apps/common/features/src/pages/file/ui/file-page.tsx new file mode 100644 index 000000000..4f6b79781 --- /dev/null +++ b/apps/common/features/src/pages/file/ui/file-page.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; +import { + getCoreRowModel, + useReactTable, + type PaginationState, +} from "@tanstack/react-table"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { Button, DataTable, Input, Label } from "@common/components"; +import { AttachmentApi, type AttachmentParams } from "@/entities/attachment"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; +import { ENDPOINT } from "@/shared/config"; +import { createColumns } from "../model/table-columns"; + +function FilePage() { + const { confirm } = useConfirm(); + const { setMessage: alert } = useAlertStore((state) => state); + + const [attachmentQuery, setAttachmentQuery] = useState({ + keyword: "", + }); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + + const { data: attachmentsList, refetch } = useQuery({ + queryKey: [ENDPOINT.ATTACH_SERVICE.ATTACHMENTS], + queryFn: () => + AttachmentApi.getAttachmentsList({ + ...attachmentQuery, + page: pagination.pageIndex, + size: pagination.pageSize, + }), + placeholderData: keepPreviousData, + enabled: false, + }); + + const { mutate: deleteAttachment } = useMutation({ + mutationFn: (atflId: string) => AttachmentApi.deleteAttachment(atflId), + onSuccess: () => { + refetch(); + alert("삭제되었습니다."); + }, + }); + + const handleDeleteAttachment = async (atflId: string) => { + if (await confirm("삭제하시겠습니까?")) { + deleteAttachment(atflId); + } + }; + + const table = useReactTable({ + data: attachmentsList?.content ?? [], + columns: createColumns(handleDeleteAttachment), + rowCount: attachmentsList?.totalElements ?? 0, + state: { + pagination, + }, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + }); + + const handleAttachmentQueryChange = + (key: keyof AttachmentParams) => (value: string) => { + setAttachmentQuery({ ...attachmentQuery, [key]: value }); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + refetch(); + }; + + useEffect(() => { + refetch(); + }, [pagination]); + + return ( + + + + + + + 파일명 + + + handleAttachmentQueryChange("keyword")(e.target.value) + } + placeholder="파일명을 입력하세요" + /> + + + + 조회 + + + + + + 파일 목록 + + + + + + + ); +} + +export { FilePage }; diff --git a/apps/common/features/src/pages/image/api/image-service.ts b/apps/common/features/src/pages/image/api/image-service.ts new file mode 100644 index 000000000..af5d95bbe --- /dev/null +++ b/apps/common/features/src/pages/image/api/image-service.ts @@ -0,0 +1,47 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + Image, + ImageFormData, + ImageList, + ImageParams, + ImageService, +} from "../model/image-interface"; + +export class ImageApiService implements ImageService { + public async getImageList(params: ImageParams): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.IMAGES}`, + { + params, + }, + ); + return response.data; + } + + public async getImageInfo(imgSn: string): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.IMAGES}/${imgSn}`, + ); + return response.data; + } + + public async createImage(data: ImageFormData): Promise { + const response = await client.post(ENDPOINT.CMS_SERVICE.IMAGES, data); + return response.data; + } + + public async updateImage(imgSn: string, data: ImageFormData): Promise { + const response = await client.put( + `${ENDPOINT.CMS_SERVICE.IMAGES}/${imgSn}`, + data, + ); + return response.data; + } + + public async deleteImage(imgSn: string): Promise { + await client.delete(`${ENDPOINT.CMS_SERVICE.IMAGES}/${imgSn}`); + } +} + +export const ImageApi = new ImageApiService(); diff --git a/apps/common/features/src/pages/image/index.ts b/apps/common/features/src/pages/image/index.ts new file mode 100644 index 000000000..5c602ca7d --- /dev/null +++ b/apps/common/features/src/pages/image/index.ts @@ -0,0 +1,3 @@ +export { CreatePage } from "./ui/create-page"; +export { ModifyPage } from "./ui/modify-page"; +export { ImagePage } from "./ui/image-page"; diff --git a/apps/common/features/src/pages/image/model/image-interface.ts b/apps/common/features/src/pages/image/model/image-interface.ts new file mode 100644 index 000000000..2026ef9b5 --- /dev/null +++ b/apps/common/features/src/pages/image/model/image-interface.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; +import type { PageableList, PageableParams } from "@/shared/model"; + +export const IMAGE_TYPE = { + IMGSUB: "IMGSUB", + IMGMAIN: "IMGMAIN", + IMGLOGIN: "IMGLOGIN", +} as const; + +export type ImageType = (typeof IMAGE_TYPE)[keyof typeof IMAGE_TYPE]; + +export interface ImageParams extends PageableParams { + keyword: string; + imgTypeCd?: string; + useYn?: string; +} + +export interface Image { + imgSn: number; + imgTypeCd: ImageType; + imgTypeNm: string; + imgTtl: string; + lnkgUrlAddr: string; + atflYn: string; + sortSeq: number; + atflCd: string; + useYn: string; + mdfcnDt: string; + mdFr: string; + mdfrNm: string; +} + +export const imageFormSchema = z.object({ + imgTtl: z.string(), + sortSeq: z.number(), + imgTypeCd: z.string(), + atflYn: z.string(), + atflCd: z.string().nullish(), + lnkgUrlAddr: z.string().nullish(), + useYn: z.string(), +}); + +export type ImageFormData = z.infer; +export type ImageList = PageableList; + +export interface ImageService { + getImageList: (params: ImageParams) => Promise; + getImageInfo: (imgSn: string) => Promise; + createImage: (data: ImageFormData) => Promise; + updateImage: (imgSn: string, data: ImageFormData) => Promise; + deleteImage: (imgSn: string) => Promise; +} diff --git a/apps/common/features/src/pages/image/model/image-table-columns.tsx b/apps/common/features/src/pages/image/model/image-table-columns.tsx new file mode 100644 index 000000000..dd80ad9c7 --- /dev/null +++ b/apps/common/features/src/pages/image/model/image-table-columns.tsx @@ -0,0 +1,73 @@ +"use client"; + +import dayjs from "dayjs"; +import { type ColumnDef } from "@tanstack/react-table"; +import { Checkbox } from "@common/components/ui"; +import { type Image } from "./image-interface"; +import { IMAGE_TYPE } from "./image-interface"; + +export const columns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( +
하수도정보시스템 CMS 통합관리시스템에 오신 것을 환영합니다.
copyright © 2023 KECO. All rights reserved.
+ + 주소 : (22689) 인천광역시 서구 환경로 42(경서동 + 종합환경연구단지) + + + 전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723, + 3722 + + 팩스 :032-590-3729 +
+ {(upload?.file as File)?.name} +