feat: [demo] MNG 데모 체험 관리 페이지 추가
- DemoTenantController: CRUD + 연장/전환/삭제 - Blade 뷰: index, demo-list, create-modal, show-modal - 라우트: sales/demo-tenants 그룹 등록 - 메뉴: 영업관리 하위에 '데모 체험 관리' 추가
This commit is contained in:
323
app/Http/Controllers/Sales/DemoTenantController.php
Normal file
323
app/Http/Controllers/Sales/DemoTenantController.php
Normal file
@@ -0,0 +1,323 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Sales\SalesPartner;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* 데모 체험 관리 컨트롤러
|
||||
*
|
||||
* 영업파트너가 고객에게 SAM을 체험시키기 위한 데모 테넌트 관리
|
||||
* - 고객 체험 테넌트 생성 (Tier 3)
|
||||
* - 목록/상세 조회
|
||||
* - 기간 연장, 정식 전환
|
||||
*
|
||||
* @see docs/features/sales/demo-tenant-policy.md
|
||||
*/
|
||||
class DemoTenantController extends Controller
|
||||
{
|
||||
private const DEMO_TYPES = ['DEMO_SHOWCASE', 'DEMO_PARTNER', 'DEMO_TRIAL'];
|
||||
|
||||
private const DEFAULT_LIMITS = [
|
||||
'max_items' => 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']));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user