feat: [attendance] 근태현황 Phase 1 구현
- 1-1: 등록/수정 버그 수정 (created_by 덮어쓰기 방지) - 1-2: 엑셀(CSV) 다운로드 기능 추가 - 1-3: 체크박스 일괄 삭제 기능 추가 - 1-4: 월간 통계 연/월 선택 기능 추가
This commit is contained in:
@@ -3,10 +3,12 @@
|
||||
namespace App\Http\Controllers\Api\Admin\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\HR\Attendance;
|
||||
use App\Services\HR\AttendanceService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AttendanceController extends Controller
|
||||
{
|
||||
@@ -41,21 +43,103 @@ public function index(Request $request): JsonResponse|Response
|
||||
}
|
||||
|
||||
/**
|
||||
* 월간 통계
|
||||
* 월간 통계 (HTMX → HTML / 일반 → JSON)
|
||||
*/
|
||||
public function stats(Request $request): JsonResponse
|
||||
public function stats(Request $request): JsonResponse|Response
|
||||
{
|
||||
$stats = $this->attendanceService->getMonthlyStats(
|
||||
$request->integer('year') ?: null,
|
||||
$request->integer('month') ?: null
|
||||
);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response(view('hr.attendances.partials.stats', compact('stats')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀(CSV) 내보내기
|
||||
*/
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
$attendances = $this->attendanceService->getExportData($request->all());
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$filename = '근태현황_'.now()->format('Ymd').'.csv';
|
||||
|
||||
return response()->streamDownload(function () use ($attendances) {
|
||||
$file = fopen('php://output', 'w');
|
||||
fwrite($file, "\xEF\xBB\xBF"); // UTF-8 BOM
|
||||
|
||||
fputcsv($file, ['날짜', '사원명', '부서', '상태', '출근', '퇴근', '비고']);
|
||||
|
||||
foreach ($attendances as $att) {
|
||||
$profile = $att->user?->tenantProfiles?->first();
|
||||
$displayName = $profile?->display_name ?? $att->user?->name ?? '-';
|
||||
$department = $profile?->department?->name ?? '-';
|
||||
$statusLabel = Attendance::STATUS_MAP[$att->status] ?? $att->status;
|
||||
$checkIn = $att->check_in ? substr($att->check_in, 0, 5) : '';
|
||||
$checkOut = $att->check_out ? substr($att->check_out, 0, 5) : '';
|
||||
|
||||
fputcsv($file, [
|
||||
$att->base_date->format('Y-m-d'),
|
||||
$displayName,
|
||||
$department,
|
||||
$statusLabel,
|
||||
$checkIn,
|
||||
$checkOut,
|
||||
$att->remarks ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($file);
|
||||
}, $filename, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 삭제
|
||||
*/
|
||||
public function bulkDestroy(Request $request): JsonResponse|Response
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'ids' => 'required|array|min:1',
|
||||
'ids.*' => 'integer',
|
||||
]);
|
||||
|
||||
try {
|
||||
$count = $this->attendanceService->bulkDelete($validated['ids']);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
$attendances = $this->attendanceService->getAttendances(
|
||||
$request->except('ids'),
|
||||
$request->integer('per_page', 20)
|
||||
);
|
||||
|
||||
return response(view('hr.attendances.partials.table', compact('attendances')));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "{$count}건의 근태가 삭제되었습니다.",
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '일괄 삭제 중 오류가 발생했습니다.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 근태 등록
|
||||
*/
|
||||
|
||||
@@ -6,14 +6,15 @@
|
||||
use App\Models\HR\Employee;
|
||||
use App\Models\Tenants\Department;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AttendanceService
|
||||
{
|
||||
/**
|
||||
* 근태 목록 조회 (페이지네이션)
|
||||
* 필터 적용 쿼리 생성 (목록/엑셀 공통)
|
||||
*/
|
||||
public function getAttendances(array $filters = [], int $perPage = 20): LengthAwarePaginator
|
||||
private function buildFilteredQuery(array $filters = [])
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
@@ -21,7 +22,6 @@ public function getAttendances(array $filters = [], int $perPage = 20): LengthAw
|
||||
->with(['user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId)])
|
||||
->forTenant($tenantId);
|
||||
|
||||
// 이름 검색
|
||||
if (! empty($filters['q'])) {
|
||||
$search = $filters['q'];
|
||||
$query->whereHas('user', function ($q) use ($search) {
|
||||
@@ -29,7 +29,6 @@ public function getAttendances(array $filters = [], int $perPage = 20): LengthAw
|
||||
});
|
||||
}
|
||||
|
||||
// 부서 필터
|
||||
if (! empty($filters['department_id'])) {
|
||||
$deptId = $filters['department_id'];
|
||||
$query->whereHas('user.tenantProfiles', function ($q) use ($tenantId, $deptId) {
|
||||
@@ -37,12 +36,10 @@ public function getAttendances(array $filters = [], int $perPage = 20): LengthAw
|
||||
});
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (! empty($filters['status'])) {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
// 날짜 범위 필터
|
||||
if (! empty($filters['date_from']) && ! empty($filters['date_to'])) {
|
||||
$query->betweenDates($filters['date_from'], $filters['date_to']);
|
||||
} elseif (! empty($filters['date_from'])) {
|
||||
@@ -51,10 +48,23 @@ public function getAttendances(array $filters = [], int $perPage = 20): LengthAw
|
||||
$query->whereDate('base_date', '<=', $filters['date_to']);
|
||||
}
|
||||
|
||||
$query->orderBy('base_date', 'desc')
|
||||
->orderBy('created_at', 'desc');
|
||||
return $query->orderBy('base_date', 'desc')->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
return $query->paginate($perPage);
|
||||
/**
|
||||
* 근태 목록 조회 (페이지네이션)
|
||||
*/
|
||||
public function getAttendances(array $filters = [], int $perPage = 20): LengthAwarePaginator
|
||||
{
|
||||
return $this->buildFilteredQuery($filters)->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 내보내기용 데이터 (전체)
|
||||
*/
|
||||
public function getExportData(array $filters = []): Collection
|
||||
{
|
||||
return $this->buildFilteredQuery($filters)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,11 +126,14 @@ public function storeAttendance(array $data): Attendance
|
||||
'status' => $data['status'] ?? 'onTime',
|
||||
'json_details' => ! empty($jsonDetails) ? $jsonDetails : null,
|
||||
'remarks' => $data['remarks'] ?? null,
|
||||
'created_by' => auth()->id(),
|
||||
'updated_by' => auth()->id(),
|
||||
]
|
||||
);
|
||||
|
||||
if ($attendance->wasRecentlyCreated) {
|
||||
$attendance->update(['created_by' => auth()->id()]);
|
||||
}
|
||||
|
||||
return $attendance->load('user');
|
||||
});
|
||||
}
|
||||
@@ -194,6 +207,28 @@ public function deleteAttendance(int $id): bool
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 삭제
|
||||
*/
|
||||
public function bulkDelete(array $ids): int
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
|
||||
$attendances = Attendance::query()
|
||||
->forTenant($tenantId)
|
||||
->whereIn('id', $ids)
|
||||
->get();
|
||||
|
||||
$count = 0;
|
||||
foreach ($attendances as $attendance) {
|
||||
$attendance->update(['deleted_by' => auth()->id()]);
|
||||
$attendance->delete();
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 목록 (드롭다운용)
|
||||
*/
|
||||
|
||||
@@ -8,9 +8,27 @@
|
||||
<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">{{ $stats['year'] }}년 {{ $stats['month'] }}월 현재</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<select id="statsYear" class="px-2 py-1 border border-gray-300 rounded text-sm text-gray-600 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
@for($y = now()->year; $y >= now()->year - 2; $y--)
|
||||
<option value="{{ $y }}" {{ $stats['year'] == $y ? 'selected' : '' }}>{{ $y }}년</option>
|
||||
@endfor
|
||||
</select>
|
||||
<select id="statsMonth" class="px-2 py-1 border border-gray-300 rounded text-sm text-gray-600 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
@for($m = 1; $m <= 12; $m++)
|
||||
<option value="{{ $m }}" {{ $stats['month'] == $m ? 'selected' : '' }}>{{ $m }}월</option>
|
||||
@endfor
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<button type="button" onclick="exportAttendances()"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-medium rounded-lg transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
엑셀 다운로드
|
||||
</button>
|
||||
<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">
|
||||
@@ -21,33 +39,14 @@ class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 te
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 통계 카드 --}}
|
||||
<div class="grid gap-4 mb-6" style="grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));">
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">정시출근</div>
|
||||
<div class="text-2xl font-bold text-emerald-600">{{ $stats['onTime'] }}건</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">지각</div>
|
||||
<div class="text-2xl font-bold text-amber-600">{{ $stats['late'] }}건</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">결근</div>
|
||||
<div class="text-2xl font-bold text-red-600">{{ $stats['absent'] }}건</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">휴가</div>
|
||||
<div class="text-2xl font-bold text-blue-600">{{ $stats['vacation'] }}건</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">기타</div>
|
||||
<div class="text-2xl font-bold text-gray-600">{{ $stats['etc'] }}건</div>
|
||||
</div>
|
||||
{{-- 통계 카드 (HTMX 갱신 대상) --}}
|
||||
<div id="stats-container" class="mb-6">
|
||||
@include('hr.attendances.partials.stats', ['stats' => $stats])
|
||||
</div>
|
||||
|
||||
{{-- 테이블 컨테이너 --}}
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
{{-- 필터 --}}
|
||||
{{-- 필터 + 일괄 삭제 --}}
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<x-filter-collapsible id="attendanceFilter">
|
||||
<form id="attendanceFilterForm" class="flex flex-wrap gap-3 items-end">
|
||||
@@ -91,7 +90,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()) }}"
|
||||
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">
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<button type="submit"
|
||||
hx-get="{{ route('api.admin.hr.attendances.index') }}"
|
||||
hx-target="#attendances-table"
|
||||
@@ -99,6 +98,9 @@ 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">
|
||||
검색
|
||||
</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>
|
||||
@@ -203,18 +205,128 @@ class="px-4 py-2 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-lg tra
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// 필터 폼 submit 이벤트 가로채기
|
||||
document.getElementById('attendanceFilterForm')?.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
htmx.trigger('#attendances-table', 'htmx:trigger');
|
||||
// ===== 일괄 선택 관리 =====
|
||||
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('서버 통신 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 통계 기간 선택 =====
|
||||
document.getElementById('statsYear').addEventListener('change', refreshStats);
|
||||
document.getElementById('statsMonth').addEventListener('change', refreshStats);
|
||||
|
||||
function refreshStats() {
|
||||
htmx.ajax('GET', '{{ route("api.admin.hr.attendances.stats") }}', {
|
||||
target: '#stats-container',
|
||||
swap: 'innerHTML',
|
||||
values: {
|
||||
year: document.getElementById('statsYear').value,
|
||||
month: document.getElementById('statsMonth').value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 엑셀 다운로드 =====
|
||||
function exportAttendances() {
|
||||
const params = new URLSearchParams(getFilterValues());
|
||||
window.location.href = '{{ route("api.admin.hr.attendances.export") }}?' + params.toString();
|
||||
}
|
||||
|
||||
// ===== 필터 =====
|
||||
document.getElementById('attendanceFilterForm')?.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
refreshTable();
|
||||
});
|
||||
|
||||
function refreshTable() {
|
||||
htmx.ajax('GET', '{{ route("api.admin.hr.attendances.index") }}', {
|
||||
target: '#attendances-table',
|
||||
swap: 'innerHTML',
|
||||
values: getFilterValues(),
|
||||
});
|
||||
}
|
||||
|
||||
function getFilterValues() {
|
||||
const form = document.getElementById('attendanceFilterForm');
|
||||
const formData = new FormData(form);
|
||||
const values = {};
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value) values[key] = value;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
// ===== 모달 =====
|
||||
function openAttendanceModal() {
|
||||
document.getElementById('att_id').value = '';
|
||||
document.getElementById('att_user_id').value = '';
|
||||
document.getElementById('att_user_id').disabled = false;
|
||||
document.getElementById('att_base_date').value = '{{ 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';
|
||||
@@ -225,12 +337,12 @@ function openAttendanceModal() {
|
||||
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 || '';
|
||||
@@ -241,12 +353,10 @@ function openEditAttendanceModal(id, userId, baseDate, status, checkIn, checkOut
|
||||
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;
|
||||
@@ -258,7 +368,6 @@ function hideModalMessage() {
|
||||
document.getElementById('attendanceModalMessage').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 등록/수정 제출
|
||||
async function submitAttendance() {
|
||||
const id = document.getElementById('att_id').value;
|
||||
const isEdit = !!id;
|
||||
@@ -271,7 +380,7 @@ function hideModalMessage() {
|
||||
};
|
||||
|
||||
if (!isEdit) {
|
||||
body.user_id = document.getElementById('att_user_id').value;
|
||||
body.user_id = parseInt(document.getElementById('att_user_id').value);
|
||||
body.base_date = document.getElementById('att_base_date').value;
|
||||
|
||||
if (!body.user_id) {
|
||||
@@ -303,12 +412,7 @@ function hideModalMessage() {
|
||||
|
||||
if (data.success) {
|
||||
showModalMessage(data.message, false);
|
||||
// 테이블 리로드
|
||||
htmx.ajax('GET', '{{ route("api.admin.hr.attendances.index") }}', {
|
||||
target: '#attendances-table',
|
||||
swap: 'innerHTML',
|
||||
values: getFilterValues(),
|
||||
});
|
||||
refreshTable();
|
||||
setTimeout(() => closeAttendanceModal(), 800);
|
||||
} else {
|
||||
showModalMessage(data.message || '오류가 발생했습니다.', true);
|
||||
@@ -317,16 +421,5 @@ function hideModalMessage() {
|
||||
showModalMessage('서버 통신 중 오류가 발생했습니다.', true);
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 필터 값 가져오기
|
||||
function getFilterValues() {
|
||||
const form = document.getElementById('attendanceFilterForm');
|
||||
const formData = new FormData(form);
|
||||
const values = {};
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value) values[key] = value;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
23
resources/views/hr/attendances/partials/stats.blade.php
Normal file
23
resources/views/hr/attendances/partials/stats.blade.php
Normal file
@@ -0,0 +1,23 @@
|
||||
{{-- 근태 월간 통계 카드 (HTMX로 갱신) --}}
|
||||
<div class="grid gap-4" style="grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));">
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">정시출근</div>
|
||||
<div class="text-2xl font-bold text-emerald-600">{{ $stats['onTime'] }}건</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">지각</div>
|
||||
<div class="text-2xl font-bold text-amber-600">{{ $stats['late'] }}건</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">결근</div>
|
||||
<div class="text-2xl font-bold text-red-600">{{ $stats['absent'] }}건</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">휴가</div>
|
||||
<div class="text-2xl font-bold text-blue-600">{{ $stats['vacation'] }}건</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
||||
<div class="text-sm text-gray-500">기타</div>
|
||||
<div class="text-2xl font-bold text-gray-600">{{ $stats['etc'] }}건</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -7,6 +7,9 @@
|
||||
<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>
|
||||
@@ -29,6 +32,12 @@
|
||||
$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') }}
|
||||
@@ -101,7 +110,7 @@ class="text-red-600 hover:text-red-800" title="삭제">
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="8" class="px-6 py-12 text-center">
|
||||
<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"/>
|
||||
|
||||
@@ -1063,6 +1063,8 @@
|
||||
// 근태현황 API
|
||||
Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/hr/attendances')->name('api.admin.hr.attendances.')->group(function () {
|
||||
Route::get('/stats', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'stats'])->name('stats');
|
||||
Route::get('/export', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'export'])->name('export');
|
||||
Route::post('/bulk-delete', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'bulkDestroy'])->name('bulk-delete');
|
||||
Route::get('/', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'index'])->name('index');
|
||||
Route::post('/', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'store'])->name('store');
|
||||
Route::put('/{id}', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'update'])->name('update');
|
||||
|
||||
Reference in New Issue
Block a user