846 lines
39 KiB
TypeScript
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>
|
|
);
|
|
}
|