feat: [hr] 사원관리 영업팀 제외 및 강제 제외 기능 추가

- 영업팀 포함 부서 사원 기본 제외 (외부직원)
- json_extra.is_excluded 플래그로 강제 제외/복원 토글
- 필터에 '제외 사원 표시' 체크박스 추가
- 제외 사원 시각적 구분 (주황 배경, 제외 뱃지)
This commit is contained in:
김보곤
2026-03-05 15:16:15 +09:00
parent 9192291400
commit 013df2592f
6 changed files with 114 additions and 5 deletions

View File

@@ -300,6 +300,38 @@ public function forceDestroy(Request $request, int $id): JsonResponse|Response
}
}
/**
* 사원 제외/복원 토글
*/
public function toggleExclude(Request $request, int $id): JsonResponse|Response
{
$employee = $this->employeeService->toggleExclude($id);
if (! $employee) {
return response()->json([
'success' => false,
'message' => '사원 정보를 찾을 수 없습니다.',
], 404);
}
$isExcluded = $employee->getJsonExtraValue('is_excluded', false);
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' => $isExcluded ? '사원이 목록에서 제외되었습니다.' : '사원이 목록에 복원되었습니다.',
'is_excluded' => $isExcluded,
]);
}
/**
* 사원 첨부파일 업로드 (로컬 + GCS 듀얼 저장)
*/

View File

@@ -18,7 +18,8 @@ public function __construct(
*/
public function index(): View
{
$stats = $this->employeeService->getStats();
$showExcluded = request()->boolean('show_excluded');
$stats = $this->employeeService->getStats($showExcluded);
$departments = $this->employeeService->getDepartments();
return view('hr.employees.index', [

View File

@@ -26,6 +26,21 @@ public function getEmployees(array $filters = [], int $perPage = 20): LengthAwar
->with(['user', 'department'])
->forTenant($tenantId);
// 제외 사원 필터 (기본: 숨김)
if (empty($filters['show_excluded'])) {
// "영업팀" 포함 부서 사원 제외
$query->where(function ($q) {
$q->whereDoesntHave('department', function ($dq) {
$dq->where('name', 'like', '%영업팀%');
})->orWhereNull('department_id');
});
// 강제 제외된 사원 제외
$query->where(function ($q) {
$q->whereNull('json_extra->is_excluded')
->orWhere('json_extra->is_excluded', false);
});
}
// 검색 필터 (이름, 이메일, 연락처)
if (! empty($filters['q'])) {
$search = $filters['q'];
@@ -89,12 +104,23 @@ public function getEmployeeById(int $id): ?Employee
/**
* 사원 통계
*/
public function getStats(): array
public function getStats(bool $showExcluded = false): array
{
$tenantId = session('selected_tenant_id');
$baseQuery = Employee::query()->forTenant($tenantId);
if (! $showExcluded) {
$baseQuery->where(function ($q) {
$q->whereDoesntHave('department', function ($dq) {
$dq->where('name', 'like', '%영업팀%');
})->orWhereNull('department_id');
})->where(function ($q) {
$q->whereNull('json_extra->is_excluded')
->orWhere('json_extra->is_excluded', false);
});
}
return [
'total' => (clone $baseQuery)->count(),
'active' => (clone $baseQuery)->where('employee_status', 'active')->count(),
@@ -394,6 +420,23 @@ public function forceDeleteEmployee(int $id): array
return ['success' => true, 'message' => "사원 '{$name}'이(가) 영구삭제되었습니다."];
}
/**
* 사원 제외/복원 토글
*/
public function toggleExclude(int $id): ?Employee
{
$employee = $this->getEmployeeById($id);
if (! $employee) {
return null;
}
$isExcluded = $employee->getJsonExtraValue('is_excluded', false);
$employee->setJsonExtraValue('is_excluded', ! $isExcluded);
$employee->save();
return $employee;
}
/**
* 부서 목록 (드롭다운용)
*/

View File

@@ -86,7 +86,13 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 f
<option value="default" {{ request('sort_by') === 'default' ? 'selected' : '' }}>상태순</option>
</select>
</div>
<div class="shrink-0">
<div class="shrink-0 flex items-end gap-3">
<label class="flex items-center gap-1.5 cursor-pointer py-2">
<input type="checkbox" name="show_excluded" value="1"
{{ request('show_excluded') ? 'checked' : '' }}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span class="text-xs text-gray-500 whitespace-nowrap">제외 사원 표시</span>
</label>
<button type="submit"
hx-get="{{ route('api.admin.hr.employees.index') }}"
hx-target="#employees-table"

View File

@@ -40,7 +40,8 @@
</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' : '' }}">
@php $isExcludedRow = $employee->getJsonExtraValue('is_excluded', false) || ($employee->department && str_contains($employee->department->name, '영업팀')); @endphp
<tr class="hover:bg-gray-50 transition-colors {{ $employee->employee_status === 'resigned' ? 'opacity-50' : '' }} {{ $isExcludedRow ? 'bg-orange-50 opacity-60' : '' }}">
{{-- 사원 정보 --}}
<td class="px-6 py-4 whitespace-nowrap">
<a href="{{ route('hr.employees.show', $employee->id) }}"
@@ -49,8 +50,11 @@ class="flex items-center gap-3 group">
{{ 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">
<div class="text-sm font-medium text-gray-900 group-hover:text-blue-600 flex items-center gap-1.5">
{{ $employee->display_name ?? $employee->user?->name ?? '-' }}
@if($isExcludedRow)
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-orange-100 text-orange-600">제외</span>
@endif
</div>
</div>
</a>
@@ -129,6 +133,28 @@ class="text-blue-600 hover:text-blue-800" title="수정">
</svg>
</a>
{{-- 제외/복원 --}}
@php $isExcluded = $employee->getJsonExtraValue('is_excluded', false); @endphp
<button type="button"
hx-post="{{ route('api.admin.hr.employees.toggle-exclude', $employee->id) }}"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
hx-target="#employees-table"
hx-swap="innerHTML"
hx-include="#employeeFilterForm"
hx-confirm="{{ $isExcluded ? ($employee->display_name ?? $employee->user?->name) . '님을 사원 목록에 복원하시겠습니까?' : ($employee->display_name ?? $employee->user?->name) . '님을 사원 목록에서 제외하시겠습니까?' }}"
class="{{ $isExcluded ? 'text-green-600 hover:text-green-800' : 'text-orange-500 hover:text-orange-700' }}"
title="{{ $isExcluded ? '복원' : '제외' }}">
@if($isExcluded)
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
@else
<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="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>
</svg>
@endif
</button>
{{-- 퇴직 처리 --}}
@if($employee->employee_status !== 'resigned')
<button type="button"

View File

@@ -1148,6 +1148,7 @@
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');
Route::delete('/{id}/force', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'forceDestroy'])->name('force-destroy');
Route::post('/{id}/toggle-exclude', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'toggleExclude'])->name('toggle-exclude');
// 첨부파일
Route::post('/{id}/files', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'uploadFile'])->name('upload-file');