feat:E-Sign 작성자 서명/도장 선택 기능 (자동서명 제거)
- 작성자 자동서명 로직 제거 → 양쪽 모두 서명 과정 필수 - 서명 화면에서 '직접 서명' / '법인도장' 선택 UI 추가 - 도장 선택 시 기존 stamp 이미지로 바로 제출 - getContract API에 has_stamp 필드 추가 - submitSignature에 use_stamp 플래그 처리 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -456,48 +456,21 @@ public function send(Request $request, int $id): JsonResponse
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// 법인도장이 있는 작성자 자동 서명 처리
|
||||
$creatorSigner = $contract->signers->firstWhere('role', 'creator');
|
||||
if ($creatorSigner && $creatorSigner->signature_image_path) {
|
||||
$creatorSigner->update([
|
||||
'status' => 'signed',
|
||||
'signed_at' => now(),
|
||||
'sign_ip_address' => $request->ip(),
|
||||
'sign_user_agent' => $request->userAgent(),
|
||||
]);
|
||||
|
||||
EsignAuditLog::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'contract_id' => $contract->id,
|
||||
'signer_id' => $creatorSigner->id,
|
||||
'action' => 'signed',
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'metadata' => ['auto_stamp' => true, 'signer_name' => $creatorSigner->name],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
// 계약 상태를 partially_signed로 변경
|
||||
$contract->update(['status' => 'partially_signed']);
|
||||
}
|
||||
|
||||
// 서명 순서 유형에 따라 알림 발송
|
||||
if ($contract->sign_order_type === 'parallel') {
|
||||
// 동시 서명: 서명 안 한 서명자에게만 발송
|
||||
// 동시 서명: 모든 서명자에게 발송
|
||||
foreach ($contract->signers as $s) {
|
||||
if ($s->status === 'signed') continue;
|
||||
$s->update(['status' => 'notified']);
|
||||
Mail::to($s->email)->send(new EsignRequestMail($contract, $s));
|
||||
}
|
||||
} else {
|
||||
// 순차 서명: 다음 미서명 서명자에게 발송
|
||||
$nextSigner = $contract->signers()
|
||||
->where('status', '!=', 'signed')
|
||||
// 순차 서명: 첫 번째 서명자에게 발송
|
||||
$firstSigner = $contract->signers()
|
||||
->orderBy('sign_order')
|
||||
->first();
|
||||
if ($nextSigner) {
|
||||
$nextSigner->update(['status' => 'notified']);
|
||||
Mail::to($nextSigner->email)->send(new EsignRequestMail($contract, $nextSigner));
|
||||
if ($firstSigner) {
|
||||
$firstSigner->update(['status' => 'notified']);
|
||||
Mail::to($firstSigner->email)->send(new EsignRequestMail($contract, $firstSigner));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ public function getContract(string $token): JsonResponse
|
||||
'email' => $signer->email,
|
||||
'role' => $signer->role,
|
||||
'status' => $signer->status,
|
||||
'has_stamp' => (bool) $signer->signature_image_path,
|
||||
],
|
||||
],
|
||||
]);
|
||||
@@ -204,19 +205,31 @@ public function submitSignature(Request $request, string $token): JsonResponse
|
||||
return response()->json(['success' => false, 'message' => '서명할 수 없는 계약 상태입니다.'], 400);
|
||||
}
|
||||
|
||||
$signatureImage = $request->input('signature_image');
|
||||
if (! $signatureImage) {
|
||||
return response()->json(['success' => false, 'message' => '서명 이미지가 필요합니다.'], 422);
|
||||
}
|
||||
$useStamp = $request->boolean('use_stamp');
|
||||
|
||||
// 서명 이미지 저장
|
||||
$imageData = base64_decode($signatureImage);
|
||||
$imagePath = "esign/{$contract->tenant_id}/signatures/{$contract->id}_{$signer->id}_" . time() . '.png';
|
||||
Storage::disk('local')->put($imagePath, $imageData);
|
||||
if ($useStamp) {
|
||||
// 도장 사용: 이미 저장된 stamp 이미지를 그대로 사용
|
||||
if (!$signer->signature_image_path) {
|
||||
return response()->json(['success' => false, 'message' => '등록된 도장 이미지가 없습니다.'], 422);
|
||||
}
|
||||
// 기존 signature_image_path 유지, 새 이미지 저장 안 함
|
||||
} else {
|
||||
// 직접 서명: signature_image 필수
|
||||
$signatureImage = $request->input('signature_image');
|
||||
if (!$signatureImage) {
|
||||
return response()->json(['success' => false, 'message' => '서명 이미지가 필요합니다.'], 422);
|
||||
}
|
||||
|
||||
// 서명 이미지 저장
|
||||
$imageData = base64_decode($signatureImage);
|
||||
$imagePath = "esign/{$contract->tenant_id}/signatures/{$contract->id}_{$signer->id}_" . time() . '.png';
|
||||
Storage::disk('local')->put($imagePath, $imageData);
|
||||
|
||||
$signer->update(['signature_image_path' => $imagePath]);
|
||||
}
|
||||
|
||||
// 서명자 상태 업데이트
|
||||
$signer->update([
|
||||
'signature_image_path' => $imagePath,
|
||||
'signed_at' => now(),
|
||||
'consent_agreed_at' => now(),
|
||||
'sign_ip_address' => $request->ip(),
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
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);
|
||||
|
||||
@@ -77,7 +78,12 @@
|
||||
|
||||
const goToSign = () => {
|
||||
if (!consent) { alert('동의 항목을 체크해 주세요.'); return; }
|
||||
setStep('sign');
|
||||
if (signMode === 'stamp') {
|
||||
// 도장 모드: 서명 pad 건너뛰고 바로 제출
|
||||
submitStamp();
|
||||
} else {
|
||||
setStep('sign');
|
||||
}
|
||||
};
|
||||
|
||||
const confirmSignature = () => {
|
||||
@@ -89,6 +95,25 @@
|
||||
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('');
|
||||
@@ -163,6 +188,29 @@ className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg
|
||||
문서 열기 / 다운로드
|
||||
</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)}
|
||||
@@ -174,9 +222,9 @@ className="mt-0.5 w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={goToSign} disabled={!consent}
|
||||
<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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user