353 lines
22 KiB
PHP
353 lines
22 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>
|
|
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 } = React;
|
|
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
const IS_ADMIN = window.__esignCreate?.isAdmin || false;
|
|
|
|
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>
|
|
);
|
|
|
|
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>
|
|
);
|
|
|
|
const App = () => {
|
|
const [form, setForm] = useState({
|
|
title: '', description: '', sign_order_type: 'counterpart_first', expires_at: '',
|
|
creator_name: '(주) 코드브릿지엑스', creator_email: 'contact@codebridge-x.com', creator_phone: '02-6347-0005',
|
|
counterpart_name: '', counterpart_email: '', counterpart_phone: '',
|
|
});
|
|
const [file, setFile] = useState(null);
|
|
const [registeredStamp, setRegisteredStamp] = useState(null); // { image_path, image_url }
|
|
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 [metadata, setMetadata] = useState({});
|
|
const fileRef = useRef(null);
|
|
|
|
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}));
|
|
|
|
// 파일 선택 시 메모리에 복사 (ERR_UPLOAD_FILE_CHANGED 방지)
|
|
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);
|
|
};
|
|
|
|
const handleTemplateSelect = (id) => {
|
|
setTemplateId(id);
|
|
if (!id) { setMetadata({}); 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({});
|
|
}
|
|
};
|
|
|
|
const selectedTemplate = templateId ? templates.find(t => t.id == templateId) : null;
|
|
const templateVars = selectedTemplate?.variables || [];
|
|
|
|
const fillTestData = () => {
|
|
const pick = arr => arr[Math.floor(Math.random() * arr.length)];
|
|
const lastNames = ['김','이','박','최','정','강','조','윤','장','임'];
|
|
const firstNames = ['민수','서연','지훈','수빈','현우','하은','도윤','예진','준호','소영'];
|
|
const types = ['공급','납품','업무협약','기술이전','유지보수','컨설팅','위탁제조','OEM'];
|
|
const year = new Date().getFullYear();
|
|
const rPhone = () => `010-${String(Math.floor(Math.random()*9000)+1000)}-${String(Math.floor(Math.random()*9000)+1000)}`;
|
|
setForm(f => ({
|
|
...f,
|
|
title: `${year}년 ${pick(types)} 계약서`,
|
|
description: `테스트용 ${pick(types)} 계약 (자동생성)`,
|
|
creator_name: '(주) 코드브릿지엑스',
|
|
creator_email: 'contact@codebridge-x.com',
|
|
creator_phone: '02-6347-0005',
|
|
counterpart_name: pick(lastNames) + pick(firstNames),
|
|
counterpart_email: 'lightone2017@codebridge-x.com',
|
|
counterpart_phone: rPhone(),
|
|
}));
|
|
};
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
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) {
|
|
const url = templateId
|
|
? `/esign/${json.data.id}/fields?template_id=${templateId}`
|
|
: `/esign/${json.data.id}/fields`;
|
|
location.href = url;
|
|
} else {
|
|
setErrors(json.errors || { general: json.message });
|
|
}
|
|
} catch (e) {
|
|
setErrors({ general: '서버 오류가 발생했습니다.' });
|
|
}
|
|
setSubmitting(false);
|
|
};
|
|
|
|
return (
|
|
<div className="p-4 sm:p-6 max-w-3xl mx-auto">
|
|
<div className="flex items-center gap-3 mb-5">
|
|
<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={fillTestData} 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>
|
|
|
|
{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>}
|
|
|
|
<form onSubmit={handleSubmit} 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">
|
|
<Input label="계약 제목" name="title" value={form.title} error={errors.title} onChange={handleChange} required placeholder="예: 2026년 공급 계약서" />
|
|
<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">계약 파일 {!templateId && <span className="text-red-500">*</span>}</label>
|
|
<input ref={fileRef} type="file" accept=".pdf,.doc,.docx" onChange={handleFileSelect} required={!templateId}
|
|
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>}
|
|
{templateId && !file && (() => {
|
|
const selectedTpl = templates.find(t => t.id == templateId);
|
|
if (selectedTpl?.file_path) {
|
|
return <p className="text-xs text-blue-600 mt-1">📄 템플릿에 포함된 PDF가 자동으로 사용됩니다. 별도 파일을 업로드하면 해당 파일이 우선 적용됩니다.</p>;
|
|
}
|
|
return <p className="text-xs text-amber-600 mt-1">템플릿 선택 시 PDF 파일은 선택사항입니다. 나중에 업로드할 수 있습니다.</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" />
|
|
</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>
|
|
|
|
{/* 필드 템플릿 (선택) */}
|
|
{templates.length > 0 && (
|
|
<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-[200px] 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>
|
|
</div>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 커스텀 변수 입력 */}
|
|
{templateVars.length > 0 && (
|
|
<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">선택한 템플릿에서 요구하는 항목입니다. 서명 화면에 자동으로 표시됩니다.</p>
|
|
<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 className="flex justify-end gap-3">
|
|
<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="submit" disabled={submitting}
|
|
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 ? '생성 중...' : '계약 생성 및 서명 위치 설정'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
ReactDOM.createRoot(document.getElementById('esign-create-root')).render(<App />);
|
|
</script>
|
|
@endverbatim
|
|
@endpush
|