feat(portal): full sidebar — Cloud, Users, Backups, Invoices, Support

Each sidebar tab is now a full-page class-based Livewire component (R1/R2) with an
English route (R13), localized DE/EN (R16), Chart.js islands and token styling:
- Cloud: instance details + specs + storage-over-time line chart + actions.
- Users: user table (roles/groups/status badges) + users-by-group doughnut.
- Backups: 14-day size bar chart + backup history table + restore.
- Invoices: invoice table + next-charge card + monthly-spend bar chart (locale-
  aware month labels, Number::currency amounts).
- Support: contact cards + tickets table + FAQ accordion (Alpine).
- Sidebar links all tabs with routeIs() active state; global toast in app shell.
- All fixture dates/numbers locale-aware (Carbon isoFormat / Number::format).

12 new Pest tests (guard + render per tab) → 26 green. R12 browser: all six tabs
HTTP 200 with ZERO console errors (Chart.js clean). Codex (R15) — clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 08:08:33 +02:00
parent 499adaff00
commit c4cff8f67c
23 changed files with 742 additions and 24 deletions

55
app/Livewire/Backups.php Normal file
View File

@ -0,0 +1,55 @@
<?php
namespace App\Livewire;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Backups extends Component
{
public function render()
{
$locale = app()->getLocale();
$gb = fn (float $v) => Number::format($v, precision: 1, locale: $locale).' GB';
$date = fn (string $iso, string $fmt) => Carbon::parse($iso)->locale($locale)->isoFormat($fmt);
// 14 days of backup sizes for the bar chart.
$sizes = [18.1, 18.1, 18.2, 18.2, 18.2, 18.3, 18.3, 18.3, 18.3, 18.4, 18.4, 18.3, 18.4, 18.4];
$rows = [];
foreach (range(0, 6) as $i) {
$rows[] = [
'when' => $date(Carbon::parse('2026-07-24')->subDays($i)->toDateString(), 'dd, D. MMMM').', 03:1'.($i % 3 + 1),
'size' => $gb(18.4 - $i * 0.02),
'status' => 'ok',
];
}
return view('livewire.backups', [
'rows' => $rows,
'lastTest' => $date('2026-07-01', 'LL'),
'sizeChart' => [
'type' => 'bar',
'data' => [
'labels' => array_map(fn ($i) => $date(Carbon::parse('2026-07-24')->subDays(13 - $i)->toDateString(), 'D.'), range(0, 13)),
'datasets' => [[
'label' => 'GB',
'data' => $sizes,
'backgroundColor' => 'token:accent/0.85',
'borderRadius' => 4,
]],
],
'options' => [
'scales' => [
'x' => ['grid' => ['display' => false]],
'y' => ['beginAtZero' => false, 'grid' => ['color' => 'token:border']],
],
'plugins' => ['legend' => ['display' => false]],
],
],
]);
}
}

57
app/Livewire/Cloud.php Normal file
View File

@ -0,0 +1,57 @@
<?php
namespace App\Livewire;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Cloud extends Component
{
public function render()
{
$locale = app()->getLocale();
$growth = [180, 188, 195, 199, 204, 210, 214, 219, 223, 228, 231, 235];
return view('livewire.cloud', [
'instance' => [
'name' => 'Cloud der Kanzlei Berger',
'domain' => 'cloud.kanzlei-berger.at',
'status' => 'active',
'plan' => __('cloud.plan_line', ['plan' => 'CluPilot Cloud Team', 'price' => 179]),
'location' => __('cloud.datacenter'),
'version' => 'Nextcloud 31.0.4',
'php' => 'PHP 8.3 · MariaDB 11.4',
'storageUsed' => 235,
'storageQuota' => 500,
'ram' => '8 GB',
'vcpu' => '4 vCPU',
],
'storageChart' => [
'type' => 'line',
'data' => [
'labels' => ['', '', '', '', '', '', '', '', '', '', '', __('cloud.now')],
'datasets' => [[
'label' => 'GB',
'data' => $growth,
'borderColor' => 'token:accent',
'backgroundColor' => 'token:accent/0.10',
'fill' => true,
'tension' => 0.35,
'pointRadius' => 0,
'borderWidth' => 2,
]],
],
'options' => [
'scales' => [
'x' => ['grid' => ['display' => false]],
'y' => ['beginAtZero' => false, 'grid' => ['color' => 'token:border']],
],
'plugins' => ['legend' => ['display' => false]],
],
],
'storageLabel' => Number::format(235, locale: $locale).' / '.Number::format(500, locale: $locale).' GB',
]);
}
}

