Follow the approved template, and stop showing invented records

The dashboard was built to my own idea of the layout rather than to the
template that was signed off: a coloured pill where the template has a stamped
mono line, bare figures where it has a label with a chevron, a unit on its own
line instead of beside the number, and no ring at all. There is no point
agreeing a design and then building something adjacent to it. The metric card
is now a component that draws the template's form, and the pages use it.

Two more places were still showing data that does not exist:

- Support listed three tickets to every customer, complete with reference
  numbers and dates. There is no ticket model; it was fixture text. A request
  list also belongs in the console, where an operator can see who filed what.
  Removed until there is something real behind it.
- The datacentre name reached the customer through the cloud tab's copy as
  well. Customer surfaces name the jurisdiction; the building is operator
  information.

And the actions column rendered an empty cell for the owner, who can be neither
re-invited nor revoked. An empty cell reads as a missing feature; it now says
so with a dash and a title.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-27 16:17:06 +02:00
parent 21d8dc310a
commit 676c38643c
12 changed files with 186 additions and 69 deletions

View File

@ -2,7 +2,6 @@
namespace App\Livewire; namespace App\Livewire;
use Illuminate\Support\Carbon;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Component; use Livewire\Component;
@ -11,15 +10,7 @@ class Support extends Component
{ {
public function render() public function render()
{ {
$locale = app()->getLocale();
$date = fn (string $iso) => Carbon::parse($iso)->locale($locale)->isoFormat('LL');
return view('livewire.support', [ 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' => [ 'faqs' => [
['q' => __('support.faq_q1'), 'a' => __('support.faq_a1')], ['q' => __('support.faq_q1'), 'a' => __('support.faq_a1')],
['q' => __('support.faq_q2'), 'a' => __('support.faq_a2')], ['q' => __('support.faq_q2'), 'a' => __('support.faq_a2')],

View File

@ -36,7 +36,9 @@ return [
'address' => 'Adresse', 'address' => 'Adresse',
'storage' => 'Speicher', 'storage' => 'Speicher',
'storage_note' => 'vertraglich zugesichert', 'storage_note' => 'vertraglich zugesichert',
'location_note' => 'kein Drittlandtransfer',
'gb' => ':n GB', 'gb' => ':n GB',
'of_seats' => 'von :total Plätzen',
'of_total' => ':used von :total', 'of_total' => ':used von :total',
'per_month' => ':amount netto / Monat', 'per_month' => ':amount netto / Monat',
'per_year' => ':amount netto / Jahr', 'per_year' => ':amount netto / Jahr',

View File

@ -18,6 +18,7 @@ return [
'col_person' => 'Person', 'col_person' => 'Person',
'col_status' => 'Status', 'col_status' => 'Status',
'owner_no_actions' => 'Der Inhaber kann nicht entfernt werden.',
'col_actions' => 'Aktionen', 'col_actions' => 'Aktionen',
'status_active' => 'Aktiv', 'status_active' => 'Aktiv',

View File

@ -36,7 +36,9 @@ return [
'address' => 'Address', 'address' => 'Address',
'storage' => 'Storage', 'storage' => 'Storage',
'storage_note' => 'contractually guaranteed', 'storage_note' => 'contractually guaranteed',
'location_note' => 'no third-country transfer',
'gb' => ':n GB', 'gb' => ':n GB',
'of_seats' => 'of :total seats',
'of_total' => ':used of :total', 'of_total' => ':used of :total',
'per_month' => ':amount net / month', 'per_month' => ':amount net / month',
'per_year' => ':amount net / year', 'per_year' => ':amount net / year',

View File

@ -18,6 +18,7 @@ return [
'col_person' => 'Person', 'col_person' => 'Person',
'col_status' => 'Status', 'col_status' => 'Status',
'owner_no_actions' => 'The owner cannot be removed.',
'col_actions' => 'Actions', 'col_actions' => 'Actions',
'status_active' => 'Active', 'status_active' => 'Active',

View File

@ -81,3 +81,43 @@
transition-duration: 0.001ms !important; transition-duration: 0.001ms !important;
} }
} }
/* Metric visuals
* The ring and the sparkline from the approved template. They live here
* rather than as utilities because both need keyframes and a per-element
* custom property, and Tailwind expresses neither.
*
* Every var() carries a fallback: an undefined custom property does not make
* a browser skip the declaration, it computes to `unset` which for a
* stroke-dashoffset silently draws a full ring at every value.
*/
.ring { transform: rotate(-90deg); }
.ring .track { fill: none; stroke: var(--surface-hover); stroke-width: 7; }
.ring .value {
fill: none;
stroke: var(--accent);
stroke-width: 7;
stroke-linecap: round;
stroke-dasharray: var(--c, 157);
stroke-dashoffset: var(--c, 157);
animation: ring-sweep 1.1s cubic-bezier(.2, .7, .2, 1) .2s forwards;
}
@keyframes ring-sweep { to { stroke-dashoffset: var(--o, 0); } }
.spark path { fill: none; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
.spark .line {
stroke: var(--accent);
stroke-dasharray: 400;
stroke-dashoffset: 400;
animation: spark-draw 1.3s cubic-bezier(.4, .1, .2, 1) .2s forwards;
}
.spark.muted .line { stroke: var(--text-muted); }
.spark .area { fill: var(--accent); stroke: none; opacity: 0; animation: spark-fade .8s ease .9s forwards; }
@keyframes spark-draw { to { stroke-dashoffset: 0; } }
@keyframes spark-fade { to { opacity: .09; } }
@media (prefers-reduced-motion: reduce) {
.ring .value { stroke-dashoffset: var(--o, 0) !important; }
.spark .line { stroke-dashoffset: 0 !important; }
.spark .area { opacity: .09 !important; }
}

View File

@ -0,0 +1,48 @@
@props([
'label',
// The figure, and the unit that follows it in lighter weight — "235" and
// "/ 500 GB" read as one measurement rather than as two numbers.
'value',
'unit' => null,
'foot' => null,
// A chevron to the page that explains the figure. Omitted where there is
// no such page: a chevron that goes nowhere is worse than none.
'href' => null,
])
{{--
One metric card, exactly as the approved template draws it: small caps
label with an optional chevron, the figure in tabular monospace with its
unit beside it, a line of context underneath, and the visual on the right.
The visual is a slot so the card does not have to know whether it is a
ring, a bar or a sparkline and so a metric with nothing to draw simply
passes none instead of getting a decorative one.
--}}
<article {{ $attributes->merge(['class' => 'rounded-lg border border-line bg-surface p-5 shadow-xs']) }}>
<div class="flex items-center justify-between gap-3">
<span class="font-mono text-[11px] uppercase tracking-[0.07em] text-muted">{{ $label }}</span>
@if ($href)
<a href="{{ $href }}" wire:navigate class="text-muted transition hover:text-ink" aria-label="{{ $label }}">
<x-ui.icon name="chevron-down" class="size-3.5 -rotate-90" />
</a>
@endif
</div>
<div class="mt-3 flex items-end justify-between gap-4">
<div class="min-w-0">
<p class="flex items-baseline gap-1.5 font-mono text-2xl font-semibold tracking-tight text-ink">
{{ $value }}
@if ($unit)<span class="text-sm font-normal text-muted">{{ $unit }}</span>@endif
</p>
@if ($foot)
<p class="mt-1.5 text-xs leading-snug text-muted">{{ $foot }}</p>
@endif
</div>
@isset($visual)
<div class="shrink-0">{{ $visual }}</div>
@endisset
</div>
{{ $slot }}
</article>

View File

@ -0,0 +1,14 @@
@props(['percent' => 0, 'size' => 62])
@php
// Circumference of r=25 in the 62-unit box. The offset is what is NOT
// filled, so a full ring is 0 and an empty one is the whole circumference.
$c = 157;
$o = (int) round($c * (1 - min(100, max(0, (float) $percent)) / 100));
@endphp
<div class="relative grid place-items-center">
<svg class="ring" viewBox="0 0 62 62" style="--c:{{ $c }};--o:{{ $o }};width:{{ $size }}px;height:{{ $size }}px" aria-hidden="true">
<circle class="track" cx="31" cy="31" r="25" />
<circle class="value" cx="31" cy="31" r="25" />
</svg>
<b class="absolute font-mono text-xs font-semibold text-ink">{{ (int) round($percent) }}%</b>
</div>

View File

@ -0,0 +1,31 @@
@props([
/** @var array<int, int|float> the series, in the order it happened */
'points' => [],
// muted where the figure is observed, accent where it can be acted on.
// Four accent charts in a row is how an accent stops meaning anything.
'tone' => 'accent',
'area' => false,
])
@php
$values = array_values(array_filter($points, 'is_numeric'));
$min = $values ? min($values) : 0;
$max = $values ? max($values) : 0;
$span = max(0.0001, $max - $min);
$step = count($values) > 1 ? 96 / (count($values) - 1) : 96;
$path = '';
foreach ($values as $i => $v) {
$x = round($i * $step, 1);
$y = round(30 - (($v - $min) / $span) * 26, 1);
$path .= ($i === 0 ? 'M' : ' ').$x.' '.$y;
}
@endphp
@if ($path !== '')
<svg class="spark {{ $tone === 'accent' ? '' : 'muted' }}" viewBox="0 0 96 34" preserveAspectRatio="none"
style="width:96px;height:34px" aria-hidden="true">
@if ($area)
<path class="area" d="{{ $path }} L96 34 L0 34 Z" />
@endif
<path class="line" d="{{ $path }}" />
</svg>
@endif

View File

@ -10,10 +10,10 @@
customer meets one vocabulary on both sides of the login. --}} customer meets one vocabulary on both sides of the login. --}}
<header class="flex flex-wrap items-end gap-4 animate-rise"> <header class="flex flex-wrap items-end gap-4 animate-rise">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<span class="inline-flex items-center gap-2 rounded-pill bg-accent-subtle px-3 py-1.5 text-xs font-semibold text-accent-text"> <p class="font-mono text-[11px] uppercase tracking-[0.07em] text-muted">
{{ __('dashboard.sheet', ['when' => $asOf->locale($locale)->isoFormat('LL, LT')]) }} {{ __('dashboard.sheet', ['when' => $asOf->locale($locale)->isoFormat('LL, LT')]) }}
</span> </p>
<h1 class="mt-3 text-2xl font-bold tracking-tight text-ink sm:text-3xl"> <h1 class="mt-2.5 text-2xl font-bold tracking-tight text-ink sm:text-3xl">
{{ $instance !== null ? __('dashboard.title_running') : __('dashboard.title_pending') }} {{ $instance !== null ? __('dashboard.title_running') : __('dashboard.title_pending') }}
</h1> </h1>
</div> </div>
@ -53,40 +53,48 @@
</div> </div>
</section> </section>
@else @else
{{-- ── What the customer has ─────────────────────────────────────── --}} {{-- ── What the customer has ─────────────────────────────────────
The template's metric card, and only the visuals there is data
for. Storage consumption is not measured yet, so that card states
the allowance instead of drawing a ring 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"> <section class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
@if ($instance->quota_gb) @if ($instance->quota_gb)
<article class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]"> <x-ui.metric
<p class="font-mono text-[11.5px] uppercase tracking-[0.07em] text-muted">{{ __('dashboard.master.storage') }}</p> :label="__('dashboard.master.storage')"
<p class="mt-2 font-mono text-2xl font-semibold tracking-tight text-ink">{{ __('dashboard.master.gb', ['n' => $instance->quota_gb]) }}</p> :value="$instance->quota_gb"
{{-- Consumption is not metered yet. An invented "used" unit="GB"
figure on the sheet a customer forwards to their :foot="__('dashboard.master.storage_note')"
auditor is the one thing this page must never do. --}} class="animate-rise [animation-delay:60ms]"
<p class="mt-1.5 text-xs text-muted">{{ __('dashboard.master.storage_note') }}</p> />
</article>
@endif @endif
<article class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:100ms]"> <x-ui.metric
<p class="font-mono text-[11.5px] uppercase tracking-[0.07em] text-muted">{{ __('dashboard.master.users') }}</p> :label="__('dashboard.master.users')"
<p class="mt-2 font-mono text-2xl font-semibold tracking-tight text-ink"> :value="$seats['used']"
{{ $seats['total'] !== null :unit="$seats['total'] !== null ? __('dashboard.master.of_seats', ['total' => $seats['total']]) : null"
? __('dashboard.master.of_total', ['used' => $seats['used'], 'total' => $seats['total']]) :href="route('users')"
: $seats['used'] }} class="animate-rise [animation-delay:100ms]"
</p> >
@if ($seats['total']) @if ($seats['total'])
<div class="mt-3 h-1.5 overflow-hidden rounded-pill bg-surface-hover"> <x-slot:visual>
{{-- R4: width is data, not decoration. --}} <x-ui.ring :percent="$seats['used'] / max(1, $seats['total']) * 100" :size="54" />
<div class="h-full rounded-pill bg-muted" style="width: {{ min(100, (int) round($seats['used'] / max(1, $seats['total']) * 100)) }}%"></div> </x-slot:visual>
</div>
@endif @endif
</article> </x-ui.metric>
@if ($traffic) @if ($traffic)
@php $state = $traffic->state(); @endphp @php $state = $traffic->state(); @endphp
<article class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:140ms]"> <x-ui.metric
<p class="font-mono text-[11.5px] uppercase tracking-[0.07em] text-muted">{{ __('dashboard.traffic.title') }}</p> :label="__('dashboard.traffic.title')"
<p class="mt-2 font-mono text-2xl font-semibold tracking-tight text-ink">{{ Bytes::human($traffic->usedBytes) }}</p> :value="Bytes::human($traffic->usedBytes)"
<div class="mt-3 h-1.5 overflow-hidden rounded-pill bg-surface-hover"> :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">
{{-- R4: width is data, not decoration. --}}
<div @class([ <div @class([
'h-full rounded-pill', 'h-full rounded-pill',
'bg-accent' => $state === 'ok', 'bg-accent' => $state === 'ok',
@ -95,26 +103,24 @@
]) ])
style="width: {{ min(100, $traffic->percent()) }}%"></div> style="width: {{ min(100, $traffic->percent()) }}%"></div>
</div> </div>
<p class="mt-2 font-mono text-xs text-muted">{{ __('dashboard.traffic.of', ['total' => Bytes::human($traffic->quotaBytes)]) }}</p>
@if ($state !== 'ok') @if ($state !== 'ok')
{{-- The offer belongs next to the warning: someone {{-- The offer belongs next to the warning: someone
reading that they are nearly out should not have to reading that they are nearly out should not have to
go looking for the way to fix it. --}} go looking for the way to fix it. --}}
<a href="{{ route('billing') }}" wire:navigate class="mt-2.5 inline-flex items-center gap-1.5 text-xs font-semibold text-accent-text hover:underline"> <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') }} <x-ui.icon name="plus" class="size-3.5" />{{ __('dashboard.traffic.buy') }}
</a> </a>
@endif @endif
</article> </x-ui.metric>
@endif @endif
@if ($location) @if ($location)
<article class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:180ms]"> <x-ui.metric
<p class="font-mono text-[11.5px] uppercase tracking-[0.07em] text-muted">{{ __('dashboard.master.location') }}</p> :label="__('dashboard.master.location')"
<p class="mt-2 text-lg font-semibold tracking-tight text-ink">{{ $location['name'] }}</p> :value="$location['name']"
@if ($location['note']) :foot="__('dashboard.master.location_note')"
<p class="mt-1.5 text-xs text-muted">{{ $location['note'] }}</p> class="animate-rise [animation-delay:180ms]"
@endif />
</article>
@endif @endif
</section> </section>

View File

@ -28,28 +28,6 @@
</div> </div>
{{-- Tickets --}} {{-- Tickets --}}
<div class="overflow-hidden rounded-lg 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-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:300ms]"> <div class="rounded-lg 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> <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"> <ul class="divide-y divide-line">

View File

@ -71,6 +71,9 @@
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-center justify-end gap-1.5"> <div class="flex items-center justify-end gap-1.5">
@if ($seat->role === 'owner')
<span class="text-muted" title="{{ __('users.owner_no_actions') }}"></span>
@endif
@if ($seat->status === 'invited') @if ($seat->status === 'invited')
<button type="button" wire:click="resend('{{ $seat->uuid }}')" class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:bg-surface-hover">{{ __('users.resend') }}</button> <button type="button" wire:click="resend('{{ $seat->uuid }}')" class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:bg-surface-hover">{{ __('users.resend') }}</button>
@endif @endif