diff --git a/app/Http/Controllers/ESign/EsignApiController.php b/app/Http/Controllers/ESign/EsignApiController.php index 4ea39061..440106a6 100644 --- a/app/Http/Controllers/ESign/EsignApiController.php +++ b/app/Http/Controllers/ESign/EsignApiController.php @@ -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([ diff --git a/app/Models/ESign/EsignFieldTemplate.php b/app/Models/ESign/EsignFieldTemplate.php index c496c21a..d346eb7d 100644 --- a/app/Models/ESign/EsignFieldTemplate.php +++ b/app/Models/ESign/EsignFieldTemplate.php @@ -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', diff --git a/resources/views/esign/create.blade.php b/resources/views/esign/create.blade.php index a967cbb1..857fcb05 100644 --- a/resources/views/esign/create.blade.php +++ b/resources/views/esign/create.blade.php @@ -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 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 &&

{file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)

} - {templateId && !file &&

템플릿 선택 시 PDF 파일은 선택사항입니다. 나중에 업로드할 수 있습니다.

} + {templateId && !file && (() => { + const selectedTpl = templates.find(t => t.id == templateId); + if (selectedTpl?.file_path) { + return

📄 템플릿에 포함된 PDF가 자동으로 사용됩니다. 별도 파일을 업로드하면 해당 파일이 우선 적용됩니다.

; + } + return

템플릿 선택 시 PDF 파일은 선택사항입니다. 나중에 업로드할 수 있습니다.

; + })()}
@@ -233,7 +240,10 @@ className={`flex items-center gap-3 p-2.5 rounded-lg border cursor-pointer trans setTemplateId(t.id)} className="text-blue-600 focus:ring-blue-500" />
- {t.name} +
+ {t.name} + {t.file_path && PDF} +
{t.category && {t.category}} 필드 {t.items_count ?? t.items?.length ?? 0}개 diff --git a/resources/views/esign/fields.blade.php b/resources/views/esign/fields.blade.php index f717d723..8590f2dc 100644 --- a/resources/views/esign/fields.blade.php +++ b/resources/views/esign/fields.blade.php @@ -423,9 +423,10 @@ className="text-gray-300 hover:text-red-500 flex-shrink-0 ml-1">× }; // ─── 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">× 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" />
+ {hasPdf && ( +
+ +
+ )}
@@ -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} />

{template.description}

)} + {/* PDF 포함 표시 */} + {template.file_path && ( +
+ + + + {template.file_name || 'PDF 포함'} +
+ )} + {/* 메타 정보 */}
서명자 {template.signer_count}명 diff --git a/routes/web.php b/routes/web.php index fd17b57d..40ac6c9e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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');