302 lines
19 KiB
PHP
302 lines
19 KiB
PHP
@php
|
||
use App\Support\Bytes;
|
||
use Illuminate\Support\Number;
|
||
|
||
$locale = app()->getLocale();
|
||
$money = fn (int $cents, string $currency) => Number::currency($cents / 100, in: $currency, locale: $locale);
|
||
@endphp
|
||
<div class="space-y-5">
|
||
{{-- Page head. The same pill the public site uses to label a section, so a
|
||
customer meets one vocabulary on both sides of the login. --}}
|
||
<header class="flex flex-wrap items-end gap-4 animate-rise">
|
||
<div class="min-w-0 flex-1">
|
||
<p class="font-mono text-[11px] uppercase tracking-[0.07em] text-muted">
|
||
{{ __('dashboard.sheet', ['when' => $asOf->locale($locale)->isoFormat('LL, LT')]) }}
|
||
</p>
|
||
<h1 class="mt-2.5 text-2xl font-bold tracking-tight text-ink sm:text-3xl">
|
||
{{ $instance !== null ? __('dashboard.title_running') : __('dashboard.title_pending') }}
|
||
</h1>
|
||
</div>
|
||
|
||
@if ($instance !== null)
|
||
<div class="flex flex-wrap items-center gap-2.5">
|
||
<span @class([
|
||
'inline-flex items-center gap-2 rounded-pill border px-3.5 py-1.5 text-xs font-semibold',
|
||
'border-success-border bg-success-bg text-success' => $instance->status === 'active',
|
||
'border-warning-border bg-warning-bg text-warning' => $instance->status !== 'active',
|
||
])>
|
||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||
{{ __('dashboard.instance_status.'.$instance->status) }}
|
||
</span>
|
||
@if ($domain)
|
||
<x-ui.button variant="ink" :href="'https://'.$domain" target="_blank" rel="noopener">
|
||
<x-ui.icon name="external-link" class="size-4" />{{ __('dashboard.cloud.open') }}
|
||
</x-ui.button>
|
||
@endif
|
||
</div>
|
||
@endif
|
||
</header>
|
||
|
||
{{-- Live provisioning progress (only while a run is in flight) --}}
|
||
<livewire:customer-provisioning />
|
||
|
||
@if ($instance === null)
|
||
{{-- Said plainly rather than papered over with an empty record: a page
|
||
of dashes reads as broken, and "we are still setting it up" and
|
||
"you have not ordered yet" are different things. --}}
|
||
<section class="rounded-lg border border-line bg-surface p-8 shadow-xs animate-rise">
|
||
<h2 class="text-lg font-semibold text-ink">{{ __('dashboard.no_instance_label') }}</h2>
|
||
<p class="mt-2 max-w-prose text-md text-muted">{{ __('dashboard.no_instance_body') }}</p>
|
||
<div class="mt-6 flex flex-wrap gap-2.5">
|
||
<x-ui.button variant="ink" :href="route('billing')" wire:navigate>{{ __('dashboard.no_instance_cta') }}</x-ui.button>
|
||
<x-ui.button variant="secondary" :href="route('support')" wire:navigate>{{ __('dashboard.nav.support') }}</x-ui.button>
|
||
</div>
|
||
</section>
|
||
@else
|
||
{{-- ── The four cards, in the template's order and form ──────────
|
||
Speicher · Benutzer · Datenvolumen · Verfügbarkeit. Figure, unit
|
||
beside it, a line of context, the visual — ring, bar, sparkline.
|
||
Every number below is measured; nothing is drawn at a level nobody
|
||
read. --}}
|
||
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||
@if ($disk)
|
||
<x-ui.metric
|
||
:label="__('dashboard.master.storage')"
|
||
:value="Number::format($disk['used'] / 1024 ** 3, precision: 0, locale: $locale)"
|
||
:unit="'/ '.Number::format($disk['total'] / 1024 ** 3, precision: 0, locale: $locale).' GB'"
|
||
:foot="trim(__('dashboard.master.storage_used', ['percent' => Number::format($disk['percent'], precision: 0, locale: $locale)])
|
||
.($disk['week_delta'] !== null ? ' · '.__('dashboard.master.storage_week', ['amount' => ($disk['week_delta'] >= 0 ? '+' : '−').Bytes::human(abs($disk['week_delta']))]) : ''))"
|
||
:href="route('cloud')"
|
||
class="animate-rise [animation-delay:60ms]"
|
||
>
|
||
<x-slot:visual><x-ui.ring :percent="$disk['percent']" :size="58" /></x-slot:visual>
|
||
</x-ui.metric>
|
||
@elseif ($instance->quota_gb)
|
||
<x-ui.metric
|
||
:label="__('dashboard.master.storage')"
|
||
:value="$instance->quota_gb"
|
||
unit="GB"
|
||
:foot="__('dashboard.master.storage_note')"
|
||
:href="route('cloud')"
|
||
class="animate-rise [animation-delay:60ms]"
|
||
/>
|
||
@endif
|
||
|
||
<x-ui.metric
|
||
:label="__('dashboard.master.users')"
|
||
:value="$seats['used']"
|
||
:unit="$seats['total'] !== null ? __('dashboard.master.of_seats', ['total' => $seats['total']]) : null"
|
||
:href="route('users')"
|
||
class="animate-rise [animation-delay:100ms]"
|
||
>
|
||
@if ($seats['total'])
|
||
<div class="mt-4 h-1.5 overflow-hidden rounded-pill bg-surface-hover">
|
||
{{-- R4: width is data, not decoration. --}}
|
||
<div class="h-full rounded-pill bg-muted"
|
||
style="width: {{ min(100, (int) round($seats['used'] / max(1, $seats['total']) * 100)) }}%"></div>
|
||
</div>
|
||
@endif
|
||
@if ($seatBreakdown !== [])
|
||
<p class="mt-2.5 text-xs text-muted">
|
||
{{ collect($seatBreakdown)
|
||
->map(fn (int $n, string $role) => trans_choice('users.role_count.'.$role, $n, ['count' => $n]))
|
||
->join(' · ') }}
|
||
</p>
|
||
@endif
|
||
</x-ui.metric>
|
||
|
||
@if ($traffic)
|
||
@php $state = $traffic->state(); @endphp
|
||
<x-ui.metric
|
||
:label="__('dashboard.traffic.label')"
|
||
:value="Number::format($traffic->usedBytes / 1024 ** 3, precision: 0, locale: $locale)"
|
||
:unit="'GB / '.Bytes::human($traffic->quotaBytes)"
|
||
:foot="__('dashboard.traffic.resets', ['days' => $traffic->daysUntilReset()])"
|
||
:href="route('billing')"
|
||
class="animate-rise [animation-delay:140ms]"
|
||
>
|
||
@if (count($trend) > 2)
|
||
{{-- Orange because this is the metric with an action
|
||
attached: when it runs out, something can be done
|
||
about it. Observed figures stay grey. --}}
|
||
<x-slot:visual><x-ui.spark :points="$trend" area /></x-slot:visual>
|
||
@endif
|
||
|
||
@if ($state !== 'ok')
|
||
<a href="{{ route('billing') }}" wire:navigate class="mt-3 inline-flex items-center gap-1.5 text-xs font-semibold text-accent-text hover:underline">
|
||
<x-ui.icon name="plus" class="size-3.5" />{{ __('dashboard.traffic.buy') }}
|
||
</a>
|
||
@endif
|
||
</x-ui.metric>
|
||
@endif
|
||
|
||
{{-- Availability, counted from our own monitoring answers. Absent
|
||
rather than 100 % when nothing has been checked: reporting an
|
||
unmonitored instance as perfect is the single most dishonest
|
||
number this page could carry. --}}
|
||
@if ($availability !== null)
|
||
<x-ui.metric
|
||
:label="__('dashboard.master.availability')"
|
||
:value="Number::format($availability, precision: 2, locale: $locale)"
|
||
unit="%"
|
||
:foot="__('dashboard.master.availability_note')"
|
||
:href="route('cloud')"
|
||
class="animate-rise [animation-delay:180ms]"
|
||
>
|
||
@if (count($availabilityTrend) > 2)
|
||
<x-slot:visual><x-ui.spark :points="$availabilityTrend" tone="muted" /></x-slot:visual>
|
||
@endif
|
||
</x-ui.metric>
|
||
@elseif ($location)
|
||
<x-ui.metric
|
||
:label="__('dashboard.master.location')"
|
||
:value="$location['name']"
|
||
:foot="__('dashboard.master.location_note')"
|
||
class="animate-rise [animation-delay:180ms]"
|
||
/>
|
||
@endif
|
||
</section>
|
||
|
||
<section class="grid gap-4 lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)] lg:items-start">
|
||
{{-- ── The proof register ────────────────────────────────────── --}}
|
||
<article class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:200ms]">
|
||
<div class="flex items-center justify-between gap-4 border-b border-line px-5 py-4">
|
||
<h2 class="text-md font-semibold text-ink">{{ __('dashboard.proofs.label') }}</h2>
|
||
<a href="{{ route('backups') }}" wire:navigate class="text-sm font-semibold text-accent-text hover:underline">{{ __('dashboard.proofs.all') }}</a>
|
||
</div>
|
||
|
||
@if ($proofs === [])
|
||
<p class="px-5 py-8 text-sm text-muted">{{ __('dashboard.proofs.empty') }}</p>
|
||
@else
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-sm">
|
||
<tbody>
|
||
@foreach ($proofs as $proof)
|
||
<tr class="border-b border-line last:border-0">
|
||
<td class="px-5 py-4">
|
||
<span class="font-semibold text-ink">{{ __('dashboard.proofs.item.'.$proof['key']) }}</span>
|
||
<span class="mt-1 block font-mono text-xs text-muted">
|
||
{{ $proof['at']?->locale($locale)->isoFormat('DD.MM. HH:mm') ?? '—' }}@if ($proof['note']) · {{ $proof['note'] }}@endif
|
||
</span>
|
||
</td>
|
||
<td class="w-px whitespace-nowrap px-5 py-4 text-right">
|
||
<span @class([
|
||
'inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-1 text-xs font-semibold',
|
||
'border-success-border bg-success-bg text-success' => $proof['state'] === 'ok',
|
||
'border-info-border bg-info-bg text-info' => $proof['state'] === 'planned',
|
||
'border-danger-border bg-danger-bg text-danger' => $proof['state'] === 'attention',
|
||
])>
|
||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||
{{ __('dashboard.proofs.state.'.$proof['state']) }}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
@endforeach
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
@endif
|
||
</article>
|
||
|
||
<div class="space-y-4">
|
||
{{-- ── The contract ───────────────────────────────────────── --}}
|
||
<article class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:240ms]">
|
||
<h2 class="text-md font-semibold text-ink">{{ __('dashboard.master.label') }}</h2>
|
||
<dl class="mt-4">
|
||
<div class="flex items-baseline justify-between gap-4 border-b border-line py-2.5">
|
||
<dt class="text-sm text-muted">{{ __('dashboard.master.package') }}</dt>
|
||
<dd class="text-right text-sm font-semibold text-ink">
|
||
{{ __('billing.plan.'.($instance->plan ?: 'team')) }}
|
||
@if ($planPrice)
|
||
<span class="mt-0.5 block font-mono text-xs font-normal text-muted">
|
||
{{ __('dashboard.master.per_'.($planPrice['term'] === 'yearly' ? 'year' : 'month'), ['amount' => $money($planPrice['cents'], $planPrice['currency'])]) }}
|
||
</span>
|
||
@endif
|
||
</dd>
|
||
</div>
|
||
@if ($contract?->performance)
|
||
<div class="flex items-baseline justify-between gap-4 border-b border-line py-2.5">
|
||
<dt class="text-sm text-muted">{{ __('dashboard.master.operation') }}</dt>
|
||
<dd class="text-right text-sm font-semibold text-ink">{{ __('billing.perf.'.$contract->performance) }}</dd>
|
||
</div>
|
||
@endif
|
||
@if ($domain)
|
||
<div class="flex items-baseline justify-between gap-4 py-2.5">
|
||
<dt class="text-sm text-muted">{{ __('dashboard.master.address') }}</dt>
|
||
<dd class="min-w-0 break-all text-right font-mono text-xs text-ink">{{ $domain }}</dd>
|
||
</div>
|
||
@endif
|
||
</dl>
|
||
</article>
|
||
|
||
@if ($nextInvoice)
|
||
<article class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:280ms]">
|
||
<h2 class="text-md font-semibold text-ink">{{ __('dashboard.invoice.label') }}</h2>
|
||
<dl class="mt-4">
|
||
@if ($nextInvoice['addons'] > 0)
|
||
{{-- Broken out only when there is something to
|
||
break out: a "Module 0,00 €" line on every
|
||
other customer's sheet is noise. --}}
|
||
<div class="flex items-baseline justify-between gap-4 border-b border-line py-2.5">
|
||
<dt class="text-sm text-muted">{{ __('dashboard.invoice.plan') }}</dt>
|
||
<dd class="font-mono text-sm text-ink">{{ $money($nextInvoice['plan'], $nextInvoice['currency']) }}</dd>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 border-b border-line py-2.5">
|
||
<dt class="text-sm text-muted">{{ __('dashboard.invoice.addons') }}</dt>
|
||
<dd class="font-mono text-sm text-ink">{{ $money($nextInvoice['addons'], $nextInvoice['currency']) }}</dd>
|
||
</div>
|
||
@endif
|
||
<div class="flex items-baseline justify-between gap-4 border-b border-line py-2.5">
|
||
<dt class="text-sm text-muted">{{ __('dashboard.invoice.amount') }}</dt>
|
||
<dd class="text-right font-mono text-md font-semibold text-ink">
|
||
{{ $money($nextInvoice['total'], $nextInvoice['currency']) }}
|
||
<span class="mt-0.5 block text-xs font-normal text-muted">{{ __('dashboard.invoice.net') }}</span>
|
||
</dd>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-2.5">
|
||
<dt class="text-sm text-muted">{{ __('dashboard.invoice.due') }}</dt>
|
||
<dd class="font-mono text-sm text-ink">{{ $nextInvoice['due']?->locale($locale)->isoFormat('LL') ?? '—' }}</dd>
|
||
</div>
|
||
</dl>
|
||
<x-ui.button variant="secondary" :href="route('invoices')" wire:navigate class="mt-4 w-full">
|
||
{{ __('dashboard.invoice.all') }}
|
||
</x-ui.button>
|
||
</article>
|
||
@endif
|
||
|
||
@if ($openTasks > 0)
|
||
{{-- Only while there is something outstanding. A permanently
|
||
green checklist is furniture; one with work left on it
|
||
is a reason to open this page. --}}
|
||
<article class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:320ms]">
|
||
<h2 class="text-md font-semibold text-ink">{{ __('dashboard.setup.label') }}</h2>
|
||
<p class="mt-2 text-sm text-body">{{ trans_choice('dashboard.setup.open', $openTasks, ['count' => $openTasks]) }}</p>
|
||
<a href="{{ route('cloud') }}" wire:navigate class="mt-3 inline-flex items-center gap-1.5 text-sm font-semibold text-accent-text hover:underline">
|
||
{{ __('dashboard.setup.continue') }}
|
||
</a>
|
||
</article>
|
||
@endif
|
||
</div>
|
||
</section>
|
||
|
||
{{-- ── The three things customers come here to do ─────────────────── --}}
|
||
<section class="grid gap-px overflow-hidden rounded-lg border border-line bg-line shadow-xs animate-rise [animation-delay:360ms] sm:grid-cols-3">
|
||
@foreach ([
|
||
['users', 'users', 'add_user'],
|
||
['backups', 'rotate-ccw', 'restore'],
|
||
['billing', 'plus', 'grow'],
|
||
] as [$route, $icon, $key])
|
||
<a href="{{ route($route) }}" wire:navigate class="flex min-h-11 items-center gap-3.5 bg-surface px-5 py-4 transition hover:bg-surface-hover">
|
||
<span class="grid size-9 shrink-0 place-items-center rounded bg-accent-subtle text-accent-text">
|
||
<x-ui.icon :name="$icon" class="size-[1.05rem]" />
|
||
</span>
|
||
<span class="min-w-0">
|
||
<span class="block text-sm font-semibold text-ink">{{ __('dashboard.quick.'.$key.'_title') }}</span>
|
||
<span class="block text-xs text-muted">{{ __('dashboard.quick.'.$key.'_body') }}</span>
|
||
</span>
|
||
</a>
|
||
@endforeach
|
||
</section>
|
||
@endif
|
||
</div>
|