feat:템플릿 비주얼 필드 에디터 추가

PDF 위에서 드래그앤드롭으로 템플릿 필드를 편집하는 기능 구현:
- template-fields.blade.php 뷰 생성 (fields.blade.php 기반, signer_order 사용)
- updateTemplateItems API 추가 (필드 일괄 저장)
- 템플릿 카드/모달에 필드 편집 링크 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-13 06:59:01 +09:00
parent a464cf40de
commit f83a62b479
5 changed files with 885 additions and 1 deletions

View File

@@ -818,6 +818,62 @@ public function destroyTemplateItem(int $templateId, int $itemId): JsonResponse
]);
}
/**
* 템플릿 필드 아이템 일괄 저장 (에디터에서 사용)
*/
public function updateTemplateItems(Request $request, int $templateId): JsonResponse
{
$request->validate([
'items' => 'present|array',
'items.*.signer_order' => 'required|integer|min:1',
'items.*.page_number' => 'required|integer|min:1',
'items.*.position_x' => 'required|numeric',
'items.*.position_y' => 'required|numeric',
'items.*.width' => 'required|numeric',
'items.*.height' => 'required|numeric',
'items.*.field_type' => 'required|in:signature,stamp,text,date,checkbox',
'items.*.field_label' => 'nullable|string|max:100',
'items.*.is_required' => 'nullable|boolean',
]);
$tenantId = session('selected_tenant_id', 1);
$template = EsignFieldTemplate::forTenant($tenantId)->findOrFail($templateId);
$items = $request->input('items', []);
DB::transaction(function () use ($template, $items) {
// 기존 아이템 삭제
EsignFieldTemplateItem::where('template_id', $template->id)->delete();
// 새 아이템 생성
foreach ($items as $i => $itemData) {
EsignFieldTemplateItem::create([
'template_id' => $template->id,
'signer_order' => $itemData['signer_order'],
'page_number' => $itemData['page_number'],
'position_x' => round($itemData['position_x'], 2),
'position_y' => round($itemData['position_y'], 2),
'width' => round($itemData['width'], 2),
'height' => round($itemData['height'], 2),
'field_type' => $itemData['field_type'],
'field_label' => $itemData['field_label'] ?? '',
'is_required' => $itemData['is_required'] ?? true,
'sort_order' => $i,
]);
}
// signer_count 업데이트
$maxOrder = collect($items)->max('signer_order') ?: 0;
$template->update(['signer_count' => $maxOrder]);
});
return response()->json([
'success' => true,
'message' => '템플릿 필드가 저장되었습니다.',
'data' => $template->fresh()->load('items'),
]);
}
/**
* 템플릿 복제
*/

View File

@@ -54,6 +54,15 @@ public function send(Request $request, int $id): View|Response
return view('esign.send', ['contractId' => $id]);
}
public function templateFields(Request $request, int $templateId): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('esign.template-fields', $templateId));
}
return view('esign.template-fields', ['templateId' => $templateId]);
}
public function templates(Request $request): View|Response
{
if ($request->header('HX-Request')) {

View File

@@ -0,0 +1,790 @@
@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

View File

@@ -236,10 +236,24 @@ className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700
{/* 서식 필드 탭 */}
{tab === 'fields' && (
<div>
{/* 비주얼 편집 버튼 */}
{tpl.file_path && (
<div className="mb-4 p-3 bg-teal-50 rounded-lg flex items-center justify-between">
<div>
<p className="text-sm font-medium text-teal-800">PDF에서 시각적으로 필드를 편집할 있습니다</p>
<p className="text-xs text-teal-600 mt-0.5">드래그 드롭으로 서명 위치를 설정하세요</p>
</div>
<a href={`/esign/templates/${tpl.id}/fields`}
className="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 text-sm font-medium flex-shrink-0 transition-colors"
onClick={e => e.stopPropagation()}>
비주얼 편집
</a>
</div>
)}
{(!tpl.items || tpl.items.length === 0) ? (
<div className="text-center py-8 text-gray-400">
<p className="text-sm mb-1">서식 필드가 없습니다.</p>
<p className="text-xs">계약의 필드 에디터에서 "템플릿으로 저장" 필드가 포함됩니다.</p>
<p className="text-xs">{tpl.file_path ? '위의 "비주얼 편집" 버튼으로 PDF에서 필드를 추가하세요.' : '계약의 필드 에디터에서 "템플릿으로 저장" 시 필드가 포함됩니다.'}</p>
</div>
) : (
<div className="border rounded-lg overflow-hidden">
@@ -339,6 +353,10 @@ className="w-7 h-7 flex items-center justify-center rounded-md text-gray-400 hov
<div className="absolute right-0 top-full mt-1 w-32 bg-white border rounded-lg shadow-lg z-10 py-1">
<button onClick={() => { setMenuOpen(false); onEdit(template); }}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-gray-50">편집</button>
{template.file_path && (
<a href={`/esign/templates/${template.id}/fields`}
className="block w-full text-left px-3 py-1.5 text-sm hover:bg-gray-50 text-teal-700">필드 편집</a>
)}
<button onClick={() => { setMenuOpen(false); onDuplicate(template.id); }}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-gray-50">복제</button>
<div className="border-t my-1"></div>
@@ -384,6 +402,15 @@ className="w-full text-left px-3 py-1.5 text-sm text-red-600 hover:bg-red-50">
<span>필드 {template.items_count ?? template.items?.length ?? 0}</span>
</div>
{/* 필드 편집 버튼 (PDF가 있을 때) */}
{template.file_path && (
<a href={`/esign/templates/${template.id}/fields`}
hx-boost="false"
className="block w-full text-center mt-2 px-3 py-1.5 bg-teal-50 text-teal-700 rounded-lg hover:bg-teal-100 text-xs font-medium transition-colors">
필드 편집
</a>
)}
{/* 하단: 생성자 + 시간 */}
<div className="flex items-center justify-between mt-3 pt-3 border-t">
<span className="text-[11px] text-gray-400">

View File

@@ -1399,6 +1399,7 @@
Route::get('/create', [EsignController::class, 'create'])->name('create');
Route::get('/docs', [EsignController::class, 'docs'])->name('docs');
Route::get('/templates', [EsignController::class, 'templates'])->name('templates');
Route::get('/templates/{templateId}/fields', [EsignController::class, 'templateFields'])->whereNumber('templateId')->name('template-fields');
Route::get('/{id}', [EsignController::class, 'detail'])->whereNumber('id')->name('detail');
Route::get('/{id}/fields', [EsignController::class, 'fields'])->whereNumber('id')->name('fields');
Route::get('/{id}/send', [EsignController::class, 'send'])->whereNumber('id')->name('send');
@@ -1430,6 +1431,7 @@
Route::post('/templates/{templateId}/upload-pdf', [EsignApiController::class, 'uploadTemplatePdf'])->whereNumber('templateId')->name('templates.upload-pdf');
Route::delete('/templates/{templateId}/remove-pdf', [EsignApiController::class, 'removeTemplatePdf'])->whereNumber('templateId')->name('templates.remove-pdf');
Route::delete('/templates/{templateId}/items/{itemId}', [EsignApiController::class, 'destroyTemplateItem'])->whereNumber('templateId')->whereNumber('itemId')->name('templates.items.destroy');
Route::put('/templates/{templateId}/items', [EsignApiController::class, 'updateTemplateItems'])->whereNumber('templateId')->name('templates.items.update');
Route::get('/templates/{templateId}/download', [EsignApiController::class, 'downloadTemplatePdf'])->whereNumber('templateId')->name('templates.download');
// 템플릿 적용 / 필드 복사