feat: [hr] 인사관리 사원관리 Phase 1 구현

- Employee, Position 모델 생성 (tenant_user_profiles, positions 테이블)
- EmployeeService 생성 (CRUD, 통계, 필터/검색/페이지네이션)
- 뷰 컨트롤러(HR/EmployeeController) + API 컨트롤러 생성
- Blade 뷰: index(통계카드+HTMX테이블), create, edit, show, partials/table
- 라우트: web.php(/hr/employees/*), api.php(/admin/hr/employees/*)
This commit is contained in:
김보곤
2026-02-26 16:43:52 +09:00
parent 6b66172af7
commit bb9193bcad
12 changed files with 1533 additions and 0 deletions

View File

@@ -0,0 +1,173 @@
<?php
namespace App\Http\Controllers\Api\Admin\HR;
use App\Http\Controllers\Controller;
use App\Services\HR\EmployeeService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class EmployeeController extends Controller
{
public function __construct(
private EmployeeService $employeeService
) {}
/**
* 사원 목록 조회 (HTMX → HTML / 일반 → JSON)
*/
public function index(Request $request): JsonResponse|Response
{
$employees = $this->employeeService->getEmployees(
$request->all(),
$request->integer('per_page', 20)
);
if ($request->header('HX-Request')) {
return response(view('hr.employees.partials.table', compact('employees')));
}
return response()->json([
'success' => true,
'data' => $employees->items(),
'meta' => [
'current_page' => $employees->currentPage(),
'last_page' => $employees->lastPage(),
'per_page' => $employees->perPage(),
'total' => $employees->total(),
],
]);
}
/**
* 사원 통계
*/
public function stats(): JsonResponse
{
$stats = $this->employeeService->getStats();
return response()->json([
'success' => true,
'data' => $stats,
]);
}
/**
* 사원 등록
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:50',
'email' => 'nullable|email|max:100|unique:users,email',
'phone' => 'nullable|string|max:20',
'password' => 'nullable|string|min:6',
'department_id' => 'nullable|integer|exists:departments,id',
'position_key' => 'nullable|string|max:50',
'job_title_key' => 'nullable|string|max:50',
'work_location_key' => 'nullable|string|max:50',
'employment_type_key' => 'nullable|string|max:50',
'employee_status' => 'nullable|string|in:active,leave,resigned',
'manager_user_id' => 'nullable|integer|exists:users,id',
'display_name' => 'nullable|string|max:50',
'employee_code' => 'nullable|string|max:30',
'hire_date' => 'nullable|date',
'address' => 'nullable|string|max:200',
'emergency_contact' => 'nullable|string|max:100',
]);
$employee = $this->employeeService->createEmployee($validated);
return response()->json([
'success' => true,
'message' => '사원이 등록되었습니다.',
'data' => $employee,
], 201);
}
/**
* 사원 상세 조회
*/
public function show(int $id): JsonResponse
{
$employee = $this->employeeService->getEmployeeById($id);
if (! $employee) {
return response()->json([
'success' => false,
'message' => '사원 정보를 찾을 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'data' => $employee,
]);
}
/**
* 사원 수정
*/
public function update(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'name' => 'sometimes|required|string|max:50',
'email' => 'nullable|email|max:100',
'phone' => 'nullable|string|max:20',
'department_id' => 'nullable|integer|exists:departments,id',
'position_key' => 'nullable|string|max:50',
'job_title_key' => 'nullable|string|max:50',
'work_location_key' => 'nullable|string|max:50',
'employment_type_key' => 'nullable|string|max:50',
'employee_status' => 'nullable|string|in:active,leave,resigned',
'manager_user_id' => 'nullable|integer|exists:users,id',
'display_name' => 'nullable|string|max:50',
'employee_code' => 'nullable|string|max:30',
'hire_date' => 'nullable|date',
'address' => 'nullable|string|max:200',
'emergency_contact' => 'nullable|string|max:100',
]);
$employee = $this->employeeService->updateEmployee($id, $validated);
if (! $employee) {
return response()->json([
'success' => false,
'message' => '사원 정보를 찾을 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'message' => '사원 정보가 수정되었습니다.',
'data' => $employee,
]);
}
/**
* 사원 삭제 (퇴직 처리)
*/
public function destroy(Request $request, int $id): JsonResponse|Response
{
$result = $this->employeeService->deleteEmployee($id);
if (! $result) {
return response()->json([
'success' => false,
'message' => '사원 정보를 찾을 수 없습니다.',
], 404);
}
if ($request->header('HX-Request')) {
$employees = $this->employeeService->getEmployees($request->all(), $request->integer('per_page', 20));
return response(view('hr.employees.partials.table', compact('employees')));
}
return response()->json([
'success' => true,
'message' => '퇴직 처리되었습니다.',
]);
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\HR;
use App\Http\Controllers\Controller;
use App\Services\HR\EmployeeService;
use Illuminate\Contracts\View\View;
class EmployeeController extends Controller
{
public function __construct(
private EmployeeService $employeeService
) {}
/**
* 사원 목록 페이지
*/
public function index(): View
{
$stats = $this->employeeService->getStats();
$departments = $this->employeeService->getDepartments();
return view('hr.employees.index', [
'stats' => $stats,
'departments' => $departments,
]);
}
/**
* 사원 등록 폼
*/
public function create(): View
{
$departments = $this->employeeService->getDepartments();
$ranks = $this->employeeService->getPositions('rank');
$titles = $this->employeeService->getPositions('title');
return view('hr.employees.create', [
'departments' => $departments,
'ranks' => $ranks,
'titles' => $titles,
]);
}
/**
* 사원 상세 페이지
*/
public function show(int $id): View
{
$employee = $this->employeeService->getEmployeeById($id);
if (! $employee) {
abort(404, '사원 정보를 찾을 수 없습니다.');
}
return view('hr.employees.show', [
'employee' => $employee,
]);
}
/**
* 사원 수정 폼
*/
public function edit(int $id): View
{
$employee = $this->employeeService->getEmployeeById($id);
if (! $employee) {
abort(404, '사원 정보를 찾을 수 없습니다.');
}
$departments = $this->employeeService->getDepartments();
$ranks = $this->employeeService->getPositions('rank');
$titles = $this->employeeService->getPositions('title');
return view('hr.employees.edit', [
'employee' => $employee,
'departments' => $departments,
'ranks' => $ranks,
'titles' => $titles,
]);
}
}

166
app/Models/HR/Employee.php Normal file
View File

@@ -0,0 +1,166 @@
<?php
namespace App\Models\HR;
use App\Models\Tenants\Department;
use App\Models\User;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Employee extends Model
{
use ModelTrait;
protected $table = 'tenant_user_profiles';
protected $fillable = [
'tenant_id',
'user_id',
'department_id',
'position_key',
'job_title_key',
'work_location_key',
'employment_type_key',
'employee_status',
'manager_user_id',
'json_extra',
'profile_photo_path',
'display_name',
];
protected $casts = [
'json_extra' => 'array',
'tenant_id' => 'int',
'user_id' => 'int',
'department_id' => 'int',
'manager_user_id' => 'int',
];
protected $appends = [
'employee_code',
'hire_date',
'position_label',
'job_title_label',
];
// =========================================================================
// 관계 정의
// =========================================================================
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function department(): BelongsTo
{
return $this->belongsTo(Department::class, 'department_id');
}
public function manager(): BelongsTo
{
return $this->belongsTo(User::class, 'manager_user_id');
}
// =========================================================================
// json_extra Accessor
// =========================================================================
public function getEmployeeCodeAttribute(): ?string
{
return $this->json_extra['employee_code'] ?? null;
}
public function getHireDateAttribute(): ?string
{
return $this->json_extra['hire_date'] ?? null;
}
public function getAddressAttribute(): ?string
{
return $this->json_extra['address'] ?? null;
}
public function getEmergencyContactAttribute(): ?string
{
return $this->json_extra['emergency_contact'] ?? null;
}
public function getPositionLabelAttribute(): ?string
{
if (! $this->position_key || ! $this->tenant_id) {
return $this->position_key;
}
$position = Position::where('tenant_id', $this->tenant_id)
->where('type', Position::TYPE_RANK)
->where('key', $this->position_key)
->first();
return $position?->name ?? $this->position_key;
}
public function getJobTitleLabelAttribute(): ?string
{
if (! $this->job_title_key || ! $this->tenant_id) {
return $this->job_title_key;
}
$position = Position::where('tenant_id', $this->tenant_id)
->where('type', Position::TYPE_TITLE)
->where('key', $this->job_title_key)
->first();
return $position?->name ?? $this->job_title_key;
}
// =========================================================================
// json_extra 헬퍼
// =========================================================================
public function getJsonExtraValue(string $key, mixed $default = null): mixed
{
return $this->json_extra[$key] ?? $default;
}
public function setJsonExtraValue(string $key, mixed $value): void
{
$extra = $this->json_extra ?? [];
if ($value === null) {
unset($extra[$key]);
} else {
$extra[$key] = $value;
}
$this->json_extra = $extra;
}
// =========================================================================
// 스코프
// =========================================================================
public function scopeForTenant($query, ?int $tenantId = null)
{
$tenantId = $tenantId ?? session('selected_tenant_id');
if ($tenantId) {
return $query->where('tenant_id', $tenantId);
}
return $query;
}
public function scopeActiveEmployees($query)
{
return $query->where('employee_status', 'active');
}
public function scopeOnLeave($query)
{
return $query->where('employee_status', 'leave');
}
public function scopeResigned($query)
{
return $query->where('employee_status', 'resigned');
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Models\HR;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
class Position extends Model
{
use ModelTrait;
protected $table = 'positions';
protected $fillable = [
'tenant_id',
'type',
'key',
'name',
'sort_order',
'is_active',
];
protected $casts = [
'tenant_id' => 'int',
'sort_order' => 'int',
'is_active' => 'bool',
];
public const TYPE_RANK = 'rank';
public const TYPE_TITLE = 'title';
// =========================================================================
// 스코프
// =========================================================================
public function scopeForTenant($query, ?int $tenantId = null)
{
$tenantId = $tenantId ?? session('selected_tenant_id');
if ($tenantId) {
return $query->where('tenant_id', $tenantId);
}
return $query;
}
public function scopeRanks($query)
{
return $query->where('type', self::TYPE_RANK);
}
public function scopeTitles($query)
{
return $query->where('type', self::TYPE_TITLE);
}
public function scopeOrdered($query)
{
return $query->orderBy('sort_order');
}
}

View File

@@ -0,0 +1,237 @@
<?php
namespace App\Services\HR;
use App\Models\HR\Employee;
use App\Models\HR\Position;
use App\Models\Tenants\Department;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class EmployeeService
{
/**
* 사원 목록 조회 (페이지네이션)
*/
public function getEmployees(array $filters = [], int $perPage = 20): LengthAwarePaginator
{
$tenantId = session('selected_tenant_id');
$query = Employee::query()
->with(['user', 'department'])
->forTenant($tenantId);
// 검색 필터 (이름, 사번, 이메일)
if (! empty($filters['q'])) {
$search = $filters['q'];
$query->where(function ($q) use ($search) {
$q->where('display_name', 'like', "%{$search}%")
->orWhereHas('user', function ($uq) use ($search) {
$uq->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhere('phone', 'like', "%{$search}%");
})
->orWhereRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.employee_code')) LIKE ?", ["%{$search}%"]);
});
}
// 상태 필터
if (! empty($filters['status'])) {
$query->where('employee_status', $filters['status']);
}
// 부서 필터
if (! empty($filters['department_id'])) {
$query->where('department_id', $filters['department_id']);
}
// 정렬
$query->orderByRaw("FIELD(employee_status, 'active', 'leave', 'resigned')")
->orderBy('created_at', 'desc');
return $query->paginate($perPage);
}
/**
* 사원 상세 조회
*/
public function getEmployeeById(int $id): ?Employee
{
$tenantId = session('selected_tenant_id');
return Employee::query()
->with(['user', 'department', 'manager'])
->forTenant($tenantId)
->find($id);
}
/**
* 사원 통계
*/
public function getStats(): array
{
$tenantId = session('selected_tenant_id');
$baseQuery = Employee::query()->forTenant($tenantId);
return [
'total' => (clone $baseQuery)->count(),
'active' => (clone $baseQuery)->where('employee_status', 'active')->count(),
'leave' => (clone $baseQuery)->where('employee_status', 'leave')->count(),
'resigned' => (clone $baseQuery)->where('employee_status', 'resigned')->count(),
];
}
/**
* 사원 등록 (User + TenantUserProfile 동시 생성)
*/
public function createEmployee(array $data): Employee
{
$tenantId = session('selected_tenant_id');
return DB::transaction(function () use ($data, $tenantId) {
// User 생성
$user = User::create([
'name' => $data['name'],
'email' => $data['email'] ?? null,
'phone' => $data['phone'] ?? null,
'password' => bcrypt($data['password'] ?? 'sam1234!'),
'is_active' => true,
]);
// json_extra 구성
$jsonExtra = [];
if (! empty($data['employee_code'])) {
$jsonExtra['employee_code'] = $data['employee_code'];
}
if (! empty($data['hire_date'])) {
$jsonExtra['hire_date'] = $data['hire_date'];
}
if (! empty($data['address'])) {
$jsonExtra['address'] = $data['address'];
}
if (! empty($data['emergency_contact'])) {
$jsonExtra['emergency_contact'] = $data['emergency_contact'];
}
// Employee(TenantUserProfile) 생성
$employee = Employee::create([
'tenant_id' => $tenantId,
'user_id' => $user->id,
'department_id' => $data['department_id'] ?? null,
'position_key' => $data['position_key'] ?? null,
'job_title_key' => $data['job_title_key'] ?? null,
'work_location_key' => $data['work_location_key'] ?? null,
'employment_type_key' => $data['employment_type_key'] ?? null,
'employee_status' => $data['employee_status'] ?? 'active',
'manager_user_id' => $data['manager_user_id'] ?? null,
'display_name' => $data['display_name'] ?? $data['name'],
'json_extra' => ! empty($jsonExtra) ? $jsonExtra : null,
]);
return $employee->load(['user', 'department']);
});
}
/**
* 사원 정보 수정
*/
public function updateEmployee(int $id, array $data): ?Employee
{
$employee = $this->getEmployeeById($id);
if (! $employee) {
return null;
}
// 기본 필드 업데이트
$updateData = array_filter([
'department_id' => $data['department_id'] ?? null,
'position_key' => $data['position_key'] ?? null,
'job_title_key' => $data['job_title_key'] ?? null,
'work_location_key' => $data['work_location_key'] ?? null,
'employment_type_key' => $data['employment_type_key'] ?? null,
'employee_status' => $data['employee_status'] ?? null,
'manager_user_id' => $data['manager_user_id'] ?? null,
'display_name' => $data['display_name'] ?? null,
], fn ($v) => $v !== null);
// json_extra 업데이트
$jsonExtraKeys = ['employee_code', 'hire_date', 'address', 'emergency_contact', 'salary', 'bank_account'];
$extra = $employee->json_extra ?? [];
foreach ($jsonExtraKeys as $key) {
if (array_key_exists($key, $data)) {
if ($data[$key] === null || $data[$key] === '') {
unset($extra[$key]);
} else {
$extra[$key] = $data[$key];
}
}
}
$updateData['json_extra'] = ! empty($extra) ? $extra : null;
$employee->update($updateData);
// User 기본정보 동기화
if ($employee->user) {
$userUpdate = [];
if (! empty($data['name'])) {
$userUpdate['name'] = $data['name'];
}
if (! empty($data['email'])) {
$userUpdate['email'] = $data['email'];
}
if (! empty($data['phone'])) {
$userUpdate['phone'] = $data['phone'];
}
if (! empty($userUpdate)) {
$employee->user->update($userUpdate);
}
}
return $employee->fresh(['user', 'department']);
}
/**
* 사원 삭제 (퇴직 처리)
*/
public function deleteEmployee(int $id): bool
{
$employee = $this->getEmployeeById($id);
if (! $employee) {
return false;
}
$employee->update(['employee_status' => 'resigned']);
return true;
}
/**
* 부서 목록 (드롭다운용)
*/
public function getDepartments(): \Illuminate\Database\Eloquent\Collection
{
$tenantId = session('selected_tenant_id');
return Department::query()
->where('is_active', true)
->when($tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
->orderBy('sort_order')
->orderBy('name')
->get(['id', 'name', 'code']);
}
/**
* 직급 목록 (드롭다운용)
*/
public function getPositions(string $type = 'rank'): \Illuminate\Database\Eloquent\Collection
{
return Position::query()
->forTenant()
->where('type', $type)
->where('is_active', true)
->ordered()
->get(['id', 'key', 'name']);
}
}

View File

@@ -0,0 +1,185 @@
@extends('layouts.app')
@section('title', '사원 등록')
@section('content')
<div class="container mx-auto px-4 py-6 max-w-2xl">
{{-- 페이지 헤더 --}}
<div class="mb-6">
<a href="{{ route('hr.employees.index') }}" class="text-sm text-gray-500 hover:text-gray-700 inline-flex items-center gap-1 mb-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
사원 목록으로
</a>
<h1 class="text-2xl font-bold text-gray-800">사원 등록</h1>
</div>
{{-- 등록 --}}
<div class="bg-white rounded-lg shadow-sm p-6">
<form id="employeeForm"
hx-post="{{ route('api.admin.hr.employees.store') }}"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}", "Accept": "application/json"}'
hx-target="#form-message"
hx-swap="innerHTML"
class="space-y-6">
<div id="form-message"></div>
{{-- 기본 정보 --}}
<div class="border-b border-gray-200 pb-4 mb-4">
<h2 class="text-lg font-semibold text-gray-700">기본 정보</h2>
</div>
{{-- 이름 --}}
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">
이름 <span class="text-red-500">*</span>
</label>
<input type="text" name="name" id="name" required
placeholder="홍길동"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
{{-- 사번 --}}
<div>
<label for="employee_code" class="block text-sm font-medium text-gray-700 mb-1">
사번
</label>
<input type="text" name="employee_code" id="employee_code"
placeholder="EMP-001"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
{{-- 이메일 / 연락처 --}}
<div class="flex gap-4" style="flex-wrap: wrap;">
<div style="flex: 1 1 200px;">
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">이메일</label>
<input type="email" name="email" id="email"
placeholder="user@example.com"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div style="flex: 1 1 200px;">
<label for="phone" class="block text-sm font-medium text-gray-700 mb-1">연락처</label>
<input type="text" name="phone" id="phone"
placeholder="010-1234-5678"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
{{-- 비밀번호 --}}
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">
비밀번호
</label>
<input type="password" name="password" id="password"
placeholder="미입력 시 기본 비밀번호(sam1234!) 설정"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="text-xs text-gray-400 mt-1">미입력 기본 비밀번호가 설정됩니다.</p>
</div>
{{-- 근무 정보 --}}
<div class="border-b border-gray-200 pb-4 mb-4 mt-8">
<h2 class="text-lg font-semibold text-gray-700">근무 정보</h2>
</div>
{{-- 부서 --}}
<div>
<label for="department_id" class="block text-sm font-medium text-gray-700 mb-1">부서</label>
<select name="department_id" id="department_id"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">선택하세요</option>
@foreach($departments as $dept)
<option value="{{ $dept->id }}">{{ $dept->name }}</option>
@endforeach
</select>
</div>
{{-- 직급 / 직책 --}}
<div class="flex gap-4" style="flex-wrap: wrap;">
<div style="flex: 1 1 200px;">
<label for="position_key" class="block text-sm font-medium text-gray-700 mb-1">직급</label>
<select name="position_key" id="position_key"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">선택하세요</option>
@foreach($ranks as $rank)
<option value="{{ $rank->key }}">{{ $rank->name }}</option>
@endforeach
</select>
</div>
<div style="flex: 1 1 200px;">
<label for="job_title_key" class="block text-sm font-medium text-gray-700 mb-1">직책</label>
<select name="job_title_key" id="job_title_key"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">선택하세요</option>
@foreach($titles as $title)
<option value="{{ $title->key }}">{{ $title->name }}</option>
@endforeach
</select>
</div>
</div>
{{-- 입사일 / 상태 --}}
<div class="flex gap-4" style="flex-wrap: wrap;">
<div style="flex: 1 1 200px;">
<label for="hire_date" class="block text-sm font-medium text-gray-700 mb-1">입사일</label>
<input type="date" name="hire_date" id="hire_date"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div style="flex: 1 1 200px;">
<label for="employee_status" class="block text-sm font-medium text-gray-700 mb-1">재직상태</label>
<select name="employee_status" id="employee_status"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="active" selected>재직</option>
<option value="leave">휴직</option>
<option value="resigned">퇴직</option>
</select>
</div>
</div>
{{-- 주소 --}}
<div>
<label for="address" class="block text-sm font-medium text-gray-700 mb-1">주소</label>
<input type="text" name="address" id="address"
placeholder="주소를 입력하세요"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
{{-- 비상연락처 --}}
<div>
<label for="emergency_contact" class="block text-sm font-medium text-gray-700 mb-1">비상연락처</label>
<input type="text" name="emergency_contact" id="emergency_contact"
placeholder="긴급 시 연락할 전화번호"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
{{-- 버튼 --}}
<div class="flex justify-end gap-3 pt-4 border-t">
<a href="{{ route('hr.employees.index') }}"
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors">
취소
</a>
<button type="submit"
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
등록
</button>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script>
document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.successful) {
try {
const response = JSON.parse(event.detail.xhr.responseText);
if (response.success) {
window.location.href = '{{ route('hr.employees.index') }}';
}
} catch (e) {}
}
});
</script>
@endpush

View File

@@ -0,0 +1,188 @@
@extends('layouts.app')
@section('title', '사원 수정')
@section('content')
<div class="container mx-auto px-4 py-6 max-w-2xl">
{{-- 페이지 헤더 --}}
<div class="mb-6">
<a href="{{ route('hr.employees.index') }}" class="text-sm text-gray-500 hover:text-gray-700 inline-flex items-center gap-1 mb-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
사원 목록으로
</a>
<h1 class="text-2xl font-bold text-gray-800">사원 수정</h1>
</div>
{{-- 수정 --}}
<div class="bg-white rounded-lg shadow-sm p-6">
<form id="employeeForm"
hx-put="{{ route('api.admin.hr.employees.update', $employee->id) }}"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}", "Accept": "application/json"}'
hx-target="#form-message"
hx-swap="innerHTML"
class="space-y-6">
<div id="form-message"></div>
{{-- 기본 정보 --}}
<div class="border-b border-gray-200 pb-4 mb-4">
<h2 class="text-lg font-semibold text-gray-700">기본 정보</h2>
</div>
{{-- 이름 --}}
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">
이름 <span class="text-red-500">*</span>
</label>
<input type="text" name="name" id="name" required
value="{{ $employee->user?->name }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
{{-- 표시 이름 --}}
<div>
<label for="display_name" class="block text-sm font-medium text-gray-700 mb-1">표시 이름</label>
<input type="text" name="display_name" id="display_name"
value="{{ $employee->display_name }}"
placeholder="사이드바 등에 표시되는 이름"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
{{-- 사번 --}}
<div>
<label for="employee_code" class="block text-sm font-medium text-gray-700 mb-1">사번</label>
<input type="text" name="employee_code" id="employee_code"
value="{{ $employee->employee_code }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
{{-- 이메일 / 연락처 --}}
<div class="flex gap-4" style="flex-wrap: wrap;">
<div style="flex: 1 1 200px;">
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">이메일</label>
<input type="email" name="email" id="email"
value="{{ $employee->user?->email }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div style="flex: 1 1 200px;">
<label for="phone" class="block text-sm font-medium text-gray-700 mb-1">연락처</label>
<input type="text" name="phone" id="phone"
value="{{ $employee->user?->phone }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
{{-- 근무 정보 --}}
<div class="border-b border-gray-200 pb-4 mb-4 mt-8">
<h2 class="text-lg font-semibold text-gray-700">근무 정보</h2>
</div>
{{-- 부서 --}}
<div>
<label for="department_id" class="block text-sm font-medium text-gray-700 mb-1">부서</label>
<select name="department_id" id="department_id"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">선택하세요</option>
@foreach($departments as $dept)
<option value="{{ $dept->id }}" {{ $employee->department_id == $dept->id ? 'selected' : '' }}>
{{ $dept->name }}
</option>
@endforeach
</select>
</div>
{{-- 직급 / 직책 --}}
<div class="flex gap-4" style="flex-wrap: wrap;">
<div style="flex: 1 1 200px;">
<label for="position_key" class="block text-sm font-medium text-gray-700 mb-1">직급</label>
<select name="position_key" id="position_key"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">선택하세요</option>
@foreach($ranks as $rank)
<option value="{{ $rank->key }}" {{ $employee->position_key === $rank->key ? 'selected' : '' }}>
{{ $rank->name }}
</option>
@endforeach
</select>
</div>
<div style="flex: 1 1 200px;">
<label for="job_title_key" class="block text-sm font-medium text-gray-700 mb-1">직책</label>
<select name="job_title_key" id="job_title_key"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">선택하세요</option>
@foreach($titles as $title)
<option value="{{ $title->key }}" {{ $employee->job_title_key === $title->key ? 'selected' : '' }}>
{{ $title->name }}
</option>
@endforeach
</select>
</div>
</div>
{{-- 입사일 / 상태 --}}
<div class="flex gap-4" style="flex-wrap: wrap;">
<div style="flex: 1 1 200px;">
<label for="hire_date" class="block text-sm font-medium text-gray-700 mb-1">입사일</label>
<input type="date" name="hire_date" id="hire_date"
value="{{ $employee->hire_date }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div style="flex: 1 1 200px;">
<label for="employee_status" class="block text-sm font-medium text-gray-700 mb-1">재직상태</label>
<select name="employee_status" id="employee_status"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="active" {{ $employee->employee_status === 'active' ? 'selected' : '' }}>재직</option>
<option value="leave" {{ $employee->employee_status === 'leave' ? 'selected' : '' }}>휴직</option>
<option value="resigned" {{ $employee->employee_status === 'resigned' ? 'selected' : '' }}>퇴직</option>
</select>
</div>
</div>
{{-- 주소 --}}
<div>
<label for="address" class="block text-sm font-medium text-gray-700 mb-1">주소</label>
<input type="text" name="address" id="address"
value="{{ $employee->address }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
{{-- 비상연락처 --}}
<div>
<label for="emergency_contact" class="block text-sm font-medium text-gray-700 mb-1">비상연락처</label>
<input type="text" name="emergency_contact" id="emergency_contact"
value="{{ $employee->emergency_contact }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
{{-- 버튼 --}}
<div class="flex justify-end gap-3 pt-4 border-t">
<a href="{{ route('hr.employees.show', $employee->id) }}"
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors">
취소
</a>
<button type="submit"
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
저장
</button>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script>
document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.successful) {
try {
const response = JSON.parse(event.detail.xhr.responseText);
if (response.success) {
window.location.href = '{{ route('hr.employees.show', $employee->id) }}';
}
} catch (e) {}
}
});
</script>
@endpush

