feat:E-Sign 필드 템플릿 저장/불러오기 및 계약 간 복사 기능
- EsignFieldTemplate, EsignFieldTemplateItem 모델 추가 - EsignApiController에 템플릿 CRUD + 적용/복사 메서드 5개 추가 - web.php에 템플릿 라우트 5개 추가 - fields.blade.php에 템플릿 드롭다운 메뉴 + 모달 3개 추가 (SaveTemplate, LoadTemplate, CopyFromContract) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
41
app/Models/ESign/EsignFieldTemplate.php
Normal file
41
app/Models/ESign/EsignFieldTemplate.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\ESign;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EsignFieldTemplate extends Model
|
||||
{
|
||||
protected $table = 'esign_field_templates';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'name',
|
||||
'description',
|
||||
'signer_count',
|
||||
'is_active',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'signer_count' => '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');
|
||||
}
|
||||
}
|
||||
41
app/Models/ESign/EsignFieldTemplateItem.php
Normal file
41
app/Models/ESign/EsignFieldTemplateItem.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\ESign;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EsignFieldTemplateItem extends Model
|
||||
{
|
||||
protected $table = 'esign_field_template_items';
|
||||
|
||||
protected $fillable = [
|
||||
'template_id',
|
||||
'signer_order',
|
||||
'page_number',
|
||||
'position_x',
|
||||
'position_y',
|
||||
'width',
|
||||
'height',
|
||||
'field_type',
|
||||
'field_label',
|
||||
'is_required',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'signer_order' => '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');
|
||||
}
|
||||
}
|
||||
@@ -39,34 +39,73 @@
|
||||
const MAX_HISTORY = 50;
|
||||
|
||||
// ─── Toolbar ───
|
||||
const Toolbar = ({ zoom, setZoom, gridEnabled, setGridEnabled, undo, redo, canUndo, canRedo, saving, saveFields, goBack }) => (
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-white border-b shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={goBack} className="text-gray-500 hover:text-gray-800 text-lg px-2" title="뒤로가기">←</button>
|
||||
<span className="font-semibold text-gray-800 text-sm">서명 위치 설정</span>
|
||||
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 (
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-white border-b shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={goBack} className="text-gray-500 hover:text-gray-800 text-lg px-2" title="뒤로가기">←</button>
|
||||
<span className="font-semibold text-gray-800 text-sm">서명 위치 설정</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => { const i = ZOOM_LEVELS.indexOf(zoom); if (i > 0) setZoom(ZOOM_LEVELS[i-1]); }}
|
||||
disabled={zoom <= ZOOM_LEVELS[0]}
|
||||
className="px-2 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-30" title="축소">−</button>
|
||||
<span className="px-2 py-1 text-xs font-mono min-w-[50px] text-center">{Math.round(zoom * 100)}%</span>
|
||||
<button onClick={() => { const i = ZOOM_LEVELS.indexOf(zoom); if (i < ZOOM_LEVELS.length - 1) setZoom(ZOOM_LEVELS[i+1]); }}
|
||||
disabled={zoom >= ZOOM_LEVELS[ZOOM_LEVELS.length - 1]}
|
||||
className="px-2 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-30" title="확대">+</button>
|
||||
<div className="w-px h-5 bg-gray-200 mx-1"></div>
|
||||
<button onClick={() => setGridEnabled(g => !g)}
|
||||
className={`px-2 py-1 text-sm border rounded ${gridEnabled ? 'bg-blue-50 border-blue-300 text-blue-600' : 'hover:bg-gray-50'}`}
|
||||
title="그리드 스냅">▦</button>
|
||||
<div className="w-px h-5 bg-gray-200 mx-1"></div>
|
||||
<button onClick={undo} disabled={!canUndo} className="px-2 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-30" title="되돌리기 (Ctrl+Z)">↩</button>
|
||||
<button onClick={redo} disabled={!canRedo} className="px-2 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-30" title="다시실행 (Ctrl+Shift+Z)">↪</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 템플릿 드롭다운 */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button onClick={() => setDropdownOpen(o => !o)}
|
||||
className="px-3 py-1.5 border rounded-lg text-sm hover:bg-gray-50 transition-colors flex items-center gap-1">
|
||||
템플릿 <span className="text-[10px]">▾</span>
|
||||
</button>
|
||||
{dropdownOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 w-48 bg-white border rounded-lg shadow-lg z-50 py-1">
|
||||
<button onClick={() => { setDropdownOpen(false); onSaveTemplate(); }}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex items-center gap-2">
|
||||
<span>📁</span> 템플릿으로 저장
|
||||
</button>
|
||||
<button onClick={() => { setDropdownOpen(false); onLoadTemplate(); }}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex items-center gap-2">
|
||||
<span>📂</span> 템플릿 불러오기
|
||||
</button>
|
||||
<div className="border-t my-1"></div>
|
||||
<button onClick={() => { setDropdownOpen(false); onCopyFromContract(); }}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex items-center gap-2">
|
||||
<span>📋</span> 다른 계약에서 복사
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={saveFields} disabled={saving}
|
||||
className="px-5 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium disabled:opacity-50 transition-colors">
|
||||
{saving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => { const i = ZOOM_LEVELS.indexOf(zoom); if (i > 0) setZoom(ZOOM_LEVELS[i-1]); }}
|
||||
disabled={zoom <= ZOOM_LEVELS[0]}
|
||||
className="px-2 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-30" title="축소">−</button>
|
||||
<span className="px-2 py-1 text-xs font-mono min-w-[50px] text-center">{Math.round(zoom * 100)}%</span>
|
||||
<button onClick={() => { const i = ZOOM_LEVELS.indexOf(zoom); if (i < ZOOM_LEVELS.length - 1) setZoom(ZOOM_LEVELS[i+1]); }}
|
||||
disabled={zoom >= ZOOM_LEVELS[ZOOM_LEVELS.length - 1]}
|
||||
className="px-2 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-30" title="확대">+</button>
|
||||
<div className="w-px h-5 bg-gray-200 mx-1"></div>
|
||||
<button onClick={() => setGridEnabled(g => !g)}
|
||||
className={`px-2 py-1 text-sm border rounded ${gridEnabled ? 'bg-blue-50 border-blue-300 text-blue-600' : 'hover:bg-gray-50'}`}
|
||||
title="그리드 스냅">▦</button>
|
||||
<div className="w-px h-5 bg-gray-200 mx-1"></div>
|
||||
<button onClick={undo} disabled={!canUndo} className="px-2 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-30" title="되돌리기 (Ctrl+Z)">↩</button>
|
||||
<button onClick={redo} disabled={!canRedo} className="px-2 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-30" title="다시실행 (Ctrl+Shift+Z)">↪</button>
|
||||
</div>
|
||||
<button onClick={saveFields} disabled={saving}
|
||||
className="px-5 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium disabled:opacity-50 transition-colors">
|
||||
{saving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
// ─── 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">×</button>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── 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 (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-96 p-5" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="text-base font-semibold mb-3">📁 템플릿으로 저장</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block mb-1">템플릿 이름 *</label>
|
||||
<input type="text" value={name} onChange={e => 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 />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block mb-1">설명</label>
|
||||
<textarea value={description} onChange={e => setDescription(e.target.value)}
|
||||
placeholder="선택사항" rows={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 resize-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button onClick={onClose} className="px-4 py-1.5 text-sm border rounded-lg hover:bg-gray-50">취소</button>
|
||||
<button onClick={handleSave} disabled={saving}
|
||||
className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
||||
{saving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── 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 (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-[480px] max-h-[70vh] flex flex-col p-5" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="text-base font-semibold mb-3">📂 템플릿 불러오기</h3>
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-400 py-8">불러오는 중...</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-8">저장된 템플릿이 없습니다.</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto space-y-2 mb-3">
|
||||
{templates.map(t => (
|
||||
<div key={t.id}
|
||||
onClick={() => 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'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t.name}</div>
|
||||
{t.description && <div className="text-xs text-gray-400 mt-0.5">{t.description}</div>}
|
||||
<div className="text-[10px] text-gray-400 mt-1">
|
||||
서명자 {t.signer_count}명 · 필드 {t.items?.length || 0}개
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={(e) => handleDelete(t.id, e)}
|
||||
className="text-gray-300 hover:text-red-500 text-lg px-1" title="삭제">×</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2 border-t">
|
||||
<button onClick={onClose} className="px-4 py-1.5 text-sm border rounded-lg hover:bg-gray-50">취소</button>
|
||||
<button onClick={handleApply} disabled={!selected || applying}
|
||||
className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
||||
{applying ? '적용 중...' : '적용'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── 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 (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-[520px] max-h-[70vh] flex flex-col p-5" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="text-base font-semibold mb-3">📋 다른 계약에서 복사</h3>
|
||||
<form onSubmit={handleSearch} className="flex gap-2 mb-3">
|
||||
<input type="text" value={search} onChange={e => setSearch(e.target.value)}
|
||||
placeholder="계약 제목 또는 코드로 검색..."
|
||||
className="flex-1 border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-200 focus:border-blue-400 outline-none" autoFocus />
|
||||
<button type="submit" className="px-3 py-2 text-sm border rounded-lg hover:bg-gray-50">검색</button>
|
||||
</form>
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-400 py-8">검색 중...</div>
|
||||
) : contracts.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-8">검색 결과가 없습니다.</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto space-y-2 mb-3">
|
||||
{contracts.map(c => (
|
||||
<div key={c.id}
|
||||
onClick={() => 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'}`}>
|
||||
<div className="text-sm font-medium">{c.title}</div>
|
||||
<div className="text-[10px] text-gray-400 mt-1 flex gap-3">
|
||||
<span>{c.contract_code}</span>
|
||||
<span>서명자: {c.signers?.map(s => s.name).join(', ')}</span>
|
||||
<span className="capitalize">{c.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2 border-t">
|
||||
<button onClick={onClose} className="px-4 py-1.5 text-sm border rounded-lg hover:bg-gray-50">취소</button>
|
||||
<button onClick={handleCopy} disabled={!selected || copying}
|
||||
className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
||||
{copying ? '복사 중...' : '복사'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── 유틸 ───
|
||||
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">×</button>
|
||||
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">×</button>
|
||||
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">×</button>
|
||||
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)}
|
||||
/>
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 썸네일 사이드바 */}
|
||||
@@ -745,6 +1090,25 @@ className="text-gray-300 hover:text-red-500 flex-shrink-0 ml-1">×</button>
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 모달 */}
|
||||
<SaveTemplateModal
|
||||
open={showSaveTemplate}
|
||||
onClose={() => setShowSaveTemplate(false)}
|
||||
onSave={handleSaveTemplate}
|
||||
/>
|
||||
<LoadTemplateModal
|
||||
open={showLoadTemplate}
|
||||
onClose={() => setShowLoadTemplate(false)}
|
||||
onApply={handleApplyTemplate}
|
||||
signerCount={signers.length}
|
||||
/>
|
||||
<CopyFromContractModal
|
||||
open={showCopyFromContract}
|
||||
onClose={() => setShowCopyFromContract(false)}
|
||||
onCopy={handleCopyFromContract}
|
||||
currentContractId={CONTRACT_ID}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user