feat: position field + menu role permissions management

This commit is contained in:
pray4skylark
2026-06-08 10:43:18 +09:00
parent 15b73e7bc0
commit fe90547457
12 changed files with 513 additions and 5 deletions
@@ -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');
+33 -2
View File
@@ -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="컴퓨터공학" />
+304
View File
@@ -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>
);
}
+2
View File
@@ -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;
+10
View File
@@ -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>;
}