10426d0c91
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
240 lines
12 KiB
PHP
240 lines
12 KiB
PHP
<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>
|