feat: [esign] 알림톡 템플릿 선택 기능 추가
- 바로빌 승인된 알림톡 템플릿 목록 조회 API 추가 - 서명 요청 발송 시 템플릿 선택 드롭다운 UI 추가 - 템플릿 미리보기 (본문 + 버튼) 표시 - send()에 template_name 파라미터 전달 지원 - 미선택 시 기존 하드코딩 폴백 유지
This commit is contained in:
@@ -819,6 +819,7 @@ public function send(Request $request, int $id): JsonResponse
|
|||||||
|
|
||||||
$sendMethod = $request->input('send_method', 'email');
|
$sendMethod = $request->input('send_method', 'email');
|
||||||
$smsFallback = $request->boolean('sms_fallback', true);
|
$smsFallback = $request->boolean('sms_fallback', true);
|
||||||
|
$templateName = $request->input('template_name');
|
||||||
|
|
||||||
$contract->update([
|
$contract->update([
|
||||||
'status' => 'pending',
|
'status' => 'pending',
|
||||||
@@ -838,7 +839,7 @@ public function send(Request $request, int $id): JsonResponse
|
|||||||
$notificationResults = [];
|
$notificationResults = [];
|
||||||
foreach ($targetSigners as $signer) {
|
foreach ($targetSigners as $signer) {
|
||||||
$signer->update(['status' => 'notified']);
|
$signer->update(['status' => 'notified']);
|
||||||
$results = $this->dispatchNotification($contract, $signer, $sendMethod, $smsFallback);
|
$results = $this->dispatchNotification($contract, $signer, $sendMethod, $smsFallback, templateName: $templateName);
|
||||||
$notificationResults[] = [
|
$notificationResults[] = [
|
||||||
'signer_id' => $signer->id,
|
'signer_id' => $signer->id,
|
||||||
'signer_name' => $signer->name,
|
'signer_name' => $signer->name,
|
||||||
@@ -966,13 +967,14 @@ private function dispatchNotification(
|
|||||||
string $sendMethod,
|
string $sendMethod,
|
||||||
bool $smsFallback,
|
bool $smsFallback,
|
||||||
bool $isReminder = false,
|
bool $isReminder = false,
|
||||||
|
?string $templateName = null,
|
||||||
): array {
|
): array {
|
||||||
$results = [];
|
$results = [];
|
||||||
$alimtalkFailed = false;
|
$alimtalkFailed = false;
|
||||||
|
|
||||||
// 알림톡 발송
|
// 알림톡 발송
|
||||||
if (in_array($sendMethod, ['alimtalk', 'both']) && $signer->phone) {
|
if (in_array($sendMethod, ['alimtalk', 'both']) && $signer->phone) {
|
||||||
$alimtalkResult = $this->sendAlimtalk($contract, $signer, $smsFallback, $isReminder);
|
$alimtalkResult = $this->sendAlimtalk($contract, $signer, $smsFallback, $isReminder, $templateName);
|
||||||
$results[] = $alimtalkResult;
|
$results[] = $alimtalkResult;
|
||||||
$alimtalkFailed = ! ($alimtalkResult['success'] ?? false);
|
$alimtalkFailed = ! ($alimtalkResult['success'] ?? false);
|
||||||
}
|
}
|
||||||
@@ -1010,6 +1012,7 @@ private function sendAlimtalk(
|
|||||||
EsignSigner $signer,
|
EsignSigner $signer,
|
||||||
bool $smsFallback = true,
|
bool $smsFallback = true,
|
||||||
bool $isReminder = false,
|
bool $isReminder = false,
|
||||||
|
?string $templateName = null,
|
||||||
): array {
|
): array {
|
||||||
try {
|
try {
|
||||||
$member = BarobillMember::where('tenant_id', $contract->tenant_id)->first();
|
$member = BarobillMember::where('tenant_id', $contract->tenant_id)->first();
|
||||||
@@ -1033,7 +1036,9 @@ private function sendAlimtalk(
|
|||||||
$signUrl = config('app.url').'/esign/sign/'.$signer->access_token;
|
$signUrl = config('app.url').'/esign/sign/'.$signer->access_token;
|
||||||
$expires = $contract->expires_at?->format('Y-m-d H:i') ?? '없음';
|
$expires = $contract->expires_at?->format('Y-m-d H:i') ?? '없음';
|
||||||
|
|
||||||
$templateName = $isReminder ? '전자계약_리마인드' : '전자계약_서명요청';
|
if (! $templateName) {
|
||||||
|
$templateName = $isReminder ? '전자계약_리마인드' : '전자계약_서명요청';
|
||||||
|
}
|
||||||
|
|
||||||
// 등록된 템플릿 본문 + 버튼 정보 조회 (정확한 포맷 유지)
|
// 등록된 템플릿 본문 + 버튼 정보 조회 (정확한 포맷 유지)
|
||||||
$tplData = $this->getTemplateData($barobill, $member->biz_no, $channelId, $templateName);
|
$tplData = $this->getTemplateData($barobill, $member->biz_no, $channelId, $templateName);
|
||||||
@@ -1228,6 +1233,99 @@ private function getTemplateData(BarobillService $barobill, string $bizNo, strin
|
|||||||
return $empty;
|
return $empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 바로빌 등록 알림톡 템플릿 목록 조회 (승인 완료된 것만)
|
||||||
|
*/
|
||||||
|
public function getAlimtalkTemplates(): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$tenantId = session('selected_tenant_id', 1);
|
||||||
|
$member = BarobillMember::where('tenant_id', $tenantId)->first();
|
||||||
|
|
||||||
|
if (! $member || ! $member->biz_no) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => '바로빌 회원 정보 또는 사업자번호가 설정되지 않았습니다.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$barobill = app(BarobillService::class);
|
||||||
|
$barobill->setServerMode($member->server_mode ?? 'production');
|
||||||
|
|
||||||
|
$channelId = $this->getKakaotalkChannelId($barobill, $member->biz_no);
|
||||||
|
if (! $channelId) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => '등록된 카카오톡 채널이 없습니다.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $barobill->getKakaotalkTemplates($member->biz_no, $channelId);
|
||||||
|
if (! ($result['success'] ?? false) || empty($result['data'])) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => '템플릿 목록을 조회할 수 없습니다.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $result['data'];
|
||||||
|
$items = [];
|
||||||
|
if (is_object($data) && isset($data->KakaotalkTemplate)) {
|
||||||
|
$items = is_array($data->KakaotalkTemplate)
|
||||||
|
? $data->KakaotalkTemplate
|
||||||
|
: [$data->KakaotalkTemplate];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 승인(Status=3)된 템플릿만 필터링
|
||||||
|
$templates = [];
|
||||||
|
foreach ($items as $tpl) {
|
||||||
|
$status = $tpl->Status ?? null;
|
||||||
|
if ($status != 3) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$buttons = [];
|
||||||
|
$btnData = $tpl->Buttons ?? null;
|
||||||
|
if ($btnData) {
|
||||||
|
$btnList = $btnData->KakaotalkButton ?? null;
|
||||||
|
if ($btnList) {
|
||||||
|
$btnList = is_array($btnList) ? $btnList : [$btnList];
|
||||||
|
foreach ($btnList as $btn) {
|
||||||
|
$buttons[] = [
|
||||||
|
'Name' => $btn->Name ?? '',
|
||||||
|
'ButtonType' => $btn->ButtonType ?? 'WL',
|
||||||
|
'Url1' => $btn->Url1 ?? '',
|
||||||
|
'Url2' => $btn->Url2 ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$templates[] = [
|
||||||
|
'name' => $tpl->TemplateName ?? '',
|
||||||
|
'content' => $tpl->TemplateContent ?? '',
|
||||||
|
'status' => $status,
|
||||||
|
'buttons' => $buttons,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'channel_id' => $channelId,
|
||||||
|
'templates' => $templates,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
\Log::error('알림톡 템플릿 목록 조회 실패', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => '템플릿 목록 조회 중 오류: '.$e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PDF 다운로드
|
* PDF 다운로드
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -34,6 +34,11 @@
|
|||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [sendMethod, setSendMethod] = useState('email');
|
const [sendMethod, setSendMethod] = useState('email');
|
||||||
const [smsFallback, setSmsFallback] = useState(true);
|
const [smsFallback, setSmsFallback] = useState(true);
|
||||||
|
const [templates, setTemplates] = useState([]);
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState('');
|
||||||
|
const [templateLoading, setTemplateLoading] = useState(false);
|
||||||
|
const [templateError, setTemplateError] = useState('');
|
||||||
|
const [templatePreview, setTemplatePreview] = useState(null);
|
||||||
|
|
||||||
const fetchContract = useCallback(async () => {
|
const fetchContract = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -46,6 +51,36 @@
|
|||||||
|
|
||||||
useEffect(() => { fetchContract(); }, [fetchContract]);
|
useEffect(() => { fetchContract(); }, [fetchContract]);
|
||||||
|
|
||||||
|
const fetchTemplates = useCallback(async () => {
|
||||||
|
setTemplateLoading(true);
|
||||||
|
setTemplateError('');
|
||||||
|
setTemplates([]);
|
||||||
|
setSelectedTemplate('');
|
||||||
|
setTemplatePreview(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/esign/contracts/alimtalk-templates', { headers: getHeaders() });
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.success && json.data?.templates?.length > 0) {
|
||||||
|
setTemplates(json.data.templates);
|
||||||
|
if (json.data.templates.length === 1) {
|
||||||
|
setSelectedTemplate(json.data.templates[0].name);
|
||||||
|
setTemplatePreview(json.data.templates[0]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTemplateError(json.message || '승인된 알림톡 템플릿이 없습니다.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setTemplateError('템플릿 목록 조회 중 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
setTemplateLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sendMethod === 'alimtalk' || sendMethod === 'both') {
|
||||||
|
if (templates.length === 0 && !templateLoading) fetchTemplates();
|
||||||
|
}
|
||||||
|
}, [sendMethod]);
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
const methodLabel = SEND_METHODS.find(m => m.value === sendMethod)?.label || sendMethod;
|
const methodLabel = SEND_METHODS.find(m => m.value === sendMethod)?.label || sendMethod;
|
||||||
if (!window.confirm(`서명 요청을 발송하시겠습니까?\n발송 방식: ${methodLabel}`)) return;
|
if (!window.confirm(`서명 요청을 발송하시겠습니까?\n발송 방식: ${methodLabel}`)) return;
|
||||||
@@ -53,7 +88,11 @@
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/esign/contracts/${CONTRACT_ID}/send`, {
|
const res = await fetch(`/esign/contracts/${CONTRACT_ID}/send`, {
|
||||||
method: 'POST', headers: getHeaders(),
|
method: 'POST', headers: getHeaders(),
|
||||||
body: JSON.stringify({ send_method: sendMethod, sms_fallback: smsFallback }),
|
body: JSON.stringify({
|
||||||
|
send_method: sendMethod,
|
||||||
|
sms_fallback: smsFallback,
|
||||||
|
template_name: (sendMethod === 'alimtalk' || sendMethod === 'both') ? selectedTemplate : undefined,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (json.success) {
|
if (json.success) {
|
||||||
@@ -138,6 +177,52 @@ className="rounded text-blue-600 focus:ring-blue-500" />
|
|||||||
<span className="text-xs text-gray-400">(카카오톡 미사용자에게 SMS로 자동 전환)</span>
|
<span className="text-xs text-gray-400">(카카오톡 미사용자에게 SMS로 자동 전환)</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 알림톡 템플릿 선택 */}
|
||||||
|
{needsAlimtalk && (
|
||||||
|
<div className="mt-4 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">알림톡 템플릿 선택</label>
|
||||||
|
{templateLoading ? (
|
||||||
|
<div className="text-sm text-gray-400 py-2">템플릿 목록 조회 중...</div>
|
||||||
|
) : templateError ? (
|
||||||
|
<div className="text-sm text-red-500 py-2">
|
||||||
|
{templateError}
|
||||||
|
<button onClick={fetchTemplates} className="ml-2 text-blue-500 hover:underline text-xs">다시 시도</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
value={selectedTemplate}
|
||||||
|
onChange={e => {
|
||||||
|
setSelectedTemplate(e.target.value);
|
||||||
|
const tpl = templates.find(t => t.name === e.target.value);
|
||||||
|
setTemplatePreview(tpl || null);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">-- 템플릿을 선택하세요 --</option>
|
||||||
|
{templates.map(t => (
|
||||||
|
<option key={t.name} value={t.name}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{templatePreview && (
|
||||||
|
<div className="mt-3 p-3 bg-white border border-gray-200 rounded-lg">
|
||||||
|
<p className="text-xs font-medium text-gray-500 mb-2">미리보기</p>
|
||||||
|
<pre className="text-xs text-gray-700 whitespace-pre-wrap leading-relaxed">{templatePreview.content}</pre>
|
||||||
|
{templatePreview.buttons?.length > 0 && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-gray-100">
|
||||||
|
{templatePreview.buttons.map((btn, i) => (
|
||||||
|
<span key={i} className="inline-block px-3 py-1 mr-1 text-xs bg-yellow-100 text-yellow-800 rounded">{btn.Name}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 서명자 연락처 확인 */}
|
{/* 서명자 연락처 확인 */}
|
||||||
@@ -171,7 +256,7 @@ className="rounded text-blue-600 focus:ring-blue-500" />
|
|||||||
{/* 발송 버튼 */}
|
{/* 발송 버튼 */}
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<a href={`/esign/${CONTRACT_ID}`} className="px-6 py-2 border rounded-lg text-gray-700 hover:bg-gray-50 text-sm" hx-boost="false">돌아가기</a>
|
<a href={`/esign/${CONTRACT_ID}`} className="px-6 py-2 border rounded-lg text-gray-700 hover:bg-gray-50 text-sm" hx-boost="false">돌아가기</a>
|
||||||
<button onClick={handleSend} disabled={sending || fieldsCount === 0}
|
<button onClick={handleSend} disabled={sending || fieldsCount === 0 || (needsAlimtalk && !selectedTemplate)}
|
||||||
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm font-medium disabled:opacity-50">
|
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm font-medium disabled:opacity-50">
|
||||||
{sending ? '발송 중...' : '서명 요청 발송'}
|
{sending ? '발송 중...' : '서명 요청 발송'}
|
||||||
</button>
|
</button>
|
||||||
@@ -180,6 +265,9 @@ className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-
|
|||||||
{fieldsCount === 0 && (
|
{fieldsCount === 0 && (
|
||||||
<p className="text-red-500 text-sm mt-3 text-right">서명 필드를 먼저 설정해 주세요.</p>
|
<p className="text-red-500 text-sm mt-3 text-right">서명 필드를 먼저 설정해 주세요.</p>
|
||||||
)}
|
)}
|
||||||
|
{needsAlimtalk && !selectedTemplate && fieldsCount > 0 && (
|
||||||
|
<p className="text-amber-600 text-sm mt-3 text-right">알림톡 템플릿을 선택해 주세요.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1520,6 +1520,7 @@
|
|||||||
Route::get('/search-partners', [EsignApiController::class, 'searchPartners'])->name('search-partners');
|
Route::get('/search-partners', [EsignApiController::class, 'searchPartners'])->name('search-partners');
|
||||||
Route::get('/search-tenants', [EsignApiController::class, 'searchTenants'])->name('search-tenants');
|
Route::get('/search-tenants', [EsignApiController::class, 'searchTenants'])->name('search-tenants');
|
||||||
Route::get('/generate-contract-number', [EsignApiController::class, 'generateContractNumber'])->name('generate-contract-number');
|
Route::get('/generate-contract-number', [EsignApiController::class, 'generateContractNumber'])->name('generate-contract-number');
|
||||||
|
Route::get('/alimtalk-templates', [EsignApiController::class, 'getAlimtalkTemplates'])->name('alimtalk-templates');
|
||||||
|
|
||||||
Route::get('/stats', [EsignApiController::class, 'stats'])->name('stats');
|
Route::get('/stats', [EsignApiController::class, 'stats'])->name('stats');
|
||||||
Route::get('/list', [EsignApiController::class, 'index'])->name('list');
|
Route::get('/list', [EsignApiController::class, 'index'])->name('list');
|
||||||
|
|||||||
Reference in New Issue
Block a user