diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index b9d416f..1491d80 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -3,12 +3,26 @@ namespace App\Livewire; use App\Livewire\Concerns\ResolvesCustomer; +use App\Models\Customer; +use App\Models\Datacenter; +use App\Models\Instance; +use App\Models\MaintenanceWindow; +use App\Models\Seat; +use App\Models\Subscription; use App\Services\Traffic\TrafficMeter; use Illuminate\Support\Carbon; -use Illuminate\Support\Number; use Livewire\Attributes\Layout; use Livewire\Component; +/** + * The customer's Betriebsblatt: what they have, and the evidence that it is + * being looked after. + * + * Every figure on this page comes from a record. Where something is not + * measured yet — storage consumption, restore tests — the page says nothing + * rather than showing a plausible number: this is the sheet a customer forwards + * to their auditor, and one invented line on it is worse than a short register. + */ #[Layout('layouts.portal-app')] class Dashboard extends Component { @@ -16,118 +30,217 @@ class Dashboard extends Component public function render() { - // Real numbers where they exist: traffic is metered, unlike the - // fixtures below, and a customer needs to see it before it runs out. - $instance = $this->customer()?->instances()->latest('id')->first(); - $traffic = $instance !== null ? TrafficMeter::for($instance) : null; + $customer = $this->customer(); - // 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); + // The instance that is actually in service. A cancelled one still has + // rows, and would otherwise keep filling this page after it is gone. + $instance = $customer?->instances() + ->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled']) + ->latest('id') + ->first(); - // ~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]; + $contract = $instance?->subscription; + $maintenance = MaintenanceWindow::forInstance($instance)->first(); return view('livewire.dashboard', [ - 'traffic' => $traffic, - 'storageUsed' => $storageUsed, - 'storageQuota' => $storageQuota, - 'storagePercent' => $storagePercent, - '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' => [ - ['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'], + 'customer' => $customer, + 'instance' => $instance, + 'contract' => $contract, + 'domain' => $this->domain($instance), + 'location' => $this->location($instance), + 'seats' => $this->seats($customer, $contract), + 'traffic' => $instance !== null ? TrafficMeter::for($instance) : null, + 'proofs' => $this->proofs($instance, $maintenance), + 'openTasks' => $instance?->onboardingTasks()->where('done', false)->count() ?? 0, + // What the plan costs is true whether or not another invoice is + // coming — a customer who has given notice still pays until the + // term ends and still needs the figure on their master record. + 'planPrice' => $contract === null ? null : [ + 'cents' => (int) $contract->price_cents, + 'currency' => (string) ($contract->currency ?: 'EUR'), + 'term' => (string) $contract->term, ], + 'nextInvoice' => $this->nextInvoice($contract, $instance), + 'asOf' => Carbon::now(), ]); } + + /** The address the customer actually reaches their cloud at. */ + private function domain(?Instance $instance): ?string + { + if ($instance === null) { + return null; + } + + return $instance->custom_domain + ?: ($instance->subdomain + ? $instance->subdomain.'.'.config('provisioning.dns.zone', 'clupilot.com') + : null); + } + + /** + * Where the data physically sits. + * + * Named from the datacenter register rather than from a translation string, + * because this is the line a customer copies into a processing record — it + * has to be the place their instance really runs on, not the place we + * usually sell. + * + * @return array{name: string, note: ?string}|null + */ + private function location(?Instance $instance): ?array + { + $code = $instance?->host?->datacenter; + + if ($code === null || $code === '') { + return null; + } + + $datacenter = Datacenter::query()->where('code', $code)->first(); + + return [ + 'name' => $datacenter?->name ?: $code, + 'note' => $datacenter?->location, + ]; + } + + /** + * Seats in use against seats bought. + * + * Invited seats count as used: the licence is committed the moment the + * invitation goes out, and showing them as free is how a customer finds out + * at the worst moment that they cannot add the person in front of them. + * + * @return array{used: int, total: int|null} + */ + private function seats(?Customer $customer, ?Subscription $contract): array + { + return [ + 'used' => $customer === null ? 0 : Seat::query() + ->where('customer_id', $customer->id) + ->whereIn('status', ['active', 'invited']) + ->count(), + 'total' => $contract?->seats, + ]; + } + + /** + * The proof register — what was done for this customer, and when. + * + * Only entries backed by a record. A row here is something the customer can + * be asked to evidence, so "we back up nightly" is not one; "the last backup + * completed at 03:12 and reported ok" is. + * + * @return array + */ + private function proofs(?Instance $instance, ?MaintenanceWindow $maintenance): array + { + if ($instance === null) { + return []; + } + + $proofs = []; + + // A failing job outranks a succeeding one, whatever their dates. + // + // Ordering by last_ok_at alone hides exactly the case this register + // exists for: last night's job failed and so has no success time at + // all, an older row that DID succeed sorts above it, and the sheet + // reports backups as fine. On the page a customer shows their auditor, + // a problem has to beat a date. + $backups = $instance->backups()->get(); + $backup = $backups->first(fn ($b) => $b->status !== 'ok') + ?? $backups->sortByDesc('last_ok_at')->first(); + + if ($backup !== null) { + $proofs[] = [ + 'key' => 'backup', + 'at' => $backup->last_ok_at, + // The job's own verdict, not "there is a row, so it worked". + 'state' => $backup->status === 'ok' ? 'ok' : 'attention', + 'note' => $backup->schedule, + ]; + } + + // A certificate that stopped renewing is the failure a customer meets as + // "my browser says my own cloud is unsafe", so it is stated either way. + $proofs[] = [ + 'key' => 'certificate', + 'at' => null, + 'state' => $instance->cert_ok ? 'ok' : 'attention', + 'note' => null, + ]; + + if ($maintenance !== null) { + $proofs[] = [ + 'key' => 'maintenance', + 'at' => $maintenance->starts_at, + 'state' => 'planned', + 'note' => $maintenance->title, + ]; + } + + if ($instance->service_ends_at !== null) { + $proofs[] = [ + 'key' => 'service_ends', + 'at' => $instance->service_ends_at, + 'state' => 'attention', + 'note' => null, + ]; + } + + // Newest first, but an entry with no time of its own — the certificate — + // stays at the top rather than sinking to the bottom of the register. + usort($proofs, fn (array $a, array $b) => ($b['at']?->timestamp ?? PHP_INT_MAX) <=> ($a['at']?->timestamp ?? PHP_INT_MAX)); + + return $proofs; + } + + /** + * What is owed next, from the contract rather than from a price list. + * + * The plan and the modules are kept apart. A customer with modules booked + * would otherwise read the plan's price as their bill and be short every + * month — and putting the combined figure next to the word "Paket" on the + * master record would be just as wrong in the other direction. + * + * Add-ons carry a MONTHLY price (SubscriptionAddon::monthlyCents), while the + * plan's price_cents is the price of a whole term. On a yearly contract the + * modules therefore multiply by twelve to land on the same invoice. + * + * Nothing at all once the customer has given notice. Cancelling leaves the + * subscription `active` until the term runs out — that is deliberate, the + * service keeps running — but `current_period_end` is then the day service + * ENDS, not the day the next charge falls. Billing a customer, on the sheet + * they check their invoices against, for a renewal that will never happen is + * the worst single line this page could carry. The end date is still stated; + * it is in the proof register as "contract ends". + * + * @return array{plan: int, addons: int, total: int, currency: string, due: \Illuminate\Support\Carbon|null, term: string}|null + */ + private function nextInvoice(?Subscription $contract, ?Instance $instance): ?array + { + if ($contract === null || $contract->status === 'cancelled') { + return null; + } + + if ($instance?->cancel_requested_at !== null || $instance?->service_ends_at !== null) { + return null; + } + + $months = $contract->isYearly() ? 12 : 1; + + $addons = $contract->addons + ->whereNull('cancelled_at') + ->sum(fn ($addon) => $addon->monthlyCents()) * $months; + + return [ + 'plan' => (int) $contract->price_cents, + 'addons' => (int) $addons, + 'total' => (int) $contract->price_cents + (int) $addons, + 'currency' => (string) ($contract->currency ?: 'EUR'), + 'due' => $contract->current_period_end, + 'term' => (string) $contract->term, + ]; + } } diff --git a/lang/de/admin.php b/lang/de/admin.php index cde5116..d1ebd1e 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -5,6 +5,11 @@ return [ 'badge' => 'Admin', 'to_portal' => 'Zum Kundenportal', + 'nav_group' => [ + 'operations' => 'Betrieb', + 'system' => 'System', + ], + 'nav' => [ 'overview' => 'Übersicht', 'customers' => 'Kunden', diff --git a/lang/de/cloud.php b/lang/de/cloud.php index 96d9a39..b7ace03 100644 --- a/lang/de/cloud.php +++ b/lang/de/cloud.php @@ -6,6 +6,7 @@ return [ 'plan_line' => ':plan · :price €/Monat', 'datacenter' => 'EU · Rechenzentrum Falkenstein', 'now' => 'jetzt', + 'instance_label' => 'Instanz', 'open' => 'Cloud öffnen', 'status_active' => 'Aktiv', 'status_provisioning' => 'Bereitstellung', diff --git a/lang/de/dashboard.php b/lang/de/dashboard.php index 1ef882a..3c32b4c 100644 --- a/lang/de/dashboard.php +++ b/lang/de/dashboard.php @@ -3,12 +3,96 @@ return [ 'traffic' => [ 'title' => 'Datenvolumen diesen Monat', + 'of' => 'von :total', 'resets' => 'setzt sich in :days Tagen zurück', 'remaining' => ':amount übrig', 'almost' => ':percent % verbraucht — es wird knapp.', 'exhausted' => 'Kontingent aufgebraucht. Ihre Nextcloud läuft weiter, aber langsamer, bis Sie nachbuchen oder der Monat wechselt.', 'buy' => 'Volumen nachbuchen', ], + // Das Betriebsblatt: was der Kunde hat, und der Nachweis, dass es betreut + // wird. Jede Zeile kommt aus einem Datensatz — was nicht gemessen wird, + // steht hier auch nicht. + 'sheet' => 'Betriebsblatt · Stand :when', + 'title_running' => 'Ihre Cloud läuft.', + 'title_pending' => 'Ihre Cloud wird eingerichtet.', + + 'instance_status' => [ + 'active' => 'Alle Dienste aktiv', + 'provisioning' => 'Wird eingerichtet', + 'cancellation_scheduled' => 'Kündigung vorgemerkt', + ], + + 'no_instance_label' => 'Noch keine Instanz', + 'no_instance_body' => 'Für dieses Konto läuft derzeit keine Cloud. Sobald eine Bestellung bezahlt ist, richten wir sie ein — dieser Bereich füllt sich dann von selbst.', + 'no_instance_cta' => 'Paket wählen', + + 'master' => [ + 'label' => 'Stammblatt', + 'package' => 'Paket', + 'operation' => 'Betriebsform', + 'location' => 'Serverstandort', + 'users' => 'Benutzer', + 'address' => 'Adresse', + 'storage' => 'Speicher', + 'storage_note' => 'vertraglich zugesichert', + 'gb' => ':n GB', + 'of_total' => ':used von :total', + 'per_month' => ':amount netto / Monat', + 'per_year' => ':amount netto / Jahr', + ], + + 'proofs' => [ + 'label' => 'Nachweise', + 'all' => 'Alle ansehen', + 'empty' => 'Sobald die erste Sicherung gelaufen ist, steht sie hier.', + 'when' => 'Zeitpunkt', + 'what' => 'Vorgang', + 'result' => 'Ergebnis', + 'item' => [ + 'backup' => 'Sicherung', + 'certificate' => 'Verschlüsselte Verbindung', + 'maintenance' => 'Geplante Wartung', + 'service_ends' => 'Vertragsende', + ], + 'state' => [ + 'ok' => 'Geprüft', + 'planned' => 'Geplant', + 'attention' => 'Prüfen', + ], + ], + + 'invoice' => [ + 'label' => 'Nächste Abrechnung', + 'plan' => 'Paket', + 'addons' => 'Module', + 'amount' => 'Betrag', + 'net' => 'zzgl. USt.', + 'due' => 'Fällig', + 'term' => 'Abrechnung', + 'all' => 'Rechnungen ansehen', + ], + + 'term' => [ + 'monthly' => 'monatlich', + 'yearly' => 'jährlich', + ], + + 'setup' => [ + 'label' => 'Einrichtung', + 'open' => '{1} Ein Schritt ist noch offen.|[2,*] :count Schritte sind noch offen.', + 'continue' => 'Einrichtung fortsetzen', + ], + + 'quick' => [ + 'add_user_title' => 'Benutzer hinzufügen', + 'add_user_body' => 'Zugang für neue Mitarbeiter anlegen', + 'restore_title' => 'Datei wiederherstellen', + 'restore_body' => 'Aus den vorhandenen Sicherungen', + 'grow_title' => 'Paket erweitern', + 'grow_body' => 'Speicher, Benutzer oder Datenvolumen', + ], + 'title' => 'Übersicht', 'subtitle' => 'Status Ihrer Cloud auf einen Blick.', 'greeting' => 'Guten Morgen, :name', @@ -26,6 +110,13 @@ return [ 'support' => 'Support', ], + // Zwei Gruppen: was der Kunde betreibt, und was im Vertrag steht. + 'nav_group' => [ + 'contract' => 'Vertrag', + ], + 'nav_footer' => "Betreut aus Österreich\nAntwort am selben Werktag", + 'signed_in_as' => 'Angemeldet als', + 'open_nav' => 'Navigation öffnen', 'close_nav' => 'Navigation schließen', 'logout' => 'Abmelden', diff --git a/lang/en/admin.php b/lang/en/admin.php index d099a51..b624c01 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -5,6 +5,11 @@ return [ 'badge' => 'Admin', 'to_portal' => 'To customer portal', + 'nav_group' => [ + 'operations' => 'Operations', + 'system' => 'System', + ], + 'nav' => [ 'overview' => 'Overview', 'customers' => 'Customers', diff --git a/lang/en/cloud.php b/lang/en/cloud.php index 42ebfa1..80f832b 100644 --- a/lang/en/cloud.php +++ b/lang/en/cloud.php @@ -6,6 +6,7 @@ return [ 'plan_line' => ':plan · €:price/mo', 'datacenter' => 'EU · Falkenstein data center', 'now' => 'now', + 'instance_label' => 'Instance', 'open' => 'Open cloud', 'status_active' => 'Active', 'status_provisioning' => 'Provisioning', diff --git a/lang/en/dashboard.php b/lang/en/dashboard.php index f1bc7ae..ee14e98 100644 --- a/lang/en/dashboard.php +++ b/lang/en/dashboard.php @@ -3,12 +3,96 @@ return [ 'traffic' => [ 'title' => 'Data transfer this month', + 'of' => 'of :total', 'resets' => 'resets in :days days', 'remaining' => ':amount left', 'almost' => ':percent % used — getting tight.', 'exhausted' => 'Allowance used up. Your Nextcloud keeps running, but slower, until you top up or the month rolls over.', 'buy' => 'Add data volume', ], + // The operating sheet: what the customer has, and the evidence it is being + // looked after. Every line comes from a record — anything not measured does + // not appear here at all. + 'sheet' => 'Operating sheet · as of :when', + 'title_running' => 'Your cloud is running.', + 'title_pending' => 'Your cloud is being set up.', + + 'instance_status' => [ + 'active' => 'All services running', + 'provisioning' => 'Being set up', + 'cancellation_scheduled' => 'Cancellation scheduled', + ], + + 'no_instance_label' => 'No instance yet', + 'no_instance_body' => 'No cloud is running for this account at the moment. As soon as an order is paid we set one up — this area then fills itself.', + 'no_instance_cta' => 'Choose a package', + + 'master' => [ + 'label' => 'Master record', + 'package' => 'Package', + 'operation' => 'Operating mode', + 'location' => 'Server location', + 'users' => 'Users', + 'address' => 'Address', + 'storage' => 'Storage', + 'storage_note' => 'contractually guaranteed', + 'gb' => ':n GB', + 'of_total' => ':used of :total', + 'per_month' => ':amount net / month', + 'per_year' => ':amount net / year', + ], + + 'proofs' => [ + 'label' => 'Evidence', + 'all' => 'View all', + 'empty' => 'Once the first backup has run, it appears here.', + 'when' => 'Time', + 'what' => 'Event', + 'result' => 'Result', + 'item' => [ + 'backup' => 'Backup', + 'certificate' => 'Encrypted connection', + 'maintenance' => 'Scheduled maintenance', + 'service_ends' => 'Contract ends', + ], + 'state' => [ + 'ok' => 'Verified', + 'planned' => 'Scheduled', + 'attention' => 'Check', + ], + ], + + 'invoice' => [ + 'label' => 'Next invoice', + 'plan' => 'Package', + 'addons' => 'Modules', + 'amount' => 'Amount', + 'net' => 'plus VAT', + 'due' => 'Due', + 'term' => 'Billed', + 'all' => 'View invoices', + ], + + 'term' => [ + 'monthly' => 'monthly', + 'yearly' => 'yearly', + ], + + 'setup' => [ + 'label' => 'Setup', + 'open' => '{1} One step is still open.|[2,*] :count steps are still open.', + 'continue' => 'Continue setup', + ], + + 'quick' => [ + 'add_user_title' => 'Add a user', + 'add_user_body' => 'Create access for a new colleague', + 'restore_title' => 'Restore a file', + 'restore_body' => 'From the backups on record', + 'grow_title' => 'Extend the package', + 'grow_body' => 'Storage, users or data allowance', + ], + 'title' => 'Overview', 'subtitle' => 'Your cloud status at a glance.', 'greeting' => 'Good morning, :name', @@ -26,6 +110,13 @@ return [ 'support' => 'Support', ], + // Two groups: what the customer operates, and what the contract says. + 'nav_group' => [ + 'contract' => 'Contract', + ], + 'nav_footer' => "Operated from Austria\nAnswered the same working day", + 'signed_in_as' => 'Signed in as', + 'open_nav' => 'Open navigation', 'close_nav' => 'Close navigation', 'logout' => 'Sign out', diff --git a/resources/css/admin-tokens.css b/resources/css/admin-tokens.css index 7918d29..a910b97 100644 --- a/resources/css/admin-tokens.css +++ b/resources/css/admin-tokens.css @@ -1,33 +1,40 @@ -/* CluPilot admin console — "Tactical Terminal": dark graphite, signal-orange, - ops status triad. Scoped to .theme-admin so it overrides the light portal - tokens only inside the admin layout — every shared component adapts for free. */ +/* CluPilot operator console — density, not a second palette. + * + * This file used to hold a complete dark "Tactical Terminal" set: its own + * surfaces, its own orange, its own status triad. That made the console a + * second product. Two colours for "healthy", two oranges, two ideas of what a + * card is — and every shared component had to work in both. + * + * The console is now the same light system as everything else. What genuinely + * differs is DENSITY: an operator scans forty rows, a customer reads four. So + * this file changes spacing and type size, and nothing about colour. + * + * Dark mode is a preference, not the operator aesthetic. Light stays the + * canonical expression on every surface. + */ .theme-admin { - --bg: #0b0e13; - --surface: #12161d; - --surface-2: #1a2029; - --surface-hover: #1c232d; + /* One step down across the board. The scale is the same scale — a console + that used different type sizes as well as different spacing stops being + recognisably the same application. */ + --text-base: 13.5px; + --text-md: 15px; + --text-2xl: 26px; + --text-3xl: 32px; - --border: #232b37; - --border-strong: #313b4a; - - --text-strong: #eef1f6; - --text: #c2cad6; - --text-muted: #8b95a5; - --text-faint: #626d7e; - - /* signal orange — brighter for dark; dark text sits on the fill (AA) */ - --accent: #ff7a2f; - --accent-hover: #ff8f4d; - --accent-active: #ff7a2f; - --accent-press: #e8630f; - --accent-subtle: rgba(255, 122, 47, .14); - --accent-border: rgba(255, 122, 47, .32); - --accent-ring: rgba(255, 122, 47, .45); - --on-accent: #0b0e13; - --accent-text: #ff9152; - - --success: #35d07f; --success-bg: rgba(53, 208, 127, .13); --success-border: rgba(53, 208, 127, .32); --success-bright: #35d07f; - --warning: #e8b931; --warning-bg: rgba(232, 185, 49, .13); --warning-border: rgba(232, 185, 49, .32); - --danger: #ff5247; --danger-bg: rgba(255, 82, 71, .13); --danger-border: rgba(255, 82, 71, .32); - --info: #5b9bff; --info-bg: rgba(91, 155, 255, .13); --info-border: rgba(91, 155, 255, .32); + /* Tighter corners on a denser grid: a 16px radius around a 40px-high row + looks inflated when there are twenty of them. */ + --radius-lg: 13px; + --radius-xl: 16px; +} + +/* Table rows an operator scans rather than reads: 40–44px, against the 48–56px + the portal uses. Written here rather than in the views so a future table + inherits it instead of restating it. */ +.theme-admin table td { + padding-top: 11px; + padding-bottom: 11px; +} + +.theme-admin table th { + padding-bottom: 10px; } diff --git a/resources/css/app.css b/resources/css/app.css index 43c2caf..9c71049 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -7,9 +7,8 @@ @import '@fontsource/ibm-plex-mono/400.css'; @import '@fontsource/ibm-plex-mono/500.css'; @import '@fontsource/ibm-plex-mono/600.css'; -/* Serif is the display voice — headlines only, matching the marketing site. */ -@import '@fontsource/ibm-plex-serif/400.css'; -@import '@fontsource/ibm-plex-serif/600.css'; +/* No serif: a serif headline made every page read as a document, which is + exactly the impression the redesign removes. One family, used assertively. */ /* Design tokens, then Tailwind v3 layers. */ @import './portal-tokens.css'; @@ -53,48 +52,6 @@ } } -@layer components { - /* Registration marks — the corner crosses on a printed form. A pseudo - element rather than markup: they are decoration, and a screen reader - announcing "plus, plus" on every panel would be noise. Tailwind cannot - express ::before content, so this is the one place it lives in CSS. */ - .doc-marks { - position: relative; - } - - .doc-marks::before, - .doc-marks::after { - content: "+"; - position: absolute; - font-family: var(--font-mono); - font-size: 0.78rem; - line-height: 1; - color: var(--border-strong); - pointer-events: none; - } - - .doc-marks::before { - top: -6px; - left: -6px; - } - - .doc-marks::after { - bottom: -6px; - right: -6px; - } - - /* The rule that precedes an eyebrow label, in the accent. Same reasoning: - decorative, and it has to sit on the text baseline of a flex row. */ - .doc-eyebrow::before { - content: ""; - display: block; - width: 22px; - height: 1px; - background: var(--accent); - flex: none; - } -} - /* iOS zooms the whole page when a field smaller than 16px takes focus, and * then leaves the page zoomed — every tap into a form throws the layout about. * diff --git a/resources/css/portal-tokens.css b/resources/css/portal-tokens.css index 690f294..021acfb 100644 --- a/resources/css/portal-tokens.css +++ b/resources/css/portal-tokens.css @@ -1,75 +1,105 @@ -/* CluPilot customer-portal design tokens — refined enterprise, light, single - orange accent. Framework-neutral CSS custom properties (work in Tailwind v3 - or v4). Values calibrated for AA contrast. See design handoff §6. */ +/* CluPilot design tokens — one set for every surface. + * + * Light, confident, one warm accent. The public site, the customer portal and + * the operator console draw from this file and differ only in density: same + * colours, same radii, same button hierarchy, same status vocabulary. A + * customer who signs in must not feel handed to a different product, and an + * operator switching between the two should not have to relearn anything. + * + * Framework-neutral custom properties (work in Tailwind v3 or v4). Contrast + * ratios are noted where they were the reason for the value. + */ :root { - /* Surfaces (light, slightly cool) */ - --bg: #f6f7f9; + /* ── Surfaces ───────────────────────────────────────────────────────── + The ground is a shade darker than the cards, so a card lifts off the + page without a shadow doing the work. */ + --bg: #f6f6f8; --surface: #ffffff; - --surface-2: #f1f3f5; - --surface-hover: #f8f9fb; + --surface-2: #fafafb; + --surface-hover: #f2f2f5; - /* Lines */ - --border: #e4e7ec; - --border-strong: #d0d5dd; + /* Lines carry the structure. Depth is used sparingly and warmly. */ + --border: #e9e9ee; + --border-strong: #dcdce4; - /* Text */ - --text-strong: #101828; - --text: #344054; - --text-muted: #667085; - --text-faint: #98a2b3; + /* ── Text ───────────────────────────────────────────────────────────── + Near-black rather than pure black: on a light ground pure black reads + as a printing error, not as emphasis. */ + --text-strong: #17171c; /* headings — 16.4:1 on white */ + --text: #43434e; /* body — 9.8:1 */ + --text-muted: #6e6e7a; /* secondary copy — 5.0:1, AA for body text */ + --text-faint: #b4b4be; /* decoration only: rules, placeholder glyphs */ - /* Accent = the single brand / interaction tone: orange */ - --accent: #f97316; /* brand tone: decorative fills, borders, tints, focus */ - --accent-hover: #ea6a0c; - --accent-active: #c2560a; /* AA-safe fill for white text on orange (~5:1) */ - --accent-press: #a8480a; /* darker press/hover for accent-fill buttons */ - --accent-subtle: #fff4ec; - --accent-border: #fed7aa; - --accent-ring: rgba(249, 115, 22, .35); + /* ── Accent ─────────────────────────────────────────────────────────── + Orange appears in FEW places, but one of them is allowed to be large — + a whole closing panel. Frequency low, area high; the reverse reads as + editorial annotation rather than as brand. */ + --accent: #f97316; /* rings, bars, chart strokes, the logo mark */ + --accent-hover: #d95f0c; + --accent-active: #b8500a; /* solid fills and text — 5.0:1 with white */ + --accent-press: #974208; + --accent-subtle: #fff3e9; + --accent-border: #e8cdb2; + --accent-ring: rgba(249, 115, 22, .38); --on-accent: #ffffff; - /* Accent as TEXT on light surfaces (links, small labels) must be dark enough - for AA — #f97316 on white is only ~2.9:1, so text uses --accent-active. */ - --accent-text: #c2560a; + /* #f97316 as TEXT on white is only 2.9:1, so anything read uses the + darker tone. This is the single most common way the palette gets + broken, which is why the two have different names. */ + --accent-text: #b8500a; - /* The ink plate: the dark surface used where the brand speaks rather than - the product — the sign-in panel, the closing block of the public site. - Warm rather than blue-black, so it reads as ink, not as chrome. */ - --plate: #17140f; - --plate-2: #211d16; - --plate-rule: #39332a; - --plate-text: #ded7c9; - --plate-muted: #93897a; + /* ── Ink fields ─────────────────────────────────────────────────────── + The dark surfaces: the site's contrast bands, the sign-in panel, the + operator sidebar. Neutral-warm, never pure black. */ + --plate: #16151b; + --plate-2: #201f27; + --plate-rule: #33323d; + --plate-text: #c9c7d2; + --plate-muted: #918f9d; /* 4.6:1 on --plate */ - /* 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; + /* ── Status ─────────────────────────────────────────────────────────── + Independent of the brand. Amber must keep meaning "this needs you", so + "scheduled" and "informational" are blue — next to an orange brand the + two are otherwise indistinguishable. */ + --success: #1c7c50; --success-bg: #eaf6f0; --success-border: #c3e3d3; + --success-bright: #30b15c; /* dots, rings and charts — never small text */ + --warning: #9a5b0b; --warning-bg: #fdf4e7; --warning-border: #eed9b3; + --danger: #a8302a; --danger-bg: #fbeeed; --danger-border: #eec9c6; + --info: #1e5aa8; --info-bg: #eef4fa; --info-border: #c3d6ea; - /* Typography */ + /* ── Type ───────────────────────────────────────────────────────────── + One family, used assertively. Mono is for operational data only — + storage, dates, addresses, versions, invoice figures — where tabular + figures let a column of numbers line up. Never for headings. */ --font-sans: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif; --font-mono: "IBM Plex Mono", ui-monospace, monospace; - /* Display voice for headlines, shared with the public site so the two do - not look like two different companies. Never used for body copy. */ - --font-serif: "IBM Plex Serif", Georgia, "Times New Roman", serif; + /* Kept for compatibility; the display voice is now the sans at weight 700. + A serif headline made every page read as a document. */ + --font-serif: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif; - --text-xs: 12px; --text-sm: 13px; --text-base: 14px; --text-md: 15px; - --text-lg: 18px; --text-xl: 22px; --text-2xl: 28px; --text-3xl: 34px; + --text-xs: 12px; --text-sm: 13px; --text-base: 14.5px; --text-md: 16px; + --text-lg: 18px; --text-xl: 22px; --text-2xl: 30px; --text-3xl: 40px; - --lh-tight: 1.2; --lh-normal: 1.5; - --tracking-tight: -0.02em; - --tracking-label: 0.06em; + --lh-tight: 1.12; --lh-normal: 1.55; + --tracking-tight: -0.03em; + --tracking-label: 0.07em; - /* Radius / shadow / motion / focus */ - --radius-sm: 6px; --radius: 8px; --radius-lg: 12px; --radius-xl: 20px; --radius-pill: 999px; + /* ── Radius ─────────────────────────────────────────────────────────── + Scaled to object size. One value everywhere is what makes an interface + read as a design-system specification rather than as a product. */ + --radius-sm: 9px; /* chips, small controls */ + --radius: 11px; /* buttons, inputs */ + --radius-lg: 16px; /* cards */ + --radius-xl: 22px; /* full-width panels, product frames */ + --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); - --shadow-md: 0 4px 8px -2px rgba(16, 24, 40, .10), 0 2px 4px -2px rgba(16, 24, 40, .06); + /* Warm, broad and low — a neutral grey drop shadow belongs to a different + design language and reads as cold against this ground. */ + --shadow-xs: 0 1px 2px rgba(40, 24, 15, .04); + --shadow-sm: 0 2px 6px rgba(40, 24, 15, .06); + --shadow-md: 0 16px 44px -18px rgba(40, 24, 15, .20); - --dur-fast: 120ms; --dur: 180ms; --dur-slow: 240ms; - --ease: cubic-bezier(.2, .6, .2, 1); + --dur-fast: 120ms; --dur: 180ms; --dur-slow: 260ms; + --ease: cubic-bezier(.2, .7, .2, 1); --focus-ring: 0 0 0 3px var(--accent-ring); } diff --git a/resources/views/coming-soon.blade.php b/resources/views/coming-soon.blade.php index 5bc5924..9e1a8a5 100644 --- a/resources/views/coming-soon.blade.php +++ b/resources/views/coming-soon.blade.php @@ -19,66 +19,69 @@ --}} @verbatim @endverbatim
-
+
{{ __('coming_soon.label') }} - {{ __('coming_soon.state') }} + {{ __('coming_soon.state') }}
- CluPilot Cloud + CluPilot Cloud

