- 컨트롤러 2개 (EsignController, EsignPublicController) - 뷰 8개 (dashboard, create, detail, fields, send, sign/auth, sign/sign, sign/done) - React 하이브리드 방식 (기존 Finance 패턴) - 라우트 추가 (인증 esign/* + 공개 esign/sign/*) - PDF.js 기반 서명 위치 설정 - signature_pad 기반 전자서명 입력 - OTP 본인인증 플로우 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
184 lines
9.3 KiB
PHP
184 lines
9.3 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', '전자계약 대시보드')
|
|
|
|
@section('content')
|
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
<div id="esign-dashboard-root"></div>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
|
|
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
|
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
|
|
<script src="https://unpkg.com/lucide@latest?v={{ time() }}"></script>
|
|
@verbatim
|
|
<script type="text/babel">
|
|
const { useState, useEffect, useCallback } = React;
|
|
|
|
const API = window.SAM_CONFIG?.apiBaseUrl || '';
|
|
const API_KEY = window.SAM_CONFIG?.apiKey || '';
|
|
|
|
const getHeaders = () => {
|
|
const token = sessionStorage.getItem('api_access_token') || '';
|
|
return {
|
|
'Accept': 'application/json',
|
|
'X-API-Key': API_KEY,
|
|
'Authorization': `Bearer ${token}`,
|
|
};
|
|
};
|
|
|
|
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 StatusBadge = ({ status }) => {
|
|
const s = STATUS_MAP[status] || { label: status, color: 'bg-gray-100 text-gray-700' };
|
|
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 StatsCard = ({ label, value, color }) => (
|
|
<div className={`bg-white rounded-lg border p-4 ${color || ''}`}>
|
|
<p className="text-sm text-gray-500">{label}</p>
|
|
<p className="text-2xl font-bold mt-1">{value}</p>
|
|
</div>
|
|
);
|
|
|
|
const App = () => {
|
|
const [contracts, setContracts] = useState([]);
|
|
const [stats, setStats] = useState({});
|
|
const [loading, setLoading] = useState(true);
|
|
const [filter, setFilter] = useState({ status: '', search: '' });
|
|
const [page, setPage] = useState(1);
|
|
const [pagination, setPagination] = useState({});
|
|
|
|
const fetchStats = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`${API}/api/v1/esign/contracts/stats`, { headers: getHeaders() });
|
|
const json = await res.json();
|
|
if (json.success) setStats(json.data);
|
|
} catch (e) { console.error(e); }
|
|
}, []);
|
|
|
|
const fetchContracts = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params = new URLSearchParams({ page, per_page: 20 });
|
|
if (filter.status) params.append('status', filter.status);
|
|
if (filter.search) params.append('search', filter.search);
|
|
|
|
const res = await fetch(`${API}/api/v1/esign/contracts?${params}`, { headers: getHeaders() });
|
|
const json = await res.json();
|
|
if (json.success) {
|
|
setContracts(json.data.data || []);
|
|
setPagination({ current_page: json.data.current_page, last_page: json.data.last_page, total: json.data.total });
|
|
}
|
|
} catch (e) { console.error(e); }
|
|
setLoading(false);
|
|
}, [filter, page]);
|
|
|
|
useEffect(() => { fetchStats(); }, [fetchStats]);
|
|
useEffect(() => { fetchContracts(); }, [fetchContracts]);
|
|
|
|
return (
|
|
<div className="p-6 max-w-7xl mx-auto">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h1 className="text-2xl font-bold text-gray-900">전자계약 (E-Sign)</h1>
|
|
<a href="/esign/create"
|
|
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium"
|
|
hx-boost="false">
|
|
+ 새 계약 생성
|
|
</a>
|
|
</div>
|
|
|
|
{/* 통계 카드 */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3 mb-6">
|
|
<StatsCard label="전체" value={stats.total || 0} />
|
|
<StatsCard label="초안" value={stats.draft || 0} />
|
|
<StatsCard label="서명 대기" value={stats.pending || 0} color="border-blue-200" />
|
|
<StatsCard label="부분 서명" value={stats.partially_signed || 0} color="border-yellow-200" />
|
|
<StatsCard label="완료" value={stats.completed || 0} color="border-green-200" />
|
|
<StatsCard label="만료" value={stats.expired || 0} />
|
|
<StatsCard label="취소/거절" value={(stats.cancelled || 0) + (stats.rejected || 0)} />
|
|
</div>
|
|
|
|
{/* 필터 */}
|
|
<div className="flex gap-3 mb-4">
|
|
<select value={filter.status} onChange={e => { setFilter(f => ({...f, status: e.target.value})); setPage(1); }}
|
|
className="border rounded-lg px-3 py-2 text-sm">
|
|
<option value="">전체 상태</option>
|
|
{Object.entries(STATUS_MAP).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
|
</select>
|
|
<input type="text" placeholder="제목 또는 코드 검색..." value={filter.search}
|
|
onChange={e => { setFilter(f => ({...f, search: e.target.value})); setPage(1); }}
|
|
className="border rounded-lg px-3 py-2 text-sm flex-1 max-w-xs" />
|
|
</div>
|
|
|
|
{/* 목록 */}
|
|
<div className="bg-white rounded-lg border overflow-hidden">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">계약코드</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">제목</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">서명자</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">상태</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">생성일</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">만료일</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{loading ? (
|
|
<tr><td colSpan="6" className="px-4 py-8 text-center text-gray-400">로딩 중...</td></tr>
|
|
) : contracts.length === 0 ? (
|
|
<tr><td colSpan="6" className="px-4 py-8 text-center text-gray-400">계약이 없습니다.</td></tr>
|
|
) : contracts.map(c => (
|
|
<tr key={c.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => location.href = `/esign/${c.id}`}>
|
|
<td className="px-4 py-3 text-sm font-mono text-gray-600">{c.contract_code}</td>
|
|
<td className="px-4 py-3 text-sm font-medium text-gray-900">{c.title}</td>
|
|
<td className="px-4 py-3 text-sm text-gray-600">
|
|
{(c.signers || []).map(s => (
|
|
<span key={s.id} className="inline-flex items-center mr-2">
|
|
<span className={`w-2 h-2 rounded-full mr-1 ${s.status === 'signed' ? 'bg-green-500' : 'bg-gray-300'}`}></span>
|
|
{s.name}
|
|
</span>
|
|
))}
|
|
</td>
|
|
<td className="px-4 py-3"><StatusBadge status={c.status} /></td>
|
|
<td className="px-4 py-3 text-sm text-gray-500">{c.created_at?.slice(0,10)}</td>
|
|
<td className="px-4 py-3 text-sm text-gray-500">{c.expires_at?.slice(0,10)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* 페이지네이션 */}
|
|
{pagination.last_page > 1 && (
|
|
<div className="flex items-center justify-between mt-4">
|
|
<span className="text-sm text-gray-500">총 {pagination.total}건</span>
|
|
<div className="flex gap-1">
|
|
{Array.from({length: pagination.last_page}, (_, i) => i + 1).map(p => (
|
|
<button key={p} onClick={() => setPage(p)}
|
|
className={`px-3 py-1 text-sm rounded ${p === pagination.current_page ? 'bg-blue-600 text-white' : 'bg-white border text-gray-700 hover:bg-gray-50'}`}>
|
|
{p}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
ReactDOM.render(<App />, document.getElementById('esign-dashboard-root'));
|
|
</script>
|
|
@endverbatim
|
|
@endpush
|