- 월간 캘린더 뷰 (사원별 필터, 날짜 클릭 등록, HTMX 월 이동) - 일괄 등록 (다수 사원 체크박스 선택 후 일괄 등록, upsert 처리) - 사원별 월간 요약 (상태별 카운트 + 총 근무시간 집계 테이블) - 초과근무 알림 (주 48h 경고 / 52h 위험 배너) - 근태 승인 워크플로우 (신청→승인→근태 레코드 자동 생성) - 자동 결근 처리 (매일 23:50 스케줄러, 주말 제외) - 연차 관리 연동 (휴가 등록 시 leave_balances 자동 차감) - GPS 출퇴근 UI (테이블 GPS 아이콘 + 상세 모달) - 탭 네비게이션 (목록/캘린더/요약/승인) HTMX 기반 전환
185 lines
9.7 KiB
PHP
185 lines
9.7 KiB
PHP
{{-- 근태 월간 캘린더 (HTMX로 로드) --}}
|
|
@php
|
|
use Carbon\Carbon;
|
|
use App\Models\HR\Attendance;
|
|
|
|
$year = $year ?? now()->year;
|
|
$month = $month ?? now()->month;
|
|
|
|
$firstDay = Carbon::create($year, $month, 1);
|
|
$lastDay = $firstDay->copy()->endOfMonth();
|
|
$startOfWeek = $firstDay->copy()->startOfWeek(Carbon::SUNDAY);
|
|
$endOfWeek = $lastDay->copy()->endOfWeek(Carbon::SATURDAY);
|
|
|
|
$today = Carbon::today();
|
|
$prevMonth = $firstDay->copy()->subMonth();
|
|
$nextMonth = $firstDay->copy()->addMonth();
|
|
@endphp
|
|
|
|
{{-- 캘린더 헤더 --}}
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="flex items-center gap-3">
|
|
<button type="button"
|
|
hx-get="{{ route('api.admin.hr.attendances.calendar', ['year' => $prevMonth->year, 'month' => $prevMonth->month]) }}"
|
|
hx-target="#attendance-calendar-container"
|
|
hx-swap="innerHTML"
|
|
class="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
|
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<h2 class="text-xl font-bold text-gray-800">{{ $year }}년 {{ $month }}월</h2>
|
|
|
|
<button type="button"
|
|
hx-get="{{ route('api.admin.hr.attendances.calendar', ['year' => $nextMonth->year, 'month' => $nextMonth->month]) }}"
|
|
hx-target="#attendance-calendar-container"
|
|
hx-swap="innerHTML"
|
|
class="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
|
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
|
</svg>
|
|
</button>
|
|
|
|
@if(!$today->isSameMonth($firstDay))
|
|
<button type="button"
|
|
hx-get="{{ route('api.admin.hr.attendances.calendar', ['year' => $today->year, 'month' => $today->month]) }}"
|
|
hx-target="#attendance-calendar-container"
|
|
hx-swap="innerHTML"
|
|
class="ml-2 px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 text-gray-600 rounded-lg transition-colors">
|
|
오늘
|
|
</button>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- 사원 필터 --}}
|
|
<div style="flex: 0 1 200px;">
|
|
<select id="calendarUserFilter"
|
|
onchange="filterCalendar()"
|
|
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 }}" {{ ($selectedUserId ?? '') == $emp->user_id ? 'selected' : '' }}>
|
|
{{ $emp->display_name ?? $emp->user?->name }}
|
|
</option>
|
|
@endforeach
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function filterCalendar() {
|
|
const userId = document.getElementById('calendarUserFilter').value;
|
|
htmx.ajax('GET', '{{ route("api.admin.hr.attendances.calendar") }}', {
|
|
target: '#attendance-calendar-container',
|
|
swap: 'innerHTML',
|
|
values: { year: {{ $year }}, month: {{ $month }}, user_id: userId },
|
|
});
|
|
}
|
|
</script>
|
|
|
|
{{-- 캘린더 그리드 --}}
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full border-collapse">
|
|
<thead>
|
|
<tr class="bg-gray-50">
|
|
<th class="px-2 py-3 text-center text-sm font-semibold text-red-500 border-b w-[14.28%]">일</th>
|
|
<th class="px-2 py-3 text-center text-sm font-semibold text-gray-600 border-b w-[14.28%]">월</th>
|
|
<th class="px-2 py-3 text-center text-sm font-semibold text-gray-600 border-b w-[14.28%]">화</th>
|
|
<th class="px-2 py-3 text-center text-sm font-semibold text-gray-600 border-b w-[14.28%]">수</th>
|
|
<th class="px-2 py-3 text-center text-sm font-semibold text-gray-600 border-b w-[14.28%]">목</th>
|
|
<th class="px-2 py-3 text-center text-sm font-semibold text-gray-600 border-b w-[14.28%]">금</th>
|
|
<th class="px-2 py-3 text-center text-sm font-semibold text-blue-500 border-b w-[14.28%]">토</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@php
|
|
$currentDate = $startOfWeek->copy();
|
|
@endphp
|
|
|
|
@while($currentDate <= $endOfWeek)
|
|
<tr>
|
|
@for($i = 0; $i < 7; $i++)
|
|
@php
|
|
$dateKey = $currentDate->format('Y-m-d');
|
|
$isCurrentMonth = $currentDate->month === (int)$month;
|
|
$isToday = $currentDate->isSameDay($today);
|
|
$isSunday = $currentDate->dayOfWeek === Carbon::SUNDAY;
|
|
$isSaturday = $currentDate->dayOfWeek === Carbon::SATURDAY;
|
|
$dayAttendances = $calendarData[$dateKey] ?? collect();
|
|
@endphp
|
|
|
|
<td class="border border-gray-100 align-top {{ !$isCurrentMonth ? 'bg-gray-50/50' : '' }}">
|
|
<div class="p-1" style="min-height: 6rem;">
|
|
{{-- 날짜 헤더 --}}
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="{{ !$isCurrentMonth ? 'text-gray-300' : '' }}
|
|
{{ $isCurrentMonth && $isSunday ? 'text-red-500' : '' }}
|
|
{{ $isCurrentMonth && $isSaturday ? 'text-blue-500' : '' }}
|
|
{{ $isCurrentMonth && !$isSunday && !$isSaturday ? 'text-gray-700' : '' }}
|
|
{{ $isToday ? 'bg-emerald-500 !text-white rounded-full w-6 h-6 flex items-center justify-center text-xs font-bold' : 'text-sm font-medium' }}
|
|
">
|
|
{{ $currentDate->day }}
|
|
</span>
|
|
|
|
@if($isCurrentMonth)
|
|
<button type="button" onclick="openAttendanceModal('{{ $dateKey }}')"
|
|
class="text-gray-300 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors p-0.5"
|
|
title="{{ $currentDate->format('m/d') }} 근태 등록">
|
|
<svg class="w-3.5 h-3.5" 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>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- 근태 목록 --}}
|
|
@if($dayAttendances->isNotEmpty())
|
|
<div class="space-y-0.5">
|
|
@foreach($dayAttendances as $att)
|
|
@php
|
|
$color = Attendance::STATUS_COLORS[$att->status] ?? 'gray';
|
|
$label = Attendance::STATUS_MAP[$att->status] ?? $att->status;
|
|
$profile = $att->user?->tenantProfiles?->first();
|
|
$name = $profile?->display_name ?? $att->user?->name ?? '?';
|
|
$checkIn = $att->check_in ? substr($att->check_in, 0, 5) : '';
|
|
$checkOut = $att->check_out ? substr($att->check_out, 0, 5) : '';
|
|
@endphp
|
|
<button type="button"
|
|
onclick="openEditAttendanceModal({{ $att->id }}, {{ $att->user_id }}, '{{ $att->base_date->toDateString() }}', '{{ $att->status }}', '{{ $checkIn }}', '{{ $checkOut }}', '{{ addslashes($att->remarks ?? '') }}')"
|
|
class="w-full text-left px-1.5 py-0.5 rounded text-xs cursor-pointer hover:opacity-80 transition-opacity truncate"
|
|
style="background-color: var(--{{ $color }}-50, #f0fdf4); color: var(--{{ $color }}-700, #15803d); border: 1px solid var(--{{ $color }}-200, #bbf7d0);"
|
|
title="{{ $name }} - {{ $label }} {{ $checkIn ? '('.$checkIn.'~'.$checkOut.')' : '' }}">
|
|
<span class="inline-flex items-center px-1 py-0 rounded text-[10px] font-bold bg-{{ $color }}-100 text-{{ $color }}-700">{{ $label }}</span>
|
|
<span class="text-gray-600">{{ mb_substr($name, 0, 3) }}</span>
|
|
@if($checkIn)
|
|
<span class="text-gray-400">{{ $checkIn }}</span>
|
|
@endif
|
|
</button>
|
|
@endforeach
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</td>
|
|
|
|
@php
|
|
$currentDate->addDay();
|
|
@endphp
|
|
@endfor
|
|
</tr>
|
|
@endwhile
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{{-- 범례 --}}
|
|
<div class="flex flex-wrap gap-3 mt-4 px-2">
|
|
@foreach(Attendance::STATUS_MAP as $key => $label)
|
|
@php $color = Attendance::STATUS_COLORS[$key] ?? 'gray'; @endphp
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="inline-block w-3 h-3 rounded-sm bg-{{ $color }}-400"></span>
|
|
<span class="text-xs text-gray-600">{{ $label }}</span>
|
|
</div>
|
|
@endforeach
|
|
</div>
|