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',
|
'start_date' => 'required|date',
|
||||||
'end_date' => 'required|date|after_or_equal:start_date',
|
'end_date' => 'required|date|after_or_equal:start_date',
|
||||||
'reason' => 'nullable|string|max:1000',
|
'reason' => 'nullable|string|max:1000',
|
||||||
|
'approval_line_id' => 'nullable|integer|exists:approval_lines,id',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\HR;
|
namespace App\Http\Controllers\HR;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Approvals\ApprovalLine;
|
||||||
use App\Models\HR\Leave;
|
use App\Models\HR\Leave;
|
||||||
use App\Services\HR\LeaveService;
|
use App\Services\HR\LeaveService;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
@@ -26,11 +27,18 @@ public function index(\Illuminate\Http\Request $request): View|Response
|
|||||||
$typeMap = Leave::TYPE_MAP;
|
$typeMap = Leave::TYPE_MAP;
|
||||||
$statusMap = Leave::STATUS_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', [
|
return view('hr.leaves.index', [
|
||||||
'employees' => $employees,
|
'employees' => $employees,
|
||||||
'departments' => $departments,
|
'departments' => $departments,
|
||||||
'typeMap' => $typeMap,
|
'typeMap' => $typeMap,
|
||||||
'statusMap' => $statusMap,
|
'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' => fn ($q) => $q->where('tenant_id', $tenantId),
|
||||||
'user.tenantProfiles.department',
|
'user.tenantProfiles.department',
|
||||||
'approver',
|
'approver',
|
||||||
|
'approval.steps.approver',
|
||||||
])
|
])
|
||||||
->forTenant($tenantId);
|
->forTenant($tenantId);
|
||||||
|
|
||||||
@@ -109,8 +110,8 @@ public function storeLeave(array $data): Leave
|
|||||||
'updated_by' => auth()->id(),
|
'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]);
|
$leave->update(['approval_id' => $approval->id]);
|
||||||
|
|
||||||
return $leave;
|
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);
|
$approvalService = app(ApprovalService::class);
|
||||||
|
|
||||||
@@ -646,20 +647,26 @@ private function createLeaveApproval(Leave $leave, int $tenantId): Approval
|
|||||||
throw new \RuntimeException('휴가신청 결재 양식이 등록되지 않았습니다.');
|
throw new \RuntimeException('휴가신청 결재 양식이 등록되지 않았습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 기본결재선 조회
|
// 2. 결재선 조회: 지정된 ID 우선, 없으면 기본결재선
|
||||||
$defaultLine = ApprovalLine::where('tenant_id', $tenantId)
|
$line = null;
|
||||||
->where('is_default', true)
|
if ($approvalLineId) {
|
||||||
->first();
|
$line = ApprovalLine::where('tenant_id', $tenantId)->find($approvalLineId);
|
||||||
|
}
|
||||||
|
if (! $line) {
|
||||||
|
$line = ApprovalLine::where('tenant_id', $tenantId)
|
||||||
|
->where('is_default', true)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
if (! $defaultLine) {
|
if (! $line) {
|
||||||
throw new \RuntimeException('기본결재선을 먼저 설정해주세요.');
|
throw new \RuntimeException('결재선을 찾을 수 없습니다. 기본결재선을 설정하거나 결재선을 선택해주세요.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 결재 본문 생성
|
// 3. 결재 본문 생성
|
||||||
$body = $this->buildLeaveApprovalBody($leave, $tenantId);
|
$body = $this->buildLeaveApprovalBody($leave, $tenantId);
|
||||||
|
|
||||||
// 4. steps 변환
|
// 4. steps 변환
|
||||||
$steps = collect($defaultLine->steps)->map(fn ($s) => [
|
$steps = collect($line->steps)->map(fn ($s) => [
|
||||||
'user_id' => $s['user_id'],
|
'user_id' => $s['user_id'],
|
||||||
'step_type' => $s['step_type'] ?? $s['type'] ?? 'approval',
|
'step_type' => $s['step_type'] ?? $s['type'] ?? 'approval',
|
||||||
])->toArray();
|
])->toArray();
|
||||||
@@ -671,7 +678,7 @@ private function createLeaveApproval(Leave $leave, int $tenantId): Approval
|
|||||||
|
|
||||||
$approval = $approvalService->createApproval([
|
$approval = $approvalService->createApproval([
|
||||||
'form_id' => $form->id,
|
'form_id' => $form->id,
|
||||||
'line_id' => $defaultLine->id,
|
'line_id' => $line->id,
|
||||||
'title' => "휴가신청 - {$userName} ({$typeName} {$period})",
|
'title' => "휴가신청 - {$userName} ({$typeName} {$period})",
|
||||||
'body' => $body,
|
'body' => $body,
|
||||||
'content' => [
|
'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 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 bg-black/40" onclick="closeLeaveModal()"></div>
|
||||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
<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">
|
<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>
|
<h3 class="text-lg font-semibold text-gray-800">휴가 신청</h3>
|
||||||
<button type="button" onclick="closeLeaveModal()" class="text-gray-400 hover:text-gray-600">
|
<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>
|
<span id="balanceDisplay" class="font-semibold text-blue-800">-</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">사유</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">사유</label>
|
||||||
<textarea name="reason" rows="3" maxlength="1000" placeholder="휴가 사유를 입력하세요..."
|
<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();
|
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() {
|
function openLeaveModal() {
|
||||||
document.getElementById('leaveForm').reset();
|
document.getElementById('leaveForm').reset();
|
||||||
document.getElementById('balanceInfo').classList.add('hidden');
|
document.getElementById('balanceInfo').classList.add('hidden');
|
||||||
document.getElementById('leaveModal').classList.remove('hidden');
|
document.getElementById('leaveModal').classList.remove('hidden');
|
||||||
|
previewApprovalSteps();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeLeaveModal() {
|
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-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-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-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>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -79,6 +80,39 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</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">
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
@if($leave->approver)
|
@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>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
<tr>
|
<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">
|
<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">
|
<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"/>
|
<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