Files
sam-docs/dev/dev_plans/employee-user-linkage-plan.md
권혁성 db63fcff85 refactor: [docs] 팀별 폴더 구조 재편 (공유/개발/프론트/기획)
- 개발팀 전용 폴더 dev/ 생성 (standards, guides, quickstart, changes, deploys, data, history, dev_plans 이동)
- 프론트엔드 전용 폴더 frontend/ 생성 (api/ → frontend/api-specs/)
- 기획팀 폴더 requests/ 생성
- plans/ → dev/dev_plans/ 이름 변경
- README.md 신규 (사람용 안내), INDEX.md 재작성 (Claude Code용)
- resources.md 신규 (노션 링크용, assets/brochure 이관 예정)
- CURRENT_WORKS.md 삭제, TODO.md → dev/ 이동
- 전체 참조 경로 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:46:03 +09:00

55 KiB

사원-회원 연결 기능 구현 계획

작성일: 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 테이블 확인

-- password nullable 확인
DESCRIBE users;

-- 필요시 수정
ALTER TABLE users MODIFY password VARCHAR(255) NULL;

1.2 user_id (로그인 ID) nullable 확인

-- user_id nullable 확인 (사원만 등록 시 로그인 ID 없음)
ALTER TABLE users MODIFY user_id VARCHAR(50) NULL;

-- UNIQUE 제약조건 수정 (NULL 허용)
-- MySQL 8.0+는 NULL 값 중복 허용

마이그레이션 파일

php artisan make:migration modify_users_for_employee_only --table=users
// 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
// 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
// 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 라우트 등록

// 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
// 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
// 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
// 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
// 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 언어 파일

// 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
// 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 타입 정의

// 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 함수

// 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 사원 등록/수정 폼

// 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 계정 생성 모달

// 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 컴포넌트

// 사원 목록에서 계정 상태 표시 및 액션 버튼 (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 계정 해제

필터 파라미터

GET /v1/employees:
  q: string           # 이름, 이메일, 사원코드 검색
  status: string      # active, leave, resigned
  department_id: int  # 부서 필터
  has_account: bool   # true=계정있음, false=계정없음
  page: int
  per_page: int

응답 예시

// 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 검토: 필요 승인: 대기