diff --git a/app/Http/Controllers/Sales/DemoTenantController.php b/app/Http/Controllers/Sales/DemoTenantController.php new file mode 100644 index 00000000..a7c5a8df --- /dev/null +++ b/app/Http/Controllers/Sales/DemoTenantController.php @@ -0,0 +1,323 @@ + 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'])); + } +} diff --git a/resources/views/sales/demo-tenants/index.blade.php b/resources/views/sales/demo-tenants/index.blade.php new file mode 100644 index 00000000..bc1c9dbb --- /dev/null +++ b/resources/views/sales/demo-tenants/index.blade.php @@ -0,0 +1,136 @@ +@extends('layouts.app') + +@section('title', '데모 체험 관리') + +@push('styles') + +@endpush + +@section('content') +
고객에게 SAM을 체험시키기 위한 데모 테넌트를 관리합니다
+전체
+{{ $stats['total'] }}
+활성
+{{ $stats['active'] }}
+만료
+{{ $stats['expired'] }}
+전환 완료
+{{ $stats['converted'] }}
+곧 만료
+{{ $stats['expiring_soon'] }}
+등록된 데모 테넌트가 없습니다.
+상단의 '체험 생성' 버튼으로 새 데모를 만들어보세요.
+| 회사명 | +유형 | +상태 | +남은 일수 | +만료일 | +생성일 | +작업 | +
|---|---|---|---|---|---|---|
| + + | ++ @switch($demo->tenant_type) + @case('DEMO_SHOWCASE') + 쇼케이스 + @break + @case('DEMO_PARTNER') + 파트너 + @break + @case('DEMO_TRIAL') + 고객체험 + @break + @endswitch + | ++ @switch($demo->demo_status) + @case('active') + 활성 + @break + @case('expired') + 만료 + @break + @case('converted') + 전환 + @break + @case('showcase') + 상시 + @break + @endswitch + | ++ @if($demo->remaining_days !== null) + + {{ $demo->remaining_days }}일 + + @else + - + @endif + | ++ {{ $demo->demo_expires_at ? $demo->demo_expires_at->format('Y-m-d') : '-' }} + | ++ {{ $demo->created_at->format('Y-m-d') }} + | ++ + | +
ID: {{ $tenant->id }}
+유형
++ @switch($tenant->tenant_type) + @case('DEMO_SHOWCASE') 쇼케이스 (Tier 1) @break + @case('DEMO_PARTNER') 파트너 데모 (Tier 2) @break + @case('DEMO_TRIAL') 고객 체험 (Tier 3) @break + @endswitch +
+만료일
++ {{ $tenant->demo_expires_at ? $tenant->demo_expires_at->format('Y-m-d H:i') : '없음 (상시)' }} + @if($tenant->remaining_days !== null) + ({{ $tenant->remaining_days }}일 남음) + @endif +
+생성일
+{{ $tenant->created_at->format('Y-m-d H:i') }}
+담당자 이메일
+{{ $tenant->parsed_options['demo_email'] ?? '-' }}
++ {{ $tenant->parsed_options['demo_extended_days'] ?? 14 }}일 연장됨 + ({{ $tenant->parsed_options['demo_extended_at'] ?? '' }}) +
++ 전환일: {{ $tenant->parsed_options['converted_at'] }} +
+