Files
sam-manage/resources/views/credit/inquiry/index.blade.php
pro 7ed908f53d feat:쿠콘 API 신용평가 조회 기능 구현
- CooconConfig 모델 및 마이그레이션 추가
- CooconService 클래스 구현 (OA12~OA17 API)
- CreditController 확장 (설정 관리, 조회 기능)
- 설정 관리 화면 추가 (CRUD, 활성화 토글)
- 사업자번호 조회 화면 업데이트 (API 연동)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 19:33:51 +09:00

400 lines
18 KiB
PHP

@extends('layouts.app')
@section('title', '신용평가 조회')
@section('content')
<div class="flex flex-col h-full">
<!-- 페이지 헤더 -->
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6 flex-shrink-0">
<div>
<h1 class="text-2xl font-bold text-gray-800">신용평가 조회</h1>
<p class="text-sm text-gray-500 mt-1">기업 신용평가 정보를 조회합니다 (쿠콘 API)</p>
</div>
<a href="{{ route('credit.settings.index') }}"
class="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
API 설정
</a>
</div>
@if(!$hasConfig)
<!-- API 설정 없음 경고 -->
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6 flex-shrink-0">
<div class="flex items-center gap-3">
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p class="font-medium text-yellow-800">쿠콘 API 설정이 필요합니다</p>
<p class="text-sm text-yellow-700">신용평가 조회를 위해 먼저 <a href="{{ route('credit.settings.index') }}" class="underline font-medium">API 설정</a> 등록해주세요.</p>
</div>
</div>
</div>
@endif
<!-- 조회 -->
<div class="bg-white rounded-lg shadow-sm p-6 mb-6 flex-shrink-0">
<form id="inquiryForm" class="flex flex-wrap gap-4 items-end">
<!-- 사업자번호 입력 -->
<div class="flex-1 min-w-[200px]">
<label class="block text-sm font-medium text-gray-700 mb-1">사업자번호 / 법인번호 <span class="text-red-500">*</span></label>
<input type="text"
name="company_key"
id="companyKeyInput"
placeholder="사업자번호 또는 법인번호 입력"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
{{ !$hasConfig ? 'disabled' : '' }}>
</div>
<!-- API 유형 선택 -->
<div class="min-w-[200px]">
<label class="block text-sm font-medium text-gray-700 mb-1">조회 항목</label>
<select name="api_type"
id="apiTypeSelect"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
{{ !$hasConfig ? 'disabled' : '' }}>
<option value="all">전체 조회</option>
@foreach($apiTypes as $code => $name)
<option value="{{ $code }}">{{ $name }}</option>
@endforeach
</select>
</div>
<!-- 조회 버튼 -->
<div>
<button type="submit"
id="btnSearch"
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
{{ !$hasConfig ? 'disabled' : '' }}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span id="btnSearchText">조회</span>
<svg id="searchSpinner" class="w-5 h-5 animate-spin hidden" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</button>
</div>
</form>
</div>
<!-- 조회 결과 영역 -->
<div id="result-container" class="bg-white rounded-lg shadow-sm flex-1 min-h-0 overflow-auto">
<!-- 초기 안내 메시지 -->
<div id="initial-message" class="flex flex-col items-center justify-center h-full text-gray-400 p-12">
<svg class="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<p class="text-lg font-medium">신용평가 조회</p>
<p class="text-sm mt-1">사업자번호 또는 법인번호를 입력하여 조회하세요</p>
</div>
<!-- 결과 표시 영역 -->
<div id="result-content" class="hidden p-6">
<!-- 결과가 여기에 렌더링됨 -->
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('inquiryForm');
const resultContainer = document.getElementById('result-container');
const initialMessage = document.getElementById('initial-message');
const resultContent = document.getElementById('result-content');
const btnSearch = document.getElementById('btnSearch');
const btnSearchText = document.getElementById('btnSearchText');
const searchSpinner = document.getElementById('searchSpinner');
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
// API 이름 매핑
const apiNames = @json($apiTypes);
// 조회 폼 제출
form.addEventListener('submit', async function(e) {
e.preventDefault();
const companyKey = document.getElementById('companyKeyInput').value.trim();
const apiType = document.getElementById('apiTypeSelect').value;
if (!companyKey) {
showToast('사업자번호 또는 법인번호를 입력해주세요.', 'warning');
return;
}
// 로딩 상태
btnSearch.disabled = true;
btnSearchText.textContent = '조회 중...';
searchSpinner.classList.remove('hidden');
try {
const response = await fetch('{{ route('credit.inquiry.search') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
'Accept': 'application/json',
},
body: JSON.stringify({
company_key: companyKey,
api_type: apiType,
}),
});
const result = await response.json();
if (result.success) {
renderResults(result.data, companyKey, apiType);
showToast('조회가 완료되었습니다.', 'success');
} else {
showToast(result.error || '조회에 실패했습니다.', 'error');
}
} catch (error) {
console.error('조회 오류:', error);
showToast('조회 중 오류가 발생했습니다.', 'error');
} finally {
btnSearch.disabled = false;
btnSearchText.textContent = '조회';
searchSpinner.classList.add('hidden');
}
});
// 결과 렌더링
function renderResults(data, companyKey, apiType) {
initialMessage.classList.add('hidden');
resultContent.classList.remove('hidden');
let html = `
<div class="mb-4 pb-4 border-b">
<h3 class="text-lg font-semibold text-gray-800">조회 결과</h3>
<p class="text-sm text-gray-500">사업자/법인번호: <span class="font-mono font-medium">${companyKey}</span></p>
</div>
`;
// 각 API 결과를 탭 형태로 표시
const tabs = [];
const contents = [];
for (const [key, value] of Object.entries(data)) {
const apiName = getApiName(key);
const isSuccess = value.success;
const tabId = `tab-${key}`;
tabs.push(`
<button type="button"
class="api-tab px-4 py-2 text-sm font-medium rounded-t-lg border-b-2 transition
${tabs.length === 0 ? 'border-blue-600 text-blue-600 bg-blue-50' : 'border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50'}"
data-tab="${tabId}">
<span class="flex items-center gap-1">
${isSuccess ? '<span class="w-2 h-2 bg-green-500 rounded-full"></span>' : '<span class="w-2 h-2 bg-red-500 rounded-full"></span>'}
${apiName}
</span>
</button>
`);
contents.push(`
<div id="${tabId}" class="api-content ${contents.length === 0 ? '' : 'hidden'}">
${renderApiResult(key, value)}
</div>
`);
}
html += `
<div class="border-b mb-4">
<div class="flex flex-wrap gap-1">
${tabs.join('')}
</div>
</div>
<div class="api-contents">
${contents.join('')}
</div>
`;
resultContent.innerHTML = html;
// 탭 클릭 이벤트
resultContent.querySelectorAll('.api-tab').forEach(tab => {
tab.addEventListener('click', function() {
const tabId = this.dataset.tab;
// 모든 탭 비활성화
resultContent.querySelectorAll('.api-tab').forEach(t => {
t.classList.remove('border-blue-600', 'text-blue-600', 'bg-blue-50');
t.classList.add('border-transparent', 'text-gray-500');
});
// 클릭한 탭 활성화
this.classList.remove('border-transparent', 'text-gray-500');
this.classList.add('border-blue-600', 'text-blue-600', 'bg-blue-50');
// 모든 컨텐츠 숨기기
resultContent.querySelectorAll('.api-content').forEach(c => c.classList.add('hidden'));
// 선택한 컨텐츠 표시
document.getElementById(tabId).classList.remove('hidden');
});
});
}
// API 이름 가져오기
function getApiName(key) {
const nameMap = {
'summary': '신용요약정보 (OA12)',
'shortTermOverdue': '단기연체정보 (OA13)',
'negativeInfoKCI': '신용도판단정보-한국신용정보원 (OA14)',
'negativeInfoCB': '신용도판단정보-신용정보사 (OA15)',
'suspensionInfo': '당좌거래정지정보 (OA16)',
'workoutInfo': '법정관리/워크아웃정보 (OA17)',
};
return nameMap[key] || key;
}
// API 결과 렌더링
function renderApiResult(key, result) {
if (!result.success) {
return `
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex items-center gap-2 text-red-800">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="font-medium">조회 실패</span>
</div>
<p class="mt-2 text-sm text-red-700">${result.error || '알 수 없는 오류가 발생했습니다.'}</p>
${result.code ? `<p class="text-xs text-red-600 mt-1">오류 코드: ${result.code}</p>` : ''}
</div>
`;
}
const data = result.data;
// 데이터가 없는 경우
if (!data || (Array.isArray(data) && data.length === 0) || Object.keys(data).length === 0) {
return `
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center">
<svg class="w-12 h-12 text-gray-400 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="text-gray-600">조회된 데이터가 없습니다.</p>
</div>
`;
}
// 성공 메시지와 데이터 표시
let html = `
<div class="bg-green-50 border border-green-200 rounded-lg p-3 mb-4">
<div class="flex items-center gap-2 text-green-800">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="font-medium">조회 성공</span>
</div>
${result.message ? `<p class="text-sm text-green-700 mt-1">${result.message}</p>` : ''}
</div>
`;
// 데이터 테이블 또는 JSON 표시
if (Array.isArray(data) && data.length > 0) {
html += renderDataTable(data);
} else if (typeof data === 'object') {
html += renderDataObject(data);
}
// Raw 데이터 토글
html += `
<div class="mt-4">
<button type="button" class="toggle-raw text-sm text-gray-500 hover:text-gray-700 flex items-center gap-1">
<svg class="w-4 h-4 transform transition" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
원본 데이터 보기
</button>
<div class="raw-data hidden mt-2">
<pre class="bg-gray-900 text-gray-100 rounded-lg p-4 text-xs overflow-auto max-h-64">${JSON.stringify(result.raw || result, null, 2)}</pre>
</div>
</div>
`;
return html;
}
// 데이터 테이블 렌더링 (배열)
function renderDataTable(data) {
if (data.length === 0) return '';
const headers = Object.keys(data[0]);
let html = `
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
${headers.map(h => `<th class="px-3 py-2 text-left font-medium text-gray-700">${h}</th>`).join('')}
</tr>
</thead>
<tbody class="divide-y">
`;
data.forEach(row => {
html += '<tr class="hover:bg-gray-50">';
headers.forEach(h => {
const value = row[h];
html += `<td class="px-3 py-2 text-gray-600">${formatValue(value)}</td>`;
});
html += '</tr>';
});
html += `
</tbody>
</table>
</div>
`;
return html;
}
// 데이터 오브젝트 렌더링
function renderDataObject(data) {
let html = '<div class="space-y-2">';
for (const [key, value] of Object.entries(data)) {
html += `
<div class="flex border-b pb-2">
<div class="w-1/3 text-sm font-medium text-gray-700">${key}</div>
<div class="w-2/3 text-sm text-gray-600">${formatValue(value)}</div>
</div>
`;
}
html += '</div>';
return html;
}
// 값 포맷팅
function formatValue(value) {
if (value === null || value === undefined) return '<span class="text-gray-400">-</span>';
if (typeof value === 'object') return `<pre class="text-xs bg-gray-100 p-1 rounded">${JSON.stringify(value, null, 2)}</pre>`;
return String(value);
}
// Raw 데이터 토글 이벤트 위임
resultContent.addEventListener('click', function(e) {
if (e.target.closest('.toggle-raw')) {
const btn = e.target.closest('.toggle-raw');
const rawData = btn.nextElementSibling;
const icon = btn.querySelector('svg');
rawData.classList.toggle('hidden');
icon.classList.toggle('rotate-90');
}
});
});
</script>
@endpush