Files
sam-manage/resources/views/hr/leaves/index.blade.php
김보곤 511bfa3ec5 feat: [leave] 휴가 신청 시 결재선 선택 기능 추가
- 휴가 신청 모달에 결재선 드롭다운 + 미리보기 UI 추가
- 선택된 결재선으로 결재 생성 (미선택 시 기본결재선 fallback)
- 휴가 목록에 결재진행 컬럼 추가 (원형 아이콘: ✓승인/✗반려/숫자대기/파랑현재)
- approval.steps.approver eager load 추가
2026-03-03 22:36:30 +09:00

619 lines
31 KiB
PHP

@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>
{{-- 도움말 버튼 --}}
<button type="button"
hx-get="{{ route('hr.leaves.help') }}"
hx-target="#leave-help-modal-container"
hx-swap="innerHTML"
class="ml-auto inline-flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 transition-colors"
title="휴가관리 도움말">
<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="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</button>
</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-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">
<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">결재선 <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="휴가 사유를 입력하세요..."
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="leave-help-modal-container"></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();
}
}
let balanceSort = 'hire_date';
let balanceDirection = 'asc';
function loadBalance(sort, direction) {
if (sort) balanceSort = sort;
if (direction) balanceDirection = direction;
const year = document.getElementById('balanceYear').value;
htmx.ajax('GET', '{{ route("api.admin.hr.leaves.balance") }}', {
target: '#balance-container',
swap: 'innerHTML',
values: { year: year, sort: balanceSort, direction: balanceDirection },
});
}
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 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() {
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(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if (ok && json.success) {
closeLeaveModal();
refreshTable();
showToast(json.message, 'success');
} else {
// Laravel 422 validation errors
const msg = json.message || (json.errors ? Object.values(json.errors).flat().join('\n') : '등록에 실패했습니다.');
showToast(msg, '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