feat: [rd] AI 견적 엔진 Phase 1 구현
- 모델 3개: AiQuotationModule, AiQuotation, AiQuotationItem - AiQuotationService: Gemini/Claude 2단계 AI 파이프라인 - RdController: R&D 대시보드 + AI 견적 Blade 화면 - AiQuotationController: AI 견적 API (생성/목록/상세/재분석) - Blade 뷰: 대시보드, 목록, 생성, 상세, HTMX 테이블 - 라우트: /rd/* (web), /admin/rd/* (api)
This commit is contained in:
109
app/Http/Controllers/Api/Admin/Rd/AiQuotationController.php
Normal file
109
app/Http/Controllers/Api/Admin/Rd/AiQuotationController.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Rd;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Rd\StoreAiQuotationRequest;
|
||||
use App\Services\Rd\AiQuotationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AiQuotationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiQuotationService $quotationService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 목록 (HTMX partial 또는 JSON)
|
||||
*/
|
||||
public function index(Request $request): View|JsonResponse
|
||||
{
|
||||
$params = $request->only(['status', 'search', 'per_page']);
|
||||
$quotations = $this->quotationService->getList($params);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return view('rd.ai-quotation.partials.table', compact('quotations'));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $quotations,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적 생성 + AI 분석 실행
|
||||
*/
|
||||
public function store(StoreAiQuotationRequest $request): JsonResponse
|
||||
{
|
||||
$result = $this->quotationService->createAndAnalyze($request->validated());
|
||||
|
||||
if ($result['ok']) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'AI 분석이 완료되었습니다.',
|
||||
'data' => $result['quotation'],
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'AI 분석에 실패했습니다.',
|
||||
'error' => $result['error'],
|
||||
'data' => $result['quotation'] ?? null,
|
||||
], 422);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 조회
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$quotation = $this->quotationService->getById($id);
|
||||
|
||||
if (! $quotation) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'AI 견적을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $quotation,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 재분석
|
||||
*/
|
||||
public function analyze(int $id): JsonResponse
|
||||
{
|
||||
$quotation = $this->quotationService->getById($id);
|
||||
|
||||
if (! $quotation) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'AI 견적을 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$result = $this->quotationService->runAnalysis($quotation);
|
||||
|
||||
if ($result['ok']) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'AI 재분석이 완료되었습니다.',
|
||||
'data' => $result['quotation'],
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'AI 재분석에 실패했습니다.',
|
||||
'error' => $result['error'],
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
74
app/Http/Controllers/RdController.php
Normal file
74
app/Http/Controllers/RdController.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Rd\AiQuotation;
|
||||
use App\Services\Rd\AiQuotationService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RdController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiQuotationService $quotationService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* R&D 대시보드
|
||||
*/
|
||||
public function index(Request $request): View|\Illuminate\Http\Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('rd.index'));
|
||||
}
|
||||
|
||||
$dashboard = $this->quotationService->getDashboardStats();
|
||||
$statuses = AiQuotation::getStatuses();
|
||||
|
||||
return view('rd.index', compact('dashboard', 'statuses'));
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 견적 목록
|
||||
*/
|
||||
public function quotations(Request $request): View|\Illuminate\Http\Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('rd.ai-quotation.index'));
|
||||
}
|
||||
|
||||
$statuses = AiQuotation::getStatuses();
|
||||
|
||||
return view('rd.ai-quotation.index', compact('statuses'));
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 견적 생성 폼
|
||||
*/
|
||||
public function createQuotation(Request $request): View|\Illuminate\Http\Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('rd.ai-quotation.create'));
|
||||
}
|
||||
|
||||
return view('rd.ai-quotation.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 견적 상세
|
||||
*/
|
||||
public function showQuotation(Request $request, int $id): View|\Illuminate\Http\Response
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 200)->header('HX-Redirect', route('rd.ai-quotation.show', $id));
|
||||
}
|
||||
|
||||
$quotation = $this->quotationService->getById($id);
|
||||
|
||||
if (! $quotation) {
|
||||
abort(404, 'AI 견적을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
return view('rd.ai-quotation.show', compact('quotation'));
|
||||
}
|
||||
}
|
||||
33
app/Http/Requests/Rd/StoreAiQuotationRequest.php
Normal file
33
app/Http/Requests/Rd/StoreAiQuotationRequest.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Rd;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreAiQuotationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => 'required|string|max:200',
|
||||
'input_type' => 'required|in:text,voice,document',
|
||||
'input_text' => 'required_if:input_type,text|nullable|string',
|
||||
'ai_provider' => 'nullable|in:gemini,claude',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'title.required' => '견적 제목을 입력하세요.',
|
||||
'title.max' => '제목은 200자 이내로 입력하세요.',
|
||||
'input_type.required' => '입력 유형을 선택하세요.',
|
||||
'input_text.required_if' => '인터뷰 내용을 입력하세요.',
|
||||
];
|
||||
}
|
||||
}
|
||||
130
app/Models/Rd/AiQuotation.php
Normal file
130
app/Models/Rd/AiQuotation.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Rd;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class AiQuotation extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'ai_quotations';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'title',
|
||||
'input_type',
|
||||
'input_text',
|
||||
'input_file_path',
|
||||
'ai_provider',
|
||||
'ai_model',
|
||||
'analysis_result',
|
||||
'quotation_result',
|
||||
'status',
|
||||
'linked_quote_id',
|
||||
'total_dev_cost',
|
||||
'total_monthly_fee',
|
||||
'created_by',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'analysis_result' => 'array',
|
||||
'quotation_result' => 'array',
|
||||
'options' => 'array',
|
||||
'total_dev_cost' => 'decimal:0',
|
||||
'total_monthly_fee' => 'decimal:0',
|
||||
];
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public static function getStatuses(): array
|
||||
{
|
||||
return [
|
||||
self::STATUS_PENDING => '대기',
|
||||
self::STATUS_PROCESSING => '분석중',
|
||||
self::STATUS_COMPLETED => '완료',
|
||||
self::STATUS_FAILED => '실패',
|
||||
];
|
||||
}
|
||||
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
self::STATUS_PENDING => '대기',
|
||||
self::STATUS_PROCESSING => '분석중',
|
||||
self::STATUS_COMPLETED => '완료',
|
||||
self::STATUS_FAILED => '실패',
|
||||
default => $this->status,
|
||||
};
|
||||
}
|
||||
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
self::STATUS_PENDING => 'badge-warning',
|
||||
self::STATUS_PROCESSING => 'badge-info',
|
||||
self::STATUS_COMPLETED => 'badge-success',
|
||||
self::STATUS_FAILED => 'badge-error',
|
||||
default => 'badge-ghost',
|
||||
};
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_COMPLETED;
|
||||
}
|
||||
|
||||
public function isProcessing(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PROCESSING;
|
||||
}
|
||||
|
||||
// Relations
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(AiQuotationItem::class, 'ai_quotation_id')->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
public function getOption(string $key, $default = null)
|
||||
{
|
||||
return data_get($this->options, $key, $default);
|
||||
}
|
||||
|
||||
public function setOption(string $key, $value): void
|
||||
{
|
||||
$options = $this->options ?? [];
|
||||
data_set($options, $key, $value);
|
||||
$this->options = $options;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function getFormattedDevCostAttribute(): string
|
||||
{
|
||||
return number_format((int) $this->total_dev_cost).'원';
|
||||
}
|
||||
|
||||
public function getFormattedMonthlyFeeAttribute(): string
|
||||
{
|
||||
return number_format((int) $this->total_monthly_fee).'원';
|
||||
}
|
||||
}
|
||||
64
app/Models/Rd/AiQuotationItem.php
Normal file
64
app/Models/Rd/AiQuotationItem.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Rd;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AiQuotationItem extends Model
|
||||
{
|
||||
protected $table = 'ai_quotation_items';
|
||||
|
||||
protected $fillable = [
|
||||
'ai_quotation_id',
|
||||
'module_id',
|
||||
'module_code',
|
||||
'module_name',
|
||||
'is_required',
|
||||
'reason',
|
||||
'dev_cost',
|
||||
'monthly_fee',
|
||||
'sort_order',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_required' => 'boolean',
|
||||
'options' => 'array',
|
||||
'dev_cost' => 'decimal:0',
|
||||
'monthly_fee' => 'decimal:0',
|
||||
];
|
||||
|
||||
public function quotation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AiQuotation::class, 'ai_quotation_id');
|
||||
}
|
||||
|
||||
public function module(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AiQuotationModule::class, 'module_id');
|
||||
}
|
||||
|
||||
public function getFormattedDevCostAttribute(): string
|
||||
{
|
||||
return number_format((int) $this->dev_cost).'원';
|
||||
}
|
||||
|
||||
public function getFormattedMonthlyFeeAttribute(): string
|
||||
{
|
||||
return number_format((int) $this->monthly_fee).'원';
|
||||
}
|
||||
|
||||
public function getOption(string $key, $default = null)
|
||||
{
|
||||
return data_get($this->options, $key, $default);
|
||||
}
|
||||
|
||||
public function setOption(string $key, $value): void
|
||||
{
|
||||
$options = $this->options ?? [];
|
||||
data_set($options, $key, $value);
|
||||
$this->options = $options;
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
88
app/Models/Rd/AiQuotationModule.php
Normal file
88
app/Models/Rd/AiQuotationModule.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Rd;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AiQuotationModule extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $table = 'ai_quotation_modules';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'module_code',
|
||||
'module_name',
|
||||
'category',
|
||||
'description',
|
||||
'keywords',
|
||||
'dev_cost',
|
||||
'monthly_fee',
|
||||
'is_active',
|
||||
'sort_order',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'keywords' => 'array',
|
||||
'options' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'dev_cost' => 'decimal:0',
|
||||
'monthly_fee' => 'decimal:0',
|
||||
];
|
||||
|
||||
public const CATEGORY_BASIC = 'basic';
|
||||
|
||||
public const CATEGORY_INDIVIDUAL = 'individual';
|
||||
|
||||
public const CATEGORY_ADDON = 'addon';
|
||||
|
||||
public static function getCategories(): array
|
||||
{
|
||||
return [
|
||||
self::CATEGORY_BASIC => '기본 패키지',
|
||||
self::CATEGORY_INDIVIDUAL => '개별 모듈',
|
||||
self::CATEGORY_ADDON => '부가 옵션',
|
||||
];
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 프롬프트에 주입할 활성 모듈 목록 조회
|
||||
*/
|
||||
public static function getActiveModulesForPrompt(): array
|
||||
{
|
||||
return self::active()
|
||||
->orderBy('sort_order')
|
||||
->get()
|
||||
->map(fn ($m) => [
|
||||
'module_code' => $m->module_code,
|
||||
'module_name' => $m->module_name,
|
||||
'category' => $m->category,
|
||||
'description' => $m->description,
|
||||
'keywords' => $m->keywords,
|
||||
'dev_cost' => (int) $m->dev_cost,
|
||||
'monthly_fee' => (int) $m->monthly_fee,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getOption(string $key, $default = null)
|
||||
{
|
||||
return data_get($this->options, $key, $default);
|
||||
}
|
||||
|
||||
public function setOption(string $key, $value): void
|
||||
{
|
||||
$options = $this->options ?? [];
|
||||
data_set($options, $key, $value);
|
||||
$this->options = $options;
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
434
app/Services/Rd/AiQuotationService.php
Normal file
434
app/Services/Rd/AiQuotationService.php
Normal file
@@ -0,0 +1,434 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Rd;
|
||||
|
||||
use App\Helpers\AiTokenHelper;
|
||||
use App\Models\Rd\AiQuotation;
|
||||
use App\Models\Rd\AiQuotationItem;
|
||||
use App\Models\Rd\AiQuotationModule;
|
||||
use App\Models\System\AiConfig;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AiQuotationService
|
||||
{
|
||||
/**
|
||||
* 목록 조회
|
||||
*/
|
||||
public function getList(array $params = []): LengthAwarePaginator
|
||||
{
|
||||
$query = AiQuotation::query()
|
||||
->with('creator:id,name')
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
if (! empty($params['status'])) {
|
||||
$query->where('status', $params['status']);
|
||||
}
|
||||
|
||||
if (! empty($params['search'])) {
|
||||
$search = $params['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('title', 'like', "%{$search}%")
|
||||
->orWhere('input_text', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
return $query->paginate($params['per_page'] ?? 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상세 조회
|
||||
*/
|
||||
public function getById(int $id): ?AiQuotation
|
||||
{
|
||||
return AiQuotation::with(['items', 'creator:id,name'])->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적 요청 생성 + AI 분석 실행
|
||||
*/
|
||||
public function createAndAnalyze(array $data): array
|
||||
{
|
||||
$provider = $data['ai_provider'] ?? 'gemini';
|
||||
|
||||
$quotation = AiQuotation::create([
|
||||
'tenant_id' => session('selected_tenant_id', 1),
|
||||
'title' => $data['title'],
|
||||
'input_type' => $data['input_type'] ?? 'text',
|
||||
'input_text' => $data['input_text'] ?? null,
|
||||
'ai_provider' => $provider,
|
||||
'status' => AiQuotation::STATUS_PENDING,
|
||||
'created_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
return $this->runAnalysis($quotation);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 분석 실행 (재분석 가능)
|
||||
*/
|
||||
public function runAnalysis(AiQuotation $quotation): array
|
||||
{
|
||||
try {
|
||||
$quotation->update(['status' => AiQuotation::STATUS_PROCESSING]);
|
||||
|
||||
$provider = $quotation->ai_provider;
|
||||
$config = AiConfig::getActive($provider);
|
||||
|
||||
if (! $config) {
|
||||
throw new \RuntimeException("{$provider} API 설정이 없습니다.");
|
||||
}
|
||||
|
||||
// 1차 호출: 업무 분석
|
||||
$modules = AiQuotationModule::getActiveModulesForPrompt();
|
||||
$analysisPrompt = $this->buildAnalysisPrompt($quotation->input_text, $modules);
|
||||
$analysisRaw = $this->callAi($config, $provider, $analysisPrompt, 'AI견적-업무분석');
|
||||
|
||||
$analysisResult = $this->parseJsonResponse($analysisRaw);
|
||||
if (! $analysisResult) {
|
||||
throw new \RuntimeException('AI 업무 분석 결과 파싱 실패');
|
||||
}
|
||||
|
||||
$quotation->update(['analysis_result' => $analysisResult]);
|
||||
|
||||
// 2차 호출: 견적 생성
|
||||
$quotationPrompt = $this->buildQuotationPrompt($analysisResult, $modules);
|
||||
$quotationRaw = $this->callAi($config, $provider, $quotationPrompt, 'AI견적-견적생성');
|
||||
|
||||
$quotationResult = $this->parseJsonResponse($quotationRaw);
|
||||
if (! $quotationResult) {
|
||||
throw new \RuntimeException('AI 견적 생성 결과 파싱 실패');
|
||||
}
|
||||
|
||||
// 추천 모듈 아이템 저장
|
||||
$this->saveQuotationItems($quotation, $quotationResult, $modules);
|
||||
|
||||
// 합계 계산
|
||||
$totals = $quotation->items()->selectRaw(
|
||||
'SUM(dev_cost) as total_dev, SUM(monthly_fee) as total_monthly'
|
||||
)->first();
|
||||
|
||||
$quotation->update([
|
||||
'quotation_result' => $quotationResult,
|
||||
'ai_model' => $config->model,
|
||||
'total_dev_cost' => $totals->total_dev ?? 0,
|
||||
'total_monthly_fee' => $totals->total_monthly ?? 0,
|
||||
'status' => AiQuotation::STATUS_COMPLETED,
|
||||
]);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'quotation' => $quotation->fresh(['items', 'creator:id,name']),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('AI 견적 분석 실패', [
|
||||
'quotation_id' => $quotation->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$quotation->update(['status' => AiQuotation::STATUS_FAILED]);
|
||||
|
||||
return [
|
||||
'ok' => false,
|
||||
'error' => $e->getMessage(),
|
||||
'quotation' => $quotation->fresh(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI API 호출 (Gemini / Claude)
|
||||
*/
|
||||
private function callAi(AiConfig $config, string $provider, string $prompt, string $menuName): ?string
|
||||
{
|
||||
return match ($provider) {
|
||||
'gemini' => $this->callGemini($config, $prompt, $menuName),
|
||||
'claude' => $this->callClaude($config, $prompt, $menuName),
|
||||
default => throw new \RuntimeException("지원하지 않는 AI Provider: {$provider}"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini API 호출
|
||||
*/
|
||||
private function callGemini(AiConfig $config, string $prompt, string $menuName): ?string
|
||||
{
|
||||
$model = $config->model;
|
||||
$apiKey = $config->api_key;
|
||||
$baseUrl = $config->base_url ?? 'https://generativelanguage.googleapis.com/v1beta';
|
||||
|
||||
$url = "{$baseUrl}/models/{$model}:generateContent?key={$apiKey}";
|
||||
|
||||
$response = Http::timeout(120)
|
||||
->withHeaders(['Content-Type' => 'application/json'])
|
||||
->post($url, [
|
||||
'contents' => [
|
||||
['parts' => [['text' => $prompt]]],
|
||||
],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0.3,
|
||||
'maxOutputTokens' => 8192,
|
||||
'responseMimeType' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('Gemini API error', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
throw new \RuntimeException('Gemini API 호출 실패: '.$response->status());
|
||||
}
|
||||
|
||||
$result = $response->json();
|
||||
|
||||
AiTokenHelper::saveGeminiUsage($result, $result['modelVersion'] ?? $model, $menuName);
|
||||
|
||||
return $result['candidates'][0]['content']['parts'][0]['text'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude API 호출
|
||||
*/
|
||||
private function callClaude(AiConfig $config, string $prompt, string $menuName): ?string
|
||||
{
|
||||
$response = Http::timeout(120)
|
||||
->withHeaders([
|
||||
'x-api-key' => $config->api_key,
|
||||
'anthropic-version' => '2023-06-01',
|
||||
'content-type' => 'application/json',
|
||||
])
|
||||
->post($config->base_url.'/messages', [
|
||||
'model' => $config->model,
|
||||
'max_tokens' => 8192,
|
||||
'temperature' => 0.3,
|
||||
'messages' => [
|
||||
['role' => 'user', 'content' => $prompt],
|
||||
],
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('Claude API error', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
throw new \RuntimeException('Claude API 호출 실패: '.$response->status());
|
||||
}
|
||||
|
||||
$result = $response->json();
|
||||
|
||||
AiTokenHelper::saveClaudeUsage($result, $config->model, $menuName);
|
||||
|
||||
return $result['content'][0]['text'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1차 프롬프트: 업무 분석
|
||||
*/
|
||||
private function buildAnalysisPrompt(string $interviewText, array $modules): string
|
||||
{
|
||||
$modulesJson = json_encode($modules, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
|
||||
return <<<PROMPT
|
||||
당신은 SAM(Smart Automation Management) ERP/MES 솔루션의 전문 컨설턴트입니다.
|
||||
|
||||
아래는 고객사 직원과의 인터뷰 내용입니다. 이를 분석하여 구조화된 업무 분석 보고서를 JSON으로 작성하세요.
|
||||
|
||||
## 인터뷰 내용
|
||||
{$interviewText}
|
||||
|
||||
## SAM 모듈 카탈로그 (분석 기준)
|
||||
{$modulesJson}
|
||||
|
||||
## 출력 형식 (반드시 이 JSON 구조를 따르세요)
|
||||
{
|
||||
"company_analysis": {
|
||||
"industry": "업종 분류",
|
||||
"scale": "소규모/중소/중견",
|
||||
"employee_count_estimate": 0,
|
||||
"current_systems": ["현재 사용 중인 시스템"],
|
||||
"digitalization_level": "상/중/하"
|
||||
},
|
||||
"business_domains": [
|
||||
{
|
||||
"domain": "업무 영역명",
|
||||
"current_process": "현재 처리 방식 설명",
|
||||
"pain_points": ["문제점 1", "문제점 2"],
|
||||
"improvement_needs": ["개선 필요사항"],
|
||||
"priority": "필수/높음/보통/낮음",
|
||||
"matched_modules": ["모듈코드"]
|
||||
}
|
||||
],
|
||||
"recommendations": {
|
||||
"essential_modules": ["반드시 필요한 모듈 코드"],
|
||||
"recommended_modules": ["권장 모듈 코드"],
|
||||
"optional_modules": ["선택 모듈 코드"],
|
||||
"package_suggestion": "BASIC_PKG 또는 INTEGRATED 또는 individual",
|
||||
"reasoning": "패키지 추천 근거"
|
||||
}
|
||||
}
|
||||
|
||||
중요: JSON만 출력하세요. 설명이나 마크다운 코드 블록 없이 순수 JSON만 반환하세요.
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2차 프롬프트: 견적 생성
|
||||
*/
|
||||
private function buildQuotationPrompt(array $analysisResult, array $modules): string
|
||||
{
|
||||
$analysisJson = json_encode($analysisResult, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
$modulesJson = json_encode($modules, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
|
||||
return <<<PROMPT
|
||||
아래 업무 분석 결과를 바탕으로 SAM 견적서를 생성하세요.
|
||||
|
||||
## 업무 분석 결과
|
||||
{$analysisJson}
|
||||
|
||||
## SAM 모듈 카탈로그 (가격 포함)
|
||||
{$modulesJson}
|
||||
|
||||
## 견적 생성 규칙
|
||||
1. 필수 모듈은 반드시 포함
|
||||
2. 기본 패키지(BASIC_PKG)에 포함된 모듈(HR, ATTENDANCE, PAYROLL, BOARD)은 개별 추가하지 않음
|
||||
3. 통합 패키지(INTEGRATED)가 개별 합산보다 저렴하면 패키지 추천
|
||||
4. is_required가 true인 항목은 반드시 필요한 모듈, false는 선택
|
||||
|
||||
## 출력 형식 (반드시 이 JSON 구조를 따르세요)
|
||||
{
|
||||
"quotation_title": "견적서 제목",
|
||||
"items": [
|
||||
{
|
||||
"module_code": "모듈코드",
|
||||
"module_name": "모듈명",
|
||||
"is_required": true,
|
||||
"reason": "이 모듈이 필요한 이유 (인터뷰 내용 기반)",
|
||||
"dev_cost": 0,
|
||||
"monthly_fee": 0
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"total_dev_cost": 0,
|
||||
"total_monthly_fee": 0,
|
||||
"discount_type": "패키지할인/볼륨할인/없음",
|
||||
"discount_rate": 0,
|
||||
"final_dev_cost": 0,
|
||||
"final_monthly_fee": 0
|
||||
},
|
||||
"implementation_plan": {
|
||||
"estimated_months": 0,
|
||||
"phases": [
|
||||
{
|
||||
"phase": 1,
|
||||
"name": "단계명",
|
||||
"modules": ["모듈코드"],
|
||||
"duration_weeks": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"analysis_summary": "고객에게 전달할 업무 분석 요약 (2~3문장)"
|
||||
}
|
||||
|
||||
중요: JSON만 출력하세요. 설명이나 마크다운 코드 블록 없이 순수 JSON만 반환하세요.
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 응답에서 JSON 추출
|
||||
*/
|
||||
private function parseJsonResponse(?string $response): ?array
|
||||
{
|
||||
if (! $response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 마크다운 코드 블록 제거
|
||||
$cleaned = preg_replace('/^```(?:json)?\s*/m', '', $response);
|
||||
$cleaned = preg_replace('/\s*```$/m', '', $cleaned);
|
||||
$cleaned = trim($cleaned);
|
||||
|
||||
$decoded = json_decode($cleaned, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
Log::warning('AI JSON 파싱 실패', [
|
||||
'error' => json_last_error_msg(),
|
||||
'response_preview' => mb_substr($response, 0, 500),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 견적 결과를 아이템으로 저장
|
||||
*/
|
||||
private function saveQuotationItems(AiQuotation $quotation, array $quotationResult, array $modules): void
|
||||
{
|
||||
// 기존 아이템 삭제 (재분석 시)
|
||||
$quotation->items()->delete();
|
||||
|
||||
$items = $quotationResult['items'] ?? [];
|
||||
$moduleMap = collect($modules)->keyBy('module_code');
|
||||
|
||||
foreach ($items as $index => $item) {
|
||||
$moduleCode = $item['module_code'] ?? '';
|
||||
$catalogModule = $moduleMap->get($moduleCode);
|
||||
|
||||
// 카탈로그에 있는 모듈이면 DB의 가격 사용 (AI hallucination 방지)
|
||||
$devCost = $catalogModule ? $catalogModule['dev_cost'] : ($item['dev_cost'] ?? 0);
|
||||
$monthlyFee = $catalogModule ? $catalogModule['monthly_fee'] : ($item['monthly_fee'] ?? 0);
|
||||
|
||||
// DB에서 module_id 조회
|
||||
$dbModule = AiQuotationModule::where('module_code', $moduleCode)->first();
|
||||
|
||||
AiQuotationItem::create([
|
||||
'ai_quotation_id' => $quotation->id,
|
||||
'module_id' => $dbModule?->id,
|
||||
'module_code' => $moduleCode,
|
||||
'module_name' => $item['module_name'] ?? $moduleCode,
|
||||
'is_required' => $item['is_required'] ?? false,
|
||||
'reason' => $item['reason'] ?? null,
|
||||
'dev_cost' => $devCost,
|
||||
'monthly_fee' => $monthlyFee,
|
||||
'sort_order' => $index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 통계
|
||||
*/
|
||||
public function getDashboardStats(): array
|
||||
{
|
||||
$stats = AiQuotation::query()
|
||||
->selectRaw("
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
|
||||
SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) as processing,
|
||||
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
|
||||
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending
|
||||
")
|
||||
->first();
|
||||
|
||||
$recent = AiQuotation::with('creator:id,name')
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
return [
|
||||
'stats' => [
|
||||
'total' => (int) $stats->total,
|
||||
'completed' => (int) $stats->completed,
|
||||
'processing' => (int) $stats->processing,
|
||||
'failed' => (int) $stats->failed,
|
||||
'pending' => (int) $stats->pending,
|
||||
],
|
||||
'recent' => $recent,
|
||||
];
|
||||
}
|
||||
}
|
||||
174
resources/views/rd/ai-quotation/create.blade.php
Normal file
174
resources/views/rd/ai-quotation/create.blade.php
Normal file
@@ -0,0 +1,174 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'AI 견적서 생성')
|
||||
|
||||
@section('content')
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800 flex items-center gap-2">
|
||||
<i class="ri-robot-line text-purple-600"></i>
|
||||
AI 견적서 생성
|
||||
</h1>
|
||||
<a href="{{ route('rd.ai-quotation.index') }}" class="bg-white hover:bg-gray-100 text-gray-700 px-4 py-2 rounded-lg border transition">
|
||||
<i class="ri-arrow-left-line"></i> 목록으로
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 생성 폼 -->
|
||||
<div class="bg-white rounded-lg shadow-sm">
|
||||
<div class="px-6 py-4 border-b border-gray-100">
|
||||
<h2 class="text-lg font-semibold text-gray-800">인터뷰 내용 입력</h2>
|
||||
<p class="text-sm text-gray-500 mt-1">고객사 인터뷰 내용을 입력하면 AI가 업무를 분석하고 맞춤형 견적서를 자동 생성합니다.</p>
|
||||
</div>
|
||||
|
||||
<form id="quotationForm" class="p-6 space-y-6">
|
||||
<!-- 제목 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">견적 제목 <span class="text-red-500">*</span></label>
|
||||
<input type="text" name="title" id="inputTitle" required maxlength="200"
|
||||
placeholder="예: (주)대한기계 ERP 도입 견적"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500">
|
||||
</div>
|
||||
|
||||
<!-- 입력 유형 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">입력 유형</label>
|
||||
<div class="flex gap-3">
|
||||
<label class="flex items-center gap-2 px-4 py-2.5 border border-purple-500 bg-purple-50 text-purple-700 rounded-lg cursor-pointer">
|
||||
<input type="radio" name="input_type" value="text" checked class="text-purple-600">
|
||||
<i class="ri-file-text-line"></i> 텍스트 입력
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-4 py-2.5 border border-gray-300 bg-gray-50 text-gray-400 rounded-lg cursor-not-allowed" title="Phase 2 예정">
|
||||
<input type="radio" name="input_type" value="voice" disabled>
|
||||
<i class="ri-mic-line"></i> 음성 파일 (Phase 2)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Provider -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">AI Provider</label>
|
||||
<div class="flex gap-3">
|
||||
<label class="flex items-center gap-2 px-4 py-2.5 border rounded-lg cursor-pointer transition hover:bg-blue-50"
|
||||
id="providerGemini">
|
||||
<input type="radio" name="ai_provider" value="gemini" checked class="text-blue-600"
|
||||
onchange="updateProviderUI()">
|
||||
<span class="font-medium">Gemini</span>
|
||||
<span class="text-xs text-gray-400">(기본)</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-4 py-2.5 border rounded-lg cursor-pointer transition hover:bg-orange-50"
|
||||
id="providerClaude">
|
||||
<input type="radio" name="ai_provider" value="claude" class="text-orange-600"
|
||||
onchange="updateProviderUI()">
|
||||
<span class="font-medium">Claude</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 인터뷰 내용 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">인터뷰 내용 <span class="text-red-500">*</span></label>
|
||||
<textarea name="input_text" id="inputText" rows="12" required
|
||||
placeholder="고객사 직원과의 인터뷰 내용을 입력하세요. 예시: "저희 회사는 블라인드 제조업체인데요. 직원이 30명 정도 되고, 현재 엑셀로 급여 관리를 하고 있어요. 생산 현황도 수기로 적고 있고, 재고 파악이 안돼요. 영업팀에서는 견적서를 한글 프로그램으로 만들어서 이메일로 보내는데, 이력 관리가 안 돼요...""
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-y"></textarea>
|
||||
<p class="text-xs text-gray-400 mt-1">인터뷰 내용이 구체적일수록 정확한 견적이 생성됩니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- 제출 -->
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-gray-100">
|
||||
<a href="{{ route('rd.ai-quotation.index') }}" class="px-6 py-2.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition">
|
||||
취소
|
||||
</a>
|
||||
<button type="submit" id="submitBtn"
|
||||
class="px-6 py-2.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition flex items-center gap-2">
|
||||
<i class="ri-robot-line"></i> AI 분석 실행
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 결과 영역 (숨김) -->
|
||||
<div id="resultArea" class="mt-6 hidden">
|
||||
<div id="resultContent"></div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function updateProviderUI() {
|
||||
const gemini = document.getElementById('providerGemini');
|
||||
const claude = document.getElementById('providerClaude');
|
||||
const selected = document.querySelector('input[name="ai_provider"]:checked').value;
|
||||
|
||||
gemini.classList.toggle('border-blue-500', selected === 'gemini');
|
||||
gemini.classList.toggle('bg-blue-50', selected === 'gemini');
|
||||
gemini.classList.toggle('border-gray-300', selected !== 'gemini');
|
||||
claude.classList.toggle('border-orange-500', selected === 'claude');
|
||||
claude.classList.toggle('bg-orange-50', selected === 'claude');
|
||||
claude.classList.toggle('border-gray-300', selected !== 'claude');
|
||||
}
|
||||
|
||||
document.getElementById('quotationForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const btn = document.getElementById('submitBtn');
|
||||
const originalHtml = btn.innerHTML;
|
||||
|
||||
// 유효성 검사
|
||||
const title = document.getElementById('inputTitle').value.trim();
|
||||
const text = document.getElementById('inputText').value.trim();
|
||||
if (!title || !text) {
|
||||
alert('제목과 인터뷰 내용을 모두 입력하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 로딩 상태
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="ri-loader-4-line animate-spin"></i> AI 분석중... (30초~1분 소요)';
|
||||
btn.classList.add('opacity-75');
|
||||
|
||||
try {
|
||||
const formData = new FormData(this);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
const token = document.querySelector('meta[name="api-token"]')?.content
|
||||
|| sessionStorage.getItem('api_token') || '';
|
||||
|
||||
const response = await fetch('{{ url("/admin/rd/ai-quotation") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data?.id) {
|
||||
// 성공 — 상세 페이지로 이동
|
||||
window.location.href = `{{ url('/rd/ai-quotation') }}/${result.data.id}`;
|
||||
} else {
|
||||
// 실패
|
||||
alert(result.message || 'AI 분석에 실패했습니다.');
|
||||
if (result.data?.id) {
|
||||
window.location.href = `{{ url('/rd/ai-quotation') }}/${result.data.id}`;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('AI 분석 요청 실패:', err);
|
||||
alert('서버 통신 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalHtml;
|
||||
btn.classList.remove('opacity-75');
|
||||
}
|
||||
});
|
||||
|
||||
// 초기 Provider UI
|
||||
updateProviderUI();
|
||||
</script>
|
||||
@endpush
|
||||
86
resources/views/rd/ai-quotation/index.blade.php
Normal file
86
resources/views/rd/ai-quotation/index.blade.php
Normal file
@@ -0,0 +1,86 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'AI 견적 엔진')
|
||||
|
||||
@section('content')
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800 flex items-center gap-2">
|
||||
<i class="ri-robot-line text-purple-600"></i>
|
||||
AI 견적 엔진
|
||||
</h1>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('rd.index') }}" class="bg-white hover:bg-gray-100 text-gray-700 px-4 py-2 rounded-lg border transition">
|
||||
<i class="ri-arrow-left-line"></i> R&D 대시보드
|
||||
</a>
|
||||
<a href="{{ route('rd.ai-quotation.create') }}" class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg transition">
|
||||
+ AI 견적 생성
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-4">
|
||||
<form id="filterForm" class="flex flex-wrap gap-3 items-end">
|
||||
<div style="flex: 1 1 200px; max-width: 300px;">
|
||||
<label class="block text-xs text-gray-500 mb-1">검색</label>
|
||||
<input type="text" name="search" placeholder="제목, 내용 검색..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500">
|
||||
</div>
|
||||
<div style="flex: 0 0 140px;">
|
||||
<label class="block text-xs text-gray-500 mb-1">상태</label>
|
||||
<select name="status" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500">
|
||||
<option value="">전체</option>
|
||||
@foreach($statuses as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" onclick="loadQuotations()" class="px-4 py-2 bg-gray-800 text-white rounded-lg text-sm hover:bg-gray-900 transition">
|
||||
검색
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 영역 (HTMX) -->
|
||||
<div id="quotation-table">
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<i class="ri-loader-4-line text-2xl animate-spin"></i>
|
||||
<p class="mt-2">목록을 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function loadQuotations(page = 1) {
|
||||
const form = document.getElementById('filterForm');
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams(formData);
|
||||
params.set('page', page);
|
||||
|
||||
const url = `{{ url('/admin/rd/ai-quotation') }}?${params.toString()}`;
|
||||
|
||||
fetch(url, {
|
||||
headers: {
|
||||
'HX-Request': 'true',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(r => r.text())
|
||||
.then(html => {
|
||||
document.getElementById('quotation-table').innerHTML = html;
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('quotation-table').innerHTML =
|
||||
'<div class="text-center py-12 text-red-500">목록을 불러오지 못했습니다.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// 초기 로드
|
||||
document.addEventListener('DOMContentLoaded', () => loadQuotations());
|
||||
</script>
|
||||
@endpush
|
||||
59
resources/views/rd/ai-quotation/partials/table.blade.php
Normal file
59
resources/views/rd/ai-quotation/partials/table.blade.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">제목</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">상태</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">AI</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">개발비</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">월 구독료</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">요청자</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">생성일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
@forelse($quotations as $q)
|
||||
<tr class="hover:bg-gray-50 cursor-pointer" onclick="location.href='{{ route('rd.ai-quotation.show', $q->id) }}'">
|
||||
<td class="px-4 py-3 text-gray-500">#{{ $q->id }}</td>
|
||||
<td class="px-4 py-3 font-medium text-gray-800">{{ Str::limit($q->title, 40) }}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="badge {{ $q->status_color }} badge-sm">{{ $q->status_label }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded-full">{{ $q->ai_provider }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-gray-700">
|
||||
@if($q->isCompleted())
|
||||
{{ number_format((int)$q->total_dev_cost) }}원
|
||||
@else
|
||||
-
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-gray-700">
|
||||
@if($q->isCompleted())
|
||||
{{ number_format((int)$q->total_monthly_fee) }}원/월
|
||||
@else
|
||||
-
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center text-gray-500">{{ $q->creator?->name ?? '-' }}</td>
|
||||
<td class="px-4 py-3 text-center text-gray-500">{{ $q->created_at->format('m/d H:i') }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="8" class="px-4 py-12 text-center text-gray-400">
|
||||
<i class="ri-robot-line text-4xl mb-2 block"></i>
|
||||
<p>AI 견적 데이터가 없습니다.</p>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if($quotations->hasPages())
|
||||
<div class="px-4 py-3 border-t border-gray-100">
|
||||
{{ $quotations->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
296
resources/views/rd/ai-quotation/show.blade.php
Normal file
296
resources/views/rd/ai-quotation/show.blade.php
Normal file
@@ -0,0 +1,296 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'AI 견적 상세')
|
||||
|
||||
@section('content')
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-2xl font-bold text-gray-800 flex items-center gap-2">
|
||||
<i class="ri-robot-line text-purple-600"></i>
|
||||
{{ $quotation->title }}
|
||||
</h1>
|
||||
<span class="badge {{ $quotation->status_color }}">{{ $quotation->status_label }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('rd.ai-quotation.index') }}" class="bg-white hover:bg-gray-100 text-gray-700 px-4 py-2 rounded-lg border transition">
|
||||
<i class="ri-arrow-left-line"></i> 목록
|
||||
</a>
|
||||
@if($quotation->isCompleted() || $quotation->status === 'failed')
|
||||
<button onclick="reanalyze()" id="reanalyzeBtn"
|
||||
class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg transition">
|
||||
<i class="ri-refresh-line"></i> AI 재분석
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 기본 정보 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 mb-1">AI Provider</p>
|
||||
<p class="font-medium text-gray-800">{{ strtoupper($quotation->ai_provider) }}{{ $quotation->ai_model ? ' ('.$quotation->ai_model.')' : '' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 mb-1">입력 유형</p>
|
||||
<p class="font-medium text-gray-800">{{ ['text' => '텍스트', 'voice' => '음성', 'document' => '문서'][$quotation->input_type] ?? $quotation->input_type }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 mb-1">요청자</p>
|
||||
<p class="font-medium text-gray-800">{{ $quotation->creator?->name ?? '-' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 mb-1">생성일</p>
|
||||
<p class="font-medium text-gray-800">{{ $quotation->created_at->format('Y-m-d H:i') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($quotation->isCompleted())
|
||||
<!-- 업무 분석 결과 -->
|
||||
@if($quotation->analysis_result)
|
||||
@php $analysis = $quotation->analysis_result; @endphp
|
||||
<div class="bg-white rounded-lg shadow-sm mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-100">
|
||||
<h2 class="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||
<i class="ri-search-eye-line text-blue-600"></i> AI 업무 분석 결과
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<!-- 기업 분석 -->
|
||||
@if(isset($analysis['company_analysis']))
|
||||
@php $company = $analysis['company_analysis']; @endphp
|
||||
<div class="flex flex-wrap gap-4 mb-6 pb-6 border-b border-gray-100">
|
||||
<div class="px-4 py-2 bg-blue-50 rounded-lg">
|
||||
<span class="text-xs text-blue-500 block">업종</span>
|
||||
<span class="font-semibold text-blue-800">{{ $company['industry'] ?? '-' }}</span>
|
||||
</div>
|
||||
<div class="px-4 py-2 bg-green-50 rounded-lg">
|
||||
<span class="text-xs text-green-500 block">규모</span>
|
||||
<span class="font-semibold text-green-800">{{ $company['scale'] ?? '-' }}</span>
|
||||
</div>
|
||||
<div class="px-4 py-2 bg-purple-50 rounded-lg">
|
||||
<span class="text-xs text-purple-500 block">디지털화 수준</span>
|
||||
<span class="font-semibold text-purple-800">{{ $company['digitalization_level'] ?? '-' }}</span>
|
||||
</div>
|
||||
@if(!empty($company['current_systems']))
|
||||
<div class="px-4 py-2 bg-gray-50 rounded-lg">
|
||||
<span class="text-xs text-gray-500 block">현재 시스템</span>
|
||||
<span class="font-semibold text-gray-800">{{ implode(', ', $company['current_systems']) }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 업무 영역별 분석 -->
|
||||
@if(!empty($analysis['business_domains']))
|
||||
<h3 class="text-sm font-semibold text-gray-600 uppercase mb-3">업무 영역 분석</h3>
|
||||
<div class="space-y-4">
|
||||
@foreach($analysis['business_domains'] as $domain)
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="font-semibold text-gray-800">{{ $domain['domain'] ?? '' }}</h4>
|
||||
@php
|
||||
$priorityColor = match($domain['priority'] ?? '') {
|
||||
'필수' => 'bg-red-100 text-red-700',
|
||||
'높음' => 'bg-orange-100 text-orange-700',
|
||||
'보통' => 'bg-yellow-100 text-yellow-700',
|
||||
default => 'bg-gray-100 text-gray-700',
|
||||
};
|
||||
@endphp
|
||||
<span class="px-2 py-0.5 text-xs rounded-full {{ $priorityColor }}">{{ $domain['priority'] ?? '' }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-2">{{ $domain['current_process'] ?? '' }}</p>
|
||||
@if(!empty($domain['pain_points']))
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
@foreach($domain['pain_points'] as $point)
|
||||
<span class="px-2 py-0.5 bg-red-50 text-red-600 text-xs rounded">{{ $point }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@if(!empty($domain['matched_modules']))
|
||||
<div class="flex flex-wrap gap-1.5 mt-2">
|
||||
@foreach($domain['matched_modules'] as $mod)
|
||||
<span class="px-2 py-0.5 bg-purple-50 text-purple-600 text-xs rounded font-mono">{{ $mod }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 추천 모듈 + 견적 -->
|
||||
<div class="bg-white rounded-lg shadow-sm mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-100">
|
||||
<h2 class="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||
<i class="ri-price-tag-3-line text-green-600"></i> 추천 모듈 및 견적
|
||||
</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">구분</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">모듈</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">추천 근거</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">개발비</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">월 구독료</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
@foreach($quotation->items as $item)
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3">
|
||||
@if($item->is_required)
|
||||
<span class="px-2 py-0.5 bg-red-100 text-red-700 text-xs rounded-full font-medium">필수</span>
|
||||
@else
|
||||
<span class="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded-full">선택</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-medium text-gray-800">{{ $item->module_name }}</div>
|
||||
<div class="text-xs text-gray-400 font-mono">{{ $item->module_code }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600 text-xs" style="max-width: 300px;">
|
||||
{{ Str::limit($item->reason, 100) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right font-medium text-gray-800">{{ number_format((int)$item->dev_cost) }}원</td>
|
||||
<td class="px-4 py-3 text-right font-medium text-gray-800">{{ number_format((int)$item->monthly_fee) }}원</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
<tfoot class="bg-purple-50 border-t-2 border-purple-200">
|
||||
<tr>
|
||||
<td colspan="3" class="px-4 py-3 text-right font-bold text-gray-800">합계</td>
|
||||
<td class="px-4 py-3 text-right font-bold text-purple-700 text-base">{{ number_format((int)$quotation->total_dev_cost) }}원</td>
|
||||
<td class="px-4 py-3 text-right font-bold text-purple-700 text-base">{{ number_format((int)$quotation->total_monthly_fee) }}원/월</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 구현 계획 (AI 생성) -->
|
||||
@if(!empty($quotation->quotation_result['implementation_plan']))
|
||||
@php $plan = $quotation->quotation_result['implementation_plan']; @endphp
|
||||
<div class="bg-white rounded-lg shadow-sm mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-100">
|
||||
<h2 class="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||
<i class="ri-calendar-schedule-line text-indigo-600"></i> 구현 계획 (AI 추천)
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<p class="text-sm text-gray-600 mb-4">예상 기간: <span class="font-semibold">{{ $plan['estimated_months'] ?? '?' }}개월</span></p>
|
||||
@if(!empty($plan['phases']))
|
||||
<div class="space-y-3">
|
||||
@foreach($plan['phases'] as $phase)
|
||||
<div class="flex items-center gap-4 p-3 bg-gray-50 rounded-lg">
|
||||
<div class="w-10 h-10 bg-indigo-100 text-indigo-700 rounded-full flex items-center justify-center font-bold shrink-0">
|
||||
{{ $phase['phase'] ?? '' }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-800">{{ $phase['name'] ?? '' }}</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-xs text-gray-500">{{ $phase['duration_weeks'] ?? '?' }}주</span>
|
||||
@if(!empty($phase['modules']))
|
||||
<span class="text-xs text-gray-400">|</span>
|
||||
@foreach($phase['modules'] as $mod)
|
||||
<span class="px-1.5 py-0.5 bg-indigo-50 text-indigo-600 text-xs rounded font-mono">{{ $mod }}</span>
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 분석 요약 -->
|
||||
@if(!empty($quotation->quotation_result['analysis_summary']))
|
||||
<div class="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-lg p-6 mb-6">
|
||||
<h3 class="font-semibold text-gray-800 mb-2 flex items-center gap-2">
|
||||
<i class="ri-lightbulb-line text-yellow-500"></i> AI 분석 요약
|
||||
</h3>
|
||||
<p class="text-gray-700">{{ $quotation->quotation_result['analysis_summary'] }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@elseif($quotation->status === 'failed')
|
||||
<!-- 실패 상태 -->
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-6 mb-6">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<i class="ri-error-warning-line text-2xl text-red-500"></i>
|
||||
<h2 class="text-lg font-semibold text-red-800">AI 분석 실패</h2>
|
||||
</div>
|
||||
<p class="text-red-600">AI 분석 중 오류가 발생했습니다. 다시 시도하거나 입력 내용을 수정해 주세요.</p>
|
||||
</div>
|
||||
|
||||
@elseif($quotation->isProcessing())
|
||||
<!-- 분석중 상태 -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6 text-center">
|
||||
<i class="ri-loader-4-line text-4xl text-blue-500 animate-spin mb-2 block"></i>
|
||||
<h2 class="text-lg font-semibold text-blue-800">AI 분석 진행중...</h2>
|
||||
<p class="text-blue-600 text-sm mt-1">분석이 완료되면 자동으로 결과가 표시됩니다.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- 입력 원문 (접이식) -->
|
||||
<details class="bg-white rounded-lg shadow-sm mb-6">
|
||||
<summary class="px-6 py-4 cursor-pointer hover:bg-gray-50 transition font-semibold text-gray-700">
|
||||
<i class="ri-file-text-line"></i> 인터뷰 원문 보기
|
||||
</summary>
|
||||
<div class="px-6 pb-6">
|
||||
<div class="bg-gray-50 rounded-lg p-4 text-sm text-gray-700 whitespace-pre-wrap">{{ $quotation->input_text }}</div>
|
||||
</div>
|
||||
</details>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
async function reanalyze() {
|
||||
if (!confirm('AI 분석을 다시 실행하시겠습니까? 기존 결과가 덮어씌워집니다.')) return;
|
||||
|
||||
const btn = document.getElementById('reanalyzeBtn');
|
||||
const original = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="ri-loader-4-line animate-spin"></i> 재분석중...';
|
||||
|
||||
try {
|
||||
const token = document.querySelector('meta[name="api-token"]')?.content
|
||||
|| sessionStorage.getItem('api_token') || '';
|
||||
|
||||
const response = await fetch('{{ url("/admin/rd/ai-quotation/{$quotation->id}/analyze") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(result.message || '재분석에 실패했습니다.');
|
||||
location.reload();
|
||||
}
|
||||
} catch (err) {
|
||||
alert('서버 통신 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = original;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
146
resources/views/rd/index.blade.php
Normal file
146
resources/views/rd/index.blade.php
Normal file
@@ -0,0 +1,146 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '연구개발 대시보드')
|
||||
|
||||
@section('content')
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800 flex items-center gap-2">
|
||||
<i class="ri-flask-line text-purple-600"></i>
|
||||
연구개발 대시보드
|
||||
</h1>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('rd.ai-quotation.index') }}" class="bg-white hover:bg-gray-100 text-gray-700 px-4 py-2 rounded-lg border transition">
|
||||
견적 목록
|
||||
</a>
|
||||
<a href="{{ route('rd.ai-quotation.create') }}" class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg transition">
|
||||
+ AI 견적 생성
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<!-- 전체 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 mb-1">전체 견적</p>
|
||||
<p class="text-3xl font-bold text-gray-800">{{ $dashboard['stats']['total'] }}</p>
|
||||
</div>
|
||||
<div class="w-11 h-11 bg-purple-100 rounded-full flex items-center justify-center text-purple-600">
|
||||
<i class="ri-file-list-3-line text-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 완료 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 mb-1">분석 완료</p>
|
||||
<p class="text-3xl font-bold text-green-600">{{ $dashboard['stats']['completed'] }}</p>
|
||||
</div>
|
||||
<div class="w-11 h-11 bg-green-100 rounded-full flex items-center justify-center text-green-600">
|
||||
<i class="ri-check-double-line text-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 분석중 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 mb-1">분석중</p>
|
||||
<p class="text-3xl font-bold text-blue-600">{{ $dashboard['stats']['processing'] }}</p>
|
||||
</div>
|
||||
<div class="w-11 h-11 bg-blue-100 rounded-full flex items-center justify-center text-blue-600">
|
||||
<i class="ri-loader-4-line text-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 실패 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 mb-1">실패</p>
|
||||
<p class="text-3xl font-bold text-red-600">{{ $dashboard['stats']['failed'] }}</p>
|
||||
</div>
|
||||
<div class="w-11 h-11 bg-red-100 rounded-full flex items-center justify-center text-red-600">
|
||||
<i class="ri-error-warning-line text-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- R&D 메뉴 카드 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<!-- AI 견적 엔진 -->
|
||||
<a href="{{ route('rd.ai-quotation.index') }}" class="bg-white rounded-lg shadow-sm p-6 hover:shadow-md transition group">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-14 h-14 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-xl flex items-center justify-center text-white shrink-0">
|
||||
<i class="ri-robot-line text-2xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-800 group-hover:text-purple-600 transition">AI 견적 엔진</h3>
|
||||
<p class="text-sm text-gray-500 mt-1">인터뷰 내용을 AI가 분석하여 SAM 표준 견적서를 자동 생성합니다.</p>
|
||||
<div class="flex gap-2 mt-3">
|
||||
<span class="px-2 py-0.5 bg-purple-50 text-purple-600 text-xs rounded-full">Gemini</span>
|
||||
<span class="px-2 py-0.5 bg-blue-50 text-blue-600 text-xs rounded-full">Claude</span>
|
||||
<span class="px-2 py-0.5 bg-green-50 text-green-600 text-xs rounded-full">Phase 1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- 모듈 카탈로그 (Phase 2) -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 opacity-60">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-14 h-14 bg-gradient-to-br from-gray-400 to-gray-500 rounded-xl flex items-center justify-center text-white shrink-0">
|
||||
<i class="ri-apps-2-line text-2xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-800">모듈 카탈로그 관리</h3>
|
||||
<p class="text-sm text-gray-500 mt-1">SAM 모듈 카탈로그를 관리하고 AI 프롬프트에 반영합니다.</p>
|
||||
<div class="flex gap-2 mt-3">
|
||||
<span class="px-2 py-0.5 bg-gray-100 text-gray-500 text-xs rounded-full">Phase 3 예정</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 최근 견적 요청 -->
|
||||
<div class="bg-white rounded-lg shadow-sm">
|
||||
<div class="px-6 py-4 border-b border-gray-100 flex justify-between items-center">
|
||||
<h2 class="text-lg font-semibold text-gray-800">최근 AI 견적 요청</h2>
|
||||
<a href="{{ route('rd.ai-quotation.index') }}" class="text-sm text-purple-600 hover:text-purple-800">전체 보기 →</a>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-50">
|
||||
@forelse($dashboard['recent'] as $quotation)
|
||||
<a href="{{ route('rd.ai-quotation.show', $quotation->id) }}" class="block px-6 py-4 hover:bg-gray-50 transition">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="badge {{ $quotation->status_color }} badge-sm">{{ $quotation->status_label }}</span>
|
||||
<span class="font-medium text-gray-800">{{ $quotation->title }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span>{{ $quotation->ai_provider }}</span>
|
||||
<span>{{ $quotation->creator?->name ?? '-' }}</span>
|
||||
<span>{{ $quotation->created_at->format('m/d H:i') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@empty
|
||||
<div class="px-6 py-12 text-center text-gray-400">
|
||||
<i class="ri-robot-line text-4xl mb-2 block"></i>
|
||||
<p>아직 AI 견적 요청이 없습니다.</p>
|
||||
<a href="{{ route('rd.ai-quotation.create') }}" class="text-purple-600 hover:text-purple-800 text-sm mt-2 inline-block">
|
||||
첫 번째 AI 견적을 생성해보세요 →
|
||||
</a>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -602,6 +602,20 @@
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 연구개발 API
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
Route::prefix('rd')->name('rd.')->group(function () {
|
||||
Route::prefix('ai-quotation')->name('ai-quotation.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\Api\Admin\Rd\AiQuotationController::class, 'index'])->name('index');
|
||||
Route::post('/', [\App\Http\Controllers\Api\Admin\Rd\AiQuotationController::class, 'store'])->name('store');
|
||||
Route::get('/{id}', [\App\Http\Controllers\Api\Admin\Rd\AiQuotationController::class, 'show'])->name('show');
|
||||
Route::post('/{id}/analyze', [\App\Http\Controllers\Api\Admin\Rd\AiQuotationController::class, 'analyze'])->name('analyze');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 일일 스크럼 API
|
||||
|
||||
Reference in New Issue
Block a user