diff --git a/app/Http/Controllers/ESign/EsignApiController.php b/app/Http/Controllers/ESign/EsignApiController.php
index 38afa2fa..f31d142e 100644
--- a/app/Http/Controllers/ESign/EsignApiController.php
+++ b/app/Http/Controllers/ESign/EsignApiController.php
@@ -5,11 +5,14 @@
use App\Http\Controllers\Controller;
use App\Mail\EsignRequestMail;
use App\Models\ESign\EsignContract;
+use App\Models\ESign\EsignFieldTemplate;
+use App\Models\ESign\EsignFieldTemplateItem;
use App\Models\ESign\EsignSigner;
use App\Models\ESign\EsignSignField;
use App\Models\ESign\EsignAuditLog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
@@ -350,4 +353,230 @@ public function download(int $id)
'Content-Type' => 'application/pdf',
]);
}
+
+ // ─── 필드 템플릿 관련 메서드 ───
+
+ /**
+ * 템플릿 목록 조회
+ */
+ public function indexTemplates(Request $request): JsonResponse
+ {
+ $tenantId = session('selected_tenant_id', 1);
+ $query = EsignFieldTemplate::forTenant($tenantId)
+ ->where('is_active', true)
+ ->with('items');
+
+ if ($signerCount = $request->input('signer_count')) {
+ $query->where('signer_count', $signerCount);
+ }
+
+ $templates = $query->orderBy('created_at', 'desc')->get();
+
+ return response()->json(['success' => true, 'data' => $templates]);
+ }
+
+ /**
+ * 템플릿 저장 (현재 필드를 템플릿으로)
+ */
+ public function storeTemplate(Request $request): JsonResponse
+ {
+ $request->validate([
+ 'name' => 'required|string|max:100',
+ 'description' => 'nullable|string',
+ 'items' => 'required|array|min:1',
+ 'items.*.signer_order' => 'required|integer|min:1',
+ 'items.*.page_number' => 'required|integer|min:1',
+ 'items.*.position_x' => 'required|numeric',
+ 'items.*.position_y' => 'required|numeric',
+ 'items.*.width' => 'required|numeric',
+ 'items.*.height' => 'required|numeric',
+ 'items.*.field_type' => 'required|in:signature,stamp,text,date,checkbox',
+ 'items.*.field_label' => 'nullable|string|max:100',
+ 'items.*.is_required' => 'nullable|boolean',
+ ]);
+
+ $tenantId = session('selected_tenant_id', 1);
+
+ // items에서 최대 signer_order를 추출하여 signer_count 결정
+ $items = $request->input('items');
+ $signerCount = max(array_column($items, 'signer_order'));
+
+ $template = DB::transaction(function () use ($tenantId, $request, $items, $signerCount) {
+ $template = EsignFieldTemplate::create([
+ 'tenant_id' => $tenantId,
+ 'name' => $request->input('name'),
+ 'description' => $request->input('description'),
+ 'signer_count' => $signerCount,
+ 'is_active' => true,
+ 'created_by' => auth()->id(),
+ ]);
+
+ foreach ($items as $i => $item) {
+ EsignFieldTemplateItem::create([
+ 'template_id' => $template->id,
+ 'signer_order' => $item['signer_order'],
+ 'page_number' => $item['page_number'],
+ 'position_x' => $item['position_x'],
+ 'position_y' => $item['position_y'],
+ 'width' => $item['width'],
+ 'height' => $item['height'],
+ 'field_type' => $item['field_type'],
+ 'field_label' => $item['field_label'] ?? null,
+ 'is_required' => $item['is_required'] ?? true,
+ 'sort_order' => $i,
+ ]);
+ }
+
+ return $template;
+ });
+
+ return response()->json([
+ 'success' => true,
+ 'message' => '필드 템플릿이 저장되었습니다.',
+ 'data' => $template->load('items'),
+ ]);
+ }
+
+ /**
+ * 템플릿 삭제 (soft: is_active=false)
+ */
+ public function destroyTemplate(int $id): JsonResponse
+ {
+ $tenantId = session('selected_tenant_id', 1);
+ $template = EsignFieldTemplate::forTenant($tenantId)->findOrFail($id);
+
+ $template->update(['is_active' => false]);
+
+ return response()->json(['success' => true, 'message' => '템플릿이 삭제되었습니다.']);
+ }
+
+ /**
+ * 템플릿을 계약에 적용
+ */
+ public function applyTemplate(Request $request, int $id): JsonResponse
+ {
+ $request->validate([
+ 'template_id' => 'required|integer',
+ ]);
+
+ $tenantId = session('selected_tenant_id', 1);
+ $contract = EsignContract::forTenant($tenantId)->with('signers')->findOrFail($id);
+ $template = EsignFieldTemplate::forTenant($tenantId)
+ ->where('is_active', true)
+ ->with('items')
+ ->findOrFail($request->input('template_id'));
+
+ // 서명자 수 확인
+ $contractSignerCount = $contract->signers->count();
+ if ($template->signer_count > $contractSignerCount) {
+ return response()->json([
+ 'success' => false,
+ 'message' => "템플릿에 필요한 서명자 수({$template->signer_count}명)가 계약의 서명자 수({$contractSignerCount}명)보다 많습니다.",
+ ], 422);
+ }
+
+ // signer_order → signer_id 매핑
+ $signerMap = [];
+ foreach ($contract->signers as $signer) {
+ $signerMap[$signer->sign_order] = $signer->id;
+ }
+
+ DB::transaction(function () use ($contract, $template, $tenantId, $signerMap) {
+ // 기존 필드 삭제
+ EsignSignField::where('contract_id', $contract->id)->delete();
+
+ // 템플릿 아이템 → 필드 생성
+ foreach ($template->items as $item) {
+ $signerId = $signerMap[$item->signer_order] ?? null;
+ if (!$signerId) continue;
+
+ EsignSignField::create([
+ 'tenant_id' => $tenantId,
+ 'contract_id' => $contract->id,
+ 'signer_id' => $signerId,
+ 'page_number' => $item->page_number,
+ 'position_x' => $item->position_x,
+ 'position_y' => $item->position_y,
+ 'width' => $item->width,
+ 'height' => $item->height,
+ 'field_type' => $item->field_type,
+ 'field_label' => $item->field_label,
+ 'is_required' => $item->is_required,
+ 'sort_order' => $item->sort_order,
+ ]);
+ }
+ });
+
+ $fields = EsignSignField::where('contract_id', $contract->id)->orderBy('sort_order')->get();
+
+ return response()->json([
+ 'success' => true,
+ 'message' => '템플릿이 적용되었습니다.',
+ 'data' => $fields,
+ ]);
+ }
+
+ /**
+ * 다른 계약에서 필드 복사
+ */
+ public function copyFieldsFromContract(Request $request, int $id, int $sourceId): JsonResponse
+ {
+ $tenantId = session('selected_tenant_id', 1);
+ $targetContract = EsignContract::forTenant($tenantId)->with('signers')->findOrFail($id);
+ $sourceContract = EsignContract::forTenant($tenantId)->with(['signers', 'signFields'])->findOrFail($sourceId);
+
+ if ($sourceContract->signFields->isEmpty()) {
+ return response()->json([
+ 'success' => false,
+ 'message' => '소스 계약에 복사할 필드가 없습니다.',
+ ], 422);
+ }
+
+ // 소스 계약 서명자의 sign_order → signer_id 매핑
+ $sourceSignerOrderMap = [];
+ foreach ($sourceContract->signers as $signer) {
+ $sourceSignerOrderMap[$signer->id] = $signer->sign_order;
+ }
+
+ // 대상 계약 sign_order → signer_id 매핑
+ $targetSignerMap = [];
+ foreach ($targetContract->signers as $signer) {
+ $targetSignerMap[$signer->sign_order] = $signer->id;
+ }
+
+ DB::transaction(function () use ($targetContract, $sourceContract, $tenantId, $sourceSignerOrderMap, $targetSignerMap) {
+ // 기존 필드 삭제
+ EsignSignField::where('contract_id', $targetContract->id)->delete();
+
+ foreach ($sourceContract->signFields as $field) {
+ // 소스 signer_id → sign_order → 대상 signer_id
+ $signOrder = $sourceSignerOrderMap[$field->signer_id] ?? null;
+ $targetSignerId = $signOrder ? ($targetSignerMap[$signOrder] ?? null) : null;
+ if (!$targetSignerId) continue;
+
+ EsignSignField::create([
+ 'tenant_id' => $tenantId,
+ 'contract_id' => $targetContract->id,
+ 'signer_id' => $targetSignerId,
+ 'page_number' => $field->page_number,
+ 'position_x' => $field->position_x,
+ 'position_y' => $field->position_y,
+ 'width' => $field->width,
+ 'height' => $field->height,
+ 'field_type' => $field->field_type,
+ 'field_label' => $field->field_label,
+ 'is_required' => $field->is_required,
+ 'sort_order' => $field->sort_order,
+ ]);
+ }
+ });
+
+ $fields = EsignSignField::where('contract_id', $targetContract->id)->orderBy('sort_order')->get();
+
+ return response()->json([
+ 'success' => true,
+ 'message' => '필드가 복사되었습니다.',
+ 'data' => $fields,
+ ]);
+ }
}
diff --git a/app/Models/ESign/EsignFieldTemplate.php b/app/Models/ESign/EsignFieldTemplate.php
new file mode 100644
index 00000000..5c66a0f2
--- /dev/null
+++ b/app/Models/ESign/EsignFieldTemplate.php
@@ -0,0 +1,41 @@
+ 'integer',
+ 'is_active' => 'boolean',
+ ];
+
+ public function scopeForTenant($query, $tenantId)
+ {
+ return $query->where('tenant_id', $tenantId);
+ }
+
+ public function items(): HasMany
+ {
+ return $this->hasMany(EsignFieldTemplateItem::class, 'template_id')->orderBy('sort_order');
+ }
+
+ public function creator(): BelongsTo
+ {
+ return $this->belongsTo(\App\Models\User::class, 'created_by');
+ }
+}
diff --git a/app/Models/ESign/EsignFieldTemplateItem.php b/app/Models/ESign/EsignFieldTemplateItem.php
new file mode 100644
index 00000000..e7b3fe41
--- /dev/null
+++ b/app/Models/ESign/EsignFieldTemplateItem.php
@@ -0,0 +1,41 @@
+ 'integer',
+ 'page_number' => 'integer',
+ 'position_x' => 'decimal:2',
+ 'position_y' => 'decimal:2',
+ 'width' => 'decimal:2',
+ 'height' => 'decimal:2',
+ 'is_required' => 'boolean',
+ 'sort_order' => 'integer',
+ ];
+
+ public function template(): BelongsTo
+ {
+ return $this->belongsTo(EsignFieldTemplate::class, 'template_id');
+ }
+}
diff --git a/resources/views/esign/fields.blade.php b/resources/views/esign/fields.blade.php
index e5df2b6a..12878e15 100644
--- a/resources/views/esign/fields.blade.php
+++ b/resources/views/esign/fields.blade.php
@@ -39,34 +39,73 @@
const MAX_HISTORY = 50;
// ─── Toolbar ───
-const Toolbar = ({ zoom, setZoom, gridEnabled, setGridEnabled, undo, redo, canUndo, canRedo, saving, saveFields, goBack }) => (
-
-
-
-
서명 위치 설정
+const Toolbar = ({ zoom, setZoom, gridEnabled, setGridEnabled, undo, redo, canUndo, canRedo, saving, saveFields, goBack, onSaveTemplate, onLoadTemplate, onCopyFromContract }) => {
+ const [dropdownOpen, setDropdownOpen] = useState(false);
+ const dropdownRef = useRef(null);
+
+ useEffect(() => {
+ const handleClickOutside = (e) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target)) setDropdownOpen(false);
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ return (
+
+
+
+ 서명 위치 설정
+
+
+
+
{Math.round(zoom * 100)}%
+
+
+
+
+
+
+
+
+ {/* 템플릿 드롭다운 */}
+
+
+ {dropdownOpen && (
+
+
+
+
+
+
+ )}
+
+
+
-
-
-
{Math.round(zoom * 100)}%
-
-
-
-
-
-
-
-
-
-);
+ );
+};
// ─── ThumbnailSidebar ───
const ThumbnailSidebar = ({ pdfDoc, totalPages, currentPage, setCurrentPage, fields }) => {
@@ -383,6 +422,217 @@ className="text-gray-300 hover:text-red-500 flex-shrink-0 ml-1">×
);
};
+// ─── SaveTemplateModal ───
+const SaveTemplateModal = ({ open, onClose, onSave }) => {
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+ const [saving, setSaving] = useState(false);
+
+ if (!open) return null;
+
+ const handleSave = async () => {
+ if (!name.trim()) { alert('템플릿 이름을 입력해주세요.'); return; }
+ setSaving(true);
+ await onSave(name.trim(), description.trim());
+ setSaving(false);
+ setName(''); setDescription('');
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
📁 템플릿으로 저장
+
+
+
+ setName(e.target.value)}
+ placeholder="예: 기본 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" autoFocus />
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+// ─── LoadTemplateModal ───
+const LoadTemplateModal = ({ open, onClose, onApply, signerCount }) => {
+ const [templates, setTemplates] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [applying, setApplying] = useState(false);
+ const [selected, setSelected] = useState(null);
+
+ useEffect(() => {
+ if (!open) return;
+ setLoading(true);
+ setSelected(null);
+ fetch(`/esign/contracts/templates?signer_count=${signerCount}`, { headers: getHeaders() })
+ .then(r => r.json())
+ .then(json => { if (json.success) setTemplates(json.data); })
+ .catch(() => {})
+ .finally(() => setLoading(false));
+ }, [open, signerCount]);
+
+ if (!open) return null;
+
+ const handleApply = async () => {
+ if (!selected) return;
+ if (!confirm('현재 필드를 모두 삭제하고 템플릿을 적용하시겠습니까?')) return;
+ setApplying(true);
+ await onApply(selected);
+ setApplying(false);
+ };
+
+ const handleDelete = async (id, e) => {
+ e.stopPropagation();
+ if (!confirm('이 템플릿을 삭제하시겠습니까?')) return;
+ try {
+ const res = await fetch(`/esign/contracts/templates/${id}`, { method: 'DELETE', headers: getHeaders() });
+ const json = await res.json();
+ if (json.success) setTemplates(prev => prev.filter(t => t.id !== id));
+ } catch (_) {}
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
📂 템플릿 불러오기
+ {loading ? (
+
불러오는 중...
+ ) : templates.length === 0 ? (
+
저장된 템플릿이 없습니다.
+ ) : (
+
+ {templates.map(t => (
+
setSelected(t.id)}
+ className={`p-3 border rounded-lg cursor-pointer transition-colors ${selected === t.id ? 'border-blue-400 bg-blue-50' : 'hover:bg-gray-50'}`}>
+
+
+
{t.name}
+ {t.description &&
{t.description}
}
+
+ 서명자 {t.signer_count}명 · 필드 {t.items?.length || 0}개
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ );
+};
+
+// ─── CopyFromContractModal ───
+const CopyFromContractModal = ({ open, onClose, onCopy, currentContractId }) => {
+ const [contracts, setContracts] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [copying, setCopying] = useState(false);
+ const [selected, setSelected] = useState(null);
+ const [search, setSearch] = useState('');
+
+ const doSearch = useCallback(async (q) => {
+ setLoading(true);
+ try {
+ const res = await fetch(`/esign/contracts/list?search=${encodeURIComponent(q)}&per_page=20`, { headers: getHeaders() });
+ const json = await res.json();
+ if (json.success) {
+ // 현재 계약은 제외
+ const filtered = (json.data.data || []).filter(c => c.id !== parseInt(currentContractId));
+ setContracts(filtered);
+ }
+ } catch (_) {}
+ setLoading(false);
+ }, [currentContractId]);
+
+ useEffect(() => {
+ if (!open) return;
+ setSelected(null);
+ setSearch('');
+ doSearch('');
+ }, [open, doSearch]);
+
+ if (!open) return null;
+
+ const handleSearch = (e) => {
+ e.preventDefault();
+ doSearch(search);
+ };
+
+ const handleCopy = async () => {
+ if (!selected) return;
+ if (!confirm('현재 필드를 모두 삭제하고 선택한 계약의 필드를 복사하시겠습니까?')) return;
+ setCopying(true);
+ await onCopy(selected);
+ setCopying(false);
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
📋 다른 계약에서 복사
+
+ {loading ? (
+
검색 중...
+ ) : contracts.length === 0 ? (
+
검색 결과가 없습니다.
+ ) : (
+
+ {contracts.map(c => (
+
setSelected(c.id)}
+ className={`p-3 border rounded-lg cursor-pointer transition-colors ${selected === c.id ? 'border-blue-400 bg-blue-50' : 'hover:bg-gray-50'}`}>
+
{c.title}
+
+ {c.contract_code}
+ 서명자: {c.signers?.map(s => s.name).join(', ')}
+ {c.status}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ );
+};
+
// ─── 유틸 ───
const round2 = (n) => Math.round(n * 100) / 100;
@@ -400,6 +650,9 @@ className="text-gray-300 hover:text-red-500 flex-shrink-0 ml-1">×
const [gridEnabled, setGridEnabled] = useState(false);
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
const [clipboard, setClipboard] = useState(null);
+ const [showSaveTemplate, setShowSaveTemplate] = useState(false);
+ const [showLoadTemplate, setShowLoadTemplate] = useState(false);
+ const [showCopyFromContract, setShowCopyFromContract] = useState(false);
// History (Undo/Redo)
const [history, setHistory] = useState([]);
@@ -582,6 +835,95 @@ className="text-gray-300 hover:text-red-500 flex-shrink-0 ml-1">×
setSaving(false);
};
+ // 템플릿으로 저장
+ const handleSaveTemplate = useCallback(async (name, description) => {
+ if (fields.length === 0) { alert('저장할 필드가 없습니다.'); return; }
+ const signers = contract.signers || [];
+ // signer_id → sign_order 매핑
+ const signerOrderMap = {};
+ signers.forEach(s => { signerOrderMap[s.id] = s.sign_order; });
+
+ const items = fields.map((f, i) => ({
+ signer_order: signerOrderMap[f.signer_id] || 1,
+ page_number: f.page_number,
+ position_x: round2(f.position_x),
+ position_y: round2(f.position_y),
+ width: round2(f.width),
+ height: round2(f.height),
+ field_type: f.field_type,
+ field_label: f.field_label || '',
+ is_required: f.is_required !== false,
+ }));
+
+ try {
+ const res = await fetch('/esign/contracts/templates', {
+ method: 'POST', headers: getHeaders(),
+ body: JSON.stringify({ name, description, items }),
+ });
+ const json = await res.json();
+ if (json.success) {
+ alert('템플릿이 저장되었습니다.');
+ setShowSaveTemplate(false);
+ } else {
+ alert(json.message || '저장 실패');
+ }
+ } catch (_) { alert('서버 오류'); }
+ }, [fields, contract]);
+
+ // 템플릿 적용
+ const handleApplyTemplate = useCallback(async (templateId) => {
+ try {
+ const res = await fetch(`/esign/contracts/${CONTRACT_ID}/apply-template`, {
+ method: 'POST', headers: getHeaders(),
+ body: JSON.stringify({ template_id: templateId }),
+ });
+ const json = await res.json();
+ if (json.success) {
+ const signers = contract.signers || [];
+ const newFields = (json.data || []).map(f => ({
+ signer_id: f.signer_id, page_number: f.page_number,
+ 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 || '',
+ is_required: f.is_required !== false,
+ }));
+ setFields(newFields);
+ pushHistory(newFields);
+ setSelectedFieldIndex(null);
+ setShowLoadTemplate(false);
+ alert('템플릿이 적용되었습니다.');
+ } else {
+ alert(json.message || '적용 실패');
+ }
+ } catch (_) { alert('서버 오류'); }
+ }, [contract, pushHistory]);
+
+ // 다른 계약에서 필드 복사
+ const handleCopyFromContract = useCallback(async (sourceId) => {
+ try {
+ const res = await fetch(`/esign/contracts/${CONTRACT_ID}/copy-fields/${sourceId}`, {
+ method: 'POST', headers: getHeaders(),
+ });
+ const json = await res.json();
+ if (json.success) {
+ const newFields = (json.data || []).map(f => ({
+ signer_id: f.signer_id, page_number: f.page_number,
+ 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 || '',
+ is_required: f.is_required !== false,
+ }));
+ setFields(newFields);
+ pushHistory(newFields);
+ setSelectedFieldIndex(null);
+ setShowCopyFromContract(false);
+ alert('필드가 복사되었습니다.');
+ } else {
+ alert(json.message || '복사 실패');
+ }
+ } catch (_) { alert('서버 오류'); }
+ }, [pushHistory]);
+
// 키보드 단축키
useEffect(() => {
const handler = (e) => {
@@ -684,6 +1026,9 @@ className="text-gray-300 hover:text-red-500 flex-shrink-0 ml-1">×
canUndo={historyIndex > 0} canRedo={historyIndex < history.length - 1}
saving={saving} saveFields={saveFields}
goBack={() => location.href = `/esign/${CONTRACT_ID}`}
+ onSaveTemplate={() => setShowSaveTemplate(true)}
+ onLoadTemplate={() => setShowLoadTemplate(true)}
+ onCopyFromContract={() => setShowCopyFromContract(true)}
/>
{/* 썸네일 사이드바 */}
@@ -745,6 +1090,25 @@ className="text-gray-300 hover:text-red-500 flex-shrink-0 ml-1">×
}}
/>
+
+ {/* 모달 */}
+
setShowSaveTemplate(false)}
+ onSave={handleSaveTemplate}
+ />
+ setShowLoadTemplate(false)}
+ onApply={handleApplyTemplate}
+ signerCount={signers.length}
+ />
+ setShowCopyFromContract(false)}
+ onCopy={handleCopyFromContract}
+ currentContractId={CONTRACT_ID}
+ />
);
};
diff --git a/routes/web.php b/routes/web.php
index 103855cb..0fb41964 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -1413,6 +1413,15 @@
Route::post('/{id}/send', [EsignApiController::class, 'send'])->whereNumber('id')->name('send');
Route::post('/{id}/remind', [EsignApiController::class, 'remind'])->whereNumber('id')->name('remind');
Route::get('/{id}/download', [EsignApiController::class, 'download'])->whereNumber('id')->name('download');
+
+ // 필드 템플릿
+ Route::get('/templates', [EsignApiController::class, 'indexTemplates'])->name('templates.index');
+ Route::post('/templates', [EsignApiController::class, 'storeTemplate'])->name('templates.store');
+ Route::delete('/templates/{templateId}', [EsignApiController::class, 'destroyTemplate'])->whereNumber('templateId')->name('templates.destroy');
+
+ // 템플릿 적용 / 필드 복사
+ Route::post('/{id}/apply-template', [EsignApiController::class, 'applyTemplate'])->whereNumber('id')->name('apply-template');
+ Route::post('/{id}/copy-fields/{sourceId}', [EsignApiController::class, 'copyFieldsFromContract'])->whereNumber('id')->whereNumber('sourceId')->name('copy-fields');
});
});