feat: admin settings (accounts, system, notification, audit)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user