diff --git a/.env.development b/.env.development index 2c9662a..f961ed9 100644 --- a/.env.development +++ b/.env.development @@ -32,12 +32,12 @@ REALTIME_BACKEND_API_URL=http://localhost:3004 ADMIN_API_KEY=your_admin_api_key # Database -POSTGRES_HOST=fems-postgres +POSTGRES_HOST=61.251.18.72 POSTGRES_PORT=5432 # POSTGRES_DB=wacefems-database -POSTGRES_DB=postgres -POSTGRES_USER=wacefems_database_user -POSTGRES_PASSWORD=wacefems-pg-password-PPw09!keep +POSTGRES_DB=duckil +POSTGRES_USER=postgres +POSTGRES_PASSWORD=gplm # Optional Database Config POSTGRES_MAX_CONNECTIONS=100 diff --git a/fems-api/src/app.js b/fems-api/src/app.js index 7b0ef6b..f3cdc8e 100644 --- a/fems-api/src/app.js +++ b/fems-api/src/app.js @@ -44,12 +44,13 @@ const initializeServer = async () => { // 데이터베이스 연결 대기 await waitForDatabase(); - // 개발 환경에서만 데이터베이스 동기화 - if (process.env.NODE_ENV !== "production") { - await sequelize.sync({ force: true }); - logger.info("Database synchronized."); - await require("./utils/createInitialAdmin")(); - } + // // 개발 환경에서만 데이터베이스 동기화 + // if (process.env.NODE_ENV !== "production") { + // await sequelize.sync({ force: true }); + // // await sequelize.sync({ alter: true }); + // logger.info("Database synchronized."); + // await require("./utils/createInitialAdmin")(); + // } // 서버 시작 const port = config.port; diff --git a/fems-api/src/controllers/app/common/common.controller.js b/fems-api/src/controllers/app/common/common.controller.js new file mode 100644 index 0000000..8318a3c --- /dev/null +++ b/fems-api/src/controllers/app/common/common.controller.js @@ -0,0 +1,59 @@ +const express = require("express"); +const router = express.Router(); +const CommonService = require("../../../services/common.service"); + +//12.11 공통메뉴 (메뉴바용) +router.get("/menu", async (req, res, next) => { + try { + const menuList = await CommonService.getMenu(); + res.json({ + success: true, + data: menuList + }); + } catch (error) { + next(error); + } +}); + +//12.12 관리자용 전체메뉴 조회 +router.get("/admin/menu", async (req, res, next) => { + try { + const menuList = await CommonService.getAdminMenu(); + res.json({ + success: true, + data: menuList + }); + } catch (error) { + next(error); + } +}); + +//12.12 메뉴 생성/수정 +router.post("/createmenu", async (req, res, next) => { + try { + const menuData = req.body; + const result = await CommonService.saveMenu(menuData); + res.json({ + success: true, + data: result + }); + } catch (error) { + next(error); + } +}); + +//12.12 메뉴 삭제 +router.delete("/menu/:id", async (req, res, next) => { + try { + const { id } = req.params; + const result = await CommonService.deleteMenu(id); + res.json({ + success: true, + data: result + }); + } catch (error) { + next(error); + } +}); + +module.exports = router; \ No newline at end of file diff --git "a/fems-api/src/models/\bMenuInfo.js" "b/fems-api/src/models/\bMenuInfo.js" new file mode 100644 index 0000000..a80aa47 --- /dev/null +++ "b/fems-api/src/models/\bMenuInfo.js" @@ -0,0 +1,76 @@ +// models/MenuInfo.js + +const { Model, DataTypes } = require("sequelize"); + +class MenuInfo extends Model { + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + menu_type: { + type: DataTypes.NUMERIC, + allowNull: true, + }, + parent_id: { + type: DataTypes.UUID, + allowNull: true, + }, + menu_name_kor: { + type: DataTypes.STRING(64), + allowNull: true, + }, + menu_name_eng: { + type: DataTypes.STRING(64), + allowNull: true, + }, + seq: { + type: DataTypes.NUMERIC, + allowNull: true, + }, + menu_url: { + type: DataTypes.STRING(256), + allowNull: true, + }, + menu_desc: { + type: DataTypes.STRING(1024), + allowNull: true, + }, + writer: { + type: DataTypes.STRING(32), + allowNull: true, + }, + regdate: { + type: DataTypes.DATE, + allowNull: true, + }, + system_name: { + type: DataTypes.STRING(32), + allowNull: true, + }, + isActive: { + type: DataTypes.BOOLEAN, + defaultValue: true, + allowNull: true, + }, + }, + { + sequelize, + modelName: 'MenuInfo', + tableName: 'menu_info_2', + timestamps: true, + indexes: [ + { + fields: ['parent_id'], + }, + ], + } + ); + return this; + } +} + +module.exports = MenuInfo; \ No newline at end of file diff --git a/fems-api/src/models/Userinfo.js b/fems-api/src/models/Userinfo.js new file mode 100644 index 0000000..bffade0 --- /dev/null +++ b/fems-api/src/models/Userinfo.js @@ -0,0 +1,70 @@ +// src/models/Userinfo.js +const { Model, DataTypes } = require("sequelize"); +const bcrypt = require("bcryptjs"); + +class UserInfo extends Model { + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + username: { + type: DataTypes.STRING(50), + allowNull: false, + unique: true, + }, + password: { + type: DataTypes.STRING, + allowNull: false, + }, + name: { + type: DataTypes.STRING(50), + allowNull: false, + }, + email: { + type: DataTypes.STRING(100), + allowNull: false, + unique: true, + validate: { + isEmail: true, + }, + }, + phone: { + type: DataTypes.STRING(20), + allowNull: false, + }, + role: { + type: DataTypes.ENUM( + "super_admin", + "company_admin", + "branch_admin", + "user" + ), + defaultValue: "user", + }, + isActive: { + type: DataTypes.BOOLEAN, + defaultValue: true, + }, + lastLoginAt: { + type: DataTypes.DATE, + allowNull: true, + }, + }, + { + sequelize, + modelName: 'UserInfo', + tableName: 'user_info_2', + timestamps: false, + underscored: true + } + ); + + return this; + } +} + +module.exports = UserInfo; \ No newline at end of file diff --git a/fems-api/src/routes/app.js b/fems-api/src/routes/app.js index 323cc7e..4e572c6 100644 --- a/fems-api/src/routes/app.js +++ b/fems-api/src/routes/app.js @@ -16,6 +16,7 @@ const departmentController = require("../controllers/app/department/department.c const healthController = require("../controllers/app/health/health.controller"); const companiesController = require("../controllers/admin/companies/companies.controller"); const deviceController = require("../controllers/app/device/device.controller"); +const commonController = require("../controllers/app/common/common.controller"); router.use("/health", healthController); router.use("/auth", authController); @@ -31,5 +32,6 @@ router.use("/equipment-parts", equipmentPartsController); router.use("/department", departmentController); router.use("/companies", companiesController); router.use("/devices", deviceController); +router.use("/common", commonController); module.exports = router; diff --git a/fems-api/src/services/common.service.js b/fems-api/src/services/common.service.js new file mode 100644 index 0000000..b4240ee --- /dev/null +++ b/fems-api/src/services/common.service.js @@ -0,0 +1,132 @@ +const { + MenuInfo, +} = require("../models"); +const { Op } = require("sequelize"); + +// 메뉴바용 메뉴 조회 (활성화된 것만) +const getMenu = async () => { + try { + const allMenus = await MenuInfo.findAll({ + order: [['seq', 'ASC']], + raw: true + }); + const menuTree = buildMenuTree(allMenus); + return { success: true, data: menuTree }; + } catch (error) { + throw error; + } +}; + +// 메뉴바용 트리 빌더 (활성화된 것만) +const buildMenuTree = (menus, parentId = null) => { + return menus + .filter(menu => menu.parent_id === parentId && menu.isActive === true) + .map(menu => ({ + id: menu.id, + menu_type: menu.menu_type, + parent_id: menu.parent_id, + menu_name_kor: menu.menu_name_kor, + menu_name_eng: menu.menu_name_eng, + seq: menu.seq, + menu_url: menu.menu_url, + menu_desc: menu.menu_desc, + isActive: menu.isActive, + children: buildMenuTree(menus, menu.id) + })); +}; + +// 관리자용 전체 메뉴 조회 +const getAdminMenu = async () => { + try { + const allMenus = await MenuInfo.findAll({ + order: [['seq', 'ASC']], + raw: true + }); + const menuTree = buildAdminMenuTree(allMenus); + return { success: true, data: menuTree }; + } catch (error) { + throw error; + } +}; + +// 관리자용 트리 빌더 (전체 메뉴) +const buildAdminMenuTree = (menus, parentId = null) => { + return menus + .filter(menu => menu.parent_id === parentId) + .map(menu => ({ + id: menu.id, + menu_type: menu.menu_type, + parent_id: menu.parent_id, + menu_name_kor: menu.menu_name_kor, + menu_name_eng: menu.menu_name_eng, + seq: menu.seq, + menu_url: menu.menu_url, + menu_desc: menu.menu_desc, + isActive: menu.isActive, + children: buildAdminMenuTree(menus, menu.id) + })); +}; + +const saveMenu = async (menuData) => { + try { + if (menuData.id) { + // 수정 + const menu = await MenuInfo.update({ + menu_type: menuData.menu_type, + parent_id: menuData.parent_id, + menu_name_kor: menuData.menu_name_kor, + menu_name_eng: menuData.menu_name_eng, + seq: menuData.seq, + menu_url: menuData.menu_url, + isActive: menuData.isActive, + menu_desc: menuData.menu_desc || null, + writer: 'system', + }, { + where: { id: menuData.id } + }); + } else { + // 생성 + const menu = await MenuInfo.create({ + menu_type: menuData.menu_type, + parent_id: menuData.parent_id, + menu_name_kor: menuData.menu_name_kor, + menu_name_eng: menuData.menu_name_eng, + seq: menuData.seq, + menu_url: menuData.menu_url, + isActive: menuData.isActive, + menu_desc: menuData.menu_desc || null, + writer: 'system', + system_name: 'web', + }); + } + + return { + success: true, + data: menuData + }; + } catch (error) { + throw error; + } +}; + +const deleteMenu = async (id) => { + try { + const result = await MenuInfo.destroy({ + where: { id } + }); + + return { + success: true, + data: result + }; + } catch (error) { + throw error; + } +}; + +module.exports = { + getMenu, + getAdminMenu, + saveMenu, + deleteMenu +}; \ No newline at end of file diff --git a/fems-api/src/utils/initialSetup/organizationSetup.js b/fems-api/src/utils/initialSetup/organizationSetup.js index 399968e..d262a25 100644 --- a/fems-api/src/utils/initialSetup/organizationSetup.js +++ b/fems-api/src/utils/initialSetup/organizationSetup.js @@ -7,93 +7,93 @@ const { userDefinitions, } = require("./setupData"); -async function createDepartments(companyId, branchId) { - const departments = {}; +// async function createDepartments(companyId, branchId) { +// const departments = {}; - for (const division of departmentStructure) { - const parentDept = await Department.create({ - name: division.name, - companyId, - branchId, - isActive: true, - }); - departments[division.name] = parentDept.id; +// for (const division of departmentStructure) { +// const parentDept = await Department.create({ +// name: division.name, +// companyId, +// branchId, +// isActive: true, +// }); +// departments[division.name] = parentDept.id; - for (const team of division.children) { - const childDept = await Department.create({ - name: team.name, - companyId, - branchId, - parentId: parentDept.id, - isActive: true, - }); - departments[team.name] = childDept.id; - } - } +// for (const team of division.children) { +// const childDept = await Department.create({ +// name: team.name, +// companyId, +// branchId, +// parentId: parentDept.id, +// isActive: true, +// }); +// departments[team.name] = childDept.id; +// } +// } - logger.info("Departments created successfully"); - return departments; -} +// logger.info("Departments created successfully"); +// return departments; +// } -async function createRoles(companyId) { - const roles = {}; +// async function createRoles(companyId) { +// const roles = {}; - for (const roleDef of roleDefinitions) { - const role = await Role.create({ - ...roleDef, - companyId, - isActive: true, - }); - roles[roleDef.name] = role.id; - } +// for (const roleDef of roleDefinitions) { +// const role = await Role.create({ +// ...roleDef, +// companyId, +// isActive: true, +// }); +// roles[roleDef.name] = role.id; +// } - logger.info("Roles created successfully"); - return roles; -} +// logger.info("Roles created successfully"); +// return roles; +// } -async function createAllUsers(companyId, branchId, departments, roles) { - // 기본 관리자 계정 생성 - for (const adminData of userDefinitions.basicAdmins) { - const existingUser = await User.findOne({ - where: { username: adminData.username }, - }); +// async function createAllUsers(companyId, branchId, departments, roles) { +// // 기본 관리자 계정 생성 +// for (const adminData of userDefinitions.basicAdmins) { +// const existingUser = await User.findOne({ +// where: { username: adminData.username }, +// }); - if (!existingUser) { - await User.create({ - ...adminData, - isActive: true, - companyId, - branchId, - }); - logger.info(`Created ${adminData.role} user successfully`); - } - } +// if (!existingUser) { +// await User.create({ +// ...adminData, +// isActive: true, +// companyId, +// branchId, +// }); +// logger.info(`Created ${adminData.role} user successfully`); +// } +// } - // 부서별 테스트 사용자 생성 - for (const userDef of userDefinitions.departmentUsers) { - const existingUser = await User.findOne({ - where: { username: userDef.username }, - }); +// // 부서별 테스트 사용자 생성 +// for (const userDef of userDefinitions.departmentUsers) { +// const existingUser = await User.findOne({ +// where: { username: userDef.username }, +// }); - if (!existingUser) { - const user = await User.create({ - ...userDef, - isActive: true, - companyId, - branchId, - departmentId: departments[userDef.department], - }); +// if (!existingUser) { +// const user = await User.create({ +// ...userDef, +// isActive: true, +// companyId, +// branchId, +// departmentId: departments[userDef.department], +// }); - for (const roleName of userDef.roles) { - await UserRole.create({ - userId: user.id, - roleId: roles[roleName], - }); - } - logger.info(`Created department user ${userDef.name} successfully`); - } - } -} +// for (const roleName of userDef.roles) { +// await UserRole.create({ +// userId: user.id, +// roleId: roles[roleName], +// }); +// } +// logger.info(`Created department user ${userDef.name} successfully`); +// } +// } +// } module.exports = { createDepartments, diff --git a/fems-api/src/utils/initialSetup/setupData.js b/fems-api/src/utils/initialSetup/setupData.js index f070b30..da04249 100644 --- a/fems-api/src/utils/initialSetup/setupData.js +++ b/fems-api/src/utils/initialSetup/setupData.js @@ -78,6 +78,14 @@ const userDefinitions = { phone: "010-0000-0000", role: "super_admin", }, + { + username: "plm_admin", + password: "1", + name: "System Administrator", + email: "chpark@gdnsil.com", + phone: "010-6300-1473", + role: "super_admin", + }, { username: "company_admin", password: "Admin123!@#", diff --git a/fems-app/src/app/(admin)/common/menu/components/UserForm.tsx b/fems-app/src/app/(admin)/common/menu/components/UserForm.tsx new file mode 100644 index 0000000..e07aff1 --- /dev/null +++ b/fems-app/src/app/(admin)/common/menu/components/UserForm.tsx @@ -0,0 +1,369 @@ +import React from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +// import { Label } from "@/components/ui/label"; +import { + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { api } from "@/lib/api"; +import { useQuery } from "@tanstack/react-query"; +import { User, Role } from "@/types/user"; +import { useAuthStore } from "@/stores/auth"; +import { AxiosError } from "axios"; +import { useToast } from "@/hooks/use-toast"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; + +// Define the form schema with Zod +const formSchema = z.object({ + username: z.string().min(3, "아이디는 3자 이상이어야 합니다"), + password: z + .string() + .optional() + .refine((val) => !val || val.length >= 8, { + message: "비밀번호는 8자 이상이어야 합니다", + }), + name: z.string().min(2, "이름은 2자 이상이어야 합니다"), + email: z.string().email("올바른 이메일 형식이 아닙니다"), + phone: z.string().regex(/^[0-9-]+$/, "올바른 전화번호 형식이 아닙니다"), + role: z.enum(["company_admin", "branch_admin", "user"], { + required_error: "역할을 선택해주세요", + }), + roleId: z.string().uuid("올바른 권한 그룹을 선택해주세요"), + isActive: z.boolean(), + branchId: z.string().uuid("올바른 지점을 선택해주세요"), + departmentId: z.string().uuid("올바른 부서를 선택해주세요"), +}); + +type FormSchema = z.infer; + +interface UserFormProps { + initialData?: Partial; + onSubmit: (data: Partial) => void; + onCancel: () => void; +} + +export const UserForm: React.FC = ({ + initialData, + onSubmit, + onCancel, +}) => { + const { token, user } = useAuthStore(); + const { toast } = useToast(); + + // Initialize the form with react-hook-form and zod resolver + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + username: initialData?.username || "", + password: initialData?.password || "", + name: initialData?.name || "", + email: initialData?.email || "", + phone: initialData?.phone || "", + role: + (initialData?.role as "company_admin" | "branch_admin" | "user") || + "user", + roleId: initialData?.Roles?.[0]?.id || "", + isActive: initialData?.isActive || false, + branchId: initialData?.branchId || "", + departmentId: initialData?.departmentId || "", + }, + }); + + // Reset form when initialData changes + React.useEffect(() => { + if (initialData) { + form.reset({ + username: initialData.username || "", + password: initialData.password || "", + name: initialData.name || "", + email: initialData.email || "", + phone: initialData.phone || "", + role: + (initialData.role as "company_admin" | "branch_admin" | "user") || + "user", + roleId: initialData.Roles?.[0]?.id || "", + isActive: initialData.isActive || false, + branchId: initialData.branchId || "", + departmentId: initialData.departmentId || "", + }); + } + }, [initialData, form]); + + // Fetch available roles + const userRoles = [ + { value: "company_admin", label: "기업 관리자" }, + { value: "branch_admin", label: "지점 관리자" }, + { value: "user", label: "일반 유저" }, + ]; + + // Fetch branches + const { data: branches } = useQuery({ + queryKey: ["branches"], + queryFn: async () => { + const { data } = await api.get<{ id: string; name: string }[]>( + "/api/v1/admin/branches" + ); + return data; + }, + enabled: !!token, + }); + + // Fetch departments + const { data: departments } = useQuery({ + queryKey: ["departments", user?.companyId], + queryFn: async () => { + const { data } = await api.get<{ id: string; name: string }[]>( + `/api/v1/admin/departments/${user?.companyId}` + ); + return data; + }, + enabled: !!token && !!user?.companyId, + }); + + // Fetch roles + const { data: roles } = useQuery({ + queryKey: ["roles", user?.companyId], + queryFn: async () => { + const { data } = await api.get( + `/api/v1/admin/roles/${user?.companyId}` + ); + return data; + }, + enabled: !!token && !!user?.companyId, + }); + + const handleSubmit = async (data: FormSchema) => { + try { + await onSubmit(data); + } catch (error) { + const err = error as AxiosError; + toast({ + title: "에러", + description: + (err.response?.data as { message: string })?.message || + "에러가 발생했습니다.", + variant: "destructive", + }); + } + }; + + return ( +
+ + ( + + 아이디 + + + + + + )} + /> + + {!initialData && ( + ( + + 비밀번호 + + + + + + )} + /> + )} + + ( + + 이름 + + + + + + )} + /> + + ( + + 이메일 + + + + + + )} + /> + + ( + + 전화번호 + + + + + + )} + /> + + ( + + 역할 + + + + )} + /> + + ( + + 권한 그룹 + + + + )} + /> + + ( + + + + + 활성화 여부 + + + )} + /> + + ( + + 지점 + + + + )} + /> + + ( + + 부서 + + + + )} + /> + +
+ + +
+ + + ); +}; diff --git a/fems-app/src/app/(admin)/common/menu/page.tsx b/fems-app/src/app/(admin)/common/menu/page.tsx new file mode 100644 index 0000000..f0a5521 --- /dev/null +++ b/fems-app/src/app/(admin)/common/menu/page.tsx @@ -0,0 +1,687 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { api } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { useToast } from "@/hooks/use-toast"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Switch } from "@/components/ui/switch"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; +import { + Loader2, + Plus, + ChevronDown, + ChevronRight, + Trash2, + AlertCircle +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +interface DBMenuItem { + id: string; + menu_type: string; + parent_id: string | null; + menu_name_kor: string; + menu_name_eng: string; + seq: string; + menu_url: string; + menu_desc?: string; + isActive: boolean; + children?: DBMenuItem[]; + order?: string; +} + +interface DBApiResponse { + success: boolean; + data: { + success: boolean; + data: DBMenuItem[]; + }; +} + +interface MenuFormDialogProps { + isOpen: boolean; + onClose: () => void; + menuData?: DBMenuItem; + onSave: (data: DBMenuItem) => void; + menus: DBMenuItem[]; +} + +interface FormData { + menuNameKor: string; + menuNameEng: string; + menuUrl: string; + menuSeq: string; + menuType: string; + isActive: boolean; + parentId: string; +} + +const getFlatMenuList = (menus: DBMenuItem[], depth = 0): { id: string; label: string; isChild: boolean }[] => { + return menus.flatMap((menu) => [ + { + id: menu.id, + label: `${' '.repeat(depth)}${menu.menu_name_kor}`, + isChild: depth > 0, // 깊이에 따라 자식 여부 판단 + }, + ...(menu.children ? getFlatMenuList(menu.children, depth + 1) : []), + ]); +}; +const MenuFormDialog = ({ isOpen, onClose, menuData, onSave, menus }: MenuFormDialogProps) => { + const [searchText, setSearchText] = useState(""); + const form = useForm({ + defaultValues: { + menuNameKor: "", + menuNameEng: "", + menuUrl: "", + menuSeq: "", + menuType: "0", + isActive: true, // 기본 활성화 상태를 true로 설정 + parentId: "root", + }, + }); + + useEffect(() => { + if (menuData) { + form.reset({ + menuNameKor: menuData.menu_name_kor || "", + menuNameEng: menuData.menu_name_eng || "", + menuUrl: menuData.menu_url || "", + menuSeq: menuData.seq || "", + menuType: menuData.menu_type || "0", + isActive: menuData.isActive || false, + parentId: menuData.parent_id || "root", + }); + } else { + form.reset({ + menuNameKor: "", + menuNameEng: "", + menuUrl: "", + menuSeq: "", + menuType: "0", + isActive: true, // 새로 추가할 때 기본 활성화 + parentId: "root", + }); + } + }, [menuData, form]); + + const onSubmit = (data: FormData) => { + onSave({ + id: menuData?.id || "", + menu_type: data.menuType, + parent_id: data.parentId === "root" ? null : data.parentId, + menu_name_kor: data.menuNameKor, + menu_name_eng: data.menuNameEng, + seq: data.menuSeq, + menu_url: data.menuUrl, + isActive: data.isActive, + children: menuData?.children || [], + }); + }; + + + return ( + + + + {menuData ? "메뉴 수정" : "메뉴 추가"} + + {menuData ? "기존 메뉴의 정보를 수정합니다." : "새로운 메뉴를 시스템에 추가합니다."} + + +
+ + ( + + 상위메뉴 선택 + setSearchText(e.target.value)} + className="mb-2" + /> + + 기본메뉴 + + {getFlatMenuList(menus) + .filter((menu) => + menu.label.toLowerCase().includes(searchText.toLowerCase()) + ) + .map((menu, index, array) => ( + + + {menu.isChild ? `└ ${menu.label.trim()}` : menu.label} + + {array[index + 1]?.isChild === false && ( + + )} + + ))} + + + + )} + /> + + ( + + 메뉴 이름 (한글) + + + + + )} + /> + + ( + + 메뉴 이름 (영문) + + + + + )} + /> + + ( + + URL + + + + + )} + /> + + ( + + 순서 + + + + + )} + /> + + ( + + 타입 + + + )} + /> + + ( + +
+ 활성화 + + + +
+
+ )} + /> + + + + + + + + +
+
+ ); +}; + +interface MenusTableProps { + menus: DBMenuItem[]; + toggleActive: (id: string, value: boolean) => void; + openSections: { [key: string]: boolean }; + toggleSection: (id: string) => void; + onMenuClick: (menu: DBMenuItem) => void; + onDelete: (id: string) => void; +} + +const MenusTable = ({ + menus, + toggleActive, + openSections, + toggleSection, + onMenuClick, + onDelete +}: MenusTableProps) => { + const renderMenuRow = (menu: DBMenuItem, level: number = 0) => ( + + { + e.stopPropagation(); + toggleSection(menu.id); + }} + > + +
+ {menu.children && menu.children.length > 0 ? ( + + ) : ( + + )} + { + e.stopPropagation(); // 부모 클릭 이벤트 방지 + onMenuClick(menu); + }} + > + {menu.menu_name_kor} + +
+
+ {menu.menu_name_eng} + {menu.menu_url} + {menu.seq} + + + {menu.menu_type === "0" ? "관리자 메뉴" : "사용자 메뉴"} + + + +
+ toggleActive(menu.id, value)} + /> + +
+
+
+ {menu.children && + menu.children.length > 0 && + openSections[menu.id] && + menu.children.map((child) => renderMenuRow(child, level + 1))} +
+ ); + + return ( + + + 메뉴 목록 + + +
+ + + + 메뉴 이름 + 영문 이름 + URL + 순서 + 타입 + 활성화/삭제 + + + + {menus.map((menu) => renderMenuRow(menu))} + +
+ {menus.length === 0 && ( +
+ 등록된 메뉴가 없습니다. +
+ )} +
+
+
+ ); +}; + +const addMenuOrder = (menus: DBMenuItem[], parentOrder: string = ''): DBMenuItem[] => { + return menus.map((menu, index) => { + const order = parentOrder ? `${parentOrder}-${index + 1}` : `${index + 1}`; + return { + ...menu, + order, + children: menu.children ? addMenuOrder(menu.children, order) : [], + }; + }); +}; + +const MenusPage = () => { + const [isOpen, setIsOpen] = useState(false); + const [selectedMenu, setSelectedMenu] = useState(null); + const [openSections, setOpenSections] = useState<{ [key: string]: boolean }>({}); + const [deleteAlertOpen, setDeleteAlertOpen] = useState(false); + const [menuToDelete, setMenuToDelete] = useState(null); + const queryClient = useQueryClient(); + const { toast } = useToast(); + + const toggleSection = (id: string) => { + setOpenSections((prev) => ({ + ...prev, + [id]: !prev[id], + })); + }; + + const { data: dbMenuData, isLoading } = useQuery({ + queryKey: ["admin-menus"], + queryFn: async () => { + const response = await api.get("/api/v1/app/common/admin/menu"); + return response.data; + }, + // 추가 옵션 + refetchOnWindowFocus: true, + refetchOnMount: true, + refetchOnReconnect: true, + }); + + const saveMenuMutation = useMutation({ + mutationFn: async (menuData: Omit) => { + const response = await api.post("/api/v1/app/common/createmenu", menuData); + return response.data; + }, + onSuccess: () => { + // 더 구체적인 refetch 처리 + queryClient.invalidateQueries({ queryKey: ["admin-menus"] }); + queryClient.refetchQueries({ queryKey: ["admin-menus"] }); + toast({ + title: "메뉴 저장 성공", + description: "메뉴가 저장되었습니다.", + }); + setIsOpen(false); + }, + onError: (error) => { + toast({ + title: "메뉴 저장 실패", + description: "메뉴 저장 중 오류가 발생했습니다. 다시 시도해주세요.", + variant: "destructive", + }); + console.error("Menu save error:", error); + }, + }); + + const toggleActiveMutation = useMutation({ + mutationFn: async ({ id, isActive }: { id: string; isActive: boolean }) => { + const response = await api.post("/api/v1/app/common/createmenu", { + id, + isActive, + }); + return response.data; + }, + onSuccess: () => { + // 더 구체적인 refetch 처리 + queryClient.invalidateQueries({ queryKey: ["admin-menus"] }); + queryClient.refetchQueries({ queryKey: ["admin-menus"] }); + }, + onError: (error) => { + toast({ + title: "상태 변경 실패", + description: "메뉴 상태 변경 중 오류가 발생했습니다.", + variant: "destructive", + }); + console.error("Toggle active error:", error); + }, + }); + + const deleteMenuMutation = useMutation({ + mutationFn: async (id: string) => { + const response = await api.delete(`/api/v1/app/common/menu/${id}`); + return response.data; + }, + onSuccess: () => { + // 더 구체적인 refetch 처리 + queryClient.invalidateQueries({ queryKey: ["admin-menus"] }); + queryClient.refetchQueries({ queryKey: ["admin-menus"] }); + toast({ + title: "메뉴 삭제 성공", + description: "메뉴가 삭제되었습니다.", + }); + setMenuToDelete(null); + }, + onError: (error) => { + toast({ + title: "메뉴 삭제 실패", + description: "메뉴 삭제 중 오류가 발생했습니다.", + variant: "destructive", + }); + console.error("Delete menu error:", error); + }, + }); + + const handleMenuSave = (menu: DBMenuItem) => { + const { children, order, ...saveData } = menu; + saveMenuMutation.mutate(saveData); + }; + + const handleMenuClick = (menu: DBMenuItem) => { + setSelectedMenu(menu); + setIsOpen(true); + }; + + const handleAddClick = () => { + setSelectedMenu(null); + setIsOpen(true); + }; + + const handleDeleteClick = (id: string) => { + setMenuToDelete(id); + setDeleteAlertOpen(true); + }; + + const handleDeleteConfirm = () => { + if (menuToDelete) { + deleteMenuMutation.mutate(menuToDelete); + } + setDeleteAlertOpen(false); + }; + + const toggleActive = (id: string, value: boolean) => { + toggleActiveMutation.mutate({ id, isActive: value }); + }; + + if (isLoading) return
Loading...
; + + const orderedMenus = addMenuOrder(dbMenuData?.data?.data || []); + + return ( +
+
+
+

메뉴 관리

+

+ 시스템의 메뉴 구조를 관리하고 설정합니다. +

+
+ +
+ + + + setIsOpen(false)} + menuData={selectedMenu || undefined} + onSave={handleMenuSave} + menus={orderedMenus} + /> + + + + + 메뉴 삭제 + + 정말로 이 메뉴를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + + + + 취소 + + 삭제 + + + + +
+ ); +}; + +export default MenusPage; \ No newline at end of file diff --git a/fems-app/src/app/(auth)/login/page.tsx b/fems-app/src/app/(auth)/login/page.tsx index e383764..d5dad3d 100644 --- a/fems-app/src/app/(auth)/login/page.tsx +++ b/fems-app/src/app/(auth)/login/page.tsx @@ -29,7 +29,7 @@ import { Loader2 } from "lucide-react"; // 로딩 아이콘 const formSchema = z.object({ username: z.string().min(4, "아이디는 4자 이상이어야 합니다"), - password: z.string().min(6, "비밀번호는 6자 이상이어야 합니다"), + password: z.string().min(1, "비밀번호는 6자 이상이어야 합니다"), }); export default function LoginPage() { diff --git a/fems-app/src/components/layout/SideNav.tsx b/fems-app/src/components/layout/SideNav.tsx index d5d8372..8a8e887 100644 --- a/fems-app/src/components/layout/SideNav.tsx +++ b/fems-app/src/components/layout/SideNav.tsx @@ -1,4 +1,3 @@ -// src/components/layout/SideNav.tsx "use client"; import Link from "next/link"; @@ -6,218 +5,67 @@ import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; import { usePermissions } from "@/hooks/usePermissions"; import { useAuth } from "@/hooks/useAuth"; +import { useQuery } from "@tanstack/react-query"; +import { useAuthStore } from "@/stores/auth"; +import { api } from "@/lib/api"; import { - LayoutDashboard, - Gauge, - TrendingUp, - DollarSign, - Zap, - Droplet, - Wind, - Flame, + Building2, Box, - Activity, - Wrench, - LineChart, - BarChart, - FileText, - Bell, - History, - Settings as SettingsIcon, - Target, - Brain, - Sliders, + DollarSign, Users, - HelpCircle, - MessageSquare, - Newspaper, + Sliders, ChevronDown, ChevronRight, - Puzzle, - Building2, Calendar, + Gauge } from "lucide-react"; import { useState } from "react"; -// 관리자 메뉴에 필요한 권한 정의 -const ADMIN_PERMISSIONS = { - COMPANY: "company:manage", - BRANCH: "branches:manage", - BILLING: "billing:manage", - USERS: "users:manage", - DEPARTMENTS: "departments:manage", - ACCOUNTS: "accounts:manage", - SYSTEM: "system:manage", -}; +interface DBMenuItem { + id: string; + menu_type: string; + parent_id: string | null; + menu_name_kor: string; + menu_name_eng: string; + seq: string; + menu_url: string; + menu_desc?: string; + isActive: boolean; + children?: DBMenuItem[]; +} -const getMenuItems = ( - hasPermission: (permission: string) => boolean, - role: string -) => { - // 기본 메뉴 아이템 (관리자 메뉴 제외) - const baseMenuItems = [ - { - title: "대시보드", - items: [ - { - title: "전체 현황", - href: "/dashboard/overview", - icon: LayoutDashboard, - }, - { title: "KPI 지표", href: "/dashboard/kpi", icon: Gauge }, - { title: "비용 현황", href: "/dashboard/costs", icon: DollarSign }, - ], - }, - { - title: "에너지 모니터링", - items: [ - { title: "전력", href: "/electricity", icon: Zap }, - { title: "가스", href: "/gas", icon: Flame }, - { title: "용수", href: "/water", icon: Droplet }, - { title: "스팀", href: "/steam", icon: Wind }, - ], - }, - { - title: "설비 관리", - items: [ - { title: "설비 목록", href: "/inventory", icon: Box }, - { title: "상태 모니터링", href: "/monitoring", icon: Activity }, - { title: "정비 관리", href: "/maintenance", icon: Wrench }, - { title: "부품 관리", href: "/parts", icon: Puzzle }, - { title: "작업자 관리", href: "/personnel", icon: Users }, - { title: "디바이스", href: "/devices", icon: Sliders }, - ], - }, - { - title: "분석/리포트", - items: [ - { title: "에너지 분석", href: "/energy", icon: LineChart }, - { title: "원단위 분석", href: "/efficiency", icon: BarChart }, - { title: "보고서", href: "/reports", icon: FileText }, - ], - }, - { - title: "알람/이벤트", - items: [ - { title: "실시간 알람", href: "/realtime", icon: Bell }, - { title: "이력 관리", href: "/history", icon: History }, - { title: "알람 설정", href: "/settings", icon: SettingsIcon }, - ], - }, - { - title: "에너지 계획", - items: [ - { title: "절감 목표", href: "/targets", icon: Target }, - { title: "수요 예측", href: "/forecast", icon: TrendingUp }, - { title: "최적화", href: "/optimization", icon: Brain }, - ], - }, - { - title: "지원/커뮤니티", - items: [ - { title: "도움말", href: "/faq", icon: HelpCircle }, - { title: "게시판", href: "/community/forum", icon: MessageSquare }, - { title: "뉴스", href: "/community/news", icon: Newspaper }, - ], - }, - ]; +interface DBApiResponse { + success: boolean; + data: { + success: boolean; + data: DBMenuItem[]; + } +} - // 관리자 권한 체크 - const isAdmin = ["super_admin", "company_admin"].includes(role); - // 관리자가 아닌 경우에만 권한 체크 - const hasAnyAdminPermission = - !isAdmin && - Object.values(ADMIN_PERMISSIONS).some((permission) => - hasPermission(permission) - ); - - // 관리자 메뉴 아이템 - const adminMenuItem = { - title: "관리", - items: [ - { - title: "회사 설정", - href: "/company/profile", - icon: Building2, - permission: ADMIN_PERMISSIONS.COMPANY, - }, - { - title: "지점/공장 관리", - href: "/company/branches", - icon: Building2, - permission: ADMIN_PERMISSIONS.BRANCH, - }, - { - title: "결재 관리", - href: "/company/billing", - icon: DollarSign, - permission: ADMIN_PERMISSIONS.BILLING, - }, - { - title: "사용자 권한 관리", - href: "/users/roles", - icon: Users, - permission: ADMIN_PERMISSIONS.USERS, - }, - { - title: "부서 관리", - href: "/users/departments", - icon: Users, - permission: ADMIN_PERMISSIONS.DEPARTMENTS, - }, - { - title: "계정 관리", - href: "/users/accounts", - icon: Users, - permission: ADMIN_PERMISSIONS.ACCOUNTS, - }, - { - title: "시스템 설정", - href: "/system", - icon: Sliders, - permission: ADMIN_PERMISSIONS.SYSTEM, - }, - ], - }; - - // 관리자면 모든 메뉴 표시, 아닌 경우 권한 체크 - const filteredAdminItems = { - ...adminMenuItem, - items: isAdmin - ? adminMenuItem.items // 관리자는 모든 메뉴 표시 - : adminMenuItem.items.filter((item) => hasPermission(item.permission)), // 권한 체크 - }; - - // 관리자이거나 권한이 있는 메뉴가 있는 경우에만 관리자 메뉴 추가 - return isAdmin || - (hasAnyAdminPermission && filteredAdminItems.items.length > 0) - ? [...baseMenuItems, filteredAdminItems] - : baseMenuItems; -}; +interface MenuItem { + id: string; // id 추가 + title: string; + items: { + id: string; // id 추가 + title: string; + href: string; + icon: React.ComponentType<{ className?: string }>; + }[]; +} interface MenuItemProps { - item: { - title: string; - items: { - title: string; - href: string; - icon: React.ComponentType<{ className?: string }>; - permission?: string; - }[]; - }; + item: MenuItem; isOpen: boolean; onToggle: () => void; pathname: string; } - -const MenuItem: React.FC = ({ +const MenuItemComponent: React.FC = ({ item, isOpen, onToggle, pathname, }) => { - const firstIcon = item.items[0]?.icon; - const IconComponent = firstIcon || Box; + const IconComponent = Box; return (
@@ -232,18 +80,14 @@ const MenuItem: React.FC = ({ > {item.title} - {isOpen ? ( - - ) : ( - - )} + {isOpen ? : } {isOpen && (
{item.items.map((subItem) => ( = ({ : "text-gray-600 hover:bg-gray-50" )} > - {subItem.icon && ( - - )} + {subItem.title} ))} @@ -264,18 +106,72 @@ const MenuItem: React.FC = ({
); }; +function processMenuItems(responseData: DBApiResponse, role: string): MenuItem[] { + if (!responseData?.data?.data) { + return []; + } + const menuData = responseData.data.data; + + // 트리 구조로 된 메뉴 데이터로부터 MenuItem[] 생성 + const buildMenuItem = (menu: DBMenuItem): MenuItem => { + const children = menu.children || []; + + // 자식 메뉴들을 seq 기준으로 정렬 + const sortedChildren = [...children].sort((a, b) => { + const seqA = parseInt(a.seq) || 0; + const seqB = parseInt(b.seq) || 0; + return seqA - seqB; + }); + + // 메뉴 아이템 생성 + const result = { + id: menu.id, + title: menu.menu_name_kor, + items: sortedChildren.map(child => ({ + id: child.id, + title: child.menu_name_kor, + href: child.menu_url || '#', + icon: Box + })) + }; + return result; + }; + + // 최상위 메뉴들을 찾고 권한 체크 후 seq로 정렬 + return menuData + .filter(menu => + (menu.menu_type !== "0" || ["super_admin", "company_admin"].includes(role)) && + !menu.parent_id + ) + .sort((a, b) => { + const seqA = parseInt(a.seq) || 0; + const seqB = parseInt(b.seq) || 0; + return seqA - seqB; + }) + .map(buildMenuItem); +} export function SideNav() { const pathname = usePathname(); const { user } = useAuth(); const { hasPermission } = usePermissions(); + const { token } = useAuthStore(); - const menuItems = getMenuItems(hasPermission, user?.role || ""); + const { data: dbMenuData } = useQuery({ + queryKey: ["menus"], + queryFn: async () => { + const response = await api.get("/api/v1/app/common/menu"); + return response.data; + }, + enabled: !!token, + }); + + const menuItems = dbMenuData ? processMenuItems(dbMenuData, user?.role || "") : []; const [openSections, setOpenSections] = useState<{ [key: string]: boolean }>( () => { return menuItems.reduce((acc: { [key: string]: boolean }, item) => { - if (item.items.some((subItem) => subItem.href === pathname)) { + if (item.items?.some((subItem) => subItem.href === pathname)) { acc[item.title] = true; } return acc; @@ -290,7 +186,6 @@ export function SideNav() { })); }; - // 날짜 포맷팅 함수 const formatDate = (dateString: string) => { return new Date(dateString).toLocaleDateString("ko-KR", { year: "numeric", @@ -299,7 +194,6 @@ export function SideNav() { }); }; - // 사업자번호 포맷팅 함수 const formatBusinessNumber = (number: string) => { if (!number) return ""; const cleaned = number.replace(/[^0-9]/g, ""); @@ -308,26 +202,29 @@ export function SideNav() { return (