{{ __('coming_soon.title') }}

{{ __('coming_soon.body') }}

{{ __('coming_soon.contact') }} diff --git a/resources/views/components/auth/brand.blade.php b/resources/views/components/auth/brand.blade.php index f48dda9..0dd4e1b 100644 --- a/resources/views/components/auth/brand.blade.php +++ b/resources/views/components/auth/brand.blade.php @@ -14,24 +14,20 @@ would only push it under the fold. --}}

diff --git a/resources/views/components/shell/head.blade.php b/resources/views/components/shell/head.blade.php new file mode 100644 index 0000000..d97a55b --- /dev/null +++ b/resources/views/components/shell/head.blade.php @@ -0,0 +1,14 @@ +@props(['title' => null]) +{{-- + The document head both shells share. Written once because a divergence here + is invisible until something is missing on exactly one of them — a favicon, + the CSRF token, a viewport that lets iOS zoom. +--}} + + + + + +{{ $title ?? config('app.name') }} +@vite(['resources/css/app.css', 'resources/js/app.js']) +@livewireStyles diff --git a/resources/views/components/shell/nav.blade.php b/resources/views/components/shell/nav.blade.php new file mode 100644 index 0000000..00fd757 --- /dev/null +++ b/resources/views/components/shell/nav.blade.php @@ -0,0 +1,65 @@ +@props([ + // [[route, icon, translation key], …] or [['label' => …, 'items' => [...]], …] + 'groups', + // dot-prefix for the labels, e.g. 'dashboard.nav' or 'admin.nav' + 'prefix', + // console routes are matched through AdminArea so recovery hostnames still + // highlight; the portal uses the router directly + 'console' => false, + 'footer' => null, +]) +{{-- + The sidebar, shared by both shells. + + Light, like everything else. The console used to be dark, which made it a + second product: two ideas of what a card is, two oranges, two status + palettes. What actually differs between an operator and a customer is + density, and that lives in the tokens — not here. +--}} + + +{{-- Backdrop. Tapping it closes the drawer, which is the behaviour every + phone user tries first. --}} +
diff --git a/resources/views/components/shell/topbar.blade.php b/resources/views/components/shell/topbar.blade.php new file mode 100644 index 0000000..a172e5a --- /dev/null +++ b/resources/views/components/shell/topbar.blade.php @@ -0,0 +1,30 @@ +@props(['crumbs' => []]) +{{-- + The bar the content scrolls under. Translucent with a blur rather than a + solid strip: on a light ground a solid bar reads as chrome bolted on top, + and the blur is the one borrowed detail worth keeping. +--}} +
+ + + {{-- On a phone only the leaf survives: the trail is a desktop affordance, + and the intermediate crumbs push the page name off screen. --}} + + +
+ {{ $slot }} +
diff --git a/resources/views/components/ui/alert.blade.php b/resources/views/components/ui/alert.blade.php index 7d54139..2a68382 100644 --- a/resources/views/components/ui/alert.blade.php +++ b/resources/views/components/ui/alert.blade.php @@ -7,6 +7,6 @@ 'danger' => 'bg-danger-bg border-danger-border text-danger', ]; @endphp -
merge(['class' => 'rounded border px-3 py-2 text-sm '.($variants[$variant] ?? $variants['info'])]) }}> +
merge(['class' => 'rounded border px-4 py-3 text-sm '.($variants[$variant] ?? $variants['info'])]) }}> {{ $slot }}
diff --git a/resources/views/components/ui/badge.blade.php b/resources/views/components/ui/badge.blade.php index 821bca0..a66823f 100644 --- a/resources/views/components/ui/badge.blade.php +++ b/resources/views/components/ui/badge.blade.php @@ -24,7 +24,7 @@ 'info' => 'bg-info-bg border-info-border text-info', ][$tone]; @endphp -merge(['class' => 'inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium '.$classes]) }}> +merge(['class' => 'inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-1 text-xs font-semibold '.$classes]) }}> {{ $slot }} diff --git a/resources/views/components/ui/button.blade.php b/resources/views/components/ui/button.blade.php index af60a40..cde07a7 100644 --- a/resources/views/components/ui/button.blade.php +++ b/resources/views/components/ui/button.blade.php @@ -3,6 +3,10 @@ 'size' => 'md', 'type' => 'button', 'loading' => false, + // Given an href this renders an anchor instead. A link that looks like a + // button must still BE a link: middle-click, "open in new tab" and the + // status bar all come from the element, not from the styling. + 'href' => null, ]) @php $base = 'inline-flex items-center justify-center gap-2 rounded font-semibold transition select-none disabled:opacity-50 disabled:pointer-events-none'; @@ -19,21 +23,30 @@ 'secondary' => 'border border-line-strong bg-surface text-body hover:bg-surface-hover', 'ghost' => 'text-body hover:bg-surface-hover', 'danger' => 'bg-danger text-on-accent hover:opacity-90', + // The document primary: ink on paper, warming to the accent on hover. + // Orange is the accent, not the loudest thing on the page — on a sheet + // full of hairlines a filled orange button is the only thing anyone + // sees, and it is rarely the most important thing on the page. + 'ink' => 'bg-ink text-bg hover:bg-accent-text', ]; $classes = $base.' '.($sizes[$size] ?? $sizes['md']).' '.($variants[$variant] ?? $variants['primary']); @endphp - +@if ($href !== null) + merge(['class' => $classes]) }}>{{ $slot }} +@else + +@endif diff --git a/resources/views/components/ui/card.blade.php b/resources/views/components/ui/card.blade.php index f795217..09e0529 100644 --- a/resources/views/components/ui/card.blade.php +++ b/resources/views/components/ui/card.blade.php @@ -1,11 +1,19 @@ @props([ 'title' => null, - 'padding' => 'p-6', + 'padding' => 'p-5', ]) +{{-- + The unit every panel is built from. Depth is one warm, broad shadow — a + neutral grey drop shadow belongs to a different design language and reads + as cold against this ground. +--}}
merge(['class' => 'rounded-lg border border-line bg-surface shadow-xs']) }}> @if ($title) -
-

