Files
SR-SYSTEM/src/app/(main)/employees/page.tsx
T

846 lines
39 KiB
TypeScript

'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Search, X, Camera, Phone, Mail, Briefcase, Ticket, FileText, ExternalLink } from 'lucide-react';
import employeeService from '@/services/employeeService';
import apiClient from '@/services/apiClient';
import { useCommonCodes } from '@/hooks/useCommonCodes';
import { useAuthStore } from '@/stores';
import type { Employee, RoleCode, EmpStatus, DispatchStatus } from '@/types';
import EmployeeEditModal from '@/components/employees/EmployeeEditModal';
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]));
// Detail Modal State
const [selectedEmployee, setSelectedEmployee] = useState<Employee | null>(null);
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null);
const [detailDispatch, setDetailDispatch] = useState<any[]>([]);
const [detailEquipment, setDetailEquipment] = useState<any[]>([]);
const [detailEmergencyContacts, setDetailEmergencyContacts] = useState<any[]>([]);
const [detailLoading, setDetailLoading] = useState(false);
// Emergency Contact Form State
const [showContactModal, setShowContactModal] = useState(false);
const [contactEditing, setContactEditing] = useState<any | null>(null); // null if creating
const [contactRel, setContactRel] = useState('');
const [contactName, setContactName] = useState('');
const [contactPhone, setContactPhone] = useState('');
const [contactSubmitting, setContactSubmitting] = useState(false);
// Phone Reveal State
const [showPhoneDialog, setShowPhoneDialog] = useState(false);
const [phoneReason, setPhoneReason] = useState('');
const [revealedPhone, setRevealedPhone] = useState('');
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 loadEmergencyContacts = async (empId: number) => {
try {
const res = await apiClient.get<any>(`/employees/${empId}/emergency-contacts`);
if (res?.data) setDetailEmergencyContacts(res.data);
} catch {
setDetailEmergencyContacts([]);
}
};
const openDetail = async (emp: Employee) => {
setSelectedEmployee(emp);
setDetailLoading(true);
setDetailDispatch([]);
setDetailEquipment([]);
setDetailEmergencyContacts([]);
setRevealedPhone('');
setPhoneReason('');
try {
const [dispatchRes, equipRes, contactRes] = await Promise.all([
employeeService.getDispatchHistories(emp.empId).catch(() => null),
apiClient.get<any>(`/employees/${emp.empId}/equipment`).catch(() => null),
apiClient.get<any>(`/employees/${emp.empId}/emergency-contacts`).catch(() => null),
]);
if (dispatchRes?.data) setDetailDispatch(dispatchRes.data);
if (equipRes?.data) setDetailEquipment(equipRes.data);
if (contactRes?.data) setDetailEmergencyContacts(contactRes.data);
} catch {
// silently ignore
}
setDetailLoading(false);
};
const closeDetail = () => {
setSelectedEmployee(null);
setDetailDispatch([]);
setDetailEquipment([]);
setDetailEmergencyContacts([]);
setDetailLoading(false);
setShowPhoneDialog(false);
setPhoneReason('');
setRevealedPhone('');
setShowContactModal(false);
setContactEditing(null);
};
const openContactForm = (contact?: any) => {
if (contact) {
setContactEditing(contact);
setContactRel(contact.relationship || '');
setContactName(contact.contactName || '');
setContactPhone(contact.phone || '');
} else {
setContactEditing(null);
setContactRel('');
setContactName('');
setContactPhone('');
}
setShowContactModal(true);
};
const handleSaveContact = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedEmployee || !contactRel.trim() || !contactName.trim() || !contactPhone.trim()) return;
setContactSubmitting(true);
try {
const payload = {
relationship: contactRel.trim(),
contactName: contactName.trim(),
phone: contactPhone.trim(),
};
if (contactEditing?.contactId) {
await apiClient.put(`/employees/${selectedEmployee.empId}/emergency-contacts/${contactEditing.contactId}`, payload);
} else {
await apiClient.post(`/employees/${selectedEmployee.empId}/emergency-contacts`, payload);
}
await loadEmergencyContacts(selectedEmployee.empId);
setShowContactModal(false);
} catch (err) {
console.error('Failed to save emergency contact', err);
} finally {
setContactSubmitting(false);
}
};
const handleDeleteContact = async (contactId: number) => {
if (!selectedEmployee || !confirm('이 비상연락처를 삭제하시겠습니까?')) return;
try {
await apiClient.delete(`/employees/${selectedEmployee.empId}/emergency-contacts/${contactId}`);
await loadEmergencyContacts(selectedEmployee.empId);
} catch (err) {
console.error('Failed to delete emergency contact', err);
}
};
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' },
];
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit' });
};
// ── Detail Modal ──
if (selectedEmployee) {
const emp = selectedEmployee;
const currentDispatch = detailDispatch.find((d: any) => !d.endDate) || null;
const activeEquipments = (detailEquipment || []).filter((eq: any) => !eq.returnedDate && eq.equipment);
const canManageContacts = user?.roleCode === 'ADMIN' || user?.roleCode === 'MANAGER';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={closeDetail}>
<div
className="bg-surface rounded-2xl shadow-xl max-w-lg w-full max-h-[85vh] overflow-y-auto animate-in fade-in zoom-in duration-200"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="sticky top-0 bg-surface rounded-t-2xl border-b border-border px-6 py-4 flex items-center justify-between z-10">
<h3 className="font-outfit text-lg font-semibold text-text-main">직원 상세 정보</h3>
<button
onClick={closeDetail}
className="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>
</div>
{detailLoading ? (
<div className="p-6 space-y-6">
<div className="bg-white rounded-2xl border border-border p-5">
<div className="flex items-center gap-4 mb-4">
<div className="w-16 h-16 rounded-full bg-gray-200 animate-pulse" />
<div className="space-y-2 flex-1">
<div className="h-5 w-32 bg-gray-200 rounded animate-pulse" />
<div className="h-4 w-24 bg-gray-200 rounded animate-pulse" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-4 bg-gray-100 rounded animate-pulse" />
))}
</div>
</div>
<div className="bg-white rounded-2xl border border-border p-5">
<div className="h-5 w-24 bg-gray-200 rounded mb-3 animate-pulse" />
<div className="h-16 bg-gray-100 rounded-xl animate-pulse" />
</div>
</div>
) : (
<div className="p-6 space-y-5">
{/* Profile Card */}
<div className="bg-white rounded-2xl border border-border p-5">
<div className="flex items-center gap-4 mb-4">
{emp.profileImage ? (
<img
src={emp.profileImage}
alt={emp.name}
className="w-16 h-16 rounded-full object-cover border-2 border-border"
/>
) : (
<div className={`w-16 h-16 rounded-full ${getInitialColor(emp.name, 0)} flex items-center justify-center text-xl font-semibold text-white`}>
{emp.name[0]}
</div>
)}
<div className="flex-1 min-w-0">
<h4 className="font-outfit text-lg font-semibold text-text-main">{emp.name}</h4>
<div className="flex flex-wrap items-center gap-1.5 mt-1">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium bg-primary/10 text-primary">
{roleLabel[emp.roleCode] || emp.roleCode}
</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium ${statusColor[emp.empStatus]}`}>
{emp.empStatus === 'ACTIVE' ? '재직' : emp.empStatus === 'ON_LEAVE' ? '휴직' : '퇴사'}
</span>
{emp.dispatchStatus === 'DISPATCHED' && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium bg-blue-100 text-blue-600">
파견중
</span>
)}
</div>
</div>
</div>
{/* Contact & Personal Info */}
<div className="grid grid-cols-2 gap-3 text-sm">
{emp.email && (
<div className="flex items-center gap-2 text-text-muted">
<Mail className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{emp.email}</span>
</div>
)}
<div className="flex items-center gap-2 text-text-muted">
<Phone className="w-3.5 h-3.5 flex-shrink-0" />
{emp.phone ? (
revealedPhone ? (
<span>{revealedPhone}</span>
) : (
<button
onClick={() => setShowPhoneDialog(true)}
className="text-xs text-primary hover:underline font-medium"
>
🔒 전화번호 보기
</button>
)
) : (
<span>-</span>
)}
</div>
</div>
</div>
{/* Emergency Contacts Section */}
<div>
<div className="flex items-center justify-between mb-2">
<h5 className="text-xs font-semibold text-text-muted uppercase tracking-wide flex items-center gap-1.5">
<Phone className="w-3.5 h-3.5" />
비상 연락망
</h5>
{canManageContacts && (
<button
onClick={() => openContactForm()}
className="text-xs font-medium text-primary hover:underline"
>
+ 추가
</button>
)}
</div>
{detailEmergencyContacts.length === 0 ? (
<div className="bg-white rounded-2xl border border-border p-4 text-center text-xs text-text-muted">
등록된 비상연락처가 없습니다.
</div>
) : (
<div className="bg-white rounded-2xl border border-border divide-y divide-gray-100 overflow-hidden">
{detailEmergencyContacts.map((contact: any) => (
<div key={contact.contactId} className="p-3 flex items-center justify-between">
<div>
<p className="text-xs font-semibold text-text-main">
{contact.contactName} <span className="text-text-muted font-normal">({contact.relationship})</span>
</p>
<p className="text-xs text-text-muted mt-0.5">{contact.phone}</p>
</div>
{canManageContacts && (
<div className="flex items-center gap-1.5">
<button
onClick={() => openContactForm(contact)}
className="text-[11px] text-primary hover:underline"
>
수정
</button>
<span className="text-text-muted text-[10px]">·</span>
<button
onClick={() => handleDeleteContact(contact.contactId)}
className="text-[11px] text-status-critical hover:underline"
>
삭제
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
{/* Current Dispatch */}
{currentDispatch && (
<div>
<h5 className="text-xs font-semibold text-text-muted uppercase tracking-wide mb-2 flex items-center gap-1.5">
<Briefcase className="w-3.5 h-3.5" />
현재 파견
</h5>
<div className="bg-white rounded-2xl border border-border p-4">
<div className="flex items-center justify-between mb-2">
<p className="font-medium text-text-main">{currentDispatch.projectName || 'Project'}</p>
<span className="text-xs text-text-muted">
{formatDate(currentDispatch.startDate)} ~ 진행중
</span>
</div>
{currentDispatch.roleTitle && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-indigo-50 text-indigo-600">
{currentDispatch.roleTitle}
</span>
)}
</div>
</div>
)}
{/* Quick Links */}
<div>
<h5 className="text-xs font-semibold text-text-muted uppercase tracking-wide mb-2">빠른 이동</h5>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => router.push(`/profile/${emp.empId}`)}
className="flex items-center justify-between px-3 py-2.5 rounded-xl bg-primary/5 border border-primary/20 text-sm font-medium text-primary hover:bg-primary/10 transition-all col-span-2"
>
<span className="flex items-center gap-2">
<ExternalLink className="w-4 h-4" />
프로필 바로가기 (관리자/통합 뷰)
</span>
<span>↗</span>
</button>
<button
onClick={() => router.push(`/dispatch?empId=${emp.empId}`)}
className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-white border border-border text-sm text-text-main hover:bg-gray-50 transition-all"
>
<Briefcase className="w-4 h-4 text-text-muted" />
파견이력
</button>
<button
onClick={() => router.push(`/itam?userId=${emp.empId}`)}
className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-white border border-border text-sm text-text-main hover:bg-gray-50 transition-all"
>
<Camera className="w-4 h-4 text-text-muted" />
지급장비
</button>
<button
onClick={() => router.push('/profile/portfolio')}
className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-white border border-border text-sm text-text-main hover:bg-gray-50 transition-all"
>
<FileText className="w-4 h-4 text-text-muted" />
포트폴리오
</button>
<button
onClick={() => router.push(`/issues?requesterId=${emp.empId}`)}
className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-white border border-border text-sm text-text-main hover:bg-gray-50 transition-all"
>
<Ticket className="w-4 h-4 text-text-muted" />
SR 티켓
</button>
<button
onClick={() => router.push(`/work-reports?userId=${emp.empId}`)}
className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-white border border-border text-sm text-text-main hover:bg-gray-50 transition-all col-span-2"
>
<FileText className="w-4 h-4 text-text-muted" />
업무보고서
</button>
</div>
</div>
{/* Assigned Equipment */}
<div>
<h5 className="text-xs font-semibold text-text-muted uppercase tracking-wide mb-2 flex items-center gap-1.5">
<ExternalLink className="w-3.5 h-3.5" />
지급 장비
</h5>
{activeEquipments.length === 0 ? (
<div className="bg-white rounded-2xl border border-border p-4 text-center text-sm text-text-muted">
현재 지급된 장비가 없습니다.
</div>
) : (
<div className="bg-white rounded-2xl border border-border overflow-hidden">
<table className="w-full text-left text-sm">
<thead>
<tr className="bg-gray-50/50">
<th className="py-2 px-3 text-[10px] font-semibold text-text-muted uppercase">자산코드</th>
<th className="py-2 px-3 text-[10px] font-semibold text-text-muted uppercase">모델명</th>
<th className="py-2 px-3 text-[10px] font-semibold text-text-muted uppercase">제조사</th>
<th className="py-2 px-3 text-[10px] font-semibold text-text-muted uppercase">지급일</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{activeEquipments.map((eq: any) => (
<tr key={eq.assignmentId} className="hover:bg-gray-50">
<td className="py-2 px-3 font-mono text-xs">{eq.equipment?.assetCode || '-'}</td>
<td className="py-2 px-3">{eq.equipment?.modelName || '-'}</td>
<td className="py-2 px-3">{eq.equipment?.manufacturer || '-'}</td>
<td className="py-2 px-3 text-xs text-text-muted">{formatDate(eq.assignedDate)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
</div>
{/* Emergency Contact Modal */}
{showContactModal && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={() => setShowContactModal(false)}>
<form
onSubmit={handleSaveContact}
className="bg-surface rounded-2xl shadow-xl max-w-sm w-full p-6 animate-in fade-in zoom-in duration-200"
onClick={(e) => e.stopPropagation()}
>
<h4 className="font-outfit text-lg font-semibold text-text-main mb-4">
{contactEditing ? '비상연락처 수정' : '비상연락처 추가'}
</h4>
<div className="space-y-3 mb-5">
<div>
<label className="block text-xs font-semibold text-text-muted mb-1">관계 (예: 부, 모, 배우자)</label>
<input
type="text"
required
value={contactRel}
onChange={(e) => setContactRel(e.target.value)}
className="w-full border border-border rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="관계 입력"
/>
</div>
<div>
<label className="block text-xs font-semibold text-text-muted mb-1">성명</label>
<input
type="text"
required
value={contactName}
onChange={(e) => setContactName(e.target.value)}
className="w-full border border-border rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="성명 입력"
/>
</div>
<div>
<label className="block text-xs font-semibold text-text-muted mb-1">연락처</label>
<input
type="text"
required
value={contactPhone}
onChange={(e) => setContactPhone(e.target.value)}
className="w-full border border-border rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="010-0000-0000"
/>
</div>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setShowContactModal(false)}
className="px-4 py-2 rounded-xl text-sm text-text-muted hover:bg-gray-100 transition-all"
>
취소
</button>
<button
type="submit"
disabled={contactSubmitting}
className="px-4 py-2 rounded-xl text-sm font-medium bg-primary text-white hover:bg-primary/90 transition-all disabled:opacity-50"
>
{contactSubmitting ? '저장 중...' : '저장'}
</button>
</div>
</form>
</div>
)}
{/* Phone Reveal Dialog */}
{showPhoneDialog && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={() => { setShowPhoneDialog(false); setPhoneReason(''); }}>
<div className="bg-surface rounded-2xl shadow-xl max-w-sm w-full p-6 animate-in fade-in zoom-in duration-200" onClick={(e) => e.stopPropagation()}>
<h4 className="font-outfit text-lg font-semibold text-text-main mb-2">전화번호 조회</h4>
<p className="text-sm text-text-muted mb-4">조회 사유를 입력해주세요.</p>
<textarea
value={phoneReason}
onChange={(e) => setPhoneReason(e.target.value)}
className="w-full border border-border rounded-xl p-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary resize-none h-24 mb-4"
placeholder="예: 업무 관련 연락 필요"
/>
<div className="flex justify-end gap-2">
<button
onClick={() => { setShowPhoneDialog(false); setPhoneReason(''); }}
className="px-4 py-2 rounded-xl text-sm text-text-muted hover:bg-gray-100 transition-all"
>
취소
</button>
<button
onClick={async () => {
if (!selectedEmployee || !phoneReason.trim()) return;
try {
const token = localStorage.getItem('accessToken');
const res = await fetch(`/api/v1/employees/${selectedEmployee.empId}/request-phone`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ reason: phoneReason }),
});
const data = await res.json();
if (data.success && data.data?.phone) {
setRevealedPhone(data.data.phone);
setShowPhoneDialog(false);
setPhoneReason('');
}
} catch {
// silently ignore
}
}}
disabled={!phoneReason.trim()}
className="px-4 py-2 rounded-xl text-sm font-medium bg-primary text-white hover:bg-primary/90 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
조회
</button>
</div>
</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>
</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>
</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}
onClick={() => openDetail(emp)}
className="hover:bg-gray-50 transition-colors cursor-pointer"
>
<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">
<span className="text-xs text-text-muted hover:text-primary transition-colors">상세 →</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="md:hidden space-y-3">
{filtered.map((emp, idx) => (
<div
key={emp.empId}
onClick={() => openDetail(emp)}
className="bg-surface rounded-2xl border border-border p-4 space-y-2 cursor-pointer hover:bg-gray-50 transition-colors"
>
<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>
<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>
<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>
</div>
))}
</div>
</div>
</div>
</div>
{/* Employee Edit Modal */}
<EmployeeEditModal
isOpen={!!editingEmployee}
onClose={() => setEditingEmployee(null)}
employee={editingEmployee}
onSuccess={() => {
fetchEmployees();
}}
/>
</div>
);
}