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:
김보곤
2026-02-12 18:02:31 +09:00
parent 4f25e0e4a1
commit 79c23f3337
5 changed files with 711 additions and 27 deletions

View File

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

View 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');
}
}

View 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');
}
}

View File

@@ -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="뒤로가기">&larr;</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="뒤로가기">&larr;</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">&times;</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="삭제">&times;</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">&times;</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">&times;</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">&times;</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">&times;</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>
);
};

View File

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