55
app/Livewire/Invoices.php Normal file
View File

@ -0,0 +1,55 @@
<?php
namespace App\Livewire;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Invoices extends Component
{
public function render()
{
$locale = app()->getLocale();
$eur = fn (float $v) => Number::currency($v, in: 'EUR', locale: $locale);
$date = fn (string $iso) => Carbon::parse($iso)->locale($locale)->isoFormat('LL');
$months = collect(['2026-03-01', '2026-04-01', '2026-05-01', '2026-06-01', '2026-07-01'])
->map(fn ($m) => Carbon::parse($m)->locale($locale)->isoFormat('MMM'))->all();
$rows = [
['no' => 'CP-2026-0007', 'date' => $date('2026-07-01'), 'amount' => $eur(198), 'status' => 'paid'],
['no' => 'CP-2026-0006', 'date' => $date('2026-06-01'), 'amount' => $eur(198), 'status' => 'paid'],
['no' => 'CP-2026-0005', 'date' => $date('2026-05-01'), 'amount' => $eur(198), 'status' => 'paid'],
['no' => 'CP-2026-0004', 'date' => $date('2026-04-01'), 'amount' => $eur(179), 'status' => 'paid'],
['no' => 'CP-2026-0003', 'date' => $date('2026-03-15'), 'amount' => $eur(179), 'status' => 'paid'],
];
return view('livewire.invoices', [
'rows' => $rows,
'nextCharge' => $date('2026-08-01'),
'nextAmount' => $eur(198),
'spendChart' => [
'type' => 'bar',
'data' => [
'labels' => $months,
'datasets' => [[
'label' => 'EUR',
'data' => [179, 179, 198, 198, 198],
'backgroundColor' => 'token:accent/0.85',
'borderRadius' => 4,
]],
],
'options' => [
'scales' => [
'x' => ['grid' => ['display' => false]],
'y' => ['beginAtZero' => true, 'grid' => ['color' => 'token:border']],
],
'plugins' => ['legend' => ['display' => false]],
],
],
]);
}
}

30
app/Livewire/Support.php Normal file
View File

@ -0,0 +1,30 @@
<?php
namespace App\Livewire;
use Illuminate\Support\Carbon;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Support extends Component
{
public function render()
{
$locale = app()->getLocale();
$date = fn (string $iso) => Carbon::parse($iso)->locale($locale)->isoFormat('LL');
return view('livewire.support', [
'tickets' => [
['subject' => __('support.ticket_migration'), 'ref' => '#4821', 'date' => $date('2026-07-22'), 'status' => 'open'],
['subject' => __('support.ticket_training'), 'ref' => '#4790', 'date' => $date('2026-07-18'), 'status' => 'progress'],
['subject' => __('support.ticket_smtp'), 'ref' => '#4712', 'date' => $date('2026-07-04'), 'status' => 'closed'],
],
'faqs' => [
['q' => __('support.faq_q1'), 'a' => __('support.faq_a1')],
['q' => __('support.faq_q2'), 'a' => __('support.faq_a2')],
['q' => __('support.faq_q3'), 'a' => __('support.faq_a3')],
],
]);
}
}

41
app/Livewire/Users.php Normal file
View File

