Files
sam-manage/resources/views/esign/detail.blade.php
김보곤 edc69040ab feat: [esign] 전자계약 수정 기능 추가
- draft 상태 계약의 제목, 설명, 서명자 정보, 파일 수정 가능
- 계약 상세 페이지에 '계약 정보 수정' 버튼 추가
- create.blade.php를 생성/수정 겸용으로 확장
2026-03-11 11:55:46 +09:00

338 lines
20 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">&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>
{/* 알림 오류 배너 */}
{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}/edit`} className="block w-full text-center px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 text-sm font-medium" hx-boost="false">
계약 정보 수정
</a>
<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