- 발송 UI에 서명 요청 + 완료 알림톡 템플릿 각각 선택 가능 - 선택한 완료 템플릿명을 DB에 저장하여 서명 완료 시 사용 - 버튼 URL 도메인을 현재 환경의 app.url로 자동 치환 (개발/운영 환경 대응)
338 lines
18 KiB
PHP
338 lines
18 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', 'SAM E-Sign - 서명 요청 발송')
|
|
|
|
@section('content')
|
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
<div id="esign-send-root" data-contract-id="{{ $contractId }}"></div>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
@include('partials.react-cdn')
|
|
@verbatim
|
|
<script type="text/babel">
|
|
const { useState, useEffect, useCallback } = React;
|
|
|
|
const CONTRACT_ID = document.getElementById('esign-send-root')?.dataset.contractId;
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
|
|
const getHeaders = () => ({
|
|
'Accept': 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken,
|
|
});
|
|
|
|
const SEND_METHODS = [
|
|
{ value: 'email', label: '이메일', desc: '수신자 이메일로 서명 요청 링크 발송', icon: '✉', recommended: true },
|
|
{ value: 'alimtalk', label: '카카오톡 알림톡', desc: '수신자 휴대폰으로 알림톡 발송 (카카오 채널 필요, 실패 시 이메일 자동 폴백)', icon: '💬' },
|
|
{ value: 'both', label: '이메일 + 알림톡 (동시)', desc: '두 채널 모두 발송하여 확인율 극대화', icon: '📡' },
|
|
];
|
|
|
|
const App = () => {
|
|
const [contract, setContract] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [sending, setSending] = useState(false);
|
|
const [sendMethod, setSendMethod] = useState('email');
|
|
const [smsFallback, setSmsFallback] = useState(true);
|
|
const [templates, setTemplates] = useState([]);
|
|
const [selectedTemplate, setSelectedTemplate] = useState('');
|
|
const [templateLoading, setTemplateLoading] = useState(false);
|
|
const [templateError, setTemplateError] = useState('');
|
|
const [templatePreview, setTemplatePreview] = useState(null);
|
|
const [selectedCompletionTemplate, setSelectedCompletionTemplate] = useState('');
|
|
const [completionTemplatePreview, setCompletionTemplatePreview] = useState(null);
|
|
|
|
const fetchContract = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`/esign/contracts/${CONTRACT_ID}`, { headers: getHeaders() });
|
|
const json = await res.json();
|
|
if (json.success) setContract(json.data);
|
|
} catch (e) { console.error(e); }
|
|
setLoading(false);
|
|
}, []);
|
|
|
|
useEffect(() => { fetchContract(); }, [fetchContract]);
|
|
|
|
const fetchTemplates = useCallback(async () => {
|
|
setTemplateLoading(true);
|
|
setTemplateError('');
|
|
setTemplates([]);
|
|
setSelectedTemplate('');
|
|
setTemplatePreview(null);
|
|
setSelectedCompletionTemplate('');
|
|
setCompletionTemplatePreview(null);
|
|
try {
|
|
const res = await fetch('/esign/contracts/alimtalk-templates', { headers: getHeaders() });
|
|
const json = await res.json();
|
|
if (json.success && json.data?.templates?.length > 0) {
|
|
setTemplates(json.data.templates);
|
|
if (json.data.templates.length === 1) {
|
|
setSelectedTemplate(json.data.templates[0].name);
|
|
setTemplatePreview(json.data.templates[0]);
|
|
}
|
|
} else {
|
|
setTemplateError(json.message || '승인된 알림톡 템플릿이 없습니다.');
|
|
}
|
|
} catch (e) {
|
|
setTemplateError('템플릿 목록 조회 중 오류가 발생했습니다.');
|
|
}
|
|
setTemplateLoading(false);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (sendMethod === 'alimtalk' || sendMethod === 'both') {
|
|
if (templates.length === 0 && !templateLoading) fetchTemplates();
|
|
}
|
|
}, [sendMethod]);
|
|
|
|
const handleSend = async () => {
|
|
const methodLabel = SEND_METHODS.find(m => m.value === sendMethod)?.label || sendMethod;
|
|
if (!window.confirm(`서명 요청을 발송하시겠습니까?\n발송 방식: ${methodLabel}`)) return;
|
|
setSending(true);
|
|
try {
|
|
const res = await fetch(`/esign/contracts/${CONTRACT_ID}/send`, {
|
|
method: 'POST', headers: getHeaders(),
|
|
body: JSON.stringify({
|
|
send_method: sendMethod,
|
|
sms_fallback: smsFallback,
|
|
template_name: (sendMethod === 'alimtalk' || sendMethod === 'both') ? selectedTemplate : undefined,
|
|
completion_template_name: (sendMethod === 'alimtalk' || sendMethod === 'both') ? selectedCompletionTemplate : undefined,
|
|
}),
|
|
});
|
|
const json = await res.json();
|
|
if (json.success) {
|
|
// 알림 실패 여부 확인
|
|
const results = json.notification_results || [];
|
|
const failures = [];
|
|
for (const nr of results) {
|
|
for (const r of (nr.results || [])) {
|
|
if (!r.success) {
|
|
failures.push(`${nr.signer_name}: ${r.channel === 'alimtalk' ? '알림톡' : '이메일'} 실패 (${r.error})`);
|
|
}
|
|
}
|
|
}
|
|
if (failures.length > 0) {
|
|
alert(`서명 요청이 발송되었으나 일부 알림이 실패했습니다:\n\n${failures.join('\n')}\n\n상세 페이지에서 확인해 주세요.`);
|
|
} else {
|
|
alert('서명 요청이 발송되었습니다.');
|
|
}
|
|
location.href = `/esign/${CONTRACT_ID}`;
|
|
} else {
|
|
alert(json.message || '발송에 실패했습니다.');
|
|
}
|
|
} catch (e) { alert('서버 오류가 발생했습니다.'); }
|
|
setSending(false);
|
|
};
|
|
|
|
if (loading) return <div className="p-6 text-center text-gray-400">로딩 중...</div>;
|
|
if (!contract) return <div className="p-6 text-center text-red-500">계약을 찾을 수 없습니다.</div>;
|
|
|
|
const signers = contract.signers || [];
|
|
const fieldsCount = contract.sign_fields?.length || 0;
|
|
const needsAlimtalk = sendMethod === 'alimtalk' || sendMethod === 'both';
|
|
const needsEmail = sendMethod === 'email' || sendMethod === 'both';
|
|
const missingPhoneSigners = signers.filter(s => !s.phone);
|
|
const missingEmailSigners = signers.filter(s => !s.email);
|
|
|
|
return (
|
|
<div className="p-4 sm:p-6 max-w-3xl mx-auto">
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<a href={`/esign/${CONTRACT_ID}`} className="text-gray-400 hover:text-gray-600" hx-boost="false">←</a>
|
|
<h1 className="text-2xl font-bold text-gray-900">서명 요청 발송</h1>
|
|
</div>
|
|
|
|
{/* 발송 전 확인 */}
|
|
<div className="bg-white rounded-lg border p-6 mb-4">
|
|
<h2 className="text-lg font-semibold mb-4">발송 전 확인</h2>
|
|
<div className="space-y-3">
|
|
<CheckItem ok={!!contract.title} label={<>계약 제목: <strong>{contract.title}</strong></>} />
|
|
<CheckItem ok={!!contract.original_file_name} label={<>PDF 파일: <strong>{contract.original_file_name || '미업로드'}</strong></>} />
|
|
<CheckItem ok={fieldsCount > 0} label={<>서명 필드: <strong>{fieldsCount}개 설정됨</strong></>} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* 발송 방식 */}
|
|
<div className="bg-white rounded-lg border p-6 mb-4">
|
|
<h2 className="text-lg font-semibold mb-4">발송 방식</h2>
|
|
<div className="space-y-3">
|
|
{SEND_METHODS.map(m => (
|
|
<label key={m.value}
|
|
className={`flex items-start gap-3 p-3 rounded-lg border-2 cursor-pointer transition ${sendMethod === m.value ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'}`}>
|
|
<input type="radio" name="send_method" value={m.value}
|
|
checked={sendMethod === m.value}
|
|
onChange={() => setSendMethod(m.value)}
|
|
className="mt-1 text-blue-600 focus:ring-blue-500" />
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-gray-900">{m.icon} {m.label}</span>
|
|
{m.recommended && <span className="px-1.5 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-700 rounded">권장</span>}
|
|
</div>
|
|
<p className="text-xs text-gray-500 mt-0.5">{m.desc}</p>
|
|
</div>
|
|
</label>
|
|
))}
|
|
</div>
|
|
|
|
{/* SMS 대체발송 */}
|
|
{needsAlimtalk && (
|
|
<label className="flex items-center gap-2 mt-4 p-3 bg-gray-50 rounded-lg cursor-pointer">
|
|
<input type="checkbox" checked={smsFallback} onChange={e => setSmsFallback(e.target.checked)}
|
|
className="rounded text-blue-600 focus:ring-blue-500" />
|
|
<span className="text-sm text-gray-700">SMS 대체발송 사용</span>
|
|
<span className="text-xs text-gray-400">(카카오톡 미사용자에게 SMS로 자동 전환)</span>
|
|
</label>
|
|
)}
|
|
|
|
{/* 서명 요청 알림톡 템플릿 선택 */}
|
|
{needsAlimtalk && (
|
|
<div className="mt-4 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">서명 요청 알림톡 템플릿</label>
|
|
{templateLoading ? (
|
|
<div className="text-sm text-gray-400 py-2">템플릿 목록 조회 중...</div>
|
|
) : templateError ? (
|
|
<div className="text-sm text-red-500 py-2">
|
|
{templateError}
|
|
<button onClick={fetchTemplates} className="ml-2 text-blue-500 hover:underline text-xs">다시 시도</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<select
|
|
value={selectedTemplate}
|
|
onChange={e => {
|
|
setSelectedTemplate(e.target.value);
|
|
const tpl = templates.find(t => t.name === e.target.value);
|
|
setTemplatePreview(tpl || null);
|
|
}}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-blue-500 focus:border-blue-500"
|
|
>
|
|
<option value="">-- 템플릿을 선택하세요 --</option>
|
|
{templates.map(t => (
|
|
<option key={t.name} value={t.name}>{t.name}</option>
|
|
))}
|
|
</select>
|
|
|
|
{templatePreview && (
|
|
<div className="mt-3 p-3 bg-white border border-gray-200 rounded-lg">
|
|
<p className="text-xs font-medium text-gray-500 mb-2">미리보기</p>
|
|
<pre className="text-xs text-gray-700 whitespace-pre-wrap leading-relaxed">{templatePreview.content}</pre>
|
|
{templatePreview.buttons?.length > 0 && (
|
|
<div className="mt-2 pt-2 border-t border-gray-100">
|
|
{templatePreview.buttons.map((btn, i) => (
|
|
<span key={i} className="inline-block px-3 py-1 mr-1 text-xs bg-yellow-100 text-yellow-800 rounded">{btn.Name}</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 완료 알림톡 템플릿 선택 */}
|
|
{needsAlimtalk && !templateLoading && !templateError && templates.length > 0 && (
|
|
<div className="mt-4 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">완료 알림톡 템플릿</label>
|
|
<p className="text-xs text-gray-500 mb-2">모든 서명자 서명 완료 시 발송될 알림톡 템플릿을 선택하세요.</p>
|
|
<select
|
|
value={selectedCompletionTemplate}
|
|
onChange={e => {
|
|
setSelectedCompletionTemplate(e.target.value);
|
|
const tpl = templates.find(t => t.name === e.target.value);
|
|
setCompletionTemplatePreview(tpl || null);
|
|
}}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-blue-500 focus:border-blue-500"
|
|
>
|
|
<option value="">-- 선택 안함 (기본 템플릿 사용) --</option>
|
|
{templates.map(t => (
|
|
<option key={t.name} value={t.name}>{t.name}</option>
|
|
))}
|
|
</select>
|
|
|
|
{completionTemplatePreview && (
|
|
<div className="mt-3 p-3 bg-white border border-gray-200 rounded-lg">
|
|
<p className="text-xs font-medium text-gray-500 mb-2">미리보기</p>
|
|
<pre className="text-xs text-gray-700 whitespace-pre-wrap leading-relaxed">{completionTemplatePreview.content}</pre>
|
|
{completionTemplatePreview.buttons?.length > 0 && (
|
|
<div className="mt-2 pt-2 border-t border-gray-100">
|
|
{completionTemplatePreview.buttons.map((btn, i) => (
|
|
<span key={i} className="inline-block px-3 py-1 mr-1 text-xs bg-yellow-100 text-yellow-800 rounded">{btn.Name}</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 서명자 연락처 확인 */}
|
|
<div className="bg-white rounded-lg border p-6 mb-4">
|
|
<h2 className="text-lg font-semibold mb-4">서명자 연락처</h2>
|
|
<div className="space-y-3">
|
|
{signers.sort((a, b) => a.sign_order - b.sign_order).map((s, i) => (
|
|
<div key={s.id} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
|
|
<span className="w-7 h-7 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-xs font-bold flex-shrink-0 mt-0.5">{s.sign_order || i + 1}</span>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-sm">{s.name} <span className="text-gray-400 text-xs">({s.role === 'creator' ? '작성자' : '상대방'})</span></p>
|
|
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-1">
|
|
<ContactStatus icon="📱" value={s.phone} label="휴대폰" needed={needsAlimtalk} />
|
|
<ContactStatus icon="✉" value={s.email} label="이메일" needed={needsEmail} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 경고 메시지 */}
|
|
{needsAlimtalk && missingPhoneSigners.length > 0 && (
|
|
<div className="mt-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
|
<p className="text-xs text-amber-700">
|
|
<strong>안내:</strong> {missingPhoneSigners.map(s => s.name).join(', ')}님의 휴대폰 번호가 없어 {sendMethod === 'both' ? '알림톡 없이 이메일만' : '이메일로 대체'} 발송됩니다.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 발송 버튼 */}
|
|
<div className="flex justify-end gap-3">
|
|
<a href={`/esign/${CONTRACT_ID}`} className="px-6 py-2 border rounded-lg text-gray-700 hover:bg-gray-50 text-sm" hx-boost="false">돌아가기</a>
|
|
<button onClick={handleSend} disabled={sending || fieldsCount === 0 || (needsAlimtalk && !selectedTemplate)}
|
|
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm font-medium disabled:opacity-50">
|
|
{sending ? '발송 중...' : '서명 요청 발송'}
|
|
</button>
|
|
</div>
|
|
|
|
{fieldsCount === 0 && (
|
|
<p className="text-red-500 text-sm mt-3 text-right">서명 필드를 먼저 설정해 주세요.</p>
|
|
)}
|
|
{needsAlimtalk && !selectedTemplate && fieldsCount > 0 && (
|
|
<p className="text-amber-600 text-sm mt-3 text-right">알림톡 템플릿을 선택해 주세요.</p>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const CheckItem = ({ ok, label }) => (
|
|
<div className="flex items-center gap-3">
|
|
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs text-white ${ok ? 'bg-green-500' : 'bg-red-500'}`}>
|
|
{ok ? '✓' : '!'}
|
|
</span>
|
|
<span className="text-sm">{label}</span>
|
|
</div>
|
|
);
|
|
|
|
const ContactStatus = ({ icon, value, label, needed }) => {
|
|
if (!value) {
|
|
return needed
|
|
? <span className="text-xs text-amber-600">{icon} {label} 미입력</span>
|
|
: <span className="text-xs text-gray-400">{icon} {label} 미입력</span>;
|
|
}
|
|
return <span className={`text-xs ${needed ? 'text-green-600' : 'text-gray-500'}`}>{icon} {value}</span>;
|
|
};
|
|
|
|
ReactDOM.createRoot(document.getElementById('esign-send-root')).render(<App />);
|
|
</script>
|
|
@endverbatim
|
|
@endpush
|