335 lines
19 KiB
PHP
335 lines
19 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', 'SAM E-Sign - 계약 상세')
|
|
|
|
@section('content')
|
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
<div id="esign-detail-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-detail-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,
|
|
});
|
|
|
|
// UTC ISO 문자열 → KST 로컬 시간 포맷
|
|
const fmtDate = (iso, withTime = true) => {
|
|
if (!iso) return '';
|
|
const d = new Date(iso);
|
|
const pad = n => String(n).padStart(2, '0');
|
|
const date = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
|
|
if (!withTime) return date;
|
|
return `${date} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
};
|
|
|
|
const STATUS_MAP = {
|
|
draft: { label: '초안', color: 'bg-gray-100 text-gray-700' },
|
|
pending: { label: '서명 대기', color: 'bg-blue-100 text-blue-700' },
|
|
partially_signed: { label: '부분 서명', color: 'bg-yellow-100 text-yellow-700' },
|
|
completed: { label: '완료', color: 'bg-green-100 text-green-700' },
|
|
expired: { label: '만료', color: 'bg-red-100 text-red-700' },
|
|
cancelled: { label: '취소', color: 'bg-gray-100 text-gray-500' },
|
|
rejected: { label: '거절', color: 'bg-red-100 text-red-700' },
|
|
};
|
|
|
|
const SIGNER_STATUS = {
|
|
waiting: { label: '대기', color: 'text-gray-500' },
|
|
notified: { label: '알림 발송됨', color: 'text-blue-500' },
|
|
authenticated: { label: '인증 완료', color: 'text-indigo-500' },
|
|
signed: { label: '서명 완료', color: 'text-green-600' },
|
|
rejected: { label: '거절', color: 'text-red-500' },
|
|
};
|
|
|
|
const ACTION_MAP = {
|
|
contract_created: '계약 생성', sign_request_sent: '서명 요청 발송', viewed: '문서 열람',
|
|
otp_sent: 'OTP 발송', authenticated: '본인인증 완료', signed: '서명 완료',
|
|
rejected: '서명 거절', contract_completed: '계약 완료', contract_cancelled: '계약 취소',
|
|
reminded: '리마인더 발송', downloaded: '문서 다운로드',
|
|
completion_notification_sent: '완료 알림 발송',
|
|
// legacy keys
|
|
created: '계약 생성', sent: '서명 요청 발송', completed: '계약 완료', cancelled: '계약 취소',
|
|
};
|
|
|
|
const SEND_METHOD_MAP = {
|
|
alimtalk: { label: '알림톡', color: 'bg-yellow-100 text-yellow-700' },
|
|
email: { label: '이메일', color: 'bg-blue-100 text-blue-700' },
|
|
both: { label: '알림톡+이메일', color: 'bg-purple-100 text-purple-700' },
|
|
};
|
|
|
|
const StatusBadge = ({ status }) => {
|
|
const s = STATUS_MAP[status] || { label: status, color: 'bg-gray-100' };
|
|
return <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${s.color}`}>{s.label}</span>;
|
|
};
|
|
|
|
const App = () => {
|
|
const [contract, setContract] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [actionLoading, setActionLoading] = useState('');
|
|
const [showRemindModal, setShowRemindModal] = useState(false);
|
|
const [remindMethod, setRemindMethod] = useState('email');
|
|
|
|
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 doAction = async (action, confirm_msg, body = {}) => {
|
|
if (confirm_msg && !window.confirm(confirm_msg)) return;
|
|
setActionLoading(action);
|
|
try {
|
|
const res = await fetch(`/esign/contracts/${CONTRACT_ID}/${action}`, {
|
|
method: 'POST',
|
|
headers: { ...getHeaders(), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const json = await res.json();
|
|
if (json.success) fetchContract();
|
|
else alert(json.message || '오류가 발생했습니다.');
|
|
} catch (e) { alert('서버 오류'); }
|
|
setActionLoading('');
|
|
};
|
|
|
|
const handleRemind = () => {
|
|
setShowRemindModal(false);
|
|
doAction('remind', null, { send_method: remindMethod });
|
|
};
|
|
|
|
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 c = contract;
|
|
|
|
// 마지막 발송 로그에서 알림 실패 확인
|
|
const lastSendLog = (c.audit_logs || []).find(l =>
|
|
['sign_request_sent', 'reminded', 'completion_notification_sent'].includes(l.action)
|
|
&& l.metadata?.notification_results
|
|
);
|
|
const notifFailures = [];
|
|
if (lastSendLog?.metadata?.notification_results) {
|
|
for (const nr of lastSendLog.metadata.notification_results) {
|
|
for (const r of (nr.results || [])) {
|
|
if (!r.success) {
|
|
notifFailures.push({ name: nr.signer_name, channel: r.channel, error: r.error });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="p-4 sm:p-6">
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<a href="/esign" className="text-gray-400 hover:text-gray-600" hx-boost="false">←</a>
|
|
<div className="flex-1">
|
|
<h1 className="text-2xl font-bold text-gray-900">{c.title}</h1>
|
|
<p className="text-sm text-gray-500 font-mono">{c.contract_code}</p>
|
|
</div>
|
|
<button onClick={() => { setLoading(true); fetchContract(); }} title="새로고침"
|
|
className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>
|
|
</svg>
|
|
</button>
|
|
<StatusBadge status={c.status} />
|
|
</div>
|
|
|
|
{/* 알림 오류 배너 */}
|
|
{notifFailures.length > 0 && (
|
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
|
<p className="text-sm font-medium text-red-700 mb-1">알림 발송 실패</p>
|
|
<ul className="text-xs text-red-600 space-y-0.5">
|
|
{notifFailures.map((f, i) => (
|
|
<li key={i}>{f.name}: {f.channel} 실패 - {f.error}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
{/* 왼쪽: 계약 정보 */}
|
|
<div className="lg:col-span-2 space-y-6">
|
|
{/* 기본 정보 */}
|
|
<div className="bg-white rounded-lg border p-6">
|
|
<h2 className="text-lg font-semibold mb-4">계약 정보</h2>
|
|
<dl className="grid grid-cols-2 gap-4 text-sm">
|
|
<div><dt className="text-gray-500">파일명</dt><dd className="font-medium">{c.original_file_name}</dd></div>
|
|
<div><dt className="text-gray-500">파일 크기</dt><dd>{c.original_file_size ? `${(c.original_file_size / 1024 / 1024).toFixed(2)} MB` : '-'}</dd></div>
|
|
<div><dt className="text-gray-500">생성일</dt><dd>{fmtDate(c.created_at, false)}</dd></div>
|
|
<div><dt className="text-gray-500">만료일</dt><dd>{fmtDate(c.expires_at, false) || '-'}</dd></div>
|
|
{c.send_method && (
|
|
<div>
|
|
<dt className="text-gray-500">발송 방식</dt>
|
|
<dd>
|
|
{(() => {
|
|
const sm = SEND_METHOD_MAP[c.send_method] || { label: c.send_method, color: 'bg-gray-100 text-gray-700' };
|
|
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${sm.color}`}>{sm.label}</span>;
|
|
})()}
|
|
</dd>
|
|
</div>
|
|
)}
|
|
{c.description && <div className="col-span-2"><dt className="text-gray-500">설명</dt><dd>{c.description}</dd></div>}
|
|
</dl>
|
|
</div>
|
|
|
|
{/* 서명자 현황 */}
|
|
<div className="bg-white rounded-lg border p-6">
|
|
<h2 className="text-lg font-semibold mb-4">서명자 현황</h2>
|
|
<div className="space-y-3">
|
|
{(c.signers || []).map((s, i) => {
|
|
const st = SIGNER_STATUS[s.status] || {};
|
|
return (
|
|
<div key={s.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
|
<div className="flex items-center gap-3">
|
|
<span className="w-8 h-8 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-sm font-bold">{s.sign_order || i+1}</span>
|
|
<div>
|
|
<p className="font-medium text-sm">{s.name} <span className="text-xs text-gray-400">({s.role === 'creator' ? '작성자' : '상대방'})</span></p>
|
|
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5">
|
|
{s.phone && <span className="text-xs text-gray-500" title="휴대폰">📱 {s.phone}</span>}
|
|
{s.email && <span className="text-xs text-gray-500" title="이메일">✉ {s.email}</span>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<span className={`text-sm font-medium ${st.color}`}>{st.label}</span>
|
|
{s.signed_at && <p className="text-xs text-gray-400">{fmtDate(s.signed_at)}</p>}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 감사 로그 */}
|
|
<div className="bg-white rounded-lg border p-6">
|
|
<h2 className="text-lg font-semibold mb-4">활동 로그</h2>
|
|
<div className="space-y-2 max-h-80 overflow-y-auto">
|
|
{(c.audit_logs || []).length === 0 ? (
|
|
<p className="text-sm text-gray-400">활동 로그가 없습니다.</p>
|
|
) : (c.audit_logs || []).map(log => (
|
|
<div key={log.id} className="py-2 border-b last:border-0">
|
|
<div className="flex items-start gap-3 text-sm">
|
|
<span className="text-xs text-gray-400 whitespace-nowrap">{fmtDate(log.created_at)}</span>
|
|
<span className="font-medium">{ACTION_MAP[log.action] || log.action}</span>
|
|
{log.signer && <span className="text-gray-500">- {log.signer.name}</span>}
|
|
</div>
|
|
{/* 알림 발송 결과 표시 */}
|
|
{log.metadata?.notification_results && (
|
|
<div className="ml-20 mt-1 space-y-0.5">
|
|
{log.metadata.notification_results.map((nr, ni) => (
|
|
<div key={ni} className="text-xs">
|
|
{(nr.results || []).map((r, ri) => (
|
|
<span key={ri} className={`inline-flex items-center gap-1 mr-2 ${r.success ? 'text-green-600' : 'text-red-500'}`}>
|
|
{r.success ? '✓' : '✗'} {nr.signer_name}: {r.channel === 'alimtalk' ? '알림톡' : '이메일'}
|
|
{!r.success && r.error && <span className="text-red-400">({r.error})</span>}
|
|
</span>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 오른쪽: 액션 */}
|
|
<div className="space-y-4">
|
|
<div className="bg-white rounded-lg border p-6">
|
|
<h2 className="text-lg font-semibold mb-4">작업</h2>
|
|
<div className="space-y-3">
|
|
{c.status === 'draft' && (
|
|
<>
|
|
<a href={`/esign/${c.id}/fields`} className="block w-full text-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium" hx-boost="false">
|
|
서명 위치 설정
|
|
</a>
|
|
<a href={`/esign/${c.id}/send`} className="block w-full text-center px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm font-medium" hx-boost="false">
|
|
서명 요청 발송
|
|
</a>
|
|
</>
|
|
)}
|
|
{['pending', 'partially_signed'].includes(c.status) && (
|
|
<>
|
|
<button onClick={() => setShowRemindModal(true)} disabled={!!actionLoading}
|
|
className="w-full px-4 py-2 bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 text-sm font-medium disabled:opacity-50">
|
|
{actionLoading === 'remind' ? '발송 중...' : '리마인더 발송'}
|
|
</button>
|
|
</>
|
|
)}
|
|
{!['completed', 'cancelled', 'rejected'].includes(c.status) && (
|
|
<button onClick={() => doAction('cancel', '계약을 취소하시겠습니까? 이 작업은 되돌릴 수 없습니다.')} disabled={!!actionLoading}
|
|
className="w-full px-4 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 text-sm font-medium disabled:opacity-50">
|
|
{actionLoading === 'cancel' ? '취소 중...' : '계약 취소'}
|
|
</button>
|
|
)}
|
|
<a href={`/esign/contracts/${c.id}/download`} target="_blank"
|
|
className="block w-full text-center px-4 py-2 border rounded-lg text-gray-700 hover:bg-gray-50 text-sm">
|
|
PDF 다운로드
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 파일 무결성 */}
|
|
<div className="bg-white rounded-lg border p-4">
|
|
<p className="text-xs text-gray-500 mb-1">원본 파일 해시 (SHA-256)</p>
|
|
<p className="text-xs font-mono text-gray-600 break-all">{c.original_file_hash || '-'}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* 리마인더 발송 방식 선택 모달 */}
|
|
{showRemindModal && (
|
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setShowRemindModal(false)}>
|
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 p-6" onClick={e => e.stopPropagation()}>
|
|
<h3 className="text-lg font-semibold mb-4">리마인더 발송 방식</h3>
|
|
<div className="space-y-2 mb-5">
|
|
{[
|
|
{ value: 'email', label: '이메일', icon: '✉', desc: '이메일로 리마인더 발송' },
|
|
{ value: 'alimtalk', label: '카카오톡 알림톡', icon: '💬', desc: '알림톡으로 리마인더 발송 (카카오 채널 필요)' },
|
|
{ value: 'both', label: '이메일 + 알림톡', icon: '📡', desc: '두 채널 모두 발송' },
|
|
].map(m => (
|
|
<label key={m.value}
|
|
className={`flex items-start gap-3 p-3 rounded-lg border-2 cursor-pointer transition ${remindMethod === m.value ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'}`}>
|
|
<input type="radio" name="remind_method" value={m.value}
|
|
checked={remindMethod === m.value}
|
|
onChange={() => setRemindMethod(m.value)}
|
|
className="mt-0.5 text-blue-600" />
|
|
<div>
|
|
<span className="text-sm font-medium">{m.icon} {m.label}</span>
|
|
<p className="text-xs text-gray-500">{m.desc}</p>
|
|
</div>
|
|
</label>
|
|
))}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button onClick={() => setShowRemindModal(false)}
|
|
className="flex-1 px-4 py-2 border rounded-lg text-gray-700 hover:bg-gray-50 text-sm">취소</button>
|
|
<button onClick={handleRemind}
|
|
className="flex-1 px-4 py-2 bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 text-sm font-medium">발송</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
ReactDOM.createRoot(document.getElementById('esign-detail-root')).render(<App />);
|
|
</script>
|
|
@endverbatim
|
|
@endpush
|