fix:E-Sign 알림톡 certKey 버그 수정 및 발송 상태 추적 기능 추가
- sendATKakaotalkEx() 호출 시 존재하지 않는 certKey 파라미터 제거 (TypeError 버그) - sendAlimtalk/dispatchNotification 결과 반환 (void → array) - send/remind 응답에 notification_results 포함 - 감사 로그 metadata에 서명자별 알림 발송 결과 저장 - EsignPublicController 다음 서명자/완료 알림에도 동일 수정 적용 - detail.blade.php: 발송 방식 배지, 서명자 연락처, 알림 오류 배너, 활동 로그 발송 결과 표시 - send.blade.php: 발송 후 알림 실패 시 경고 메시지 표시 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -712,9 +712,15 @@ public function send(Request $request, int $id): JsonResponse
|
||||
$targetSigners = $first ? collect([$first]) : collect();
|
||||
}
|
||||
|
||||
$notificationResults = [];
|
||||
foreach ($targetSigners as $signer) {
|
||||
$signer->update(['status' => 'notified']);
|
||||
$this->dispatchNotification($contract, $signer, $sendMethod, $smsFallback);
|
||||
$results = $this->dispatchNotification($contract, $signer, $sendMethod, $smsFallback);
|
||||
$notificationResults[] = [
|
||||
'signer_id' => $signer->id,
|
||||
'signer_name' => $signer->name,
|
||||
'results' => $results,
|
||||
];
|
||||
}
|
||||
|
||||
EsignAuditLog::create([
|
||||
@@ -723,11 +729,34 @@ public function send(Request $request, int $id): JsonResponse
|
||||
'action' => 'sign_request_sent',
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'metadata' => ['sent_by' => auth()->id(), 'send_method' => $sendMethod],
|
||||
'metadata' => [
|
||||
'sent_by' => auth()->id(),
|
||||
'send_method' => $sendMethod,
|
||||
'notification_results' => $notificationResults,
|
||||
],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json(['success' => true, 'message' => '서명 요청이 발송되었습니다.']);
|
||||
// 실패한 알림 확인
|
||||
$failures = [];
|
||||
foreach ($notificationResults as $nr) {
|
||||
foreach ($nr['results'] as $r) {
|
||||
if (!$r['success']) {
|
||||
$failures[] = "{$nr['signer_name']}: {$r['channel']} 실패 ({$r['error']})";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$message = '서명 요청이 발송되었습니다.';
|
||||
if (!empty($failures)) {
|
||||
$message .= ' (일부 알림 실패: ' . implode(', ', $failures) . ')';
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
'notification_results' => $notificationResults,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -748,14 +777,20 @@ public function remind(Request $request, int $id): JsonResponse
|
||||
->orderBy('sign_order')
|
||||
->first();
|
||||
|
||||
$notificationResults = [];
|
||||
if ($nextSigner) {
|
||||
$nextSigner->update(['status' => 'notified']);
|
||||
$this->dispatchNotification(
|
||||
$results = $this->dispatchNotification(
|
||||
$contract, $nextSigner,
|
||||
$contract->send_method ?? 'alimtalk',
|
||||
$contract->sms_fallback ?? true,
|
||||
isReminder: true,
|
||||
);
|
||||
$notificationResults[] = [
|
||||
'signer_id' => $nextSigner->id,
|
||||
'signer_name' => $nextSigner->name,
|
||||
'results' => $results,
|
||||
];
|
||||
}
|
||||
|
||||
EsignAuditLog::create([
|
||||
@@ -767,15 +802,32 @@ public function remind(Request $request, int $id): JsonResponse
|
||||
'metadata' => [
|
||||
'reminded_by' => auth()->id(),
|
||||
'target_signer_id' => $nextSigner?->id,
|
||||
'notification_results' => $notificationResults,
|
||||
],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
// 실패 확인
|
||||
$failures = [];
|
||||
foreach ($notificationResults as $nr) {
|
||||
foreach ($nr['results'] as $r) {
|
||||
if (!$r['success']) {
|
||||
$failures[] = "{$r['channel']} 실패 ({$r['error']})";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$message = $nextSigner
|
||||
? "{$nextSigner->name}에게 리마인더가 발송되었습니다."
|
||||
: '리마인더가 기록되었습니다.';
|
||||
if (!empty($failures)) {
|
||||
$message .= ' (일부 알림 실패: ' . implode(', ', $failures) . ')';
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $nextSigner
|
||||
? "{$nextSigner->name}에게 리마인더가 발송되었습니다."
|
||||
: '리마인더가 기록되었습니다.',
|
||||
'message' => $message,
|
||||
'notification_results' => $notificationResults,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -788,18 +840,32 @@ private function dispatchNotification(
|
||||
string $sendMethod,
|
||||
bool $smsFallback,
|
||||
bool $isReminder = false,
|
||||
): void {
|
||||
): array {
|
||||
$results = [];
|
||||
|
||||
// 알림톡 발송
|
||||
if (in_array($sendMethod, ['alimtalk', 'both']) && $signer->phone) {
|
||||
$this->sendAlimtalk($contract, $signer, $smsFallback, $isReminder);
|
||||
$results[] = $this->sendAlimtalk($contract, $signer, $smsFallback, $isReminder);
|
||||
}
|
||||
|
||||
// 이메일 발송 (email/both 선택 시, 또는 알림톡인데 번호 없으면 폴백)
|
||||
if (in_array($sendMethod, ['email', 'both']) || ($sendMethod === 'alimtalk' && !$signer->phone)) {
|
||||
if ($signer->email) {
|
||||
Mail::to($signer->email)->send(new EsignRequestMail($contract, $signer, $isReminder));
|
||||
try {
|
||||
Mail::to($signer->email)->send(new EsignRequestMail($contract, $signer, $isReminder));
|
||||
$results[] = ['success' => true, 'channel' => 'email', 'error' => null];
|
||||
} catch (\Throwable $e) {
|
||||
\Log::warning('E-Sign 이메일 발송 실패', [
|
||||
'contract_id' => $contract->id,
|
||||
'signer_id' => $signer->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$results[] = ['success' => false, 'channel' => 'email', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -810,11 +876,11 @@ private function sendAlimtalk(
|
||||
EsignSigner $signer,
|
||||
bool $smsFallback = true,
|
||||
bool $isReminder = false,
|
||||
): void {
|
||||
): array {
|
||||
try {
|
||||
$member = BarobillMember::where('tenant_id', $contract->tenant_id)->first();
|
||||
if (!$member) {
|
||||
return;
|
||||
return ['success' => false, 'channel' => 'alimtalk', 'error' => '바로빌 회원 미등록'];
|
||||
}
|
||||
|
||||
$barobill = app(BarobillService::class);
|
||||
@@ -833,9 +899,8 @@ private function sendAlimtalk(
|
||||
? "[SAM] {$signer->name}님, 전자계약 서명 요청이 도착했습니다. {$signUrl}"
|
||||
: '';
|
||||
|
||||
$barobill->sendATKakaotalkEx(
|
||||
$result = $barobill->sendATKakaotalkEx(
|
||||
corpNum: $member->corp_num,
|
||||
certKey: $member->cert_key,
|
||||
senderId: $member->kakaotalk_sender_id ?? '',
|
||||
templateName: $templateName,
|
||||
receiverName: $signer->name,
|
||||
@@ -852,12 +917,24 @@ private function sendAlimtalk(
|
||||
],
|
||||
smsMessage: $smsMessage,
|
||||
);
|
||||
|
||||
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 호출 실패'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'channel' => 'alimtalk', 'error' => null];
|
||||
} catch (\Throwable $e) {
|
||||
\Log::warning('E-Sign 알림톡 발송 실패', [
|
||||
'contract_id' => $contract->id,
|
||||
'signer_id' => $signer->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return ['success' => false, 'channel' => 'alimtalk', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -310,19 +310,25 @@ public function submitSignature(Request $request, string $token): JsonResponse
|
||||
// 모든 서명자에게 완료 알림 발송
|
||||
$sendMethod = $contract->send_method ?? 'alimtalk';
|
||||
foreach ($allSigners as $completedSigner) {
|
||||
$completionResults = [];
|
||||
try {
|
||||
// 이메일 발송
|
||||
if (in_array($sendMethod, ['email', 'both']) || !$completedSigner->phone) {
|
||||
if ($completedSigner->email) {
|
||||
Mail::to($completedSigner->email)->send(
|
||||
new EsignCompletedMail($contract, $completedSigner, $allSigners)
|
||||
);
|
||||
try {
|
||||
Mail::to($completedSigner->email)->send(
|
||||
new EsignCompletedMail($contract, $completedSigner, $allSigners)
|
||||
);
|
||||
$completionResults[] = ['success' => true, 'channel' => 'email', 'error' => null];
|
||||
} catch (\Throwable $e) {
|
||||
$completionResults[] = ['success' => false, 'channel' => 'email', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 알림톡 발송
|
||||
if (in_array($sendMethod, ['alimtalk', 'both']) && $completedSigner->phone) {
|
||||
$this->sendCompletionAlimtalk($contract, $completedSigner);
|
||||
$completionResults[] = $this->sendCompletionAlimtalk($contract, $completedSigner);
|
||||
}
|
||||
|
||||
EsignAuditLog::create([
|
||||
@@ -332,7 +338,14 @@ public function submitSignature(Request $request, string $token): JsonResponse
|
||||
'action' => 'completion_notification_sent',
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'metadata' => ['send_method' => $sendMethod],
|
||||
'metadata' => [
|
||||
'send_method' => $sendMethod,
|
||||
'notification_results' => [[
|
||||
'signer_id' => $completedSigner->id,
|
||||
'signer_name' => $completedSigner->name,
|
||||
'results' => $completionResults,
|
||||
]],
|
||||
],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -358,6 +371,8 @@ public function submitSignature(Request $request, string $token): JsonResponse
|
||||
$nextSendMethod = $contract->send_method ?? 'alimtalk';
|
||||
$nextSmsFallback = $contract->sms_fallback ?? true;
|
||||
|
||||
$notificationResults = [];
|
||||
|
||||
// 알림톡 발송
|
||||
if (in_array($nextSendMethod, ['alimtalk', 'both']) && $nextSigner->phone) {
|
||||
try {
|
||||
@@ -367,9 +382,8 @@ public function submitSignature(Request $request, string $token): JsonResponse
|
||||
$barobill->setServerMode($member->is_test_mode ? 'test' : 'production');
|
||||
$nextSignUrl = config('app.url') . '/esign/sign/' . $nextSigner->access_token;
|
||||
$nextExpires = $contract->expires_at?->format('Y-m-d H:i') ?? '없음';
|
||||
$barobill->sendATKakaotalkEx(
|
||||
$atResult = $barobill->sendATKakaotalkEx(
|
||||
corpNum: $member->corp_num,
|
||||
certKey: $member->cert_key,
|
||||
senderId: $member->kakaotalk_sender_id ?? '',
|
||||
templateName: '전자계약_서명요청',
|
||||
receiverName: $nextSigner->name,
|
||||
@@ -379,16 +393,30 @@ public function submitSignature(Request $request, string $token): JsonResponse
|
||||
buttons: [['Name' => '계약서 확인하기', 'ButtonType' => 'WL', 'Url1' => $nextSignUrl, 'Url2' => $nextSignUrl]],
|
||||
smsMessage: $nextSmsFallback ? "[SAM] {$nextSigner->name}님, 전자계약 서명 요청이 도착했습니다. {$nextSignUrl}" : '',
|
||||
);
|
||||
$notificationResults[] = [
|
||||
'success' => $atResult['success'] ?? false,
|
||||
'channel' => 'alimtalk',
|
||||
'error' => $atResult['error'] ?? null,
|
||||
];
|
||||
} else {
|
||||
$notificationResults[] = ['success' => false, 'channel' => 'alimtalk', 'error' => '바로빌 회원 미등록'];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('다음 서명자 알림톡 발송 실패', ['error' => $e->getMessage()]);
|
||||
$notificationResults[] = ['success' => false, 'channel' => 'alimtalk', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
// 이메일 발송
|
||||
if (in_array($nextSendMethod, ['email', 'both']) || ($nextSendMethod === 'alimtalk' && !$nextSigner->phone)) {
|
||||
if ($nextSigner->email) {
|
||||
Mail::to($nextSigner->email)->send(new EsignRequestMail($contract, $nextSigner));
|
||||
try {
|
||||
Mail::to($nextSigner->email)->send(new EsignRequestMail($contract, $nextSigner));
|
||||
$notificationResults[] = ['success' => true, 'channel' => 'email', 'error' => null];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('다음 서명자 이메일 발송 실패', ['error' => $e->getMessage()]);
|
||||
$notificationResults[] = ['success' => false, 'channel' => 'email', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,7 +427,14 @@ public function submitSignature(Request $request, string $token): JsonResponse
|
||||
'action' => 'sign_request_sent',
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'metadata' => ['triggered_by' => 'auto_after_sign'],
|
||||
'metadata' => [
|
||||
'triggered_by' => 'auto_after_sign',
|
||||
'notification_results' => [[
|
||||
'signer_id' => $nextSigner->id,
|
||||
'signer_name' => $nextSigner->name,
|
||||
'results' => $notificationResults,
|
||||
]],
|
||||
],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
@@ -484,12 +519,12 @@ public function downloadDocument(string $token): StreamedResponse|JsonResponse
|
||||
|
||||
// ─── Private ───
|
||||
|
||||
private function sendCompletionAlimtalk(EsignContract $contract, EsignSigner $signer): void
|
||||
private function sendCompletionAlimtalk(EsignContract $contract, EsignSigner $signer): array
|
||||
{
|
||||
try {
|
||||
$member = BarobillMember::where('tenant_id', $contract->tenant_id)->first();
|
||||
if (!$member) {
|
||||
return;
|
||||
return ['success' => false, 'channel' => 'alimtalk', 'error' => '바로빌 회원 미등록'];
|
||||
}
|
||||
|
||||
$barobill = app(BarobillService::class);
|
||||
@@ -498,9 +533,8 @@ private function sendCompletionAlimtalk(EsignContract $contract, EsignSigner $si
|
||||
$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');
|
||||
|
||||
$barobill->sendATKakaotalkEx(
|
||||
$result = $barobill->sendATKakaotalkEx(
|
||||
corpNum: $member->corp_num,
|
||||
certKey: $member->cert_key,
|
||||
senderId: $member->kakaotalk_sender_id ?? '',
|
||||
templateName: '전자계약_완료',
|
||||
receiverName: $signer->name,
|
||||
@@ -519,12 +553,24 @@ private function sendCompletionAlimtalk(EsignContract $contract, EsignSigner $si
|
||||
? "[SAM] {$signer->name}님, 전자계약이 완료되었습니다. {$signUrl}"
|
||||
: '',
|
||||
);
|
||||
|
||||
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 호출 실패'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'channel' => 'alimtalk', 'error' => null];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('E-Sign 완료 알림톡 발송 실패', [
|
||||
'contract_id' => $contract->id,
|
||||
'signer_id' => $signer->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return ['success' => false, 'channel' => 'alimtalk', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,10 +51,19 @@
|
||||
};
|
||||
|
||||
const ACTION_MAP = {
|
||||
created: '계약 생성', sent: '서명 요청 발송', viewed: '문서 열람',
|
||||
contract_created: '계약 생성', sign_request_sent: '서명 요청 발송', viewed: '문서 열람',
|
||||
otp_sent: 'OTP 발송', authenticated: '본인인증 완료', signed: '서명 완료',
|
||||
rejected: '서명 거절', completed: '계약 완료', cancelled: '계약 취소', reminded: '리마인더 발송',
|
||||
downloaded: '문서 다운로드',
|
||||
rejected: '서명 거절', contract_completed: '계약 완료', contract_cancelled: '계약 취소',
|
||||
reminded: '리마인더 발송', downloaded: '문서 다운로드',
|
||||
completion_notification_sent: '완료 알림 발송',
|
||||
// legacy keys
|
||||
created: '계약 생성', sent: '서명 요청 발송', completed: '계약 완료', cancelled: '계약 취소',
|
||||
};
|
||||
|
||||
const SEND_METHOD_MAP = {
|
||||
alimtalk: { label: '알림톡', color: 'bg-yellow-100 text-yellow-700' },
|
||||
email: { label: '이메일', color: 'bg-blue-100 text-blue-700' },
|
||||
both: { label: '알림톡+이메일', color: 'bg-purple-100 text-purple-700' },
|
||||
};
|
||||
|
||||
const StatusBadge = ({ status }) => {
|
||||
@@ -97,6 +106,22 @@
|
||||
|
||||
const c = contract;
|
||||
|
||||
// 마지막 발송 로그에서 알림 실패 확인
|
||||
const lastSendLog = (c.audit_logs || []).find(l =>
|
||||
['sign_request_sent', 'reminded', 'completion_notification_sent'].includes(l.action)
|
||||
&& l.metadata?.notification_results
|
||||
);
|
||||
const notifFailures = [];
|
||||
if (lastSendLog?.metadata?.notification_results) {
|
||||
for (const nr of lastSendLog.metadata.notification_results) {
|
||||
for (const r of (nr.results || [])) {
|
||||
if (!r.success) {
|
||||
notifFailures.push({ name: nr.signer_name, channel: r.channel, error: r.error });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
@@ -114,6 +139,18 @@ className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hov
|
||||
<StatusBadge status={c.status} />
|
||||
</div>
|
||||
|
||||
{/* 알림 오류 배너 */}
|
||||
{notifFailures.length > 0 && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-sm font-medium text-red-700 mb-1">알림 발송 실패</p>
|
||||
<ul className="text-xs text-red-600 space-y-0.5">
|
||||
{notifFailures.map((f, i) => (
|
||||
<li key={i}>{f.name}: {f.channel} 실패 - {f.error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 왼쪽: 계약 정보 */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
@@ -125,6 +162,17 @@ className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hov
|
||||
<div><dt className="text-gray-500">파일 크기</dt><dd>{c.original_file_size ? `${(c.original_file_size / 1024 / 1024).toFixed(2)} MB` : '-'}</dd></div>
|
||||
<div><dt className="text-gray-500">생성일</dt><dd>{fmtDate(c.created_at, false)}</dd></div>
|
||||
<div><dt className="text-gray-500">만료일</dt><dd>{fmtDate(c.expires_at, false) || '-'}</dd></div>
|
||||
{c.send_method && (
|
||||
<div>
|
||||
<dt className="text-gray-500">발송 방식</dt>
|
||||
<dd>
|
||||
{(() => {
|
||||
const sm = SEND_METHOD_MAP[c.send_method] || { label: c.send_method, color: 'bg-gray-100 text-gray-700' };
|
||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${sm.color}`}>{sm.label}</span>;
|
||||
})()}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{c.description && <div className="col-span-2"><dt className="text-gray-500">설명</dt><dd>{c.description}</dd></div>}
|
||||
</dl>
|
||||
</div>
|
||||
@@ -141,7 +189,10 @@ className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hov
|
||||
<span className="w-8 h-8 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-sm font-bold">{s.sign_order || i+1}</span>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{s.name} <span className="text-xs text-gray-400">({s.role === 'creator' ? '작성자' : '상대방'})</span></p>
|
||||
<p className="text-xs text-gray-500">{s.email}</p>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5">
|
||||
{s.phone && <span className="text-xs text-gray-500" title="휴대폰">📱 {s.phone}</span>}
|
||||
{s.email && <span className="text-xs text-gray-500" title="이메일">✉ {s.email}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
@@ -157,14 +208,31 @@ className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hov
|
||||
{/* 감사 로그 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">활동 로그</h2>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
<div className="space-y-2 max-h-80 overflow-y-auto">
|
||||
{(c.audit_logs || []).length === 0 ? (
|
||||
<p className="text-sm text-gray-400">활동 로그가 없습니다.</p>
|
||||
) : (c.audit_logs || []).map(log => (
|
||||
<div key={log.id} className="flex items-start gap-3 text-sm py-2 border-b last:border-0">
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap">{fmtDate(log.created_at)}</span>
|
||||
<span className="font-medium">{ACTION_MAP[log.action] || log.action}</span>
|
||||
{log.signer && <span className="text-gray-500">- {log.signer.name}</span>}
|
||||
<div key={log.id} className="py-2 border-b last:border-0">
|
||||
<div className="flex items-start gap-3 text-sm">
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap">{fmtDate(log.created_at)}</span>
|
||||
<span className="font-medium">{ACTION_MAP[log.action] || log.action}</span>
|
||||
{log.signer && <span className="text-gray-500">- {log.signer.name}</span>}
|
||||
</div>
|
||||
{/* 알림 발송 결과 표시 */}
|
||||
{log.metadata?.notification_results && (
|
||||
<div className="ml-20 mt-1 space-y-0.5">
|
||||
{log.metadata.notification_results.map((nr, ni) => (
|
||||
<div key={ni} className="text-xs">
|
||||
{(nr.results || []).map((r, ri) => (
|
||||
<span key={ri} className={`inline-flex items-center gap-1 mr-2 ${r.success ? 'text-green-600' : 'text-red-500'}`}>
|
||||
{r.success ? '✓' : '✗'} {nr.signer_name}: {r.channel === 'alimtalk' ? '알림톡' : '이메일'}
|
||||
{!r.success && r.error && <span className="text-red-400">({r.error})</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -57,7 +57,21 @@
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
alert('서명 요청이 발송되었습니다.');
|
||||
// 알림 실패 여부 확인
|
||||
const results = json.notification_results || [];
|
||||
const failures = [];
|
||||
for (const nr of results) {
|
||||
for (const r of (nr.results || [])) {
|
||||
if (!r.success) {
|
||||
failures.push(`${nr.signer_name}: ${r.channel === 'alimtalk' ? '알림톡' : '이메일'} 실패 (${r.error})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
alert(`서명 요청이 발송되었으나 일부 알림이 실패했습니다:\n\n${failures.join('\n')}\n\n상세 페이지에서 확인해 주세요.`);
|
||||
} else {
|
||||
alert('서명 요청이 발송되었습니다.');
|
||||
}
|
||||
location.href = `/esign/${CONTRACT_ID}`;
|
||||
} else {
|
||||
alert(json.message || '발송에 실패했습니다.');
|
||||
|
||||
Reference in New Issue
Block a user