Files
sam-manage/resources/views/esign/template-fields.blade.php
김보곤 d8528ed54d fix: [esign] 그룹 드래그 후 선택 해제되는 문제 수정
- 드래그 완료 후 click 이벤트에서 선택 변경 무시
- 여백 클릭 시에만 그룹 선택 해제
2026-02-23 14:08:15 +09:00

1108 lines
59 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-template-fields-root" data-template-id="{{ $templateId }}"></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 TEMPLATE_ID = document.getElementById('esign-template-fields-root')?.dataset.templateId;
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 SIGNER_LABELS = ['서명자 1', '서명자 2', '서명자 3', '서명자 4', '서명자 5', '서명자 6'];
const SYSTEM_VARIABLES = [
{ key: 'signer1_name', label: '갑(1) 이름' },
{ key: 'signer2_name', label: '을(2) 이름' },
{ key: 'signer1_email', label: '갑(1) 이메일' },
{ key: 'signer2_email', label: '을(2) 이메일' },
{ key: 'contract_title', label: '계약 제목' },
{ key: 'current_date', label: '계약일 (오늘)' },
{ key: 'expires_at', label: '만료일' },
];
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 }) => (
<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">
<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) ───
// 기본: 점선 + 드래그=이동 / 더블클릭: 실선 + 핸들=크기조절 / ESC: 모드해제
const FieldOverlay = ({ field, index, selected, signerCount, containerRef, onSelect, onUpdate, onDragEnd, gridEnabled, selectedIndices, onGroupMove }) => {
const fieldRef = useRef(null);
const [resizeMode, setResizeMode] = useState(false);
const draggedRef = useRef(false);
const latestRef = useRef({ field, index, onUpdate, onDragEnd, selectedIndices, onGroupMove });
latestRef.current = { field, index, onUpdate, onDragEnd, selectedIndices, onGroupMove };
const signerIdx = field.signer_order - 1;
const color = SIGNER_COLORS[signerIdx % SIGNER_COLORS.length] || '#888';
const signerName = SIGNER_LABELS[signerIdx] || `서명자 ${field.signer_order}`;
const typeInfo = FIELD_TYPES.find(t => t.value === field.field_type) || FIELD_TYPES[0];
// 선택 해제 시 리사이즈 모드 자동 해제
useEffect(() => { if (!selected) setResizeMode(false); }, [selected]);
// ESC 키로 리사이즈 모드 해제
useEffect(() => {
if (!resizeMode) return;
const h = (e) => { if (e.key === 'Escape') { e.stopPropagation(); setResizeMode(false); } };
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
}, [resizeMode]);
// interact.js: resizeMode에 따라 드래그 또는 리사이즈만 활성화
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);
if (resizeMode) {
interactable
.draggable({ enabled: false })
.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(); },
},
});
} else {
interactable
.resizable({ enabled: false })
.draggable({
inertia: false,
modifiers,
listeners: {
move(event) {
draggedRef.current = true;
const { field: f, index: idx, onUpdate: update, selectedIndices: si, onGroupMove: groupMove } = latestRef.current;
const rect = containerRef.current.getBoundingClientRect();
const dx = (event.dx / rect.width) * 100;
const dy = (event.dy / rect.height) * 100;
if (si && si.size > 1 && si.has(idx) && groupMove) {
groupMove(dx, dy);
} else {
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();
setTimeout(() => { draggedRef.current = false; }, 0);
},
},
});
}
return () => interactable.unset();
}, [gridEnabled, resizeMode]);
return (
<div ref={fieldRef}
onClick={(e) => { e.stopPropagation(); if (draggedRef.current) return; onSelect(index, e); }}
onDoubleClick={(e) => { e.stopPropagation(); onSelect(index, e); setResizeMode(prev => !prev); }}
style={{
position: 'absolute',
left: `${field.position_x}%`, top: `${field.position_y}%`,
width: `${field.width}%`, height: `${field.height}%`,
border: resizeMode ? `2px solid ${color}` : `2px dashed ${color}`,
backgroundColor: resizeMode ? `${color}30` : (selected ? `${color}20` : `${color}10`),
cursor: resizeMode ? 'default' : 'move',
borderRadius: '4px',
zIndex: selected ? 20 : 10,
touchAction: 'none',
boxShadow: resizeMode ? `0 0 0 2px ${color}, 0 2px 12px rgba(0,0,0,0.2)` : (selected ? `0 0 0 1px ${color}, 0 2px 8px rgba(0,0,0,0.15)` : 'none'),
}}
className={`flex items-center select-none group transition-shadow ${{'L':'justify-start','C':'justify-center','R':'justify-end'}[field.text_align] || 'justify-start'}`}>
<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_variable ? `{{${field.field_variable}}}` : (field.field_label || signerName)}</span>
</div>
{/* 리사이즈 모드 안내 툴팁 */}
{resizeMode && (
<div style={{ position: 'absolute', top: -20, left: 0, fontSize: 9, backgroundColor: '#374151', color: 'white', padding: '1px 6px', borderRadius: 3, whiteSpace: 'nowrap', zIndex: 40, lineHeight: '16px' }}>
크기 조절 · ESC 해제
</div>
)}
{/* 리사이즈 핸들 (리사이즈 모드에서만 표시) */}
{resizeMode && <>
{['nw','n','ne','w','e','sw','s','se'].map(dir => {
const styles = {
nw: { top: -5, left: -5, cursor: 'nw-resize' },
n: { top: -5, left: '50%', transform: 'translateX(-50%)', cursor: 'n-resize' },
ne: { top: -5, right: -5, cursor: 'ne-resize' },
w: { top: '50%', left: -5, transform: 'translateY(-50%)', cursor: 'w-resize' },
e: { top: '50%', right: -5, transform: 'translateY(-50%)', cursor: 'e-resize' },
sw: { bottom: -5, left: -5, cursor: 'sw-resize' },
s: { bottom: -5, left: '50%', transform: 'translateX(-50%)', cursor: 's-resize' },
se: { bottom: -5, right: -5, cursor: 'se-resize' },
};
return (
<div key={dir} style={{
...styles[dir], position: 'absolute',
width: 10, height: 10, backgroundColor: 'white',
border: `2px solid ${color}`, borderRadius: 2,
zIndex: 30,
}} />
);
})}
</>}
</div>
);
};
// ─── PropertiesPanel (템플릿용: signer_order 기반) ───
const PropertiesPanel = ({ signerCount, fields, selectedFieldIndex, currentPage, onAddField, onUpdateField, onRemoveField, onSelectField, onSetSignerCount, templateVariables, onSetTemplateVariables }) => {
const selectedField = selectedFieldIndex !== null ? fields[selectedFieldIndex] : null;
const signers = Array.from({ length: signerCount }, (_, i) => ({ order: i + 1, label: SIGNER_LABELS[i] || `서명자 ${i + 1}` }));
const [newVarKey, setNewVarKey] = React.useState('');
const [newVarLabel, setNewVarLabel] = React.useState('');
const addVariable = () => {
const key = newVarKey.trim();
const label = newVarLabel.trim();
if (!key || !label) return;
if (templateVariables.some(v => v.key === key)) { alert('이미 존재하는 변수 키입니다.'); return; }
onSetTemplateVariables([...templateVariables, { key, label, type: 'text', default: '' }]);
setNewVarKey('');
setNewVarLabel('');
};
const removeVariable = (key) => {
onSetTemplateVariables(templateVariables.filter(v => v.key !== key));
};
const allVariables = [
...SYSTEM_VARIABLES.map(v => ({ ...v, group: '시스템' })),
...templateVariables.map(v => ({ ...v, group: '커스텀' })),
];
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>
<div className="flex items-center gap-2">
<select value={signerCount} onChange={e => onSetSignerCount(parseInt(e.target.value))}
className="border rounded px-2 py-1 text-xs flex-1">
{[1, 2, 3, 4, 5, 6].map(n => (
<option key={n} value={n}>{n}</option>
))}
</select>
</div>
</div>
{/* 커스텀 변수 관리 */}
<div className="border-t pt-3">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">커스텀 변수</h3>
<div className="space-y-1 mb-2">
{templateVariables.length === 0 && <p className="text-[10px] text-gray-400">정의된 변수 없음</p>}
{templateVariables.map(v => (
<div key={v.key} className="flex items-center justify-between bg-amber-50 rounded px-2 py-1">
<div className="min-w-0">
<span className="text-[10px] font-mono text-amber-700 block truncate">{v.key}</span>
<span className="text-[10px] text-gray-500 block truncate">{v.label}</span>
</div>
<button onClick={() => removeVariable(v.key)} className="text-gray-300 hover:text-red-500 flex-shrink-0 ml-1 text-xs">&times;</button>
</div>
))}
</div>
<div className="flex gap-1">
<input type="text" value={newVarKey} onChange={e => setNewVarKey(e.target.value.replace(/[^a-z0-9_]/gi, ''))}
placeholder="키 (영문)" className="border rounded px-1.5 py-1 text-[10px] w-20" />
<input type="text" value={newVarLabel} onChange={e => setNewVarLabel(e.target.value)}
placeholder="표시명" className="border rounded px-1.5 py-1 text-[10px] flex-1"
onKeyDown={e => e.key === 'Enter' && addVariable()} />
<button onClick={addVariable} className="px-2 py-1 bg-amber-500 text-white rounded text-[10px] hover:bg-amber-600 flex-shrink-0">+</button>
</div>
</div>
{/* 필드 도구상자 */}
<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.order} 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.label}</span>
</div>
<div className="grid grid-cols-3 gap-1">
{FIELD_TYPES.map(ft => (
<button key={ft.value}
onClick={() => onAddField(s.order, 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_order}
onChange={e => onUpdateField(selectedFieldIndex, { signer_order: parseInt(e.target.value) })}
className="w-full border rounded px-2 py-1 text-xs mt-0.5">
{signers.map(s => <option key={s.order} value={s.order}>{s.label}</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>
{['text', 'date'].includes(selectedField.field_type) && (
<div>
<label className="text-[10px] text-gray-500">변수 연결</label>
<select value={selectedField.field_variable || ''}
onChange={e => onUpdateField(selectedFieldIndex, { field_variable: e.target.value || null })}
className={`w-full border rounded px-2 py-1 text-xs mt-0.5 ${selectedField.field_variable ? 'border-amber-400 bg-amber-50' : ''}`}>
<option value="">(없음 - 수동 입력)</option>
<optgroup label="시스템 변수">
{SYSTEM_VARIABLES.map(v => <option key={v.key} value={v.key}>{v.label}</option>)}
</optgroup>
{templateVariables.length > 0 && (
<optgroup label="커스텀 변수">
{templateVariables.map(v => <option key={v.key} value={v.key}>{v.label}</option>)}
</optgroup>
)}
</select>
{selectedField.field_variable && (
<div className="mt-1 px-2 py-1 bg-amber-50 border border-amber-200 rounded">
<span className="text-[10px] text-amber-700 font-mono">{`{{${selectedField.field_variable}}}`}</span>
</div>
)}
</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 className="grid grid-cols-2 gap-1.5">
<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>
<label className="text-[10px] text-gray-500">폰트 크기 (pt)</label>
<input type="number" value={selectedField.font_size || ''} min="6" max="72" placeholder="자동"
onChange={e => onUpdateField(selectedFieldIndex, { font_size: e.target.value ? parseInt(e.target.value) : null })}
className="w-full border rounded px-2 py-1 text-xs mt-0.5" />
</div>
</div>
{['text', 'date'].includes(selectedField.field_type) && (
<div>
<label className="text-[10px] text-gray-500">가로 정렬</label>
<div className="flex gap-1 mt-0.5">
{[{ value: 'L', label: '왼쪽', icon: '⫷' }, { value: 'C', label: '가운데', icon: '⫿' }, { value: 'R', label: '오른쪽', icon: '⫸' }].map(a => (
<button key={a.value}
onClick={() => onUpdateField(selectedFieldIndex, { text_align: a.value })}
className={`flex-1 px-2 py-1.5 border rounded text-[10px] font-medium transition-colors ${(selectedField.text_align || 'L') === a.value ? 'bg-blue-600 text-white border-blue-600' : 'text-gray-600 hover:bg-gray-50'}`}
title={a.label}>
{a.label}
</button>
))}
</div>
</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 si = f.signer_order - 1;
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_LABELS[si] || `서명자 ${f.signer_order}`}</span>
{f.field_variable && <span className="text-[8px] bg-amber-100 text-amber-700 px-1 rounded flex-shrink-0" title={f.field_variable}>변수</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>
);
};
// ─── 유틸 ───
const round2 = (n) => Math.round(n * 100) / 100;
// ─── App ───
const App = () => {
const [template, setTemplate] = useState(null);
const [fields, setFields] = useState([]);
const [signerCount, setSignerCount] = useState(2);
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 [templateVariables, setTemplateVariables] = useState([]);
const [selectedIndices, setSelectedIndices] = useState(new Set());
const [lassoStart, setLassoStart] = useState(null);
const [lassoEnd, setLassoEnd] = useState(null);
// 필드 선택 헬퍼
const selectField = useCallback((idx, e) => {
if (e && (e.ctrlKey || e.metaKey || e.shiftKey)) {
setSelectedIndices(prev => {
const next = new Set(prev);
if (next.has(idx)) {
next.delete(idx);
} else {
next.add(idx);
}
setSelectedFieldIndex(next.size > 0 ? (next.has(idx) ? idx : [...next][next.size - 1]) : null);
return next;
});
} else {
setSelectedFieldIndex(idx);
setSelectedIndices(new Set(idx !== null ? [idx] : []));
}
}, []);
const clearSelection = useCallback(() => {
setSelectedFieldIndex(null);
setSelectedIndices(new Set());
}, []);
// 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 lassoUsedRef = useRef(false);
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 fetchTemplate = useCallback(async () => {
try {
const res = await fetch(`/esign/contracts/templates/${TEMPLATE_ID}`, { headers: getHeaders() });
const json = await res.json();
if (json.success) {
setTemplate(json.data);
const sc = json.data.signer_count || 2;
setSignerCount(sc < 1 ? 2 : sc);
setTemplateVariables(json.data.variables || []);
const loadedFields = (json.data.items || []).map(f => ({
signer_order: f.signer_order,
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 || '',
field_variable: f.field_variable || null,
font_size: f.font_size || null,
text_align: f.text_align || 'L',
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 (!template) return;
if (!template.file_path) return;
try {
const url = `/esign/contracts/templates/${TEMPLATE_ID}/download`;
const res = await fetch(url, { headers: { 'X-CSRF-TOKEN': csrfToken } });
if (!res.ok) return;
const arrayBuffer = await res.arrayBuffer();
const doc = await pdfjsLib.getDocument({
data: arrayBuffer,
cMapUrl: 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/cmaps/',
cMapPacked: true,
}).promise;
setPdfDoc(doc);
setTotalPages(doc.numPages);
} catch (e) { console.error('PDF 로드 실패:', e); }
}, [template]);
// 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(() => { fetchTemplate(); }, [fetchTemplate]);
useEffect(() => { loadPdf(); }, [loadPdf]);
useEffect(() => { renderPage(currentPage); }, [renderPage, currentPage]);
// PDF.js 메모리 정리
useEffect(() => {
return () => { if (pdfDoc) pdfDoc.destroy(); };
}, [pdfDoc]);
// 필드 추가
const addField = useCallback((signerOrder, fieldType = 'signature') => {
const newField = {
signer_order: signerOrder, page_number: currentPage,
position_x: 25, position_y: 40, width: 20, height: 5,
field_type: fieldType, field_label: '', field_variable: null, font_size: null, text_align: 'L', is_required: true,
};
const newFields = [...fields, newField];
setFields(newFields);
setSelectedFieldIndex(newFields.length - 1);
setSelectedIndices(new Set([newFields.length - 1]));
pushHistory(newFields);
}, [fields, currentPage, pushHistory]);
// 필드 삭제
const removeField = useCallback((idx) => {
const newFields = fields.filter((_, i) => i !== idx);
setFields(newFields);
clearSelection();
pushHistory(newFields);
}, [fields, pushHistory, clearSelection]);
// 필드 업데이트 (드래그/리사이즈 중)
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]);
// 그룹 드래그 (올가미 선택된 필드 동시 이동)
const handleGroupMove = useCallback((dx, dy) => {
setFields(prev => prev.map((f, i) => {
if (!selectedIndices.has(i)) return f;
return {
...f,
position_x: round2(Math.max(0, Math.min(100 - f.width, f.position_x + dx))),
position_y: round2(Math.max(0, Math.min(100 - f.height, f.position_y + dy))),
};
}));
}, [selectedIndices]);
// 드래그/리사이즈 종료 시 히스토리 push
const handleDragEnd = useCallback(() => {
pushHistory(fields);
}, [fields, pushHistory]);
// 서명자 수 변경
const handleSetSignerCount = useCallback((count) => {
setSignerCount(count);
// 서명자 수보다 큰 signer_order를 가진 필드가 있으면 경고
const hasExceeding = fields.some(f => f.signer_order > count);
if (hasExceeding) {
if (!window.confirm(`서명자 ${count}명보다 높은 순서의 필드가 있습니다. 해당 필드를 삭제하시겠습니까?`)) {
return;
}
const newFields = fields.filter(f => f.signer_order <= count);
setFields(newFields);
pushHistory(newFields);
clearSelection();
}
}, [fields, pushHistory, clearSelection]);
// 저장
const saveFields = async () => {
setSaving(true);
try {
// 1) 필드 아이템 저장
const res = await fetch(`/esign/contracts/templates/${TEMPLATE_ID}/items`, {
method: 'PUT', headers: getHeaders(),
body: JSON.stringify({
items: fields.map((f, i) => ({
signer_order: f.signer_order,
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 || '',
field_variable: f.field_variable || null,
font_size: f.font_size || null,
text_align: f.text_align || 'L',
is_required: f.is_required !== false,
})),
}),
});
const json = await res.json();
if (!json.success) { alert(json.message || '저장 실패'); setSaving(false); return; }
// 2) 템플릿 변수 정의 저장
const res2 = await fetch(`/esign/contracts/templates/${TEMPLATE_ID}`, {
method: 'PUT', headers: getHeaders(),
body: JSON.stringify({
name: template.name,
description: template.description || '',
category: template.category || '',
variables: templateVariables,
}),
});
const json2 = await res2.json();
if (json2.success) {
alert('템플릿 필드와 변수가 저장되었습니다.');
} else {
alert('필드는 저장되었으나 변수 저장 실패: ' + (json2.message || ''));
}
} catch (e) { alert('서버 오류'); }
setSaving(false);
};
// 올가미(Lasso) 선택
const handleLassoStart = useCallback((e) => {
if (e.target !== containerRef.current) return;
if (e.button !== 0) return;
const rect = containerRef.current.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
setLassoStart({ x, y });
setLassoEnd({ x, y });
if (!(e.ctrlKey || e.metaKey || e.shiftKey)) {
clearSelection();
}
}, [clearSelection]);
const handleLassoMove = useCallback((e) => {
if (!lassoStart) return;
const rect = containerRef.current.getBoundingClientRect();
const x = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
const y = Math.max(0, Math.min(100, ((e.clientY - rect.top) / rect.height) * 100));
setLassoEnd({ x, y });
}, [lassoStart]);
const handleLassoEnd = useCallback(() => {
if (!lassoStart || !lassoEnd) {
setLassoStart(null);
setLassoEnd(null);
return;
}
const minX = Math.min(lassoStart.x, lassoEnd.x);
const maxX = Math.max(lassoStart.x, lassoEnd.x);
const minY = Math.min(lassoStart.y, lassoEnd.y);
const maxY = Math.max(lassoStart.y, lassoEnd.y);
if (maxX - minX < 1 && maxY - minY < 1) {
setLassoStart(null);
setLassoEnd(null);
return;
}
const selected = new Set(selectedIndices);
fields.forEach((f, idx) => {
if (f.page_number !== currentPage) return;
const fRight = f.position_x + f.width;
const fBottom = f.position_y + f.height;
if (f.position_x < maxX && fRight > minX && f.position_y < maxY && fBottom > minY) {
selected.add(idx);
}
});
setSelectedIndices(selected);
if (selected.size === 1) {
setSelectedFieldIndex([...selected][0]);
} else {
setSelectedFieldIndex(null);
}
// 올가미 완료 후 click 이벤트가 clearSelection을 호출하지 않도록 플래그
lassoUsedRef.current = true;
setTimeout(() => { lassoUsedRef.current = false; }, 0);
setLassoStart(null);
setLassoEnd(null);
}, [lassoStart, lassoEnd, fields, currentPage, selectedIndices]);
// 키보드 단축키
useEffect(() => {
const handler = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
e.preventDefault();
if (e.shiftKey) redo(); else undo();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === 'y') {
e.preventDefault();
redo();
return;
}
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIndices.size > 0) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
e.preventDefault();
const newFields = fields.filter((_, i) => !selectedIndices.has(i));
setFields(newFields);
clearSelection();
pushHistory(newFields);
return;
}
if (e.key === 'Escape') {
clearSelection();
setLassoStart(null);
setLassoEnd(null);
return;
}
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key) && selectedIndices.size > 0) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
e.preventDefault();
const step = e.shiftKey ? 1 : 0.25;
const newFields = fields.map((f, i) => {
if (!selectedIndices.has(i)) return f;
const updated = { ...f };
switch (e.key) {
case 'ArrowLeft': updated.position_x = round2(Math.max(0, f.position_x - step)); break;
case 'ArrowRight': updated.position_x = round2(Math.min(100 - f.width, f.position_x + step)); break;
case 'ArrowUp': updated.position_y = round2(Math.max(0, f.position_y - step)); break;
case 'ArrowDown': updated.position_y = round2(Math.min(100 - f.height, f.position_y + step)); break;
}
return updated;
});
setFields(newFields);
pushHistory(newFields);
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === 'x' && selectedIndices.size > 0) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
e.preventDefault();
const cut = fields.filter((_, i) => selectedIndices.has(i)).map(f => ({ ...f }));
setClipboard(cut);
const newFields = fields.filter((_, i) => !selectedIndices.has(i));
setFields(newFields);
clearSelection();
pushHistory(newFields);
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === 'c' && selectedIndices.size > 0) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
e.preventDefault();
const copied = fields.filter((_, i) => selectedIndices.has(i)).map(f => ({ ...f }));
setClipboard(copied);
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === 'v' && clipboard) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
e.preventDefault();
const items = Array.isArray(clipboard) ? clipboard : [clipboard];
const pasted = items.map(f => ({
...f,
position_x: Math.min(80, f.position_x + 2),
position_y: Math.min(90, f.position_y + 2),
page_number: currentPage,
}));
const newFields = [...fields, ...pasted];
setFields(newFields);
const newIndices = new Set();
for (let i = fields.length; i < newFields.length; i++) newIndices.add(i);
setSelectedIndices(newIndices);
setSelectedFieldIndex(newIndices.size === 1 ? [...newIndices][0] : null);
pushHistory(newFields);
setClipboard(pasted);
return;
}
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, selectedIndices, fields, clearSelection, clipboard, currentPage, pushHistory]);
if (loading) return <div className="flex items-center justify-center h-screen text-gray-400">로딩 ...</div>;
if (!template) return <div className="flex items-center justify-center h-screen text-red-500">템플릿을 찾을 없습니다.</div>;
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/templates'}
/>
<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={() => clearSelection()}>
{!pdfDoc && !template.file_path ? (
<div className="flex items-center justify-center h-full">
<div className="text-center p-8">
<svg className="mx-auto mb-4 text-gray-300" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
</svg>
<p className="text-gray-500 mb-4 text-sm"> 템플릿에 PDF 파일이 없습니다.</p>
<p className="text-gray-400 text-xs">템플릿 관리 화면에서 PDF를 먼저 업로드해주세요.</p>
<a href="/esign/templates" className="inline-block mt-4 px-4 py-2 border rounded-lg text-sm text-gray-700 hover:bg-gray-50">
템플릿 관리로 이동
</a>
</div>
</div>
) : (
<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, userSelect: 'none' }}
onClick={(e) => {
e.stopPropagation();
if (lassoUsedRef.current) return;
if (e.target === e.currentTarget) clearSelection();
}}
onMouseDown={handleLassoStart}
onMouseMove={handleLassoMove}
onMouseUp={handleLassoEnd}
onMouseLeave={() => { setLassoStart(null); setLassoEnd(null); }}>
{pageFields.map((f) => (
<FieldOverlay
key={f._idx}
field={f}
index={f._idx}
selected={selectedIndices.has(f._idx)}
selectedIndices={selectedIndices}
signerCount={signerCount}
containerRef={containerRef}
onSelect={selectField}
onUpdate={updateField}
onDragEnd={handleDragEnd}
onGroupMove={handleGroupMove}
gridEnabled={gridEnabled}
/>
))}
{lassoStart && lassoEnd && (
<div style={{
position: 'absolute',
left: `${Math.min(lassoStart.x, lassoEnd.x)}%`,
top: `${Math.min(lassoStart.y, lassoEnd.y)}%`,
width: `${Math.abs(lassoEnd.x - lassoStart.x)}%`,
height: `${Math.abs(lassoEnd.y - lassoStart.y)}%`,
border: '2px dashed #3B82F6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
pointerEvents: 'none',
zIndex: 50,
}} />
)}
</div>
</div>
</div>
)}
</div>
{/* 속성 패널 (우측) */}
<PropertiesPanel
signerCount={signerCount}
fields={fields}
selectedFieldIndex={selectedFieldIndex}
currentPage={currentPage}
onAddField={addField}
onUpdateField={updateFieldWithHistory}
onRemoveField={removeField}
onSetSignerCount={handleSetSignerCount}
onSelectField={(idx) => {
setSelectedFieldIndex(idx);
setSelectedIndices(new Set([idx]));
if (fields[idx]) setCurrentPage(fields[idx].page_number);
}}
templateVariables={templateVariables}
onSetTemplateVariables={setTemplateVariables}
/>
</div>
</div>
);
};
ReactDOM.createRoot(document.getElementById('esign-template-fields-root')).render(<App />);
</script>
@endverbatim
@endpush