feat: position field + menu role permissions management
This commit is contained in:
@@ -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<byte[]> downloadTemplate() throws Exception {
|
||||
String[] headers = {"로그인ID", "이름", "이메일", "전화번호", "권한", "재직상태", "파견상태", "생년월일", "학력", "학교명", "학과", "입사일"};
|
||||
String[] headers = {"로그인ID", "이름", "이메일", "전화번호", "권한", "재직상태", "파견상태", "생년월일", "학력", "학교명", "직급", "학과", "입사일"};
|
||||
Map<Integer, String[]> dropdowns = Map.of(
|
||||
4, new String[]{"ADMIN", "MANAGER", "EMPLOYEE"},
|
||||
5, new String[]{"ACTIVE", "ON_LEAVE", "RESIGNED"},
|
||||
|
||||
@@ -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<List<MenuPermission>> getAll() {
|
||||
return ApiResponse.ok(repository.findAllByOrderBySortOrderAsc());
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PutMapping("/{menuKey}")
|
||||
public ApiResponse<MenuPermission> update(@PathVariable String menuKey, @RequestBody Map<String, String> 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));
|
||||
}
|
||||
}
|
||||
@@ -22,5 +22,6 @@ public record EmployeeSummary(
|
||||
LocalDate graduationDate,
|
||||
String residence,
|
||||
String profileImage,
|
||||
String schoolName
|
||||
String schoolName,
|
||||
String position
|
||||
) {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<MenuPermission, Long> {
|
||||
List<MenuPermission> findAllByOrderBySortOrderAsc();
|
||||
Optional<MenuPermission> findByMenuKey(String menuKey);
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
@@ -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<CreateForm>({ ...emptyCreateForm });
|
||||
const [editForm, setEditForm] = useState<EditForm>({
|
||||
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<string | null>(null);
|
||||
const [editError, setEditError] = useState<string | null>(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() {
|
||||
<th className="px-4 sm:px-6 py-3">상태</th>
|
||||
<th className="px-4 sm:px-6 py-3 hidden lg:table-cell">생년월일</th>
|
||||
<th className="px-4 sm:px-6 py-3 hidden lg:table-cell">학력</th>
|
||||
<th className="px-4 sm:px-6 py-3 hidden xl:table-cell">직급</th>
|
||||
<th className="px-4 sm:px-6 py-3 hidden xl:table-cell">거주지</th>
|
||||
<th className="px-4 sm:px-6 py-3 hidden sm:table-cell">입사일</th>
|
||||
<th className="px-4 sm:px-6 py-3 text-right">액션</th>
|
||||
@@ -308,6 +312,7 @@ export default function AccountsPage() {
|
||||
</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-3 text-text-muted hidden xl:table-cell">{emp.position || '-'}</td>
|
||||
<td className="px-4 sm:px-6 py-3 text-text-muted hidden xl:table-cell">{emp.residence || '-'}</td>
|
||||
<td className="px-4 sm:px-6 py-3 text-text-muted hidden sm:table-cell">{emp.joinedDate}</td>
|
||||
<td className="px-4 sm:px-6 py-3 text-right">
|
||||
@@ -416,6 +421,19 @@ export default function AccountsPage() {
|
||||
<label className={labelClass}>학교명</label>
|
||||
<input value={createForm.schoolName} onChange={(e) => setCreateForm((f) => ({ ...f, schoolName: e.target.value }))} className={fieldClass} placeholder="서울대학교" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>직급</label>
|
||||
<select value={createForm.position} onChange={(e) => setCreateForm((f) => ({ ...f, position: e.target.value }))} className={selectClass}>
|
||||
<option value="">선택하세요</option>
|
||||
<option value="STAFF">사원</option>
|
||||
<option value="SENIOR">주임</option>
|
||||
<option value="ASSISTANT_MANAGER">대리</option>
|
||||
<option value="MANAGER">과장</option>
|
||||
<option value="DEPUTY_GENERAL_MANAGER">차장</option>
|
||||
<option value="GENERAL_MANAGER">부장</option>
|
||||
<option value="DIRECTOR">이사</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>학과</label>
|
||||
<input value={createForm.major} onChange={(e) => setCreateForm((f) => ({ ...f, major: e.target.value }))} className={fieldClass} placeholder="컴퓨터공학" />
|
||||
@@ -570,6 +588,19 @@ export default function AccountsPage() {
|
||||
<label className={labelClass}>학교명</label>
|
||||
<input value={editForm.schoolName} onChange={(e) => setEditForm((f) => ({ ...f, schoolName: e.target.value }))} className={fieldClass} placeholder="서울대학교" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>직급</label>
|
||||
<select value={editForm.position} onChange={(e) => setEditForm((f) => ({ ...f, position: e.target.value }))} className={selectClass}>
|
||||
<option value="">선택하세요</option>
|
||||
<option value="STAFF">사원</option>
|
||||
<option value="SENIOR">주임</option>
|
||||
<option value="ASSISTANT_MANAGER">대리</option>
|
||||
<option value="MANAGER">과장</option>
|
||||
<option value="DEPUTY_GENERAL_MANAGER">차장</option>
|
||||
<option value="GENERAL_MANAGER">부장</option>
|
||||
<option value="DIRECTOR">이사</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>학과</label>
|
||||
<input value={editForm.major} onChange={(e) => setEditForm((f) => ({ ...f, major: e.target.value }))} className={fieldClass} placeholder="컴퓨터공학" />
|
||||
|
||||
@@ -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<Role, string> = {
|
||||
ADMIN: 'ADMIN',
|
||||
MANAGER: 'MANAGER',
|
||||
EMPLOYEE: 'EMPLOYEE',
|
||||
};
|
||||
|
||||
const ROLE_COLORS: Record<Role, { bg: string; text: string; active: string }> = {
|
||||
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<Role> {
|
||||
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<Role>): string {
|
||||
return JSON.stringify(Array.from(roles));
|
||||
}
|
||||
|
||||
export default function MenuPermissionsPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const [permissions, setPermissions] = useState<MenuPermission[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState<Record<number, boolean>>({});
|
||||
const [errorToast, setErrorToast] = useState<string | null>(null);
|
||||
|
||||
// Track local toggle state before API confirms
|
||||
const [localRoles, setLocalRoles] = useState<Record<number, Set<Role>>>({});
|
||||
|
||||
const timersRef = useRef<Record<number, NodeJS.Timeout>>({});
|
||||
|
||||
// ── Fetch on mount ──
|
||||
useEffect(() => {
|
||||
apiClient.get('/menu-permissions')
|
||||
.then((res: any) => {
|
||||
if (res.success) {
|
||||
setPermissions(res.data);
|
||||
const initial: Record<number, Set<Role>> = {};
|
||||
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<Role>) => {
|
||||
// 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<Role>();
|
||||
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<string, MenuPermission[]> = {};
|
||||
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 (
|
||||
<div className="p-4 sm:p-6 lg:p-8 text-center text-gray-500">
|
||||
Access denied. Admin only.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Shield className="w-6 h-6 text-primary" />
|
||||
<div>
|
||||
<h2 className="font-outfit text-2xl font-semibold text-text-main tracking-tight">
|
||||
Menu Permission Management
|
||||
</h2>
|
||||
<p className="text-text-muted text-sm">
|
||||
Control which roles can access each menu item
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Toast */}
|
||||
{errorToast && (
|
||||
<div className="mb-4 flex items-center gap-2 bg-status-critical-bg border border-status-critical/20 text-status-critical px-4 py-3 rounded-xl text-sm">
|
||||
<X className="w-4 h-4 flex-shrink-0" />
|
||||
{errorToast}
|
||||
<button onClick={() => setErrorToast(null)} className="ml-auto hover:opacity-70">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table Card */}
|
||||
<div className="bg-white rounded-2xl shadow-card border border-border overflow-hidden">
|
||||
{/* Table Header */}
|
||||
<div className="grid grid-cols-12 gap-4 px-6 py-3 bg-gray-50/50 border-b border-border text-[10px] font-bold text-text-muted uppercase tracking-widest">
|
||||
<div className="col-span-5 sm:col-span-4">Menu Name</div>
|
||||
<div className="col-span-3 hidden sm:block">Menu Key</div>
|
||||
<div className="col-span-7 sm:col-span-5 flex items-center gap-6 justify-end pr-2">
|
||||
{ROLES.map((role) => (
|
||||
<span key={role} className="w-20 text-center">{ROLE_LABEL[role]}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Body */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16 text-text-muted">
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
Loading permissions...
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{rootMenus.map((root) => {
|
||||
const roles = localRoles[root.id] || new Set<Role>();
|
||||
const children = childrenByParent[root.menuKey] || [];
|
||||
|
||||
return (
|
||||
<React.Fragment key={root.id}>
|
||||
{/* Root row */}
|
||||
<div className="grid grid-cols-12 gap-4 px-6 py-3.5 hover:bg-gray-50/30 items-center text-sm">
|
||||
<div className="col-span-5 sm:col-span-4 font-medium text-text-main">
|
||||
{root.menuName}
|
||||
</div>
|
||||
<div className="col-span-3 hidden sm:block font-mono text-xs text-text-muted">
|
||||
{root.menuKey}
|
||||
</div>
|
||||
<div className="col-span-7 sm:col-span-5 flex items-center gap-6 justify-end pr-2">
|
||||
{ROLES.map((role) => {
|
||||
const isActive = roles.has(role);
|
||||
const colors = ROLE_COLORS[role];
|
||||
const isSaving = saving[root.id];
|
||||
return (
|
||||
<button
|
||||
key={role}
|
||||
disabled={isSaving}
|
||||
onClick={() => handleToggle(root.id, role)}
|
||||
className={`w-20 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold transition-all cursor-pointer
|
||||
${isActive ? colors.active : `${colors.bg} ${colors.text} hover:opacity-80`}
|
||||
${isSaving ? 'opacity-60 cursor-wait' : ''}
|
||||
`}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : isActive ? (
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<X className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{isActive ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Child rows */}
|
||||
{children.map((child) => {
|
||||
const childRoles = localRoles[child.id] || new Set<Role>();
|
||||
return (
|
||||
<div
|
||||
key={child.id}
|
||||
className="grid grid-cols-12 gap-4 px-6 py-3 hover:bg-gray-50/30 items-center text-sm bg-surface/40"
|
||||
>
|
||||
<div className="col-span-5 sm:col-span-4 flex items-center gap-2 text-text-main">
|
||||
<span className="w-4 h-px bg-border flex-shrink-0" />
|
||||
<span className="text-text-muted">{child.menuName}</span>
|
||||
</div>
|
||||
<div className="col-span-3 hidden sm:block font-mono text-xs text-text-muted">
|
||||
{child.menuKey}
|
||||
</div>
|
||||
<div className="col-span-7 sm:col-span-5 flex items-center gap-6 justify-end pr-2">
|
||||
{ROLES.map((role) => {
|
||||
const isActive = childRoles.has(role);
|
||||
const colors = ROLE_COLORS[role];
|
||||
const isSaving = saving[child.id];
|
||||
return (
|
||||
<button
|
||||
key={role}
|
||||
disabled={isSaving}
|
||||
onClick={() => handleToggle(child.id, role)}
|
||||
className={`w-20 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold transition-all cursor-pointer
|
||||
${isActive ? colors.active : `${colors.bg} ${colors.text} hover:opacity-80`}
|
||||
${isSaving ? 'opacity-60 cursor-wait' : ''}
|
||||
`}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : isActive ? (
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<X className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{isActive ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{rootMenus.length === 0 && (
|
||||
<div className="text-center py-12 text-text-muted">
|
||||
No menu permissions found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, number>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user