1150 lines
62 KiB
PHP
1150 lines
62 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', 'SAM E-Sign - 새 계약 생성')
|
|
|
|
@section('content')
|
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
<div id="esign-create-root"></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>
|
|
window.__esignCreate = {
|
|
isAdmin: @json(auth()->user()?->is_super_admin || auth()->user()?->hasRole(['admin', 'super-admin'])),
|
|
};
|
|
</script>
|
|
@verbatim
|
|
<script type="text/babel">
|
|
const { useState, useEffect, useRef, useCallback } = React;
|
|
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
const IS_ADMIN = window.__esignCreate?.isAdmin || false;
|
|
|
|
const SIGNER_COLORS = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'];
|
|
const FIELD_TYPE_INFO = {
|
|
signature: { label: '서명', icon: '✍' },
|
|
stamp: { label: '도장', icon: '📌' },
|
|
text: { label: '텍스트', icon: 'T' },
|
|
date: { label: '날짜', icon: '📅' },
|
|
checkbox: { label: '체크', icon: '☑' },
|
|
};
|
|
const WIZARD_STEPS = [
|
|
{ id: 1, title: '계약 정보' },
|
|
{ id: 2, title: '템플릿 & 미리보기' },
|
|
{ id: 3, title: '확인 & 생성' },
|
|
];
|
|
const TITLE_PRESETS = [
|
|
{ value: '영업파트너 계약서', label: '영업파트너 계약서' },
|
|
{ value: '비밀유지 서약서', label: '비밀유지 서약서' },
|
|
{ value: '고객 서비스이용 계약서', label: '고객 서비스이용 계약서' },
|
|
{ value: '__custom__', label: '직접입력' },
|
|
];
|
|
|
|
// ─── Input ───
|
|
const Input = ({ label, name, value, error, onChange, type = 'text', required = false, placeholder = '', style }) => (
|
|
<div style={style}>
|
|
<label className="block text-xs font-medium text-gray-700 mb-1">{label} {required && <span className="text-red-500">*</span>}</label>
|
|
<input type={type} value={value} onChange={e => onChange(name, e.target.value)}
|
|
placeholder={placeholder} required={required}
|
|
className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors" />
|
|
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
|
</div>
|
|
);
|
|
|
|
// ─── SignerRow ───
|
|
const SignerRow = ({ prefix, title, subtitle, color, form, errors, onChange }) => (
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: color }}></span>
|
|
<h3 className="text-xs font-semibold text-gray-800">{title}</h3>
|
|
<span className="text-[11px] text-gray-400">{subtitle}</span>
|
|
</div>
|
|
<div className="flex gap-3" style={{ flexWrap: 'wrap' }}>
|
|
<Input label="이름" name={`${prefix}_name`} value={form[`${prefix}_name`]} error={errors[`${prefix}_name`]}
|
|
onChange={onChange} required placeholder="홍길동" style={{ flex: '1 1 140px', minWidth: 120 }} />
|
|
<Input label="이메일" name={`${prefix}_email`} value={form[`${prefix}_email`]} error={errors[`${prefix}_email`]}
|
|
onChange={onChange} type="email" required placeholder="hong@example.com" style={{ flex: '2.5 1 200px', minWidth: 180 }} />
|
|
<Input label="전화번호" name={`${prefix}_phone`} value={form[`${prefix}_phone`]} error={errors[`${prefix}_phone`]}
|
|
onChange={onChange} placeholder="010-1234-5678" style={{ flex: '1 1 140px', minWidth: 120 }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// ─── StepIndicator ───
|
|
const StepIndicator = ({ currentStep, onStepClick }) => (
|
|
<div className="flex items-center justify-center mb-6">
|
|
{WIZARD_STEPS.map((s, i) => (
|
|
<React.Fragment key={s.id}>
|
|
{i > 0 && <div className={`h-0.5 w-8 sm:w-16 mx-1 transition-colors ${i + 1 <= currentStep ? 'bg-blue-400' : 'bg-gray-200'}`} />}
|
|
<button type="button"
|
|
onClick={() => i + 1 < currentStep && onStepClick(i + 1)}
|
|
className={`flex items-center gap-1.5 ${i + 1 < currentStep ? 'cursor-pointer' : 'cursor-default'}`}>
|
|
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-bold transition-colors ${
|
|
i + 1 < currentStep ? 'bg-blue-500 text-white' :
|
|
i + 1 === currentStep ? 'bg-blue-600 text-white ring-2 ring-blue-200' :
|
|
'bg-gray-200 text-gray-400'
|
|
}`}>{i + 1 < currentStep ? '\u2713' : i + 1}</div>
|
|
<span className={`text-xs hidden sm:inline ${i + 1 === currentStep ? 'text-gray-800 font-medium' : 'text-gray-400'}`}>{s.title}</span>
|
|
</button>
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
// ─── PdfFieldPreview (PDF + 필드 위치 오버레이) ───
|
|
const PdfFieldPreview = ({ templateId, items, hasPdf }) => {
|
|
const [pdfDoc, setPdfDoc] = useState(null);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(0);
|
|
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
|
const [loading, setLoading] = useState(false);
|
|
const canvasRef = useRef(null);
|
|
const renderTaskRef = useRef(null);
|
|
|
|
// PDF 로드
|
|
useEffect(() => {
|
|
if (!templateId || !hasPdf) { setPdfDoc(null); setTotalPages(0); return; }
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
const load = async () => {
|
|
try {
|
|
const res = await fetch(`/esign/contracts/templates/${templateId}/download`, {
|
|
headers: { 'X-CSRF-TOKEN': csrfToken },
|
|
});
|
|
if (!res.ok || cancelled) return;
|
|
const buf = await res.arrayBuffer();
|
|
const doc = await pdfjsLib.getDocument({ data: buf }).promise;
|
|
if (cancelled) { doc.destroy(); return; }
|
|
setPdfDoc(doc);
|
|
setTotalPages(doc.numPages);
|
|
setCurrentPage(1);
|
|
} catch (e) { console.error('PDF load failed:', e); }
|
|
if (!cancelled) setLoading(false);
|
|
};
|
|
load();
|
|
return () => { cancelled = true; };
|
|
}, [templateId, hasPdf]);
|
|
|
|
// 페이지 렌더링
|
|
useEffect(() => {
|
|
if (!pdfDoc || !canvasRef.current) return;
|
|
if (renderTaskRef.current) { try { renderTaskRef.current.cancel(); } catch (_) {} }
|
|
const render = async () => {
|
|
try {
|
|
const page = await pdfDoc.getPage(currentPage);
|
|
const baseVp = page.getViewport({ scale: 1.0 });
|
|
const scale = 520 / baseVp.width;
|
|
const vp = page.getViewport({ scale });
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
canvas.width = vp.width;
|
|
canvas.height = vp.height;
|
|
setCanvasSize({ width: vp.width, height: vp.height });
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
const task = page.render({ canvasContext: ctx, viewport: vp });
|
|
renderTaskRef.current = task;
|
|
await task.promise;
|
|
renderTaskRef.current = null;
|
|
} catch (e) { if (e.name !== 'RenderingCancelledException') console.error(e); }
|
|
};
|
|
render();
|
|
}, [pdfDoc, currentPage]);
|
|
|
|
// 메모리 해제
|
|
useEffect(() => { return () => { if (pdfDoc) pdfDoc.destroy(); }; }, [pdfDoc]);
|
|
|
|
if (!templateId) return null;
|
|
|
|
const pageItems = items.filter(it => it.page_number === currentPage);
|
|
|
|
if (!hasPdf) {
|
|
return (
|
|
<div className="mt-4 bg-gray-50 rounded-lg border border-gray-200 p-6 text-center">
|
|
<svg className="mx-auto mb-2 text-gray-300" width="40" height="40" 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-xs text-gray-500">이 템플릿에 PDF가 포함되어 있지 않습니다.</p>
|
|
<p className="text-xs text-gray-400 mt-1">필드 위치는 아래 목록에서 확인할 수 있습니다.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="mt-4 flex items-center justify-center py-12">
|
|
<div className="text-xs text-gray-400">PDF 로딩 중...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="mt-4">
|
|
<div className="flex justify-center">
|
|
<div className="relative inline-block bg-white rounded shadow-lg" style={{ width: canvasSize.width || 'auto' }}>
|
|
<canvas ref={canvasRef} className="block rounded" />
|
|
{/* 필드 오버레이 */}
|
|
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
|
|
{pageItems.map((item, i) => {
|
|
const color = SIGNER_COLORS[(item.signer_order - 1) % SIGNER_COLORS.length];
|
|
const ft = FIELD_TYPE_INFO[item.field_type] || { icon: '?', label: item.field_type };
|
|
return (
|
|
<div key={i} style={{
|
|
position: 'absolute',
|
|
left: `${item.position_x}%`, top: `${item.position_y}%`,
|
|
width: `${item.width}%`, height: `${item.height}%`,
|
|
border: `2px dashed ${color}`,
|
|
backgroundColor: `${color}18`,
|
|
borderRadius: 3,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
}}>
|
|
<span style={{ color, fontSize: 10, fontWeight: 600 }} className="truncate px-0.5">
|
|
{ft.icon} {item.field_variable ? `{{${item.field_variable}}}` : (item.field_label || ft.label)}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* 페이지 네비게이션 */}
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-center gap-3 mt-3">
|
|
<button type="button" onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
|
disabled={currentPage <= 1} className="px-2 py-1 border rounded text-sm hover:bg-gray-50 disabled:opacity-30">←</button>
|
|
<span className="text-xs text-gray-500">{currentPage} / {totalPages} 페이지</span>
|
|
<button type="button" onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
|
disabled={currentPage >= totalPages} className="px-2 py-1 border rounded text-sm hover:bg-gray-50 disabled:opacity-30">→</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── FieldItemList (필드 목록 테이블) ───
|
|
const FieldItemList = ({ items }) => {
|
|
if (!items.length) return null;
|
|
return (
|
|
<div className="mt-4">
|
|
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">배치될 필드 ({items.length}개)</h4>
|
|
<div className="border rounded-lg overflow-hidden">
|
|
<table className="w-full text-xs">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="text-left px-3 py-1.5 text-gray-500 font-medium">서명자</th>
|
|
<th className="text-left px-3 py-1.5 text-gray-500 font-medium">유형</th>
|
|
<th className="text-left px-3 py-1.5 text-gray-500 font-medium">라벨 / 변수</th>
|
|
<th className="text-center px-3 py-1.5 text-gray-500 font-medium">페이지</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{items.map((item, i) => {
|
|
const color = SIGNER_COLORS[(item.signer_order - 1) % SIGNER_COLORS.length];
|
|
const ft = FIELD_TYPE_INFO[item.field_type] || { icon: '?', label: '?' };
|
|
return (
|
|
<tr key={i} className="hover:bg-gray-50">
|
|
<td className="px-3 py-1.5">
|
|
<span className="inline-flex items-center gap-1">
|
|
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: color }}></span>
|
|
{item.signer_order === 1 ? '작성자' : '상대방'} ({item.signer_order}번)
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-1.5">{ft.icon} {ft.label}</td>
|
|
<td className="px-3 py-1.5">
|
|
{item.field_variable
|
|
? <code className="text-amber-600 bg-amber-50 px-1 rounded text-[10px]">{`{{${item.field_variable}}}`}</code>
|
|
: <span className="text-gray-400">{item.field_label || '-'}</span>}
|
|
</td>
|
|
<td className="px-3 py-1.5 text-center text-gray-500">p.{item.page_number}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── PartnerSearchModal ───
|
|
const PartnerSearchModal = ({ open, onClose, onSelect }) => {
|
|
const [query, setQuery] = useState('');
|
|
const [results, setResults] = useState([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [activeIdx, setActiveIdx] = useState(-1);
|
|
const inputRef = useRef(null);
|
|
const debounceRef = useRef(null);
|
|
|
|
const doSearch = useCallback((q) => {
|
|
setLoading(true);
|
|
fetch(`/esign/contracts/search-partners?q=${encodeURIComponent(q)}`, {
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken },
|
|
})
|
|
.then(r => r.json())
|
|
.then(json => { if (json.success) { setResults(json.data); setActiveIdx(-1); } })
|
|
.catch(() => {})
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setQuery(''); setResults([]); setActiveIdx(-1);
|
|
doSearch('');
|
|
setTimeout(() => inputRef.current?.focus(), 100);
|
|
}, [open, doSearch]);
|
|
|
|
const handleInput = (val) => {
|
|
setQuery(val);
|
|
clearTimeout(debounceRef.current);
|
|
debounceRef.current = setTimeout(() => doSearch(val), 250);
|
|
};
|
|
|
|
const handleKeyDown = (e) => {
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIdx(i => Math.min(results.length - 1, i + 1)); }
|
|
else if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIdx(i => Math.max(0, i - 1)); }
|
|
else if (e.key === 'Enter' && activeIdx >= 0 && results[activeIdx]) { e.preventDefault(); onSelect(results[activeIdx]); onClose(); }
|
|
else if (e.key === 'Escape') { onClose(); }
|
|
};
|
|
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
|
|
<div className="absolute inset-0 bg-black/40" />
|
|
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4 flex flex-col" style={{ maxHeight: 'min(480px, 80vh)' }} onClick={e => e.stopPropagation()}>
|
|
<div className="flex items-center justify-between px-5 py-3 border-b flex-shrink-0">
|
|
<h3 className="text-sm font-semibold text-gray-900">영업파트너 검색</h3>
|
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg">×</button>
|
|
</div>
|
|
<div className="px-5 py-3 flex-shrink-0">
|
|
<input ref={inputRef} type="text" value={query}
|
|
onChange={e => handleInput(e.target.value)} onKeyDown={handleKeyDown}
|
|
placeholder="이름, 이메일 또는 전화번호로 검색..."
|
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" />
|
|
</div>
|
|
<div className="px-5 pb-4 overflow-y-auto flex-1 min-h-0">
|
|
{loading && <p className="text-xs text-gray-400 py-3 text-center">검색 중...</p>}
|
|
{!loading && results.length === 0 && <p className="text-xs text-gray-400 py-3 text-center">검색 결과가 없습니다.</p>}
|
|
{!loading && results.map((p, i) => (
|
|
<button key={p.id} type="button"
|
|
onClick={() => { onSelect(p); onClose(); }}
|
|
onMouseEnter={() => setActiveIdx(i)}
|
|
className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i === activeIdx ? 'bg-blue-50 ring-1 ring-blue-200' : 'hover:bg-gray-50'}`}>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-gray-800">{p.name}</span>
|
|
{p.position && <span className="text-xs text-blue-600 bg-blue-50 px-1.5 py-0.5 rounded">{p.position}</span>}
|
|
</div>
|
|
{p.company_name && <span className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">{p.company_name}</span>}
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-0.5">
|
|
{p.phone && <span className="text-xs text-gray-400">{p.phone}</span>}
|
|
{p.email && <span className="text-xs text-gray-400">{p.email}</span>}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── TenantSearchModal ───
|
|
const TenantSearchModal = ({ open, onClose, onSelect }) => {
|
|
const [query, setQuery] = useState('');
|
|
const [results, setResults] = useState([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [activeIdx, setActiveIdx] = useState(-1);
|
|
const inputRef = useRef(null);
|
|
const debounceRef = useRef(null);
|
|
|
|
const doSearch = useCallback((q) => {
|
|
setLoading(true);
|
|
fetch(`/esign/contracts/search-tenants?q=${encodeURIComponent(q)}`, {
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken },
|
|
})
|
|
.then(r => r.json())
|
|
.then(json => { if (json.success) { setResults(json.data); setActiveIdx(-1); } })
|
|
.catch(() => {})
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setQuery(''); setResults([]); setActiveIdx(-1);
|
|
doSearch('');
|
|
setTimeout(() => inputRef.current?.focus(), 100);
|
|
}, [open, doSearch]);
|
|
|
|
const handleInput = (val) => {
|
|
setQuery(val);
|
|
clearTimeout(debounceRef.current);
|
|
debounceRef.current = setTimeout(() => doSearch(val), 250);
|
|
};
|
|
|
|
const handleKeyDown = (e) => {
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIdx(i => Math.min(results.length - 1, i + 1)); }
|
|
else if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIdx(i => Math.max(0, i - 1)); }
|
|
else if (e.key === 'Enter' && activeIdx >= 0 && results[activeIdx]) { e.preventDefault(); onSelect(results[activeIdx]); onClose(); }
|
|
else if (e.key === 'Escape') { onClose(); }
|
|
};
|
|
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
|
|
<div className="absolute inset-0 bg-black/40" />
|
|
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4 flex flex-col" style={{ maxHeight: 'min(480px, 80vh)' }} onClick={e => e.stopPropagation()}>
|
|
<div className="flex items-center justify-between px-5 py-3 border-b flex-shrink-0">
|
|
<h3 className="text-sm font-semibold text-gray-900">고객 검색</h3>
|
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg">×</button>
|
|
</div>
|
|
<div className="px-5 py-3 flex-shrink-0">
|
|
<input ref={inputRef} type="text" value={query}
|
|
onChange={e => handleInput(e.target.value)} onKeyDown={handleKeyDown}
|
|
placeholder="상호, 사업자등록번호, 대표자명, 전화번호로 검색..."
|
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" />
|
|
</div>
|
|
<div className="px-5 pb-4 overflow-y-auto flex-1 min-h-0">
|
|
{loading && <p className="text-xs text-gray-400 py-3 text-center">검색 중...</p>}
|
|
{!loading && results.length === 0 && <p className="text-xs text-gray-400 py-3 text-center">검색 결과가 없습니다.</p>}
|
|
{!loading && results.map((t, i) => (
|
|
<button key={t.id} type="button"
|
|
onClick={() => { onSelect(t); onClose(); }}
|
|
onMouseEnter={() => setActiveIdx(i)}
|
|
className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i === activeIdx ? 'bg-blue-50 ring-1 ring-blue-200' : 'hover:bg-gray-50'}`}>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-gray-800">{t.company_name}</span>
|
|
{t.ceo_name && <span className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">대표: {t.ceo_name}</span>}
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-0.5">
|
|
{t.business_number && <span className="text-xs text-gray-400">{t.business_number}</span>}
|
|
{t.phone && <span className="text-xs text-gray-400">{t.phone}</span>}
|
|
{t.address && <span className="text-xs text-gray-400 truncate" style={{ maxWidth: 200 }}>{t.address}</span>}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── App (Wizard) ───
|
|
const App = () => {
|
|
const [step, setStep] = useState(1);
|
|
const [form, setForm] = useState({
|
|
title: '영업파트너 계약서', description: '', sign_order_type: 'counterpart_first',
|
|
expires_at: (() => { const d = new Date(); d.setDate(d.getDate() + 7); return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0') + 'T23:59'; })(),
|
|
creator_name: '(주) 코드브릿지엑스', creator_email: 'contact@codebridge-x.com', creator_phone: '02-6347-0005',
|
|
counterpart_name: '김보곤', counterpart_email: 'lightone2017@gmail.com', counterpart_phone: '010-5123-8210',
|
|
});
|
|
const [titleType, setTitleType] = useState('영업파트너 계약서');
|
|
const [file, setFile] = useState(null);
|
|
const [registeredStamp, setRegisteredStamp] = useState(null);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [errors, setErrors] = useState({});
|
|
const [templates, setTemplates] = useState([]);
|
|
const [templateId, setTemplateId] = useState('');
|
|
const [templateCategory, setTemplateCategory] = useState('');
|
|
const [templateSearch, setTemplateSearch] = useState('');
|
|
const [templateItems, setTemplateItems] = useState([]);
|
|
const [metadata, setMetadata] = useState({});
|
|
const [partnerModalOpen, setPartnerModalOpen] = useState(false);
|
|
const [tenantModalOpen, setTenantModalOpen] = useState(false);
|
|
const fileRef = useRef(null);
|
|
|
|
const hasTemplates = templates.length > 0;
|
|
const selectedTemplate = templateId ? templates.find(t => t.id == templateId) : null;
|
|
const templateVars = selectedTemplate?.variables || [];
|
|
|
|
// 초기 데이터 로드
|
|
useEffect(() => {
|
|
fetch('/esign/contracts/stamp', { headers: { 'Accept': 'application/json' } })
|
|
.then(r => r.json())
|
|
.then(json => { if (json.success) setRegisteredStamp(json.data); })
|
|
.catch(() => {});
|
|
fetch('/esign/contracts/templates', { headers: { 'Accept': 'application/json' } })
|
|
.then(r => r.json())
|
|
.then(json => { if (json.success) setTemplates(json.data); })
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
const handleChange = (key, val) => setForm(f => ({...f, [key]: val}));
|
|
|
|
const handleFileSelect = async (e) => {
|
|
const selected = e.target.files[0];
|
|
if (!selected) return;
|
|
const buffer = await selected.arrayBuffer();
|
|
const copied = new File([buffer], selected.name, { type: selected.type });
|
|
setFile(copied);
|
|
};
|
|
|
|
// 템플릿 선택 시 상세 정보(items) 로드
|
|
const handleTemplateSelect = async (id) => {
|
|
setTemplateId(id);
|
|
if (!id) { setMetadata({}); setTemplateItems([]); return; }
|
|
|
|
const tpl = templates.find(t => t.id == id);
|
|
if (tpl?.variables?.length) {
|
|
const defaults = {};
|
|
tpl.variables.forEach(v => { defaults[v.key] = v.default || ''; });
|
|
setMetadata(defaults);
|
|
} else {
|
|
setMetadata({});
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/esign/contracts/templates/${id}`, { headers: { 'Accept': 'application/json' } });
|
|
const json = await res.json();
|
|
if (json.success) setTemplateItems(json.data.items || []);
|
|
} catch (_) { setTemplateItems([]); }
|
|
};
|
|
|
|
const isCustomerContract = form.title === '고객 서비스이용 계약서';
|
|
|
|
// 계약번호 자동 채번
|
|
const fetchContractNumber = async () => {
|
|
try {
|
|
const res = await fetch('/esign/contracts/generate-contract-number', {
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken },
|
|
});
|
|
const json = await res.json();
|
|
if (json.success) return json.data.contract_number;
|
|
} catch (_) {}
|
|
return '';
|
|
};
|
|
|
|
// 계약 제목 변경 시 기본값 세팅 (라벨 기반 매칭)
|
|
const applyTitleDefaults = async (title) => {
|
|
if (title === '고객 서비스이용 계약서') {
|
|
const contractNum = await fetchContractNumber();
|
|
const labelDefaults = {
|
|
'계약번호': contractNum || '',
|
|
'총개발비': '20,000,000',
|
|
'총개발비용': '20,000,000',
|
|
'월구독료': '500,000',
|
|
};
|
|
setMetadata(prev => {
|
|
const updated = { ...prev };
|
|
templateVars.forEach(v => {
|
|
if (labelDefaults[v.label] !== undefined && !updated[v.key]?.trim()) {
|
|
updated[v.key] = labelDefaults[v.label];
|
|
}
|
|
});
|
|
return updated;
|
|
});
|
|
}
|
|
};
|
|
|
|
// 고객(명함 등록 고객) 선택 핸들러
|
|
const handleTenantSelect = (tenant) => {
|
|
const keyMap = {
|
|
company_name: tenant.company_name,
|
|
biz_no: tenant.business_number,
|
|
address: tenant.address,
|
|
phone: tenant.phone,
|
|
};
|
|
const labelMap = {
|
|
'상호': tenant.company_name,
|
|
'사업자등록증': tenant.business_number,
|
|
'사업자등록번호': tenant.business_number,
|
|
'주소': tenant.address,
|
|
'전화': tenant.phone,
|
|
'전화번호': tenant.phone,
|
|
};
|
|
|
|
setMetadata(prev => {
|
|
const updated = { ...prev };
|
|
templateVars.forEach(v => {
|
|
if (keyMap[v.key] !== undefined && keyMap[v.key]) {
|
|
updated[v.key] = keyMap[v.key];
|
|
return;
|
|
}
|
|
if (labelMap[v.label] !== undefined && labelMap[v.label]) {
|
|
updated[v.key] = labelMap[v.label];
|
|
}
|
|
});
|
|
return updated;
|
|
});
|
|
|
|
// 상대방 서명자 정보도 채우기
|
|
setForm(f => ({
|
|
...f,
|
|
counterpart_name: tenant.company_name || f.counterpart_name,
|
|
counterpart_email: tenant.email || f.counterpart_email,
|
|
counterpart_phone: tenant.phone || f.counterpart_phone,
|
|
}));
|
|
};
|
|
|
|
// 개발용: 주소/사업자등록번호/상호 랜덤 입력
|
|
const fillRandomVariables = () => {
|
|
const addresses = [
|
|
'서울특별시 강남구 테헤란로 123, 4층',
|
|
'서울특별시 서초구 서초대로 456, 7층',
|
|
'경기도 성남시 분당구 판교로 789',
|
|
'서울특별시 마포구 월드컵북로 54길 12',
|
|
'인천광역시 연수구 송도과학로 32',
|
|
];
|
|
const bizNos = [
|
|
'123-45-67890', '234-56-78901', '345-67-89012',
|
|
'456-78-90123', '567-89-01234',
|
|
];
|
|
const companies = [
|
|
'(주)테스트컴퍼니', '(주)블루오션', '(주)스마트솔루션',
|
|
'(주)넥스트웨이브', '(주)그린테크',
|
|
];
|
|
const pick = arr => arr[Math.floor(Math.random() * arr.length)];
|
|
const labelMap = {
|
|
'주소': pick(addresses),
|
|
'사업자등록번호': pick(bizNos),
|
|
'상호': pick(companies),
|
|
};
|
|
setMetadata(prev => {
|
|
const updated = { ...prev };
|
|
templateVars.forEach(v => {
|
|
if (labelMap[v.label] !== undefined) {
|
|
updated[v.key] = labelMap[v.label];
|
|
}
|
|
});
|
|
return updated;
|
|
});
|
|
};
|
|
|
|
const fillRandomCounterpart = async () => {
|
|
try {
|
|
const res = await fetch(`/esign/contracts/search-partners?q=`, {
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken },
|
|
});
|
|
const json = await res.json();
|
|
if (json.success && json.data.length > 0) {
|
|
const partner = json.data[Math.floor(Math.random() * json.data.length)];
|
|
setForm(f => ({
|
|
...f,
|
|
counterpart_name: partner.name || '',
|
|
counterpart_email: partner.email || '',
|
|
counterpart_phone: partner.phone || '',
|
|
}));
|
|
}
|
|
} catch (_) {}
|
|
};
|
|
|
|
// 파트너 선택 시 자동 채우기
|
|
const handlePartnerSelect = (partner) => {
|
|
// key 기반 매핑 (변수 key → 파트너 필드)
|
|
const keyMap = {
|
|
partner_name: partner.name,
|
|
phone: partner.phone,
|
|
address: partner.address,
|
|
biz_no: partner.biz_no,
|
|
company_name: partner.company_name,
|
|
position: partner.position,
|
|
};
|
|
// label 기반 fallback 매핑
|
|
const labelMap = {
|
|
'파트너명': partner.name,
|
|
'전화번호': partner.phone,
|
|
'주소': partner.address,
|
|
'사업자등록번호': partner.biz_no,
|
|
'상호': partner.company_name,
|
|
'포지션': partner.position,
|
|
'직책': partner.position,
|
|
'역할': partner.position,
|
|
};
|
|
|
|
setMetadata(prev => {
|
|
const updated = { ...prev };
|
|
templateVars.forEach(v => {
|
|
// key 매칭 우선
|
|
if (keyMap[v.key] !== undefined && keyMap[v.key]) {
|
|
updated[v.key] = keyMap[v.key];
|
|
return;
|
|
}
|
|
// label fallback
|
|
if (labelMap[v.label] !== undefined && labelMap[v.label]) {
|
|
updated[v.key] = labelMap[v.label];
|
|
}
|
|
});
|
|
return updated;
|
|
});
|
|
|
|
// 상대방 서명자 정보도 자동 채우기
|
|
setForm(f => ({
|
|
...f,
|
|
counterpart_name: partner.name || f.counterpart_name,
|
|
counterpart_email: partner.email || f.counterpart_email,
|
|
counterpart_phone: partner.phone || f.counterpart_phone,
|
|
}));
|
|
};
|
|
|
|
// Step 1 유효성 검사
|
|
const validateStep1 = () => {
|
|
const newErrors = {};
|
|
if (!form.title.trim()) newErrors.title = '계약 제목을 입력해주세요.';
|
|
if (!form.creator_name.trim()) newErrors.creator_name = '작성자 이름을 입력해주세요.';
|
|
if (!form.creator_email.trim()) newErrors.creator_email = '작성자 이메일을 입력해주세요.';
|
|
if (!form.counterpart_name.trim()) newErrors.counterpart_name = '상대방 이름을 입력해주세요.';
|
|
if (!form.counterpart_email.trim()) newErrors.counterpart_email = '상대방 이메일을 입력해주세요.';
|
|
setErrors(newErrors);
|
|
return Object.keys(newErrors).length === 0;
|
|
};
|
|
|
|
// 다음 단계
|
|
const goNext = () => {
|
|
if (step === 1) {
|
|
if (!validateStep1()) return;
|
|
if (hasTemplates) { setStep(2); } else { handleSubmit(); }
|
|
} else if (step === 2) {
|
|
applyTitleDefaults(form.title);
|
|
// 상대방 이름/전화번호를 계약 변수에 자동 입력
|
|
setMetadata(prev => {
|
|
const updated = { ...prev };
|
|
const labelMap = {
|
|
'파트너명': form.counterpart_name,
|
|
'전화번호': form.counterpart_phone,
|
|
};
|
|
templateVars.forEach(v => {
|
|
if (labelMap[v.label] !== undefined && labelMap[v.label] && !updated[v.key]?.trim()) {
|
|
updated[v.key] = labelMap[v.label];
|
|
}
|
|
});
|
|
return updated;
|
|
});
|
|
setStep(3);
|
|
}
|
|
};
|
|
|
|
// 계약 생성 제출
|
|
const handleSubmit = async (goToSend = false) => {
|
|
setSubmitting(true);
|
|
setErrors({});
|
|
|
|
const fd = new FormData();
|
|
Object.entries(form).forEach(([k, v]) => { if (v) fd.append(k, v); });
|
|
if (file) fd.append('file', file);
|
|
if (templateId) fd.append('template_id', templateId);
|
|
if (Object.keys(metadata).length > 0) {
|
|
Object.entries(metadata).forEach(([k, v]) => { fd.append(`metadata[${k}]`, v || ''); });
|
|
}
|
|
|
|
try {
|
|
fd.append('_token', csrfToken);
|
|
fd.append('signers[0][name]', form.creator_name);
|
|
fd.append('signers[0][email]', form.creator_email);
|
|
fd.append('signers[0][phone]', form.creator_phone || '');
|
|
fd.append('signers[0][role]', 'creator');
|
|
fd.append('signers[1][name]', form.counterpart_name);
|
|
fd.append('signers[1][email]', form.counterpart_email);
|
|
fd.append('signers[1][phone]', form.counterpart_phone || '');
|
|
fd.append('signers[1][role]', 'counterpart');
|
|
|
|
const res = await fetch('/esign/contracts/store', {
|
|
method: 'POST',
|
|
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken },
|
|
body: fd,
|
|
});
|
|
const json = await res.json();
|
|
if (json.success) {
|
|
if (goToSend && json.auto_applied) {
|
|
location.href = `/esign/${json.data.id}/send`;
|
|
} else {
|
|
location.href = `/esign/${json.data.id}/fields`;
|
|
}
|
|
} else {
|
|
setErrors(json.errors || { general: json.message });
|
|
if (json.errors) setStep(1); // 서버 유효성 에러 → step 1으로
|
|
}
|
|
} catch (e) {
|
|
setErrors({ general: '서버 오류가 발생했습니다.' });
|
|
}
|
|
setSubmitting(false);
|
|
};
|
|
|
|
// ─── Step 1: 계약 정보 + 서명자 ───
|
|
const renderStep1 = () => (
|
|
<div className="space-y-4">
|
|
{/* 계약 정보 */}
|
|
<div className="bg-white rounded-lg border p-4">
|
|
<h2 className="text-sm font-semibold text-gray-900 mb-3">계약 정보</h2>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-700 mb-1">계약 제목 <span className="text-red-500">*</span></label>
|
|
<select value={titleType} onChange={e => {
|
|
const val = e.target.value;
|
|
setTitleType(val);
|
|
if (val !== '__custom__') {
|
|
handleChange('title', val);
|
|
applyTitleDefaults(val);
|
|
} else {
|
|
handleChange('title', '');
|
|
}
|
|
}}
|
|
className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none mb-1">
|
|
{TITLE_PRESETS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
|
|
</select>
|
|
{titleType === '__custom__' && (
|
|
<input type="text" value={form.title} onChange={e => handleChange('title', e.target.value)}
|
|
placeholder="계약 제목을 직접 입력하세요"
|
|
className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors mt-1" />
|
|
)}
|
|
{errors.title && <p className="text-red-500 text-xs mt-1">{errors.title}</p>}
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-700 mb-1">설명</label>
|
|
<textarea value={form.description} onChange={e => handleChange('description', e.target.value)}
|
|
placeholder="계약에 대한 간단한 설명 (선택)" rows={2}
|
|
className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-700 mb-1">계약 파일 <span className="text-gray-400 font-normal">(선택 - 나중에 업로드 가능)</span></label>
|
|
<input ref={fileRef} type="file" accept=".pdf,.doc,.docx" onChange={handleFileSelect}
|
|
className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm file:mr-3 file:py-0.5 file:px-2.5 file:rounded file:border-0 file:bg-blue-50 file:text-blue-700 file:text-xs file:font-medium file:cursor-pointer" />
|
|
{file && <p className="text-xs text-gray-500 mt-1">{file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)</p>}
|
|
</div>
|
|
<div className="flex gap-3" style={{ flexWrap: 'wrap' }}>
|
|
<div style={{ flex: '1 1 200px' }}>
|
|
<label className="block text-xs font-medium text-gray-700 mb-1">서명 순서</label>
|
|
<select value={form.sign_order_type} onChange={e => handleChange('sign_order_type', e.target.value)}
|
|
className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
|
|
<option value="counterpart_first">상대방 먼저 서명</option>
|
|
<option value="creator_first">작성자 먼저 서명</option>
|
|
</select>
|
|
</div>
|
|
<div style={{ flex: '1 1 200px' }}>
|
|
<Input label="만료일" name="expires_at" value={form.expires_at} error={errors.expires_at} onChange={handleChange} type="datetime-local" />
|
|
<p className="text-[11px] text-gray-400 mt-1">기본값: 오늘로부터 7일 후</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 서명자 정보 */}
|
|
<div className="bg-white rounded-lg border p-4">
|
|
<h2 className="text-sm font-semibold text-gray-900 mb-3">서명자 정보</h2>
|
|
<div className="space-y-4">
|
|
<SignerRow prefix="creator" title="작성자" subtitle="나" color="#3B82F6"
|
|
form={form} errors={errors} onChange={handleChange} />
|
|
|
|
{/* 법인도장 */}
|
|
<div className={`rounded-lg p-3 border ${registeredStamp ? 'bg-blue-50 border-blue-200' : 'bg-gray-50 border-gray-200'}`}>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={registeredStamp ? '#3B82F6' : '#9CA3AF'} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="12" cy="12" r="4"/>
|
|
</svg>
|
|
<span className={`text-xs font-semibold ${registeredStamp ? 'text-blue-800' : 'text-gray-600'}`}>법인도장</span>
|
|
<span className="text-[11px] text-gray-400">발송 시 작성자 서명을 자동 처리합니다</span>
|
|
</div>
|
|
{registeredStamp ? (
|
|
<div className="flex items-center gap-3">
|
|
<img src={registeredStamp.image_url} alt="법인도장" className="h-16 w-16 object-contain border border-blue-300 rounded bg-white p-1" />
|
|
<div className="flex-1">
|
|
<p className="text-xs text-blue-700">등록된 법인도장이 자동 적용됩니다.</p>
|
|
<a href="/esign#settings" className="text-[11px] text-blue-500 hover:underline">대시보드에서 변경</a>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<p className="text-xs text-gray-500 mb-1">등록된 법인도장이 없습니다.</p>
|
|
<a href="/esign#settings" className="text-xs text-blue-600 hover:underline">대시보드에서 법인도장을 먼저 등록해 주세요</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t"></div>
|
|
<SignerRow prefix="counterpart" title="상대방" subtitle="서명 요청 대상" color="#EF4444"
|
|
form={form} errors={errors} onChange={handleChange} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* 네비게이션 */}
|
|
<div className="flex justify-between">
|
|
<a href="/esign" className="px-4 py-1.5 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 text-sm transition-colors" hx-boost="false">취소</a>
|
|
<button type="button" onClick={goNext}
|
|
className="px-5 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium transition-colors">
|
|
{hasTemplates ? '다음 \u2192' : '계약 생성 및 서명 위치 설정'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// ─── Step 2: 템플릿 선택 & 필드 미리보기 ───
|
|
const renderStep2 = () => (
|
|
<div className="space-y-4">
|
|
{/* 템플릿 선택 */}
|
|
<div className="bg-white rounded-lg border p-4">
|
|
<h2 className="text-sm font-semibold text-gray-900 mb-1">필드 템플릿 선택</h2>
|
|
<p className="text-xs text-gray-400 mb-3">템플릿을 선택하면 계약 생성 시 서명/텍스트 필드가 자동으로 배치됩니다</p>
|
|
|
|
<div className="flex gap-2 mb-3">
|
|
<select value={templateCategory} onChange={e => setTemplateCategory(e.target.value)}
|
|
className="border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
|
|
<option value="">전체 카테고리</option>
|
|
{[...new Set(templates.map(t => t.category).filter(Boolean))].sort().map(c =>
|
|
<option key={c} value={c}>{c}</option>
|
|
)}
|
|
</select>
|
|
<input type="text" value={templateSearch} onChange={e => setTemplateSearch(e.target.value)}
|
|
placeholder="검색..." className="border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none flex-1" />
|
|
</div>
|
|
|
|
<div className="space-y-1.5 max-h-[240px] overflow-y-auto">
|
|
<label className={`flex items-center gap-3 p-2.5 rounded-lg border cursor-pointer transition-colors ${!templateId ? 'border-blue-400 bg-blue-50' : 'hover:bg-gray-50'}`}>
|
|
<input type="radio" name="template" checked={!templateId} onChange={() => handleTemplateSelect('')}
|
|
className="text-blue-600 focus:ring-blue-500" />
|
|
<div>
|
|
<span className="text-sm text-gray-700">없음</span>
|
|
<span className="text-xs text-gray-400 ml-1">(필드 에디터에서 수동 배치)</span>
|
|
</div>
|
|
</label>
|
|
{templates
|
|
.filter(t => !templateCategory || t.category === templateCategory)
|
|
.filter(t => !templateSearch || t.name.toLowerCase().includes(templateSearch.toLowerCase()))
|
|
.map(t => (
|
|
<label key={t.id}
|
|
className={`flex items-center gap-3 p-2.5 rounded-lg border cursor-pointer transition-colors ${templateId == t.id ? 'border-blue-400 bg-blue-50' : 'hover:bg-gray-50'}`}>
|
|
<input type="radio" name="template" checked={templateId == t.id} onChange={() => handleTemplateSelect(t.id)}
|
|
className="text-blue-600 focus:ring-blue-500" />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-sm text-gray-800">{t.name}</span>
|
|
{t.file_path && <span className="text-[11px] px-1.5 py-0.5 bg-blue-100 text-blue-600 rounded font-medium">PDF</span>}
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
{t.category && <span className="text-[11px] px-1.5 py-0.5 bg-gray-100 text-gray-500 rounded">{t.category}</span>}
|
|
<span className="text-[11px] text-gray-400">필드 {t.items_count ?? t.items?.length ?? 0}개</span>
|
|
<span className="text-[11px] text-gray-400">서명자 {t.signer_count}명</span>
|
|
</div>
|
|
{t.description && <p className="text-[11px] text-gray-400 mt-0.5 truncate">{t.description}</p>}
|
|
</div>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* PDF 미리보기 + 필드 목록 */}
|
|
{templateId && (
|
|
<div className="bg-white rounded-lg border p-4">
|
|
<h2 className="text-sm font-semibold text-gray-900 mb-1">필드 배치 미리보기</h2>
|
|
<p className="text-xs text-gray-400 mb-2">
|
|
<span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-blue-500"></span> 작성자</span>
|
|
<span className="mx-2">|</span>
|
|
<span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-red-500"></span> 상대방</span>
|
|
</p>
|
|
|
|
{/* PDF 프리뷰 (필드 오버레이 포함) */}
|
|
<PdfFieldPreview
|
|
templateId={templateId}
|
|
items={templateItems}
|
|
hasPdf={!!selectedTemplate?.file_path}
|
|
/>
|
|
|
|
{/* 필드 목록 테이블 */}
|
|
<FieldItemList items={templateItems} />
|
|
|
|
{/* 파일 안내 */}
|
|
{!file && selectedTemplate?.file_path && (
|
|
<div className="mt-3 px-3 py-2 bg-blue-50 border border-blue-200 rounded-lg">
|
|
<p className="text-xs text-blue-700">이 템플릿에 포함된 PDF가 계약 파일로 자동 사용됩니다.</p>
|
|
<p className="text-[11px] text-blue-500 mt-0.5">Step 1에서 별도 파일을 업로드하면 해당 파일이 우선 적용됩니다.</p>
|
|
</div>
|
|
)}
|
|
{!file && !selectedTemplate?.file_path && (
|
|
<div className="mt-3 px-3 py-2 bg-amber-50 border border-amber-200 rounded-lg">
|
|
<p className="text-xs text-amber-700">이 템플릿에 PDF가 포함되어 있지 않습니다. 계약 파일은 나중에 필드 에디터에서 업로드할 수 있습니다.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 네비게이션 */}
|
|
<div className="flex justify-between">
|
|
<button type="button" onClick={() => setStep(1)}
|
|
className="px-4 py-1.5 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 text-sm transition-colors">
|
|
← 이전
|
|
</button>
|
|
<button type="button" onClick={goNext}
|
|
className="px-5 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium transition-colors">
|
|
다음 →
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// ─── Step 3: 확인 & 생성 ───
|
|
const renderStep3 = () => (
|
|
<div className="space-y-4">
|
|
{/* 요약 */}
|
|
<div className="bg-white rounded-lg border p-4">
|
|
<h2 className="text-sm font-semibold text-gray-900 mb-3">계약 요약</h2>
|
|
<div className="space-y-2 text-xs">
|
|
<div className="flex justify-between py-1.5 border-b border-gray-100">
|
|
<span className="text-gray-500">계약 제목</span>
|
|
<span className="text-gray-800 font-medium">{form.title}</span>
|
|
</div>
|
|
<div className="flex justify-between py-1.5 border-b border-gray-100">
|
|
<span className="text-gray-500">작성자</span>
|
|
<span className="text-gray-800">{form.creator_name} ({form.creator_email})</span>
|
|
</div>
|
|
<div className="flex justify-between py-1.5 border-b border-gray-100">
|
|
<span className="text-gray-500">상대방</span>
|
|
<span className="text-gray-800">{form.counterpart_name} ({form.counterpart_email})</span>
|
|
</div>
|
|
<div className="flex justify-between py-1.5 border-b border-gray-100">
|
|
<span className="text-gray-500">서명 순서</span>
|
|
<span className="text-gray-800">{form.sign_order_type === 'counterpart_first' ? '상대방 먼저' : '작성자 먼저'}</span>
|
|
</div>
|
|
<div className="flex justify-between py-1.5 border-b border-gray-100">
|
|
<span className="text-gray-500">계약 파일</span>
|
|
<span className="text-gray-800">
|
|
{file ? file.name : (selectedTemplate?.file_path ? `${selectedTemplate.name} (템플릿 PDF)` : '없음 (나중에 업로드)')}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between py-1.5">
|
|
<span className="text-gray-500">필드 템플릿</span>
|
|
<span className="text-gray-800">
|
|
{selectedTemplate
|
|
? <span>{selectedTemplate.name} <span className="text-gray-400">({templateItems.length}개 필드)</span></span>
|
|
: <span className="text-gray-400">없음 (수동 배치)</span>}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 변수 입력 (템플릿 선택 시) */}
|
|
{templateId && (
|
|
<div className="bg-amber-50 rounded-lg border border-amber-200 p-4">
|
|
<h2 className="text-sm font-semibold text-amber-800 mb-1">계약 변수 입력</h2>
|
|
<p className="text-xs text-amber-600 mb-3">입력한 값이 PDF 필드에 자동으로 채워집니다.</p>
|
|
|
|
{/* 시스템 변수 (자동) */}
|
|
<div className="mb-3">
|
|
<div className="text-[11px] font-medium text-gray-500 mb-1.5">자동 입력 항목</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-50 border border-green-200 rounded text-[11px] text-green-700">
|
|
<span className="text-green-500">✓</span> 작성자명: {form.creator_name}
|
|
</span>
|
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-50 border border-green-200 rounded text-[11px] text-green-700">
|
|
<span className="text-green-500">✓</span> 상대방명: {form.counterpart_name}
|
|
</span>
|
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-50 border border-green-200 rounded text-[11px] text-green-700">
|
|
<span className="text-green-500">✓</span> 계약일: {new Date().toLocaleDateString('ko-KR')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 커스텀 변수 (직접 입력) */}
|
|
{templateVars.length > 0 && (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="text-[11px] font-medium text-gray-500">직접 입력 항목</div>
|
|
<button type="button" onClick={fillRandomVariables}
|
|
className="p-1 bg-amber-500 hover:bg-amber-600 text-white rounded transition-colors"
|
|
title="주소/사업자등록번호/상호 랜덤 입력 (개발용)">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
{isCustomerContract ? (
|
|
<button type="button" onClick={() => setTenantModalOpen(true)}
|
|
className="inline-flex items-center gap-1 px-2.5 py-1 bg-white border border-green-300 text-green-700 rounded-md text-xs font-medium hover:bg-green-50 transition-colors">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
</svg>
|
|
고객 불러오기
|
|
</button>
|
|
) : (
|
|
<button type="button" onClick={() => setPartnerModalOpen(true)}
|
|
className="inline-flex items-center gap-1 px-2.5 py-1 bg-white border border-amber-300 text-amber-700 rounded-md text-xs font-medium hover:bg-amber-50 transition-colors">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
</svg>
|
|
영업파트너 불러오기
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="space-y-3">
|
|
{templateVars.map(v => (
|
|
<Input key={v.key} label={v.label} name={`meta_${v.key}`}
|
|
value={metadata[v.key] || ''} error={errors[`metadata.${v.key}`]}
|
|
onChange={(_, val) => setMetadata(m => ({...m, [v.key]: val}))}
|
|
placeholder={v.default || `${v.label} 입력`} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 템플릿 미선택 안내 */}
|
|
{!templateId && (
|
|
<div className="bg-gray-50 rounded-lg border border-gray-200 p-4">
|
|
<p className="text-xs text-gray-500">템플릿 없이 생성합니다. 계약 생성 후 필드 에디터에서 서명 위치를 수동으로 배치하세요.</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 네비게이션 */}
|
|
<div className="flex justify-between items-center">
|
|
<button type="button" onClick={() => setStep(2)}
|
|
className="px-4 py-1.5 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 text-sm transition-colors">
|
|
← 이전
|
|
</button>
|
|
<div className="flex gap-3">
|
|
{templateId && (
|
|
<button type="button" disabled={submitting}
|
|
onClick={() => handleSubmit(true)}
|
|
className="px-4 py-1.5 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm font-medium disabled:opacity-50 transition-colors">
|
|
{submitting ? '생성 중...' : '계약 생성 및 바로 발송'}
|
|
</button>
|
|
)}
|
|
<button type="button" disabled={submitting}
|
|
onClick={() => handleSubmit(false)}
|
|
className="px-4 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium disabled:opacity-50 transition-colors">
|
|
{submitting ? '생성 중...' : templateId ? '계약 생성 및 필드 확인' : '계약 생성 및 서명 위치 설정'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="p-4 sm:p-6 max-w-3xl mx-auto">
|
|
<PartnerSearchModal open={partnerModalOpen} onClose={() => setPartnerModalOpen(false)} onSelect={handlePartnerSelect} />
|
|
<TenantSearchModal open={tenantModalOpen} onClose={() => setTenantModalOpen(false)} onSelect={handleTenantSelect} />
|
|
{/* 헤더 */}
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<a href="/esign" className="text-gray-400 hover:text-gray-600 text-lg" hx-boost="false">←</a>
|
|
<h1 className="text-xl font-bold text-gray-900">새 계약 생성</h1>
|
|
{IS_ADMIN && (
|
|
<button type="button" onClick={fillRandomCounterpart} title="랜덤 상대방 정보 채우기"
|
|
className="w-8 h-8 flex items-center justify-center rounded-lg text-amber-500 hover:bg-amber-50 hover:text-amber-600 transition-colors">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 스텝 인디케이터 (템플릿 있을 때만) */}
|
|
{hasTemplates && <StepIndicator currentStep={step} onStepClick={setStep} />}
|
|
|
|
{/* 에러 */}
|
|
{errors.general && <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-4 text-sm">{errors.general}</div>}
|
|
|
|
{/* 위자드 본문 */}
|
|
{step === 1 && renderStep1()}
|
|
{step === 2 && renderStep2()}
|
|
{step === 3 && renderStep3()}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
ReactDOM.createRoot(document.getElementById('esign-create-root')).render(<App />);
|
|
</script>
|
|
@endverbatim
|
|
@endpush
|