254 lines
13 KiB
PHP
254 lines
13 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', '서명 위치 설정')
|
|
|
|
@section('content')
|
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
<div id="esign-fields-root" data-contract-id="{{ $contractId }}"></div>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
@include('partials.react-cdn')
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
|
<script>pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';</script>
|
|
@verbatim
|
|
<script type="text/babel">
|
|
const { useState, useEffect, useCallback, useRef } = React;
|
|
|
|
const CONTRACT_ID = document.getElementById('esign-fields-root')?.dataset.contractId;
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
|
|
const getHeaders = () => ({
|
|
'Accept': 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken,
|
|
});
|
|
|
|
const FIELD_TYPES = [
|
|
{ value: 'signature', label: '서명' },
|
|
{ value: 'stamp', label: '도장' },
|
|
{ value: 'text', label: '텍스트' },
|
|
{ value: 'date', label: '날짜' },
|
|
{ value: 'checkbox', label: '체크박스' },
|
|
];
|
|
|
|
const SIGNER_COLORS = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'];
|
|
|
|
const App = () => {
|
|
const [contract, setContract] = useState(null);
|
|
const [fields, setFields] = useState([]);
|
|
const [pdfPages, setPdfPages] = useState([]);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(0);
|
|
const [saving, setSaving] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const canvasRef = useRef(null);
|
|
const containerRef = useRef(null);
|
|
const [pdfDoc, setPdfDoc] = useState(null);
|
|
|
|
// 계약 정보 로드
|
|
const fetchContract = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`/esign/contracts/${CONTRACT_ID}`, { headers: getHeaders() });
|
|
const json = await res.json();
|
|
if (json.success) {
|
|
setContract(json.data);
|
|
if (json.data.sign_fields?.length) {
|
|
setFields(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,
|
|
})));
|
|
}
|
|
}
|
|
} catch (e) { console.error(e); }
|
|
setLoading(false);
|
|
}, []);
|
|
|
|
// PDF 로드
|
|
const loadPdf = useCallback(async () => {
|
|
if (!contract) return;
|
|
try {
|
|
const url = `/esign/contracts/${CONTRACT_ID}/download`;
|
|
const res = await fetch(url, {
|
|
headers: { 'X-CSRF-TOKEN': csrfToken }
|
|
});
|
|
const arrayBuffer = await res.arrayBuffer();
|
|
const doc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
|
setPdfDoc(doc);
|
|
setTotalPages(doc.numPages);
|
|
} catch (e) { console.error('PDF 로드 실패:', e); }
|
|
}, [contract]);
|
|
|
|
// PDF 페이지 렌더링
|
|
const renderPage = useCallback(async (pageNum) => {
|
|
if (!pdfDoc || !canvasRef.current) return;
|
|
const page = await pdfDoc.getPage(pageNum);
|
|
const scale = 1.5;
|
|
const viewport = page.getViewport({ scale });
|
|
const canvas = canvasRef.current;
|
|
canvas.height = viewport.height;
|
|
canvas.width = viewport.width;
|
|
const ctx = canvas.getContext('2d');
|
|
await page.render({ canvasContext: ctx, viewport }).promise;
|
|
}, [pdfDoc]);
|
|
|
|
useEffect(() => { fetchContract(); }, [fetchContract]);
|
|
useEffect(() => { loadPdf(); }, [loadPdf]);
|
|
useEffect(() => { renderPage(currentPage); }, [renderPage, currentPage]);
|
|
|
|
// 필드 추가
|
|
const addField = (signerId) => {
|
|
setFields(prev => [...prev, {
|
|
signer_id: signerId, page_number: currentPage,
|
|
position_x: 10, position_y: 80, width: 20, height: 5,
|
|
field_type: 'signature', field_label: '',
|
|
}]);
|
|
};
|
|
|
|
// 필드 삭제
|
|
const removeField = (idx) => setFields(prev => prev.filter((_, i) => i !== idx));
|
|
|
|
// 필드 수정
|
|
const updateField = (idx, key, val) => {
|
|
setFields(prev => prev.map((f, i) => i === idx ? {...f, [key]: val} : f));
|
|
};
|
|
|
|
// 저장
|
|
const saveFields = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const res = await fetch(`/esign/contracts/${CONTRACT_ID}/fields`, {
|
|
method: 'POST', headers: getHeaders(),
|
|
body: JSON.stringify({ fields: fields.map((f, i) => ({...f, sort_order: i})) }),
|
|
});
|
|
const json = await res.json();
|
|
if (json.success) {
|
|
alert('서명 위치가 저장되었습니다.');
|
|
location.href = `/esign/${CONTRACT_ID}`;
|
|
} else {
|
|
alert(json.message || '저장 실패');
|
|
}
|
|
} catch (e) { alert('서버 오류'); }
|
|
setSaving(false);
|
|
};
|
|
|
|
if (loading) return <div className="p-6 text-center text-gray-400">로딩 중...</div>;
|
|
if (!contract) return <div className="p-6 text-center text-red-500">계약을 찾을 수 없습니다.</div>;
|
|
|
|
const signers = contract.signers || [];
|
|
const pageFields = fields.filter(f => f.page_number === currentPage);
|
|
|
|
return (
|
|
<div className="p-6 max-w-7xl mx-auto">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-3">
|
|
<a href={`/esign/${CONTRACT_ID}`} className="text-gray-400 hover:text-gray-600" hx-boost="false">←</a>
|
|
<h1 className="text-2xl font-bold text-gray-900">서명 위치 설정</h1>
|
|
</div>
|
|
<button onClick={saveFields} disabled={saving}
|
|
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium disabled:opacity-50">
|
|
{saving ? '저장 중...' : '저장'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
|
{/* 왼쪽: PDF 미리보기 */}
|
|
<div className="lg:col-span-3">
|
|
<div className="bg-white rounded-lg border p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex gap-2">
|
|
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage <= 1}
|
|
className="px-3 py-1 border rounded text-sm disabled:opacity-30">« 이전</button>
|
|
<span className="px-3 py-1 text-sm">{currentPage} / {totalPages}</span>
|
|
<button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}
|
|
className="px-3 py-1 border rounded text-sm disabled:opacity-30">다음 »</button>
|
|
</div>
|
|
</div>
|
|
<div ref={containerRef} className="relative bg-gray-100 overflow-auto" style={{maxHeight: '700px'}}>
|
|
<canvas ref={canvasRef} />
|
|
{/* 서명 필드 오버레이 */}
|
|
{pageFields.map((f, idx) => {
|
|
const realIdx = fields.indexOf(f);
|
|
const signerIdx = signers.findIndex(s => s.id === f.signer_id);
|
|
const color = SIGNER_COLORS[signerIdx % SIGNER_COLORS.length] || '#888';
|
|
return (
|
|
<div key={realIdx}
|
|
style={{
|
|
position: 'absolute', left: `${f.position_x}%`, top: `${f.position_y}%`,
|
|
width: `${f.width}%`, height: `${f.height}%`,
|
|
border: `2px dashed ${color}`, backgroundColor: `${color}20`,
|
|
cursor: 'move', borderRadius: '4px',
|
|
}}
|
|
className="flex items-center justify-center text-xs font-medium"
|
|
title={`${signers[signerIdx]?.name || ''} - ${FIELD_TYPES.find(t => t.value === f.field_type)?.label || ''}`}>
|
|
<span style={{color}}>{signers[signerIdx]?.name?.charAt(0) || '?'}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 오른쪽: 필드 설정 */}
|
|
<div className="space-y-4">
|
|
{/* 서명자별 필드 추가 */}
|
|
<div className="bg-white rounded-lg border p-4">
|
|
<h3 className="font-semibold text-sm mb-3">서명 필드 추가</h3>
|
|
{signers.map((s, i) => (
|
|
<button key={s.id} onClick={() => addField(s.id)}
|
|
className="w-full mb-2 px-3 py-2 border rounded-lg text-sm text-left hover:bg-gray-50"
|
|
style={{borderColor: SIGNER_COLORS[i % SIGNER_COLORS.length]}}>
|
|
<span className="inline-block w-3 h-3 rounded-full mr-2" style={{backgroundColor: SIGNER_COLORS[i % SIGNER_COLORS.length]}}></span>
|
|
{s.name} ({s.role === 'creator' ? '작성자' : '상대방'})
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* 필드 목록 */}
|
|
<div className="bg-white rounded-lg border p-4">
|
|
<h3 className="font-semibold text-sm mb-3">필드 목록 ({fields.length}개)</h3>
|
|
<div className="space-y-3 max-h-96 overflow-y-auto">
|
|
{fields.map((f, idx) => {
|
|
const signer = signers.find(s => s.id === f.signer_id);
|
|
return (
|
|
<div key={idx} className="p-3 bg-gray-50 rounded-lg text-xs space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="font-medium">{signer?.name || '?'} - p.{f.page_number}</span>
|
|
<button onClick={() => removeField(idx)} className="text-red-400 hover:text-red-600">×</button>
|
|
</div>
|
|
<select value={f.field_type} onChange={e => updateField(idx, 'field_type', e.target.value)}
|
|
className="w-full border rounded px-2 py-1 text-xs">
|
|
{FIELD_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
|
</select>
|
|
<div className="grid grid-cols-2 gap-1">
|
|
<input type="number" placeholder="X%" value={f.position_x} step="0.5"
|
|
onChange={e => updateField(idx, 'position_x', parseFloat(e.target.value) || 0)}
|
|
className="border rounded px-2 py-1 text-xs" />
|
|
<input type="number" placeholder="Y%" value={f.position_y} step="0.5"
|
|
onChange={e => updateField(idx, 'position_y', parseFloat(e.target.value) || 0)}
|
|
className="border rounded px-2 py-1 text-xs" />
|
|
<input type="number" placeholder="W%" value={f.width} step="0.5"
|
|
onChange={e => updateField(idx, 'width', parseFloat(e.target.value) || 0)}
|
|
className="border rounded px-2 py-1 text-xs" />
|
|
<input type="number" placeholder="H%" value={f.height} step="0.5"
|
|
onChange={e => updateField(idx, 'height', parseFloat(e.target.value) || 0)}
|
|
className="border rounded px-2 py-1 text-xs" />
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
ReactDOM.createRoot(document.getElementById('esign-fields-root')).render(<App />);
|
|
</script>
|
|
@endverbatim
|
|
@endpush
|