feat: [attendance] 근태현황/근태관리 메뉴 분리
- 근태현황(/hr/attendances): 조회 전용 (목록/캘린더/요약) - 근태관리(/hr/attendances/manage): CRUD + 승인 관리 - table-manage.blade.php: 관리용 테이블 (체크박스/수정/삭제) - table.blade.php: 조회용 테이블 (GPS 포함, CRUD 제거) - API 컨트롤러 view 파라미터로 테이블 분기
This commit is contained in:
@@ -27,7 +27,11 @@ public function index(Request $request): JsonResponse|Response
|
|||||||
);
|
);
|
||||||
|
|
||||||
if ($request->header('HX-Request')) {
|
if ($request->header('HX-Request')) {
|
||||||
return response(view('hr.attendances.partials.table', compact('attendances')));
|
$viewName = $request->input('view') === 'manage'
|
||||||
|
? 'hr.attendances.partials.table-manage'
|
||||||
|
: 'hr.attendances.partials.table';
|
||||||
|
|
||||||
|
return response(view($viewName, compact('attendances')));
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
use App\Models\HR\Attendance;
|
use App\Models\HR\Attendance;
|
||||||
use App\Services\HR\AttendanceService;
|
use App\Services\HR\AttendanceService;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
class AttendanceController extends Controller
|
class AttendanceController extends Controller
|
||||||
{
|
{
|
||||||
@@ -14,17 +16,35 @@ public function __construct(
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 근태현황 목록 페이지
|
* 근태현황 목록 페이지 (조회 전용)
|
||||||
*/
|
*/
|
||||||
public function index(): View
|
public function index(): View
|
||||||
{
|
{
|
||||||
$stats = $this->attendanceService->getMonthlyStats();
|
$stats = $this->attendanceService->getMonthlyStats();
|
||||||
$departments = $this->attendanceService->getDepartments();
|
$departments = $this->attendanceService->getDepartments();
|
||||||
$employees = $this->attendanceService->getActiveEmployees();
|
|
||||||
$statusMap = Attendance::STATUS_MAP;
|
$statusMap = Attendance::STATUS_MAP;
|
||||||
|
|
||||||
return view('hr.attendances.index', [
|
return view('hr.attendances.index', [
|
||||||
'stats' => $stats,
|
'stats' => $stats,
|
||||||
|
'departments' => $departments,
|
||||||
|
'statusMap' => $statusMap,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 근태관리 페이지 (등록/수정/삭제/승인)
|
||||||
|
*/
|
||||||
|
public function manage(Request $request): View|Response
|
||||||
|
{
|
||||||
|
if ($request->header('HX-Request')) {
|
||||||
|
return response('', 200)->header('HX-Redirect', route('hr.attendances.manage'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$departments = $this->attendanceService->getDepartments();
|
||||||
|
$employees = $this->attendanceService->getActiveEmployees();
|
||||||
|
$statusMap = Attendance::STATUS_MAP;
|
||||||
|
|
||||||
|
return view('hr.attendances.manage', [
|
||||||
'departments' => $departments,
|
'departments' => $departments,
|
||||||
'employees' => $employees,
|
'employees' => $employees,
|
||||||
'statusMap' => $statusMap,
|
'statusMap' => $statusMap,
|
||||||
|
|||||||
@@ -22,13 +22,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
|
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||||
<button type="button" onclick="openBulkModal()"
|
|
||||||
class="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-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="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
|
||||||
</svg>
|
|
||||||
일괄 등록
|
|
||||||
</button>
|
|
||||||
<button type="button" onclick="exportAttendances()"
|
<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">
|
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">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -36,13 +29,6 @@ class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-
|
|||||||
</svg>
|
</svg>
|
||||||
엑셀 다운로드
|
엑셀 다운로드
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onclick="openAttendanceModal()"
|
|
||||||
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>
|
|
||||||
근태 등록
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -60,10 +46,6 @@ class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-trans
|
|||||||
class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-transparent text-gray-500 hover:text-gray-700">
|
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>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- 통계 카드 (HTMX 갱신 대상) --}}
|
{{-- 통계 카드 (HTMX 갱신 대상) --}}
|
||||||
@@ -83,7 +65,7 @@ class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-trans
|
|||||||
{{-- 목록 탭 --}}
|
{{-- 목록 탭 --}}
|
||||||
<div id="content-list">
|
<div id="content-list">
|
||||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||||
{{-- 필터 + 일괄 삭제 --}}
|
{{-- 필터 --}}
|
||||||
<div class="px-6 py-4 border-b border-gray-200">
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
<x-filter-collapsible id="attendanceFilter">
|
<x-filter-collapsible id="attendanceFilter">
|
||||||
<form id="attendanceFilterForm" class="flex flex-wrap gap-3 items-end">
|
<form id="attendanceFilterForm" class="flex flex-wrap gap-3 items-end">
|
||||||
@@ -127,7 +109,7 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 f
|
|||||||
value="{{ request('date_to', now()->toDateString()) }}"
|
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">
|
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>
|
||||||
<div class="shrink-0 flex items-center gap-2">
|
<div class="shrink-0">
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
hx-get="{{ route('api.admin.hr.attendances.index') }}"
|
hx-get="{{ route('api.admin.hr.attendances.index') }}"
|
||||||
hx-target="#attendances-table"
|
hx-target="#attendances-table"
|
||||||
@@ -135,9 +117,6 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 f
|
|||||||
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm rounded-lg transition-colors">
|
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm rounded-lg transition-colors">
|
||||||
검색
|
검색
|
||||||
</button>
|
</button>
|
||||||
<button type="button" id="bulkDeleteBtn" onclick="bulkDeleteAttendances()" class="hidden px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition-colors">
|
|
||||||
선택 삭제 (<span id="bulkDeleteCount">0</span>건)
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</x-filter-collapsible>
|
</x-filter-collapsible>
|
||||||
@@ -178,262 +157,10 @@ class="min-h-[200px]">
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- 승인 탭 --}}
|
|
||||||
<div id="content-requests" class="hidden">
|
|
||||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
|
||||||
<div id="attendance-requests-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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- 근태 등록/수정 모달 --}}
|
{{-- GPS 정보 모달 --}}
|
||||||
<div id="attendanceModal" class="fixed inset-0 z-50 hidden">
|
|
||||||
<div class="fixed inset-0 bg-black/40" onclick="closeAttendanceModal()"></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-md relative">
|
|
||||||
{{-- 헤더 --}}
|
|
||||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
|
||||||
<h3 id="attendanceModalTitle" class="text-lg font-semibold text-gray-800">근태 등록</h3>
|
|
||||||
<button type="button" onclick="closeAttendanceModal()" 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-4">
|
|
||||||
<div id="attendanceModalMessage" class="hidden rounded-lg px-4 py-3 text-sm"></div>
|
|
||||||
|
|
||||||
<input type="hidden" id="att_id" value="">
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">사원</label>
|
|
||||||
<select id="att_user_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($employees as $emp)
|
|
||||||
<option value="{{ $emp->user_id }}">{{ $emp->display_name ?? $emp->user?->name }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">날짜</label>
|
|
||||||
<input type="date" id="att_base_date" value="{{ 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>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">상태</label>
|
|
||||||
<select id="att_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">
|
|
||||||
@foreach($statusMap as $key => $label)
|
|
||||||
<option value="{{ $key }}">{{ $label }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<div style="flex: 1;">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">출근</label>
|
|
||||||
<input type="time" id="att_check_in" value="09:00"
|
|
||||||
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: 1;">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">퇴근</label>
|
|
||||||
<input type="time" id="att_check_out" value="18:00"
|
|
||||||
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>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">비고</label>
|
|
||||||
<input type="text" id="att_remarks" placeholder="비고 사항 입력..."
|
|
||||||
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 id="leaveBalanceInfo" class="hidden rounded-lg px-4 py-3 text-sm bg-blue-50 text-blue-700">
|
|
||||||
잔여 연차: <span id="leaveBalanceCount" class="font-bold">-</span>일
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- 푸터 --}}
|
|
||||||
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
|
|
||||||
<button type="button" onclick="closeAttendanceModal()"
|
|
||||||
class="px-4 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button type="button" onclick="submitAttendance()"
|
|
||||||
id="attendanceSubmitBtn"
|
|
||||||
class="px-4 py-2 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors">
|
|
||||||
등록
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- 일괄 등록 모달 --}}
|
|
||||||
<div id="bulkModal" class="fixed inset-0 z-50 hidden">
|
|
||||||
<div class="fixed inset-0 bg-black/40" onclick="closeBulkModal()"></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-2xl relative" style="max-height: 90vh; display: flex; flex-direction: column;">
|
|
||||||
{{-- 헤더 --}}
|
|
||||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 shrink-0">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-800">일괄 근태 등록</h3>
|
|
||||||
<button type="button" onclick="closeBulkModal()" 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-4 overflow-y-auto" style="flex: 1;">
|
|
||||||
<div id="bulkModalMessage" class="hidden rounded-lg px-4 py-3 text-sm"></div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
|
||||||
<div style="flex: 1 1 150px;">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">날짜</label>
|
|
||||||
<input type="date" id="bulk_base_date" value="{{ 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 style="flex: 1 1 130px;">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">상태</label>
|
|
||||||
<select id="bulk_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">
|
|
||||||
@foreach($statusMap as $key => $label)
|
|
||||||
<option value="{{ $key }}">{{ $label }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div style="flex: 0 1 110px;">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">출근</label>
|
|
||||||
<input type="time" id="bulk_check_in" value="09:00"
|
|
||||||
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 110px;">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">퇴근</label>
|
|
||||||
<input type="time" id="bulk_check_out" value="18:00"
|
|
||||||
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>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">비고</label>
|
|
||||||
<input type="text" id="bulk_remarks" placeholder="비고 사항..."
|
|
||||||
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>
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<label class="block text-sm font-medium text-gray-700">사원 선택</label>
|
|
||||||
<label class="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
|
|
||||||
<input type="checkbox" id="bulk_select_all" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
|
||||||
전체 선택
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="border border-gray-200 rounded-lg overflow-y-auto" style="max-height: 280px;">
|
|
||||||
@foreach($employees as $emp)
|
|
||||||
<label class="flex items-center gap-3 px-4 py-2.5 hover:bg-gray-50 cursor-pointer border-b border-gray-100 last:border-b-0">
|
|
||||||
<input type="checkbox" class="bulk-emp-checkbox rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
||||||
value="{{ $emp->user_id }}" data-name="{{ $emp->display_name ?? $emp->user?->name }}">
|
|
||||||
<span class="text-sm text-gray-700">{{ $emp->display_name ?? $emp->user?->name }}</span>
|
|
||||||
</label>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-gray-400 mt-1">선택: <span id="bulkSelectedCount">0</span>명</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- 푸터 --}}
|
|
||||||
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 shrink-0">
|
|
||||||
<button type="button" onclick="closeBulkModal()"
|
|
||||||
class="px-4 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button type="button" onclick="submitBulkAttendance()"
|
|
||||||
class="px-4 py-2 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg transition-colors">
|
|
||||||
일괄 등록
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- 승인 신청 모달 --}}
|
|
||||||
<div id="requestModal" class="fixed inset-0 z-50 hidden">
|
|
||||||
<div class="fixed inset-0 bg-black/40" onclick="closeRequestModal()"></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-md relative">
|
|
||||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
|
||||||
<h3 id="requestModalTitle" class="text-lg font-semibold text-gray-800">근태 신청</h3>
|
|
||||||
<button type="button" onclick="closeRequestModal()" 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-4">
|
|
||||||
<div id="requestModalMessage" class="hidden rounded-lg px-4 py-3 text-sm"></div>
|
|
||||||
<input type="hidden" id="req_id" value="">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">신청자</label>
|
|
||||||
<select id="req_user_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($employees as $emp)
|
|
||||||
<option value="{{ $emp->user_id }}">{{ $emp->display_name ?? $emp->user?->name }}</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">신청 유형</label>
|
|
||||||
<select id="req_type" 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="vacation">휴가</option>
|
|
||||||
<option value="businessTrip">출장</option>
|
|
||||||
<option value="remote">재택</option>
|
|
||||||
<option value="fieldWork">외근</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<div style="flex: 1;">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">시작일</label>
|
|
||||||
<input type="date" id="req_start_date" value="{{ 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 style="flex: 1;">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">종료일</label>
|
|
||||||
<input type="date" id="req_end_date" value="{{ 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>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">사유</label>
|
|
||||||
<textarea id="req_reason" rows="3" placeholder="신청 사유를 입력하세요..."
|
|
||||||
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"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
|
|
||||||
<button type="button" onclick="closeRequestModal()"
|
|
||||||
class="px-4 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button type="button" onclick="submitRequest()"
|
|
||||||
class="px-4 py-2 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors">
|
|
||||||
신청
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- GPS 설정 모달 --}}
|
|
||||||
<div id="gpsModal" class="fixed inset-0 z-50 hidden">
|
<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 bg-black/40" onclick="closeGpsModal()"></div>
|
||||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||||
@@ -466,19 +193,16 @@ class="px-4 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg
|
|||||||
<script>
|
<script>
|
||||||
// ===== 현재 탭 상태 =====
|
// ===== 현재 탭 상태 =====
|
||||||
let currentTab = 'list';
|
let currentTab = 'list';
|
||||||
const tabLoaded = { list: true, calendar: false, summary: false, requests: false };
|
const tabLoaded = { list: true, calendar: false, summary: false };
|
||||||
|
|
||||||
function switchTab(tab) {
|
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('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');
|
document.getElementById('content-' + currentTab).classList.add('hidden');
|
||||||
|
|
||||||
// 새 탭 활성화
|
|
||||||
currentTab = tab;
|
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('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');
|
document.getElementById('content-' + tab).classList.remove('hidden');
|
||||||
|
|
||||||
// 최초 로드 시 데이터 가져오기
|
|
||||||
if (!tabLoaded[tab]) {
|
if (!tabLoaded[tab]) {
|
||||||
tabLoaded[tab] = true;
|
tabLoaded[tab] = true;
|
||||||
loadTabData(tab);
|
loadTabData(tab);
|
||||||
@@ -501,81 +225,6 @@ function loadTabData(tab) {
|
|||||||
swap: 'innerHTML',
|
swap: 'innerHTML',
|
||||||
values: { year: year, month: month },
|
values: { year: year, month: month },
|
||||||
});
|
});
|
||||||
} else if (tab === 'requests') {
|
|
||||||
htmx.ajax('GET', '{{ route("api.admin.hr.attendances.requests.index") }}', {
|
|
||||||
target: '#attendance-requests-container',
|
|
||||||
swap: 'innerHTML',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 일괄 선택 관리 =====
|
|
||||||
const selectedAttendanceIds = new Set();
|
|
||||||
|
|
||||||
document.getElementById('attendances-table').addEventListener('change', function(e) {
|
|
||||||
if (e.target.classList.contains('att-checkbox')) {
|
|
||||||
const id = parseInt(e.target.dataset.id);
|
|
||||||
if (e.target.checked) selectedAttendanceIds.add(id);
|
|
||||||
else selectedAttendanceIds.delete(id);
|
|
||||||
updateBulkUI();
|
|
||||||
}
|
|
||||||
if (e.target.classList.contains('att-checkbox-all')) {
|
|
||||||
const checkboxes = document.querySelectorAll('.att-checkbox');
|
|
||||||
checkboxes.forEach(cb => {
|
|
||||||
cb.checked = e.target.checked;
|
|
||||||
const id = parseInt(cb.dataset.id);
|
|
||||||
if (e.target.checked) selectedAttendanceIds.add(id);
|
|
||||||
else selectedAttendanceIds.delete(id);
|
|
||||||
});
|
|
||||||
updateBulkUI();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateBulkUI() {
|
|
||||||
const btn = document.getElementById('bulkDeleteBtn');
|
|
||||||
const count = document.getElementById('bulkDeleteCount');
|
|
||||||
if (selectedAttendanceIds.size > 0) {
|
|
||||||
btn.classList.remove('hidden');
|
|
||||||
count.textContent = selectedAttendanceIds.size;
|
|
||||||
} else {
|
|
||||||
btn.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTMX 스왑 후 선택 초기화
|
|
||||||
document.body.addEventListener('htmx:afterSwap', function(e) {
|
|
||||||
if (e.detail.target.id === 'attendances-table') {
|
|
||||||
selectedAttendanceIds.clear();
|
|
||||||
updateBulkUI();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 일괄 삭제
|
|
||||||
async function bulkDeleteAttendances() {
|
|
||||||
if (selectedAttendanceIds.size === 0) return;
|
|
||||||
if (!confirm(selectedAttendanceIds.size + '건의 근태를 삭제하시겠습니까?')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('{{ route("api.admin.hr.attendances.bulk-delete") }}', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ ids: Array.from(selectedAttendanceIds) }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success) {
|
|
||||||
selectedAttendanceIds.clear();
|
|
||||||
updateBulkUI();
|
|
||||||
refreshTable();
|
|
||||||
} else {
|
|
||||||
alert(data.message || '삭제 중 오류가 발생했습니다.');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert('서버 통신 중 오류가 발생했습니다.');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,7 +234,6 @@ function updateBulkUI() {
|
|||||||
|
|
||||||
function onPeriodChange() {
|
function onPeriodChange() {
|
||||||
refreshStats();
|
refreshStats();
|
||||||
// 캘린더/요약 탭이 로드된 경우 갱신
|
|
||||||
if (tabLoaded.calendar) {
|
if (tabLoaded.calendar) {
|
||||||
tabLoaded.calendar = false;
|
tabLoaded.calendar = false;
|
||||||
if (currentTab === 'calendar') {
|
if (currentTab === 'calendar') {
|
||||||
@@ -643,281 +291,6 @@ function getFilterValues() {
|
|||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 근태 등록/수정 모달 =====
|
|
||||||
function openAttendanceModal(date) {
|
|
||||||
document.getElementById('att_id').value = '';
|
|
||||||
document.getElementById('att_user_id').value = '';
|
|
||||||
document.getElementById('att_user_id').disabled = false;
|
|
||||||
document.getElementById('att_base_date').value = date || '{{ now()->toDateString() }}';
|
|
||||||
document.getElementById('att_base_date').disabled = false;
|
|
||||||
document.getElementById('att_status').value = 'onTime';
|
|
||||||
document.getElementById('att_check_in').value = '09:00';
|
|
||||||
document.getElementById('att_check_out').value = '18:00';
|
|
||||||
document.getElementById('att_remarks').value = '';
|
|
||||||
document.getElementById('attendanceModalTitle').textContent = '근태 등록';
|
|
||||||
document.getElementById('attendanceSubmitBtn').textContent = '등록';
|
|
||||||
document.getElementById('leaveBalanceInfo').classList.add('hidden');
|
|
||||||
hideModalMessage();
|
|
||||||
document.getElementById('attendanceModal').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditAttendanceModal(id, userId, baseDate, status, checkIn, checkOut, remarks) {
|
|
||||||
document.getElementById('att_id').value = id;
|
|
||||||
document.getElementById('att_user_id').value = userId;
|
|
||||||
document.getElementById('att_user_id').disabled = true;
|
|
||||||
document.getElementById('att_base_date').value = baseDate;
|
|
||||||
document.getElementById('att_base_date').disabled = true;
|
|
||||||
document.getElementById('att_status').value = status;
|
|
||||||
document.getElementById('att_check_in').value = checkIn || '';
|
|
||||||
document.getElementById('att_check_out').value = checkOut || '';
|
|
||||||
document.getElementById('att_remarks').value = remarks || '';
|
|
||||||
document.getElementById('attendanceModalTitle').textContent = '근태 수정';
|
|
||||||
document.getElementById('attendanceSubmitBtn').textContent = '수정';
|
|
||||||
document.getElementById('leaveBalanceInfo').classList.add('hidden');
|
|
||||||
hideModalMessage();
|
|
||||||
document.getElementById('attendanceModal').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeAttendanceModal() {
|
|
||||||
document.getElementById('attendanceModal').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showModalMessage(message, isError) {
|
|
||||||
const el = document.getElementById('attendanceModalMessage');
|
|
||||||
el.textContent = message;
|
|
||||||
el.className = 'rounded-lg px-4 py-3 text-sm ' + (isError ? 'bg-red-50 text-red-700' : 'bg-emerald-50 text-emerald-700');
|
|
||||||
el.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideModalMessage() {
|
|
||||||
document.getElementById('attendanceModalMessage').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 상태 변경 시 휴가이면 잔여 연차 표시
|
|
||||||
document.getElementById('att_status').addEventListener('change', function() {
|
|
||||||
if (this.value === 'vacation') {
|
|
||||||
const userId = document.getElementById('att_user_id').value;
|
|
||||||
if (userId) fetchLeaveBalance(userId);
|
|
||||||
} else {
|
|
||||||
document.getElementById('leaveBalanceInfo').classList.add('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('att_user_id').addEventListener('change', function() {
|
|
||||||
if (document.getElementById('att_status').value === 'vacation' && this.value) {
|
|
||||||
fetchLeaveBalance(this.value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function fetchLeaveBalance(userId) {
|
|
||||||
try {
|
|
||||||
const res = await fetch('{{ url("/api/admin/hr/attendances/leave-balance") }}/' + userId, {
|
|
||||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success) {
|
|
||||||
document.getElementById('leaveBalanceCount').textContent = data.data.remaining;
|
|
||||||
document.getElementById('leaveBalanceInfo').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
} catch(e) { /* 조회 실패 시 표시하지 않음 */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitAttendance() {
|
|
||||||
const id = document.getElementById('att_id').value;
|
|
||||||
const isEdit = !!id;
|
|
||||||
|
|
||||||
const body = {
|
|
||||||
status: document.getElementById('att_status').value,
|
|
||||||
check_in: document.getElementById('att_check_in').value || null,
|
|
||||||
check_out: document.getElementById('att_check_out').value || null,
|
|
||||||
remarks: document.getElementById('att_remarks').value || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isEdit) {
|
|
||||||
body.user_id = parseInt(document.getElementById('att_user_id').value);
|
|
||||||
body.base_date = document.getElementById('att_base_date').value;
|
|
||||||
|
|
||||||
if (!body.user_id) {
|
|
||||||
showModalMessage('사원을 선택해주세요.', true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!body.base_date) {
|
|
||||||
showModalMessage('날짜를 선택해주세요.', true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = isEdit
|
|
||||||
? '{{ url("/api/admin/hr/attendances") }}/' + id
|
|
||||||
: '{{ route("api.admin.hr.attendances.store") }}';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: isEdit ? 'PUT' : 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
showModalMessage(data.message, false);
|
|
||||||
refreshTable();
|
|
||||||
setTimeout(() => closeAttendanceModal(), 800);
|
|
||||||
} else {
|
|
||||||
showModalMessage(data.message || '오류가 발생했습니다.', true);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showModalMessage('서버 통신 중 오류가 발생했습니다.', true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 일괄 등록 모달 =====
|
|
||||||
function openBulkModal() {
|
|
||||||
document.getElementById('bulk_base_date').value = '{{ now()->toDateString() }}';
|
|
||||||
document.getElementById('bulk_status').value = 'onTime';
|
|
||||||
document.getElementById('bulk_check_in').value = '09:00';
|
|
||||||
document.getElementById('bulk_check_out').value = '18:00';
|
|
||||||
document.getElementById('bulk_remarks').value = '';
|
|
||||||
document.querySelectorAll('.bulk-emp-checkbox').forEach(cb => cb.checked = false);
|
|
||||||
document.getElementById('bulk_select_all').checked = false;
|
|
||||||
document.getElementById('bulkSelectedCount').textContent = '0';
|
|
||||||
document.getElementById('bulkModalMessage').classList.add('hidden');
|
|
||||||
document.getElementById('bulkModal').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeBulkModal() {
|
|
||||||
document.getElementById('bulkModal').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 전체 선택
|
|
||||||
document.getElementById('bulk_select_all').addEventListener('change', function() {
|
|
||||||
document.querySelectorAll('.bulk-emp-checkbox').forEach(cb => cb.checked = this.checked);
|
|
||||||
updateBulkSelectedCount();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('change', function(e) {
|
|
||||||
if (e.target.classList.contains('bulk-emp-checkbox')) {
|
|
||||||
updateBulkSelectedCount();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateBulkSelectedCount() {
|
|
||||||
const count = document.querySelectorAll('.bulk-emp-checkbox:checked').length;
|
|
||||||
document.getElementById('bulkSelectedCount').textContent = count;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitBulkAttendance() {
|
|
||||||
const checkedBoxes = document.querySelectorAll('.bulk-emp-checkbox:checked');
|
|
||||||
if (checkedBoxes.length === 0) {
|
|
||||||
showBulkMessage('사원을 1명 이상 선택해주세요.', true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userIds = Array.from(checkedBoxes).map(cb => parseInt(cb.value));
|
|
||||||
const body = {
|
|
||||||
user_ids: userIds,
|
|
||||||
base_date: document.getElementById('bulk_base_date').value,
|
|
||||||
status: document.getElementById('bulk_status').value,
|
|
||||||
check_in: document.getElementById('bulk_check_in').value || null,
|
|
||||||
check_out: document.getElementById('bulk_check_out').value || null,
|
|
||||||
remarks: document.getElementById('bulk_remarks').value || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('{{ route("api.admin.hr.attendances.bulk-store") }}', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success) {
|
|
||||||
showBulkMessage(data.message, false);
|
|
||||||
refreshTable();
|
|
||||||
setTimeout(() => closeBulkModal(), 1000);
|
|
||||||
} else {
|
|
||||||
showBulkMessage(data.message || '오류가 발생했습니다.', true);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showBulkMessage('서버 통신 중 오류가 발생했습니다.', true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showBulkMessage(message, isError) {
|
|
||||||
const el = document.getElementById('bulkModalMessage');
|
|
||||||
el.textContent = message;
|
|
||||||
el.className = 'rounded-lg px-4 py-3 text-sm ' + (isError ? 'bg-red-50 text-red-700' : 'bg-emerald-50 text-emerald-700');
|
|
||||||
el.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 승인 신청 모달 =====
|
|
||||||
function openRequestModal() {
|
|
||||||
document.getElementById('req_id').value = '';
|
|
||||||
document.getElementById('req_user_id').value = '';
|
|
||||||
document.getElementById('req_type').value = 'vacation';
|
|
||||||
document.getElementById('req_start_date').value = '{{ now()->toDateString() }}';
|
|
||||||
document.getElementById('req_end_date').value = '{{ now()->toDateString() }}';
|
|
||||||
document.getElementById('req_reason').value = '';
|
|
||||||
document.getElementById('requestModalTitle').textContent = '근태 신청';
|
|
||||||
document.getElementById('requestModalMessage').classList.add('hidden');
|
|
||||||
document.getElementById('requestModal').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeRequestModal() {
|
|
||||||
document.getElementById('requestModal').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitRequest() {
|
|
||||||
const userId = document.getElementById('req_user_id').value;
|
|
||||||
if (!userId) { showRequestMessage('사원을 선택해주세요.', true); return; }
|
|
||||||
|
|
||||||
const body = {
|
|
||||||
user_id: parseInt(userId),
|
|
||||||
request_type: document.getElementById('req_type').value,
|
|
||||||
start_date: document.getElementById('req_start_date').value,
|
|
||||||
end_date: document.getElementById('req_end_date').value,
|
|
||||||
reason: document.getElementById('req_reason').value || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('{{ route("api.admin.hr.attendances.requests.store") }}', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success) {
|
|
||||||
showRequestMessage(data.message, false);
|
|
||||||
if (tabLoaded.requests) loadTabData('requests');
|
|
||||||
setTimeout(() => closeRequestModal(), 800);
|
|
||||||
} else {
|
|
||||||
showRequestMessage(data.message || '오류가 발생했습니다.', true);
|
|
||||||
}
|
|
||||||
} catch(e) {
|
|
||||||
showRequestMessage('서버 통신 중 오류가 발생했습니다.', true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showRequestMessage(message, isError) {
|
|
||||||
const el = document.getElementById('requestModalMessage');
|
|
||||||
el.textContent = message;
|
|
||||||
el.className = 'rounded-lg px-4 py-3 text-sm ' + (isError ? 'bg-red-50 text-red-700' : 'bg-emerald-50 text-emerald-700');
|
|
||||||
el.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== GPS 모달 =====
|
// ===== GPS 모달 =====
|
||||||
function openGpsModal(gpsData) {
|
function openGpsModal(gpsData) {
|
||||||
const content = document.getElementById('gpsDetailContent');
|
const content = document.getElementById('gpsDetailContent');
|
||||||
@@ -943,41 +316,5 @@ function openGpsModal(gpsData) {
|
|||||||
function closeGpsModal() {
|
function closeGpsModal() {
|
||||||
document.getElementById('gpsModal').classList.add('hidden');
|
document.getElementById('gpsModal').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 승인/반려 처리 =====
|
|
||||||
async function approveRequest(id) {
|
|
||||||
if (!confirm('승인하시겠습니까?')) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch('{{ url("/api/admin/hr/attendance-requests") }}/' + id + '/approve', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' },
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success) {
|
|
||||||
loadTabData('requests');
|
|
||||||
refreshTable();
|
|
||||||
} else {
|
|
||||||
alert(data.message || '승인 처리 중 오류');
|
|
||||||
}
|
|
||||||
} catch(e) { alert('서버 통신 중 오류'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rejectRequest(id) {
|
|
||||||
const reason = prompt('반려 사유를 입력하세요:');
|
|
||||||
if (reason === null) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch('{{ url("/api/admin/hr/attendance-requests") }}/' + id + '/reject', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' },
|
|
||||||
body: JSON.stringify({ reject_reason: reason }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success) {
|
|
||||||
loadTabData('requests');
|
|
||||||
} else {
|
|
||||||
alert(data.message || '반려 처리 중 오류');
|
|
||||||
}
|
|
||||||
} catch(e) { alert('서버 통신 중 오류'); }
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
@endpush
|
@endpush
|
||||||
|
|||||||
811
resources/views/hr/attendances/manage.blade.php
Normal file
811
resources/views/hr/attendances/manage.blade.php
Normal file
@@ -0,0 +1,811 @@
|
|||||||
|
@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 sm:gap-3">
|
||||||
|
<button type="button" onclick="openBulkModal()"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-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="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
</svg>
|
||||||
|
일괄 등록
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="openAttendanceModal()"
|
||||||
|
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>
|
||||||
|
근태 등록
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 탭 네비게이션 --}}
|
||||||
|
<div class="flex items-center gap-1 mb-4 border-b border-gray-200">
|
||||||
|
<button type="button" onclick="switchTab('manage')" id="tab-manage"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 탭 콘텐츠 영역 --}}
|
||||||
|
<div id="manage-content">
|
||||||
|
{{-- 등록/관리 탭 --}}
|
||||||
|
<div id="content-manage">
|
||||||
|
<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="manageFilter">
|
||||||
|
<form id="manageFilterForm" 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 flex items-center gap-2">
|
||||||
|
<button type="submit"
|
||||||
|
hx-get="{{ route('api.admin.hr.attendances.index') }}"
|
||||||
|
hx-target="#manage-table"
|
||||||
|
hx-include="#manageFilterForm"
|
||||||
|
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm rounded-lg transition-colors">
|
||||||
|
검색
|
||||||
|
</button>
|
||||||
|
<button type="button" id="bulkDeleteBtn" onclick="bulkDeleteAttendances()" class="hidden px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition-colors">
|
||||||
|
선택 삭제 (<span id="bulkDeleteCount">0</span>건)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</x-filter-collapsible>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- HTMX 테이블 영역 --}}
|
||||||
|
<div id="manage-table"
|
||||||
|
hx-get="{{ route('api.admin.hr.attendances.index') }}"
|
||||||
|
hx-vals='{"date_from": "{{ now()->startOfMonth()->toDateString() }}", "date_to": "{{ now()->toDateString() }}", "view": "manage"}'
|
||||||
|
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-requests" class="hidden">
|
||||||
|
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||||
|
<div id="attendance-requests-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>
|
||||||
|
|
||||||
|
{{-- 근태 등록/수정 모달 --}}
|
||||||
|
<div id="attendanceModal" class="fixed inset-0 z-50 hidden">
|
||||||
|
<div class="fixed inset-0 bg-black/40" onclick="closeAttendanceModal()"></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-md relative">
|
||||||
|
{{-- 헤더 --}}
|
||||||
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 id="attendanceModalTitle" class="text-lg font-semibold text-gray-800">근태 등록</h3>
|
||||||
|
<button type="button" onclick="closeAttendanceModal()" 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-4">
|
||||||
|
<div id="attendanceModalMessage" class="hidden rounded-lg px-4 py-3 text-sm"></div>
|
||||||
|
|
||||||
|
<input type="hidden" id="att_id" value="">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">사원</label>
|
||||||
|
<select id="att_user_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($employees as $emp)
|
||||||
|
<option value="{{ $emp->user_id }}">{{ $emp->display_name ?? $emp->user?->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">날짜</label>
|
||||||
|
<input type="date" id="att_base_date" value="{{ 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>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">상태</label>
|
||||||
|
<select id="att_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">
|
||||||
|
@foreach($statusMap as $key => $label)
|
||||||
|
<option value="{{ $key }}">{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">출근</label>
|
||||||
|
<input type="time" id="att_check_in" value="09:00"
|
||||||
|
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: 1;">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">퇴근</label>
|
||||||
|
<input type="time" id="att_check_out" value="18:00"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">비고</label>
|
||||||
|
<input type="text" id="att_remarks" placeholder="비고 사항 입력..."
|
||||||
|
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 id="leaveBalanceInfo" class="hidden rounded-lg px-4 py-3 text-sm bg-blue-50 text-blue-700">
|
||||||
|
잔여 연차: <span id="leaveBalanceCount" class="font-bold">-</span>일
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 푸터 --}}
|
||||||
|
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
|
||||||
|
<button type="button" onclick="closeAttendanceModal()"
|
||||||
|
class="px-4 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="submitAttendance()"
|
||||||
|
id="attendanceSubmitBtn"
|
||||||
|
class="px-4 py-2 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors">
|
||||||
|
등록
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 일괄 등록 모달 --}}
|
||||||
|
<div id="bulkModal" class="fixed inset-0 z-50 hidden">
|
||||||
|
<div class="fixed inset-0 bg-black/40" onclick="closeBulkModal()"></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-2xl relative" style="max-height: 90vh; display: flex; flex-direction: column;">
|
||||||
|
{{-- 헤더 --}}
|
||||||
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 shrink-0">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800">일괄 근태 등록</h3>
|
||||||
|
<button type="button" onclick="closeBulkModal()" 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-4 overflow-y-auto" style="flex: 1;">
|
||||||
|
<div id="bulkModalMessage" class="hidden rounded-lg px-4 py-3 text-sm"></div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<div style="flex: 1 1 150px;">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">날짜</label>
|
||||||
|
<input type="date" id="bulk_base_date" value="{{ 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 style="flex: 1 1 130px;">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">상태</label>
|
||||||
|
<select id="bulk_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">
|
||||||
|
@foreach($statusMap as $key => $label)
|
||||||
|
<option value="{{ $key }}">{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 0 1 110px;">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">출근</label>
|
||||||
|
<input type="time" id="bulk_check_in" value="09:00"
|
||||||
|
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 110px;">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">퇴근</label>
|
||||||
|
<input type="time" id="bulk_check_out" value="18:00"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">비고</label>
|
||||||
|
<input type="text" id="bulk_remarks" placeholder="비고 사항..."
|
||||||
|
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>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700">사원 선택</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
|
||||||
|
<input type="checkbox" id="bulk_select_all" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||||
|
전체 선택
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="border border-gray-200 rounded-lg overflow-y-auto" style="max-height: 280px;">
|
||||||
|
@foreach($employees as $emp)
|
||||||
|
<label class="flex items-center gap-3 px-4 py-2.5 hover:bg-gray-50 cursor-pointer border-b border-gray-100 last:border-b-0">
|
||||||
|
<input type="checkbox" class="bulk-emp-checkbox rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
value="{{ $emp->user_id }}" data-name="{{ $emp->display_name ?? $emp->user?->name }}">
|
||||||
|
<span class="text-sm text-gray-700">{{ $emp->display_name ?? $emp->user?->name }}</span>
|
||||||
|
</label>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">선택: <span id="bulkSelectedCount">0</span>명</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 푸터 --}}
|
||||||
|
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 shrink-0">
|
||||||
|
<button type="button" onclick="closeBulkModal()"
|
||||||
|
class="px-4 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="submitBulkAttendance()"
|
||||||
|
class="px-4 py-2 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg transition-colors">
|
||||||
|
일괄 등록
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 승인 신청 모달 --}}
|
||||||
|
<div id="requestModal" class="fixed inset-0 z-50 hidden">
|
||||||
|
<div class="fixed inset-0 bg-black/40" onclick="closeRequestModal()"></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-md relative">
|
||||||
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 id="requestModalTitle" class="text-lg font-semibold text-gray-800">근태 신청</h3>
|
||||||
|
<button type="button" onclick="closeRequestModal()" 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-4">
|
||||||
|
<div id="requestModalMessage" class="hidden rounded-lg px-4 py-3 text-sm"></div>
|
||||||
|
<input type="hidden" id="req_id" value="">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">신청자</label>
|
||||||
|
<select id="req_user_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($employees as $emp)
|
||||||
|
<option value="{{ $emp->user_id }}">{{ $emp->display_name ?? $emp->user?->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">신청 유형</label>
|
||||||
|
<select id="req_type" 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="vacation">휴가</option>
|
||||||
|
<option value="businessTrip">출장</option>
|
||||||
|
<option value="remote">재택</option>
|
||||||
|
<option value="fieldWork">외근</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">시작일</label>
|
||||||
|
<input type="date" id="req_start_date" value="{{ 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 style="flex: 1;">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">종료일</label>
|
||||||
|
<input type="date" id="req_end_date" value="{{ 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>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">사유</label>
|
||||||
|
<textarea id="req_reason" rows="3" placeholder="신청 사유를 입력하세요..."
|
||||||
|
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"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
|
||||||
|
<button type="button" onclick="closeRequestModal()"
|
||||||
|
class="px-4 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="submitRequest()"
|
||||||
|
class="px-4 py-2 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors">
|
||||||
|
신청
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
// ===== 현재 탭 상태 =====
|
||||||
|
let currentTab = 'manage';
|
||||||
|
const tabLoaded = { manage: true, requests: 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) {
|
||||||
|
if (tab === 'requests') {
|
||||||
|
htmx.ajax('GET', '{{ route("api.admin.hr.attendances.requests.index") }}', {
|
||||||
|
target: '#attendance-requests-container',
|
||||||
|
swap: 'innerHTML',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 일괄 선택 관리 =====
|
||||||
|
const selectedAttendanceIds = new Set();
|
||||||
|
|
||||||
|
document.getElementById('manage-table').addEventListener('change', function(e) {
|
||||||
|
if (e.target.classList.contains('att-checkbox')) {
|
||||||
|
const id = parseInt(e.target.dataset.id);
|
||||||
|
if (e.target.checked) selectedAttendanceIds.add(id);
|
||||||
|
else selectedAttendanceIds.delete(id);
|
||||||
|
updateBulkUI();
|
||||||
|
}
|
||||||
|
if (e.target.classList.contains('att-checkbox-all')) {
|
||||||
|
const checkboxes = document.querySelectorAll('.att-checkbox');
|
||||||
|
checkboxes.forEach(cb => {
|
||||||
|
cb.checked = e.target.checked;
|
||||||
|
const id = parseInt(cb.dataset.id);
|
||||||
|
if (e.target.checked) selectedAttendanceIds.add(id);
|
||||||
|
else selectedAttendanceIds.delete(id);
|
||||||
|
});
|
||||||
|
updateBulkUI();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateBulkUI() {
|
||||||
|
const btn = document.getElementById('bulkDeleteBtn');
|
||||||
|
const count = document.getElementById('bulkDeleteCount');
|
||||||
|
if (selectedAttendanceIds.size > 0) {
|
||||||
|
btn.classList.remove('hidden');
|
||||||
|
count.textContent = selectedAttendanceIds.size;
|
||||||
|
} else {
|
||||||
|
btn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function(e) {
|
||||||
|
if (e.detail.target.id === 'manage-table') {
|
||||||
|
selectedAttendanceIds.clear();
|
||||||
|
updateBulkUI();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 일괄 삭제
|
||||||
|
async function bulkDeleteAttendances() {
|
||||||
|
if (selectedAttendanceIds.size === 0) return;
|
||||||
|
if (!confirm(selectedAttendanceIds.size + '건의 근태를 삭제하시겠습니까?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('{{ route("api.admin.hr.attendances.bulk-delete") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ids: Array.from(selectedAttendanceIds) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
selectedAttendanceIds.clear();
|
||||||
|
updateBulkUI();
|
||||||
|
refreshTable();
|
||||||
|
} else {
|
||||||
|
alert(data.message || '삭제 중 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('서버 통신 중 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 필터 =====
|
||||||
|
document.getElementById('manageFilterForm')?.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
refreshTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
function refreshTable() {
|
||||||
|
const values = getFilterValues();
|
||||||
|
values.view = 'manage';
|
||||||
|
htmx.ajax('GET', '{{ route("api.admin.hr.attendances.index") }}', {
|
||||||
|
target: '#manage-table',
|
||||||
|
swap: 'innerHTML',
|
||||||
|
values: values,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilterValues() {
|
||||||
|
const form = document.getElementById('manageFilterForm');
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const values = {};
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
if (value) values[key] = value;
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 근태 등록/수정 모달 =====
|
||||||
|
function openAttendanceModal(date) {
|
||||||
|
document.getElementById('att_id').value = '';
|
||||||
|
document.getElementById('att_user_id').value = '';
|
||||||
|
document.getElementById('att_user_id').disabled = false;
|
||||||
|
document.getElementById('att_base_date').value = date || '{{ now()->toDateString() }}';
|
||||||
|
document.getElementById('att_base_date').disabled = false;
|
||||||
|
document.getElementById('att_status').value = 'onTime';
|
||||||
|
document.getElementById('att_check_in').value = '09:00';
|
||||||
|
document.getElementById('att_check_out').value = '18:00';
|
||||||
|
document.getElementById('att_remarks').value = '';
|
||||||
|
document.getElementById('attendanceModalTitle').textContent = '근태 등록';
|
||||||
|
document.getElementById('attendanceSubmitBtn').textContent = '등록';
|
||||||
|
document.getElementById('leaveBalanceInfo').classList.add('hidden');
|
||||||
|
hideModalMessage();
|
||||||
|
document.getElementById('attendanceModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditAttendanceModal(id, userId, baseDate, status, checkIn, checkOut, remarks) {
|
||||||
|
document.getElementById('att_id').value = id;
|
||||||
|
document.getElementById('att_user_id').value = userId;
|
||||||
|
document.getElementById('att_user_id').disabled = true;
|
||||||
|
document.getElementById('att_base_date').value = baseDate;
|
||||||
|
document.getElementById('att_base_date').disabled = true;
|
||||||
|
document.getElementById('att_status').value = status;
|
||||||
|
document.getElementById('att_check_in').value = checkIn || '';
|
||||||
|
document.getElementById('att_check_out').value = checkOut || '';
|
||||||
|
document.getElementById('att_remarks').value = remarks || '';
|
||||||
|
document.getElementById('attendanceModalTitle').textContent = '근태 수정';
|
||||||
|
document.getElementById('attendanceSubmitBtn').textContent = '수정';
|
||||||
|
document.getElementById('leaveBalanceInfo').classList.add('hidden');
|
||||||
|
hideModalMessage();
|
||||||
|
document.getElementById('attendanceModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAttendanceModal() {
|
||||||
|
document.getElementById('attendanceModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showModalMessage(message, isError) {
|
||||||
|
const el = document.getElementById('attendanceModalMessage');
|
||||||
|
el.textContent = message;
|
||||||
|
el.className = 'rounded-lg px-4 py-3 text-sm ' + (isError ? 'bg-red-50 text-red-700' : 'bg-emerald-50 text-emerald-700');
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideModalMessage() {
|
||||||
|
document.getElementById('attendanceModalMessage').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태 변경 시 휴가이면 잔여 연차 표시
|
||||||
|
document.getElementById('att_status').addEventListener('change', function() {
|
||||||
|
if (this.value === 'vacation') {
|
||||||
|
const userId = document.getElementById('att_user_id').value;
|
||||||
|
if (userId) fetchLeaveBalance(userId);
|
||||||
|
} else {
|
||||||
|
document.getElementById('leaveBalanceInfo').classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('att_user_id').addEventListener('change', function() {
|
||||||
|
if (document.getElementById('att_status').value === 'vacation' && this.value) {
|
||||||
|
fetchLeaveBalance(this.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchLeaveBalance(userId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('{{ url("/api/admin/hr/attendances/leave-balance") }}/' + userId, {
|
||||||
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
document.getElementById('leaveBalanceCount').textContent = data.data.remaining;
|
||||||
|
document.getElementById('leaveBalanceInfo').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch(e) { /* 조회 실패 시 표시하지 않음 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAttendance() {
|
||||||
|
const id = document.getElementById('att_id').value;
|
||||||
|
const isEdit = !!id;
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
status: document.getElementById('att_status').value,
|
||||||
|
check_in: document.getElementById('att_check_in').value || null,
|
||||||
|
check_out: document.getElementById('att_check_out').value || null,
|
||||||
|
remarks: document.getElementById('att_remarks').value || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isEdit) {
|
||||||
|
body.user_id = parseInt(document.getElementById('att_user_id').value);
|
||||||
|
body.base_date = document.getElementById('att_base_date').value;
|
||||||
|
|
||||||
|
if (!body.user_id) {
|
||||||
|
showModalMessage('사원을 선택해주세요.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!body.base_date) {
|
||||||
|
showModalMessage('날짜를 선택해주세요.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = isEdit
|
||||||
|
? '{{ url("/api/admin/hr/attendances") }}/' + id
|
||||||
|
: '{{ route("api.admin.hr.attendances.store") }}';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: isEdit ? 'PUT' : 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showModalMessage(data.message, false);
|
||||||
|
refreshTable();
|
||||||
|
setTimeout(() => closeAttendanceModal(), 800);
|
||||||
|
} else {
|
||||||
|
showModalMessage(data.message || '오류가 발생했습니다.', true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showModalMessage('서버 통신 중 오류가 발생했습니다.', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 일괄 등록 모달 =====
|
||||||
|
function openBulkModal() {
|
||||||
|
document.getElementById('bulk_base_date').value = '{{ now()->toDateString() }}';
|
||||||
|
document.getElementById('bulk_status').value = 'onTime';
|
||||||
|
document.getElementById('bulk_check_in').value = '09:00';
|
||||||
|
document.getElementById('bulk_check_out').value = '18:00';
|
||||||
|
document.getElementById('bulk_remarks').value = '';
|
||||||
|
document.querySelectorAll('.bulk-emp-checkbox').forEach(cb => cb.checked = false);
|
||||||
|
document.getElementById('bulk_select_all').checked = false;
|
||||||
|
document.getElementById('bulkSelectedCount').textContent = '0';
|
||||||
|
document.getElementById('bulkModalMessage').classList.add('hidden');
|
||||||
|
document.getElementById('bulkModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBulkModal() {
|
||||||
|
document.getElementById('bulkModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('bulk_select_all').addEventListener('change', function() {
|
||||||
|
document.querySelectorAll('.bulk-emp-checkbox').forEach(cb => cb.checked = this.checked);
|
||||||
|
updateBulkSelectedCount();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('change', function(e) {
|
||||||
|
if (e.target.classList.contains('bulk-emp-checkbox')) {
|
||||||
|
updateBulkSelectedCount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateBulkSelectedCount() {
|
||||||
|
const count = document.querySelectorAll('.bulk-emp-checkbox:checked').length;
|
||||||
|
document.getElementById('bulkSelectedCount').textContent = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBulkAttendance() {
|
||||||
|
const checkedBoxes = document.querySelectorAll('.bulk-emp-checkbox:checked');
|
||||||
|
if (checkedBoxes.length === 0) {
|
||||||
|
showBulkMessage('사원을 1명 이상 선택해주세요.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIds = Array.from(checkedBoxes).map(cb => parseInt(cb.value));
|
||||||
|
const body = {
|
||||||
|
user_ids: userIds,
|
||||||
|
base_date: document.getElementById('bulk_base_date').value,
|
||||||
|
status: document.getElementById('bulk_status').value,
|
||||||
|
check_in: document.getElementById('bulk_check_in').value || null,
|
||||||
|
check_out: document.getElementById('bulk_check_out').value || null,
|
||||||
|
remarks: document.getElementById('bulk_remarks').value || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('{{ route("api.admin.hr.attendances.bulk-store") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
showBulkMessage(data.message, false);
|
||||||
|
refreshTable();
|
||||||
|
setTimeout(() => closeBulkModal(), 1000);
|
||||||
|
} else {
|
||||||
|
showBulkMessage(data.message || '오류가 발생했습니다.', true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showBulkMessage('서버 통신 중 오류가 발생했습니다.', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBulkMessage(message, isError) {
|
||||||
|
const el = document.getElementById('bulkModalMessage');
|
||||||
|
el.textContent = message;
|
||||||
|
el.className = 'rounded-lg px-4 py-3 text-sm ' + (isError ? 'bg-red-50 text-red-700' : 'bg-emerald-50 text-emerald-700');
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 승인 신청 모달 =====
|
||||||
|
function openRequestModal() {
|
||||||
|
document.getElementById('req_id').value = '';
|
||||||
|
document.getElementById('req_user_id').value = '';
|
||||||
|
document.getElementById('req_type').value = 'vacation';
|
||||||
|
document.getElementById('req_start_date').value = '{{ now()->toDateString() }}';
|
||||||
|
document.getElementById('req_end_date').value = '{{ now()->toDateString() }}';
|
||||||
|
document.getElementById('req_reason').value = '';
|
||||||
|
document.getElementById('requestModalTitle').textContent = '근태 신청';
|
||||||
|
document.getElementById('requestModalMessage').classList.add('hidden');
|
||||||
|
document.getElementById('requestModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRequestModal() {
|
||||||
|
document.getElementById('requestModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRequest() {
|
||||||
|
const userId = document.getElementById('req_user_id').value;
|
||||||
|
if (!userId) { showRequestMessage('사원을 선택해주세요.', true); return; }
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
user_id: parseInt(userId),
|
||||||
|
request_type: document.getElementById('req_type').value,
|
||||||
|
start_date: document.getElementById('req_start_date').value,
|
||||||
|
end_date: document.getElementById('req_end_date').value,
|
||||||
|
reason: document.getElementById('req_reason').value || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('{{ route("api.admin.hr.attendances.requests.store") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
showRequestMessage(data.message, false);
|
||||||
|
if (tabLoaded.requests) loadTabData('requests');
|
||||||
|
setTimeout(() => closeRequestModal(), 800);
|
||||||
|
} else {
|
||||||
|
showRequestMessage(data.message || '오류가 발생했습니다.', true);
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
showRequestMessage('서버 통신 중 오류가 발생했습니다.', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRequestMessage(message, isError) {
|
||||||
|
const el = document.getElementById('requestModalMessage');
|
||||||
|
el.textContent = message;
|
||||||
|
el.className = 'rounded-lg px-4 py-3 text-sm ' + (isError ? 'bg-red-50 text-red-700' : 'bg-emerald-50 text-emerald-700');
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 승인/반려 처리 =====
|
||||||
|
async function approveRequest(id) {
|
||||||
|
if (!confirm('승인하시겠습니까?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('{{ url("/api/admin/hr/attendance-requests") }}/' + id + '/approve', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' },
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
loadTabData('requests');
|
||||||
|
refreshTable();
|
||||||
|
} else {
|
||||||
|
alert(data.message || '승인 처리 중 오류');
|
||||||
|
}
|
||||||
|
} catch(e) { alert('서버 통신 중 오류'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejectRequest(id) {
|
||||||
|
const reason = prompt('반려 사유를 입력하세요:');
|
||||||
|
if (reason === null) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('{{ url("/api/admin/hr/attendance-requests") }}/' + id + '/reject', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' },
|
||||||
|
body: JSON.stringify({ reject_reason: reason }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
loadTabData('requests');
|
||||||
|
} else {
|
||||||
|
alert(data.message || '반려 처리 중 오류');
|
||||||
|
}
|
||||||
|
} catch(e) { alert('서버 통신 중 오류'); }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
133
resources/views/hr/attendances/partials/table-manage.blade.php
Normal file
133
resources/views/hr/attendances/partials/table-manage.blade.php
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
{{-- 근태관리 테이블 (CRUD 포함, HTMX로 로드) --}}
|
||||||
|
@php
|
||||||
|
use App\Models\HR\Attendance;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<x-table-swipe>
|
||||||
|
<table class="min-w-full">
|
||||||
|
<thead class="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-center" style="width: 40px;">
|
||||||
|
<input type="checkbox" class="att-checkbox-all rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">날짜</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">사원</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">부서</th>
|
||||||
|
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">상태</th>
|
||||||
|
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">출근</th>
|
||||||
|
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">퇴근</th>
|
||||||
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">비고</th>
|
||||||
|
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">작업</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-100">
|
||||||
|
@forelse($attendances as $attendance)
|
||||||
|
@php
|
||||||
|
$profile = $attendance->user?->tenantProfiles?->first();
|
||||||
|
$department = $profile?->department;
|
||||||
|
$displayName = $profile?->display_name ?? $attendance->user?->name ?? '-';
|
||||||
|
$color = Attendance::STATUS_COLORS[$attendance->status] ?? 'gray';
|
||||||
|
$label = Attendance::STATUS_MAP[$attendance->status] ?? $attendance->status;
|
||||||
|
$checkIn = $attendance->check_in ? substr($attendance->check_in, 0, 5) : '-';
|
||||||
|
$checkOut = $attendance->check_out ? substr($attendance->check_out, 0, 5) : '-';
|
||||||
|
@endphp
|
||||||
|
<tr class="hover:bg-gray-50 transition-colors">
|
||||||
|
{{-- 체크박스 --}}
|
||||||
|
<td class="px-4 py-4 text-center">
|
||||||
|
<input type="checkbox" class="att-checkbox rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
data-id="{{ $attendance->id }}">
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{{-- 날짜 --}}
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700">
|
||||||
|
{{ $attendance->base_date->format('m-d') }}
|
||||||
|
<span class="text-xs text-gray-400 ml-1">{{ ['일','월','화','수','목','금','토'][$attendance->base_date->dayOfWeek] }}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{{-- 사원 --}}
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="shrink-0 w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-xs font-medium">
|
||||||
|
{{ mb_substr($displayName, 0, 1) }}
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-gray-900">{{ $displayName }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{{-- 부서 --}}
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700">
|
||||||
|
{{ $department?->name ?? '-' }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{{-- 상태 --}}
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-{{ $color }}-100 text-{{ $color }}-700">
|
||||||
|
{{ $label }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{{-- 출근 --}}
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-500">
|
||||||
|
{{ $checkIn }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{{-- 퇴근 --}}
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-500">
|
||||||
|
{{ $checkOut }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{{-- 비고 --}}
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-500" style="max-width: 200px;">
|
||||||
|
<span class="truncate block">{{ $attendance->remarks ?? '' }}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{{-- 작업 --}}
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
{{-- 수정 --}}
|
||||||
|
<button type="button"
|
||||||
|
onclick="openEditAttendanceModal({{ $attendance->id }}, {{ $attendance->user_id }}, '{{ $attendance->base_date->toDateString() }}', '{{ $attendance->status }}', '{{ $attendance->check_in ? substr($attendance->check_in, 0, 5) : '' }}', '{{ $attendance->check_out ? substr($attendance->check_out, 0, 5) : '' }}', '{{ addslashes($attendance->remarks ?? '') }}')"
|
||||||
|
class="text-blue-600 hover:text-blue-800" title="수정">
|
||||||
|
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{{-- 삭제 --}}
|
||||||
|
<button type="button"
|
||||||
|
hx-delete="{{ route('api.admin.hr.attendances.destroy', $attendance->id) }}"
|
||||||
|
hx-vals='{"view": "manage"}'
|
||||||
|
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
|
||||||
|
hx-target="#manage-table"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-confirm="{{ $displayName }}님의 {{ $attendance->base_date->format('m/d') }} 근태를 삭제하시겠습니까?"
|
||||||
|
class="text-red-600 hover:text-red-800" title="삭제">
|
||||||
|
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="px-6 py-12 text-center">
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<svg class="w-12 h-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-gray-500">근태 기록이 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</x-table-swipe>
|
||||||
|
|
||||||
|
{{-- 페이지네이션 --}}
|
||||||
|
@if($attendances->hasPages())
|
||||||
|
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||||
|
{{ $attendances->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{{-- 근태현황 테이블 (HTMX로 로드) --}}
|
{{-- 근태현황 테이블 (조회 전용, HTMX로 로드) --}}
|
||||||
@php
|
@php
|
||||||
use App\Models\HR\Attendance;
|
use App\Models\HR\Attendance;
|
||||||
@endphp
|
@endphp
|
||||||
@@ -7,9 +7,6 @@
|
|||||||
<table class="min-w-full">
|
<table class="min-w-full">
|
||||||
<thead class="bg-gray-50 border-b border-gray-200">
|
<thead class="bg-gray-50 border-b border-gray-200">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3 text-center" style="width: 40px;">
|
|
||||||
<input type="checkbox" class="att-checkbox-all rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
|
||||||
</th>
|
|
||||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">날짜</th>
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">날짜</th>
|
||||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">사원</th>
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">사원</th>
|
||||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">부서</th>
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">부서</th>
|
||||||
@@ -18,7 +15,6 @@
|
|||||||
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">퇴근</th>
|
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">퇴근</th>
|
||||||
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">비고</th>
|
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-600">비고</th>
|
||||||
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-600">GPS</th>
|
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-600">GPS</th>
|
||||||
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-600">작업</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white divide-y divide-gray-100">
|
<tbody class="bg-white divide-y divide-gray-100">
|
||||||
@@ -33,12 +29,6 @@
|
|||||||
$checkOut = $attendance->check_out ? substr($attendance->check_out, 0, 5) : '-';
|
$checkOut = $attendance->check_out ? substr($attendance->check_out, 0, 5) : '-';
|
||||||
@endphp
|
@endphp
|
||||||
<tr class="hover:bg-gray-50 transition-colors">
|
<tr class="hover:bg-gray-50 transition-colors">
|
||||||
{{-- 체크박스 --}}
|
|
||||||
<td class="px-4 py-4 text-center">
|
|
||||||
<input type="checkbox" class="att-checkbox rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
||||||
data-id="{{ $attendance->id }}">
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{{-- 날짜 --}}
|
{{-- 날짜 --}}
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700">
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700">
|
||||||
{{ $attendance->base_date->format('m-d') }}
|
{{ $attendance->base_date->format('m-d') }}
|
||||||
@@ -102,37 +92,10 @@ class="text-emerald-600 hover:text-emerald-800" title="GPS 정보 보기">
|
|||||||
</span>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{{-- 작업 --}}
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
|
||||||
<div class="flex items-center justify-center gap-2">
|
|
||||||
{{-- 수정 --}}
|
|
||||||
<button type="button"
|
|
||||||
onclick="openEditAttendanceModal({{ $attendance->id }}, {{ $attendance->user_id }}, '{{ $attendance->base_date->toDateString() }}', '{{ $attendance->status }}', '{{ $attendance->check_in ? substr($attendance->check_in, 0, 5) : '' }}', '{{ $attendance->check_out ? substr($attendance->check_out, 0, 5) : '' }}', '{{ addslashes($attendance->remarks ?? '') }}')"
|
|
||||||
class="text-blue-600 hover:text-blue-800" title="수정">
|
|
||||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{{-- 삭제 --}}
|
|
||||||
<button type="button"
|
|
||||||
hx-delete="{{ route('api.admin.hr.attendances.destroy', $attendance->id) }}"
|
|
||||||
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
|
|
||||||
hx-target="#attendances-table"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
hx-confirm="{{ $displayName }}님의 {{ $attendance->base_date->format('m/d') }} 근태를 삭제하시겠습니까?"
|
|
||||||
class="text-red-600 hover:text-red-800" title="삭제">
|
|
||||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="10" class="px-6 py-12 text-center">
|
<td colspan="8" class="px-6 py-12 text-center">
|
||||||
<div class="flex flex-col items-center gap-2">
|
<div class="flex flex-col items-center gap-2">
|
||||||
<svg class="w-12 h-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
|||||||
@@ -903,6 +903,7 @@
|
|||||||
// 근태현황
|
// 근태현황
|
||||||
Route::prefix('attendances')->name('attendances.')->group(function () {
|
Route::prefix('attendances')->name('attendances.')->group(function () {
|
||||||
Route::get('/', [\App\Http\Controllers\HR\AttendanceController::class, 'index'])->name('index');
|
Route::get('/', [\App\Http\Controllers\HR\AttendanceController::class, 'index'])->name('index');
|
||||||
|
Route::get('/manage', [\App\Http\Controllers\HR\AttendanceController::class, 'manage'])->name('manage');
|
||||||
});
|
});
|
||||||
|
|
||||||
// 휴가관리
|
// 휴가관리
|
||||||
|
|||||||
Reference in New Issue
Block a user