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:
@@ -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()],
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── 필드 템플릿 관련 메서드 ───
|
||||
|
||||
/**
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 속성 패널 (우측) */}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user