feat(dashboard): rich overview with Chart.js (customer-portal template)

- Chart.js as an Alpine island (x-ui.chart): configs are PHP arrays; colours use
  token: strings resolved from CSS design tokens at runtime (R3). Respects
  reduced-motion; IBM Plex chart defaults.
- Tokens: soft --radius-xl (20px), --success-bright for dots/rings/charts.
  Staggered 'rise' reveal keyframe. Global toast in the app shell.
- Overview rebuilt to the customer template: storage doughnut ring, availability
  sparkline, KPI tiles, upsell, cloud card, interactive onboarding checklist
  (Alpine), backups, modules (Lucide icons — no emoji, R9), activity feed.
- Locale-aware fixture display: Carbon isoFormat dates + Number::format sizes so
  the EN dashboard is not mixed-language (R16).
- 14 Pest tests green; R12 browser: /dashboard 0 console errors with Chart.js;
  R7 no overflow at 375/768/1280. Reviewed with Codex (R15) — clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 07:59:19 +02:00
parent 2b08b072fe
commit 499adaff00
12 changed files with 583 additions and 138 deletions

View File

@ -2,6 +2,8 @@
namespace App\Livewire;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
@ -10,34 +12,111 @@ class Dashboard extends Component
{
public function render()
{
// Fixture data until the provisioning engine (separate handoff) provides
// real instances / provisioning_runs. The dashboard is a pure view (§12.3).
$storageUsed = 172;
$storageQuota = 250;
// Locale-aware date / number formatting for the fixture values below.
$locale = app()->getLocale();
$date = fn (string $iso, string $fmt) => Carbon::parse($iso)->locale($locale)->isoFormat($fmt);
$gb = fn (float $v) => Number::format($v, precision: 1, locale: $locale).' GB';
// Fixture data until the provisioning engine (separate handoff) supplies
// real instances / metrics. Tenant-specific values stand in for DB rows.
$storageUsed = 235;
$storageQuota = 500;
$storagePercent = (int) round($storageUsed / max(1, $storageQuota) * 100);
$storageTone = $storagePercent >= 90 ? 'danger' : ($storagePercent >= 85 ? 'warning' : 'accent');
// ~30 days of availability, gently trending near 100%.
$availability = [99.95, 99.97, 99.96, 99.98, 99.99, 99.97, 99.98, 99.99, 99.99, 99.98,
99.97, 99.99, 100, 99.98, 99.99, 99.99, 99.97, 99.98, 99.99, 100,
99.98, 99.99, 99.99, 99.98, 100, 99.99, 99.98, 99.99, 100, 99.98];
return view('livewire.dashboard', [
'storageUsed' => $storageUsed,
'storageQuota' => $storageQuota,
'storagePercent' => $storagePercent,
'storageTone' => $storageTone,
'activeUsers' => 14,
'lastBackup' => __('dashboard.today_time', ['time' => '04:12']),
'uptime' => '99.98',
'instanceUrl' => 'https://kunde-demo.clupilot.com',
'instanceStatus' => 'active',
'provisioning' => false,
'steps' => [
['label' => __('dashboard.steps.validate'), 'state' => 'done'],
['label' => __('dashboard.steps.provision'), 'state' => 'done'],
['label' => __('dashboard.steps.configure'), 'state' => 'done'],
['label' => __('dashboard.steps.acceptance'), 'state' => 'done'],
'usersActive' => 8,
'usersQuota' => 20,
'lastBackup' => '03:12',
'availability' => Number::format(99.98, precision: 2, locale: $locale),
'storageChart' => [
'type' => 'doughnut',
'data' => [
'datasets' => [[
'data' => [$storagePercent, 100 - $storagePercent],
'backgroundColor' => ['token:accent', 'token:surface-2'],
'borderWidth' => 0,
]],
],
'options' => [
'cutout' => '76%',
'plugins' => [
'legend' => ['display' => false],
'tooltip' => ['enabled' => false],
],
],
],
'availabilityChart' => [
'type' => 'line',
'data' => [
'labels' => array_fill(0, count($availability), ''),
'datasets' => [[
'data' => $availability,
'borderColor' => 'token:success-bright',
'backgroundColor' => 'token:success-bright/0.12',
'fill' => true,
'tension' => 0.4,
'pointRadius' => 0,
'borderWidth' => 2,
]],
],
'options' => [
'scales' => [
'x' => ['display' => false],
'y' => ['display' => false, 'min' => 99.9, 'max' => 100],
],
'plugins' => [
'legend' => ['display' => false],
'tooltip' => ['enabled' => false],
],
],
],
'cloud' => [
'name' => 'Cloud der Kanzlei Berger',
'domain' => 'cloud.kanzlei-berger.at',
'package' => __('dashboard.cloud.plan_line', ['plan' => 'CluPilot Cloud Team', 'price' => 179]),
'since' => $date('2026-03-14', 'LL'),
'location' => __('dashboard.cloud.datacenter'),
'version' => 'Nextcloud 31.0.4',
],
'onboarding' => [
['label' => __('dashboard.onboarding.instance'), 'done' => true, 'date' => $date('2026-03-14', 'D. MMMM')],
['label' => __('dashboard.onboarding.domain'), 'done' => true, 'date' => $date('2026-03-15', 'D. MMMM')],
['label' => __('dashboard.onboarding.branding'), 'done' => true, 'date' => $date('2026-03-15', 'D. MMMM')],
['label' => __('dashboard.onboarding.users'), 'done' => true, 'date' => $date('2026-03-16', 'D. MMMM')],
['label' => __('dashboard.onboarding.migration'), 'done' => false, 'date' => null],
['label' => __('dashboard.onboarding.training'), 'done' => false, 'date' => null],
['label' => __('dashboard.onboarding.docs'), 'done' => false, 'date' => null],
],
'backups' => [
['when' => __('dashboard.today').', 03:12', 'size' => $gb(18.4)],
['when' => __('dashboard.yesterday').', 03:09', 'size' => $gb(18.4)],
['when' => $date('2026-07-21', 'D. MMMM').', 03:11', 'size' => $gb(18.3)],
],
'modules' => [
['icon' => 'cloud', 'name' => 'Nextcloud + Collabora', 'desc' => __('dashboard.modules.nextcloud'), 'status' => 'active'],
['icon' => 'shield-check', 'name' => 'Vaultwarden', 'desc' => __('dashboard.modules.vaultwarden'), 'status' => 'active'],
['icon' => 'receipt', 'name' => 'Paperless-ngx', 'desc' => __('dashboard.modules.paperless'), 'status' => 'available'],
['icon' => 'pen', 'name' => __('dashboard.modules.signature_name'), 'desc' => __('dashboard.modules.signature'), 'status' => 'soon'],
],
'activity' => [
['label' => __('dashboard.activity_items.backup_ok'), 'time' => '04:12'],
['label' => __('dashboard.activity_items.user_added'), 'time' => __('dashboard.yesterday')],
['label' => __('dashboard.activity_items.login'), 'time' => __('dashboard.yesterday')],
['icon' => 'download', 'text' => __('dashboard.activity_items.backup_ok'), 'time' => '03:12'],
['icon' => 'shield-check', 'text' => __('dashboard.activity_items.security'), 'time' => '04:01'],
['icon' => 'users', 'text' => __('dashboard.activity_items.upload'), 'time' => '07:56'],
['icon' => 'shield-check', 'text' => __('dashboard.activity_items.cert'), 'time' => __('dashboard.yesterday').' 22:10'],
],
]);
}

View File

@ -3,11 +3,14 @@
return [
'title' => 'Übersicht',
'subtitle' => 'Status Ihrer Cloud auf einen Blick.',
'greeting' => 'Guten Morgen, :name',
'system_ok' => 'Alle Systeme normal',
'as_of' => 'Zustand Ihrer Cloud auf einen Blick — Stand heute, :time Uhr.',
'nav' => [
'overview' => 'Übersicht',
'cloud' => 'Meine Cloud',
'users' => 'Nutzer',
'users' => 'Benutzer',
'backups' => 'Backups',
'invoices' => 'Rechnungen',
'support' => 'Support',
@ -17,19 +20,84 @@ return [
'close_nav' => 'Navigation schließen',
'logout' => 'Abmelden',
'today' => 'Heute',
'yesterday' => 'Gestern',
'kpi' => [
'storage' => 'Speicher',
'users' => 'Aktive Nutzer',
'users' => 'Benutzer',
'backup' => 'Letztes Backup',
'uptime' => 'Verfügbarkeit',
'availability' => 'Verfügbarkeit · 30 Tage',
],
'occupied' => 'belegt',
'storage_week' => '+3,4 GB diese Woche',
'users_detail' => '3 Gruppen · 2 Gastzugänge',
'backup_verified' => 'heute · verschlüsselt & geprüft',
'upsell' => [
'title' => 'Ihr Speicher ist zu :percent % belegt.',
'body' => 'Jederzeit erweiterbar — +100 GB für 10 € pro Monat, monatlich kündbar.',
'cta' => 'Speicher erweitern',
],
'cloud' => [
'title' => 'Meine Cloud',
'open' => 'Cloud öffnen',
'package' => 'Paket',
'status' => 'Status',
'location' => 'Serverstandort',
'version' => 'Version',
'active_since' => 'Aktiv seit :date',
'current' => 'aktuell',
'plan_line' => ':plan · :price €/Monat',
'datacenter' => 'EU · Rechenzentrum Falkenstein',
],
'onboarding' => [
'title' => 'Einrichtung Ihrer Cloud',
'progress' => ':done von :total erledigt',
'review' => 'Jetzt prüfen',
'next' => 'Weiter',
'done_toast' => 'Erledigt — schöner Fortschritt!',
'instance' => 'Instanz erstellt & gestartet',
'domain' => 'Eigene Domain verbunden',
'branding' => 'Kanzlei-Logo & Farben hinterlegt',
'users' => '8 Benutzer angelegt, Rechte gesetzt',
'migration' => 'Datenübernahme aus Dropbox freigeben',
'training' => 'Einschulungstermin wählen',
'docs' => 'Dokumentationsmappe herunterladen',
],
'backups' => [
'title' => 'Backups',
'schedule' => 'täglich 03:00',
'restore' => 'Wiederherstellen',
'note' => 'Clientseitig verschlüsselt, getrennt gelagert. Letzter Wiederherstellungstest: 01.07. — erfolgreich.',
'restore_toast' => 'Wiederherstellung: wir melden uns binnen 1 Std.',
],
'modules' => [
'title' => 'Ihre Module',
'addon' => 'zubuchbar',
'active' => 'Aktiv',
'add' => 'Hinzubuchen',
'soon' => 'In Vorbereitung',
'nextcloud' => 'Dateien, Office, Kalender, Talk',
'vaultwarden' => 'Team-Passwortmanager · Zusatzmodul 19 €/Monat',
'paperless' => 'Digitales Dokumentenarchiv · ab 39 €/Monat',
'signature_name' => 'Digitale Signatur',
'signature' => 'Rechtsgültige Unterschriften · ab 29 €/Monat',
'add_toast' => 'Zusatzmodul zubuchbar — wir beraten Sie gern.',
],
'instance' => 'Instanz',
'open_cloud' => 'Cloud öffnen',
'provisioning' => 'Bereitstellung',
'activity' => 'Aktivität',
'today_time' => 'Heute :time',
'yesterday' => 'Gestern',
'activity_live' => 'live',
'activity_items' => [
'backup_ok' => 'Backup abgeschlossen — 18,4 GB',
'security' => 'Sicherheitsupdates eingespielt',
'upload' => 'M. Huber hat 14 Dateien hochgeladen',
'cert' => 'SSL-Zertifikat automatisch erneuert',
],
'status' => [
'active' => 'Aktiv',
@ -38,23 +106,8 @@ return [
'failed' => 'Fehlgeschlagen',
],
'steps' => [
'validate' => 'Bestellung geprüft',
'provision' => 'Instanz erstellt',
'configure' => 'Cloud konfiguriert',
'acceptance' => 'Abnahme bestanden',
],
'step_state' => [
'pending' => 'Ausstehend',
'running' => 'Läuft',
'done' => 'Abgeschlossen',
'failed' => 'Fehlgeschlagen',
],
'activity_items' => [
'backup_ok' => 'Backup erfolgreich abgeschlossen',
'user_added' => 'Neuer Nutzer hinzugefügt',
'login' => 'Anmeldung am Portal',
],
'instance' => 'Instanz',
'open_cloud' => 'Cloud öffnen',
'provisioning' => 'Bereitstellung',
'today_time' => 'Heute :time',
];

View File

@ -3,6 +3,9 @@
return [
'title' => 'Overview',
'subtitle' => 'Your cloud status at a glance.',
'greeting' => 'Good morning, :name',
'system_ok' => 'All systems normal',
'as_of' => 'Your cloud status at a glance — as of today, :time.',
'nav' => [
'overview' => 'Overview',
@ -17,19 +20,84 @@ return [
'close_nav' => 'Close navigation',
'logout' => 'Sign out',
'today' => 'Today',
'yesterday' => 'Yesterday',
'kpi' => [
'storage' => 'Storage',
'users' => 'Active users',
'users' => 'Users',
'backup' => 'Last backup',
'uptime' => 'Uptime',
'availability' => 'Uptime · 30 days',
],
'occupied' => 'used',
'storage_week' => '+3.4 GB this week',
'users_detail' => '3 groups · 2 guest accounts',
'backup_verified' => 'today · encrypted & verified',
'upsell' => [
'title' => 'Your storage is :percent% used.',
'body' => 'Expandable any time — +100 GB for €10 per month, cancel monthly.',
'cta' => 'Add storage',
],
'cloud' => [
'title' => 'My cloud',
'open' => 'Open cloud',
'package' => 'Plan',
'status' => 'Status',
'location' => 'Server location',
'version' => 'Version',
'active_since' => 'Active since :date',
'current' => 'up to date',
'plan_line' => ':plan · €:price/mo',
'datacenter' => 'EU · Falkenstein data center',
],
'onboarding' => [
'title' => 'Setting up your cloud',
'progress' => ':done of :total done',
'review' => 'Review now',
'next' => 'Next',
'done_toast' => 'Done — nice progress!',
'instance' => 'Instance created & started',
'domain' => 'Custom domain connected',
'branding' => 'Firm logo & colors applied',
'users' => '8 users created, permissions set',
'migration' => 'Approve data import from Dropbox',
'training' => 'Choose a training appointment',
'docs' => 'Download the documentation pack',
],
'backups' => [
'title' => 'Backups',
'schedule' => 'daily 03:00',
'restore' => 'Restore',
'note' => 'Client-side encrypted, stored separately. Last restore test: 01 Jul — successful.',
'restore_toast' => 'Restore: we will get back to you within 1 hr.',
],
'modules' => [
'title' => 'Your modules',
'addon' => 'add-on',
'active' => 'Active',
'add' => 'Add',
'soon' => 'Coming soon',
'nextcloud' => 'Files, office, calendar, Talk',
'vaultwarden' => 'Team password manager · add-on €19/month',
'paperless' => 'Digital document archive · from €39/month',
'signature_name' => 'Digital signature',
'signature' => 'Legally binding signatures · from €29/month',
'add_toast' => 'Add-on available — we are happy to advise you.',
],
'instance' => 'Instance',
'open_cloud' => 'Open cloud',
'provisioning' => 'Provisioning',
'activity' => 'Activity',
'today_time' => 'Today :time',
'yesterday' => 'Yesterday',
'activity_live' => 'live',
'activity_items' => [
'backup_ok' => 'Backup completed — 18.4 GB',
'security' => 'Security updates applied',
'upload' => 'M. Huber uploaded 14 files',
'cert' => 'SSL certificate auto-renewed',
],
'status' => [
'active' => 'Active',
@ -38,23 +106,8 @@ return [
'failed' => 'Failed',
],
'steps' => [
'validate' => 'Order validated',
'provision' => 'Instance created',
'configure' => 'Cloud configured',
'acceptance' => 'Acceptance passed',
],
'step_state' => [
'pending' => 'Pending',
'running' => 'Running',
'done' => 'Done',
'failed' => 'Failed',
],
'activity_items' => [
'backup_ok' => 'Backup completed successfully',
'user_added' => 'New user added',
'login' => 'Portal sign-in',
],
'instance' => 'Instance',
'open_cloud' => 'Open cloud',
'provisioning' => 'Provisioning',
'today_time' => 'Today :time',
];

19
package-lock.json generated
View File

@ -7,6 +7,7 @@
"dependencies": {
"@fontsource/ibm-plex-mono": "^5.3.0",
"@fontsource/ibm-plex-sans": "^5.3.0",
"chart.js": "^4.5.1",
"laravel-echo": "^2.4.0",
"pusher-js": "^8.6.0"
},
@ -123,6 +124,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@ -703,6 +710,18 @@
"node": ">=8"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",

View File

@ -17,6 +17,7 @@
"dependencies": {
"@fontsource/ibm-plex-mono": "^5.3.0",
"@fontsource/ibm-plex-sans": "^5.3.0",
"chart.js": "^4.5.1",
"laravel-echo": "^2.4.0",
"pusher-js": "^8.6.0"
}

View File

@ -33,6 +33,7 @@
/* Status semantics — only in badges/alerts, never a second decorative tone */
--success: #067a48; --success-bg: #ecfdf3; --success-border: #abefc6;
--success-bright: #30b15c; /* dots/rings/charts only (large marks, not small text) */
--warning: #b54708; --warning-bg: #fffaeb; --warning-border: #fedf89;
--danger: #b42318; --danger-bg: #fef3f2; --danger-border: #fecdca;
--info: #175cd3; --info-bg: #eff8ff; --info-border: #b2ddff;
@ -49,7 +50,7 @@
--tracking-label: 0.06em;
/* Radius / shadow / motion / focus */
--radius-sm: 6px; --radius: 8px; --radius-lg: 12px; --radius-pill: 999px;
--radius-sm: 6px; --radius: 8px; --radius-lg: 12px; --radius-xl: 20px; --radius-pill: 999px;
--shadow-xs: 0 1px 2px rgba(16, 24, 40, .05);
--shadow-sm: 0 1px 3px rgba(16, 24, 40, .10), 0 1px 2px rgba(16, 24, 40, .06);

View File

@ -1,49 +1,95 @@
import './echo';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
const prefersReducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Resolve "token:name" / "token:name/0.12" strings in a chart config to real
// colours pulled from the CSS design tokens (keeps charts on-palette, R3).
function tokenColor(spec, css) {
const [name, alpha] = spec.split('/');
const value = css.getPropertyValue('--' + name.trim()).trim();
if (!value) return spec;
if (alpha && value.startsWith('#')) {
let hex = value.slice(1);
if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
return value;
}
function resolveTokens(node, css) {
if (typeof node === 'string') return node.startsWith('token:') ? tokenColor(node.slice(6), css) : node;
if (Array.isArray(node)) return node.map((n) => resolveTokens(n, css));
if (node && typeof node === 'object') {
const out = {};
for (const key of Object.keys(node)) out[key] = resolveTokens(node[key], css);
return out;
}
return node;
}
// Segmented OTP input behaviour (used by <x-ui.otp-input>). Alpine ships with
// Livewire; register on alpine:init.
document.addEventListener('alpine:init', () => {
// ── Segmented OTP input ──────────────────────────────────────────────
window.Alpine.data('otpInput', ({ length = 6 } = {}) => ({
length,
digits: Array(length).fill(''),
get value() {
return this.digits.join('');
},
cells() {
return this.$root.querySelectorAll('input[inputmode="numeric"]');
},
focusCell(i) {
const cell = this.cells()[i];
if (cell) cell.focus();
},
onInput(i, e) {
const digit = e.target.value.replace(/\D/g, '').slice(-1);
this.digits[i] = digit;
if (digit && i < this.length - 1) this.focusCell(i + 1);
this.maybeSubmit();
},
onBackspace(i) {
if (!this.digits[i] && i > 0) this.focusCell(i - 1);
},
onPaste(e) {
const text = (e.clipboardData.getData('text') || '').replace(/\D/g, '').slice(0, this.length);
for (let i = 0; i < this.length; i++) this.digits[i] = text[i] || '';
this.focusCell(Math.min(text.length, this.length - 1));
this.maybeSubmit();
},
maybeSubmit() {
if (this.value.length === this.length) {
// Wait for Alpine to flush :value into the hidden input, otherwise
// requestSubmit() can post a stale/short code.
this.$nextTick(() => this.$root.closest('form')?.requestSubmit());
}
},
}));
// ── Chart.js island ──────────────────────────────────────────────────
window.Alpine.data('chart', (config) => ({
instance: null,
init() {
const css = getComputedStyle(document.documentElement);
Chart.defaults.font.family = css.getPropertyValue('--font-sans').trim() || 'sans-serif';
Chart.defaults.color = css.getPropertyValue('--text-muted').trim() || '#667085';
Chart.defaults.borderColor = css.getPropertyValue('--border').trim() || '#e4e7ec';
const cfg = resolveTokens(config, css);
cfg.options = {
responsive: true,
maintainAspectRatio: false,
animation: prefersReducedMotion() ? false : cfg.options?.animation,
...cfg.options,
};
this.instance = new Chart(this.$refs.canvas, cfg);
},
destroy() {
this.instance?.destroy();
},
}));
});

View File

@ -0,0 +1,13 @@
@props([
// Chart.js config as a PHP array. Colours may use "token:accent" /
// "token:accent/0.12" strings, resolved from CSS design tokens at runtime.
'config',
'label' => null,
])
<div
x-data="chart(@js($config))"
@if ($label) role="img" aria-label="{{ $label }}" @endif
{{ $attributes->merge(['class' => 'relative']) }}
>
<canvas x-ref="canvas"></canvas>
</div>

View File

@ -13,6 +13,12 @@
'chevron-down' => '<path d="m6 9 6 6 6-6"/>',
'external-link'=> '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
'shield-check' => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>',
'shield' => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
'pen' => '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
'download' => '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
'alert-triangle' => '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/>',
'check' => '<polyline points="20 6 9 17 4 12"/>',
'plus' => '<path d="M5 12h14"/><path d="M12 5v14"/>',
];
$body = $icons[$name] ?? '';
@endphp

View File

@ -85,6 +85,20 @@
</div>
</div>
{{-- Global toast any element can trigger it with $dispatch('notify', { message }) --}}
<div
x-data="{ show: false, msg: '', timer: null }"
@notify.window="msg = $event.detail.message; show = true; clearTimeout(timer); timer = setTimeout(() => show = false, 2400)"
x-show="show"
x-cloak
x-transition.opacity.duration.200ms
role="status"
aria-live="polite"
class="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-pill bg-ink px-5 py-3 text-sm font-medium text-on-accent shadow-md"
>
<span x-text="msg"></span>
</div>
@livewireScripts
</body>
</html>

View File

@ -1,61 +1,212 @@
<div class="space-y-6">
<div>
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('dashboard.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('dashboard.subtitle') }}</p>
<div class="space-y-4" x-data="{ msgs: @js(['addon' => __('dashboard.modules.add_toast'), 'restore' => __('dashboard.backups.restore_toast')]) }">
{{-- Header --}}
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('dashboard.greeting', ['name' => auth()->user()->name]) }}</h1>
<span class="ml-auto inline-flex items-center gap-2 rounded-pill bg-success-bg px-3.5 py-1.5 text-xs font-semibold text-success">
<span class="size-2 rounded-pill bg-success-bright" aria-hidden="true"></span>{{ __('dashboard.system_ok') }}
</span>
<p class="w-full text-sm text-muted">{{ __('dashboard.as_of', ['time' => '08:42']) }}</p>
</div>
{{-- KPI row: 1 2 4 (R7) --}}
{{-- KPI row --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<x-ui.stat-tile
:label="__('dashboard.kpi.storage')"
:value="$storageUsed"
:unit="'/ '.$storageQuota.' GB'"
:percent="$storagePercent"
:tone="$storageTone"
/>
<x-ui.stat-tile :label="__('dashboard.kpi.users')" :value="$activeUsers" />
<x-ui.stat-tile :label="__('dashboard.kpi.backup')" :value="$lastBackup" />
<x-ui.stat-tile :label="__('dashboard.kpi.uptime')" :value="$uptime" unit="%" />
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
{{-- Instance card --}}
<x-ui.card class="lg:col-span-2">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.instance') }}</p>
<p class="mt-1 font-mono text-sm text-ink">{{ $instanceUrl }}</p>
<div class="mt-2">
<x-ui.badge :status="$instanceStatus">{{ __('dashboard.status.'.$instanceStatus) }}</x-ui.badge>
{{-- Storage ring --}}
<div class="flex items-center gap-4 rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
<div class="relative size-24 shrink-0" wire:ignore>
<x-ui.chart :config="$storageChart" class="size-24" :label="__('dashboard.kpi.storage')" />
<div class="pointer-events-none absolute inset-0 grid place-items-center text-center">
<div>
<span class="font-mono text-lg font-semibold text-ink">{{ $storagePercent }}%</span>
<span class="block text-[0.62rem] text-muted">{{ __('dashboard.occupied') }}</span>
</div>
</div>
<a
href="{{ $instanceUrl }}"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center justify-center gap-2 rounded bg-accent-active px-4 py-2.5 text-sm font-semibold text-on-accent transition hover:bg-accent-press"
>
<x-ui.icon name="external-link" class="size-4" />
{{ __('dashboard.open_cloud') }}
</a>
</div>
</x-ui.card>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.kpi.storage') }}</p>
<p class="mt-1 font-mono text-xl font-semibold text-ink">{{ $storageUsed }}<span class="ml-1 text-sm font-normal text-muted">/ {{ $storageQuota }} GB</span></p>
<p class="mt-0.5 text-xs text-muted">{{ __('dashboard.storage_week') }}</p>
</div>
</div>
{{-- Provisioning progress (live via Reverb once the engine lands) --}}
<x-ui.card :title="__('dashboard.provisioning')">
<x-ui.progress-stepper :steps="$steps" />
</x-ui.card>
{{-- Users --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.kpi.users') }}</p>
<p class="mt-2 font-mono text-2xl font-semibold text-ink">{{ $usersActive }}<span class="ml-1 text-sm font-normal text-muted">/ {{ $usersQuota }}</span></p>
<p class="mt-1 text-xs text-muted">{{ __('dashboard.users_detail') }}</p>
</div>
{{-- Last backup --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:180ms]">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.kpi.backup') }}</p>
<p class="mt-2 flex items-center gap-1.5 font-mono text-2xl font-semibold text-success">
{{ $lastBackup }}<x-ui.icon name="check" class="size-5" />
</p>
<p class="mt-1 text-xs text-muted">{{ __('dashboard.backup_verified') }}</p>
</div>
{{-- Availability sparkline --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:240ms]">
<p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.kpi.availability') }}</p>
<p class="mt-2 font-mono text-2xl font-semibold text-ink">{{ $availability }}<span class="ml-1 text-sm font-normal text-muted">%</span></p>
<div class="mt-2 h-8" wire:ignore>
<x-ui.chart :config="$availabilityChart" class="h-8" :label="__('dashboard.kpi.availability')" />
</div>
</div>
</div>
{{-- Activity --}}
<x-ui.card :title="__('dashboard.activity')">
<ul class="divide-y divide-line">
@foreach ($activity as $item)
<li class="flex items-center justify-between py-2.5 text-sm">
<span class="text-body">{{ $item['label'] }}</span>
<span class="font-mono text-xs text-faint">{{ $item['time'] }}</span>
</li>
@endforeach
</ul>
</x-ui.card>
{{-- Upsell --}}
<div class="flex flex-wrap items-center gap-4 rounded-xl border border-accent-border bg-accent-subtle px-5 py-4 animate-rise [animation-delay:300ms]">
<x-ui.icon name="alert-triangle" class="size-6 shrink-0 text-accent-active" />
<div class="min-w-0">
<p class="text-sm font-semibold text-ink">{{ __('dashboard.upsell.title', ['percent' => $storagePercent]) }}</p>
<p class="text-sm text-muted">{{ __('dashboard.upsell.body') }}</p>
</div>
<x-ui.button variant="secondary" size="sm" class="ml-auto" @click="$dispatch('notify', { message: msgs.addon })">
{{ __('dashboard.upsell.cta') }}
</x-ui.button>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-[1.35fr_1fr] lg:items-start">
{{-- Left column --}}
<div class="flex flex-col gap-4">
{{-- Cloud card --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:360ms]">
<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">{{ $cloud['name'] }}</p>
<a href="https://{{ $cloud['domain'] }}" target="_blank" rel="noopener noreferrer" class="font-mono text-sm text-accent-text hover:underline">{{ $cloud['domain'] }}</a>
</div>
<a href="https://{{ $cloud['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" />{{ __('dashboard.cloud.open') }}
</a>
</div>
<div class="mt-4 grid grid-cols-1 gap-3 border-t border-line pt-4 sm:grid-cols-2">
<div><p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.cloud.package') }}</p><p class="mt-1 text-sm text-body">{{ $cloud['package'] }}</p></div>
<div><p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.cloud.status') }}</p><p class="mt-1 text-sm text-success">{{ __('dashboard.cloud.active_since', ['date' => $cloud['since']]) }}</p></div>
<div><p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.cloud.location') }}</p><p class="mt-1 text-sm text-body">{{ $cloud['location'] }}</p></div>
<div><p class="text-xs font-semibold uppercase tracking-wide text-faint">{{ __('dashboard.cloud.version') }}</p><p class="mt-1 font-mono text-sm text-body">{{ $cloud['version'] }} · {{ __('dashboard.cloud.current') }}</p></div>
</div>
</div>
{{-- Onboarding checklist (Alpine) --}}
<div
class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:420ms]"
x-data="{
tasks: @js(collect($onboarding)->map(fn ($t) => ['label' => $t['label'], 'done' => $t['done'], 'date' => $t['date']])),
total: {{ count($onboarding) }},
get doneCount() { return this.tasks.filter(t => t.done).length; },
get percent() { return Math.round(this.doneCount / this.total * 100); },
complete(i) {
if (this.tasks[i].done) return;
this.tasks[i].done = true;
$dispatch('notify', { message: @js(__('dashboard.onboarding.done_toast')) });
},
nextIndex() { return this.tasks.findIndex(t => !t.done); },
}"
>
<div class="flex items-baseline gap-3">
<h2 class="font-semibold text-ink">{{ __('dashboard.onboarding.title') }}</h2>
<span class="ml-auto font-mono text-sm font-semibold text-muted" x-text="doneCount + ' / ' + total"></span>
</div>
<div class="mt-3 h-1.5 overflow-hidden rounded-pill bg-surface-2">
<div class="h-full rounded-pill bg-accent transition-all duration-700" :style="`width: ${percent}%`"></div>
</div>
<ul class="mt-2">
<template x-for="(task, i) in tasks" :key="i">
<li>
<button
type="button"
class="flex w-full items-center gap-3 rounded-lg px-2.5 py-3 text-left text-sm transition hover:bg-surface-hover"
:class="!task.done && i === nextIndex() ? 'bg-accent-subtle' : ''"
:disabled="task.done"
@click="complete(i)"
>
<span
class="grid size-6 shrink-0 place-items-center rounded-pill border"
:class="task.done ? 'border-success-bright bg-success-bright text-on-accent' : (i === nextIndex() ? 'border-accent' : 'border-line-strong')"
>
<svg x-show="task.done" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" class="size-3.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
</span>
<span class="min-w-0 flex-1" :class="task.done ? 'text-muted line-through' : 'text-body'" x-text="task.label"></span>
<span x-show="task.date" class="text-xs text-faint" x-text="task.date"></span>
<span x-show="!task.done && i === nextIndex()" class="text-xs font-semibold text-accent-text whitespace-nowrap">{{ __('dashboard.onboarding.review') }} </span>
</button>
</li>
</template>
</ul>
</div>
</div>
{{-- Right column --}}
<div class="flex flex-col gap-4">
{{-- Backups --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:360ms]">
<div class="flex items-center">
<h2 class="font-semibold text-ink">{{ __('dashboard.backups.title') }}</h2>
<span class="ml-auto rounded-pill bg-success-bg px-2.5 py-1 text-xs font-semibold text-success">{{ __('dashboard.backups.schedule') }}</span>
</div>
<ul class="mt-2 divide-y divide-line">
@foreach ($backups as $b)
<li class="flex items-center gap-3 py-2.5 text-sm">
<x-ui.icon name="check" class="size-4 text-success" />
<span class="font-mono text-xs text-muted">{{ $b['when'] }}</span>
<span class="ml-auto font-mono text-xs text-muted">{{ $b['size'] }}</span>
<x-ui.button variant="secondary" size="sm" @click="$dispatch('notify', { message: msgs.restore })">{{ __('dashboard.backups.restore') }}</x-ui.button>
</li>
@endforeach
</ul>
<p class="mt-3 flex gap-2 text-xs leading-relaxed text-muted">
<x-ui.icon name="shield" class="mt-px size-4 shrink-0 text-accent-active" />
<span>{{ __('dashboard.backups.note') }}</span>
</p>
</div>
{{-- Modules --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:420ms]">
<div class="flex items-center">
<h2 class="font-semibold text-ink">{{ __('dashboard.modules.title') }}</h2>
<span class="ml-auto rounded-pill bg-surface-2 px-2.5 py-1 text-xs font-semibold text-muted">{{ __('dashboard.modules.addon') }}</span>
</div>
<ul class="mt-2 divide-y divide-line">
@foreach ($modules as $m)
<li class="flex items-center gap-3 py-2.5 text-sm">
<span class="grid size-9 shrink-0 place-items-center rounded-lg bg-surface-2 text-muted"><x-ui.icon :name="$m['icon']" class="size-5" /></span>
<div class="min-w-0">
<p class="font-medium text-ink">{{ $m['name'] }}</p>
<p class="text-xs text-muted">{{ $m['desc'] }}</p>
</div>
@if ($m['status'] === 'active')
<span class="ml-auto rounded-pill bg-success-bg px-2.5 py-1 text-xs font-semibold text-success">{{ __('dashboard.modules.active') }}</span>
@elseif ($m['status'] === 'available')
<x-ui.button variant="secondary" size="sm" class="ml-auto" @click="$dispatch('notify', { message: msgs.addon })">{{ __('dashboard.modules.add') }}</x-ui.button>
@else
<span class="ml-auto rounded-pill bg-surface-2 px-2.5 py-1 text-xs font-semibold text-muted">{{ __('dashboard.modules.soon') }}</span>
@endif
</li>
@endforeach
</ul>
</div>
{{-- Activity feed --}}
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:480ms]">
<div class="flex items-center">
<h2 class="font-semibold text-ink">{{ __('dashboard.activity') }}</h2>
<span class="ml-auto rounded-pill bg-surface-2 px-2.5 py-1 text-xs font-semibold text-muted">{{ __('dashboard.activity_live') }}</span>
</div>
<ul class="mt-2 divide-y divide-line">
@foreach ($activity as $a)
<li class="flex items-start gap-3 py-2.5 text-sm">
<span class="grid size-7 shrink-0 place-items-center rounded-lg bg-surface-2 text-accent-active"><x-ui.icon :name="$a['icon']" class="size-4" /></span>
<div class="min-w-0 flex-1">
<p class="text-body">{{ $a['text'] }}</p>
<p class="font-mono text-xs text-faint">{{ $a['time'] }}</p>
</div>
</li>
@endforeach
</ul>
</div>
</div>
</div>
</div>

View File

@ -34,7 +34,7 @@ export default {
text: 'var(--accent-text)',
},
'on-accent': 'var(--on-accent)',
success: { DEFAULT: 'var(--success)', bg: 'var(--success-bg)', border: 'var(--success-border)' },
success: { DEFAULT: 'var(--success)', bg: 'var(--success-bg)', border: 'var(--success-border)', bright: 'var(--success-bright)' },
warning: { DEFAULT: 'var(--warning)', bg: 'var(--warning-bg)', border: 'var(--warning-border)' },
danger: { DEFAULT: 'var(--danger)', bg: 'var(--danger-bg)', border: 'var(--danger-border)' },
info: { DEFAULT: 'var(--info)', bg: 'var(--info-bg)', border: 'var(--info-border)' },
@ -48,12 +48,21 @@ export default {
lg: 'var(--text-lg)', xl: 'var(--text-xl)', '2xl': 'var(--text-2xl)', '3xl': 'var(--text-3xl)',
},
borderRadius: {
sm: 'var(--radius-sm)', DEFAULT: 'var(--radius)', lg: 'var(--radius-lg)', pill: 'var(--radius-pill)',
sm: 'var(--radius-sm)', DEFAULT: 'var(--radius)', lg: 'var(--radius-lg)', xl: 'var(--radius-xl)', pill: 'var(--radius-pill)',
},
boxShadow: {
xs: 'var(--shadow-xs)', sm: 'var(--shadow-sm)', md: 'var(--shadow-md)',
},
ringColor: { accent: 'var(--accent-ring)' },
keyframes: {
rise: {
'0%': { opacity: '0', transform: 'translateY(16px)' },
'100%': { opacity: '1', transform: 'none' },
},
},
animation: {
rise: 'rise 0.6s cubic-bezier(.2,.7,.2,1) both',
},
},
},
plugins: [],