feat:E-Sign 템플릿 변수 자동채움 시스템 구현

- 시스템 변수 (서명자명, 이메일, 계약제목, 날짜 등) 자동 해석
- 커스텀 변수 정의/관리 (템플릿별 계약금액, 기간 등)
- 템플릿 필드 에디터: 변수 관리 + 필드-변수 바인딩 UI
- 계약 생성 폼: 템플릿 변수 입력 섹션 추가
- 계약 필드 에디터: 변수 연결 정보 표시
- PdfSignatureService: font_size 반영 렌더링

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-13 07:44:45 +09:00
parent b206eeeb2d
commit 5ffabed6b4
9 changed files with 249 additions and 21 deletions

View File

@@ -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;
}
}

View File

@@ -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',
];

View File

@@ -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',
];

View File

@@ -19,6 +19,7 @@ class EsignFieldTemplateItem extends Model
'height',
'field_type',
'field_label',
'field_variable',
'font_size',
'is_required',
'sort_order',

View File

@@ -20,6 +20,7 @@ class EsignSignField extends Model
'height',
'field_type',
'field_label',
'field_variable',
'font_size',
'field_value',
'is_required',

View File

@@ -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);

View File

@@ -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
<div className="space-y-1.5 max-h-[200px] overflow-y-auto">
<label className={`flex items-center gap-3 p-2.5 rounded-lg border cursor-pointer transition-colors ${!templateId ? 'border-blue-400 bg-blue-50' : 'hover:bg-gray-50'}`}>
<input type="radio" name="template" checked={!templateId} onChange={() => setTemplateId('')}
<input type="radio" name="template" checked={!templateId} onChange={() => handleTemplateSelect('')}
className="text-blue-600 focus:ring-blue-500" />
<div>
<span className="text-sm text-gray-700">없음</span>
@@ -237,7 +257,7 @@ className="text-blue-600 focus:ring-blue-500" />
.map(t => (
<label key={t.id}
className={`flex items-center gap-3 p-2.5 rounded-lg border cursor-pointer transition-colors ${templateId == t.id ? 'border-blue-400 bg-blue-50' : 'hover:bg-gray-50'}`}>
<input type="radio" name="template" checked={templateId == t.id} onChange={() => setTemplateId(t.id)}
<input type="radio" name="template" checked={templateId == t.id} onChange={() => handleTemplateSelect(t.id)}
className="text-blue-600 focus:ring-blue-500" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
@@ -256,6 +276,22 @@ className="text-blue-600 focus:ring-blue-500" />
</div>
)}
{/* 커스텀 변수 입력 */}
{templateVars.length > 0 && (
<div className="bg-amber-50 rounded-lg border border-amber-200 p-4">
<h2 className="text-sm font-semibold text-amber-800 mb-1">계약 정보 입력</h2>
<p className="text-xs text-amber-600 mb-3">선택한 템플릿에서 요구하는 항목입니다. 서명 화면에 자동으로 표시됩니다.</p>
<div className="space-y-3">
{templateVars.map(v => (
<Input key={v.key} label={v.label} name={`meta_${v.key}`}
value={metadata[v.key] || ''} error={errors[`metadata.${v.key}`]}
onChange={(_, val) => setMetadata(m => ({...m, [v.key]: val}))}
placeholder={v.default || `${v.label} 입력`} />
))}
</div>
</div>
)}
{/* 버튼 */}
<div className="flex justify-end gap-3">
<a href="/esign" className="px-4 py-1.5 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 text-sm transition-colors" hx-boost="false">취소</a>

View File

