fix:템플릿 선택 시 PDF 파일 필수 해제 및 후속 업로드 지원

- create: 템플릿 선택 시 PDF required 제거, 안내 메시지 표시
- fields: PDF 없는 계약 시 업로드 UI 표시
- API: uploadPdf 엔드포인트 추가 (POST /{id}/upload-pdf)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-12 20:05:28 +09:00
parent c6116ad611
commit 9b119fa7c9
4 changed files with 74 additions and 2 deletions

View File

@@ -354,6 +354,40 @@ public function download(int $id)
]);
}
/**
* PDF 업로드 (PDF 없이 생성된 계약에 나중에 업로드)
*/
public function uploadPdf(Request $request, int $id): JsonResponse
{
$request->validate([
'file' => 'required|file|mimes:pdf|max:20480',
]);
$tenantId = session('selected_tenant_id', 1);
$contract = EsignContract::forTenant($tenantId)->findOrFail($id);
if ($contract->original_file_path) {
return response()->json(['success' => false, 'message' => '이미 PDF 파일이 존재합니다.'], 422);
}
$file = $request->file('file');
$filePath = $file->store("esign/{$tenantId}/contracts", 'local');
$contract->update([
'original_file_path' => $filePath,
'original_file_name' => $file->getClientOriginalName(),
'original_file_hash' => hash_file('sha256', $file->getRealPath()),
'original_file_size' => $file->getSize(),
'updated_by' => auth()->id(),
]);
return response()->json([
'success' => true,
'message' => 'PDF 파일이 업로드되었습니다.',
'data' => ['path' => $filePath, 'name' => $file->getClientOriginalName()],
]);
}
// ─── 필드 템플릿 관련 메서드 ───
/**

View File

@@ -163,10 +163,11 @@ className="w-8 h-8 flex items-center justify-center rounded-lg text-amber-500 ho
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">PDF 파일 <span className="text-red-500">*</span></label>
<input ref={fileRef} type="file" accept=".pdf" onChange={e => setFile(e.target.files[0])} required
<label className="block text-sm font-medium text-gray-700 mb-1">PDF 파일 {!templateId && <span className="text-red-500">*</span>}</label>
<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>}
</div>
<div className="flex gap-4" style={{ flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 200px' }}>

View File

@@ -718,9 +718,11 @@ className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-7
// PDF 로드
const loadPdf = useCallback(async () => {
if (!contract) return;
if (!contract.original_file_path) return; // PDF 없는 계약
try {
const url = `/esign/contracts/${CONTRACT_ID}/download`;
const res = await fetch(url, { headers: { 'X-CSRF-TOKEN': csrfToken } });
if (!res.ok) return;
const arrayBuffer = await res.arrayBuffer();
const doc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
setPdfDoc(doc);
@@ -728,6 +730,24 @@ className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-7
} catch (e) { console.error('PDF 로드 실패:', e); }
}, [contract]);
// PDF 파일 업로드 (PDF 없는 계약용)
const uploadPdf = async (file) => {
const fd = new FormData();
fd.append('file', file);
fd.append('_token', csrfToken);
try {
const res = await fetch(`/esign/contracts/${CONTRACT_ID}/upload-pdf`, {
method: 'POST',
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: fd,
});
const json = await res.json();
if (json.success) {
setContract(prev => ({ ...prev, original_file_path: json.data.path, original_file_name: json.data.name }));
}
} catch (e) { console.error('PDF 업로드 실패:', e); }
};
// PDF 페이지 렌더링
const renderPage = useCallback(async (pageNum) => {
if (!pdfDoc || !canvasRef.current) return;
@@ -1054,6 +1074,21 @@ className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-7
{/* PDF 뷰어 (중앙) */}
<div className="flex-1 overflow-auto bg-gray-200 relative"
onClick={() => setSelectedFieldIndex(null)}>
{!pdfDoc && !contract.original_file_path ? (
<div className="flex items-center justify-center h-full">
<div className="text-center p-8">
<svg className="mx-auto mb-4 text-gray-300" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<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>
<p className="text-gray-500 mb-4 text-sm">PDF 파일이 없습니다. 파일을 업로드하면 서명 위치를 설정할 있습니다.</p>
<label className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium cursor-pointer transition-colors">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
PDF 업로드
<input type="file" accept=".pdf" className="hidden" onChange={e => { if (e.target.files[0]) uploadPdf(e.target.files[0]); }} />
</label>
</div>
</div>
) : (
<div className="flex justify-center p-6 min-h-full">
<div className="relative" style={{ width: canvasSize.width || 'auto', height: canvasSize.height || 'auto' }}>
<canvas ref={canvasRef} className="shadow-lg block" />
@@ -1086,6 +1121,7 @@ className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-7
</div>
</div>
</div>
)}
</div>
{/* 속성 패널 (우측) */}

View File

@@ -1414,6 +1414,7 @@
Route::post('/{id}/send', [EsignApiController::class, 'send'])->whereNumber('id')->name('send');
Route::post('/{id}/remind', [EsignApiController::class, 'remind'])->whereNumber('id')->name('remind');
Route::get('/{id}/download', [EsignApiController::class, 'download'])->whereNumber('id')->name('download');
Route::post('/{id}/upload-pdf', [EsignApiController::class, 'uploadPdf'])->whereNumber('id')->name('upload-pdf');
// 필드 템플릿
Route::get('/templates', [EsignApiController::class, 'indexTemplates'])->name('templates.index');