feat: position field + menu role permissions management
This commit is contained in:
@@ -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