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:
@@ -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([
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -423,9 +423,10 @@ className="text-gray-300 hover:text-red-500 flex-shrink-0 ml-1">×</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">×</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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user