feat: 직원 동적 프로필 조회 페이지(/profile/[empId]) 구현하여 404 에러 해결

This commit is contained in:
pray4skylark
2026-09-30 18:28:38 +09:00
parent 1c5b1eec2a
commit 54976ba6f4
+623
View File
@@ -0,0 +1,623 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import {
UserCircle,
Mail,
Briefcase,
Calendar,
Phone,
Plus,
Pencil,
Trash2,
X,
AlertCircle,
ArrowLeft,
Shield,
Loader2,
ExternalLink,
} from 'lucide-react';
import employeeService from '@/services/employeeService';
import apiClient from '@/services/apiClient';
import { useAuthStore } from '@/stores';
import type { Employee, DispatchHistory } from '@/types';
interface EmergencyContactItem {
contactId?: number;
id?: number;
relationship?: string;
relation?: string;
contactName?: string;
name?: string;
phone: string;
}
export default function DynamicEmployeeProfilePage() {
const params = useParams();
const router = useRouter();
const currentUser = useAuthStore((s) => s.user);
const empId = Number(params?.empId);
const [employee, setEmployee] = useState<Employee | null>(null);
const [dispatchHistories, setDispatchHistories] = useState<DispatchHistory[]>([]);
const [emergencyContacts, setEmergencyContacts] = useState<EmergencyContactItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Phone Reveal Dialog State
const [showPhoneDialog, setShowPhoneDialog] = useState(false);
const [phoneReason, setPhoneReason] = useState('');
const [revealedPhone, setRevealedPhone] = useState('');
const [phoneLoading, setPhoneLoading] = useState(false);
// Emergency Contact Modal State
const [contactModalOpen, setContactModalOpen] = useState(false);
const [editingContact, setEditingContact] = useState<EmergencyContactItem | null>(null);
const [contactRelation, setContactRelation] = useState('');
const [contactName, setContactName] = useState('');
const [contactPhone, setContactPhone] = useState('');
const [contactSubmitting, setContactSubmitting] = useState(false);
const canManage =
currentUser?.roleCode === 'ADMIN' ||
currentUser?.roleCode === 'MANAGER' ||
currentUser?.empId === empId;
const loadEmployeeData = async () => {
if (!empId || isNaN(empId)) {
setError('유효하지 않은 직원 ID입니다.');
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const [empRes, historyRes, contactRes] = await Promise.allSettled([
employeeService.getById(empId),
employeeService.getDispatchHistories(empId),
apiClient.get<any>(`/employees/${empId}/emergency-contacts`),
]);
if (empRes.status === 'fulfilled' && empRes.value?.success) {
setEmployee(empRes.value.data);
} else {
setError('직원 정보를 찾을 수 없습니다.');
return;
}
if (historyRes.status === 'fulfilled' && historyRes.value?.success) {
setDispatchHistories(Array.isArray(historyRes.value.data) ? historyRes.value.data : []);
}
if (contactRes.status === 'fulfilled' && contactRes.value?.data) {
setEmergencyContacts(contactRes.value.data);
}
} catch (err) {
console.error(err);
setError('직원 프로필 정보를 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadEmployeeData();
}, [empId]);
// Request Phone Number (Audited)
const handleRequestPhone = async () => {
if (!phoneReason.trim()) return;
try {
setPhoneLoading(true);
const token = localStorage.getItem('accessToken');
const res = await fetch(`/api/v1/employees/${empId}/request-phone`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ reason: phoneReason.trim() }),
});
const data = await res.json();
if (data.success && data.data?.phone) {
setRevealedPhone(data.data.phone);
setShowPhoneDialog(false);
setPhoneReason('');
} else {
alert(data.message || '전화번호 조회에 실패했습니다.');
}
} catch {
alert('전화번호 조회 중 오류가 발생했습니다.');
} finally {
setPhoneLoading(false);
}
};
// Contact CRUD
const openContactModal = (contact?: EmergencyContactItem) => {
if (contact) {
setEditingContact(contact);
setContactRelation(contact.relationship || contact.relation || '');
setContactName(contact.contactName || contact.name || '');
setContactPhone(contact.phone || '');
} else {
setEditingContact(null);
setContactRelation('');
setContactName('');
setContactPhone('');
}
setContactModalOpen(true);
};
const handleSaveContact = async (e: React.FormEvent) => {
e.preventDefault();
if (!contactRelation.trim() || !contactName.trim() || !contactPhone.trim()) return;
try {
setContactSubmitting(true);
const payload = {
relationship: contactRelation.trim(),
contactName: contactName.trim(),
phone: contactPhone.trim(),
};
const contactId = editingContact?.contactId || editingContact?.id;
if (contactId) {
await apiClient.put(`/employees/${empId}/emergency-contacts/${contactId}`, payload);
} else {
await apiClient.post(`/employees/${empId}/emergency-contacts`, payload);
}
const res = await apiClient.get<any>(`/employees/${empId}/emergency-contacts`);
if (res?.data) setEmergencyContacts(res.data);
setContactModalOpen(false);
} catch (err) {
console.error(err);
alert('비상 연락처 저장에 실패했습니다.');
} finally {
setContactSubmitting(false);
}
};
const handleDeleteContact = async (contactItem: EmergencyContactItem) => {
const contactId = contactItem.contactId || contactItem.id;
if (!contactId || !confirm('이 비상연락처를 삭제하시겠습니까?')) return;
try {
await apiClient.delete(`/employees/${empId}/emergency-contacts/${contactId}`);
setEmergencyContacts((prev) =>
prev.filter((c) => (c.contactId || c.id) !== contactId)
);
} catch (err) {
console.error(err);
alert('비상연락처 삭제에 실패했습니다.');
}
};
const getRoleBadge = (roleCode: string) => {
switch (roleCode) {
case 'ADMIN':
return 'bg-red-50 text-red-700 border-red-200';
case 'MANAGER':
return 'bg-indigo-50 text-indigo-700 border-indigo-200';
default:
return 'bg-gray-100 text-gray-700 border-gray-200';
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'ACTIVE':
return 'bg-emerald-50 text-emerald-700 border-emerald-200';
case 'ON_LEAVE':
return 'bg-amber-50 text-amber-700 border-amber-200';
default:
return 'bg-gray-100 text-gray-700 border-gray-200';
}
};
if (loading) {
return (
<div className="p-4 sm:p-6 lg:p-8 flex items-center justify-center min-h-[60vh]">
<div className="text-center">
<Loader2 className="w-10 h-10 text-primary animate-spin mx-auto mb-3" />
<p className="text-text-muted text-sm">직원 프로필 정보를 불러오는 중...</p>
</div>
</div>
);
}
if (error || !employee) {
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-4xl mx-auto">
<div className="bg-surface rounded-2xl border border-border p-12 text-center shadow-card">
<AlertCircle className="w-12 h-12 text-status-critical mx-auto mb-4" />
<h3 className="font-outfit text-lg font-semibold text-text-main mb-1">
프로필을 찾을 수 없습니다
</h3>
<p className="text-text-muted text-sm mb-6">{error || '해당 직원의 정보가 존재하지 않습니다.'}</p>
<button
onClick={() => router.push('/employees')}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-primary text-white rounded-xl text-sm font-medium hover:bg-primary/90 transition-all"
>
<ArrowLeft className="w-4 h-4" />
직원 목록으로 돌아가기
</button>
</div>
</div>
</div>
);
}
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="max-w-4xl mx-auto space-y-6">
{/* Navigation Bar */}
<div className="flex items-center justify-between">
<button
onClick={() => router.push('/employees')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-border bg-white text-text-muted text-xs font-medium hover:bg-gray-50 transition-all"
>
<ArrowLeft className="w-3.5 h-3.5" />
직원 목록
</button>
<div className="text-xs text-text-muted">직원 ID: #{employee.empId}</div>
</div>
{/* Profile Header Card */}
<div className="bg-surface rounded-2xl shadow-card border border-border p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-5">
{employee.profileImage ? (
<img
src={employee.profileImage}
alt={employee.name}
className="w-20 h-20 rounded-2xl object-cover border-2 border-border"
/>
) : (
<div className="w-20 h-20 rounded-2xl bg-indigo-500 flex items-center justify-center text-white font-bold text-2xl">
{employee.name[0]}
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-1.5">
<h2 className="font-outfit text-2xl font-semibold text-text-main tracking-tight">
{employee.name}
</h2>
<span
className={`px-2.5 py-0.5 rounded-full text-xs font-semibold border ${getRoleBadge(
employee.roleCode
)}`}
>
{employee.roleCode}
</span>
<span
className={`px-2.5 py-0.5 rounded-full text-xs font-semibold border ${getStatusBadge(
employee.empStatus
)}`}
>
{employee.empStatus === 'ACTIVE'
? '재직'
: employee.empStatus === 'ON_LEAVE'
? '휴직'
: '퇴사'}
</span>
{employee.dispatchStatus === 'DISPATCHED' && (
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-blue-50 text-blue-700 border border-blue-200">
파견중
</span>
)}
</div>
<p className="text-sm text-text-muted">{employee.email}</p>
</div>
</div>
</div>
{/* Contact Info & Details */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Basic Contact Info */}
<div className="bg-surface rounded-2xl shadow-card border border-border p-6">
<h3 className="font-outfit text-base font-semibold text-text-main mb-4 flex items-center gap-2">
<Mail className="w-4 h-4 text-primary" />
기본 연락처 정보
</h3>
<div className="space-y-3.5 text-sm">
<div className="flex items-center justify-between border-b border-gray-100 pb-2.5">
<span className="text-text-muted">이메일</span>
<span className="font-medium text-text-main">{employee.email || '-'}</span>
</div>
<div className="flex items-center justify-between border-b border-gray-100 pb-2.5">
<span className="text-text-muted">전화번호</span>
<div>
{employee.phone ? (
revealedPhone ? (
<span className="font-mono text-text-main font-semibold">{revealedPhone}</span>
) : (
<button
onClick={() => setShowPhoneDialog(true)}
className="inline-flex items-center gap-1 text-xs text-primary font-medium hover:underline"
>
<Shield className="w-3.5 h-3.5" />
🔒 전화번호 보기
</button>
)
) : (
<span className="text-text-muted">-</span>
)}
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-text-muted">직원 구분</span>
<span className="font-medium text-text-main">{employee.roleCode}</span>
</div>
</div>
</div>
{/* Quick Actions / Associated Views */}
<div className="bg-surface rounded-2xl shadow-card border border-border p-6">
<h3 className="font-outfit text-base font-semibold text-text-main mb-4 flex items-center gap-2">
<ExternalLink className="w-4 h-4 text-primary" />
연관 페이지 이동
</h3>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => router.push(`/dispatch?empId=${empId}`)}
className="p-3 rounded-xl border border-border bg-white text-left hover:bg-gray-50 transition-all text-xs font-medium text-text-main flex items-center justify-between"
>
<span>파견 관리</span>
<span className="text-text-muted">↗</span>
</button>
<button
onClick={() => router.push(`/itam?userId=${empId}`)}
className="p-3 rounded-xl border border-border bg-white text-left hover:bg-gray-50 transition-all text-xs font-medium text-text-main flex items-center justify-between"
>
<span>지급 장비 (ITAM)</span>
<span className="text-text-muted">↗</span>
</button>
<button
onClick={() => router.push(`/issues?requesterId=${empId}`)}
className="p-3 rounded-xl border border-border bg-white text-left hover:bg-gray-50 transition-all text-xs font-medium text-text-main flex items-center justify-between"
>
<span>요청 SR 티켓</span>
<span className="text-text-muted">↗</span>
</button>
<button
onClick={() => router.push(`/work-reports?userId=${empId}`)}
className="p-3 rounded-xl border border-border bg-white text-left hover:bg-gray-50 transition-all text-xs font-medium text-text-main flex items-center justify-between"
>
<span>업무 보고서</span>
<span className="text-text-muted">↗</span>
</button>
</div>
</div>
</div>
{/* Emergency Contacts Section */}
<div className="bg-surface rounded-2xl shadow-card border border-border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-outfit text-base font-semibold text-text-main flex items-center gap-2">
<Phone className="w-4 h-4 text-primary" />
비상 연락망
</h3>
{canManage && (
<button
onClick={() => openContactModal()}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-xl bg-primary/10 text-primary text-xs font-semibold hover:bg-primary/20 transition-all"
>
<Plus className="w-3.5 h-3.5" />
연락처 추가
</button>
)}
</div>
{emergencyContacts.length === 0 ? (
<div className="p-8 text-center text-text-muted text-xs bg-gray-50/50 rounded-xl border border-dashed border-border">
등록된 비상연락처 정보가 없습니다.
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{emergencyContacts.map((contact, idx) => {
const nameStr = contact.contactName || contact.name || '연락처';
const relStr = contact.relationship || contact.relation || '관계';
return (
<div
key={contact.contactId || contact.id || idx}
className="p-4 rounded-xl border border-border bg-white flex items-center justify-between"
>
<div>
<p className="text-sm font-semibold text-text-main">
{nameStr} <span className="text-xs text-text-muted font-normal">({relStr})</span>
</p>
<p className="text-xs text-text-muted font-mono mt-0.5">{contact.phone}</p>
</div>
{canManage && (
<div className="flex items-center gap-1">
<button
onClick={() => openContactModal(contact)}
className="p-1.5 text-text-muted hover:text-primary transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDeleteContact(contact)}
className="p-1.5 text-text-muted hover:text-status-critical transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* Dispatch Histories Section */}
<div className="bg-surface rounded-2xl shadow-card border border-border p-6">
<h3 className="font-outfit text-base font-semibold text-text-main mb-4 flex items-center gap-2">
<Briefcase className="w-4 h-4 text-primary" />
파견 프로젝트 이력
</h3>
{dispatchHistories.length === 0 ? (
<div className="p-8 text-center text-text-muted text-xs bg-gray-50/50 rounded-xl border border-dashed border-border">
파견 이력 데이터가 없습니다.
</div>
) : (
<div className="space-y-3">
{dispatchHistories.map((dh) => (
<div
key={dh.dispatchId}
className="p-4 rounded-xl border border-border bg-white flex flex-col sm:flex-row sm:items-center justify-between gap-2"
>
<div>
<h4 className="text-sm font-semibold text-text-main">
{dh.projectName || '프로젝트'}
</h4>
{dh.roleTitle && (
<p className="text-xs text-primary font-medium mt-0.5">{dh.roleTitle}</p>
)}
</div>
<div className="text-xs text-text-muted">
{dh.startDate} ~ {dh.endDate || '진행중'}
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Phone Reveal Dialog */}
{showPhoneDialog && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
onClick={() => setShowPhoneDialog(false)}
>
<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-1">
전화번호 열람 요청
</h4>
<p className="text-xs text-text-muted mb-4">
개인정보 보호를 위해 전화번호 열람 사유를 입력해주세요. (감사로그 기록됨)
</p>
<textarea
value={phoneReason}
onChange={(e) => setPhoneReason(e.target.value)}
placeholder="예: 프로젝트 비상 연락 필요"
className="w-full border border-border rounded-xl p-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary h-24 resize-none mb-4"
/>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowPhoneDialog(false)}
className="px-4 py-2 rounded-xl text-xs text-text-muted hover:bg-gray-100 transition-all"
>
취소
</button>
<button
onClick={handleRequestPhone}
disabled={!phoneReason.trim() || phoneLoading}
className="px-4 py-2 rounded-xl text-xs font-semibold bg-primary text-white hover:bg-primary/90 transition-all disabled:opacity-50"
>
{phoneLoading ? '조회 중...' : '조회하기'}
</button>
</div>
</div>
</div>
)}
{/* Emergency Contact Edit Modal */}
{contactModalOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
onClick={() => setContactModalOpen(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()}
>
<div className="flex items-center justify-between mb-4">
<h4 className="font-outfit text-lg font-semibold text-text-main">
{editingContact ? '비상연락처 수정' : '비상연락처 추가'}
</h4>
<button
type="button"
onClick={() => setContactModalOpen(false)}
className="p-1 text-text-muted hover:text-text-main"
>
<X className="w-4 h-4" />
</button>
</div>
<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={contactRelation}
onChange={(e) => setContactRelation(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={() => setContactModalOpen(false)}
className="px-4 py-2 rounded-xl text-xs text-text-muted hover:bg-gray-100 transition-all"
>
취소
</button>
<button
type="submit"
disabled={contactSubmitting}
className="px-4 py-2 rounded-xl text-xs font-semibold bg-primary text-white hover:bg-primary/90 transition-all disabled:opacity-50"
>
{contactSubmitting ? '저장 중...' : '저장'}
</button>
</div>
</form>
</div>
)}
</div>
);
}