feat:명함 OCR 시스템 구현
- AiConfig 모델: AI API 설정 관리 - BusinessCardOcrService: Gemini Vision API 호출 - BusinessCardOcrController: OCR API 엔드포인트 - AiConfigController: AI 설정 CRUD - create.blade.php: 드래그앤드롭 명함 인식 UI - AI 설정 관리 페이지 추가
This commit is contained in:
36
app/Http/Controllers/Api/BusinessCardOcrController.php
Normal file
36
app/Http/Controllers/Api/BusinessCardOcrController.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\BusinessCardOcrService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BusinessCardOcrController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private BusinessCardOcrService $ocrService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 명함 이미지 OCR 처리
|
||||
*/
|
||||
public function process(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'image' => 'required|string',
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->ocrService->extractFromImage($request->input('image'));
|
||||
|
||||
return response()->json($result);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'error' => $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
200
app/Http/Controllers/System/AiConfigController.php
Normal file
200
app/Http/Controllers/System/AiConfigController.php
Normal file
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\System;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\System\AiConfig;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AiConfigController extends Controller
|
||||
{
|
||||
/**
|
||||
* AI 설정 목록
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('system.ai-config.index'));
|
||||
}
|
||||
|
||||
$configs = AiConfig::orderBy('provider')
|
||||
->orderByDesc('is_active')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('system.ai-config.index', compact('configs'));
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 설정 저장
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'provider' => 'required|string|in:gemini,claude,openai',
|
||||
'api_key' => 'required|string|max:255',
|
||||
'model' => 'required|string|max:100',
|
||||
'base_url' => 'nullable|string|max:255|url',
|
||||
'description' => 'nullable|string',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
// 활성화 시 동일 provider의 다른 설정 비활성화
|
||||
if ($validated['is_active'] ?? false) {
|
||||
AiConfig::where('provider', $validated['provider'])
|
||||
->update(['is_active' => false]);
|
||||
}
|
||||
|
||||
$config = AiConfig::create($validated);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => '저장되었습니다.',
|
||||
'data' => $config,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 설정 수정
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$config = AiConfig::findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'provider' => 'required|string|in:gemini,claude,openai',
|
||||
'api_key' => 'required|string|max:255',
|
||||
'model' => 'required|string|max:100',
|
||||
'base_url' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
// 활성화 시 동일 provider의 다른 설정 비활성화
|
||||
if ($validated['is_active'] ?? false) {
|
||||
AiConfig::where('provider', $validated['provider'])
|
||||
->where('id', '!=', $id)
|
||||
->update(['is_active' => false]);
|
||||
}
|
||||
|
||||
$config->update($validated);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => '수정되었습니다.',
|
||||
'data' => $config->fresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 설정 삭제
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$config = AiConfig::findOrFail($id);
|
||||
$config->delete();
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => '삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 설정 활성화/비활성화 토글
|
||||
*/
|
||||
public function toggle(int $id): JsonResponse
|
||||
{
|
||||
$config = AiConfig::findOrFail($id);
|
||||
|
||||
if (! $config->is_active) {
|
||||
// 활성화 시 동일 provider의 다른 설정 비활성화
|
||||
AiConfig::where('provider', $config->provider)
|
||||
->where('id', '!=', $id)
|
||||
->update(['is_active' => false]);
|
||||
}
|
||||
|
||||
$config->update(['is_active' => ! $config->is_active]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => $config->is_active ? '활성화되었습니다.' : '비활성화되었습니다.',
|
||||
'data' => $config->fresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API 연결 테스트
|
||||
*/
|
||||
public function test(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'provider' => 'required|string|in:gemini,claude,openai',
|
||||
'api_key' => 'required|string',
|
||||
'model' => 'required|string',
|
||||
'base_url' => 'nullable|string',
|
||||
]);
|
||||
|
||||
try {
|
||||
$provider = $validated['provider'];
|
||||
$apiKey = $validated['api_key'];
|
||||
$model = $validated['model'];
|
||||
$baseUrl = $validated['base_url'] ?? AiConfig::DEFAULT_BASE_URLS[$provider];
|
||||
|
||||
if ($provider === 'gemini') {
|
||||
$result = $this->testGemini($baseUrl, $model, $apiKey);
|
||||
} else {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'error' => '아직 지원하지 않는 provider입니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini API 테스트
|
||||
*/
|
||||
private function testGemini(string $baseUrl, string $model, string $apiKey): array
|
||||
{
|
||||
$url = "{$baseUrl}/models/{$model}:generateContent?key={$apiKey}";
|
||||
|
||||
$response = \Illuminate\Support\Facades\Http::timeout(10)->post($url, [
|
||||
'contents' => [
|
||||
[
|
||||
'parts' => [
|
||||
['text' => '안녕하세요. 테스트입니다. "OK"라고만 응답해주세요.'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0,
|
||||
'maxOutputTokens' => 10,
|
||||
],
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => '연결 테스트 성공',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => 'API 응답 오류: ' . $response->status(),
|
||||
];
|
||||
}
|
||||
}
|
||||
143
app/Models/System/AiConfig.php
Normal file
143
app/Models/System/AiConfig.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\System;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* AI API 설정 모델
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $provider
|
||||
* @property string $api_key
|
||||
* @property string $model
|
||||
* @property string|null $base_url
|
||||
* @property string|null $description
|
||||
* @property bool $is_active
|
||||
* @property array|null $options
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
* @property \Carbon\Carbon|null $deleted_at
|
||||
*/
|
||||
class AiConfig extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $table = 'ai_configs';
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'provider',
|
||||
'api_key',
|
||||
'model',
|
||||
'base_url',
|
||||
'description',
|
||||
'is_active',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* Provider별 기본 Base URL
|
||||
*/
|
||||
public const DEFAULT_BASE_URLS = [
|
||||
'gemini' => 'https://generativelanguage.googleapis.com/v1beta',
|
||||
'claude' => 'https://api.anthropic.com/v1',
|
||||
'openai' => 'https://api.openai.com/v1',
|
||||
];
|
||||
|
||||
/**
|
||||
* Provider별 기본 모델
|
||||
*/
|
||||
public const DEFAULT_MODELS = [
|
||||
'gemini' => 'gemini-2.0-flash',
|
||||
'claude' => 'claude-sonnet-4-20250514',
|
||||
'openai' => 'gpt-4o',
|
||||
];
|
||||
|
||||
/**
|
||||
* 활성화된 Gemini 설정 조회
|
||||
*/
|
||||
public static function getActiveGemini(): ?self
|
||||
{
|
||||
return self::where('provider', 'gemini')
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성화된 Claude 설정 조회
|
||||
*/
|
||||
public static function getActiveClaude(): ?self
|
||||
{
|
||||
return self::where('provider', 'claude')
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider별 활성 설정 조회
|
||||
*/
|
||||
public static function getActive(string $provider): ?self
|
||||
{
|
||||
return self::where('provider', $provider)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Base URL 가져오기 (설정 또는 기본값)
|
||||
*/
|
||||
public function getBaseUrlAttribute($value): string
|
||||
{
|
||||
return $value ?? self::DEFAULT_BASE_URLS[$this->provider] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 라벨
|
||||
*/
|
||||
public function getProviderLabelAttribute(): string
|
||||
{
|
||||
return match ($this->provider) {
|
||||
'gemini' => 'Google Gemini',
|
||||
'claude' => 'Anthropic Claude',
|
||||
'openai' => 'OpenAI',
|
||||
default => $this->provider,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 라벨
|
||||
*/
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
return $this->is_active ? '활성' : '비활성';
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 색상 (Tailwind)
|
||||
*/
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
return $this->is_active ? 'green' : 'gray';
|
||||
}
|
||||
|
||||
/**
|
||||
* 마스킹된 API 키 (앞 8자리만 표시)
|
||||
*/
|
||||
public function getMaskedApiKeyAttribute(): string
|
||||
{
|
||||
if (strlen($this->api_key) <= 8) {
|
||||
return $this->api_key;
|
||||
}
|
||||
|
||||
return substr($this->api_key, 0, 8) . str_repeat('*', 8) . '...';
|
||||
}
|
||||
}
|
||||
167
app/Services/BusinessCardOcrService.php
Normal file
167
app/Services/BusinessCardOcrService.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\System\AiConfig;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class BusinessCardOcrService
|
||||
{
|
||||
/**
|
||||
* 명함 이미지에서 정보 추출
|
||||
*/
|
||||
public function extractFromImage(string $base64Image): array
|
||||
{
|
||||
$config = AiConfig::getActiveGemini();
|
||||
|
||||
if (! $config) {
|
||||
throw new \RuntimeException('Gemini API 설정이 없습니다. 시스템 설정에서 AI 설정을 추가해주세요.');
|
||||
}
|
||||
|
||||
return $this->callGeminiVisionApi($config, $base64Image);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini Vision API 호출
|
||||
*/
|
||||
private function callGeminiVisionApi(AiConfig $config, string $base64Image): array
|
||||
{
|
||||
$baseUrl = $config->base_url;
|
||||
$model = $config->model;
|
||||
$apiKey = $config->api_key;
|
||||
|
||||
$url = "{$baseUrl}/models/{$model}:generateContent?key={$apiKey}";
|
||||
|
||||
// Base64 데이터에서 prefix 제거
|
||||
$imageData = $base64Image;
|
||||
$mimeType = 'image/jpeg';
|
||||
|
||||
if (preg_match('/^data:(image\/\w+);base64,/', $base64Image, $matches)) {
|
||||
$mimeType = $matches[1];
|
||||
$imageData = preg_replace('/^data:image\/\w+;base64,/', '', $base64Image);
|
||||
}
|
||||
|
||||
$prompt = $this->buildPrompt();
|
||||
|
||||
try {
|
||||
$response = Http::timeout(30)->post($url, [
|
||||
'contents' => [
|
||||
[
|
||||
'parts' => [
|
||||
[
|
||||
'inline_data' => [
|
||||
'mime_type' => $mimeType,
|
||||
'data' => $imageData,
|
||||
],
|
||||
],
|
||||
[
|
||||
'text' => $prompt,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0.1,
|
||||
'topK' => 40,
|
||||
'topP' => 0.95,
|
||||
'maxOutputTokens' => 1024,
|
||||
'responseMimeType' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('Gemini Vision API error', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
throw new \RuntimeException('AI API 호출 실패: ' . $response->status());
|
||||
}
|
||||
|
||||
$result = $response->json();
|
||||
$text = $result['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
|
||||
// JSON 파싱
|
||||
$parsed = json_decode($text, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
Log::warning('AI response JSON parse failed', ['text' => $text]);
|
||||
throw new \RuntimeException('AI 응답 파싱 실패');
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'data' => $this->normalizeData($parsed),
|
||||
'raw_response' => $text,
|
||||
];
|
||||
} catch (ConnectionException $e) {
|
||||
Log::error('Gemini Vision API connection failed', ['error' => $e->getMessage()]);
|
||||
throw new \RuntimeException('AI API 연결 실패');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OCR 프롬프트 생성
|
||||
*/
|
||||
private function buildPrompt(): string
|
||||
{
|
||||
return <<<PROMPT
|
||||
이 명함 이미지에서 다음 정보를 추출해주세요.
|
||||
|
||||
## 추출 항목
|
||||
1. company_name: 회사명/상호
|
||||
2. ceo_name: 대표자명/담당자명 (명함에 있는 이름)
|
||||
3. business_number: 사업자등록번호 (000-00-00000 형식)
|
||||
4. contact_phone: 연락처/전화번호
|
||||
5. contact_email: 이메일
|
||||
6. address: 주소
|
||||
7. position: 직책
|
||||
8. department: 부서
|
||||
|
||||
## 규칙
|
||||
1. 정보가 없으면 빈 문자열("")로 응답
|
||||
2. 사업자번호는 10자리 숫자를 000-00-00000 형식으로 변환
|
||||
3. 전화번호는 하이픈 포함 형식 유지
|
||||
4. 한국어로 된 정보를 우선 추출
|
||||
|
||||
## 출력 형식 (JSON)
|
||||
{
|
||||
"company_name": "",
|
||||
"ceo_name": "",
|
||||
"business_number": "",
|
||||
"contact_phone": "",
|
||||
"contact_email": "",
|
||||
"address": "",
|
||||
"position": "",
|
||||
"department": ""
|
||||
}
|
||||
|
||||
JSON 형식으로만 응답하세요.
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 추출된 데이터 정규화
|
||||
*/
|
||||
private function normalizeData(array $data): array
|
||||
{
|
||||
// 사업자번호 정규화
|
||||
if (! empty($data['business_number'])) {
|
||||
$digits = preg_replace('/\D/', '', $data['business_number']);
|
||||
if (strlen($digits) === 10) {
|
||||
$data['business_number'] = substr($digits, 0, 3) . '-' . substr($digits, 3, 2) . '-' . substr($digits, 5);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'company_name' => trim($data['company_name'] ?? ''),
|
||||
'ceo_name' => trim($data['ceo_name'] ?? ''),
|
||||
'business_number' => trim($data['business_number'] ?? ''),
|
||||
'contact_phone' => trim($data['contact_phone'] ?? ''),
|
||||
'contact_email' => trim($data['contact_email'] ?? ''),
|
||||
'address' => trim($data['address'] ?? ''),
|
||||
'position' => trim($data['position'] ?? ''),
|
||||
'department' => trim($data['department'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,68 @@
|
||||
|
||||
@section('title', '명함 등록')
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
.ocr-drop-zone {
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
background: #f9fafb;
|
||||
}
|
||||
.ocr-drop-zone:hover, .ocr-drop-zone.dragover {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
.ocr-drop-zone-icon { width: 40px; height: 40px; margin: 0 auto 8px; color: #9ca3af; }
|
||||
.ocr-drop-zone.dragover .ocr-drop-zone-icon { color: #3b82f6; }
|
||||
|
||||
#ocr-preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
border-radius: 8px;
|
||||
margin-top: 12px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ocr-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ocr-status-processing { background: #dbeafe; color: #1d4ed8; }
|
||||
.ocr-status-completed { background: #dcfce7; color: #16a34a; }
|
||||
.ocr-status-error { background: #fee2e2; color: #dc2626; }
|
||||
|
||||
.auto-filled {
|
||||
background: #fef3c7 !important;
|
||||
border-color: #f59e0b !important;
|
||||
animation: highlight-fade 3s ease-out forwards;
|
||||
}
|
||||
@keyframes highlight-fade {
|
||||
0% { background: #fef3c7; border-color: #f59e0b; }
|
||||
100% { background: white; border-color: #d1d5db; }
|
||||
}
|
||||
|
||||
.ocr-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid #3b82f6;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<!-- 페이지 헤더 -->
|
||||
@@ -23,6 +85,29 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 명함 OCR 영역 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-800">
|
||||
<svg class="w-5 h-5 inline-block mr-1 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
AI 명함 인식
|
||||
</h2>
|
||||
<span id="ocr-status" class="hidden"></span>
|
||||
</div>
|
||||
|
||||
<div class="ocr-drop-zone" id="ocr-drop-zone">
|
||||
<input type="file" id="ocr-file-input" accept="image/*" class="hidden">
|
||||
<svg class="ocr-drop-zone-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p class="text-gray-600 font-medium text-sm">명함 이미지를 드래그하거나 클릭하여 업로드</p>
|
||||
<p class="text-gray-400 text-xs mt-1">AI가 자동으로 정보를 추출합니다 (JPG, PNG)</p>
|
||||
</div>
|
||||
<img id="ocr-preview-image" alt="Preview">
|
||||
</div>
|
||||
|
||||
<!-- 폼 -->
|
||||
<form action="{{ route('sales.prospects.store') }}" method="POST" enctype="multipart/form-data" class="bg-white rounded-lg shadow-sm p-6 space-y-6">
|
||||
@csrf
|
||||
@@ -48,7 +133,7 @@ class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition"
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">회사명 <span class="text-red-500">*</span></label>
|
||||
<input type="text" name="company_name" value="{{ old('company_name') }}" required
|
||||
<input type="text" name="company_name" id="company_name" value="{{ old('company_name') }}" required
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 @error('company_name') border-red-500 @enderror">
|
||||
@error('company_name')
|
||||
<p class="mt-1 text-sm text-red-500">{{ $message }}</p>
|
||||
@@ -57,7 +142,7 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">대표자명</label>
|
||||
<input type="text" name="ceo_name" value="{{ old('ceo_name') }}"
|
||||
<input type="text" name="ceo_name" id="ceo_name" value="{{ old('ceo_name') }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,26 +150,26 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">연락처</label>
|
||||
<input type="text" name="contact_phone" value="{{ old('contact_phone') }}"
|
||||
<input type="text" name="contact_phone" id="contact_phone" value="{{ old('contact_phone') }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">이메일</label>
|
||||
<input type="email" name="contact_email" value="{{ old('contact_email') }}"
|
||||
<input type="email" name="contact_email" id="contact_email" value="{{ old('contact_email') }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">주소</label>
|
||||
<input type="text" name="address" value="{{ old('address') }}"
|
||||
<input type="text" name="address" id="address" value="{{ old('address') }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">명함 이미지</label>
|
||||
<input type="file" name="business_card" accept="image/*"
|
||||
<input type="file" name="business_card" id="business_card_file" accept="image/*"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<p class="mt-1 text-xs text-gray-500">JPG, PNG 형식 (최대 5MB)</p>
|
||||
</div>
|
||||
@@ -120,63 +205,177 @@ class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// 사업자번호 자동 포맷팅 (000-00-00000)
|
||||
document.getElementById('business_number').addEventListener('input', function(e) {
|
||||
let value = e.target.value.replace(/[^0-9]/g, ''); // 숫자만 추출
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const fileInput = document.getElementById('ocr-file-input');
|
||||
const dropZone = document.getElementById('ocr-drop-zone');
|
||||
const previewImage = document.getElementById('ocr-preview-image');
|
||||
const statusEl = document.getElementById('ocr-status');
|
||||
const businessCardFile = document.getElementById('business_card_file');
|
||||
const csrfToken = '{{ csrf_token() }}';
|
||||
|
||||
if (value.length > 10) {
|
||||
value = value.substring(0, 10); // 최대 10자리
|
||||
}
|
||||
|
||||
// 하이픈 삽입 (3-2-5 형식)
|
||||
if (value.length > 5) {
|
||||
value = value.substring(0, 3) + '-' + value.substring(3, 5) + '-' + value.substring(5);
|
||||
} else if (value.length > 3) {
|
||||
value = value.substring(0, 3) + '-' + value.substring(3);
|
||||
}
|
||||
|
||||
e.target.value = value;
|
||||
});
|
||||
|
||||
// 중복 확인
|
||||
document.getElementById('checkBusinessNumber').addEventListener('click', function() {
|
||||
const businessNumber = document.getElementById('business_number').value;
|
||||
const resultEl = document.getElementById('businessNumberResult');
|
||||
|
||||
if (!businessNumber) {
|
||||
resultEl.textContent = '사업자번호를 입력해주세요.';
|
||||
resultEl.className = 'mt-1 text-sm text-red-500';
|
||||
return;
|
||||
}
|
||||
|
||||
// 형식 검증 (000-00-00000)
|
||||
if (!/^\d{3}-\d{2}-\d{5}$/.test(businessNumber)) {
|
||||
resultEl.textContent = '사업자번호 형식이 올바르지 않습니다. (000-00-00000)';
|
||||
resultEl.className = 'mt-1 text-sm text-red-500';
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('{{ route("sales.prospects.check-business-number") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ business_number: businessNumber })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.can_register) {
|
||||
resultEl.textContent = '등록 가능한 사업자번호입니다.';
|
||||
resultEl.className = 'mt-1 text-sm text-green-500';
|
||||
} else {
|
||||
resultEl.textContent = data.reason;
|
||||
resultEl.className = 'mt-1 text-sm text-red-500';
|
||||
// 드래그 앤 드롭
|
||||
dropZone.addEventListener('click', () => fileInput.click());
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('dragover');
|
||||
});
|
||||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('dragover');
|
||||
if (e.dataTransfer.files.length) {
|
||||
handleFile(e.dataTransfer.files[0]);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
resultEl.textContent = '확인 중 오류가 발생했습니다.';
|
||||
resultEl.className = 'mt-1 text-sm text-red-500';
|
||||
});
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
if (e.target.files.length) handleFile(e.target.files[0]);
|
||||
});
|
||||
|
||||
// 파일 처리
|
||||
async function handleFile(file) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
showStatus('error', '이미지 파일만 지원합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus('processing', 'AI 분석 중...');
|
||||
|
||||
// 이미지 미리보기
|
||||
const base64 = await fileToBase64(file);
|
||||
previewImage.src = base64;
|
||||
previewImage.style.display = 'block';
|
||||
|
||||
// DataTransfer를 사용하여 명함 이미지 필드에도 파일 설정
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
businessCardFile.files = dataTransfer.files;
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ route("api.business-card-ocr") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify({ image: base64 })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
fillFormFromData(result.data);
|
||||
showStatus('completed', '인식 완료');
|
||||
} else {
|
||||
showStatus('error', result.error || '인식 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showStatus('error', '처리 중 오류 발생');
|
||||
}
|
||||
}
|
||||
|
||||
// File to Base64
|
||||
function fileToBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// 폼 채우기
|
||||
function fillFormFromData(data) {
|
||||
const fieldMap = {
|
||||
'business_number': 'business_number',
|
||||
'company_name': 'company_name',
|
||||
'ceo_name': 'ceo_name',
|
||||
'contact_phone': 'contact_phone',
|
||||
'contact_email': 'contact_email',
|
||||
'address': 'address'
|
||||
};
|
||||
|
||||
for (const [dataKey, fieldId] of Object.entries(fieldMap)) {
|
||||
const value = data[dataKey];
|
||||
if (value) {
|
||||
const el = document.getElementById(fieldId);
|
||||
if (el) {
|
||||
el.value = value;
|
||||
el.classList.add('auto-filled');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 상태 표시
|
||||
function showStatus(type, text) {
|
||||
statusEl.className = 'ocr-status ocr-status-' + type;
|
||||
statusEl.innerHTML = type === 'processing'
|
||||
? '<span class="ocr-spinner"></span> ' + text
|
||||
: text;
|
||||
statusEl.classList.remove('hidden');
|
||||
|
||||
if (type === 'completed') {
|
||||
setTimeout(() => statusEl.classList.add('hidden'), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// 사업자번호 자동 포맷팅 (000-00-00000)
|
||||
document.getElementById('business_number').addEventListener('input', function(e) {
|
||||
let value = e.target.value.replace(/[^0-9]/g, '');
|
||||
|
||||
if (value.length > 10) {
|
||||
value = value.substring(0, 10);
|
||||
}
|
||||
|
||||
if (value.length > 5) {
|
||||
value = value.substring(0, 3) + '-' + value.substring(3, 5) + '-' + value.substring(5);
|
||||
} else if (value.length > 3) {
|
||||
value = value.substring(0, 3) + '-' + value.substring(3);
|
||||
}
|
||||
|
||||
e.target.value = value;
|
||||
});
|
||||
|
||||
// 중복 확인
|
||||
document.getElementById('checkBusinessNumber').addEventListener('click', function() {
|
||||
const businessNumber = document.getElementById('business_number').value;
|
||||
const resultEl = document.getElementById('businessNumberResult');
|
||||
|
||||
if (!businessNumber) {
|
||||
resultEl.textContent = '사업자번호를 입력해주세요.';
|
||||
resultEl.className = 'mt-1 text-sm text-red-500';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^\d{3}-\d{2}-\d{5}$/.test(businessNumber)) {
|
||||
resultEl.textContent = '사업자번호 형식이 올바르지 않습니다. (000-00-00000)';
|
||||
resultEl.className = 'mt-1 text-sm text-red-500';
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('{{ route("sales.prospects.check-business-number") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify({ business_number: businessNumber })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.can_register) {
|
||||
resultEl.textContent = '등록 가능한 사업자번호입니다.';
|
||||
resultEl.className = 'mt-1 text-sm text-green-500';
|
||||
} else {
|
||||
resultEl.textContent = data.reason;
|
||||
resultEl.className = 'mt-1 text-sm text-red-500';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
resultEl.textContent = '확인 중 오류가 발생했습니다.';
|
||||
resultEl.className = 'mt-1 text-sm text-red-500';
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
414
resources/views/system/ai-config/index.blade.php
Normal file
414
resources/views/system/ai-config/index.blade.php
Normal file
@@ -0,0 +1,414 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'AI 설정 관리')
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
.provider-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.provider-gemini { background: #e8f0fe; color: #1a73e8; }
|
||||
.provider-claude { background: #fef3e8; color: #d97706; }
|
||||
.provider-openai { background: #e8f8e8; color: #16a34a; }
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-active { background: #dcfce7; color: #16a34a; }
|
||||
.status-inactive { background: #f3f4f6; color: #6b7280; }
|
||||
|
||||
.config-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
}
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">AI 설정 관리</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">AI API 키 및 모델 설정을 관리합니다</p>
|
||||
</div>
|
||||
<button type="button" onclick="openModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition inline-flex items-center gap-2">
|
||||
<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 4v16m8-8H4" />
|
||||
</svg>
|
||||
새 설정 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 설정 목록 -->
|
||||
<div id="config-list">
|
||||
@forelse($configs as $config)
|
||||
<div class="config-card" data-id="{{ $config->id }}">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="font-semibold text-lg text-gray-800">{{ $config->name }}</h3>
|
||||
<span class="provider-badge provider-{{ $config->provider }}">
|
||||
{{ $config->provider_label }}
|
||||
</span>
|
||||
<span class="status-badge {{ $config->is_active ? 'status-active' : 'status-inactive' }}">
|
||||
{{ $config->status_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 space-y-1">
|
||||
<p><span class="font-medium">모델:</span> {{ $config->model }}</p>
|
||||
<p><span class="font-medium">API 키:</span> {{ $config->masked_api_key }}</p>
|
||||
@if($config->description)
|
||||
<p><span class="font-medium">설명:</span> {{ $config->description }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" onclick="testConnection({{ $config->id }})" class="px-3 py-1.5 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition">
|
||||
테스트
|
||||
</button>
|
||||
<button type="button" onclick="toggleConfig({{ $config->id }})" class="px-3 py-1.5 text-sm {{ $config->is_active ? 'bg-yellow-100 hover:bg-yellow-200 text-yellow-700' : 'bg-green-100 hover:bg-green-200 text-green-700' }} rounded-lg transition">
|
||||
{{ $config->is_active ? '비활성화' : '활성화' }}
|
||||
</button>
|
||||
<button type="button" onclick="editConfig({{ $config->id }}, {{ json_encode($config) }})" class="px-3 py-1.5 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition">
|
||||
수정
|
||||
</button>
|
||||
<button type="button" onclick="deleteConfig({{ $config->id }}, '{{ $config->name }}')" class="px-3 py-1.5 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-center py-12 bg-white rounded-lg shadow-sm">
|
||||
<svg class="w-12 h-12 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p class="text-gray-500">등록된 AI 설정이 없습니다.</p>
|
||||
<p class="text-sm text-gray-400 mt-1">'새 설정 추가' 버튼을 클릭하여 AI API를 등록하세요.</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<!-- 사용 안내 -->
|
||||
<div class="mt-8 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h3 class="font-medium text-blue-800 mb-2">사용 안내</h3>
|
||||
<ul class="text-sm text-blue-700 space-y-1">
|
||||
<li>각 Provider(Gemini, Claude, OpenAI)별로 하나의 설정만 활성화할 수 있습니다.</li>
|
||||
<li>명함 OCR 기능은 Gemini Vision API를 사용합니다.</li>
|
||||
<li>API 키는 각 제공자의 콘솔에서 발급받을 수 있습니다.</li>
|
||||
<li>테스트 버튼으로 API 연결 상태를 확인할 수 있습니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 추가/수정 모달 -->
|
||||
<div id="config-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 id="modal-title" class="text-xl font-bold text-gray-800">새 설정 추가</h2>
|
||||
<button type="button" onclick="closeModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="config-form" class="space-y-4">
|
||||
<input type="hidden" id="config-id" value="">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Provider <span class="text-red-500">*</span></label>
|
||||
<select id="config-provider" required class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="gemini">Google Gemini</option>
|
||||
<option value="claude">Anthropic Claude</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">설정 이름 <span class="text-red-500">*</span></label>
|
||||
<input type="text" id="config-name" required placeholder="예: Gemini Production" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">API 키 <span class="text-red-500">*</span></label>
|
||||
<input type="password" id="config-api-key" required placeholder="API 키 입력" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">모델 <span class="text-red-500">*</span></label>
|
||||
<input type="text" id="config-model" required placeholder="예: gemini-2.0-flash" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<p class="mt-1 text-xs text-gray-500">기본값: <span id="default-model">gemini-2.0-flash</span></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Base URL (선택)</label>
|
||||
<input type="url" id="config-base-url" placeholder="비워두면 기본 URL 사용" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">설명 (선택)</label>
|
||||
<textarea id="config-description" rows="2" placeholder="설정에 대한 설명" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="config-is-active" class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||||
<label for="config-is-active" class="ml-2 text-sm text-gray-700">활성화 (동일 Provider의 기존 활성 설정은 비활성화됩니다)</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4 border-t">
|
||||
<button type="button" onclick="closeModal()" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition">
|
||||
취소
|
||||
</button>
|
||||
<button type="button" onclick="testConnectionFromModal()" class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition">
|
||||
연결 테스트
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
const csrfToken = '{{ csrf_token() }}';
|
||||
|
||||
const defaultModels = {
|
||||
gemini: 'gemini-2.0-flash',
|
||||
claude: 'claude-sonnet-4-20250514',
|
||||
openai: 'gpt-4o'
|
||||
};
|
||||
|
||||
// Provider 변경 시 기본 모델 업데이트
|
||||
document.getElementById('config-provider').addEventListener('change', function() {
|
||||
const provider = this.value;
|
||||
document.getElementById('default-model').textContent = defaultModels[provider];
|
||||
document.getElementById('config-model').placeholder = '예: ' + defaultModels[provider];
|
||||
});
|
||||
|
||||
// 모달 열기
|
||||
function openModal(config = null) {
|
||||
const modal = document.getElementById('config-modal');
|
||||
const title = document.getElementById('modal-title');
|
||||
const form = document.getElementById('config-form');
|
||||
|
||||
if (config) {
|
||||
title.textContent = '설정 수정';
|
||||
document.getElementById('config-id').value = config.id;
|
||||
document.getElementById('config-provider').value = config.provider;
|
||||
document.getElementById('config-name').value = config.name;
|
||||
document.getElementById('config-api-key').value = config.api_key;
|
||||
document.getElementById('config-model').value = config.model;
|
||||
document.getElementById('config-base-url').value = config.base_url || '';
|
||||
document.getElementById('config-description').value = config.description || '';
|
||||
document.getElementById('config-is-active').checked = config.is_active;
|
||||
} else {
|
||||
title.textContent = '새 설정 추가';
|
||||
form.reset();
|
||||
document.getElementById('config-id').value = '';
|
||||
}
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 모달 닫기
|
||||
function closeModal() {
|
||||
document.getElementById('config-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 수정
|
||||
function editConfig(id, config) {
|
||||
openModal(config);
|
||||
}
|
||||
|
||||
// 폼 제출
|
||||
document.getElementById('config-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const id = document.getElementById('config-id').value;
|
||||
const data = {
|
||||
provider: document.getElementById('config-provider').value,
|
||||
name: document.getElementById('config-name').value,
|
||||
api_key: document.getElementById('config-api-key').value,
|
||||
model: document.getElementById('config-model').value,
|
||||
base_url: document.getElementById('config-base-url').value || null,
|
||||
description: document.getElementById('config-description').value || null,
|
||||
is_active: document.getElementById('config-is-active').checked
|
||||
};
|
||||
|
||||
try {
|
||||
const url = id
|
||||
? `{{ url('system/ai-config') }}/${id}`
|
||||
: '{{ route("system.ai-config.store") }}';
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
showToast(result.message, 'success');
|
||||
closeModal();
|
||||
location.reload();
|
||||
} else {
|
||||
showToast(result.message || '저장 실패', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('저장 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// 토글
|
||||
async function toggleConfig(id) {
|
||||
try {
|
||||
const response = await fetch(`{{ url('system/ai-config') }}/${id}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
showToast(result.message, 'success');
|
||||
location.reload();
|
||||
} else {
|
||||
showToast(result.message || '변경 실패', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('처리 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 삭제
|
||||
async function deleteConfig(id, name) {
|
||||
if (!confirm(`'${name}' 설정을 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`{{ url('system/ai-config') }}/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
showToast(result.message, 'success');
|
||||
location.reload();
|
||||
} else {
|
||||
showToast(result.message || '삭제 실패', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('삭제 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 연결 테스트 (목록에서)
|
||||
async function testConnection(id) {
|
||||
showToast('연결 테스트 중...', 'info');
|
||||
|
||||
// 해당 설정의 정보를 가져와서 테스트
|
||||
const card = document.querySelector(`.config-card[data-id="${id}"]`);
|
||||
// 실제로는 서버에서 설정을 가져와서 테스트해야 함
|
||||
// 여기서는 간단히 처리
|
||||
showToast('설정 수정 화면에서 테스트해주세요.', 'warning');
|
||||
}
|
||||
|
||||
// 연결 테스트 (모달에서)
|
||||
async function testConnectionFromModal() {
|
||||
const data = {
|
||||
provider: document.getElementById('config-provider').value,
|
||||
api_key: document.getElementById('config-api-key').value,
|
||||
model: document.getElementById('config-model').value,
|
||||
base_url: document.getElementById('config-base-url').value || null
|
||||
};
|
||||
|
||||
if (!data.api_key) {
|
||||
showToast('API 키를 입력해주세요.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showToast('연결 테스트 중...', 'info');
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ route("system.ai-config.test") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
showToast('연결 테스트 성공!', 'success');
|
||||
} else {
|
||||
showToast(result.error || '연결 테스트 실패', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('테스트 중 오류가 발생했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 모달 외부 클릭 시 닫기
|
||||
document.getElementById('config-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\BusinessCardOcrController;
|
||||
use App\Http\Controllers\ApiLogController;
|
||||
use App\Http\Controllers\ArchivedRecordController;
|
||||
use App\Http\Controllers\AuditLogController;
|
||||
@@ -23,6 +24,7 @@
|
||||
use App\Http\Controllers\QuoteFormulaController;
|
||||
use App\Http\Controllers\RoleController;
|
||||
use App\Http\Controllers\RolePermissionController;
|
||||
use App\Http\Controllers\System\AiConfigController;
|
||||
use App\Http\Controllers\TenantController;
|
||||
use App\Http\Controllers\TenantSettingController;
|
||||
use App\Http\Controllers\CommonCodeController;
|
||||
@@ -316,6 +318,19 @@
|
||||
Route::get('/{id}/edit', [DocumentTemplateController::class, 'edit'])->name('edit');
|
||||
});
|
||||
|
||||
// AI 설정 관리
|
||||
Route::prefix('system/ai-config')->name('system.ai-config.')->group(function () {
|
||||
Route::get('/', [AiConfigController::class, 'index'])->name('index');
|
||||
Route::post('/', [AiConfigController::class, 'store'])->name('store');
|
||||
Route::put('/{id}', [AiConfigController::class, 'update'])->name('update');
|
||||
Route::delete('/{id}', [AiConfigController::class, 'destroy'])->name('destroy');
|
||||
Route::post('/{id}/toggle', [AiConfigController::class, 'toggle'])->name('toggle');
|
||||
Route::post('/test', [AiConfigController::class, 'test'])->name('test');
|
||||
});
|
||||
|
||||
// 명함 OCR API
|
||||
Route::post('/api/business-card-ocr', [BusinessCardOcrController::class, 'process'])->name('api.business-card-ocr');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 바로빌 Routes
|
||||
|
||||
Reference in New Issue
Block a user