feat: 감사 로그 API 연동, 직원 상세 모달 내 비상연락처 관리 및 프로필 링크 추가, 목록 수정 버튼 제거

This commit is contained in:
pray4skylark
2026-09-30 18:11:14 +09:00
parent e6fbd40ac1
commit df950f258d
5 changed files with 388 additions and 70 deletions
+219 -30
View File
@@ -40,8 +40,17 @@ export default function EmployeesPage() {
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('');
@@ -73,22 +82,34 @@ export default function EmployeesPage() {
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] = await Promise.all([
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
}
@@ -100,10 +121,65 @@ export default function EmployeesPage() {
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('');
@@ -140,6 +216,7 @@ export default function EmployeesPage() {
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}>
@@ -149,7 +226,7 @@ export default function EmployeesPage() {
>
{/* 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">Employee Detail</h3>
<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"
@@ -241,6 +318,59 @@ export default function EmployeesPage() {
</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>
@@ -268,6 +398,16 @@ export default function EmployeesPage() {
<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"
@@ -296,6 +436,15 @@ export default function EmployeesPage() {
<Ticket className="w-4 h-4 text-text-muted" />
SR 티켓
</button>
</div>
</div>
<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"
@@ -345,6 +494,72 @@ export default function EmployeesPage() {
)}
</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(''); }}>
@@ -582,21 +797,7 @@ export default function EmployeesPage() {
</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">
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted hover:text-primary transition-colors">상세 →</span>
{(user?.roleCode === 'ADMIN' || user?.roleCode === 'MANAGER') && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setEditingEmployee(emp);
}}
className="px-2 py-1 bg-primary/10 text-primary hover:bg-primary/20 rounded text-xs font-semibold transition-colors"
>
수정
</button>
)}
</div>
<span className="text-xs text-text-muted hover:text-primary transition-colors">상세 →</span>
</td>
</tr>
))}
@@ -622,22 +823,10 @@ export default function EmployeesPage() {
<p className="text-xs text-text-muted truncate">{roleLabel[emp.roleCode] || emp.roleCode}</p>
</div>
</div>
<div className="flex items-center gap-2">
<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>
{(user?.roleCode === 'ADMIN' || user?.roleCode === 'MANAGER') && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setEditingEmployee(emp);
}}
className="px-2 py-1 bg-primary/10 text-primary hover:bg-primary/20 rounded text-xs font-semibold"
>
수정
</button>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-text-muted">