feat: 관리자 전용 직원 수정/삭제 모달 및 신규 파견 등록/종료 기능 추가

This commit is contained in:
pray4skylark
2026-09-30 16:36:48 +09:00
parent d5ad6600a0
commit 642402c948
4 changed files with 624 additions and 5 deletions
+22 -1
View File
@@ -10,6 +10,7 @@ import { useAuthStore } from '@/stores';
import employeeService, { type PortfolioEntry, type CreateDispatchRequest } from '@/services/employeeService';
import projectService from '@/services/projectService';
import type { EmployeeSearchResult, Project } from '@/types';
import CreateDispatchModal from '@/components/dispatch/CreateDispatchModal';
function initialColor(name: string) {
const colors = [
@@ -275,7 +276,27 @@ export default function DispatchPage() {
<span>공수 {entry.contributionRate}%</span>
</p>
</div>
<span className="text-xs bg-primary/10 text-primary px-2.5 py-1 rounded-full font-medium">{entry.contributionRate}%</span>
<div className="flex items-center gap-2">
<span className="text-xs bg-primary/10 text-primary px-2.5 py-1 rounded-full font-medium">{entry.contributionRate}%</span>
{(user?.roleCode === 'MANAGER' || user?.roleCode === 'ADMIN') && !entry.endDate && (
<button
type="button"
onClick={async () => {
if (!confirm(`'${entry.projectName}' 파견을 종료하시겠습니까?`)) return;
try {
await employeeService.endDispatch(entry.dispatchId);
alert('파견이 종료되었습니다.');
fetchPortfolio();
} catch (err: any) {
alert(err.response?.data?.message || '파견 종료 실패');
}
}}
className="px-2.5 py-1 bg-red-100 text-red-700 hover:bg-red-200 rounded-full text-xs font-semibold transition-colors"
>
파견 종료
</button>
)}
</div>
</div>
<div className="px-4 sm:px-6 py-4 space-y-3">
+44 -4
View File
@@ -8,6 +8,7 @@ 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',
@@ -36,6 +37,7 @@ export default function EmployeesPage() {
// 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 [detailLoading, setDetailLoading] = useState(false);
@@ -580,7 +582,21 @@ 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">
<span className="text-xs text-text-muted">View details →</span>
<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>
</td>
</tr>
))}
@@ -606,9 +622,23 @@ export default function EmployeesPage() {
<p className="text-xs text-text-muted truncate">{roleLabel[emp.roleCode] || emp.roleCode}</p>
</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 className="flex items-center gap-2">
<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">
<div className="truncate">Email: {emp.email}</div>
@@ -620,6 +650,16 @@ export default function EmployeesPage() {
</div>
</div>
</div>
{/* Employee Edit Modal */}
<EmployeeEditModal
isOpen={!!editingEmployee}
onClose={() => setEditingEmployee(null)}
employee={editingEmployee}
onSuccess={() => {
fetchEmployees();
}}
/>
</div>
);
}
@@ -0,0 +1,280 @@
'use client';
import React, { useState, useEffect } from 'react';
import { X, Save, AlertCircle, Search } from 'lucide-react';
import employeeService from '@/services/employeeService';
import projectService from '@/services/projectService';
import type { Employee, Project, CreateDispatchRequest } from '@/types';
interface CreateDispatchModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export default function CreateDispatchModal({
isOpen,
onClose,
onSuccess,
}: CreateDispatchModalProps) {
const [employees, setEmployees] = useState<Employee[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [employeeId, setEmployeeId] = useState<number | ''>('');
const [projectId, setProjectId] = useState<number | ''>('');
const [roleTitle, setRoleTitle] = useState('');
const [contributionRate, setContributionRate] = useState<number>(100);
const [individualTech, setIndividualTech] = useState('');
const [performedTasks, setPerformedTasks] = useState('');
const [startDate, setStartDate] = useState(new Date().toISOString().split('T')[0]);
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
loadData();
}
}, [isOpen]);
const loadData = async () => {
try {
setFetching(true);
setError(null);
const [empRes, projRes] = await Promise.all([
employeeService.getAll({ size: 200 }),
projectService.getAll(),
]);
if (empRes.success && empRes.data) {
setEmployees(empRes.data.content || []);
}
if (projRes.success && projRes.data) {
setProjects(projRes.data || []);
}
} catch (err) {
console.error('Failed to load dispatch form options', err);
setError('직원 및 프로젝트 목록을 불러오지 못했습니다.');
} finally {
setFetching(false);
}
};
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!employeeId) {
setError('파견할 직원을 선택해 주세요.');
return;
}
if (!projectId) {
setError('파견 프로젝트를 선택해 주세요.');
return;
}
if (!roleTitle.trim()) {
setError('담당 역할을 입력해 주세요.');
return;
}
try {
setLoading(true);
setError(null);
const request: CreateDispatchRequest = {
employeeId: Number(employeeId),
projectId: Number(projectId),
roleTitle: roleTitle.trim(),
contributionRate: Number(contributionRate) || 100,
individualTech: individualTech.trim(),
performedTasks: performedTasks.trim(),
startDate,
};
const res = await employeeService.createDispatch(request);
if (res.success) {
onSuccess();
onClose();
// Reset form
setEmployeeId('');
setProjectId('');
setRoleTitle('');
setContributionRate(100);
setIndividualTech('');
setPerformedTasks('');
}
} catch (err: any) {
setError(err.response?.data?.message || '파견 등록 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
<div className="bg-surface border border-border rounded-2xl shadow-soft w-full max-w-lg overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-surface-container-low">
<h2 className="font-outfit text-lg font-semibold text-text-main">
신규 파견(투입) 등록
</h2>
<button
onClick={onClose}
className="p-2 text-text-muted hover:text-text-main rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-4 max-h-[80vh] overflow-y-auto">
{error && (
<div className="flex items-center gap-2 p-3 bg-red-50 text-red-600 text-sm rounded-xl border border-red-200">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{error}</span>
</div>
)}
{fetching ? (
<div className="py-8 text-center text-text-muted text-sm">목록 로딩 중...</div>
) : (
<>
{/* Employee Selection */}
<div>
<label className="block text-xs font-semibold text-text-muted mb-1.5">
직원 선택 *
</label>
<select
value={employeeId}
onChange={(e) => setEmployeeId(e.target.value ? Number(e.target.value) : '')}
className="w-full px-3 py-2.5 border border-border rounded-xl bg-surface-container-lowest text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
required
>
<option value="">-- 직원을 선택하세요 --</option>
{employees.map((emp) => (
<option key={emp.empId} value={emp.empId}>
{emp.name} ({emp.email}) - {emp.dispatchStatus === 'DISPATCHED' ? '파견중' : '대기'}
</option>
))}
</select>
</div>
{/* Project Selection */}
<div>
<label className="block text-xs font-semibold text-text-muted mb-1.5">
프로젝트 선택 *
</label>
<select
value={projectId}
onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : '')}
className="w-full px-3 py-2.5 border border-border rounded-xl bg-surface-container-lowest text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
required
>
<option value="">-- 프로젝트를 선택하세요 --</option>
{projects.map((proj) => (
<option key={proj.projectId} value={proj.projectId}>
[{proj.contractCompany}] {proj.projectName}
</option>
))}
</select>
</div>
{/* Role Title & Contribution Rate */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-text-muted mb-1.5">
담당 역할 *
</label>
<input
type="text"
value={roleTitle}
onChange={(e) => setRoleTitle(e.target.value)}
placeholder="예: 백엔드 개발, PL, QA"
className="w-full px-3 py-2 border border-border rounded-xl bg-surface-container-lowest text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-text-muted mb-1.5">
투입 기여도 (%) *
</label>
<input
type="number"
min={10}
max={100}
value={contributionRate}
onChange={(e) => setContributionRate(Number(e.target.value))}
className="w-full px-3 py-2 border border-border rounded-xl bg-surface-container-lowest text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
required
/>
</div>
</div>
{/* Start Date */}
<div>
<label className="block text-xs font-semibold text-text-muted mb-1.5">
투입 시작일 *
</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-xl bg-surface-container-lowest text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
required
/>
</div>
{/* Tech Stack */}
<div>
<label className="block text-xs font-semibold text-text-muted mb-1.5">
사용 기술 스택
</label>
<input
type="text"
value={individualTech}
onChange={(e) => setIndividualTech(e.target.value)}
placeholder="예: Java 17, Spring Boot, React, PostgreSQL"
className="w-full px-3 py-2 border border-border rounded-xl bg-surface-container-lowest text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
/>
</div>
{/* Performed Tasks */}
<div>
<label className="block text-xs font-semibold text-text-muted mb-1.5">
수행 업무 요약
</label>
<textarea
rows={3}
value={performedTasks}
onChange={(e) => setPerformedTasks(e.target.value)}
placeholder="담당 업무에 대한 설명을 입력하세요"
className="w-full px-3 py-2 border border-border rounded-xl bg-surface-container-lowest text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
/>
</div>
</>
)}
{/* Buttons */}
<div className="flex justify-end gap-2 pt-4 border-t border-border">
<button
type="button"
onClick={onClose}
className="px-4 py-2 border border-border text-text-main rounded-xl text-xs font-bold hover:bg-surface-container-low transition-colors"
>
취소
</button>
<button
type="submit"
disabled={loading || fetching}
className="flex items-center gap-1.5 px-4 py-2 bg-primary text-white rounded-xl text-xs font-bold hover:bg-primary/90 disabled:opacity-50 transition-colors shadow-sm"
>
<Save className="w-4 h-4" />
{loading ? '등록중...' : '파견 등록'}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,278 @@
'use client';
import React, { useState, useEffect } from 'react';
import { X, Save, AlertCircle, Trash2, Key } from 'lucide-react';
import employeeService from '@/services/employeeService';
import type { Employee, RoleCode, EmpStatus } from '@/types';
interface EmployeeEditModalProps {
isOpen: boolean;
onClose: () => void;
employee: Employee | null;
onSuccess: () => void;
}
export default function EmployeeEditModal({
isOpen,
onClose,
employee,
onSuccess,
}: EmployeeEditModalProps) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [position, setPosition] = useState('');
const [joinedDate, setJoinedDate] = useState('');
const [roleCode, setRoleCode] = useState<RoleCode>('EMPLOYEE');
const [empStatus, setEmpStatus] = useState<EmpStatus>('ACTIVE');
const [newPassword, setNewPassword] = useState('');
const [showResetPassword, setShowResetPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (employee) {
setName(employee.name || '');
setEmail(employee.email || '');
setPhone(employee.phone || '');
setPosition(employee.position || '');
setJoinedDate(employee.joinedDate || '');
setRoleCode(employee.roleCode || 'EMPLOYEE');
setEmpStatus(employee.empStatus || 'ACTIVE');
setNewPassword('');
setShowResetPassword(false);
setError(null);
}
}, [employee]);
if (!isOpen || !employee) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
setError('이름을 입력해주세요.');
return;
}
try {
setLoading(true);
setError(null);
// Update employee information
const updateData: Partial<Employee> = {
name,
email,
phone,
position,
joinedDate,
roleCode,
empStatus,
};
const res = await employeeService.update(employee.empId, updateData);
// Reset password if requested
if (showResetPassword && newPassword.trim()) {
await employeeService.resetPassword(employee.empId, newPassword.trim());
}
if (res.success) {
onSuccess();
onClose();
}
} catch (err: any) {
setError(err.response?.data?.message || '직원 정보 수정 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
};
const handleDelete = async () => {
if (!confirm(`정말로 직원 '${employee.name}' 님을 삭제(퇴사) 처리하시겠습니까?`)) {
return;
}
try {
setLoading(true);
setError(null);
const res = await employeeService.delete(employee.empId);
if (res.success) {
onSuccess();
onClose();
}
} catch (err: any) {
setError(err.response?.data?.message || '직원 삭제에 실패했습니다.');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
<div className="bg-surface border border-border rounded-2xl shadow-soft w-full max-w-lg overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-surface-container-low">
<h2 className="font-outfit text-lg font-semibold text-text-main">
직원 정보 수정 — {employee.name}
</h2>
<button
onClick={onClose}
className="p-2 text-text-muted hover:text-text-main rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form Body */}
<form onSubmit={handleSubmit} className="p-6 space-y-4 max-h-[80vh] overflow-y-auto">
{error && (
<div className="flex items-center gap-2 p-3 bg-red-50 text-red-600 text-sm rounded-xl border border-red-200">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{error}</span>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-text-muted mb-1.5">이름 *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-xl bg-surface-container-lowest text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-text-muted mb-1.5">이메일</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-xl bg-surface-container-lowest text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-text-muted mb-1.5">전화번호</label>
<input
type="text"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="010-0000-0000"
className="w-full px-3 py-2 border border-border rounded-xl bg-surface-container-lowest text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
/>
</div>
<div>
<label className="block text-xs font-semibold text-text-muted mb-1.5">직급 / 직책</label>
<input
type="text"
value={position}
onChange={(e) => setPosition(e.target.value)}
placeholder="예: 수석연구원, 팀장"
className="w-full px-3 py-2 border border-border rounded-xl bg-surface-container-lowest text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs font-semibold text-text-muted mb-1.5">시스템 권한</label>
<select
value={roleCode}
onChange={(e) => setRoleCode(e.target.value as RoleCode)}
className="w-full px-3 py-2 border border-border rounded-xl bg-surface-container-lowest text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
>
<option value="EMPLOYEE">일반 직원 (EMPLOYEE)</option>
<option value="MANAGER">매니저 (MANAGER)</option>
<option value="ADMIN">최고 관리자 (ADMIN)</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-text-muted mb-1.5">재직 상태</label>
<select
value={empStatus}
onChange={(e) => setEmpStatus(e.target.value as EmpStatus)}
className="w-full px-3 py-2 border border-border rounded-xl bg-surface-container-lowest text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
>
<option value="ACTIVE">재직 (ACTIVE)</option>
<option value="ON_LEAVE">휴직 (ON_LEAVE)</option>
<option value="RESIGNED">퇴사 (RESIGNED)</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-text-muted mb-1.5">입사일</label>
<input
type="date"
value={joinedDate}
onChange={(e) => setJoinedDate(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-xl bg-surface-container-lowest text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
/>
</div>
</div>
{/* Reset Password Toggle */}
<div className="pt-2 border-t border-border">
<button
type="button"
onClick={() => setShowResetPassword(!showResetPassword)}
className="flex items-center gap-2 text-xs font-semibold text-primary hover:underline"
>
<Key className="w-3.5 h-3.5" />
{showResetPassword ? '비밀번호 재설정 취소' : '비밀번호 강제 재설정'}
</button>
{showResetPassword && (
<div className="mt-3">
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="새 초기 비밀번호를 입력하세요 (예: admin123)"
className="w-full px-3 py-2 border border-border rounded-xl bg-surface-container-lowest text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
/>
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex items-center justify-between pt-4 border-t border-border">
<button
type="button"
onClick={handleDelete}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-2 text-red-600 hover:bg-red-50 rounded-xl text-xs font-bold transition-colors"
>
<Trash2 className="w-4 h-4" />
직원 삭제
</button>
<div className="flex gap-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 border border-border text-text-main rounded-xl text-xs font-bold hover:bg-surface-container-low transition-colors"
>
취소
</button>
<button
type="submit"
disabled={loading}
className="flex items-center gap-1.5 px-4 py-2 bg-primary text-white rounded-xl text-xs font-bold hover:bg-primary/90 disabled:opacity-50 transition-colors shadow-sm"
>
<Save className="w-4 h-4" />
{loading ? '저장중...' : '저장'}
</button>
</div>
</div>
</form>
</div>
</div>
);
}