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:
김보곤
2026-03-02 17:43:47 +09:00
parent 85ec94f07f
commit 44f139f757
13 changed files with 1707 additions and 0 deletions

View 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);
}
}

View 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'));
}
}

View 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' => '인터뷰 내용을 입력하세요.',
];
}
}

View 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).'원';
}
}

View 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();
}
}

View 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();
}
}

View 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,
];
}
}

View 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="고객사 직원과의 인터뷰 내용을 입력하세요.&#10;&#10;예시:&#10;&quot;저희 회사는 블라인드 제조업체인데요. 직원이 30명 정도 되고, 현재 엑셀로 급여 관리를 하고 있어요. 생산 현황도 수기로 적고 있고, 재고 파악이 안돼요. 영업팀에서는 견적서를 한글 프로그램으로 만들어서 이메일로 보내는데, 이력 관리가 안 돼요...&quot;"
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

View 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

View 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>

View 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

View 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

View File

@@ -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