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>
);
}
+55 -52
View File
@@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { UserCircle, Mail, Briefcase, Calendar, Lock, Shield, CheckCircle, XCircle, AlertCircle, Phone, Plus, Pencil, Trash2, X, Save, GraduationCap, MapPin, Camera, Loader2 } from 'lucide-react';
import { UserCircle, Mail, Briefcase, Calendar, Lock, Shield, CheckCircle, XCircle, AlertCircle, Phone, Plus, Pencil, Trash2, X, Save, Camera, Loader2 } from 'lucide-react';
import employeeService from '@/services/employeeService';
import type { ApiResponse, Employee, DispatchHistory, EmergencyContact } from '@/types';
@@ -10,14 +10,6 @@ interface EmployeeWithId extends Employee {
empId: number;
}
const EDUCATION_LABELS: Record<string, string> = {
HIGH_SCHOOL: '고졸',
COLLEGE: '전문대',
BACHELOR: '학사',
MASTER: '석사',
DOCTOR: '박사',
};
export default function ProfilePage() {
const router = useRouter();
const [employee, setEmployee] = useState<EmployeeWithId | null>(null);
@@ -41,6 +33,11 @@ export default function ProfilePage() {
const [contactFormError, setContactFormError] = useState<string | null>(null);
const [isSavingContact, setIsSavingContact] = useState(false);
// Phone reveal state
const [showPhoneDialog, setShowPhoneDialog] = useState(false);
const [phoneReason, setPhoneReason] = useState('');
const [revealedPhone, setRevealedPhone] = useState('');
useEffect(() => {
loadProfile();
}, []);
@@ -365,50 +362,23 @@ export default function ProfilePage() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<div className="flex items-center gap-2 text-text-muted text-xs mb-1">
<Calendar className="w-4 h-4" />
<span>생년월일</span>
<Phone className="w-4 h-4" />
<span>전화번호</span>
</div>
<p className="text-text-main font-medium">{formatDate(employee.birthDate)}</p>
</div>
<div>
<div className="flex items-center gap-2 text-text-muted text-xs mb-1">
<GraduationCap className="w-4 h-4" />
<span>학력</span>
</div>
<p className="text-text-main font-medium">{employee.education ? (EDUCATION_LABELS[employee.education] || employee.education) : '-'}</p>
</div>
<div>
<div className="flex items-center gap-2 text-text-muted text-xs mb-1">
<GraduationCap className="w-4 h-4" />
<span>학교</span>
</div>
<p className="text-text-main font-medium">{employee.schoolName || '-'}</p>
</div>
<div>
<div className="flex items-center gap-2 text-text-muted text-xs mb-1">
<GraduationCap className="w-4 h-4" />
<span>학과</span>
</div>
<p className="text-text-main font-medium">{employee.major || '-'}</p>
</div>
<div>
<div className="flex items-center gap-2 text-text-muted text-xs mb-1">
<Calendar className="w-4 h-4" />
<span>졸업일</span>
</div>
<p className="text-text-main font-medium">{formatDate(employee.graduationDate)}</p>
</div>
<div className="sm:col-span-2">
<div className="flex items-center gap-2 text-text-muted text-xs mb-1">
<MapPin className="w-4 h-4" />
<span>현재 거주지</span>
</div>
<p className="text-text-main font-medium">{employee.residence || '-'}</p>
{employee.phone ? (
<div>
{revealedPhone ? (
<p className="text-text-main font-medium font-mono text-sm">{revealedPhone}</p>
) : (
<button onClick={() => setShowPhoneDialog(true)}
className="text-xs text-primary underline flex items-center gap-1">
🔒 전화번호 보기
</button>
)}
</div>
) : (
<p className="text-text-main font-medium">-</p>
)}
</div>
</div>
</div>
@@ -609,6 +579,39 @@ export default function ProfilePage() {
</div>
</div>
{/* Phone Reveal Dialog */}
{showPhoneDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={() => setShowPhoneDialog(false)}>
<div className="bg-white rounded-2xl shadow-xl max-w-sm w-full mx-4 p-6" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-bold text-text-main mb-1">전화번호 열람</h3>
<p className="text-xs text-text-muted mb-3">열람 사유가 감사 로그에 기록됩니다.</p>
<textarea value={phoneReason} onChange={(e) => setPhoneReason(e.target.value)} rows={2}
className="w-full px-3 py-2 border border-border rounded-lg text-sm mb-4 focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="열람 사유를 입력하세요" />
<div className="flex gap-3">
<button type="button" onClick={() => { setShowPhoneDialog(false); setPhoneReason(''); }}
className="flex-1 px-4 py-2 rounded-xl border border-border text-sm font-medium text-text-muted hover:bg-gray-100">취소</button>
<button onClick={async () => {
if (!phoneReason.trim()) return;
try {
const token = localStorage.getItem('accessToken');
const res = await fetch(`/api/v1/employees/${employee.empId}/request-phone`, {
method: 'POST', headers: {'Content-Type': 'application/json', Authorization: `Bearer ${token}`},
body: JSON.stringify({ reason: phoneReason }),
});
const json = await res.json();
if (json.success) setRevealedPhone(json.data.phone);
setShowPhoneDialog(false);
setPhoneReason('');
setTimeout(() => setRevealedPhone(''), 5000);
} catch { alert('열람 요청 실패'); }
}} disabled={!phoneReason.trim()}
className="flex-1 px-4 py-2 rounded-xl bg-primary text-white text-sm font-semibold disabled:opacity-50">열람</button>
</div>
</div>
</div>
)}
{/* Emergency Contact Modal */}
{contactModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
+2 -110
View File
@@ -30,14 +30,6 @@ const statusBadgeColor: Record<EmpStatus, string> = {
RESIGNED: 'bg-status-critical-bg text-status-critical-text',
};
const educationLabels: Record<string, string> = {
HIGH_SCHOOL: '고졸',
COLLEGE: '전문대',
BACHELOR: '학사',
MASTER: '석사',
DOCTOR: '박사',
};
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) {
@@ -53,13 +45,7 @@ interface CreateForm {
roleCode: RoleCode;
empStatus: EmpStatus;
joinedDate: string;
birthDate: string;
education: string;
schoolName: string;
position: string;
major: string;
graduationDate: string;
residence: string;
}
interface EditForm {
@@ -70,21 +56,15 @@ interface EditForm {
empStatus: EmpStatus;
joinedDate: string;
leftDate: string;
birthDate: string;
education: string;
schoolName: string;
position: string;
managerId: string;
backupManagerId: string;
major: string;
graduationDate: string;
residence: string;
}
const emptyCreateForm: CreateForm = {
loginId: '', name: '', password: '', email: '', phone: '',
roleCode: 'EMPLOYEE', empStatus: 'ACTIVE', joinedDate: '',
birthDate: '', education: '', schoolName: '', position: '', major: '', graduationDate: '', residence: '',
position: '',
};
export default function AccountsPage() {
@@ -99,7 +79,7 @@ export default function AccountsPage() {
const [createForm, setCreateForm] = useState<CreateForm>({ ...emptyCreateForm });
const [editForm, setEditForm] = useState<EditForm>({
name: '', email: '', phone: '', roleCode: 'EMPLOYEE', empStatus: 'ACTIVE',
joinedDate: '', leftDate: '', birthDate: '', education: '', schoolName: '', position: '', managerId: '', backupManagerId: '', major: '', graduationDate: '', residence: '',
joinedDate: '', leftDate: '', position: '', managerId: '', backupManagerId: '',
});
const [createError, setCreateError] = useState<string | null>(null);
const [editError, setEditError] = useState<string | null>(null);
@@ -175,15 +155,9 @@ export default function AccountsPage() {
empStatus: emp.empStatus,
joinedDate: emp.joinedDate || '',
leftDate: emp.leftDate || '',
birthDate: emp.birthDate || '',
education: emp.education || '',
schoolName: emp.schoolName || '',
position: emp.position || '',
managerId: String(emp.managerId ?? ''),
backupManagerId: String(emp.backupManagerId ?? ''),
major: emp.major || '',
graduationDate: emp.graduationDate || '',
residence: emp.residence || '',
});
setEditError(null);
setShowEditModal(true);
@@ -277,10 +251,7 @@ export default function AccountsPage() {
<th className="px-4 sm:px-6 py-3 hidden md:table-cell">이메일</th>
<th className="px-4 sm:px-6 py-3">권한</th>
<th className="px-4 sm:px-6 py-3">상태</th>
<th className="px-4 sm:px-6 py-3 hidden lg:table-cell">생년월일</th>
<th className="px-4 sm:px-6 py-3 hidden lg:table-cell">학력</th>
<th className="px-4 sm:px-6 py-3 hidden xl:table-cell">직급</th>
<th className="px-4 sm:px-6 py-3 hidden xl:table-cell">거주지</th>
<th className="px-4 sm:px-6 py-3 hidden sm:table-cell">입사일</th>
<th className="px-4 sm:px-6 py-3 text-right">액션</th>
</tr>
@@ -311,16 +282,7 @@ export default function AccountsPage() {
{statusLabels[emp.empStatus]}
</span>
</td>
<td className="px-4 sm:px-6 py-3 text-text-muted hidden lg:table-cell">{emp.birthDate || '-'}</td>
<td className="px-4 sm:px-6 py-3 hidden lg:table-cell">
{emp.education ? (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-indigo-50 text-indigo-700">
{educationLabels[emp.education] || emp.education}
</span>
) : '-'}
</td>
<td className="px-4 sm:px-6 py-3 text-text-muted hidden xl:table-cell">{emp.position || '-'}</td>
<td className="px-4 sm:px-6 py-3 text-text-muted hidden xl:table-cell">{emp.residence || '-'}</td>
<td className="px-4 sm:px-6 py-3 text-text-muted hidden sm:table-cell">{emp.joinedDate}</td>
<td className="px-4 sm:px-6 py-3 text-right">
<div className="flex items-center justify-end gap-1">
@@ -407,27 +369,6 @@ export default function AccountsPage() {
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">추가 정보</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>생년월일</label>
<input type="date" value={createForm.birthDate} onChange={(e) => setCreateForm((f) => ({ ...f, birthDate: e.target.value }))} className={fieldClass} />
</div>
<div>
<label className={labelClass}>학력</label>
<select value={createForm.education} onChange={(e) => setCreateForm((f) => ({ ...f, education: e.target.value }))} className={selectClass}>
<option value="">선택하세요</option>
<option value="HIGH_SCHOOL">고졸</option>
<option value="COLLEGE">전문대</option>
<option value="BACHELOR">학사</option>
<option value="MASTER">석사</option>
<option value="DOCTOR">박사</option>
</select>
</div>
</div>
<div>
<label className={labelClass}>학교명</label>
<input value={createForm.schoolName} onChange={(e) => setCreateForm((f) => ({ ...f, schoolName: e.target.value }))} className={fieldClass} placeholder="서울대학교" />
</div>
<div>
<label className={labelClass}>직급</label>
<select value={createForm.position} onChange={(e) => setCreateForm((f) => ({ ...f, position: e.target.value }))} className={selectClass}>
@@ -441,20 +382,6 @@ export default function AccountsPage() {
<option value="DIRECTOR">이사</option>
</select>
</div>
<div>
<label className={labelClass}>학과</label>
<input value={createForm.major} onChange={(e) => setCreateForm((f) => ({ ...f, major: e.target.value }))} className={fieldClass} placeholder="컴퓨터공학" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>졸업일</label>
<input type="date" value={createForm.graduationDate} onChange={(e) => setCreateForm((f) => ({ ...f, graduationDate: e.target.value }))} className={fieldClass} />
</div>
<div>
<label className={labelClass}>현재 거주지</label>
<input value={createForm.residence} onChange={(e) => setCreateForm((f) => ({ ...f, residence: e.target.value }))} className={fieldClass} placeholder="서울시 강남구" />
</div>
</div>
{createError && (
<div className="bg-status-critical-bg border border-status-critical text-status-critical-text rounded-lg px-3 py-2 text-sm">
@@ -574,27 +501,6 @@ export default function AccountsPage() {
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>생년월일</label>
<input type="date" value={editForm.birthDate} onChange={(e) => setEditForm((f) => ({ ...f, birthDate: e.target.value }))} className={fieldClass} />
</div>
<div>
<label className={labelClass}>학력</label>
<select value={editForm.education} onChange={(e) => setEditForm((f) => ({ ...f, education: e.target.value }))} className={selectClass}>
<option value="">선택하세요</option>
<option value="HIGH_SCHOOL">고졸</option>
<option value="COLLEGE">전문대</option>
<option value="BACHELOR">학사</option>
<option value="MASTER">석사</option>
<option value="DOCTOR">박사</option>
</select>
</div>
</div>
<div>
<label className={labelClass}>학교명</label>
<input value={editForm.schoolName} onChange={(e) => setEditForm((f) => ({ ...f, schoolName: e.target.value }))} className={fieldClass} placeholder="서울대학교" />
</div>
<div>
<label className={labelClass}>직급</label>
<select value={editForm.position} onChange={(e) => setEditForm((f) => ({ ...f, position: e.target.value }))} className={selectClass}>
@@ -626,20 +532,6 @@ export default function AccountsPage() {
))}
</select>
</div>
<div>
<label className={labelClass}>학과</label>
<input value={editForm.major} onChange={(e) => setEditForm((f) => ({ ...f, major: e.target.value }))} className={fieldClass} placeholder="컴퓨터공학" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>졸업일</label>
<input type="date" value={editForm.graduationDate} onChange={(e) => setEditForm((f) => ({ ...f, graduationDate: e.target.value }))} className={fieldClass} />
</div>
<div>
<label className={labelClass}>현재 거주지</label>
<input value={editForm.residence} onChange={(e) => setEditForm((f) => ({ ...f, residence: e.target.value }))} className={fieldClass} placeholder="서울시 강남구" />
</div>
</div>
{/* Password Reset */}
<div className="border-t border-border pt-3 mt-3">
+1 -7
View File
@@ -55,16 +55,10 @@ export interface Employee {
dispatchStatus: DispatchStatus;
joinedDate: string;
leftDate: string | null;
birthDate: string | null;
education: string | null;
schoolName: string | null;
position: string | null;
profileImage: string | null;
managerId?: number;
backupManagerId?: number;
major: string | null;
graduationDate: string | null;
residence: string | null;
profileImage: string | null;
emergencyContacts?: EmergencyContact[];
}