{{ $title }}

+ {{-- A real heading, not a small-caps label. It was set in --text-faint, + which is 2.8:1 on white: a decoration token used for something a + reader has to read. --}} +
+

{{ $title }}

@endif
diff --git a/resources/views/components/ui/input.blade.php b/resources/views/components/ui/input.blade.php index 6b87243..ba76336 100644 --- a/resources/views/components/ui/input.blade.php +++ b/resources/views/components/ui/input.blade.php @@ -10,7 +10,7 @@ $errors ??= new \Illuminate\Support\ViewErrorBag; $id = $attributes->get('id', $name); $hasError = $errors->has($name); - $field = 'block w-full rounded border bg-surface px-3 py-2 text-sm text-ink placeholder:text-faint transition ' + $field = 'block w-full rounded border bg-surface px-3.5 py-2.5 text-sm text-ink placeholder:text-faint transition ' .($hasError ? 'border-danger bg-danger-bg' : 'border-line'); @endphp
@@ -27,7 +27,7 @@ > @if ($hint && ! $hasError) -

{{ $hint }}

+

{{ $hint }}

@endif @error($name) diff --git a/resources/views/components/ui/nav-item.blade.php b/resources/views/components/ui/nav-item.blade.php index 1586e14..aa4beb8 100644 --- a/resources/views/components/ui/nav-item.blade.php +++ b/resources/views/components/ui/nav-item.blade.php @@ -6,21 +6,26 @@ @php // items-center, not baseline: the icon is a block-level svg (Tailwind's // preflight makes it one) and must sit BESIDE the label, never above it. - $base = 'flex items-center gap-3 rounded border-l-2 px-3 py-2 text-sm font-medium min-h-11'; + // R18. + $base = 'relative flex min-h-11 items-center gap-3 rounded px-3 text-sm'; - // The label is a flex row of its own so that an icon handed to the default - // slot instead of the icon slot still lands next to the text. Passing it in - // the default slot put a display:block svg into an inline span and pushed - // the label onto a second line — the sidebar entry then stood twice as tall - // as every other one. Layout must not depend on which slot was used. + // The label is a flex row of its own so an icon handed to the default slot + // instead of the icon slot still lands next to the text. Passing it in the + // default slot put a display:block svg into an inline span and pushed the + // label onto a second line — the entry then stood twice as tall as every + // other one. Layout must not depend on which slot was used. $label = 'flex min-w-0 items-center gap-3'; + + // Active is a soft pill with a 3px accent tab flush at its left edge — a + // physical "you are here" marker rather than a colour wash. The tab is a + // pseudo-element via before:, so it cannot push the label around. $state = $active - ? 'bg-accent-subtle text-accent-text border-accent' - : 'text-muted border-transparent hover:bg-surface-hover hover:text-body'; + ? 'bg-surface-hover font-semibold text-ink before:absolute before:left-0 before:top-2.5 before:bottom-2.5 before:w-[3px] before:rounded-r before:bg-accent before:content-[\'\']' + : 'font-medium text-muted hover:bg-surface-hover hover:text-ink'; @endphp @if ($disabled) - {{-- Section not built yet: render a non-interactive item, never a dead link. --}} - merge(['class' => $base.' border-transparent text-faint cursor-not-allowed']) }}> + {{-- Section not built yet: a non-interactive item, never a dead link. --}} + merge(['class' => $base.' cursor-not-allowed font-medium text-faint']) }}> @isset($icon){{ $icon }}@endisset {{ $slot }} diff --git a/resources/views/errors/layout.blade.php b/resources/views/errors/layout.blade.php index d4491bf..3dc445f 100644 --- a/resources/views/errors/layout.blade.php +++ b/resources/views/errors/layout.blade.php @@ -37,62 +37,66 @@ --}} @verbatim @endverbatim
-
+
{{ __('errors.heading') }}{{ $code }}
+ CluPilot

