feat:E-Sign 전자계약 서명 솔루션 MNG 프론트엔드 구현
- 컨트롤러 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>
This commit is contained in:
56
app/Http/Controllers/ESign/EsignController.php
Normal file
56
app/Http/Controllers/ESign/EsignController.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\ESign;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EsignController extends Controller
|
||||
{
|
||||
public function dashboard(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('esign.dashboard'));
|
||||
}
|
||||
|
||||
return view('esign.dashboard');
|
||||
}
|
||||
|
||||
public function create(Request $request): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('esign.create'));
|
||||
}
|
||||
|
||||
return view('esign.create');
|
||||
}
|
||||
|
||||
public function detail(Request $request, int $id): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('esign.detail', $id));
|
||||
}
|
||||
|
||||
return view('esign.detail', ['contractId' => $id]);
|
||||
}
|
||||
|
||||
public function fields(Request $request, int $id): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('esign.fields', $id));
|
||||
}
|
||||
|
||||
return view('esign.fields', ['contractId' => $id]);
|
||||
}
|
||||
|
||||
public function send(Request $request, int $id): View|Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('esign.send', $id));
|
||||
}
|
||||
|
||||
return view('esign.send', ['contractId' => $id]);
|
||||
}
|
||||
}
|
||||
25
app/Http/Controllers/ESign/EsignPublicController.php
Normal file
25
app/Http/Controllers/ESign/EsignPublicController.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\ESign;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EsignPublicController extends Controller
|
||||
{
|
||||
public function auth(string $token): View
|
||||
{
|
||||
return view('esign.sign.auth', ['token' => $token]);
|
||||
}
|
||||
|
||||
public function sign(string $token): View
|
||||
{
|
||||
return view('esign.sign.sign', ['token' => $token]);
|
||||
}
|
||||
|
||||
public function done(string $token): View
|
||||
{
|
||||
return view('esign.sign.done', ['token' => $token]);
|
||||
}
|
||||
}
|
||||
157
resources/views/esign/create.blade.php
Normal file
157
resources/views/esign/create.blade.php
Normal file
@@ -0,0 +1,157 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '새 전자계약 생성')
|
||||
|
||||
@section('content')
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<div id="esign-create-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>
|
||||
@verbatim
|
||||
<script type="text/babel">
|
||||
const { useState, useRef } = 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 App = () => {
|
||||
const [form, setForm] = useState({
|
||||
title: '', description: '', sign_order_type: 'counterpart_first', expires_at: '',
|
||||
creator_name: '', creator_email: '', creator_phone: '',
|
||||
counterpart_name: '', counterpart_email: '', counterpart_phone: '',
|
||||
});
|
||||
const [file, setFile] = useState(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const fileRef = useRef(null);
|
||||
|
||||
const handleChange = (key, val) => setForm(f => ({...f, [key]: val}));
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setErrors({});
|
||||
|
||||
const fd = new FormData();
|
||||
Object.entries(form).forEach(([k, v]) => { if (v) fd.append(k, v); });
|
||||
if (file) fd.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/api/v1/esign/contracts`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-API-Key': API_KEY, 'Authorization': `Bearer ${sessionStorage.getItem('api_access_token') || ''}` },
|
||||
body: fd,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
location.href = `/esign/${json.data.id}/fields`;
|
||||
} else {
|
||||
setErrors(json.errors || { general: json.message });
|
||||
}
|
||||
} catch (e) {
|
||||
setErrors({ general: '서버 오류가 발생했습니다.' });
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const Input = ({ label, name, type = 'text', required = false, placeholder = '' }) => (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{label} {required && <span className="text-red-500">*</span>}</label>
|
||||
<input type={type} value={form[name] || ''} onChange={e => handleChange(name, e.target.value)}
|
||||
placeholder={placeholder} required={required}
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
{errors[name] && <p className="text-red-500 text-xs mt-1">{errors[name]}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<a href="/esign" className="text-gray-400 hover:text-gray-600" hx-boost="false">←</a>
|
||||
<h1 className="text-2xl font-bold text-gray-900">새 전자계약 생성</h1>
|
||||
</div>
|
||||
|
||||
{errors.general && <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-4">{errors.general}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 계약 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">계약 정보</h2>
|
||||
<div className="space-y-4">
|
||||
<Input label="계약 제목" name="title" required placeholder="예: 2026년 공급 계약서" />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">설명</label>
|
||||
<textarea value={form.description} onChange={e => handleChange('description', e.target.value)}
|
||||
placeholder="계약에 대한 간단한 설명 (선택)" rows={3}
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">PDF 파일 <span className="text-red-500">*</span></label>
|
||||
<input ref={fileRef} type="file" accept=".pdf" onChange={e => setFile(e.target.files[0])} required
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-blue-50 file:text-blue-700 file:text-sm file:font-medium" />
|
||||
{file && <p className="text-xs text-gray-500 mt-1">{file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)</p>}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">서명 순서</label>
|
||||
<select value={form.sign_order_type} onChange={e => handleChange('sign_order_type', e.target.value)}
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm">
|
||||
<option value="counterpart_first">상대방 먼저 서명</option>
|
||||
<option value="creator_first">작성자 먼저 서명</option>
|
||||
</select>
|
||||
</div>
|
||||
<Input label="만료일" name="expires_at" type="datetime-local" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 작성자 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">작성자 (나) 정보</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Input label="이름" name="creator_name" required placeholder="홍길동" />
|
||||
<Input label="이메일" name="creator_email" type="email" required placeholder="hong@example.com" />
|
||||
<Input label="전화번호" name="creator_phone" placeholder="010-1234-5678" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상대방 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">상대방 정보</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Input label="이름" name="counterpart_name" required placeholder="김철수" />
|
||||
<Input label="이메일" name="counterpart_email" type="email" required placeholder="kim@example.com" />
|
||||
<Input label="전화번호" name="counterpart_phone" placeholder="010-5678-1234" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<a href="/esign" className="px-6 py-2 border rounded-lg text-gray-700 hover:bg-gray-50 text-sm" hx-boost="false">취소</a>
|
||||
<button type="submit" disabled={submitting}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium disabled:opacity-50">
|
||||
{submitting ? '생성 중...' : '계약 생성 및 서명 위치 설정'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('esign-create-root'));
|
||||
</script>
|
||||
@endverbatim
|
||||
@endpush
|
||||
183
resources/views/esign/dashboard.blade.php
Normal file
183
resources/views/esign/dashboard.blade.php
Normal file
@@ -0,0 +1,183 @@
|
||||
@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
|
||||
211
resources/views/esign/detail.blade.php
Normal file
211
resources/views/esign/detail.blade.php
Normal file
@@ -0,0 +1,211 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '전자계약 상세')
|
||||
|
||||
@section('content')
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<div id="esign-detail-root" data-contract-id="{{ $contractId }}"></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>
|
||||
@verbatim
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect, useCallback } = React;
|
||||
|
||||
const API = window.SAM_CONFIG?.apiBaseUrl || '';
|
||||
const API_KEY = window.SAM_CONFIG?.apiKey || '';
|
||||
const CONTRACT_ID = document.getElementById('esign-detail-root')?.dataset.contractId;
|
||||
|
||||
const getHeaders = () => ({
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': API_KEY,
|
||||
'Authorization': `Bearer ${sessionStorage.getItem('api_access_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 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(`${API}/api/v1/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(`${API}/api/v1/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-6 max-w-5xl mx-auto">
|
||||
<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>
|
||||
<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={`${API}/api/v1/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.render(<App />, document.getElementById('esign-detail-root'));
|
||||
</script>
|
||||
@endverbatim
|
||||
@endpush
|
||||
258
resources/views/esign/fields.blade.php
Normal file
258
resources/views/esign/fields.blade.php
Normal file
@@ -0,0 +1,258 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '서명 위치 설정')
|
||||
|
||||
@section('content')
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<div id="esign-fields-root" data-contract-id="{{ $contractId }}"></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://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';</script>
|
||||
@verbatim
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect, useCallback, useRef } = React;
|
||||
|
||||
const API = window.SAM_CONFIG?.apiBaseUrl || '';
|
||||
const API_KEY = window.SAM_CONFIG?.apiKey || '';
|
||||
const CONTRACT_ID = document.getElementById('esign-fields-root')?.dataset.contractId;
|
||||
|
||||
const getHeaders = () => ({
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': API_KEY,
|
||||
'Authorization': `Bearer ${sessionStorage.getItem('api_access_token') || ''}`,
|
||||
});
|
||||
|
||||
const FIELD_TYPES = [
|
||||
{ value: 'signature', label: '서명' },
|
||||
{ value: 'stamp', label: '도장' },
|
||||
{ value: 'text', label: '텍스트' },
|
||||
{ value: 'date', label: '날짜' },
|
||||
{ value: 'checkbox', label: '체크박스' },
|
||||
];
|
||||
|
||||
const SIGNER_COLORS = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'];
|
||||
|
||||
const App = () => {
|
||||
const [contract, setContract] = useState(null);
|
||||
const [fields, setFields] = useState([]);
|
||||
const [pdfPages, setPdfPages] = useState([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const canvasRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
const [pdfDoc, setPdfDoc] = useState(null);
|
||||
|
||||
// 계약 정보 로드
|
||||
const fetchContract = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/v1/esign/contracts/${CONTRACT_ID}`, { headers: getHeaders() });
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
setContract(json.data);
|
||||
if (json.data.sign_fields?.length) {
|
||||
setFields(json.data.sign_fields.map(f => ({
|
||||
signer_id: f.signer_id, page_number: f.page_number,
|
||||
position_x: parseFloat(f.position_x), position_y: parseFloat(f.position_y),
|
||||
width: parseFloat(f.width), height: parseFloat(f.height),
|
||||
field_type: f.field_type, field_label: f.field_label,
|
||||
})));
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
// PDF 로드
|
||||
const loadPdf = useCallback(async () => {
|
||||
if (!contract) return;
|
||||
try {
|
||||
const token = sessionStorage.getItem('api_access_token') || '';
|
||||
const url = `${API}/api/v1/esign/contracts/${CONTRACT_ID}/download`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'X-API-Key': API_KEY, 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const doc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
setPdfDoc(doc);
|
||||
setTotalPages(doc.numPages);
|
||||
} catch (e) { console.error('PDF 로드 실패:', e); }
|
||||
}, [contract]);
|
||||
|
||||
// PDF 페이지 렌더링
|
||||
const renderPage = useCallback(async (pageNum) => {
|
||||
if (!pdfDoc || !canvasRef.current) return;
|
||||
const page = await pdfDoc.getPage(pageNum);
|
||||
const scale = 1.5;
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = canvasRef.current;
|
||||
canvas.height = viewport.height;
|
||||
canvas.width = viewport.width;
|
||||
const ctx = canvas.getContext('2d');
|
||||
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||
}, [pdfDoc]);
|
||||
|
||||
useEffect(() => { fetchContract(); }, [fetchContract]);
|
||||
useEffect(() => { loadPdf(); }, [loadPdf]);
|
||||
useEffect(() => { renderPage(currentPage); }, [renderPage, currentPage]);
|
||||
|
||||
// 필드 추가
|
||||
const addField = (signerId) => {
|
||||
setFields(prev => [...prev, {
|
||||
signer_id: signerId, page_number: currentPage,
|
||||
position_x: 10, position_y: 80, width: 20, height: 5,
|
||||
field_type: 'signature', field_label: '',
|
||||
}]);
|
||||
};
|
||||
|
||||
// 필드 삭제
|
||||
const removeField = (idx) => setFields(prev => prev.filter((_, i) => i !== idx));
|
||||
|
||||
// 필드 수정
|
||||
const updateField = (idx, key, val) => {
|
||||
setFields(prev => prev.map((f, i) => i === idx ? {...f, [key]: val} : f));
|
||||
};
|
||||
|
||||
// 저장
|
||||
const saveFields = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`${API}/api/v1/esign/contracts/${CONTRACT_ID}/fields`, {
|
||||
method: 'POST', headers: getHeaders(),
|
||||
body: JSON.stringify({ fields: fields.map((f, i) => ({...f, sort_order: i})) }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
alert('서명 위치가 저장되었습니다.');
|
||||
location.href = `/esign/${CONTRACT_ID}`;
|
||||
} else {
|
||||
alert(json.message || '저장 실패');
|
||||
}
|
||||
} catch (e) { alert('서버 오류'); }
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
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 signers = contract.signers || [];
|
||||
const pageFields = fields.filter(f => f.page_number === currentPage);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<a href={`/esign/${CONTRACT_ID}`} className="text-gray-400 hover:text-gray-600" hx-boost="false">←</a>
|
||||
<h1 className="text-2xl font-bold text-gray-900">서명 위치 설정</h1>
|
||||
</div>
|
||||
<button onClick={saveFields} disabled={saving}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium disabled:opacity-50">
|
||||
{saving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* 왼쪽: PDF 미리보기 */}
|
||||
<div className="lg:col-span-3">
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage <= 1}
|
||||
className="px-3 py-1 border rounded text-sm disabled:opacity-30">« 이전</button>
|
||||
<span className="px-3 py-1 text-sm">{currentPage} / {totalPages}</span>
|
||||
<button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}
|
||||
className="px-3 py-1 border rounded text-sm disabled:opacity-30">다음 »</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={containerRef} className="relative bg-gray-100 overflow-auto" style={{maxHeight: '700px'}}>
|
||||
<canvas ref={canvasRef} />
|
||||
{/* 서명 필드 오버레이 */}
|
||||
{pageFields.map((f, idx) => {
|
||||
const realIdx = fields.indexOf(f);
|
||||
const signerIdx = signers.findIndex(s => s.id === f.signer_id);
|
||||
const color = SIGNER_COLORS[signerIdx % SIGNER_COLORS.length] || '#888';
|
||||
return (
|
||||
<div key={realIdx}
|
||||
style={{
|
||||
position: 'absolute', left: `${f.position_x}%`, top: `${f.position_y}%`,
|
||||
width: `${f.width}%`, height: `${f.height}%`,
|
||||
border: `2px dashed ${color}`, backgroundColor: `${color}20`,
|
||||
cursor: 'move', borderRadius: '4px',
|
||||
}}
|
||||
className="flex items-center justify-center text-xs font-medium"
|
||||
title={`${signers[signerIdx]?.name || ''} - ${FIELD_TYPES.find(t => t.value === f.field_type)?.label || ''}`}>
|
||||
<span style={{color}}>{signers[signerIdx]?.name?.charAt(0) || '?'}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 필드 설정 */}
|
||||
<div className="space-y-4">
|
||||
{/* 서명자별 필드 추가 */}
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<h3 className="font-semibold text-sm mb-3">서명 필드 추가</h3>
|
||||
{signers.map((s, i) => (
|
||||
<button key={s.id} onClick={() => addField(s.id)}
|
||||
className="w-full mb-2 px-3 py-2 border rounded-lg text-sm text-left hover:bg-gray-50"
|
||||
style={{borderColor: SIGNER_COLORS[i % SIGNER_COLORS.length]}}>
|
||||
<span className="inline-block w-3 h-3 rounded-full mr-2" style={{backgroundColor: SIGNER_COLORS[i % SIGNER_COLORS.length]}}></span>
|
||||
{s.name} ({s.role === 'creator' ? '작성자' : '상대방'})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 필드 목록 */}
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<h3 className="font-semibold text-sm mb-3">필드 목록 ({fields.length}개)</h3>
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{fields.map((f, idx) => {
|
||||
const signer = signers.find(s => s.id === f.signer_id);
|
||||
return (
|
||||
<div key={idx} className="p-3 bg-gray-50 rounded-lg text-xs space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{signer?.name || '?'} - p.{f.page_number}</span>
|
||||
<button onClick={() => removeField(idx)} className="text-red-400 hover:text-red-600">×</button>
|
||||
</div>
|
||||
<select value={f.field_type} onChange={e => updateField(idx, 'field_type', e.target.value)}
|
||||
className="w-full border rounded px-2 py-1 text-xs">
|
||||
{FIELD_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</select>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<input type="number" placeholder="X%" value={f.position_x} step="0.5"
|
||||
onChange={e => updateField(idx, 'position_x', parseFloat(e.target.value) || 0)}
|
||||
className="border rounded px-2 py-1 text-xs" />
|
||||
<input type="number" placeholder="Y%" value={f.position_y} step="0.5"
|
||||
onChange={e => updateField(idx, 'position_y', parseFloat(e.target.value) || 0)}
|
||||
className="border rounded px-2 py-1 text-xs" />
|
||||
<input type="number" placeholder="W%" value={f.width} step="0.5"
|
||||
onChange={e => updateField(idx, 'width', parseFloat(e.target.value) || 0)}
|
||||
className="border rounded px-2 py-1 text-xs" />
|
||||
<input type="number" placeholder="H%" value={f.height} step="0.5"
|
||||
onChange={e => updateField(idx, 'height', parseFloat(e.target.value) || 0)}
|
||||
className="border rounded px-2 py-1 text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('esign-fields-root'));
|
||||
</script>
|
||||
@endverbatim
|
||||
@endpush
|
||||
133
resources/views/esign/send.blade.php
Normal file
133
resources/views/esign/send.blade.php
Normal file
@@ -0,0 +1,133 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '서명 요청 발송')
|
||||
|
||||
@section('content')
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<div id="esign-send-root" data-contract-id="{{ $contractId }}"></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>
|
||||
@verbatim
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect, useCallback } = React;
|
||||
|
||||
const API = window.SAM_CONFIG?.apiBaseUrl || '';
|
||||
const API_KEY = window.SAM_CONFIG?.apiKey || '';
|
||||
const CONTRACT_ID = document.getElementById('esign-send-root')?.dataset.contractId;
|
||||
|
||||
const getHeaders = () => ({
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': API_KEY,
|
||||
'Authorization': `Bearer ${sessionStorage.getItem('api_access_token') || ''}`,
|
||||
});
|
||||
|
||||
const App = () => {
|
||||
const [contract, setContract] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const fetchContract = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/v1/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 handleSend = async () => {
|
||||
if (!window.confirm('서명 요청을 발송하시겠습니까?\n첫 번째 서명자에게 이메일이 발송됩니다.')) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const res = await fetch(`${API}/api/v1/esign/contracts/${CONTRACT_ID}/send`, {
|
||||
method: 'POST', headers: getHeaders(),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
alert('서명 요청이 발송되었습니다.');
|
||||
location.href = `/esign/${CONTRACT_ID}`;
|
||||
} else {
|
||||
alert(json.message || '발송에 실패했습니다.');
|
||||
}
|
||||
} catch (e) { alert('서버 오류가 발생했습니다.'); }
|
||||
setSending(false);
|
||||
};
|
||||
|
||||
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 signers = contract.signers || [];
|
||||
const fieldsCount = contract.sign_fields?.length || 0;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<a href={`/esign/${CONTRACT_ID}`} className="text-gray-400 hover:text-gray-600" hx-boost="false">←</a>
|
||||
<h1 className="text-2xl font-bold text-gray-900">서명 요청 발송</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">발송 전 확인</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs text-white ${contract.title ? 'bg-green-500' : 'bg-red-500'}`}>
|
||||
{contract.title ? '✓' : '!'}
|
||||
</span>
|
||||
<span className="text-sm">계약 제목: <strong>{contract.title}</strong></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs text-white ${contract.original_file_name ? 'bg-green-500' : 'bg-red-500'}`}>
|
||||
{contract.original_file_name ? '✓' : '!'}
|
||||
</span>
|
||||
<span className="text-sm">PDF 파일: <strong>{contract.original_file_name || '미업로드'}</strong></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs text-white ${fieldsCount > 0 ? 'bg-green-500' : 'bg-red-500'}`}>
|
||||
{fieldsCount > 0 ? '✓' : '!'}
|
||||
</span>
|
||||
<span className="text-sm">서명 필드: <strong>{fieldsCount}개 설정됨</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">서명 순서</h2>
|
||||
<div className="space-y-3">
|
||||
{signers.sort((a, b) => a.sign_order - b.sign_order).map((s, i) => (
|
||||
<div key={s.id} className="flex items-center gap-4 p-3 bg-gray-50 rounded-lg">
|
||||
<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-gray-400 text-xs">({s.role === 'creator' ? '작성자' : '상대방'})</span></p>
|
||||
<p className="text-xs text-gray-500">{s.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<a href={`/esign/${CONTRACT_ID}`} className="px-6 py-2 border rounded-lg text-gray-700 hover:bg-gray-50 text-sm" hx-boost="false">돌아가기</a>
|
||||
<button onClick={handleSend} disabled={sending || fieldsCount === 0}
|
||||
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm font-medium disabled:opacity-50">
|
||||
{sending ? '발송 중...' : '서명 요청 발송'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{fieldsCount === 0 && (
|
||||
<p className="text-red-500 text-sm mt-3 text-right">서명 필드를 먼저 설정해 주세요.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('esign-send-root'));
|
||||
</script>
|
||||
@endverbatim
|
||||
@endpush
|
||||
164
resources/views/esign/sign/auth.blade.php
Normal file
164
resources/views/esign/sign/auth.blade.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>전자계약 본인인증 - SAM E-Sign</title>
|
||||
@vite(['resources/css/app.css'])
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<div id="esign-auth-root" data-token="{{ $token }}"></div>
|
||||
|
||||
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect, useCallback } = React;
|
||||
|
||||
const TOKEN = document.getElementById('esign-auth-root')?.dataset.token;
|
||||
const API = window.SAM_CONFIG?.apiBaseUrl || '{{ config("services.api.base_url", "") }}';
|
||||
const API_KEY = '{{ config("services.api.key", "") }}';
|
||||
|
||||
const App = () => {
|
||||
const [contract, setContract] = useState(null);
|
||||
const [signer, setSigner] = useState(null);
|
||||
const [step, setStep] = useState('loading'); // loading, info, otp
|
||||
const [otp, setOtp] = useState('');
|
||||
const [otpSending, setOtpSending] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const fetchContract = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/v1/esign/sign/${TOKEN}`, {
|
||||
headers: { 'Accept': 'application/json', 'X-API-Key': API_KEY },
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
setContract(json.data.contract);
|
||||
setSigner(json.data.signer);
|
||||
setStep('info');
|
||||
} else {
|
||||
setError(json.message || '계약 정보를 불러올 수 없습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
setError('서버에 연결할 수 없습니다.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchContract(); }, [fetchContract]);
|
||||
|
||||
const sendOtp = async () => {
|
||||
setOtpSending(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`${API}/api/v1/esign/sign/${TOKEN}/otp/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
setStep('otp');
|
||||
} else {
|
||||
setError(json.message || 'OTP 발송에 실패했습니다.');
|
||||
}
|
||||
} catch (e) { setError('서버 오류'); }
|
||||
setOtpSending(false);
|
||||
};
|
||||
|
||||
const verifyOtp = async (e) => {
|
||||
e.preventDefault();
|
||||
setVerifying(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`${API}/api/v1/esign/sign/${TOKEN}/otp/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
|
||||
body: JSON.stringify({ otp_code: otp }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
sessionStorage.setItem('esign_session_token', json.data.sign_session_token);
|
||||
location.href = `/esign/sign/${TOKEN}/sign`;
|
||||
} else {
|
||||
setError(json.message || '인증 코드가 올바르지 않습니다.');
|
||||
}
|
||||
} catch (e) { setError('서버 오류'); }
|
||||
setVerifying(false);
|
||||
};
|
||||
|
||||
if (step === 'loading') return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<p className="text-gray-400">{error || '로딩 중...'}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* 헤더 */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">SAM E-Sign</h1>
|
||||
<p className="text-gray-500 mt-1">전자계약 서명</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
{error && <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-4 text-sm">{error}</div>}
|
||||
|
||||
{step === 'info' && (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold mb-4">계약 정보 확인</h2>
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-xs text-gray-500 mb-1">계약 제목</p>
|
||||
<p className="font-medium">{contract?.title}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-xs text-gray-500 mb-1">서명자</p>
|
||||
<p className="font-medium">{signer?.name} ({signer?.email})</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-xs text-gray-500 mb-1">서명 기한</p>
|
||||
<p className="font-medium text-red-600">{contract?.expires_at?.slice(0,10) || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
본인인증을 위해 등록된 이메일로 인증 코드를 발송합니다.
|
||||
</p>
|
||||
<button onClick={sendOtp} disabled={otpSending}
|
||||
className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium disabled:opacity-50">
|
||||
{otpSending ? '발송 중...' : '인증 코드 발송'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'otp' && (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold mb-4">인증 코드 입력</h2>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
<strong>{signer?.email}</strong>로 발송된 6자리 인증 코드를 입력해 주세요.
|
||||
</p>
|
||||
<form onSubmit={verifyOtp} className="space-y-4">
|
||||
<input type="text" value={otp} onChange={e => setOtp(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000" maxLength={6} autoFocus
|
||||
className="w-full text-center text-3xl tracking-[0.5em] border-2 rounded-lg px-4 py-4 focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
<button type="submit" disabled={verifying || otp.length !== 6}
|
||||
className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium disabled:opacity-50">
|
||||
{verifying ? '확인 중...' : '인증 확인'}
|
||||
</button>
|
||||
</form>
|
||||
<button onClick={sendOtp} className="w-full mt-3 py-2 text-sm text-blue-600 hover:text-blue-800">
|
||||
인증 코드 재발송
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('esign-auth-root'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
127
resources/views/esign/sign/done.blade.php
Normal file
127
resources/views/esign/sign/done.blade.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>서명 완료 - SAM E-Sign</title>
|
||||
@vite(['resources/css/app.css'])
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<div id="esign-done-root" data-token="{{ $token }}"></div>
|
||||
|
||||
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
const TOKEN = document.getElementById('esign-done-root')?.dataset.token;
|
||||
const API = window.SAM_CONFIG?.apiBaseUrl || '{{ config("services.api.base_url", "") }}';
|
||||
const API_KEY = '{{ config("services.api.key", "") }}';
|
||||
|
||||
const App = () => {
|
||||
const [contract, setContract] = useState(null);
|
||||
const [signer, setSigner] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/v1/esign/sign/${TOKEN}`, {
|
||||
headers: { 'Accept': 'application/json', 'X-API-Key': API_KEY },
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
setContract(json.data.contract);
|
||||
setSigner(json.data.signer);
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
setLoading(false);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center min-h-screen"><p className="text-gray-400">로딩 중...</p></div>;
|
||||
|
||||
const isSigned = signer?.status === 'signed';
|
||||
const isRejected = signer?.status === 'rejected';
|
||||
const isCompleted = contract?.status === 'completed';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">SAM E-Sign</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border p-8">
|
||||
{isSigned && (
|
||||
<>
|
||||
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<svg className="w-10 h-10 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">서명이 완료되었습니다</h2>
|
||||
<p className="text-gray-500 mb-6">
|
||||
{isCompleted
|
||||
? '모든 서명자의 서명이 완료되어 계약이 체결되었습니다.'
|
||||
: '서명이 정상적으로 접수되었습니다. 다른 서명자의 서명이 완료되면 알려드리겠습니다.'
|
||||
}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isRejected && (
|
||||
<>
|
||||
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<svg className="w-10 h-10 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">서명이 거절되었습니다</h2>
|
||||
<p className="text-gray-500 mb-6">서명 거절이 접수되었습니다. 계약 담당자에게 알림이 발송됩니다.</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isSigned && !isRejected && (
|
||||
<>
|
||||
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<svg className="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">처리 완료</h2>
|
||||
<p className="text-gray-500 mb-6">요청이 처리되었습니다.</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-left text-sm">
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="text-gray-500">계약</span>
|
||||
<span className="font-medium">{contract?.title || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="text-gray-500">서명자</span>
|
||||
<span className="font-medium">{signer?.name || '-'}</span>
|
||||
</div>
|
||||
{signer?.signed_at && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">서명일시</span>
|
||||
<span className="font-medium">{signer.signed_at?.slice(0,16).replace('T', ' ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 mt-6">
|
||||
SAM E-Sign 전자계약 서명 시스템
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('esign-done-root'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
228
resources/views/esign/sign/sign.blade.php
Normal file
228
resources/views/esign/sign/sign.blade.php
Normal file
@@ -0,0 +1,228 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>전자서명 - SAM E-Sign</title>
|
||||
@vite(['resources/css/app.css'])
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<div id="esign-sign-root" data-token="{{ $token }}"></div>
|
||||
|
||||
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/signature_pad@4.1.7/dist/signature_pad.umd.min.js"></script>
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect, useCallback, useRef } = React;
|
||||
|
||||
const TOKEN = document.getElementById('esign-sign-root')?.dataset.token;
|
||||
const API = window.SAM_CONFIG?.apiBaseUrl || '{{ config("services.api.base_url", "") }}';
|
||||
const API_KEY = '{{ config("services.api.key", "") }}';
|
||||
|
||||
const App = () => {
|
||||
const [contract, setContract] = useState(null);
|
||||
const [signer, setSigner] = useState(null);
|
||||
const [step, setStep] = useState('document'); // document, sign, confirm
|
||||
const [consent, setConsent] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [signatureData, setSignatureData] = useState(null);
|
||||
const canvasRef = useRef(null);
|
||||
const padRef = useRef(null);
|
||||
|
||||
// 계약 정보 로드
|
||||
const fetchContract = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/v1/esign/sign/${TOKEN}`, {
|
||||
headers: { 'Accept': 'application/json', 'X-API-Key': API_KEY },
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
setContract(json.data.contract);
|
||||
setSigner(json.data.signer);
|
||||
} else {
|
||||
setError(json.message);
|
||||
}
|
||||
} catch (e) { setError('서버 연결 실패'); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchContract(); }, [fetchContract]);
|
||||
|
||||
// signature_pad 초기화
|
||||
useEffect(() => {
|
||||
if (step === 'sign' && canvasRef.current && !padRef.current) {
|
||||
const canvas = canvasRef.current;
|
||||
canvas.width = canvas.offsetWidth * 2;
|
||||
canvas.height = canvas.offsetHeight * 2;
|
||||
canvas.getContext('2d').scale(2, 2);
|
||||
padRef.current = new SignaturePad(canvas, {
|
||||
backgroundColor: 'rgb(255, 255, 255)',
|
||||
penColor: 'rgb(0, 0, 0)',
|
||||
});
|
||||
}
|
||||
}, [step]);
|
||||
|
||||
const clearSignature = () => {
|
||||
if (padRef.current) padRef.current.clear();
|
||||
};
|
||||
|
||||
const goToSign = () => {
|
||||
if (!consent) { alert('동의 항목을 체크해 주세요.'); return; }
|
||||
setStep('sign');
|
||||
};
|
||||
|
||||
const confirmSignature = () => {
|
||||
if (!padRef.current || padRef.current.isEmpty()) {
|
||||
alert('서명을 입력해 주세요.'); return;
|
||||
}
|
||||
const data = padRef.current.toDataURL('image/png');
|
||||
setSignatureData(data.replace('data:image/png;base64,', ''));
|
||||
setStep('confirm');
|
||||
};
|
||||
|
||||
const submitSignature = async () => {
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`${API}/api/v1/esign/sign/${TOKEN}/submit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
|
||||
body: JSON.stringify({ signature_image: signatureData }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
location.href = `/esign/sign/${TOKEN}/done`;
|
||||
} else {
|
||||
setError(json.message || '서명 제출에 실패했습니다.');
|
||||
setStep('sign');
|
||||
}
|
||||
} catch (e) { setError('서버 오류'); }
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const rejectContract = async () => {
|
||||
const reason = prompt('거절 사유를 입력해 주세요:');
|
||||
if (!reason) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/v1/esign/sign/${TOKEN}/reject`, {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
alert('서명이 거절되었습니다.');
|
||||
location.href = `/esign/sign/${TOKEN}/done`;
|
||||
} else { alert(json.message); }
|
||||
} catch (e) { alert('서버 오류'); }
|
||||
};
|
||||
|
||||
if (!contract) return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<p className="text-gray-400">{error || '로딩 중...'}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* 상단 바 */}
|
||||
<div className="bg-white border-b px-4 py-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-semibold text-gray-900">{contract.title}</h1>
|
||||
<p className="text-xs text-gray-500">{signer?.name} 님의 전자서명</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{step !== 'confirm' && (
|
||||
<button onClick={rejectContract} className="px-3 py-1.5 text-sm border border-red-300 text-red-600 rounded-lg hover:bg-red-50">
|
||||
거절
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="bg-red-50 border-b border-red-200 text-red-700 px-4 py-2 text-sm">{error}</div>}
|
||||
|
||||
{/* 문서 확인 단계 */}
|
||||
{step === 'document' && (
|
||||
<div className="flex-1 flex flex-col p-4 max-w-2xl mx-auto w-full">
|
||||
<div className="flex-1 bg-white rounded-lg border p-6 mb-4">
|
||||
<h2 className="text-lg font-semibold mb-4">계약 문서 확인</h2>
|
||||
<div className="bg-gray-100 rounded-lg p-8 text-center mb-4">
|
||||
<p className="text-gray-500 mb-3">PDF 문서</p>
|
||||
<a href={`${API}/api/v1/esign/sign/${TOKEN}/document`} target="_blank"
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm">
|
||||
문서 열기 / 다운로드
|
||||
</a>
|
||||
</div>
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" checked={consent} onChange={e => setConsent(e.target.checked)}
|
||||
className="mt-0.5 w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
|
||||
<span className="text-sm text-gray-700">
|
||||
위 계약서의 내용을 확인하였으며, 전자서명에 동의합니다.
|
||||
전자서명법에 의거하여 전자서명은 자필서명과 동일한 법적 효력을 가집니다.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={goToSign} disabled={!consent}
|
||||
className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium disabled:opacity-50">
|
||||
서명하기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 서명 단계 */}
|
||||
{step === 'sign' && (
|
||||
<div className="flex-1 flex flex-col p-4 max-w-2xl mx-auto w-full">
|
||||
<div className="flex-1 bg-white rounded-lg border p-6 mb-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">서명 입력</h2>
|
||||
<button onClick={clearSignature} className="text-sm text-gray-500 hover:text-gray-700">지우기</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-3">아래 영역에 서명을 입력해 주세요.</p>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg overflow-hidden" style={{height: '200px'}}>
|
||||
<canvas ref={canvasRef} style={{width: '100%', height: '100%', touchAction: 'none'}} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => setStep('document')} className="flex-1 py-3 border rounded-lg text-gray-700 hover:bg-gray-50">
|
||||
이전
|
||||
</button>
|
||||
<button onClick={confirmSignature} className="flex-1 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||
서명 확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 확인 단계 */}
|
||||
{step === 'confirm' && (
|
||||
<div className="flex-1 flex flex-col p-4 max-w-2xl mx-auto w-full">
|
||||
<div className="flex-1 bg-white rounded-lg border p-6 mb-4">
|
||||
<h2 className="text-lg font-semibold mb-4">서명 확인</h2>
|
||||
<p className="text-sm text-gray-600 mb-4">아래 서명이 맞는지 확인해 주세요.</p>
|
||||
<div className="border rounded-lg p-4 bg-gray-50 flex items-center justify-center" style={{height: '200px'}}>
|
||||
{signatureData && <img src={`data:image/png;base64,${signatureData}`} alt="서명" className="max-h-full" />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => { setStep('sign'); padRef.current = null; }} className="flex-1 py-3 border rounded-lg text-gray-700 hover:bg-gray-50">
|
||||
다시 서명
|
||||
</button>
|
||||
<button onClick={submitSignature} disabled={submitting}
|
||||
className="flex-1 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium disabled:opacity-50">
|
||||
{submitting ? '제출 중...' : '서명 제출'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('esign-sign-root'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,6 +3,8 @@
|
||||
use App\Http\Controllers\Api\BusinessCardOcrController;
|
||||
use App\Http\Controllers\ApiLogController;
|
||||
use App\Http\Controllers\AppVersionController;
|
||||
use App\Http\Controllers\ESign\EsignController;
|
||||
use App\Http\Controllers\ESign\EsignPublicController;
|
||||
use App\Http\Controllers\ArchivedRecordController;
|
||||
use App\Http\Controllers\AuditLogController;
|
||||
use App\Http\Controllers\Auth\LoginController;
|
||||
@@ -1386,4 +1388,24 @@
|
||||
Route::post('/{id}/diarize', [MeetingMinuteController::class, 'diarize'])->name('diarize');
|
||||
Route::get('/{id}/download-audio', [MeetingMinuteController::class, 'downloadAudio'])->name('download-audio');
|
||||
});
|
||||
|
||||
// 전자계약 (E-Sign) - 인증 필요
|
||||
Route::prefix('esign')->name('esign.')->group(function () {
|
||||
Route::get('/', [EsignController::class, 'dashboard'])->name('dashboard');
|
||||
Route::get('/create', [EsignController::class, 'create'])->name('create');
|
||||
Route::get('/{id}', [EsignController::class, 'detail'])->whereNumber('id')->name('detail');
|
||||
Route::get('/{id}/fields', [EsignController::class, 'fields'])->whereNumber('id')->name('fields');
|
||||
Route::get('/{id}/send', [EsignController::class, 'send'])->whereNumber('id')->name('send');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Public E-Sign Routes (인증 불필요 - 서명자용)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
Route::prefix('esign/sign')->group(function () {
|
||||
Route::get('/{token}', [EsignPublicController::class, 'auth'])->name('esign.sign.auth');
|
||||
Route::get('/{token}/sign', [EsignPublicController::class, 'sign'])->name('esign.sign.do');
|
||||
Route::get('/{token}/done', [EsignPublicController::class, 'done'])->name('esign.sign.done');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user