Files
sam-manage/resources/views/esign/sign/sign.blade.php
김보곤 36627b976d fix:전자서명 done 페이지 계약/서명자 정보 표시 수정
getContract API가 항상 데이터를 반환하되 is_signable 플래그로
서명 가능 여부를 전달하도록 변경. done 페이지에서 signed/completed
상태의 계약도 정상적으로 정보를 표시할 수 있도록 수정.

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

289 lines
14 KiB
PHP

<!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.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>
<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 STYLES = {
canvasWrap: { height: '200px' },
canvas: { width: '100%', height: '100%', touchAction: 'none', background: '#fff' },
previewWrap: { height: '200px' },
};
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 [signMode, setSignMode] = useState('signature'); // 'signature' | 'stamp'
const canvasRef = useRef(null);
const padRef = useRef(null);
// 계약 정보 로드
const fetchContract = useCallback(async () => {
try {
const res = await fetch(`/esign/sign/${TOKEN}/api/contract`, {
headers: { 'Accept': 'application/json' },
});
const json = await res.json();
if (json.success) {
setContract(json.data.contract);
setSigner(json.data.signer);
// 서명 불가 상태면 에러 표시
if (!json.data.is_signable && json.data.status_message) {
setError(json.data.status_message);
}
} 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: 'rgba(0,0,0,0)',
penColor: 'rgb(0, 0, 0)',
});
}
}, [step]);
const clearSignature = () => {
if (padRef.current) padRef.current.clear();
};
const goToSign = () => {
if (!consent) { alert('동의 항목을 체크해 주세요.'); return; }
if (signMode === 'stamp') {
// 도장 모드: 서명 pad 건너뛰고 바로 제출
submitStamp();
} else {
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 submitStamp = async () => {
setSubmitting(true);
setError('');
try {
const res = await fetch(`/esign/sign/${TOKEN}/api/submit`, {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ use_stamp: true }),
});
const json = await res.json();
if (json.success) {
location.href = `/esign/sign/${TOKEN}/done`;
} else {
setError(json.message || '도장 제출에 실패했습니다.');
}
} catch (e) { setError('서버 오류'); }
setSubmitting(false);
};
const submitSignature = async () => {
setSubmitting(true);
setError('');
try {
const res = await fetch(`/esign/sign/${TOKEN}/api/submit`, {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
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(`/esign/sign/${TOKEN}/api/reject`, {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
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 flex-col p-4 max-w-2xl mx-auto w-full">
<div className="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={`/esign/sign/${TOKEN}/api/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>
{signer?.has_stamp && (
<div className="border-t pt-4 mt-4">
<p className="text-sm font-medium text-gray-700 mb-3">서명 방법을 선택해 주세요.</p>
<div className="flex gap-3">
<label className={`flex-1 flex items-center gap-2 p-3 border-2 rounded-lg cursor-pointer transition-colors ${signMode === 'signature' ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'}`}>
<input type="radio" name="signMode" value="signature" checked={signMode === 'signature'}
onChange={() => setSignMode('signature')} className="text-blue-600 focus:ring-blue-500" />
<div>
<span className="text-sm font-medium text-gray-900">직접 서명</span>
<p className="text-xs text-gray-500">터치/마우스로 서명</p>
</div>
</label>
<label className={`flex-1 flex items-center gap-2 p-3 border-2 rounded-lg cursor-pointer transition-colors ${signMode === 'stamp' ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'}`}>
<input type="radio" name="signMode" value="stamp" checked={signMode === 'stamp'}
onChange={() => setSignMode('stamp')} className="text-blue-600 focus:ring-blue-500" />
<div>
<span className="text-sm font-medium text-gray-900">법인도장</span>
<p className="text-xs text-gray-500">등록된 도장 사용</p>
</div>
</label>
</div>
</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 || submitting}
className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium disabled:opacity-50">
{submitting ? '처리 중...' : (signMode === 'stamp' && signer?.has_stamp ? '도장 찍기' : '서명하기')}
</button>
</div>
)}
{/* 서명 단계 */}
{step === 'sign' && (
<div className="flex flex-col p-4 max-w-2xl mx-auto w-full">
<div className="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={STYLES.canvasWrap}>
<canvas ref={canvasRef} style={STYLES.canvas} />
</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 flex-col p-4 max-w-2xl mx-auto w-full">
<div className="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={STYLES.previewWrap}>
{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.createRoot(document.getElementById('esign-sign-root')).render(<App />);
</script>
</body>
</html>