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
parent
21d8dc310a
commit
676c38643c
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
|
|
@ -11,15 +10,7 @@ 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')],
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ return [
|
|||
'address' => 'Adresse',
|
||||
'storage' => 'Speicher',
|
||||
'storage_note' => 'vertraglich zugesichert',
|
||||
'location_note' => 'kein Drittlandtransfer',
|
||||
'gb' => ':n GB',
|
||||
'of_seats' => 'von :total Plätzen',
|
||||
'of_total' => ':used von :total',
|
||||
'per_month' => ':amount netto / Monat',
|
||||
'per_year' => ':amount netto / Jahr',
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ return [
|
|||
|
||||
'col_person' => 'Person',
|
||||
'col_status' => 'Status',
|
||||
'owner_no_actions' => 'Der Inhaber kann nicht entfernt werden.',
|
||||
'col_actions' => 'Aktionen',
|
||||
|
||||
'status_active' => 'Aktiv',
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ return [
|
|||
'address' => 'Address',
|
||||
'storage' => 'Storage',
|
||||
'storage_note' => 'contractually guaranteed',
|
||||
'location_note' => 'no third-country transfer',
|
||||
'gb' => ':n GB',
|
||||
'of_seats' => 'of :total seats',
|
||||
'of_total' => ':used of :total',
|
||||
'per_month' => ':amount net / month',
|
||||
'per_year' => ':amount net / year',
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ return [
|
|||
|
||||
'col_person' => 'Person',
|
||||
'col_status' => 'Status',
|
||||
'owner_no_actions' => 'The owner cannot be removed.',
|
||||
'col_actions' => 'Actions',
|
||||
|
||||
'status_active' => 'Active',
|
||||
|
|
|
|||
|
|
@ -81,3 +81,43 @@
|
|||
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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -10,10 +10,10 @@
|
|||
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">
|
||||
<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')]) }}
|
||||
</span>
|
||||
<h1 class="mt-3 text-2xl font-bold tracking-tight text-ink sm:text-3xl">
|
||||
</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>
|
||||
|
|
@ -53,40 +53,48 @@
|
|||
</div>
|
||||
</section>
|
||||
@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">
|
||||
@if ($instance->quota_gb)
|
||||
<article class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<p class="font-mono text-[11.5px] uppercase tracking-[0.07em] text-muted">{{ __('dashboard.master.storage') }}</p>
|
||||
<p class="mt-2 font-mono text-2xl font-semibold tracking-tight text-ink">{{ __('dashboard.master.gb', ['n' => $instance->quota_gb]) }}</p>
|
||||
{{-- Consumption is not metered yet. An invented "used"
|
||||
figure on the sheet a customer forwards to their
|
||||
auditor is the one thing this page must never do. --}}
|
||||
<p class="mt-1.5 text-xs text-muted">{{ __('dashboard.master.storage_note') }}</p>
|
||||
</article>
|
||||
<x-ui.metric
|
||||
:label="__('dashboard.master.storage')"
|
||||
:value="$instance->quota_gb"
|
||||
unit="GB"
|
||||
:foot="__('dashboard.master.storage_note')"
|
||||
class="animate-rise [animation-delay:60ms]"
|
||||
/>
|
||||
@endif
|
||||
|
||||
<article class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:100ms]">
|
||||
<p class="font-mono text-[11.5px] uppercase tracking-[0.07em] text-muted">{{ __('dashboard.master.users') }}</p>
|
||||
<p class="mt-2 font-mono text-2xl font-semibold tracking-tight text-ink">
|
||||
{{ $seats['total'] !== null
|
||||
? __('dashboard.master.of_total', ['used' => $seats['used'], 'total' => $seats['total']])
|
||||
: $seats['used'] }}
|
||||
</p>
|
||||
<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-3 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>
|
||||
<x-slot:visual>
|
||||
<x-ui.ring :percent="$seats['used'] / max(1, $seats['total']) * 100" :size="54" />
|
||||
</x-slot:visual>
|
||||
@endif
|
||||
</article>
|
||||
</x-ui.metric>
|
||||
|
||||
@if ($traffic)
|
||||
@php $state = $traffic->state(); @endphp
|
||||
<article class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:140ms]">
|
||||
<p class="font-mono text-[11.5px] uppercase tracking-[0.07em] text-muted">{{ __('dashboard.traffic.title') }}</p>
|
||||
<p class="mt-2 font-mono text-2xl font-semibold tracking-tight text-ink">{{ Bytes::human($traffic->usedBytes) }}</p>
|
||||
<div class="mt-3 h-1.5 overflow-hidden rounded-pill bg-surface-hover">
|
||||
<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">
|
||||
{{-- R4: width is data, not decoration. --}}
|
||||
<div @class([
|
||||
'h-full rounded-pill',
|
||||
'bg-accent' => $state === 'ok',
|
||||
|
|
@ -95,26 +103,24 @@
|
|||
])
|
||||
style="width: {{ min(100, $traffic->percent()) }}%"></div>
|
||||
</div>
|
||||
<p class="mt-2 font-mono text-xs text-muted">{{ __('dashboard.traffic.of', ['total' => Bytes::human($traffic->quotaBytes)]) }}</p>
|
||||
@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-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') }}
|
||||
</a>
|
||||
@endif
|
||||
</article>
|
||||
</x-ui.metric>
|
||||
@endif
|
||||
|
||||
@if ($location)
|
||||
<article class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:180ms]">
|
||||
<p class="font-mono text-[11.5px] uppercase tracking-[0.07em] text-muted">{{ __('dashboard.master.location') }}</p>
|
||||
<p class="mt-2 text-lg font-semibold tracking-tight text-ink">{{ $location['name'] }}</p>
|
||||
@if ($location['note'])
|
||||
<p class="mt-1.5 text-xs text-muted">{{ $location['note'] }}</p>
|
||||
@endif
|
||||
</article>
|
||||
<x-ui.metric
|
||||
:label="__('dashboard.master.location')"
|
||||
:value="$location['name']"
|
||||
:foot="__('dashboard.master.location_note')"
|
||||
class="animate-rise [animation-delay:180ms]"
|
||||
/>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -28,28 +28,6 @@
|
|||
</div>
|
||||
|
||||
{{-- 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="border-b border-line px-5 py-3"><h2 class="font-semibold text-ink">{{ __('support.faq') }}</h2></div>
|
||||
<ul class="divide-y divide-line">
|
||||
|
|
|
|||
|
|
@ -71,6 +71,9 @@
|
|||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<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')
|
||||
<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
|
||||
|
|
|
|||
Loading…
Reference in New Issue