Central panel SaaS upgrade — Plans/Subscriptions/SuperAdmins/Detail page

Models & migrations:
- subscriptions table (company, plan, period, amount, status, dates, invoice)
- super_admins: role enum (owner/admin/support/sales/finance) + phone + notes
- Subscription model with STATUSES/PERIODS/PAYMENT_METHODS + invoice number
  generator + extends company.active_until on mark_paid
- Company model: subscriptions() + latestSubscription() relations
- SuperAdmin model: role helpers (isOwner, canManageBilling, canManageTenants)

Filament Central panel:
- PlanResource (CRUD, features checklist, limits per plan, abonati count badge)
- SubscriptionResource (CRUD, mark_paid action, navigation badge for overdue)
- SuperAdminResource (CRUD, reset password, toggle 2FA, can't self-delete)
- ViewCompany page with live stats (users/clients/vehicles/WO/parts/revenue/
  storage/last_login + days_until_expiry), subscriptions history table,
  config snapshot, action buttons (open/issue invoice/upload logo/suspend)
- CompanyResource: row click → view, openUrlInNewTab action, recordTitleAttribute,
  empty state, view route registered
- PlatformStats widget upgraded: 6 cards (incl. MRR realized this month, overdue
  invoices count, click-through to filtered tables)
- RevenueChart: 12-month MRR line chart
- RecentTenants: latest 8 tenants with click-through
- PendingPayments: pending+overdue invoices table
- Database notifications enabled + Cmd+K global search
- HEAD_END render hook: PWA manifest + theme color + emoji favicon
- /admin-manifest.json route

Seeder:
- Plans aligned with new FEATURE_OPTIONS (kanban/pdf/reports/ai/api/reverb/etc)
- 4 plans: Free / Basic / Pro / Enterprise (with proper limits)
- SuperAdmin gets role='owner'
- Demo subscription for psauto on Pro plan, marked paid this month
This commit is contained in:
2026-05-07 22:02:44 +00:00
parent 0399262514
commit 10426d0c91
27 changed files with 1442 additions and 16 deletions
@@ -0,0 +1,239 @@
<x-filament-panels::page>
<style>
.cv-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
.cv-card {
background: #fff; border: 1px solid #e5e7eb; border-radius: 10px;
padding: 16px; position: relative;
}
.dark .cv-card { background: #1f2937; border-color: #374151; }
.cv-card .label { font-size: 11px; color: #9ca3af; text-transform: uppercase; letter-spacing: .5px; margin-bottom: 4px; }
.cv-card .value { font-size: 22px; font-weight: 700; }
.cv-card .sub { font-size: 12px; color: #6b7280; margin-top: 2px; }
.cv-card.warn { border-color: #f59e0b; background: #fffbeb; }
.dark .cv-card.warn { background: #78350f30; }
.cv-card.danger { border-color: #ef4444; background: #fef2f2; }
.dark .cv-card.danger { background: #7f1d1d30; }
.cv-card.success { border-color: #10b981; background: #ecfdf5; }
.dark .cv-card.success { background: #064e3b30; }
.cv-section { margin-top: 24px; }
.cv-section-title { font-size: 14px; font-weight: 600; color: #6b7280; margin-bottom: 12px; text-transform: uppercase; letter-spacing: .5px; }
.cv-table { width: 100%; border-collapse: collapse; font-size: 13px; background: #fff; border-radius: 8px; overflow: hidden; border: 1px solid #e5e7eb; }
.dark .cv-table { background: #1f2937; border-color: #374151; }
.cv-table th, .cv-table td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #f3f4f6; }
.dark .cv-table th, .dark .cv-table td { border-color: #374151; }
.cv-table th { font-size: 11px; font-weight: 600; color: #9ca3af; text-transform: uppercase; }
.cv-badge { display: inline-block; padding: 3px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; }
.cv-badge-success { background: #dcfce7; color: #166534; }
.cv-badge-warning { background: #fef3c7; color: #92400e; }
.cv-badge-danger { background: #fee2e2; color: #991b1b; }
.cv-badge-gray { background: #f3f4f6; color: #4b5563; }
.dark .cv-badge-success { background: #14532d; color: #86efac; }
.dark .cv-badge-warning { background: #78350f; color: #fde68a; }
.dark .cv-badge-danger { background: #7f1d1d; color: #fca5a5; }
.dark .cv-badge-gray { background: #374151; color: #d1d5db; }
.cv-header {
display: flex; gap: 16px; align-items: center;
background: linear-gradient(135deg, #eff6ff, #dbeafe);
padding: 20px; border-radius: 12px; border: 1px solid #bfdbfe;
margin-bottom: 20px;
}
.dark .cv-header { background: linear-gradient(135deg, #1e3a8a40, #1e40af30); border-color: #1e3a8a; }
.cv-header img { max-height: 64px; max-width: 80px; border-radius: 8px; background: #fff; padding: 4px; }
.cv-header h2 { font-size: 22px; font-weight: 700; }
.cv-header .sub { font-size: 13px; color: #4b5563; }
.dark .cv-header .sub { color: #9ca3af; }
</style>
@php
$stats = $this->getStats();
$daysLeft = $this->getDaysUntilExpiry();
$statusColors = [
'active' => 'success',
'trial' => 'warning',
'suspended' => 'danger',
'expired' => 'danger',
'archived' => 'gray',
];
$statusLabel = match ($this->record->status) {
'active' => 'Activ',
'trial' => 'Trial',
'suspended' => 'Suspendat',
'expired' => 'Expirat',
'archived' => 'Arhivat',
default => $this->record->status,
};
@endphp
{{-- HEADER --}}
<div class="cv-header">
@if ($this->record->getLogoUrl())
<img src="{{ $this->record->getLogoUrl() }}" alt="logo">
@endif
<div style="flex:1;">
<h2>{{ $this->record->display_name ?? $this->record->name }}</h2>
<div class="sub">
<a href="{{ $this->record->url() }}" target="_blank" style="color:inherit;text-decoration:underline;">{{ $this->record->slug }}.service.mir.md</a>
@if ($this->record->city) · {{ $this->record->city }} @endif
@if ($this->record->phone) · {{ $this->record->phone }} @endif
@if ($this->record->email) · {{ $this->record->email }} @endif
</div>
<div style="margin-top:8px;display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
<span class="cv-badge cv-badge-{{ $statusColors[$this->record->status] ?? 'gray' }}">
{{ $statusLabel }}
</span>
@if ($this->record->plan)
<span class="cv-badge cv-badge-gray">📦 {{ $this->record->plan->name }}</span>
@else
<span class="cv-badge cv-badge-warning"> Fără plan</span>
@endif
@if ($daysLeft !== null)
@if ($daysLeft < 0)
<span class="cv-badge cv-badge-danger">Expirat acum {{ abs($daysLeft) }} zile</span>
@elseif ($daysLeft < 7)
<span class="cv-badge cv-badge-warning">Expiră în {{ $daysLeft }} zile</span>
@else
<span class="cv-badge cv-badge-success">{{ $daysLeft }} zile rămase</span>
@endif
@endif
</div>
</div>
</div>
{{-- STATS GRID --}}
<div class="cv-section">
<div class="cv-section-title">📊 Activitate live</div>
<div class="cv-grid">
<div class="cv-card">
<div class="label">Useri</div>
<div class="value">{{ $stats['users'] }}</div>
</div>
<div class="cv-card">
<div class="label">Clienți</div>
<div class="value">{{ $stats['clients'] }}</div>
</div>
<div class="cv-card">
<div class="label">Mașini</div>
<div class="value">{{ $stats['vehicles'] }}</div>
</div>
<div class="cv-card {{ $stats['work_orders_open'] > 0 ? 'warn' : '' }}">
<div class="label">Fișe lucru</div>
<div class="value">{{ $stats['work_orders'] }}</div>
<div class="sub">{{ $stats['work_orders_open'] }} deschise</div>
</div>
<div class="cv-card {{ $stats['parts_low_stock'] > 0 ? 'warn' : '' }}">
<div class="label">Piese în stoc</div>
<div class="value">{{ $stats['parts'] }}</div>
@if ($stats['parts_low_stock'])
<div class="sub"> {{ $stats['parts_low_stock'] }} sub minim</div>
@endif
</div>
<div class="cv-card success">
<div class="label">Venit luna curentă</div>
<div class="value">{{ number_format($stats['revenue_this_month'], 0, ',', ' ') }}</div>
<div class="sub">{{ $this->record->settings['currency'] ?? 'MDL' }}</div>
</div>
<div class="cv-card">
<div class="label">Venit luna trecută</div>
<div class="value">{{ number_format($stats['revenue_last_month'], 0, ',', ' ') }}</div>
<div class="sub">{{ $this->record->settings['currency'] ?? 'MDL' }}</div>
</div>
<div class="cv-card">
<div class="label">Storage media</div>
<div class="value">{{ $stats['storage_mb'] }}</div>
<div class="sub">MB folosiți</div>
</div>
<div class="cv-card {{ $stats['last_login'] && \Carbon\Carbon::parse($stats['last_login'])->diffInDays() > 14 ? 'danger' : '' }}">
<div class="label">Ultima logare</div>
<div class="value" style="font-size:14px;">
{{ $stats['last_login'] ? \Carbon\Carbon::parse($stats['last_login'])->diffForHumans() : 'Niciodată' }}
</div>
@if ($stats['last_login'] && \Carbon\Carbon::parse($stats['last_login'])->diffInDays() > 14)
<div class="sub"> Posibil churn</div>
@endif
</div>
</div>
</div>
{{-- SUBSCRIPTIONS --}}
<div class="cv-section">
<div class="cv-section-title">💳 Istoric abonamente</div>
@if ($this->record->subscriptions->isEmpty())
<div style="padding:32px;text-align:center;color:#9ca3af;font-size:13px;border:1px dashed #e5e7eb;border-radius:8px;">
Niciun abonament emis. Folosește „Generează factură" de sus.
</div>
@else
<table class="cv-table">
<thead>
<tr>
<th>Factură</th>
<th>Plan</th>
<th>Perioadă</th>
<th>Sumă</th>
<th>Status</th>
<th>Plătit</th>
</tr>
</thead>
<tbody>
@foreach ($this->record->subscriptions as $s)
<tr>
<td><b>{{ $s->invoice_number ?? '—' }}</b></td>
<td>{{ $s->plan?->name ?? '—' }}</td>
<td>{{ $s->period_start?->format('d.m.Y') }} {{ $s->period_end?->format('d.m.Y') }}</td>
<td><b>{{ number_format($s->amount, 2) }} {{ $s->currency }}</b></td>
<td>
<span class="cv-badge cv-badge-{{ match($s->status){'paid'=>'success','overdue'=>'danger','pending'=>'warning',default=>'gray'} }}">
{{ \App\Models\Central\Subscription::STATUSES[$s->status] ?? $s->status }}
</span>
</td>
<td>{{ $s->paid_at?->format('d.m.Y') ?? '—' }}</td>
</tr>
@endforeach
</tbody>
</table>
@endif
</div>
{{-- CONFIG --}}
<div class="cv-section">
<div class="cv-section-title"> Configurare tenant</div>
<div class="cv-grid" style="grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));">
<div class="cv-card">
<div class="label">Limbă</div>
<div class="value" style="font-size:14px;">
{{ ['ro' => 'Română', 'ru' => 'Русский', 'en' => 'English'][$this->record->settings['language'] ?? 'ro'] ?? '—' }}
</div>
</div>
<div class="cv-card">
<div class="label">Monedă</div>
<div class="value" style="font-size:14px;">{{ $this->record->settings['currency'] ?? 'MDL' }}</div>
</div>
<div class="cv-card">
<div class="label">Tarif normo-oră</div>
<div class="value" style="font-size:14px;">{{ $this->record->settings['labor_rate'] ?? '—' }}</div>
</div>
<div class="cv-card">
<div class="label">Onboarded</div>
<div class="value" style="font-size:14px;">
{{ ! empty($this->record->settings['onboarded_at']) ? '✓ Da' : '✗ Nu' }}
</div>
</div>
<div class="cv-card">
<div class="label">Theme color</div>
<div class="value" style="display:flex;align-items:center;gap:6px;font-size:14px;">
<span style="display:inline-block;width:18px;height:18px;border-radius:3px;background:{{ $this->record->settings['theme_color'] ?? '#3B82F6' }};"></span>
{{ $this->record->settings['theme_color'] ?? '#3B82F6' }}
</div>
</div>
<div class="cv-card">
<div class="label">AI provider</div>
<div class="value" style="font-size:14px;">
{{ $this->record->settings['ai']['default_provider'] ?? 'neconfigurat' }}
</div>
</div>
</div>
</div>
</x-filament-panels::page>