feat: [esign] 고객(counterpart) 도장 업로드/선택 기능 추가
- 백엔드: submitSignature에 stamp_image(base64) 파라미터 처리 추가 - 프론트: counterpart 역할에 도장 선택 UI 항상 표시 - 프론트: 도장 이미지 업로드(PNG/JPG, 5MB 제한) 및 미리보기 기능 - 기존 Creator 법인도장 흐름은 변경 없음
This commit is contained in:
@@ -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()];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
문서 열기 / 다운로드
|
||||
</a>
|
||||
</div>
|
||||
{signer?.has_stamp && (
|
||||
{(signer?.has_stamp || signer?.role === 'counterpart') && (
|
||||
<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">
|
||||
@@ -208,11 +255,37 @@ className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg
|
||||
<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>
|
||||
<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">
|
||||
<div className="bg-gray-50 rounded-lg p-3 mb-3 flex items-center justify-center" style={{height: '150px'}}>
|
||||
<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">
|
||||
@@ -228,7 +301,7 @@ className="mt-0.5 w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-
|
||||
</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 ? '도장 찍기' : '서명하기')}
|
||||
{submitting ? '처리 중...' : (signMode === 'stamp' ? '도장 찍기' : '서명하기')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user