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
parent
499adaff00
commit
c4cff8f67c
|
|
@ -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]],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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')],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]]],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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' => 'Mo–Fr, 08–18 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.',
|
||||
];
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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' => 'Mon–Fri, 8am–6pm',
|
||||
'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.',
|
||||
];
|
||||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
Loading…
Reference in New Issue