100, 'max_orders' => 50, 'max_productions' => 30, 'max_users' => 5, 'max_storage_gb' => 1, 'max_ai_tokens' => 100000, ]; /** * 데모 체험 관리 메인 화면 */ public function index(Request $request): View { if ($request->header('HX-Request') && ! $request->header('HX-Boosted')) { return response('', 200)->header('HX-Redirect', route('sales.demo-tenants.index')); } $partner = SalesPartner::where('user_id', auth()->id())->first(); $isAdmin = auth()->user()->isHqAdmin ?? false; $query = Tenant::withoutGlobalScopes() ->whereIn('tenant_type', self::DEMO_TYPES); if (! $isAdmin && $partner) { $query->where('demo_source_partner_id', $partner->id); } $demos = $query->orderByDesc('created_at')->get()->map(function ($t) { $t->demo_status = $this->getDemoStatus($t); $t->remaining_days = $this->getRemainingDays($t->demo_expires_at); $t->parsed_options = $t->options ?? []; return $t; }); $stats = [ 'total' => $demos->count(), 'active' => $demos->where('demo_status', 'active')->count(), 'expired' => $demos->where('demo_status', 'expired')->count(), 'converted' => $demos->where('demo_status', 'converted')->count(), 'expiring_soon' => $demos->filter(fn ($d) => $d->demo_status === 'active' && $d->remaining_days !== null && $d->remaining_days <= 7)->count(), ]; return view('sales.demo-tenants.index', compact('demos', 'stats', 'partner', 'isAdmin')); } /** * 목록 부분 새로고침 (HTMX) */ public function refreshList(Request $request): View { $partner = SalesPartner::where('user_id', auth()->id())->first(); $isAdmin = auth()->user()->isHqAdmin ?? false; $query = Tenant::withoutGlobalScopes() ->whereIn('tenant_type', self::DEMO_TYPES); if (! $isAdmin && $partner) { $query->where('demo_source_partner_id', $partner->id); } $demos = $query->orderByDesc('created_at')->get()->map(function ($t) { $t->demo_status = $this->getDemoStatus($t); $t->remaining_days = $this->getRemainingDays($t->demo_expires_at); $t->parsed_options = $t->options ?? []; return $t; }); $stats = [ 'total' => $demos->count(), 'active' => $demos->where('demo_status', 'active')->count(), 'expired' => $demos->where('demo_status', 'expired')->count(), 'converted' => $demos->where('demo_status', 'converted')->count(), 'expiring_soon' => $demos->filter(fn ($d) => $d->demo_status === 'active' && $d->remaining_days !== null && $d->remaining_days <= 7)->count(), ]; return view('sales.demo-tenants.partials.demo-list', compact('demos', 'stats')); } /** * 상세 조회 모달 (HTMX) */ public function show(int $id): View { $tenant = $this->findAndAuthorize($id); $tenant->demo_status = $this->getDemoStatus($tenant); $tenant->remaining_days = $this->getRemainingDays($tenant->demo_expires_at); $tenant->parsed_options = $tenant->options ?? []; // 데이터 건수 조회 $dataCounts = []; foreach (['clients' => '거래처', 'items' => '품목', 'orders' => '수주', 'quotes' => '견적'] as $table => $label) { try { $dataCounts[$table] = ['label' => $label, 'count' => DB::table($table)->where('tenant_id', $id)->count()]; } catch (\Exception $e) { $dataCounts[$table] = ['label' => $label, 'count' => 0]; } } return view('sales.demo-tenants.partials.show-modal', compact('tenant', 'dataCounts')); } /** * 생성 폼 모달 (HTMX) */ public function createForm(): View { return view('sales.demo-tenants.partials.create-modal'); } /** * 고객 체험 테넌트 생성 */ public function store(Request $request) { $request->validate([ 'company_name' => 'required|string|max:200', 'email' => 'nullable|email|max:100', 'duration_days' => 'required|integer|min:7|max:60', 'preset' => 'nullable|string|in:manufacturing,none', ]); $partner = SalesPartner::where('user_id', auth()->id())->first(); $preset = $request->input('preset', 'manufacturing'); $options = [ 'demo_preset' => $preset === 'none' ? null : $preset, 'demo_limits' => self::DEFAULT_LIMITS, 'demo_email' => $request->input('email'), 'created_by_user_id' => auth()->id(), 'created_by_user_name' => auth()->user()->name ?? '', ]; $tenant = new Tenant; $tenant->forceFill([ 'company_name' => $request->input('company_name'), 'tenant_type' => 'DEMO_TRIAL', 'tenant_st_code' => 'trial', 'demo_expires_at' => now()->addDays($request->input('duration_days', 30)), 'demo_source_partner_id' => $partner?->id, 'options' => $options, 'is_active' => true, 'storage_limit' => 1073741824, 'storage_used' => 0, ]); $tenant->save(); if ($request->header('HX-Request')) { return response('', 200) ->header('HX-Trigger', json_encode(['showToast' => '데모 체험 테넌트가 생성되었습니다.', 'refreshDemoList' => true])); } return redirect()->route('sales.demo-tenants.index')->with('success', '데모 체험 테넌트가 생성되었습니다.'); } /** * 체험 기간 연장 */ public function extend(Request $request, int $id) { $tenant = $this->findAndAuthorize($id); if ($tenant->tenant_type !== 'DEMO_TRIAL') { return $this->htmxError('고객 체험 유형만 연장 가능합니다.'); } $opts = $tenant->options ?? []; if (! empty($opts['demo_extended'])) { return $this->htmxError('이미 연장된 체험입니다. 연장은 1회만 가능합니다.'); } $days = $request->input('days', 14); $opts['demo_extended'] = true; $opts['demo_extended_at'] = now()->toDateTimeString(); $opts['demo_extended_days'] = $days; $tenant->forceFill([ 'demo_expires_at' => $tenant->demo_expires_at ? $tenant->demo_expires_at->addDays($days) : now()->addDays($days), 'options' => $opts, 'tenant_st_code' => 'trial', ]); $tenant->save(); return response('', 200) ->header('HX-Trigger', json_encode(['showToast' => "{$days}일 연장되었습니다.", 'refreshDemoList' => true, 'closeModal' => true])); } /** * 정식 전환 */ public function convert(int $id) { $tenant = $this->findAndAuthorize($id); if (! in_array($tenant->tenant_type, self::DEMO_TYPES)) { return $this->htmxError('데모 테넌트가 아닙니다.'); } $opts = $tenant->options ?? []; unset($opts['demo_preset'], $opts['demo_limits'], $opts['demo_read_only']); $opts['converted_at'] = now()->toDateTimeString(); $opts['converted_by_user_id'] = auth()->id(); $tenant->forceFill([ 'tenant_type' => 'STD', 'tenant_st_code' => 'active', 'demo_expires_at' => null, 'options' => $opts, ]); $tenant->save(); return response('', 200) ->header('HX-Trigger', json_encode(['showToast' => '정식 테넌트로 전환되었습니다.', 'refreshDemoList' => true, 'closeModal' => true])); } /** * 데모 테넌트 삭제 */ public function destroy(int $id) { $tenant = $this->findAndAuthorize($id); if (! in_array($tenant->tenant_type, self::DEMO_TYPES)) { return $this->htmxError('데모 테넌트만 삭제 가능합니다.'); } // 데이터 존재 확인 foreach (['orders', 'clients', 'items'] as $table) { try { if (DB::table($table)->where('tenant_id', $id)->exists()) { return $this->htmxError('데이터가 존재하는 테넌트는 삭제할 수 없습니다.'); } } catch (\Exception $e) { } } $tenant->forceDelete(); return response('', 200) ->header('HX-Trigger', json_encode(['showToast' => '데모 테넌트가 삭제되었습니다.', 'refreshDemoList' => true, 'closeModal' => true])); } // ── helpers ── private function findAndAuthorize(int $id): Tenant { $tenant = Tenant::withoutGlobalScopes() ->whereIn('tenant_type', self::DEMO_TYPES) ->findOrFail($id); $isAdmin = auth()->user()->isHqAdmin ?? false; if (! $isAdmin) { $partner = SalesPartner::where('user_id', auth()->id())->first(); if (! $partner || $tenant->demo_source_partner_id !== $partner->id) { abort(403, '권한이 없습니다.'); } } return $tenant; } private function getDemoStatus(Tenant $tenant): string { if ($tenant->tenant_type === 'DEMO_SHOWCASE') { return 'showcase'; } if ($tenant->tenant_st_code === 'converted' || $tenant->tenant_type === 'STD') { return 'converted'; } if ($tenant->tenant_st_code === 'expired') { return 'expired'; } if ($tenant->demo_expires_at && $tenant->demo_expires_at->isPast()) { return 'expired'; } return 'active'; } private function getRemainingDays($expiresAt): ?int { if (! $expiresAt) { return null; } return max(0, (int) now()->diffInDays($expiresAt, false)); } private function htmxError(string $message) { return response('', 200) ->header('HX-Trigger', json_encode(['showToast' => $message, 'toastType' => 'error'])); } }