@ -0,0 +1,41 @@
<?php
namespace App\Livewire;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Users extends Component
{
public function render()
{
return view('livewire.users', [
'people' => [
['name' => 'Dr. Sabine Berger', 'email' => 's.berger@kanzlei-berger.at', 'role' => __('users.role_admin'), 'group' => 'Partner', 'status' => 'active'],
['name' => 'Michael Huber', 'email' => 'm.huber@kanzlei-berger.at', 'role' => __('users.role_member'), 'group' => 'Anwälte', 'status' => 'active'],
['name' => 'Julia Wagner', 'email' => 'j.wagner@kanzlei-berger.at', 'role' => __('users.role_member'), 'group' => 'Anwälte', 'status' => 'active'],
['name' => 'Thomas Bauer', 'email' => 't.bauer@kanzlei-berger.at', 'role' => __('users.role_member'), 'group' => 'Sekretariat', 'status' => 'active'],
['name' => 'Lena Mayr', 'email' => 'l.mayr@kanzlei-berger.at', 'role' => __('users.role_member'), 'group' => 'Sekretariat', 'status' => 'active'],
['name' => 'Paul Steiner', 'email' => 'p.steiner@kanzlei-berger.at', 'role' => __('users.role_member'), 'group' => 'Anwälte', 'status' => 'active'],
['name' => 'Gast · Notariat Külz', 'email' => 'extern@notariat-kuelz.at', 'role' => __('users.role_guest'), 'group' => __('users.guests'), 'status' => 'guest'],
['name' => 'Gast · Steuerberatung Reiss', 'email' => 'extern@stb-reiss.at', 'role' => __('users.role_guest'), 'group' => __('users.guests'), 'status' => 'guest'],
],
'groupsChart' => [
'type' => 'doughnut',
'data' => [
'labels' => ['Partner', 'Anwälte', 'Sekretariat', __('users.guests')],
'datasets' => [[
'data' => [1, 3, 2, 2],
'backgroundColor' => ['token:accent', 'token:info', 'token:success-bright', 'token:surface-2'],
'borderWidth' => 0,
]],
],
'options' => [
'cutout' => '62%',
'plugins' => ['legend' => ['position' => 'bottom', 'labels' => ['boxWidth' => 10, 'padding' => 12]]],
],
],
]);
}
}

15
lang/de/backups.php Normal file
View File

@ -0,0 +1,15 @@
<?php
return [
'title' => 'Backups',
'subtitle' => 'Automatische, verschlüsselte Sicherungen Ihrer Cloud.',
'schedule' => 'täglich 03:00',
'size_trend' => 'Backup-Größe (14 Tage)',
'col_when' => 'Zeitpunkt',
'col_size' => 'Größe',
'col_status' => 'Status',
'ok' => 'Erfolgreich',
'restore' => 'Wiederherstellen',
'note' => 'Clientseitig verschlüsselt, getrennt gelagert. Letzter Wiederherstellungstest: :date — erfolgreich.',
'restore_toast' => 'Wiederherstellung: wir melden uns binnen 1 Std.',
];

24
lang/de/cloud.php Normal file
View File

@ -0,0 +1,24 @@
<?php
return [
'title' => 'Meine Cloud',
'subtitle' => 'Details und Ressourcen Ihrer Instanz.',
'plan_line' => ':plan · :price €/Monat',
'datacenter' => 'EU · Rechenzentrum Falkenstein',
'now' => 'jetzt',
'open' => 'Cloud öffnen',
'status_active' => 'Aktiv',
'plan' => 'Paket',
'location' => 'Serverstandort',
'version' => 'Version',
'current' => 'aktuell',
'runtime' => 'Laufzeit',
'resources' => 'Ressourcen',
'storage' => 'Speicher',
'storage_growth' => 'Speicherverlauf',
'storage_growth_sub' => 'Belegter Speicher der letzten 12 Wochen.',
'restart' => 'Neu starten',
'snapshot' => 'Snapshot erstellen',
'logs' => 'Protokolle',
'action_toast' => 'Aktion im Prototyp nur angedeutet.',
];

16
lang/de/invoices.php Normal file
View File

@ -0,0 +1,16 @@
<?php
return [
'title' => 'Rechnungen',
'subtitle' => 'Ihre Abrechnungen und kommende Zahlungen.',
'col_no' => 'Nummer',
'col_date' => 'Datum',
'col_amount' => 'Betrag',
'col_status' => 'Status',
'paid' => 'Bezahlt',
'pdf' => 'PDF',
'next_charge' => 'Nächste Abbuchung',
'on_date' => 'am :date',
'spend' => 'Ausgaben (5 Monate)',
'download_toast' => 'Rechnungs-PDF im Prototyp nur angedeutet.',
];

27
lang/de/support.php Normal file
View File

