feat: sensitive fields removal + phone encryption + click-to-reveal + audit log

This commit is contained in:
pray4skylark
2026-06-10 16:23:39 +09:00
parent 969efe3fc3
commit cae0225f80
13 changed files with 313 additions and 257 deletions
+80 -39
View File
@@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Search, X, Camera, Phone, Mail, Calendar, MapPin, GraduationCap, Briefcase, Ticket, FileText, ExternalLink } from 'lucide-react';
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';
@@ -40,6 +40,11 @@ export default function EmployeesPage() {
const [detailEquipment, setDetailEquipment] = useState<any[]>([]);
const [detailLoading, setDetailLoading] = useState(false);
// Phone Reveal State
const [showPhoneDialog, setShowPhoneDialog] = useState(false);
const [phoneReason, setPhoneReason] = useState('');
const [revealedPhone, setRevealedPhone] = useState('');
const fetchEmployees = async () => {
try {
setLoading(true);
@@ -71,6 +76,8 @@ export default function EmployeesPage() {
setDetailLoading(true);
setDetailDispatch([]);
setDetailEquipment([]);
setRevealedPhone('');
setPhoneReason('');
try {
const [dispatchRes, equipRes] = await Promise.all([
@@ -92,6 +99,9 @@ export default function EmployeesPage() {
setDetailDispatch([]);
setDetailEquipment([]);
setDetailLoading(false);
setShowPhoneDialog(false);
setPhoneReason('');
setRevealedPhone('');
};
const [skillKeyword, setSkillKeyword] = useState('');
@@ -123,14 +133,6 @@ export default function EmployeesPage() {
return new Date(dateStr).toLocaleDateString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit' });
};
const educationLabel: Record<string, string> = {
HIGH_SCHOOL: '고졸',
ASSOCIATE: '전문대',
BACHELOR: '학사',
MASTER: '석사',
DOCTORATE: '박사',
};
// ── Detail Modal ──
if (selectedEmployee) {
const emp = selectedEmployee;
@@ -217,36 +219,23 @@ export default function EmployeesPage() {
<span className="truncate">{emp.email}</span>
</div>
)}
{emp.phone && (
<div className="flex items-center gap-2 text-text-muted">
<Phone className="w-3.5 h-3.5 flex-shrink-0" />
<span>{emp.phone}</span>
</div>
)}
{emp.birthDate && (
<div className="flex items-center gap-2 text-text-muted">
<Calendar className="w-3.5 h-3.5 flex-shrink-0" />
<span>{formatDate(emp.birthDate)}</span>
</div>
)}
{emp.residence && (
<div className="flex items-center gap-2 text-text-muted">
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{emp.residence}</span>
</div>
)}
{emp.education && (
<div className="flex items-center gap-2 text-text-muted">
<GraduationCap className="w-3.5 h-3.5 flex-shrink-0" />
<span>{educationLabel[emp.education] || emp.education}{emp.schoolName ? ` · ${emp.schoolName}` : ''}{emp.major ? ` · ${emp.major}` : ''}</span>
</div>
)}
{emp.graduationDate && (
<div className="flex items-center gap-2 text-text-muted">
<Calendar className="w-3.5 h-3.5 flex-shrink-0" />
<span>졸업 {formatDate(emp.graduationDate)}</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>
@@ -353,6 +342,58 @@ export default function EmployeesPage() {
</div>
)}
</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>
);
}