diff --git a/app/Http/Controllers/Api/Admin/ApprovalApiController.php b/app/Http/Controllers/Api/Admin/ApprovalApiController.php index 5c62c03c..9c64d91e 100644 --- a/app/Http/Controllers/Api/Admin/ApprovalApiController.php +++ b/app/Http/Controllers/Api/Admin/ApprovalApiController.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Controller; use App\Models\Boards\File; use App\Services\ApprovalService; +use App\Services\CareerCertService; use App\Services\EmploymentCertService; use App\Services\GoogleCloudStorageService; use Illuminate\Http\JsonResponse; @@ -292,6 +293,43 @@ public function certPdf(int $id) return $service->generatePdfResponse($content); } + // ========================================================================= + // 경력증명서 + // ========================================================================= + + /** + * 사원 경력증명서 정보 조회 + */ + public function careerCertInfo(int $userId): JsonResponse + { + try { + $tenantId = session('selected_tenant_id'); + $service = app(CareerCertService::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 careerCertPdf(int $id) + { + $approval = \App\Models\Approvals\Approval::where('tenant_id', session('selected_tenant_id')) + ->findOrFail($id); + + $content = $approval->content ?? []; + $service = app(CareerCertService::class); + + return $service->generatePdfResponse($content); + } + // ========================================================================= // 워크플로우 // ========================================================================= diff --git a/app/Services/CareerCertService.php b/app/Services/CareerCertService.php new file mode 100644 index 00000000..501d5fa9 --- /dev/null +++ b/app/Services/CareerCertService.php @@ -0,0 +1,236 @@ +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 ?? ''); + + // 생년월일 추출 (주민번호 앞 6자리) + $residentNumber = $employee->resident_number ?? ''; + $birthDate = ''; + if (strlen($residentNumber) >= 6) { + $yy = substr($residentNumber, 0, 2); + $mm = substr($residentNumber, 2, 2); + $dd = substr($residentNumber, 4, 2); + // 7번째 자리로 세기 판단 + $century = '19'; + if (strlen($residentNumber) >= 7) { + $genderDigit = substr($residentNumber, 7, 1); // 하이픈 뒤 첫째 자리 + if (in_array($genderDigit, ['3', '4'])) { + $century = '20'; + } + } + $birthDate = $century.$yy.'-'.$mm.'-'.$dd; + } + + return [ + 'name' => $employee->user->name ?? $employee->display_name ?? '', + 'birth_date' => $birthDate, + 'address' => $employee->address ?? '', + 'department' => $employee->department?->name ?? '', + 'position' => $employee->position_label ?? '', + 'hire_date' => $employee->hire_date ?? '', + 'resign_date' => $employee->resign_date ?? '', + 'job_description' => '', + 'company_name' => $companyName, + 'business_num' => $tenant->business_num ?? '', + 'ceo_name' => $tenant->ceo_name ?? '', + 'phone' => $tenant->phone ?? '', + 'company_address' => $tenant->address ?? '', + ]; + } + + /** + * 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); + + // === 1. 인적사항 === + $pdf->SetFont($font, 'B', 12); + $pdf->Cell(0, 8, '1. 인적사항', 0, 1, 'L'); + $pdf->Ln(2); + + $this->addTableRow($pdf, $font, [ + ['성 명', $content['name'] ?? '-', 40], + ['생년월일', $content['birth_date'] ?? '-', 40], + ]); + $this->addTableRow($pdf, $font, [ + ['주 소', $content['address'] ?? '-', 0], + ]); + $pdf->Ln(6); + + // === 2. 경력사항 === + $pdf->SetFont($font, 'B', 12); + $pdf->Cell(0, 8, '2. 경력사항', 0, 1, 'L'); + $pdf->Ln(2); + + $this->addTableRow($pdf, $font, [ + ['회 사 명', $content['company_name'] ?? '-', 40], + ['사업자번호', $content['business_num'] ?? '-', 40], + ]); + $this->addTableRow($pdf, $font, [ + ['대 표 자', $content['ceo_name'] ?? '-', 40], + ['대표전화', $content['phone'] ?? '-', 40], + ]); + $this->addTableRow($pdf, $font, [ + ['소 재 지', $content['company_address'] ?? '-', 0], + ]); + $this->addTableRow($pdf, $font, [ + ['소속부서', $content['department'] ?? '-', 40], + ['직위/직급', $content['position'] ?? '-', 40], + ]); + + $hireDate = $content['hire_date'] ?? ''; + $resignDate = $content['resign_date'] ?? ''; + $periodDisplay = $hireDate ? $hireDate.' ~ '.($resignDate ?: '현재') : '-'; + $this->addTableRow($pdf, $font, [ + ['근무기간', $periodDisplay, 0], + ]); + $this->addTableRow($pdf, $font, [ + ['담당업무', $content['job_description'] ?? '-', 0], + ]); + $this->addTableRow($pdf, $font, [ + ['사용용도', $content['purpose'] ?? '-', 0], + ]); + $pdf->Ln(12); + + // 증명 문구 + $pdf->SetFont($font, '', 12); + $pdf->Cell(0, 10, '위 사람은 당사에 재직(근무) 하였음을 증명합니다.', 0, 1, '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(12); + + // 회사명 + 대표이사 + $ceoName = $content['ceo_name'] ?? ''; + $pdf->SetFont($font, 'B', 14); + $pdf->Cell(0, 10, ($content['company_name'] ?? '').' 대표이사 '.$ceoName.' (인)', 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 b5e2ffa9..ba9bce15 100644 --- a/resources/views/approvals/create.blade.php +++ b/resources/views/approvals/create.blade.php @@ -100,6 +100,11 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline- 'employees' => $employees ?? collect(), ]) + {{-- 경력증명서 전용 폼 --}} + @include('approvals.partials._career-cert-form', [ + 'employees' => $employees ?? collect(), + ]) + {{-- 지출결의서 전용 폼 --}} @include('approvals.partials._expense-form', [ 'initialData' => [], @@ -223,6 +228,7 @@ class="p-1 text-gray-400 hover:text-gray-600 transition"> let isExpenseForm = false; let isLeaveForm = false; let isCertForm = false; +let isCareerCertForm = false; // 양식코드별 표시할 유형 목록 const leaveTypesByFormCode = { @@ -466,6 +472,7 @@ function switchFormMode(formId) { const expenseContainer = document.getElementById('expense-form-container'); const leaveContainer = document.getElementById('leave-form-container'); const certContainer = document.getElementById('cert-form-container'); + const careerCertContainer = document.getElementById('career-cert-form-container'); const bodyArea = document.getElementById('body-area'); const expenseLoadBtn = document.getElementById('expense-load-btn'); @@ -475,11 +482,13 @@ function switchFormMode(formId) { expenseContainer.style.display = 'none'; leaveContainer.style.display = 'none'; certContainer.style.display = 'none'; + careerCertContainer.style.display = 'none'; expenseLoadBtn.style.display = 'none'; bodyArea.style.display = 'none'; isExpenseForm = false; isLeaveForm = false; isCertForm = false; + isCareerCertForm = false; if (code === 'expense') { isExpenseForm = true; @@ -498,9 +507,13 @@ function switchFormMode(formId) { } else if (code === 'employment_cert') { isCertForm = true; certContainer.style.display = ''; - // 초기 사원 정보 로드 const certUserId = document.getElementById('cert-user-id').value; if (certUserId) loadCertInfo(certUserId); + } else if (code === 'career_cert') { + isCareerCertForm = true; + careerCertContainer.style.display = ''; + const ccUserId = document.getElementById('cc-user-id').value; + if (ccUserId) loadCareerCertInfo(ccUserId); } else { bodyArea.style.display = ''; } @@ -511,7 +524,7 @@ function applyBodyTemplate(formId) { switchFormMode(formId); // 전용 폼이면 제목만 자동 설정하고 body template 적용 건너뜀 - if (isExpenseForm || isLeaveForm || isCertForm) { + if (isExpenseForm || isLeaveForm || isCertForm || isCareerCertForm) { const titleEl = document.getElementById('title'); if (!titleEl.value.trim()) { const formSelect = document.getElementById('form_id'); @@ -639,6 +652,32 @@ function applyBodyTemplate(formId) { issue_date: document.getElementById('cert-issue-date').value, }; formBody = null; + } else if (isCareerCertForm) { + const purpose = getCareerCertPurpose(); + if (!purpose) { + showToast('사용용도를 입력해주세요.', 'warning'); + return; + } + + formContent = { + cert_user_id: document.getElementById('cc-user-id').value, + name: document.getElementById('cc-name').value, + birth_date: document.getElementById('cc-birth-date').value, + address: document.getElementById('cc-address').value, + department: document.getElementById('cc-department').value, + position: document.getElementById('cc-position').value, + hire_date: document.getElementById('cc-hire-date').value, + resign_date: document.getElementById('cc-resign-date').value, + job_description: document.getElementById('cc-job-description').value, + company_name: document.getElementById('cc-company').value, + business_num: document.getElementById('cc-business-num').value, + ceo_name: document.getElementById('cc-ceo-name').value, + phone: document.getElementById('cc-phone').value, + company_address: document.getElementById('cc-company-address').value, + purpose: purpose, + issue_date: document.getElementById('cc-issue-date').value, + }; + formBody = null; } const payload = { @@ -906,6 +945,182 @@ function printCertPreview() { win.onload = function() { win.print(); }; } +// ========================================================================= +// 경력증명서 관련 함수 +// ========================================================================= + +async function loadCareerCertInfo(userId) { + if (!userId) return; + + try { + const resp = await fetch(`/api/admin/approvals/career-cert-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('cc-name').value = d.name || ''; + document.getElementById('cc-birth-date').value = d.birth_date || ''; + document.getElementById('cc-address').value = d.address || ''; + document.getElementById('cc-company').value = d.company_name || ''; + document.getElementById('cc-business-num').value = d.business_num || ''; + document.getElementById('cc-ceo-name').value = d.ceo_name || ''; + document.getElementById('cc-phone').value = d.phone || ''; + document.getElementById('cc-company-address').value = d.company_address || ''; + document.getElementById('cc-department').value = d.department || ''; + document.getElementById('cc-position').value = d.position || ''; + document.getElementById('cc-hire-date').value = d.hire_date || ''; + document.getElementById('cc-resign-date').value = d.resign_date || ''; + document.getElementById('cc-job-description').value = d.job_description || ''; + } else { + showToast(data.message || '사원 정보를 불러올 수 없습니다.', 'error'); + } + } catch (e) { + showToast('사원 정보 조회 중 오류가 발생했습니다.', 'error'); + } +} + +function onCareerCertPurposeChange() { + const sel = document.getElementById('cc-purpose-select'); + const customWrap = document.getElementById('cc-purpose-custom-wrap'); + if (sel.value === '__custom__') { + customWrap.style.display = ''; + document.getElementById('cc-purpose-custom').focus(); + } else { + customWrap.style.display = 'none'; + } +} + +function getCareerCertPurpose() { + const sel = document.getElementById('cc-purpose-select'); + if (sel.value === '__custom__') { + return document.getElementById('cc-purpose-custom').value.trim(); + } + return sel.value; +} + +function openCareerCertPreview() { + const name = document.getElementById('cc-name').value || '-'; + const birthDate = document.getElementById('cc-birth-date').value || '-'; + const address = document.getElementById('cc-address').value || '-'; + const company = document.getElementById('cc-company').value || '-'; + const businessNum = document.getElementById('cc-business-num').value || '-'; + const ceoName = document.getElementById('cc-ceo-name').value || '-'; + const phone = document.getElementById('cc-phone').value || '-'; + const companyAddress = document.getElementById('cc-company-address').value || '-'; + const department = document.getElementById('cc-department').value || '-'; + const position = document.getElementById('cc-position').value || '-'; + const hireDate = document.getElementById('cc-hire-date').value || '-'; + const resignDate = document.getElementById('cc-resign-date').value || '현재'; + const jobDescription = document.getElementById('cc-job-description').value || '-'; + const purpose = getCareerCertPurpose() || '-'; + const issueDate = document.getElementById('cc-issue-date').value || '-'; + const issueDateFormatted = issueDate !== '-' ? issueDate.replace(/-/g, function(m, i) { return i === 4 ? '년 ' : '월 '; }) + '일' : '-'; + + const el = document.getElementById('career-cert-preview-content'); + el.innerHTML = buildCareerCertPreviewHtml({ name, birthDate, address, company, businessNum, ceoName, phone, companyAddress, department, position, hireDate, resignDate, jobDescription, purpose, issueDateFormatted }); + + document.getElementById('career-cert-preview-modal').style.display = ''; + document.body.style.overflow = 'hidden'; +} + +function closeCareerCertPreview() { + document.getElementById('career-cert-preview-modal').style.display = 'none'; + document.body.style.overflow = ''; +} + +function printCareerCertPreview() { + const content = document.getElementById('career-cert-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 buildCareerCertPreviewHtml(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 ` +

