From b9f72576b046bbb9db1f7a7a7e72d6855b202db5 Mon Sep 17 00:00:00 2001 From: kent Date: Fri, 26 Dec 2025 01:32:34 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20=EC=82=AC=EC=9B=90-=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0=20=EA=B8=B0=EB=8A=A5=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - changes/20251225_employee_user_linkage.md: 변경 이력 문서 - plans/employee-user-linkage-plan.md: 구현 계획 문서 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- changes/20251225_employee_user_linkage.md | 78 + plans/employee-user-linkage-plan.md | 1816 +++++++++++++++++++++ 2 files changed, 1894 insertions(+) create mode 100644 changes/20251225_employee_user_linkage.md create mode 100644 plans/employee-user-linkage-plan.md diff --git a/changes/20251225_employee_user_linkage.md b/changes/20251225_employee_user_linkage.md new file mode 100644 index 0000000..3612ffd --- /dev/null +++ b/changes/20251225_employee_user_linkage.md @@ -0,0 +1,78 @@ +# 변경 내용 요약 + +**날짜:** 2025-12-25 +**작업자:** Claude Code +**이슈:** employee-user-linkage-plan.md 구현 + +## 📋 변경 개요 +사원-회원 연결 기능의 핵심 API 구현: +- 사원 전용 등록 (시스템 계정 없이) +- 계정 해제 기능 (revokeAccount) + +## 📁 수정된 파일 + +### 1. api/app/Services/EmployeeService.php +- **store()**: password 생성 로직 수정 - `create_account=false`면 password=NULL 허용 +- **revokeAccount()**: 신규 메서드 추가 - 시스템 계정 해제 (password=NULL, 토큰 무효화) + +### 2. api/app/Http/Controllers/Api/V1/EmployeeController.php +- **revokeAccount()**: 신규 액션 추가 +- **createAccount()**: 응답 메시지 i18n 키로 변경 + +### 3. api/routes/api.php +- `POST /employees/{id}/revoke-account` 라우트 추가 + +### 4. api/lang/ko/employee.php (신규) +- 사원 관련 메시지 키 정의 + +### 5. api/lang/en/employee.php (신규) +- 영문 메시지 키 정의 + +## 🔧 상세 변경 사항 + +### 1. EmployeeService::store() 수정 + +**변경 전:** +```php +'password' => Hash::make($data['password'] ?? Str::random(16)), +``` + +**변경 후:** +```php +$password = null; +$createAccount = $data['create_account'] ?? false; +if ($createAccount && ! empty($data['password'])) { + $password = Hash::make($data['password']); +} +// ... +'password' => $password, +``` + +**이유:** 사원 전용 등록 지원 (로그인 불가) + +### 2. EmployeeService::revokeAccount() 추가 + +```php +public function revokeAccount(int $id): TenantUserProfile +{ + // tenant_id 격리 적용 + // password=NULL로 설정 (로그인 불가) + // 기존 토큰 무효화 +} +``` + +**이유:** 시스템 계정 해제 기능 + +## ✅ 테스트 체크리스트 +- [x] PHP 문법 검사 통과 +- [x] Pint 코드 포맷 통과 +- [x] 라우트 등록 확인 +- [ ] Swagger 문서 작성 (추후) +- [ ] API 통합 테스트 (추후) + +## ⚠️ 배포 시 주의사항 +- users.password 컬럼이 nullable인지 확인 필요 +- 기존 사원 데이터에 영향 없음 + +## 🔗 관련 문서 +- docs/plans/employee-user-linkage-plan.md diff --git a/plans/employee-user-linkage-plan.md b/plans/employee-user-linkage-plan.md new file mode 100644 index 0000000..f6ca8f0 --- /dev/null +++ b/plans/employee-user-linkage-plan.md @@ -0,0 +1,1816 @@ +# 사원-회원 연결 기능 구현 계획 + +> **작성일**: 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 +**검토**: 필요 +**승인**: 대기