1817 lines
55 KiB
Markdown
1817 lines
55 KiB
Markdown
|
|
# 사원-회원 연결 기능 구현 계획
|
||
|
|
|
||
|
|
> **작성일**: 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
|
||
|
|
<?php
|
||
|
|
// api/app/Services/EmployeeService.php
|
||
|
|
|
||
|
|
namespace App\Services;
|
||
|
|
|
||
|
|
use App\Models\Members\User;
|
||
|
|
use App\Models\Tenants\TenantUserProfile;
|
||
|
|
use Illuminate\Support\Facades\DB;
|
||
|
|
use Illuminate\Support\Facades\Hash;
|
||
|
|
|
||
|
|
class EmployeeService
|
||
|
|
{
|
||
|
|
/**
|
||
|
|
* 사원 상세 조회
|
||
|
|
*/
|
||
|
|
public function show(int $userId): array
|
||
|
|
{
|
||
|
|
$tenantId = auth()->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
|
||
|
|
<?php
|
||
|
|
// api/app/Http/Controllers/Api/V1/EmployeeController.php
|
||
|
|
|
||
|
|
namespace App\Http\Controllers\Api\V1;
|
||
|
|
|
||
|
|
use App\Http\Controllers\Controller;
|
||
|
|
use App\Services\EmployeeService;
|
||
|
|
use App\Http\Requests\Employee\IndexRequest;
|
||
|
|
use App\Http\Requests\Employee\StoreRequest;
|
||
|
|
use App\Http\Requests\Employee\UpdateRequest;
|
||
|
|
use App\Http\Requests\Employee\CreateAccountRequest;
|
||
|
|
|
||
|
|
class EmployeeController extends Controller
|
||
|
|
{
|
||
|
|
public function __construct(
|
||
|
|
private EmployeeService $service
|
||
|
|
) {}
|
||
|
|
|
||
|
|
public function index(IndexRequest $request)
|
||
|
|
{
|
||
|
|
$result = $this->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
|
||
|
|
<?php
|
||
|
|
// api/app/Http/Requests/Employee/IndexRequest.php
|
||
|
|
|
||
|
|
namespace App\Http\Requests\Employee;
|
||
|
|
|
||
|
|
use Illuminate\Foundation\Http\FormRequest;
|
||
|
|
|
||
|
|
class IndexRequest extends FormRequest
|
||
|
|
{
|
||
|
|
public function rules(): array
|
||
|
|
{
|
||
|
|
return [
|
||
|
|
'q' => '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
|
||
|
|
<?php
|
||
|
|
// api/app/Http/Requests/Employee/StoreRequest.php
|
||
|
|
|
||
|
|
namespace App\Http\Requests\Employee;
|
||
|
|
|
||
|
|
use Illuminate\Foundation\Http\FormRequest;
|
||
|
|
|
||
|
|
class StoreRequest extends FormRequest
|
||
|
|
{
|
||
|
|
public function rules(): array
|
||
|
|
{
|
||
|
|
return [
|
||
|
|
'name' => '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
|
||
|
|
<?php
|
||
|
|
// api/app/Http/Requests/Employee/UpdateRequest.php
|
||
|
|
|
||
|
|
namespace App\Http\Requests\Employee;
|
||
|
|
|
||
|
|
use Illuminate\Foundation\Http\FormRequest;
|
||
|
|
|
||
|
|
class UpdateRequest extends FormRequest
|
||
|
|
{
|
||
|
|
public function rules(): array
|
||
|
|
{
|
||
|
|
return [
|
||
|
|
'name' => '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
|
||
|
|
<?php
|
||
|
|
// api/app/Http/Requests/Employee/CreateAccountRequest.php
|
||
|
|
|
||
|
|
namespace App\Http\Requests\Employee;
|
||
|
|
|
||
|
|
use Illuminate\Foundation\Http\FormRequest;
|
||
|
|
|
||
|
|
class CreateAccountRequest extends FormRequest
|
||
|
|
{
|
||
|
|
public function rules(): array
|
||
|
|
{
|
||
|
|
return [
|
||
|
|
'login_id' => '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
|
||
|
|
<?php
|
||
|
|
// api/app/Swagger/v1/EmployeeApi.php
|
||
|
|
|
||
|
|
namespace App\Swagger\V1;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @OA\Tag(
|
||
|
|
* name="Employees",
|
||
|
|
* description="사원 관리 API"
|
||
|
|
* )
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @OA\Get(
|
||
|
|
* path="/api/v1/employees",
|
||
|
|
* operationId="getEmployees",
|
||
|
|
* tags={"Employees"},
|
||
|
|
* summary="사원 목록 조회",
|
||
|
|
* security={{"bearerAuth":{}}},
|
||
|
|
* @OA\Parameter(name="q", in="query", description="검색어 (이름, 이메일, 사원코드)", @OA\Schema(type="string")),
|
||
|
|
* @OA\Parameter(name="status", in="query", description="상태", @OA\Schema(type="string", enum={"active", "leave", "resigned"})),
|
||
|
|
* @OA\Parameter(name="department_id", in="query", description="부서 ID", @OA\Schema(type="integer")),
|
||
|
|
* @OA\Parameter(name="has_account", in="query", description="계정 보유 여부", @OA\Schema(type="boolean")),
|
||
|
|
* @OA\Parameter(name="page", in="query", description="페이지", @OA\Schema(type="integer")),
|
||
|
|
* @OA\Parameter(name="per_page", in="query", description="페이지당 수", @OA\Schema(type="integer")),
|
||
|
|
* @OA\Response(response=200, description="성공", @OA\JsonContent(
|
||
|
|
* @OA\Property(property="success", type="boolean", example=true),
|
||
|
|
* @OA\Property(property="data", type="object")
|
||
|
|
* )),
|
||
|
|
* @OA\Response(response=401, description="인증 실패")
|
||
|
|
* )
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @OA\Post(
|
||
|
|
* path="/api/v1/employees",
|
||
|
|
* operationId="createEmployee",
|
||
|
|
* tags={"Employees"},
|
||
|
|
* summary="사원 등록 (계정 없이)",
|
||
|
|
* security={{"bearerAuth":{}}},
|
||
|
|
* @OA\RequestBody(
|
||
|
|
* required=true,
|
||
|
|
* @OA\JsonContent(
|
||
|
|
* required={"name"},
|
||
|
|
* @OA\Property(property="name", type="string", example="홍길동"),
|
||
|
|
* @OA\Property(property="email", type="string", example="hong@example.com"),
|
||
|
|
* @OA\Property(property="phone", type="string", example="010-1234-5678"),
|
||
|
|
* @OA\Property(property="department_id", type="integer", example=1),
|
||
|
|
* @OA\Property(property="position_key", type="string", example="staff"),
|
||
|
|
* @OA\Property(property="employment_type", type="string", enum={"regular", "contract", "parttime", "intern"}),
|
||
|
|
* @OA\Property(property="employee_code", type="string", example="EMP-001"),
|
||
|
|
* @OA\Property(property="hire_date", type="string", format="date", example="2024-01-15")
|
||
|
|
* )
|
||
|
|
* ),
|
||
|
|
* @OA\Response(response=200, description="성공"),
|
||
|
|
* @OA\Response(response=422, description="유효성 검사 실패")
|
||
|
|
* )
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @OA\Get(
|
||
|
|
* path="/api/v1/employees/{id}",
|
||
|
|
* operationId="getEmployee",
|
||
|
|
* tags={"Employees"},
|
||
|
|
* summary="사원 상세 조회",
|
||
|
|
* security={{"bearerAuth":{}}},
|
||
|
|
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||
|
|
* @OA\Response(response=200, description="성공"),
|
||
|
|
* @OA\Response(response=404, description="사원 없음")
|
||
|
|
* )
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @OA\Patch(
|
||
|
|
* path="/api/v1/employees/{id}",
|
||
|
|
* operationId="updateEmployee",
|
||
|
|
* tags={"Employees"},
|
||
|
|
* summary="사원 정보 수정",
|
||
|
|
* security={{"bearerAuth":{}}},
|
||
|
|
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||
|
|
* @OA\RequestBody(
|
||
|
|
* @OA\JsonContent(
|
||
|
|
* @OA\Property(property="name", type="string"),
|
||
|
|
* @OA\Property(property="email", type="string"),
|
||
|
|
* @OA\Property(property="phone", type="string"),
|
||
|
|
* @OA\Property(property="department_id", type="integer"),
|
||
|
|
* @OA\Property(property="position_key", type="string"),
|
||
|
|
* @OA\Property(property="status", type="string", enum={"active", "leave", "resigned"})
|
||
|
|
* )
|
||
|
|
* ),
|
||
|
|
* @OA\Response(response=200, description="성공"),
|
||
|
|
* @OA\Response(response=404, description="사원 없음")
|
||
|
|
* )
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @OA\Delete(
|
||
|
|
* path="/api/v1/employees/{id}",
|
||
|
|
* operationId="deleteEmployee",
|
||
|
|
* tags={"Employees"},
|
||
|
|
* summary="사원 삭제",
|
||
|
|
* security={{"bearerAuth":{}}},
|
||
|
|
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||
|
|
* @OA\Response(response=200, description="성공"),
|
||
|
|
* @OA\Response(response=404, description="사원 없음")
|
||
|
|
* )
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @OA\Post(
|
||
|
|
* path="/api/v1/employees/{id}/create-account",
|
||
|
|
* operationId="createEmployeeAccount",
|
||
|
|
* tags={"Employees"},
|
||
|
|
* summary="시스템 계정 생성",
|
||
|
|
* description="사원에게 시스템 로그인 계정을 부여합니다",
|
||
|
|
* security={{"bearerAuth":{}}},
|
||
|
|
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||
|
|
* @OA\RequestBody(
|
||
|
|
* required=true,
|
||
|
|
* @OA\JsonContent(
|
||
|
|
* required={"login_id", "password", "password_confirmation"},
|
||
|
|
* @OA\Property(property="login_id", type="string", example="hong.gildong"),
|
||
|
|
* @OA\Property(property="password", type="string", format="password", example="SecurePass123!"),
|
||
|
|
* @OA\Property(property="password_confirmation", type="string", format="password"),
|
||
|
|
* @OA\Property(property="must_change_password", type="boolean", example=true)
|
||
|
|
* )
|
||
|
|
* ),
|
||
|
|
* @OA\Response(response=200, description="성공"),
|
||
|
|
* @OA\Response(response=400, description="이미 계정 보유"),
|
||
|
|
* @OA\Response(response=422, description="유효성 검사 실패")
|
||
|
|
* )
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @OA\Post(
|
||
|
|
* path="/api/v1/employees/{id}/revoke-account",
|
||
|
|
* operationId="revokeEmployeeAccount",
|
||
|
|
* tags={"Employees"},
|
||
|
|
* summary="시스템 계정 해제",
|
||
|
|
* description="사원의 시스템 로그인 권한을 해제합니다 (사원 정보는 유지)",
|
||
|
|
* security={{"bearerAuth":{}}},
|
||
|
|
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||
|
|
* @OA\Response(response=200, description="성공"),
|
||
|
|
* @OA\Response(response=400, description="계정 없음 또는 슈퍼관리자"),
|
||
|
|
* @OA\Response(response=404, description="사원 없음")
|
||
|
|
* )
|
||
|
|
*/
|
||
|
|
class EmployeeApi {}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Phase 3: React 프론트엔드 (1일)
|
||
|
|
|
||
|
|
#### 3.1 수정 파일
|
||
|
|
```
|
||
|
|
react/src/components/hr/EmployeeManagement/
|
||
|
|
├── actions.ts # API 함수 추가
|
||
|
|
├── types.ts # 타입 정의
|
||
|
|
├── index.tsx # 계정 연결/해제 UI 추가
|
||
|
|
├── EmployeeFormModal.tsx # 사원 등록/수정 폼
|
||
|
|
└── CreateAccountModal.tsx # 계정 생성 모달 (신규)
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 3.2 types.ts 타입 정의
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
// react/src/components/hr/EmployeeManagement/types.ts
|
||
|
|
|
||
|
|
export type EmployeeStatus = 'active' | 'leave' | 'resigned';
|
||
|
|
export type EmploymentType = 'regular' | 'contract' | 'parttime' | 'intern';
|
||
|
|
export type Gender = 'male' | 'female';
|
||
|
|
|
||
|
|
export interface Department {
|
||
|
|
id: number;
|
||
|
|
name: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface EmployeeProfile {
|
||
|
|
employee_code: string | null;
|
||
|
|
department: Department | null;
|
||
|
|
position: string | null;
|
||
|
|
employment_type: EmploymentType;
|
||
|
|
status: EmployeeStatus;
|
||
|
|
hire_date: string | null;
|
||
|
|
salary: number | null;
|
||
|
|
rank: string | null;
|
||
|
|
gender: Gender | null;
|
||
|
|
address: {
|
||
|
|
zipcode?: string;
|
||
|
|
address1?: string;
|
||
|
|
address2?: string;
|
||
|
|
} | null;
|
||
|
|
bank_account: {
|
||
|
|
bank_name?: string;
|
||
|
|
account_number?: string;
|
||
|
|
account_holder?: string;
|
||
|
|
} | null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface Employee {
|
||
|
|
id: number;
|
||
|
|
name: string;
|
||
|
|
email: string | null;
|
||
|
|
phone: string | null;
|
||
|
|
has_account: boolean;
|
||
|
|
login_id: string | null;
|
||
|
|
profile: EmployeeProfile;
|
||
|
|
created_at: string;
|
||
|
|
updated_at: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface EmployeeListItem {
|
||
|
|
id: number;
|
||
|
|
name: string;
|
||
|
|
email: string | null;
|
||
|
|
phone: string | null;
|
||
|
|
has_account: boolean;
|
||
|
|
profile: {
|
||
|
|
employee_code: string | null;
|
||
|
|
department: Department | null;
|
||
|
|
position: string | null;
|
||
|
|
status: EmployeeStatus;
|
||
|
|
hire_date: string | null;
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface EmployeeFormData {
|
||
|
|
name: string;
|
||
|
|
email?: string;
|
||
|
|
phone?: string;
|
||
|
|
department_id?: number;
|
||
|
|
position_key?: string;
|
||
|
|
employment_type?: EmploymentType;
|
||
|
|
employee_code?: string;
|
||
|
|
hire_date?: string;
|
||
|
|
salary?: number;
|
||
|
|
rank?: string;
|
||
|
|
gender?: Gender;
|
||
|
|
address?: {
|
||
|
|
zipcode?: string;
|
||
|
|
address1?: string;
|
||
|
|
address2?: string;
|
||
|
|
};
|
||
|
|
bank_account?: {
|
||
|
|
bank_name?: string;
|
||
|
|
account_number?: string;
|
||
|
|
account_holder?: string;
|
||
|
|
};
|
||
|
|
resident_number?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface CreateAccountFormData {
|
||
|
|
login_id: string;
|
||
|
|
password: string;
|
||
|
|
password_confirmation: string;
|
||
|
|
must_change_password?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface EmployeeFilters {
|
||
|
|
q?: string;
|
||
|
|
status?: EmployeeStatus;
|
||
|
|
department_id?: number;
|
||
|
|
has_account?: boolean;
|
||
|
|
page?: number;
|
||
|
|
per_page?: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface PaginatedResponse<T> {
|
||
|
|
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<EmployeeListItem>; 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<EmployeeFormData>
|
||
|
|
): 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<EmployeeFormData>({
|
||
|
|
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 (
|
||
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
|
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>{isEdit ? '사원 정보 수정' : '사원 등록'}</DialogTitle>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||
|
|
{/* 기본 정보 */}
|
||
|
|
<div className="grid grid-cols-2 gap-4">
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="name">이름 *</Label>
|
||
|
|
<Input
|
||
|
|
id="name"
|
||
|
|
value={formData.name}
|
||
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||
|
|
required
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="employee_code">사원번호</Label>
|
||
|
|
<Input
|
||
|
|
id="employee_code"
|
||
|
|
value={formData.employee_code}
|
||
|
|
onChange={(e) => setFormData({ ...formData, employee_code: e.target.value })}
|
||
|
|
placeholder="EMP-001"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="grid grid-cols-2 gap-4">
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="email">이메일</Label>
|
||
|
|
<Input
|
||
|
|
id="email"
|
||
|
|
type="email"
|
||
|
|
value={formData.email}
|
||
|
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="phone">전화번호</Label>
|
||
|
|
<Input
|
||
|
|
id="phone"
|
||
|
|
value={formData.phone}
|
||
|
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||
|
|
placeholder="010-0000-0000"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 조직 정보 */}
|
||
|
|
<div className="grid grid-cols-2 gap-4">
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="department_id">부서</Label>
|
||
|
|
<Select
|
||
|
|
value={formData.department_id?.toString()}
|
||
|
|
onValueChange={(v) => setFormData({ ...formData, department_id: parseInt(v) })}
|
||
|
|
>
|
||
|
|
<SelectTrigger>
|
||
|
|
<SelectValue placeholder="부서 선택" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{departments.map((dept) => (
|
||
|
|
<SelectItem key={dept.id} value={dept.id.toString()}>
|
||
|
|
{dept.name}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="position_key">직급</Label>
|
||
|
|
<Input
|
||
|
|
id="position_key"
|
||
|
|
value={formData.position_key}
|
||
|
|
onChange={(e) => setFormData({ ...formData, position_key: e.target.value })}
|
||
|
|
placeholder="사원, 대리, 과장..."
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="grid grid-cols-3 gap-4">
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="employment_type">고용형태</Label>
|
||
|
|
<Select
|
||
|
|
value={formData.employment_type}
|
||
|
|
onValueChange={(v) => setFormData({ ...formData, employment_type: v as EmploymentType })}
|
||
|
|
>
|
||
|
|
<SelectTrigger>
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="regular">정규직</SelectItem>
|
||
|
|
<SelectItem value="contract">계약직</SelectItem>
|
||
|
|
<SelectItem value="parttime">파트타임</SelectItem>
|
||
|
|
<SelectItem value="intern">인턴</SelectItem>
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="hire_date">입사일</Label>
|
||
|
|
<Input
|
||
|
|
id="hire_date"
|
||
|
|
type="date"
|
||
|
|
value={formData.hire_date}
|
||
|
|
onChange={(e) => setFormData({ ...formData, hire_date: e.target.value })}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="gender">성별</Label>
|
||
|
|
<Select
|
||
|
|
value={formData.gender}
|
||
|
|
onValueChange={(v) => setFormData({ ...formData, gender: v as Gender })}
|
||
|
|
>
|
||
|
|
<SelectTrigger>
|
||
|
|
<SelectValue placeholder="선택" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="male">남성</SelectItem>
|
||
|
|
<SelectItem value="female">여성</SelectItem>
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<DialogFooter>
|
||
|
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||
|
|
취소
|
||
|
|
</Button>
|
||
|
|
<Button type="submit" disabled={loading}>
|
||
|
|
{loading ? '처리중...' : isEdit ? '수정' : '등록'}
|
||
|
|
</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</form>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 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<CreateAccountFormData>({
|
||
|
|
login_id: '',
|
||
|
|
password: '',
|
||
|
|
password_confirmation: '',
|
||
|
|
must_change_password: true,
|
||
|
|
});
|
||
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||
|
|
|
||
|
|
const validate = (): boolean => {
|
||
|
|
const newErrors: Record<string, string> = {};
|
||
|
|
|
||
|
|
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 (
|
||
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
|
|
<DialogContent className="max-w-md">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle className="flex items-center gap-2">
|
||
|
|
<UserPlus className="w-5 h-5" />
|
||
|
|
시스템 계정 생성
|
||
|
|
</DialogTitle>
|
||
|
|
<DialogDescription>
|
||
|
|
{employee?.name}님에게 시스템 로그인 계정을 부여합니다.
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="login_id">로그인 ID *</Label>
|
||
|
|
<Input
|
||
|
|
id="login_id"
|
||
|
|
value={formData.login_id}
|
||
|
|
onChange={(e) => setFormData({ ...formData, login_id: e.target.value })}
|
||
|
|
placeholder="예: hong.gildong"
|
||
|
|
className={errors.login_id ? 'border-destructive' : ''}
|
||
|
|
/>
|
||
|
|
{errors.login_id && (
|
||
|
|
<p className="text-sm text-destructive mt-1 flex items-center gap-1">
|
||
|
|
<AlertCircle className="w-3 h-3" />
|
||
|
|
{errors.login_id}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="password">비밀번호 *</Label>
|
||
|
|
<Input
|
||
|
|
id="password"
|
||
|
|
type="password"
|
||
|
|
value={formData.password}
|
||
|
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||
|
|
className={errors.password ? 'border-destructive' : ''}
|
||
|
|
/>
|
||
|
|
{errors.password && (
|
||
|
|
<p className="text-sm text-destructive mt-1 flex items-center gap-1">
|
||
|
|
<AlertCircle className="w-3 h-3" />
|
||
|
|
{errors.password}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="password_confirmation">비밀번호 확인 *</Label>
|
||
|
|
<Input
|
||
|
|
id="password_confirmation"
|
||
|
|
type="password"
|
||
|
|
value={formData.password_confirmation}
|
||
|
|
onChange={(e) =>
|
||
|
|
setFormData({ ...formData, password_confirmation: e.target.value })
|
||
|
|
}
|
||
|
|
className={errors.password_confirmation ? 'border-destructive' : ''}
|
||
|
|
/>
|
||
|
|
{errors.password_confirmation && (
|
||
|
|
<p className="text-sm text-destructive mt-1 flex items-center gap-1">
|
||
|
|
<AlertCircle className="w-3 h-3" />
|
||
|
|
{errors.password_confirmation}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex items-center space-x-2">
|
||
|
|
<Checkbox
|
||
|
|
id="must_change_password"
|
||
|
|
checked={formData.must_change_password}
|
||
|
|
onCheckedChange={(checked) =>
|
||
|
|
setFormData({ ...formData, must_change_password: !!checked })
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
<Label htmlFor="must_change_password" className="text-sm font-normal">
|
||
|
|
첫 로그인 시 비밀번호 변경 요구
|
||
|
|
</Label>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<DialogFooter>
|
||
|
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||
|
|
취소
|
||
|
|
</Button>
|
||
|
|
<Button type="submit" disabled={loading}>
|
||
|
|
{loading ? '생성 중...' : '계정 생성'}
|
||
|
|
</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</form>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 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 (
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
{/* 계정 상태 배지 */}
|
||
|
|
{employee.has_account ? (
|
||
|
|
<span className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300">
|
||
|
|
계정 있음
|
||
|
|
</span>
|
||
|
|
) : (
|
||
|
|
<span className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400">
|
||
|
|
계정 없음
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 액션 드롭다운 */}
|
||
|
|
<DropdownMenu>
|
||
|
|
<DropdownMenuTrigger asChild>
|
||
|
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||
|
|
<MoreHorizontal className="w-4 h-4" />
|
||
|
|
</Button>
|
||
|
|
</DropdownMenuTrigger>
|
||
|
|
<DropdownMenuContent align="end">
|
||
|
|
<DropdownMenuItem onClick={() => onEdit(employee)}>
|
||
|
|
<Edit className="w-4 h-4 mr-2" />
|
||
|
|
수정
|
||
|
|
</DropdownMenuItem>
|
||
|
|
|
||
|
|
<DropdownMenuSeparator />
|
||
|
|
|
||
|
|
{employee.has_account ? (
|
||
|
|
<DropdownMenuItem
|
||
|
|
onClick={() => onRevokeAccount(employee.id.toString())}
|
||
|
|
className="text-amber-600"
|
||
|
|
>
|
||
|
|
<UserMinus className="w-4 h-4 mr-2" />
|
||
|
|
계정 해제
|
||
|
|
</DropdownMenuItem>
|
||
|
|
) : (
|
||
|
|
<DropdownMenuItem onClick={() => onCreateAccount(employee)}>
|
||
|
|
<UserPlus className="w-4 h-4 mr-2" />
|
||
|
|
계정 생성
|
||
|
|
</DropdownMenuItem>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<DropdownMenuSeparator />
|
||
|
|
|
||
|
|
<DropdownMenuItem
|
||
|
|
onClick={() => onDelete(employee.id.toString())}
|
||
|
|
className="text-destructive"
|
||
|
|
>
|
||
|
|
<Trash2 className="w-4 h-4 mr-2" />
|
||
|
|
삭제
|
||
|
|
</DropdownMenuItem>
|
||
|
|
</DropdownMenuContent>
|
||
|
|
</DropdownMenu>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 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
|
||
|
|
**검토**: 필요
|
||
|
|
**승인**: 대기
|