diff --git a/app/Http/Controllers/ESign/EsignPublicController.php b/app/Http/Controllers/ESign/EsignPublicController.php index 8945612f..e874f2f7 100644 --- a/app/Http/Controllers/ESign/EsignPublicController.php +++ b/app/Http/Controllers/ESign/EsignPublicController.php @@ -3,18 +3,18 @@ namespace App\Http\Controllers\ESign; use App\Http\Controllers\Controller; +use App\Mail\EsignCompletedMail; +use App\Mail\EsignRequestMail; use App\Models\Barobill\BarobillMember; use App\Models\ESign\EsignAuditLog; use App\Models\ESign\EsignContract; use App\Models\ESign\EsignSigner; -use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; -use App\Mail\EsignCompletedMail; -use App\Mail\EsignRequestMail; -use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Mail; use App\Services\Barobill\BarobillService; use App\Services\ESign\PdfSignatureService; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Illuminate\View\View; @@ -232,21 +232,29 @@ public function submitSignature(Request $request, string $token): JsonResponse $useStamp = $request->boolean('use_stamp'); if ($useStamp) { - // 도장 사용: 이미 저장된 stamp 이미지를 그대로 사용 - if (!$signer->signature_image_path) { + $stampImage = $request->input('stamp_image'); + if ($stampImage) { + // 고객 업로드 도장: base64 디코딩 후 저장 + $imageData = base64_decode($stampImage); + if (strlen($imageData) > 5 * 1024 * 1024) { + return response()->json(['success' => false, 'message' => '도장 이미지는 5MB 이하만 가능합니다.'], 422); + } + $imagePath = "esign/{$contract->tenant_id}/signatures/{$contract->id}_{$signer->id}_stamp_".time().'.png'; + Storage::disk('local')->put($imagePath, $imageData); + $signer->update(['signature_image_path' => $imagePath]); + } elseif (! $signer->signature_image_path) { return response()->json(['success' => false, 'message' => '등록된 도장 이미지가 없습니다.'], 422); } - // 기존 signature_image_path 유지, 새 이미지 저장 안 함 } else { // 직접 서명: signature_image 필수 $signatureImage = $request->input('signature_image'); - if (!$signatureImage) { + 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'; + $imagePath = "esign/{$contract->tenant_id}/signatures/{$contract->id}_{$signer->id}_".time().'.png'; Storage::disk('local')->put($imagePath, $imageData); $signer->update(['signature_image_path' => $imagePath]); @@ -286,7 +294,7 @@ public function submitSignature(Request $request, string $token): JsonResponse // PDF에 서명 이미지/텍스트 합성 try { - $pdfService = new PdfSignatureService(); + $pdfService = new PdfSignatureService; $pdfService->mergeSignatures($contract); } catch (\Throwable $e) { Log::error('PDF 서명 합성 실패', [ @@ -313,7 +321,7 @@ public function submitSignature(Request $request, string $token): JsonResponse $completionResults = []; try { // 이메일 발송 - if (in_array($sendMethod, ['email', 'both']) || !$completedSigner->phone) { + if (in_array($sendMethod, ['email', 'both']) || ! $completedSigner->phone) { if ($completedSigner->email) { try { Mail::to($completedSigner->email)->send( @@ -380,7 +388,7 @@ public function submitSignature(Request $request, string $token): JsonResponse if ($member) { $barobill = app(BarobillService::class); $barobill->setServerMode($member->is_test_mode ? 'test' : 'production'); - $nextSignUrl = config('app.url') . '/esign/sign/' . $nextSigner->access_token; + $nextSignUrl = config('app.url').'/esign/sign/'.$nextSigner->access_token; $nextExpires = $contract->expires_at?->format('Y-m-d H:i') ?? '없음'; $atResult = $barobill->sendATKakaotalkEx( corpNum: $member->corp_num, @@ -408,7 +416,7 @@ public function submitSignature(Request $request, string $token): JsonResponse } // 이메일 발송 - if (in_array($nextSendMethod, ['email', 'both']) || ($nextSendMethod === 'alimtalk' && !$nextSigner->phone)) { + if (in_array($nextSendMethod, ['email', 'both']) || ($nextSendMethod === 'alimtalk' && ! $nextSigner->phone)) { if ($nextSigner->email) { try { Mail::to($nextSigner->email)->send(new EsignRequestMail($contract, $nextSigner)); @@ -498,7 +506,7 @@ public function downloadDocument(string $token): StreamedResponse|JsonResponse } else { // 서명 전: 텍스트/날짜/체크박스 필드가 합성된 미리보기 PDF 생성 try { - $pdfService = new PdfSignatureService(); + $pdfService = new PdfSignatureService; $filePath = $pdfService->generatePreview($contract); } catch (\Throwable $e) { Log::warning('미리보기 PDF 생성 실패, 원본 제공', ['error' => $e->getMessage()]); @@ -510,7 +518,7 @@ public function downloadDocument(string $token): StreamedResponse|JsonResponse return response()->json(['success' => false, 'message' => '문서 파일이 존재하지 않습니다.'], 404); } - $fileName = $contract->original_file_name ?: ($contract->title . '.pdf'); + $fileName = $contract->original_file_name ?: ($contract->title.'.pdf'); return Storage::disk('local')->download($filePath, $fileName, [ 'Content-Type' => 'application/pdf', @@ -523,14 +531,14 @@ private function sendCompletionAlimtalk(EsignContract $contract, EsignSigner $si { try { $member = BarobillMember::where('tenant_id', $contract->tenant_id)->first(); - if (!$member) { + if (! $member) { return ['success' => false, 'channel' => 'alimtalk', 'error' => '바로빌 회원 미등록']; } $barobill = app(BarobillService::class); $barobill->setServerMode($member->is_test_mode ? 'test' : 'production'); - $signUrl = config('app.url') . '/esign/sign/' . $signer->access_token; + $signUrl = config('app.url').'/esign/sign/'.$signer->access_token; $completedAt = $contract->completed_at?->format('Y-m-d H:i') ?? now()->format('Y-m-d H:i'); $result = $barobill->sendATKakaotalkEx( @@ -554,12 +562,13 @@ private function sendCompletionAlimtalk(EsignContract $contract, EsignSigner $si : '', ); - if (!($result['success'] ?? false)) { + if (! ($result['success'] ?? false)) { Log::warning('E-Sign 완료 알림톡 발송 실패', [ 'contract_id' => $contract->id, 'signer_id' => $signer->id, 'error' => $result['error'] ?? 'Unknown error', ]); + return ['success' => false, 'channel' => 'alimtalk', 'error' => $result['error'] ?? 'API 호출 실패']; } @@ -570,6 +579,7 @@ private function sendCompletionAlimtalk(EsignContract $contract, EsignSigner $si 'signer_id' => $signer->id, 'error' => $e->getMessage(), ]); + return ['success' => false, 'channel' => 'alimtalk', 'error' => $e->getMessage()]; } } diff --git a/resources/views/esign/sign/sign.blade.php b/resources/views/esign/sign/sign.blade.php index 97332bb9..2aaefe1b 100644 --- a/resources/views/esign/sign/sign.blade.php +++ b/resources/views/esign/sign/sign.blade.php @@ -37,8 +37,11 @@ const [error, setError] = useState(''); const [signatureData, setSignatureData] = useState(null); const [signMode, setSignMode] = useState('signature'); // '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 () => { @@ -80,11 +83,35 @@ if (padRef.current) padRef.current.clear(); }; + 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) => { + setStampPreview(ev.target.result); + setStampData(ev.target.result.replace(/^data:image\/\w+;base64,/, '')); + }; + reader.readAsDataURL(file); + }; + const goToSign = () => { if (!consent) { alert('동의 항목을 체크해 주세요.'); return; } if (signMode === 'stamp') { - // 도장 모드: 서명 pad 건너뛰고 바로 제출 - submitStamp(); + if (signer?.has_stamp) { + submitStamp(); + } else { + submitStampWithUpload(); + } } else { setStep('sign'); } @@ -118,6 +145,26 @@ 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(''); @@ -192,7 +239,7 @@ className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg 문서 열기 / 다운로드 - {signer?.has_stamp && ( + {(signer?.has_stamp || signer?.role === 'counterpart') && (
서명 방법을 선택해 주세요.
등록된 도장 사용
+ {signer?.has_stamp ? '법인도장' : '도장'} +{signer?.has_stamp ? '등록된 도장 사용' : '도장 이미지 업로드'}
도장 이미지를 업로드해 주세요.
+PNG, JPG (5MB 이하)
+ + +