{{ $title }}

@if ($body)

{{ $body }}

@@ -108,7 +112,7 @@
-

CluPilot Cloud

+

CluPilot Cloud

diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index f924f3d..14cc0b3 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -1,103 +1,67 @@ - - - - - - {{ $title ?? __('admin.console') }} - @vite(['resources/css/app.css', 'resources/js/app.js']) - @livewireStyles + -{{-- theme-admin swaps every design token to the dark Tactical-Terminal set. --}} - +{{-- + theme-admin no longer swaps the palette. It used to load a complete dark set + — its own surfaces, its own orange, its own status triad — which made the + console a second product and forced every shared component to work in two + worlds. It now carries density only: smaller type, tighter rows, tighter + corners. Colour comes from one place. +--}} + + @php $release = App\Services\Deployment\Release::current(); @endphp +
- - -
+ ]], + ['label' => __('admin.nav_group.system'), 'items' => [ + ['admin.secrets', 'lock', 'secrets', 'secrets.manage'], + ['admin.settings', 'settings', 'settings', null], + ]], + ]" + />
-
- - -
- + @auth
- -
+
@csrf -
@endauth -
+ -
+
{{ $slot }}
@@ -114,13 +78,12 @@ x-transition:leave-start="opacity-100 translate-y-0" x-transition:leave-end="opacity-0 translate-y-12" role="status" aria-live="polite" - class="fixed bottom-6 left-1/2 z-[80] -translate-x-1/2 rounded-pill border border-line bg-surface-2 px-5 py-3 text-sm font-medium text-ink shadow-md" + class="fixed bottom-6 left-1/2 z-[80] -translate-x-1/2 rounded-pill bg-plate px-5 py-3 text-sm font-semibold text-white shadow-md" >
@livewire('wire-elements-modal') - @livewireScripts diff --git a/resources/views/layouts/portal-app.blade.php b/resources/views/layouts/portal-app.blade.php index 0a17bcb..e4c510c 100644 --- a/resources/views/layouts/portal-app.blade.php +++ b/resources/views/layouts/portal-app.blade.php @@ -1,16 +1,9 @@ - - - - - - {{ $title ?? config('app.name') }} - @vite(['resources/css/app.css', 'resources/js/app.js']) - @livewireStyles + - + @php $maintenanceWindows = collect(); if (auth()->check()) { @@ -21,12 +14,13 @@ } } @endphp + @if (auth()->check() && ! ($portalCustomer ?? null)) {{-- No customer behind this login (e.g. an operator account): say so, or every customer action on these pages looks like a dead button. --}} -
- - {{ __('dashboard.no_customer_title') }} +
+ + {{ __('dashboard.no_customer_title') }} {{ __('dashboard.no_customer_hint') }} @if (auth()->user()->can('console.view')) {{ __('dashboard.no_customer_cta') }} @@ -35,54 +29,34 @@ @endif
- {{-- Sidebar: fixed drawer on small screens, fixed full-height column on large (R7). --}} - - - {{-- Mobile backdrop --}} -
+
-
- - {{-- Impersonation is a MODE, not a notice: it stays visible rather - than hiding in a dropdown, because acting as someone else - without noticing is how mistakes happen. --}} + + {{-- Impersonation is a MODE, not a notice: it stays visible + rather than hiding in a dropdown, because acting as someone + else without noticing is how mistakes happen. --}} @if (session('impersonator_id')) -
- - {{ __('impersonate.banner', ['name' => auth()->user()->name]) }} +
+ + {{ __('impersonate.banner', ['name' => auth()->user()->name]) }}
@csrf
@endif -
- - {{-- Notices --}} @if ($maintenanceWindows->isNotEmpty()) -
+
-