경 력 증 명 서

+ +

1. 인적사항

+ + + + + + + + + + + +
성 명${e(d.name)}생년월일${e(d.birthDate)}
주 소${e(d.address)}
+ +

2. 경력사항

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
회 사 명${e(d.company)}사업자번호${e(d.businessNum)}
대 표 자${e(d.ceoName)}대표전화${e(d.phone)}
소 재 지${e(d.companyAddress)}
소속부서${e(d.department)}직위/직급${e(d.position)}
근무기간${e(d.hireDate)} ~ ${e(d.resignDate)}
담당업무${e(d.jobDescription)}
+ +

3. 발급정보

+ + + + + +
사용용도${e(d.purpose)}
+ +

+ 위 사람은 당사에 재직(근무) 하였음을 증명합니다. +

+ +

+ ${e(d.issueDateFormatted)} +

+ +
+

${e(d.company)}

+

대표이사    ${e(d.ceoName)}    (인)

+
+ `; +} + 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/_career-cert-form.blade.php b/resources/views/approvals/partials/_career-cert-form.blade.php new file mode 100644 index 00000000..180e6879 --- /dev/null +++ b/resources/views/approvals/partials/_career-cert-form.blade.php @@ -0,0 +1,195 @@ +{{-- + 경력증명서 전용 폼 + Props: + $employees (Collection) - 활성 사원 목록 +--}} +@php + $employees = $employees ?? collect(); + $currentUserId = auth()->id(); +@endphp + + + +{{-- 경력증명서 미리보기 모달 --}} + diff --git a/resources/views/approvals/partials/_career-cert-show.blade.php b/resources/views/approvals/partials/_career-cert-show.blade.php new file mode 100644 index 00000000..47d858ca --- /dev/null +++ b/resources/views/approvals/partials/_career-cert-show.blade.php @@ -0,0 +1,135 @@ +{{-- + 경력증명서 읽기전용 렌더링 + Props: + $content (array) - approvals.content JSON +--}} +
+ {{-- 미리보기 / PDF 다운로드 버튼 --}} +
+ + + + + + PDF 다운로드 + +
+ + {{-- 인적사항 --}} +
+
+

1. 인적사항

+
+
+
+
+ 성명 +
{{ $content['name'] ?? '-' }}
+
+
+ 생년월일 +
{{ $content['birth_date'] ?? '-' }}
+
+
+ 주소 +
{{ $content['address'] ?? '-' }}
+
+
+
+
+ + {{-- 경력사항 --}} +
+
+

2. 경력사항

+
+
+
+
+ 회사명 +
{{ $content['company_name'] ?? '-' }}
+
+
+ 사업자번호 +
{{ $content['business_num'] ?? '-' }}
+
+
+ 소속부서 +
{{ $content['department'] ?? '-' }}
+
+
+ 직위/직급 +
{{ $content['position'] ?? '-' }}
+
+
+ 근무기간 +
+ {{ $content['hire_date'] ?? '-' }} ~ {{ $content['resign_date'] ?? '현재' }} +
+
+
+ 담당업무 +
{{ $content['job_description'] ?? '-' }}
+
+
+
+
+ + {{-- 발급정보 --}} +
+
+

3. 발급정보

+
+
+
+
+ 사용용도 +
{{ $content['purpose'] ?? '-' }}
+
+
+ 발급일 +
{{ $content['issue_date'] ?? '-' }}
+
+
+
+
+
+ +{{-- 미리보기 모달 --}} + diff --git a/resources/views/approvals/show.blade.php b/resources/views/approvals/show.blade.php index b36520a2..e1bef106 100644 --- a/resources/views/approvals/show.blade.php +++ b/resources/views/approvals/show.blade.php @@ -84,6 +84,8 @@ class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition @include('approvals.partials._expense-show', ['content' => $approval->content]) @elseif(!empty($approval->content) && $approval->form?->code === 'employment_cert') @include('approvals.partials._certificate-show', ['content' => $approval->content]) + @elseif(!empty($approval->content) && $approval->form?->code === 'career_cert') + @include('approvals.partials._career-cert-show', ['content' => $approval->content]) @elseif($approval->body && preg_match('/<[a-z][\s\S]*>/i', $approval->body))
{!! strip_tags($approval->body, '