feat: [approval] 기안함 휴가신청 → 휴가관리 연동

- 기안함에서 휴가/근태신청/사유서 양식 선택 시 전용 입력 폼 표시
- 양식코드별 유형 필터링 (leave/attendance_request/reason_report)
- saveApproval()에서 content에 구조화된 데이터 포함
- handleApprovalCompleted()에서 Leave 없을 시 자동 생성
- createLeaveFromApproval() 메서드 추가
This commit is contained in:
김보곤
2026-03-05 15:57:36 +09:00
parent 7b9c101065
commit a63b501964
4 changed files with 230 additions and 6 deletions

View File

@@ -90,6 +90,11 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-
<div id="quill-container" style="display: none; min-height: 300px;"></div>
</div>
{{-- 휴가/근태신청/사유서 전용 --}}
@include('approvals.partials._leave-form', [
'employees' => $employees ?? collect(),
])
{{-- 지출결의서 전용 --}}
@include('approvals.partials._expense-form', [
'initialData' => [],
@@ -211,6 +216,84 @@ class="p-1 text-gray-400 hover:text-gray-600 transition">
const formCodes = @json($forms->pluck('code', 'id'));
const linesData = @json($lines);
let isExpenseForm = false;
let isLeaveForm = false;
// 양식코드별 표시할 유형 목록
const leaveTypesByFormCode = {
leave: [
{ value: 'annual', label: '연차' },
{ value: 'half_am', label: '오전반차' },
{ value: 'half_pm', label: '오후반차' },
{ value: 'sick', label: '병가' },
{ value: 'family', label: '경조사' },
{ value: 'maternity', label: '출산' },
{ value: 'parental', label: '육아' },
],
attendance_request: [
{ value: 'business_trip', label: '출장' },
{ value: 'remote', label: '재택근무' },
{ value: 'field_work', label: '외근' },
{ value: 'early_leave', label: '조퇴' },
],
reason_report: [
{ value: 'late_reason', label: '지각사유서' },
{ value: 'absent_reason', label: '결근사유서' },
],
};
function populateLeaveTypes(formCode) {
const select = document.getElementById('leave-type');
select.innerHTML = '';
const types = leaveTypesByFormCode[formCode] || [];
types.forEach(t => {
const opt = document.createElement('option');
opt.value = t.value;
opt.textContent = t.label;
select.appendChild(opt);
});
}
function getLeaveFormData() {
return {
user_id: parseInt(document.getElementById('leave-user-id').value),
leave_type: document.getElementById('leave-type').value,
start_date: document.getElementById('leave-start-date').value,
end_date: document.getElementById('leave-end-date').value,
reason: document.getElementById('leave-reason').value.trim() || null,
};
}
function buildLeaveBody(data) {
const typeSelect = document.getElementById('leave-type');
const userSelect = document.getElementById('leave-user-id');
const typeName = typeSelect.options[typeSelect.selectedIndex]?.text || data.leave_type;
const userName = userSelect.options[userSelect.selectedIndex]?.text || '';
const rows = [
['신청자', userName],
['유형', typeName],
];
if (data.start_date === data.end_date) {
rows.push(['대상일', data.start_date]);
} else {
rows.push(['기간', data.start_date + ' ~ ' + data.end_date]);
}
if (data.reason) {
rows.push(['사유', data.reason]);
}
let html = '<p>아래와 같이 신청합니다.</p>';
html += '<table style="border-collapse:collapse; width:100%; margin-top:12px; font-size:14px;">';
rows.forEach(([label, value]) => {
html += '<tr><th style="padding:8px 12px; background:#f8f9fa; border:1px solid #dee2e6; text-align:left; width:120px; font-weight:600;">'
+ escapeHtml(label) + '</th><td style="padding:8px 12px; border:1px solid #dee2e6;">'
+ escapeHtml(value) + '</td></tr>';
});
html += '</table>';
return html;
}
function escapeHtml(str) {
if (!str) return '';
@@ -375,17 +458,39 @@ function applyQuickLine(lineId) {
function switchFormMode(formId) {
const code = formCodes[formId];
const expenseContainer = document.getElementById('expense-form-container');
const leaveContainer = document.getElementById('leave-form-container');
const bodyArea = document.getElementById('body-area');
const expenseLoadBtn = document.getElementById('expense-load-btn');
const leaveFormCodes = ['leave', 'attendance_request', 'reason_report'];
if (code === 'expense') {
isExpenseForm = true;
isLeaveForm = false;
expenseContainer.style.display = '';
leaveContainer.style.display = 'none';
expenseLoadBtn.style.display = '';
bodyArea.style.display = 'none';
} else if (leaveFormCodes.includes(code)) {
isExpenseForm = false;
isLeaveForm = true;
expenseContainer.style.display = 'none';
leaveContainer.style.display = '';
expenseLoadBtn.style.display = 'none';
bodyArea.style.display = 'none';
populateLeaveTypes(code);
// 시작일/종료일 기본값: 오늘
const today = new Date().toISOString().slice(0, 10);
const startEl = document.getElementById('leave-start-date');
const endEl = document.getElementById('leave-end-date');
if (!startEl.value) startEl.value = today;
if (!endEl.value) endEl.value = today;
} else {
isExpenseForm = false;
isLeaveForm = false;
expenseContainer.style.display = 'none';
leaveContainer.style.display = 'none';
expenseLoadBtn.style.display = 'none';
bodyArea.style.display = '';
}
@@ -396,7 +501,7 @@ function applyBodyTemplate(formId) {
switchFormMode(formId);
// 전용 폼이면 제목만 자동 설정하고 body template 적용 건너뜀
if (isExpenseForm) {
if (isExpenseForm || isLeaveForm) {
const titleEl = document.getElementById('title');
if (!titleEl.value.trim()) {
const formSelect = document.getElementById('form_id');
@@ -474,21 +579,42 @@ function applyBodyTemplate(formId) {
return;
}
let expenseContent = {};
let formContent = {};
let formBody = getBodyContent();
let attachmentFileIds = [];
if (isExpenseForm) {
const expenseEl = document.getElementById('expense-form-container');
if (expenseEl && expenseEl._x_dataStack) {
expenseContent = expenseEl._x_dataStack[0].getFormData();
formContent = expenseEl._x_dataStack[0].getFormData();
attachmentFileIds = expenseEl._x_dataStack[0].getFileIds();
}
formBody = null;
} else if (isLeaveForm) {
const leaveData = getLeaveFormData();
if (!leaveData.leave_type) {
showToast('유형을 선택해주세요.', 'warning');
return;
}
if (!leaveData.start_date || !leaveData.end_date) {
showToast('시작일과 종료일을 입력해주세요.', 'warning');
return;
}
if (leaveData.start_date > leaveData.end_date) {
showToast('종료일이 시작일보다 이전입니다.', 'warning');
return;
}
formContent = leaveData;
formBody = buildLeaveBody(leaveData);
}
const payload = {
form_id: document.getElementById('form_id').value,
title: title,
body: isExpenseForm ? null : getBodyContent(),
content: isExpenseForm ? expenseContent : {},
body: formBody,
content: formContent,
attachment_file_ids: attachmentFileIds,
is_urgent: document.getElementById('is_urgent').checked,
steps: steps,

View File

@@ -0,0 +1,57 @@
{{--
휴가/근태신청/사유서 전용
Props:
$employees (Collection) - 활성 사원 목록
--}}
@php
$employees = $employees ?? collect();
$currentUserId = auth()->id();
@endphp
<div id="leave-form-container" style="display: none;" class="mb-4">
<div class="space-y-4">
{{-- 신청자 --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">신청자 <span class="text-red-500">*</span></label>
<select id="leave-user-id"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
style="max-width: 300px;">
@foreach($employees as $emp)
<option value="{{ $emp->user_id }}" {{ $emp->user_id == $currentUserId ? 'selected' : '' }}>
{{ $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 id="leave-type"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
style="max-width: 300px;">
</select>
</div>
{{-- 시작일 / 종료일 --}}
<div class="flex gap-4 flex-wrap">
<div style="flex: 1 1 200px; max-width: 300px;">
<label class="block text-sm font-medium text-gray-700 mb-1">시작일 <span class="text-red-500">*</span></label>
<input type="date" id="leave-start-date"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div style="flex: 1 1 200px; max-width: 300px;">
<label class="block text-sm font-medium text-gray-700 mb-1">종료일 <span class="text-red-500">*</span></label>
<input type="date" id="leave-end-date"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
{{-- 사유 --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">사유</label>
<textarea id="leave-reason" rows="3" placeholder="사유를 입력하세요..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
</div>
</div>
</div>