@ -0,0 +1,27 @@
<?php
return [
'title' => 'Support',
'subtitle' => 'Wir sind für Sie da — persönlich und schnell.',
'new' => 'Anfrage stellen',
'new_toast' => 'Support-Formular im Prototyp nur angedeutet.',
'contact_hours' => 'Erreichbarkeit',
'contact_hours_val' => 'MoFr, 0818 Uhr',
'contact_email' => 'E-Mail',
'contact_sla' => 'Reaktionszeit',
'contact_sla_val' => 'Team-Paket · binnen 4 Std.',
'tickets' => 'Ihre Anfragen',
'status_open' => 'Offen',
'status_progress' => 'In Bearbeitung',
'status_closed' => 'Erledigt',
'ticket_migration' => 'Datenübernahme aus Dropbox',
'ticket_training' => 'Einschulungstermin Sekretariat',
'ticket_smtp' => 'E-Mail-Versand einrichten',
'faq' => 'Häufige Fragen',
'faq_q1' => 'Wie stelle ich ein Backup wieder her?',
'faq_a1' => 'Wählen Sie unter „Backups" den gewünschten Zeitpunkt und klicken Sie auf „Wiederherstellen". Wir bestätigen die Wiederherstellung binnen einer Stunde.',
'faq_q2' => 'Kann ich weitere Benutzer hinzufügen?',
'faq_a2' => 'Ja, bis zum Limit Ihres Pakets jederzeit unter „Benutzer". Zusätzliche Plätze buchen Sie mit einem Klick dazu.',
'faq_q3' => 'Wo liegen meine Daten?',
'faq_a3' => 'Ausschließlich in der EU, im Rechenzentrum Falkenstein — verschlüsselt und DSGVO-konform.',
];

19
lang/de/users.php Normal file
View File

@ -0,0 +1,19 @@
<?php
return [
'title' => 'Benutzer',
'subtitle' => ':count Benutzer · 3 Gruppen · 2 Gastzugänge',
'add' => 'Benutzer hinzufügen',
'col_name' => 'Name',
'col_group' => 'Gruppe',
'col_role' => 'Rolle',
'col_status' => 'Status',
'guest' => 'Gast',
'active' => 'Aktiv',
'guests' => 'Gäste',
'by_group' => 'Nach Gruppe',
'role_admin' => 'Administrator',
'role_member' => 'Mitglied',
'role_guest' => 'Gast',
'action_toast' => 'Benutzerverwaltung im Prototyp nur angedeutet.',
];

15
lang/en/backups.php Normal file
View File

@ -0,0 +1,15 @@
<?php
return [
'title' => 'Backups',
'subtitle' => 'Automatic, encrypted backups of your cloud.',
'schedule' => 'daily 03:00',
'size_trend' => 'Backup size (14 days)',
'col_when' => 'When',
'col_size' => 'Size',
'col_status' => 'Status',
'ok' => 'Successful',
'restore' => 'Restore',
'note' => 'Client-side encrypted, stored separately. Last restore test: :date — successful.',
'restore_toast' => 'Restore: we will get back to you within 1 hr.',
];

24
lang/en/cloud.php Normal file
View File

@ -0,0 +1,24 @@
<?php
return [
'title' => 'My cloud',
'subtitle' => 'Details and resources of your instance.',
'plan_line' => ':plan · €:price/mo',
'datacenter' => 'EU · Falkenstein data center',
'now' => 'now',
'open' => 'Open cloud',
'status_active' => 'Active',
'plan' => 'Plan',
'location' => 'Server location',
'version' => 'Version',
'current' => 'up to date',
'runtime' => 'Runtime',
'resources' => 'Resources',
'storage' => 'Storage',
'storage_growth' => 'Storage over time',
'storage_growth_sub' => 'Used storage over the last 12 weeks.',
'restart' => 'Restart',
'snapshot' => 'Create snapshot',
'logs' => 'Logs',
'action_toast' => 'Action is only indicated in this prototype.',
];

16
lang/en/invoices.php Normal file
View File

@ -0,0 +1,16 @@
<?php
return [
'title' => 'Invoices',
'subtitle' => 'Your billing history and upcoming charges.',
'col_no' => 'Number',
'col_date' => 'Date',
'col_amount' => 'Amount',
'col_status' => 'Status',
'paid' => 'Paid',
'pdf' => 'PDF',
'next_charge' => 'Next charge',
'on_date' => 'on :date',
'spend' => 'Spend (5 months)',
'download_toast' => 'Invoice PDF is only indicated in this prototype.',
];

