Files
sam-manage/resources/views/esign/fields.blade.php
김보곤 ec43fe1991 feat:E-Sign 템플릿 관리 시스템 Phase 2 구현
- 템플릿 관리 전용 페이지 (카드 그리드, 검색/필터, 편집/복제/삭제)
- API: showTemplate, updateTemplate, duplicateTemplate 추가
- indexTemplates에 category/search 필터 추가
- 계약 생성 시 템플릿 선택 UI 추가
- 필드 에디터에서 URL 파라미터 template_id 자동 적용
- EsignFieldTemplate 모델에 category 필드 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 18:55:06 +09:00

1133 lines
57 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@extends('layouts.app')
@section('title', 'SAM E-Sign - 서명 위치 설정')
@section('content')
<meta name="csrf-token" content="{{ csrf_token() }}">
<div id="esign-fields-root" data-contract-id="{{ $contractId }}"></div>
@endsection
@push('scripts')
@include('partials.react-cdn')
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script>pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';</script>
<script src="https://cdn.jsdelivr.net/npm/interactjs/dist/interact.min.js"></script>
@verbatim
<script type="text/babel">
const { useState, useEffect, useCallback, useRef, useMemo } = React;
const CONTRACT_ID = document.getElementById('esign-fields-root')?.dataset.contractId;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const getHeaders = () => ({
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || csrfToken,
});
const FIELD_TYPES = [
{ value: 'signature', label: '서명', icon: '✍' },
{ value: 'stamp', label: '도장', icon: '📌' },
{ value: 'text', label: '텍스트', icon: 'T' },
{ value: 'date', label: '날짜', icon: '📅' },
{ value: 'checkbox', label: '체크박스', icon: '☑' },
];
const SIGNER_COLORS = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899'];
const ZOOM_LEVELS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
const MAX_HISTORY = 50;
// ─── Toolbar ───
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>
);
};
// ─── ThumbnailSidebar ───
const ThumbnailSidebar = ({ pdfDoc, totalPages, currentPage, setCurrentPage, fields }) => {
const canvasRefs = useRef({});
const [rendered, setRendered] = useState({});
useEffect(() => {
if (!pdfDoc) return;
const renderThumbs = async () => {
for (let i = 1; i <= totalPages; i++) {
const canvas = canvasRefs.current[i];
if (!canvas || rendered[i]) continue;
const page = await pdfDoc.getPage(i);
const vp = page.getViewport({ scale: 0.15 });
canvas.width = vp.width;
canvas.height = vp.height;
await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise;
setRendered(prev => ({ ...prev, [i]: true }));
}
};
renderThumbs();
}, [pdfDoc, totalPages]);
const fieldCountByPage = useMemo(() => {
const counts = {};
fields.forEach(f => { counts[f.page_number] = (counts[f.page_number] || 0) + 1; });
return counts;
}, [fields]);
return (
<div className="w-20 bg-gray-50 border-r overflow-y-auto flex-shrink-0" style={{ height: 'calc(100vh - 105px)' }}>
<div className="p-2 space-y-2">
{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
<button key={p} onClick={() => setCurrentPage(p)}
className={`w-full relative rounded border-2 transition-all ${p === currentPage ? 'border-blue-500 shadow-md' : 'border-transparent hover:border-gray-300'}`}>
<canvas ref={el => canvasRefs.current[p] = el} className="w-full rounded" />
<span className="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-[10px] text-center py-0.5 rounded-b">{p}</span>
{fieldCountByPage[p] > 0 && (
<span className="absolute -top-1 -right-1 bg-blue-500 text-white text-[9px] w-4 h-4 rounded-full flex items-center justify-center font-bold">
{fieldCountByPage[p]}
</span>
)}
</button>
))}
</div>
</div>
);
};
// ─── FieldOverlay (interact.js) ───
const FieldOverlay = ({ field, index, selected, signers, containerRef, onSelect, onUpdate, onDragEnd, gridEnabled }) => {
const fieldRef = useRef(null);
// Ref로 최신 field/callbacks 추적 (interact.js 재초기화 방지)
const latestRef = useRef({ field, index, onUpdate, onDragEnd });
latestRef.current = { field, index, onUpdate, onDragEnd };
const signerIdx = signers.findIndex(s => s.id === field.signer_id);
const color = SIGNER_COLORS[signerIdx % SIGNER_COLORS.length] || '#888';
const signerName = signers[signerIdx]?.name || '?';
const typeInfo = FIELD_TYPES.find(t => t.value === field.field_type) || FIELD_TYPES[0];
// interact.js는 gridEnabled 변경 시에만 재초기화
useEffect(() => {
const el = fieldRef.current;
if (!el) return;
const modifiers = [
interact.modifiers.restrictRect({ restriction: 'parent', endOnly: false }),
];
if (gridEnabled) {
modifiers.push(interact.modifiers.snap({
targets: [interact.snappers.grid({ x: 1, y: 1 })],
range: 10, relativePoints: [{ x: 0, y: 0 }],
}));
}
const interactable = interact(el)
.draggable({
inertia: false,
modifiers,
listeners: {
move(event) {
const { field: f, index: idx, onUpdate: update } = latestRef.current;
const rect = containerRef.current.getBoundingClientRect();
const dx = (event.dx / rect.width) * 100;
const dy = (event.dy / rect.height) * 100;
const newX = Math.max(0, Math.min(100 - f.width, f.position_x + dx));
const newY = Math.max(0, Math.min(100 - f.height, f.position_y + dy));
update(idx, { position_x: round2(newX), position_y: round2(newY) });
},
end() { latestRef.current.onDragEnd(); },
},
})
.resizable({
edges: { left: true, right: true, top: true, bottom: true },
modifiers: [
interact.modifiers.restrictEdges({ outer: 'parent' }),
interact.modifiers.restrictSize({ min: { width: 30, height: 20 } }),
],
listeners: {
move(event) {
const { field: f, index: idx, onUpdate: update } = latestRef.current;
const rect = containerRef.current.getBoundingClientRect();
const newX = f.position_x + (event.deltaRect.left / rect.width) * 100;
const newY = f.position_y + (event.deltaRect.top / rect.height) * 100;
const newW = (event.rect.width / rect.width) * 100;
const newH = (event.rect.height / rect.height) * 100;
update(idx, {
position_x: round2(Math.max(0, newX)),
position_y: round2(Math.max(0, newY)),
width: round2(Math.max(2, newW)),
height: round2(Math.max(1, newH)),
});
},
end() { latestRef.current.onDragEnd(); },
},
});
return () => interactable.unset();
}, [gridEnabled]);
return (
<div ref={fieldRef}
onClick={(e) => { e.stopPropagation(); onSelect(index); }}
style={{
position: 'absolute',
left: `${field.position_x}%`, top: `${field.position_y}%`,
width: `${field.width}%`, height: `${field.height}%`,
border: selected ? `2px solid ${color}` : `2px dashed ${color}`,
backgroundColor: selected ? `${color}30` : `${color}15`,
cursor: 'move', borderRadius: '4px',
zIndex: selected ? 20 : 10,
touchAction: 'none',
boxShadow: selected ? `0 0 0 1px ${color}, 0 2px 8px rgba(0,0,0,0.15)` : 'none',
}}
className="flex items-center justify-center select-none group transition-shadow">
<div className="flex items-center gap-0.5 text-[10px] font-medium truncate px-1" style={{ color }}>
<span>{typeInfo.icon}</span>
<span className="truncate">{field.field_label || signerName}</span>
</div>
{/* 리사이즈 핸들 (선택 시) */}
{selected && <>
{['nw','n','ne','w','e','sw','s','se'].map(dir => {
const styles = {
nw: { top: -4, left: -4, cursor: 'nw-resize' },
n: { top: -4, left: '50%', transform: 'translateX(-50%)', cursor: 'n-resize' },
ne: { top: -4, right: -4, cursor: 'ne-resize' },
w: { top: '50%', left: -4, transform: 'translateY(-50%)', cursor: 'w-resize' },
e: { top: '50%', right: -4, transform: 'translateY(-50%)', cursor: 'e-resize' },
sw: { bottom: -4, left: -4, cursor: 'sw-resize' },
s: { bottom: -4, left: '50%', transform: 'translateX(-50%)', cursor: 's-resize' },
se: { bottom: -4, right: -4, cursor: 'se-resize' },
};
return (
<div key={dir} style={{
...styles[dir], position: 'absolute',
width: 8, height: 8, backgroundColor: 'white',
border: `2px solid ${color}`, borderRadius: 2,
zIndex: 30,
}} />
);
})}
</>}
</div>
);
};
// ─── PropertiesPanel ───
const PropertiesPanel = ({ signers, fields, selectedFieldIndex, currentPage, onAddField, onUpdateField, onRemoveField, onSelectField }) => {
const selectedField = selectedFieldIndex !== null ? fields[selectedFieldIndex] : null;
return (
<div className="w-72 bg-white border-l overflow-y-auto flex-shrink-0" style={{ height: 'calc(100vh - 105px)' }}>
<div className="p-3 space-y-4">
{/* 필드 도구상자 */}
<div>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">필드 추가</h3>
{signers.map((s, i) => {
const color = SIGNER_COLORS[i % SIGNER_COLORS.length];
return (
<div key={s.id} className="mb-3">
<div className="flex items-center gap-1.5 mb-1.5">
<span className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: color }}></span>
<span className="text-xs font-medium text-gray-700 truncate">{s.name}</span>
<span className="text-[10px] text-gray-400">({s.role === 'creator' ? '작성자' : '상대방'})</span>
</div>
<div className="grid grid-cols-3 gap-1">
{FIELD_TYPES.map(ft => (
<button key={ft.value}
onClick={() => onAddField(s.id, ft.value)}
className="px-1.5 py-1.5 border rounded text-[10px] hover:bg-gray-50 transition-colors flex flex-col items-center gap-0.5"
style={{ borderColor: `${color}40` }}>
<span className="text-sm">{ft.icon}</span>
<span className="text-gray-600">{ft.label}</span>
</button>
))}
</div>
</div>
);
})}
</div>
<div className="border-t pt-3">
{/* 선택된 필드 속성 */}
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
{selectedField ? '필드 속성' : '필드 속성 (선택 없음)'}
</h3>
{selectedField ? (
<div className="space-y-2">
<div>
<label className="text-[10px] text-gray-500">서명자</label>
<select value={selectedField.signer_id}
onChange={e => onUpdateField(selectedFieldIndex, { signer_id: parseInt(e.target.value) })}
className="w-full border rounded px-2 py-1 text-xs mt-0.5">
{signers.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div>
<label className="text-[10px] text-gray-500">유형</label>
<select value={selectedField.field_type}
onChange={e => onUpdateField(selectedFieldIndex, { field_type: e.target.value })}
className="w-full border rounded px-2 py-1 text-xs mt-0.5">
{FIELD_TYPES.map(t => <option key={t.value} value={t.value}>{t.icon} {t.label}</option>)}
</select>
</div>
<div>
<label className="text-[10px] text-gray-500">라벨</label>
<input type="text" value={selectedField.field_label || ''}
onChange={e => onUpdateField(selectedFieldIndex, { field_label: e.target.value })}
placeholder="선택사항"
className="w-full border rounded px-2 py-1 text-xs mt-0.5" />
</div>
<div className="grid grid-cols-2 gap-1.5">
<div>
<label className="text-[10px] text-gray-500">X (%)</label>
<input type="number" value={selectedField.position_x} step="0.5" min="0" max="100"
onChange={e => onUpdateField(selectedFieldIndex, { position_x: parseFloat(e.target.value) || 0 })}
className="w-full border rounded px-2 py-1 text-xs mt-0.5" />
</div>
<div>
<label className="text-[10px] text-gray-500">Y (%)</label>
<input type="number" value={selectedField.position_y} step="0.5" min="0" max="100"
onChange={e => onUpdateField(selectedFieldIndex, { position_y: parseFloat(e.target.value) || 0 })}
className="w-full border rounded px-2 py-1 text-xs mt-0.5" />
</div>
<div>
<label className="text-[10px] text-gray-500">W (%)</label>
<input type="number" value={selectedField.width} step="0.5" min="2" max="100"
onChange={e => onUpdateField(selectedFieldIndex, { width: parseFloat(e.target.value) || 2 })}
className="w-full border rounded px-2 py-1 text-xs mt-0.5" />
</div>
<div>
<label className="text-[10px] text-gray-500">H (%)</label>
<input type="number" value={selectedField.height} step="0.5" min="1" max="100"
onChange={e => onUpdateField(selectedFieldIndex, { height: parseFloat(e.target.value) || 1 })}
className="w-full border rounded px-2 py-1 text-xs mt-0.5" />
</div>
</div>
<div>
<label className="text-[10px] text-gray-500">페이지</label>
<input type="number" value={selectedField.page_number} min="1"
onChange={e => onUpdateField(selectedFieldIndex, { page_number: parseInt(e.target.value) || 1 })}
className="w-full border rounded px-2 py-1 text-xs mt-0.5" />
</div>
<div className="flex items-center gap-2 pt-1">
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
<input type="checkbox" checked={selectedField.is_required !== false}
onChange={e => onUpdateField(selectedFieldIndex, { is_required: e.target.checked })}
className="rounded border-gray-300" />
필수 항목
</label>
</div>
<button onClick={() => onRemoveField(selectedFieldIndex)}
className="w-full mt-2 px-3 py-1.5 border border-red-200 text-red-600 rounded text-xs hover:bg-red-50 transition-colors">
필드 삭제
</button>
</div>
) : (
<p className="text-xs text-gray-400">PDF에서 필드를 클릭하여 선택하세요</p>
)}
</div>
{/* 필드 목록 */}
<div className="border-t pt-3">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
필드 목록 ({fields.length})
</h3>
<div className="space-y-1 max-h-60 overflow-y-auto">
{fields.length === 0 && <p className="text-xs text-gray-400">아직 추가된 필드가 없습니다</p>}
{fields.map((f, idx) => {
const signer = signers.find(s => s.id === f.signer_id);
const si = signers.findIndex(s => s.id === f.signer_id);
const color = SIGNER_COLORS[si % SIGNER_COLORS.length] || '#888';
const typeInfo = FIELD_TYPES.find(t => t.value === f.field_type) || FIELD_TYPES[0];
const isSelected = idx === selectedFieldIndex;
return (
<div key={idx}
onClick={() => onSelectField(idx)}
className={`flex items-center justify-between px-2 py-1.5 rounded cursor-pointer text-xs transition-colors ${isSelected ? 'bg-blue-50 ring-1 ring-blue-200' : 'hover:bg-gray-50'}`}>
<div className="flex items-center gap-1.5 min-w-0">
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: color }}></span>
<span className="truncate">{typeInfo.icon} {f.field_label || signer?.name || '?'}</span>
<span className="text-gray-400 flex-shrink-0">p.{f.page_number}</span>
</div>
<button onClick={(e) => { e.stopPropagation(); onRemoveField(idx); }}
className="text-gray-300 hover:text-red-500 flex-shrink-0 ml-1">&times;</button>
</div>
);
})}
</div>
</div>
</div>
</div>
);
};
// ─── 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;
// ─── App ───
const App = () => {
const [contract, setContract] = useState(null);
const [fields, setFields] = useState([]);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
const [pdfDoc, setPdfDoc] = useState(null);
const [selectedFieldIndex, setSelectedFieldIndex] = useState(null);
const [zoom, setZoom] = useState(1.0);
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([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const skipHistoryRef = useRef(false);
const canvasRef = useRef(null);
const containerRef = useRef(null);
const renderTaskRef = useRef(null);
const pushHistory = useCallback((newFields) => {
if (skipHistoryRef.current) return;
setHistory(prev => {
const trimmed = prev.slice(0, historyIndex + 1);
const next = [...trimmed, JSON.parse(JSON.stringify(newFields))];
if (next.length > MAX_HISTORY) next.shift();
return next;
});
setHistoryIndex(prev => Math.min(prev + 1, MAX_HISTORY - 1));
}, [historyIndex]);
const undo = useCallback(() => {
if (historyIndex <= 0) return;
const newIndex = historyIndex - 1;
skipHistoryRef.current = true;
setFields(JSON.parse(JSON.stringify(history[newIndex])));
setHistoryIndex(newIndex);
skipHistoryRef.current = false;
}, [history, historyIndex]);
const redo = useCallback(() => {
if (historyIndex >= history.length - 1) return;
const newIndex = historyIndex + 1;
skipHistoryRef.current = true;
setFields(JSON.parse(JSON.stringify(history[newIndex])));
setHistoryIndex(newIndex);
skipHistoryRef.current = false;
}, [history, historyIndex]);
// 계약 정보 로드
const fetchContract = useCallback(async () => {
try {
const res = await fetch(`/esign/contracts/${CONTRACT_ID}`, { headers: getHeaders() });
const json = await res.json();
if (json.success) {
setContract(json.data);
const loadedFields = (json.data.sign_fields || []).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(loadedFields);
// 초기 히스토리
setHistory([JSON.parse(JSON.stringify(loadedFields))]);
setHistoryIndex(0);
}
} catch (e) { console.error(e); }
setLoading(false);
}, []);
// PDF 로드
const loadPdf = useCallback(async () => {
if (!contract) return;
try {
const url = `/esign/contracts/${CONTRACT_ID}/download`;
const res = await fetch(url, { headers: { 'X-CSRF-TOKEN': csrfToken } });
const arrayBuffer = await res.arrayBuffer();
const doc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
setPdfDoc(doc);
setTotalPages(doc.numPages);
} catch (e) { console.error('PDF 로드 실패:', e); }
}, [contract]);
// PDF 페이지 렌더링
const renderPage = useCallback(async (pageNum) => {
if (!pdfDoc || !canvasRef.current) return;
// 이전 렌더 작업 취소
if (renderTaskRef.current) {
try { renderTaskRef.current.cancel(); } catch (_) {}
renderTaskRef.current = null;
}
try {
const page = await pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale: zoom });
const canvas = canvasRef.current;
if (!canvas) return;
canvas.height = viewport.height;
canvas.width = viewport.width;
setCanvasSize({ width: viewport.width, height: viewport.height });
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
const renderTask = page.render({ canvasContext: ctx, viewport });
renderTaskRef.current = renderTask;
await renderTask.promise;
renderTaskRef.current = null;
} catch (e) {
if (e.name !== 'RenderingCancelledException') console.error(e);
}
}, [pdfDoc, zoom]);
useEffect(() => { fetchContract(); }, [fetchContract]);
useEffect(() => { loadPdf(); }, [loadPdf]);
useEffect(() => { renderPage(currentPage); }, [renderPage, currentPage]);
// URL 파라미터로 템플릿 자동 적용
const autoAppliedRef = useRef(false);
useEffect(() => {
if (!contract || autoAppliedRef.current) return;
const params = new URLSearchParams(window.location.search);
const urlTemplateId = params.get('template_id');
if (urlTemplateId && fields.length === 0) {
autoAppliedRef.current = true;
handleApplyTemplate(parseInt(urlTemplateId));
window.history.replaceState({}, '', window.location.pathname);
}
}, [contract]);
// PDF.js 메모리 정리
useEffect(() => {
return () => { if (pdfDoc) pdfDoc.destroy(); };
}, [pdfDoc]);
// 필드 추가
const addField = useCallback((signerId, fieldType = 'signature') => {
const newField = {
signer_id: signerId, page_number: currentPage,
position_x: 25, position_y: 40, width: 20, height: 5,
field_type: fieldType, field_label: '', is_required: true,
};
const newFields = [...fields, newField];
setFields(newFields);
setSelectedFieldIndex(newFields.length - 1);
pushHistory(newFields);
}, [fields, currentPage, pushHistory]);
// 필드 삭제
const removeField = useCallback((idx) => {
const newFields = fields.filter((_, i) => i !== idx);
setFields(newFields);
setSelectedFieldIndex(null);
pushHistory(newFields);
}, [fields, pushHistory]);
// 필드 업데이트 (드래그/리사이즈 중 - 히스토리 안 쌓음)
const updateField = useCallback((idx, updates) => {
setFields(prev => prev.map((f, i) => i === idx ? { ...f, ...updates } : f));
}, []);
// 필드 업데이트 + 히스토리
const updateFieldWithHistory = useCallback((idx, updates) => {
const newFields = fields.map((f, i) => i === idx ? { ...f, ...updates } : f);
setFields(newFields);
pushHistory(newFields);
}, [fields, pushHistory]);
// 드래그/리사이즈 종료 시 히스토리 push
const handleDragEnd = useCallback(() => {
pushHistory(fields);
}, [fields, pushHistory]);
// 저장
const saveFields = async () => {
setSaving(true);
try {
const res = await fetch(`/esign/contracts/${CONTRACT_ID}/fields`, {
method: 'POST', headers: getHeaders(),
body: JSON.stringify({
fields: fields.map((f, i) => ({
signer_id: f.signer_id,
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,
sort_order: i,
})),
}),
});
const json = await res.json();
if (json.success) {
alert('서명 위치가 저장되었습니다.');
location.href = `/esign/${CONTRACT_ID}`;
} else {
alert(json.message || '저장 실패');
}
} catch (e) { alert('서버 오류'); }
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) => {
// Ctrl+Z / Ctrl+Shift+Z
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
e.preventDefault();
if (e.shiftKey) redo(); else undo();
return;
}
// Ctrl+Y
if ((e.ctrlKey || e.metaKey) && e.key === 'y') {
e.preventDefault();
redo();
return;
}
// Delete / Backspace
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedFieldIndex !== null) {
// 입력 필드에 포커스가 있으면 무시
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
e.preventDefault();
removeField(selectedFieldIndex);
return;
}
// Escape - 선택 해제
if (e.key === 'Escape') {
setSelectedFieldIndex(null);
return;
}
// 화살표 키 - 미세 조정
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key) && selectedFieldIndex !== null) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
e.preventDefault();
const step = e.shiftKey ? 1 : 0.25;
const f = fields[selectedFieldIndex];
if (!f) return;
let updates = {};
switch (e.key) {
case 'ArrowLeft': updates.position_x = Math.max(0, f.position_x - step); break;
case 'ArrowRight': updates.position_x = Math.min(100 - f.width, f.position_x + step); break;
case 'ArrowUp': updates.position_y = Math.max(0, f.position_y - step); break;
case 'ArrowDown': updates.position_y = Math.min(100 - f.height, f.position_y + step); break;
}
updates.position_x = round2(updates.position_x ?? f.position_x);
updates.position_y = round2(updates.position_y ?? f.position_y);
updateFieldWithHistory(selectedFieldIndex, updates);
return;
}
// Ctrl+C - 복사
if ((e.ctrlKey || e.metaKey) && e.key === 'c' && selectedFieldIndex !== null) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
e.preventDefault();
setClipboard({ ...fields[selectedFieldIndex] });
return;
}
// Ctrl+V - 붙여넣기
if ((e.ctrlKey || e.metaKey) && e.key === 'v' && clipboard) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
e.preventDefault();
const pasted = {
...clipboard,
position_x: Math.min(80, clipboard.position_x + 2),
position_y: Math.min(90, clipboard.position_y + 2),
page_number: currentPage,
};
const newFields = [...fields, pasted];
setFields(newFields);
setSelectedFieldIndex(newFields.length - 1);
pushHistory(newFields);
setClipboard(pasted);
return;
}
// Ctrl+- / Ctrl+= (줌)
if ((e.ctrlKey || e.metaKey) && (e.key === '-' || e.key === '=')) {
e.preventDefault();
setZoom(prev => {
const i = ZOOM_LEVELS.indexOf(prev);
if (e.key === '-' && i > 0) return ZOOM_LEVELS[i - 1];
if (e.key === '=' && i < ZOOM_LEVELS.length - 1) return ZOOM_LEVELS[i + 1];
return prev;
});
return;
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [undo, redo, selectedFieldIndex, fields, removeField, updateFieldWithHistory, clipboard, currentPage, pushHistory]);
if (loading) return <div className="flex items-center justify-center h-screen text-gray-400">로딩 ...</div>;
if (!contract) return <div className="flex items-center justify-center h-screen text-red-500">계약을 찾을 없습니다.</div>;
const signers = contract.signers || [];
const pageFields = fields.map((f, idx) => ({ ...f, _idx: idx })).filter(f => f.page_number === currentPage);
return (
<div className="flex flex-col h-screen bg-gray-100">
<Toolbar
zoom={zoom} setZoom={setZoom}
gridEnabled={gridEnabled} setGridEnabled={setGridEnabled}
undo={undo} redo={redo}
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">
{/* 썸네일 사이드바 */}
<ThumbnailSidebar
pdfDoc={pdfDoc} totalPages={totalPages}
currentPage={currentPage} setCurrentPage={setCurrentPage}
fields={fields}
/>
{/* PDF 뷰어 (중앙) */}
<div className="flex-1 overflow-auto bg-gray-200 relative"
onClick={() => setSelectedFieldIndex(null)}>
<div className="flex justify-center p-6 min-h-full">
<div className="relative" style={{ width: canvasSize.width || 'auto', height: canvasSize.height || 'auto' }}>
<canvas ref={canvasRef} className="shadow-lg block" />
{/* 그리드 오버레이 */}
{gridEnabled && canvasSize.width > 0 && (
<div style={{
position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 5,
backgroundImage: 'linear-gradient(rgba(59,130,246,0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(59,130,246,0.08) 1px, transparent 1px)',
backgroundSize: '5% 5%',
}} />
)}
{/* 필드 오버레이 컨테이너 */}
<div ref={containerRef}
style={{ position: 'absolute', inset: 0, zIndex: 10 }}
onClick={(e) => { if (e.target === e.currentTarget) setSelectedFieldIndex(null); }}>
{pageFields.map((f) => (
<FieldOverlay
key={f._idx}
field={f}
index={f._idx}
selected={selectedFieldIndex === f._idx}
signers={signers}
containerRef={containerRef}
onSelect={setSelectedFieldIndex}
onUpdate={updateField}
onDragEnd={handleDragEnd}
gridEnabled={gridEnabled}
/>
))}
</div>
</div>
</div>
</div>
{/* 속성 패널 (우측) */}
<PropertiesPanel
signers={signers}
fields={fields}
selectedFieldIndex={selectedFieldIndex}
currentPage={currentPage}
onAddField={addField}
onUpdateField={updateFieldWithHistory}
onRemoveField={removeField}
onSelectField={(idx) => {
setSelectedFieldIndex(idx);
if (fields[idx]) setCurrentPage(fields[idx].page_number);
}}
/>
</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>
);
};
ReactDOM.createRoot(document.getElementById('esign-fields-root')).render(<App />);
</script>
@endverbatim
@endpush