feat: admin settings (accounts, system, notification, audit)

This commit is contained in:
pray4skylark
2026-06-02 16:51:51 +09:00
parent f5899aec46
commit 016b7475f7
10 changed files with 1594 additions and 13 deletions
+474
View File
@@ -0,0 +1,474 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Users, Plus, Pencil, Trash2, X, Search, AlertTriangle } from 'lucide-react';
import { useAuthStore } from '@/stores';
import employeeService from '@/services/employeeService';
import type { Employee, RoleCode, EmpStatus } from '@/types';
const roleLabels: Record<RoleCode, string> = {
ADMIN: '최고관리자',
MANAGER: '본사관리자',
EMPLOYEE: '일반사용자',
};
const roleBadgeColor: Record<RoleCode, string> = {
ADMIN: 'bg-purple-100 text-purple-700',
MANAGER: 'bg-indigo-100 text-indigo-700',
EMPLOYEE: 'bg-gray-100 text-gray-700',
};
const statusLabels: Record<EmpStatus, string> = {
ACTIVE: '재직',
ON_LEAVE: '휴직',
RESIGNED: '퇴사',
};
const statusBadgeColor: Record<EmpStatus, string> = {
ACTIVE: 'bg-status-resolved-bg text-status-resolved-text',
ON_LEAVE: 'bg-status-pending-bg text-status-pending-text',
RESIGNED: 'bg-status-critical-bg text-status-critical-text',
};
const initialColors = ['bg-indigo-500', 'bg-purple-500', 'bg-blue-500', 'bg-green-500', 'bg-orange-500', 'bg-pink-500'];
function getInitialColor(name: string, index: number) {
return initialColors[index % initialColors.length];
}
interface CreateForm {
loginId: string;
name: string;
password: string;
email: string;
roleCode: RoleCode;
empStatus: EmpStatus;
}
interface EditForm {
name: string;
email: string;
roleCode: RoleCode;
empStatus: EmpStatus;
}
export default function AccountsPage() {
const user = useAuthStore((s) => s.user);
const [employees, setEmployees] = useState<Employee[]>([]);
const [loading, setLoading] = useState(true);
const [searchKeyword, setSearchKeyword] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [targetEmployee, setTargetEmployee] = useState<Employee | null>(null);
const [createForm, setCreateForm] = useState<CreateForm>({
loginId: '', name: '', password: '', email: '', roleCode: 'EMPLOYEE', empStatus: 'ACTIVE',
});
const [editForm, setEditForm] = useState<EditForm>({
name: '', email: '', roleCode: 'EMPLOYEE', empStatus: 'ACTIVE',
});
const [createError, setCreateError] = useState<string | null>(null);
const [editError, setEditError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const fetchEmployees = async () => {
try {
setLoading(true);
const res = await employeeService.getAll({ size: 200 });
setEmployees(res.data.content);
} catch {
// silent
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchEmployees();
}, []);
if (user?.roleCode !== 'ADMIN') {
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
<div className="bg-status-critical-bg border border-status-critical rounded-2xl p-8 text-center">
<p className="text-status-critical-text font-medium">접근 거부. 관리자 권한이 필요합니다.</p>
</div>
</div>
</div>
);
}
const filtered = searchKeyword.trim()
? employees.filter((e) => e.name.toLowerCase().includes(searchKeyword.toLowerCase()))
: employees;
const openCreate = () => {
setCreateForm({ loginId: '', name: '', password: '', email: '', roleCode: 'EMPLOYEE', empStatus: 'ACTIVE' });
setCreateError(null);
setShowCreateModal(true);
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!createForm.loginId.trim() || !createForm.name.trim() || !createForm.password.trim()) {
setCreateError('로그인 ID, 이름, 비밀번호는 필수입니다.');
return;
}
setIsSubmitting(true);
setCreateError(null);
try {
await employeeService.create(createForm);
setShowCreateModal(false);
await fetchEmployees();
} catch (err: any) {
setCreateError(err.response?.data?.message || '계정 생성에 실패했습니다.');
} finally {
setIsSubmitting(false);
}
};
const openEdit = (emp: Employee) => {
setTargetEmployee(emp);
setEditForm({ name: emp.name, email: emp.email, roleCode: emp.roleCode, empStatus: emp.empStatus });
setEditError(null);
setShowEditModal(true);
};
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (!editForm.name.trim() || !targetEmployee) return;
setIsSubmitting(true);
setEditError(null);
try {
await employeeService.update(targetEmployee.empId, editForm);
setShowEditModal(false);
await fetchEmployees();
} catch (err: any) {
setEditError(err.response?.data?.message || '수정에 실패했습니다.');
} finally {
setIsSubmitting(false);
}
};
const openDelete = (emp: Employee) => {
setTargetEmployee(emp);
setShowDeleteDialog(true);
};
const handleDelete = async () => {
if (!targetEmployee) return;
setIsSubmitting(true);
try {
await employeeService.delete(targetEmployee.empId);
setShowDeleteDialog(false);
await fetchEmployees();
} catch (err: any) {
alert(err.response?.data?.message || '삭제에 실패했습니다.');
setShowDeleteDialog(false);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
<Users className="w-6 h-6 text-primary" />
<div>
<h2 className="font-outfit text-2xl font-semibold text-text-main tracking-tight">계정 관리</h2>
<p className="text-text-muted text-sm">직원 계정 생성, 수정, 삭제 (ADMIN 전용)</p>
</div>
</div>
<button
onClick={openCreate}
className="bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-semibold hover:bg-primary-hover transition-all flex items-center gap-1.5 shadow-soft"
>
<Plus className="w-4 h-4" /> 새 계정
</button>
</div>
{/* Search */}
<div className="relative w-full sm:w-72 mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<input
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="w-full pl-9 pr-3 py-2 rounded-xl bg-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary transition-all"
placeholder="이름으로 검색..."
/>
</div>
{/* Table */}
{loading ? (
<div className="bg-surface rounded-2xl border border-border shadow-card p-12 text-center text-text-muted">로딩 중...</div>
) : (
<div className="bg-surface rounded-2xl border border-border shadow-card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="bg-gray-50/50 border-b border-border text-[10px] font-bold text-text-muted uppercase tracking-widest">
<th className="px-4 sm:px-6 py-3">이름</th>
<th className="px-4 sm:px-6 py-3">이메일</th>
<th className="px-4 sm:px-6 py-3">권한</th>
<th className="px-4 sm:px-6 py-3">상태</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>
</tr>
</thead>
<tbody className="divide-y divide-border text-sm">
{filtered.map((emp, idx) => (
<tr key={emp.empId} className="hover:bg-gray-50/30 transition-colors">
<td className="px-4 sm:px-6 py-3">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-full ${getInitialColor(emp.name, idx)} flex items-center justify-center text-xs font-medium text-white flex-shrink-0`}>
{emp.name[0]}
</div>
<span className="font-medium text-text-main">{emp.name}</span>
</div>
</td>
<td className="px-4 sm:px-6 py-3 text-text-muted">{emp.email || '-'}</td>
<td className="px-4 sm:px-6 py-3">
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${roleBadgeColor[emp.roleCode]}`}>
{roleLabels[emp.roleCode]}
</span>
</td>
<td className="px-4 sm:px-6 py-3">
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${statusBadgeColor[emp.empStatus]}`}>
{statusLabels[emp.empStatus]}
</span>
</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">
<div className="flex items-center justify-end gap-1">
<button onClick={() => openEdit(emp)} className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors" title="수정">
<Pencil className="w-3.5 h-3.5 text-text-muted hover:text-primary" />
</button>
<button onClick={() => openDelete(emp)} className="p-1.5 hover:bg-red-50 rounded-lg transition-colors" title="삭제">
<Trash2 className="w-3.5 h-3.5 text-text-muted hover:text-status-critical-text" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{filtered.length === 0 && (
<div className="text-center py-12 text-text-muted">검색 결과가 없습니다.</div>
)}
</div>
)}
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={() => setShowCreateModal(false)}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-text-main">새 계정 생성</h3>
<button onClick={() => setShowCreateModal(false)} className="p-1.5 hover:bg-gray-100 rounded-lg">
<X className="w-5 h-5 text-text-muted" />
</button>
</div>
<form onSubmit={handleCreate} className="space-y-3">
<div>
<label className="text-xs font-medium text-text-muted">로그인 ID *</label>
<input
value={createForm.loginId}
onChange={(e) => setCreateForm((f) => ({ ...f, loginId: e.target.value }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="login_id"
/>
</div>
<div>
<label className="text-xs font-medium text-text-muted">이름 *</label>
<input
value={createForm.name}
onChange={(e) => setCreateForm((f) => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="홍길동"
/>
</div>
<div>
<label className="text-xs font-medium text-text-muted">비밀번호 *</label>
<input
type="password"
value={createForm.password}
onChange={(e) => setCreateForm((f) => ({ ...f, password: e.target.value }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="••••••••"
/>
</div>
<div>
<label className="text-xs font-medium text-text-muted">이메일</label>
<input
type="email"
value={createForm.email}
onChange={(e) => setCreateForm((f) => ({ ...f, email: e.target.value }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="email@example.com"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium text-text-muted">권한</label>
<select
value={createForm.roleCode}
onChange={(e) => setCreateForm((f) => ({ ...f, roleCode: e.target.value as RoleCode }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="EMPLOYEE">일반사용자</option>
<option value="MANAGER">본사관리자</option>
<option value="ADMIN">최고관리자</option>
</select>
</div>
<div>
<label className="text-xs font-medium text-text-muted">상태</label>
<select
value={createForm.empStatus}
onChange={(e) => setCreateForm((f) => ({ ...f, empStatus: e.target.value as EmpStatus }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="ACTIVE">재직</option>
<option value="ON_LEAVE">휴직</option>
<option value="RESIGNED">퇴사</option>
</select>
</div>
</div>
{createError && (
<div className="bg-status-critical-bg border border-status-critical text-status-critical-text rounded-lg px-3 py-2 text-sm">
{createError}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<button type="button" onClick={() => setShowCreateModal(false)} className="px-4 py-2 rounded-xl text-sm font-medium text-text-muted hover:bg-gray-100">
취소
</button>
<button type="submit" disabled={isSubmitting} className="px-4 py-2 rounded-xl text-sm font-semibold bg-primary text-white hover:bg-primary-hover disabled:opacity-50">
{isSubmitting ? '생성 중...' : '생성'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit Modal */}
{showEditModal && targetEmployee && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={() => setShowEditModal(false)}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-text-main">계정 수정 — {targetEmployee.name}</h3>
<button onClick={() => setShowEditModal(false)} className="p-1.5 hover:bg-gray-100 rounded-lg">
<X className="w-5 h-5 text-text-muted" />
</button>
</div>
<form onSubmit={handleUpdate} className="space-y-3">
<div>
<label className="text-xs font-medium text-text-muted">이름 *</label>
<input
value={editForm.name}
onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="text-xs font-medium text-text-muted">이메일</label>
<input
type="email"
value={editForm.email}
onChange={(e) => setEditForm((f) => ({ ...f, email: e.target.value }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium text-text-muted">권한</label>
<select
value={editForm.roleCode}
onChange={(e) => setEditForm((f) => ({ ...f, roleCode: e.target.value as RoleCode }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="EMPLOYEE">일반사용자</option>
<option value="MANAGER">본사관리자</option>
<option value="ADMIN">최고관리자</option>
</select>
</div>
<div>
<label className="text-xs font-medium text-text-muted">상태</label>
<select
value={editForm.empStatus}
onChange={(e) => setEditForm((f) => ({ ...f, empStatus: e.target.value as EmpStatus }))}
className="w-full px-3 py-2 border border-border rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="ACTIVE">재직</option>
<option value="ON_LEAVE">휴직</option>
<option value="RESIGNED">퇴사</option>
</select>
</div>
</div>
{editError && (
<div className="bg-status-critical-bg border border-status-critical text-status-critical-text rounded-lg px-3 py-2 text-sm">
{editError}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<button type="button" onClick={() => setShowEditModal(false)} className="px-4 py-2 rounded-xl text-sm font-medium text-text-muted hover:bg-gray-100">
취소
</button>
<button type="submit" disabled={isSubmitting} className="px-4 py-2 rounded-xl text-sm font-semibold bg-primary text-white hover:bg-primary-hover disabled:opacity-50">
{isSubmitting ? '저장 중...' : '저장'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirmation */}
{showDeleteDialog && targetEmployee && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={() => setShowDeleteDialog(false)}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-status-critical-bg flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-5 h-5 text-status-critical-text" />
</div>
<div>
<h3 className="text-lg font-bold text-text-main">계정 삭제</h3>
<p className="text-text-muted text-sm">이 작업은 되돌릴 수 없습니다</p>
</div>
</div>
<div className="bg-gray-50 rounded-xl px-4 py-3 mb-6">
<p className="text-sm text-text-main">
<span className="font-semibold">{targetEmployee.name}</span> 계정을 삭제하시겠습니까?
</p>
<p className="text-xs text-text-muted mt-1">소프트 삭제 처리되며 시스템 접근이 차단됩니다.</p>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => setShowDeleteDialog(false)}
disabled={isSubmitting}
className="flex-1 px-4 py-2.5 rounded-xl border border-border text-text-main text-sm font-medium hover:bg-gray-100 transition-all disabled:opacity-50"
>
취소
</button>
<button
type="button"
onClick={handleDelete}
disabled={isSubmitting}
className="flex-1 px-4 py-2.5 rounded-xl bg-status-critical text-white text-sm font-semibold hover:bg-status-critical-hover transition-all disabled:opacity-50"
>
{isSubmitting ? '삭제 중...' : '삭제'}
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}
+410
View File
@@ -0,0 +1,410 @@
'use client';
import React, { useState, useEffect, useMemo } from 'react';
import {
Shield,
Bell,
UserPlus,
Clock,
Filter,
Search,
AlertTriangle,
Activity,
RefreshCw,
} from 'lucide-react';
import { useAuthStore } from '@/stores';
import apiClient from '@/services/apiClient';
/* ─── Types ─── */
interface Notification {
id: number;
title: string;
message: string;
type: string;
isRead: string;
createdAt: string;
}
interface EmployeeEntry {
empId: number;
name: string;
loginId: string;
roleCode: string;
empStatus: string;
createdAt?: string;
}
interface AuditEntry {
id: string;
time: string;
type: 'SLA' | 'ACCOUNT' | 'SYSTEM';
title: string;
message: string;
}
/* ─── Helpers ─── */
function formatDateTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return iso;
}
}
function formatDateOnly(iso: string): string {
try {
const d = new Date(iso);
return d.toISOString().slice(0, 10);
} catch {
return '';
}
}
const typeConfig: Record<
AuditEntry['type'],
{ label: string; bg: string; text: string; icon: React.ReactNode }
> = {
SLA: {
label: 'SLA',
bg: 'bg-status-critical-bg',
text: 'text-status-critical-text',
icon: <AlertTriangle className="w-3.5 h-3.5" />,
},
ACCOUNT: {
label: '계정',
bg: 'bg-blue-50',
text: 'text-blue-600',
icon: <UserPlus className="w-3.5 h-3.5" />,
},
SYSTEM: {
label: '시스템',
bg: 'bg-purple-50',
text: 'text-purple-600',
icon: <Bell className="w-3.5 h-3.5" />,
},
};
/* ─── Component ─── */
export default function AuditLogPage() {
const user = useAuthStore((s) => s.user);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [employees, setEmployees] = useState<EmployeeEntry[]>([]);
const [loading, setLoading] = useState(true);
// Filters
const [dateFilter, setDateFilter] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('ALL');
/* ─── Data Fetching ─── */
useEffect(() => {
if (user?.roleCode !== 'ADMIN') return;
const fetchAll = async () => {
setLoading(true);
try {
const [notifRes, empRes] = await Promise.allSettled([
apiClient.get('/notifications') as Promise<any>,
apiClient.get('/employees', { params: { size: 200 } }) as Promise<any>,
]);
if (notifRes.status === 'fulfilled' && notifRes.value?.success) {
setNotifications(notifRes.value.data ?? []);
}
if (empRes.status === 'fulfilled' && empRes.value?.success) {
setEmployees(empRes.value.data?.content ?? empRes.value.data ?? []);
}
} catch {
// silent
} finally {
setLoading(false);
}
};
fetchAll();
}, [user]);
/* ─── Merge into timeline ─── */
const auditEntries = useMemo<AuditEntry[]>(() => {
const entries: AuditEntry[] = [];
// Map notifications → audit entries
notifications.forEach((n) => {
let type: AuditEntry['type'] = 'SYSTEM';
if (n.type?.includes('SLA') || n.title?.includes('SLA')) type = 'SLA';
else if (n.type?.includes('NOTIF') || n.type?.includes('TICKET')) type = 'SYSTEM';
else type = 'SYSTEM';
entries.push({
id: `notif-${n.id}`,
time: n.createdAt,
type,
title: n.title || '시스템 알림',
message: n.message || '',
});
});
// Map employee creations (use createdAt if available, else synthetic)
employees.forEach((emp) => {
if (emp.createdAt) {
entries.push({
id: `emp-${emp.empId}`,
time: emp.createdAt,
type: 'ACCOUNT',
title: `계정 생성 — ${emp.name}`,
message: `로그인 ID: ${emp.loginId} / 역할: ${emp.roleCode}`,
});
}
});
// Sort newest first
entries.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
return entries;
}, [notifications, employees]);
/* ─── Filtering ─── */
const filtered = useMemo(() => {
return auditEntries.filter((e) => {
if (dateFilter && formatDateOnly(e.time) !== dateFilter) return false;
if (typeFilter !== 'ALL' && e.type !== typeFilter) return false;
return true;
});
}, [auditEntries, dateFilter, typeFilter]);
/* ─── 403 Gate ─── */
if (user?.roleCode !== 'ADMIN') {
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
<div className="bg-surface rounded-2xl shadow-card border border-border p-12 text-center">
<Shield className="w-12 h-12 text-status-critical mx-auto mb-4" />
<h3 className="font-outfit text-lg font-semibold text-text-main mb-1">
접근 권한 없음
</h3>
<p className="text-text-muted text-sm">
이 페이지는 관리자(ADMIN)만 접근할 수 있습니다.
</p>
</div>
</div>
</div>
);
}
/* ─── Render ─── */
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6 sm:mb-8">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-[#4F46E5] flex items-center justify-center flex-shrink-0">
<Activity className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="font-outfit text-2xl sm:text-3xl font-semibold text-text-main tracking-tight">
감사 로그
</h2>
<p className="text-text-muted mt-0.5 text-sm">
시스템 알림 및 계정 활동을 시간순으로 조회합니다.
</p>
</div>
</div>
<button
onClick={() => {
setLoading(true);
Promise.allSettled([
apiClient.get('/notifications') as Promise<any>,
apiClient.get('/employees', { params: { size: 200 } }) as Promise<any>,
]).then(([nRes, eRes]) => {
if (nRes.status === 'fulfilled' && nRes.value?.success)
setNotifications(nRes.value.data ?? []);
if (eRes.status === 'fulfilled' && eRes.value?.success)
setEmployees(eRes.value.data?.content ?? eRes.value.data ?? []);
}).finally(() => setLoading(false));
}}
className="flex items-center gap-1.5 px-3 py-2 rounded-xl border border-border text-text-muted text-xs font-medium hover:bg-gray-100 transition-all self-start"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
새로고침
</button>
</div>
{/* Filters */}
<div className="bg-surface rounded-2xl shadow-card border border-border p-4 mb-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
<div className="flex items-center gap-2 text-text-muted">
<Filter className="w-4 h-4" />
<span className="text-xs font-semibold uppercase tracking-wider">필터</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* Date filter */}
<input
type="date"
value={dateFilter}
onChange={(e) => setDateFilter(e.target.value)}
className="px-3 py-1.5 rounded-lg bg-gray-50 border border-border text-xs text-text-main focus:outline-none focus:ring-2 focus:ring-[#4F46E5]"
/>
{/* Type filter */}
<div className="flex gap-1.5">
<button
onClick={() => setTypeFilter('ALL')}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
typeFilter === 'ALL'
? 'bg-[#4F46E5] text-white'
: 'text-text-muted hover:bg-gray-100'
}`}
>
전체
</button>
{(Object.keys(typeConfig) as AuditEntry['type'][]).map((t) => (
<button
key={t}
onClick={() => setTypeFilter(t)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
typeFilter === t
? 'bg-[#4F46E5] text-white'
: 'text-text-muted hover:bg-gray-100'
}`}
>
{typeConfig[t].label}
</button>
))}
</div>
{(dateFilter || typeFilter !== 'ALL') && (
<button
onClick={() => {
setDateFilter('');
setTypeFilter('ALL');
}}
className="text-xs text-[#4F46E5] hover:underline font-medium"
>
초기화
</button>
)}
</div>
</div>
</div>
{/* Loading */}
{loading && auditEntries.length === 0 && (
<div className="bg-surface rounded-2xl shadow-card border border-border p-12 text-center">
<RefreshCw className="w-6 h-6 text-text-muted animate-spin mx-auto mb-3" />
<p className="text-text-muted text-sm">데이터를 불러오는 중…</p>
</div>
)}
{/* Empty */}
{!loading && filtered.length === 0 && (
<div className="bg-surface rounded-2xl shadow-card border border-border p-12 text-center">
<Clock className="w-12 h-12 text-gray-200 mx-auto mb-4" />
<h3 className="font-outfit text-lg font-semibold text-text-main mb-1">
기록이 없습니다
</h3>
<p className="text-text-muted text-sm">
{dateFilter || typeFilter !== 'ALL'
? '선택한 조건에 맞는 감사 기록이 없습니다.'
: '감사 로그 데이터가 아직 없습니다.'}
</p>
</div>
)}
{/* Timeline */}
{filtered.length > 0 && (
<div className="relative">
{/* Vertical line */}
<div className="absolute left-5 top-0 bottom-0 w-px bg-gray-200 hidden sm:block" />
<div className="space-y-1">
{filtered.map((entry) => {
const cfg = typeConfig[entry.type];
return (
<div
key={entry.id}
className="group relative flex gap-4 sm:gap-5"
>
{/* Dot */}
<div className="relative z-10 flex-shrink-0 mt-5 hidden sm:block">
<div
className={`w-3 h-3 rounded-full border-2 border-white shadow-sm ${
entry.type === 'SLA'
? 'bg-status-critical'
: entry.type === 'ACCOUNT'
? 'bg-blue-500'
: 'bg-[#4F46E5]'
}`}
/>
</div>
{/* Card */}
<div className="flex-1 bg-surface rounded-2xl shadow-card border border-border p-4 sm:p-5 hover:shadow-elevated transition-shadow">
<div className="flex flex-col sm:flex-row sm:items-start gap-3">
{/* Meta */}
<div className="flex items-center gap-2 flex-shrink-0">
<span
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-semibold ${cfg.bg} ${cfg.text}`}
>
{cfg.icon}
{cfg.label}
</span>
<span className="text-[11px] text-text-muted whitespace-nowrap">
{formatDateTime(entry.time)}
</span>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-text-main leading-snug">
{entry.title}
</p>
{entry.message && (
<p className="text-xs text-text-muted mt-1 leading-relaxed line-clamp-2">
{entry.message}
</p>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Stats footer */}
{auditEntries.length > 0 && (
<div className="mt-6 flex items-center justify-between text-xs text-text-muted">
<span>
총 <span className="font-semibold text-text-main">{auditEntries.length}</span>건
{filtered.length !== auditEntries.length && (
<span>
{' '}
· 필터 결과{' '}
<span className="font-semibold text-[#4F46E5]">{filtered.length}</span>건
</span>
)}
</span>
<span>최신순 정렬</span>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,293 @@
'use client';
import { useEffect, useState } from 'react';
import { useAuthStore } from '@/stores';
import apiClient from '@/services/apiClient';
import type { Notification } from '@/types';
import {
Bell,
BellRing,
Mail,
MessageSquare,
Link as LinkIcon,
ShieldAlert,
Ticket,
FileText,
CheckCircle,
Wrench,
Lock,
} from 'lucide-react';
// ============================================
// Types
// ============================================
interface NotificationChannelSetting {
type: string;
label: string;
icon: React.ReactNode;
slack: boolean;
email: boolean;
}
// ============================================
// Static notification type config
// ============================================
const NOTIFICATION_TYPES: NotificationChannelSetting[] = [
{ type: 'SLA_WARNING', label: 'SLA 경고', icon: <ShieldAlert className="w-4 h-4" />, slack: true, email: true },
{ type: 'TICKET_ASSIGNED', label: '티켓 배정', icon: <Ticket className="w-4 h-4" />, slack: true, email: false },
{ type: 'REPORT_SUBMITTED', label: '보고서 제출', icon: <FileText className="w-4 h-4" />, slack: false, email: true },
{ type: 'REPORT_APPROVED', label: '보고서 결재', icon: <CheckCircle className="w-4 h-4" />, slack: false, email: true },
{ type: 'EQUIPMENT_MAINTENANCE', label: '장비 유지보수', icon: <Wrench className="w-4 h-4" />, slack: true, email: true },
];
// ============================================
// Helpers
// ============================================
function formatRelativeTime(dateStr: string): string {
const now = new Date();
const date = new Date(dateStr);
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return '방금 전';
if (diffMin < 60) return `${diffMin}분 전`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}시간 전`;
const diffDay = Math.floor(diffHr / 24);
if (diffDay < 7) return `${diffDay}일 전`;
return date.toLocaleDateString('ko-KR');
}
function getTypeLabel(type: string): string {
return NOTIFICATION_TYPES.find((t) => t.type === type)?.label ?? type;
}
function getTypeIcon(type: string): React.ReactNode {
return NOTIFICATION_TYPES.find((t) => t.type === type)?.icon ?? <Bell className="w-4 h-4" />;
}
// ============================================
// Page Component
// ============================================
export default function NotificationSettingsPage() {
const { user } = useAuthStore();
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(true);
const [channels, setChannels] = useState<NotificationChannelSetting[]>(NOTIFICATION_TYPES);
const [webhookUrl, setWebhookUrl] = useState('');
// Role guard
if (user?.roleCode !== 'ADMIN') {
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto flex flex-col items-center justify-center min-h-[60vh] gap-4">
<Lock className="w-16 h-16 text-gray-300" />
<h2 className="text-xl font-semibold text-gray-700">접근 권한이 없습니다</h2>
<p className="text-sm text-gray-500">ADMIN 권한만 이 페이지에 접근할 수 있습니다.</p>
</div>
</div>
);
}
// Fetch notifications
useEffect(() => {
let cancelled = false;
async function fetchNotifications() {
try {
const res = await apiClient.get<{ content: Notification[] }>('/notifications?size=20&sort=createdAt,desc');
if (!cancelled && res?.content) {
setNotifications(res.content);
}
} catch {
// silent fail — show empty
} finally {
if (!cancelled) setLoading(false);
}
}
fetchNotifications();
return () => { cancelled = true; };
}, []);
// Mark as read
async function handleMarkRead(notificationId: number) {
try {
await apiClient.put(`/notifications/${notificationId}/read`);
setNotifications((prev) =>
prev.map((n) => (n.notificationId === notificationId ? { ...n, isRead: true } : n)),
);
} catch {
// silent
}
}
// Toggle channel
function toggleChannel(type: string, channel: 'slack' | 'email') {
setChannels((prev) =>
prev.map((c) => (c.type === type ? { ...c, [channel]: !c[channel] } : c)),
);
}
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-indigo-50">
<BellRing className="w-6 h-6 text-indigo-600" />
</div>
<div>
<h1 className="text-2xl font-semibold text-gray-900">알림 설정</h1>
<p className="text-sm text-gray-500">알림 채널 및 템플릿 관리 (ADMIN 전용)</p>
</div>
</div>
{/* 1. 알림 유형별 설정 */}
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-semibold text-gray-900">알림 유형별 채널 설정</h2>
<p className="text-xs text-gray-500 mt-1">각 알림 유형별로 Slack / Email 발송 여부를 설정합니다.</p>
</div>
<div className="divide-y divide-gray-100">
{channels.map((ch) => (
<div key={ch.type} className="px-6 py-4 flex items-center justify-between hover:bg-gray-50/50 transition-colors">
<div className="flex items-center gap-3">
<span className="p-2 rounded-lg bg-indigo-50 text-indigo-600">{ch.icon}</span>
<div>
<p className="text-sm font-medium text-gray-900">{ch.label}</p>
<p className="text-xs text-gray-400 font-mono">{ch.type}</p>
</div>
</div>
<div className="flex items-center gap-4">
{/* Slack toggle */}
<label className="flex items-center gap-2 cursor-pointer select-none">
<MessageSquare className="w-4 h-4 text-gray-400" />
<span className="text-xs text-gray-600">Slack</span>
<button
type="button"
onClick={() => toggleChannel(ch.type, 'slack')}
className={`relative w-10 h-5 rounded-full transition-colors ${ch.slack ? 'bg-indigo-600' : 'bg-gray-300'}`}
>
<span
className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${ch.slack ? 'translate-x-5' : ''}`}
/>
</button>
</label>
{/* Email toggle */}
<label className="flex items-center gap-2 cursor-pointer select-none">
<Mail className="w-4 h-4 text-gray-400" />
<span className="text-xs text-gray-600">Email</span>
<button
type="button"
onClick={() => toggleChannel(ch.type, 'email')}
className={`relative w-10 h-5 rounded-full transition-colors ${ch.email ? 'bg-indigo-600' : 'bg-gray-300'}`}
>
<span
className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${ch.email ? 'translate-x-5' : ''}`}
/>
</button>
</label>
</div>
</div>
))}
</div>
</div>
{/* 2. Slack Webhook URL */}
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-1">Slack Webhook URL</h2>
<p className="text-xs text-gray-500 mb-4">Slack 알림을 받을 웹훅 URL을 입력합니다. (현재 환경변수에서 읽음)</p>
<div className="flex gap-3">
<input
type="url"
value={webhookUrl}
onChange={(e) => setWebhookUrl(e.target.value)}
placeholder="https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX"
className="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent bg-gray-50"
/>
<button
type="button"
className="px-5 py-2.5 rounded-xl bg-indigo-600 text-white text-sm font-semibold hover:bg-indigo-700 transition-colors whitespace-nowrap"
>
저장
</button>
</div>
</div>
{/* 3. 최근 알림 내역 */}
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900">최근 알림 내역</h2>
<p className="text-xs text-gray-500 mt-1">최근 20개 알림 목록</p>
</div>
</div>
{loading ? (
<div className="px-6 py-12 flex justify-center">
<div className="w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin" />
</div>
) : notifications.length === 0 ? (
<div className="px-6 py-12 text-center text-sm text-gray-400">표시할 알림이 없습니다.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100">
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider w-28">유형</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">제목</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider w-32">시간</th>
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider w-20">상태</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{notifications.map((n) => (
<tr
key={n.notificationId}
onClick={() => !n.isRead && handleMarkRead(n.notificationId)}
className={`transition-colors ${!n.isRead ? 'bg-indigo-50/40 hover:bg-indigo-50 cursor-pointer' : 'hover:bg-gray-50/60'}`}
>
<td className="px-6 py-3.5">
<div className="flex items-center gap-2">
<span className="text-indigo-500">{getTypeIcon(n.type)}</span>
<span className="text-xs font-medium text-gray-700">{getTypeLabel(n.type)}</span>
</div>
</td>
<td className="px-6 py-3.5">
<div className="flex items-center gap-2">
<span className={`text-sm ${!n.isRead ? 'font-semibold text-gray-900' : 'text-gray-600'}`}>
{n.title}
</span>
{n.linkUrl && (
<LinkIcon className="w-3.5 h-3.5 text-gray-400 flex-shrink-0" />
)}
</div>
<p className="text-xs text-gray-400 mt-0.5 truncate max-w-md">{n.message}</p>
</td>
<td className="px-6 py-3.5 text-xs text-gray-500 whitespace-nowrap">
{formatRelativeTime(n.createdAt)}
</td>
<td className="px-6 py-3.5 text-center">
{n.isRead ? (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500">
읽음
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-700">
안 읽음
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}
+238
View File
@@ -0,0 +1,238 @@
'use client';
import { useState, useEffect } from 'react';
import { Shield, Clock, Moon, Key, Bell, Upload, Save, Loader2 } from 'lucide-react';
import { useAuthStore } from '@/stores';
import apiClient from '@/services/apiClient';
import type { SystemConfig } from '@/types';
interface ConfigSection {
title: string;
icon: React.ReactNode;
fields: {
label: string;
key: string;
type: 'number' | 'text' | 'time' | 'toggle';
unit?: string;
}[];
}
const SECTIONS: ConfigSection[] = [
{
title: 'SLA 설정',
icon: <Clock className="w-5 h-5" />,
fields: [
{ label: '체크 간격', key: 'sla_check_interval', type: 'number', unit: 'ms' },
{ label: '제외 상태', key: 'sla_excluded_statuses', type: 'text' },
],
},
{
title: 'Parking 모드',
icon: <Moon className="w-5 h-5" />,
fields: [
{ label: '활성화', key: 'parking_enabled', type: 'toggle' },
{ label: '평일 시작', key: 'parking_weekday_start', type: 'time' },
{ label: '평일 종료', key: 'parking_weekday_end', type: 'time' },
{ label: '주말 적용', key: 'parking_weekends', type: 'toggle' },
],
},
{
title: 'JWT 설정',
icon: <Key className="w-5 h-5" />,
fields: [
{ label: 'Access 만료', key: 'jwt_access_expiration', type: 'number', unit: 'ms' },
{ label: 'Refresh 만료', key: 'jwt_refresh_expiration', type: 'number', unit: 'ms' },
],
},
{
title: '알림 설정',
icon: <Bell className="w-5 h-5" />,
fields: [
{ label: 'Slack 활성화', key: 'notification_slack_enabled', type: 'toggle' },
{ label: 'Email 활성화', key: 'notification_email_enabled', type: 'toggle' },
],
},
{
title: '파일 업로드',
icon: <Upload className="w-5 h-5" />,
fields: [
{ label: '최대 크기', key: 'file_upload_max_size', type: 'number', unit: 'bytes' },
],
},
];
function ToggleButton({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
onClick={() => onChange(!value)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
value ? 'bg-primary' : 'bg-gray-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
value ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
);
}
export default function SystemSettingsPage() {
const user = useAuthStore((s) => s.user);
const [configs, setConfigs] = useState<SystemConfig[]>([]);
const [values, setValues] = useState<Record<string, string>>({});
const [changed, setChanged] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
apiClient.get<SystemConfig[]>('/admin/config')
.then((res: any) => {
if (res.success) {
setConfigs(res.data);
const map: Record<string, string> = {};
res.data.forEach((c: SystemConfig) => { map[c.configKey] = c.configValue; });
setValues(map);
}
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleChange = (key: string, val: string) => {
setValues((prev) => ({ ...prev, [key]: val }));
setChanged((prev) => new Set(prev).add(key));
};
const handleToggle = (key: string) => {
const current = values[key] === 'true';
handleChange(key, String(!current));
};
const handleSave = async () => {
setSaving(true);
const payload: Record<string, string> = {};
changed.forEach((k) => { payload[k] = values[k]; });
try {
const res: any = await apiClient.put('/admin/config/batch', payload);
if (res.success) {
setChanged(new Set());
}
} catch (e) {
console.error('Save error:', e);
} finally {
setSaving(false);
}
};
const getField = (key: string) => configs.find((c) => c.configKey === key);
if (user?.roleCode !== 'ADMIN') {
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto text-center py-20">
<Shield className="w-16 h-16 mx-auto text-status-critical mb-4" />
<h2 className="text-xl font-semibold text-text-main">접근 거부</h2>
<p className="text-text-muted mt-2">시스템 설정은 관리자(ADMIN)만 접근할 수 있습니다.</p>
</div>
</div>
);
}
if (loading) {
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto text-center py-20 text-text-muted">Loading...</div>
</div>
);
}
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto">
<div className="flex items-center gap-3 mb-8">
<Shield className="w-6 h-6 text-primary" />
<div>
<h2 className="font-outfit text-2xl font-semibold text-text-main tracking-tight">System Settings</h2>
<p className="text-text-muted text-sm">Manage system-wide configuration</p>
</div>
</div>
<div className="space-y-6">
{SECTIONS.map((section) => (
<div key={section.title} className="bg-surface rounded-2xl shadow-card border border-border overflow-hidden">
<div className="px-6 py-4 border-b border-border flex items-center gap-2 bg-gray-50/50">
<span className="text-primary">{section.icon}</span>
<h3 className="font-outfit text-base font-semibold text-text-main">{section.title}</h3>
</div>
<div className="divide-y divide-border">
{section.fields.map((field) => {
const config = getField(field.key);
const val = values[field.key] ?? '';
const isChanged = changed.has(field.key);
return (
<div key={field.key} className="px-6 py-4 flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<label className="text-sm font-medium text-text-main">{field.label}</label>
{config?.description && (
<p className="text-xs text-text-muted mt-0.5">{config.description}</p>
)}
</div>
<div className="flex items-center gap-2">
{field.type === 'toggle' ? (
<ToggleButton value={val === 'true'} onChange={() => handleToggle(field.key)} />
) : field.type === 'time' ? (
<input
type="time"
value={val}
onChange={(e) => handleChange(field.key, e.target.value)}
className="px-3 py-1.5 border border-border rounded-lg text-sm w-32 focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
) : (
<div className="flex items-center gap-1">
<input
type={field.type === 'number' ? 'number' : 'text'}
value={val}
onChange={(e) => handleChange(field.key, e.target.value)}
className={`px-3 py-1.5 border rounded-lg text-sm w-40 focus:outline-none focus:ring-2 focus:ring-primary/30 ${
isChanged ? 'border-primary bg-primary/5' : 'border-border'
}`}
/>
{field.unit && (
<span className="text-xs text-text-muted w-10">{field.unit}</span>
)}
</div>
)}
{isChanged && (
<span className="w-2 h-2 rounded-full bg-primary" title="Changed" />
)}
</div>
</div>
);
})}
</div>
</div>
))}
</div>
<div className="flex justify-end mt-8">
<button
onClick={handleSave}
disabled={changed.size === 0 || saving}
className={`flex items-center gap-2 px-6 py-2.5 rounded-xl text-sm font-semibold transition-all ${
changed.size === 0 || saving
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-primary text-white hover:bg-primary-hover shadow-card'
}`}
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
저장 ({changed.size})
</button>
</div>
</div>
</div>
);
}
+44 -13
View File
@@ -34,6 +34,7 @@ const Sidebar = ({ isCollapsed }: { isCollapsed?: boolean }) => {
const pathname = usePathname();
const router = useRouter();
const [isItamOpen, setIsItamOpen] = useState(pathname.startsWith('/itam'));
const [isAdminOpen, setIsAdminOpen] = useState(pathname.startsWith('/settings'));
const user = useAuthStore((s) => s.user);
const role = user?.roleCode;
const isMgmt = role === 'MANAGER' || role === 'ADMIN';
@@ -209,19 +210,49 @@ const Sidebar = ({ isCollapsed }: { isCollapsed?: boolean }) => {
)}
{role === 'ADMIN' && (
<Link
href="/settings/codes"
className={`flex items-center rounded-xl font-bold text-xs transition-all ${
isCollapsed ? 'justify-center py-3' : 'px-3 py-2.5'
} ${
pathname === '/settings/codes'
? 'bg-primary text-white shadow-lg shadow-primary/20'
: 'text-text-muted hover:bg-gray-50 hover:text-text-main'
}`}
>
<Settings className={`w-4.5 h-4.5 flex-shrink-0 ${isCollapsed ? '' : 'mr-3'}`} />
{!isCollapsed && <span className="whitespace-nowrap">Settings</span>}
</Link>
<>
<button
onClick={() => !isCollapsed && setIsAdminOpen(!isAdminOpen)}
className={`w-full flex items-center justify-between rounded-xl font-bold text-xs transition-all ${
isCollapsed ? 'justify-center py-3' : 'px-3 py-2.5'
} ${
pathname.startsWith('/settings') && !isCollapsed
? 'text-primary'
: 'text-text-muted hover:bg-gray-50 hover:text-text-main'
}`}
>
<div className="flex items-center">
<Settings className={`w-4.5 h-4.5 flex-shrink-0 ${isCollapsed ? '' : 'mr-3'}`} />
{!isCollapsed && <span className="whitespace-nowrap">Admin</span>}
</div>
{!isCollapsed && (
<ChevronDown className={`w-3.5 h-3.5 transition-transform duration-300 ${isAdminOpen ? 'rotate-180' : ''}`} />
)}
</button>
{isAdminOpen && !isCollapsed && (
<div className="pl-6 space-y-1 mt-1">
{[
{ name: 'Codes', href: '/settings/codes', icon: Settings },
{ name: 'Accounts', href: '/settings/accounts', icon: Users },
{ name: 'System', href: '/settings/system', icon: Monitor },
{ name: 'Notification', href: '/settings/notification', icon: FileText },
{ name: 'Audit Log', href: '/settings/audit', icon: History },
].map((sub) => {
const SubIcon = sub.icon;
const isActive = pathname === sub.href;
return (
<Link key={sub.name} href={sub.href}
className={`flex items-center gap-2.5 px-3 py-2 rounded-xl text-xs font-bold transition-all ${
isActive ? 'bg-primary/10 text-primary' : 'text-text-muted hover:text-text-main'
}`}>
<SubIcon className="w-3.5 h-3.5" />
{sub.name}
</Link>
);
})}
</div>
)}
</>
)}
<Link
+7
View File
@@ -188,6 +188,13 @@ export interface Equipment {
images?: EquipmentImage[] | string;
}
export interface SystemConfig {
configId: number;
configKey: string;
configValue: string;
description: string;
}
export interface EquipmentSummary {
counts: Record<string, number>;
}