feat: [leave] 휴가관리 Phase 1 구현
- Leave, LeavePolicy, LeaveGrant 모델 생성 - LeaveBalance 헬퍼 메서드 추가 (useLeave, restoreLeave, canUse) - LeaveService 핵심 로직 (신청, 승인, 반려, 취소, 잔여연차, 통계) - API 컨트롤러 (목록, 등록, 승인/반려/취소, 잔여연차, 통계, CSV 내보내기) - 뷰 컨트롤러 + 라우트 등록 (web, api) - Blade 뷰 (index + 3개 탭 partials: table, balance, stats)
This commit is contained in:
270
app/Http/Controllers/Api/Admin/HR/LeaveController.php
Normal file
270
app/Http/Controllers/Api/Admin/HR/LeaveController.php
Normal file
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\HR\LeaveService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class LeaveController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private LeaveService $leaveService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 휴가 목록 (HTMX → HTML / 일반 → JSON)
|
||||
*/
|
||||
public function index(Request $request): JsonResponse|Response
|
||||
{
|
||||
$leaves = $this->leaveService->getLeaves(
|
||||
$request->all(),
|
||||
$request->integer('per_page', 20)
|
||||
);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(view('hr.leaves.partials.table', compact('leaves')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $leaves->items(),
|
||||
'meta' => [
|
||||
'current_page' => $leaves->currentPage(),
|
||||
'last_page' => $leaves->lastPage(),
|
||||
'per_page' => $leaves->perPage(),
|
||||
'total' => $leaves->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가 신청 등록
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'user_id' => 'required|integer|exists:users,id',
|
||||
'leave_type' => 'required|string|in:annual,half_am,half_pm,sick,family,maternity,parental',
|
||||
'start_date' => 'required|date',
|
||||
'end_date' => 'required|date|after_or_equal:start_date',
|
||||
'reason' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
try {
|
||||
$leave = $this->leaveService->storeLeave($validated);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '휴가 신청이 등록되었습니다.',
|
||||
'data' => $leave,
|
||||
], 201);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 422);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '휴가 등록 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인
|
||||
*/
|
||||
public function approve(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$leave = $this->leaveService->approve($id);
|
||||
|
||||
if (! $leave) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '대기 중인 휴가 신청을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '승인 처리되었습니다.',
|
||||
'data' => $leave,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '승인 처리 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 반려
|
||||
*/
|
||||
public function reject(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'reject_reason' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
try {
|
||||
$leave = $this->leaveService->reject($id, $validated['reject_reason'] ?? null);
|
||||
|
||||
if (! $leave) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '대기 중인 휴가 신청을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '반려 처리되었습니다.',
|
||||
'data' => $leave,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '반려 처리 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소
|
||||
*/
|
||||
public function cancel(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$leave = $this->leaveService->cancel($id);
|
||||
|
||||
if (! $leave) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '승인된 휴가 신청을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '취소 처리되었습니다. 연차가 복원되었습니다.',
|
||||
'data' => $leave,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '취소 처리 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 잔여연차 목록 (HTMX → HTML)
|
||||
*/
|
||||
public function balance(Request $request): JsonResponse|Response
|
||||
{
|
||||
$year = $request->integer('year', now()->year);
|
||||
$balances = $this->leaveService->getBalanceSummary($year);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(view('hr.leaves.partials.balance', compact('balances', 'year')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $balances,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 사원 잔여연차 (JSON)
|
||||
*/
|
||||
public function userBalance(Request $request, int $userId): JsonResponse
|
||||
{
|
||||
$year = $request->integer('year', now()->year);
|
||||
$balance = $this->leaveService->getUserBalance($userId, $year);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $balance ? [
|
||||
'total_days' => $balance->total_days,
|
||||
'used_days' => $balance->used_days,
|
||||
'remaining_days' => $balance->remaining,
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용현황 통계 (HTMX → HTML)
|
||||
*/
|
||||
public function stats(Request $request): JsonResponse|Response
|
||||
{
|
||||
$year = $request->integer('year', now()->year);
|
||||
$stats = $this->leaveService->getUsageStats($year);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(view('hr.leaves.partials.stats', compact('stats')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV 내보내기
|
||||
*/
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
$filters = $request->all();
|
||||
$leaves = $this->leaveService->getExportData($filters);
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition' => 'attachment; filename="leaves_'.now()->format('Ymd_His').'.csv"',
|
||||
];
|
||||
|
||||
return response()->stream(function () use ($leaves) {
|
||||
$output = fopen('php://output', 'w');
|
||||
fprintf($output, chr(0xEF).chr(0xBB).chr(0xBF)); // BOM
|
||||
|
||||
fputcsv($output, ['사원', '부서', '유형', '시작일', '종료일', '일수', '사유', '상태', '승인자', '신청일']);
|
||||
|
||||
foreach ($leaves as $leave) {
|
||||
$profile = $leave->user?->tenantProfiles?->first();
|
||||
fputcsv($output, [
|
||||
$leave->user?->name ?? '-',
|
||||
$profile?->department?->name ?? '-',
|
||||
$leave->type_label,
|
||||
$leave->start_date->format('Y-m-d'),
|
||||
$leave->end_date->format('Y-m-d'),
|
||||
$leave->days,
|
||||
$leave->reason ?? '-',
|
||||
$leave->status_label,
|
||||
$leave->approver?->name ?? '-',
|
||||
$leave->created_at->format('Y-m-d H:i'),
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($output);
|
||||
}, 200, $headers);
|
||||
}
|
||||
}
|
||||
31
app/Http/Controllers/HR/LeaveController.php
Normal file
31
app/Http/Controllers/HR/LeaveController.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\HR\Leave;
|
||||
use App\Services\HR\LeaveService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class LeaveController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private LeaveService $leaveService
|
||||
) {}
|
||||
|
||||
public function index(): View|Response
|
||||
{
|
||||
$employees = $this->leaveService->getActiveEmployees();
|
||||
$departments = $this->leaveService->getDepartments();
|
||||
$typeMap = Leave::TYPE_MAP;
|
||||
$statusMap = Leave::STATUS_MAP;
|
||||
|
||||
return view('hr.leaves.index', [
|
||||
'employees' => $employees,
|
||||
'departments' => $departments,
|
||||
'typeMap' => $typeMap,
|
||||
'statusMap' => $statusMap,
|
||||
]);
|
||||
}
|
||||
}
|
||||
155
app/Models/HR/Leave.php
Normal file
155
app/Models/HR/Leave.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\HR;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Leave extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'leaves';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'leave_type',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'days',
|
||||
'reason',
|
||||
'status',
|
||||
'approved_by',
|
||||
'approved_at',
|
||||
'reject_reason',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'tenant_id' => 'int',
|
||||
'user_id' => 'int',
|
||||
'approved_by' => 'int',
|
||||
'created_by' => 'int',
|
||||
'updated_by' => 'int',
|
||||
'deleted_by' => 'int',
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'days' => 'float',
|
||||
'approved_at' => 'datetime',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 상수
|
||||
// =========================================================================
|
||||
|
||||
public const TYPE_MAP = [
|
||||
'annual' => '연차',
|
||||
'half_am' => '오전반차',
|
||||
'half_pm' => '오후반차',
|
||||
'sick' => '병가',
|
||||
'family' => '경조사',
|
||||
'maternity' => '출산',
|
||||
'parental' => '육아',
|
||||
];
|
||||
|
||||
public const STATUS_MAP = [
|
||||
'pending' => '대기',
|
||||
'approved' => '승인',
|
||||
'rejected' => '반려',
|
||||
'cancelled' => '취소',
|
||||
];
|
||||
|
||||
public const STATUS_COLORS = [
|
||||
'pending' => 'amber',
|
||||
'approved' => 'emerald',
|
||||
'rejected' => 'red',
|
||||
'cancelled' => 'gray',
|
||||
];
|
||||
|
||||
public const DEDUCTIBLE_TYPES = ['annual', 'half_am', 'half_pm'];
|
||||
|
||||
// =========================================================================
|
||||
// 관계
|
||||
// =========================================================================
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function approver(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by');
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Accessor
|
||||
// =========================================================================
|
||||
|
||||
public function getTypeLabelAttribute(): string
|
||||
{
|
||||
return self::TYPE_MAP[$this->leave_type] ?? $this->leave_type;
|
||||
}
|
||||
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
return self::STATUS_MAP[$this->status] ?? $this->status;
|
||||
}
|
||||
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
return self::STATUS_COLORS[$this->status] ?? 'gray';
|
||||
}
|
||||
|
||||
public function getIsDeductibleAttribute(): bool
|
||||
{
|
||||
return in_array($this->leave_type, self::DEDUCTIBLE_TYPES);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
public function scopeForTenant($query, ?int $tenantId = null)
|
||||
{
|
||||
$tenantId = $tenantId ?? session('selected_tenant_id');
|
||||
if ($tenantId) {
|
||||
return $query->where($this->table.'.tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function scopeBetweenDates($query, string $startDate, string $endDate)
|
||||
{
|
||||
return $query->where(function ($q) use ($startDate, $endDate) {
|
||||
$q->where('start_date', '<=', $endDate)
|
||||
->where('end_date', '>=', $startDate);
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeForUser($query, int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
public function scopeForYear($query, int $year)
|
||||
{
|
||||
return $query->whereYear('start_date', $year);
|
||||
}
|
||||
|
||||
public function scopeWithStatus($query, string $status)
|
||||
{
|
||||
return $query->where('status', $status);
|
||||
}
|
||||
}
|
||||
@@ -35,4 +35,60 @@ public function getRemainingAttribute(): float
|
||||
{
|
||||
return $this->remaining_days ?? ($this->total_days - $this->used_days);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
public function scopeForTenant($query, ?int $tenantId = null)
|
||||
{
|
||||
$tenantId = $tenantId ?? session('selected_tenant_id');
|
||||
if ($tenantId) {
|
||||
return $query->where($this->table.'.tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function scopeForUser($query, int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
public function scopeForYear($query, int $year)
|
||||
{
|
||||
return $query->where('year', $year);
|
||||
}
|
||||
|
||||
public function scopeCurrentYear($query)
|
||||
{
|
||||
return $query->where('year', now()->year);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 헬퍼
|
||||
// =========================================================================
|
||||
|
||||
public function useLeave(float $days): bool
|
||||
{
|
||||
if (! $this->canUse($days)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->increment('used_days', $days);
|
||||
$this->refresh();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function restoreLeave(float $days): void
|
||||
{
|
||||
$this->decrement('used_days', $days);
|
||||
$this->refresh();
|
||||
}
|
||||
|
||||
public function canUse(float $days): bool
|
||||
{
|
||||
return ($this->total_days - $this->used_days) >= $days;
|
||||
}
|
||||
}
|
||||
|
||||
88
app/Models/HR/LeaveGrant.php
Normal file
88
app/Models/HR/LeaveGrant.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\HR;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class LeaveGrant extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'leave_grants';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'grant_type',
|
||||
'grant_date',
|
||||
'grant_days',
|
||||
'reason',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'tenant_id' => 'int',
|
||||
'user_id' => 'int',
|
||||
'created_by' => 'int',
|
||||
'grant_date' => 'date',
|
||||
'grant_days' => 'float',
|
||||
];
|
||||
|
||||
public const TYPE_MAP = [
|
||||
'annual' => '정기연차',
|
||||
'monthly' => '월차',
|
||||
'reward' => '포상',
|
||||
'condolence' => '경조사',
|
||||
'other' => '기타',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계
|
||||
// =========================================================================
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Accessor
|
||||
// =========================================================================
|
||||
|
||||
public function getGrantTypeLabelAttribute(): string
|
||||
{
|
||||
return self::TYPE_MAP[$this->grant_type] ?? $this->grant_type;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
public function scopeForTenant($query, ?int $tenantId = null)
|
||||
{
|
||||
$tenantId = $tenantId ?? session('selected_tenant_id');
|
||||
if ($tenantId) {
|
||||
return $query->where($this->table.'.tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function scopeForUser($query, int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
public function scopeForYear($query, int $year)
|
||||
{
|
||||
return $query->whereYear('grant_date', $year);
|
||||
}
|
||||
}
|
||||
74
app/Models/HR/LeavePolicy.php
Normal file
74
app/Models/HR/LeavePolicy.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\HR;
|
||||
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class LeavePolicy extends Model
|
||||
{
|
||||
protected $table = 'leave_policies';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'standard_type',
|
||||
'fiscal_start_month',
|
||||
'fiscal_start_day',
|
||||
'default_annual_leave',
|
||||
'additional_leave_per_year',
|
||||
'max_annual_leave',
|
||||
'carry_over_enabled',
|
||||
'carry_over_max_days',
|
||||
'carry_over_expiry_months',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'tenant_id' => 'int',
|
||||
'fiscal_start_month' => 'int',
|
||||
'fiscal_start_day' => 'int',
|
||||
'default_annual_leave' => 'int',
|
||||
'additional_leave_per_year' => 'int',
|
||||
'max_annual_leave' => 'int',
|
||||
'carry_over_enabled' => 'boolean',
|
||||
'carry_over_max_days' => 'int',
|
||||
'carry_over_expiry_months' => 'int',
|
||||
];
|
||||
|
||||
public const STANDARD_TYPE_MAP = [
|
||||
'fiscal' => '회계연도',
|
||||
'hire' => '입사일',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 관계
|
||||
// =========================================================================
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Accessor
|
||||
// =========================================================================
|
||||
|
||||
public function getStandardTypeLabelAttribute(): string
|
||||
{
|
||||
return self::STANDARD_TYPE_MAP[$this->standard_type] ?? $this->standard_type;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 스코프
|
||||
// =========================================================================
|
||||
|
||||
public function scopeForTenant($query, ?int $tenantId = null)
|
||||
{
|
||||
$tenantId = $tenantId ?? session('selected_tenant_id');
|
||||
if ($tenantId) {
|
||||
return $query->where($this->table.'.tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
431
app/Services/HR/LeaveService.php
Normal file
431
app/Services/HR/LeaveService.php
Normal file
@@ -0,0 +1,431 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\HR;
|
||||
|
||||
use App\Models\HR\Attendance;
|
||||
use App\Models\HR\Leave;
|
||||
use App\Models\HR\LeaveBalance;
|
||||
use App\Models\Tenants\Department;
|
||||
use Carbon\CarbonPeriod;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class LeaveService
|
||||
{
|
||||
/**
|
||||
* 휴가 목록 조회 (필터 + 페이지네이션)
|
||||
*/
|
||||
public function getLeaves(array $filters = [], int $perPage = 20): LengthAwarePaginator
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$query = Leave::query()
|
||||
->with([
|
||||
'user',
|
||||
'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId),
|
||||
'user.tenantProfiles.department',
|
||||
'approver',
|
||||
])
|
||||
->forTenant($tenantId);
|
||||
|
||||
if (! empty($filters['q'])) {
|
||||
$search = $filters['q'];
|
||||
$query->whereHas('user', fn ($q) => $q->where('name', 'like', "%{$search}%"));
|
||||
}
|
||||
|
||||
if (! empty($filters['user_id'])) {
|
||||
$query->where('user_id', $filters['user_id']);
|
||||
}
|
||||
|
||||
if (! empty($filters['department_id'])) {
|
||||
$deptId = $filters['department_id'];
|
||||
$query->whereHas('user.tenantProfiles', function ($q) use ($tenantId, $deptId) {
|
||||
$q->where('tenant_id', $tenantId)->where('department_id', $deptId);
|
||||
});
|
||||
}
|
||||
|
||||
if (! empty($filters['leave_type'])) {
|
||||
$query->where('leave_type', $filters['leave_type']);
|
||||
}
|
||||
|
||||
if (! empty($filters['status'])) {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
if (! empty($filters['date_from']) && ! empty($filters['date_to'])) {
|
||||
$query->betweenDates($filters['date_from'], $filters['date_to']);
|
||||
} elseif (! empty($filters['date_from'])) {
|
||||
$query->where('start_date', '>=', $filters['date_from']);
|
||||
} elseif (! empty($filters['date_to'])) {
|
||||
$query->where('end_date', '<=', $filters['date_to']);
|
||||
}
|
||||
|
||||
return $query
|
||||
->orderByRaw("FIELD(status, 'pending', 'approved', 'rejected', 'cancelled')")
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가 신청 등록
|
||||
*/
|
||||
public function storeLeave(array $data): Leave
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$days = $this->calculateDays($data['leave_type'], $data['start_date'], $data['end_date']);
|
||||
|
||||
// 연차 차감 대상이면 잔여일수 검증
|
||||
if (in_array($data['leave_type'], Leave::DEDUCTIBLE_TYPES)) {
|
||||
$balance = LeaveBalance::query()
|
||||
->forTenant($tenantId)
|
||||
->forUser($data['user_id'])
|
||||
->forYear(now()->year)
|
||||
->first();
|
||||
|
||||
if (! $balance || ! $balance->canUse($days)) {
|
||||
throw new \RuntimeException('잔여 연차가 부족합니다.');
|
||||
}
|
||||
}
|
||||
|
||||
return Leave::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $data['user_id'],
|
||||
'leave_type' => $data['leave_type'],
|
||||
'start_date' => $data['start_date'],
|
||||
'end_date' => $data['end_date'],
|
||||
'days' => $days,
|
||||
'reason' => $data['reason'] ?? null,
|
||||
'status' => 'pending',
|
||||
'created_by' => auth()->id(),
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인 → LeaveBalance 차감 → Attendance 자동 생성
|
||||
*/
|
||||
public function approve(int $id): ?Leave
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$leave = Leave::query()
|
||||
->forTenant($tenantId)
|
||||
->withStatus('pending')
|
||||
->find($id);
|
||||
|
||||
if (! $leave) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($leave, $tenantId) {
|
||||
$leave->update([
|
||||
'status' => 'approved',
|
||||
'approved_by' => auth()->id(),
|
||||
'approved_at' => now(),
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// 연차 차감 대상이면 LeaveBalance 차감
|
||||
if ($leave->is_deductible) {
|
||||
$balance = LeaveBalance::query()
|
||||
->forTenant($tenantId)
|
||||
->forUser($leave->user_id)
|
||||
->forYear($leave->start_date->year)
|
||||
->first();
|
||||
|
||||
if ($balance) {
|
||||
$balance->useLeave($leave->days);
|
||||
}
|
||||
}
|
||||
|
||||
// 기간 내 영업일마다 Attendance(vacation) 자동 생성
|
||||
$this->createAttendanceRecords($leave, $tenantId);
|
||||
|
||||
return $leave->fresh(['user', 'approver']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 반려
|
||||
*/
|
||||
public function reject(int $id, ?string $reason = null): ?Leave
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$leave = Leave::query()
|
||||
->forTenant($tenantId)
|
||||
->withStatus('pending')
|
||||
->find($id);
|
||||
|
||||
if (! $leave) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$leave->update([
|
||||
'status' => 'rejected',
|
||||
'approved_by' => auth()->id(),
|
||||
'approved_at' => now(),
|
||||
'reject_reason' => $reason,
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return $leave->fresh(['user', 'approver']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소 → LeaveBalance 복원 → Attendance 삭제
|
||||
*/
|
||||
public function cancel(int $id): ?Leave
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$leave = Leave::query()
|
||||
->forTenant($tenantId)
|
||||
->withStatus('approved')
|
||||
->find($id);
|
||||
|
||||
if (! $leave) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($leave, $tenantId) {
|
||||
$leave->update([
|
||||
'status' => 'cancelled',
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// 연차 차감 대상이면 LeaveBalance 복원
|
||||
if ($leave->is_deductible) {
|
||||
$balance = LeaveBalance::query()
|
||||
->forTenant($tenantId)
|
||||
->forUser($leave->user_id)
|
||||
->forYear($leave->start_date->year)
|
||||
->first();
|
||||
|
||||
if ($balance) {
|
||||
$balance->restoreLeave($leave->days);
|
||||
}
|
||||
}
|
||||
|
||||
// 해당 기간 vacation Attendance soft delete
|
||||
$this->deleteAttendanceRecords($leave, $tenantId);
|
||||
|
||||
return $leave->fresh(['user']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 사원 잔여연차 요약
|
||||
*/
|
||||
public function getBalanceSummary(?int $year = null): Collection
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$year = $year ?? now()->year;
|
||||
|
||||
return LeaveBalance::query()
|
||||
->with([
|
||||
'user',
|
||||
'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId),
|
||||
'user.tenantProfiles.department',
|
||||
])
|
||||
->forTenant($tenantId)
|
||||
->forYear($year)
|
||||
->orderBy('user_id')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 사원 잔여연차
|
||||
*/
|
||||
public function getUserBalance(int $userId, ?int $year = null): ?LeaveBalance
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$year = $year ?? now()->year;
|
||||
|
||||
return LeaveBalance::query()
|
||||
->forTenant($tenantId)
|
||||
->forUser($userId)
|
||||
->forYear($year)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 유형별/사원별 사용 통계
|
||||
*/
|
||||
public function getUsageStats(?int $year = null): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$year = $year ?? now()->year;
|
||||
|
||||
// 유형별 집계
|
||||
$byType = Leave::query()
|
||||
->forTenant($tenantId)
|
||||
->forYear($year)
|
||||
->withStatus('approved')
|
||||
->select('leave_type', DB::raw('COUNT(*) as count'), DB::raw('SUM(days) as total_days'))
|
||||
->groupBy('leave_type')
|
||||
->get()
|
||||
->keyBy('leave_type');
|
||||
|
||||
// 사원별 유형 크로스 테이블
|
||||
$byUser = Leave::query()
|
||||
->forTenant($tenantId)
|
||||
->forYear($year)
|
||||
->withStatus('approved')
|
||||
->with([
|
||||
'user',
|
||||
'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId),
|
||||
'user.tenantProfiles.department',
|
||||
])
|
||||
->select('user_id', 'leave_type', DB::raw('SUM(days) as total_days'))
|
||||
->groupBy('user_id', 'leave_type')
|
||||
->get()
|
||||
->groupBy('user_id');
|
||||
|
||||
return [
|
||||
'by_type' => $byType,
|
||||
'by_user' => $byUser,
|
||||
'year' => $year,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 일수 자동 계산 (반차 = 0.5, 연차/기타 = 영업일수)
|
||||
*/
|
||||
public function calculateDays(string $type, string $start, string $end): float
|
||||
{
|
||||
if (in_array($type, ['half_am', 'half_pm'])) {
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
$period = CarbonPeriod::create($start, $end);
|
||||
$businessDays = 0;
|
||||
|
||||
foreach ($period as $date) {
|
||||
if (! $date->isWeekend()) {
|
||||
$businessDays++;
|
||||
}
|
||||
}
|
||||
|
||||
return (float) $businessDays;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV 내보내기용 데이터
|
||||
*/
|
||||
public function getExportData(array $filters = []): Collection
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$query = Leave::query()
|
||||
->with([
|
||||
'user',
|
||||
'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId),
|
||||
'user.tenantProfiles.department',
|
||||
'approver',
|
||||
])
|
||||
->forTenant($tenantId);
|
||||
|
||||
if (! empty($filters['status'])) {
|
||||
$query->withStatus($filters['status']);
|
||||
}
|
||||
|
||||
if (! empty($filters['leave_type'])) {
|
||||
$query->where('leave_type', $filters['leave_type']);
|
||||
}
|
||||
|
||||
if (! empty($filters['date_from']) && ! empty($filters['date_to'])) {
|
||||
$query->betweenDates($filters['date_from'], $filters['date_to']);
|
||||
}
|
||||
|
||||
return $query->orderBy('start_date', 'desc')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 목록
|
||||
*/
|
||||
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 getActiveEmployees(): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
return \App\Models\HR\Employee::query()
|
||||
->with('user:id,name')
|
||||
->forTenant($tenantId)
|
||||
->activeEmployees()
|
||||
->orderBy('display_name')
|
||||
->get(['id', 'user_id', 'display_name', 'department_id']);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 승인 시 기간 내 영업일마다 Attendance(vacation) 자동 생성
|
||||
*/
|
||||
private function createAttendanceRecords(Leave $leave, int $tenantId): void
|
||||
{
|
||||
$period = CarbonPeriod::create($leave->start_date, $leave->end_date);
|
||||
|
||||
foreach ($period as $date) {
|
||||
if ($date->isWeekend()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Attendance::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $leave->user_id,
|
||||
'base_date' => $date->toDateString(),
|
||||
],
|
||||
[
|
||||
'status' => 'vacation',
|
||||
'remarks' => $leave->reason ? mb_substr($leave->reason, 0, 100) : null,
|
||||
'updated_by' => auth()->id(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소 시 해당 기간 vacation Attendance soft delete
|
||||
*/
|
||||
private function deleteAttendanceRecords(Leave $leave, int $tenantId): void
|
||||
{
|
||||
Attendance::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('user_id', $leave->user_id)
|
||||
->where('status', 'vacation')
|
||||
->whereBetween('base_date', [
|
||||
$leave->start_date->toDateString(),
|
||||
$leave->end_date->toDateString(),
|
||||
])
|
||||
->update(['deleted_by' => auth()->id()]);
|
||||
|
||||
Attendance::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('user_id', $leave->user_id)
|
||||
->where('status', 'vacation')
|
||||
->whereBetween('base_date', [
|
||||
$leave->start_date->toDateString(),
|
||||
$leave->end_date->toDateString(),
|
||||
])
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
545
resources/views/hr/leaves/index.blade.php
Normal file
545
resources/views/hr/leaves/index.blade.php
Normal file
@@ -0,0 +1,545 @@
|
||||
@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">휴가 신청, 승인/반려 및 연차 현황을 관리합니다.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<button type="button" onclick="openLeaveModal()"
|
||||
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>
|
||||
휴가 신청
|
||||
</button>
|
||||
<button type="button" onclick="exportLeaves()"
|
||||
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>
|
||||
엑셀 다운로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 탭 네비게이션 --}}
|
||||
<div class="flex items-center gap-1 mb-4 border-b border-gray-200">
|
||||
<button type="button" onclick="switchTab('list')" id="tab-list"
|
||||
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-blue-600 text-blue-600">
|
||||
휴가신청
|
||||
</button>
|
||||
<button type="button" onclick="switchTab('balance')" id="tab-balance"
|
||||
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-transparent text-gray-500 hover:text-gray-700">
|
||||
잔여연차
|
||||
</button>
|
||||
<button type="button" onclick="switchTab('stats')" id="tab-stats"
|
||||
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-transparent text-gray-500 hover:text-gray-700">
|
||||
사용현황
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- 탭 콘텐츠 영역 --}}
|
||||
<div id="leaves-content">
|
||||
{{-- 휴가신청 탭 --}}
|
||||
<div id="content-list">
|
||||
<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="leaveFilter">
|
||||
<form id="leaveFilterForm" class="flex flex-wrap gap-3 items-end">
|
||||
<div style="flex: 1 1 180px; max-width: 260px;">
|
||||
<label class="block text-xs text-gray-500 mb-1">검색</label>
|
||||
<input type="text" name="q" placeholder="사원 이름..."
|
||||
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 }}">{{ $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="leave_type"
|
||||
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($typeMap as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex: 0 1 120px;">
|
||||
<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>
|
||||
@foreach($statusMap as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex: 0 1 150px;">
|
||||
<label class="block text-xs text-gray-500 mb-1">시작일</label>
|
||||
<input type="date" name="date_from"
|
||||
value="{{ now()->startOfYear()->toDateString() }}"
|
||||
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="date_to"
|
||||
value="{{ now()->toDateString() }}"
|
||||
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 class="shrink-0">
|
||||
<button type="submit"
|
||||
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="leaves-table"
|
||||
hx-get="{{ route('api.admin.hr.leaves.index') }}"
|
||||
hx-vals='{"date_from": "{{ now()->startOfYear()->toDateString() }}", "date_to": "{{ now()->toDateString() }}"}'
|
||||
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>
|
||||
|
||||
{{-- 잔여연차 탭 --}}
|
||||
<div id="content-balance" class="hidden">
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center gap-3">
|
||||
<label class="text-sm text-gray-600 font-medium">연도:</label>
|
||||
<select id="balanceYear" onchange="loadBalance()"
|
||||
class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
@for($y = now()->year; $y >= now()->year - 2; $y--)
|
||||
<option value="{{ $y }}">{{ $y }}년</option>
|
||||
@endfor
|
||||
</select>
|
||||
</div>
|
||||
<div id="balance-container">
|
||||
<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>
|
||||
|
||||
{{-- 사용현황 탭 --}}
|
||||
<div id="content-stats" class="hidden">
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center gap-3">
|
||||
<label class="text-sm text-gray-600 font-medium">연도:</label>
|
||||
<select id="statsYear" onchange="loadStats()"
|
||||
class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
@for($y = now()->year; $y >= now()->year - 2; $y--)
|
||||
<option value="{{ $y }}">{{ $y }}년</option>
|
||||
@endfor
|
||||
</select>
|
||||
</div>
|
||||
<div id="stats-container">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 휴가 신청 모달 --}}
|
||||
<div id="leaveModal" class="fixed inset-0 z-50 hidden">
|
||||
<div class="fixed inset-0 bg-black/40" onclick="closeLeaveModal()"></div>
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md relative">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-800">휴가 신청</h3>
|
||||
<button type="button" onclick="closeLeaveModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<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="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form id="leaveForm" onsubmit="submitLeave(event)">
|
||||
<div class="px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">사원 <span class="text-red-500">*</span></label>
|
||||
<select name="user_id" id="leaveUserId" required onchange="loadUserBalance()"
|
||||
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($employees as $emp)
|
||||
<option value="{{ $emp->user_id }}">{{ $emp->display_name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">유형 <span class="text-red-500">*</span></label>
|
||||
<select name="leave_type" id="leaveType" required onchange="onTypeChange()"
|
||||
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">
|
||||
@foreach($typeMap as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">시작일 <span class="text-red-500">*</span></label>
|
||||
<input type="date" name="start_date" id="leaveStartDate" required
|
||||
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 class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">종료일 <span class="text-red-500">*</span></label>
|
||||
<input type="date" name="end_date" id="leaveEndDate" required
|
||||
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>
|
||||
{{-- 잔여연차 표시 --}}
|
||||
<div id="balanceInfo" class="hidden p-3 bg-blue-50 rounded-lg text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-blue-700">잔여 연차:</span>
|
||||
<span id="balanceDisplay" class="font-semibold text-blue-800">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">사유</label>
|
||||
<textarea name="reason" rows="3" maxlength="1000" placeholder="휴가 사유를 입력하세요..."
|
||||
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"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 px-6 py-4 border-t border-gray-200">
|
||||
<button type="button" onclick="closeLeaveModal()"
|
||||
class="px-4 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit" id="leaveSubmitBtn"
|
||||
class="px-4 py-2 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors">
|
||||
신청
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 반려 사유 모달 --}}
|
||||
<div id="rejectModal" class="fixed inset-0 z-50 hidden">
|
||||
<div class="fixed inset-0 bg-black/40" onclick="closeRejectModal()"></div>
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-sm relative">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-800">반려 사유</h3>
|
||||
<button type="button" onclick="closeRejectModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<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="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form onsubmit="submitReject(event)">
|
||||
<div class="px-6 py-4">
|
||||
<input type="hidden" id="rejectLeaveId">
|
||||
<textarea id="rejectReason" rows="3" maxlength="1000" placeholder="반려 사유를 입력하세요..."
|
||||
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"></textarea>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 px-6 py-4 border-t border-gray-200">
|
||||
<button type="button" onclick="closeRejectModal()"
|
||||
class="px-4 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 text-sm text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors">
|
||||
반려
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// ===== 탭 관리 =====
|
||||
let currentTab = 'list';
|
||||
const tabLoaded = { list: true, balance: false, stats: false };
|
||||
|
||||
function switchTab(tab) {
|
||||
document.getElementById('tab-' + currentTab).className = 'px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-transparent text-gray-500 hover:text-gray-700';
|
||||
document.getElementById('content-' + currentTab).classList.add('hidden');
|
||||
|
||||
currentTab = tab;
|
||||
document.getElementById('tab-' + tab).className = 'px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-blue-600 text-blue-600';
|
||||
document.getElementById('content-' + tab).classList.remove('hidden');
|
||||
|
||||
if (!tabLoaded[tab]) {
|
||||
tabLoaded[tab] = true;
|
||||
if (tab === 'balance') loadBalance();
|
||||
if (tab === 'stats') loadStats();
|
||||
}
|
||||
}
|
||||
|
||||
function loadBalance() {
|
||||
const year = document.getElementById('balanceYear').value;
|
||||
htmx.ajax('GET', '{{ route("api.admin.hr.leaves.balance") }}', {
|
||||
target: '#balance-container',
|
||||
swap: 'innerHTML',
|
||||
values: { year: year },
|
||||
});
|
||||
}
|
||||
|
||||
function loadStats() {
|
||||
const year = document.getElementById('statsYear').value;
|
||||
htmx.ajax('GET', '{{ route("api.admin.hr.leaves.stats") }}', {
|
||||
target: '#stats-container',
|
||||
swap: 'innerHTML',
|
||||
values: { year: year },
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 필터 =====
|
||||
document.getElementById('leaveFilterForm')?.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
refreshTable();
|
||||
});
|
||||
|
||||
function refreshTable() {
|
||||
htmx.ajax('GET', '{{ route("api.admin.hr.leaves.index") }}', {
|
||||
target: '#leaves-table',
|
||||
swap: 'innerHTML',
|
||||
values: getFilterValues(),
|
||||
});
|
||||
}
|
||||
|
||||
function getFilterValues() {
|
||||
const form = document.getElementById('leaveFilterForm');
|
||||
const formData = new FormData(form);
|
||||
const values = {};
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value) values[key] = value;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
// ===== 엑셀 다운로드 =====
|
||||
function exportLeaves() {
|
||||
const params = new URLSearchParams(getFilterValues());
|
||||
window.location.href = '{{ route("api.admin.hr.leaves.export") }}?' + params.toString();
|
||||
}
|
||||
|
||||
// ===== 휴가 신청 모달 =====
|
||||
function openLeaveModal() {
|
||||
document.getElementById('leaveForm').reset();
|
||||
document.getElementById('balanceInfo').classList.add('hidden');
|
||||
document.getElementById('leaveModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeLeaveModal() {
|
||||
document.getElementById('leaveModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
const deductibleTypes = ['annual', 'half_am', 'half_pm'];
|
||||
|
||||
function onTypeChange() {
|
||||
const type = document.getElementById('leaveType').value;
|
||||
const startDate = document.getElementById('leaveStartDate');
|
||||
const endDate = document.getElementById('leaveEndDate');
|
||||
|
||||
if (type === 'half_am' || type === 'half_pm') {
|
||||
endDate.value = startDate.value;
|
||||
endDate.setAttribute('readonly', true);
|
||||
} else {
|
||||
endDate.removeAttribute('readonly');
|
||||
}
|
||||
|
||||
loadUserBalance();
|
||||
}
|
||||
|
||||
function loadUserBalance() {
|
||||
const userId = document.getElementById('leaveUserId').value;
|
||||
const type = document.getElementById('leaveType').value;
|
||||
|
||||
if (!userId || !deductibleTypes.includes(type)) {
|
||||
document.getElementById('balanceInfo').classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('{{ url("/api/admin/hr/leaves/balance") }}/' + userId, {
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success && res.data) {
|
||||
document.getElementById('balanceDisplay').textContent =
|
||||
res.data.remaining_days + '일 (부여: ' + res.data.total_days + ' / 사용: ' + res.data.used_days + ')';
|
||||
document.getElementById('balanceInfo').classList.remove('hidden');
|
||||
} else {
|
||||
document.getElementById('balanceDisplay').textContent = '연차 정보 없음';
|
||||
document.getElementById('balanceInfo').classList.remove('hidden');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('balanceInfo').classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
function submitLeave(e) {
|
||||
e.preventDefault();
|
||||
const form = document.getElementById('leaveForm');
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
const btn = document.getElementById('leaveSubmitBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '처리 중...';
|
||||
|
||||
fetch('{{ route("api.admin.hr.leaves.store") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) {
|
||||
closeLeaveModal();
|
||||
refreshTable();
|
||||
showToast(res.message, 'success');
|
||||
} else {
|
||||
showToast(res.message || '등록에 실패했습니다.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(() => showToast('네트워크 오류가 발생했습니다.', 'error'))
|
||||
.finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '신청';
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 승인/반려/취소 =====
|
||||
function approveLeave(id) {
|
||||
if (!confirm('승인하시겠습니까?')) return;
|
||||
|
||||
fetch('{{ url("/api/admin/hr/leaves") }}/' + id + '/approve', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) {
|
||||
refreshTable();
|
||||
showToast(res.message, 'success');
|
||||
} else {
|
||||
showToast(res.message || '승인 처리에 실패했습니다.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(() => showToast('네트워크 오류가 발생했습니다.', 'error'));
|
||||
}
|
||||
|
||||
function openRejectModal(id) {
|
||||
document.getElementById('rejectLeaveId').value = id;
|
||||
document.getElementById('rejectReason').value = '';
|
||||
document.getElementById('rejectModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeRejectModal() {
|
||||
document.getElementById('rejectModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
function submitReject(e) {
|
||||
e.preventDefault();
|
||||
const id = document.getElementById('rejectLeaveId').value;
|
||||
const reason = document.getElementById('rejectReason').value;
|
||||
|
||||
fetch('{{ url("/api/admin/hr/leaves") }}/' + id + '/reject', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ reject_reason: reason })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) {
|
||||
closeRejectModal();
|
||||
refreshTable();
|
||||
showToast(res.message, 'success');
|
||||
} else {
|
||||
showToast(res.message || '반려 처리에 실패했습니다.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(() => showToast('네트워크 오류가 발생했습니다.', 'error'));
|
||||
}
|
||||
|
||||
function cancelLeave(id) {
|
||||
if (!confirm('취소하시겠습니까? 연차가 복원됩니다.')) return;
|
||||
|
||||
fetch('{{ url("/api/admin/hr/leaves") }}/' + id + '/cancel', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res.success) {
|
||||
refreshTable();
|
||||
showToast(res.message, 'success');
|
||||
} else {
|
||||
showToast(res.message || '취소 처리에 실패했습니다.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(() => showToast('네트워크 오류가 발생했습니다.', 'error'));
|
||||
}
|
||||
|
||||
// ===== 토스트 =====
|
||||
function showToast(message, type) {
|
||||
if (typeof window.showToastNotification === 'function') {
|
||||
window.showToastNotification(message, type);
|
||||
return;
|
||||
}
|
||||
const colors = { success: 'bg-emerald-500', error: 'bg-red-500', info: 'bg-blue-500' };
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `fixed top-4 right-4 z-[60] px-4 py-3 rounded-lg text-white text-sm shadow-lg ${colors[type] || colors.info}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 3000);
|
||||
}
|
||||
|
||||
// ===== 반차 시작일 변경 시 종료일 동기화 =====
|
||||
document.getElementById('leaveStartDate')?.addEventListener('change', function() {
|
||||
const type = document.getElementById('leaveType').value;
|
||||
if (type === 'half_am' || type === 'half_pm') {
|
||||
document.getElementById('leaveEndDate').value = this.value;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
88
resources/views/hr/leaves/partials/balance.blade.php
Normal file
88
resources/views/hr/leaves/partials/balance.blade.php
Normal file
@@ -0,0 +1,88 @@
|
||||
{{-- 잔여연차 현황 (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-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($balances as $balance)
|
||||
@php
|
||||
$profile = $balance->user?->tenantProfiles?->first();
|
||||
$department = $profile?->department;
|
||||
$displayName = $profile?->display_name ?? $balance->user?->name ?? '-';
|
||||
$remaining = $balance->total_days - $balance->used_days;
|
||||
$rate = $balance->total_days > 0 ? round(($balance->used_days / $balance->total_days) * 100) : 0;
|
||||
$barColor = $rate >= 90 ? 'bg-red-500' : ($rate >= 70 ? 'bg-amber-500' : 'bg-blue-500');
|
||||
@endphp
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
{{-- 사원 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="shrink-0 w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-xs font-medium">
|
||||
{{ mb_substr($displayName, 0, 1) }}
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900">{{ $displayName }}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{{-- 부서 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700">
|
||||
{{ $department?->name ?? '-' }}
|
||||
</td>
|
||||
|
||||
{{-- 부여 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-700 font-medium">
|
||||
{{ $balance->total_days }}일
|
||||
</td>
|
||||
|
||||
{{-- 사용 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-700">
|
||||
{{ $balance->used_days }}일
|
||||
</td>
|
||||
|
||||
{{-- 잔여 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<span class="text-sm font-semibold {{ $remaining <= 3 ? 'text-red-600' : 'text-blue-600' }}">
|
||||
{{ $remaining }}일
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{{-- 소진율 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center" style="min-width: 120px;">
|
||||
<div class="flex items-center gap-2 justify-center">
|
||||
<div class="flex-1 bg-gray-200 rounded-full h-2" style="max-width: 80px;">
|
||||
<div class="{{ $barColor }} h-2 rounded-full" style="width: {{ $rate }}%;"></div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500">{{ $rate }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="6" 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="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<p class="text-gray-500">{{ $year }}년 연차 정보가 없습니다.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</x-table-swipe>
|
||||
|
||||
@if($balances->count())
|
||||
<div class="px-6 py-3 bg-gray-50 border-t border-gray-200 text-sm text-gray-500">
|
||||
총 {{ $balances->count() }}명 | 부여합계: {{ $balances->sum('total_days') }}일 | 사용합계: {{ $balances->sum('used_days') }}일 | 잔여합계: {{ $balances->sum('total_days') - $balances->sum('used_days') }}일
|
||||
</div>
|
||||
@endif
|
||||
89
resources/views/hr/leaves/partials/stats.blade.php
Normal file
89
resources/views/hr/leaves/partials/stats.blade.php
Normal file
@@ -0,0 +1,89 @@
|
||||
{{-- 사용현황 통계 (HTMX로 로드) --}}
|
||||
@php
|
||||
use App\Models\HR\Leave;
|
||||
|
||||
$byType = $stats['by_type'] ?? collect();
|
||||
$byUser = $stats['by_user'] ?? collect();
|
||||
$year = $stats['year'] ?? now()->year;
|
||||
$typeMap = Leave::TYPE_MAP;
|
||||
$totalApproved = $byType->sum('count');
|
||||
$totalDays = $byType->sum('total_days');
|
||||
@endphp
|
||||
|
||||
<div class="p-6 space-y-6">
|
||||
{{-- 유형별 집계 카드 --}}
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">{{ $year }}년 유형별 현황</h3>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{{-- 전체 합계 --}}
|
||||
<div class="p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div class="text-xs text-gray-500 mb-1">전체</div>
|
||||
<div class="text-2xl font-bold text-gray-800">{{ $totalApproved }}건</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">{{ $totalDays }}일</div>
|
||||
</div>
|
||||
|
||||
@foreach($typeMap as $typeKey => $typeLabel)
|
||||
@php
|
||||
$typeData = $byType->get($typeKey);
|
||||
$count = $typeData->count ?? 0;
|
||||
$days = $typeData->total_days ?? 0;
|
||||
@endphp
|
||||
<div class="p-4 bg-white rounded-lg border border-gray-200">
|
||||
<div class="text-xs text-gray-500 mb-1">{{ $typeLabel }}</div>
|
||||
<div class="text-2xl font-bold text-gray-800">{{ $count }}건</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">{{ $days }}일</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 사원별 유형 크로스 테이블 --}}
|
||||
@if($byUser->isNotEmpty())
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">사원별 사용현황 (승인된 휴가)</h3>
|
||||
<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-left text-xs font-semibold text-gray-600">사원</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-600">부서</th>
|
||||
@foreach($typeMap as $typeLabel)
|
||||
<th class="px-3 py-3 text-center text-xs font-semibold text-gray-600">{{ $typeLabel }}</th>
|
||||
@endforeach
|
||||
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-600 bg-gray-100">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-100">
|
||||
@foreach($byUser as $userId => $userLeaves)
|
||||
@php
|
||||
$firstLeave = $userLeaves->first();
|
||||
$profile = $firstLeave?->user?->tenantProfiles?->first();
|
||||
$department = $profile?->department;
|
||||
$displayName = $profile?->display_name ?? $firstLeave?->user?->name ?? '-';
|
||||
$userTypeMap = $userLeaves->keyBy('leave_type');
|
||||
$userTotal = $userLeaves->sum('total_days');
|
||||
@endphp
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">{{ $displayName }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-700">{{ $department?->name ?? '-' }}</td>
|
||||
@foreach($typeMap as $typeKey => $typeLabel)
|
||||
<td class="px-3 py-3 whitespace-nowrap text-center text-sm text-gray-700">
|
||||
@php $val = $userTypeMap->get($typeKey)?->total_days ?? 0; @endphp
|
||||
{{ $val > 0 ? $val : '-' }}
|
||||
</td>
|
||||
@endforeach
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center text-sm font-semibold text-blue-600 bg-blue-50/50">
|
||||
{{ $userTotal }}일
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</x-table-swipe>
|
||||
</div>
|
||||
@else
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
{{ $year }}년 승인된 휴가가 없습니다.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
136
resources/views/hr/leaves/partials/table.blade.php
Normal file
136
resources/views/hr/leaves/partials/table.blade.php
Normal file
@@ -0,0 +1,136 @@
|
||||
{{-- 휴가 목록 테이블 (HTMX로 로드) --}}
|
||||
@php
|
||||
use App\Models\HR\Leave;
|
||||
@endphp
|
||||
|
||||
<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-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-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-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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-100">
|
||||
@forelse($leaves as $leave)
|
||||
@php
|
||||
$profile = $leave->user?->tenantProfiles?->first();
|
||||
$department = $profile?->department;
|
||||
$displayName = $profile?->display_name ?? $leave->user?->name ?? '-';
|
||||
$color = Leave::STATUS_COLORS[$leave->status] ?? 'gray';
|
||||
$statusLabel = Leave::STATUS_MAP[$leave->status] ?? $leave->status;
|
||||
$typeLabel = Leave::TYPE_MAP[$leave->leave_type] ?? $leave->leave_type;
|
||||
@endphp
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
{{-- 사원 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="shrink-0 w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-xs font-medium">
|
||||
{{ mb_substr($displayName, 0, 1) }}
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900">{{ $displayName }}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{{-- 부서 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700">
|
||||
{{ $department?->name ?? '-' }}
|
||||
</td>
|
||||
|
||||
{{-- 유형 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-700">
|
||||
{{ $typeLabel }}
|
||||
</td>
|
||||
|
||||
{{-- 기간 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-700">
|
||||
{{ $leave->start_date->format('m-d') }}
|
||||
@if($leave->start_date->ne($leave->end_date))
|
||||
~ {{ $leave->end_date->format('m-d') }}
|
||||
@endif
|
||||
</td>
|
||||
|
||||
{{-- 일수 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-700">
|
||||
{{ $leave->days == intval($leave->days) ? intval($leave->days) : $leave->days }}일
|
||||
</td>
|
||||
|
||||
{{-- 사유 --}}
|
||||
<td class="px-6 py-4 text-sm text-gray-500" style="max-width: 200px;">
|
||||
<span class="truncate block" title="{{ $leave->reason }}">{{ $leave->reason ?? '-' }}</span>
|
||||
@if($leave->reject_reason)
|
||||
<span class="text-xs text-red-500 block mt-0.5" title="{{ $leave->reject_reason }}">
|
||||
반려: {{ Str::limit($leave->reject_reason, 20) }}
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
|
||||
{{-- 상태 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-{{ $color }}-100 text-{{ $color }}-700">
|
||||
{{ $statusLabel }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{{-- 처리자 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
@if($leave->approver)
|
||||
{{ $leave->approver->name }}
|
||||
<span class="text-xs text-gray-400 block">{{ $leave->approved_at?->format('m-d H:i') }}</span>
|
||||
@else
|
||||
-
|
||||
@endif
|
||||
</td>
|
||||
|
||||
{{-- 액션 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
@if($leave->status === 'pending')
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<button type="button" onclick="approveLeave({{ $leave->id }})"
|
||||
class="px-2.5 py-1 text-xs font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 rounded transition-colors">
|
||||
승인
|
||||
</button>
|
||||
<button type="button" onclick="openRejectModal({{ $leave->id }})"
|
||||
class="px-2.5 py-1 text-xs font-medium text-red-700 bg-red-50 hover:bg-red-100 rounded transition-colors">
|
||||
반려
|
||||
</button>
|
||||
</div>
|
||||
@elseif($leave->status === 'approved')
|
||||
<button type="button" onclick="cancelLeave({{ $leave->id }})"
|
||||
class="px-2.5 py-1 text-xs font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded transition-colors">
|
||||
취소
|
||||
</button>
|
||||
@else
|
||||
<span class="text-xs text-gray-400">-</span>
|
||||
@endif
|
||||
</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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<p class="text-gray-500">휴가 신청 내역이 없습니다.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</x-table-swipe>
|
||||
|
||||
{{-- 페이지네이션 --}}
|
||||
@if($leaves->hasPages())
|
||||
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||
{{ $leaves->links() }}
|
||||
</div>
|
||||
@endif
|
||||
@@ -1085,3 +1085,16 @@
|
||||
Route::post('/{id}/approve', [\App\Http\Controllers\Api\Admin\HR\AttendanceRequestController::class, 'approve'])->name('approve');
|
||||
Route::post('/{id}/reject', [\App\Http\Controllers\Api\Admin\HR\AttendanceRequestController::class, 'reject'])->name('reject');
|
||||
});
|
||||
|
||||
// 휴가관리 API
|
||||
Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/hr/leaves')->name('api.admin.hr.leaves.')->group(function () {
|
||||
Route::get('/balance', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'balance'])->name('balance');
|
||||
Route::get('/balance/{userId}', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'userBalance'])->name('user-balance');
|
||||
Route::get('/stats', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'stats'])->name('stats');
|
||||
Route::get('/export', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'export'])->name('export');
|
||||
Route::get('/', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'index'])->name('index');
|
||||
Route::post('/', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'store'])->name('store');
|
||||
Route::post('/{id}/approve', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'approve'])->name('approve');
|
||||
Route::post('/{id}/reject', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'reject'])->name('reject');
|
||||
Route::post('/{id}/cancel', [\App\Http\Controllers\Api\Admin\HR\LeaveController::class, 'cancel'])->name('cancel');
|
||||
});
|
||||
|
||||
@@ -904,6 +904,11 @@
|
||||
Route::prefix('attendances')->name('attendances.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\HR\AttendanceController::class, 'index'])->name('index');
|
||||
});
|
||||
|
||||
// 휴가관리
|
||||
Route::prefix('leaves')->name('leaves.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\HR\LeaveController::class, 'index'])->name('index');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user