feat: features 프로젝트 재생성

This commit is contained in:
2025-04-29 13:22:00 +09:00
parent 22233a9cba
commit 9d3b110077
246 changed files with 13954 additions and 13 deletions
+40
View File
@@ -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*
+3
View File
@@ -0,0 +1,3 @@
{
"plugins": ["prettier-plugin-tailwindcss"]
}
@@ -0,0 +1,2 @@
import { BannerPage } from "@/pages/banner";
export default BannerPage;
@@ -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 <VModifyPage sequential={sequential} />;
}
@@ -0,0 +1,2 @@
import { CreatePage } from "@/pages/banner";
export default CreatePage;
@@ -0,0 +1,2 @@
import { BbsPage } from "@/pages/bbs";
export default BbsPage;
@@ -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 <VModifyPage bbsId={id} />;
}
@@ -0,0 +1,2 @@
import { CreatePage } from "@/pages/bbs";
export default CreatePage;
@@ -0,0 +1,2 @@
import { ContentsPage } from "@/pages/contents";
export default ContentsPage;
@@ -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 <VModifyPage sequential={sequential} />;
}
@@ -0,0 +1,2 @@
import { CreatePage } from "@/pages/contents";
export default CreatePage;
@@ -0,0 +1,2 @@
import { DashboardPage } from "@/pages/dashboard";
export default DashboardPage;
@@ -0,0 +1,2 @@
import { FilePage } from "@/pages/file";
export default FilePage;
@@ -0,0 +1,2 @@
import { ImagePage } from "@/pages/image";
export default ImagePage;
@@ -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 <VModifyPage sequential={sequential} />;
}
@@ -0,0 +1,2 @@
import { CreatePage } from "@/pages/image";
export default CreatePage;
@@ -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 (
<BaseLayout
queryClient={queryClient}
currentPath={pathname ?? ""}
handleMenuClick={handleMenuClick}
>
<PageLayout>{children}</PageLayout>
</BaseLayout>
);
}
@@ -0,0 +1,2 @@
import { SkeletonPage } from "@/shared/ui/skeleton-page";
export default SkeletonPage;
@@ -0,0 +1,2 @@
import { MenuPage } from "@/pages/menu";
export default MenuPage;
@@ -0,0 +1,2 @@
import { ModulePage } from "@/pages/module";
export default ModulePage;
@@ -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 <VModifyPage sequential={sequential} />;
}
@@ -0,0 +1,2 @@
import { CreatePage } from "@/pages/module";
export default CreatePage;
@@ -0,0 +1,2 @@
import { OrganizationPage } from "@/pages/organization";
export default OrganizationPage;
@@ -0,0 +1,2 @@
import { SitePage } from "@/pages/site";
export default SitePage;
@@ -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 <VModifyPage siteId={id} />;
}
@@ -0,0 +1,2 @@
import { CreatePage } from "@/pages/site";
export default CreatePage;
@@ -0,0 +1,2 @@
import { TemplatePage } from "@/pages/template";
export default TemplatePage;
@@ -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 <VModifyPage sequential={sequential} />;
}
@@ -0,0 +1,2 @@
import { CreatePage } from "@/pages/template";
export default CreatePage;
@@ -0,0 +1,2 @@
import { TermsPage } from "@/pages/terms";
export default TermsPage;
@@ -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 <VModifyPage sequential={sequential} />;
}
@@ -0,0 +1,2 @@
import { CreatePage } from "@/pages/terms";
export default CreatePage;
@@ -0,0 +1,100 @@
import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
REFRESH_TOKEN,
SERVER_API_URL,
SITE_ID,
} from "@/shared/config";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
async function handler(
request: Request,
params: { url: string[] },
method: "GET" | "POST" | "PUT" | "DELETE",
) {
const cookieStore = await cookies();
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
};
let body = {};
const isRefreshHandler = params.url.includes("refresh");
// silent refresh
if (isRefreshHandler) {
const refreshToken = cookieStore.get(REFRESH_TOKEN);
headers[CLAIM_NAME] = refreshToken?.value ?? "";
if (!headers[CLAIM_NAME]) {
return NextResponse.json({ message: "Can't refresh" }, { status: 401 });
}
}
try {
if (!isRefreshHandler) {
body = await request.json();
}
const result = await fetch(`${SERVER_API_URL}/${params.url.join("/")}`, {
method,
headers,
body: JSON.stringify(body),
});
if (!result.ok) {
return NextResponse.json(
{ message: "Invalid Credentials" },
{ status: 401 },
);
}
const accessToken = result.headers.get(ACCESS_TOKEN);
const refreshToken = result.headers.get(REFRESH_TOKEN);
const userId = result.headers.get(AUTH_USER_ID);
if (accessToken && userId) {
const response = NextResponse.json({
[ACCESS_TOKEN]: accessToken,
[AUTH_USER_ID]: userId,
});
if (refreshToken) {
cookieStore.set(REFRESH_TOKEN, refreshToken, {
httpOnly: true,
sameSite: "lax",
path: "/",
});
}
return response;
}
return NextResponse.json(
{ message: "Invalid Credentials" },
{ status: 401 },
);
} catch (error) {
return NextResponse.json(
{ message: "Server Error", error },
{ status: 500 },
);
}
}
export async function POST(
request: Request,
props: { params: Promise<{ url: string[] }> },
) {
const params = await props.params;
return handler(request, params, "POST");
}
export async function PUT(
request: Request,
props: { params: Promise<{ url: string[] }> },
) {
const params = await props.params;
return handler(request, params, "PUT");
}
@@ -0,0 +1,2 @@
import { LoginPage } from "@/pages/auth/login";
export default LoginPage;
+8
View File
@@ -0,0 +1,8 @@
import type { Metadata } from "next";
import { App } from "@/app";
export const metadata: Metadata = {
title: "유역하수도 통합 정보플랫폼",
};
export default App;
+21
View File
@@ -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;
+42
View File
@@ -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*"],
};
+32
View File
@@ -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;
+50
View File
@@ -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"
}
}
View File
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

