Files
sam-manage/resources/views/esign/create.blade.php
김보곤 d729e2c586 feat: [esign] 근로계약서 작성 시 사원 연봉 자동 반영
- 사원검색 API에 연봉 금액 포함
- 사원 선택 시 연봉 총금액/월급여 템플릿 변수 자동 채움
2026-03-11 16:46:56 +09:00

1621 lines
88 KiB
PHP

@extends('layouts.app')
@section('title', isset($contractId) ? 'SAM E-Sign - 계약 수정' : 'SAM E-Sign - 새 계약 생성')
@section('content')
<meta name="csrf-token" content="{{ csrf_token() }}">
<div id="esign-create-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>
<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 EDIT_CONTRACT_ID = document.getElementById('esign-create-root')?.dataset.contractId || '';
const IS_EDIT = !!EDIT_CONTRACT_ID;
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: '근로계약서', 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">&larr;</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">&rarr;</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">&times;</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>
);
};
// ─── EmployeeSearchModal (근로계약서용 사원 검색) ───
const EmployeeSearchModal = ({ 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-employees?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">&times;</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((emp, i) => (
<button key={emp.id} type="button"
onClick={() => { onSelect(emp); 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">{emp.name}</span>
{emp.position && <span className="text-xs text-blue-600 bg-blue-50 px-1.5 py-0.5 rounded">{emp.position}</span>}
{emp.job_title && <span className="text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{emp.job_title}</span>}
</div>
{emp.department && <span className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">{emp.department}</span>}
</div>
<div className="flex items-center gap-3 mt-0.5">
{emp.phone && <span className="text-xs text-gray-400">{emp.phone}</span>}
{emp.email && <span className="text-xs text-gray-400">{emp.email}</span>}
{emp.hire_date && <span className="text-xs text-gray-400">입사: {emp.hire_date}</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">&times;</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 [editLoading, setEditLoading] = useState(IS_EDIT);
const [existingFileName, setExistingFileName] = useState('');
const [editFields, setEditFields] = useState([]);
const [editSigners, setEditSigners] = useState([]);
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(() => {});
// 수정 모드: 기존 계약 데이터 로드
if (IS_EDIT) {
fetch(`/esign/contracts/${EDIT_CONTRACT_ID}`, { headers: { 'Accept': 'application/json' } })
.then(r => r.json())
.then(json => {
if (json.success) {
const c = json.data;
if (c.status !== 'draft') {
alert('초안 상태의 계약만 수정할 수 있습니다.');
location.href = `/esign/${EDIT_CONTRACT_ID}`;
return;
}
const creator = (c.signers || []).find(s => s.role === 'creator') || {};
const counterpart = (c.signers || []).find(s => s.role === 'counterpart') || {};
const expiresAt = c.expires_at ? new Date(c.expires_at) : null;
const expiresStr = expiresAt
? expiresAt.getFullYear() + '-' + String(expiresAt.getMonth()+1).padStart(2,'0') + '-' + String(expiresAt.getDate()).padStart(2,'0') + 'T' + String(expiresAt.getHours()).padStart(2,'0') + ':' + String(expiresAt.getMinutes()).padStart(2,'0')
: '';
setForm({
title: c.title || '',
description: c.description || '',
sign_order_type: c.sign_order_type || 'counterpart_first',
expires_at: expiresStr,
creator_name: creator.name || '',
creator_email: creator.email || '',
creator_phone: creator.phone || '',
counterpart_name: counterpart.name || '',
counterpart_email: counterpart.email || '',
counterpart_phone: counterpart.phone || '',
});
// 제목 타입 판별
const preset = TITLE_PRESETS.find(p => p.value === c.title);
setTitleType(preset ? c.title : '__custom__');
if (c.original_file_name) setExistingFileName(c.original_file_name);
// 필드 및 서명자 정보 로드
if (c.sign_fields?.length) setEditFields(c.sign_fields);
if (c.signers?.length) setEditSigners(c.signers);
}
})
.catch(() => { alert('계약 정보를 불러오지 못했습니다.'); })
.finally(() => setEditLoading(false));
}
}, []);
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 isLaborContract = form.title === '근로계약서';
const [employeeModalOpen, setEmployeeModalOpen] = useState(false);
// 계약번호 자동 채번
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 pick = arr => arr[Math.floor(Math.random() * arr.length)];
const randInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
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 names = ['김민수', '이서연', '박지훈', '최예린', '정우성', '한소희', '강동원', '윤세아'];
const jobTypes = ['생산직', '사무직', '영업직', '기술직', '관리직', '연구직'];
const jobContents = [
'블라인드 제조 및 품질관리', '영업 관리 및 고객 응대',
'생산라인 운영 및 관리', '소프트웨어 개발 및 유지보수',
'경영지원 및 총무업무', '물류 관리 및 배송 업무',
];
const depts = ['생산부', '영업부', '관리부', '개발부', '총무부', '물류부'];
const positions = ['사원', '주임', '대리', '과장', '차장', '부장'];
// 근로계약서용 label 매핑 (부분 매칭)
const laborMap = {
'직원.*주소': pick(addresses),
'직종.*구분': pick(jobTypes),
'업무.*내용': pick(jobContents),
'업무.*기간': `2026.03.11 ~ 2027.03.10`,
'출생.*년도': `${randInt(1970, 2000)}`,
'근무.*시간': `09:00 ~ 18:00`,
'휴게.*시간': `12:00 ~ 13:00`,
'급여': `${pick(['2,200,000', '2,500,000', '2,800,000', '3,000,000', '3,500,000'])}원`,
'급여.*지급일': `매월 ${pick(['10', '15', '25'])}일`,
'부서': pick(depts),
'직책': pick(positions),
'직위': pick(positions),
'계약자.*이름': pick(names),
'연락처': `010-${randInt(1000,9999)}-${randInt(1000,9999)}`,
'전화.*번호': `010-${randInt(1000,9999)}-${randInt(1000,9999)}`,
};
// 기본 label 매핑 (기존 — 정확 매칭)
const baseMap = {
'주소': pick(addresses),
'사업자등록번호': pick(bizNos),
'상호': pick(companies),
'파트너명': pick(names),
};
const isLabor = form.title === '근로계약서';
setMetadata(prev => {
const updated = { ...prev };
templateVars.forEach(v => {
// 1. 정확 매칭
if (baseMap[v.label] !== undefined) {
updated[v.key] = baseMap[v.label];
return;
}
// 2. 근로계약서: 부분 매칭 (정규식)
if (isLabor) {
for (const [pattern, value] of Object.entries(laborMap)) {
if (new RegExp(pattern, 'i').test(v.label)) {
updated[v.key] = value;
return;
}
}
}
// 3. 매칭 안 되면 라벨 기반 스마트 기본값
if (!updated[v.key]) {
updated[v.key] = guessValueByLabel(v.label);
}
});
return updated;
});
};
// 라벨 텍스트를 분석하여 실제 데이터와 유사한 값을 생성
const guessValueByLabel = (label) => {
const pick = arr => arr[Math.floor(Math.random() * arr.length)];
const randInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
const l = label.toLowerCase();
// 이름/성명
if (/이름|성명|대표자|계약자|서명자|사원명/.test(l))
return pick(['김민수','이서연','박지훈','최예린','정우성','한소희','강동원','윤세아']);
// 주소
if (/주소|소재지/.test(l))
return pick(['서울특별시 강남구 테헤란로 123, 4층','경기도 성남시 분당구 판교로 789','서울특별시 마포구 월드컵북로 54길 12','인천광역시 연수구 송도과학로 32']);
// 전화번호/연락처
if (/전화|연락처|핸드폰|휴대폰|번호.*전화|mobile|phone/.test(l))
return `010-${randInt(1000,9999)}-${randInt(1000,9999)}`;
// 이메일
if (/이메일|email|메일/.test(l))
return pick(['test','info','admin','dev']) + '@' + pick(['example.com','test.kr','company.co.kr']);
// 사업자등록번호
if (/사업자.*번호|사업자.*등록/.test(l))
return `${randInt(100,999)}-${randInt(10,99)}-${randInt(10000,99999)}`;
// 주민등록번호/생년월일
if (/주민.*번호|resident/.test(l))
return `${randInt(70,99)}${String(randInt(1,12)).padStart(2,'0')}${String(randInt(1,28)).padStart(2,'0')}-*******`;
if (/생년월일|생년|출생/.test(l))
return `${randInt(1970,2000)}.${randInt(1,12)}.${randInt(1,28)}`;
// 년도
if (/년도|연도/.test(l))
return `${randInt(2024,2027)}`;
// 금액/원/급여/수당/보수
if (/금액|급여|보수|수당|임금|월급|연봉|$/.test(l))
return pick(['2,200,000','2,500,000','2,800,000','3,000,000','3,500,000','4,000,000']) + '원';
// 기간
if (/기간|시작.*종료|계약.*/.test(l))
return `2026.03.11 ~ 2027.03.10`;
// 날짜/일자/일시
if (/날짜|일자|일시|작성일|계약일|시작일|종료일/.test(l))
return `2026.${String(randInt(1,12)).padStart(2,'0')}.${String(randInt(1,28)).padStart(2,'0')}`;
// 시간
if (/시간|시각/.test(l))
return `${String(randInt(8,18)).padStart(2,'0')}:00`;
// 회사/상호/업체
if (/회사|상호|업체|법인|사업장/.test(l))
return pick(['(주)코드브릿지엑스','(주)블루오션','(주)스마트솔루션','(주)넥스트웨이브','(주)그린테크']);
// 부서
if (/부서||소속/.test(l))
return pick(['생산부','영업부','관리부','개발부','총무부','물류부']);
// 직책/직위/직급
if (/직책|직위|직급/.test(l))
return pick(['사원','주임','대리','과장','차장','부장']);
// 직종/업무/업종
if (/직종|업종|업무/.test(l))
return pick(['생산직','사무직','영업직','기술직','관리직']);
// 퍼센트/비율
if (/비율|퍼센트|%|/.test(l))
return `${randInt(5,30)}%`;
// 수량/개수
if (/수량|개수|인원|/.test(l))
return `${randInt(1,50)}`;
// 기타: 라벨 자체를 활용한 짧은 값
return label;
};
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,
}));
};
// 사원 선택 시 근로계약서 변수 자동 채우기
const handleEmployeeSelect = (emp) => {
// 입사일에서 년/월/일 분리
let hireYear = '', hireMonth = '', hireDay = '';
let endYear = '', endMonth = '', endDay = '';
// 연봉 금액 포맷
const annualSalary = emp.annual_salary ? Number(emp.annual_salary).toLocaleString() : '';
const monthlySalary = emp.annual_salary ? Math.round(emp.annual_salary / 12).toLocaleString() : '';
if (emp.hire_date) {
const hd = emp.hire_date.replace(/-/g, '');
if (hd.length >= 8) {
hireYear = hd.substring(0, 4);
hireMonth = hd.substring(4, 6);
hireDay = hd.substring(6, 8);
// 1년 후 계산
const endDate = new Date(parseInt(hireYear), parseInt(hireMonth) - 1, parseInt(hireDay));
endDate.setFullYear(endDate.getFullYear() + 1);
endYear = String(endDate.getFullYear());
endMonth = String(endDate.getMonth() + 1).padStart(2, '0');
endDay = String(endDate.getDate()).padStart(2, '0');
}
}
const labelMap = {
'직원.*주소': emp.address,
'주소': emp.address,
'출생.*년도': emp.birth_year,
'출생.*월$': emp.birth_month,
'출생.*일$': emp.birth_day,
'부서': emp.department,
'직책': emp.position,
'직위': emp.job_title || emp.position,
'연락처': emp.phone,
'전화.*번호': emp.phone,
'이메일': emp.email,
'계약자.*이름': emp.name,
'사원.*이름': emp.name,
'근로자.*이름': emp.name,
'근로자.*성명': emp.name,
'입사.*일': emp.hire_date,
// 연봉계약 종료일 = 입사일 + 1년 (구체적 패턴 먼저 매칭)
'연봉계약.*종료.*년도': endYear,
'연봉계약.*종료.*월$': endMonth,
'연봉계약.*종료.*일$': endDay,
// 근로계약 종료일 = 입사일 + 1년
'근로계약.*종료.*년도': endYear,
'근로계약.*종료.*월$': endMonth,
'근로계약.*종료.*일$': endDay,
// 계약 종료일 = 입사일 + 1년
'계약.*종료.*년도': endYear,
'계약.*종료.*월$': endMonth,
'계약.*종료.*일$': endDay,
// 연봉계약 시작일 = 입사일
'연봉계약.*시작.*년도': hireYear,
'연봉계약.*시작.*월$': hireMonth,
'연봉계약.*시작.*일$': hireDay,
// 근로계약 시작일 = 입사일
'근로계약.*시작.*년도': hireYear,
'근로계약.*시작.*월$': hireMonth,
'근로계약.*시작.*일$': hireDay,
// 계약일 = 입사일 (가장 일반적인 패턴, 마지막에 매칭)
'계약.*연도': hireYear,
'계약.*월$': hireMonth,
'계약.*일$': hireDay,
// 연봉 금액 (사원관리 연봉정보에서 자동 반영)
'연봉.*총.*금액': annualSalary,
'연봉.*금액': annualSalary,
'연봉액': annualSalary,
'연간.*급여': annualSalary,
'연봉$': annualSalary,
'월.*급여': monthlySalary,
'월급': monthlySalary,
};
setMetadata(prev => {
const updated = { ...prev };
templateVars.forEach(v => {
for (const [pattern, value] of Object.entries(labelMap)) {
if (value && new RegExp(pattern, 'i').test(v.label)) {
updated[v.key] = value;
return;
}
}
});
return updated;
});
// 상대방(직원) 서명자 정보 자동 채우기
setForm(f => ({
...f,
counterpart_name: emp.name || f.counterpart_name,
counterpart_email: emp.email || f.counterpart_email,
counterpart_phone: emp.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 (IS_EDIT) { handleSubmit(); 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 (!IS_EDIT && templateId) fd.append('template_id', templateId);
if (!IS_EDIT && 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');
// 수정 모드: 필드 값 전송
if (IS_EDIT && editFields.length > 0) {
editFields.forEach((f, i) => {
fd.append(`fields[${i}][id]`, f.id);
fd.append(`fields[${i}][field_value]`, f.field_value || '');
});
}
let url, method;
if (IS_EDIT) {
url = `/esign/contracts/${EDIT_CONTRACT_ID}`;
method = 'PUT';
// FormData는 PUT을 지원하지 않으므로 _method 사용
fd.append('_method', 'PUT');
} else {
url = '/esign/contracts/store';
method = 'POST';
}
const res = await fetch(url, {
method: 'POST',
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: fd,
});
const json = await res.json();
if (json.success) {
const contractId = IS_EDIT ? EDIT_CONTRACT_ID : json.data.id;
if (IS_EDIT) {
location.href = `/esign/${contractId}`;
} else if (goToSend && json.auto_applied) {
location.href = `/esign/${contractId}/send`;
} else {
location.href = `/esign/${contractId}/fields`;
}
} else {
setErrors(json.errors || { general: json.message });
if (json.errors) setStep(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>}
{!file && IS_EDIT && existingFileName && <p className="text-xs text-gray-500 mt-1">현재 파일: {existingFileName} <span className="text-gray-400">( 파일을 선택하면 교체됩니다)</span></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>
{/* 수정 모드: 서명 필드 값 편집 */}
{IS_EDIT && editFields.length > 0 && (
<div className="bg-white rounded-lg border p-4">
<h2 className="text-sm font-semibold text-gray-900 mb-3">서명 필드 </h2>
<p className="text-xs text-gray-500 mb-3"> 필드의 값을 수정할 있습니다. 서명/도장 필드는 서명 입력됩니다.</p>
<div className="space-y-2">
{editFields.map((field, idx) => {
const signerInfo = editSigners.find(s => s.id === field.signer_id);
const signerLabel = signerInfo ? (signerInfo.role === 'creator' ? '작성자' : '상대방') : '';
const signerColor = signerInfo?.role === 'creator' ? '#3B82F6' : '#EF4444';
const isEditable = ['text', 'date'].includes(field.field_type);
return (
<div key={field.id} className="flex items-center gap-3 p-2 rounded-lg bg-gray-50">
<span className="shrink-0 w-5 h-5 rounded flex items-center justify-center text-white text-[10px] font-bold"
style={{ backgroundColor: signerColor }}>
{FIELD_TYPE_INFO[field.field_type]?.icon || '?'}
</span>
<div className="shrink-0" style={{ width: 90 }}>
<span className="text-[10px] px-1.5 py-0.5 rounded-full text-white font-medium" style={{ backgroundColor: signerColor }}>
{signerLabel}
</span>
</div>
<div className="shrink-0 text-xs text-gray-600" style={{ width: 100 }}>
{field.field_label || FIELD_TYPE_INFO[field.field_type]?.label || field.field_type}
</div>
<div style={{ flex: 1 }}>
{isEditable ? (
<input
type={field.field_type === 'date' ? 'date' : 'text'}
value={field.field_value || ''}
onChange={e => {
const updated = [...editFields];
updated[idx] = { ...updated[idx], field_value: e.target.value };
setEditFields(updated);
}}
placeholder={field.field_variable ? `{{${field.field_variable}}}` : '값 입력'}
className="w-full border border-gray-300 rounded px-2 py-1 text-xs focus:ring-1 focus:ring-blue-500 outline-none"
/>
) : field.field_type === 'checkbox' ? (
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={field.field_value === '1' || field.field_value === 'true'}
onChange={e => {
const updated = [...editFields];
updated[idx] = { ...updated[idx], field_value: e.target.checked ? '1' : '0' };
setEditFields(updated);
}}
className="rounded border-gray-300 text-blue-600"
/>
<span className="text-xs text-gray-500">{field.field_label || '체크'}</span>
</label>
) : (
<span className="text-xs text-gray-400 italic">서명 입력</span>
)}
</div>
<span className="shrink-0 text-[10px] text-gray-400">P{field.page_number}</span>
</div>
);
})}
</div>
</div>
)}
{IS_EDIT && editFields.length === 0 && (
<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">
<a href={IS_EDIT ? `/esign/${EDIT_CONTRACT_ID}` : '/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} disabled={submitting}
className="px-5 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium transition-colors disabled:opacity-50">
{IS_EDIT ? (submitting ? '수정 중...' : '계약 수정') : 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">
&larr; 이전
</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">
다음 &rarr;
</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">&#10003;</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">&#10003;</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">&#10003;</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>
) : isLaborContract ? (
<button type="button" onClick={() => setEmployeeModalOpen(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 bg-white border border-blue-300 text-blue-700 rounded-md text-xs font-medium hover:bg-blue-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">
&larr; 이전
</button>
<div className="flex gap-3">
{!IS_EDIT && 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 ? (IS_EDIT ? '수정 중...' : '생성 중...') : IS_EDIT ? '계약 수정' : 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} />
<EmployeeSearchModal open={employeeModalOpen} onClose={() => setEmployeeModalOpen(false)} onSelect={handleEmployeeSelect} />
{/* 헤더 */}
{editLoading && <div className="p-6 text-center text-gray-400">계약 정보를 불러오는 ...</div>}
{!editLoading && <>
<div className="flex items-center gap-3 mb-2">
<a href={IS_EDIT ? `/esign/${EDIT_CONTRACT_ID}` : '/esign'} className="text-gray-400 hover:text-gray-600 text-lg" hx-boost="false">&larr;</a>
<h1 className="text-xl font-bold text-gray-900">{IS_EDIT ? '계약 수정' : '새 계약 생성'}</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