feat: [payroll] 급여명세서 이메일 발송 기능 추가
- PayslipMail Mailable 클래스 생성 (admin@codebridge-x.com 발송) - 급여명세서 이메일 템플릿 (전통 한국식 양식) - 이메일 발송 API 엔드포인트 추가 (POST /payrolls/{id}/send-payslip) - 목록 테이블에 이메일 발송 아이콘 버튼 추가 - 급여명세서 미리보기 모달 + 인쇄 기능
This commit is contained in:
@@ -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)
|
||||
*/
|
||||
|
||||
41
app/Mail/PayslipMail.php
Normal file
41
app/Mail/PayslipMail.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class PayslipMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public array $payslipData
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$year = $this->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 [];
|
||||
}
|
||||
}
|
||||
@@ -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})에게 급여명세서를 발송했습니다.",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 설정 수정
|
||||
*/
|
||||
|
||||
106
resources/views/emails/payslip.blade.php
Normal file
106
resources/views/emails/payslip.blade.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
|
||||
.container { max-width: 720px; margin: 0 auto; background: #fff; padding: 40px 36px; }
|
||||
h1 { text-align: center; font-size: 22px; font-weight: 800; letter-spacing: 2px; margin: 0 0 28px; border-bottom: 3px solid #333; padding-bottom: 16px; }
|
||||
.info-table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
|
||||
.info-table td { padding: 6px 10px; font-size: 13px; border: 1px solid #999; }
|
||||
.info-table .label { font-weight: 600; background: #f0f0f0; width: 70px; white-space: nowrap; }
|
||||
.info-table .value { min-width: 100px; }
|
||||
.payslip-table { width: 100%; border-collapse: collapse; margin-bottom: 0; }
|
||||
.payslip-table th, .payslip-table td { border: 1px solid #999; padding: 7px 12px; font-size: 13px; }
|
||||
.payslip-table th { background: #e8e8e8; font-weight: 700; text-align: center; letter-spacing: 4px; }
|
||||
.payslip-table .item-name { text-align: center; }
|
||||
.payslip-table .item-amount { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.payslip-table .total-row td { font-weight: 700; background: #f8f8f8; }
|
||||
.payslip-table .net-row td { font-weight: 800; font-size: 14px; background: #f0f7ff; }
|
||||
.footer { display: flex; justify-content: space-between; align-items: center; margin-top: 20px; padding-top: 12px; font-size: 12px; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>{{ $payslipData['pay_year'] }}년{{ str_pad($payslipData['pay_month'], 2, '0', STR_PAD_LEFT) }}월분 급여명세서</h1>
|
||||
|
||||
{{-- 사원 정보 --}}
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<td class="label">사원코드</td>
|
||||
<td class="value">{{ $payslipData['employee_code'] ?? '-' }}</td>
|
||||
<td class="label">사원명</td>
|
||||
<td class="value">{{ $payslipData['employee_name'] ?? '-' }}</td>
|
||||
<td class="label">입사일</td>
|
||||
<td class="value">{{ $payslipData['hire_date'] ?? '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">부 서</td>
|
||||
<td class="value">{{ $payslipData['department'] ?? '-' }}</td>
|
||||
<td class="label">직 급</td>
|
||||
<td class="value">{{ $payslipData['position'] ?? '-' }}</td>
|
||||
<td class="label">호 봉</td>
|
||||
<td class="value">{{ $payslipData['grade'] ?? '-' }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{{-- 지급/공제 내역 --}}
|
||||
@php
|
||||
$payments = $payslipData['payments'] ?? [];
|
||||
$deductionItems = $payslipData['deduction_items'] ?? [];
|
||||
$maxRows = max(count($payments), count($deductionItems));
|
||||
@endphp
|
||||
<table class="payslip-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 25%;">지 급 내 역</th>
|
||||
<th style="width: 25%;">지 급 액</th>
|
||||
<th style="width: 25%;">공 제 내 역</th>
|
||||
<th style="width: 25%;">공 제 액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for($i = 0; $i < $maxRows; $i++)
|
||||
<tr>
|
||||
<td class="item-name">{{ $payments[$i]['name'] ?? '' }}</td>
|
||||
<td class="item-amount">{{ isset($payments[$i]['amount']) ? number_format($payments[$i]['amount']) : '' }}</td>
|
||||
<td class="item-name">{{ $deductionItems[$i]['name'] ?? '' }}</td>
|
||||
<td class="item-amount">{{ isset($deductionItems[$i]['amount']) ? number_format($deductionItems[$i]['amount']) : '' }}</td>
|
||||
</tr>
|
||||
@endfor
|
||||
|
||||
{{-- 빈 행 (최소 높이 확보) --}}
|
||||
@for($i = $maxRows; $i < 8; $i++)
|
||||
<tr>
|
||||
<td class="item-name"> </td>
|
||||
<td class="item-amount"></td>
|
||||
<td class="item-name"></td>
|
||||
<td class="item-amount"></td>
|
||||
</tr>
|
||||
@endfor
|
||||
|
||||
{{-- 공제액계 --}}
|
||||
<tr class="total-row">
|
||||
<td class="item-name"></td>
|
||||
<td class="item-amount"></td>
|
||||
<td class="item-name" style="font-weight: 700; letter-spacing: 4px;">공 제 액 계</td>
|
||||
<td class="item-amount">{{ number_format($payslipData['total_deductions'] ?? 0) }}</td>
|
||||
</tr>
|
||||
|
||||
{{-- 지급액계 / 차인지급액 --}}
|
||||
<tr class="net-row">
|
||||
<td class="item-name" style="letter-spacing: 4px;">지 급 액 계</td>
|
||||
<td class="item-amount">{{ number_format($payslipData['gross_salary'] ?? 0) }}</td>
|
||||
<td class="item-name" style="letter-spacing: 2px;">차인지급액</td>
|
||||
<td class="item-amount">{{ number_format($payslipData['net_salary'] ?? 0) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="footer">
|
||||
<span>귀하의 노고에 감사드립니다.</span>
|
||||
<span>㈜코드브릿지엑스(CodebridgeX)</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -797,6 +797,50 @@ class="px-5 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 급여명세서 이메일 모달 --}}
|
||||
<div id="payslipEmailModal" class="fixed inset-0 z-50 hidden">
|
||||
<div class="fixed inset-0 bg-black/40" onclick="closePayslipEmailModal()"></div>
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4 overflow-y-auto">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full relative my-8" style="max-width: 820px;" onclick="event.stopPropagation()">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-lg font-semibold text-gray-800">급여명세서 이메일 발송</h3>
|
||||
<span id="payslipEmailRecipient" class="text-sm text-gray-500"></span>
|
||||
</div>
|
||||
<button type="button" onclick="closePayslipEmailModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="payslipEmailContent" class="px-6 py-4 overflow-y-auto" style="max-height: 70vh;"></div>
|
||||
<div class="flex items-center justify-between px-6 py-4 border-t border-gray-200">
|
||||
<div id="payslipEmailStatus" class="text-sm text-gray-500"></div>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" onclick="printPayslipPreview()"
|
||||
class="px-4 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors inline-flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"/>
|
||||
</svg>
|
||||
인쇄
|
||||
</button>
|
||||
<button type="button" onclick="closePayslipEmailModal()"
|
||||
class="px-4 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors">
|
||||
취소
|
||||
</button>
|
||||
<button type="button" id="payslipSendBtn" onclick="sendPayslipEmail()"
|
||||
class="px-5 py-2 text-sm text-white bg-violet-600 hover:bg-violet-700 rounded-lg transition-colors inline-flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
이메일 전송
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@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 += '<tr>' +
|
||||
'<td style="border:1px solid #999;padding:7px 12px;text-align:center;font-size:13px;">' + (payments[i]?.name || '') + '</td>' +
|
||||
'<td style="border:1px solid #999;padding:7px 12px;text-align:right;font-size:13px;font-variant-numeric:tabular-nums;">' + (payments[i] ? numberFormatPayslip(payments[i].amount) : '') + '</td>' +
|
||||
'<td style="border:1px solid #999;padding:7px 12px;text-align:center;font-size:13px;">' + (deductions[i]?.name || '') + '</td>' +
|
||||
'<td style="border:1px solid #999;padding:7px 12px;text-align:right;font-size:13px;font-variant-numeric:tabular-nums;">' + (deductions[i] ? numberFormatPayslip(deductions[i].amount) : '') + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
return '<div style="max-width:720px;margin:0 auto;font-family:Malgun Gothic,Apple SD Gothic Neo,sans-serif;">' +
|
||||
'<h1 style="text-align:center;font-size:22px;font-weight:800;letter-spacing:2px;margin:0 0 28px;border-bottom:3px solid #333;padding-bottom:16px;">' + yearMonth + ' 급여명세서</h1>' +
|
||||
'<table style="width:100%;border-collapse:collapse;margin-bottom:20px;">' +
|
||||
'<tr><td style="border:1px solid #999;padding:6px 10px;font-weight:600;background:#f0f0f0;width:70px;font-size:13px;">사원코드</td><td style="border:1px solid #999;padding:6px 10px;font-size:13px;min-width:100px;">' + (d.employee_code || '-') + '</td>' +
|
||||
'<td style="border:1px solid #999;padding:6px 10px;font-weight:600;background:#f0f0f0;width:70px;font-size:13px;">사원명</td><td style="border:1px solid #999;padding:6px 10px;font-size:13px;min-width:100px;">' + (d.user_name || '-') + '</td>' +
|
||||
'<td style="border:1px solid #999;padding:6px 10px;font-weight:600;background:#f0f0f0;width:70px;font-size:13px;">입사일</td><td style="border:1px solid #999;padding:6px 10px;font-size:13px;min-width:100px;">' + (d.hire_date || '-') + '</td></tr>' +
|
||||
'<tr><td style="border:1px solid #999;padding:6px 10px;font-weight:600;background:#f0f0f0;font-size:13px;">부 서</td><td style="border:1px solid #999;padding:6px 10px;font-size:13px;">' + (d.department || '-') + '</td>' +
|
||||
'<td style="border:1px solid #999;padding:6px 10px;font-weight:600;background:#f0f0f0;font-size:13px;">직 급</td><td style="border:1px solid #999;padding:6px 10px;font-size:13px;">' + (d.position || '-') + '</td>' +
|
||||
'<td style="border:1px solid #999;padding:6px 10px;font-weight:600;background:#f0f0f0;font-size:13px;">호 봉</td><td style="border:1px solid #999;padding:6px 10px;font-size:13px;"></td></tr>' +
|
||||
'</table>' +
|
||||
'<table style="width:100%;border-collapse:collapse;">' +
|
||||
'<thead><tr>' +
|
||||
'<th style="border:1px solid #999;padding:7px 12px;background:#e8e8e8;font-weight:700;text-align:center;letter-spacing:4px;font-size:13px;width:25%;">지 급 내 역</th>' +
|
||||
'<th style="border:1px solid #999;padding:7px 12px;background:#e8e8e8;font-weight:700;text-align:center;letter-spacing:4px;font-size:13px;width:25%;">지 급 액</th>' +
|
||||
'<th style="border:1px solid #999;padding:7px 12px;background:#e8e8e8;font-weight:700;text-align:center;letter-spacing:4px;font-size:13px;width:25%;">공 제 내 역</th>' +
|
||||
'<th style="border:1px solid #999;padding:7px 12px;background:#e8e8e8;font-weight:700;text-align:center;letter-spacing:4px;font-size:13px;width:25%;">공 제 액</th>' +
|
||||
'</tr></thead><tbody>' +
|
||||
rows +
|
||||
'<tr><td style="border:1px solid #999;padding:7px 12px;font-size:13px;"></td><td style="border:1px solid #999;padding:7px 12px;font-size:13px;"></td>' +
|
||||
'<td style="border:1px solid #999;padding:7px 12px;text-align:center;font-weight:700;letter-spacing:4px;font-size:13px;background:#f8f8f8;">공 제 액 계</td>' +
|
||||
'<td style="border:1px solid #999;padding:7px 12px;text-align:right;font-weight:700;font-size:13px;background:#f8f8f8;font-variant-numeric:tabular-nums;">' + numberFormatPayslip(d.total_deductions) + '</td></tr>' +
|
||||
'<tr><td style="border:1px solid #999;padding:7px 12px;text-align:center;font-weight:800;letter-spacing:4px;font-size:14px;background:#f0f7ff;">지 급 액 계</td>' +
|
||||
'<td style="border:1px solid #999;padding:7px 12px;text-align:right;font-weight:800;font-size:14px;background:#f0f7ff;font-variant-numeric:tabular-nums;">' + numberFormatPayslip(d.gross_salary) + '</td>' +
|
||||
'<td style="border:1px solid #999;padding:7px 12px;text-align:center;font-weight:800;letter-spacing:2px;font-size:14px;background:#f0f7ff;">차인지급액</td>' +
|
||||
'<td style="border:1px solid #999;padding:7px 12px;text-align:right;font-weight:800;font-size:14px;background:#f0f7ff;font-variant-numeric:tabular-nums;">' + numberFormatPayslip(d.net_salary) + '</td></tr>' +
|
||||
'</tbody></table>' +
|
||||
'<div style="display:flex;justify-content:space-between;align-items:center;margin-top:20px;padding-top:12px;font-size:12px;color:#666;">' +
|
||||
'<span>귀하의 노고에 감사드립니다.</span>' +
|
||||
'<span>㈜코드브릿지엑스(CodebridgeX)</span>' +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
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 = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg> 이메일 전송';
|
||||
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 = '<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg> 발송 중...';
|
||||
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 = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> 전송 완료';
|
||||
} else {
|
||||
showToast(result.message || '발송 실패', 'error');
|
||||
statusEl.textContent = result.message || '발송 실패';
|
||||
statusEl.className = 'text-sm text-red-600';
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg> 이메일 전송';
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
showToast('이메일 발송 중 오류가 발생했습니다.', 'error');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg> 이메일 전송';
|
||||
});
|
||||
}
|
||||
|
||||
function printPayslipPreview() {
|
||||
const content = document.getElementById('payslipEmailContent').innerHTML;
|
||||
const win = window.open('', '_blank');
|
||||
win.document.write('<html><head><title>급여명세서</title><style>body{font-family:"Malgun Gothic","Apple SD Gothic Neo",sans-serif;padding:40px 36px;}@media print{body{padding:20px 30px;}}</style></head><body>' + content + '</body></html>');
|
||||
win.document.close();
|
||||
win.print();
|
||||
}
|
||||
|
||||
// ===== 토스트 메시지 =====
|
||||
function showToast(message, type) {
|
||||
if (typeof window.showToastMessage === 'function') {
|
||||
|
||||
@@ -169,6 +169,41 @@
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
|
||||
{{-- 이메일 발송 --}}
|
||||
@php
|
||||
$userEmail = $payroll->user?->email ?? '';
|
||||
$hireDate = $profile?->hire_date ?? '-';
|
||||
$positionLabel = $profile?->position_label ?? '-';
|
||||
@endphp
|
||||
<button type="button" onclick='openPayslipEmailModal({{ $payroll->id }}, @json([
|
||||
"user_name" => $displayName,
|
||||
"user_email" => $userEmail,
|
||||
"department" => $department?->name ?? "-",
|
||||
"position" => $positionLabel,
|
||||
"hire_date" => $hireDate,
|
||||
"employee_code" => $payroll->user_id,
|
||||
"pay_year" => $payroll->pay_year,
|
||||
"pay_month" => $payroll->pay_month,
|
||||
"base_salary" => (int) $payroll->base_salary,
|
||||
"overtime_pay" => (int) $payroll->overtime_pay,
|
||||
"bonus" => (int) $payroll->bonus,
|
||||
"allowances" => $payroll->allowances,
|
||||
"gross_salary" => (int) $payroll->gross_salary,
|
||||
"pension" => (int) $payroll->pension,
|
||||
"health_insurance" => (int) $payroll->health_insurance,
|
||||
"employment_insurance" => (int) $payroll->employment_insurance,
|
||||
"long_term_care" => (int) $payroll->long_term_care,
|
||||
"income_tax" => (int) $payroll->income_tax,
|
||||
"resident_tax" => (int) $payroll->resident_tax,
|
||||
"deductions" => $payroll->deductions,
|
||||
"total_deductions" => (int) $payroll->total_deductions,
|
||||
"net_salary" => (int) $payroll->net_salary,
|
||||
]))' class="p-1 text-violet-600 hover:text-violet-800" title="급여명세서 이메일 발송">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user