diff --git a/backend/src/main/java/com/srsystem/controller/EmployeeController.java b/backend/src/main/java/com/srsystem/controller/EmployeeController.java index fea4ca3..a832407 100644 --- a/backend/src/main/java/com/srsystem/controller/EmployeeController.java +++ b/backend/src/main/java/com/srsystem/controller/EmployeeController.java @@ -289,6 +289,7 @@ public class EmployeeController { if (row.containsKey("생년월일")) emp.setBirthDate(java.time.LocalDate.parse(row.get("생년월일"))); if (row.containsKey("학력")) emp.setEducation(row.get("학력")); if (row.containsKey("학교명")) emp.setSchoolName(row.get("학교명")); + if (row.containsKey("직급")) emp.setPosition(row.get("직급")); if (row.containsKey("학과")) emp.setMajor(row.get("학과")); if (row.containsKey("입사일")) emp.setJoinedDate(java.time.LocalDate.parse(row.get("입사일"))); return emp; @@ -328,7 +329,7 @@ public class EmployeeController { @PreAuthorize("hasRole('ADMIN')") @GetMapping("/batch/template") public ResponseEntity downloadTemplate() throws Exception { - String[] headers = {"로그인ID", "이름", "이메일", "전화번호", "권한", "재직상태", "파견상태", "생년월일", "학력", "학교명", "학과", "입사일"}; + String[] headers = {"로그인ID", "이름", "이메일", "전화번호", "권한", "재직상태", "파견상태", "생년월일", "학력", "학교명", "직급", "학과", "입사일"}; Map dropdowns = Map.of( 4, new String[]{"ADMIN", "MANAGER", "EMPLOYEE"}, 5, new String[]{"ACTIVE", "ON_LEAVE", "RESIGNED"}, diff --git a/backend/src/main/java/com/srsystem/controller/MenuPermissionController.java b/backend/src/main/java/com/srsystem/controller/MenuPermissionController.java new file mode 100644 index 0000000..4b97fff --- /dev/null +++ b/backend/src/main/java/com/srsystem/controller/MenuPermissionController.java @@ -0,0 +1,36 @@ +package com.srsystem.controller; + +import com.srsystem.dto.ApiResponse; +import com.srsystem.entity.MenuPermission; +import com.srsystem.repository.MenuPermissionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/menu-permissions") +@RequiredArgsConstructor +public class MenuPermissionController { + + private final MenuPermissionRepository repository; + + @GetMapping + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + public ApiResponse> getAll() { + return ApiResponse.ok(repository.findAllByOrderBySortOrderAsc()); + } + + @PreAuthorize("hasRole('ADMIN')") + @PutMapping("/{menuKey}") + public ApiResponse update(@PathVariable String menuKey, @RequestBody Map body) { + MenuPermission perm = repository.findByMenuKey(menuKey) + .orElseThrow(() -> new RuntimeException("Menu not found: " + menuKey)); + if (body.containsKey("allowedRoles")) { + perm.setAllowedRoles(body.get("allowedRoles")); + } + return ApiResponse.ok(repository.save(perm)); + } +} diff --git a/backend/src/main/java/com/srsystem/dto/EmployeeSummary.java b/backend/src/main/java/com/srsystem/dto/EmployeeSummary.java index ca05009..3dec69a 100644 --- a/backend/src/main/java/com/srsystem/dto/EmployeeSummary.java +++ b/backend/src/main/java/com/srsystem/dto/EmployeeSummary.java @@ -22,5 +22,6 @@ public record EmployeeSummary( LocalDate graduationDate, String residence, String profileImage, - String schoolName + String schoolName, + String position ) {} diff --git a/backend/src/main/java/com/srsystem/entity/Employee.java b/backend/src/main/java/com/srsystem/entity/Employee.java index ae84409..1171e66 100644 --- a/backend/src/main/java/com/srsystem/entity/Employee.java +++ b/backend/src/main/java/com/srsystem/entity/Employee.java @@ -77,6 +77,9 @@ public class Employee extends BaseEntity { @Column(name = "school_name", length = 200) private String schoolName; + @Column(name = "position", length = 50) + private String position; + @Column(name = "graduation_date") private LocalDate graduationDate; diff --git a/backend/src/main/java/com/srsystem/entity/MenuPermission.java b/backend/src/main/java/com/srsystem/entity/MenuPermission.java new file mode 100644 index 0000000..8f1c903 --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/MenuPermission.java @@ -0,0 +1,38 @@ +package com.srsystem.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Table(name = "tb_menu_permission") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@SQLDelete(sql = "UPDATE tb_menu_permission SET is_deleted = 'Y' WHERE id = ?") +@SQLRestriction("is_deleted = 'N'") +public class MenuPermission extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sq_menu_permissions") + @SequenceGenerator(name = "sq_menu_permissions", sequenceName = "sq_menu_permissions", allocationSize = 50) + private Long id; + + @Column(name = "menu_key", nullable = false, unique = true, length = 100) + private String menuKey; + + @Column(name = "menu_name", nullable = false, length = 200) + private String menuName; + + @Column(name = "parent_key", length = 100) + private String parentKey; + + @Column(name = "allowed_roles", nullable = false, columnDefinition = "TEXT") + private String allowedRoles; + + @Column(name = "sort_order") + private Integer sortOrder; +} diff --git a/backend/src/main/java/com/srsystem/repository/MenuPermissionRepository.java b/backend/src/main/java/com/srsystem/repository/MenuPermissionRepository.java new file mode 100644 index 0000000..4b0a18c --- /dev/null +++ b/backend/src/main/java/com/srsystem/repository/MenuPermissionRepository.java @@ -0,0 +1,11 @@ +package com.srsystem.repository; + +import com.srsystem.entity.MenuPermission; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; +import java.util.Optional; + +public interface MenuPermissionRepository extends JpaRepository { + List findAllByOrderBySortOrderAsc(); + Optional findByMenuKey(String menuKey); +} diff --git a/backend/src/main/java/com/srsystem/service/EmployeeService.java b/backend/src/main/java/com/srsystem/service/EmployeeService.java index 1ced14d..bb5b434 100644 --- a/backend/src/main/java/com/srsystem/service/EmployeeService.java +++ b/backend/src/main/java/com/srsystem/service/EmployeeService.java @@ -107,6 +107,7 @@ public class EmployeeService { employee.setResidence(updateRequest.getResidence()); employee.setProfileImage(updateRequest.getProfileImage()); employee.setSchoolName(updateRequest.getSchoolName()); + employee.setPosition(updateRequest.getPosition()); return employee; } @@ -173,7 +174,8 @@ public class EmployeeService { employee.getGraduationDate(), employee.getResidence(), employee.getProfileImage(), - employee.getSchoolName() + employee.getSchoolName(), + employee.getPosition() ); } diff --git a/backend/src/main/resources/db/migration/V16__add_position_and_menu_permission.sql b/backend/src/main/resources/db/migration/V16__add_position_and_menu_permission.sql new file mode 100644 index 0000000..f85f433 --- /dev/null +++ b/backend/src/main/resources/db/migration/V16__add_position_and_menu_permission.sql @@ -0,0 +1,69 @@ +ALTER TABLE tb_employee ADD COLUMN IF NOT EXISTS position VARCHAR(50); + +CREATE SEQUENCE IF NOT EXISTS sq_menu_permissions INCREMENT BY 50; + +CREATE TABLE tb_menu_permission ( + id BIGSERIAL PRIMARY KEY, + menu_key VARCHAR(100) NOT NULL UNIQUE, + menu_name VARCHAR(200) NOT NULL, + parent_key VARCHAR(100), + allowed_roles TEXT NOT NULL DEFAULT '["ADMIN","MANAGER","EMPLOYEE"]', + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMP, + updated_at TIMESTAMP, + is_deleted CHAR(1) DEFAULT 'N' +); + +INSERT INTO tb_menu_permission (menu_key, menu_name, parent_key, allowed_roles, sort_order) VALUES +('dashboard', 'Dashboard', null, '["ADMIN","MANAGER","EMPLOYEE"]', 1), +('issues', 'SR Issues', null, '["ADMIN","MANAGER","EMPLOYEE"]', 2), +('dispatch', 'Dispatch', null, '["ADMIN","MANAGER","EMPLOYEE"]', 3), +('itam', 'ITAM (Assets)', null, '["ADMIN","MANAGER"]', 4), +('itam-hub', 'Asset Hub', 'itam', '["ADMIN","MANAGER"]', 5), +('itam-register', 'Register', 'itam', '["ADMIN","MANAGER"]', 6), +('itam-checkout', 'Check-out', 'itam', '["ADMIN","MANAGER"]', 7), +('itam-checkin', 'Check-in', 'itam', '["ADMIN","MANAGER"]', 8), +('employees', 'Employees', null, '["ADMIN","MANAGER"]', 5), +('projects', 'Projects', null, '["ADMIN","MANAGER"]', 6), +('reports', 'Work Reports', null, '["ADMIN","MANAGER","EMPLOYEE"]', 7), +('reports-review', 'Report Review', null, '["ADMIN","MANAGER"]', 8), +('training', 'Training', null, '["ADMIN","MANAGER","EMPLOYEE"]', 9), +('calendar', 'Calendar', null, '["ADMIN","MANAGER","EMPLOYEE"]', 10), +('profile', 'Profile', null, '["ADMIN","MANAGER","EMPLOYEE"]', 11), +('profile-portfolio', 'Portfolio', 'profile', '["ADMIN","MANAGER","EMPLOYEE"]', 12), +('settings', 'Admin', null, '["ADMIN"]', 99), +('settings-codes', 'Codes', 'settings', '["ADMIN"]', 100), +('settings-accounts', 'Accounts', 'settings', '["ADMIN"]', 101), +('settings-batch-employees', 'Batch Employees', 'settings', '["ADMIN"]', 102), +('settings-system', 'System', 'settings', '["ADMIN"]', 103), +('settings-notification', 'Notification', 'settings', '["ADMIN"]', 104), +('settings-audit', 'Audit Log', 'settings', '["ADMIN"]', 105), +('settings-menus', 'Menu Permissions', 'settings', '["ADMIN"]', 106); + +INSERT INTO tb_common_code (id, group_code, code_value, code_name, sort_order, is_active, created_at, updated_at) +SELECT nextval('sq_common_codes'), 'POSITION', 'STAFF', '사원', 1, 'Y', NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM tb_common_code WHERE group_code = 'POSITION' AND code_value = 'STAFF'); + +INSERT INTO tb_common_code (id, group_code, code_value, code_name, sort_order, is_active, created_at, updated_at) +SELECT nextval('sq_common_codes'), 'POSITION', 'SENIOR', '주임', 2, 'Y', NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM tb_common_code WHERE group_code = 'POSITION' AND code_value = 'SENIOR'); + +INSERT INTO tb_common_code (id, group_code, code_value, code_name, sort_order, is_active, created_at, updated_at) +SELECT nextval('sq_common_codes'), 'POSITION', 'ASSISTANT_MANAGER', '대리', 3, 'Y', NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM tb_common_code WHERE group_code = 'POSITION' AND code_value = 'ASSISTANT_MANAGER'); + +INSERT INTO tb_common_code (id, group_code, code_value, code_name, sort_order, is_active, created_at, updated_at) +SELECT nextval('sq_common_codes'), 'POSITION', 'MANAGER', '과장', 4, 'Y', NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM tb_common_code WHERE group_code = 'POSITION' AND code_value = 'MANAGER'); + +INSERT INTO tb_common_code (id, group_code, code_value, code_name, sort_order, is_active, created_at, updated_at) +SELECT nextval('sq_common_codes'), 'POSITION', 'DEPUTY_GENERAL_MANAGER', '차장', 5, 'Y', NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM tb_common_code WHERE group_code = 'POSITION' AND code_value = 'DEPUTY_GENERAL_MANAGER'); + +INSERT INTO tb_common_code (id, group_code, code_value, code_name, sort_order, is_active, created_at, updated_at) +SELECT nextval('sq_common_codes'), 'POSITION', 'GENERAL_MANAGER', '부장', 6, 'Y', NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM tb_common_code WHERE group_code = 'POSITION' AND code_value = 'GENERAL_MANAGER'); + +INSERT INTO tb_common_code (id, group_code, code_value, code_name, sort_order, is_active, created_at, updated_at) +SELECT nextval('sq_common_codes'), 'POSITION', 'DIRECTOR', '이사', 7, 'Y', NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM tb_common_code WHERE group_code = 'POSITION' AND code_value = 'DIRECTOR'); diff --git a/src/app/(main)/settings/accounts/page.tsx b/src/app/(main)/settings/accounts/page.tsx index ef330ea..7802405 100644 --- a/src/app/(main)/settings/accounts/page.tsx +++ b/src/app/(main)/settings/accounts/page.tsx @@ -56,6 +56,7 @@ interface CreateForm { birthDate: string; education: string; schoolName: string; + position: string; major: string; graduationDate: string; residence: string; @@ -72,6 +73,7 @@ interface EditForm { birthDate: string; education: string; schoolName: string; + position: string; major: string; graduationDate: string; residence: string; @@ -80,7 +82,7 @@ interface EditForm { const emptyCreateForm: CreateForm = { loginId: '', name: '', password: '', email: '', phone: '', roleCode: 'EMPLOYEE', empStatus: 'ACTIVE', joinedDate: '', - birthDate: '', education: '', schoolName: '', major: '', graduationDate: '', residence: '', + birthDate: '', education: '', schoolName: '', position: '', major: '', graduationDate: '', residence: '', }; export default function AccountsPage() { @@ -95,7 +97,7 @@ export default function AccountsPage() { const [createForm, setCreateForm] = useState({ ...emptyCreateForm }); const [editForm, setEditForm] = useState({ name: '', email: '', phone: '', roleCode: 'EMPLOYEE', empStatus: 'ACTIVE', - joinedDate: '', leftDate: '', birthDate: '', education: '', schoolName: '', major: '', graduationDate: '', residence: '', + joinedDate: '', leftDate: '', birthDate: '', education: '', schoolName: '', position: '', major: '', graduationDate: '', residence: '', }); const [createError, setCreateError] = useState(null); const [editError, setEditError] = useState(null); @@ -174,6 +176,7 @@ export default function AccountsPage() { birthDate: emp.birthDate || '', education: emp.education || '', schoolName: emp.schoolName || '', + position: emp.position || '', major: emp.major || '', graduationDate: emp.graduationDate || '', residence: emp.residence || '', @@ -269,6 +272,7 @@ export default function AccountsPage() { 상태 생년월일 학력 + 직급 거주지 입사일 액션 @@ -308,6 +312,7 @@ export default function AccountsPage() { ) : '-'} + {emp.position || '-'} {emp.residence || '-'} {emp.joinedDate} @@ -416,6 +421,19 @@ export default function AccountsPage() { setCreateForm((f) => ({ ...f, schoolName: e.target.value }))} className={fieldClass} placeholder="서울대학교" /> +
+ + +
setCreateForm((f) => ({ ...f, major: e.target.value }))} className={fieldClass} placeholder="컴퓨터공학" /> @@ -570,6 +588,19 @@ export default function AccountsPage() { setEditForm((f) => ({ ...f, schoolName: e.target.value }))} className={fieldClass} placeholder="서울대학교" />
+
+ + +
setEditForm((f) => ({ ...f, major: e.target.value }))} className={fieldClass} placeholder="컴퓨터공학" /> diff --git a/src/app/(main)/settings/menus/page.tsx b/src/app/(main)/settings/menus/page.tsx new file mode 100644 index 0000000..c9a61b8 --- /dev/null +++ b/src/app/(main)/settings/menus/page.tsx @@ -0,0 +1,304 @@ +'use client'; + +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { Shield, Check, X, Loader2 } from 'lucide-react'; +import { useAuthStore } from '@/stores'; +import apiClient from '@/services/apiClient'; +import type { MenuPermission } from '@/types'; + +const ROLES = ['ADMIN', 'MANAGER', 'EMPLOYEE'] as const; +type Role = (typeof ROLES)[number]; + +const ROLE_LABEL: Record = { + ADMIN: 'ADMIN', + MANAGER: 'MANAGER', + EMPLOYEE: 'EMPLOYEE', +}; + +const ROLE_COLORS: Record = { + ADMIN: { bg: 'bg-gray-100', text: 'text-gray-500', active: 'bg-primary text-white' }, + MANAGER: { bg: 'bg-indigo-50', text: 'text-indigo-400', active: 'bg-indigo-500 text-white' }, + EMPLOYEE: { bg: 'bg-emerald-50', text: 'text-emerald-400', active: 'bg-emerald-500 text-white' }, +}; + +function parseRoles(allowedRoles: string): Set { + try { + const arr = JSON.parse(allowedRoles) as string[]; + return new Set(arr.filter((r): r is Role => ROLES.includes(r as Role))); + } catch { + return new Set(); + } +} + +function serializeRoles(roles: Set): string { + return JSON.stringify(Array.from(roles)); +} + +export default function MenuPermissionsPage() { + const user = useAuthStore((s) => s.user); + + const [permissions, setPermissions] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState>({}); + const [errorToast, setErrorToast] = useState(null); + + // Track local toggle state before API confirms + const [localRoles, setLocalRoles] = useState>>({}); + + const timersRef = useRef>({}); + + // ── Fetch on mount ── + useEffect(() => { + apiClient.get('/menu-permissions') + .then((res: any) => { + if (res.success) { + setPermissions(res.data); + const initial: Record> = {}; + res.data.forEach((p: MenuPermission) => { + initial[p.id] = parseRoles(p.allowedRoles); + }); + setLocalRoles(initial); + } + }) + .catch(() => setErrorToast('Failed to load menu permissions')) + .finally(() => setLoading(false)); + }, []); + + // ── Auto-dismiss error toast ── + useEffect(() => { + if (errorToast) { + const t = setTimeout(() => setErrorToast(null), 4000); + return () => clearTimeout(t); + } + }, [errorToast]); + + // ── Debounced save ── + const scheduleSave = useCallback((menuId: number, roles: Set) => { + // Clear existing timer for this menu + if (timersRef.current[menuId]) { + clearTimeout(timersRef.current[menuId]); + } + + setSaving((prev) => ({ ...prev, [menuId]: true })); + + timersRef.current[menuId] = setTimeout(async () => { + try { + const menu = permissions.find((p) => p.id === menuId); + if (!menu) return; + + const res: any = await apiClient.put(`/menu-permissions/${menu.menuKey}`, { + allowedRoles: serializeRoles(roles), + }); + + if (res.success) { + setPermissions((prev) => + prev.map((p) => (p.id === menuId ? res.data : p)) + ); + } + } catch (e: any) { + setErrorToast(e?.message || 'Failed to save permission'); + // Revert local state on error + const menu = permissions.find((p) => p.id === menuId); + if (menu) { + setLocalRoles((prev) => ({ ...prev, [menuId]: parseRoles(menu.allowedRoles) })); + } + } finally { + setSaving((prev) => ({ ...prev, [menuId]: false })); + } + }, 400); + }, [permissions]); + + // ── Toggle handler ── + const handleToggle = useCallback((menuId: number, role: Role) => { + setLocalRoles((prev) => { + const current = prev[menuId] || new Set(); + const next = new Set(current); + if (next.has(role)) { + next.delete(role); + } else { + next.add(role); + } + scheduleSave(menuId, next); + return { ...prev, [menuId]: next }; + }); + }, [scheduleSave]); + + // ── Cleanup timers on unmount ── + useEffect(() => { + return () => { + Object.values(timersRef.current).forEach(clearTimeout); + }; + }, []); + + // ── Group by parent ── + const rootMenus = permissions.filter((p) => p.parentKey === null).sort((a, b) => a.sortOrder - b.sortOrder); + const childrenByParent: Record = {}; + permissions.forEach((p) => { + if (p.parentKey) { + if (!childrenByParent[p.parentKey]) childrenByParent[p.parentKey] = []; + childrenByParent[p.parentKey].push(p); + } + }); + Object.values(childrenByParent).forEach((arr) => arr.sort((a, b) => a.sortOrder - b.sortOrder)); + + // ── ADMIN guard ── + if (user?.roleCode !== 'ADMIN') { + return ( +
+ Access denied. Admin only. +
+ ); + } + + return ( +
+
+ {/* Header */} +
+ +
+

+ Menu Permission Management +

+

+ Control which roles can access each menu item +

+
+
+ + {/* Error Toast */} + {errorToast && ( +
+ + {errorToast} + +
+ )} + + {/* Table Card */} +
+ {/* Table Header */} +
+
Menu Name
+
Menu Key
+
+ {ROLES.map((role) => ( + {ROLE_LABEL[role]} + ))} +
+
+ + {/* Table Body */} + {loading ? ( +
+ + Loading permissions... +
+ ) : ( +
+ {rootMenus.map((root) => { + const roles = localRoles[root.id] || new Set(); + const children = childrenByParent[root.menuKey] || []; + + return ( + + {/* Root row */} +
+
+ {root.menuName} +
+
+ {root.menuKey} +
+
+ {ROLES.map((role) => { + const isActive = roles.has(role); + const colors = ROLE_COLORS[role]; + const isSaving = saving[root.id]; + return ( + + ); + })} +
+
+ + {/* Child rows */} + {children.map((child) => { + const childRoles = localRoles[child.id] || new Set(); + return ( +
+
+ + {child.menuName} +
+
+ {child.menuKey} +
+
+ {ROLES.map((role) => { + const isActive = childRoles.has(role); + const colors = ROLE_COLORS[role]; + const isSaving = saving[child.id]; + return ( + + ); + })} +
+
+ ); + })} +
+ ); + })} + + {rootMenus.length === 0 && ( +
+ No menu permissions found. +
+ )} +
+ )} +
+
+
+ ); +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 9c9be0e..44f525c 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -23,6 +23,7 @@ import { Settings, CheckSquare, Briefcase, + Shield, } from 'lucide-react'; import { useAuthStore } from '@/stores'; @@ -238,6 +239,7 @@ const Sidebar = ({ isCollapsed }: { isCollapsed?: boolean }) => { { name: 'System', href: '/settings/system', icon: Monitor }, { name: 'Notification', href: '/settings/notification', icon: FileText }, { name: 'Audit Log', href: '/settings/audit', icon: History }, + { name: 'Menu Perms', href: '/settings/menus', icon: Shield }, ].map((sub) => { const SubIcon = sub.icon; const isActive = pathname === sub.href; diff --git a/src/types/index.ts b/src/types/index.ts index f6c3377..979b32a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -58,6 +58,7 @@ export interface Employee { birthDate: string | null; education: string | null; schoolName: string | null; + position: string | null; major: string | null; graduationDate: string | null; residence: string | null; @@ -213,6 +214,15 @@ export interface SystemConfig { description: string; } +export interface MenuPermission { + id: number; + menuKey: string; + menuName: string; + parentKey: string | null; + allowedRoles: string; + sortOrder: number; +} + export interface EquipmentSummary { counts: Record; }