diff --git a/app/Livewire/Admin/Maintenance.php b/app/Livewire/Admin/Maintenance.php index 60e1cc5..a4eb55c 100644 --- a/app/Livewire/Admin/Maintenance.php +++ b/app/Livewire/Admin/Maintenance.php @@ -45,6 +45,52 @@ class Maintenance extends Component : array_values(array_unique([...$current, ...$ids])); } + /** + * Set the end from the start. Typing a full timestamp by hand is the + * fiddliest part of this form, and the end is almost always "start plus a + * round number of minutes". + */ + public function setDuration(int $minutes): void + { + $this->authorize('maintenance.manage'); + + $start = $this->parsed($this->startsAt); + if ($start === null) { + // No start yet: assume the next half hour, which is what someone + // scheduling a window in a hurry means anyway. + $start = now()->addMinutes(30 - (now()->minute % 30))->startOfMinute(); + $this->startsAt = $start->format('Y-m-d\TH:i'); + } + + $this->endsAt = $start->copy()->addMinutes($minutes)->format('Y-m-d\TH:i'); + } + + /** Minutes between start and end, or null while either is unusable. */ + public function durationMinutes(): ?int + { + $start = $this->parsed($this->startsAt); + $end = $this->parsed($this->endsAt); + + if ($start === null || $end === null || $end->lessThanOrEqualTo($start)) { + return null; + } + + return (int) $start->diffInMinutes($end); + } + + private function parsed(string $value): ?\Illuminate\Support\Carbon + { + if (trim($value) === '') { + return null; + } + + try { + return \Illuminate\Support\Carbon::parse($value); + } catch (\Throwable) { + return null; // half-typed input is normal while the field is live + } + } + public function saveDraft(): void { $this->authorize('maintenance.manage'); @@ -196,6 +242,7 @@ class Maintenance extends Component 'windows' => $windows, 'datacenters' => Datacenter::query()->orderBy('name')->get(), 'hosts' => Host::query()->orderBy('datacenter')->orderBy('name')->get(), + 'durationMinutes' => $this->durationMinutes(), ]); } } diff --git a/lang/de/maintenance.php b/lang/de/maintenance.php index aa5bc90..d6f40ac 100644 --- a/lang/de/maintenance.php +++ b/lang/de/maintenance.php @@ -10,7 +10,8 @@ return [ 'col_impact' => 'Betroffen', 'col_state' => 'Status', 'col_actions' => 'Aktionen', - 'impact' => ':hosts Host(s) · :customers Kunde(n)', + 'impact_hosts' => '{0} keine Hosts|{1} 1 Host|[2,*] :count Hosts', + 'impact_customers' => '{0} keine Kunden|{1} 1 Kunde|[2,*] :count Kunden', 'state_draft' => 'Entwurf', 'state_upcoming' => 'Geplant', @@ -24,6 +25,9 @@ return [ 'field_public_hint' => 'Dieser Text erscheint im Kundenportal und in der E-Mail.', 'field_internal_ph' => 'Nur für das Team sichtbar.', 'selected' => ':n ausgewählt', + 'duration' => 'Dauer', + 'duration_value' => ':n min', + 'select_none' => 'Keine', 'select_all' => 'Alle', 'field_title' => 'Titel', 'field_public' => 'Beschreibung für Kunden', diff --git a/lang/en/maintenance.php b/lang/en/maintenance.php index 421567f..3a73935 100644 --- a/lang/en/maintenance.php +++ b/lang/en/maintenance.php @@ -10,7 +10,8 @@ return [ 'col_impact' => 'Affected', 'col_state' => 'Status', 'col_actions' => 'Actions', - 'impact' => ':hosts host(s) · :customers customer(s)', + 'impact_hosts' => '{0} no hosts|{1} 1 host|[2,*] :count hosts', + 'impact_customers' => '{0} no customers|{1} 1 customer|[2,*] :count customers', 'state_draft' => 'Draft', 'state_upcoming' => 'Scheduled', @@ -24,6 +25,9 @@ return [ 'field_public_hint' => 'This text appears in the customer portal and the email.', 'field_internal_ph' => 'Visible to your team only.', 'selected' => ':n selected', + 'duration' => 'Duration', + 'duration_value' => ':n min', + 'select_none' => 'None', 'select_all' => 'All', 'field_title' => 'Title', 'field_public' => 'Customer-facing description', diff --git a/resources/views/components/ui/icon.blade.php b/resources/views/components/ui/icon.blade.php index 9350f38..850ee8e 100644 --- a/resources/views/components/ui/icon.blade.php +++ b/resources/views/components/ui/icon.blade.php @@ -18,6 +18,8 @@ 'download' => '', 'alert-triangle' => '', 'check' => '', + 'bell' => '', + 'calendar' => '', 'qr-code' => '', 'lock' => '', 'unlock' => '', diff --git a/resources/views/livewire/admin/maintenance.blade.php b/resources/views/livewire/admin/maintenance.blade.php index 1b290f2..29cbf8d 100644 --- a/resources/views/livewire/admin/maintenance.blade.php +++ b/resources/views/livewire/admin/maintenance.blade.php @@ -4,7 +4,7 @@

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

-
+
{{-- Windows list --}}
@if ($windows->isEmpty()) @@ -27,18 +27,36 @@ {{ $w['title'] }} {{ $w['starts_at']->isoFormat('DD.MM. HH:mm') }} – {{ $w['ends_at']->isoFormat('HH:mm') }} - {{ __('maintenance.impact', ['hosts' => $w['hosts'], 'customers' => $w['affected']]) }} + + {{ trans_choice('maintenance.impact_hosts', $w['hosts'], ['count' => $w['hosts']]) }} + · + {{ trans_choice('maintenance.impact_customers', $w['affected'], ['count' => $w['affected']]) }} + {{ __('maintenance.state_'.$w['state']) }} -
+ {{-- Icon buttons: the labels wrapped to two lines and made the + column unreadable. Same pattern as the other admin tables. --}} +
@if ($w['is_draft']) - + @endif @if ($w['notifiable']) - + @endif @if ($w['cancellable']) - + @endif
@@ -73,15 +91,36 @@ class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink placeholder:text-faint focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30">
-
- @foreach ([['startsAt', 'field_start'], ['endsAt', 'field_end']] as [$field, $key]) -
- - - @error($field)

{{ $message }}

@enderror -
- @endforeach +
+
+ @foreach ([['startsAt', 'field_start'], ['endsAt', 'field_end']] as [$field, $key]) +
+ +
+ + +
+ @error($field)

{{ $message }}

@enderror +
+ @endforeach +
+ + {{-- Typing an end time by hand is the fiddliest part of this form; + these set it from the start time. --}} +
+ {{ __('maintenance.duration') }} + @foreach ([30, 60, 120, 240] as $minutes) + + @endforeach + @if ($durationMinutes !== null && ! in_array($durationMinutes, [30, 60, 120, 240], true)) + {{ __('maintenance.duration_value', ['n' => $durationMinutes]) }} + @endif +
{{-- Host picker, grouped by datacenter --}} @@ -91,23 +130,35 @@ {{ __('maintenance.selected', ['n' => count($hostIds)]) }}
@error('hostIds')

{{ $message }}

@enderror -
+
@foreach ($datacenters as $dc) - @php $dcHosts = $hosts->where('datacenter', $dc->code); @endphp + @php + $dcHosts = $hosts->where('datacenter', $dc->code); + $dcSelected = $dcHosts->whereIn('id', $hostIds)->count(); + @endphp @if ($dcHosts->isNotEmpty()) -
-
-

{{ $dc->name }}

+
+
+

+ {{ $dc->name }} + @if ($dcSelected) + {{ $dcSelected }}/{{ $dcHosts->count() }} + @endif +

+ class="rounded-pill border border-line px-2 py-0.5 text-[11px] font-semibold text-muted transition hover:border-accent-border hover:text-accent-text"> + {{ $dcSelected === $dcHosts->count() ? __('maintenance.select_none') : __('maintenance.select_all') }} +
-
+
@foreach ($dcHosts as $host) -
diff --git a/tests/Feature/Admin/MaintenanceTest.php b/tests/Feature/Admin/MaintenanceTest.php index e5e5d7e..2479185 100644 --- a/tests/Feature/Admin/MaintenanceTest.php +++ b/tests/Feature/Admin/MaintenanceTest.php @@ -110,3 +110,36 @@ it('shows the banner to an affected customer within the horizon', function () { $other = affectedCustomerOn(Host::factory()->active()->create()); expect(MaintenanceWindow::bannerFor($other))->toHaveCount(0); }); + +it('fills the end time from a duration chip', function () { + $owner = operator('Owner'); + + // With a start set, the chip just adds the minutes. + $component = Livewire::actingAs($owner)->test(App\Livewire\Admin\Maintenance::class) + ->set('startsAt', '2026-08-01T22:00') + ->call('setDuration', 120); + + expect($component->get('endsAt'))->toBe('2026-08-02T00:00') + ->and($component->instance()->durationMinutes())->toBe(120); + + // Without one, it starts at the next half hour rather than doing nothing. + $fresh = Livewire::actingAs($owner)->test(App\Livewire\Admin\Maintenance::class) + ->call('setDuration', 60); + + expect($fresh->get('startsAt'))->not->toBe('') + ->and($fresh->instance()->durationMinutes())->toBe(60); +}); + +it('reports no duration while the times are unusable', function () { + $component = Livewire::actingAs(operator('Owner'))->test(App\Livewire\Admin\Maintenance::class); + + expect($component->instance()->durationMinutes())->toBeNull(); + + // A half-typed value must not blow up the live-updating form. + $component->set('startsAt', '2026-08-')->set('endsAt', ''); + expect($component->instance()->durationMinutes())->toBeNull(); + + // An end before the start is not a duration either. + $component->set('startsAt', '2026-08-01T22:00')->set('endsAt', '2026-08-01T21:00'); + expect($component->instance()->durationMinutes())->toBeNull(); +});