diff --git a/app/Http/Controllers/Api/Admin/ApprovalApiController.php b/app/Http/Controllers/Api/Admin/ApprovalApiController.php index 083ffdac..ddb19e62 100644 --- a/app/Http/Controllers/Api/Admin/ApprovalApiController.php +++ b/app/Http/Controllers/Api/Admin/ApprovalApiController.php @@ -9,6 +9,7 @@ use App\Services\CareerCertService; use App\Services\EmploymentCertService; use App\Services\GoogleCloudStorageService; +use App\Services\ResignationService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; @@ -368,6 +369,43 @@ public function appointmentCertPdf(int $id) return $service->generatePdfResponse($content); } + // ========================================================================= + // 사직서 + // ========================================================================= + + /** + * 사원 사직서 정보 조회 + */ + public function resignationInfo(int $userId): JsonResponse + { + try { + $tenantId = session('selected_tenant_id'); + $service = app(ResignationService::class); + $data = $service->getCertInfo($userId, $tenantId); + + return response()->json(['success' => true, 'data' => $data]); + } catch (\Throwable $e) { + return response()->json([ + 'success' => false, + 'message' => '사원 정보를 불러올 수 없습니다.', + ], 400); + } + } + + /** + * 사직서 PDF 다운로드 + */ + public function resignationPdf(int $id) + { + $approval = \App\Models\Approvals\Approval::where('tenant_id', session('selected_tenant_id')) + ->findOrFail($id); + + $content = $approval->content ?? []; + $service = app(ResignationService::class); + + return $service->generatePdfResponse($content); + } + // ========================================================================= // 워크플로우 // ========================================================================= diff --git a/app/Services/ResignationService.php b/app/Services/ResignationService.php new file mode 100644 index 00000000..165cb118 --- /dev/null +++ b/app/Services/ResignationService.php @@ -0,0 +1,201 @@ +where('tenant_id', $tenantId) + ->where('user_id', $userId) + ->with(['user', 'department']) + ->firstOrFail(); + + $tenant = Tenant::findOrFail($tenantId); + + $displaySetting = TenantSetting::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('setting_group', 'company') + ->where('setting_key', 'display_company_name') + ->first(); + $displayName = $displaySetting?->setting_value ?? ''; + $companyName = ! empty($displayName) ? $displayName : ($tenant->company_name ?? ''); + + // 주민등록번호 마스킹 + $residentNumber = $employee->resident_number ?? ''; + $maskedResident = ''; + if (strlen($residentNumber) >= 8) { + $front = substr($residentNumber, 0, 6); + $back = substr($residentNumber, 7, 1); + $maskedResident = $front.'-'.$back.'******'; + } elseif (! empty($residentNumber)) { + $maskedResident = $residentNumber; + } + + return [ + 'name' => $employee->user->name ?? $employee->display_name ?? '', + 'resident_number' => $maskedResident, + 'department' => $employee->department?->name ?? '', + 'position' => $employee->position_label ?? '', + 'hire_date' => $employee->hire_date ?? '', + 'address' => $employee->address ?? '', + 'company_name' => $companyName, + 'ceo_name' => $tenant->ceo_name ?? '', + ]; + } + + /** + * content JSON 기반 PDF Response 생성 + */ + public function generatePdfResponse(array $content): \Illuminate\Http\Response + { + $pdf = new \TCPDF('P', 'mm', 'A4', true, 'UTF-8', false); + $pdf->SetCreator('SAM'); + $pdf->SetAuthor($content['company_name'] ?? 'SAM'); + $pdf->SetTitle('사직서'); + + $pdf->setPrintHeader(false); + $pdf->setPrintFooter(false); + $pdf->SetMargins(20, 20, 20); + $pdf->SetAutoPageBreak(true, 20); + + $font = $this->getKoreanFont(); + + $pdf->AddPage(); + + // 제목 + $pdf->SetFont($font, 'B', 22); + $pdf->Cell(0, 20, '사 직 서', 0, 1, 'C'); + $pdf->Ln(8); + + // 테이블 + $this->addTableRow($pdf, $font, [ + ['소 속', $content['department'] ?? '-', 40], + ['직 위', $content['position'] ?? '-', 40], + ]); + $this->addTableRow($pdf, $font, [ + ['성 명', $content['name'] ?? '-', 40], + ['주민등록번호', $content['resident_number'] ?? '-', 40], + ]); + $this->addTableRow($pdf, $font, [ + ['입사일', $content['hire_date'] ?? '-', 40], + ['퇴사(예정)일', $content['resign_date'] ?? '-', 40], + ]); + $this->addTableRow($pdf, $font, [ + ['주 소', $content['address'] ?? '-', 0], + ]); + $this->addTableRow($pdf, $font, [ + ['사 유', $content['reason'] ?? '-', 0], + ]); + $pdf->Ln(12); + + // 증명 문구 + $pdf->SetFont($font, '', 12); + $pdf->MultiCell(0, 8, '상기 본인은 위 사유로 인하여 사직하고자'."\n".'이에 사직서를 제출하오니 허가하여 주시기 바랍니다.', 0, 'C'); + $pdf->Ln(6); + + // 날짜 + $issueDate = $content['issue_date'] ?? date('Y-m-d'); + $issueDateFormatted = $this->formatDate($issueDate); + $pdf->SetFont($font, 'B', 12); + $pdf->Cell(0, 10, $issueDateFormatted, 0, 1, 'C'); + $pdf->Ln(6); + + // 신청인 + $pdf->SetFont($font, '', 12); + $pdf->Cell(0, 10, '신청인 '.($content['name'] ?? '').' (인)', 0, 1, 'C'); + $pdf->Ln(12); + + // 회사명 + 대표이사 귀하 + $ceoName = $content['ceo_name'] ?? ''; + $pdf->SetFont($font, 'B', 14); + $pdf->Cell(0, 10, ($content['company_name'] ?? '').' 대표이사 귀하', 0, 1, 'C'); + + $pdfContent = $pdf->Output('', 'S'); + $fileName = '사직서_'.($content['name'] ?? '').'.pdf'; + + return response($pdfContent, 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="'.$fileName.'"', + ]); + } + + private function addTableRow(\TCPDF $pdf, string $font, array $cells): void + { + $pageWidth = $pdf->getPageWidth() - 40; + $rowHeight = 8; + $thWidth = 30; + + if (count($cells) === 1) { + $pdf->SetFont($font, 'B', 10); + $pdf->SetFillColor(248, 249, 250); + $pdf->Cell($thWidth, $rowHeight, $cells[0][0], 1, 0, 'L', true); + $pdf->SetFont($font, '', 10); + $pdf->Cell($pageWidth - $thWidth, $rowHeight, $cells[0][1], 1, 1, 'L'); + } else { + $tdWidth = ($pageWidth - $thWidth * 2) / 2; + foreach ($cells as $cell) { + $pdf->SetFont($font, 'B', 10); + $pdf->SetFillColor(248, 249, 250); + $pdf->Cell($thWidth, $rowHeight, $cell[0], 1, 0, 'L', true); + $pdf->SetFont($font, '', 10); + $pdf->Cell($tdWidth, $rowHeight, $cell[1], 1, 0, 'L'); + } + $pdf->Ln(); + } + } + + private function formatDate(string $date): string + { + try { + return date('Y년 m월 d일', strtotime($date)); + } catch (\Throwable) { + return $date; + } + } + + private function getKoreanFont(): string + { + if ($this->koreanFontName) { + return $this->koreanFontName; + } + + if (defined('K_PATH_FONTS') && file_exists(K_PATH_FONTS.'pretendard.php')) { + $this->koreanFontName = 'pretendard'; + + return $this->koreanFontName; + } + + $fontPath = storage_path('fonts/Pretendard-Regular.ttf'); + if (file_exists($fontPath)) { + try { + $this->koreanFontName = \TCPDF_FONTS::addTTFfont($fontPath, 'TrueTypeUnicode', '', 96); + } catch (\Throwable $e) { + Log::warning('TCPDF 한글 폰트 등록 실패', ['error' => $e->getMessage()]); + } + } + + return $this->koreanFontName ?: 'helvetica'; + } +} diff --git a/resources/views/approvals/create.blade.php b/resources/views/approvals/create.blade.php index d5cffc23..11e23655 100644 --- a/resources/views/approvals/create.blade.php +++ b/resources/views/approvals/create.blade.php @@ -110,6 +110,11 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline- 'employees' => $employees ?? collect(), ]) + {{-- 사직서 전용 폼 --}} + @include('approvals.partials._resignation-form', [ + 'employees' => $employees ?? collect(), + ]) + {{-- 지출결의서 전용 폼 --}} @include('approvals.partials._expense-form', [ 'initialData' => [], @@ -235,6 +240,7 @@ class="p-1 text-gray-400 hover:text-gray-600 transition"> let isCertForm = false; let isCareerCertForm = false; let isAppointmentCertForm = false; +let isResignationForm = false; // 양식코드별 표시할 유형 목록 const leaveTypesByFormCode = { @@ -480,6 +486,7 @@ function switchFormMode(formId) { const certContainer = document.getElementById('cert-form-container'); const careerCertContainer = document.getElementById('career-cert-form-container'); const appointmentCertContainer = document.getElementById('appointment-cert-form-container'); + const resignationContainer = document.getElementById('resignation-form-container'); const bodyArea = document.getElementById('body-area'); const expenseLoadBtn = document.getElementById('expense-load-btn'); @@ -491,6 +498,7 @@ function switchFormMode(formId) { certContainer.style.display = 'none'; careerCertContainer.style.display = 'none'; appointmentCertContainer.style.display = 'none'; + resignationContainer.style.display = 'none'; expenseLoadBtn.style.display = 'none'; bodyArea.style.display = 'none'; isExpenseForm = false; @@ -498,6 +506,7 @@ function switchFormMode(formId) { isCertForm = false; isCareerCertForm = false; isAppointmentCertForm = false; + isResignationForm = false; if (code === 'expense') { isExpenseForm = true; @@ -528,6 +537,11 @@ function switchFormMode(formId) { appointmentCertContainer.style.display = ''; const acUserId = document.getElementById('ac-user-id').value; if (acUserId) loadAppointmentCertInfo(acUserId); + } else if (code === 'resignation') { + isResignationForm = true; + resignationContainer.style.display = ''; + const rgUserId = document.getElementById('rg-user-id').value; + if (rgUserId) loadResignationInfo(rgUserId); } else { bodyArea.style.display = ''; } @@ -538,7 +552,7 @@ function applyBodyTemplate(formId) { switchFormMode(formId); // 전용 폼이면 제목을 양식명으로 설정하고 body template 적용 건너뜀 - if (isExpenseForm || isLeaveForm || isCertForm || isCareerCertForm || isAppointmentCertForm) { + if (isExpenseForm || isLeaveForm || isCertForm || isCareerCertForm || isAppointmentCertForm || isResignationForm) { const titleEl = document.getElementById('title'); const formSelect = document.getElementById('form_id'); titleEl.value = formSelect.options[formSelect.selectedIndex].text; @@ -712,6 +726,33 @@ function applyBodyTemplate(formId) { issue_date: document.getElementById('ac-issue-date').value, }; formBody = null; + } else if (isResignationForm) { + const reason = getResignationReason(); + if (!reason) { + showToast('사직사유를 입력해주세요.', 'warning'); + return; + } + const resignDate = document.getElementById('rg-resign-date').value; + if (!resignDate) { + showToast('퇴사(예정)일을 입력해주세요.', 'warning'); + return; + } + + formContent = { + cert_user_id: document.getElementById('rg-user-id').value, + name: document.getElementById('rg-name').value, + resident_number: document.getElementById('rg-resident').value, + department: document.getElementById('rg-department').value, + position: document.getElementById('rg-position').value, + hire_date: document.getElementById('rg-hire-date').value, + resign_date: resignDate, + address: document.getElementById('rg-address').value, + reason: reason, + company_name: document.getElementById('rg-company-name').value, + ceo_name: document.getElementById('rg-ceo-name').value, + issue_date: document.getElementById('rg-issue-date').value, + }; + formBody = null; } const payload = { @@ -1294,6 +1335,150 @@ function buildAppointmentCertPreviewHtml(d) { `; } +// ========================================================================= +// 사직서 관련 함수 +// ========================================================================= + +async function loadResignationInfo(userId) { + if (!userId) return; + + try { + const resp = await fetch(`/api/admin/approvals/resignation-info/${userId}`, { + headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' } + }); + const data = await resp.json(); + + if (data.success) { + const d = data.data; + document.getElementById('rg-name').value = d.name || ''; + document.getElementById('rg-resident').value = d.resident_number || ''; + document.getElementById('rg-department').value = d.department || ''; + document.getElementById('rg-position').value = d.position || ''; + document.getElementById('rg-hire-date').value = d.hire_date || ''; + document.getElementById('rg-address').value = d.address || ''; + document.getElementById('rg-company-name').value = d.company_name || ''; + document.getElementById('rg-ceo-name').value = d.ceo_name || ''; + } else { + showToast(data.message || '사원 정보를 불러올 수 없습니다.', 'error'); + } + } catch (e) { + showToast('사원 정보 조회 중 오류가 발생했습니다.', 'error'); + } +} + +function onResignationReasonChange() { + const sel = document.getElementById('rg-reason-select'); + const customWrap = document.getElementById('rg-reason-custom-wrap'); + if (sel.value === '__custom__') { + customWrap.style.display = ''; + document.getElementById('rg-reason-custom').focus(); + } else { + customWrap.style.display = 'none'; + } +} + +function getResignationReason() { + const sel = document.getElementById('rg-reason-select'); + if (sel.value === '__custom__') { + return document.getElementById('rg-reason-custom').value.trim(); + } + return sel.value; +} + +function openResignationPreview() { + const department = document.getElementById('rg-department').value || '-'; + const position = document.getElementById('rg-position').value || '-'; + const name = document.getElementById('rg-name').value || '-'; + const resident = document.getElementById('rg-resident').value || '-'; + const hireDate = document.getElementById('rg-hire-date').value || '-'; + const resignDate = document.getElementById('rg-resign-date').value || '-'; + const address = document.getElementById('rg-address').value || '-'; + const reason = getResignationReason() || '-'; + const issueDate = document.getElementById('rg-issue-date').value || '-'; + const issueDateFormatted = issueDate !== '-' ? issueDate.replace(/-/g, function(m, i) { return i === 4 ? '년 ' : '월 '; }) + '일' : '-'; + const company = document.getElementById('rg-company-name').value || '-'; + const ceoName = document.getElementById('rg-ceo-name').value || '-'; + + const el = document.getElementById('resignation-preview-content'); + el.innerHTML = buildResignationPreviewHtml({ department, position, name, resident, hireDate, resignDate, address, reason, issueDateFormatted, company, ceoName }); + + document.getElementById('resignation-preview-modal').style.display = ''; + document.body.style.overflow = 'hidden'; +} + +function closeResignationPreview() { + document.getElementById('resignation-preview-modal').style.display = 'none'; + document.body.style.overflow = ''; +} + +function printResignationPreview() { + const content = document.getElementById('resignation-preview-content').innerHTML; + const win = window.open('', '_blank', 'width=800,height=1000'); + win.document.write('사직서'); + win.document.write(''); + win.document.write(''); + win.document.write(content); + win.document.write(''); + win.document.close(); + win.onload = function() { win.print(); }; +} + +function buildResignationPreviewHtml(d) { + const e = (s) => { const div = document.createElement('div'); div.textContent = s; return div.innerHTML; }; + const thStyle = 'border:1px solid #333; padding:10px 14px; background:#f8f9fa; font-weight:600; text-align:left; white-space:nowrap; width:120px; font-size:14px;'; + const tdStyle = 'border:1px solid #333; padding:10px 14px; font-size:14px;'; + + return ` +