{{ __('maintenance.notices') }}

+

{{ __('maintenance.notices') }}

@foreach ($maintenanceWindows as $mw) @php $isActive = now()->betweenIncluded($mw->starts_at, $mw->ends_at); @endphp
- +
-

{{ $mw->title }}

+

{{ $mw->title }}

{{ $isActive ? __('maintenance.banner_active', ['end' => $mw->ends_at->isoFormat('LT')]) @@ -130,15 +101,15 @@ @auth

- -
+
@csrf - @@ -146,9 +117,9 @@
@endauth -
+ -
+
{{ $slot }}
@@ -168,7 +139,7 @@ x-transition:leave-end="opacity-0 translate-y-12" role="status" aria-live="polite" - class="fixed bottom-6 left-1/2 z-[80] -translate-x-1/2 rounded-pill bg-ink px-5 py-3 text-sm font-medium text-on-accent shadow-md" + class="fixed bottom-6 left-1/2 z-[80] -translate-x-1/2 rounded-pill bg-plate px-5 py-3 text-sm font-semibold text-white shadow-md" >
diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index 3d1563c..d84813b 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -14,10 +14,10 @@
- CluPilot + CluPilot
-

{{ __('auth.login_title') }}

+

{{ __('auth.login_title') }}

{{ __('auth.login_subtitle') }}

@if (session('status')) diff --git a/resources/views/livewire/auth/register.blade.php b/resources/views/livewire/auth/register.blade.php index 883a5fb..05af8fa 100644 --- a/resources/views/livewire/auth/register.blade.php +++ b/resources/views/livewire/auth/register.blade.php @@ -14,10 +14,10 @@
- CluPilot + CluPilot
-

{{ __('auth.register_title') }}

+

{{ __('auth.register_title') }}

{{ __('auth.register_subtitle') }}

diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 6ce715d..ca91c7d 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -1,272 +1,262 @@ -
- {{-- Header --}} -
-

{{ __('dashboard.greeting', ['name' => auth()->user()->name]) }}

- - {{ __('dashboard.system_ok') }} - -

{{ __('dashboard.as_of', ['time' => '08:42']) }}

-
+@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 +
+ {{-- 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. --}} +
+
+ + {{ __('dashboard.sheet', ['when' => $asOf->locale($locale)->isoFormat('LL, LT')]) }} + +

+ {{ $instance !== null ? __('dashboard.title_running') : __('dashboard.title_pending') }} +

+
+ + @if ($instance !== null) +
+ $instance->status === 'active', + 'border-warning-border bg-warning-bg text-warning' => $instance->status !== 'active', + ])> + + {{ __('dashboard.instance_status.'.$instance->status) }} + + @if ($domain) + + {{ __('dashboard.cloud.open') }} + + @endif +
+ @endif +
{{-- Live provisioning progress (only while a run is in flight) --}} - {{-- Traffic: the one allowance that throttles the service when it runs out, - so it gets a full-width band rather than a tile in the KPI row. --}} - @if ($traffic) - @php - $state = $traffic->state(); - $bar = ['ok' => 'bg-accent', 'warn' => 'bg-warning', 'critical' => 'bg-warning', 'exhausted' => 'bg-danger'][$state]; - $frame = [ - 'ok' => 'border-line', - 'warn' => 'border-warning-border bg-warning-bg', - 'critical' => 'border-warning-border bg-warning-bg', - 'exhausted' => 'border-danger-border bg-danger-bg', - ][$state]; - @endphp -
-
-
-

