diff --git a/app/Http/Controllers/Api/Admin/HR/PayrollController.php b/app/Http/Controllers/Api/Admin/HR/PayrollController.php index 18645db3..0a5d6e93 100644 --- a/app/Http/Controllers/Api/Admin/HR/PayrollController.php +++ b/app/Http/Controllers/Api/Admin/HR/PayrollController.php @@ -980,6 +980,46 @@ public function generateJournalEntry(Request $request): JsonResponse } } + /** + * 급여명세서 이메일 발송 + */ + public function sendPayslip(Request $request, int $id): JsonResponse + { + if ($denied = $this->checkPayrollAccess()) { + return $denied; + } + + try { + $result = $this->payrollService->sendPayslip($id); + + if (! $result) { + return response()->json([ + 'success' => false, + 'message' => '급여 정보를 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'message' => $result['message'], + 'data' => ['email' => $result['email']], + ]); + } catch (\RuntimeException $e) { + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 422); + } catch (\Throwable $e) { + report($e); + + return response()->json([ + 'success' => false, + 'message' => '이메일 발송 중 오류가 발생했습니다.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + /** * 급여 계산 미리보기 (AJAX) */ diff --git a/app/Mail/PayslipMail.php b/app/Mail/PayslipMail.php new file mode 100644 index 00000000..17f2fc03 --- /dev/null +++ b/app/Mail/PayslipMail.php @@ -0,0 +1,41 @@ +payslipData['pay_year'] ?? ''; + $month = str_pad($this->payslipData['pay_month'] ?? '', 2, '0', STR_PAD_LEFT); + + return new Envelope( + from: new \Illuminate\Mail\Mailables\Address('admin@codebridge-x.com', '(주)코드브릿지엑스'), + subject: "[SAM] {$year}년{$month}월분 급여명세서", + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.payslip', + ); + } + + public function attachments(): array + { + return []; + } +} diff --git a/app/Services/HR/PayrollService.php b/app/Services/HR/PayrollService.php index c0e19b11..77d92487 100644 --- a/app/Services/HR/PayrollService.php +++ b/app/Services/HR/PayrollService.php @@ -2,6 +2,7 @@ namespace App\Services\HR; +use App\Mail\PayslipMail; use App\Models\HR\Employee; use App\Models\HR\IncomeTaxBracket; use App\Models\HR\Payroll; @@ -10,6 +11,7 @@ use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Mail; class PayrollService { @@ -763,6 +765,96 @@ public function resolveFamilyCount(int $userId): int return max(1, min(11, 1 + $dependentCount)); } + /** + * 급여명세서 이메일 발송 + */ + public function sendPayslip(int $id): ?array + { + $tenantId = session('selected_tenant_id', 1); + + $payroll = Payroll::query() + ->with(['user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId), 'user.tenantProfiles.department']) + ->forTenant($tenantId) + ->find($id); + + if (! $payroll) { + return null; + } + + $user = $payroll->user; + $email = $user?->email; + + if (! $email) { + throw new \RuntimeException('해당 직원의 이메일 주소가 등록되어 있지 않습니다.'); + } + + $profile = $user->tenantProfiles?->first(); + + // 지급 항목 구성 + $payments = []; + $payments[] = ['name' => '기본급', 'amount' => (int) $payroll->base_salary]; + if ((int) $payroll->bonus > 0) { + $payments[] = ['name' => '식대', 'amount' => (int) $payroll->bonus]; + } + if ((int) $payroll->overtime_pay > 0) { + $payments[] = ['name' => '고정연장근로수당', 'amount' => (int) $payroll->overtime_pay]; + } + foreach ($payroll->allowances ?? [] as $a) { + if (($a['amount'] ?? 0) > 0) { + $payments[] = ['name' => $a['name'] ?? '기타수당', 'amount' => (int) $a['amount']]; + } + } + + // 공제 항목 구성 + $deductionItems = []; + if ((int) $payroll->pension > 0) { + $deductionItems[] = ['name' => '국민연금', 'amount' => (int) $payroll->pension]; + } + if ((int) $payroll->health_insurance > 0) { + $deductionItems[] = ['name' => '건강보험', 'amount' => (int) $payroll->health_insurance]; + } + if ((int) $payroll->employment_insurance > 0) { + $deductionItems[] = ['name' => '고용보험', 'amount' => (int) $payroll->employment_insurance]; + } + if ((int) $payroll->long_term_care > 0) { + $deductionItems[] = ['name' => '장기요양보험료', 'amount' => (int) $payroll->long_term_care]; + } + if ((int) $payroll->income_tax > 0) { + $deductionItems[] = ['name' => '소득세', 'amount' => (int) $payroll->income_tax]; + } + if ((int) $payroll->resident_tax > 0) { + $deductionItems[] = ['name' => '지방소득세', 'amount' => (int) $payroll->resident_tax]; + } + foreach ($payroll->deductions ?? [] as $d) { + if (($d['amount'] ?? 0) > 0) { + $deductionItems[] = ['name' => $d['name'] ?? '기타공제', 'amount' => (int) $d['amount']]; + } + } + + $payslipData = [ + 'pay_year' => $payroll->pay_year, + 'pay_month' => $payroll->pay_month, + 'employee_code' => $user->id, + 'employee_name' => $profile?->display_name ?? $user->name ?? '-', + 'hire_date' => $profile?->hire_date ?? '-', + 'department' => $profile?->department?->name ?? '-', + 'position' => $profile?->position_label ?? '-', + 'grade' => '', + 'payments' => $payments, + 'deduction_items' => $deductionItems, + 'gross_salary' => (int) $payroll->gross_salary, + 'total_deductions' => (int) $payroll->total_deductions, + 'net_salary' => (int) $payroll->net_salary, + ]; + + Mail::to($email)->send(new PayslipMail($payslipData)); + + return [ + 'email' => $email, + 'message' => "{$payslipData['employee_name']}님({$email})에게 급여명세서를 발송했습니다.", + ]; + } + /** * 급여 설정 수정 */ diff --git a/resources/views/emails/payslip.blade.php b/resources/views/emails/payslip.blade.php new file mode 100644 index 00000000..a58e86f3 --- /dev/null +++ b/resources/views/emails/payslip.blade.php @@ -0,0 +1,106 @@ + + + + + + + +
+

{{ $payslipData['pay_year'] }}년{{ str_pad($payslipData['pay_month'], 2, '0', STR_PAD_LEFT) }}월분 급여명세서

+ + {{-- 사원 정보 --}} + + + + + + + + + + + + + + + + + +
사원코드{{ $payslipData['employee_code'] ?? '-' }}사원명{{ $payslipData['employee_name'] ?? '-' }}입사일{{ $payslipData['hire_date'] ?? '-' }}
부 서{{ $payslipData['department'] ?? '-' }}직 급{{ $payslipData['position'] ?? '-' }}호 봉{{ $payslipData['grade'] ?? '-' }}
+ + {{-- 지급/공제 내역 --}} + @php + $payments = $payslipData['payments'] ?? []; + $deductionItems = $payslipData['deduction_items'] ?? []; + $maxRows = max(count($payments), count($deductionItems)); + @endphp + + + + + + + + + + + @for($i = 0; $i < $maxRows; $i++) + + + + + + + @endfor + + {{-- 빈 행 (최소 높이 확보) --}} + @for($i = $maxRows; $i < 8; $i++) + + + + + + + @endfor + + {{-- 공제액계 --}} + + + + + + + + {{-- 지급액계 / 차인지급액 --}} + + + + + + + +
지 급 내 역지 급 액공 제 내 역공 제 액
{{ $payments[$i]['name'] ?? '' }}{{ isset($payments[$i]['amount']) ? number_format($payments[$i]['amount']) : '' }}{{ $deductionItems[$i]['name'] ?? '' }}{{ isset($deductionItems[$i]['amount']) ? number_format($deductionItems[$i]['amount']) : '' }}
 
공 제 액 계{{ number_format($payslipData['total_deductions'] ?? 0) }}
지 급 액 계{{ number_format($payslipData['gross_salary'] ?? 0) }}차인지급액{{ number_format($payslipData['net_salary'] ?? 0) }}
+ + +
+ + diff --git a/resources/views/hr/payrolls/index.blade.php b/resources/views/hr/payrolls/index.blade.php index 18643dff..74fa7856 100644 --- a/resources/views/hr/payrolls/index.blade.php +++ b/resources/views/hr/payrolls/index.blade.php @@ -797,6 +797,50 @@ class="px-5 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg + +{{-- 급여명세서 이메일 모달 --}} + @endsection @push('scripts') @@ -1592,6 +1636,142 @@ function savePayrollSettings() { }); } + // ===== 급여명세서 이메일 ===== + let _payslipPayrollId = null; + + function numberFormatPayslip(n) { + return (n || 0).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); + } + + function buildPayslipHtml(d) { + const yearMonth = d.pay_year + '년' + String(d.pay_month).padStart(2, '0') + '월분'; + + // 지급 항목 + const payments = []; + if (d.base_salary > 0) payments.push({ name: '기본급', amount: d.base_salary }); + if (d.bonus > 0) payments.push({ name: '식대', amount: d.bonus }); + if (d.overtime_pay > 0) payments.push({ name: '고정연장근로수당', amount: d.overtime_pay }); + (d.allowances || []).forEach(a => { if ((a.amount || 0) > 0) payments.push({ name: a.name || '기타수당', amount: a.amount }); }); + + // 공제 항목 + const deductions = []; + if (d.pension > 0) deductions.push({ name: '국민연금', amount: d.pension }); + if (d.health_insurance > 0) deductions.push({ name: '건강보험', amount: d.health_insurance }); + if (d.employment_insurance > 0) deductions.push({ name: '고용보험', amount: d.employment_insurance }); + if (d.long_term_care > 0) deductions.push({ name: '장기요양보험료', amount: d.long_term_care }); + if (d.income_tax > 0) deductions.push({ name: '소득세', amount: d.income_tax }); + if (d.resident_tax > 0) deductions.push({ name: '지방소득세', amount: d.resident_tax }); + (d.deductions || []).forEach(x => { if ((x.amount || 0) > 0) deductions.push({ name: x.name || '기타공제', amount: x.amount }); }); + + const maxRows = Math.max(payments.length, deductions.length, 8); + + let rows = ''; + for (let i = 0; i < maxRows; i++) { + rows += '' + + '' + (payments[i]?.name || '') + '' + + '' + (payments[i] ? numberFormatPayslip(payments[i].amount) : '') + '' + + '' + (deductions[i]?.name || '') + '' + + '' + (deductions[i] ? numberFormatPayslip(deductions[i].amount) : '') + '' + + ''; + } + + return '
' + + '

' + yearMonth + ' 급여명세서

' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
사원코드' + (d.employee_code || '-') + '사원명' + (d.user_name || '-') + '입사일' + (d.hire_date || '-') + '
부 서' + (d.department || '-') + '직 급' + (d.position || '-') + '호 봉
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + rows + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
지 급 내 역지 급 액공 제 내 역공 제 액
공 제 액 계' + numberFormatPayslip(d.total_deductions) + '
지 급 액 계' + numberFormatPayslip(d.gross_salary) + '차인지급액' + numberFormatPayslip(d.net_salary) + '
' + + '
' + + '귀하의 노고에 감사드립니다.' + + '㈜코드브릿지엑스(CodebridgeX)' + + '
'; + } + + function openPayslipEmailModal(id, data) { + _payslipPayrollId = id; + const email = data.user_email || '(이메일 없음)'; + document.getElementById('payslipEmailRecipient').textContent = data.user_name + ' <' + email + '>'; + document.getElementById('payslipEmailContent').innerHTML = buildPayslipHtml(data); + document.getElementById('payslipEmailStatus').textContent = ''; + document.getElementById('payslipSendBtn').disabled = false; + document.getElementById('payslipSendBtn').textContent = ''; + document.getElementById('payslipSendBtn').innerHTML = ' 이메일 전송'; + document.getElementById('payslipEmailModal').classList.remove('hidden'); + } + + function closePayslipEmailModal() { + document.getElementById('payslipEmailModal').classList.add('hidden'); + _payslipPayrollId = null; + } + + function sendPayslipEmail() { + if (!_payslipPayrollId) return; + + const btn = document.getElementById('payslipSendBtn'); + const statusEl = document.getElementById('payslipEmailStatus'); + btn.disabled = true; + btn.innerHTML = ' 발송 중...'; + statusEl.textContent = ''; + + fetch('/api/admin/hr/payrolls/' + _payslipPayrollId + '/send-payslip', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': '{{ csrf_token() }}', + 'Accept': 'application/json', + }, + }) + .then(r => r.json()) + .then(result => { + if (result.success) { + showToast(result.message, 'success'); + statusEl.textContent = result.message; + statusEl.className = 'text-sm text-emerald-600'; + btn.innerHTML = ' 전송 완료'; + } else { + showToast(result.message || '발송 실패', 'error'); + statusEl.textContent = result.message || '발송 실패'; + statusEl.className = 'text-sm text-red-600'; + btn.disabled = false; + btn.innerHTML = ' 이메일 전송'; + } + }) + .catch(err => { + console.error(err); + showToast('이메일 발송 중 오류가 발생했습니다.', 'error'); + btn.disabled = false; + btn.innerHTML = ' 이메일 전송'; + }); + } + + function printPayslipPreview() { + const content = document.getElementById('payslipEmailContent').innerHTML; + const win = window.open('', '_blank'); + win.document.write('급여명세서' + content + ''); + win.document.close(); + win.print(); + } + // ===== 토스트 메시지 ===== function showToast(message, type) { if (typeof window.showToastMessage === 'function') { diff --git a/resources/views/hr/payrolls/partials/table.blade.php b/resources/views/hr/payrolls/partials/table.blade.php index a75b4d92..673614ed 100644 --- a/resources/views/hr/payrolls/partials/table.blade.php +++ b/resources/views/hr/payrolls/partials/table.blade.php @@ -169,6 +169,41 @@ @endif + + {{-- 이메일 발송 --}} + @php + $userEmail = $payroll->user?->email ?? ''; + $hireDate = $profile?->hire_date ?? '-'; + $positionLabel = $profile?->position_label ?? '-'; + @endphp + diff --git a/routes/api.php b/routes/api.php index 046c1c7c..1decaeb3 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1244,6 +1244,7 @@ Route::post('/{id}/confirm', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'confirm'])->name('confirm'); Route::post('/{id}/unconfirm', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'unconfirm'])->name('unconfirm'); Route::post('/{id}/pay', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'pay'])->name('pay'); + Route::post('/{id}/send-payslip', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'sendPayslip'])->name('send-payslip'); }); // 급여 설정 API