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 /> +
+
+ +