Files
sam-manage/resources/views/esign/detail.blade.php
김보곤 156a7430a8 feat:대시보드/계약상세/템플릿 관리 새로고침 버튼 추가
- 대시보드: 제목 옆 새로고침 버튼 (통계+목록 갱신)
- 계약 상세: 상태 뱃지 앞 새로고침 버튼 (계약 정보 갱신)
- 템플릿 관리: 제목 옆 새로고침 버튼 (템플릿 목록 갱신)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 10:11:14 +09:00

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">&larr;</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