diff --git a/app/Http/Controllers/ESign/EsignApiController.php b/app/Http/Controllers/ESign/EsignApiController.php index b5c54385..ae83635c 100644 --- a/app/Http/Controllers/ESign/EsignApiController.php +++ b/app/Http/Controllers/ESign/EsignApiController.php @@ -95,6 +95,8 @@ public function store(Request $request): JsonResponse 'signers.*.email' => 'required|email|max:200', 'signers.*.phone' => 'nullable|string|max:20', 'signers.*.role' => 'required|in:creator,counterpart', + 'metadata' => 'nullable|array', + 'metadata.*' => 'nullable|string|max:500', ]); $tenantId = session('selected_tenant_id', 1); @@ -145,6 +147,7 @@ public function store(Request $request): JsonResponse 'original_file_hash' => $fileHash, 'original_file_size' => $fileSize, 'status' => 'draft', + 'metadata' => $request->input('metadata'), 'expires_at' => $request->input('expires_at') ? \Carbon\Carbon::parse($request->input('expires_at')) : now()->addDays($request->input('expires_days', 30)), @@ -383,6 +386,7 @@ public function configureFields(Request $request, int $id): JsonResponse 'fields.*.height' => 'required|numeric', 'fields.*.field_type' => 'required|in:signature,stamp,text,date,checkbox', 'fields.*.field_label' => 'nullable|string|max:100', + 'fields.*.field_variable' => 'nullable|string|max:50', 'fields.*.font_size' => 'nullable|integer|min:6|max:72', 'fields.*.is_required' => 'nullable|boolean', ]); @@ -405,6 +409,7 @@ public function configureFields(Request $request, int $id): JsonResponse 'height' => $field['height'], 'field_type' => $field['field_type'], 'field_label' => $field['field_label'] ?? null, + 'field_variable' => $field['field_variable'] ?? null, 'font_size' => $field['font_size'] ?? null, 'is_required' => $field['is_required'] ?? true, 'sort_order' => $i, @@ -623,6 +628,7 @@ public function storeTemplate(Request $request): JsonResponse 'items.*.height' => 'required|numeric', 'items.*.field_type' => 'required|in:signature,stamp,text,date,checkbox', 'items.*.field_label' => 'nullable|string|max:100', + 'items.*.field_variable' => 'nullable|string|max:50', 'items.*.font_size' => 'nullable|integer|min:6|max:72', 'items.*.is_required' => 'nullable|boolean', ]); @@ -683,6 +689,7 @@ public function storeTemplate(Request $request): JsonResponse 'height' => $item['height'], 'field_type' => $item['field_type'], 'field_label' => $item['field_label'] ?? null, + 'field_variable' => $item['field_variable'] ?? null, 'font_size' => $item['font_size'] ?? null, 'is_required' => $item['is_required'] ?? true, 'sort_order' => $i, @@ -722,16 +729,27 @@ public function updateTemplate(Request $request, int $id): JsonResponse 'name' => 'required|string|max:100', 'description' => 'nullable|string', 'category' => 'nullable|string|max:50', + 'variables' => 'nullable|array', + 'variables.*.key' => 'required|string|max:50', + 'variables.*.label' => 'required|string|max:100', + 'variables.*.type' => 'nullable|in:text,number,date', + 'variables.*.default' => 'nullable|string|max:500', ]); $tenantId = session('selected_tenant_id', 1); $template = EsignFieldTemplate::forTenant($tenantId)->findOrFail($id); - $template->update([ + $updateData = [ 'name' => $request->input('name'), 'description' => $request->input('description'), 'category' => $request->input('category'), - ]); + ]; + + if ($request->has('variables')) { + $updateData['variables'] = $request->input('variables'); + } + + $template->update($updateData); return response()->json([ 'success' => true, @@ -837,6 +855,7 @@ public function updateTemplateItems(Request $request, int $templateId): JsonResp 'items.*.height' => 'required|numeric', 'items.*.field_type' => 'required|in:signature,stamp,text,date,checkbox', 'items.*.field_label' => 'nullable|string|max:100', + 'items.*.field_variable' => 'nullable|string|max:50', 'items.*.font_size' => 'nullable|integer|min:6|max:72', 'items.*.is_required' => 'nullable|boolean', ]); @@ -862,6 +881,7 @@ public function updateTemplateItems(Request $request, int $templateId): JsonResp 'height' => round($itemData['height'], 2), 'field_type' => $itemData['field_type'], 'field_label' => $itemData['field_label'] ?? '', + 'field_variable' => $itemData['field_variable'] ?? null, 'font_size' => $itemData['font_size'] ?? null, 'is_required' => $itemData['is_required'] ?? true, 'sort_order' => $i, @@ -913,6 +933,7 @@ public function duplicateTemplate(int $id): JsonResponse 'description' => $template->description, 'category' => $template->category, 'signer_count' => $template->signer_count, + 'variables' => $template->variables, 'is_active' => true, 'created_by' => auth()->id(), ], $fileData)); @@ -928,6 +949,7 @@ public function duplicateTemplate(int $id): JsonResponse 'height' => $item->height, 'field_type' => $item->field_type, 'field_label' => $item->field_label, + 'field_variable' => $item->field_variable, 'font_size' => $item->font_size, 'is_required' => $item->is_required, 'sort_order' => $item->sort_order, @@ -988,7 +1010,10 @@ public function applyTemplate(Request $request, int $id): JsonResponse $signerMap[$signer->sign_order] = $signer->id; } - DB::transaction(function () use ($contract, $template, $tenantId, $signerMap) { + // 변수 해석용 맵 구성 + $variableValues = $this->buildVariableMap($contract, $template); + + DB::transaction(function () use ($contract, $template, $tenantId, $signerMap, $variableValues) { // 기존 필드 삭제 EsignSignField::where('contract_id', $contract->id)->delete(); @@ -997,6 +1022,12 @@ public function applyTemplate(Request $request, int $id): JsonResponse $signerId = $signerMap[$item->signer_order] ?? null; if (!$signerId) continue; + // 변수가 바인딩된 필드는 자동 채움 + $fieldValue = null; + if ($item->field_variable && isset($variableValues[$item->field_variable])) { + $fieldValue = $variableValues[$item->field_variable]; + } + EsignSignField::create([ 'tenant_id' => $tenantId, 'contract_id' => $contract->id, @@ -1008,7 +1039,9 @@ public function applyTemplate(Request $request, int $id): JsonResponse 'height' => $item->height, 'field_type' => $item->field_type, 'field_label' => $item->field_label, + 'field_variable' => $item->field_variable, 'font_size' => $item->font_size, + 'field_value' => $fieldValue, 'is_required' => $item->is_required, 'sort_order' => $item->sort_order, ]); @@ -1073,6 +1106,7 @@ public function copyFieldsFromContract(Request $request, int $id, int $sourceId) 'height' => $field->height, 'field_type' => $field->field_type, 'field_label' => $field->field_label, + 'field_variable' => $field->field_variable, 'font_size' => $field->font_size, 'is_required' => $field->is_required, 'sort_order' => $field->sort_order, @@ -1088,4 +1122,35 @@ public function copyFieldsFromContract(Request $request, int $id, int $sourceId) 'data' => $fields, ]); } + + /** + * 변수 해석 맵 구성 (시스템 변수 + 커스텀 변수) + */ + private function buildVariableMap(EsignContract $contract, EsignFieldTemplate $template): array + { + $map = []; + + // 시스템 변수: 서명자 정보 + $signers = $contract->signers->sortBy('sign_order'); + $idx = 1; + foreach ($signers as $signer) { + $map["signer{$idx}_name"] = $signer->name; + $map["signer{$idx}_email"] = $signer->email; + $map["signer{$idx}_phone"] = $signer->phone ?? ''; + $idx++; + } + + // 시스템 변수: 계약 정보 + $map['contract_title'] = $contract->title ?? ''; + $map['current_date'] = now()->format('Y.m.d'); + $map['expires_at'] = $contract->expires_at ? $contract->expires_at->format('Y.m.d') : ''; + + // 커스텀 변수: contract.metadata에서 조회 + $metadata = $contract->metadata ?? []; + foreach ($metadata as $key => $value) { + $map[$key] = $value ?? ''; + } + + return $map; + } } diff --git a/app/Models/ESign/EsignContract.php b/app/Models/ESign/EsignContract.php index 8abe5a62..e160f2c7 100644 --- a/app/Models/ESign/EsignContract.php +++ b/app/Models/ESign/EsignContract.php @@ -26,6 +26,7 @@ class EsignContract extends Model 'signed_file_path', 'signed_file_hash', 'status', + 'metadata', 'expires_at', 'completed_at', 'created_by', @@ -35,6 +36,7 @@ class EsignContract extends Model protected $casts = [ 'original_file_size' => 'integer', + 'metadata' => 'array', 'expires_at' => 'datetime', 'completed_at' => 'datetime', ]; diff --git a/app/Models/ESign/EsignFieldTemplate.php b/app/Models/ESign/EsignFieldTemplate.php index d346eb7d..293c8e33 100644 --- a/app/Models/ESign/EsignFieldTemplate.php +++ b/app/Models/ESign/EsignFieldTemplate.php @@ -20,12 +20,14 @@ class EsignFieldTemplate extends Model 'file_hash', 'file_size', 'signer_count', + 'variables', 'is_active', 'created_by', ]; protected $casts = [ 'signer_count' => 'integer', + 'variables' => 'array', 'is_active' => 'boolean', ]; diff --git a/app/Models/ESign/EsignFieldTemplateItem.php b/app/Models/ESign/EsignFieldTemplateItem.php index 9535cb34..b4100932 100644 --- a/app/Models/ESign/EsignFieldTemplateItem.php +++ b/app/Models/ESign/EsignFieldTemplateItem.php @@ -19,6 +19,7 @@ class EsignFieldTemplateItem extends Model 'height', 'field_type', 'field_label', + 'field_variable', 'font_size', 'is_required', 'sort_order', diff --git a/app/Models/ESign/EsignSignField.php b/app/Models/ESign/EsignSignField.php index 4cb93083..132941ea 100644 --- a/app/Models/ESign/EsignSignField.php +++ b/app/Models/ESign/EsignSignField.php @@ -20,6 +20,7 @@ class EsignSignField extends Model 'height', 'field_type', 'field_label', + 'field_variable', 'font_size', 'field_value', 'is_required', diff --git a/app/Services/ESign/PdfSignatureService.php b/app/Services/ESign/PdfSignatureService.php index 33ffd1e4..9016bd7d 100644 --- a/app/Services/ESign/PdfSignatureService.php +++ b/app/Services/ESign/PdfSignatureService.php @@ -149,7 +149,7 @@ private function overlayDate(Fpdi $pdf, EsignSignField $field, float $x, float $ $dateText = now()->format('Y-m-d'); } - $this->renderText($pdf, $dateText, $x, $y, $w, $h); + $this->renderText($pdf, $dateText, $x, $y, $w, $h, $field->font_size); } /** @@ -162,7 +162,7 @@ private function overlayText(Fpdi $pdf, EsignSignField $field, float $x, float $ return; } - $this->renderText($pdf, $text, $x, $y, $w, $h); + $this->renderText($pdf, $text, $x, $y, $w, $h, $field->font_size); } /** @@ -174,18 +174,22 @@ private function overlayCheckbox(Fpdi $pdf, EsignSignField $field, float $x, flo return; } - $this->renderText($pdf, "\xe2\x9c\x93", $x, $y, $w, $h); // ✓ (UTF-8) + $this->renderText($pdf, "\xe2\x9c\x93", $x, $y, $w, $h, $field->font_size); // ✓ (UTF-8) } /** * 텍스트를 지정 영역에 렌더링하는 공통 메서드. */ - private function renderText(Fpdi $pdf, string $text, float $x, float $y, float $w, float $h): void + private function renderText(Fpdi $pdf, string $text, float $x, float $y, float $w, float $h, ?int $fieldFontSize = null): void { - // 영역 높이에 맞춰 폰트 크기 산출 (pt 단위, 여백 고려) - $fontSize = min($h * 0.7, 12); - if ($fontSize < 4) { - $fontSize = 4; + // 필드에 지정된 폰트 크기 우선, 없으면 영역 높이 기반 자동 산출 + if ($fieldFontSize && $fieldFontSize >= 4) { + $fontSize = $fieldFontSize; + } else { + $fontSize = min($h * 0.7, 12); + if ($fontSize < 4) { + $fontSize = 4; + } } $pdf->SetFont('helvetica', '', $fontSize); diff --git a/resources/views/esign/create.blade.php b/resources/views/esign/create.blade.php index 9fa65e05..f2e2a5d8 100644 --- a/resources/views/esign/create.blade.php +++ b/resources/views/esign/create.blade.php @@ -62,6 +62,7 @@ className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus: const [templateId, setTemplateId] = useState(''); const [templateCategory, setTemplateCategory] = useState(''); const [templateSearch, setTemplateSearch] = useState(''); + const [metadata, setMetadata] = useState({}); const fileRef = useRef(null); useEffect(() => { @@ -73,6 +74,22 @@ className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus: const handleChange = (key, val) => setForm(f => ({...f, [key]: val})); + const handleTemplateSelect = (id) => { + setTemplateId(id); + if (!id) { setMetadata({}); return; } + const tpl = templates.find(t => t.id == id); + if (tpl?.variables?.length) { + const defaults = {}; + tpl.variables.forEach(v => { defaults[v.key] = v.default || ''; }); + setMetadata(defaults); + } else { + setMetadata({}); + } + }; + + const selectedTemplate = templateId ? templates.find(t => t.id == templateId) : null; + const templateVars = selectedTemplate?.variables || []; + const fillTestData = () => { const pick = arr => arr[Math.floor(Math.random() * arr.length)]; const lastNames = ['김','이','박','최','정','강','조','윤','장','임']; @@ -102,6 +119,9 @@ className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus: 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); + if (Object.keys(metadata).length > 0) { + Object.entries(metadata).forEach(([k, v]) => { fd.append(`metadata[${k}]`, v || ''); }); + } try { fd.append('_token', csrfToken); @@ -224,7 +244,7 @@ className="border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:ring-2