Files
sam-manage/resources/views/esign/sign/sign.blade.php
김보곤 8b25140a81 fix: [esign] 서명 방법 기본값을 도장으로 변경
- signMode 기본값 signature → stamp 변경
- creator(본사)에서도 서명/도장 선택 UI 표시
2026-02-25 13:47:41 +09:00

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>