- 개발팀 전용 폴더 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>
55 KiB
55 KiB
사원-회원 연결 기능 구현 계획
작성일: 2025-12-25 목적: 시스템 계정(회원) 없이 사원만 등록하고, 필요 시 계정을 연결/해제하는 기능 구현 관련 문서:
docs/features/hr/hr-api-analysis.md
1. 배경 및 문제 정의
현재 상황
users테이블: 시스템 계정 정보 (로그인용)tenant_user_profiles테이블: 테넌트별 사원 정보- 문제: 시스템을 사용하지 않는 사원도 인사관리(급여, 출퇴근 등)를 위해 등록 필요
요구사항
- 사원만 등록: 로그인 계정 없이 인사정보만 관리
- 계정 연결: 기존 사원에게 로그인 계정 부여
- 계정 해제: 로그인 권한 회수 (사원 정보는 유지)
설계 방향
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.passwordnullable 확인users.user_idnullable 확인 (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 검토: 필요 승인: 대기