feat: [approval] 경력증명서 기안/조회/PDF 기능 추가

- CareerCertService: 사원 경력정보 조회 + TCPDF PDF 생성
- 기안 작성 폼: 사원 선택, 인적/경력/발급 정보, 미리보기
- 상세 조회: 읽기전용 렌더링 + 미리보기/PDF 다운로드
- API: career-cert-info, career-cert-pdf 엔드포인트
This commit is contained in:
김보곤
2026-03-05 23:41:20 +09:00
parent 7a277c6986
commit 2bf13cc886
7 changed files with 910 additions and 2 deletions

View File

@@ -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);
}
// =========================================================================
// 워크플로우
// =========================================================================

View File

@@ -0,0 +1,236 @@
<?php
namespace App\Services;
use App\Models\HR\Employee;
use App\Models\Tenants\Tenant;
use App\Models\Tenants\TenantSetting;
use Illuminate\Support\Facades\Log;
class CareerCertService
{
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);
}
}
}
/**
* 사원의 경력증명서 정보 조회
*/
public function getCertInfo(int $userId, int $tenantId): array
{
$employee = Employee::withoutGlobalScopes()
->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';
}
}

View File

@@ -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('<html><head><title>경력증명서</title>');
win.document.write('<style>body{font-family:"Pretendard","Malgun Gothic",sans-serif;padding:48px 56px;margin:0;} table{border-collapse:collapse;width:100%;} th,td{border:1px solid #333;padding:10px 14px;font-size:14px;} th{background:#f8f9fa;font-weight:600;text-align:left;white-space:nowrap;width:120px;} @media print{body{padding:40px 48px;}}</style>');
win.document.write('</head><body>');
win.document.write(content);
win.document.write('</body></html>');
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 `
<h1 style="text-align:center; font-size:28px; font-weight:700; letter-spacing:12px; margin-bottom:40px;">경 력 증 명 서</h1>
<h3 style="font-size:15px; font-weight:600; margin:24px 0 10px; color:#333;">1. 인적사항</h3>
<table style="border-collapse:collapse; width:100%; margin-bottom:20px;">
<tr>
<th style="${thStyle}">성 명</th>
<td style="${tdStyle}">${e(d.name)}</td>
<th style="${thStyle}">생년월일</th>
<td style="${tdStyle}">${e(d.birthDate)}</td>
</tr>
<tr>
<th style="${thStyle}">주 소</th>
<td style="${tdStyle}" colspan="3">${e(d.address)}</td>
</tr>
</table>
<h3 style="font-size:15px; font-weight:600; margin:24px 0 10px; color:#333;">2. 경력사항</h3>
<table style="border-collapse:collapse; width:100%; margin-bottom:20px;">
<tr>
<th style="${thStyle}">회 사 명</th>
<td style="${tdStyle}">${e(d.company)}</td>
<th style="${thStyle}">사업자번호</th>
<td style="${tdStyle}">${e(d.businessNum)}</td>
</tr>
<tr>
<th style="${thStyle}">대 표 자</th>
<td style="${tdStyle}">${e(d.ceoName)}</td>
<th style="${thStyle}">대표전화</th>
<td style="${tdStyle}">${e(d.phone)}</td>
</tr>
<tr>
<th style="${thStyle}">소 재 지</th>
<td style="${tdStyle}" colspan="3">${e(d.companyAddress)}</td>
</tr>
<tr>
<th style="${thStyle}">소속부서</th>
<td style="${tdStyle}">${e(d.department)}</td>
<th style="${thStyle}">직위/직급</th>
<td style="${tdStyle}">${e(d.position)}</td>
</tr>
<tr>
<th style="${thStyle}">근무기간</th>
<td style="${tdStyle}" colspan="3">${e(d.hireDate)} ~ ${e(d.resignDate)}</td>
</tr>
<tr>
<th style="${thStyle}">담당업무</th>
<td style="${tdStyle}" colspan="3">${e(d.jobDescription)}</td>
</tr>
</table>
<h3 style="font-size:15px; font-weight:600; margin:24px 0 10px; color:#333;">3. 발급정보</h3>
<table style="border-collapse:collapse; width:100%; margin-bottom:32px;">
<tr>
<th style="${thStyle}">사용용도</th>
<td style="${tdStyle}" colspan="3">${e(d.purpose)}</td>
</tr>
</table>
<p style="text-align:center; font-size:15px; line-height:2; margin:36px 0;">
위 사람은 당사에 재직(근무) 하였음을 증명합니다.
</p>
<p style="text-align:center; font-size:15px; font-weight:500; margin-bottom:48px;">
${e(d.issueDateFormatted)}
</p>
<div style="text-align:center; margin-top:32px;">
<p style="font-size:16px; font-weight:600; margin-bottom:8px;">${e(d.company)}</p>
<p style="font-size:14px; color:#555;">대표이사 &nbsp;&nbsp; ${e(d.ceoName)} &nbsp;&nbsp; (인)</p>
</div>
`;
}
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;';