{{ __('dashboard.traffic.title') }}

- {{ __('dashboard.traffic.resets', ['days' => $traffic->daysUntilReset()]) }} -
-

- {{ \App\Support\Bytes::human($traffic->usedBytes) }} - / {{ \App\Support\Bytes::human($traffic->quotaBytes) }} + @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. --}} +

+

{{ __('dashboard.no_instance_label') }}

+

{{ __('dashboard.no_instance_body') }}

+
+ {{ __('dashboard.no_instance_cta') }} + {{ __('dashboard.nav.support') }} +
+
+ @else + {{-- ── What the customer has ─────────────────────────────────────── --}} +
+ @if ($instance->quota_gb) +
+

{{ __('dashboard.master.storage') }}

+

{{ __('dashboard.master.gb', ['n' => $instance->quota_gb]) }}

+ {{-- 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. --}} +

{{ __('dashboard.master.storage_note') }}

+
+ @endif + +
+

{{ __('dashboard.master.users') }}

+

+ {{ $seats['total'] !== null + ? __('dashboard.master.of_total', ['used' => $seats['used'], 'total' => $seats['total']]) + : $seats['used'] }}

-
+ @if ($seats['total']) +
+ {{-- R4: width is data, not decoration. --}} +
+
+ @endif + -
- {{-- R4: width is data, not decoration — the only inline style allowed. --}} -
-
- -
-

