getContract API가 항상 데이터를 반환하되 is_signable 플래그로 서명 가능 여부를 전달하도록 변경. done 페이지에서 signed/completed 상태의 계약도 정상적으로 정보를 표시할 수 있도록 수정. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
289 lines
14 KiB
PHP
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>
|