Files
sam-manage/resources/views/hr/attendance-integrated/index.blade.php
김보곤 be35f7ba49 feat: [hr] 연차잔여 탭에 재직상태 필터 추가 (전체/재직자/퇴직자)
- 필터 기본값: 재직자 (active + leave)
- 퇴직자 선택 시 resigned만 표시
- 전체 선택 시 모든 상태 표시
2026-03-05 15:16:54 +09:00

493 lines
23 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">
{{-- 신규 신청 드롭다운 --}}
<div class="relative" id="new-request-dropdown">
<button type="button" onclick="toggleDropdown()"
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>
신규 신청
<svg class="w-3 h-3 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="dropdown-menu" class="hidden absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50">
<div class="py-1">
<div class="px-3 py-1.5 text-xs font-semibold text-gray-400 uppercase">휴가</div>
<button onclick="openLeaveModal('annual')" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">연차</button>
<button onclick="openLeaveModal('half_am')" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">오전반차</button>
<button onclick="openLeaveModal('half_pm')" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">오후반차</button>
<button onclick="openLeaveModal('sick')" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">병가</button>
<button onclick="openLeaveModal('family')" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">경조사</button>
<div class="border-t border-gray-100 my-1"></div>
<div class="px-3 py-1.5 text-xs font-semibold text-gray-400 uppercase">근태신청</div>
<button onclick="openLeaveModal('business_trip')" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">출장</button>
<button onclick="openLeaveModal('remote')" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">재택근무</button>
<button onclick="openLeaveModal('field_work')" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">외근</button>
<button onclick="openLeaveModal('early_leave')" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">조퇴</button>
<div class="border-t border-gray-100 my-1"></div>
<div class="px-3 py-1.5 text-xs font-semibold text-gray-400 uppercase">사유서</div>
<button onclick="openLeaveModal('late_reason')" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">지각 사유서</button>
<button onclick="openLeaveModal('absent_reason')" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">결근 사유서</button>
</div>
</div>
</div>
</div>
</div>
{{-- 네비게이션 --}}
<div class="flex items-center gap-1 mb-4 border-b border-gray-200">
<button type="button" onclick="switchTab('attendance')" id="tab-attendance"
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('requests')" id="tab-requests"
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('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>
</div>
{{-- 콘텐츠 영역 --}}
<div id="integrated-content">
{{-- 1: 근태현황 --}}
<div id="content-attendance">
@include('hr.attendance-integrated.partials.tab-attendance')
</div>
{{-- 2: 신청/결재 (lazy load) --}}
<div id="content-requests" class="hidden">
{{-- 필터 --}}
<div class="bg-white rounded-lg border border-gray-200 p-4 mb-4">
<div class="flex flex-wrap items-end gap-3">
<div style="flex: 1 1 180px; max-width: 220px;">
<label class="block text-xs font-medium text-gray-600 mb-1">이름검색</label>
<input type="text" id="req-search" placeholder="이름 검색..."
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
onkeydown="if(event.key==='Enter') loadRequests()">
</div>
<div style="flex: 0 0 130px;">
<label class="block text-xs font-medium text-gray-600 mb-1">유형</label>
<select id="req-type" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" onchange="loadRequests()">
<option value="">전체</option>
@foreach($leaveTypeMap as $code => $label)
<option value="{{ $code }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div style="flex: 0 0 130px;">
<label class="block text-xs font-medium text-gray-600 mb-1">상태</label>
<select id="req-status" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" onchange="loadRequests()">
<option value="">전체</option>
@foreach($leaveStatusMap as $code => $label)
<option value="{{ $code }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div style="flex: 0 0 150px;">
<label class="block text-xs font-medium text-gray-600 mb-1">시작일</label>
<input type="date" id="req-date-from" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" onchange="loadRequests()">
</div>
<div style="flex: 0 0 150px;">
<label class="block text-xs font-medium text-gray-600 mb-1">종료일</label>
<input type="date" id="req-date-to" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" onchange="loadRequests()">
</div>
<div class="shrink-0">
<button onclick="loadRequests()" class="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm font-medium rounded-lg transition-colors">
검색
</button>
</div>
</div>
</div>
{{-- 신청 목록 테이블 --}}
<div id="requests-table-container">
<div class="flex items-center justify-center py-12 text-gray-400">
<svg class="w-5 h-5 animate-spin mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
불러오는 ...
</div>
</div>
</div>
{{-- 3: 연차잔여 (lazy load) --}}
<div id="content-balance" class="hidden">
{{-- 필터 --}}
<div class="bg-white rounded-lg border border-gray-200 p-4 mb-4">
<div class="flex flex-wrap items-end gap-3">
<div style="flex: 0 0 130px;">
<label class="block text-xs font-medium text-gray-600 mb-1">연도</label>
<select id="balance-year" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" onchange="loadBalance()">
@for($y = now()->year; $y >= now()->year - 2; $y--)
<option value="{{ $y }}" @if($y === now()->year) selected @endif>{{ $y }}</option>
@endfor
</select>
</div>
<div style="flex: 0 0 130px;">
<label class="block text-xs font-medium text-gray-600 mb-1">재직상태</label>
<select id="balance-emp-status" class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" onchange="loadBalance()">
<option value="">전체</option>
<option value="active" selected>재직자</option>
<option value="resigned">퇴직자</option>
</select>
</div>
<div class="shrink-0">
<button onclick="loadBalance()" class="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm font-medium rounded-lg transition-colors">
조회
</button>
</div>
</div>
</div>
{{-- 잔여연차 테이블 --}}
<div id="balance-table-container">
<div class="flex items-center justify-center py-12 text-gray-400">
<svg class="w-5 h-5 animate-spin mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
불러오는 ...
</div>
</div>
</div>
</div>
</div>
{{-- 신청 모달 --}}
@include('hr.attendance-integrated.partials.modal-leave')
@endsection
@push('scripts')
<script>
// =========================================================================
// 탭 전환
// =========================================================================
let currentTab = 'attendance';
const tabLoaded = { attendance: true, requests: false, balance: false };
function switchTab(tab) {
// 이전 탭 숨기기
document.getElementById('content-' + currentTab).classList.add('hidden');
document.getElementById('tab-' + currentTab).classList.remove('border-blue-600', 'text-blue-600');
document.getElementById('tab-' + currentTab).classList.add('border-transparent', 'text-gray-500');
// 새 탭 보이기
currentTab = tab;
document.getElementById('content-' + tab).classList.remove('hidden');
document.getElementById('tab-' + tab).classList.add('border-blue-600', 'text-blue-600');
document.getElementById('tab-' + tab).classList.remove('border-transparent', 'text-gray-500');
// lazy load
if (!tabLoaded[tab]) {
tabLoaded[tab] = true;
if (tab === 'requests') loadRequests();
if (tab === 'balance') loadBalance();
}
}
// =========================================================================
// 근태현황 탭
// =========================================================================
function loadAttendances(page) {
const params = new URLSearchParams();
const q = document.getElementById('att-search')?.value;
const dept = document.getElementById('att-department')?.value;
const status = document.getElementById('att-status')?.value;
const from = document.getElementById('att-date-from')?.value;
const to = document.getElementById('att-date-to')?.value;
if (q) params.set('q', q);
if (dept) params.set('department_id', dept);
if (status) params.set('status', status);
if (from) params.set('date_from', from);
if (to) params.set('date_to', to);
if (page) params.set('page', page);
htmx.ajax('GET', '/api/admin/hr/attendances?' + params.toString(), {
target: '#attendance-table-container',
swap: 'innerHTML'
});
}
function loadAttendanceStats(year, month) {
htmx.ajax('GET', '/api/admin/hr/attendances/stats?year=' + year + '&month=' + month, {
target: '#attendance-stats-container',
swap: 'innerHTML'
});
}
// =========================================================================
// 신청/결재 탭
// =========================================================================
function loadRequests(page) {
const params = new URLSearchParams();
const q = document.getElementById('req-search')?.value;
const type = document.getElementById('req-type')?.value;
const status = document.getElementById('req-status')?.value;
const from = document.getElementById('req-date-from')?.value;
const to = document.getElementById('req-date-to')?.value;
if (q) params.set('q', q);
if (type) params.set('leave_type', type);
if (status) params.set('status', status);
if (from) params.set('date_from', from);
if (to) params.set('date_to', to);
if (page) params.set('page', page);
htmx.ajax('GET', '/api/admin/hr/leaves?' + params.toString(), {
target: '#requests-table-container',
swap: 'innerHTML'
});
}
// =========================================================================
// 연차잔여 탭
// =========================================================================
function loadBalance(sort, direction) {
const year = document.getElementById('balance-year')?.value || new Date().getFullYear();
const empStatus = document.getElementById('balance-emp-status')?.value || '';
const params = new URLSearchParams({ year });
if (empStatus) params.set('emp_status', empStatus);
if (sort) params.set('sort', sort);
if (direction) params.set('direction', direction);
htmx.ajax('GET', '/api/admin/hr/leaves/balance?' + params.toString(), {
target: '#balance-table-container',
swap: 'innerHTML'
});
}
// =========================================================================
// 드롭다운
// =========================================================================
function toggleDropdown() {
document.getElementById('dropdown-menu').classList.toggle('hidden');
}
document.addEventListener('click', function(e) {
const dd = document.getElementById('new-request-dropdown');
if (dd && !dd.contains(e.target)) {
document.getElementById('dropdown-menu')?.classList.add('hidden');
}
});
// =========================================================================
// 신청 모달
// =========================================================================
const leaveTypeMap = @json($leaveTypeMap);
function openLeaveModal(type) {
document.getElementById('dropdown-menu')?.classList.add('hidden');
const modal = document.getElementById('leave-modal');
const form = document.getElementById('leave-form');
form.reset();
// 유형 설정
document.getElementById('modal-leave-type').value = type;
document.getElementById('modal-type-label').textContent = leaveTypeMap[type] || type;
// 사유서: end_date를 start_date와 동일하게, 기간 표시 숨기기
const isReasonReport = ['late_reason', 'absent_reason'].includes(type);
const isHalfDay = ['half_am', 'half_pm'].includes(type);
document.getElementById('end-date-row').style.display = isReasonReport || isHalfDay ? 'none' : '';
document.getElementById('days-info').style.display = isReasonReport ? 'none' : '';
// 날짜 기본값 설정
const today = new Date().toISOString().split('T')[0];
document.getElementById('modal-start-date').value = today;
if (!isReasonReport && !isHalfDay) {
document.getElementById('modal-end-date').value = today;
}
// 잔여연차 표시 (연차 차감 대상만)
const deductibleTypes = ['annual', 'half_am', 'half_pm'];
document.getElementById('balance-info-row').style.display = deductibleTypes.includes(type) ? '' : 'none';
modal.classList.remove('hidden');
}
function closeLeaveModal() {
document.getElementById('leave-modal').classList.add('hidden');
}
function onUserChange() {
const userId = document.getElementById('modal-user-id').value;
if (!userId) return;
// 잔여연차 조회
fetch('/api/admin/hr/leaves/balance/' + userId)
.then(r => r.json())
.then(data => {
if (data.success && data.data) {
document.getElementById('balance-remaining').textContent =
data.data.remaining_days + '일 (부여: ' + data.data.total_days + ' / 사용: ' + data.data.used_days + ')';
} else {
document.getElementById('balance-remaining').textContent = '정보 없음';
}
});
}
function submitLeave() {
const type = document.getElementById('modal-leave-type').value;
const isReasonReport = ['late_reason', 'absent_reason'].includes(type);
const isHalfDay = ['half_am', 'half_pm'].includes(type);
const startDate = document.getElementById('modal-start-date').value;
let endDate = startDate;
if (!isReasonReport && !isHalfDay) {
endDate = document.getElementById('modal-end-date').value || startDate;
}
const body = {
user_id: document.getElementById('modal-user-id').value,
leave_type: type,
start_date: startDate,
end_date: endDate,
reason: document.getElementById('modal-reason').value || null,
approval_line_id: document.getElementById('modal-approval-line')?.value || null,
};
fetch('/api/admin/hr/leaves', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify(body),
})
.then(r => r.json())
.then(data => {
if (data.success) {
closeLeaveModal();
showToast(data.message, 'success');
// 신청/결재 탭 새로고침
if (tabLoaded.requests) loadRequests();
if (tabLoaded.balance) loadBalance();
} else {
showToast(data.message || '등록 실패', 'error');
}
})
.catch(() => showToast('등록 중 오류 발생', 'error'));
}
function cancelLeave(id) {
if (!confirm('신청을 취소하시겠습니까? 승인된 건은 연차가 복원됩니다.')) return;
fetch('/api/admin/hr/leaves/' + id + '/cancel', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
})
.then(r => r.json())
.then(data => {
if (data.success) {
showToast(data.message, 'success');
loadRequests();
if (tabLoaded.balance) loadBalance();
} else {
showToast(data.message || '취소 실패', 'error');
}
});
}
function deleteLeave(id) {
if (!confirm('신청을 삭제하시겠습니까?')) return;
fetch('/api/admin/hr/leaves/' + id, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
})
.then(r => r.json())
.then(data => {
if (data.success) {
showToast(data.message, 'success');
loadRequests();
if (tabLoaded.balance) loadBalance();
} else {
showToast(data.message || '삭제 실패', 'error');
}
});
}
function forceDeleteLeave(id) {
if (!confirm('영구 삭제하시겠습니까?\n이 작업은 되돌릴 수 없으며, 연결된 결재 기록도 함께 삭제됩니다.')) return;
fetch('/api/admin/hr/leaves/' + id + '?force=1', {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
})
.then(r => r.json())
.then(data => {
if (data.success) {
showToast(data.message, 'success');
loadRequests();
if (tabLoaded.balance) loadBalance();
} else {
showToast(data.message || '영구 삭제 실패', 'error');
}
});
}
function deleteAttendance(id, force) {
const msg = force
? '영구 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.'
: '근태 기록을 삭제하시겠습니까?';
if (!confirm(msg)) return;
const url = '/api/admin/hr/attendances/' + id + (force ? '?force=1' : '');
fetch(url, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
})
.then(r => r.json())
.then(data => {
if (data.success) {
showToast(data.message, 'success');
loadAttendances();
} else {
showToast(data.message || '삭제 실패', 'error');
}
});
}
// =========================================================================
// 토스트
// =========================================================================
function showToast(message, type) {
const toast = document.createElement('div');
toast.className = 'fixed top-4 right-4 z-[9999] px-4 py-3 rounded-lg shadow-lg text-sm font-medium transition-all ' +
(type === 'success' ? 'bg-emerald-500 text-white' : 'bg-red-500 text-white');
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
// =========================================================================
// 초기 로드
// =========================================================================
document.addEventListener('DOMContentLoaded', function() {
loadAttendances();
});
</script>
@endpush