init: SR System — Helpdesk & ITAM Portal
This commit is contained in:
@@ -0,0 +1,841 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Search, Plus, MoreHorizontal, X, Pencil, Trash2, AlertTriangle } from 'lucide-react';
|
||||
import employeeService from '@/services/employeeService';
|
||||
import { useCommonCodes } from '@/hooks/useCommonCodes';
|
||||
import { useAuthStore } from '@/stores';
|
||||
import type { Employee, RoleCode, EmpStatus, DispatchStatus } from '@/types';
|
||||
|
||||
const statusColor: Record<string, 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];
|
||||
}
|
||||
|
||||
export default function EmployeesPage() {
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const { codes: empStatusCodes, loading: statusCodesLoading } = useCommonCodes('EMP_STATUS');
|
||||
const { codes: empRoleCodes, loading: roleCodesLoading } = useCommonCodes('EMP_ROLE');
|
||||
|
||||
const roleLabel = roleCodesLoading ? {} : Object.fromEntries(empRoleCodes.map((c) => [c.codeValue, c.codeName]));
|
||||
|
||||
// Add Employee Modal State
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [modalError, setModalError] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
loginId: '',
|
||||
name: '',
|
||||
email: '',
|
||||
roleCode: 'EMPLOYEE' as RoleCode,
|
||||
empStatus: 'ACTIVE' as EmpStatus,
|
||||
dispatchStatus: 'WAITING' as DispatchStatus,
|
||||
});
|
||||
|
||||
// Edit Modal State
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [editModalError, setEditModalError] = useState<string | null>(null);
|
||||
const [editData, setEditData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
roleCode: 'EMPLOYEE' as RoleCode,
|
||||
empStatus: 'ACTIVE' as EmpStatus,
|
||||
dispatchStatus: 'WAITING' as DispatchStatus,
|
||||
});
|
||||
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null);
|
||||
|
||||
// Delete Confirmation State
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [deletingEmployee, setDeletingEmployee] = useState<Employee | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const fetchEmployees = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await employeeService.getAll({ size: 100 });
|
||||
setEmployees(res.data.content);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to load employees. Please try again.');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
if (user.roleCode === 'EMPLOYEE') {
|
||||
setError('Only managers and admins can view employee list.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchEmployees();
|
||||
}, [user]);
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setIsModalOpen(true);
|
||||
setFormData({
|
||||
loginId: '',
|
||||
name: '',
|
||||
email: '',
|
||||
roleCode: 'EMPLOYEE',
|
||||
empStatus: 'ACTIVE',
|
||||
dispatchStatus: 'WAITING',
|
||||
});
|
||||
setModalError(null);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setFormData({
|
||||
loginId: '',
|
||||
name: '',
|
||||
email: '',
|
||||
roleCode: 'EMPLOYEE',
|
||||
empStatus: 'ACTIVE',
|
||||
dispatchStatus: 'WAITING',
|
||||
});
|
||||
setModalError(null);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.loginId.trim() || !formData.name.trim()) {
|
||||
setModalError('Login ID and Name are required.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setModalError(null);
|
||||
|
||||
try {
|
||||
await employeeService.create(formData);
|
||||
alert('Employee added successfully!');
|
||||
handleCloseModal();
|
||||
await fetchEmployees();
|
||||
} catch (err: any) {
|
||||
setModalError(err.response?.data?.message || 'Failed to add employee. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Edit handlers
|
||||
const handleEdit = (emp: Employee) => {
|
||||
setEditingEmployee(emp);
|
||||
setEditData({
|
||||
name: emp.name,
|
||||
email: emp.email || '',
|
||||
roleCode: emp.roleCode,
|
||||
empStatus: emp.empStatus,
|
||||
dispatchStatus: emp.dispatchStatus,
|
||||
});
|
||||
setEditModalError(null);
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseEditModal = () => {
|
||||
setIsEditModalOpen(false);
|
||||
setEditingEmployee(null);
|
||||
setEditData({
|
||||
name: '',
|
||||
email: '',
|
||||
roleCode: 'EMPLOYEE',
|
||||
empStatus: 'ACTIVE',
|
||||
dispatchStatus: 'WAITING',
|
||||
});
|
||||
setEditModalError(null);
|
||||
};
|
||||
|
||||
const handleEditInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setEditData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleUpdateSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!editData.name.trim()) {
|
||||
setEditModalError('Name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingEmployee) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
setEditModalError(null);
|
||||
|
||||
try {
|
||||
await employeeService.update(editingEmployee.empId, editData);
|
||||
alert('Employee updated successfully!');
|
||||
handleCloseEditModal();
|
||||
await fetchEmployees();
|
||||
} catch (err: any) {
|
||||
setEditModalError(err.response?.data?.message || 'Failed to update employee. Please try again.');
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete handlers
|
||||
const handleDelete = (emp: Employee) => {
|
||||
setDeletingEmployee(emp);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDeleteDialog = () => {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeletingEmployee(null);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deletingEmployee) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
|
||||
try {
|
||||
await employeeService.delete(deletingEmployee.empId);
|
||||
alert('Employee deleted successfully!');
|
||||
handleCloseDeleteDialog();
|
||||
await fetchEmployees();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.message || 'Failed to delete employee. Please try again.');
|
||||
handleCloseDeleteDialog();
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [skillKeyword, setSkillKeyword] = useState('');
|
||||
const [skillResults, setSkillResults] = useState<Employee[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!skillKeyword.trim()) { setSkillResults([]); return; }
|
||||
const timer = setTimeout(() => {
|
||||
employeeService.search(skillKeyword.trim()).then((res) => {
|
||||
const empIds = new Set(res.data.map((r: any) => r.empId));
|
||||
setSkillResults(employees.filter((e) => empIds.has(e.empId)));
|
||||
}).catch(() => {});
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [skillKeyword, employees]);
|
||||
|
||||
const displayList = skillKeyword.trim() ? skillResults : employees;
|
||||
const filtered = filter === 'all' ? displayList : displayList.filter((e) => e.empStatus === filter);
|
||||
|
||||
const stats = [
|
||||
{ label: '전체', value: employees.length, color: 'text-text-main' },
|
||||
{ label: '재직', value: employees.filter((e) => e.empStatus === 'ACTIVE').length, color: 'text-status-resolved-text' },
|
||||
{ label: '파견중', value: employees.filter((e) => e.dispatchStatus === 'DISPATCHED').length, color: 'text-status-pending-text' },
|
||||
{ label: '휴직', value: employees.filter((e) => e.empStatus === 'ON_LEAVE').length, color: 'text-status-critical-text' },
|
||||
];
|
||||
|
||||
// Add Employee Modal
|
||||
if (isModalOpen) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-surface rounded-2xl shadow-xl max-w-md w-full mx-4 p-6 relative animate-in fade-in zoom-in duration-200">
|
||||
<button
|
||||
onClick={handleCloseModal}
|
||||
className="absolute top-4 right-4 p-1.5 text-text-muted hover:text-text-main hover:bg-gray-100 rounded-lg transition-all"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<h3 className="font-outfit text-xl font-semibold text-text-main mb-1">Add Employee</h3>
|
||||
<p className="text-text-muted text-sm mb-6">Create a new employee account</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-1.5">
|
||||
Login ID <span className="text-status-critical-text">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="loginId"
|
||||
value={formData.loginId}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
||||
placeholder="Enter login ID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-1.5">
|
||||
Name <span className="text-status-critical-text">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
||||
placeholder="Enter full name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-1.5">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-1.5">Role</label>
|
||||
<select
|
||||
name="roleCode"
|
||||
value={formData.roleCode}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
||||
>
|
||||
{roleCodesLoading ? (
|
||||
<option value="">Loading roles...</option>
|
||||
) : (
|
||||
empRoleCodes.map((code) => (
|
||||
<option key={code.codeValue} value={code.codeValue}>
|
||||
{code.codeName} ({code.codeValue})
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-1.5">Employee Status</label>
|
||||
<select
|
||||
name="empStatus"
|
||||
value={formData.empStatus}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
||||
>
|
||||
{statusCodesLoading ? (
|
||||
<option value="">Loading status...</option>
|
||||
) : (
|
||||
empStatusCodes.map((code) => (
|
||||
<option key={code.codeValue} value={code.codeValue}>
|
||||
{code.codeName} ({code.codeValue})
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-1.5">Dispatch Status</label>
|
||||
<select
|
||||
name="dispatchStatus"
|
||||
value={formData.dispatchStatus}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
||||
>
|
||||
<option value="WAITING">대기 (WAITING)</option>
|
||||
<option value="DISPATCHED">파견중 (DISPATCHED)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modalError && (
|
||||
<div className="bg-status-critical-bg border border-status-critical text-status-critical-text rounded-xl px-4 py-3 text-sm">
|
||||
{modalError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseModal}
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-primary hover:bg-primary-hover text-white text-sm font-semibold shadow-soft transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? 'Adding...' : 'Add Employee'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Edit Employee Modal
|
||||
if (isEditModalOpen) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-surface rounded-2xl shadow-xl max-w-md w-full mx-4 p-6 relative animate-in fade-in zoom-in duration-200">
|
||||
<button
|
||||
onClick={handleCloseEditModal}
|
||||
className="absolute top-4 right-4 p-1.5 text-text-muted hover:text-text-main hover:bg-gray-100 rounded-lg transition-all"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<h3 className="font-outfit text-xl font-semibold text-text-main mb-1">Edit Employee</h3>
|
||||
<p className="text-text-muted text-sm mb-6">Update employee information</p>
|
||||
|
||||
<form onSubmit={handleUpdateSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-1.5">
|
||||
Name <span className="text-status-critical-text">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={editData.name}
|
||||
onChange={handleEditInputChange}
|
||||
required
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
||||
placeholder="Enter full name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-1.5">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={editData.email}
|
||||
onChange={handleEditInputChange}
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-1.5">Role</label>
|
||||
<select
|
||||
name="roleCode"
|
||||
value={editData.roleCode}
|
||||
onChange={handleEditInputChange}
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
||||
>
|
||||
{roleCodesLoading ? (
|
||||
<option value="">Loading roles...</option>
|
||||
) : (
|
||||
empRoleCodes.map((code) => (
|
||||
<option key={code.codeValue} value={code.codeValue}>
|
||||
{code.codeName} ({code.codeValue})
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-1.5">Employee Status</label>
|
||||
<select
|
||||
name="empStatus"
|
||||
value={editData.empStatus}
|
||||
onChange={handleEditInputChange}
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
||||
>
|
||||
{statusCodesLoading ? (
|
||||
<option value="">Loading status...</option>
|
||||
) : (
|
||||
empStatusCodes.map((code) => (
|
||||
<option key={code.codeValue} value={code.codeValue}>
|
||||
{code.codeName} ({code.codeValue})
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-main mb-1.5">Dispatch Status</label>
|
||||
<select
|
||||
name="dispatchStatus"
|
||||
value={editData.dispatchStatus}
|
||||
onChange={handleEditInputChange}
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-gray-50 border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:bg-white transition-all"
|
||||
>
|
||||
<option value="WAITING">대기 (WAITING)</option>
|
||||
<option value="DISPATCHED">파견중 (DISPATCHED)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editModalError && (
|
||||
<div className="bg-status-critical-bg border border-status-critical text-status-critical-text rounded-xl px-4 py-3 text-sm">
|
||||
{editModalError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseEditModal}
|
||||
disabled={isUpdating}
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isUpdating}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-primary hover:bg-primary-hover text-white text-sm font-semibold shadow-soft transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isUpdating ? 'Updating...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Delete Confirmation Dialog
|
||||
if (isDeleteDialogOpen) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-surface rounded-2xl shadow-xl max-w-md w-full mx-4 p-6 relative animate-in fade-in zoom-in duration-200">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 rounded-full bg-status-critical-bg flex items-center justify-center flex-shrink-0">
|
||||
<AlertTriangle className="w-6 h-6 text-status-critical-text" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-outfit text-xl font-semibold text-text-main">Delete Employee</h3>
|
||||
<p className="text-text-muted text-sm">This action cannot be undone</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl px-4 py-3 mb-6">
|
||||
<p className="text-sm text-text-main">
|
||||
Are you sure you want to delete <span className="font-semibold">{deletingEmployee?.name}</span>?
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
This will soft-delete the employee record and remove their access to the system.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseDeleteDialog}
|
||||
disabled={isDeleting}
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={isDeleting}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-status-critical hover:bg-status-critical-hover text-white text-sm font-semibold shadow-soft transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4 mb-6 sm:mb-8">
|
||||
<div>
|
||||
<h2 className="font-outfit text-2xl sm:text-3xl font-semibold text-text-main tracking-tight">Employees</h2>
|
||||
<p className="text-text-muted mt-1 text-sm">Manage employee records, dispatch status, and role assignments.</p>
|
||||
</div>
|
||||
<div className="h-10 w-32 bg-gray-200 rounded-xl animate-pulse" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="bg-surface rounded-2xl p-5 border border-border">
|
||||
<div className="h-3 w-12 bg-gray-200 rounded mb-2 animate-pulse" />
|
||||
<div className="h-7 w-16 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-2xl border border-border shadow-card overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
|
||||
<div className="flex space-x-2">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="h-8 w-16 bg-gray-200 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
<div className="h-9 w-48 bg-gray-200 rounded-lg animate-pulse" />
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex items-center py-4 border-b border-gray-100 last:border-0">
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200 mr-3 animate-pulse" />
|
||||
<div className="flex-1 h-4 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4 mb-6 sm:mb-8">
|
||||
<div>
|
||||
<h2 className="font-outfit text-2xl sm:text-3xl font-semibold text-text-main tracking-tight">Employees</h2>
|
||||
<p className="text-text-muted mt-1 text-sm">Manage employee records, dispatch status, and role assignments.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-status-critical-bg border border-status-critical text-status-critical-text rounded-2xl p-6 sm:p-8 text-center">
|
||||
<p className="font-medium">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (employees.length === 0) {
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4 mb-6 sm:mb-8">
|
||||
<div>
|
||||
<h2 className="font-outfit text-2xl sm:text-3xl font-semibold text-text-main tracking-tight">Employees</h2>
|
||||
<p className="text-text-muted mt-1 text-sm">Manage employee records, dispatch status, and role assignments.</p>
|
||||
</div>
|
||||
<button onClick={handleOpenModal} className="w-full sm:w-auto bg-primary hover:bg-primary-hover text-white px-5 py-2.5 rounded-xl text-sm font-semibold shadow-soft transition-all flex items-center justify-center">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Employee
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-surface rounded-2xl border border-border shadow-card p-12 text-center">
|
||||
<p className="text-text-muted text-lg">No employees found</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4 mb-6 sm:mb-8">
|
||||
<div>
|
||||
<h2 className="font-outfit text-2xl sm:text-3xl font-semibold text-text-main tracking-tight">Employees</h2>
|
||||
<p className="text-text-muted mt-1 text-sm">Manage employee records, dispatch status, and role assignments.</p>
|
||||
</div>
|
||||
<button onClick={handleOpenModal} className="w-full sm:w-auto bg-primary hover:bg-primary-hover text-white px-5 py-2.5 rounded-xl text-sm font-semibold shadow-soft transition-all flex items-center justify-center">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Employee
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
{stats.map((s) => (
|
||||
<div key={s.label} className="bg-surface rounded-2xl p-5 border border-border">
|
||||
<p className="text-xs text-text-muted font-medium mb-1">{s.label}</p>
|
||||
<p className={`font-outfit text-2xl font-semibold ${s.color}`}>{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-surface rounded-2xl border border-border shadow-card overflow-hidden">
|
||||
<div className="px-4 sm:px-6 py-4 border-b border-border flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
key="all"
|
||||
onClick={() => setFilter('all')}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
filter === 'all' ? 'bg-primary text-white' : 'text-text-muted hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
{statusCodesLoading ? (
|
||||
<span className="text-xs text-text-muted">Loading...</span>
|
||||
) : (
|
||||
empStatusCodes.map((code) => (
|
||||
<button
|
||||
key={code.codeValue}
|
||||
onClick={() => setFilter(code.codeValue)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
filter === code.codeValue ? 'bg-primary text-white' : 'text-text-muted hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{code.codeName}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="relative w-full sm:w-56">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
|
||||
<input
|
||||
value={skillKeyword}
|
||||
onChange={(e) => setSkillKeyword(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-1.5 rounded-lg bg-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="Search by name or skill..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{/* Desktop table */}
|
||||
<div className="hidden md:block">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="bg-gray-50/50">
|
||||
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted uppercase whitespace-nowrap">이름</th>
|
||||
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted uppercase whitespace-nowrap">권한</th>
|
||||
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted uppercase whitespace-nowrap">상태</th>
|
||||
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted uppercase whitespace-nowrap">이메일</th>
|
||||
<th className="py-3 px-4 sm:px-6 text-xs font-semibold text-text-muted uppercase whitespace-nowrap">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 text-sm">
|
||||
{filtered.map((emp, idx) => (
|
||||
<tr key={emp.empId} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="py-4 px-4 sm:px-6">
|
||||
<div className="flex items-center">
|
||||
<div className={`w-8 h-8 rounded-full ${getInitialColor(emp.name, idx)} flex items-center justify-center text-xs font-medium text-white mr-3 flex-shrink-0`}>
|
||||
{emp.name[0]}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-text-main truncate">{emp.name}</p>
|
||||
<p className="text-xs text-text-muted truncate">{emp.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-4 sm:px-6">
|
||||
<span className="text-xs font-medium text-text-muted">{roleLabel[emp.roleCode] || emp.roleCode}</span>
|
||||
</td>
|
||||
<td className="py-4 px-4 sm:px-6">
|
||||
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${statusColor[emp.empStatus]}`}>
|
||||
{emp.empStatus === 'ACTIVE' ? '재직' : emp.empStatus === 'ON_LEAVE' ? '휴직' : '퇴사'}
|
||||
{emp.dispatchStatus === 'DISPATCHED' && (
|
||||
<span className="ml-1.5 px-1.5 py-0.5 rounded bg-blue-100 text-blue-600 text-[10px]">파견중</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-4 sm:px-6 text-text-muted truncate max-w-[150px] sm:max-w-none">{emp.email}</td>
|
||||
<td className="py-4 px-4 sm:px-6">
|
||||
{user?.roleCode === 'ADMIN' && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleEdit(emp)}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5 text-text-muted hover:text-primary" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(emp)}
|
||||
className="p-1.5 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-text-muted hover:text-status-critical-text" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{user?.roleCode !== 'ADMIN' && (
|
||||
<button
|
||||
onClick={() => router.push(`/profile/${emp.empId}`)}
|
||||
className="p-1.5 text-text-muted hover:text-primary hover:bg-gray-100 rounded-lg transition-all"
|
||||
title="View Profile"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{filtered.map((emp, idx) => (
|
||||
<div key={emp.empId} className="bg-surface rounded-2xl border border-border p-4 space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-white text-sm font-semibold ${getInitialColor(emp.name, idx)}`}>
|
||||
{emp.name.charAt(0)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-main truncate">{emp.name}</p>
|
||||
<p className="text-xs text-text-muted truncate">{roleLabel[emp.roleCode] || emp.roleCode}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-[10px] font-medium px-2.5 py-1 rounded-full ${statusColor[emp.empStatus] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{emp.empStatus === 'ACTIVE' ? '재직' : emp.empStatus === 'ON_LEAVE' ? '휴직' : '퇴사'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-text-muted">
|
||||
<div className="truncate">Email: {emp.email}</div>
|
||||
<div>상태: {emp.dispatchStatus === 'DISPATCHED' ? '파견중' : '대기'}</div>
|
||||
</div>
|
||||
{user?.roleCode === 'ADMIN' && (
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => handleEdit(emp)}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium text-text-muted hover:bg-gray-100 transition-all"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(emp)}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium text-status-critical-text hover:bg-red-50 transition-all"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user