27
lang/en/support.php Normal file
View File

@ -0,0 +1,27 @@
<?php
return [
'title' => 'Support',
'subtitle' => 'We are here for you — personal and fast.',
'new' => 'New request',
'new_toast' => 'Support form is only indicated in this prototype.',
'contact_hours' => 'Availability',
'contact_hours_val' => 'MonFri, 8am6pm',
'contact_email' => 'Email',
'contact_sla' => 'Response time',
'contact_sla_val' => 'Team plan · within 4 hrs',
'tickets' => 'Your requests',
'status_open' => 'Open',
'status_progress' => 'In progress',
'status_closed' => 'Resolved',
'ticket_migration' => 'Data import from Dropbox',
'ticket_training' => 'Training session for the office',
'ticket_smtp' => 'Set up email delivery',
'faq' => 'Frequently asked questions',
'faq_q1' => 'How do I restore a backup?',
'faq_a1' => 'Under “Backups”, pick the point in time and click “Restore”. We confirm the restore within one hour.',
'faq_q2' => 'Can I add more users?',
'faq_a2' => 'Yes, any time under “Users” up to your plan limit. Add extra seats with one click.',
'faq_q3' => 'Where is my data stored?',
'faq_a3' => 'Exclusively in the EU, at the Falkenstein data center — encrypted and GDPR-compliant.',
];

19
lang/en/users.php Normal file
View File

@ -0,0 +1,19 @@
<?php
return [
'title' => 'Users',
'subtitle' => ':count users · 3 groups · 2 guest accounts',
'add' => 'Add user',
'col_name' => 'Name',
'col_group' => 'Group',
'col_role' => 'Role',
'col_status' => 'Status',
'guest' => 'Guest',
'active' => 'Active',
'guests' => 'Guests',
'by_group' => 'By group',
'role_admin' => 'Administrator',
'role_member' => 'Member',
'role_guest' => 'Guest',
'action_toast' => 'User management is only indicated in this prototype.',
];

View File

@ -22,30 +22,19 @@
</button>
</div>
<nav class="space-y-1">
<x-ui.nav-item :href="route('dashboard')" :active="request()->routeIs('dashboard')">
<x-slot:icon><x-ui.icon name="gauge" /></x-slot:icon>
{{ __('dashboard.nav.overview') }}
</x-ui.nav-item>
<x-ui.nav-item :disabled="true">
<x-slot:icon><x-ui.icon name="cloud" /></x-slot:icon>
{{ __('dashboard.nav.cloud') }}
</x-ui.nav-item>
<x-ui.nav-item :disabled="true">
<x-slot:icon><x-ui.icon name="users" /></x-slot:icon>
{{ __('dashboard.nav.users') }}
</x-ui.nav-item>
<x-ui.nav-item :disabled="true">
<x-slot:icon><x-ui.icon name="database" /></x-slot:icon>
{{ __('dashboard.nav.backups') }}
</x-ui.nav-item>
<x-ui.nav-item :disabled="true">
<x-slot:icon><x-ui.icon name="receipt" /></x-slot:icon>
{{ __('dashboard.nav.invoices') }}
</x-ui.nav-item>
<x-ui.nav-item :disabled="true">
<x-slot:icon><x-ui.icon name="life-buoy" /></x-slot:icon>
{{ __('dashboard.nav.support') }}
</x-ui.nav-item>
@foreach ([
['dashboard', 'gauge', 'overview'],
['cloud', 'cloud', 'cloud'],
['users', 'users', 'users'],
['backups', 'database', 'backups'],
['invoices', 'receipt', 'invoices'],
['support', 'life-buoy', 'support'],
] as [$route, $icon, $key])
<x-ui.nav-item :href="route($route)" :active="request()->routeIs($route)">
<x-slot:icon><x-ui.icon :name="$icon" /></x-slot:icon>
{{ __('dashboard.nav.'.$key) }}
</x-ui.nav-item>
@endforeach
</nav>
</aside>

View File

