feat: [leave] 휴가 신청 시 결재선 선택 기능 추가

- 휴가 신청 모달에 결재선 드롭다운 + 미리보기 UI 추가
- 선택된 결재선으로 결재 생성 (미선택 시 기본결재선 fallback)
- 휴가 목록에 결재진행 컬럼 추가 (원형 아이콘: ✓승인/✗반려/숫자대기/파랑현재)
- approval.steps.approver eager load 추가
This commit is contained in:
김보곤
2026-03-03 22:36:05 +09:00
parent 81b64f25aa
commit 511bfa3ec5
5 changed files with 114 additions and 13 deletions

View File

@@ -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 {

View File

@@ -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,
]);
}

View File

@@ -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' => [

View File

@@ -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() {

View File

@@ -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"/>