- 대시보드: 제목 옆 새로고침 버튼 (통계+목록 갱신) - 계약 상세: 상태 뱃지 앞 새로고침 버튼 (계약 정보 갱신) - 템플릿 관리: 제목 옆 새로고침 버튼 (템플릿 목록 갱신) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
214 lines
12 KiB
PHP
214 lines
12 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,
|
|
});
|
|
|
|
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 = {
|
|
created: '계약 생성', sent: '서명 요청 발송', viewed: '문서 열람',
|
|
otp_sent: 'OTP 발송', authenticated: '본인인증 완료', signed: '서명 완료',
|
|
rejected: '서명 거절', completed: '계약 완료', cancelled: '계약 취소', reminded: '리마인더 발송',
|
|
downloaded: '문서 다운로드',
|
|
};
|
|
|
|
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 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) => {
|
|
if (confirm_msg && !window.confirm(confirm_msg)) return;
|
|
setActionLoading(action);
|
|
try {
|
|
const res = await fetch(`/esign/contracts/${CONTRACT_ID}/${action}`, {
|
|
method: 'POST', headers: getHeaders(),
|
|
});
|
|
const json = await res.json();
|
|
if (json.success) fetchContract();
|
|
else alert(json.message || '오류가 발생했습니다.');
|
|
} catch (e) { alert('서버 오류'); }
|
|
setActionLoading('');
|
|
};
|
|
|
|
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;
|
|
|
|
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>
|
|
|
|
<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>{c.created_at?.slice(0,10)}</dd></div>
|
|
<div><dt className="text-gray-500">만료일</dt><dd>{c.expires_at?.slice(0,10) || '-'}</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>
|
|
<p className="text-xs text-gray-500">{s.email}</p>
|
|
</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">{s.signed_at?.slice(0,16).replace('T', ' ')}</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-64 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="flex items-start gap-3 text-sm py-2 border-b last:border-0">
|
|
<span className="text-xs text-gray-400 whitespace-nowrap">{log.created_at?.slice(0,16).replace('T', ' ')}</span>
|
|
<span className="font-medium">{ACTION_MAP[log.action] || log.action}</span>
|
|
{log.signer && <span className="text-gray-500">- {log.signer.name}</span>}
|
|
</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>
|
|
<button onClick={() => doAction('send', '서명 요청을 발송하시겠습니까?')} disabled={!!actionLoading}
|
|
className="w-full px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm font-medium disabled:opacity-50">
|
|
{actionLoading === 'send' ? '발송 중...' : '서명 요청 발송'}
|
|
</button>
|
|
</>
|
|
)}
|
|
{['pending', 'partially_signed'].includes(c.status) && (
|
|
<button onClick={() => doAction('remind', '리마인더를 발송하시겠습니까?')} 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>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
ReactDOM.createRoot(document.getElementById('esign-detail-root')).render(<App />);
|
|
</script>
|
|
@endverbatim
|
|
@endpush
|