feat: [esign] 전자서명 고도화 — 서명 플로우 연동 + 계약 생성 마법사 + 감사 로그
- esign_contracts: verification_required, verification_template_id 컬럼 추가 - esign_signers: verification_status, verification_passed_at 컬럼 추가 - 서명 플로우: OTP 인증 → 필기 확인(verification.blade.php) → 서명/도장 - auth.blade.php: verification_required 시 필기 확인 페이지로 리다이렉트 - create.blade.php: 필기 확인 사용 토글 + 템플릿 선택 UI - EsignApiController: store()에 verification 필드 저장 - EsignPublicController: getVerification(), submitVerification() 추가 - 감사 로그: verification_viewed, verification_attempted, verification_passed, verification_completed 액션
This commit is contained in:
@@ -532,6 +532,8 @@ public function store(Request $request): JsonResponse
|
||||
'expires_at' => $request->input('expires_at')
|
||||
? \Carbon\Carbon::parse($request->input('expires_at'))
|
||||
: now()->addDays($request->input('expires_days', 30)),
|
||||
'verification_required' => (bool) $request->input('verification_required', false),
|
||||
'verification_template_id' => $request->input('verification_template_id') ?: null,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
@@ -137,6 +137,7 @@ public function getContract(string $token): JsonResponse
|
||||
'send_method' => $contract->tenant_id == 1 ? 'email' : ($contract->send_method ?? 'email'),
|
||||
'is_signable' => $isSignable,
|
||||
'status_message' => $statusMessage,
|
||||
'verification_required' => (bool) $contract->verification_required,
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -1065,4 +1066,165 @@ private function applyCompanyStamp(EsignSigner $signer, int $tenantId): ?string
|
||||
|
||||
return $localPath;
|
||||
}
|
||||
|
||||
// ─── 필기 확인 (Handwriting Verification) ───
|
||||
|
||||
/**
|
||||
* 필기 확인 페이지
|
||||
*/
|
||||
public function verification(string $token): View
|
||||
{
|
||||
return view('esign.sign.verification', ['token' => $token]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 필기 확인 단계 정보 조회
|
||||
*/
|
||||
public function getVerification(string $token): JsonResponse
|
||||
{
|
||||
$signer = $this->findSigner($token);
|
||||
if (! $signer) {
|
||||
return response()->json(['success' => false, 'message' => '유효하지 않은 서명 링크입니다.'], 404);
|
||||
}
|
||||
|
||||
$contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id);
|
||||
if (! $contract || ! $contract->verification_required) {
|
||||
return response()->json(['success' => false, 'message' => '필기 확인이 필요하지 않은 계약입니다.'], 400);
|
||||
}
|
||||
|
||||
$service = app(\App\Services\ESign\HandwritingVerificationService::class);
|
||||
$templateId = $contract->verification_template_id;
|
||||
$steps = $service->getVerificationSteps($contract->id, $signer->id, $templateId);
|
||||
$allPassed = $service->isAllStepsPassed($contract->id, $signer->id, $steps);
|
||||
|
||||
// 이미 모두 통과한 경우 서명자 상태 업데이트
|
||||
if ($allPassed && $signer->verification_status !== 'passed') {
|
||||
$signer->update([
|
||||
'verification_status' => 'passed',
|
||||
'verification_passed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// 감사 로그
|
||||
EsignAuditLog::create([
|
||||
'tenant_id' => $contract->tenant_id,
|
||||
'contract_id' => $contract->id,
|
||||
'signer_id' => $signer->id,
|
||||
'action' => 'verification_viewed',
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => substr(request()->userAgent() ?? '', 0, 500),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'contract_title' => $contract->title,
|
||||
'signer_name' => $signer->name,
|
||||
'steps' => $steps,
|
||||
'all_passed' => $allPassed,
|
||||
'max_attempts' => $contract->verificationTemplate
|
||||
? $contract->verificationTemplate->max_attempts
|
||||
: (int) config('esign.handwriting_verification.max_attempts', 5),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 필기 인식 제출
|
||||
*/
|
||||
public function submitVerification(string $token): JsonResponse
|
||||
{
|
||||
$signer = $this->findSigner($token);
|
||||
if (! $signer) {
|
||||
return response()->json(['success' => false, 'message' => '유효하지 않은 서명 링크입니다.'], 404);
|
||||
}
|
||||
|
||||
$contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id);
|
||||
if (! $contract || ! $contract->verification_required) {
|
||||
return response()->json(['success' => false, 'message' => '필기 확인이 필요하지 않은 계약입니다.'], 400);
|
||||
}
|
||||
|
||||
$request = request();
|
||||
$request->validate([
|
||||
'step_order' => 'required|integer|min:1',
|
||||
'handwriting_image' => 'required|string',
|
||||
]);
|
||||
|
||||
$service = app(\App\Services\ESign\HandwritingVerificationService::class);
|
||||
$templateId = $contract->verification_template_id;
|
||||
$steps = $service->getVerificationSteps($contract->id, $signer->id, $templateId);
|
||||
|
||||
$stepOrder = (int) $request->input('step_order');
|
||||
$currentStep = collect($steps)->firstWhere('order', $stepOrder);
|
||||
|
||||
if (! $currentStep) {
|
||||
return response()->json(['success' => false, 'message' => '잘못된 단계입니다.'], 400);
|
||||
}
|
||||
|
||||
$maxAttempts = $contract->verificationTemplate
|
||||
? $contract->verificationTemplate->max_attempts
|
||||
: (int) config('esign.handwriting_verification.max_attempts', 5);
|
||||
|
||||
$result = $service->submitHandwriting(
|
||||
tenantId: $contract->tenant_id,
|
||||
contractId: $contract->id,
|
||||
signerId: $signer->id,
|
||||
stepOrder: $stepOrder,
|
||||
promptText: $currentStep['text'],
|
||||
base64Image: $request->input('handwriting_image'),
|
||||
threshold: $currentStep['threshold'] ?? 80.0,
|
||||
maxAttempts: $maxAttempts,
|
||||
);
|
||||
|
||||
// 감사 로그
|
||||
EsignAuditLog::create([
|
||||
'tenant_id' => $contract->tenant_id,
|
||||
'contract_id' => $contract->id,
|
||||
'signer_id' => $signer->id,
|
||||
'action' => $result['is_passed'] ? 'verification_passed' : 'verification_attempted',
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => substr(request()->userAgent() ?? '', 0, 500),
|
||||
'metadata' => [
|
||||
'step_order' => $stepOrder,
|
||||
'similarity_score' => $result['similarity_score'],
|
||||
'is_passed' => $result['is_passed'],
|
||||
'attempt_number' => $result['attempt_number'],
|
||||
],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
// 모든 단계 통과 시 서명자 상태 업데이트
|
||||
if ($result['is_passed']) {
|
||||
$allSteps = $service->getVerificationSteps($contract->id, $signer->id, $templateId);
|
||||
if ($service->isAllStepsPassed($contract->id, $signer->id, $allSteps)) {
|
||||
$signer->update([
|
||||
'verification_status' => 'passed',
|
||||
'verification_passed_at' => now(),
|
||||
]);
|
||||
|
||||
EsignAuditLog::create([
|
||||
'tenant_id' => $contract->tenant_id,
|
||||
'contract_id' => $contract->id,
|
||||
'signer_id' => $signer->id,
|
||||
'action' => 'verification_completed',
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => substr(request()->userAgent() ?? '', 0, 500),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'recognized_text' => $result['recognized_text'],
|
||||
'similarity_score' => $result['similarity_score'],
|
||||
'is_passed' => $result['is_passed'],
|
||||
'attempt_number' => $result['attempt_number'],
|
||||
'attempts_remaining' => $result['attempts_remaining'],
|
||||
'hints' => array_map(fn ($h) => is_array($h) ? $h['message'] : $h, $result['hints'] ?? []),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models\ESign;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
@@ -28,6 +29,8 @@ class EsignContract extends Model
|
||||
'send_method',
|
||||
'sms_fallback',
|
||||
'completion_template_name',
|
||||
'verification_required',
|
||||
'verification_template_id',
|
||||
'metadata',
|
||||
'expires_at',
|
||||
'completed_at',
|
||||
@@ -39,6 +42,8 @@ class EsignContract extends Model
|
||||
protected $casts = [
|
||||
'original_file_size' => 'integer',
|
||||
'sms_fallback' => 'boolean',
|
||||
'verification_required' => 'boolean',
|
||||
'verification_template_id' => 'integer',
|
||||
'metadata' => 'array',
|
||||
'expires_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
@@ -54,6 +59,11 @@ public function signFields(): HasMany
|
||||
return $this->hasMany(EsignSignField::class, 'contract_id');
|
||||
}
|
||||
|
||||
public function verificationTemplate(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EsignVerificationTemplate::class, 'verification_template_id');
|
||||
}
|
||||
|
||||
public function auditLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(EsignAuditLog::class, 'contract_id');
|
||||
|
||||
@@ -47,6 +47,8 @@ class EsignSigner extends Model
|
||||
'sign_user_agent',
|
||||
'status',
|
||||
'rejected_reason',
|
||||
'verification_status',
|
||||
'verification_passed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -57,6 +59,7 @@ class EsignSigner extends Model
|
||||
'auth_verified_at' => 'datetime',
|
||||
'signed_at' => 'datetime',
|
||||
'consent_agreed_at' => 'datetime',
|
||||
'verification_passed_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
|
||||
@@ -547,6 +547,9 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
|
||||
const [existingFileName, setExistingFileName] = useState('');
|
||||
const [editFields, setEditFields] = useState([]);
|
||||
const [editSigners, setEditSigners] = useState([]);
|
||||
const [verificationRequired, setVerificationRequired] = useState(false);
|
||||
const [verificationTemplateId, setVerificationTemplateId] = useState('');
|
||||
const [verificationTemplates, setVerificationTemplates] = useState([]);
|
||||
const fileRef = useRef(null);
|
||||
|
||||
const hasTemplates = templates.length > 0;
|
||||
@@ -563,6 +566,10 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
|
||||
.then(r => r.json())
|
||||
.then(json => { if (json.success) setTemplates(json.data); })
|
||||
.catch(() => {});
|
||||
fetch('/esign-verification/api/templates', { headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' } })
|
||||
.then(r => r.json())
|
||||
.then(json => { if (json.success) setVerificationTemplates(json.data || []); })
|
||||
.catch(() => {});
|
||||
|
||||
// 수정 모드: 기존 계약 데이터 로드
|
||||
if (IS_EDIT) {
|
||||
@@ -1149,6 +1156,8 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i =
|
||||
Object.entries(form).forEach(([k, v]) => { if (v) fd.append(k, v); });
|
||||
if (file) fd.append('file', file);
|
||||
if (!IS_EDIT && templateId) fd.append('template_id', templateId);
|
||||
fd.append('verification_required', verificationRequired ? '1' : '0');
|
||||
if (verificationRequired && verificationTemplateId) fd.append('verification_template_id', verificationTemplateId);
|
||||
if (!IS_EDIT && Object.keys(metadata).length > 0) {
|
||||
Object.entries(metadata).forEach(([k, v]) => { fd.append(`metadata[${k}]`, v || ''); });
|
||||
}
|
||||
@@ -1264,6 +1273,32 @@ className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:
|
||||
<p className="text-[11px] text-gray-400 mt-1">기본값: 오늘로부터 7일 후</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필기 확인 (고도화) */}
|
||||
<div className={`rounded-lg p-3 border ${verificationRequired ? 'bg-blue-50 border-blue-200' : 'bg-gray-50 border-gray-200'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={verificationRequired} onChange={e => setVerificationRequired(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
|
||||
<span className={`text-xs font-semibold ${verificationRequired ? 'text-blue-800' : 'text-gray-600'}`}>필기 문구 확인</span>
|
||||
</label>
|
||||
<span className="text-[11px] text-gray-400">서명 전 자필 문구 작성 + 인식 검증 (보험사 방식)</span>
|
||||
</div>
|
||||
{verificationRequired && (
|
||||
<div className="mt-2">
|
||||
<select value={verificationTemplateId} onChange={e => setVerificationTemplateId(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
|
||||
<option value="">기본 (본인은 위 내용을 확인하였습니다)</option>
|
||||
{verificationTemplates.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name} ({(t.steps || []).length}단계)</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[11px] text-gray-400 mt-1">
|
||||
<a href="/esign-verification/templates" target="_blank" className="text-blue-500 hover:underline">템플릿 관리</a>에서 확인 문구를 설정할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
const [otpSending, setOtpSending] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [verificationRequired, setVerificationRequired] = useState(false);
|
||||
|
||||
const fetchContract = useCallback(async () => {
|
||||
try {
|
||||
@@ -41,6 +42,7 @@
|
||||
setContract(json.data.contract);
|
||||
setSigner(json.data.signer);
|
||||
setSendMethod(json.data.send_method || 'email');
|
||||
setVerificationRequired(!!json.data.verification_required);
|
||||
setStep('info');
|
||||
} else {
|
||||
setError(json.message || '계약 정보를 불러올 수 없습니다.');
|
||||
@@ -84,7 +86,9 @@
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
sessionStorage.setItem('esign_session_token', json.data.sign_session_token);
|
||||
location.href = `/esign/sign/${TOKEN}/sign`;
|
||||
location.href = verificationRequired
|
||||
? `/esign/sign/${TOKEN}/verification`
|
||||
: `/esign/sign/${TOKEN}/sign`;
|
||||
} else {
|
||||
setError(json.message || '인증 코드가 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
374
resources/views/esign/sign/verification.blade.php
Normal file
374
resources/views/esign/sign/verification.blade.php
Normal file
@@ -0,0 +1,374 @@
|
||||
<!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-verification-root" data-token="{{ $token }}"></div>
|
||||
|
||||
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
<script>
|
||||
(function(){var w=console.warn;console.warn=function(){if(typeof arguments[0]==='string'&&arguments[0].indexOf('in-browser Babel')>-1)return;w.apply(console,arguments)};})();
|
||||
</script>
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
@verbatim
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect, useCallback, useRef } = React;
|
||||
|
||||
const TOKEN = document.getElementById('esign-verification-root')?.dataset.token;
|
||||
|
||||
const STYLES = {
|
||||
container: { maxWidth: '500px', margin: '0 auto', padding: '16px' },
|
||||
card: { background: '#fff', borderRadius: '16px', boxShadow: '0 1px 3px rgba(0,0,0,0.1)', padding: '24px', marginBottom: '16px' },
|
||||
header: { textAlign: 'center', marginBottom: '24px' },
|
||||
stepBadge: { display: 'inline-flex', alignItems: 'center', gap: '6px', padding: '4px 12px', borderRadius: '999px', background: '#EFF6FF', color: '#2563EB', fontSize: '13px', fontWeight: '600', marginBottom: '12px' },
|
||||
guideBox: { padding: '16px', background: '#F0F9FF', borderRadius: '12px', border: '1px solid #BAE6FD', textAlign: 'center', marginBottom: '16px' },
|
||||
guideText: { fontSize: '18px', fontWeight: '700', color: '#0C4A6E', marginTop: '4px' },
|
||||
canvasWrap: { border: '2px solid #D1D5DB', borderRadius: '12px', overflow: 'hidden', background: '#fff', marginBottom: '16px' },
|
||||
canvas: { display: 'block', width: '100%', touchAction: 'none' },
|
||||
btnRow: { display: 'flex', gap: '8px', justifyContent: 'flex-end' },
|
||||
btnPrimary: { padding: '12px 28px', background: '#2563EB', color: '#fff', border: 'none', borderRadius: '10px', fontSize: '15px', fontWeight: '600', cursor: 'pointer' },
|
||||
btnSecondary: { padding: '12px 28px', background: '#F3F4F6', color: '#374151', border: '1px solid #D1D5DB', borderRadius: '10px', fontSize: '15px', fontWeight: '500', cursor: 'pointer' },
|
||||
resultBox: (passed) => ({ padding: '16px', borderRadius: '12px', background: passed ? '#ECFDF5' : '#FEF2F2', border: `1px solid ${passed ? '#10B981' : '#EF4444'}`, marginBottom: '16px' }),
|
||||
scoreText: (passed) => ({ fontSize: '28px', fontWeight: '700', color: passed ? '#065F46' : '#991B1B' }),
|
||||
progressBar: { width: '100%', height: '8px', background: '#E5E7EB', borderRadius: '4px', overflow: 'hidden', marginBottom: '12px' },
|
||||
};
|
||||
|
||||
// ─── Handwriting Canvas ───
|
||||
const HandwritingCanvas = ({ guideText, onDataChange, canvasRef }) => {
|
||||
const containerRef = useRef(null);
|
||||
const drawingRef = useRef(false);
|
||||
const lastPtRef = useRef(null);
|
||||
|
||||
const getPoint = (e) => {
|
||||
const r = canvasRef.current.getBoundingClientRect();
|
||||
const t = e.touches ? e.touches[0] : e;
|
||||
return { x: t.clientX - r.left, y: t.clientY - r.top };
|
||||
};
|
||||
|
||||
const initCanvas = useCallback(() => {
|
||||
const c = canvasRef.current;
|
||||
if (!c || !containerRef.current) return;
|
||||
c.width = containerRef.current.offsetWidth;
|
||||
c.height = 160;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(0, 0, c.width, c.height);
|
||||
if (guideText) {
|
||||
ctx.save();
|
||||
ctx.font = '20px "Pretendard", "Malgun Gothic", sans-serif';
|
||||
ctx.fillStyle = 'rgba(156,163,175,0.2)';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(guideText, c.width / 2, c.height / 2);
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(229,231,235,0.5)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(16, c.height / 2 + 16);
|
||||
ctx.lineTo(c.width - 16, c.height / 2 + 16);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.restore();
|
||||
onDataChange(null);
|
||||
}, [guideText, onDataChange]);
|
||||
|
||||
const startDraw = (e) => { e.preventDefault(); drawingRef.current = true; lastPtRef.current = getPoint(e); };
|
||||
const draw = (e) => {
|
||||
e.preventDefault();
|
||||
if (!drawingRef.current) return;
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
const pt = getPoint(e);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lastPtRef.current.x, lastPtRef.current.y);
|
||||
ctx.lineTo(pt.x, pt.y);
|
||||
ctx.strokeStyle = '#1F2937';
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.stroke();
|
||||
lastPtRef.current = pt;
|
||||
};
|
||||
const endDraw = () => {
|
||||
if (drawingRef.current) {
|
||||
drawingRef.current = false;
|
||||
onDataChange(canvasRef.current.toDataURL('image/png'));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { initCanvas(); }, [initCanvas]);
|
||||
|
||||
useEffect(() => {
|
||||
const c = canvasRef.current;
|
||||
if (!c) return;
|
||||
const opts = { passive: false };
|
||||
c.addEventListener('mousedown', startDraw);
|
||||
c.addEventListener('mousemove', draw);
|
||||
c.addEventListener('mouseup', endDraw);
|
||||
c.addEventListener('mouseleave', endDraw);
|
||||
c.addEventListener('touchstart', startDraw, opts);
|
||||
c.addEventListener('touchmove', draw, opts);
|
||||
c.addEventListener('touchend', endDraw);
|
||||
return () => {
|
||||
c.removeEventListener('mousedown', startDraw);
|
||||
c.removeEventListener('mousemove', draw);
|
||||
c.removeEventListener('mouseup', endDraw);
|
||||
c.removeEventListener('mouseleave', endDraw);
|
||||
c.removeEventListener('touchstart', startDraw);
|
||||
c.removeEventListener('touchmove', draw);
|
||||
c.removeEventListener('touchend', endDraw);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={STYLES.canvasWrap}>
|
||||
<canvas ref={canvasRef} style={STYLES.canvas} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Main App ───
|
||||
const App = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [steps, setSteps] = useState([]);
|
||||
const [currentStepIdx, setCurrentStepIdx] = useState(0);
|
||||
const [imageData, setImageData] = useState(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [contractTitle, setContractTitle] = useState('');
|
||||
const [signerName, setSignerName] = useState('');
|
||||
const canvasRef = useRef(null);
|
||||
|
||||
// 필기 확인 단계 로드
|
||||
const fetchVerification = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/esign/sign/${TOKEN}/api/verification`, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
setSteps(json.data.steps || []);
|
||||
setContractTitle(json.data.contract_title || '');
|
||||
setSignerName(json.data.signer_name || '');
|
||||
// 이미 모든 단계 통과했으면 서명 페이지로
|
||||
if (json.data.all_passed) {
|
||||
window.location.href = `/esign/sign/${TOKEN}/sign`;
|
||||
return;
|
||||
}
|
||||
// 첫 미통과 단계로 이동
|
||||
const idx = (json.data.steps || []).findIndex(s => !s.is_passed);
|
||||
setCurrentStepIdx(idx >= 0 ? idx : 0);
|
||||
} else {
|
||||
setError(json.message || '확인 정보를 불러올 수 없습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
setError('서버 연결 실패');
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchVerification(); }, [fetchVerification]);
|
||||
|
||||
const currentStep = steps[currentStepIdx];
|
||||
|
||||
const handleClear = () => {
|
||||
const c = canvasRef.current;
|
||||
if (!c) return;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(0, 0, c.width, c.height);
|
||||
if (currentStep?.text) {
|
||||
ctx.save();
|
||||
ctx.font = '20px "Pretendard", "Malgun Gothic", sans-serif';
|
||||
ctx.fillStyle = 'rgba(156,163,175,0.2)';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(currentStep.text, c.width / 2, c.height / 2);
|
||||
ctx.restore();
|
||||
}
|
||||
setImageData(null);
|
||||
setResult(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!imageData || !currentStep) return;
|
||||
setSubmitting(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await fetch(`/esign/sign/${TOKEN}/api/verification/submit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
step_order: currentStep.order,
|
||||
handwriting_image: imageData,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
setResult(json.data);
|
||||
if (json.data.is_passed) {
|
||||
// 단계 통과 → steps 업데이트
|
||||
setSteps(prev => prev.map((s, i) => i === currentStepIdx ? { ...s, is_passed: true } : s));
|
||||
// 다음 단계 또는 서명 페이지로
|
||||
setTimeout(() => {
|
||||
const nextIdx = steps.findIndex((s, i) => i > currentStepIdx && !s.is_passed);
|
||||
if (nextIdx >= 0) {
|
||||
setCurrentStepIdx(nextIdx);
|
||||
setResult(null);
|
||||
setImageData(null);
|
||||
} else {
|
||||
// 모든 단계 완료 → 서명 페이지
|
||||
window.location.href = `/esign/sign/${TOKEN}/sign`;
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
} else {
|
||||
setError(json.message || '인식에 실패했습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
setError('서버 연결 실패: ' + e.message);
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={STYLES.container}>
|
||||
<div style={{ ...STYLES.card, textAlign: 'center', padding: '60px 24px' }}>
|
||||
<div style={{ fontSize: '14px', color: '#6B7280' }}>로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !currentStep) {
|
||||
return (
|
||||
<div style={STYLES.container}>
|
||||
<div style={{ ...STYLES.card, textAlign: 'center', padding: '40px 24px' }}>
|
||||
<div style={{ fontSize: '40px', marginBottom: '12px' }}>⚠️</div>
|
||||
<div style={{ fontSize: '15px', color: '#991B1B' }}>{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const passedCount = steps.filter(s => s.is_passed).length;
|
||||
|
||||
return (
|
||||
<div style={STYLES.container}>
|
||||
{/* 헤더 */}
|
||||
<div style={STYLES.header}>
|
||||
<div style={{ fontSize: '13px', color: '#6B7280', marginBottom: '4px' }}>{contractTitle}</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '700', color: '#1F2937' }}>필기 확인</div>
|
||||
<div style={{ fontSize: '13px', color: '#6B7280', marginTop: '2px' }}>{signerName}님</div>
|
||||
</div>
|
||||
|
||||
{/* 진행 상태 */}
|
||||
<div style={STYLES.card}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<span style={STYLES.stepBadge}>
|
||||
Step {currentStepIdx + 1} / {steps.length}
|
||||
</span>
|
||||
<span style={{ fontSize: '13px', color: '#6B7280' }}>
|
||||
{passedCount}/{steps.length} 완료
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 단계 인디케이터 */}
|
||||
<div style={{ display: 'flex', gap: '4px', marginBottom: '16px' }}>
|
||||
{steps.map((s, i) => (
|
||||
<div key={i} style={{
|
||||
flex: 1, height: '4px', borderRadius: '2px',
|
||||
background: s.is_passed ? '#10B981' : i === currentStepIdx ? '#2563EB' : '#E5E7EB'
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 가이드 문구 */}
|
||||
<div style={STYLES.guideBox}>
|
||||
<div style={{ fontSize: '13px', color: '#0369A1' }}>아래 문구를 직접 손으로 써 주세요</div>
|
||||
<div style={STYLES.guideText}>"{currentStep?.text}"</div>
|
||||
</div>
|
||||
|
||||
{/* 캔버스 */}
|
||||
<HandwritingCanvas
|
||||
guideText={currentStep?.text || ''}
|
||||
onDataChange={setImageData}
|
||||
canvasRef={canvasRef}
|
||||
/>
|
||||
|
||||
{/* 결과 표시 */}
|
||||
{result && (
|
||||
<div style={STYLES.resultBox(result.is_passed)}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '15px', fontWeight: '600', color: result.is_passed ? '#065F46' : '#991B1B' }}>
|
||||
{result.is_passed ? '✅ 통과' : '❌ 미통과'}
|
||||
</span>
|
||||
<span style={STYLES.scoreText(result.is_passed)}>
|
||||
{(result.similarity_score || 0).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div style={STYLES.progressBar}>
|
||||
<div style={{
|
||||
width: `${Math.min(result.similarity_score || 0, 100)}%`, height: '100%',
|
||||
background: (result.similarity_score || 0) >= 80 ? '#10B981' : (result.similarity_score || 0) >= 60 ? '#F59E0B' : '#EF4444',
|
||||
borderRadius: '4px', transition: 'width 0.5s ease'
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: '#374151' }}>
|
||||
인식: "{result.recognized_text || '(인식 실패)'}"
|
||||
</div>
|
||||
{result.attempts_remaining !== undefined && !result.is_passed && (
|
||||
<div style={{ fontSize: '12px', color: '#6B7280', marginTop: '4px' }}>
|
||||
남은 시도: {result.attempts_remaining}회
|
||||
</div>
|
||||
)}
|
||||
{result.hints && result.hints.length > 0 && (
|
||||
<div style={{ marginTop: '8px', padding: '8px 12px', background: '#FFFBEB', borderRadius: '8px', border: '1px solid #FDE68A' }}>
|
||||
{result.hints.map((h, i) => (
|
||||
<div key={i} style={{ fontSize: '12px', color: '#78350F' }}>💡 {h}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{result.is_passed && (
|
||||
<div style={{ fontSize: '13px', color: '#065F46', marginTop: '8px', textAlign: 'center' }}>
|
||||
{currentStepIdx < steps.length - 1 ? '다음 단계로 이동합니다...' : '서명 페이지로 이동합니다...'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 버튼 */}
|
||||
<div style={STYLES.btnRow}>
|
||||
<button onClick={handleClear} style={STYLES.btnSecondary}>초기화</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !imageData || (result && result.is_passed)}
|
||||
style={{ ...STYLES.btnPrimary, opacity: (submitting || !imageData || (result && result.is_passed)) ? 0.5 : 1 }}
|
||||
>
|
||||
{submitting ? '인식 중...' : '확인'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div style={{ textAlign: 'center', padding: '8px', fontSize: '12px', color: '#9CA3AF' }}>
|
||||
글씨를 또박또박 쓰면 인식률이 높아집니다
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('esign-verification-root')).render(<App />);
|
||||
</script>
|
||||
@endverbatim
|
||||
</body>
|
||||
</html>
|
||||
@@ -2251,6 +2251,7 @@
|
||||
Route::prefix('esign/sign')->group(function () {
|
||||
// 화면 라우트
|
||||
Route::get('/{token}', [EsignPublicController::class, 'auth'])->name('esign.sign.auth');
|
||||
Route::get('/{token}/verification', [EsignPublicController::class, 'verification'])->name('esign.sign.verification');
|
||||
Route::get('/{token}/sign', [EsignPublicController::class, 'sign'])->name('esign.sign.do');
|
||||
Route::get('/{token}/done', [EsignPublicController::class, 'done'])->name('esign.sign.done');
|
||||
|
||||
@@ -2258,6 +2259,8 @@
|
||||
Route::get('/{token}/api/contract', [EsignPublicController::class, 'getContract'])->name('esign.sign.api.contract');
|
||||
Route::post('/{token}/api/otp/send', [EsignPublicController::class, 'sendOtp'])->name('esign.sign.api.otp.send');
|
||||
Route::post('/{token}/api/otp/verify', [EsignPublicController::class, 'verifyOtp'])->name('esign.sign.api.otp.verify');
|
||||
Route::get('/{token}/api/verification', [EsignPublicController::class, 'getVerification'])->name('esign.sign.api.verification');
|
||||
Route::post('/{token}/api/verification/submit', [EsignPublicController::class, 'submitVerification'])->name('esign.sign.api.verification.submit');
|
||||
Route::post('/{token}/api/submit', [EsignPublicController::class, 'submitSignature'])->name('esign.sign.api.submit');
|
||||
Route::post('/{token}/api/reject', [EsignPublicController::class, 'rejectContract'])->name('esign.sign.api.reject');
|
||||
Route::get('/{token}/api/document', [EsignPublicController::class, 'downloadDocument'])->name('esign.sign.api.document');
|
||||
|
||||
Reference in New Issue
Block a user