diff --git a/app/Http/Controllers/Api/Admin/ApprovalApiController.php b/app/Http/Controllers/Api/Admin/ApprovalApiController.php index 058d2a67..5c62c03c 100644 --- a/app/Http/Controllers/Api/Admin/ApprovalApiController.php +++ b/app/Http/Controllers/Api/Admin/ApprovalApiController.php @@ -279,48 +279,17 @@ public function certInfo(int $userId): JsonResponse } /** - * 재직증명서 DOCX 생성 + * 재직증명서 PDF 다운로드 (content JSON 기반 HTML→PDF) */ - public function generateCertDocx(Request $request): JsonResponse + public function certPdf(int $id) { - $request->validate([ - 'user_id' => 'required|integer', - 'purpose' => 'required|string|max:200', - 'address' => 'nullable|string|max:500', - ]); + $approval = \App\Models\Approvals\Approval::where('tenant_id', session('selected_tenant_id')) + ->findOrFail($id); - try { - $tenantId = session('selected_tenant_id'); - $service = app(EmploymentCertService::class); - $certInfo = $service->getCertInfo($request->input('user_id'), $tenantId); + $content = $approval->content ?? []; + $service = app(EmploymentCertService::class); - // 주소가 수정된 경우 덮어쓰기 - if ($request->filled('address')) { - $certInfo['address'] = $request->input('address'); - } - $certInfo['purpose'] = $request->input('purpose'); - - $storagePath = $service->generateDocx($certInfo, $tenantId); - $displayName = '재직증명서_'.($certInfo['name'] ?? '').'_'.date('Ymd').'.docx'; - $fileRecord = $service->createFileRecord($storagePath, $displayName, $tenantId); - - return response()->json([ - 'success' => true, - 'data' => [ - 'file_id' => $fileRecord->id, - 'file_name' => $displayName, - ], - 'message' => '재직증명서가 생성되었습니다.', - ]); - } catch (\Throwable $e) { - report($e); - - return response()->json([ - 'success' => false, - 'message' => '재직증명서 생성에 실패했습니다.', - 'error' => config('app.debug') ? $e->getMessage() : null, - ], 500); - } + return $service->generatePdfResponse($content); } // ========================================================================= diff --git a/app/Services/EmploymentCertService.php b/app/Services/EmploymentCertService.php index 1029b516..3da83705 100644 --- a/app/Services/EmploymentCertService.php +++ b/app/Services/EmploymentCertService.php @@ -2,16 +2,24 @@ namespace App\Services; -use App\Models\Commons\File; use App\Models\HR\Employee; use App\Models\Tenants\Tenant; -use Illuminate\Support\Str; -use PhpOffice\PhpWord\PhpWord; -use PhpOffice\PhpWord\SimpleType\Jc; -use PhpOffice\PhpWord\SimpleType\TblWidth; +use Illuminate\Support\Facades\Log; class EmploymentCertService { + private ?string $koreanFontName = null; + + public function __construct() + { + if (! defined('K_PATH_FONTS')) { + $tcpdfFontsDir = dirname(__DIR__, 2).'/storage/fonts/tcpdf/'; + if (is_dir($tcpdfFontsDir)) { + define('K_PATH_FONTS', $tcpdfFontsDir); + } + } + } + /** * 사원의 재직증명서 정보 조회 */ @@ -47,168 +55,169 @@ public function getCertInfo(int $userId, int $tenantId): array } /** - * DOCX 생성 (PhpWord 직접 생성 - 외부 템플릿 불필요) + * content JSON 기반 PDF Response 생성 */ - public function generateDocx(array $data, int $tenantId): string + public function generatePdfResponse(array $content): \Illuminate\Http\Response { - $phpWord = new PhpWord; - $phpWord->setDefaultFontName('맑은 고딕'); - $phpWord->setDefaultFontSize(11); + $pdf = new \TCPDF('P', 'mm', 'A4', true, 'UTF-8', false); + $pdf->SetCreator('SAM'); + $pdf->SetAuthor($content['company_name'] ?? 'SAM'); + $pdf->SetTitle('재직증명서'); - $section = $phpWord->addSection([ - 'marginTop' => 1440, // 1 inch - 'marginBottom' => 1440, - 'marginLeft' => 1440, - 'marginRight' => 1440, - ]); + $pdf->setPrintHeader(false); + $pdf->setPrintFooter(false); + $pdf->SetMargins(20, 20, 20); + $pdf->SetAutoPageBreak(true, 20); - $hireDateFormatted = ''; - if (! empty($data['hire_date'])) { - try { - $hireDateFormatted = date('Y년 m월 d일', strtotime($data['hire_date'])); - } catch (\Throwable) { - $hireDateFormatted = $data['hire_date']; - } - } - $issueDateFormatted = date('Y년 m월 d일'); + $font = $this->getKoreanFont(); + + $pdf->AddPage(); // 제목 - $section->addText('재 직 증 명 서', [ - 'size' => 22, - 'bold' => true, - ], ['alignment' => Jc::CENTER, 'spaceAfter' => 400]); - - $section->addTextBreak(); + $pdf->SetFont($font, 'B', 22); + $pdf->Cell(0, 20, '재 직 증 명 서', 0, 1, 'C'); + $pdf->Ln(8); // === 1. 인적사항 === - $section->addText('1. 인적사항', ['size' => 12, 'bold' => true], ['spaceAfter' => 120]); + $pdf->SetFont($font, 'B', 12); + $pdf->Cell(0, 8, '1. 인적사항', 0, 1, 'L'); + $pdf->Ln(2); - $tableStyle = [ - 'borderSize' => 6, - 'borderColor' => '333333', - 'cellMargin' => 80, - 'width' => 100 * 50, - 'unit' => TblWidth::PERCENT, - ]; - $thStyle = ['bgColor' => 'F8F9FA', 'valign' => 'center']; - $thFont = ['size' => 10, 'bold' => true]; - $tdFont = ['size' => 10]; - - $table = $section->addTable($tableStyle); - - $table->addRow(400); - $table->addCell(1800, $thStyle)->addText('성 명', $thFont); - $table->addCell(3200)->addText($data['name'] ?? '', $tdFont); - $table->addCell(1800, $thStyle)->addText('주민등록번호', $thFont); - $table->addCell(3200)->addText($data['resident_number_full'] ?? '', $tdFont); - - $table->addRow(400); - $table->addCell(1800, $thStyle)->addText('주 소', $thFont); - $table->addCell(8200, ['gridSpan' => 3])->addText($data['address'] ?? '', $tdFont); - - $section->addTextBreak(); + $this->addTableRow($pdf, $font, [ + ['성 명', $content['name'] ?? '-', 40], + ['주민등록번호', $content['resident_number'] ?? '-', 40], + ]); + $this->addTableRow($pdf, $font, [ + ['주 소', $content['address'] ?? '-', 0], + ]); + $pdf->Ln(6); // === 2. 재직사항 === - $section->addText('2. 재직사항', ['size' => 12, 'bold' => true], ['spaceAfter' => 120]); + $pdf->SetFont($font, 'B', 12); + $pdf->Cell(0, 8, '2. 재직사항', 0, 1, 'L'); + $pdf->Ln(2); - $table2 = $section->addTable($tableStyle); + $this->addTableRow($pdf, $font, [ + ['회 사 명', $content['company_name'] ?? '-', 0], + ]); + $this->addTableRow($pdf, $font, [ + ['사업자번호', $content['business_num'] ?? '-', 0], + ]); + $this->addTableRow($pdf, $font, [ + ['근무부서', $content['department'] ?? '-', 40], + ['직 급', $content['position'] ?? '-', 40], + ]); - $table2->addRow(400); - $table2->addCell(1800, $thStyle)->addText('회 사 명', $thFont); - $table2->addCell(8200, ['gridSpan' => 3])->addText($data['company_name'] ?? '', $tdFont); - - $table2->addRow(400); - $table2->addCell(1800, $thStyle)->addText('사업자번호', $thFont); - $table2->addCell(8200, ['gridSpan' => 3])->addText($data['business_num'] ?? '', $tdFont); - - $table2->addRow(400); - $table2->addCell(1800, $thStyle)->addText('근무부서', $thFont); - $table2->addCell(3200)->addText($data['department'] ?? '', $tdFont); - $table2->addCell(1800, $thStyle)->addText('직 급', $thFont); - $table2->addCell(3200)->addText($data['position'] ?? '', $tdFont); - - $table2->addRow(400); - $table2->addCell(1800, $thStyle)->addText('재직기간', $thFont); - $table2->addCell(8200, ['gridSpan' => 3])->addText($hireDateFormatted.' ~ 현재', $tdFont); - - $section->addTextBreak(); + $hireDate = $content['hire_date'] ?? ''; + $hireDateDisplay = $hireDate ? $hireDate.' ~ 현재' : '-'; + $this->addTableRow($pdf, $font, [ + ['재직기간', $hireDateDisplay, 0], + ]); + $pdf->Ln(6); // === 3. 발급정보 === - $section->addText('3. 발급정보', ['size' => 12, 'bold' => true], ['spaceAfter' => 120]); + $pdf->SetFont($font, 'B', 12); + $pdf->Cell(0, 8, '3. 발급정보', 0, 1, 'L'); + $pdf->Ln(2); - $table3 = $section->addTable($tableStyle); - - $table3->addRow(400); - $table3->addCell(1800, $thStyle)->addText('사용용도', $thFont); - $table3->addCell(8200, ['gridSpan' => 3])->addText($data['purpose'] ?? '', $tdFont); - - $section->addTextBreak(2); + $this->addTableRow($pdf, $font, [ + ['사용용도', $content['purpose'] ?? '-', 0], + ]); + $pdf->Ln(12); // 증명 문구 - $section->addText( - '위 사항을 증명합니다.', - ['size' => 12], - ['alignment' => Jc::CENTER, 'spaceBefore' => 400, 'spaceAfter' => 400] - ); - - $section->addTextBreak(); + $pdf->SetFont($font, '', 12); + $pdf->Cell(0, 10, '위 사항을 증명합니다.', 0, 1, 'C'); + $pdf->Ln(6); // 발급일 - $section->addText( - $issueDateFormatted, - ['size' => 12, 'bold' => true], - ['alignment' => Jc::CENTER, 'spaceAfter' => 600] - ); - - $section->addTextBreak(); + $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(12); // 회사명 + 대표이사 - $section->addText( - ($data['company_name'] ?? ''), - ['size' => 14, 'bold' => true], - ['alignment' => Jc::CENTER] - ); - $section->addText( - '대표이사 '.($data['ceo_name'] ?? '').' (인)', - ['size' => 12], - ['alignment' => Jc::CENTER] - ); + $pdf->SetFont($font, 'B', 14); + $pdf->Cell(0, 10, $content['company_name'] ?? '', 0, 1, 'C'); + $pdf->SetFont($font, '', 12); + $pdf->Cell(0, 10, '대표이사 (인)', 0, 1, 'C'); - // 파일 저장 - $outputDir = storage_path("app/approvals/{$tenantId}"); - if (! is_dir($outputDir)) { - mkdir($outputDir, 0755, true); - } + $pdfContent = $pdf->Output('', 'S'); + $fileName = '재직증명서_'.($content['name'] ?? '').'.pdf'; - $storedName = Str::random(40).'.docx'; - $storagePath = "approvals/{$tenantId}/{$storedName}"; - $fullPath = storage_path("app/{$storagePath}"); - - $objWriter = \PhpOffice\PhpWord\IOFactory::createWriter($phpWord, 'Word2007'); - $objWriter->save($fullPath); - - return $storagePath; + return response($pdfContent, 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="'.$fileName.'"', + ]); } /** - * 파일 레코드 생성 및 approval에 첨부 + * 테이블 행 추가 (TCPDF) */ - public function createFileRecord(string $storagePath, string $displayName, int $tenantId): File + private function addTableRow(\TCPDF $pdf, string $font, array $cells): void { - $fullPath = storage_path("app/{$storagePath}"); + $pageWidth = $pdf->getPageWidth() - 40; // margins + $rowHeight = 8; + $thWidth = 30; - return File::create([ - 'tenant_id' => $tenantId, - 'document_type' => 'approval_attachment', - 'display_name' => $displayName, - 'original_name' => $displayName, - 'stored_name' => basename($storagePath), - 'file_path' => $storagePath, - 'mime_type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'file_size' => filesize($fullPath), - 'file_type' => 'docx', - 'is_temp' => false, - 'uploaded_by' => auth()->id(), - ]); + if (count($cells) === 1) { + // 단일 셀: th + td (전체 너비) + $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 { + // 복수 셀: th+td + th+td + $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 dde30bcf..b5e2ffa9 100644 --- a/resources/views/approvals/create.blade.php +++ b/resources/views/approvals/create.blade.php @@ -223,7 +223,6 @@ class="p-1 text-gray-400 hover:text-gray-600 transition"> let isExpenseForm = false; let isLeaveForm = false; let isCertForm = false; -let certFileId = null; // 양식코드별 표시할 유형 목록 const leaveTypesByFormCode = { @@ -499,7 +498,6 @@ function switchFormMode(formId) { } else if (code === 'employment_cert') { isCertForm = true; certContainer.style.display = ''; - certFileId = null; // 초기 사원 정보 로드 const certUserId = document.getElementById('cert-user-id').value; if (certUserId) loadCertInfo(certUserId); @@ -641,35 +639,6 @@ function applyBodyTemplate(formId) { issue_date: document.getElementById('cert-issue-date').value, }; formBody = null; - - // DOCX 생성 - if (!certFileId) { - showToast('재직증명서 DOCX를 생성 중입니다...', 'info'); - try { - const certResp = await fetch('/api/admin/approvals/generate-cert-docx', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' }, - body: JSON.stringify({ - user_id: formContent.cert_user_id, - purpose: purpose, - address: formContent.address, - }), - }); - const certData = await certResp.json(); - if (certData.success) { - certFileId = certData.data.file_id; - } else { - showToast(certData.message || 'DOCX 생성 실패', 'error'); - return; - } - } catch (e) { - showToast('DOCX 생성 중 오류가 발생했습니다.', 'error'); - return; - } - } - if (certFileId) { - attachmentFileIds.push(certFileId); - } } const payload = { @@ -872,7 +841,6 @@ function closeExpenseLoadModal() { document.getElementById('cert-department').value = d.department || ''; document.getElementById('cert-position').value = d.position || ''; document.getElementById('cert-hire-date').value = d.hire_date ? d.hire_date + ' ~' : ''; - certFileId = null; // 사원 변경 시 DOCX 재생성 필요 } else { showToast(data.message || '사원 정보를 불러올 수 없습니다.', 'error'); } @@ -890,7 +858,6 @@ function onCertPurposeChange() { } else { customWrap.style.display = 'none'; } - certFileId = null; // 용도 변경 시 DOCX 재생성 필요 } function getCertPurpose() { diff --git a/resources/views/approvals/edit.blade.php b/resources/views/approvals/edit.blade.php index 905265c5..1390f382 100644 --- a/resources/views/approvals/edit.blade.php +++ b/resources/views/approvals/edit.blade.php @@ -260,7 +260,6 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm fon const linesData = @json($lines); let isExpenseForm = false; let isCertForm = false; -let certFileId = null; function escapeHtml(str) { if (!str) return ''; @@ -440,7 +439,6 @@ function switchFormMode(formId) { } else if (code === 'employment_cert') { isCertForm = true; certContainer.style.display = ''; - certFileId = null; const certUserId = document.getElementById('cert-user-id').value; if (certUserId) loadCertInfo(certUserId); } else { @@ -602,35 +600,6 @@ function applyBodyTemplate(formId) { issue_date: document.getElementById('cert-issue-date').value, }; formBody = null; - - // DOCX 생성 - if (!certFileId) { - showToast('재직증명서 DOCX를 생성 중입니다...', 'info'); - try { - const certResp = await fetch('/api/admin/approvals/generate-cert-docx', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' }, - body: JSON.stringify({ - user_id: formContent.cert_user_id, - purpose: purpose, - address: formContent.address, - }), - }); - const certData = await certResp.json(); - if (certData.success) { - certFileId = certData.data.file_id; - } else { - showToast(certData.message || 'DOCX 생성 실패', 'error'); - return; - } - } catch (e) { - showToast('DOCX 생성 중 오류가 발생했습니다.', 'error'); - return; - } - } - if (certFileId) { - attachmentFileIds.push(certFileId); - } } const payload = { @@ -735,7 +704,6 @@ function applyBodyTemplate(formId) { document.getElementById('cert-department').value = d.department || ''; document.getElementById('cert-position').value = d.position || ''; document.getElementById('cert-hire-date').value = d.hire_date ? d.hire_date + ' ~' : ''; - certFileId = null; } else { showToast(data.message || '사원 정보를 불러올 수 없습니다.', 'error'); } @@ -753,7 +721,6 @@ function onCertPurposeChange() { } else { customWrap.style.display = 'none'; } - certFileId = null; } function getCertPurpose() { diff --git a/resources/views/approvals/partials/_certificate-show.blade.php b/resources/views/approvals/partials/_certificate-show.blade.php index 7f777f92..689d93a6 100644 --- a/resources/views/approvals/partials/_certificate-show.blade.php +++ b/resources/views/approvals/partials/_certificate-show.blade.php @@ -4,8 +4,8 @@ $content (array) - approvals.content JSON --}}