291 lines
18 KiB
PHP
291 lines
18 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
|
|
{{-- ── What the customer has ─────────────────────────────────────
|
|
The template's four cards, in its order and its form: label with a
|
|
chevron, the figure and its unit on one line, a line of context
|
|
underneath, the visual on the right or a bar across the foot.
|
|
|
|
Three of the template's visuals have no source yet — storage
|
|
consumption is not sampled, instance_traffic keeps one row per
|
|
period rather than a daily series, and monitoring_targets records
|
|
a state but no uptime figure. Those cards carry the form and state
|
|
what is true instead of drawing a chart at an invented level. This
|
|
is the sheet a customer forwards to their auditor. --}}
|
|
<section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
|
@if ($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
|
|
</x-ui.metric>
|
|
|
|
@if ($traffic)
|
|
@php $state = $traffic->state(); @endphp
|
|
<x-ui.metric
|
|
:label="__('dashboard.traffic.title')"
|
|
:value="Bytes::human($traffic->usedBytes)"
|
|
:unit="__('dashboard.traffic.of', ['total' => Bytes::human($traffic->quotaBytes)])"
|
|
:foot="__('dashboard.traffic.resets', ['days' => $traffic->daysUntilReset()])"
|
|
:href="route('billing')"
|
|
class="animate-rise [animation-delay:140ms]"
|
|
>
|
|
<div class="mt-4 h-1.5 overflow-hidden rounded-pill bg-surface-hover">
|
|
<div @class([
|
|
'h-full rounded-pill',
|
|
'bg-accent' => $state === 'ok',
|
|
'bg-warning' => $state === 'warn' || $state === 'critical',
|
|
'bg-danger' => $state === 'exhausted',
|
|
])
|
|
style="width: {{ min(100, $traffic->percent()) }}%"></div>
|
|
</div>
|
|
@if ($state !== 'ok')
|
|
{{-- The offer belongs next to the warning: someone
|
|
reading that they are nearly out should not have to
|
|
go looking for the way to fix it. --}}
|
|
<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
|
|
|
|
{{-- Where the template shows availability. There is no uptime
|
|
figure to show, so this card carries the thing this product is
|
|
actually about and can prove: when the last backup ran and
|
|
whether it was verified. --}}
|
|
@php $lastBackup = collect($proofs)->firstWhere('key', 'backup'); @endphp
|
|
@if ($lastBackup)
|
|
<x-ui.metric
|
|
:label="__('dashboard.master.last_backup')"
|
|
:value="$lastBackup['at']?->locale($locale)->isoFormat('HH:mm') ?? '—'"
|
|
:unit="$lastBackup['at']?->locale($locale)->isoFormat('DD.MM.')"
|
|
:foot="__('dashboard.proofs.state.'.$lastBackup['state']).' · '.($lastBackup['note'] ?? '')"
|
|
:href="route('backups')"
|
|
class="animate-rise [animation-delay:180ms]"
|
|
/>
|
|
@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>
|