user()->name, self::ALLOWED_PAYROLL_USERS)) { return response()->json([ 'success' => false, 'message' => '급여관리는 관계자만 볼 수 있습니다.', ], 403); } return null; } /** * 급여 목록 조회 (HTMX → HTML / 일반 → JSON) */ public function index(Request $request): JsonResponse|Response { if ($denied = $this->checkPayrollAccess()) { return $denied; } $payrolls = $this->payrollService->getPayrolls( $request->all(), $request->integer('per_page', 20) ); if ($request->header('HX-Request')) { return response(view('hr.payrolls.partials.table', compact('payrolls'))); } return response()->json([ 'success' => true, 'data' => $payrolls->items(), 'meta' => [ 'current_page' => $payrolls->currentPage(), 'last_page' => $payrolls->lastPage(), 'per_page' => $payrolls->perPage(), 'total' => $payrolls->total(), ], ]); } /** * 월간 통계 (HTMX → HTML / 일반 → JSON) */ public function stats(Request $request): JsonResponse|Response { if ($denied = $this->checkPayrollAccess()) { return $denied; } $stats = $this->payrollService->getMonthlyStats( $request->integer('year') ?: null, $request->integer('month') ?: null ); if ($request->header('HX-Request')) { return response(view('hr.payrolls.partials.stats', compact('stats'))); } return response()->json([ 'success' => true, 'data' => $stats, ]); } /** * 급여 등록 */ public function store(Request $request): JsonResponse { if ($denied = $this->checkPayrollAccess()) { return $denied; } $validated = $request->validate([ 'user_id' => 'required|integer|exists:users,id', 'pay_year' => 'required|integer|min:2020|max:2100', 'pay_month' => 'required|integer|min:1|max:12', 'base_salary' => 'required|numeric|min:0', 'overtime_pay' => 'nullable|numeric|min:0', 'bonus' => 'nullable|numeric|min:0', 'allowances' => 'nullable|array', 'allowances.*.name' => 'required_with:allowances|string', 'allowances.*.amount' => 'required_with:allowances|numeric|min:0', 'deductions' => 'nullable|array', 'deductions.*.name' => 'required_with:deductions|string', 'deductions.*.amount' => 'required_with:deductions|numeric', 'deduction_overrides' => 'nullable|array', 'deduction_overrides.pension' => 'nullable|numeric', 'deduction_overrides.health_insurance' => 'nullable|numeric', 'deduction_overrides.long_term_care' => 'nullable|numeric', 'deduction_overrides.employment_insurance' => 'nullable|numeric', 'deduction_overrides.income_tax' => 'nullable|numeric', 'deduction_overrides.resident_tax' => 'nullable|numeric', 'note' => 'nullable|string|max:500', ]); try { $payroll = $this->payrollService->storePayroll($validated); return response()->json([ 'success' => true, 'message' => '급여가 등록되었습니다.', 'data' => $payroll, ], 201); } catch (\InvalidArgumentException $e) { return response()->json([ 'success' => false, 'message' => $e->getMessage(), ], 422); } catch (\Illuminate\Database\UniqueConstraintViolationException $e) { return response()->json([ 'success' => false, 'message' => '해당 직원의 동일 기간 급여가 이미 등록되어 있습니다.', ], 422); } catch (\Throwable $e) { report($e); return response()->json([ 'success' => false, 'message' => '급여 등록 중 오류가 발생했습니다.', 'error' => config('app.debug') ? $e->getMessage() : null, ], 500); } } /** * 급여 수정 */ public function update(Request $request, int $id): JsonResponse { if ($denied = $this->checkPayrollAccess()) { return $denied; } $validated = $request->validate([ 'base_salary' => 'sometimes|required|numeric|min:0', 'overtime_pay' => 'nullable|numeric|min:0', 'bonus' => 'nullable|numeric|min:0', 'allowances' => 'nullable|array', 'allowances.*.name' => 'required_with:allowances|string', 'allowances.*.amount' => 'required_with:allowances|numeric|min:0', 'deductions' => 'nullable|array', 'deductions.*.name' => 'required_with:deductions|string', 'deductions.*.amount' => 'required_with:deductions|numeric', 'deduction_overrides' => 'nullable|array', 'deduction_overrides.pension' => 'nullable|numeric', 'deduction_overrides.health_insurance' => 'nullable|numeric', 'deduction_overrides.long_term_care' => 'nullable|numeric', 'deduction_overrides.employment_insurance' => 'nullable|numeric', 'deduction_overrides.income_tax' => 'nullable|numeric', 'deduction_overrides.resident_tax' => 'nullable|numeric', 'note' => 'nullable|string|max:500', ]); try { $payroll = $this->payrollService->updatePayroll($id, $validated); if (! $payroll) { return response()->json([ 'success' => false, 'message' => '급여 정보를 찾을 수 없거나 수정할 수 없는 상태입니다.', ], 404); } return response()->json([ 'success' => true, 'message' => '급여가 수정되었습니다.', 'data' => $payroll, ]); } catch (\Throwable $e) { report($e); return response()->json([ 'success' => false, 'message' => '급여 수정 중 오류가 발생했습니다.', 'error' => config('app.debug') ? $e->getMessage() : null, ], 500); } } /** * 급여 삭제 */ public function destroy(Request $request, int $id): JsonResponse|Response { if ($denied = $this->checkPayrollAccess()) { return $denied; } try { $result = $this->payrollService->deletePayroll($id); if (! $result) { return response()->json([ 'success' => false, 'message' => '급여 정보를 찾을 수 없거나 삭제할 수 없는 상태입니다.', ], 404); } if ($request->header('HX-Request')) { $payrolls = $this->payrollService->getPayrolls( $request->all(), $request->integer('per_page', 20) ); return response(view('hr.payrolls.partials.table', compact('payrolls'))); } return response()->json([ 'success' => true, 'message' => '급여가 삭제되었습니다.', ]); } catch (\Throwable $e) { report($e); return response()->json([ 'success' => false, 'message' => '급여 삭제 중 오류가 발생했습니다.', 'error' => config('app.debug') ? $e->getMessage() : null, ], 500); } } /** * 급여 확정 */ public function confirm(Request $request, int $id): JsonResponse { if ($denied = $this->checkPayrollAccess()) { return $denied; } try { $payroll = $this->payrollService->confirmPayroll($id); if (! $payroll) { return response()->json([ 'success' => false, 'message' => '급여를 확정할 수 없습니다.', ], 404); } return response()->json([ 'success' => true, 'message' => '급여가 확정되었습니다.', 'data' => $payroll, ]); } catch (\Throwable $e) { report($e); return response()->json([ 'success' => false, 'message' => '급여 확정 중 오류가 발생했습니다.', 'error' => config('app.debug') ? $e->getMessage() : null, ], 500); } } /** * 급여 확정 취소 */ public function unconfirm(Request $request, int $id): JsonResponse { if ($denied = $this->checkPayrollAccess()) { return $denied; } try { $payroll = $this->payrollService->unconfirmPayroll($id); if (! $payroll) { return response()->json([ 'success' => false, 'message' => '급여 확정을 취소할 수 없습니다.', ], 404); } return response()->json([ 'success' => true, 'message' => '급여 확정이 취소되었습니다.', 'data' => $payroll, ]); } catch (\Throwable $e) { report($e); return response()->json([ 'success' => false, 'message' => '급여 확정 취소 중 오류가 발생했습니다.', 'error' => config('app.debug') ? $e->getMessage() : null, ], 500); } } /** * 급여 지급 처리 */ public function pay(Request $request, int $id): JsonResponse { if ($denied = $this->checkPayrollAccess()) { return $denied; } try { $payroll = $this->payrollService->payPayroll($id); if (! $payroll) { return response()->json([ 'success' => false, 'message' => '급여를 지급 처리할 수 없습니다.', ], 404); } return response()->json([ 'success' => true, 'message' => '급여가 지급 처리되었습니다.', 'data' => $payroll, ]); } catch (\Throwable $e) { report($e); return response()->json([ 'success' => false, 'message' => '급여 지급 처리 중 오류가 발생했습니다.', 'error' => config('app.debug') ? $e->getMessage() : null, ], 500); } } /** * 전월 급여 복사 등록 */ public function copyFromPrevious(Request $request): JsonResponse { if ($denied = $this->checkPayrollAccess()) { return $denied; } $validated = $request->validate([ 'pay_year' => 'required|integer|min:2020|max:2100', 'pay_month' => 'required|integer|min:1|max:12', ]); try { $result = $this->payrollService->copyFromPreviousMonth($validated['pay_year'], $validated['pay_month']); if (! empty($result['no_previous'])) { $prevYear = $validated['pay_month'] === 1 ? $validated['pay_year'] - 1 : $validated['pay_year']; $prevMonth = $validated['pay_month'] === 1 ? 12 : $validated['pay_month'] - 1; return response()->json([ 'success' => false, 'message' => "{$prevYear}년 {$prevMonth}월 급여 데이터가 없습니다.", ], 422); } return response()->json([ 'success' => true, 'message' => "전월 복사 완료: {$result['created']}건 생성, {$result['skipped']}건 건너뜀 (이미 존재).", 'data' => $result, ]); } catch (\Throwable $e) { report($e); return response()->json([ 'success' => false, 'message' => '전월 복사 중 오류가 발생했습니다.', 'error' => config('app.debug') ? $e->getMessage() : null, ], 500); } } /** * 일괄 생성 */ public function bulkGenerate(Request $request): JsonResponse { if ($denied = $this->checkPayrollAccess()) { return $denied; } $validated = $request->validate([ 'year' => 'required|integer|min:2020|max:2100', 'month' => 'required|integer|min:1|max:12', ]); try { $result = $this->payrollService->bulkGenerate($validated['year'], $validated['month']); return response()->json([ 'success' => true, 'message' => "신규 {$result['created']}건 생성, {$result['skipped']}건 건너뜀 (이미 존재).", 'data' => $result, ]); } catch (\Throwable $e) { report($e); return response()->json([ 'success' => false, 'message' => '일괄 생성 중 오류가 발생했습니다.', 'error' => config('app.debug') ? $e->getMessage() : null, ], 500); } } /** * 엑셀(CSV) 내보내기 */ public function export(Request $request): StreamedResponse|JsonResponse { if ($denied = $this->checkPayrollAccess()) { return $denied; } $payrolls = $this->payrollService->getExportData($request->all()); $year = $request->input('year', now()->year); $month = $request->input('month', now()->month); $filename = "급여관리_{$year}년{$month}월_".now()->format('Ymd').'.csv'; return response()->streamDownload(function () use ($payrolls) { $file = fopen('php://output', 'w'); fwrite($file, "\xEF\xBB\xBF"); // UTF-8 BOM fputcsv($file, ['사원명', '부서', '기본급', '고정연장근로수당', '식대(비과세)', '총지급액', '국민연금', '건강보험', '장기요양보험', '고용보험', '근로소득세', '지방소득세', '총공제액', '실수령액', '상태']); foreach ($payrolls as $payroll) { $profile = $payroll->user?->tenantProfiles?->first(); $displayName = $profile?->display_name ?? $payroll->user?->name ?? '-'; $department = $profile?->department?->name ?? '-'; $statusLabel = Payroll::STATUS_MAP[$payroll->status] ?? $payroll->status; fputcsv($file, [ $displayName, $department, $payroll->base_salary, $payroll->overtime_pay, $payroll->bonus, $payroll->gross_salary, $payroll->pension, $payroll->health_insurance, $payroll->long_term_care, $payroll->employment_insurance, $payroll->income_tax, $payroll->resident_tax, $payroll->total_deductions, $payroll->net_salary, $statusLabel, ]); } fclose($file); }, $filename, [ 'Content-Type' => 'text/csv; charset=UTF-8', ]); } /** * 급여 설정 조회 (HTMX → HTML / 일반 → JSON) */ public function settingsIndex(Request $request): JsonResponse|Response { if ($denied = $this->checkPayrollAccess()) { return $denied; } $settings = $this->payrollService->getSettings(); if ($request->header('HX-Request')) { return response(view('hr.payrolls.partials.settings', compact('settings'))); } return response()->json([ 'success' => true, 'data' => $settings, ]); } /** * 급여 설정 수정 */ public function settingsUpdate(Request $request): JsonResponse { if ($denied = $this->checkPayrollAccess()) { return $denied; } $validated = $request->validate([ 'health_insurance_rate' => 'nullable|numeric|min:0|max:100', 'long_term_care_rate' => 'nullable|numeric|min:0|max:100', 'pension_rate' => 'nullable|numeric|min:0|max:100', 'employment_insurance_rate' => 'nullable|numeric|min:0|max:100', 'pension_max_salary' => 'nullable|numeric|min:0', 'pension_min_salary' => 'nullable|numeric|min:0', 'pay_day' => 'nullable|integer|min:1|max:31', 'auto_calculate' => 'nullable|boolean', ]); try { $settings = $this->payrollService->updateSettings($validated); return response()->json([ 'success' => true, 'message' => '급여 설정이 저장되었습니다.', 'data' => $settings, ]); } catch (\Throwable $e) { report($e); return response()->json([ 'success' => false, 'message' => '설정 저장 중 오류가 발생했습니다.', 'error' => config('app.debug') ? $e->getMessage() : null, ], 500); } } /** * 급여 계산 미리보기 (AJAX) */ public function calculate(Request $request): JsonResponse { if ($denied = $this->checkPayrollAccess()) { return $denied; } $validated = $request->validate([ 'base_salary' => 'required|numeric|min:0', 'overtime_pay' => 'nullable|numeric|min:0', 'bonus' => 'nullable|numeric|min:0', 'allowances' => 'nullable|array', 'deductions' => 'nullable|array', 'user_id' => 'nullable|integer', ]); $familyCount = 1; if (! empty($validated['user_id'])) { $familyCount = $this->payrollService->resolveFamilyCount($validated['user_id']); } $result = $this->payrollService->calculateAmounts($validated, null, $familyCount); return response()->json([ 'success' => true, 'data' => array_merge($result, ['family_count' => $familyCount]), ]); } }