- @if ($state === 'exhausted') - {{ __('dashboard.traffic.exhausted') }} - @elseif ($state === 'ok') - {{ __('dashboard.traffic.remaining', ['amount' => \App\Support\Bytes::human($traffic->remainingBytes())]) }} - @else - {{ __('dashboard.traffic.almost', ['percent' => $traffic->percent()]) }} + @if ($traffic) + @php $state = $traffic->state(); @endphp +

+

{{ __('dashboard.traffic.title') }}

+

{{ Bytes::human($traffic->usedBytes) }}

+
+
$state === 'ok', + 'bg-warning' => $state === 'warn' || $state === 'critical', + 'bg-danger' => $state === 'exhausted', + ]) + style="width: {{ min(100, $traffic->percent()) }}%">
+
+

{{ __('dashboard.traffic.of', ['total' => Bytes::human($traffic->quotaBytes)]) }}

+ @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. --}} + + {{ __('dashboard.traffic.buy') }} + @endif -

- @if ($state !== 'ok') - {{-- The offer belongs next to the warning: someone reading that - they are nearly out should not have to go looking. --}} - - {{ __('dashboard.traffic.buy') }} - +
+ @endif + + @if ($location) +
+

{{ __('dashboard.master.location') }}

+

{{ $location['name'] }}

