2026-02-12 07:02:48 +09:00
|
|
|
|
@extends('layouts.app')
|
|
|
|
|
|
|
2026-02-12 14:29:09 +09:00
|
|
|
|
@section('title', 'SAM E-Sign - 서명 위치 설정')
|
2026-02-12 07:02:48 +09:00
|
|
|
|
|
|
|
|
|
|
@section('content')
|
|
|
|
|
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
|
|
|
|
<div id="esign-fields-root" data-contract-id="{{ $contractId }}"></div>
|
|
|
|
|
|
@endsection
|
|
|
|
|
|
|
|
|
|
|
|
@push('scripts')
|
2026-02-12 10:35:04 +09:00
|
|
|
|
@include('partials.react-cdn')
|
2026-02-12 07:02:48 +09:00
|
|
|
|
<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>
|
2026-02-12 15:39:29 +09:00
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/interactjs/dist/interact.min.js"></script>
|
2026-02-12 07:02:48 +09:00
|
|
|
|
@verbatim
|
|
|
|
|
|
<script type="text/babel">
|
2026-02-12 15:39:29 +09:00
|
|
|
|
const { useState, useEffect, useCallback, useRef, useMemo } = React;
|
2026-02-12 07:02:48 +09:00
|
|
|
|
|
|
|
|
|
|
const CONTRACT_ID = document.getElementById('esign-fields-root')?.dataset.contractId;
|
refactor:E-Sign 외부 API 호출을 MNG 내부 라우트로 전환
- Finance 패턴과 동일하게 MNG 직접 DB 접근 방식으로 변경
- MNG 모델 4개 추가: EsignContract, EsignSigner, EsignSignField, EsignAuditLog
- EsignApiController 추가: stats, index, show, store, cancel, configureFields, send, download
- 모든 뷰(dashboard, create, detail, fields, send)에서 외부 API URL 제거
- 기존 X-API-Key/Bearer 인증 대신 MNG 세션 인증(CSRF) 사용
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 10:24:09 +09:00
|
|
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
2026-02-12 07:02:48 +09:00
|
|
|
|
|
|
|
|
|
|
const getHeaders = () => ({
|
|
|
|
|
|
'Accept': 'application/json',
|
|
|
|
|
|
'Content-Type': 'application/json',
|
2026-02-12 15:39:29 +09:00
|
|
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || csrfToken,
|
2026-02-12 07:02:48 +09:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const FIELD_TYPES = [
|
2026-02-12 15:39:29 +09:00
|
|
|
|
{ value: 'signature', label: '서명', icon: '✍' },
|
|
|
|
|
|
{ value: 'stamp', label: '도장', icon: '📌' },
|
|
|
|
|
|
{ value: 'text', label: '텍스트', icon: 'T' },
|
|
|
|
|
|
{ value: 'date', label: '날짜', icon: '📅' },
|
|
|
|
|
|
{ value: 'checkbox', label: '체크박스', icon: '☑' },
|
2026-02-12 07:02:48 +09:00
|
|
|
|
];
|
|
|
|
|
|
|
2026-02-12 15:39:29 +09:00
|
|
|
|
const SIGNER_COLORS = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899'];
|
2026-02-12 07:02:48 +09:00
|
|
|
|
|
2026-02-12 15:39:29 +09:00
|
|
|
|
const ZOOM_LEVELS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
|
|
|
|
|
|
const MAX_HISTORY = 50;
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Toolbar ───
|
2026-02-12 18:02:31 +09:00
|
|
|
|
const Toolbar = ({ zoom, setZoom, gridEnabled, setGridEnabled, undo, redo, canUndo, canRedo, saving, saveFields, goBack, onSaveTemplate, onLoadTemplate, onCopyFromContract }) => {
|
|
|
|
|
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
|
|
|
|
const dropdownRef = useRef(null);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const handleClickOutside = (e) => {
|
|
|
|
|
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) setDropdownOpen(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
|
|
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex items-center justify-between px-4 py-2 bg-white border-b shadow-sm">
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<button onClick={goBack} className="text-gray-500 hover:text-gray-800 text-lg px-2" title="뒤로가기">←</button>
|
|
|
|
|
|
<span className="font-semibold text-gray-800 text-sm">서명 위치 설정</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
|
<button onClick={() => { const i = ZOOM_LEVELS.indexOf(zoom); if (i > 0) setZoom(ZOOM_LEVELS[i-1]); }}
|
|
|
|
|
|
disabled={zoom <= ZOOM_LEVELS[0]}
|
|
|
|
|
|
className="px-2 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-30" title="축소">−</button>
|
|
|
|
|
|
<span className="px-2 py-1 text-xs font-mono min-w-[50px] text-center">{Math.round(zoom * 100)}%</span>
|
|
|
|
|
|
<button onClick={() => { const i = ZOOM_LEVELS.indexOf(zoom); if (i < ZOOM_LEVELS.length - 1) setZoom(ZOOM_LEVELS[i+1]); }}
|
|
|
|
|
|
disabled={zoom >= ZOOM_LEVELS[ZOOM_LEVELS.length - 1]}
|
|
|
|
|
|
className="px-2 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-30" title="확대">+</button>
|
|
|
|
|
|
<div className="w-px h-5 bg-gray-200 mx-1"></div>
|
|
|
|
|
|
<button onClick={() => setGridEnabled(g => !g)}
|
|
|
|
|
|
className={`px-2 py-1 text-sm border rounded ${gridEnabled ? 'bg-blue-50 border-blue-300 text-blue-600' : 'hover:bg-gray-50'}`}
|
|
|
|
|
|
title="그리드 스냅">▦</button>
|
|
|
|
|
|
<div className="w-px h-5 bg-gray-200 mx-1"></div>
|
|
|
|
|
|
<button onClick={undo} disabled={!canUndo} className="px-2 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-30" title="되돌리기 (Ctrl+Z)">↩</button>
|
|
|
|
|
|
<button onClick={redo} disabled={!canRedo} className="px-2 py-1 text-sm border rounded hover:bg-gray-50 disabled:opacity-30" title="다시실행 (Ctrl+Shift+Z)">↪</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
{/* 템플릿 드롭다운 */}
|
|
|
|
|
|
<div className="relative" ref={dropdownRef}>
|
|
|
|
|
|
<button onClick={() => setDropdownOpen(o => !o)}
|
|
|
|
|
|
className="px-3 py-1.5 border rounded-lg text-sm hover:bg-gray-50 transition-colors flex items-center gap-1">
|
|
|
|
|
|
템플릿 <span className="text-[10px]">▾</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{dropdownOpen && (
|
|
|
|
|
|
<div className="absolute right-0 top-full mt-1 w-48 bg-white border rounded-lg shadow-lg z-50 py-1">
|
|
|
|
|
|
<button onClick={() => { setDropdownOpen(false); onSaveTemplate(); }}
|
|
|
|
|
|
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex items-center gap-2">
|
|
|
|
|
|
<span>📁</span> 템플릿으로 저장
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button onClick={() => { setDropdownOpen(false); onLoadTemplate(); }}
|
|
|
|
|
|
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex items-center gap-2">
|
|
|
|
|
|
<span>📂</span> 템플릿 불러오기
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<div className="border-t my-1"></div>
|
|
|
|
|
|
<button onClick={() => { setDropdownOpen(false); onCopyFromContract(); }}
|
|
|
|
|
|
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex items-center gap-2">
|
|
|
|
|
|
<span>📋</span> 다른 계약에서 복사
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button onClick={saveFields} disabled={saving}
|
|
|
|
|
|
className="px-5 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium disabled:opacity-50 transition-colors">
|
|
|
|
|
|
{saving ? '저장 중...' : '저장'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-02-12 15:39:29 +09:00
|
|
|
|
</div>
|
2026-02-12 18:02:31 +09:00
|
|
|
|
);
|
|
|
|
|
|
};
|
2026-02-12 15:39:29 +09:00
|
|
|
|
|
|
|
|
|
|
// ─── ThumbnailSidebar ───
|
|
|
|
|
|
const ThumbnailSidebar = ({ pdfDoc, totalPages, currentPage, setCurrentPage, fields }) => {
|
|
|
|
|
|
const canvasRefs = useRef({});
|
|
|
|
|
|
const [rendered, setRendered] = useState({});
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!pdfDoc) return;
|
|
|
|
|
|
const renderThumbs = async () => {
|
|
|
|
|
|
for (let i = 1; i <= totalPages; i++) {
|
|
|
|
|
|
const canvas = canvasRefs.current[i];
|
|
|
|
|
|
if (!canvas || rendered[i]) continue;
|
|
|
|
|
|
const page = await pdfDoc.getPage(i);
|
|
|
|
|
|
const vp = page.getViewport({ scale: 0.15 });
|
|
|
|
|
|
canvas.width = vp.width;
|
|
|
|
|
|
canvas.height = vp.height;
|
|
|
|
|
|
await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise;
|
|
|
|
|
|
setRendered(prev => ({ ...prev, [i]: true }));
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
renderThumbs();
|
|
|
|
|
|
}, [pdfDoc, totalPages]);
|
|
|
|
|
|
|
|
|
|
|
|
const fieldCountByPage = useMemo(() => {
|
|
|
|
|
|
const counts = {};
|
|
|
|
|
|
fields.forEach(f => { counts[f.page_number] = (counts[f.page_number] || 0) + 1; });
|
|
|
|
|
|
return counts;
|
|
|
|
|
|
}, [fields]);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="w-20 bg-gray-50 border-r overflow-y-auto flex-shrink-0" style={{ height: 'calc(100vh - 105px)' }}>
|
|
|
|
|
|
<div className="p-2 space-y-2">
|
|
|
|
|
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
|
|
|
|
|
|
<button key={p} onClick={() => setCurrentPage(p)}
|
|
|
|
|
|
className={`w-full relative rounded border-2 transition-all ${p === currentPage ? 'border-blue-500 shadow-md' : 'border-transparent hover:border-gray-300'}`}>
|
|
|
|
|
|
<canvas ref={el => canvasRefs.current[p] = el} className="w-full rounded" />
|
|
|
|
|
|
<span className="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-[10px] text-center py-0.5 rounded-b">{p}</span>
|
|
|
|
|
|
{fieldCountByPage[p] > 0 && (
|
|
|
|
|
|
<span className="absolute -top-1 -right-1 bg-blue-500 text-white text-[9px] w-4 h-4 rounded-full flex items-center justify-center font-bold">
|
|
|
|
|
|
{fieldCountByPage[p]}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ─── FieldOverlay (interact.js) ───
|
|
|
|
|
|
const FieldOverlay = ({ field, index, selected, signers, containerRef, onSelect, onUpdate, onDragEnd, gridEnabled }) => {
|
|
|
|
|
|
const fieldRef = useRef(null);
|
|
|
|
|
|
// Ref로 최신 field/callbacks 추적 (interact.js 재초기화 방지)
|
|
|
|
|
|
const latestRef = useRef({ field, index, onUpdate, onDragEnd });
|
|
|
|
|
|
latestRef.current = { field, index, onUpdate, onDragEnd };
|
|
|
|
|
|
|
|
|
|
|
|
const signerIdx = signers.findIndex(s => s.id === field.signer_id);
|
|
|
|
|
|
const color = SIGNER_COLORS[signerIdx % SIGNER_COLORS.length] || '#888';
|
|
|
|
|
|
const signerName = signers[signerIdx]?.name || '?';
|
|
|
|
|
|
const typeInfo = FIELD_TYPES.find(t => t.value === field.field_type) || FIELD_TYPES[0];
|
|
|
|
|
|
|
|
|
|
|
|
// interact.js는 gridEnabled 변경 시에만 재초기화
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const el = fieldRef.current;
|
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
|
|
|
|
|
|
const modifiers = [
|
|
|
|
|
|
interact.modifiers.restrictRect({ restriction: 'parent', endOnly: false }),
|
|
|
|
|
|
];
|
|
|
|
|
|
if (gridEnabled) {
|
|
|
|
|
|
modifiers.push(interact.modifiers.snap({
|
|
|
|
|
|
targets: [interact.snappers.grid({ x: 1, y: 1 })],
|
|
|
|
|
|
range: 10, relativePoints: [{ x: 0, y: 0 }],
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const interactable = interact(el)
|
|
|
|
|
|
.draggable({
|
|
|
|
|
|
inertia: false,
|
|
|
|
|
|
modifiers,
|
|
|
|
|
|
listeners: {
|
|
|
|
|
|
move(event) {
|
|
|
|
|
|
const { field: f, index: idx, onUpdate: update } = latestRef.current;
|
|
|
|
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
|
|
|
|
const dx = (event.dx / rect.width) * 100;
|
|
|
|
|
|
const dy = (event.dy / rect.height) * 100;
|
|
|
|
|
|
const newX = Math.max(0, Math.min(100 - f.width, f.position_x + dx));
|
|
|
|
|
|
const newY = Math.max(0, Math.min(100 - f.height, f.position_y + dy));
|
|
|
|
|
|
update(idx, { position_x: round2(newX), position_y: round2(newY) });
|
|
|
|
|
|
},
|
|
|
|
|
|
end() { latestRef.current.onDragEnd(); },
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
.resizable({
|
|
|
|
|
|
edges: { left: true, right: true, top: true, bottom: true },
|
|
|
|
|
|
modifiers: [
|
|
|
|
|
|
interact.modifiers.restrictEdges({ outer: 'parent' }),
|
|
|
|
|
|
interact.modifiers.restrictSize({ min: { width: 30, height: 20 } }),
|
|
|
|
|
|
],
|
|
|
|
|
|
listeners: {
|
|
|
|
|
|
move(event) {
|
|
|
|
|
|
const { field: f, index: idx, onUpdate: update } = latestRef.current;
|
|
|
|
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
|
|
|
|
const newX = f.position_x + (event.deltaRect.left / rect.width) * 100;
|
|
|
|
|
|
const newY = f.position_y + (event.deltaRect.top / rect.height) * 100;
|
|
|
|
|
|
const newW = (event.rect.width / rect.width) * 100;
|
|
|
|
|
|
const newH = (event.rect.height / rect.height) * 100;
|
|
|
|
|
|
update(idx, {
|
|
|
|
|
|
position_x: round2(Math.max(0, newX)),
|
|
|
|
|
|
position_y: round2(Math.max(0, newY)),
|
|
|
|
|
|
width: round2(Math.max(2, newW)),
|
|
|
|
|
|
height: round2(Math.max(1, newH)),
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
end() { latestRef.current.onDragEnd(); },
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return () => interactable.unset();
|
|
|
|
|
|
}, [gridEnabled]);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div ref={fieldRef}
|
|
|
|
|
|
onClick={(e) => { e.stopPropagation(); onSelect(index); }}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
left: `${field.position_x}%`, top: `${field.position_y}%`,
|
|
|
|
|
|
width: `${field.width}%`, height: `${field.height}%`,
|
|
|
|
|
|
border: selected ? `2px solid ${color}` : `2px dashed ${color}`,
|
|
|
|
|
|
backgroundColor: selected ? `${color}30` : `${color}15`,
|
|
|
|
|
|
cursor: 'move', borderRadius: '4px',
|
|
|
|
|
|
zIndex: selected ? 20 : 10,
|
|
|
|
|
|
touchAction: 'none',
|
|
|
|
|
|
boxShadow: selected ? `0 0 0 1px ${color}, 0 2px 8px rgba(0,0,0,0.15)` : 'none',
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="flex items-center justify-center select-none group transition-shadow">
|
|
|
|
|
|
<div className="flex items-center gap-0.5 text-[10px] font-medium truncate px-1" style={{ color }}>
|
|
|
|
|
|
<span>{typeInfo.icon}</span>
|
|
|
|
|
|
<span className="truncate">{field.field_label || signerName}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/* 리사이즈 핸들 (선택 시) */}
|
|
|
|
|
|
{selected && <>
|
|
|
|
|
|
{['nw','n','ne','w','e','sw','s','se'].map(dir => {
|
|
|
|
|
|
const styles = {
|
|
|
|
|
|
nw: { top: -4, left: -4, cursor: 'nw-resize' },
|
|
|
|
|
|
n: { top: -4, left: '50%', transform: 'translateX(-50%)', cursor: 'n-resize' },
|
|
|
|
|
|
ne: { top: -4, right: -4, cursor: 'ne-resize' },
|
|
|
|
|
|
w: { top: '50%', left: -4, transform: 'translateY(-50%)', cursor: 'w-resize' },
|
|
|
|
|
|
e: { top: '50%', right: -4, transform: 'translateY(-50%)', cursor: 'e-resize' },
|
|
|
|
|
|
sw: { bottom: -4, left: -4, cursor: 'sw-resize' },
|
|
|
|
|
|
s: { bottom: -4, left: '50%', transform: 'translateX(-50%)', cursor: 's-resize' },
|
|
|
|
|
|
se: { bottom: -4, right: -4, cursor: 'se-resize' },
|
|
|
|
|
|
};
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={dir} style={{
|
|
|
|
|
|
...styles[dir], position: 'absolute',
|
|
|
|
|
|
width: 8, height: 8, backgroundColor: 'white',
|
|
|
|
|
|
border: `2px solid ${color}`, borderRadius: 2,
|
|
|
|
|
|
zIndex: 30,
|
|
|
|
|
|
}} />
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ─── PropertiesPanel ───
|
|
|
|
|
|
const PropertiesPanel = ({ signers, fields, selectedFieldIndex, currentPage, onAddField, onUpdateField, onRemoveField, onSelectField }) => {
|
|
|
|
|
|
const selectedField = selectedFieldIndex !== null ? fields[selectedFieldIndex] : null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="w-72 bg-white border-l overflow-y-auto flex-shrink-0" style={{ height: 'calc(100vh - 105px)' }}>
|
|
|
|
|
|
<div className="p-3 space-y-4">
|
|
|
|
|
|
{/* 필드 도구상자 */}
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">필드 추가</h3>
|
|
|
|
|
|
{signers.map((s, i) => {
|
|
|
|
|
|
const color = SIGNER_COLORS[i % SIGNER_COLORS.length];
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={s.id} className="mb-3">
|
|
|
|
|
|
<div className="flex items-center gap-1.5 mb-1.5">
|
|
|
|
|
|
<span className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: color }}></span>
|
|
|
|
|
|
<span className="text-xs font-medium text-gray-700 truncate">{s.name}</span>
|
|
|
|
|
|
<span className="text-[10px] text-gray-400">({s.role === 'creator' ? '작성자' : '상대방'})</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="grid grid-cols-3 gap-1">
|
|
|
|
|
|
{FIELD_TYPES.map(ft => (
|
|
|
|
|
|
<button key={ft.value}
|
|
|
|
|
|
onClick={() => onAddField(s.id, ft.value)}
|
|
|
|
|
|
className="px-1.5 py-1.5 border rounded text-[10px] hover:bg-gray-50 transition-colors flex flex-col items-center gap-0.5"
|
|
|
|
|
|
style={{ borderColor: `${color}40` }}>
|
|
|
|
|
|
<span className="text-sm">{ft.icon}</span>
|
|
|
|
|
|
<span className="text-gray-600">{ft.label}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="border-t pt-3">
|
|
|
|
|
|
{/* 선택된 필드 속성 */}
|
|
|
|
|
|
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
|
|
|
|
|
|
{selectedField ? '필드 속성' : '필드 속성 (선택 없음)'}
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
{selectedField ? (
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="text-[10px] text-gray-500">서명자</label>
|
|
|
|
|
|
<select value={selectedField.signer_id}
|
|
|
|
|
|
onChange={e => onUpdateField(selectedFieldIndex, { signer_id: parseInt(e.target.value) })}
|
|
|
|
|
|
className="w-full border rounded px-2 py-1 text-xs mt-0.5">
|
|
|
|
|
|
{signers.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="text-[10px] text-gray-500">유형</label>
|
|
|
|
|
|
<select value={selectedField.field_type}
|
|
|
|
|
|
onChange={e => onUpdateField(selectedFieldIndex, { field_type: e.target.value })}
|
|
|
|
|
|
className="w-full border rounded px-2 py-1 text-xs mt-0.5">
|
|
|
|
|
|
{FIELD_TYPES.map(t => <option key={t.value} value={t.value}>{t.icon} {t.label}</option>)}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="text-[10px] text-gray-500">라벨</label>
|
|
|
|
|
|
<input type="text" value={selectedField.field_label || ''}
|
|
|
|
|
|
onChange={e => onUpdateField(selectedFieldIndex, { field_label: e.target.value })}
|
|
|
|
|
|
placeholder="선택사항"
|
|
|
|
|
|
className="w-full border rounded px-2 py-1 text-xs mt-0.5" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-1.5">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="text-[10px] text-gray-500">X (%)</label>
|
|
|
|
|
|
<input type="number" value={selectedField.position_x} step="0.5" min="0" max="100"
|
|
|
|
|
|
onChange={e => onUpdateField(selectedFieldIndex, { position_x: parseFloat(e.target.value) || 0 })}
|
|
|
|
|
|
className="w-full border rounded px-2 py-1 text-xs mt-0.5" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="text-[10px] text-gray-500">Y (%)</label>
|
|
|
|
|
|
<input type="number" value={selectedField.position_y} step="0.5" min="0" max="100"
|
|
|
|
|
|
onChange={e => onUpdateField(selectedFieldIndex, { position_y: parseFloat(e.target.value) || 0 })}
|
|
|
|
|
|
className="w-full border rounded px-2 py-1 text-xs mt-0.5" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="text-[10px] text-gray-500">W (%)</label>
|
|
|
|
|
|
<input type="number" value={selectedField.width} step="0.5" min="2" max="100"
|
|
|
|
|
|
onChange={e => onUpdateField(selectedFieldIndex, { width: parseFloat(e.target.value) || 2 })}
|
|
|
|
|
|
className="w-full border rounded px-2 py-1 text-xs mt-0.5" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="text-[10px] text-gray-500">H (%)</label>
|
|
|
|
|
|
<input type="number" value={selectedField.height} step="0.5" min="1" max="100"
|
|
|
|
|
|
onChange={e => onUpdateField(selectedFieldIndex, { height: parseFloat(e.target.value) || 1 })}
|
|
|
|
|
|
className="w-full border rounded px-2 py-1 text-xs mt-0.5" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="text-[10px] text-gray-500">페이지</label>
|
|
|
|
|
|
<input type="number" value={selectedField.page_number} min="1"
|
|
|
|
|
|
onChange={e => onUpdateField(selectedFieldIndex, { page_number: parseInt(e.target.value) || 1 })}
|
|
|
|
|
|
className="w-full border rounded px-2 py-1 text-xs mt-0.5" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2 pt-1">
|
|
|
|
|
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
|
|
|
|
|
|
<input type="checkbox" checked={selectedField.is_required !== false}
|
|
|
|
|
|
onChange={e => onUpdateField(selectedFieldIndex, { is_required: e.target.checked })}
|
|
|
|
|
|
className="rounded border-gray-300" />
|
|
|
|
|
|
필수 항목
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button onClick={() => onRemoveField(selectedFieldIndex)}
|
|
|
|
|
|
className="w-full mt-2 px-3 py-1.5 border border-red-200 text-red-600 rounded text-xs hover:bg-red-50 transition-colors">
|
|
|
|
|
|
필드 삭제
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p className="text-xs text-gray-400">PDF에서 필드를 클릭하여 선택하세요</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 필드 목록 */}
|
|
|
|
|
|
<div className="border-t pt-3">
|
|
|
|
|
|
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
|
|
|
|
|
|
필드 목록 ({fields.length}개)
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<div className="space-y-1 max-h-60 overflow-y-auto">
|
|
|
|
|
|
{fields.length === 0 && <p className="text-xs text-gray-400">아직 추가된 필드가 없습니다</p>}
|
|
|
|
|
|
{fields.map((f, idx) => {
|
|
|
|
|
|
const signer = signers.find(s => s.id === f.signer_id);
|
|
|
|
|
|
const si = signers.findIndex(s => s.id === f.signer_id);
|
|
|
|
|
|
const color = SIGNER_COLORS[si % SIGNER_COLORS.length] || '#888';
|
|
|
|
|
|
const typeInfo = FIELD_TYPES.find(t => t.value === f.field_type) || FIELD_TYPES[0];
|
|
|
|
|
|
const isSelected = idx === selectedFieldIndex;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={idx}
|
|
|
|
|
|
onClick={() => onSelectField(idx)}
|
|
|
|
|
|
className={`flex items-center justify-between px-2 py-1.5 rounded cursor-pointer text-xs transition-colors ${isSelected ? 'bg-blue-50 ring-1 ring-blue-200' : 'hover:bg-gray-50'}`}>
|
|
|
|
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
|
|
|
|
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: color }}></span>
|
|
|
|
|
|
<span className="truncate">{typeInfo.icon} {f.field_label || signer?.name || '?'}</span>
|
|
|
|
|
|
<span className="text-gray-400 flex-shrink-0">p.{f.page_number}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button onClick={(e) => { e.stopPropagation(); onRemoveField(idx); }}
|
|
|
|
|
|
className="text-gray-300 hover:text-red-500 flex-shrink-0 ml-1">×</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-12 18:02:31 +09:00
|
|
|
|
// ─── SaveTemplateModal ───
|
|
|
|
|
|
const SaveTemplateModal = ({ open, onClose, onSave }) => {
|
|
|
|
|
|
const [name, setName] = useState('');
|
|
|
|
|
|
const [description, setDescription] = useState('');
|
|
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
if (!open) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const handleSave = async () => {
|
|
|
|
|
|
if (!name.trim()) { alert('템플릿 이름을 입력해주세요.'); return; }
|
|
|
|
|
|
setSaving(true);
|
|
|
|
|
|
await onSave(name.trim(), description.trim());
|
|
|
|
|
|
setSaving(false);
|
|
|
|
|
|
setName(''); setDescription('');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
|
|
|
|
|
<div className="bg-white rounded-xl shadow-2xl w-96 p-5" onClick={e => e.stopPropagation()}>
|
|
|
|
|
|
<h3 className="text-base font-semibold mb-3">📁 템플릿으로 저장</h3>
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="text-xs text-gray-500 block mb-1">템플릿 이름 *</label>
|
|
|
|
|
|
<input type="text" value={name} onChange={e => setName(e.target.value)}
|
|
|
|
|
|
placeholder="예: 기본 2인 서명 배치"
|
|
|
|
|
|
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-200 focus:border-blue-400 outline-none" autoFocus />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="text-xs text-gray-500 block mb-1">설명</label>
|
|
|
|
|
|
<textarea value={description} onChange={e => setDescription(e.target.value)}
|
|
|
|
|
|
placeholder="선택사항" rows={2}
|
|
|
|
|
|
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-200 focus:border-blue-400 outline-none resize-none" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex justify-end gap-2 mt-4">
|
|
|
|
|
|
<button onClick={onClose} className="px-4 py-1.5 text-sm border rounded-lg hover:bg-gray-50">취소</button>
|
|
|
|
|
|
<button onClick={handleSave} disabled={saving}
|
|
|
|
|
|
className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
|
|
|
|
|
{saving ? '저장 중...' : '저장'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ─── LoadTemplateModal ───
|
|
|
|
|
|
const LoadTemplateModal = ({ open, onClose, onApply, signerCount }) => {
|
|
|
|
|
|
const [templates, setTemplates] = useState([]);
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
const [applying, setApplying] = useState(false);
|
|
|
|
|
|
const [selected, setSelected] = useState(null);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!open) return;
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
setSelected(null);
|
|
|
|
|
|
fetch(`/esign/contracts/templates?signer_count=${signerCount}`, { headers: getHeaders() })
|
|
|
|
|
|
.then(r => r.json())
|
|
|
|
|
|
.then(json => { if (json.success) setTemplates(json.data); })
|
|
|
|
|
|
.catch(() => {})
|
|
|
|
|
|
.finally(() => setLoading(false));
|
|
|
|
|
|
}, [open, signerCount]);
|
|
|
|
|
|
|
|
|
|
|
|
if (!open) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const handleApply = async () => {
|
|
|
|
|
|
if (!selected) return;
|
|
|
|
|
|
if (!confirm('현재 필드를 모두 삭제하고 템플릿을 적용하시겠습니까?')) return;
|
|
|
|
|
|
setApplying(true);
|
|
|
|
|
|
await onApply(selected);
|
|
|
|
|
|
setApplying(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleDelete = async (id, e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
if (!confirm('이 템플릿을 삭제하시겠습니까?')) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`/esign/contracts/templates/${id}`, { method: 'DELETE', headers: getHeaders() });
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success) setTemplates(prev => prev.filter(t => t.id !== id));
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
|
|
|
|
|
<div className="bg-white rounded-xl shadow-2xl w-[480px] max-h-[70vh] flex flex-col p-5" onClick={e => e.stopPropagation()}>
|
|
|
|
|
|
<h3 className="text-base font-semibold mb-3">📂 템플릿 불러오기</h3>
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
|
<div className="text-center text-gray-400 py-8">불러오는 중...</div>
|
|
|
|
|
|
) : templates.length === 0 ? (
|
|
|
|
|
|
<div className="text-center text-gray-400 py-8">저장된 템플릿이 없습니다.</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="flex-1 overflow-y-auto space-y-2 mb-3">
|
|
|
|
|
|
{templates.map(t => (
|
|
|
|
|
|
<div key={t.id}
|
|
|
|
|
|
onClick={() => setSelected(t.id)}
|
|
|
|
|
|
className={`p-3 border rounded-lg cursor-pointer transition-colors ${selected === t.id ? 'border-blue-400 bg-blue-50' : 'hover:bg-gray-50'}`}>
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="text-sm font-medium">{t.name}</div>
|
|
|
|
|
|
{t.description && <div className="text-xs text-gray-400 mt-0.5">{t.description}</div>}
|
|
|
|
|
|
<div className="text-[10px] text-gray-400 mt-1">
|
|
|
|
|
|
서명자 {t.signer_count}명 · 필드 {t.items?.length || 0}개
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button onClick={(e) => handleDelete(t.id, e)}
|
|
|
|
|
|
className="text-gray-300 hover:text-red-500 text-lg px-1" title="삭제">×</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="flex justify-end gap-2 pt-2 border-t">
|
|
|
|
|
|
<button onClick={onClose} className="px-4 py-1.5 text-sm border rounded-lg hover:bg-gray-50">취소</button>
|
|
|
|
|
|
<button onClick={handleApply} disabled={!selected || applying}
|
|
|
|
|
|
className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
|
|
|
|
|
{applying ? '적용 중...' : '적용'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ─── CopyFromContractModal ───
|
|
|
|
|
|
const CopyFromContractModal = ({ open, onClose, onCopy, currentContractId }) => {
|
|
|
|
|
|
const [contracts, setContracts] = useState([]);
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
const [copying, setCopying] = useState(false);
|
|
|
|
|
|
const [selected, setSelected] = useState(null);
|
|
|
|
|
|
const [search, setSearch] = useState('');
|
|
|
|
|
|
|
|
|
|
|
|
const doSearch = useCallback(async (q) => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`/esign/contracts/list?search=${encodeURIComponent(q)}&per_page=20`, { headers: getHeaders() });
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success) {
|
|
|
|
|
|
// 현재 계약은 제외
|
|
|
|
|
|
const filtered = (json.data.data || []).filter(c => c.id !== parseInt(currentContractId));
|
|
|
|
|
|
setContracts(filtered);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}, [currentContractId]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!open) return;
|
|
|
|
|
|
setSelected(null);
|
|
|
|
|
|
setSearch('');
|
|
|
|
|
|
doSearch('');
|
|
|
|
|
|
}, [open, doSearch]);
|
|
|
|
|
|
|
|
|
|
|
|
if (!open) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const handleSearch = (e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
doSearch(search);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleCopy = async () => {
|
|
|
|
|
|
if (!selected) return;
|
|
|
|
|
|
if (!confirm('현재 필드를 모두 삭제하고 선택한 계약의 필드를 복사하시겠습니까?')) return;
|
|
|
|
|
|
setCopying(true);
|
|
|
|
|
|
await onCopy(selected);
|
|
|
|
|
|
setCopying(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
|
|
|
|
|
<div className="bg-white rounded-xl shadow-2xl w-[520px] max-h-[70vh] flex flex-col p-5" onClick={e => e.stopPropagation()}>
|
|
|
|
|
|
<h3 className="text-base font-semibold mb-3">📋 다른 계약에서 복사</h3>
|
|
|
|
|
|
<form onSubmit={handleSearch} className="flex gap-2 mb-3">
|
|
|
|
|
|
<input type="text" value={search} onChange={e => setSearch(e.target.value)}
|
|
|
|
|
|
placeholder="계약 제목 또는 코드로 검색..."
|
|
|
|
|
|
className="flex-1 border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-200 focus:border-blue-400 outline-none" autoFocus />
|
|
|
|
|
|
<button type="submit" className="px-3 py-2 text-sm border rounded-lg hover:bg-gray-50">검색</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
|
<div className="text-center text-gray-400 py-8">검색 중...</div>
|
|
|
|
|
|
) : contracts.length === 0 ? (
|
|
|
|
|
|
<div className="text-center text-gray-400 py-8">검색 결과가 없습니다.</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="flex-1 overflow-y-auto space-y-2 mb-3">
|
|
|
|
|
|
{contracts.map(c => (
|
|
|
|
|
|
<div key={c.id}
|
|
|
|
|
|
onClick={() => setSelected(c.id)}
|
|
|
|
|
|
className={`p-3 border rounded-lg cursor-pointer transition-colors ${selected === c.id ? 'border-blue-400 bg-blue-50' : 'hover:bg-gray-50'}`}>
|
|
|
|
|
|
<div className="text-sm font-medium">{c.title}</div>
|
|
|
|
|
|
<div className="text-[10px] text-gray-400 mt-1 flex gap-3">
|
|
|
|
|
|
<span>{c.contract_code}</span>
|
|
|
|
|
|
<span>서명자: {c.signers?.map(s => s.name).join(', ')}</span>
|
|
|
|
|
|
<span className="capitalize">{c.status}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="flex justify-end gap-2 pt-2 border-t">
|
|
|
|
|
|
<button onClick={onClose} className="px-4 py-1.5 text-sm border rounded-lg hover:bg-gray-50">취소</button>
|
|
|
|
|
|
<button onClick={handleCopy} disabled={!selected || copying}
|
|
|
|
|
|
className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
|
|
|
|
|
{copying ? '복사 중...' : '복사'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-12 15:39:29 +09:00
|
|
|
|
// ─── 유틸 ───
|
|
|
|
|
|
const round2 = (n) => Math.round(n * 100) / 100;
|
|
|
|
|
|
|
|
|
|
|
|
// ─── App ───
|
2026-02-12 07:02:48 +09:00
|
|
|
|
const App = () => {
|
|
|
|
|
|
const [contract, setContract] = useState(null);
|
|
|
|
|
|
const [fields, setFields] = useState([]);
|
|
|
|
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
|
|
|
|
const [totalPages, setTotalPages] = useState(0);
|
|
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
2026-02-12 15:39:29 +09:00
|
|
|
|
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);
|
2026-02-12 18:02:31 +09:00
|
|
|
|
const [showSaveTemplate, setShowSaveTemplate] = useState(false);
|
|
|
|
|
|
const [showLoadTemplate, setShowLoadTemplate] = useState(false);
|
|
|
|
|
|
const [showCopyFromContract, setShowCopyFromContract] = useState(false);
|
2026-02-12 15:39:29 +09:00
|
|
|
|
|
|
|
|
|
|
// History (Undo/Redo)
|
|
|
|
|
|
const [history, setHistory] = useState([]);
|
|
|
|
|
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
|
|
|
|
const skipHistoryRef = useRef(false);
|
|
|
|
|
|
|
2026-02-12 07:02:48 +09:00
|
|
|
|
const canvasRef = useRef(null);
|
|
|
|
|
|
const containerRef = useRef(null);
|
2026-02-12 15:39:29 +09:00
|
|
|
|
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]);
|
2026-02-12 07:02:48 +09:00
|
|
|
|
|
|
|
|
|
|
// 계약 정보 로드
|
|
|
|
|
|
const fetchContract = useCallback(async () => {
|
|
|
|
|
|
try {
|
refactor:E-Sign 외부 API 호출을 MNG 내부 라우트로 전환
- Finance 패턴과 동일하게 MNG 직접 DB 접근 방식으로 변경
- MNG 모델 4개 추가: EsignContract, EsignSigner, EsignSignField, EsignAuditLog
- EsignApiController 추가: stats, index, show, store, cancel, configureFields, send, download
- 모든 뷰(dashboard, create, detail, fields, send)에서 외부 API URL 제거
- 기존 X-API-Key/Bearer 인증 대신 MNG 세션 인증(CSRF) 사용
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 10:24:09 +09:00
|
|
|
|
const res = await fetch(`/esign/contracts/${CONTRACT_ID}`, { headers: getHeaders() });
|
2026-02-12 07:02:48 +09:00
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success) {
|
|
|
|
|
|
setContract(json.data);
|
2026-02-12 15:39:29 +09:00
|
|
|
|
const loadedFields = (json.data.sign_fields || []).map(f => ({
|
|
|
|
|
|
signer_id: f.signer_id, page_number: f.page_number,
|
|
|
|
|
|
position_x: parseFloat(f.position_x), position_y: parseFloat(f.position_y),
|
|
|
|
|
|
width: parseFloat(f.width), height: parseFloat(f.height),
|
|
|
|
|
|
field_type: f.field_type, field_label: f.field_label || '',
|
|
|
|
|
|
is_required: f.is_required !== false,
|
|
|
|
|
|
}));
|
|
|
|
|
|
setFields(loadedFields);
|
|
|
|
|
|
// 초기 히스토리
|
|
|
|
|
|
setHistory([JSON.parse(JSON.stringify(loadedFields))]);
|
|
|
|
|
|
setHistoryIndex(0);
|
2026-02-12 07:02:48 +09:00
|
|
|
|
}
|
|
|
|
|
|
} catch (e) { console.error(e); }
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// PDF 로드
|
|
|
|
|
|
const loadPdf = useCallback(async () => {
|
|
|
|
|
|
if (!contract) return;
|
|
|
|
|
|
try {
|
refactor:E-Sign 외부 API 호출을 MNG 내부 라우트로 전환
- Finance 패턴과 동일하게 MNG 직접 DB 접근 방식으로 변경
- MNG 모델 4개 추가: EsignContract, EsignSigner, EsignSignField, EsignAuditLog
- EsignApiController 추가: stats, index, show, store, cancel, configureFields, send, download
- 모든 뷰(dashboard, create, detail, fields, send)에서 외부 API URL 제거
- 기존 X-API-Key/Bearer 인증 대신 MNG 세션 인증(CSRF) 사용
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 10:24:09 +09:00
|
|
|
|
const url = `/esign/contracts/${CONTRACT_ID}/download`;
|
2026-02-12 15:39:29 +09:00
|
|
|
|
const res = await fetch(url, { headers: { 'X-CSRF-TOKEN': csrfToken } });
|
2026-02-12 07:02:48 +09:00
|
|
|
|
const arrayBuffer = await res.arrayBuffer();
|
|
|
|
|
|
const doc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
|
|
|
|
|
setPdfDoc(doc);
|
|
|
|
|
|
setTotalPages(doc.numPages);
|
|
|
|
|
|
} catch (e) { console.error('PDF 로드 실패:', e); }
|
|
|
|
|
|
}, [contract]);
|
|
|
|
|
|
|
|
|
|
|
|
// PDF 페이지 렌더링
|
|
|
|
|
|
const renderPage = useCallback(async (pageNum) => {
|
|
|
|
|
|
if (!pdfDoc || !canvasRef.current) return;
|
2026-02-12 15:39:29 +09:00
|
|
|
|
// 이전 렌더 작업 취소
|
|
|
|
|
|
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]);
|
2026-02-12 07:02:48 +09:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => { fetchContract(); }, [fetchContract]);
|
|
|
|
|
|
useEffect(() => { loadPdf(); }, [loadPdf]);
|
|
|
|
|
|
useEffect(() => { renderPage(currentPage); }, [renderPage, currentPage]);
|
|
|
|
|
|
|
2026-02-12 18:55:06 +09:00
|
|
|
|
// URL 파라미터로 템플릿 자동 적용
|
|
|
|
|
|
const autoAppliedRef = useRef(false);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!contract || autoAppliedRef.current) return;
|
|
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
|
|
|
|
const urlTemplateId = params.get('template_id');
|
|
|
|
|
|
if (urlTemplateId && fields.length === 0) {
|
|
|
|
|
|
autoAppliedRef.current = true;
|
|
|
|
|
|
handleApplyTemplate(parseInt(urlTemplateId));
|
|
|
|
|
|
window.history.replaceState({}, '', window.location.pathname);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [contract]);
|
|
|
|
|
|
|
2026-02-12 15:39:29 +09:00
|
|
|
|
// PDF.js 메모리 정리
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
return () => { if (pdfDoc) pdfDoc.destroy(); };
|
|
|
|
|
|
}, [pdfDoc]);
|
|
|
|
|
|
|
2026-02-12 07:02:48 +09:00
|
|
|
|
// 필드 추가
|
2026-02-12 15:39:29 +09:00
|
|
|
|
const addField = useCallback((signerId, fieldType = 'signature') => {
|
|
|
|
|
|
const newField = {
|
2026-02-12 07:02:48 +09:00
|
|
|
|
signer_id: signerId, page_number: currentPage,
|
2026-02-12 15:39:29 +09:00
|
|
|
|
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]);
|
2026-02-12 07:02:48 +09:00
|
|
|
|
|
|
|
|
|
|
// 필드 삭제
|
2026-02-12 15:39:29 +09:00
|
|
|
|
const removeField = useCallback((idx) => {
|
|
|
|
|
|
const newFields = fields.filter((_, i) => i !== idx);
|
|
|
|
|
|
setFields(newFields);
|
|
|
|
|
|
setSelectedFieldIndex(null);
|
|
|
|
|
|
pushHistory(newFields);
|
|
|
|
|
|
}, [fields, pushHistory]);
|
2026-02-12 07:02:48 +09:00
|
|
|
|
|
2026-02-12 15:39:29 +09:00
|
|
|
|
// 필드 업데이트 (드래그/리사이즈 중 - 히스토리 안 쌓음)
|
|
|
|
|
|
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]);
|
2026-02-12 07:02:48 +09:00
|
|
|
|
|
|
|
|
|
|
// 저장
|
|
|
|
|
|
const saveFields = async () => {
|
|
|
|
|
|
setSaving(true);
|
|
|
|
|
|
try {
|
refactor:E-Sign 외부 API 호출을 MNG 내부 라우트로 전환
- Finance 패턴과 동일하게 MNG 직접 DB 접근 방식으로 변경
- MNG 모델 4개 추가: EsignContract, EsignSigner, EsignSignField, EsignAuditLog
- EsignApiController 추가: stats, index, show, store, cancel, configureFields, send, download
- 모든 뷰(dashboard, create, detail, fields, send)에서 외부 API URL 제거
- 기존 X-API-Key/Bearer 인증 대신 MNG 세션 인증(CSRF) 사용
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 10:24:09 +09:00
|
|
|
|
const res = await fetch(`/esign/contracts/${CONTRACT_ID}/fields`, {
|
2026-02-12 07:02:48 +09:00
|
|
|
|
method: 'POST', headers: getHeaders(),
|
2026-02-12 15:39:29 +09:00
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
fields: fields.map((f, i) => ({
|
|
|
|
|
|
signer_id: f.signer_id,
|
|
|
|
|
|
page_number: f.page_number,
|
|
|
|
|
|
position_x: round2(f.position_x),
|
|
|
|
|
|
position_y: round2(f.position_y),
|
|
|
|
|
|
width: round2(f.width),
|
|
|
|
|
|
height: round2(f.height),
|
|
|
|
|
|
field_type: f.field_type,
|
|
|
|
|
|
field_label: f.field_label || '',
|
|
|
|
|
|
is_required: f.is_required !== false,
|
|
|
|
|
|
sort_order: i,
|
|
|
|
|
|
})),
|
|
|
|
|
|
}),
|
2026-02-12 07:02:48 +09:00
|
|
|
|
});
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success) {
|
|
|
|
|
|
alert('서명 위치가 저장되었습니다.');
|
|
|
|
|
|
location.href = `/esign/${CONTRACT_ID}`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
alert(json.message || '저장 실패');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) { alert('서버 오류'); }
|
|
|
|
|
|
setSaving(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-12 18:02:31 +09:00
|
|
|
|
// 템플릿으로 저장
|
|
|
|
|
|
const handleSaveTemplate = useCallback(async (name, description) => {
|
|
|
|
|
|
if (fields.length === 0) { alert('저장할 필드가 없습니다.'); return; }
|
|
|
|
|
|
const signers = contract.signers || [];
|
|
|
|
|
|
// signer_id → sign_order 매핑
|
|
|
|
|
|
const signerOrderMap = {};
|
|
|
|
|
|
signers.forEach(s => { signerOrderMap[s.id] = s.sign_order; });
|
|
|
|
|
|
|
|
|
|
|
|
const items = fields.map((f, i) => ({
|
|
|
|
|
|
signer_order: signerOrderMap[f.signer_id] || 1,
|
|
|
|
|
|
page_number: f.page_number,
|
|
|
|
|
|
position_x: round2(f.position_x),
|
|
|
|
|
|
position_y: round2(f.position_y),
|
|
|
|
|
|
width: round2(f.width),
|
|
|
|
|
|
height: round2(f.height),
|
|
|
|
|
|
field_type: f.field_type,
|
|
|
|
|
|
field_label: f.field_label || '',
|
|
|
|
|
|
is_required: f.is_required !== false,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch('/esign/contracts/templates', {
|
|
|
|
|
|
method: 'POST', headers: getHeaders(),
|
|
|
|
|
|
body: JSON.stringify({ name, description, items }),
|
|
|
|
|
|
});
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success) {
|
|
|
|
|
|
alert('템플릿이 저장되었습니다.');
|
|
|
|
|
|
setShowSaveTemplate(false);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
alert(json.message || '저장 실패');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) { alert('서버 오류'); }
|
|
|
|
|
|
}, [fields, contract]);
|
|
|
|
|
|
|
|
|
|
|
|
// 템플릿 적용
|
|
|
|
|
|
const handleApplyTemplate = useCallback(async (templateId) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`/esign/contracts/${CONTRACT_ID}/apply-template`, {
|
|
|
|
|
|
method: 'POST', headers: getHeaders(),
|
|
|
|
|
|
body: JSON.stringify({ template_id: templateId }),
|
|
|
|
|
|
});
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success) {
|
|
|
|
|
|
const signers = contract.signers || [];
|
|
|
|
|
|
const newFields = (json.data || []).map(f => ({
|
|
|
|
|
|
signer_id: f.signer_id, page_number: f.page_number,
|
|
|
|
|
|
position_x: parseFloat(f.position_x), position_y: parseFloat(f.position_y),
|
|
|
|
|
|
width: parseFloat(f.width), height: parseFloat(f.height),
|
|
|
|
|
|
field_type: f.field_type, field_label: f.field_label || '',
|
|
|
|
|
|
is_required: f.is_required !== false,
|
|
|
|
|
|
}));
|
|
|
|
|
|
setFields(newFields);
|
|
|
|
|
|
pushHistory(newFields);
|
|
|
|
|
|
setSelectedFieldIndex(null);
|
|
|
|
|
|
setShowLoadTemplate(false);
|
|
|
|
|
|
alert('템플릿이 적용되었습니다.');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
alert(json.message || '적용 실패');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) { alert('서버 오류'); }
|
|
|
|
|
|
}, [contract, pushHistory]);
|
|
|
|
|
|
|
|
|
|
|
|
// 다른 계약에서 필드 복사
|
|
|
|
|
|
const handleCopyFromContract = useCallback(async (sourceId) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`/esign/contracts/${CONTRACT_ID}/copy-fields/${sourceId}`, {
|
|
|
|
|
|
method: 'POST', headers: getHeaders(),
|
|
|
|
|
|
});
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success) {
|
|
|
|
|
|
const newFields = (json.data || []).map(f => ({
|
|
|
|
|
|
signer_id: f.signer_id, page_number: f.page_number,
|
|
|
|
|
|
position_x: parseFloat(f.position_x), position_y: parseFloat(f.position_y),
|
|
|
|
|
|
width: parseFloat(f.width), height: parseFloat(f.height),
|
|
|
|
|
|
field_type: f.field_type, field_label: f.field_label || '',
|
|
|
|
|
|
is_required: f.is_required !== false,
|
|
|
|
|
|
}));
|
|
|
|
|
|
setFields(newFields);
|
|
|
|
|
|
pushHistory(newFields);
|
|
|
|
|
|
setSelectedFieldIndex(null);
|
|
|
|
|
|
setShowCopyFromContract(false);
|
|
|
|
|
|
alert('필드가 복사되었습니다.');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
alert(json.message || '복사 실패');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) { alert('서버 오류'); }
|
|
|
|
|
|
}, [pushHistory]);
|
|
|
|
|
|
|
2026-02-12 15:39:29 +09:00
|
|
|
|
// 키보드 단축키
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const handler = (e) => {
|
|
|
|
|
|
// Ctrl+Z / Ctrl+Shift+Z
|
|
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
if (e.shiftKey) redo(); else undo();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Ctrl+Y
|
|
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'y') {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
redo();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Delete / Backspace
|
|
|
|
|
|
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedFieldIndex !== null) {
|
|
|
|
|
|
// 입력 필드에 포커스가 있으면 무시
|
|
|
|
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
removeField(selectedFieldIndex);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Escape - 선택 해제
|
|
|
|
|
|
if (e.key === 'Escape') {
|
|
|
|
|
|
setSelectedFieldIndex(null);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 화살표 키 - 미세 조정
|
|
|
|
|
|
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key) && selectedFieldIndex !== null) {
|
|
|
|
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const step = e.shiftKey ? 1 : 0.25;
|
|
|
|
|
|
const f = fields[selectedFieldIndex];
|
|
|
|
|
|
if (!f) return;
|
|
|
|
|
|
let updates = {};
|
|
|
|
|
|
switch (e.key) {
|
|
|
|
|
|
case 'ArrowLeft': updates.position_x = Math.max(0, f.position_x - step); break;
|
|
|
|
|
|
case 'ArrowRight': updates.position_x = Math.min(100 - f.width, f.position_x + step); break;
|
|
|
|
|
|
case 'ArrowUp': updates.position_y = Math.max(0, f.position_y - step); break;
|
|
|
|
|
|
case 'ArrowDown': updates.position_y = Math.min(100 - f.height, f.position_y + step); break;
|
|
|
|
|
|
}
|
|
|
|
|
|
updates.position_x = round2(updates.position_x ?? f.position_x);
|
|
|
|
|
|
updates.position_y = round2(updates.position_y ?? f.position_y);
|
|
|
|
|
|
updateFieldWithHistory(selectedFieldIndex, updates);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Ctrl+C - 복사
|
|
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'c' && selectedFieldIndex !== null) {
|
|
|
|
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
setClipboard({ ...fields[selectedFieldIndex] });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Ctrl+V - 붙여넣기
|
|
|
|
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'v' && clipboard) {
|
|
|
|
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const pasted = {
|
|
|
|
|
|
...clipboard,
|
|
|
|
|
|
position_x: Math.min(80, clipboard.position_x + 2),
|
|
|
|
|
|
position_y: Math.min(90, clipboard.position_y + 2),
|
|
|
|
|
|
page_number: currentPage,
|
|
|
|
|
|
};
|
|
|
|
|
|
const newFields = [...fields, pasted];
|
|
|
|
|
|
setFields(newFields);
|
|
|
|
|
|
setSelectedFieldIndex(newFields.length - 1);
|
|
|
|
|
|
pushHistory(newFields);
|
|
|
|
|
|
setClipboard(pasted);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Ctrl+- / Ctrl+= (줌)
|
|
|
|
|
|
if ((e.ctrlKey || e.metaKey) && (e.key === '-' || e.key === '=')) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
setZoom(prev => {
|
|
|
|
|
|
const i = ZOOM_LEVELS.indexOf(prev);
|
|
|
|
|
|
if (e.key === '-' && i > 0) return ZOOM_LEVELS[i - 1];
|
|
|
|
|
|
if (e.key === '=' && i < ZOOM_LEVELS.length - 1) return ZOOM_LEVELS[i + 1];
|
|
|
|
|
|
return prev;
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
window.addEventListener('keydown', handler);
|
|
|
|
|
|
return () => window.removeEventListener('keydown', handler);
|
|
|
|
|
|
}, [undo, redo, selectedFieldIndex, fields, removeField, updateFieldWithHistory, clipboard, currentPage, pushHistory]);
|
|
|
|
|
|
|
|
|
|
|
|
if (loading) return <div className="flex items-center justify-center h-screen text-gray-400">로딩 중...</div>;
|
|
|
|
|
|
if (!contract) return <div className="flex items-center justify-center h-screen text-red-500">계약을 찾을 수 없습니다.</div>;
|
2026-02-12 07:02:48 +09:00
|
|
|
|
|
|
|
|
|
|
const signers = contract.signers || [];
|
2026-02-12 15:39:29 +09:00
|
|
|
|
const pageFields = fields.map((f, idx) => ({ ...f, _idx: idx })).filter(f => f.page_number === currentPage);
|
2026-02-12 07:02:48 +09:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-02-12 15:39:29 +09:00
|
|
|
|
<div className="flex flex-col h-screen bg-gray-100">
|
|
|
|
|
|
<Toolbar
|
|
|
|
|
|
zoom={zoom} setZoom={setZoom}
|
|
|
|
|
|
gridEnabled={gridEnabled} setGridEnabled={setGridEnabled}
|
|
|
|
|
|
undo={undo} redo={redo}
|
|
|
|
|
|
canUndo={historyIndex > 0} canRedo={historyIndex < history.length - 1}
|
|
|
|
|
|
saving={saving} saveFields={saveFields}
|
|
|
|
|
|
goBack={() => location.href = `/esign/${CONTRACT_ID}`}
|
2026-02-12 18:02:31 +09:00
|
|
|
|
onSaveTemplate={() => setShowSaveTemplate(true)}
|
|
|
|
|
|
onLoadTemplate={() => setShowLoadTemplate(true)}
|
|
|
|
|
|
onCopyFromContract={() => setShowCopyFromContract(true)}
|
2026-02-12 15:39:29 +09:00
|
|
|
|
/>
|
|
|
|
|
|
<div className="flex flex-1 overflow-hidden">
|
|
|
|
|
|
{/* 썸네일 사이드바 */}
|
|
|
|
|
|
<ThumbnailSidebar
|
|
|
|
|
|
pdfDoc={pdfDoc} totalPages={totalPages}
|
|
|
|
|
|
currentPage={currentPage} setCurrentPage={setCurrentPage}
|
|
|
|
|
|
fields={fields}
|
|
|
|
|
|
/>
|
2026-02-12 07:02:48 +09:00
|
|
|
|
|
2026-02-12 15:39:29 +09:00
|
|
|
|
{/* PDF 뷰어 (중앙) */}
|
|
|
|
|
|
<div className="flex-1 overflow-auto bg-gray-200 relative"
|
|
|
|
|
|
onClick={() => setSelectedFieldIndex(null)}>
|
|
|
|
|
|
<div className="flex justify-center p-6 min-h-full">
|
|
|
|
|
|
<div className="relative" style={{ width: canvasSize.width || 'auto', height: canvasSize.height || 'auto' }}>
|
|
|
|
|
|
<canvas ref={canvasRef} className="shadow-lg block" />
|
|
|
|
|
|
{/* 그리드 오버레이 */}
|
|
|
|
|
|
{gridEnabled && canvasSize.width > 0 && (
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 5,
|
|
|
|
|
|
backgroundImage: 'linear-gradient(rgba(59,130,246,0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(59,130,246,0.08) 1px, transparent 1px)',
|
|
|
|
|
|
backgroundSize: '5% 5%',
|
|
|
|
|
|
}} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
{/* 필드 오버레이 컨테이너 */}
|
|
|
|
|
|
<div ref={containerRef}
|
|
|
|
|
|
style={{ position: 'absolute', inset: 0, zIndex: 10 }}
|
|
|
|
|
|
onClick={(e) => { if (e.target === e.currentTarget) setSelectedFieldIndex(null); }}>
|
|
|
|
|
|
{pageFields.map((f) => (
|
|
|
|
|
|
<FieldOverlay
|
|
|
|
|
|
key={f._idx}
|
|
|
|
|
|
field={f}
|
|
|
|
|
|
index={f._idx}
|
|
|
|
|
|
selected={selectedFieldIndex === f._idx}
|
|
|
|
|
|
signers={signers}
|
|
|
|
|
|
containerRef={containerRef}
|
|
|
|
|
|
onSelect={setSelectedFieldIndex}
|
|
|
|
|
|
onUpdate={updateField}
|
|
|
|
|
|
onDragEnd={handleDragEnd}
|
|
|
|
|
|
gridEnabled={gridEnabled}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
2026-02-12 07:02:48 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-12 15:39:29 +09:00
|
|
|
|
{/* 속성 패널 (우측) */}
|
|
|
|
|
|
<PropertiesPanel
|
|
|
|
|
|
signers={signers}
|
|
|
|
|
|
fields={fields}
|
|
|
|
|
|
selectedFieldIndex={selectedFieldIndex}
|
|
|
|
|
|
currentPage={currentPage}
|
|
|
|
|
|
onAddField={addField}
|
|
|
|
|
|
onUpdateField={updateFieldWithHistory}
|
|
|
|
|
|
onRemoveField={removeField}
|
|
|
|
|
|
onSelectField={(idx) => {
|
|
|
|
|
|
setSelectedFieldIndex(idx);
|
|
|
|
|
|
if (fields[idx]) setCurrentPage(fields[idx].page_number);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-02-12 07:02:48 +09:00
|
|
|
|
</div>
|
2026-02-12 18:02:31 +09:00
|
|
|
|
|
|
|
|
|
|
{/* 모달 */}
|
|
|
|
|
|
<SaveTemplateModal
|
|
|
|
|
|
open={showSaveTemplate}
|
|
|
|
|
|
onClose={() => setShowSaveTemplate(false)}
|
|
|
|
|
|
onSave={handleSaveTemplate}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<LoadTemplateModal
|
|
|
|
|
|
open={showLoadTemplate}
|
|
|
|
|
|
onClose={() => setShowLoadTemplate(false)}
|
|
|
|
|
|
onApply={handleApplyTemplate}
|
|
|
|
|
|
signerCount={signers.length}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<CopyFromContractModal
|
|
|
|
|
|
open={showCopyFromContract}
|
|
|
|
|
|
onClose={() => setShowCopyFromContract(false)}
|
|
|
|
|
|
onCopy={handleCopyFromContract}
|
|
|
|
|
|
currentContractId={CONTRACT_ID}
|
|
|
|
|
|
/>
|
2026-02-12 07:02:48 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-12 10:35:04 +09:00
|
|
|
|
ReactDOM.createRoot(document.getElementById('esign-fields-root')).render(<App />);
|
2026-02-12 07:02:48 +09:00
|
|
|
|
</script>
|
|
|
|
|
|
@endverbatim
|
|
|
|
|
|
@endpush
|