- 근태현황(/hr/attendances): 조회 전용 (목록/캘린더/요약) - 근태관리(/hr/attendances/manage): CRUD + 승인 관리 - table-manage.blade.php: 관리용 테이블 (체크박스/수정/삭제) - table.blade.php: 조회용 테이블 (GPS 포함, CRUD 제거) - API 컨트롤러 view 파라미터로 테이블 분기
321 lines
15 KiB
PHP
321 lines
15 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>
|
|
<div class="flex items-center gap-2 mt-1">
|
|
<select id="statsYear" class="px-2 py-1 border border-gray-300 rounded text-sm text-gray-600 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
|
@for($y = now()->year; $y >= now()->year - 2; $y--)
|
|
<option value="{{ $y }}" {{ $stats['year'] == $y ? 'selected' : '' }}>{{ $y }}년</option>
|
|
@endfor
|
|
</select>
|
|
<select id="statsMonth" class="px-2 py-1 border border-gray-300 rounded text-sm text-gray-600 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
|
@for($m = 1; $m <= 12; $m++)
|
|
<option value="{{ $m }}" {{ $stats['month'] == $m ? 'selected' : '' }}>{{ $m }}월</option>
|
|
@endfor
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
|
|
<button type="button" onclick="exportAttendances()"
|
|
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('calendar')" id="tab-calendar"
|
|
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('summary')" id="tab-summary"
|
|
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>
|
|
|
|
{{-- 통계 카드 (HTMX 갱신 대상) --}}
|
|
<div id="stats-container" class="mb-4">
|
|
@include('hr.attendances.partials.stats', ['stats' => $stats])
|
|
</div>
|
|
|
|
{{-- 초과근무 알림 영역 --}}
|
|
<div id="overtime-alerts-container" class="mb-4"
|
|
hx-get="{{ route('api.admin.hr.attendances.overtime-alerts') }}"
|
|
hx-trigger="load"
|
|
hx-swap="innerHTML">
|
|
</div>
|
|
|
|
{{-- 탭 콘텐츠 영역 --}}
|
|
<div id="attendances-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="attendanceFilter">
|
|
<form id="attendanceFilterForm" 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="사원 이름..."
|
|
value="{{ request('q') }}"
|
|
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 }}" {{ request('department_id') == $dept->id ? 'selected' : '' }}>
|
|
{{ $dept->name }}
|
|
</option>
|
|
@endforeach
|
|
</select>
|
|
</div>
|
|
<div style="flex: 0 1 140px;">
|
|
<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 }}" {{ request('status') === $key ? 'selected' : '' }}>{{ $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="{{ request('date_from', now()->startOfMonth()->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="{{ request('date_to', 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"
|
|
hx-get="{{ route('api.admin.hr.attendances.index') }}"
|
|
hx-target="#attendances-table"
|
|
hx-include="#attendanceFilterForm"
|
|
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="attendances-table"
|
|
hx-get="{{ route('api.admin.hr.attendances.index') }}"
|
|
hx-vals='{"date_from": "{{ now()->startOfMonth()->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-calendar" class="hidden">
|
|
<div class="bg-white rounded-lg shadow-sm p-4">
|
|
<div id="attendance-calendar-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-summary" class="hidden">
|
|
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
|
<div id="attendance-summary-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>
|
|
|
|
{{-- GPS 정보 모달 --}}
|
|
<div id="gpsModal" class="fixed inset-0 z-50 hidden">
|
|
<div class="fixed inset-0 bg-black/40" onclick="closeGpsModal()"></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">GPS 출퇴근 정보</h3>
|
|
<button type="button" onclick="closeGpsModal()" 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>
|
|
<div class="px-6 py-4 space-y-3">
|
|
<div id="gpsDetailContent" class="text-sm text-gray-700">
|
|
GPS 데이터가 없습니다.
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center justify-end px-6 py-4 border-t border-gray-200">
|
|
<button type="button" onclick="closeGpsModal()"
|
|
class="px-4 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
|
|
닫기
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script>
|
|
// ===== 현재 탭 상태 =====
|
|
let currentTab = 'list';
|
|
const tabLoaded = { list: true, calendar: false, summary: 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;
|
|
loadTabData(tab);
|
|
}
|
|
}
|
|
|
|
function loadTabData(tab) {
|
|
const year = document.getElementById('statsYear').value;
|
|
const month = document.getElementById('statsMonth').value;
|
|
|
|
if (tab === 'calendar') {
|
|
htmx.ajax('GET', '{{ route("api.admin.hr.attendances.calendar") }}', {
|
|
target: '#attendance-calendar-container',
|
|
swap: 'innerHTML',
|
|
values: { year: year, month: month },
|
|
});
|
|
} else if (tab === 'summary') {
|
|
htmx.ajax('GET', '{{ route("api.admin.hr.attendances.summary") }}', {
|
|
target: '#attendance-summary-container',
|
|
swap: 'innerHTML',
|
|
values: { year: year, month: month },
|
|
});
|
|
}
|
|
}
|
|
|
|
// ===== 통계 기간 선택 =====
|
|
document.getElementById('statsYear').addEventListener('change', onPeriodChange);
|
|
document.getElementById('statsMonth').addEventListener('change', onPeriodChange);
|
|
|
|
function onPeriodChange() {
|
|
refreshStats();
|
|
if (tabLoaded.calendar) {
|
|
tabLoaded.calendar = false;
|
|
if (currentTab === 'calendar') {
|
|
tabLoaded.calendar = true;
|
|
loadTabData('calendar');
|
|
}
|
|
}
|
|
if (tabLoaded.summary) {
|
|
tabLoaded.summary = false;
|
|
if (currentTab === 'summary') {
|
|
tabLoaded.summary = true;
|
|
loadTabData('summary');
|
|
}
|
|
}
|
|
}
|
|
|
|
function refreshStats() {
|
|
htmx.ajax('GET', '{{ route("api.admin.hr.attendances.stats") }}', {
|
|
target: '#stats-container',
|
|
swap: 'innerHTML',
|
|
values: {
|
|
year: document.getElementById('statsYear').value,
|
|
month: document.getElementById('statsMonth').value,
|
|
},
|
|
});
|
|
}
|
|
|
|
// ===== 엑셀 다운로드 =====
|
|
function exportAttendances() {
|
|
const params = new URLSearchParams(getFilterValues());
|
|
window.location.href = '{{ route("api.admin.hr.attendances.export") }}?' + params.toString();
|
|
}
|
|
|
|
// ===== 필터 =====
|
|
document.getElementById('attendanceFilterForm')?.addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
refreshTable();
|
|
});
|
|
|
|
function refreshTable() {
|
|
htmx.ajax('GET', '{{ route("api.admin.hr.attendances.index") }}', {
|
|
target: '#attendances-table',
|
|
swap: 'innerHTML',
|
|
values: getFilterValues(),
|
|
});
|
|
}
|
|
|
|
function getFilterValues() {
|
|
const form = document.getElementById('attendanceFilterForm');
|
|
const formData = new FormData(form);
|
|
const values = {};
|
|
for (const [key, value] of formData.entries()) {
|
|
if (value) values[key] = value;
|
|
}
|
|
return values;
|
|
}
|
|
|
|
// ===== GPS 모달 =====
|
|
function openGpsModal(gpsData) {
|
|
const content = document.getElementById('gpsDetailContent');
|
|
if (gpsData && gpsData.check_in_location) {
|
|
let html = '<div class="space-y-2">';
|
|
if (gpsData.check_in_location) {
|
|
html += '<div><span class="font-medium">출근 위치:</span> ' + (gpsData.check_in_location.address || gpsData.check_in_location.lat + ', ' + gpsData.check_in_location.lng) + '</div>';
|
|
}
|
|
if (gpsData.check_out_location) {
|
|
html += '<div><span class="font-medium">퇴근 위치:</span> ' + (gpsData.check_out_location.address || gpsData.check_out_location.lat + ', ' + gpsData.check_out_location.lng) + '</div>';
|
|
}
|
|
if (gpsData.is_auto_checked) {
|
|
html += '<div class="text-xs text-emerald-600">자동 출퇴근 처리됨</div>';
|
|
}
|
|
html += '</div>';
|
|
content.innerHTML = html;
|
|
} else {
|
|
content.textContent = 'GPS 데이터가 없습니다.';
|
|
}
|
|
document.getElementById('gpsModal').classList.remove('hidden');
|
|
}
|
|
|
|
function closeGpsModal() {
|
|
document.getElementById('gpsModal').classList.add('hidden');
|
|
}
|
|
</script>
|
|
@endpush
|