+ @if ($location['note']) +

{{ $location['note'] }}

+ @endif +
+ @endif + + +
+ {{-- ── The proof register ────────────────────────────────────── --}} +
+
+

{{ __('dashboard.proofs.label') }}

+ {{ __('dashboard.proofs.all') }} +
+ + @if ($proofs === []) +

{{ __('dashboard.proofs.empty') }}

+ @else +
+ + + @foreach ($proofs as $proof) + + + + + @endforeach + +
+ {{ __('dashboard.proofs.item.'.$proof['key']) }} + + {{ $proof['at']?->locale($locale)->isoFormat('DD.MM. HH:mm') ?? '—' }}@if ($proof['note']) · {{ $proof['note'] }}@endif + + + $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', + ])> + + {{ __('dashboard.proofs.state.'.$proof['state']) }} + +
+
+ @endif +
+ +
+ {{-- ── The contract ───────────────────────────────────────── --}} +
+

{{ __('dashboard.master.label') }}

+
+
+
{{ __('dashboard.master.package') }}
+
+ {{ __('billing.plan.'.($instance->plan ?: 'team')) }} + @if ($planPrice) + + {{ __('dashboard.master.per_'.($planPrice['term'] === 'yearly' ? 'year' : 'month'), ['amount' => $money($planPrice['cents'], $planPrice['currency'])]) }} + + @endif +
+
+ @if ($contract?->performance) +
+
{{ __('dashboard.master.operation') }}
+
{{ __('billing.perf.'.$contract->performance) }}
+
+ @endif + @if ($domain) +
+
{{ __('dashboard.master.address') }}
+
{{ $domain }}
+
+ @endif +
+
+ + @if ($nextInvoice) +
+

{{ __('dashboard.invoice.label') }}

+
+ @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. --}} +
+
{{ __('dashboard.invoice.plan') }}
+
{{ $money($nextInvoice['plan'], $nextInvoice['currency']) }}
+
+
+
{{ __('dashboard.invoice.addons') }}
+
{{ $money($nextInvoice['addons'], $nextInvoice['currency']) }}
+
+ @endif +
+
{{ __('dashboard.invoice.amount') }}
+
+ {{ $money($nextInvoice['total'], $nextInvoice['currency']) }} + {{ __('dashboard.invoice.net') }} +
+
+
+
{{ __('dashboard.invoice.due') }}
+
{{ $nextInvoice['due']?->locale($locale)->isoFormat('LL') ?? '—' }}
+
+
+ + {{ __('dashboard.invoice.all') }} + +
+ @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. --}} + @endif
-
+ + + {{-- ── The three things customers come here to do ─────────────────── --}} +
+ @foreach ([ + ['users', 'users', 'add_user'], + ['backups', 'rotate-ccw', 'restore'], + ['billing', 'plus', 'grow'], + ] as [$route, $icon, $key]) + + + + + + {{ __('dashboard.quick.'.$key.'_title') }} + {{ __('dashboard.quick.'.$key.'_body') }} + + + @endforeach +
@endif - - {{-- KPI row --}} -
- {{-- Storage ring --}} -
-
- -
-
- {{ $storagePercent }}% - {{ __('dashboard.occupied') }} -
-
-
-
-

{{ __('dashboard.kpi.storage') }}

-

{{ $storageUsed }}/ {{ $storageQuota }} GB

-

{{ __('dashboard.storage_week') }}

-
-
- - {{-- Users --}} -
-

{{ __('dashboard.kpi.users') }}

-

{{ $usersActive }}/ {{ $usersQuota }}

-

{{ __('dashboard.users_detail') }}

-
- - {{-- Last backup --}} -
-

{{ __('dashboard.kpi.backup') }}

-

- {{ $lastBackup }} -

-

{{ __('dashboard.backup_verified') }}

-
- - {{-- Availability sparkline --}} -
-

{{ __('dashboard.kpi.availability') }}

-

{{ $availability }}%

-
- -
-
-
- - {{-- Upsell --}} -
- {{-- Shimmer sweep (left → right), disabled for reduced motion --}} - - -
-

{{ __('dashboard.upsell.title', ['percent' => $storagePercent]) }}

-

{{ __('dashboard.upsell.body') }}

-
- - {{ __('dashboard.upsell.cta') }} - -
- -
- {{-- Left column --}} -
- {{-- Cloud card --}} -
- -
-

{{ __('dashboard.cloud.package') }}

{{ $cloud['package'] }}

-

{{ __('dashboard.cloud.status') }}

{{ __('dashboard.cloud.active_since', ['date' => $cloud['since']]) }}

-

{{ __('dashboard.cloud.location') }}

{{ $cloud['location'] }}

-

{{ __('dashboard.cloud.version') }}

{{ $cloud['version'] }} · {{ __('dashboard.cloud.current') }}

-
-
- - {{-- Onboarding checklist (Alpine) --}} -
-
-

{{ __('dashboard.onboarding.title') }}

- -
-
-
-
-
    - -
-
-
- - {{-- Right column --}} -
- {{-- Backups --}} -
-
-

{{ __('dashboard.backups.title') }}

- {{ __('dashboard.backups.schedule') }} -
-
    - @foreach ($backups as $b) -
  • - - {{ $b['when'] }} - {{ $b['size'] }} - {{ __('dashboard.backups.restore') }} -
  • - @endforeach -
-

- - {{ __('dashboard.backups.note') }} -

-
- - {{-- Modules --}} -
-
-

{{ __('dashboard.modules.title') }}

- {{ __('dashboard.modules.addon') }} -
-
    - @foreach ($modules as $m) -
  • - -
    -

    {{ $m['name'] }}

    -

    {{ $m['desc'] }}

    -
    - @if ($m['status'] === 'active') - {{ __('dashboard.modules.active') }} - @elseif ($m['status'] === 'available') - {{ __('dashboard.modules.add') }} - @else - {{ __('dashboard.modules.soon') }} - @endif -
  • - @endforeach -
-
- - {{-- Activity feed --}} -
-
-

{{ __('dashboard.activity') }}

- {{ __('dashboard.activity_live') }} -
-
    - @foreach ($activity as $a) -
  • - -
    -

    {{ $a['text'] }}

    -

    {{ $a['time'] }}

    -
    -
  • - @endforeach -
-
-
-
diff --git a/resources/views/status.blade.php b/resources/views/status.blade.php index 6fe0d46..7fea40c 100644 --- a/resources/views/status.blade.php +++ b/resources/views/status.blade.php @@ -8,27 +8,24 @@ - @verbatim