feat: [attendance] 근태현황 Phase 1 구현

- 1-1: 등록/수정 버그 수정 (created_by 덮어쓰기 방지)
- 1-2: 엑셀(CSV) 다운로드 기능 추가
- 1-3: 체크박스 일괄 삭제 기능 추가
- 1-4: 월간 통계 연/월 선택 기능 추가
This commit is contained in:
김보곤
2026-02-26 20:45:19 +09:00
parent 607593ff3f
commit 402e264290
6 changed files with 311 additions and 65 deletions

View File

@@ -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);
}
}
/**
* 근태 등록
*/

View File

@@ -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;
}
/**
* 부서 목록 (드롭다운용)
*/

View File

@@ -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

View 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>

View File

@@ -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"/>

View File

@@ -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');