사 직 서

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
소 속${e(d.department)}직 위${e(d.position)}
성 명${e(d.name)}주민등록번호${e(d.resident)}
입사일${e(d.hireDate)}퇴사(예정)일${e(d.resignDate)}
주 소${e(d.address)}
사 유${e(d.reason)}
+ +

+ 상기 본인은 위 사유로 인하여 사직하고자
+ 이에 사직서를 제출하오니 허가하여 주시기 바랍니다. +

+ +

+ ${e(d.issueDateFormatted)} +

+ +

+ 신청인    ${e(d.name)}    (인) +

+ +
+

${e(d.company)}    대표이사 귀하

+
+ `; +} + function buildCertPreviewHtml(d) { const e = (s) => { const div = document.createElement('div'); div.textContent = s; return div.innerHTML; }; const thStyle = 'border:1px solid #333; padding:10px 14px; background:#f8f9fa; font-weight:600; text-align:left; white-space:nowrap; width:120px; font-size:14px;'; diff --git a/resources/views/approvals/partials/_resignation-form.blade.php b/resources/views/approvals/partials/_resignation-form.blade.php new file mode 100644 index 00000000..137b4f98 --- /dev/null +++ b/resources/views/approvals/partials/_resignation-form.blade.php @@ -0,0 +1,156 @@ +{{-- + 사직서 전용 폼 + Props: + $employees (Collection) - 활성 사원 목록 +--}} +@php + $employees = $employees ?? collect(); + $currentUserId = auth()->id(); +@endphp + + + +{{-- 사직서 미리보기 모달 --}} + diff --git a/resources/views/approvals/partials/_resignation-show.blade.php b/resources/views/approvals/partials/_resignation-show.blade.php new file mode 100644 index 00000000..95e8c3da --- /dev/null +++ b/resources/views/approvals/partials/_resignation-show.blade.php @@ -0,0 +1,105 @@ +{{-- + 사직서 읽기전용 렌더링 + Props: + $content (array) - approvals.content JSON +--}} +
+ {{-- 미리보기 / PDF 다운로드 버튼 --}} +
+ + + + + + PDF 다운로드 + +
+ + {{-- 인적사항 --}} +
+
+

인적사항

+
+
+
+
+ 소속 +
{{ $content['department'] ?? '-' }}
+
+
+ 직위 +
{{ $content['position'] ?? '-' }}
+
+
+ 성명 +
{{ $content['name'] ?? '-' }}
+
+
+ 주민등록번호 +
{{ $content['resident_number'] ?? '-' }}
+
+
+ 입사일 +
{{ $content['hire_date'] ?? '-' }}
+
+
+ 퇴사(예정)일 +
{{ $content['resign_date'] ?? '-' }}
+
+
+ 주소 +
{{ $content['address'] ?? '-' }}
+
+
+
+
+ + {{-- 사직사유 --}} +
+
+

사직사유

+
+
+
{{ $content['reason'] ?? '-' }}
+
+
+
+ +{{-- 미리보기 모달 --}} + diff --git a/resources/views/approvals/show.blade.php b/resources/views/approvals/show.blade.php index 3d3916b5..29043132 100644 --- a/resources/views/approvals/show.blade.php +++ b/resources/views/approvals/show.blade.php @@ -88,6 +88,8 @@ class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition @include('approvals.partials._career-cert-show', ['content' => $approval->content]) @elseif(!empty($approval->content) && $approval->form?->code === 'appointment_cert') @include('approvals.partials._appointment-cert-show', ['content' => $approval->content]) + @elseif(!empty($approval->content) && $approval->form?->code === 'resignation') + @include('approvals.partials._resignation-show', ['content' => $approval->content]) @elseif($approval->body && preg_match('/<[a-z][\s\S]*>/i', $approval->body))
{!! strip_tags($approval->body, '