diff --git a/README.md b/README.md index 798425682..e74e7e815 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,9 @@ export default nextConfig; ### 6. globals.css(또는 전역 css) 수정 ```css -@import "@common/assets/index.css"; +@import "@common/assets/styles/base.css"; +@import "@common/assets/styles/diveicon.css"; +@import "@common/assets/styles/components.css"; @tailwind base; @tailwind components; diff --git a/apps/cs/app/(with-base-layout)/menu/page.tsx b/apps/cs/app/(with-base-layout)/menu/page.tsx new file mode 100644 index 000000000..bbac0670c --- /dev/null +++ b/apps/cs/app/(with-base-layout)/menu/page.tsx @@ -0,0 +1,2 @@ +import { MenuPage } from "@/pages/menu"; +export default MenuPage; diff --git a/apps/cs/eslint.config.mjs b/apps/cs/eslint.config.mjs index c85fb67c4..9109635e9 100644 --- a/apps/cs/eslint.config.mjs +++ b/apps/cs/eslint.config.mjs @@ -11,6 +11,11 @@ const compat = new FlatCompat({ const eslintConfig = [ ...compat.extends("next/core-web-vitals", "next/typescript"), + { + rules: { + "react-hooks/exhaustive-deps": "off", + }, + }, ]; export default eslintConfig; diff --git a/apps/cs/middleware.ts b/apps/cs/middleware.ts index e7b03dddc..c9284d69b 100644 --- a/apps/cs/middleware.ts +++ b/apps/cs/middleware.ts @@ -5,10 +5,15 @@ const serverApiUrl = export function middleware(req: NextRequest) { const pathname = req.nextUrl.pathname; + const searchParams = req.nextUrl.searchParams; + if (pathname.startsWith("/server")) { - return NextResponse.rewrite( - `${serverApiUrl}${pathname.replace("/server", "")}`, - ); + const queryParams = searchParams.toString(); + const newUrl = `${serverApiUrl}${pathname.replace("/server", "")}${ + queryParams ? `?${queryParams}` : "" + }`; + console.log("Rewriting to: ", newUrl); + return NextResponse.rewrite(new URL(newUrl)); } return NextResponse.next(); diff --git a/apps/cs/package.json b/apps/cs/package.json index 4b400a285..4394eaf17 100644 --- a/apps/cs/package.json +++ b/apps/cs/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "dev": "NODE_OPTIONS='--inspect' next dev", + "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint" @@ -13,6 +13,7 @@ "@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", @@ -23,6 +24,7 @@ "js-cookie": "^3.0.5", "next": "15.1.7", "react-chartjs-2": "^5.3.0", + "react-dnd": "^16.0.1", "react-hook-form": "^7.54.2", "tailwindcss-animate": "^1.0.7", "zod": "^3.24.2", diff --git a/apps/cs/src/app/index.tsx b/apps/cs/src/app/index.tsx index d1d5e56e1..5a73f2fdd 100644 --- a/apps/cs/src/app/index.tsx +++ b/apps/cs/src/app/index.tsx @@ -1,10 +1,13 @@ 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"); -const App = ({ children }: Readonly<{ children: React.ReactNode }>) => { return ( - + {children} diff --git a/apps/cs/src/app/providers/app-provider.tsx b/apps/cs/src/app/providers/app-provider.tsx index cf477dcc0..b14c3dc96 100644 --- a/apps/cs/src/app/providers/app-provider.tsx +++ b/apps/cs/src/app/providers/app-provider.tsx @@ -2,16 +2,18 @@ import { ConfirmProvider } from "@frontend-opensource/use-react-hooks"; import { AuthWrapper } from "@/entities/auth"; -import { QueryProvider, StoreProvider } from "@/shared/providers"; +import { StoreProvider } from "@/shared/providers"; +import { AlertDialog } from "@/shared/ui/alert-dialog"; const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { return ( - - - {children} - - + + + {children} + + + ); }; diff --git a/apps/cs/src/app/styles/components.css b/apps/cs/src/app/styles/components.css index ec137cf25..c1eb79210 100644 --- a/apps/cs/src/app/styles/components.css +++ b/apps/cs/src/app/styles/components.css @@ -1,25 +1,87 @@ -@tailwind components; +.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; +} -@layer components { - .card { - @apply relative m-[0.6rem] box-border min-h-[2rem] rounded-[0.5rem] p-[0.6rem] text-[1.3rem] text-[#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-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 { - @apply border border-[#dbe2e6] bg-[rgba(255,255,255,0.6)] shadow-blur; + border-color: #3f4042; + background-color: rgba(255, 255, 255, 0.065); } .card-header { - @apply -mx-[0.6rem] flex items-center justify-between border-b border-b-[#e1e7eb] p-4; - } - .card-footer { - @apply -mx-[0.6rem] mt-2 flex items-center justify-center border-t border-t-[#e1e7eb] py-4; - } - - [data-mode="dark"] { - .card-border { - @apply border-[#3f4042] bg-[rgba(255,255,255,0.065)]; - } - .card-header { - @apply border-b-[#3f4042]; - } + border-bottom-color: #3f4042; } } diff --git a/apps/cs/src/entities/auth/ui/auth-wrapper.tsx b/apps/cs/src/entities/auth/ui/auth-wrapper.tsx index ba175e697..705c03231 100644 --- a/apps/cs/src/entities/auth/ui/auth-wrapper.tsx +++ b/apps/cs/src/entities/auth/ui/auth-wrapper.tsx @@ -1,44 +1,47 @@ "use client"; +import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { useQuery } from "@tanstack/react-query"; import { client } from "@/shared/api"; -import { ENDPOINT, AUTH_USER_ID } from "@/shared/config"; -import { getCookie, useUserStore } from "@/shared/lib"; 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 { setUserInfo } = useUserStore((state) => state); + const { userInfo, setUserInfo } = useUserStore((state) => state); - useQuery({ - queryKey: [ENDPOINT.USER_SERVICE.USERS], - queryFn: async () => { + 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 { - 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}`, - ); - setUserInfo(response.data); - return response; + 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}; }; diff --git a/apps/cs/src/entities/code/ui/code-select.tsx b/apps/cs/src/entities/code/ui/code-select.tsx index 00b3157aa..bd175a8a9 100644 --- a/apps/cs/src/entities/code/ui/code-select.tsx +++ b/apps/cs/src/entities/code/ui/code-select.tsx @@ -11,10 +11,7 @@ import { CodeApi } from "../api/code-service"; type CodeSelectProps = { upCd: string; - value?: string; - defaultValue?: string; - onValueChange?: (value: string) => void; -}; +} & React.ComponentPropsWithoutRef; function CodeSelect({ upCd, defaultValue, ...props }: CodeSelectProps) { const { data: code } = useQuery({ diff --git a/apps/cs/src/pages/site/api/site-service.ts b/apps/cs/src/entities/site/api/site-service.ts similarity index 91% rename from apps/cs/src/pages/site/api/site-service.ts rename to apps/cs/src/entities/site/api/site-service.ts index 22363de68..629bf5f01 100644 --- a/apps/cs/src/pages/site/api/site-service.ts +++ b/apps/cs/src/entities/site/api/site-service.ts @@ -2,16 +2,16 @@ import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; import { siteFormSchema, - type ISiteInfo, type SiteService, + type SiteList, type ISiteDetail, type ISiteParams, type SiteFormData, } from "../model/site-interface"; export class SiteApiService implements SiteService { - public async siteInfo(params: ISiteParams): Promise { - const response = await client.get( + public async getSiteList(params: ISiteParams): Promise { + const response = await client.get( `${ENDPOINT.CMS_SERVICE.SITES}`, { params }, ); diff --git a/apps/cs/src/entities/site/index.ts b/apps/cs/src/entities/site/index.ts new file mode 100644 index 000000000..985b9346a --- /dev/null +++ b/apps/cs/src/entities/site/index.ts @@ -0,0 +1,7 @@ +export { SiteApi } from "./api/site-service"; +export { + siteFormSchema, + type ISiteParams, + type ISite, + type SiteFormData, +} from "./model/site-interface"; diff --git a/apps/cs/src/entities/site/model/site-interface.ts b/apps/cs/src/entities/site/model/site-interface.ts new file mode 100644 index 000000000..ae14297ae --- /dev/null +++ b/apps/cs/src/entities/site/model/site-interface.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; +import type { IPageableParams, PageableList } from "@/shared/model"; + +export interface ISiteParams extends IPageableParams { + siteNm?: string; + siteKndCd?: string; + useYn?: string; +} + +export interface ISite { + siteId: string; + siteNm: string; + siteKindCode: string; + bscSiteYn: string; + siteAddr: string; + useYn: string; + regDt: string; + rgTrNm: string; + dmnAddr: string; +} + +export interface ISiteDetail 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).nullable(), + siteKndCd: z.string().max(10).nonempty(), + siteAddr: z.string().max(2000).nullable(), + faxNo: z.string().max(100).nullable(), + telNo: z.string().max(1000).nullable(), + lwndCn: z.string().max(2000).nullable(), + bscSiteYn: z.string(), + bkmkIcon: z.string().max(100).nullable(), + useYn: z.string(), + siteSkn: z.string().max(10).nullable(), + dmnAddr: z.string().nullable(), +}); + +export type SiteFormData = z.infer; +export type SiteList = PageableList; + +export interface SiteService { + getSiteList(params: ISiteParams): Promise; + siteDetail(siteId: string): Promise; + modifySite(siteId: string, data: SiteFormData): Promise; + createSite(data: SiteFormData): Promise; +} diff --git a/apps/cs/src/pages/auth/login/ui/login-page.tsx b/apps/cs/src/pages/auth/login/ui/login-page.tsx index d5e12966a..b5545f256 100644 --- a/apps/cs/src/pages/auth/login/ui/login-page.tsx +++ b/apps/cs/src/pages/auth/login/ui/login-page.tsx @@ -4,8 +4,10 @@ import Image from "next/image"; import { images } from "@common/assets"; import { LoginApi } from "@/entities/auth"; import "./login.css"; +import { useRouter } from "next/navigation"; function LoginPage() { + const router = useRouter(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -13,7 +15,8 @@ function LoginPage() { const userId = formData.get("id") as string; const password = formData.get("password") as string; - LoginApi.login({ userId, password }); + await LoginApi.login({ userId, password }); + router.push("/site"); }; return ( diff --git a/apps/cs/src/pages/menu/api/menu-service.ts b/apps/cs/src/pages/menu/api/menu-service.ts new file mode 100644 index 000000000..4fe6c845a --- /dev/null +++ b/apps/cs/src/pages/menu/api/menu-service.ts @@ -0,0 +1,29 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { Menu, MenuService, TreeMenu } from "../model/menu-interface"; + +export class MenuApiService implements MenuService { + public async getTreeMenuList(siteId: string): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.MENUS}/${siteId}/tree`, + ); + return response.data; + } + + public async modifyTreeMenu(siteId: string, data: TreeMenu[]): Promise { + const response = await client.put( + `${ENDPOINT.CMS_SERVICE.MENUS}/${siteId}/tree`, + data, + ); + return response.data; + } + + public async modifyMenuName(menuCd: number, name: string): Promise { + const response = await client.put( + `${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}/${name}`, + ); + return response.data; + } +} + +export const MenuApi = new MenuApiService(); diff --git a/apps/cs/src/pages/menu/index.ts b/apps/cs/src/pages/menu/index.ts new file mode 100644 index 000000000..fa0eda54f --- /dev/null +++ b/apps/cs/src/pages/menu/index.ts @@ -0,0 +1 @@ +export { MenuPage } from "./ui/menu-page"; diff --git a/apps/cs/src/pages/menu/lib/utils.ts b/apps/cs/src/pages/menu/lib/utils.ts new file mode 100644 index 000000000..333ebad94 --- /dev/null +++ b/apps/cs/src/pages/menu/lib/utils.ts @@ -0,0 +1,98 @@ +import type { DropOptions, NodeModel } from "@minoru/react-dnd-treeview"; +import type { Menu, TreeMenu } from "../model/menu-interface"; + +export const generateTreeData = (data: Menu[]): NodeModel[] => { + return data.reduce[]>((acc, menu) => { + const node: NodeModel = { + id: menu.menuCd ?? 0, + parent: menu.parentId ?? 0, + text: menu.menuKornNm, + droppable: true, + data: menu, + }; + + acc.push(node); + + if (menu.children?.length) { + acc.push(...generateTreeData(menu.children)); + } + + return acc; + }, []); +}; + +export const generateNewTreeData = ( + treeData: NodeModel[], + options: DropOptions, +): TreeMenu | null => { + const { dragSourceId, dragSource, dropTargetId, dropTarget } = options; + + if (!dragSource?.data) return null; + + const siblings = treeData.filter((node) => node.parent === dropTargetId); + + const newSortSeq = + siblings.findIndex((sibling) => sibling.id === dragSourceId) + 1; + + const childNodes = treeData.filter( + (node) => node.parent === dragSourceId && node.data, + ); + + const baseNode = (node: NodeModel): TreeMenu => { + if (!node.data) return {}; + return { + menuCd: node.data?.menuCd, + name: node.data?.menuKornNm, + icon: node.data?.iconNm, + }; + }; + + const treeMenu: TreeMenu = { + ...baseNode(dragSource), + parentId: dropTargetId === 0 ? null : (dropTargetId as number), + sortSeq: newSortSeq, + levelNo: (dropTarget?.data?.levelNo ?? 0) + 1, + }; + + const result: TreeMenu = { + ...treeMenu, + children: childNodes.map((child) => ({ + ...baseNode(child), + parentId: child.data?.parentId === 0 ? null : (dropTargetId as number), + sortSeq: child.data?.sortSeq, + levelNo: (treeMenu?.levelNo ?? 0) + 1, + })), + }; + + return result; +}; + +export const handleSelectNode = + (callback: (node: NodeModel) => void) => (node: NodeModel) => { + callback(node); + }; + +export const handleDropNode = + (callback: (newTree: TreeMenu) => void) => + (newTree: NodeModel[], options: DropOptions) => { + const newTreeData = generateNewTreeData(newTree, options); + if (!newTreeData) return; + callback(newTreeData); + }; + +export const handleNodeTextChange = + (treeData: NodeModel[]) => + (callback: (newTree: NodeModel[]) => void) => + (id: NodeModel["id"], value: string) => { + const newTree = treeData.map((node) => { + if (node.id === id) { + return { + ...node, + text: value, + }; + } + + return node; + }); + callback(newTree); + }; diff --git a/apps/cs/src/pages/menu/model/menu-interface.ts b/apps/cs/src/pages/menu/model/menu-interface.ts new file mode 100644 index 000000000..fc22c462c --- /dev/null +++ b/apps/cs/src/pages/menu/model/menu-interface.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; + +export const MENU_TYPE = { + EMPTY: "EMPTY", + CONTENTS: "CONTENTS", + BOARD: "BOARD", + INSIDE: "INSIDE", + OUTSIDE: "OUTSIDE", + BOTTOM: "BOTTOM", +} as const; + +export type MenuType = (typeof MENU_TYPE)[keyof typeof MENU_TYPE]; + +export interface TreeMenu { + menuCd?: number; + name?: string; + sortSeq?: number; + parentId?: number | null; + levelNo?: number; + icon?: string; + children?: TreeMenu[]; +} + +export interface Menu extends Omit { + menuKornNm: string; + menuEngNm: string; + iconNm: string; + children: Menu[]; + urlPath: string; + menuLnkgTypeCd: string; + menuLnkgSn: number; + qrcd: string; + useYn: string; + otptYn: string; + requiredUrlPath: boolean; +} + +export const menuFormSchema = z.object({ + siteId: z.string(), + menuKornNm: z.string(), + menuSubKornNm: z.string().nullish(), + menuEngNm: z.string().nullish(), + menuSubEngNm: z.string().nullish(), + levelNo: z.number(), + urlPath: z.string().nullish(), + iconNm: z.string().nullish(), + menuLnkgTypeCd: z.enum([ + MENU_TYPE.EMPTY, + MENU_TYPE.CONTENTS, + MENU_TYPE.BOARD, + MENU_TYPE.INSIDE, + MENU_TYPE.OUTSIDE, + MENU_TYPE.BOTTOM, + ]), + menuLnkgSn: z.number().nullish(), + otptYn: z.enum(["Y", "N"]).default("Y").nullish(), + prntMenuCd: z.number().nullish(), + sortSeq: z.number(), + evlUseYn: z.enum(["Y", "N"]).nullish(), + qrcd: z.string().nullish(), + useYn: z.enum(["Y", "N"]).default("Y").nullish(), +}); + +export type MenuFormData = z.infer; + +export interface MenuService { + getTreeMenuList: (siteId: string) => Promise; + modifyTreeMenu: (siteId: string, data: TreeMenu[]) => Promise; + modifyMenuName: (menuCd: number, name: string) => Promise; +} diff --git a/apps/cs/src/pages/menu/ui/base-bottom-menu-form.tsx b/apps/cs/src/pages/menu/ui/base-bottom-menu-form.tsx new file mode 100644 index 000000000..d8afc344d --- /dev/null +++ b/apps/cs/src/pages/menu/ui/base-bottom-menu-form.tsx @@ -0,0 +1,130 @@ +import { type UseFormReturn } from "react-hook-form"; +import { + IconFinder, + Input, + Label, + RadioGroup, + RadioGroupItem, + TableCell, + TableHead, + TableRow, +} from "@common/components"; +import { + FormItem, + FormControl, + FormField, + FormMessage, +} from "@/shared/ui/form"; +import type { MenuFormData } from "../model/menu-interface"; + +export function BaseBottomMenuForm({ + form, +}: { + form: UseFormReturn; +}) { + const handleIconClick = (icon: string) => { + form.setValue("iconNm", icon); + }; + + return ( + <> + + 메뉴 서브명 + + ( + + + + + + + )} + /> + + + + 메뉴 아이콘 + +
+
+ {form.watch("iconNm")} +
+ +
+
+
+ + 출력 여부 + + ( + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+ )} + /> +
+
+ + 사용 여부 + + ( + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+ )} + /> +
+
+ + ); +} diff --git a/apps/cs/src/pages/menu/ui/base-menu-form.tsx b/apps/cs/src/pages/menu/ui/base-menu-form.tsx new file mode 100644 index 000000000..9af6dd6f1 --- /dev/null +++ b/apps/cs/src/pages/menu/ui/base-menu-form.tsx @@ -0,0 +1,130 @@ +import { type UseFormReturn } from "react-hook-form"; +import { + Input, + Label, + RadioGroup, + RadioGroupItem, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@common/components"; +import { + FormItem, + FormControl, + FormField, + FormMessage, +} from "@/shared/ui/form"; +import { MENU_TYPE, type MenuFormData } from "../model/menu-interface"; + +interface BaseMenuFormProps { + form: UseFormReturn; + menuId?: number; +} + +export function BaseMenuForm({ form, menuId }: BaseMenuFormProps) { + return ( + + + + + + + + 메뉴ID + +
+ {menuId} +
+
+
+ + 메뉴명 + + ( + + + + + + + )} + /> + + + + 메뉴 영문명 + + ( + + + + + + + )} + /> + + + + 메뉴 유형 + + ( + + + +
+ {Object.entries(MENU_TYPE).map(([key, value]) => ( +
+ + +
+ ))} +
+
+
+ +
+ )} + /> +
+
+
+
+ ); +} diff --git a/apps/cs/src/pages/menu/ui/board-menu-form.tsx b/apps/cs/src/pages/menu/ui/board-menu-form.tsx new file mode 100644 index 000000000..a63fe24ff --- /dev/null +++ b/apps/cs/src/pages/menu/ui/board-menu-form.tsx @@ -0,0 +1,144 @@ +import { type UseFormReturn } from "react-hook-form"; +import { + Label, + RadioGroup, + RadioGroupItem, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@common/components"; +import { + FormItem, + FormControl, + FormField, + FormMessage, +} from "@/shared/ui/form"; +import type { MenuFormData } from "../model/menu-interface"; +import { BaseBottomMenuForm } from "./base-bottom-menu-form"; + +export function BoardMenuForm({ form }: { form: UseFormReturn }) { + return ( + + + + + + + + 게시판 + +
+ {form.watch("menuLnkgSn")} +
+
+
+ + 연결 형태 + + ( + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+ )} + /> +
+
+ + 메뉴 평가 사용 여부 + + ( + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+ )} + /> +
+
+ + + + + QR코드 + + ( + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+ )} + /> +
+
+ + 담당자 + +
+ {form.watch("menuLnkgSn")} +
+
+
+
+
+ ); +} diff --git a/apps/cs/src/pages/menu/ui/bottom-menu-form.tsx b/apps/cs/src/pages/menu/ui/bottom-menu-form.tsx new file mode 100644 index 000000000..88199147b --- /dev/null +++ b/apps/cs/src/pages/menu/ui/bottom-menu-form.tsx @@ -0,0 +1,22 @@ +import { type UseFormReturn } from "react-hook-form"; +import { Table, TableBody } from "@common/components"; +import type { MenuFormData } from "../model/menu-interface"; +import { BaseBottomMenuForm } from "./base-bottom-menu-form"; + +export function BottomMenuForm({ + form, +}: { + form: UseFormReturn; +}) { + return ( + + + + + + + + +
+ ); +} diff --git a/apps/cs/src/pages/menu/ui/content-menu-form.tsx b/apps/cs/src/pages/menu/ui/content-menu-form.tsx new file mode 100644 index 000000000..65e806f25 --- /dev/null +++ b/apps/cs/src/pages/menu/ui/content-menu-form.tsx @@ -0,0 +1,148 @@ +import { type UseFormReturn } from "react-hook-form"; +import { + Label, + RadioGroup, + RadioGroupItem, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@common/components"; +import { + FormItem, + FormControl, + FormField, + FormMessage, +} from "@/shared/ui/form"; +import type { MenuFormData } from "../model/menu-interface"; +import { BaseBottomMenuForm } from "./base-bottom-menu-form"; + +export function ContentMenuForm({ + form, +}: { + form: UseFormReturn; +}) { + return ( + + + + + + + + 콘텐츠 + +
+ {form.watch("menuLnkgSn")} +
+
+
+ + 연결 형태 + + ( + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+ )} + /> +
+
+ + 메뉴 평가 사용 여부 + + ( + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+ )} + /> +
+
+ + + + + QR코드 + + ( + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+ )} + /> +
+
+ + 담당자 + +
+ {form.watch("menuLnkgSn")} +
+
+
+
+
+ ); +} diff --git a/apps/cs/src/pages/menu/ui/empty-menu-form.tsx b/apps/cs/src/pages/menu/ui/empty-menu-form.tsx new file mode 100644 index 000000000..1cdaaed97 --- /dev/null +++ b/apps/cs/src/pages/menu/ui/empty-menu-form.tsx @@ -0,0 +1,18 @@ +import { type UseFormReturn } from "react-hook-form"; +import { Table, TableBody } from "@common/components"; +import type { MenuFormData } from "../model/menu-interface"; +import { BaseBottomMenuForm } from "./base-bottom-menu-form"; + +export function EmptyMenuForm({ form }: { form: UseFormReturn }) { + return ( + + + + + + + + +
+ ); +} diff --git a/apps/cs/src/pages/menu/ui/inside-menu-form.tsx b/apps/cs/src/pages/menu/ui/inside-menu-form.tsx new file mode 100644 index 000000000..8e63a2615 --- /dev/null +++ b/apps/cs/src/pages/menu/ui/inside-menu-form.tsx @@ -0,0 +1,22 @@ +import { type UseFormReturn } from "react-hook-form"; +import { Table, TableBody } from "@common/components"; +import type { MenuFormData } from "../model/menu-interface"; +import { BaseBottomMenuForm } from "./base-bottom-menu-form"; + +export function InsideMenuForm({ + form, +}: { + form: UseFormReturn; +}) { + return ( + + + + + + + + +
+ ); +} diff --git a/apps/cs/src/pages/menu/ui/menu-form.tsx b/apps/cs/src/pages/menu/ui/menu-form.tsx new file mode 100644 index 000000000..894927ba0 --- /dev/null +++ b/apps/cs/src/pages/menu/ui/menu-form.tsx @@ -0,0 +1,111 @@ +import { useState } from "react"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { type UseFormReturn } from "react-hook-form"; +import { Button } from "@common/components"; +import { Form } from "@/shared/ui/form"; +import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; +import { MENU_TYPE, type MenuFormData } from "../model/menu-interface"; +import { BaseMenuForm } from "./base-menu-form"; +import { EmptyMenuForm } from "./empty-menu-form"; +import { ContentMenuForm } from "./content-menu-form"; +import { BoardMenuForm } from "./board-menu-form"; +import { InsideMenuForm } from "./inside-menu-form"; +import { OutsideMenuForm } from "./outside-menu-form"; +import { BottomMenuForm } from "./bottom-menu-form"; + +interface MenuFormProps { + form: UseFormReturn; + menuId?: number; + handleSave: (data: MenuFormData) => Promise; + handleDelete?: () => Promise; +} + +export function MenuForm({ + form, + menuId, + handleSave, + handleDelete, +}: MenuFormProps) { + const isModifyMode = !!menuId; + const { confirm } = useConfirm(); + const [onConfirm, setOnConfirm] = useState(false); + + const onSubmit = async (data: MenuFormData) => { + const confirmMessage = isModifyMode + ? "수정하시겠습니까?" + : "저장하시겠습니까?"; + + setOnConfirm(true); + if (await confirm(confirmMessage)) { + setOnConfirm(false); + handleSave(data); + } + setOnConfirm(false); + }; + + const onDelete = async () => { + setOnConfirm(true); + if (await confirm("삭제하시겠습니까?")) { + setOnConfirm(false); + handleDelete?.(); + } + setOnConfirm(false); + }; + + const menuType = form.watch("menuLnkgTypeCd"); + + return ( + <> +
+ +
+
+

+ 메뉴 등록/수정 +

+
+
+ +
+ {renderMenuTypeComponent(menuType, form)} +
+
+
+
+ + {isModifyMode && ( + + )} +
+
+ + + + ); +} + +function renderMenuTypeComponent( + menuType: string, + form: UseFormReturn, +) { + switch (menuType) { + case MENU_TYPE.EMPTY: + return ; + case MENU_TYPE.CONTENTS: + return ; + case MENU_TYPE.BOARD: + return ; + case MENU_TYPE.INSIDE: + return ; + case MENU_TYPE.OUTSIDE: + return ; + case MENU_TYPE.BOTTOM: + return ; + default: + return null; + } +} diff --git a/apps/cs/src/pages/menu/ui/menu-node.tsx b/apps/cs/src/pages/menu/ui/menu-node.tsx new file mode 100644 index 000000000..f8a150633 --- /dev/null +++ b/apps/cs/src/pages/menu/ui/menu-node.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import type { NodeModel } from "@minoru/react-dnd-treeview"; +import { cn } from "@common/components/lib"; +import { Button, Input } from "@common/components/ui"; +import type { Menu } from "../model/menu-interface"; +import { MenuApi } from "../api/menu-service"; + +type MenuNodeProps = { + node: NodeModel; + depth: number; + isOpen: boolean; + isSelected: boolean; + onToggle: (id: NodeModel["id"]) => void; + onSelect: (node: NodeModel) => void; +}; + +function MenuNode({ + node, + depth, + isOpen, + isSelected, + onToggle, + onSelect, +}: MenuNodeProps) { + const { id, text, data } = node; + const indent = depth * 10 + 5; + + const { mutateAsync: modifyMenuName } = useMutation({ + mutationFn: (nodeName: string) => { + const encodedName = encodeURIComponent(nodeName); + return MenuApi.modifyMenuName(id as number, encodedName); + }, + }); + + const [editMode, setEditMode] = useState(false); + const [nodeName, setNodeName] = useState(text); + + const handleToggle = () => onToggle(id); + const handleSelect = () => onSelect(node); + + const handleSave = async () => { + await modifyMenuName(nodeName); + setEditMode(false); + }; + + return ( +
+
+ {editMode ? ( + <> + setNodeName(e.target.value)} + className="h-[2.5rem] !text-[1.3rem] text-foreground" + /> + + ) : ( + <> + {data?.children?.length && ( + + )} + {text} + + )} +
+ {editMode ? ( +
+ + +
+ ) : ( + + )} +
+ ); +} + +export { MenuNode }; diff --git a/apps/cs/src/pages/menu/ui/menu-page.tsx b/apps/cs/src/pages/menu/ui/menu-page.tsx new file mode 100644 index 000000000..5cc63b627 --- /dev/null +++ b/apps/cs/src/pages/menu/ui/menu-page.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { useQuery } from "@tanstack/react-query"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Button, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components/ui"; +import { SiteApi } from "@/entities/site"; +import { ENDPOINT } from "@/shared/config"; +import { PageLayout } from "@/shared/ui/page-layout"; +import { MenuApi } from "../api/menu-service"; +import { + MENU_TYPE, + menuFormSchema, + type MenuFormData, +} from "../model/menu-interface"; +import { MenuTree } from "./menu-tree"; +import { MenuForm } from "./menu-form"; +import { generateTreeData } from "../lib/utils"; + +function MenuPage() { + const [siteId, setSiteId] = useState(""); + + const { data: treeData } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.MENUS, siteId], + queryFn: () => MenuApi.getTreeMenuList(siteId), + select: (data) => generateTreeData(data), + enabled: !!siteId, + }); + + const { data: options } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.SITES], + queryFn: () => SiteApi.getSiteList({}), + select: (data) => + data.content.map((site) => ({ + label: site.siteNm, + value: site.siteId, + })), + }); + + const form = useForm({ + resolver: zodResolver(menuFormSchema), + defaultValues: { + menuLnkgTypeCd: MENU_TYPE.EMPTY, + }, + }); + + const handleSave = async (data: MenuFormData) => { + console.log(data); + }; + + const handleDelete = async () => {}; + + return ( + +
+
+ + {treeData && ( + <> +
+ + +
+
+ +
+ + )} +
+
+ +
+
+
+ ); +} + +export { MenuPage }; diff --git a/apps/cs/src/pages/menu/ui/menu-tree.tsx b/apps/cs/src/pages/menu/ui/menu-tree.tsx new file mode 100644 index 000000000..629dd1252 --- /dev/null +++ b/apps/cs/src/pages/menu/ui/menu-tree.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useState } from "react"; +import { DndProvider } from "react-dnd"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + Tree, + MultiBackend, + getBackendOptions, + type NodeModel, +} from "@minoru/react-dnd-treeview"; +import { ENDPOINT } from "@/shared/config"; +import { MenuApi } from "../api/menu-service"; +import type { Menu, TreeMenu } from "../model/menu-interface"; +import { handleDropNode, handleSelectNode } from "../lib/utils"; +import { MenuNode } from "./menu-node"; + +type MenuTreeProps = { + treeData: NodeModel[]; + siteId: string; +}; + +function MenuTree({ treeData, siteId }: MenuTreeProps) { + const queryClient = useQueryClient(); + + const { mutate: modifyTreeMenu } = useMutation({ + mutationFn: (newTree: TreeMenu) => + MenuApi.modifyTreeMenu(siteId, [newTree]), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.CMS_SERVICE.MENUS], + }); + }, + }); + + const [selectedNode, setSelectedNode] = useState | null>( + null, + ); + + return ( + + , { depth, isOpen, onToggle }) => ( + + )} + onDrop={handleDropNode(modifyTreeMenu)} + classes={{ + root: "p-4", + container: "flex flex-col", + dropTarget: "!bg-[rgba(32,148,250,0.5)] rounded-sm", + placeholder: "relative", + }} + sort={false} + dropTargetOffset={5} + insertDroppableFirst={false} + canDrop={(_, { dragSource, dropTargetId }) => { + if (dragSource?.parent === dropTargetId) { + return true; + } + }} + placeholderRender={(_, { depth }) => { + return ( +
+ ); + }} + /> + + ); +} + +export { MenuTree }; diff --git a/apps/cs/src/pages/menu/ui/outside-menu-form.tsx b/apps/cs/src/pages/menu/ui/outside-menu-form.tsx new file mode 100644 index 000000000..b92977a83 --- /dev/null +++ b/apps/cs/src/pages/menu/ui/outside-menu-form.tsx @@ -0,0 +1,22 @@ +import { type UseFormReturn } from "react-hook-form"; +import { Table, TableBody } from "@common/components"; +import type { MenuFormData } from "../model/menu-interface"; +import { BaseBottomMenuForm } from "./base-bottom-menu-form"; + +export function OutsideMenuForm({ + form, +}: { + form: UseFormReturn; +}) { + return ( + + + + + + + + +
+ ); +} diff --git a/apps/cs/src/pages/site/api/domain-service.ts b/apps/cs/src/pages/site/api/domain-service.ts new file mode 100644 index 000000000..7cc36363f --- /dev/null +++ b/apps/cs/src/pages/site/api/domain-service.ts @@ -0,0 +1,28 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + Domain, + DomainService, + DomainList, + IDomainParams, +} from "../model/domain-interface"; + +export class DomainApiService implements DomainService { + public async getDomainList(params: IDomainParams): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.DOMAINS}`, + { params }, + ); + return response.data; + } + + public async saveDomainList(body: Domain[]): Promise { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.DOMAINS}`, + body, + ); + return response.data; + } +} + +export const DomainApi = new DomainApiService(); diff --git a/apps/cs/src/pages/site/api/ip-service.ts b/apps/cs/src/pages/site/api/ip-service.ts new file mode 100644 index 000000000..748676b21 --- /dev/null +++ b/apps/cs/src/pages/site/api/ip-service.ts @@ -0,0 +1,22 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { IIpParams, IP, IpList, IpService } from "../model/ip-interface"; + +export class IpApiService implements IpService { + public async getIpList(params: IIpParams): Promise { + const response = await client.get(`${ENDPOINT.CMS_SERVICE.IPS}`, { + params, + }); + return response.data; + } + + public async saveIpList(body: IP[]): Promise { + const response = await client.post( + `${ENDPOINT.CMS_SERVICE.IPS}`, + body, + ); + return response.data; + } +} + +export const IpApi = new IpApiService(); diff --git a/apps/cs/src/pages/site/model/domain-interface.ts b/apps/cs/src/pages/site/model/domain-interface.ts new file mode 100644 index 000000000..5f1076275 --- /dev/null +++ b/apps/cs/src/pages/site/model/domain-interface.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; +import type { IPageableParams, PageableList } from "@/shared/model"; + +export interface IDomainParams extends IPageableParams { + keywordType?: string; + keyword?: string; + siteId?: string; + useYn?: string; +} + +export const domainSchema = z.object({ + siteDmnNo: z.number().nullish(), + siteId: z.string(), + dmnAddr: z.string().nonempty(), + useYn: z.string(), + rprsDmnYn: z.string(), + mode: z.string(), +}); + +export const domainFormSchema = z.object({ + domains: z.array(domainSchema), +}); + +export type Domain = z.infer; +export type DomainFormData = z.infer; +export type DomainList = PageableList; + +export interface DomainService { + getDomainList(params: IDomainParams): Promise; + saveDomainList(body: Domain[]): Promise; +} diff --git a/apps/cs/src/pages/site/model/ip-interface.ts b/apps/cs/src/pages/site/model/ip-interface.ts new file mode 100644 index 000000000..db1d393f1 --- /dev/null +++ b/apps/cs/src/pages/site/model/ip-interface.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; +import type { IPageableParams, PageableList } from "@/shared/model"; + +export interface IIpParams extends IPageableParams { + siteId: string; + ipAddr?: string; + prmYn?: string; +} + +export const ipSchema = z.object({ + mngNo: z.number().nullish(), + siteId: z.string(), + ipAddr: z.string().nonempty(), + prmYn: z.string(), + mode: z.string(), +}); + +export const ipFormSchema = z.object({ + ips: z.array(ipSchema), +}); + +export type IP = z.infer; +export type IPFormData = z.infer; +export type IpList = PageableList; + +export interface IpService { + getIpList(params: IIpParams): Promise; + saveIpList(body: IP[]): Promise; +} diff --git a/apps/cs/src/pages/site/model/site-interface.ts b/apps/cs/src/pages/site/model/site-interface.ts deleted file mode 100644 index 4d03e2c8b..000000000 --- a/apps/cs/src/pages/site/model/site-interface.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { z } from "zod"; - -export interface ISiteParams { - siteNm: string; - siteKndCd: string; - useYn: string; - page: number; - size: number; - sort: string[]; -} - -export interface ISite { - siteId: string; - siteNm: string; - siteKindCode: string; - bscSiteYn: string; - siteAddr: string; - useYn: string; - regDt: string; - rgTrNm: string; -} - -export interface ISiteInfo { - totalPages: number; - totalElements: number; - number: number; - sort: { - empty: boolean; - sorted: boolean; - unsorted: boolean; - }; - size: number; - content: ISite[]; - pageable: { - sort: { - empty: boolean; - sorted: boolean; - unsorted: boolean; - }; - offset: number; - pageNumber: number; - pageSize: number; - paged: boolean; - unpaged: boolean; - }; - numberOfElements: number; - first: boolean; - last: boolean; - empty: boolean; -} - -export interface ISiteDetail 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).optional(), - siteKndCd: z.string().max(10).nonempty(), - siteAddr: z.string().max(2000).optional(), - faxNo: z.string().max(100).optional(), - telNo: z.string().max(1000).optional(), - lwndCn: z.string().max(2000).optional(), - bscSiteYn: z.string(), - bkmkIcon: z.string().max(100).optional(), - useYn: z.string(), - siteSkn: z.string().max(10).optional(), -}); - -export type SiteFormData = z.infer; - -export interface SiteService { - siteInfo(query: ISiteParams): Promise; - siteDetail(siteId: string): Promise; - modifySite(siteId: string, data: SiteFormData): Promise; - createSite(data: SiteFormData): Promise; -} diff --git a/apps/cs/src/pages/site/model/table-columns.tsx b/apps/cs/src/pages/site/model/table-columns.tsx index 35c084896..2a07ce4b1 100644 --- a/apps/cs/src/pages/site/model/table-columns.tsx +++ b/apps/cs/src/pages/site/model/table-columns.tsx @@ -1,6 +1,6 @@ import dayjs from "dayjs"; import { createColumnHelper } from "@tanstack/react-table"; -import type { ISite } from "./site-interface"; +import type { ISite } from "@/entities/site"; const columnHelper = createColumnHelper(); @@ -17,8 +17,8 @@ const columns = [ id: "siteNm", header: "사이트명", }), - columnHelper.accessor("siteAddr", { - id: "siteAddr", + columnHelper.accessor("dmnAddr", { + id: "dmnAddr", header: "대표 URL", }), columnHelper.accessor("bscSiteYn", { diff --git a/apps/cs/src/pages/site/ui/create-page.tsx b/apps/cs/src/pages/site/ui/create-page.tsx index a79c16022..66a9c63c4 100644 --- a/apps/cs/src/pages/site/ui/create-page.tsx +++ b/apps/cs/src/pages/site/ui/create-page.tsx @@ -1,53 +1,46 @@ "use client"; -import { useState } from "react"; import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; import { useMutation } from "@tanstack/react-query"; import { zodResolver } from "@hookform/resolvers/zod"; -import { SiteApi } from "../api/site-service"; -import { siteFormSchema, type SiteFormData } from "../model/site-interface"; -import { SitePageLayout } from "./layout"; +import { SiteApi, siteFormSchema, type SiteFormData } from "@/entities/site"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; +import { PageLayout } from "@/shared/ui/page-layout"; import { SiteForm } from "./site-form"; -import { - Button, - Dialog, - DialogClose, - DialogContent, - DialogFooter, -} from "@common/components/ui"; function CreatePage() { const router = useRouter(); + const { setMessage: alert } = useAlertStore((state) => state); const { mutateAsync: createSite } = useMutation({ mutationFn: (data: SiteFormData) => SiteApi.createSite(data), onSuccess: () => { - setIsCreateSuccess(true); + alert("등록되었습니다.", router.back); }, }); - const [isCreateSuccess, setIsCreateSuccess] = useState(false); - const form = useForm({ resolver: zodResolver(siteFormSchema), + defaultValues: { + siteId: "", + siteNm: "", + siteExpln: "", + siteKndCd: "", + siteAddr: "", + faxNo: "", + telNo: "", + lwndCn: "", + bscSiteYn: "", + bkmkIcon: "", + useYn: "", + siteSkn: "", + }, }); return ( - + - router.back()}> - -
-

등록되었습니다.

-
- - - - - -
-
-
+ ); } diff --git a/apps/cs/src/pages/site/ui/domain-management-dialog.tsx b/apps/cs/src/pages/site/ui/domain-management-dialog.tsx index c675f92d9..4a7e854ee 100644 --- a/apps/cs/src/pages/site/ui/domain-management-dialog.tsx +++ b/apps/cs/src/pages/site/ui/domain-management-dialog.tsx @@ -1,4 +1,8 @@ import { useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { useForm, useFieldArray } from "react-hook-form"; import { Dialog, DialogTrigger, @@ -13,197 +17,304 @@ import { TableHeader, TableRow, Input, - Checkbox, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + RadioGroup, + RadioGroupItem, DialogFooter, DialogClose, + Checkbox, } from "@common/components"; - -export type Domain = { - id: number; - domainUrl: string; - isRepresentative: boolean; - isActive: "true" | "false"; -}; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/shared/ui/form"; +import { ENDPOINT } from "@/shared/config"; +import { DomainApi } from "../api/domain-service"; +import { + domainFormSchema, + type Domain, + type DomainFormData, +} from "../model/domain-interface"; +import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; type DomainManagementDialogProps = { - triggerDisabled?: boolean; - handleSave: (rows: Domain[]) => void; + siteId: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; + handleSaveDomain?: (rows: Domain[]) => void; }; function DomainManagementDialog({ - triggerDisabled, - handleSave, + siteId, + handleSaveDomain, + ...props }: DomainManagementDialogProps) { - const [rows, setRows] = useState([]); - const [rowSelection, setRowSelection] = useState([]); + const { confirm } = useConfirm(); + const { setMessage: alert } = useAlertStore((state) => state); - const onSave = () => { - handleSave(rows); + const { data: domainList } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.DOMAINS, props.open], + queryFn: () => DomainApi.getDomainList({ siteId }), + select: (data) => { + const domains = data.content.map((item) => ({ + ...item, + mode: "U", + })); + + initialForm(); + return domains; + }, + }); + + const { mutateAsync } = useMutation({ + mutationFn: (body: Domain[]) => DomainApi.saveDomainList(body), + }); + + const form = useForm({ + resolver: zodResolver(domainFormSchema), + values: { + domains: domainList ?? [], + }, + }); + const { fields, append, remove, update } = useFieldArray({ + control: form.control, + name: "domains", + }); + + const [onConfirm, setOnConfirm] = useState(false); + const [selectedRows, setSelectedRows] = useState([]); + + const initialForm = () => { + form.reset(); + setSelectedRows([]); + }; + + const onSubmit = async (data: DomainFormData) => { + setOnConfirm(true); + if (await confirm("저장하시겠습니까?")) { + setOnConfirm(false); + await mutateAsync(data.domains); + handleSaveDomain?.(data.domains); + alert("저장되었습니다."); + } + setOnConfirm(false); }; const handleAddDomain = () => { - const newDomain: Domain = { - id: Date.now(), - domainUrl: "", - isRepresentative: rows.length === 0, - isActive: "true", - }; - setRows([...rows, newDomain]); - }; - - const handleDeleteDomain = () => { - const filteredRows = rows.filter((row) => !rowSelection.includes(row.id)); - const hasRepresentative = filteredRows.some((row) => row.isRepresentative); - - let updatedRows = filteredRows; - if (!hasRepresentative && filteredRows.length > 0) { - updatedRows = filteredRows.map((row, index) => - index === 0 ? { ...row, isRepresentative: true } : row, - ); - } - - setRows(updatedRows); - setRowSelection([]); - }; - - const handleDomainUrlChange = (id: number, value: string) => { - setRows( - rows.map((row) => (row.id === id ? { ...row, domainUrl: value } : row)), - ); - }; - - const handleRepresentativeChange = (id: number) => { - setRows( - rows.map((row) => ({ - ...row, - isRepresentative: row.id === id, - })), - ); - }; - - const handleActiveChange = (id: number, value: "true" | "false") => { - setRows( - rows.map((row) => (row.id === id ? { ...row, isActive: value } : row)), - ); - }; - - const toggleRowSelection = (id: number) => { - setRowSelection((prev) => { - if (prev.includes(id)) { - return prev.filter((item) => item !== id); - } else { - return [...prev, id]; - } + append({ + siteDmnNo: null, + siteId, + dmnAddr: "", + useYn: "Y", + rprsDmnYn: "N", + mode: "C", }); }; + const handleSelectAll = () => { + if (selectedRows.length === fields.length) { + setSelectedRows([]); + } else { + setSelectedRows(fields.map((_, index) => index)); + } + }; + + const handleSelectRow = (i: number) => { + setSelectedRows((prev) => + prev.includes(i) ? prev.filter((j) => j !== i) : [...prev, i], + ); + }; + + const handleDeleteDomain = async () => { + if (selectedRows.length === 0) return; + + const sortedIndices = [...selectedRows].sort((a, b) => b - a); + sortedIndices.forEach((i) => { + const field = fields[i]; + if (field?.siteDmnNo) { + update(i, { + ...field, + mode: "D", + }); + } else { + remove(i); + } + }); + + setSelectedRows([]); + }; + + const handleChangeRprsDmnYn = (i: number) => { + fields.forEach((_, j) => { + if (j === i) return; + form.setValue(`domains.${j}.rprsDmnYn`, "N"); + }); + }; + + const isAllSelected = + fields.length > 0 && selectedRows.length === fields.length; + return ( - - - - - - - 도메인 관리 - -
- - -
-
- - - - - - - - - - - NO - 선택 - 도메인URL - 대표URL - 사용여부 - - - - {rows.map((row, idx) => ( - - {idx + 1} - - toggleRowSelection(row.id)} - className="cursor-pointer" - /> - - - - handleDomainUrlChange(row.id, e.target.value) - } - className="!text-[1.3rem]" - /> - - - handleRepresentativeChange(row.id)} - className="cursor-pointer" - /> - - - - - - ))} - -
-
- - - - - - - - -
-
+ <> + + + + + + + 도메인 관리 + +
+ { + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(onSubmit)(e); + }} + > +
+ + +
+
+ + + + + + + + + + + + + 도메인 주소 + 대표 URL + 사용여부 + + + + {fields.map((field, i) => { + if (field.mode === "D") return null; + return ( + + + handleSelectRow(i)} + /> + + + ( + + + + + + + )} + /> + + + ( + + + { + field.onChange(checked ? "Y" : "N"); + handleChangeRprsDmnYn(i); + }} + /> + + + + )} + /> + + + ( + + + +
+ + +
+
+ + +
+
+
+ +
+ )} + /> +
+
+ ); + })} +
+
+
+ + + + + + +
+ +
+
+ + ); } diff --git a/apps/cs/src/pages/site/ui/ip-management-dialog.tsx b/apps/cs/src/pages/site/ui/ip-management-dialog.tsx index 06d9638b3..97721a6d6 100644 --- a/apps/cs/src/pages/site/ui/ip-management-dialog.tsx +++ b/apps/cs/src/pages/site/ui/ip-management-dialog.tsx @@ -1,4 +1,8 @@ import { useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { useForm, useFieldArray } from "react-hook-form"; import { Dialog, DialogTrigger, @@ -15,162 +19,266 @@ import { Input, RadioGroup, RadioGroupItem, - Checkbox, DialogFooter, DialogClose, + Checkbox, } from "@common/components"; - -type IP = { - id: number; - ipAddress: string; - allowed: boolean; -}; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/shared/ui/form"; +import { ENDPOINT } from "@/shared/config"; +import { IpApi } from "../api/ip-service"; +import { ipFormSchema, type IP, type IPFormData } from "../model/ip-interface"; +import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; type IPManagementDialogProps = { + siteId: string; triggerDisabled?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; }; -function IPManagementDialog({ triggerDisabled }: IPManagementDialogProps) { - const [rows, setRows] = useState([]); - const [rowSelection, setRowSelection] = useState([]); +function IPManagementDialog({ + siteId, + triggerDisabled, + ...props +}: IPManagementDialogProps) { + const { confirm } = useConfirm(); + const { setMessage: alert } = useAlertStore((state) => state); - const onSave = () => {}; + const { data: ipList } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.IPS, props.open], + queryFn: () => IpApi.getIpList({ siteId, sort: [] }), + select: (data) => { + const ips = data.content.map((item) => ({ + ...item, + mode: "U", + })); + + initialForm(); + return ips; + }, + }); + + const { mutateAsync } = useMutation({ + mutationFn: (body: IP[]) => IpApi.saveIpList(body), + }); + + const form = useForm({ + resolver: zodResolver(ipFormSchema), + values: { + ips: ipList ?? [], + }, + }); + const { fields, append, remove, update } = useFieldArray({ + control: form.control, + name: "ips", + }); + + const [onConfirm, setOnConfirm] = useState(false); + const [selectedRows, setSelectedRows] = useState([]); + + const initialForm = () => { + form.reset(); + setSelectedRows([]); + }; + + const onSubmit = async (data: IPFormData) => { + setOnConfirm(true); + if (await confirm("저장하시겠습니까?")) { + setOnConfirm(false); + await mutateAsync(data.ips); + alert("저장되었습니다."); + } + }; const handleAddIP = () => { - const newIP: IP = { - id: Date.now(), - ipAddress: "", - allowed: true, - }; - setRows([...rows, newIP]); - }; - - const handleDeleteIP = () => { - const filteredRows = rows.filter((row) => !rowSelection.includes(row.id)); - setRows(filteredRows); - setRowSelection([]); - }; - - const handleIPAddressChange = (id: number, value: string) => { - setRows( - rows.map((row) => (row.id === id ? { ...row, ipAddress: value } : row)), - ); - }; - - const handleAllowedChange = (id: number, value: boolean) => { - setRows( - rows.map((row) => (row.id === id ? { ...row, allowed: value } : row)), - ); - }; - - const toggleRowSelection = (id: number) => { - setRowSelection((prev) => { - if (prev.includes(id)) { - return prev.filter((item) => item !== id); - } else { - return [...prev, id]; - } + append({ + mngNo: null, + siteId, + ipAddr: "", + prmYn: "Y", + mode: "C", }); }; + const handleSelectAll = () => { + if (selectedRows.length === fields.length) { + setSelectedRows([]); + } else { + setSelectedRows(fields.map((_, index) => index)); + } + }; + + const handleSelectRow = (i: number) => { + setSelectedRows((prev) => + prev.includes(i) ? prev.filter((j) => j !== i) : [...prev, i], + ); + }; + + const handleDeleteIP = async () => { + if (selectedRows.length === 0) return; + + const sortedIndices = [...selectedRows].sort((a, b) => b - a); + sortedIndices.forEach((i) => { + const field = fields[i]; + if (field?.mngNo) { + update(i, { + ...field, + mode: "D", + }); + } else { + remove(i); + } + }); + + setSelectedRows([]); + }; + + const isAllSelected = + fields.length > 0 && selectedRows.length === fields.length; + return ( - - - - - - - IP 관리 - -
- - - -
-
- - - - - - - - - - NO - 선택 - IP 주소 - 허용여부 - - - - {rows.map((row, idx) => ( - - {idx + 1} - - toggleRowSelection(row.id)} - className="cursor-pointer" - /> - - - - handleIPAddressChange(row.id, e.target.value) - } - className="!text-[1.3rem]" - /> - - - - handleAllowedChange(row.id, value === "true") - } - className="flex justify-center" - > -
- - -
-
- - -
-
-
-
- ))} -
-
-
- - - - - - - - -
-
+ <> + + + + + + + IP 관리 + +
+ { + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(onSubmit)(e); + }} + > +
+ + +
+
+ + + + + + + + + + + + IP 주소 + 허용여부 + + + + {fields.map((field, i) => { + if (field.mode === "D") return null; + return ( + + + handleSelectRow(i)} + /> + + + ( + + + + + + + )} + /> + + + ( + + + +
+ + +
+
+ + +
+
+
+ +
+ )} + /> +
+
+ ); + })} +
+
+
+ + + + + + +
+ +
+
+ + ); } diff --git a/apps/cs/src/pages/site/ui/modify-page.tsx b/apps/cs/src/pages/site/ui/modify-page.tsx index 8c3bbd093..1f1061f9d 100644 --- a/apps/cs/src/pages/site/ui/modify-page.tsx +++ b/apps/cs/src/pages/site/ui/modify-page.tsx @@ -1,94 +1,76 @@ "use client"; -import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; -import { useMutation, useQuery } from "@tanstack/react-query"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { SiteApi, siteFormSchema, type SiteFormData } from "@/entities/site"; import { ENDPOINT } from "@/shared/config"; -import { SiteApi } from "../api/site-service"; -import { - siteFormSchema, - type ISiteDetail, - type SiteFormData, -} from "../model/site-interface"; -import { SitePageLayout } from "./layout"; +import { PageLayout } from "@/shared/ui/page-layout"; +import { useAlertStore } from "@/shared/lib/use-alert-store"; import { SiteForm } from "./site-form"; -import { - Button, - Dialog, - DialogClose, - DialogContent, - DialogFooter, -} from "@common/components/ui"; function ModifyPage({ siteId }: { siteId: string }) { const isModifyMode = !!siteId; const router = useRouter(); + const queryClient = useQueryClient(); + const { setMessage: alert } = useAlertStore((state) => state); + const { data: siteDetail } = useQuery({ queryKey: [ENDPOINT.CMS_SERVICE.SITES, siteId], queryFn: () => SiteApi.siteDetail(siteId), - select: (data) => ({ - ...data, - faxNo: data.faxNumber, - bscSiteYn: data.basicSiteYn, - }), + select: (data) => { + const keys = Object.keys(siteFormSchema.shape); + const filteredData = Object.fromEntries( + Object.entries(data).filter(([key]) => keys.includes(key)), + ); + return { + ...filteredData, + faxNo: data.faxNumber, + bscSiteYn: data.basicSiteYn, + }; + }, enabled: isModifyMode, }); const { mutateAsync: modifySite } = useMutation({ mutationFn: (data: SiteFormData) => SiteApi.modifySite(siteId, data), onSuccess: () => { - setMessage("수정되었습니다."); + alert("수정되었습니다.", () => { + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.CMS_SERVICE.SITES], + }); + }); }, }); const { mutateAsync: deleteSite } = useMutation({ mutationFn: () => SiteApi.deleteSite(siteId), onSuccess: () => { - setMessage("삭제되었습니다."); + alert("삭제되었습니다.", () => { + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.CMS_SERVICE.SITES], + }); + router.back(); + }); }, }); - const [message, setMessage] = useState(""); const form = useForm({ resolver: zodResolver(siteFormSchema), + values: siteDetail as SiteFormData, }); - useEffect(() => { - if (!siteDetail) return; - - const keys = Object.keys(siteFormSchema.shape); - for (const key of keys) { - form.setValue( - key as keyof SiteFormData, - siteDetail[key as keyof ISiteDetail] ?? "", - ); - } - }, [siteDetail]); - return ( - + - router.back()}> - -
-

