feat:E-Sign 법인도장(Corporate Seal) 등록 및 자동 서명 기능
- 계약 생성 화면에 법인도장 업로드 UI 추가 (미리보기/삭제) - store()에서 base64 이미지 디코딩 후 저장, creator signer에 연결 - send()에서 법인도장 있는 작성자 자동 서명 처리 (상대방에게만 이메일 발송) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -99,6 +99,7 @@ public function store(Request $request): JsonResponse
|
||||
'metadata' => 'nullable|array',
|
||||
'metadata.*' => 'nullable|string|max:500',
|
||||
'file' => 'nullable|file|mimes:pdf,doc,docx|max:20480',
|
||||
'creator_stamp_image' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
@@ -178,6 +179,21 @@ public function store(Request $request): JsonResponse
|
||||
]);
|
||||
}
|
||||
|
||||
// 법인도장 이미지 처리
|
||||
if ($request->input('creator_stamp_image')) {
|
||||
$creatorSigner = EsignSigner::withoutGlobalScopes()
|
||||
->where('contract_id', $contract->id)
|
||||
->where('role', 'creator')
|
||||
->first();
|
||||
|
||||
if ($creatorSigner) {
|
||||
$imageData = base64_decode($request->input('creator_stamp_image'));
|
||||
$imagePath = "esign/{$tenantId}/signatures/{$contract->id}_{$creatorSigner->id}_stamp.png";
|
||||
Storage::disk('local')->put($imagePath, $imageData);
|
||||
$creatorSigner->update(['signature_image_path' => $imagePath]);
|
||||
}
|
||||
}
|
||||
|
||||
// 감사 로그
|
||||
EsignAuditLog::create([
|
||||
'tenant_id' => $tenantId,
|
||||
@@ -440,16 +456,45 @@ 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()->orderBy('sign_order')->first();
|
||||
// 순차 서명: 다음 미서명 서명자에게 발송
|
||||
$nextSigner = $contract->signers()
|
||||
->where('status', '!=', 'signed')
|
||||
->orderBy('sign_order')
|
||||
->first();
|
||||
if ($nextSigner) {
|
||||
$nextSigner->update(['status' => 'notified']);
|
||||
Mail::to($nextSigner->email)->send(new EsignRequestMail($contract, $nextSigner));
|
||||
|
||||
@@ -56,6 +56,8 @@ className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:
|
||||
counterpart_name: '', counterpart_email: '', counterpart_phone: '',
|
||||
});
|
||||
const [file, setFile] = useState(null);
|
||||
const [stampImage, setStampImage] = useState(null);
|
||||
const [stampPreview, setStampPreview] = useState(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [templates, setTemplates] = useState([]);
|
||||
@@ -64,6 +66,25 @@ className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:
|
||||
const [templateSearch, setTemplateSearch] = useState('');
|
||||
const [metadata, setMetadata] = useState({});
|
||||
const fileRef = useRef(null);
|
||||
const stampFileRef = useRef(null);
|
||||
|
||||
const handleStampSelect = (e) => {
|
||||
const selected = e.target.files[0];
|
||||
if (!selected) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const base64 = reader.result.split(',')[1];
|
||||
setStampImage(base64);
|
||||
setStampPreview(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(selected);
|
||||
};
|
||||
|
||||
const removeStamp = () => {
|
||||
setStampImage(null);
|
||||
setStampPreview(null);
|
||||
if (stampFileRef.current) stampFileRef.current.value = '';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/esign/contracts/templates', { headers: { 'Accept': 'application/json' } })
|
||||
@@ -132,6 +153,8 @@ className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:
|
||||
Object.entries(metadata).forEach(([k, v]) => { fd.append(`metadata[${k}]`, v || ''); });
|
||||
}
|
||||
|
||||
if (stampImage) fd.append('creator_stamp_image', stampImage);
|
||||
|
||||
try {
|
||||
fd.append('_token', csrfToken);
|
||||
fd.append('signers[0][name]', form.creator_name);
|
||||
@@ -227,6 +250,39 @@ className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:
|
||||
<div className="space-y-4">
|
||||
<SignerRow prefix="creator" title="작성자" subtitle="나" color="#3B82F6"
|
||||
form={form} errors={errors} onChange={handleChange} />
|
||||
|
||||
{/* 법인도장 등록 */}
|
||||
<div className="bg-blue-50 rounded-lg p-3 border border-blue-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3B82F6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3"/>
|
||||
<circle cx="12" cy="12" r="4"/>
|
||||
</svg>
|
||||
<span className="text-xs font-semibold text-blue-800">법인도장</span>
|
||||
<span className="text-[11px] text-blue-500">발송 시 작성자 서명을 자동 처리합니다</span>
|
||||
</div>
|
||||
{stampPreview ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={stampPreview} alt="법인도장 미리보기" className="h-16 w-16 object-contain border border-blue-300 rounded bg-white p-1" />
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-blue-700">법인도장이 등록되었습니다.</p>
|
||||
</div>
|
||||
<button type="button" onClick={removeStamp}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-full text-red-400 hover:bg-red-50 hover:text-red-600 transition-colors" title="삭제">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<input ref={stampFileRef} type="file" accept="image/*" onChange={handleStampSelect} className="hidden" />
|
||||
<button type="button" onClick={() => stampFileRef.current?.click()}
|
||||
className="px-3 py-1.5 bg-white border border-blue-300 rounded-md text-xs font-medium text-blue-700 hover:bg-blue-100 transition-colors">
|
||||
법인도장 이미지 등록
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t"></div>
|
||||
<SignerRow prefix="counterpart" title="상대방" subtitle="서명 요청 대상" color="#EF4444"
|
||||
form={form} errors={errors} onChange={handleChange} />
|
||||
|
||||
Reference in New Issue
Block a user