Files
sam-manage/resources/views/hr/attendances/index.blade.php
김보곤 5283487f7e feat: [attendance] 근태현황/근태관리 메뉴 분리
- 근태현황(/hr/attendances): 조회 전용 (목록/캘린더/요약)
- 근태관리(/hr/attendances/manage): CRUD + 승인 관리
- table-manage.blade.php: 관리용 테이블 (체크박스/수정/삭제)
- table.blade.php: 조회용 테이블 (GPS 포함, CRUD 제거)
- API 컨트롤러 view 파라미터로 테이블 분기
2026-02-27 09:10:43 +09:00

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