@ -0,0 +1,46 @@
<div class="space-y-6" x-data="{ msgs: @js(['restore' => __('backups.restore_toast')]) }">
<div class="flex flex-wrap items-center gap-3 animate-rise">
<div>
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('backups.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('backups.subtitle') }}</p>
</div>
<span class="ml-auto rounded-pill bg-success-bg px-3 py-1.5 text-xs font-semibold text-success">{{ __('backups.schedule') }}</span>
</div>
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="font-semibold text-ink">{{ __('backups.size_trend') }}</h2>
<div class="mt-3 h-52" wire:ignore>
<x-ui.chart :config="$sizeChart" class="h-52" :label="__('backups.size_trend')" />
</div>
</div>
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms]">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
<th class="px-4 py-3 font-semibold">{{ __('backups.col_when') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('backups.col_size') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('backups.col_status') }}</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody>
@foreach ($rows as $r)
<tr class="border-b border-line last:border-0 hover:bg-surface-hover">
<td class="px-4 py-3 font-mono text-xs text-body">{{ $r['when'] }}</td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $r['size'] }}</td>
<td class="px-4 py-3"><x-ui.badge status="active">{{ __('backups.ok') }}</x-ui.badge></td>
<td class="px-4 py-3 text-right"><x-ui.button variant="secondary" size="sm" @click="$dispatch('notify', { message: msgs.restore })">{{ __('backups.restore') }}</x-ui.button></td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
<p class="flex gap-2 text-xs leading-relaxed text-muted animate-rise [animation-delay:180ms]">
<x-ui.icon name="shield" class="mt-px size-4 shrink-0 text-accent-active" />
<span>{{ __('backups.note', ['date' => $lastTest]) }}</span>
</p>
</div>

View File

@ -0,0 +1,50 @@
<div class="space-y-6" x-data="{ msgs: @js(['soon' => __('cloud.action_toast')]) }">
<div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('cloud.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('cloud.subtitle') }}</p>
</div>
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
<div class="flex flex-wrap items-center gap-3">
<span class="grid size-11 place-items-center rounded-lg bg-ink font-semibold text-on-accent">B</span>
<div class="min-w-0">
<p class="font-semibold text-ink">{{ $instance['name'] }}</p>
<a href="https://{{ $instance['domain'] }}" target="_blank" rel="noopener noreferrer" class="font-mono text-sm text-accent-text hover:underline">{{ $instance['domain'] }}</a>
</div>
<x-ui.badge :status="$instance['status']" class="sm:ml-2">{{ __('cloud.status_'.$instance['status']) }}</x-ui.badge>
<a href="https://{{ $instance['domain'] }}" target="_blank" rel="noopener noreferrer"
class="ml-auto inline-flex items-center gap-2 rounded-pill bg-accent-active px-4 py-2 text-sm font-semibold text-on-accent transition hover:bg-accent-press">
<x-ui.icon name="external-link" class="size-4" />{{ __('cloud.open') }}
</a>
</div>
<dl class="mt-4 grid grid-cols-1 gap-4 border-t border-line pt-4 sm:grid-cols-2 lg:grid-cols-3">
@foreach ([
['cloud.plan', $instance['plan']],
['cloud.location', $instance['location']],
['cloud.version', $instance['version'].' · '.__('cloud.current')],
['cloud.runtime', $instance['php']],
['cloud.resources', $instance['vcpu'].' · '.$instance['ram']],
['cloud.storage', $storageLabel],
] as [$key, $val])
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __($key) }}</dt>
<dd class="mt-1 font-mono text-sm text-body">{{ $val }}</dd>
</div>
@endforeach
</dl>
</div>
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('cloud.storage_growth') }}</h2>
<p class="text-sm text-muted">{{ __('cloud.storage_growth_sub') }}</p>
<div class="mt-4 h-56" wire:ignore>
<x-ui.chart :config="$storageChart" class="h-56" :label="__('cloud.storage_growth')" />
</div>
</div>
<div class="flex flex-wrap gap-3 animate-rise [animation-delay:180ms]">
<x-ui.button variant="secondary" @click="$dispatch('notify', { message: msgs.soon })">{{ __('cloud.restart') }}</x-ui.button>
<x-ui.button variant="secondary" @click="$dispatch('notify', { message: msgs.soon })">{{ __('cloud.snapshot') }}</x-ui.button>
<x-ui.button variant="secondary" @click="$dispatch('notify', { message: msgs.soon })">{{ __('cloud.logs') }}</x-ui.button>
</div>
</div>