View File

@@ -0,0 +1,112 @@
@extends('layouts.app')
@section('title', '사원관리')
@section('content')
<div class="px-4 py-6">
{{-- 페이지 헤더 --}}
<div class="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-4 mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-800">사원관리</h1>
<p class="text-sm text-gray-500 mt-1">{{ now()->format('Y년 n월 j일') }} 현재</p>
</div>
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
<a href="{{ route('hr.employees.create') }}"
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
사원 등록
</a>
</div>
</div>
{{-- 통계 카드 --}}
<div class="grid gap-4 mb-6" style="grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));">
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">전체</div>
<div class="text-2xl font-bold text-gray-800">{{ $stats['total'] }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">재직</div>
<div class="text-2xl font-bold text-emerald-600">{{ $stats['active'] }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">휴직</div>
<div class="text-2xl font-bold text-amber-600">{{ $stats['leave'] }}</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-4">
<div class="text-sm text-gray-500">퇴직</div>
<div class="text-2xl font-bold text-red-600">{{ $stats['resigned'] }}</div>
</div>
</div>
{{-- 테이블 컨테이너 --}}
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
{{-- 필터 --}}
<div class="px-6 py-4 border-b border-gray-200">
<x-filter-collapsible id="employeeFilter">
<form id="employeeFilterForm" class="flex flex-wrap gap-3 items-end">
<div style="flex: 1 1 200px; max-width: 300px;">
<label class="block text-xs text-gray-500 mb-1">검색</label>
<input type="text" name="q" placeholder="이름, 사번, 이메일..."
value="{{ request('q') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div style="flex: 0 1 160px;">
<label class="block text-xs text-gray-500 mb-1">부서</label>
<select name="department_id"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">전체 부서</option>
@foreach($departments as $dept)
<option value="{{ $dept->id }}" {{ request('department_id') == $dept->id ? 'selected' : '' }}>
{{ $dept->name }}
</option>
@endforeach
</select>
</div>
<div style="flex: 0 1 130px;">
<label class="block text-xs text-gray-500 mb-1">상태</label>
<select name="status"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">전체 상태</option>
<option value="active" {{ request('status') === 'active' ? 'selected' : '' }}>재직</option>
<option value="leave" {{ request('status') === 'leave' ? 'selected' : '' }}>휴직</option>
<option value="resigned" {{ request('status') === 'resigned' ? 'selected' : '' }}>퇴직</option>
</select>
</div>
<div class="shrink-0">
<button type="submit"
hx-get="{{ route('api.admin.hr.employees.index') }}"
hx-target="#employees-table"
hx-include="#employeeFilterForm"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm rounded-lg transition-colors">
검색
</button>
</div>
</form>
</x-filter-collapsible>
</div>
{{-- HTMX 테이블 영역 --}}
<div id="employees-table"
hx-get="{{ route('api.admin.hr.employees.index') }}"
hx-trigger="load"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
class="min-h-[200px]">
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
document.getElementById('employeeFilterForm')?.addEventListener('submit', function(e) {
e.preventDefault();
htmx.trigger('#employees-table', 'htmx:trigger');
});
</script>
@endpush

View File

@@ -0,0 +1,146 @@
{{-- 사원 목록 테이블 (HTMX로 로드) --}}
<x-table-swipe>
<table class="min-w-full">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">사원</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">부서</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">직급/직책</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">상태</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">입사일</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">연락처</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">작업</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100">
@forelse($employees as $employee)
<tr class="hover:bg-gray-50 transition-colors {{ $employee->employee_status === 'resigned' ? 'opacity-50' : '' }}">
{{-- 사원 정보 --}}
<td class="px-6 py-4 whitespace-nowrap">
<a href="{{ route('hr.employees.show', $employee->id) }}"
class="flex items-center gap-3 group">
<div class="shrink-0 w-9 h-9 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-sm font-medium">
{{ mb_substr($employee->display_name ?? $employee->user?->name ?? '?', 0, 1) }}
</div>
<div>
<div class="text-sm font-medium text-gray-900 group-hover:text-blue-600">
{{ $employee->display_name ?? $employee->user?->name ?? '-' }}
</div>
@if($employee->employee_code)
<div class="text-xs text-gray-400">{{ $employee->employee_code }}</div>
@endif
</div>
</a>
</td>
{{-- 부서 --}}
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700">
{{ $employee->department?->name ?? '-' }}
</td>
{{-- 직급/직책 --}}
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700">
<div>{{ $employee->position_label ?? '-' }}</div>
@if($employee->job_title_label)
<div class="text-xs text-gray-400">{{ $employee->job_title_label }}</div>
@endif
</td>
{{-- 상태 --}}
<td class="px-6 py-4 whitespace-nowrap text-center">
@switch($employee->employee_status)
@case('active')
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700">
재직
</span>
@break
@case('leave')
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700">
휴직
</span>
@break
@case('resigned')
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">
퇴직
</span>
@break
@default
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700">
{{ $employee->employee_status ?? '-' }}
</span>
@endswitch
</td>
{{-- 입사일 --}}
<td class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-500">
{{ $employee->hire_date ?? '-' }}
</td>
{{-- 연락처 --}}
<td class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-500">
{{ $employee->user?->phone ?? $employee->user?->email ?? '-' }}
</td>
{{-- 작업 --}}
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="flex items-center justify-center gap-2">
{{-- 상세 --}}
<a href="{{ route('hr.employees.show', $employee->id) }}"
class="text-gray-600 hover:text-gray-800" title="상세">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
</a>
{{-- 수정 --}}
<a href="{{ route('hr.employees.edit', $employee->id) }}"
class="text-blue-600 hover:text-blue-800" title="수정">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</a>
{{-- 퇴직 처리 --}}
@if($employee->employee_status !== 'resigned')
<button type="button"
hx-delete="{{ route('api.admin.hr.employees.destroy', $employee->id) }}"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
hx-target="#employees-table"
hx-swap="innerHTML"
hx-confirm="{{ $employee->display_name ?? $employee->user?->name }}님을 퇴직 처리하시겠습니까?"
class="text-red-600 hover:text-red-800" title="퇴직처리">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>
</button>
@endif
</div>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-6 py-12 text-center">
<div class="flex flex-col items-center gap-2">
<svg class="w-12 h-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
<p class="text-gray-500">등록된 사원이 없습니다.</p>
<a href="{{ route('hr.employees.create') }}"
class="mt-2 text-sm text-blue-600 hover:text-blue-700 font-medium">
번째 사원 등록하기 &rarr;
</a>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</x-table-swipe>
{{-- 페이지네이션 --}}
@if($employees->hasPages())
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50">
{{ $employees->links() }}
</div>
@endif

View File

@@ -0,0 +1,152 @@
@extends('layouts.app')
@section('title', '사원 상세')
@section('content')
<div class="container mx-auto px-4 py-6 max-w-3xl">
{{-- 페이지 헤더 --}}
<div class="mb-6">
<a href="{{ route('hr.employees.index') }}" class="text-sm text-gray-500 hover:text-gray-700 inline-flex items-center gap-1 mb-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
사원 목록으로
</a>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-800">사원 상세</h1>
<a href="{{ route('hr.employees.edit', $employee->id) }}"
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
수정
</a>
</div>
</div>
{{-- 프로필 카드 --}}
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<div class="flex items-center gap-5">
<div class="shrink-0 w-16 h-16 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-2xl font-bold">
{{ mb_substr($employee->display_name ?? $employee->user?->name ?? '?', 0, 1) }}
</div>
<div>
<h2 class="text-xl font-bold text-gray-900">
{{ $employee->display_name ?? $employee->user?->name ?? '-' }}
</h2>
<div class="flex flex-wrap items-center gap-2 mt-1">
@if($employee->employee_code)
<span class="text-sm text-gray-500">{{ $employee->employee_code }}</span>
<span class="text-gray-300">|</span>
@endif
@if($employee->department)
<span class="text-sm text-gray-500">{{ $employee->department->name }}</span>
<span class="text-gray-300">|</span>
@endif
<span class="text-sm text-gray-500">{{ $employee->position_label ?? '-' }}</span>
@if($employee->job_title_label)
<span class="text-gray-300">|</span>
<span class="text-sm text-gray-500">{{ $employee->job_title_label }}</span>
@endif
</div>
<div class="mt-2">
@switch($employee->employee_status)
@case('active')
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700">재직</span>
@break
@case('leave')
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700">휴직</span>
@break
@case('resigned')
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">퇴직</span>
@break
@endswitch
</div>
</div>
</div>
</div>
{{-- 상세 정보 --}}
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-800">상세 정보</h3>
</div>
<div class="divide-y divide-gray-100">
{{-- 기본 정보 --}}
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">이름</div>
<div class="text-sm text-gray-900">{{ $employee->user?->name ?? '-' }}</div>
</div>
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">표시 이름</div>
<div class="text-sm text-gray-900">{{ $employee->display_name ?? '-' }}</div>
</div>
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">사번</div>
<div class="text-sm text-gray-900">{{ $employee->employee_code ?? '-' }}</div>
</div>
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">이메일</div>
<div class="text-sm text-gray-900">{{ $employee->user?->email ?? '-' }}</div>
</div>
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">연락처</div>
<div class="text-sm text-gray-900">{{ $employee->user?->phone ?? '-' }}</div>
</div>
{{-- 근무 정보 --}}
<div class="px-6 py-4 bg-gray-50">
<span class="text-sm font-semibold text-gray-600">근무 정보</span>
</div>
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">부서</div>
<div class="text-sm text-gray-900">{{ $employee->department?->name ?? '-' }}</div>
</div>
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">직급</div>
<div class="text-sm text-gray-900">{{ $employee->position_label ?? '-' }}</div>
</div>
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">직책</div>
<div class="text-sm text-gray-900">{{ $employee->job_title_label ?? '-' }}</div>
</div>
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">입사일</div>
<div class="text-sm text-gray-900">{{ $employee->hire_date ?? '-' }}</div>
</div>
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">재직상태</div>
<div class="text-sm text-gray-900">
@switch($employee->employee_status)
@case('active') 재직 @break
@case('leave') 휴직 @break
@case('resigned') 퇴직 @break
@default {{ $employee->employee_status }} @break
@endswitch
</div>
</div>
{{-- 추가 정보 --}}
<div class="px-6 py-4 bg-gray-50">
<span class="text-sm font-semibold text-gray-600">추가 정보</span>
</div>
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">주소</div>
<div class="text-sm text-gray-900">{{ $employee->address ?? '-' }}</div>
</div>
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">비상연락처</div>
<div class="text-sm text-gray-900">{{ $employee->emergency_contact ?? '-' }}</div>
</div>
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">등록일</div>
<div class="text-sm text-gray-900">{{ $employee->created_at?->format('Y-m-d H:i') ?? '-' }}</div>
</div>
<div class="px-6 py-3 flex" style="flex-wrap: wrap;">
<div class="shrink-0 text-sm font-medium text-gray-500" style="width: 140px;">수정일</div>
<div class="text-sm text-gray-900">{{ $employee->updated_at?->format('Y-m-d H:i') ?? '-' }}</div>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -1034,3 +1034,18 @@
// 요약 결과 조회 (HTMX 지원)
Route::get('/{id}/summary', [MeetingLogController::class, 'summary'])->name('summary');
});
/*
|--------------------------------------------------------------------------
| HR (인사관리) API
|--------------------------------------------------------------------------
*/
Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/hr/employees')->name('api.admin.hr.employees.')->group(function () {
Route::get('/stats', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'stats'])->name('stats');
Route::get('/', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'index'])->name('index');
Route::post('/', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'store'])->name('store');
Route::get('/{id}', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'show'])->name('show');
Route::put('/{id}', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'update'])->name('update');
Route::delete('/{id}', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'destroy'])->name('destroy');
});

View File

@@ -883,6 +883,21 @@
return response()->file(public_path('일일자금일보.html'));
});
/*
|--------------------------------------------------------------------------
| HR Routes (인사관리)
|--------------------------------------------------------------------------
*/
Route::middleware('auth')->prefix('hr')->name('hr.')->group(function () {
// 사원관리
Route::prefix('employees')->name('employees.')->group(function () {
Route::get('/', [\App\Http\Controllers\HR\EmployeeController::class, 'index'])->name('index');
Route::get('/create', [\App\Http\Controllers\HR\EmployeeController::class, 'create'])->name('create');
Route::get('/{id}', [\App\Http\Controllers\HR\EmployeeController::class, 'show'])->name('show');
Route::get('/{id}/edit', [\App\Http\Controllers\HR\EmployeeController::class, 'edit'])->name('edit');
});
});
/*
|--------------------------------------------------------------------------
| Finance Routes (재무 관리)