diff --git a/app/Http/Controllers/Api/Admin/HR/AttendanceController.php b/app/Http/Controllers/Api/Admin/HR/AttendanceController.php index 3ba9314d..f614979b 100644 --- a/app/Http/Controllers/Api/Admin/HR/AttendanceController.php +++ b/app/Http/Controllers/Api/Admin/HR/AttendanceController.php @@ -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); + } + } + /** * 근태 등록 */ diff --git a/app/Services/HR/AttendanceService.php b/app/Services/HR/AttendanceService.php index dd3d0d10..e77660e4 100644 --- a/app/Services/HR/AttendanceService.php +++ b/app/Services/HR/AttendanceService.php @@ -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; + } + /** * 부서 목록 (드롭다운용) */ diff --git a/resources/views/hr/attendances/index.blade.php b/resources/views/hr/attendances/index.blade.php index 70f85390..18f0cc97 100644 --- a/resources/views/hr/attendances/index.blade.php +++ b/resources/views/hr/attendances/index.blade.php @@ -8,9 +8,27 @@

근태현황

-

{{ $stats['year'] }}년 {{ $stats['month'] }}월 현재

+
+ + +
+
- {{-- 통계 카드 --}} -
-
-
정시출근
-
{{ $stats['onTime'] }}건
-
-
-
지각
-
{{ $stats['late'] }}건
-
-
-
결근
-
{{ $stats['absent'] }}건
-
-
-
휴가
-
{{ $stats['vacation'] }}건
-
-
-
기타
-
{{ $stats['etc'] }}건
-
+ {{-- 통계 카드 (HTMX 갱신 대상) --}} +
+ @include('hr.attendances.partials.stats', ['stats' => $stats])
{{-- 테이블 컨테이너 --}}
- {{-- 필터 --}} + {{-- 필터 + 일괄 삭제 --}}
@@ -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">
-
+
+
@@ -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') @endpush diff --git a/resources/views/hr/attendances/partials/stats.blade.php b/resources/views/hr/attendances/partials/stats.blade.php new file mode 100644 index 00000000..9bc5cf37 --- /dev/null +++ b/resources/views/hr/attendances/partials/stats.blade.php @@ -0,0 +1,23 @@ +{{-- 근태 월간 통계 카드 (HTMX로 갱신) --}} +
+
+
정시출근
+
{{ $stats['onTime'] }}건
+
+
+
지각
+
{{ $stats['late'] }}건
+
+
+
결근
+
{{ $stats['absent'] }}건
+
+
+
휴가
+
{{ $stats['vacation'] }}건
+
+
+
기타
+
{{ $stats['etc'] }}건
+
+
diff --git a/resources/views/hr/attendances/partials/table.blade.php b/resources/views/hr/attendances/partials/table.blade.php index 1d327452..d297ba5c 100644 --- a/resources/views/hr/attendances/partials/table.blade.php +++ b/resources/views/hr/attendances/partials/table.blade.php @@ -7,6 +7,9 @@ + @@ -29,6 +32,12 @@ $checkOut = $attendance->check_out ? substr($attendance->check_out, 0, 5) : '-'; @endphp + {{-- 체크박스 --}} + + {{-- 날짜 --}} @empty -
+ + 날짜 사원 부서
+ + {{ $attendance->base_date->format('m-d') }} @@ -101,7 +110,7 @@ class="text-red-600 hover:text-red-800" title="삭제">
+
diff --git a/routes/api.php b/routes/api.php index d9b67568..6c56e82c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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');