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++)
+
+ | {{ $payments[$i]['name'] ?? '' }} |
+ {{ isset($payments[$i]['amount']) ? number_format($payments[$i]['amount']) : '' }} |
+ {{ $deductionItems[$i]['name'] ?? '' }} |
+ {{ isset($deductionItems[$i]['amount']) ? number_format($deductionItems[$i]['amount']) : '' }} |
+
+ @endfor
+
+ {{-- 빈 행 (최소 높이 확보) --}}
+ @for($i = $maxRows; $i < 8; $i++)
+
+ | |
+ |
+ |
+ |
+
+ @endfor
+
+ {{-- 공제액계 --}}
+
+ |
+ |
+ 공 제 액 계 |
+ {{ 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