View File

@@ -0,0 +1,195 @@
{{--
경력증명서 전용
Props:
$employees (Collection) - 활성 사원 목록
--}}
@php
$employees = $employees ?? collect();
$currentUserId = auth()->id();
@endphp
<div id="career-cert-form-container" style="display: none;" class="mb-4">
<div class="space-y-4">
{{-- 대상 사원 --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">대상 사원 <span class="text-red-500">*</span></label>
<div class="flex items-center gap-2">
<select id="cc-user-id" onchange="loadCareerCertInfo(this.value)"
class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
style="max-width: 300px;">
@foreach($employees as $emp)
<option value="{{ $emp->user_id }}" {{ $emp->user_id == $currentUserId ? 'selected' : '' }}>
{{ $emp->display_name }}
</option>
@endforeach
</select>
<button type="button" onclick="openCareerCertPreview()"
class="shrink-0 px-3 py-2 bg-indigo-50 text-indigo-600 hover:bg-indigo-100 border border-indigo-200 rounded-lg text-sm font-medium transition 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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
미리보기
</button>
</div>
</div>
{{-- 인적사항 --}}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="bg-gray-50 px-4 py-2 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-700">1. 인적사항</h3>
</div>
<div class="p-4 space-y-3">
<div class="flex gap-4 flex-wrap">
<div style="flex: 1 1 200px; max-width: 300px;">
<label class="block text-xs font-medium text-gray-500 mb-1">성명</label>
<input type="text" id="cc-name" readonly
class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 text-gray-700">
</div>
<div style="flex: 1 1 200px; max-width: 300px;">
<label class="block text-xs font-medium text-gray-500 mb-1">생년월일</label>
<input type="text" id="cc-birth-date" readonly
class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 text-gray-700">
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">주소 <span class="text-blue-500 text-xs">(수정 가능)</span></label>
<input type="text" id="cc-address"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
</div>
{{-- 경력사항 --}}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="bg-gray-50 px-4 py-2 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-700">2. 경력사항</h3>
</div>
<div class="p-4 space-y-3">
<div class="flex gap-4 flex-wrap">
<div style="flex: 1 1 250px;">
<label class="block text-xs font-medium text-gray-500 mb-1">회사명</label>
<input type="text" id="cc-company" readonly
class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 text-gray-700">
</div>
<div style="flex: 1 1 200px; max-width: 250px;">
<label class="block text-xs font-medium text-gray-500 mb-1">사업자번호</label>
<input type="text" id="cc-business-num" readonly
class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 text-gray-700">
</div>
</div>
<div class="flex gap-4 flex-wrap">
<div style="flex: 1 1 200px; max-width: 250px;">
<label class="block text-xs font-medium text-gray-500 mb-1">대표자</label>
<input type="text" id="cc-ceo-name" readonly
class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 text-gray-700">
</div>
<div style="flex: 1 1 200px; max-width: 250px;">
<label class="block text-xs font-medium text-gray-500 mb-1">대표전화</label>
<input type="text" id="cc-phone" readonly
class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 text-gray-700">
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">소재지</label>
<input type="text" id="cc-company-address" readonly
class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 text-gray-700">
</div>
<div class="flex gap-4 flex-wrap">
<div style="flex: 1 1 200px; max-width: 200px;">
<label class="block text-xs font-medium text-gray-500 mb-1">소속부서</label>
<input type="text" id="cc-department" readonly
class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 text-gray-700">
</div>
<div style="flex: 1 1 200px; max-width: 200px;">
<label class="block text-xs font-medium text-gray-500 mb-1">직위/직급</label>
<input type="text" id="cc-position" readonly
class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 text-gray-700">
</div>
</div>
<div class="flex gap-4 flex-wrap">
<div style="flex: 1 1 200px; max-width: 200px;">
<label class="block text-xs font-medium text-gray-500 mb-1">근무기간 (시작)</label>
<input type="text" id="cc-hire-date" readonly
class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 text-gray-700">
</div>
<div style="flex: 1 1 200px; max-width: 200px;">
<label class="block text-xs font-medium text-gray-500 mb-1">근무기간 (종료) <span class="text-blue-500 text-xs">(수정 가능)</span></label>
<input type="date" id="cc-resign-date"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">담당업무 <span class="text-blue-500 text-xs">(수정 가능)</span></label>
<input type="text" id="cc-job-description" placeholder="예: 소프트웨어 개발, 프로젝트 관리 등"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
</div>
{{-- 발급정보 --}}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="bg-gray-50 px-4 py-2 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-700">3. 발급정보</h3>
</div>
<div class="p-4 space-y-3">
<div class="flex gap-4 flex-wrap">
<div style="flex: 1 1 250px; max-width: 300px;">
<label class="block text-xs font-medium text-gray-500 mb-1">사용용도 <span class="text-red-500">*</span></label>
<select id="cc-purpose-select" onchange="onCareerCertPurposeChange()"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="은행 제출용">은행 제출용</option>
<option value="관공서 제출용">관공서 제출용</option>
<option value="비자 신청용">비자 신청용</option>
<option value="대출 신청용">대출 신청용</option>
<option value="이직 제출용">이직 제출용</option>
<option value="__custom__">기타 (직접입력)</option>
</select>
</div>
<div id="cc-purpose-custom-wrap" style="flex: 1 1 250px; max-width: 300px; display: none;">
<label class="block text-xs font-medium text-gray-500 mb-1">직접입력</label>
<input type="text" id="cc-purpose-custom" placeholder="용도를 입력하세요"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<div style="max-width: 200px;">
<label class="block text-xs font-medium text-gray-500 mb-1">발급일</label>
<input type="text" id="cc-issue-date" readonly
class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-gray-50 text-gray-700"
value="{{ now()->format('Y-m-d') }}">
</div>
</div>
</div>
</div>
</div>
{{-- 경력증명서 미리보기 모달 --}}
<div id="career-cert-preview-modal" style="display: none;" class="fixed inset-0 z-50">
<div class="absolute inset-0 bg-black/50" onclick="closeCareerCertPreview()"></div>
<div class="relative flex items-center justify-center min-h-full p-4">
<div class="bg-white rounded-xl shadow-2xl w-full overflow-hidden relative" style="max-width: 700px;">
<div class="flex items-center justify-between px-5 py-3 border-b border-gray-200 bg-gray-50">
<h3 class="text-base font-semibold text-gray-800">경력증명서 미리보기</h3>
<div class="flex items-center gap-2">
<button type="button" onclick="printCareerCertPreview()"
class="px-3 py-1.5 bg-white border border-gray-300 hover:bg-gray-50 rounded-lg text-xs font-medium transition inline-flex items-center gap-1">
<svg class="w-3.5 h-3.5" 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="closeCareerCertPreview()"
class="p-1 text-gray-400 hover:text-gray-600 transition">
<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>
<div class="overflow-y-auto" style="max-height: 80vh;">
<div id="career-cert-preview-content" style="padding: 48px 56px; font-family: 'Pretendard', 'Malgun Gothic', sans-serif;">
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,135 @@
{{--
경력증명서 읽기전용 렌더링
Props:
$content (array) - approvals.content JSON
--}}
<div class="space-y-4">
{{-- 미리보기 / PDF 다운로드 버튼 --}}
<div class="flex justify-end gap-2">
<button type="button" onclick="openCareerCertShowPreview()"
class="px-3 py-1.5 bg-indigo-50 text-indigo-600 hover:bg-indigo-100 border border-indigo-200 rounded-lg text-sm font-medium transition 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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
증명서 미리보기
</button>
<a href="{{ route('api.admin.approvals.career-cert-pdf', $approval->id) }}" target="_blank"
class="px-3 py-1.5 bg-green-50 text-green-600 hover:bg-green-100 border border-green-200 rounded-lg text-sm font-medium transition 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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
PDF 다운로드
</a>
</div>
{{-- 인적사항 --}}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="bg-gray-50 px-4 py-2 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-700">1. 인적사항</h3>
</div>
<div class="p-4">
<div class="grid gap-3" style="grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));">
<div>
<span class="text-xs text-gray-500">성명</span>
<div class="text-sm font-medium mt-0.5">{{ $content['name'] ?? '-' }}</div>
</div>
<div>
<span class="text-xs text-gray-500">생년월일</span>
<div class="text-sm font-medium mt-0.5">{{ $content['birth_date'] ?? '-' }}</div>
</div>
<div style="grid-column: 1 / -1;">
<span class="text-xs text-gray-500">주소</span>
<div class="text-sm font-medium mt-0.5">{{ $content['address'] ?? '-' }}</div>
</div>
</div>
</div>
</div>
{{-- 경력사항 --}}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="bg-gray-50 px-4 py-2 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-700">2. 경력사항</h3>
</div>
<div class="p-4">
<div class="grid gap-3" style="grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));">
<div>
<span class="text-xs text-gray-500">회사명</span>
<div class="text-sm font-medium mt-0.5">{{ $content['company_name'] ?? '-' }}</div>
</div>
<div>
<span class="text-xs text-gray-500">사업자번호</span>
<div class="text-sm font-medium mt-0.5">{{ $content['business_num'] ?? '-' }}</div>
</div>
<div>
<span class="text-xs text-gray-500">소속부서</span>
<div class="text-sm font-medium mt-0.5">{{ $content['department'] ?? '-' }}</div>
</div>
<div>
<span class="text-xs text-gray-500">직위/직급</span>
<div class="text-sm font-medium mt-0.5">{{ $content['position'] ?? '-' }}</div>
</div>
<div>
<span class="text-xs text-gray-500">근무기간</span>
<div class="text-sm font-medium mt-0.5">
{{ $content['hire_date'] ?? '-' }} ~ {{ $content['resign_date'] ?? '현재' }}
</div>
</div>
<div>
<span class="text-xs text-gray-500">담당업무</span>
<div class="text-sm font-medium mt-0.5">{{ $content['job_description'] ?? '-' }}</div>
</div>
</div>
</div>
</div>
{{-- 발급정보 --}}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="bg-gray-50 px-4 py-2 border-b border-gray-200">
<h3 class="text-sm font-semibold text-gray-700">3. 발급정보</h3>
</div>
<div class="p-4">
<div class="grid gap-3" style="grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));">
<div>
<span class="text-xs text-gray-500">사용용도</span>
<div class="text-sm font-medium mt-0.5">{{ $content['purpose'] ?? '-' }}</div>
</div>
<div>
<span class="text-xs text-gray-500">발급일</span>
<div class="text-sm font-medium mt-0.5">{{ $content['issue_date'] ?? '-' }}</div>
</div>
</div>
</div>
</div>
</div>
{{-- 미리보기 모달 --}}
<div id="career-cert-show-preview-modal" style="display: none;" class="fixed inset-0 z-50">
<div class="absolute inset-0 bg-black/50" onclick="closeCareerCertShowPreview()"></div>
<div class="relative flex items-center justify-center min-h-full p-4">
<div class="bg-white rounded-xl shadow-2xl w-full overflow-hidden relative" style="max-width: 700px;">
<div class="flex items-center justify-between px-5 py-3 border-b border-gray-200 bg-gray-50">
<h3 class="text-base font-semibold text-gray-800">경력증명서 미리보기</h3>
<div class="flex items-center gap-2">
<button type="button" onclick="printCareerCertShowPreview()"
class="px-3 py-1.5 bg-white border border-gray-300 hover:bg-gray-50 rounded-lg text-xs font-medium transition inline-flex items-center gap-1">
<svg class="w-3.5 h-3.5" 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="closeCareerCertShowPreview()"
class="p-1 text-gray-400 hover:text-gray-600 transition">
<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>
<div class="overflow-y-auto" style="max-height: 80vh;">
<div id="career-cert-show-preview-content" style="padding: 48px 56px; font-family: 'Pretendard', 'Malgun Gothic', sans-serif;">
</div>
</div>
</div>
</div>
</div>

View File

@@ -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))
<div class="prose prose-sm max-w-none text-gray-700">
{!! strip_tags($approval->body, '<p><br><strong><b><em><i><u><s><del><h1><h2><h3><h4><h5><h6><ul><ol><li><blockquote><pre><code><a><span><div><table><thead><tbody><tr><th><td>') !!}
@@ -489,6 +491,89 @@ class="bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded-lg transition te
// 재직증명서 미리보기 (show 페이지용)
// =========================================================================
@if(!empty($approval->content) && $approval->form?->code === 'career_cert')
const _careerCertContent = @json($approval->content);
function openCareerCertShowPreview() {
const c = _careerCertContent;
const issueDate = c.issue_date || '';
const issueDateFormatted = issueDate ? issueDate.replace(/-/g, function(m, i) { return i === 4 ? '년 ' : '월 '; }) + '일' : '-';
const el = document.getElementById('career-cert-show-preview-content');
el.innerHTML = _buildCareerCertHtml({
name: c.name || '-',
birthDate: c.birth_date || '-',
address: c.address || '-',
company: c.company_name || '-',
businessNum: c.business_num || '-',
ceoName: c.ceo_name || '-',
phone: c.phone || '-',
companyAddress: c.company_address || '-',
department: c.department || '-',
position: c.position || '-',
hireDate: c.hire_date || '-',
resignDate: c.resign_date || '현재',
jobDescription: c.job_description || '-',
purpose: c.purpose || '-',
issueDateFormatted: issueDateFormatted,
});
document.getElementById('career-cert-show-preview-modal').style.display = '';
document.body.style.overflow = 'hidden';
}
function closeCareerCertShowPreview() {
document.getElementById('career-cert-show-preview-modal').style.display = 'none';
document.body.style.overflow = '';
}
function printCareerCertShowPreview() {
const content = document.getElementById('career-cert-show-preview-content').innerHTML;
const win = window.open('', '_blank', 'width=800,height=1000');
win.document.write('<html><head><title>경력증명서</title>');
win.document.write('<style>body{font-family:"Pretendard","Malgun Gothic",sans-serif;padding:48px 56px;margin:0;} table{border-collapse:collapse;width:100%;} th,td{border:1px solid #333;padding:10px 14px;font-size:14px;} th{background:#f8f9fa;font-weight:600;text-align:left;white-space:nowrap;width:120px;} @media print{body{padding:40px 48px;}}</style>');
win.document.write('</head><body>');
win.document.write(content);
win.document.write('</body></html>');
win.document.close();
win.onload = function() { win.print(); };
}
function _buildCareerCertHtml(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 `
<h1 style="text-align:center; font-size:28px; font-weight:700; letter-spacing:12px; margin-bottom:40px;">경 력 증 명 서</h1>
<h3 style="font-size:15px; font-weight:600; margin:24px 0 10px; color:#333;">1. 인적사항</h3>
<table style="border-collapse:collapse; width:100%; margin-bottom:20px;">
<tr><th style="${thStyle}">성 명</th><td style="${tdStyle}">${e(d.name)}</td><th style="${thStyle}">생년월일</th><td style="${tdStyle}">${e(d.birthDate)}</td></tr>
<tr><th style="${thStyle}">주 소</th><td style="${tdStyle}" colspan="3">${e(d.address)}</td></tr>
</table>
<h3 style="font-size:15px; font-weight:600; margin:24px 0 10px; color:#333;">2. 경력사항</h3>
<table style="border-collapse:collapse; width:100%; margin-bottom:20px;">
<tr><th style="${thStyle}">회 사 명</th><td style="${tdStyle}">${e(d.company)}</td><th style="${thStyle}">사업자번호</th><td style="${tdStyle}">${e(d.businessNum)}</td></tr>
<tr><th style="${thStyle}">대 표 자</th><td style="${tdStyle}">${e(d.ceoName)}</td><th style="${thStyle}">대표전화</th><td style="${tdStyle}">${e(d.phone)}</td></tr>
<tr><th style="${thStyle}">소 재 지</th><td style="${tdStyle}" colspan="3">${e(d.companyAddress)}</td></tr>
<tr><th style="${thStyle}">소속부서</th><td style="${tdStyle}">${e(d.department)}</td><th style="${thStyle}">직위/직급</th><td style="${tdStyle}">${e(d.position)}</td></tr>
<tr><th style="${thStyle}">근무기간</th><td style="${tdStyle}" colspan="3">${e(d.hireDate)} ~ ${e(d.resignDate)}</td></tr>
<tr><th style="${thStyle}">담당업무</th><td style="${tdStyle}" colspan="3">${e(d.jobDescription)}</td></tr>
</table>
<h3 style="font-size:15px; font-weight:600; margin:24px 0 10px; color:#333;">3. 발급정보</h3>
<table style="border-collapse:collapse; width:100%; margin-bottom:32px;">
<tr><th style="${thStyle}">사용용도</th><td style="${tdStyle}" colspan="3">${e(d.purpose)}</td></tr>
</table>
<p style="text-align:center; font-size:15px; line-height:2; margin:36px 0;">위 사람은 당사에 재직(근무) 하였음을 증명합니다.</p>
<p style="text-align:center; font-size:15px; font-weight:500; margin-bottom:48px;">${e(d.issueDateFormatted)}</p>
<div style="text-align:center; margin-top:32px;">
<p style="font-size:16px; font-weight:600; margin-bottom:8px;">${e(d.company)}</p>
<p style="font-size:14px; color:#555;">대표이사 &nbsp;&nbsp; ${e(d.ceoName)} &nbsp;&nbsp; (인)</p>
</div>
`;
}
@endif
@if(!empty($approval->content) && $approval->form?->code === 'employment_cert')
const _certContent = @json($approval->content);

View File

@@ -973,6 +973,10 @@
Route::get('/cert-info/{userId}', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'certInfo'])->name('cert-info');
Route::get('/{id}/cert-pdf', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'certPdf'])->name('cert-pdf');
// 경력증명서
Route::get('/career-cert-info/{userId}', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'careerCertInfo'])->name('career-cert-info');
Route::get('/{id}/career-cert-pdf', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'careerCertPdf'])->name('career-cert-pdf');
// CRUD
Route::post('/', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'store'])->name('store');
Route::get('/{id}', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'show'])->name('show');