feat: [leave] 휴가 신청 시 결재선 선택 기능 추가
- 휴가 신청 모달에 결재선 드롭다운 + 미리보기 UI 추가 - 선택된 결재선으로 결재 생성 (미선택 시 기본결재선 fallback) - 휴가 목록에 결재진행 컬럼 추가 (원형 아이콘: ✓승인/✗반려/숫자대기/파랑현재) - approval.steps.approver eager load 추가
This commit is contained in:
@@ -52,6 +52,7 @@ public function store(Request $request): JsonResponse
|
||||
'start_date' => 'required|date',
|
||||
'end_date' => 'required|date|after_or_equal:start_date',
|
||||
'reason' => 'nullable|string|max:1000',
|
||||
'approval_line_id' => 'nullable|integer|exists:approval_lines,id',
|
||||
]);
|
||||
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Approvals\ApprovalLine;
|
||||
use App\Models\HR\Leave;
|
||||
use App\Services\HR\LeaveService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
@@ -26,11 +27,18 @@ public function index(\Illuminate\Http\Request $request): View|Response
|
||||
$typeMap = Leave::TYPE_MAP;
|
||||
$statusMap = Leave::STATUS_MAP;
|
||||
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
$approvalLines = ApprovalLine::where('tenant_id', $tenantId)
|
||||
->orderByDesc('is_default')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'steps', 'is_default']);
|
||||
|
||||
return view('hr.leaves.index', [
|
||||
'employees' => $employees,
|
||||
'departments' => $departments,
|
||||
'typeMap' => $typeMap,
|
||||
'statusMap' => $statusMap,
|
||||
'approvalLines' => $approvalLines,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ public function getLeaves(array $filters = [], int $perPage = 20): LengthAwarePa
|
||||
'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId),
|
||||
'user.tenantProfiles.department',
|
||||
'approver',
|
||||
'approval.steps.approver',
|
||||
])
|
||||
->forTenant($tenantId);
|
||||
|
||||
@@ -109,8 +110,8 @@ public function storeLeave(array $data): Leave
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// 결재 자동 생성 + 상신
|
||||
$approval = $this->createLeaveApproval($leave, $tenantId);
|
||||
// 결재 자동 생성 + 상신 (선택된 결재선 전달)
|
||||
$approval = $this->createLeaveApproval($leave, $tenantId, $data['approval_line_id'] ?? null);
|
||||
$leave->update(['approval_id' => $approval->id]);
|
||||
|
||||
return $leave;
|
||||
@@ -632,7 +633,7 @@ public function getActiveEmployees(): \Illuminate\Database\Eloquent\Collection
|
||||
/**
|
||||
* 휴가신청 결재 자동 생성 + 상신
|
||||
*/
|
||||
private function createLeaveApproval(Leave $leave, int $tenantId): Approval
|
||||
private function createLeaveApproval(Leave $leave, int $tenantId, ?int $approvalLineId = null): Approval
|
||||
{
|
||||
$approvalService = app(ApprovalService::class);
|
||||
|
||||
@@ -646,20 +647,26 @@ private function createLeaveApproval(Leave $leave, int $tenantId): Approval
|
||||
throw new \RuntimeException('휴가신청 결재 양식이 등록되지 않았습니다.');
|
||||
}
|
||||
|
||||
// 2. 기본결재선 조회
|
||||
$defaultLine = ApprovalLine::where('tenant_id', $tenantId)
|
||||
->where('is_default', true)
|
||||
->first();
|
||||
// 2. 결재선 조회: 지정된 ID 우선, 없으면 기본결재선
|
||||
$line = null;
|
||||
if ($approvalLineId) {
|
||||
$line = ApprovalLine::where('tenant_id', $tenantId)->find($approvalLineId);
|
||||
}
|
||||
if (! $line) {
|
||||
$line = ApprovalLine::where('tenant_id', $tenantId)
|
||||
->where('is_default', true)
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $defaultLine) {
|
||||
throw new \RuntimeException('기본결재선을 먼저 설정해주세요.');
|
||||
if (! $line) {
|
||||
throw new \RuntimeException('결재선을 찾을 수 없습니다. 기본결재선을 설정하거나 결재선을 선택해주세요.');
|
||||
}
|
||||
|
||||
// 3. 결재 본문 생성
|
||||
$body = $this->buildLeaveApprovalBody($leave, $tenantId);
|
||||
|
||||
// 4. steps 변환
|
||||
$steps = collect($defaultLine->steps)->map(fn ($s) => [
|
||||
$steps = collect($line->steps)->map(fn ($s) => [
|
||||
'user_id' => $s['user_id'],
|
||||
'step_type' => $s['step_type'] ?? $s['type'] ?? 'approval',
|
||||
])->toArray();
|
||||
@@ -671,7 +678,7 @@ private function createLeaveApproval(Leave $leave, int $tenantId): Approval
|
||||
|
||||
$approval = $approvalService->createApproval([
|
||||
'form_id' => $form->id,
|
||||
'line_id' => $defaultLine->id,
|
||||
'line_id' => $line->id,
|
||||
'title' => "휴가신청 - {$userName} ({$typeName} {$period})",
|
||||
'body' => $body,
|
||||
'content' => [
|
||||
|
||||
@@ -182,7 +182,7 @@ class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:
|
||||
<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="bg-white rounded-lg shadow-xl w-full max-w-lg 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">
|
||||
@@ -231,6 +231,22 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 f
|
||||
<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">결재선 <span class="text-red-500">*</span></label>
|
||||
<select name="approval_line_id" id="leaveApprovalLine" required
|
||||
onchange="previewApprovalSteps()"
|
||||
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($approvalLines as $line)
|
||||
<option value="{{ $line->id }}" {{ $line->is_default ? 'selected' : '' }}
|
||||
data-steps='@json($line->steps)'>
|
||||
{{ $line->name }}{{ $line->is_default ? ' (기본)' : '' }} — {{ count($line->steps ?? []) }}단계
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<div id="approvalStepsPreview" class="mt-2 flex items-center gap-1 overflow-x-auto py-1"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">사유</label>
|
||||
<textarea name="reason" rows="3" maxlength="1000" placeholder="휴가 사유를 입력하세요..."
|
||||
@@ -364,11 +380,46 @@ function exportLeaves() {
|
||||
window.location.href = '{{ route("api.admin.hr.leaves.export") }}?' + params.toString();
|
||||
}
|
||||
|
||||
// ===== 결재선 미리보기 =====
|
||||
function previewApprovalSteps() {
|
||||
const select = document.getElementById('leaveApprovalLine');
|
||||
const preview = document.getElementById('approvalStepsPreview');
|
||||
if (!select || !preview) return;
|
||||
|
||||
const option = select.options[select.selectedIndex];
|
||||
if (!option || !option.dataset.steps) {
|
||||
preview.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let steps;
|
||||
try { steps = JSON.parse(option.dataset.steps); } catch { steps = []; }
|
||||
if (!steps.length) {
|
||||
preview.innerHTML = '<span class="text-xs text-gray-400">결재 단계가 없습니다.</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const typeLabels = { approval: '결재', agreement: '합의', reference: '참조' };
|
||||
const typeColors = { approval: '#2563EB', agreement: '#D97706', reference: '#6B7280' };
|
||||
const typeBgs = { approval: '#EFF6FF', agreement: '#FFFBEB', reference: '#F9FAFB' };
|
||||
|
||||
preview.innerHTML = steps.map((s, i) => {
|
||||
const type = s.step_type || s.type || 'approval';
|
||||
const color = typeColors[type] || '#6B7280';
|
||||
const bg = typeBgs[type] || '#F9FAFB';
|
||||
const name = s.user_name || '사용자 ' + s.user_id;
|
||||
const arrow = i > 0 ? '<span style="color:#D1D5DB;margin:0 2px;">→</span>' : '';
|
||||
return arrow + '<span style="display:inline-flex;align-items:center;gap:2px;padding:2px 8px;border-radius:9999px;font-size:12px;white-space:nowrap;background:' + bg + ';color:' + color + ';">'
|
||||
+ name + ' <span style="color:' + color + ';opacity:0.7;">(' + (typeLabels[type] || type) + ')</span></span>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ===== 휴가 신청 모달 =====
|
||||
function openLeaveModal() {
|
||||
document.getElementById('leaveForm').reset();
|
||||
document.getElementById('balanceInfo').classList.add('hidden');
|
||||
document.getElementById('leaveModal').classList.remove('hidden');
|
||||
previewApprovalSteps();
|
||||
}
|
||||
|
||||
function closeLeaveModal() {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<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-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>
|
||||
@@ -79,6 +80,39 @@
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{{-- 결재진행 --}}
|
||||
<td class="px-4 py-4 whitespace-nowrap">
|
||||
@if($leave->approval && $leave->approval->steps->count() > 0)
|
||||
<div class="flex items-center gap-0.5">
|
||||
@foreach($leave->approval->steps->sortBy('step_order') as $step)
|
||||
@php
|
||||
$stepColor = match($step->status) {
|
||||
'approved' => 'bg-green-500',
|
||||
'rejected' => 'bg-red-500',
|
||||
'on_hold' => 'bg-amber-500',
|
||||
default => 'bg-gray-300',
|
||||
};
|
||||
$isCurrent = $step->status === 'pending'
|
||||
&& $step->step_order == $leave->approval->current_step
|
||||
&& in_array($step->step_type, ['approval', 'agreement']);
|
||||
if ($isCurrent) $stepColor = 'bg-blue-500 ring-2 ring-blue-200';
|
||||
@endphp
|
||||
<div class="relative group">
|
||||
<div class="w-6 h-6 rounded-full {{ $stepColor }} flex items-center justify-center text-white text-xs font-medium"
|
||||
title="{{ $step->approver?->name ?? '미지정' }} ({{ match($step->step_type) { 'approval' => '결재', 'agreement' => '합의', 'reference' => '참조', default => '' } }})">
|
||||
@if($step->status === 'approved') ✓
|
||||
@elseif($step->status === 'rejected') ✗
|
||||
@else {{ $step->step_order }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<span class="text-xs text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
|
||||
{{-- 처리자 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
@if($leave->approver)
|
||||
@@ -122,7 +156,7 @@ class="px-2.5 py-1 text-xs font-medium text-red-700 bg-red-50 hover:bg-red-100 r
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="9" class="px-6 py-12 text-center">
|
||||
<td colspan="10" 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"/>
|
||||
|
||||
Reference in New Issue
Block a user