user()->name, self::ALLOWED_PAYROLL_USERS)) { return response()->json([ 'success' => false, 'message' => '급여관리는 관계자만 볼 수 있습니다.', ], 403); } return null; } /** * 사업소득 지급 목록 (HTMX → 스프레드시트 파셜) */ public function index(Request $request): JsonResponse|Response { if ($denied = $this->checkPayrollAccess()) { return $denied; } $year = $request->integer('year') ?: now()->year; $month = $request->integer('month') ?: now()->month; $earners = $this->service->getActiveEarners(); $payments = $this->service->getPayments($year, $month); $stats = $this->service->getMonthlyStats($year, $month); $earnersForJs = $earners->map(fn ($e) => [ 'user_id' => $e->user_id, 'business_name' => $e->business_name ?? ($e->user?->name ?? ''), 'user_name' => $e->user?->name ?? '', 'business_reg_number' => $e->business_registration_number ?? '', ])->values(); if ($request->header('HX-Request')) { return response( view('hr.business-income-payments.partials.stats', compact('stats')). ''. view('hr.business-income-payments.partials.spreadsheet', compact('payments', 'earnersForJs', 'year', 'month')) ); } return response()->json([ 'success' => true, 'data' => $payments, 'stats' => $stats, ]); } /** * 일괄 저장 */ public function bulkSave(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', 'items' => 'present|array', 'items.*.payment_id' => 'nullable|integer', 'items.*.user_id' => 'nullable|integer', 'items.*.display_name' => 'required|string|max:100', 'items.*.business_reg_number' => 'nullable|string|max:20', 'items.*.gross_amount' => 'required|numeric|min:0', 'items.*.service_content' => 'nullable|string|max:200', 'items.*.payment_date' => 'nullable|date', 'items.*.note' => 'nullable|string|max:500', ]); $result = $this->service->bulkSave( $validated['year'], $validated['month'], $validated['items'] ); return response()->json([ 'success' => true, 'message' => "저장 {$result['saved']}건, 삭제 {$result['deleted']}건, 건너뜀 {$result['skipped']}건", 'data' => $result, ]); } /** * XLSX 내보내기 (스타일링 포함) */ public function export(Request $request): StreamedResponse|JsonResponse { if ($denied = $this->checkPayrollAccess()) { return $denied; } $year = $request->integer('year') ?: now()->year; $month = $request->integer('month') ?: now()->month; $payments = $this->service->getExportData($year, $month); $filename = "사업소득자임금대장_{$year}년{$month}월_".now()->format('Ymd').'.xlsx'; $spreadsheet = new Spreadsheet; $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle('사업소득자 임금대장'); $lastCol = 'K'; $headers = ['구분', '상호/성명', "사업자등록번호\n/주민등록번호", '용역내용', '지급총액', "소득세\n(3%)", "지방소득세\n(0.3%)", '공제합계', '실지급액', '지급일자', '비고']; // ── Row 1: 제목 ── $sheet->mergeCells("A1:{$lastCol}1"); $sheet->setCellValue('A1', "< {$year}년도 {$month}월 사업소득자 임금대장 >"); $sheet->getStyle('A1')->applyFromArray([ 'font' => ['bold' => true, 'size' => 14], 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER], ]); $sheet->getRowDimension(1)->setRowHeight(30); // ── Row 2: 헤더 ── foreach ($headers as $colIdx => $header) { $cell = chr(65 + $colIdx).'2'; $sheet->setCellValue($cell, $header); } $sheet->getStyle("A2:{$lastCol}2")->applyFromArray([ 'font' => ['bold' => true, 'size' => 10, 'color' => ['argb' => 'FFFFFFFF']], 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['argb' => 'FF1F3864']], 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER, 'wrapText' => true], 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['argb' => 'FF000000']]], ]); $sheet->getRowDimension(2)->setRowHeight(36); // ── Row 3~: 데이터 ── $dataStartRow = 3; $row = $dataStartRow; $moneyColumns = ['E', 'F', 'G', 'H', 'I']; foreach ($payments as $idx => $payment) { $name = $payment->display_name ?: ($payment->user?->name ?? '-'); $regNumber = $payment->business_reg_number ?? ''; $sheet->setCellValue("A{$row}", $idx + 1); $sheet->setCellValue("B{$row}", $name); $sheet->setCellValueExplicit("C{$row}", $regNumber, \PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING); $sheet->setCellValue("D{$row}", $payment->service_content ?? ''); $sheet->setCellValue("E{$row}", (int) $payment->gross_amount); $sheet->setCellValue("F{$row}", (int) $payment->income_tax); $sheet->setCellValue("G{$row}", (int) $payment->local_income_tax); $sheet->setCellValue("H{$row}", (int) $payment->total_deductions); $sheet->setCellValue("I{$row}", (int) $payment->net_amount); $sheet->setCellValue("J{$row}", $payment->payment_date?->format('Y-m-d') ?? ''); $sheet->setCellValue("K{$row}", $payment->note ?? ''); // 지급일자 빨간색 if ($payment->payment_date) { $sheet->getStyle("J{$row}")->getFont()->setColor(new Color('FF0000')); } $row++; } // 빈 행 채움 (최소 10행) $minEndRow = $dataStartRow + 9; while ($row <= $minEndRow) { $row++; } $lastDataRow = $row - 1; // ── 데이터 영역 스타일 ── $dataRange = "A{$dataStartRow}:{$lastCol}{$lastDataRow}"; $sheet->getStyle($dataRange)->applyFromArray([ 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['argb' => 'FF000000']]], 'alignment' => ['vertical' => Alignment::VERTICAL_CENTER], 'font' => ['size' => 10], ]); // 가운데 정렬: 구분, 용역내용, 지급일자, 비고 foreach (['A', 'D', 'J', 'K'] as $col) { $sheet->getStyle("{$col}{$dataStartRow}:{$col}{$lastDataRow}") ->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); } // 금액 서식: #,##0 + 오른쪽 정렬 foreach ($moneyColumns as $col) { $range = "{$col}{$dataStartRow}:{$col}{$lastDataRow}"; $sheet->getStyle($range)->getNumberFormat()->setFormatCode('#,##0'); $sheet->getStyle($range)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT); } // ── 열 너비 ── $widths = ['A' => 8, 'B' => 14, 'C' => 22, 'D' => 14, 'E' => 14, 'F' => 14, 'G' => 14, 'H' => 14, 'I' => 14, 'J' => 14, 'K' => 14]; foreach ($widths as $col => $width) { $sheet->getColumnDimension($col)->setWidth($width); } // ── 응답 반환 ── return response()->streamDownload(function () use ($spreadsheet) { $writer = new Xlsx($spreadsheet); $writer->save('php://output'); $spreadsheet->disconnectWorksheets(); }, $filename, [ 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'Cache-Control' => 'max-age=0', ]); } }