feat: [hr] 입퇴사자 현황 페이지 구현
- EmployeeService에 근속기간 조회/통계/CSV 내보내기 메서드 추가 - API 컨트롤러에 tenure/tenureExport 엔드포인트 추가 - EmployeeTenureController 뷰 컨트롤러 생성 - 통계 카드 6개 (전체/재직/퇴직/평균근속/올해입사/올해퇴사) - HTMX 테이블 (사원/부서/직책/상태/입사일/퇴사일/근속기간/근속일수) - 필터: 이름검색, 부서, 상태, 입사기간 범위, 정렬 - CSV 엑셀 다운로드 기능
This commit is contained in:
@@ -6,11 +6,13 @@
|
||||
use App\Models\Boards\File;
|
||||
use App\Services\GoogleCloudStorageService;
|
||||
use App\Services\HR\EmployeeService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class EmployeeController extends Controller
|
||||
{
|
||||
@@ -383,6 +385,119 @@ public function downloadFile(int $id, int $fileId, GoogleCloudStorageService $gc
|
||||
abort(404, '파일이 서버에 존재하지 않습니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 입퇴사자 현황 목록 (HTMX → HTML / 일반 → JSON)
|
||||
*/
|
||||
public function tenure(Request $request): JsonResponse|Response
|
||||
{
|
||||
$employees = $this->employeeService->getEmployeeTenure(
|
||||
$request->all(),
|
||||
$request->integer('per_page', 50)
|
||||
);
|
||||
|
||||
// 근속기간 계산 추가
|
||||
$employees->getCollection()->each(function ($employee) {
|
||||
$hireDate = $employee->hire_date;
|
||||
if ($hireDate) {
|
||||
$hire = Carbon::parse($hireDate);
|
||||
$end = $employee->resign_date ? Carbon::parse($employee->resign_date) : today();
|
||||
$tenureDays = $hire->diffInDays($end);
|
||||
$diff = $hire->diff($end);
|
||||
|
||||
$employee->tenure_days = $tenureDays;
|
||||
$employee->tenure_label = $this->formatTenureLabel($diff);
|
||||
} else {
|
||||
$employee->tenure_days = 0;
|
||||
$employee->tenure_label = '-';
|
||||
}
|
||||
});
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
$stats = $this->employeeService->getTenureStats();
|
||||
|
||||
return response(view('hr.employee-tenure.partials.table', compact('employees', 'stats')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $employees->items(),
|
||||
'meta' => [
|
||||
'current_page' => $employees->currentPage(),
|
||||
'last_page' => $employees->lastPage(),
|
||||
'per_page' => $employees->perPage(),
|
||||
'total' => $employees->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 입퇴사자 현황 CSV 내보내기
|
||||
*/
|
||||
public function tenureExport(Request $request): StreamedResponse
|
||||
{
|
||||
$employees = $this->employeeService->getTenureExportData($request->all());
|
||||
|
||||
$filename = '입퇴사자현황_'.now()->format('Ymd').'.csv';
|
||||
|
||||
return response()->streamDownload(function () use ($employees) {
|
||||
$handle = fopen('php://output', 'w');
|
||||
|
||||
// BOM for Excel UTF-8
|
||||
fwrite($handle, "\xEF\xBB\xBF");
|
||||
|
||||
// 헤더
|
||||
fputcsv($handle, ['No.', '사원명', '부서', '직책', '상태', '입사일', '퇴사일', '근속기간', '근속일수']);
|
||||
|
||||
$index = 1;
|
||||
foreach ($employees as $employee) {
|
||||
$hireDate = $employee->hire_date;
|
||||
$tenureDays = 0;
|
||||
$tenureLabel = '-';
|
||||
|
||||
if ($hireDate) {
|
||||
$hire = Carbon::parse($hireDate);
|
||||
$end = $employee->resign_date ? Carbon::parse($employee->resign_date) : today();
|
||||
$tenureDays = $hire->diffInDays($end);
|
||||
$tenureLabel = $this->formatTenureLabel($hire->diff($end));
|
||||
}
|
||||
|
||||
$statusMap = ['active' => '재직', 'leave' => '휴직', 'resigned' => '퇴직'];
|
||||
|
||||
fputcsv($handle, [
|
||||
$index++,
|
||||
$employee->display_name ?? $employee->user?->name ?? '-',
|
||||
$employee->department?->name ?? '-',
|
||||
$employee->position_label ?? '-',
|
||||
$statusMap[$employee->employee_status] ?? $employee->employee_status,
|
||||
$employee->hire_date ?? '-',
|
||||
$employee->resign_date ?? '-',
|
||||
$tenureLabel,
|
||||
$tenureDays,
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
}, $filename, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
|
||||
private function formatTenureLabel(\DateInterval $diff): string
|
||||
{
|
||||
$parts = [];
|
||||
if ($diff->y > 0) {
|
||||
$parts[] = "{$diff->y}년";
|
||||
}
|
||||
if ($diff->m > 0) {
|
||||
$parts[] = "{$diff->m}개월";
|
||||
}
|
||||
if ($diff->d > 0 || empty($parts)) {
|
||||
$parts[] = "{$diff->d}일";
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 직급/직책 추가
|
||||
*/
|
||||
|
||||
34
app/Http/Controllers/HR/EmployeeTenureController.php
Normal file
34
app/Http/Controllers/HR/EmployeeTenureController.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\HR\EmployeeService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class EmployeeTenureController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private EmployeeService $employeeService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 입퇴사자 현황 페이지
|
||||
*/
|
||||
public function index(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('hr.employee-tenure'));
|
||||
}
|
||||
|
||||
$stats = $this->employeeService->getTenureStats();
|
||||
$departments = $this->employeeService->getDepartments();
|
||||
|
||||
return view('hr.employee-tenure.index', [
|
||||
'stats' => $stats,
|
||||
'departments' => $departments,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
use App\Models\HR\Position;
|
||||
use App\Models\Tenants\Department;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -380,6 +382,191 @@ public function getPositions(string $type = 'rank'): \Illuminate\Database\Eloque
|
||||
->get(['id', 'key', 'name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 입퇴사자 현황 조회 (페이지네이션)
|
||||
*/
|
||||
public function getEmployeeTenure(array $filters = [], int $perPage = 50): LengthAwarePaginator
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$query = Employee::query()
|
||||
->with(['user', 'department'])
|
||||
->forTenant($tenantId)
|
||||
->whereNotNull('json_extra->hire_date');
|
||||
|
||||
// 이름 검색
|
||||
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}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 부서 필터
|
||||
if (! empty($filters['department_id'])) {
|
||||
$query->where('department_id', $filters['department_id']);
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (! empty($filters['status'])) {
|
||||
if ($filters['status'] === 'active') {
|
||||
$query->where('employee_status', 'active');
|
||||
} elseif ($filters['status'] === 'resigned') {
|
||||
$query->where('employee_status', 'resigned');
|
||||
}
|
||||
}
|
||||
|
||||
// 입사기간 범위
|
||||
if (! empty($filters['hire_from'])) {
|
||||
$query->where('json_extra->hire_date', '>=', $filters['hire_from']);
|
||||
}
|
||||
if (! empty($filters['hire_to'])) {
|
||||
$query->where('json_extra->hire_date', '<=', $filters['hire_to']);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
$sortBy = $filters['sort_by'] ?? 'hire_date_desc';
|
||||
switch ($sortBy) {
|
||||
case 'hire_date_asc':
|
||||
$query->orderByRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.hire_date')) ASC");
|
||||
break;
|
||||
case 'tenure_desc':
|
||||
$query->orderByRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.hire_date')) ASC");
|
||||
break;
|
||||
case 'tenure_asc':
|
||||
$query->orderByRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.hire_date')) DESC");
|
||||
break;
|
||||
default: // hire_date_desc
|
||||
$query->orderByRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.hire_date')) DESC");
|
||||
break;
|
||||
}
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 입퇴사자 통계
|
||||
*/
|
||||
public function getTenureStats(): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$baseQuery = Employee::query()
|
||||
->forTenant($tenantId)
|
||||
->whereNotNull('json_extra->hire_date');
|
||||
|
||||
$total = (clone $baseQuery)->count();
|
||||
$active = (clone $baseQuery)->where('employee_status', 'active')->count();
|
||||
$resigned = (clone $baseQuery)->where('employee_status', 'resigned')->count();
|
||||
|
||||
// 올해 입사자
|
||||
$thisYear = now()->year;
|
||||
$hiredThisYear = (clone $baseQuery)
|
||||
->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.hire_date')) LIKE ?", ["{$thisYear}%"])
|
||||
->count();
|
||||
|
||||
// 올해 퇴사자
|
||||
$resignedThisYear = Employee::query()
|
||||
->forTenant($tenantId)
|
||||
->whereNotNull('json_extra->resign_date')
|
||||
->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.resign_date')) LIKE ?", ["{$thisYear}%"])
|
||||
->count();
|
||||
|
||||
// 평균 근속기간 (재직자 기준)
|
||||
$activeEmployees = (clone $baseQuery)
|
||||
->where('employee_status', 'active')
|
||||
->get(['json_extra']);
|
||||
|
||||
$avgTenureDays = 0;
|
||||
if ($activeEmployees->isNotEmpty()) {
|
||||
$totalDays = 0;
|
||||
$count = 0;
|
||||
foreach ($activeEmployees as $emp) {
|
||||
$hireDate = $emp->json_extra['hire_date'] ?? null;
|
||||
if ($hireDate) {
|
||||
$totalDays += Carbon::parse($hireDate)->diffInDays(today());
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
$avgTenureDays = $count > 0 ? (int) round($totalDays / $count) : 0;
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'active' => $active,
|
||||
'resigned' => $resigned,
|
||||
'hired_this_year' => $hiredThisYear,
|
||||
'resigned_this_year' => $resignedThisYear,
|
||||
'avg_tenure_days' => $avgTenureDays,
|
||||
'avg_tenure_label' => $this->formatTenure($avgTenureDays),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV 내보내기용 전체 데이터
|
||||
*/
|
||||
public function getTenureExportData(array $filters = []): Collection
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$query = Employee::query()
|
||||
->with(['user', 'department'])
|
||||
->forTenant($tenantId)
|
||||
->whereNotNull('json_extra->hire_date');
|
||||
|
||||
if (! empty($filters['department_id'])) {
|
||||
$query->where('department_id', $filters['department_id']);
|
||||
}
|
||||
|
||||
if (! empty($filters['status'])) {
|
||||
if ($filters['status'] === 'active') {
|
||||
$query->where('employee_status', 'active');
|
||||
} elseif ($filters['status'] === 'resigned') {
|
||||
$query->where('employee_status', 'resigned');
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($filters['hire_from'])) {
|
||||
$query->where('json_extra->hire_date', '>=', $filters['hire_from']);
|
||||
}
|
||||
if (! empty($filters['hire_to'])) {
|
||||
$query->where('json_extra->hire_date', '<=', $filters['hire_to']);
|
||||
}
|
||||
|
||||
return $query->orderByRaw("JSON_UNQUOTE(JSON_EXTRACT(json_extra, '$.hire_date')) DESC")->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 근속일수 → "N년 M개월 D일" 변환
|
||||
*/
|
||||
public function formatTenure(int $days): string
|
||||
{
|
||||
if ($days <= 0) {
|
||||
return '0일';
|
||||
}
|
||||
|
||||
$years = intdiv($days, 365);
|
||||
$remaining = $days % 365;
|
||||
$months = intdiv($remaining, 30);
|
||||
$d = $remaining % 30;
|
||||
|
||||
$parts = [];
|
||||
if ($years > 0) {
|
||||
$parts[] = "{$years}년";
|
||||
}
|
||||
if ($months > 0) {
|
||||
$parts[] = "{$months}개월";
|
||||
}
|
||||
if ($d > 0 || empty($parts)) {
|
||||
$parts[] = "{$d}일";
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 직급/직책 추가
|
||||
*/
|
||||
|
||||
141
resources/views/hr/employee-tenure/index.blade.php
Normal file
141
resources/views/hr/employee-tenure/index.blade.php
Normal file
@@ -0,0 +1,141 @@
|
||||
@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('api.admin.hr.employees.tenure-export') }}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-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 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
엑셀 다운로드
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 통계 카드 --}}
|
||||
<div class="grid gap-4 mb-6" style="grid-template-columns: repeat(auto-fit, minmax(140px, 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-red-600">{{ $stats['resigned'] }}명</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-blue-600">{{ $stats['avg_tenure_label'] }}</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-indigo-600">{{ $stats['hired_this_year'] }}명</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-orange-600">{{ $stats['resigned_this_year'] }}명</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="tenureFilter">
|
||||
<form id="tenureFilterForm" class="flex flex-wrap gap-3 items-end">
|
||||
<div style="flex: 1 1 200px; max-width: 280px;">
|
||||
<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="resigned" {{ request('status') === 'resigned' ? 'selected' : '' }}>퇴직</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex: 0 1 150px;">
|
||||
<label class="block text-xs text-gray-500 mb-1">입사일(시작)</label>
|
||||
<input type="date" name="hire_from"
|
||||
value="{{ request('hire_from') }}"
|
||||
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 150px;">
|
||||
<label class="block text-xs text-gray-500 mb-1">입사일(종료)</label>
|
||||
<input type="date" name="hire_to"
|
||||
value="{{ request('hire_to') }}"
|
||||
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="sort_by"
|
||||
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="hire_date_desc" {{ request('sort_by', 'hire_date_desc') === 'hire_date_desc' ? 'selected' : '' }}>입사일 최신순</option>
|
||||
<option value="hire_date_asc" {{ request('sort_by') === 'hire_date_asc' ? 'selected' : '' }}>입사일 빠른순</option>
|
||||
<option value="tenure_desc" {{ request('sort_by') === 'tenure_desc' ? 'selected' : '' }}>근속기간 긴순</option>
|
||||
<option value="tenure_asc" {{ request('sort_by') === 'tenure_asc' ? 'selected' : '' }}>근속기간 짧은순</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<button type="submit"
|
||||
hx-get="{{ route('api.admin.hr.employees.tenure') }}"
|
||||
hx-target="#tenure-table"
|
||||
hx-include="#tenureFilterForm"
|
||||
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="tenure-table"
|
||||
hx-get="{{ route('api.admin.hr.employees.tenure') }}"
|
||||
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('tenureFilterForm')?.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
htmx.trigger('#tenure-table', 'htmx:trigger');
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
116
resources/views/hr/employee-tenure/partials/table.blade.php
Normal file
116
resources/views/hr/employee-tenure/partials/table.blade.php
Normal file
@@ -0,0 +1,116 @@
|
||||
{{-- 입퇴사자 현황 테이블 (HTMX로 로드) --}}
|
||||
<x-table-swipe>
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-600" style="width: 50px;">No.</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-600">사원</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-600">부서</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-600">직책</th>
|
||||
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-600">상태</th>
|
||||
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-600">입사일</th>
|
||||
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-600">퇴사일</th>
|
||||
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-600">근속기간</th>
|
||||
<th class="px-4 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-60' : '' }}">
|
||||
{{-- No. --}}
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center text-sm text-gray-500">
|
||||
{{ ($employees->currentPage() - 1) * $employees->perPage() + $loop->iteration }}
|
||||
</td>
|
||||
|
||||
{{-- 사원 --}}
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<a href="{{ route('hr.employees.show', $employee->id) }}"
|
||||
class="flex items-center gap-3 group">
|
||||
<div class="shrink-0 w-8 h-8 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>
|
||||
<span class="text-sm font-medium text-gray-900 group-hover:text-blue-600">
|
||||
{{ $employee->display_name ?? $employee->user?->name ?? '-' }}
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
|
||||
{{-- 부서 --}}
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-700">
|
||||
{{ $employee->department?->name ?? '-' }}
|
||||
</td>
|
||||
|
||||
{{-- 직책 --}}
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-700">
|
||||
{{ $employee->position_label ?? '-' }}
|
||||
</td>
|
||||
|
||||
{{-- 상태 --}}
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center">
|
||||
@if($employee->employee_status === '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>
|
||||
@elseif($employee->employee_status === '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>
|
||||
@else
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700">
|
||||
{{ $employee->employee_status === 'leave' ? '휴직' : ($employee->employee_status ?? '-') }}
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
|
||||
{{-- 입사일 --}}
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center text-sm text-gray-500">
|
||||
{{ $employee->hire_date ?? '-' }}
|
||||
</td>
|
||||
|
||||
{{-- 퇴사일 --}}
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center text-sm text-gray-500">
|
||||
{{ $employee->resign_date ?? '-' }}
|
||||
</td>
|
||||
|
||||
{{-- 근속기간 --}}
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center text-sm font-medium {{ $employee->employee_status === 'active' ? 'text-blue-700' : 'text-gray-500' }}">
|
||||
{{ $employee->tenure_label ?? '-' }}
|
||||
</td>
|
||||
|
||||
{{-- 근속일수 --}}
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center text-sm text-gray-500">
|
||||
{{ number_format($employee->tenure_days ?? 0) }}일
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="9" 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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</x-table-swipe>
|
||||
|
||||
{{-- 하단 요약 + 페이지네이션 --}}
|
||||
@if($employees->isNotEmpty())
|
||||
<div class="px-6 py-3 border-t border-gray-200 bg-gray-50">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="text-sm text-gray-600">
|
||||
총 <strong>{{ number_format($employees->total()) }}명</strong>
|
||||
@if(isset($stats))
|
||||
· 평균 근속 <strong class="text-blue-600">{{ $stats['avg_tenure_label'] }}</strong>
|
||||
@endif
|
||||
</div>
|
||||
@if($employees->hasPages())
|
||||
<div>{{ $employees->links() }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@@ -1043,6 +1043,8 @@
|
||||
Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/hr/employees')->name('api.admin.hr.employees.')->group(function () {
|
||||
Route::get('/search-users', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'searchUsers'])->name('search-users');
|
||||
Route::get('/stats', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'stats'])->name('stats');
|
||||
Route::get('/tenure', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'tenure'])->name('tenure');
|
||||
Route::get('/tenure-export', [\App\Http\Controllers\Api\Admin\HR\EmployeeController::class, 'tenureExport'])->name('tenure-export');
|
||||
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');
|
||||
|
||||
@@ -897,6 +897,9 @@
|
||||
Route::get('/{id}/edit', [\App\Http\Controllers\HR\EmployeeController::class, 'edit'])->name('edit');
|
||||
});
|
||||
|
||||
// 입퇴사자 현황
|
||||
Route::get('/employee-tenure', [\App\Http\Controllers\HR\EmployeeTenureController::class, 'index'])->name('employee-tenure');
|
||||
|
||||
// 근태현황
|
||||
Route::prefix('attendances')->name('attendances.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\HR\AttendanceController::class, 'index'])->name('index');
|
||||
|
||||
Reference in New Issue
Block a user