View File

@ -0,0 +1,49 @@
<div class="space-y-6" x-data="{ msgs: @js(['download' => __('invoices.download_toast')]) }">
<div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('invoices.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('invoices.subtitle') }}</p>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px] lg:items-start">
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
<th class="px-4 py-3 font-semibold">{{ __('invoices.col_no') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('invoices.col_date') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('invoices.col_amount') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('invoices.col_status') }}</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody>
@foreach ($rows as $r)
<tr class="border-b border-line last:border-0 hover:bg-surface-hover">
<td class="px-4 py-3 font-mono text-xs text-body">{{ $r['no'] }}</td>
<td class="px-4 py-3 text-body">{{ $r['date'] }}</td>
<td class="px-4 py-3 font-mono text-body">{{ $r['amount'] }}</td>
<td class="px-4 py-3"><x-ui.badge status="active">{{ __('invoices.paid') }}</x-ui.badge></td>
<td class="px-4 py-3 text-right"><x-ui.button variant="ghost" size="sm" @click="$dispatch('notify', { message: msgs.download })"><x-ui.icon name="download" class="size-4" />{{ __('invoices.pdf') }}</x-ui.button></td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
<div class="flex flex-col gap-6">
<div class="rounded-xl border border-accent-border bg-accent-subtle p-5 shadow-xs animate-rise [animation-delay:120ms]">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('invoices.next_charge') }}</p>
<p class="mt-1 font-mono text-2xl font-semibold text-ink">{{ $nextAmount }}</p>
<p class="mt-1 text-sm text-muted">{{ __('invoices.on_date', ['date' => $nextCharge]) }}</p>
</div>
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:180ms]">
<h2 class="font-semibold text-ink">{{ __('invoices.spend') }}</h2>
<div class="mt-3 h-48" wire:ignore>
<x-ui.chart :config="$spendChart" class="h-48" :label="__('invoices.spend')" />
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,67 @@
<div class="space-y-6" x-data="{ open: null, msgs: @js(['sent' => __('support.new_toast')]) }">
<div class="flex flex-wrap items-center gap-3 animate-rise">
<div>
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('support.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('support.subtitle') }}</p>
</div>
<x-ui.button variant="primary" class="ml-auto" @click="$dispatch('notify', { message: msgs.sent })">
<x-ui.icon name="plus" class="size-4" />{{ __('support.new') }}
</x-ui.button>
</div>
{{-- Contact --}}
@php $delays = ['[animation-delay:60ms]', '[animation-delay:120ms]', '[animation-delay:180ms]']; @endphp
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
@foreach ([
['life-buoy', __('support.contact_hours'), __('support.contact_hours_val')],
['external-link', __('support.contact_email'), 'support@clupilot.com'],
['shield-check', __('support.contact_sla'), __('support.contact_sla_val')],
] as $i => [$icon, $label, $val])
<div class="flex items-center gap-3 rounded-xl border border-line bg-surface p-4 shadow-xs animate-rise {{ $delays[$i] }}">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-surface-2 text-accent-active"><x-ui.icon :name="$icon" class="size-5" /></span>
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ $label }}</p>
<p class="mt-0.5 truncate text-sm font-medium text-ink">{{ $val }}</p>
</div>
</div>
@endforeach
</div>
{{-- Tickets --}}
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:240ms]">
<div class="border-b border-line px-5 py-3"><h2 class="font-semibold text-ink">{{ __('support.tickets') }}</h2></div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<tbody>
@foreach ($tickets as $t)
<tr class="border-b border-line last:border-0 hover:bg-surface-hover">
<td class="px-5 py-3">
<p class="font-medium text-ink">{{ $t['subject'] }}</p>
<p class="font-mono text-xs text-muted">{{ $t['ref'] }} · {{ $t['date'] }}</p>
</td>
<td class="px-5 py-3 text-right">
<x-ui.badge :status="$t['status'] === 'open' ? 'provisioning' : ($t['status'] === 'progress' ? 'warning' : 'active')">{{ __('support.status_'.$t['status']) }}</x-ui.badge>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
{{-- FAQ accordion --}}
<div class="rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:300ms]">
<div class="border-b border-line px-5 py-3"><h2 class="font-semibold text-ink">{{ __('support.faq') }}</h2></div>
<ul class="divide-y divide-line">
@foreach ($faqs as $i => $f)
<li>
<button type="button" class="flex w-full items-center gap-3 px-5 py-4 text-left text-sm font-medium text-ink hover:bg-surface-hover" @click="open = open === {{ $i }} ? null : {{ $i }}" :aria-expanded="open === {{ $i }}">
<span class="flex-1">{{ $f['q'] }}</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 text-muted transition-transform" :class="open === {{ $i }} ? 'rotate-180' : ''" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div x-show="open === {{ $i }}" x-cloak x-transition class="px-5 pb-4 text-sm text-muted">{{ $f['a'] }}</div>
</li>
@endforeach
</ul>
</div>
</div>