@@ -244,7 +244,7 @@ className={`w-full relative rounded border-2 transition-all ${p === currentPage
className="flex items-center justify-center select-none group transition-shadow">
<div className="flex items-center gap-0.5 text-[10px] font-medium truncate px-1" style={{ color }}>
<span>{typeInfo.icon}</span>
<span className="truncate">{field.field_label || signerName}</span>
<span className="truncate">{field.field_variable ? (field.field_value || `{{${field.field_variable}}}`) : (field.field_label || signerName)}</span>
</div>
{/* 리사이즈 핸들 (선택 시) */}
{selected && <>
@@ -338,6 +338,15 @@ className="w-full border rounded px-2 py-1 text-xs mt-0.5">
placeholder="선택사항"
className="w-full border rounded px-2 py-1 text-xs mt-0.5" />
</div>
{selectedField.field_variable && (
<div className="px-2 py-1.5 bg-amber-50 border border-amber-200 rounded">
<div className="text-[10px] text-amber-700 font-medium">변수 연결됨</div>
<div className="text-[10px] font-mono text-amber-600">{`{{${selectedField.field_variable}}}`}</div>
{selectedField.field_value && (
<div className="text-[10px] text-gray-600 mt-0.5">: {selectedField.field_value}</div>
)}
</div>
)}
<div className="grid grid-cols-2 gap-1.5">
<div>
<label className="text-[10px] text-gray-500">X (%)</label>
@@ -725,6 +734,8 @@ className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-7
position_x: parseFloat(f.position_x), position_y: parseFloat(f.position_y),
width: parseFloat(f.width), height: parseFloat(f.height),
field_type: f.field_type, field_label: f.field_label || '',
field_variable: f.field_variable || null,
field_value: f.field_value || null,
font_size: f.font_size || null,
is_required: f.is_required !== false,
}));
@@ -825,7 +836,7 @@ className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-7
const newField = {
signer_id: signerId, page_number: currentPage,
position_x: 25, position_y: 40, width: 20, height: 5,
field_type: fieldType, field_label: '', font_size: null, is_required: true,
field_type: fieldType, field_label: '', field_variable: null, field_value: null, font_size: null, is_required: true,
};
const newFields = [...fields, newField];
setFields(newFields);
@@ -874,6 +885,7 @@ className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-7
height: round2(f.height),
field_type: f.field_type,
field_label: f.field_label || '',
field_variable: f.field_variable || null,
font_size: f.font_size || null,
is_required: f.is_required !== false,
sort_order: i,
@@ -908,6 +920,7 @@ className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-7
height: round2(f.height),
field_type: f.field_type,
field_label: f.field_label || '',
field_variable: f.field_variable || null,
font_size: f.font_size || null,
is_required: f.is_required !== false,
}));
@@ -948,6 +961,8 @@ className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-7
position_x: parseFloat(f.position_x), position_y: parseFloat(f.position_y),
width: parseFloat(f.width), height: parseFloat(f.height),
field_type: f.field_type, field_label: f.field_label || '',
field_variable: f.field_variable || null,
field_value: f.field_value || null,
font_size: f.font_size || null,
is_required: f.is_required !== false,
}));
@@ -975,6 +990,8 @@ className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-7
position_x: parseFloat(f.position_x), position_y: parseFloat(f.position_y),
width: parseFloat(f.width), height: parseFloat(f.height),
field_type: f.field_type, field_label: f.field_label || '',
field_variable: f.field_variable || null,
field_value: f.field_value || null,
font_size: f.font_size || null,
is_required: f.is_required !== false,
}));

View File

@@ -36,6 +36,16 @@
const SIGNER_COLORS = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899'];
const SIGNER_LABELS = ['서명자 1', '서명자 2', '서명자 3', '서명자 4', '서명자 5', '서명자 6'];
const SYSTEM_VARIABLES = [
{ key: 'signer1_name', label: '갑(1) 이름' },
{ key: 'signer2_name', label: '을(2) 이름' },
{ key: 'signer1_email', label: '갑(1) 이메일' },
{ key: 'signer2_email', label: '을(2) 이메일' },
{ key: 'contract_title', label: '계약 제목' },
{ key: 'current_date', label: '계약일 (오늘)' },
{ key: 'expires_at', label: '만료일' },
];
const ZOOM_LEVELS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
const MAX_HISTORY = 50;
@@ -206,7 +216,7 @@ className={`w-full relative rounded border-2 transition-all ${p === currentPage
className="flex items-center justify-center select-none group transition-shadow">
<div className="flex items-center gap-0.5 text-[10px] font-medium truncate px-1" style={{ color }}>
<span>{typeInfo.icon}</span>
<span className="truncate">{field.field_label || signerName}</span>
<span className="truncate">{field.field_variable ? `{{${field.field_variable}}}` : (field.field_label || signerName)}</span>
</div>
{selected && <>
{['nw','n','ne','w','e','sw','s','se'].map(dir => {
@@ -235,9 +245,30 @@ className="flex items-center justify-center select-none group transition-shadow"
};
// ─── PropertiesPanel (템플릿용: signer_order 기반) ───
const PropertiesPanel = ({ signerCount, fields, selectedFieldIndex, currentPage, onAddField, onUpdateField, onRemoveField, onSelectField, onSetSignerCount }) => {
const PropertiesPanel = ({ signerCount, fields, selectedFieldIndex, currentPage, onAddField, onUpdateField, onRemoveField, onSelectField, onSetSignerCount, templateVariables, onSetTemplateVariables }) => {
const selectedField = selectedFieldIndex !== null ? fields[selectedFieldIndex] : null;
const signers = Array.from({ length: signerCount }, (_, i) => ({ order: i + 1, label: SIGNER_LABELS[i] || `서명자 ${i + 1}` }));
const [newVarKey, setNewVarKey] = React.useState('');
const [newVarLabel, setNewVarLabel] = React.useState('');
const addVariable = () => {
const key = newVarKey.trim();
const label = newVarLabel.trim();
if (!key || !label) return;
if (templateVariables.some(v => v.key === key)) { alert('이미 존재하는 변수 키입니다.'); return; }
onSetTemplateVariables([...templateVariables, { key, label, type: 'text', default: '' }]);
setNewVarKey('');
setNewVarLabel('');
};
const removeVariable = (key) => {
onSetTemplateVariables(templateVariables.filter(v => v.key !== key));
};
const allVariables = [
...SYSTEM_VARIABLES.map(v => ({ ...v, group: '시스템' })),
...templateVariables.map(v => ({ ...v, group: '커스텀' })),
];
return (
<div className="w-72 bg-white border-l overflow-y-auto flex-shrink-0" style={{ height: 'calc(100vh - 105px)' }}>
@@ -255,6 +286,31 @@ className="border rounded px-2 py-1 text-xs flex-1">
</div>
</div>
{/* 커스텀 변수 관리 */}
<div className="border-t pt-3">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">커스텀 변수</h3>
<div className="space-y-1 mb-2">
{templateVariables.length === 0 && <p className="text-[10px] text-gray-400">정의된 변수 없음</p>}
{templateVariables.map(v => (
<div key={v.key} className="flex items-center justify-between bg-amber-50 rounded px-2 py-1">
<div className="min-w-0">
<span className="text-[10px] font-mono text-amber-700 block truncate">{v.key}</span>
<span className="text-[10px] text-gray-500 block truncate">{v.label}</span>
</div>
<button onClick={() => removeVariable(v.key)} className="text-gray-300 hover:text-red-500 flex-shrink-0 ml-1 text-xs">&times;</button>
</div>
))}
</div>
<div className="flex gap-1">
<input type="text" value={newVarKey} onChange={e => setNewVarKey(e.target.value.replace(/[^a-z0-9_]/gi, ''))}
placeholder="키 (영문)" className="border rounded px-1.5 py-1 text-[10px] w-20" />
<input type="text" value={newVarLabel} onChange={e => setNewVarLabel(e.target.value)}
placeholder="표시명" className="border rounded px-1.5 py-1 text-[10px] flex-1"
onKeyDown={e => e.key === 'Enter' && addVariable()} />
<button onClick={addVariable} className="px-2 py-1 bg-amber-500 text-white rounded text-[10px] hover:bg-amber-600 flex-shrink-0">+</button>
</div>
</div>
{/* 필드 도구상자 */}
<div>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">필드 추가</h3>
@@ -311,6 +367,29 @@ className="w-full border rounded px-2 py-1 text-xs mt-0.5">
placeholder="선택사항"
className="w-full border rounded px-2 py-1 text-xs mt-0.5" />
</div>
{['text', 'date'].includes(selectedField.field_type) && (
<div>
<label className="text-[10px] text-gray-500">변수 연결</label>
<select value={selectedField.field_variable || ''}
onChange={e => onUpdateField(selectedFieldIndex, { field_variable: e.target.value || null })}
className={`w-full border rounded px-2 py-1 text-xs mt-0.5 ${selectedField.field_variable ? 'border-amber-400 bg-amber-50' : ''}`}>
<option value="">(없음 - 수동 입력)</option>
<optgroup label="시스템 변수">
{SYSTEM_VARIABLES.map(v => <option key={v.key} value={v.key}>{v.label}</option>)}
</optgroup>
{templateVariables.length > 0 && (
<optgroup label="커스텀 변수">
{templateVariables.map(v => <option key={v.key} value={v.key}>{v.label}</option>)}
</optgroup>
)}
</select>
{selectedField.field_variable && (
<div className="mt-1 px-2 py-1 bg-amber-50 border border-amber-200 rounded">
<span className="text-[10px] text-amber-700 font-mono">{`{{${selectedField.field_variable}}}`}</span>
</div>
)}
</div>
)}
<div className="grid grid-cols-2 gap-1.5">
<div>
<label className="text-[10px] text-gray-500">X (%)</label>
@@ -388,6 +467,7 @@ className={`flex items-center justify-between px-2 py-1.5 rounded cursor-pointer
<div className="flex items-center gap-1.5 min-w-0">
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: color }}></span>
<span className="truncate">{typeInfo.icon} {f.field_label || SIGNER_LABELS[si] || `서명자 ${f.signer_order}`}</span>
{f.field_variable && <span className="text-[8px] bg-amber-100 text-amber-700 px-1 rounded flex-shrink-0" title={f.field_variable}>변수</span>}
<span className="text-gray-400 flex-shrink-0">p.{f.page_number}</span>
</div>
<button onClick={(e) => { e.stopPropagation(); onRemoveField(idx); }}
@@ -420,6 +500,7 @@ className="text-gray-300 hover:text-red-500 flex-shrink-0 ml-1">&times;</button>
const [gridEnabled, setGridEnabled] = useState(false);
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
const [clipboard, setClipboard] = useState(null);
const [templateVariables, setTemplateVariables] = useState([]);
// History (Undo/Redo)
const [history, setHistory] = useState([]);
@@ -468,6 +549,7 @@ className="text-gray-300 hover:text-red-500 flex-shrink-0 ml-1">&times;</button>
setTemplate(json.data);
const sc = json.data.signer_count || 2;
setSignerCount(sc < 1 ? 2 : sc);
setTemplateVariables(json.data.variables || []);
const loadedFields = (json.data.items || []).map(f => ({
signer_order: f.signer_order,
page_number: f.page_number,
@@ -477,6 +559,7 @@ className="text-gray-300 hover:text-red-500 flex-shrink-0 ml-1">&times;</button>
height: parseFloat(f.height),
field_type: f.field_type,
field_label: f.field_label || '',
field_variable: f.field_variable || null,
font_size: f.font_size || null,
is_required: f.is_required !== false,
}));
@@ -543,7 +626,7 @@ className="text-gray-300 hover:text-red-500 flex-shrink-0 ml-1">&times;</button>
const newField = {
signer_order: signerOrder, page_number: currentPage,
position_x: 25, position_y: 40, width: 20, height: 5,
field_type: fieldType, field_label: '', font_size: null, is_required: true,
field_type: fieldType, field_label: '', field_variable: null, font_size: null, is_required: true,
};
const newFields = [...fields, newField];
setFields(newFields);
@@ -596,6 +679,7 @@ className="text-gray-300 hover:text-red-500 flex-shrink-0 ml-1">&times;</button>
const saveFields = async () => {
setSaving(true);
try {
// 1) 필드 아이템 저장
const res = await fetch(`/esign/contracts/templates/${TEMPLATE_ID}/items`, {
method: 'PUT', headers: getHeaders(),
body: JSON.stringify({
@@ -608,16 +692,30 @@ className="text-gray-300 hover:text-red-500 flex-shrink-0 ml-1">&times;</button>
height: round2(f.height),
field_type: f.field_type,
field_label: f.field_label || '',
field_variable: f.field_variable || null,
font_size: f.font_size || null,
is_required: f.is_required !== false,
})),
}),
});
const json = await res.json();
if (json.success) {
alert('템플릿 필드가 저장되었습니다.');
if (!json.success) { alert(json.message || '저장 실패'); setSaving(false); return; }
// 2) 템플릿 변수 정의 저장
const res2 = await fetch(`/esign/contracts/templates/${TEMPLATE_ID}`, {
method: 'PUT', headers: getHeaders(),
body: JSON.stringify({
name: template.name,
description: template.description || '',
category: template.category || '',
variables: templateVariables,
}),
});
const json2 = await res2.json();
if (json2.success) {
alert('템플릿 필드와 변수가 저장되었습니다.');
} else {
alert(json.message || '저장 실패');
alert('필드는 저장되었으나 변수 저장 실패: ' + (json2.message || ''));
}
} catch (e) { alert('서버 오류'); }
setSaving(false);
@@ -788,6 +886,8 @@ className="text-gray-300 hover:text-red-500 flex-shrink-0 ml-1">&times;</button>
setSelectedFieldIndex(idx);
if (fields[idx]) setCurrentPage(fields[idx].page_number);
}}
templateVariables={templateVariables}
onSetTemplateVariables={setTemplateVariables}
/>
</div>
</div>