{message}

-
- - - - - -
-
-
+ ); } diff --git a/apps/cs/src/pages/site/ui/site-form.tsx b/apps/cs/src/pages/site/ui/site-form.tsx index c554a868c..463b5a6fa 100644 --- a/apps/cs/src/pages/site/ui/site-form.tsx +++ b/apps/cs/src/pages/site/ui/site-form.tsx @@ -4,10 +4,6 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { type UseFormReturn } from "react-hook-form"; import { Button, - Dialog, - DialogClose, - DialogContent, - DialogFooter, Input, Label, RadioGroup, @@ -19,6 +15,7 @@ import { TableRow, } from "@common/components"; import { CodeSelect } from "@/entities/code"; +import type { SiteFormData } from "@/entities/site"; import { FormItem, FormControl, @@ -26,47 +23,54 @@ import { Form, FormMessage, } from "@/shared/ui/form"; -import { type ISiteDetail, type SiteFormData } from "../model/site-interface"; +import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; +import type { Domain } from "../model/domain-interface"; import { IPManagementDialog } from "./ip-management-dialog"; -import { - DomainManagementDialog, - type Domain, -} from "./domain-management-dialog"; +import { DomainManagementDialog } from "./domain-management-dialog"; type SiteFormProps = { form: UseFormReturn; siteId?: string; - handleSave: (data: SiteFormData) => void; - handleDelete?: () => void; + handleSave: (data: SiteFormData) => Promise; + handleDelete?: () => Promise; }; function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) { - const router = useRouter(); - const { message, confirm, onConfirm, onCancel } = useConfirm(); - - const [viewState, setViewState] = useState>({}); - const [representativeDomain, setRepresentativeDomain] = useState(""); - const isModifyMode = !!siteId; - const handleViewStateChange = (key: keyof ISiteDetail) => (value: string) => { - setViewState({ ...viewState, [key]: value }); - }; + const router = useRouter(); + const { confirm } = useConfirm(); + const [onConfirm, setOnConfirm] = useState(false); + const [isIpManagementOpen, setIsIpManagementOpen] = useState(false); + const [isDomainManagementOpen, setIsDomainManagementOpen] = useState(false); - const handleDomainSave = (rows: Domain[]) => { - setRepresentativeDomain( - rows.find((row) => row.isRepresentative)?.domainUrl || "", + const handleSaveDomain = (rows: Domain[]) => { + form.setValue( + "dmnAddr", + rows.find((row) => row.rprsDmnYn === "Y")?.dmnAddr || "", ); }; - const onSubmit = (data: SiteFormData) => { - handleSave(data); + const onSubmit = async (data: SiteFormData) => { + const confirmMessage = isModifyMode + ? "수정하시겠습니까?" + : "저장하시겠습니까?"; + + setOnConfirm(true); + if (await confirm(confirmMessage)) { + setOnConfirm(false); + handleSave(data); + } + setOnConfirm(false); }; const onDelete = async () => { - if (await confirm("정말 삭제하시겠습니까?")) { + setOnConfirm(true); + if (await confirm("삭제하시겠습니까?")) { + setOnConfirm(false); handleDelete?.(); } + setOnConfirm(false); }; return ( @@ -97,6 +101,7 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) { @@ -120,10 +125,7 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) { { - handleViewStateChange("siteKndCd")(value); - field.onChange(value); - }} + onValueChange={field.onChange} /> @@ -132,7 +134,10 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) { /> {isModifyMode && ( )}
@@ -149,6 +154,7 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) { @@ -170,6 +176,7 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) { @@ -191,6 +198,7 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) { @@ -207,10 +215,13 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) {
- {representativeDomain} + {form.watch("dmnAddr")}
@@ -227,6 +238,7 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) { @@ -248,6 +260,7 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) { @@ -269,6 +282,7 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) { @@ -290,6 +304,7 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) { @@ -311,6 +326,7 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) { @@ -405,25 +421,7 @@ function SiteForm({ form, siteId, handleSave, handleDelete }: SiteFormProps) { - - -
-

{message}

-
- - - - - - - - -
-
+ ); } diff --git a/apps/cs/src/pages/site/ui/site-page.tsx b/apps/cs/src/pages/site/ui/site-page.tsx index 6415b8d31..110b2e4eb 100644 --- a/apps/cs/src/pages/site/ui/site-page.tsx +++ b/apps/cs/src/pages/site/ui/site-page.tsx @@ -6,32 +6,26 @@ import { ENDPOINT } from "@/shared/config/api"; import { Button, DataTable, Input } from "@common/components"; import { useQuery } from "@tanstack/react-query"; import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; -import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { SiteApi } from "../api/site-service"; -import { type ISite, type ISiteParams } from "../model/site-interface"; +import { Button, DataTable, Input } from "@common/components"; +import { SiteApi, type ISite, type ISiteParams } from "@/entities/site"; +import { ENDPOINT } from "@/shared/config"; +import { PageLayout } from "@/shared/ui/page-layout"; import { columns } from "../model/table-columns"; -import { SitePageLayout } from "./layout"; function SitePage() { const router = useRouter(); - const { data: siteInfo, refetch } = useQuery({ + const { data: siteList, refetch } = useQuery({ queryKey: [ENDPOINT.CMS_SERVICE.SITES], - queryFn: () => SiteApi.siteInfo(siteQuery), + queryFn: () => SiteApi.getSiteList(siteQuery), enabled: false, }); const [siteQuery, setSiteQuery] = useState({ siteNm: "", - siteKndCd: "", - useYn: "", - page: 0, - size: 0, - sort: [], }); const table = useReactTable({ - data: siteInfo?.content ?? [], + data: siteList?.content ?? [], columns, getCoreRowModel: getCoreRowModel(), }); @@ -47,23 +41,20 @@ function SitePage() { useEffect(() => { refetch(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( - +
-
+
-
-

사이트 목록

+

사이트 목록

@@ -80,7 +71,7 @@ function SitePage() {
- + ); } diff --git a/apps/cs/src/shared/config/api.ts b/apps/cs/src/shared/config/api.ts index bab8cac7b..e07b6c9ff 100644 --- a/apps/cs/src/shared/config/api.ts +++ b/apps/cs/src/shared/config/api.ts @@ -11,6 +11,9 @@ export const ENDPOINT = { CMS_SERVICE: { SITES: "/cms-service/api/v1/sites", CODE: "/cms-service/api/v1/code/cache", + IPS: "/cms-service/api/v1/site-ips", + DOMAINS: "/cms-service/api/v1/site-dmns", + MENUS: "/cms-service/api/v1/menus", }, }; diff --git a/apps/cs/src/shared/lib/use-alert-store.ts b/apps/cs/src/shared/lib/use-alert-store.ts new file mode 100644 index 000000000..c21a00ed3 --- /dev/null +++ b/apps/cs/src/shared/lib/use-alert-store.ts @@ -0,0 +1,14 @@ +import { useStore } from "zustand"; +import { useContext } from "react"; +import type { AlertStore } from "../store/alert-store"; +import { StoreContext } from "../providers/store-provider"; + +export const useAlertStore = (selector: (store: AlertStore) => T): T => { + const storeContext = useContext(StoreContext); + + if (!storeContext) { + throw new Error("useAlertStore must be used within StoreProvider"); + } + + return useStore(storeContext.alertStore, selector); +}; diff --git a/apps/cs/src/shared/lib/use-user-store.ts b/apps/cs/src/shared/lib/use-user-store.ts index f4bf9763a..748caed4c 100644 --- a/apps/cs/src/shared/lib/use-user-store.ts +++ b/apps/cs/src/shared/lib/use-user-store.ts @@ -1,6 +1,6 @@ import { useStore } from "zustand"; import { useContext } from "react"; -import { type UserStore } from "@/shared/store/user-store"; +import type { UserStore } from "../store/user-store"; import { StoreContext } from "../providers/store-provider"; export const useUserStore = (selector: (store: UserStore) => T): T => { diff --git a/apps/cs/src/shared/model/api-interface.ts b/apps/cs/src/shared/model/api-interface.ts new file mode 100644 index 000000000..5aa4cd09b --- /dev/null +++ b/apps/cs/src/shared/model/api-interface.ts @@ -0,0 +1,34 @@ +interface SortConfig { + empty: boolean; + sorted: boolean; + unsorted: boolean; +} + +interface PageableConfig { + sort: SortConfig; + offset: number; + pageNumber: number; + pageSize: number; + paged: boolean; + unpaged: boolean; +} + +export interface PageableList { + content: T; + pageable: PageableConfig; + sort: SortConfig; + totalPages: number; + totalElements: number; + number: number; + numberOfElements: number; + size: number; + first: boolean; + last: boolean; + empty: boolean; +} + +export interface IPageableParams { + page?: number; + size?: number; + sort?: string[]; +} diff --git a/apps/cs/src/shared/model/index.ts b/apps/cs/src/shared/model/index.ts new file mode 100644 index 000000000..cf589d200 --- /dev/null +++ b/apps/cs/src/shared/model/index.ts @@ -0,0 +1 @@ +export * from "./api-interface"; diff --git a/apps/cs/src/shared/providers/index.ts b/apps/cs/src/shared/providers/index.ts index 5d9f096a3..f2e092170 100644 --- a/apps/cs/src/shared/providers/index.ts +++ b/apps/cs/src/shared/providers/index.ts @@ -1,2 +1 @@ -export { QueryProvider } from "./query-provider"; export { StoreProvider } from "./store-provider"; diff --git a/apps/cs/src/shared/providers/store-provider.tsx b/apps/cs/src/shared/providers/store-provider.tsx index ec913f235..4814705ab 100644 --- a/apps/cs/src/shared/providers/store-provider.tsx +++ b/apps/cs/src/shared/providers/store-provider.tsx @@ -1,9 +1,11 @@ "use client"; import { createContext, useRef } from "react"; -import { createUserStore } from "@/shared/store/user-store"; +import { createAlertStore } from "../store/alert-store"; +import { createUserStore } from "../store/user-store"; type StoreContextType = { + alertStore: ReturnType; userStore: ReturnType; }; @@ -15,6 +17,7 @@ export const StoreProvider = ({ children }: { children: React.ReactNode }) => { const storeRef = useRef(null); if (storeRef.current === null) { storeRef.current = { + alertStore: createAlertStore(), userStore: createUserStore(), }; } diff --git a/apps/cs/src/shared/store/alert-store.ts b/apps/cs/src/shared/store/alert-store.ts new file mode 100644 index 000000000..3bba65848 --- /dev/null +++ b/apps/cs/src/shared/store/alert-store.ts @@ -0,0 +1,25 @@ +import { createStore } from "zustand/vanilla"; + +export type AlertState = { + message: string; + callback?: () => void; +}; + +export type AlertActions = { + setMessage: (message: string, callback?: () => void) => void; +}; + +export type AlertStore = AlertState & AlertActions; + +export const defaultInitState: AlertState = { + message: "", + callback: undefined, +}; + +export const createAlertStore = (initState: AlertState = defaultInitState) => { + return createStore()((set) => ({ + ...initState, + setMessage: (message: string, callback?: () => void) => + set(() => ({ message, callback })), + })); +}; diff --git a/apps/cs/src/shared/ui/alert-dialog.tsx b/apps/cs/src/shared/ui/alert-dialog.tsx new file mode 100644 index 000000000..728276713 --- /dev/null +++ b/apps/cs/src/shared/ui/alert-dialog.tsx @@ -0,0 +1,32 @@ +import { + Dialog, + DialogContent, + DialogFooter, + DialogClose, + Button, +} from "@common/components/ui"; +import { useAlertStore } from "../lib/use-alert-store"; + +function AlertDialog() { + const { message, callback, setMessage } = useAlertStore((state) => state); + const handleOpenChange = () => { + setMessage(""); + callback?.(); + }; + return ( + + +
+

{message}

+
+ + + + + +
+
+ ); +} + +export { AlertDialog }; diff --git a/apps/cs/src/shared/ui/confirm-dialog.tsx b/apps/cs/src/shared/ui/confirm-dialog.tsx new file mode 100644 index 000000000..8f9ce2264 --- /dev/null +++ b/apps/cs/src/shared/ui/confirm-dialog.tsx @@ -0,0 +1,46 @@ +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogClose, + Button, + type ButtonProps, +} from "@common/components/ui"; + +type ConfirmDialogProps = { + open: boolean; + confirmButtonProps?: ButtonProps & { label?: string }; + cancelButtonProps?: ButtonProps & { label?: string }; +}; + +function ConfirmDialog({ + open, + confirmButtonProps, + cancelButtonProps, +}: ConfirmDialogProps) { + const { message, onConfirm, onCancel } = useConfirm(); + return ( + + +
+

{message}

+
+ + + + + + + + +
+
+ ); +} + +export { ConfirmDialog }; diff --git a/apps/cs/src/pages/site/ui/layout.tsx b/apps/cs/src/shared/ui/page-layout.tsx similarity index 56% rename from apps/cs/src/pages/site/ui/layout.tsx rename to apps/cs/src/shared/ui/page-layout.tsx index 3fec47e17..e4f11141b 100644 --- a/apps/cs/src/pages/site/ui/layout.tsx +++ b/apps/cs/src/shared/ui/page-layout.tsx @@ -1,12 +1,18 @@ -function SitePageLayout({ children }: { children: React.ReactNode }) { +function PageLayout({ + children, + pageTitle, +}: { + children: React.ReactNode; + pageTitle: string; +}) { return (

- 사이트 관리 + {pageTitle}

{children}
); } -export { SitePageLayout }; +export { PageLayout }; diff --git a/apps/cs/tailwind.config.ts b/apps/cs/tailwind.config.ts index 19ab5b282..9b4b42431 100644 --- a/apps/cs/tailwind.config.ts +++ b/apps/cs/tailwind.config.ts @@ -4,7 +4,6 @@ import presetConfig from "../../tailwind.config.preset"; export default { presets: [presetConfig], - darkMode: ["class", "[data-mode='dark']"], content: [ "./src/**/*.{js,ts,jsx,tsx,mdx}", "../../packages/common/components/src/ui/**/*.{js,ts,jsx,tsx,mdx}", diff --git a/apps/wr/app/reuseFaciltyManage/reuseFacilityInsert/publicReuseFaciltyInsert/page.tsx b/apps/wr/app/reuseFaciltyManage/reuseFacilityInsert/publicReuseFaciltyInsert/page.tsx new file mode 100644 index 000000000..57c48a84d --- /dev/null +++ b/apps/wr/app/reuseFaciltyManage/reuseFacilityInsert/publicReuseFaciltyInsert/page.tsx @@ -0,0 +1,52 @@ +'use client' + +import { ScrollArea, SearchDiv, SelectBox, UiTable } from '@common/business_components'; +import { Button } from '@common/components'; +import { PublicReuseFaciltyFormInsert } from '../../../../components/ui/publicReuseInsertForm'; +import { useWrStore } from '../../../store'; + + + + export default function PublicReuseFaciltyInsert() { + const {pubFac} = useWrStore((state) => state); + const { pubFacHeadList, publicReuseFacilityData} = pubFac; + const searchDiv = '하수재이용시설 ' + const selectBox = [{ text: '서울하수처리시설', val: "1_hasu" }, { text: '서울1하수처리시설', val: "2_hasu" }, { text: '서울2하수처리시설', val: "3_hasu" }] + const headName = () => { + return ( +

○공공 하수처리시설 등록

+ ) + } + const onSelectValue = (val: string) => { + console.log(val) + } + return ( + <> +
+
+

하수재이용시설 등록

+
+ +
+ +
+
+ + +
+
+
+
+
+ +
+
+ + + {/* */} + +
+
+ + ) +} diff --git a/packages/common/assets/src/styles/base.css b/packages/common/assets/src/styles/base.css index a99b29843..7fc103b43 100644 --- a/packages/common/assets/src/styles/base.css +++ b/packages/common/assets/src/styles/base.css @@ -45,7 +45,7 @@ --container: #f1f6f9; --title: #4c5e6f; } - [data-mode="dark"] { + [data-theme="black"] { --background: #0a0a0a; --foreground: #fafafa; --card: #0a0a0a; diff --git a/packages/common/assets/src/styles/components.css b/packages/common/assets/src/styles/components.css new file mode 100644 index 000000000..c1eb79210 --- /dev/null +++ b/packages/common/assets/src/styles/components.css @@ -0,0 +1,87 @@ +.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-left: -0.6rem; + margin-right: -0.6rem; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid #e1e7eb; + padding: 1rem; +} + +.card-footer { + margin-left: -0.6rem; + margin-right: -0.6rem; + margin-top: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + border-top: 1px solid #e1e7eb; + padding-top: 1rem; + padding-bottom: 1rem; +} + +/* 블랙 테마 */ +[data-theme="black"] { + .card-border { + border-color: #3f4042; + background-color: rgba(255, 255, 255, 0.065); + } + .card-header { + border-bottom-color: #3f4042; + } +} diff --git a/packages/common/assets/src/styles/index.ts b/packages/common/assets/src/styles/index.ts index 807e81006..8b6116d95 100644 --- a/packages/common/assets/src/styles/index.ts +++ b/packages/common/assets/src/styles/index.ts @@ -2,6 +2,7 @@ export const styles = { base: require("./base.css"), diveicon: require("./diveicon.css"), grid: require("./grid.css"), + components: require("./components.css"), } as const; export type StyleType = keyof typeof styles; diff --git a/packages/common/assets/tsup.config.ts b/packages/common/assets/tsup.config.ts index 450a1b173..59c4afaeb 100644 --- a/packages/common/assets/tsup.config.ts +++ b/packages/common/assets/tsup.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ "src/styles/base.css", "src/styles/diveicon.css", "src/styles/grid.css", + "src/styles/components.css", ], format: ["cjs", "esm"], dts: true, diff --git a/packages/common/business_components/src/base.css b/packages/common/business_components/src/base.css index 6b56f5eff..daaa92719 100644 --- a/packages/common/business_components/src/base.css +++ b/packages/common/business_components/src/base.css @@ -1,3 +1,7 @@ +@import "@common/assets/styles/base.css"; +@import "@common/assets/styles/diveicon.css"; +@import "@common/assets/styles/components.css"; + @tailwind base; @tailwind components; @tailwind utilities; @@ -70,4 +74,4 @@ body { @apply bg-background text-foreground; } -} +} \ No newline at end of file diff --git a/packages/common/business_components/src/ui/index.ts b/packages/common/business_components/src/ui/index.ts index 795fffa2a..430d5edd9 100644 --- a/packages/common/business_components/src/ui/index.ts +++ b/packages/common/business_components/src/ui/index.ts @@ -9,4 +9,9 @@ export * from "./search_input"; export * from "./search_input_select"; export * from "./select"; export * from "./table"; - +export * from "./main_content_div"; +export * from "./title"; +export * from "./search_form_left"; +export * from "./search_form_right"; +export * from "./table_div"; +export * from "./tab"; \ No newline at end of file diff --git a/packages/common/business_components/src/ui/main_content_div/index.tsx b/packages/common/business_components/src/ui/main_content_div/index.tsx new file mode 100644 index 000000000..f1f85fa9c --- /dev/null +++ b/packages/common/business_components/src/ui/main_content_div/index.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +export default function MainContentDiv(props: {children: React.ReactNode}) { + return ( +
+ {props.children} +
+ ) +} + +export { MainContentDiv }; \ No newline at end of file diff --git a/packages/common/business_components/src/ui/search_form/index.tsx b/packages/common/business_components/src/ui/search_form/index.tsx index 7c6024748..1f7950358 100644 --- a/packages/common/business_components/src/ui/search_form/index.tsx +++ b/packages/common/business_components/src/ui/search_form/index.tsx @@ -1,6 +1,6 @@ function SearchDiv ({children}:{ children: React.ReactNode; }) { return ( -
+
{children}
) diff --git a/packages/common/business_components/src/ui/search_form_left/index.tsx b/packages/common/business_components/src/ui/search_form_left/index.tsx new file mode 100644 index 000000000..950306231 --- /dev/null +++ b/packages/common/business_components/src/ui/search_form_left/index.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +function SearchFormLeft(props: {children: React.ReactNode}) { + return ( +
+ {props.children} +
+ ) +} + +export { SearchFormLeft }; \ No newline at end of file diff --git a/packages/common/business_components/src/ui/search_form_right/index.tsx b/packages/common/business_components/src/ui/search_form_right/index.tsx new file mode 100644 index 000000000..676b90849 --- /dev/null +++ b/packages/common/business_components/src/ui/search_form_right/index.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +function SearchFormRight(props: {children: React.ReactNode}) { + return ( +
+ {props.children} +
+ ) +} + +export { SearchFormRight }; \ No newline at end of file diff --git a/packages/common/business_components/src/ui/search_input/index.tsx b/packages/common/business_components/src/ui/search_input/index.tsx index 61ff8ec3f..9b38b475a 100644 --- a/packages/common/business_components/src/ui/search_input/index.tsx +++ b/packages/common/business_components/src/ui/search_input/index.tsx @@ -1,15 +1,16 @@ import { Input, Label } from "@common/components"; import React from "react"; -const SearchInput = React.forwardRef< - HTMLDivElement& HTMLInputElement, - React.HTMLAttributes & {className:string, label:string}> -(({className, label}, ref) => { +const SearchInput = React.forwardRefvoid}>(({className, label, textValue, setText}, ref) => { + const changeText=(e:React.ChangeEvent)=>{ + if(e?.target?.value) setText(e.target.value) + } + return ( -
- {label && } - +
+ {label && } +
) }); -export { SearchInput }; +export { SearchInput }; \ No newline at end of file diff --git a/packages/common/business_components/src/ui/search_input_select/index.tsx b/packages/common/business_components/src/ui/search_input_select/index.tsx index c6face023..5a386b77b 100644 --- a/packages/common/business_components/src/ui/search_input_select/index.tsx +++ b/packages/common/business_components/src/ui/search_input_select/index.tsx @@ -11,19 +11,23 @@ export type SelectDataType = { * @param label - string label * @param selectData - SelectDataType[] 선택 데이터 * @param onSelectValue - (val:string)=>void 선택 값 콜백 + * @param textValue - string 입력 값 + * @param setText - (val:string)=>void 입력 값 설정 콜백 + * @param selectValue - string select 선택 값 * @param refs - React.forwardRef | null 입력값 ref * @returns React.FC */ -const SearchInputSelect = React.forwardRefvoid, textValue:string, setText:(val:string)=>void}>(({className, label, selectData, onSelectValue, textValue, setText}) => { +const SearchInputSelect = React.forwardRefvoid, textValue:string, setText:(val:string)=>void, selectValue:string}>(({className, label, selectData, onSelectValue, textValue, setText, selectValue}, ref) => { const changeText=(e:React.ChangeEvent)=>{ if(e?.target?.value) setText(e.target.value) } + return (
- {label && } + {label && }
- - + +
+
+ {filteredIconNames.map((icon) => ( + + ))} +
+ + + ); +}; + +const IconItem = ({ + icon, + selectedIcon, + handleClick, +}: { + icon: string; + selectedIcon: string; + handleClick: (icon: string) => void; +}) => { + return ( +
handleClick(icon)} + > + + + {icon} +
+ ); +}; + +export { IconFinder }; diff --git a/packages/common/components/src/ui/index.ts b/packages/common/components/src/ui/index.ts index 438c18dbd..650eb30e1 100644 --- a/packages/common/components/src/ui/index.ts +++ b/packages/common/components/src/ui/index.ts @@ -7,8 +7,8 @@ export * from "./data-table"; export * from "./date-picker"; export * from "./dialog"; export * from "./dropdown-menu"; -export * from "./input"; export * from "./file-search"; +export * from "./icon-finder"; export * from "./input"; export * from "./label"; export * from "./layout"; diff --git a/packages/common/components/src/ui/input/index.tsx b/packages/common/components/src/ui/input/index.tsx index a980f0ebd..11cbca71d 100644 --- a/packages/common/components/src/ui/input/index.tsx +++ b/packages/common/components/src/ui/input/index.tsx @@ -3,15 +3,13 @@ import * as React from "react"; import { cn } from "../../lib/utils"; const Input = React.forwardRef>( - ({ className, type, value, ...props }, ref) => { + ({ className, ...props }, ref) => { return ( diff --git a/packages/common/components/src/ui/layout/base.tsx b/packages/common/components/src/ui/layout/base.tsx index 19b64511f..9dd778e03 100644 --- a/packages/common/components/src/ui/layout/base.tsx +++ b/packages/common/components/src/ui/layout/base.tsx @@ -1,28 +1,35 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { LNB } from "./lnb"; +import { QueryProvider } from "../../provider/query-provider"; +import { SidebarProvider } from "../sidebar"; +import { LNB } from "../lnb"; import { Header } from "../header"; import { Footer } from "./footer"; import "./common.css"; +import type { QueryClient } from "@tanstack/react-query"; -const queryClient = new QueryClient(); - -export function BaseLayout({ children }: { children: React.ReactNode }) { +export function BaseLayout({ + children, + queryClient, +}: { + children: React.ReactNode; + queryClient?: QueryClient; +}) { return ( - -
-
-
- -
-
- {children} + + +
+
+
+ +
+
+ {children} +
+
-
-
- + + ); } diff --git a/packages/common/components/src/ui/layout/lnb.tsx b/packages/common/components/src/ui/layout/lnb.tsx deleted file mode 100644 index f7d3a9be8..000000000 --- a/packages/common/components/src/ui/layout/lnb.tsx +++ /dev/null @@ -1,104 +0,0 @@ -export function LNB() { - return ( -