feat:E-Sign 템플릿에 PDF 파일 포함 기능 추가

- 모델: EsignFieldTemplate fillable에 file 컬럼 추가
- storeTemplate: include_pdf + contract_id로 계약 PDF를 템플릿으로 복사
- store(계약 생성): template_id로 템플릿 PDF 자동 복사 (사용자 업로드 우선)
- duplicateTemplate: 복제 시 PDF 파일도 복사
- 템플릿 PDF 다운로드 엔드포인트 추가
- SaveTemplateModal: "현재 PDF 파일 포함" 체크박스 추가
- create: 템플릿 카드에 PDF 뱃지, PDF 자동 사용 안내
- templates: 템플릿 카드에 PDF 파일명 표시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-12 20:16:56 +09:00
parent f326194c99
commit f050be52fe
6 changed files with 140 additions and 12 deletions

View File

@@ -14,6 +14,7 @@
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class EsignApiController extends Controller
@@ -88,6 +89,7 @@ public function store(Request $request): JsonResponse
'sign_order_type' => 'required|in:counterpart_first,creator_first',
'expires_at' => 'nullable|date',
'expires_days' => 'nullable|integer|min:1|max:365',
'template_id' => 'nullable|integer',
'signers' => 'required|array|size:2',
'signers.*.name' => 'required|string|max:100',
'signers.*.email' => 'required|email|max:200',
@@ -108,11 +110,28 @@ public function store(Request $request): JsonResponse
$fileSize = null;
if ($request->hasFile('file')) {
// 사용자가 직접 업로드한 파일 우선 사용
$file = $request->file('file');
$fileName = $file->getClientOriginalName();
$fileSize = $file->getSize();
$fileHash = hash_file('sha256', $file->getRealPath());
$filePath = $file->store("esign/{$tenantId}/contracts", 'local');
} elseif ($request->input('template_id')) {
// 템플릿에 PDF가 있으면 복사
$template = EsignFieldTemplate::forTenant($tenantId)
->where('is_active', true)
->find($request->input('template_id'));
if ($template && $template->file_path && Storage::disk('local')->exists($template->file_path)) {
$ext = pathinfo($template->file_path, PATHINFO_EXTENSION) ?: 'pdf';
$newPath = "esign/{$tenantId}/contracts/" . Str::random(40) . ".{$ext}";
Storage::disk('local')->copy($template->file_path, $newPath);
$filePath = $newPath;
$fileName = $template->file_name;
$fileHash = $template->file_hash;
$fileSize = $template->file_size;
}
}
$contract = EsignContract::create([
@@ -388,6 +407,25 @@ public function uploadPdf(Request $request, int $id): JsonResponse
]);
}
/**
* 템플릿 PDF 다운로드
*/
public function downloadTemplatePdf(int $id)
{
$tenantId = session('selected_tenant_id', 1);
$template = EsignFieldTemplate::forTenant($tenantId)->findOrFail($id);
if (!$template->file_path || !Storage::disk('local')->exists($template->file_path)) {
abort(404, 'PDF 파일을 찾을 수 없습니다.');
}
$fileName = $template->file_name ?: 'template.pdf';
return Storage::disk('local')->download($template->file_path, $fileName, [
'Content-Type' => 'application/pdf',
]);
}
// ─── 필드 템플릿 관련 메서드 ───
/**
@@ -423,6 +461,8 @@ public function storeTemplate(Request $request): JsonResponse
'name' => 'required|string|max:100',
'description' => 'nullable|string',
'category' => 'nullable|string|max:50',
'include_pdf' => 'nullable|boolean',
'contract_id' => 'nullable|integer',
'items' => 'required|array|min:1',
'items.*.signer_order' => 'required|integer|min:1',
'items.*.page_number' => 'required|integer|min:1',
@@ -441,8 +481,36 @@ public function storeTemplate(Request $request): JsonResponse
$items = $request->input('items');
$signerCount = max(array_column($items, 'signer_order'));
$template = DB::transaction(function () use ($tenantId, $request, $items, $signerCount) {
$template = EsignFieldTemplate::create([
// PDF 포함 여부 확인
$includePdf = $request->boolean('include_pdf');
$contractId = $request->input('contract_id');
$sourceContract = null;
if ($includePdf && $contractId) {
$sourceContract = EsignContract::forTenant($tenantId)->find($contractId);
if (!$sourceContract || !$sourceContract->original_file_path || !Storage::disk('local')->exists($sourceContract->original_file_path)) {
$sourceContract = null;
}
}
$template = DB::transaction(function () use ($tenantId, $request, $items, $signerCount, $sourceContract) {
$fileData = [];
if ($sourceContract) {
$timestamp = now()->format('YmdHis');
$ext = pathinfo($sourceContract->original_file_path, PATHINFO_EXTENSION) ?: 'pdf';
$newPath = "esign/{$tenantId}/templates/{$timestamp}.{$ext}";
Storage::disk('local')->copy($sourceContract->original_file_path, $newPath);
$fileData = [
'file_path' => $newPath,
'file_name' => $sourceContract->original_file_name,
'file_hash' => $sourceContract->original_file_hash,
'file_size' => $sourceContract->original_file_size,
];
}
$template = EsignFieldTemplate::create(array_merge([
'tenant_id' => $tenantId,
'name' => $request->input('name'),
'description' => $request->input('description'),
@@ -450,7 +518,7 @@ public function storeTemplate(Request $request): JsonResponse
'signer_count' => $signerCount,
'is_active' => true,
'created_by' => auth()->id(),
]);
], $fileData));
foreach ($items as $i => $item) {
EsignFieldTemplateItem::create([
@@ -531,7 +599,22 @@ public function duplicateTemplate(int $id): JsonResponse
->findOrFail($id);
$newTemplate = DB::transaction(function () use ($template, $tenantId) {
$newTemplate = EsignFieldTemplate::create([
$fileData = [];
if ($template->file_path && Storage::disk('local')->exists($template->file_path)) {
$timestamp = now()->format('YmdHis');
$ext = pathinfo($template->file_path, PATHINFO_EXTENSION) ?: 'pdf';
$newPath = "esign/{$tenantId}/templates/{$timestamp}_copy.{$ext}";
Storage::disk('local')->copy($template->file_path, $newPath);
$fileData = [
'file_path' => $newPath,
'file_name' => $template->file_name,
'file_hash' => $template->file_hash,
'file_size' => $template->file_size,
];
}
$newTemplate = EsignFieldTemplate::create(array_merge([
'tenant_id' => $tenantId,
'name' => $template->name . ' (복사)',
'description' => $template->description,
@@ -539,7 +622,7 @@ public function duplicateTemplate(int $id): JsonResponse
'signer_count' => $template->signer_count,
'is_active' => true,
'created_by' => auth()->id(),
]);
], $fileData));
foreach ($template->items as $item) {
EsignFieldTemplateItem::create([

View File

@@ -15,6 +15,10 @@ class EsignFieldTemplate extends Model
'name',
'description',
'category',
'file_path',
'file_name',
'file_hash',
'file_size',
'signer_count',
'is_active',
'created_by',

View File

@@ -101,6 +101,7 @@ className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring
const fd = new FormData();
Object.entries(form).forEach(([k, v]) => { if (v) fd.append(k, v); });
if (file) fd.append('file', file);
if (templateId) fd.append('template_id', templateId);
try {
fd.append('_token', csrfToken);
@@ -167,7 +168,13 @@ className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring
<input ref={fileRef} type="file" accept=".pdf" onChange={e => setFile(e.target.files[0])} required={!templateId}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-blue-50 file:text-blue-700 file:text-sm file:font-medium file:cursor-pointer" />
{file && <p className="text-xs text-gray-500 mt-1">{file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)</p>}
{templateId && !file && <p className="text-xs text-amber-600 mt-1">템플릿 선택 PDF 파일은 선택사항입니다. 나중에 업로드할 있습니다.</p>}
{templateId && !file && (() => {
const selectedTpl = templates.find(t => t.id == templateId);
if (selectedTpl?.file_path) {
return <p className="text-xs text-blue-600 mt-1">📄 템플릿에 포함된 PDF가 자동으로 사용됩니다. 별도 파일을 업로드하면 해당 파일이 우선 적용됩니다.</p>;
}
return <p className="text-xs text-amber-600 mt-1">템플릿 선택 PDF 파일은 선택사항입니다. 나중에 업로드할 있습니다.</p>;
})()}
</div>
<div className="flex gap-4" style={{ flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 200px' }}>
@@ -233,7 +240,10 @@ className={`flex items-center gap-3 p-2.5 rounded-lg border cursor-pointer trans
<input type="radio" name="template" checked={templateId == t.id} onChange={() => setTemplateId(t.id)}
className="text-blue-600 focus:ring-blue-500" />
<div className="flex-1 min-w-0">
<span className="text-sm text-gray-800">{t.name}</span>
<div className="flex items-center gap-1.5">
<span className="text-sm text-gray-800">{t.name}</span>
{t.file_path && <span className="text-[11px] px-1.5 py-0.5 bg-blue-100 text-blue-600 rounded font-medium">PDF</span>}
</div>
<div className="flex items-center gap-2 mt-0.5">
{t.category && <span className="text-[11px] px-1.5 py-0.5 bg-gray-100 text-gray-500 rounded">{t.category}</span>}
<span className="text-[11px] text-gray-400">필드 {t.items_count ?? t.items?.length ?? 0}</span>

View File

@@ -423,9 +423,10 @@ className="text-gray-300 hover:text-red-500 flex-shrink-0 ml-1">&times;</button>
};
// ─── SaveTemplateModal ───
const SaveTemplateModal = ({ open, onClose, onSave }) => {
const SaveTemplateModal = ({ open, onClose, onSave, hasPdf }) => {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [includePdf, setIncludePdf] = useState(false);
const [saving, setSaving] = useState(false);
if (!open) return null;
@@ -433,9 +434,9 @@ className="text-gray-300 hover:text-red-500 flex-shrink-0 ml-1">&times;</button>
const handleSave = async () => {
if (!name.trim()) { alert('템플릿 이름을 입력해주세요.'); return; }
setSaving(true);
await onSave(name.trim(), description.trim());
await onSave(name.trim(), description.trim(), includePdf);
setSaving(false);
setName(''); setDescription('');
setName(''); setDescription(''); setIncludePdf(false);
};
return (
@@ -455,6 +456,18 @@ className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-bl
placeholder="선택사항" rows={2}
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-200 focus:border-blue-400 outline-none resize-none" />
</div>
{hasPdf && (
<div className="bg-blue-50 rounded-lg px-3 py-2.5">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={includePdf} onChange={e => setIncludePdf(e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
<div>
<span className="text-sm text-gray-800 font-medium">현재 PDF 파일 포함</span>
<p className="text-[11px] text-gray-500 mt-0.5">템플릿 사용 PDF가 자동으로 적용됩니다</p>
</div>
</label>
</div>
)}
</div>
<div className="flex justify-end gap-2 mt-4">
<button onClick={onClose} className="px-4 py-1.5 text-sm border rounded-lg hover:bg-gray-50">취소</button>
@@ -869,7 +882,7 @@ className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-7
};
// 템플릿으로 저장
const handleSaveTemplate = useCallback(async (name, description) => {
const handleSaveTemplate = useCallback(async (name, description, includePdf = false) => {
if (fields.length === 0) { alert('저장할 필드가 없습니다.'); return; }
const signers = contract.signers || [];
// signer_id → sign_order 매핑
@@ -888,10 +901,16 @@ className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-7
is_required: f.is_required !== false,
}));
const payload = { name, description, items };
if (includePdf) {
payload.include_pdf = true;
payload.contract_id = parseInt(CONTRACT_ID);
}
try {
const res = await fetch('/esign/contracts/templates', {
method: 'POST', headers: getHeaders(),
body: JSON.stringify({ name, description, items }),
body: JSON.stringify(payload),
});
const json = await res.json();
if (json.success) {
@@ -1145,6 +1164,7 @@ className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-7
open={showSaveTemplate}
onClose={() => setShowSaveTemplate(false)}
onSave={handleSaveTemplate}
hasPdf={!!contract.original_file_path}
/>
<LoadTemplateModal
open={showLoadTemplate}

View File

@@ -167,6 +167,16 @@ className="w-full text-left px-3 py-1.5 text-sm text-red-600 hover:bg-red-50">
<p className="text-xs text-gray-500 mb-3 line-clamp-2">{template.description}</p>
)}
{/* PDF 포함 표시 */}
{template.file_path && (
<div className="flex items-center gap-1.5 mb-2 px-2 py-1.5 bg-blue-50 rounded-lg">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#3B82F6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
</svg>
<span className="text-[11px] text-blue-600 font-medium truncate">{template.file_name || 'PDF 포함'}</span>
</div>
)}
{/* 메타 정보 */}
<div className="flex items-center gap-3 text-[11px] text-gray-400">
<span>서명자 {template.signer_count}</span>

View File

@@ -1423,6 +1423,7 @@
Route::put('/templates/{templateId}', [EsignApiController::class, 'updateTemplate'])->whereNumber('templateId')->name('templates.update');
Route::post('/templates/{templateId}/duplicate', [EsignApiController::class, 'duplicateTemplate'])->whereNumber('templateId')->name('templates.duplicate');
Route::delete('/templates/{templateId}', [EsignApiController::class, 'destroyTemplate'])->whereNumber('templateId')->name('templates.destroy');
Route::get('/templates/{templateId}/download', [EsignApiController::class, 'downloadTemplatePdf'])->whereNumber('templateId')->name('templates.download');
// 템플릿 적용 / 필드 복사
Route::post('/{id}/apply-template', [EsignApiController::class, 'applyTemplate'])->whereNumber('id')->name('apply-template');