407 lines
20 KiB
PHP
407 lines
20 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>
|
|
@verbatim
|
|
<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('stamp'); // 'signature' | 'stamp'
|
|
const [stampPreview, setStampPreview] = useState(null);
|
|
const [stampData, setStampData] = useState(null);
|
|
const canvasRef = useRef(null);
|
|
const padRef = useRef(null);
|
|
const stampInputRef = 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 removeBackground = (img) => {
|
|
// 도장 이미지 최대 크기 제한 (500px) - 용량 절감
|
|
const MAX_SIZE = 500;
|
|
let w = img.naturalWidth, h = img.naturalHeight;
|
|
if (w > MAX_SIZE || h > MAX_SIZE) {
|
|
const ratio = Math.min(MAX_SIZE / w, MAX_SIZE / h);
|
|
w = Math.round(w * ratio);
|
|
h = Math.round(h * ratio);
|
|
}
|
|
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = w;
|
|
canvas.height = h;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(img, 0, 0, w, h);
|
|
|
|
const imageData = ctx.getImageData(0, 0, w, h);
|
|
const data = imageData.data;
|
|
const threshold = 210;
|
|
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
const r = data[i], g = data[i + 1], b = data[i + 2];
|
|
const brightness = r * 0.299 + g * 0.587 + b * 0.114;
|
|
if (brightness > threshold) {
|
|
data[i + 3] = 0;
|
|
} else {
|
|
const fade = Math.max(0, (brightness - (threshold - 40)) / 40);
|
|
if (fade > 0) {
|
|
data[i + 3] = Math.round(data[i + 3] * (1 - fade));
|
|
}
|
|
}
|
|
}
|
|
|
|
ctx.putImageData(imageData, 0, 0);
|
|
return canvas.toDataURL('image/png');
|
|
};
|
|
|
|
const handleStampUpload = (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
alert('5MB 이하의 이미지만 업로드 가능합니다.');
|
|
e.target.value = '';
|
|
return;
|
|
}
|
|
if (!file.type.match(/^image\/(png|jpeg|jpg)$/)) {
|
|
alert('PNG 또는 JPG 이미지만 업로드 가능합니다.');
|
|
e.target.value = '';
|
|
return;
|
|
}
|
|
const reader = new FileReader();
|
|
reader.onload = (ev) => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
const transparentDataUrl = removeBackground(img);
|
|
setStampPreview(transparentDataUrl);
|
|
setStampData(transparentDataUrl.replace('data:image/png;base64,', ''));
|
|
};
|
|
img.src = ev.target.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
};
|
|
|
|
const goToSign = () => {
|
|
if (!consent) { alert('동의 항목을 체크해 주세요.'); return; }
|
|
if (signMode === 'stamp') {
|
|
if (signer?.has_stamp) {
|
|
submitStamp();
|
|
} else {
|
|
submitStampWithUpload();
|
|
}
|
|
} 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 submitStampWithUpload = async () => {
|
|
if (!stampData) { alert('도장 이미지를 업로드해 주세요.'); return; }
|
|
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, stamp_image: stampData }),
|
|
});
|
|
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 || signer?.role === 'counterpart' || signer?.role === 'creator') && (
|
|
<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">{signer?.has_stamp ? '법인도장' : '도장'}</span>
|
|
<p className="text-xs text-gray-500">{signer?.has_stamp ? '등록된 도장 사용' : '도장 이미지 업로드'}</p>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
{signMode === 'stamp' && !signer?.has_stamp && (
|
|
<div className="mt-4 p-4 border-2 border-dashed border-gray-300 rounded-lg">
|
|
{!stampPreview ? (
|
|
<div className="text-center">
|
|
<p className="text-sm text-gray-500 mb-3">도장 이미지를 업로드해 주세요.</p>
|
|
<p className="text-xs text-gray-400 mb-3">PNG, JPG (5MB 이하)</p>
|
|
<input ref={stampInputRef} type="file" accept="image/png,image/jpeg"
|
|
onChange={handleStampUpload} className="hidden" />
|
|
<button type="button" onClick={() => stampInputRef.current?.click()}
|
|
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 text-sm border border-gray-300">
|
|
파일 선택
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="text-center">
|
|
<p className="text-xs text-green-600 mb-2">배경이 자동 제거되었습니다</p>
|
|
<div className="rounded-lg p-3 mb-3 flex items-center justify-center" style={{height: '150px', background: 'repeating-conic-gradient(#e5e7eb 0% 25%, #fff 0% 50%) 0 0 / 16px 16px'}}>
|
|
<img src={stampPreview} alt="도장 미리보기" className="max-h-full max-w-full object-contain" />
|
|
</div>
|
|
<button type="button" onClick={() => { setStampPreview(null); setStampData(null); if (stampInputRef.current) stampInputRef.current.value = ''; }}
|
|
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 border border-gray-300 rounded-lg hover:bg-gray-50">
|
|
다시 선택
|
|
</button>
|
|
</div>
|
|
)}
|
|
</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' ? '도장 찍기' : '서명하기')}
|
|
</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>
|
|
@endverbatim
|
|
</body>
|
|
</html>
|