Files
sam-manage/resources/views/esign/template-fields.blade.php
김보곤 f83a62b479 feat:템플릿 비주얼 필드 에디터 추가
PDF 위에서 드래그앤드롭으로 템플릿 필드를 편집하는 기능 구현:
- template-fields.blade.php 뷰 생성 (fields.blade.php 기반, signer_order 사용)
- updateTemplateItems API 추가 (필드 일괄 저장)
- 템플릿 카드/모달에 필드 편집 링크 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 10:11:14 +09:00

791 lines
41 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 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) ───
const FieldOverlay = ({ field, index, selected, signerCount, containerRef, onSelect, onUpdate, onDragEnd, gridEnabled }) => {
const fieldRef = useRef(null);
const latestRef = useRef({ field, index, onUpdate, onDragEnd });
latestRef.current = { field, index, onUpdate, onDragEnd };
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(() => {
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 (템플릿용: signer_order 기반) ───
const PropertiesPanel = ({ signerCount, fields, selectedFieldIndex, currentPage, onAddField, onUpdateField, onRemoveField, onSelectField, onSetSignerCount }) => {
const selectedField = selectedFieldIndex !== null ? fields[selectedFieldIndex] : null;
const signers = Array.from({ length: signerCount }, (_, i) => ({ order: i + 1, label: SIGNER_LABELS[i] || `서명자 ${i + 1}` }));
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>
<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>
<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 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>
<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);
// 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 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);
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 || '',
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 }).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: '', 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 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);
setSelectedFieldIndex(null);
}
}, [fields, pushHistory]);
// 저장
const saveFields = async () => {
setSaving(true);
try {
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 || '',
is_required: f.is_required !== false,
})),
}),
});
const json = await res.json();
if (json.success) {
alert('템플릿 필드가 저장되었습니다.');
} else {
alert(json.message || '저장 실패');
}
} catch (e) { alert('서버 오류'); }
setSaving(false);
};
// 키보드 단축키
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') && selectedFieldIndex !== null) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
e.preventDefault();
removeField(selectedFieldIndex);
return;
}
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;
}
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;
}
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;
}
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 (!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={() => setSelectedFieldIndex(null)}>
{!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 }}
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}
signerCount={signerCount}
containerRef={containerRef}
onSelect={setSelectedFieldIndex}
onUpdate={updateField}
onDragEnd={handleDragEnd}
gridEnabled={gridEnabled}
/>
))}
</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);
if (fields[idx]) setCurrentPage(fields[idx].page_number);
}}
/>
</div>
</div>
);
};
ReactDOM.createRoot(document.getElementById('esign-template-fields-root')).render(<App />);
</script>
@endverbatim
@endpush