feat:E-Sign 템플릿 변수 자동채움 시스템 구현
- 시스템 변수 (서명자명, 이메일, 계약제목, 날짜 등) 자동 해석 - 커스텀 변수 정의/관리 (템플릿별 계약금액, 기간 등) - 템플릿 필드 에디터: 변수 관리 + 필드-변수 바인딩 UI - 계약 생성 폼: 템플릿 변수 입력 섹션 추가 - 계약 필드 에디터: 변수 연결 정보 표시 - PdfSignatureService: font_size 반영 렌더링 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ class EsignFieldTemplateItem extends Model
|
||||
'height',
|
||||
'field_type',
|
||||
'field_label',
|
||||
'field_variable',
|
||||
'font_size',
|
||||
'is_required',
|
||||
'sort_order',
|
||||
|
||||
@@ -20,6 +20,7 @@ class EsignSignField extends Model
|
||||
'height',
|
||||
'field_type',
|
||||
'field_label',
|
||||
'field_variable',
|
||||
'font_size',
|
||||
'field_value',
|
||||
'is_required',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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">×</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">×</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">×</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">×</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">×</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">×</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">×</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">×</button>
|
||||
setSelectedFieldIndex(idx);
|
||||
if (fields[idx]) setCurrentPage(fields[idx].page_number);
|
||||
}}
|
||||
templateVariables={templateVariables}
|
||||
onSetTemplateVariables={setTemplateVariables}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user