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')); } /** * 조직도 관리 */ public function orgChart(Request $request): View|\Illuminate\Http\Response { if ($request->header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('rd.org-chart')); } $tenantId = session('selected_tenant_id'); // 부서 트리 (parent_id=null이 최상위) $departments = Department::where('tenant_id', $tenantId) ->where('is_active', true) ->orderBy('sort_order') ->orderBy('name') ->get(); // 전체 직원 (활성 상태) $rawEmployees = Employee::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->where('employee_status', 'active') ->with(['user', 'department']) ->orderBy('display_name') ->get(); // Blade @json 호환을 위해 미리 배열로 변환 $employees = $rawEmployees->map(function ($e) { return [ 'id' => $e->id, 'user_id' => $e->user_id, 'department_id' => $e->department_id, 'display_name' => $e->display_name ?? $e->user?->name ?? '(이름없음)', 'position_label' => $e->position_label, ]; })->values(); // 회사 정보 (조직도 최상단) $tenant = Tenant::find($tenantId); $companyName = $tenant->company_name ?? 'SAM'; $ceoName = $tenant->ceo_name ?? ''; return view('rd.org-chart', compact('departments', 'employees', 'companyName', 'ceoName')); } /** * 조직도 - 직원 부서 배치 */ public function orgChartAssign(Request $request): JsonResponse { $request->validate([ 'employee_id' => 'required|integer', 'department_id' => 'required|integer', ]); $tenantId = session('selected_tenant_id'); $employee = Employee::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->where('id', $request->employee_id) ->first(); if (! $employee) { return response()->json(['success' => false, 'message' => '직원을 찾을 수 없습니다.'], 404); } $employee->department_id = $request->department_id; $employee->save(); return response()->json(['success' => true]); } /** * 조직도 - 직원 부서 해제 (미배치로 이동) */ public function orgChartUnassign(Request $request): JsonResponse { $request->validate([ 'employee_id' => 'required|integer', ]); $tenantId = session('selected_tenant_id'); $employee = Employee::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->where('id', $request->employee_id) ->first(); if (! $employee) { return response()->json(['success' => false, 'message' => '직원을 찾을 수 없습니다.'], 404); } $employee->department_id = null; $employee->save(); return response()->json(['success' => true]); } /** * 조직도 - 부서 내 직원 순서/이동 일괄 처리 */ public function orgChartReorder(Request $request): JsonResponse { $request->validate([ 'moves' => 'required|array', 'moves.*.employee_id' => 'required|integer', 'moves.*.department_id' => 'nullable|integer', ]); $tenantId = session('selected_tenant_id'); foreach ($request->moves as $move) { Employee::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->where('id', $move['employee_id']) ->update(['department_id' => $move['department_id']]); } return response()->json(['success' => true]); } /** * 조직도 - 부서 순서 변경 (드래그 앤 드롭) */ public function orgChartReorderDepts(Request $request): JsonResponse { $request->validate([ 'orders' => 'required|array', 'orders.*.id' => 'required|integer', 'orders.*.parent_id' => 'nullable|integer', 'orders.*.sort_order' => 'required|integer', ]); $tenantId = session('selected_tenant_id'); foreach ($request->orders as $order) { Department::where('tenant_id', $tenantId) ->where('id', $order['id']) ->update([ 'parent_id' => $order['parent_id'], 'sort_order' => $order['sort_order'], ]); } return response()->json(['success' => true]); } /** * 조직도 - 부서 숨기기/표시 토글 */ public function orgChartToggleHide(Request $request): JsonResponse { $request->validate([ 'department_id' => 'required|integer', 'hidden' => 'required|boolean', ]); $tenantId = session('selected_tenant_id'); $dept = Department::where('tenant_id', $tenantId) ->where('id', $request->department_id) ->first(); if (! $dept) { return response()->json(['success' => false, 'message' => '부서를 찾을 수 없습니다.'], 404); } $options = $dept->options ?? []; $options['orgchart_hidden'] = $request->hidden; $dept->options = $options; $dept->save(); return response()->json(['success' => true]); } /** * 중대재해처벌법 실무 점검 */ public function safetyAudit(Request $request): View|\Illuminate\Http\Response { if ($request->header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('rd.safety-audit')); } return view('rd.safety-audit'); } /** * 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 documentQuotation(Request $request, int $id): View { $quotation = $this->quotationService->getById($id); if (! $quotation || ! $quotation->isCompleted()) { abort(404, '완료된 견적만 문서로 조회할 수 있습니다.'); } $template = $request->query('template', 'classic'); $allowed = ['classic', 'modern', 'blue', 'dark', 'colorful']; if (! in_array($template, $allowed)) { $template = 'classic'; } return view('rd.ai-quotation.document', compact('quotation', 'template')); } /** * 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')); } /** * AI 견적 편집 (제조 모드) */ public function editQuotation(Request $request, int $id): View|\Illuminate\Http\Response { if ($request->header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('rd.ai-quotation.edit', $id)); } $quotation = $this->quotationService->getById($id); if (! $quotation) { abort(404, 'AI 견적을 찾을 수 없습니다.'); } if (! $quotation->isCompleted()) { abort(403, '완료된 견적만 편집할 수 있습니다.'); } return view('rd.ai-quotation.edit', compact('quotation')); } /** * 기획디자인 - 플래닝 캔버스 */ public function planningDesign(Request $request): View|\Illuminate\Http\Response { if ($request->header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('rd.planning-design')); } return view('rd.planning-design.index'); } /** * 디자인 인사이트 - UI/UX 연구 도구 */ public function designInsight(Request $request): View|\Illuminate\Http\Response { if ($request->header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('rd.design-insight')); } return view('rd.design-insight.index'); } /** * 사운드 로고 생성기 */ public function soundLogo(Request $request): View|\Illuminate\Http\Response { if ($request->header('HX-Request')) { return response('', 200)->header('HX-Redirect', route('rd.sound-logo')); } return view('rd.sound-logo.index'); } /** * 사운드 로고 AI 생성 (Gemini API) */ public function soundLogoGenerate(Request $request): JsonResponse { $request->validate([ 'prompt' => 'required|string|max:500', 'category' => 'nullable|string', 'duration' => 'nullable|numeric|min:0.3|max:5', ]); $apiKey = config('services.gemini.api_key'); $baseUrl = config('services.gemini.base_url', 'https://generativelanguage.googleapis.com/v1beta'); $model = config('services.gemini.model', 'gemini-2.5-flash'); if (! $apiKey) { return response()->json(['success' => false, 'error' => 'Gemini API 키가 설정되지 않았습니다.'], 500); } $category = $request->category ?? '기업 시그널'; $duration = $request->duration ?? 1.5; $prompt = <<prompt} - 카테고리: {$category} - 목표 길이: {$duration}초 ## 사용 가능한 음표 C3, C#3, D3, D#3, E3, F3, F#3, G3, G#3, A3, A#3, B3, C4, C#4, D4, D#4, E4, F4, F#4, G4, G#4, A4, A#4, B4, C5, C#5, D5, D#5, E5, F5, F#5, G5, G#5, A5, A#5, B5, C6 ## 음표 타입 - note: 단일 음 (note 필드 필수) - chord: 화음 (chord 배열 필수, 2~4개 음) - rest: 쉼표 (duration만 필요) ## 신스 타입 - sine: 부드러움 (기업 로고, 알림에 적합) - triangle: 따뜻함 (성공, 게임에 적합) - square: 8bit/디지털 (게임, UI에 적합) - sawtooth: 날카로움 (록, 긴급 알림에 적합) ## 반드시 아래 JSON 형식으로만 응답하세요 { "name": "사운드 이름", "desc": "사운드 설명 (한줄)", "synth": "sine", "adsr": { "attack": 10, "decay": 80, "sustain": 0.6, "release": 400 }, "volume": 0.8, "reverb": 0.3, "notes": [ { "type": "note", "note": "C5", "duration": 0.20, "velocity": 0.8 }, { "type": "rest", "duration": 0.10 }, { "type": "chord", "chord": ["C4", "E4", "G4"], "duration": 0.50, "velocity": 1.0 } ] } ## 설계 원칙 - 음표의 duration 합계가 목표 길이({$duration}초)에 근접하도록 설계 - velocity: 0.3~1.0 (음의 강약으로 표현력 추가) - ADSR: attack(1~500ms), decay(10~1000ms), sustain(0~1.0), release(10~3000ms) - 카테고리 특성에 맞는 synth와 ADSR 선택 - 음악적으로 조화롭고 기억에 남는 멜로디 설계 - 최소 2개, 최대 12개 음표 사용 PROMPT; try { $response = Http::timeout(30)->post( "{$baseUrl}/models/{$model}:generateContent?key={$apiKey}", [ 'contents' => [ ['parts' => [['text' => $prompt]]], ], 'generationConfig' => [ 'temperature' => 0.9, 'maxOutputTokens' => 2048, 'responseMimeType' => 'application/json', ], ] ); } catch (\Exception $e) { Log::error('SoundLogo AI 생성 실패', ['error' => $e->getMessage()]); return response()->json(['success' => false, 'error' => 'AI 서버 연결 실패'], 500); } if (! $response->successful()) { Log::error('SoundLogo AI API 오류', ['status' => $response->status(), 'body' => $response->body()]); return response()->json(['success' => false, 'error' => 'AI 생성 실패: '.$response->status()], 500); } $data = $response->json(); $text = $data['candidates'][0]['content']['parts'][0]['text'] ?? ''; // JSON 파싱 (코드블록 제거) $text = preg_replace('/^```(?:json)?\s*/m', '', $text); $text = preg_replace('/```\s*$/m', '', $text); $result = json_decode(trim($text), true); if (! $result || ! isset($result['notes'])) { Log::warning('SoundLogo AI 응답 파싱 실패', ['text' => substr($text, 0, 500)]); return response()->json(['success' => false, 'error' => 'AI 응답을 파싱할 수 없습니다.'], 500); } return response()->json(['success' => true, 'data' => $result]); } /** * 사운드 로고 TTS 음성 생성 (Gemini TTS API) */ public function soundLogoTts(Request $request): JsonResponse { $request->validate([ 'text' => 'required|string|max:200', ]); $apiKey = config('services.gemini.api_key'); $baseUrl = config('services.gemini.base_url', 'https://generativelanguage.googleapis.com/v1beta'); if (! $apiKey) { return response()->json(['success' => false, 'error' => 'Gemini API 키가 설정되지 않았습니다.'], 500); } try { $response = Http::timeout(30)->post( "{$baseUrl}/models/gemini-2.5-flash-preview-tts:generateContent?key={$apiKey}", [ 'contents' => [ ['parts' => [['text' => $request->text]]], ], 'generationConfig' => [ 'responseModalities' => ['AUDIO'], 'speechConfig' => [ 'voiceConfig' => [ 'prebuiltVoiceConfig' => [ 'voiceName' => 'Kore', ], ], ], ], ] ); } catch (\Exception $e) { Log::error('SoundLogo TTS 생성 실패', ['error' => $e->getMessage()]); return response()->json(['success' => false, 'error' => 'TTS 서버 연결 실패'], 500); } if (! $response->successful()) { return response()->json(['success' => false, 'error' => 'TTS 생성 실패: '.$response->status()], 500); } $data = $response->json(); $inlineData = $data['candidates'][0]['content']['parts'][0]['inlineData'] ?? null; if (! $inlineData || empty($inlineData['data'])) { return response()->json(['success' => false, 'error' => '음성 데이터를 받지 못했습니다.'], 500); } return response()->json([ 'success' => true, 'audio_data' => $inlineData['data'], 'mime_type' => $inlineData['mimeType'] ?? 'audio/L16;rate=24000', ]); } }