# 사원-회원 연결 기능 구현 계획 > **작성일**: 2025-12-25 > **목적**: 시스템 계정(회원) 없이 사원만 등록하고, 필요 시 계정을 연결/해제하는 기능 구현 > **관련 문서**: `docs/features/hr/hr-api-analysis.md` --- ## 1. 배경 및 문제 정의 ### 현재 상황 - `users` 테이블: 시스템 계정 정보 (로그인용) - `tenant_user_profiles` 테이블: 테넌트별 사원 정보 - **문제**: 시스템을 사용하지 않는 사원도 인사관리(급여, 출퇴근 등)를 위해 등록 필요 ### 요구사항 1. **사원만 등록**: 로그인 계정 없이 인사정보만 관리 2. **계정 연결**: 기존 사원에게 로그인 계정 부여 3. **계정 해제**: 로그인 권한 회수 (사원 정보는 유지) ### 설계 방향 ``` users.password = NULL → 사원만 (로그인 불가) users.password = 설정됨 → 회원 (로그인 가능) ``` --- ## 2. 아키텍처 ### 데이터 모델 ``` ┌─────────────────────────────────────────────────┐ │ users (전역) │ │ id, user_id, name, email, phone │ │ password (nullable) ← 핵심! │ │ is_active, is_super_admin │ └─────────────────────────────────────────────────┘ │ │ 1:N ▼ ┌─────────────────────────────────────────────────┐ │ user_tenants (피벗) │ │ user_id, tenant_id, is_active, is_default │ └─────────────────────────────────────────────────┘ │ │ 1:1 ▼ ┌─────────────────────────────────────────────────┐ │ tenant_user_profiles (테넌트별) │ │ tenant_id, user_id │ │ department_id, position_key, employment_type │ │ employee_status (active/leave/resigned) │ │ json_extra (사원코드, 급여, 입사일 등) │ └─────────────────────────────────────────────────┘ ``` ### 상태 구분 | 유형 | users.password | users.user_id | 로그인 | 설명 | |------|---------------|---------------|--------|------| | 사원만 | `NULL` | `NULL` | ❌ | 인사관리용 | | 회원 연결 | 설정됨 | 설정됨 | ✅ | 시스템 사용자 | --- ## 3. 구현 범위 ### Phase 1: DB 스키마 확인 및 수정 (0.5일) #### 1.1 users 테이블 확인 ```sql -- password nullable 확인 DESCRIBE users; -- 필요시 수정 ALTER TABLE users MODIFY password VARCHAR(255) NULL; ``` #### 1.2 user_id (로그인 ID) nullable 확인 ```sql -- user_id nullable 확인 (사원만 등록 시 로그인 ID 없음) ALTER TABLE users MODIFY user_id VARCHAR(50) NULL; -- UNIQUE 제약조건 수정 (NULL 허용) -- MySQL 8.0+는 NULL 값 중복 허용 ``` #### 마이그레이션 파일 ```bash php artisan make:migration modify_users_for_employee_only --table=users ``` ```php // database/migrations/xxxx_modify_users_for_employee_only.php public function up(): void { Schema::table('users', function (Blueprint $table) { $table->string('user_id', 50)->nullable()->change(); $table->string('password')->nullable()->change(); }); } ``` --- ### Phase 2: API 백엔드 구현 (1.5일) #### 2.1 파일 구조 ``` api/app/ ├── Services/ │ └── EmployeeService.php # 신규 ├── Http/ │ ├── Controllers/Api/V1/ │ │ └── EmployeeController.php # 신규 │ └── Requests/Employee/ │ ├── IndexRequest.php # 신규 │ ├── StoreRequest.php # 신규 │ ├── UpdateRequest.php # 신규 │ └── CreateAccountRequest.php # 신규 └── Swagger/v1/ └── EmployeeApi.php # 신규 ``` #### 2.2 EmployeeService 핵심 메서드 ```php user()->current_tenant_id; $profile = TenantUserProfile::query() ->where('tenant_id', $tenantId) ->where('user_id', $userId) ->with(['user', 'department']) ->firstOrFail(); return [ 'id' => $profile->user_id, 'name' => $profile->user->name, 'email' => $profile->user->email, 'phone' => $profile->user->phone, 'has_account' => $profile->user->password !== null, 'login_id' => $profile->user->user_id, 'profile' => [ 'employee_code' => $profile->json_extra['employee_code'] ?? null, 'department' => $profile->department ? [ 'id' => $profile->department->id, 'name' => $profile->department->name, ] : null, 'position' => $profile->position_key, 'employment_type' => $profile->employment_type_key, 'status' => $profile->employee_status, 'hire_date' => $profile->json_extra['hire_date'] ?? null, 'salary' => $profile->json_extra['salary'] ?? null, 'rank' => $profile->json_extra['rank'] ?? null, 'gender' => $profile->json_extra['gender'] ?? null, 'address' => $profile->json_extra['address'] ?? null, 'bank_account' => $profile->json_extra['bank_account'] ?? null, ], 'created_at' => $profile->created_at, 'updated_at' => $profile->updated_at, ]; } /** * 사원 목록 조회 * @param array $params [q, status, department_id, has_account, page, per_page] */ public function index(array $params): array { $tenantId = auth()->user()->current_tenant_id; $query = TenantUserProfile::query() ->where('tenant_id', $tenantId) ->with(['user', 'department']); // 검색 if (!empty($params['q'])) { $q = $params['q']; $query->whereHas('user', function ($q2) use ($q) { $q2->where('name', 'like', "%{$q}%") ->orWhere('email', 'like', "%{$q}%"); })->orWhereJsonContains('json_extra->employee_code', $q); } // 상태 필터 if (!empty($params['status'])) { $query->where('employee_status', $params['status']); } // 부서 필터 if (!empty($params['department_id'])) { $query->where('department_id', $params['department_id']); } // 계정 보유 필터 if (isset($params['has_account'])) { $hasAccount = filter_var($params['has_account'], FILTER_VALIDATE_BOOLEAN); $query->whereHas('user', function ($q) use ($hasAccount) { if ($hasAccount) { $q->whereNotNull('password'); } else { $q->whereNull('password'); } }); } return $query->paginate($params['per_page'] ?? 20)->toArray(); } /** * 사원 등록 (회원 계정 없이) */ public function store(array $data): User { $tenantId = auth()->user()->current_tenant_id; return DB::transaction(function () use ($data, $tenantId) { // 1. users 테이블에 생성 (password 없이!) $user = User::create([ 'name' => $data['name'], 'email' => $data['email'] ?? null, 'phone' => $data['phone'] ?? null, 'user_id' => null, // 로그인 ID 없음 'password' => null, // 로그인 불가 'is_active' => true, 'created_by' => auth()->id(), ]); // 2. 테넌트 연결 $user->tenants()->attach($tenantId, [ 'is_active' => true, 'is_default' => true, 'joined_at' => now(), ]); // 3. 사원 프로필 생성 TenantUserProfile::create([ 'tenant_id' => $tenantId, 'user_id' => $user->id, 'department_id' => $data['department_id'] ?? null, 'position_key' => $data['position_key'] ?? null, 'employment_type_key' => $data['employment_type'] ?? 'regular', 'employee_status' => 'active', 'json_extra' => [ 'employee_code' => $data['employee_code'] ?? null, 'hire_date' => $data['hire_date'] ?? null, 'salary' => $data['salary'] ?? null, 'rank' => $data['rank'] ?? null, 'gender' => $data['gender'] ?? null, 'address' => $data['address'] ?? null, 'bank_account' => $data['bank_account'] ?? null, 'resident_number' => isset($data['resident_number']) ? encrypt($data['resident_number']) : null, ], 'created_by' => auth()->id(), ]); return $user->load('profile'); }); } /** * 시스템 계정 생성 (사원 → 회원 연결) */ public function createAccount(int $userId, array $data): User { $user = User::findOrFail($userId); // 이미 계정이 있는 경우 if ($user->password !== null) { throw new \Exception(__('employee.already_has_account')); } // user_id 중복 체크 if (User::where('user_id', $data['login_id'])->exists()) { throw new \Exception(__('employee.login_id_exists')); } $user->update([ 'user_id' => $data['login_id'], 'password' => Hash::make($data['password']), 'must_change_password' => $data['must_change_password'] ?? true, 'updated_by' => auth()->id(), ]); return $user; } /** * 시스템 계정 해제 (회원 연결 해제) */ public function revokeAccount(int $userId): User { $user = User::findOrFail($userId); // 계정이 없는 경우 if ($user->password === null) { throw new \Exception(__('employee.no_account')); } // 슈퍼관리자는 해제 불가 if ($user->is_super_admin) { throw new \Exception(__('employee.cannot_revoke_super_admin')); } DB::transaction(function () use ($user) { // 비밀번호 제거 (로그인 불가) $user->update([ 'password' => null, 'updated_by' => auth()->id(), ]); // 기존 토큰 모두 삭제 $user->tokens()->delete(); }); return $user; } /** * 사원 수정 */ public function update(int $userId, array $data): User { $tenantId = auth()->user()->current_tenant_id; $user = User::findOrFail($userId); DB::transaction(function () use ($user, $data, $tenantId) { // 1. users 기본 정보 수정 $user->update([ 'name' => $data['name'] ?? $user->name, 'email' => $data['email'] ?? $user->email, 'phone' => $data['phone'] ?? $user->phone, 'updated_by' => auth()->id(), ]); // 2. 프로필 수정 $profile = TenantUserProfile::where('tenant_id', $tenantId) ->where('user_id', $user->id) ->first(); if ($profile) { $profile->update([ 'department_id' => $data['department_id'] ?? $profile->department_id, 'position_key' => $data['position_key'] ?? $profile->position_key, 'employment_type_key' => $data['employment_type'] ?? $profile->employment_type_key, 'employee_status' => $data['status'] ?? $profile->employee_status, 'updated_by' => auth()->id(), ]); // json_extra 업데이트 $jsonExtra = $profile->json_extra ?? []; $allowedKeys = ['employee_code', 'hire_date', 'salary', 'rank', 'gender', 'address', 'bank_account']; foreach ($allowedKeys as $key) { if (isset($data[$key])) { $jsonExtra[$key] = $data[$key]; } } $profile->update(['json_extra' => $jsonExtra]); } }); return $user->fresh(['profile']); } /** * 사원 삭제 (Soft Delete) */ public function destroy(int $userId): bool { $user = User::findOrFail($userId); // 슈퍼관리자 삭제 불가 if ($user->is_super_admin) { throw new \Exception(__('employee.cannot_delete_super_admin')); } $user->update(['deleted_by' => auth()->id()]); return $user->delete(); } } ``` #### 2.3 EmployeeController ```php service->index($request->validated()); return $this->success($result); } public function show(int $id) { $result = $this->service->show($id); return $this->success($result); } public function store(StoreRequest $request) { $result = $this->service->store($request->validated()); return $this->success($result, __('employee.created')); } public function update(UpdateRequest $request, int $id) { $result = $this->service->update($id, $request->validated()); return $this->success($result, __('employee.updated')); } public function destroy(int $id) { $this->service->destroy($id); return $this->success(null, __('employee.deleted')); } /** * 시스템 계정 생성 (사원 → 회원 연결) */ public function createAccount(CreateAccountRequest $request, int $id) { $result = $this->service->createAccount($id, $request->validated()); return $this->success($result, __('employee.account_created')); } /** * 시스템 계정 해제 (회원 연결 해제) */ public function revokeAccount(int $id) { $result = $this->service->revokeAccount($id); return $this->success($result, __('employee.account_revoked')); } } ``` #### 2.4 라우트 등록 ```php // api/routes/api.php (v1 그룹 내) Route::prefix('employees')->middleware(['auth:sanctum'])->group(function () { Route::get('', [EmployeeController::class, 'index']); Route::post('', [EmployeeController::class, 'store']); Route::get('/{id}', [EmployeeController::class, 'show']); Route::patch('/{id}', [EmployeeController::class, 'update']); Route::delete('/{id}', [EmployeeController::class, 'destroy']); // 계정 연결/해제 Route::post('/{id}/create-account', [EmployeeController::class, 'createAccount']); Route::post('/{id}/revoke-account', [EmployeeController::class, 'revokeAccount']); }); ``` #### 2.5 FormRequest 정의 ```php 'nullable|string|max:100', 'status' => 'nullable|string|in:active,leave,resigned', 'department_id' => 'nullable|integer|exists:departments,id', 'has_account' => 'nullable|string|in:true,false,1,0', 'page' => 'nullable|integer|min:1', 'per_page' => 'nullable|integer|min:1|max:100', ]; } } ``` ```php 'required|string|max:100', 'email' => 'nullable|email|max:255', 'phone' => 'nullable|string|max:20', 'department_id' => 'nullable|integer|exists:departments,id', 'position_key' => 'nullable|string|max:50', 'employment_type' => 'nullable|string|in:regular,contract,parttime,intern', 'employee_code' => 'nullable|string|max:50', 'hire_date' => 'nullable|date', 'salary' => 'nullable|numeric|min:0', 'rank' => 'nullable|string|max:50', 'gender' => 'nullable|string|in:male,female', 'address' => 'nullable|array', 'bank_account' => 'nullable|array', 'resident_number' => 'nullable|string|max:20', ]; } } ``` ```php 'sometimes|string|max:100', 'email' => 'nullable|email|max:255', 'phone' => 'nullable|string|max:20', 'department_id' => 'nullable|integer|exists:departments,id', 'position_key' => 'nullable|string|max:50', 'employment_type' => 'nullable|string|in:regular,contract,parttime,intern', 'status' => 'nullable|string|in:active,leave,resigned', 'employee_code' => 'nullable|string|max:50', 'hire_date' => 'nullable|date', 'salary' => 'nullable|numeric|min:0', 'rank' => 'nullable|string|max:50', 'gender' => 'nullable|string|in:male,female', 'address' => 'nullable|array', 'bank_account' => 'nullable|array', ]; } } ``` ```php 'required|string|max:50|unique:users,user_id', 'password' => 'required|string|min:8|confirmed', 'must_change_password' => 'nullable|boolean', ]; } } ``` #### 2.6 언어 파일 ```php // api/lang/ko/employee.php return [ 'created' => '사원이 등록되었습니다.', 'updated' => '사원 정보가 수정되었습니다.', 'deleted' => '사원이 삭제되었습니다.', 'account_created' => '시스템 계정이 생성되었습니다.', 'account_revoked' => '시스템 계정이 해제되었습니다.', 'already_has_account' => '이미 시스템 계정이 있습니다.', 'no_account' => '시스템 계정이 없습니다.', 'login_id_exists' => '이미 사용 중인 로그인 ID입니다.', 'cannot_revoke_super_admin' => '슈퍼관리자 계정은 해제할 수 없습니다.', 'cannot_delete_super_admin' => '슈퍼관리자는 삭제할 수 없습니다.', ]; ``` #### 2.7 Swagger 문서 ```php { data: T[]; total: number; per_page: number; current_page: number; last_page: number; } ``` #### 3.3 actions.ts API 함수 ```typescript // react/src/components/hr/EmployeeManagement/actions.ts 'use server'; import { cookies } from 'next/headers'; import { Employee, EmployeeListItem, EmployeeFormData, EmployeeFilters, PaginatedResponse, CreateAccountFormData, } from './types'; const API_BASE = process.env.NEXT_PUBLIC_API_URL; async function getApiHeaders() { const cookieStore = await cookies(); const token = cookieStore.get('auth_token')?.value; return { 'Content-Type': 'application/json', Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }; } /** * 사원 목록 조회 */ export async function getEmployees( filters: EmployeeFilters = {} ): Promise<{ success: boolean; data?: PaginatedResponse; error?: string }> { const headers = await getApiHeaders(); const params = new URLSearchParams(); if (filters.q) params.append('q', filters.q); if (filters.status) params.append('status', filters.status); if (filters.department_id) params.append('department_id', filters.department_id.toString()); if (filters.has_account !== undefined) params.append('has_account', filters.has_account.toString()); if (filters.page) params.append('page', filters.page.toString()); if (filters.per_page) params.append('per_page', filters.per_page.toString()); const response = await fetch(`${API_BASE}/api/v1/employees?${params}`, { headers }); const result = await response.json(); if (!response.ok) { return { success: false, error: result.message }; } return { success: true, data: result.data }; } /** * 사원 상세 조회 */ export async function getEmployee( id: string ): Promise<{ success: boolean; data?: Employee; error?: string }> { const headers = await getApiHeaders(); const response = await fetch(`${API_BASE}/api/v1/employees/${id}`, { headers }); const result = await response.json(); if (!response.ok) { return { success: false, error: result.message }; } return { success: true, data: result.data }; } /** * 사원 등록 (계정 없이) */ export async function createEmployee( data: EmployeeFormData ): Promise<{ success: boolean; data?: Employee; error?: string }> { const headers = await getApiHeaders(); const response = await fetch(`${API_BASE}/api/v1/employees`, { method: 'POST', headers, body: JSON.stringify(data), }); const result = await response.json(); if (!response.ok) { return { success: false, error: result.message }; } return { success: true, data: result.data }; } /** * 사원 정보 수정 */ export async function updateEmployee( id: string, data: Partial ): Promise<{ success: boolean; data?: Employee; error?: string }> { const headers = await getApiHeaders(); const response = await fetch(`${API_BASE}/api/v1/employees/${id}`, { method: 'PATCH', headers, body: JSON.stringify(data), }); const result = await response.json(); if (!response.ok) { return { success: false, error: result.message }; } return { success: true, data: result.data }; } /** * 사원 삭제 */ export async function deleteEmployee( id: string ): Promise<{ success: boolean; error?: string }> { const headers = await getApiHeaders(); const response = await fetch(`${API_BASE}/api/v1/employees/${id}`, { method: 'DELETE', headers, }); const result = await response.json(); if (!response.ok) { return { success: false, error: result.message }; } return { success: true }; } /** * 부서 목록 조회 */ export async function getDepartments(): Promise<{ success: boolean; data?: { id: number; name: string }[]; error?: string; }> { const headers = await getApiHeaders(); const response = await fetch(`${API_BASE}/api/v1/departments`, { headers }); const result = await response.json(); if (!response.ok) { return { success: false, error: result.message }; } return { success: true, data: result.data }; } /** * 시스템 계정 생성 */ export async function createEmployeeAccount( employeeId: string, data: { login_id: string; password: string; password_confirmation: string } ): Promise<{ success: boolean; error?: string }> { const headers = await getApiHeaders(); const response = await fetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${employeeId}/create-account`, { method: 'POST', headers, body: JSON.stringify({ login_id: data.login_id, password: data.password, password_confirmation: data.password_confirmation, }), } ); const result = await response.json(); if (!response.ok) { return { success: false, error: result.message }; } return { success: true }; } /** * 시스템 계정 해제 */ export async function revokeEmployeeAccount( employeeId: string ): Promise<{ success: boolean; error?: string }> { const headers = await getApiHeaders(); const response = await fetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${employeeId}/revoke-account`, { method: 'POST', headers, } ); const result = await response.json(); if (!response.ok) { return { success: false, error: result.message }; } return { success: true }; } ``` #### 3.4 EmployeeFormModal.tsx 사원 등록/수정 폼 ```typescript // react/src/components/hr/EmployeeManagement/EmployeeFormModal.tsx 'use client'; import { useState, useEffect } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Employee, EmployeeFormData, EmploymentType, Gender } from './types'; import { createEmployee, updateEmployee, getDepartments } from './actions'; import { toast } from 'sonner'; interface EmployeeFormModalProps { open: boolean; onOpenChange: (open: boolean) => void; employee?: Employee | null; // null = 신규, Employee = 수정 onSuccess: () => void; } export function EmployeeFormModal({ open, onOpenChange, employee, onSuccess, }: EmployeeFormModalProps) { const isEdit = !!employee; const [loading, setLoading] = useState(false); const [departments, setDepartments] = useState<{ id: number; name: string }[]>([]); const [formData, setFormData] = useState({ name: '', email: '', phone: '', department_id: undefined, position_key: '', employment_type: 'regular', employee_code: '', hire_date: '', gender: undefined, }); // 부서 목록 로드 useEffect(() => { getDepartments().then((result) => { if (result.success) { setDepartments(result.data || []); } }); }, []); // 수정 모드: 기존 데이터 로드 useEffect(() => { if (employee) { setFormData({ name: employee.name, email: employee.email || '', phone: employee.phone || '', department_id: employee.profile.department?.id, position_key: employee.profile.position || '', employment_type: employee.profile.employment_type, employee_code: employee.profile.employee_code || '', hire_date: employee.profile.hire_date || '', gender: employee.profile.gender || undefined, }); } else { // 신규 모드: 초기화 setFormData({ name: '', email: '', phone: '', department_id: undefined, position_key: '', employment_type: 'regular', employee_code: '', hire_date: '', gender: undefined, }); } }, [employee, open]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); try { const result = isEdit ? await updateEmployee(employee.id.toString(), formData) : await createEmployee(formData); if (result.success) { toast.success(isEdit ? '사원 정보가 수정되었습니다.' : '사원이 등록되었습니다.'); onSuccess(); onOpenChange(false); } else { toast.error(result.error || '처리 중 오류가 발생했습니다.'); } } finally { setLoading(false); } }; return ( {isEdit ? '사원 정보 수정' : '사원 등록'}
{/* 기본 정보 */}
setFormData({ ...formData, name: e.target.value })} required />
setFormData({ ...formData, employee_code: e.target.value })} placeholder="EMP-001" />
setFormData({ ...formData, email: e.target.value })} />
setFormData({ ...formData, phone: e.target.value })} placeholder="010-0000-0000" />
{/* 조직 정보 */}
setFormData({ ...formData, position_key: e.target.value })} placeholder="사원, 대리, 과장..." />
setFormData({ ...formData, hire_date: e.target.value })} />
); } ``` #### 3.5 CreateAccountModal.tsx 계정 생성 모달 ```typescript // react/src/components/hr/EmployeeManagement/CreateAccountModal.tsx 'use client'; import { useState } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { Employee, CreateAccountFormData } from './types'; import { createEmployeeAccount } from './actions'; import { toast } from 'sonner'; import { AlertCircle, UserPlus } from 'lucide-react'; interface CreateAccountModalProps { open: boolean; onOpenChange: (open: boolean) => void; employee: Employee | null; onSuccess: () => void; } export function CreateAccountModal({ open, onOpenChange, employee, onSuccess, }: CreateAccountModalProps) { const [loading, setLoading] = useState(false); const [formData, setFormData] = useState({ login_id: '', password: '', password_confirmation: '', must_change_password: true, }); const [errors, setErrors] = useState>({}); const validate = (): boolean => { const newErrors: Record = {}; if (!formData.login_id.trim()) { newErrors.login_id = '로그인 ID를 입력해주세요.'; } else if (formData.login_id.length < 4) { newErrors.login_id = '로그인 ID는 4자 이상이어야 합니다.'; } if (!formData.password) { newErrors.password = '비밀번호를 입력해주세요.'; } else if (formData.password.length < 8) { newErrors.password = '비밀번호는 8자 이상이어야 합니다.'; } if (formData.password !== formData.password_confirmation) { newErrors.password_confirmation = '비밀번호가 일치하지 않습니다.'; } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!validate() || !employee) return; setLoading(true); try { const result = await createEmployeeAccount(employee.id.toString(), formData); if (result.success) { toast.success('시스템 계정이 생성되었습니다.'); onSuccess(); onOpenChange(false); // 폼 초기화 setFormData({ login_id: '', password: '', password_confirmation: '', must_change_password: true, }); } else { toast.error(result.error || '계정 생성에 실패했습니다.'); } } finally { setLoading(false); } }; return ( 시스템 계정 생성 {employee?.name}님에게 시스템 로그인 계정을 부여합니다.
setFormData({ ...formData, login_id: e.target.value })} placeholder="예: hong.gildong" className={errors.login_id ? 'border-destructive' : ''} /> {errors.login_id && (

{errors.login_id}

)}
setFormData({ ...formData, password: e.target.value })} className={errors.password ? 'border-destructive' : ''} /> {errors.password && (

{errors.password}

)}
setFormData({ ...formData, password_confirmation: e.target.value }) } className={errors.password_confirmation ? 'border-destructive' : ''} /> {errors.password_confirmation && (

{errors.password_confirmation}

)}
setFormData({ ...formData, must_change_password: !!checked }) } />
); } ``` #### 3.6 EmployeeActions 컴포넌트 ```typescript // 사원 목록에서 계정 상태 표시 및 액션 버튼 (index.tsx에 포함) import { UserPlus, UserMinus, MoreHorizontal, Edit, Trash2 } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; interface EmployeeActionsProps { employee: EmployeeListItem; onEdit: (employee: EmployeeListItem) => void; onDelete: (id: string) => void; onCreateAccount: (employee: EmployeeListItem) => void; onRevokeAccount: (id: string) => void; } function EmployeeActions({ employee, onEdit, onDelete, onCreateAccount, onRevokeAccount, }: EmployeeActionsProps) { return (
{/* 계정 상태 배지 */} {employee.has_account ? ( 계정 있음 ) : ( 계정 없음 )} {/* 액션 드롭다운 */} onEdit(employee)}> 수정 {employee.has_account ? ( onRevokeAccount(employee.id.toString())} className="text-amber-600" > 계정 해제 ) : ( onCreateAccount(employee)}> 계정 생성 )} onDelete(employee.id.toString())} className="text-destructive" > 삭제
); } ``` --- ## 4. API 명세 ### 엔드포인트 목록 | Method | Endpoint | 설명 | 인증 | |--------|----------|------|------| | GET | `/v1/employees` | 사원 목록 | ✅ | | POST | `/v1/employees` | 사원 등록 (계정 없이) | ✅ | | GET | `/v1/employees/{id}` | 사원 상세 | ✅ | | PATCH | `/v1/employees/{id}` | 사원 수정 | ✅ | | DELETE | `/v1/employees/{id}` | 사원 삭제 | ✅ | | POST | `/v1/employees/{id}/create-account` | 계정 생성 | ✅ | | POST | `/v1/employees/{id}/revoke-account` | 계정 해제 | ✅ | ### 필터 파라미터 ```yaml GET /v1/employees: q: string # 이름, 이메일, 사원코드 검색 status: string # active, leave, resigned department_id: int # 부서 필터 has_account: bool # true=계정있음, false=계정없음 page: int per_page: int ``` ### 응답 예시 ```json // GET /v1/employees { "success": true, "data": { "data": [ { "id": 1, "name": "홍길동", "email": "hong@example.com", "phone": "010-1234-5678", "has_account": false, "profile": { "employee_code": "EMP-001", "department": { "id": 1, "name": "개발팀" }, "position": "사원", "status": "active", "hire_date": "2024-01-15" } } ], "total": 50, "per_page": 20, "current_page": 1 } } ``` --- ## 5. 체크리스트 ### Phase 1: DB 스키마 (0.5일) - [ ] `users.password` nullable 확인 - [ ] `users.user_id` nullable 확인 (UNIQUE 제약 조건 주의) - [ ] 마이그레이션 파일 생성 - [ ] 마이그레이션 실행 및 검증 - [ ] 기존 데이터 영향 확인 ### Phase 2: API 백엔드 (1.5일) - [ ] EmployeeService 생성 - [ ] index() - 목록 조회 + has_account 필터 - [ ] show() - 상세 조회 - [ ] store() - 사원만 등록 (password NULL) - [ ] update() - 수정 - [ ] destroy() - 삭제 - [ ] createAccount() - 계정 생성 - [ ] revokeAccount() - 계정 해제 - [ ] EmployeeController 생성 - [ ] FormRequest 생성 (Index, Store, Update, CreateAccount) - [ ] 라우트 등록 - [ ] 언어 파일 추가 (ko/employee.php) - [ ] Swagger 문서 작성 - [ ] Pint 실행 - [ ] 테스트 ### Phase 3: React 프론트엔드 (1일) - [ ] types.ts 타입 정의 - [ ] Employee, EmployeeListItem, EmployeeProfile 인터페이스 - [ ] EmployeeFormData, CreateAccountFormData 인터페이스 - [ ] EmployeeFilters, PaginatedResponse 인터페이스 - [ ] actions.ts API 함수 추가 - [ ] getEmployees() - 목록 조회 - [ ] getEmployee() - 상세 조회 - [ ] createEmployee() - 사원 등록 - [ ] updateEmployee() - 사원 수정 - [ ] deleteEmployee() - 사원 삭제 - [ ] createEmployeeAccount() - 계정 생성 - [ ] revokeEmployeeAccount() - 계정 해제 - [ ] getDepartments() - 부서 목록 조회 - [ ] EmployeeFormModal.tsx 구현 - [ ] 사원 등록/수정 폼 (기본정보, 조직정보) - [ ] 부서 목록 Select - [ ] 고용형태, 성별 Select - [ ] CreateAccountModal.tsx 구현 - [ ] 로그인 ID, 비밀번호 입력 - [ ] 비밀번호 확인, 유효성 검사 - [ ] 첫 로그인 비밀번호 변경 옵션 - [ ] index.tsx 수정 - [ ] 사원 목록에 계정 상태 배지 표시 - [ ] EmployeeActions 드롭다운 (수정, 계정생성/해제, 삭제) - [ ] has_account 필터 추가 - [ ] 모달 상태 관리 --- ## 6. 예상 일정 | Phase | 내용 | 예상 일수 | |-------|------|----------| | Phase 1 | DB 스키마 확인/수정 | 0.5일 | | Phase 2 | API 백엔드 구현 | 1.5일 | | Phase 3 | React 프론트엔드 | 1일 | | **합계** | | **3일** | --- ## 7. 주의사항 ### 보안 - 주민등록번호는 반드시 암호화 저장 (`encrypt()`) - 계정 해제 시 기존 토큰 모두 삭제 - 슈퍼관리자 계정은 해제/삭제 불가 ### 데이터 정합성 - 사원 삭제 시 관련 데이터 처리 (근태, 급여 등) - user_id UNIQUE 제약조건과 NULL 허용 동시 적용 ### 기존 시스템 호환 - 기존 사용자 데이터에 영향 없음 - 로그인 로직 변경 필요 없음 (password NULL이면 이미 로그인 불가) --- ## 8. 관련 문서 - `docs/features/hr/hr-api-analysis.md` - HR API 상세 분석 - `docs/specs/database-schema.md` - DB 스키마 - `docs/standards/api-rules.md` - API 개발 규칙 - `docs/architecture/security-policy.md` - 보안 정책 --- **작성자**: Claude **검토**: 필요 **승인**: 대기