Files
sam-manage/resources/views/esign/fields.blade.php
김보곤 440cd11ece refactor:esign 페이지 및 전역 레이아웃 React CDN 통합
- esign 전자서명 관련 9개 파일 업데이트
- layouts/app.blade.php 업데이트
- fcm.js React 관련 변경사항 반영
2026-02-12 10:35:04 +09:00

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">&larr;</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">&laquo; 이전</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">다음 &raquo;</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">&times;</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