+18
View File
@@ -0,0 +1,18 @@
import "./styles/base.css";
import "./styles/components.css";
import { AppProvider } from "./providers";
import { cookies } from "next/headers";
const App = async ({ children }: Readonly<{ children: React.ReactNode }>) => {
const theme = (await cookies()).get("site-theme-key");
return (
<html lang="ko" data-theme={theme?.value}>
<body>
<AppProvider>{children}</AppProvider>
</body>
</html>
);
};
export { App };
@@ -0,0 +1,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 (
<QueryClientProvider client={queryClient}>
<StoreProvider>
<ConfirmProvider>
<AuthWrapper>
<MenuWrapper>{children}</MenuWrapper>
<AlertDialog />
<ConfirmDialog />
</AuthWrapper>
</ConfirmProvider>
</StoreProvider>
</QueryClientProvider>
);
};
export { AppProvider };
@@ -0,0 +1 @@
export { AppProvider } from "./app-provider";
@@ -0,0 +1,11 @@
@import "@common/assets/styles/base.css";
@import "@common/assets/styles/diveicon.css";
@tailwind base;
@tailwind utilities;
html,
body {
font-family: "Noto Sans KR", sans-serif;
font-size: 10px;
}
@@ -0,0 +1,129 @@
.card {
position: relative;
margin: 0.6rem;
box-sizing: border-box;
min-height: 2rem;
border-radius: 0.5rem;
padding: 0.6rem;
font-size: 1.3rem;
color: #666;
}
.card_width10 {
width: calc(10% - 12px);
}
.card_width20 {
width: calc(20% - 12px);
}
.card_width25 {
width: calc(25% - 12px);
}
.card_width30 {
width: calc(30% - 12px);
}
.card_width33 {
width: calc(33.333% - 12px);
}
.card_width40 {
width: calc(40% - 12px);
}
.card_width45 {
width: calc(45% - 12px);
}
.card_width50 {
width: calc(50% - 12px);
}
.card_width60 {
width: calc(60% - 12px);
}
.card_width66 {
width: calc(66.666% - 12px);
}
.card_width70 {
width: calc(70% - 12px);
}
.card_width75 {
width: calc(75% - 12px);
}
.card_width80 {
width: calc(80% - 12px);
}
.card_width90 {
width: calc(90% - 12px);
}
.card_width100 {
width: calc(100% - 12px);
}
.card_nopadding {
padding: 0 !important;
}
.card_nomargin {
width: 100%;
margin: 0 !important;
padding: 0 !important;
}
.topline {
border-top: 1px solid #ccc;
}
.bottomline {
border-bottom: 1px solid #e6eef3;
background: #fff;
}
.card-border {
border: 1px solid #dbe2e6;
background-color: rgba(255, 255, 255, 0.6);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.card-header {
margin-top: -0.6rem;
margin-left: -0.6rem;
margin-right: -0.6rem;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e1e7eb;
padding: 1rem;
}
.card-footer {
margin-left: -0.6rem;
margin-right: -0.6rem;
margin-top: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
border-top: 1px solid #e1e7eb;
padding-top: 1rem;
padding-bottom: 1rem;
}
/* 블랙 테마 */
[data-theme="black"] {
.card-border {
background-color: rgba(255, 255, 255, 0.065);
border-color: #38383a;
}
.card-header {
border-bottom-color: #3f4042;
}
}
@@ -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<AttachmentList> {
const response = await client.get<AttachmentList>(
`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}`,
{ params },
);
return response.data;
}
public async getAttachmentsInfo(atflCd: string): Promise<Attachment[]> {
const response = await client.get<Attachment[]>(
`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflCd}`,
);
return response.data;
}
public async deleteAttachment(atflId: string): Promise<void> {
await client.delete(`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflId}`);
}
public async deleteAttachmentByAtflCd(atflCd: string): Promise<void> {
await client.delete(
`${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/atflCd/${atflCd}`,
);
}
public async getImageUrl(atflId: string): Promise<string> {
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<string> {
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();
@@ -0,0 +1,5 @@
export { AttachmentApi } from "./api/attachment-service";
export type {
AttachmentParams,
Attachment,
} from "./model/attachment-interface";
@@ -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<Attachment>;
export interface AttachmentService {
getAttachmentsList: (params: AttachmentParams) => Promise<AttachmentList>;
getAttachmentsInfo: (atflCd: string) => Promise<Attachment[]>;
deleteAttachment: (atflId: string) => Promise<void>;
deleteAttachmentByAtflCd: (atflCd: string) => Promise<void>;
getImageUrl: (atflId: string) => Promise<string>;
getImageUrlByAtflCd: (atflCd: string) => Promise<string>;
}
@@ -0,0 +1,85 @@
import { AxiosError } from "axios";
import {
ENDPOINT,
JWT_EXPIRATION_TIME,
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
} from "@/shared/config";
import type { LoginService, ILogin } from "../model/login-interface";
import { tokenManager } from "../lib/token-manager";
export class LoginApiService implements LoginService {
private static instance: LoginApiService;
private constructor() {}
public static getInstance(): LoginApiService {
if (!LoginApiService.instance) {
LoginApiService.instance = new LoginApiService();
}
return LoginApiService.instance;
}
public async login(payload: ILogin): Promise<void> {
try {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
method: "POST",
body: JSON.stringify({
userId: payload.userId,
password: payload.password,
}),
});
const data = await response.json();
const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error("Missing required authentication tokens");
}
tokenManager.setToken(CLAIM_NAME, accessToken);
tokenManager.setToken(AUTH_USER_ID, tokenId);
tokenManager.setupRefreshTimer(
() => this.silentRefresh(),
JWT_EXPIRATION_TIME,
);
} catch (error) {
if (error instanceof AxiosError) {
throw new Error(`Login failed: ${error.response?.data?.message}`);
}
throw new Error(`Login failed: ${error}`);
}
}
public async silentRefresh(): Promise<void> {
try {
const response = await fetch(
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
{ method: "PUT" },
);
const data = await response.json();
const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error("Missing required authentication tokens");
}
tokenManager.setToken(CLAIM_NAME, accessToken);
tokenManager.setToken(AUTH_USER_ID, tokenId);
} catch (error) {
tokenManager.clearAuthToken();
if (error instanceof AxiosError) {
throw new Error(
`Silent refresh failed: ${error.response?.data?.message}`,
);
}
throw new Error(`Silent refresh failed: ${error}`);
}
}
}
export const LoginApi = LoginApiService.getInstance();
@@ -0,0 +1,2 @@
export { LoginApi } from "./api/login-service";
export { AuthWrapper } from "./lib/auth-wrapper";
@@ -0,0 +1,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<IUserInfo>(
`${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 };
@@ -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<void>,
expirationTime: number,
): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
this.refreshTimer = setTimeout(() => {
callback().catch((error) => {
console.error("Silent refresh failed:", error);
this.clearAuthToken();
});
}, expirationTime - 60000);
}
public clearAuthToken(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
this.removeToken(CLAIM_NAME);
this.removeToken(AUTH_USER_ID);
}
}
export const tokenManager = TokenManager.getInstance();
@@ -0,0 +1,9 @@
export interface ILogin {
userId: string;
password: string;
}
export interface LoginService {
login(payload: ILogin): Promise<void>;
silentRefresh(): Promise<void>;
}
@@ -0,0 +1,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<BoardList> {
const response = await client.get<BoardList>(
`${ENDPOINT.BOARD_SERVICE.BBS}`,
{ params },
);
return response.data;
}
public async getBoardPopupList(
params: BoardPopupParams,
): Promise<BoardPopupList> {
const response = await client.get<BoardPopupList>(
`${ENDPOINT.BOARD_SERVICE.BBS}`,
{ params },
);
return response.data;
}
public async getBoardInfo(bbsId: string): Promise<Board> {
const response = await client.get<Board>(
`${ENDPOINT.BOARD_SERVICE.BBS}/${bbsId}`,
);
return response.data;
}
public async createBoard(data: BoardFormData): Promise<Board> {
const response = await client.post<Board>(
`${ENDPOINT.BOARD_SERVICE.BBS}`,
data,
);
return response.data;
}
public async updateBoard(bbsId: string, data: BoardFormData): Promise<Board> {
const response = await client.put<Board>(
`${ENDPOINT.BOARD_SERVICE.BBS}/${bbsId}`,
data,
);
return response.data;
}
public async deleteBoard(bbsId: string): Promise<void> {
await client.delete(`${ENDPOINT.BOARD_SERVICE.BBS}/${bbsId}`);
}
}
export const BoardApi = new BoardApiService();
@@ -0,0 +1,8 @@
export { BoardApi } from "./api/board-service";
export {
boardFormSchema,
type Board,
type BoardFormData,
type BoardParams,
type BoardPopup,
} from "./model/board-interface";
@@ -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<Board>;
export type BoardPopupList = PageableList<BoardPopup>;
export type BoardFormData = z.infer<typeof boardFormSchema>;
export interface BoardService {
getBoardList: (params: BoardParams) => Promise<BoardList>;
getBoardPopupList: (params: BoardPopupParams) => Promise<BoardPopupList>;
getBoardInfo: (bbsId: string) => Promise<Board>;
createBoard: (data: BoardFormData) => Promise<Board>;
updateBoard: (bbsId: string, data: BoardFormData) => Promise<Board>;
deleteBoard: (bbsId: string) => Promise<void>;
}
@@ -0,0 +1 @@
export { CodeSelect } from "../ui/code-select";
@@ -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<ICode[]> {
const response = await client.get<ICode[]>(
`${ENDPOINT.CMS_SERVICE.CODE}/${upCd}`,
);
return response.data;
}
}
export const CodeApi = new CodeApiService();
@@ -0,0 +1,3 @@
export { CodeApi } from "./api/code-service";
export type { ICode } from "./model/code-interface";
export { CodeSelect } from "./ui/code-select";
@@ -0,0 +1,10 @@
export interface ICode {
lwpoCd: string;
upCd: string;
lwpoCdNm: string;
unit: string;
}
export interface CodeService {
getCode(upCd: string): Promise<ICode[]>;
}
@@ -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<typeof Select>;
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 (
<Select {...props}>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder={defaultValueText} />
</SelectTrigger>
<SelectContent>
{code?.map((item) => (
<SelectItem key={item.lwpoCd} value={item.lwpoCd}>
{item.lwpoCdNm}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export { CodeSelect };
@@ -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<ContentsList> {
const response = await client.get<ContentsList>(
`${ENDPOINT.CMS_SERVICE.CONTENTS}`,
{ params },
);
return response.data;
}
public async getContentsVersionList(
params: ContentsVersionParams,
): Promise<ContentsVersionList> {
const response = await client.get<ContentsVersionList>(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}`,
{ params },
);
return response.data;
}
public async getLatestContentsInfo(
contsSn: number,
contsVer: number,
): Promise<ContentsDetail> {
const response = await client.get<ContentsDetail>(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`,
);
return response.data;
}
public async createContents(data: ContentsFormData): Promise<void> {
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}`,
data,
);
return response.data;
}
public async updateContents(
contsSn: number,
contsVer: number,
data: ContentsFormData,
): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`,
data,
);
return response.data;
}
public async deleteContents(
contsSn: number,
contsVer: number,
): Promise<void> {
await client.delete(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}`,
);
}
public async requestApprove(
contsSn: number,
contsVer: number,
): Promise<void> {
await client.put(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}/approveRequest`,
);
}
public async approveContents(
contsSn: number,
contsVer: number,
): Promise<void> {
await client.put(
`${ENDPOINT.CMS_SERVICE.CONTENTS_DETAILS}/${contsSn}/${contsVer}/approve`,
);
}
}
export const ContentsApi = new ContentsApiService();
@@ -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";
@@ -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<typeof contentsSchema>;
export type ContentsDetail = z.infer<typeof contentsDetailSchema>;
export type ContentsFormData = z.infer<typeof contentsFormSchema>;
export type ContentsList = PageableList<Contents>;
export type ContentsVersionList = PageableList<ContentsVersion>;
export interface ContentsService {
getContentsList(params: ContentsParams): Promise<ContentsList>;
getContentsVersionList(
params: ContentsVersionParams,
): Promise<ContentsVersionList>;
getLatestContentsInfo(
contsSn: number,
contsVer: number,
): Promise<ContentsDetail>;
createContents(data: ContentsFormData): Promise<void>;
updateContents(
contsSn: number,
contsVer: number,
data: ContentsFormData,
): Promise<void>;
deleteContents(contsSn: number, contsVer: number): Promise<void>;
requestApprove(contsSn: number, contsVer: number): Promise<void>;
approveContents(contsSn: number, contsVer: number): Promise<void>;
}
@@ -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<FileInfo> {
const response = await client.post<FileInfo>(
ENDPOINT.ATTACH_SERVICE.EDITOR_UPLOAD,
data,
);
return response.data;
}
}
export const EditorApi = new EditorApiService();
@@ -0,0 +1 @@
export { Editor } from "./ui/editor";
@@ -0,0 +1,10 @@
export const blobToBase64 = (blob: Blob): Promise<string> => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result as string;
resolve(base64.split(",")[1] || "");
};
reader.readAsDataURL(blob);
});
};
@@ -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,
};
};
@@ -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",
},
};
@@ -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<FileInfo>;
}
@@ -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<TinyEditorInstance | null>(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 (
<TinyEditor
{...baseConfig}
onInit={(_: unknown, editor: TinyEditorInstance) => {
editorRef.current = editor;
}}
init={{
...baseConfig.init,
disabled: props.disabled,
images_upload_handler: imagesUploadHandler,
file_picker_callback: filePickerCallback,
}}
{...props}
/>
);
};
export { Editor };
@@ -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<MenuAuthorityList> {
const response = await client.get<MenuAuthorityList>(
`${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}`,
{ params },
);
return response.data;
}
public async saveMenuAuthority(data: MenuAuthorityPayload[]): Promise<void> {
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}`,
data,
);
return response.data;
}
}
export const MenuAuthorityApi = new MenuAuthorityApiService();
@@ -0,0 +1 @@
export { ManagementAuthorityDialog } from "./ui/management-authority-dialog";
@@ -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<typeof menuAuthoritySchema>;
export type MenuAuthorityList = PageableList<MenuAuthority>;
export type MenuAuthorityPayload = z.infer<typeof menuAuthorityPayloadSchema>;
export type MenuAuthorityFormData = z.infer<typeof menuAuthorityFormSchema>;
export interface MenuAuthorityService {
getMenuAuthorityList(params: MenuAuthorityParams): Promise<MenuAuthorityList>;
saveMenuAuthority(data: MenuAuthorityPayload[]): Promise<void>;
}
@@ -0,0 +1,117 @@
import type { ColumnDef } from "@tanstack/react-table";
import type { UseFormReturn } from "react-hook-form";
import { Checkbox } from "@common/components/ui";
import type {
MenuAuthority,
MenuAuthorityFormData,
MenuAuthorityPayload,
} from "./menu-authority-interface";
export const createColumns = (
form: UseFormReturn<MenuAuthorityFormData>,
): ColumnDef<MenuAuthority>[] => {
const getCurrentRow = (i: number) => form.watch("menuAuthority")[i];
const handleCheckedChange =
(i: number, key: keyof MenuAuthorityPayload) => (checked: boolean) => {
if (getCurrentRow(i)?.mode === "R") {
form.setValue(`menuAuthority.${i}.mode`, "U");
}
form.setValue(`menuAuthority.${i}.${key}`, checked ? "Y" : "N");
};
return [
{
accessorKey: "groupNm",
header: "그룹명",
},
{
accessorKey: "mngAuthrtYn",
header: "관리권한",
cell: ({ row }) => {
return (
<Checkbox
checked={getCurrentRow(row.index)?.mngAuthrtYn === "Y"}
onCheckedChange={(checked) => {
if (checked) {
handleCheckedChange(
row.index,
"regAuthrtYn",
)(checked as boolean);
handleCheckedChange(
row.index,
"mdfcnAuthrtYn",
)(checked as boolean);
handleCheckedChange(
row.index,
"delAuthrtYn",
)(checked as boolean);
handleCheckedChange(
row.index,
"inqAuthrtYn",
)(checked as boolean);
}
handleCheckedChange(row.index, "mngAuthrtYn")(checked as boolean);
}}
/>
);
},
},
{
accessorKey: "regAuthrtYn",
header: "등록권한",
cell: ({ row }) => {
return (
<Checkbox
checked={getCurrentRow(row.index)?.regAuthrtYn === "Y"}
onCheckedChange={(checked) => {
if (checked) {
handleCheckedChange(
row.index,
"inqAuthrtYn",
)(checked as boolean);
}
handleCheckedChange(row.index, "regAuthrtYn")(checked as boolean);
}}
/>
);
},
},
{
accessorKey: "mdfcnAuthrtYn",
header: "수정권한 ",
cell: ({ row }) => {
return (
<Checkbox
checked={getCurrentRow(row.index)?.mdfcnAuthrtYn === "Y"}
onCheckedChange={handleCheckedChange(row.index, "mdfcnAuthrtYn")}
/>
);
},
},
{
accessorKey: "delAuthrtYn",
header: "삭제권한",
cell: ({ row }) => {
return (
<Checkbox
checked={getCurrentRow(row.index)?.delAuthrtYn === "Y"}
onCheckedChange={handleCheckedChange(row.index, "delAuthrtYn")}
/>
);
},
},
{
accessorKey: "inqAuthrtYn",
header: "조회권한",
cell: ({ row }) => {
return (
<Checkbox
checked={getCurrentRow(row.index)?.inqAuthrtYn === "Y"}
onCheckedChange={handleCheckedChange(row.index, "inqAuthrtYn")}
/>
);
},
},
];
};
@@ -0,0 +1,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<typeof Dialog>;
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<MenuAuthorityFormData>({
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 (
<Dialog {...props}>
<DialogTrigger asChild>
<Button className="gap-3" disabled={!menuCd}>
<i className="diveicon di-personal-information" />
권한관리
</Button>
</DialogTrigger>
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>그룹별 메뉴 권한</DialogTitle>
</DialogHeader>
<DataTable table={table} />
<DialogFooter className="!justify-center">
<Button size="lg" onClick={handleSave}>
저장
</Button>
<DialogClose asChild>
<Button size="lg" color="white">
취소
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { ManagementAuthorityDialog };
@@ -0,0 +1,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<MenuItem[]> {
const response = await client.get<MenuItem[]>(
`${ENDPOINT.CMS_SERVICE.MENU_ROLES}/${siteId}`,
);
return response.data;
}
}
export const MenuApi = new MenuApiService();
@@ -0,0 +1 @@
export { MenuWrapper } from "./lib/menu-wrapper";
@@ -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 };
@@ -0,0 +1,20 @@
import type { MenuItem } from "../model/menu-interface";
export const getCurrentMenu = (
menus: MenuItem[],
pathname: string,
): MenuItem | null => {
for (const menu of menus) {
if (
menu.urlPath !== "" &&
(menu.urlPath === pathname || pathname.startsWith(menu.urlPath))
)
return menu;
if (menu.children) {
const children = getCurrentMenu(menu.children, pathname);
if (children) return children;
}
}
return null;
};
@@ -0,0 +1,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<MenuItem[]>;
}
@@ -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<OrganizationTree[]> {
const response = await client.get<OrganizationTree[]>(
`${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}/tree`,
);
return response.data;
}
public async getOrganizationInfo(ognzSn: number): Promise<Organization> {
const response = await client.get<Organization>(
`${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}/${ognzSn}`,
);
return response.data;
}
public async createOrganization(data: OrganizationFormData): Promise<void> {
await client.post<void>(`${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}`, data);
}
public async deleteOrganization(ognzSn: number): Promise<void> {
await client.delete<void>(
`${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}/${ognzSn}`,
);
}
public async updateOrganizationName(
ognzSn: number,
name: string,
): Promise<Organization> {
const response = await client.put<Organization>(
`${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}/${ognzSn}/${name}`,
);
return response.data;
}
public async updateOrganization(
ognzSn: number,
data: OrganizationFormData,
): Promise<void> {
await client.put<void>(
`${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}/${ognzSn}`,
data,
);
}
public async updateOrganizationByDnd(
data: OrganizationTreeForDnd[],
): Promise<void> {
await client.put<void>(
`${ENDPOINT.CMS_SERVICE.ORGANIZATIONS}/dnd/tree`,
data,
);
}
}
export const OrganizationApi = new OrganizationApiService();
@@ -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";
@@ -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<OrganizationTree>[] => {
return data.reduce<NodeModel<OrganizationTree>[]>((acc, menu) => {
const node: NodeModel<OrganizationTree> = {
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<OrganizationTree>[],
options: DropOptions<OrganizationTree>,
): 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<OrganizationTree>[],
options: DropOptions<OrganizationTree>,
) => {
const newTreeData = generateDndOrganizationTreeData(newTree, options);
if (!newTreeData) return;
callback(newTreeData);
};
@@ -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<typeof organizationSchema>;
export type OrganizationFormData = z.infer<typeof organizationFormSchema>;
export type OrganizationTree = Organization & {
children?: OrganizationTree[];
};
export type OrganizationTreeForDnd = Omit<OrganizationTree, "useYn">;
export interface OrganizationService {
getOrganizationsTreeList(): Promise<OrganizationTree[]>;
getOrganizationInfo(ognzSn: number): Promise<Organization>;
createOrganization(data: OrganizationFormData): Promise<void>;
deleteOrganization(ognzSn: number): Promise<void>;
updateOrganizationName(ognzSn: number, name: string): Promise<Organization>;
updateOrganization(ognzSn: number, data: OrganizationFormData): Promise<void>;
updateOrganizationByDnd(data: OrganizationTreeForDnd[]): Promise<void>;
}
@@ -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<PageableList<Post>> {
const response = await client.get<PageableList<Post>>(
`${ENDPOINT.CMS_SERVICE.POSTINGS}`,
{ params },
);
return response.data;
}
public async savePosting(data: PostPayload[]): Promise<Post[]> {
const response = await client.post<Post[]>(
`${ENDPOINT.CMS_SERVICE.POSTINGS}`,
data,
);
return response.data;
}
}
export const PostingApi = new PostingApiService();
@@ -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";
@@ -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<typeof postSchema>;
export type PostPayload = z.infer<typeof postPayloadSchema>;
export type PostingFormData = z.infer<typeof postingFormSchema>;
export interface PostService {
getPostingHistory: (params: PostingParams) => Promise<PageableList<Post>>;
savePosting: (data: PostPayload[]) => Promise<Post[]>;
}

Some files were not shown because too many files have changed in this diff Show More