View File

@ -0,0 +1,48 @@
<div class="space-y-6" x-data="{ msgs: @js(['soon' => __('users.action_toast')]) }">
<div class="flex flex-wrap items-center gap-3 animate-rise">
<div>
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('users.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('users.subtitle', ['count' => count($people)]) }}</p>
</div>
<x-ui.button variant="primary" class="ml-auto" @click="$dispatch('notify', { message: msgs.soon })">
<x-ui.icon name="plus" class="size-4" />{{ __('users.add') }}
</x-ui.button>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px] lg:items-start">
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
<th class="px-4 py-3 font-semibold">{{ __('users.col_name') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('users.col_group') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('users.col_role') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('users.col_status') }}</th>
</tr>
</thead>
<tbody>
@foreach ($people as $p)
<tr class="border-b border-line last:border-0 hover:bg-surface-hover">
<td class="px-4 py-3">
<p class="font-medium text-ink">{{ $p['name'] }}</p>
<p class="font-mono text-xs text-muted">{{ $p['email'] }}</p>
</td>
<td class="px-4 py-3 text-body">{{ $p['group'] }}</td>
<td class="px-4 py-3 text-body">{{ $p['role'] }}</td>
<td class="px-4 py-3"><x-ui.badge :status="$p['status'] === 'guest' ? 'info' : 'active'">{{ $p['status'] === 'guest' ? __('users.guest') : __('users.active') }}</x-ui.badge></td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('users.by_group') }}</h2>
<div class="mt-3 h-56" wire:ignore>
<x-ui.chart :config="$groupsChart" class="h-56" :label="__('users.by_group')" />
</div>
</div>
</div>
</div>

View File

@ -2,7 +2,12 @@
use App\Livewire\Auth\Login;
use App\Livewire\Auth\TwoFactorChallenge;
use App\Livewire\Backups;
use App\Livewire\Cloud;
use App\Livewire\Dashboard;
use App\Livewire\Invoices;
use App\Livewire\Support;
use App\Livewire\Users;
use Illuminate\Support\Facades\Route;
Route::get('/', fn () => view('welcome'))->name('home');
@ -14,6 +19,13 @@ Route::middleware('guest')->group(function () {
Route::get('/two-factor-challenge', TwoFactorChallenge::class)->name('two-factor.login');
});
// Customer portal — each sidebar tab is a full-page class-based Livewire
// component (R1/R2); paths are English (R13).
Route::middleware('auth')->group(function () {
Route::get('/dashboard', Dashboard::class)->name('dashboard');
Route::get('/cloud', Cloud::class)->name('cloud');
Route::get('/users', Users::class)->name('users');
Route::get('/backups', Backups::class)->name('backups');
Route::get('/invoices', Invoices::class)->name('invoices');
Route::get('/support', Support::class)->name('support');
});

View File

@ -0,0 +1,17 @@
<?php
use App\Models\User;
$tabs = ['dashboard', 'cloud', 'users', 'backups', 'invoices', 'support'];
it('redirects guests from portal tabs to login', function (string $tab) {
$this->get(route($tab))->assertRedirect('/login');
})->with($tabs);
it('renders portal tabs for authenticated users', function (string $tab) {
$user = User::factory()->create();
$this->actingAs($user)->get(route($tab))
->assertOk()
->assertSee('CluPilot');
})->with($tabs);