Offene Punkte: nach Dringlichkeit gruppiert, in der Formensprache der Bereitschaftsseite

main
nexxo 2026-08-02 16:35:14 +02:00
parent 566d5fc47f
commit d7bb0f2e63
6 changed files with 180 additions and 58 deletions

View File

@ -24,8 +24,10 @@ class OpenWork extends Component
public function render()
{
return view('livewire.admin.open-work', [
'items' => Register::all(),
'groups' => Register::grouped(),
'counts' => Register::counts(),
'worst' => Register::worst(),
'total' => count(Register::all()),
])->title(__('open_work.title'));
}
}

View File

@ -108,6 +108,55 @@ final class OpenWork
];
}
/**
* Die Zustände nach Dringlichkeit, und das ist keine Geschmacksfrage.
*
* Sortiert wird danach, was ein offener Punkt HEUTE anrichtet: verkauft und
* nicht geliefert kostet in diesem Moment Geld und Vertrauen. Halb gebaut
* kann jederzeit jemandem vor die Füße fallen. Ungeprüft ist ein Risiko,
* das noch niemand gesehen hat. Beschlossen tut gar nichts, bis jemand
* anfängt.
*
* Ohne diese Reihenfolge stand die Liste in der Reihenfolge, in der sie
* geschrieben wurde also zufällig.
*/
public const ORDER = [self::SOLD, self::PARTIAL, self::UNPROVEN, self::PLANNED];
/**
* Nach Zustand gruppiert, dringendste Gruppe zuerst. Leere Gruppen fallen
* weg eine Überschrift ohne Inhalt liest sich wie ein Fehler.
*
* @return array<string, list<array<string, string>>>
*/
public static function grouped(): array
{
$groups = [];
foreach (self::ORDER as $state) {
$items = array_values(array_filter(self::all(), fn (array $i) => $i['state'] === $state));
if ($items !== []) {
$groups[$state] = $items;
}
}
return $groups;
}
/** Der dringendste Zustand, der gerade vorkommt — oder null, wenn nichts offen ist. */
public static function worst(): ?string
{
foreach (self::ORDER as $state) {
foreach (self::all() as $item) {
if ($item['state'] === $state) {
return $state;
}
}
}
return null;
}
/** @return array<string, int> Zustand => Anzahl */
public static function counts(): array
{

View File

@ -4,6 +4,8 @@ return [
'title' => 'Offene Punkte',
'subtitle' => 'Was angefangen und nicht fertig ist — mit dem, was passiert, wenn es so bleibt.',
'summary' => '{1} Ein offener Punkt.|[2,*] :n offene Punkte.',
'empty' => 'Nichts offen. Das ist selten und meistens ein Zeichen, dass jemand vergessen hat einzutragen.',
'state' => [

View File

@ -4,6 +4,8 @@ return [
'title' => 'Open work',
'subtitle' => 'What was started and is not finished — with what happens if it stays that way.',
'summary' => '{1} One open item.|[2,*] :n open items.',
'empty' => 'Nothing open. That is rare, and usually a sign somebody forgot to write it down.',
'state' => [

View File

@ -1,61 +1,87 @@
<div class="space-y-6">
{{-- Kopf wie überall in der Konsole: Augenbraue, Titel, ein Satz darunter. --}}
<header class="animate-rise">
<p class="lbl">{{ __('admin.console') }}</p>
<h1 class="mt-[7px] text-[23px] font-bold leading-[1.12] tracking-[-0.03em] text-ink min-[901px]:text-[30px]">
{{ __('open_work.title') }}
</h1>
<p class="mt-2 max-w-[76ch] text-sm leading-relaxed text-muted">{{ __('open_work.subtitle') }}</p>
</header>
{{-- Gebaut wie die Bereitschaftsseite, und das mit Absicht.
{{-- Die Zählung nach Zustand, ganz oben. Sie beantwortet die einzige Frage,
die man vor der Liste hat: ist etwas dabei, das gerade Geld kostet? --}}
@if ($counts)
<div class="flex flex-wrap gap-2 animate-rise [animation-delay:40ms]">
@foreach (['sold', 'partial', 'unproven', 'planned'] as $state)
@if (($counts[$state] ?? 0) > 0)
<span class="inline-flex items-center gap-2 rounded-pill border px-3 py-1 text-xs font-medium
{{ $state === 'sold'
? 'border-danger/30 bg-danger/5 text-danger'
: 'border-line bg-surface text-body' }}">
<span class="tabular-nums font-bold">{{ $counts[$state] }}</span>
{{ __('open_work.state.'.$state) }}
</span>
@endif
@endforeach
Die erste Fassung nahm das Muster der Host-Anlegen-Seite: `lbl`-Etikett,
große Kopfzeile, pro Punkt eine fette Karte mit einer Beschreibungsliste
darin. Das ist die Sprache einer Formularseite, nicht die einer Liste mit
Zuständen und für dieselbe Aufgabe gibt es in dieser Konsole längst eine:
Bereitschaft. Zwei Muster für ein Problem sind zwei Sprachen.
Also: schlichte Kopfzeile, ein Sammelbanner mit dem dringendsten Zustand,
danach je Zustand eine Karte mit einer `divide-y`-Liste. Ein Punkt ist eine
Zeile mit Symbol, Titel und drei kleinen Angaben darunter nicht
ein eigener Kasten. --}}
@php
// Ton je Zustand, an einer Stelle. Nur „verkauft, nicht geliefert" ist rot:
// wenn alles auffällt, fällt nichts auf.
$tone = [
App\Support\OpenWork::SOLD => ['alert-triangle', 'text-danger'],
App\Support\OpenWork::PARTIAL => ['alert-triangle', 'text-warning'],
App\Support\OpenWork::UNPROVEN => ['shield-check', 'text-muted'],
App\Support\OpenWork::PLANNED => ['bell', 'text-muted'],
];
@endphp
<div class="space-y-5">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('open_work.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('open_work.subtitle') }}</p>
</div>
@if ($groups)
{{-- Ein Satz, der die Frage vor der Liste beantwortet: ist etwas dabei,
das gerade Geld kostet? Der Ton kommt vom dringendsten Zustand, der
wirklich vorkommt nicht von einer festen Farbe. --}}
@php([$worstIcon, $worstText] = $tone[$worst])
<div class="flex items-center gap-3 rounded-lg border p-5 shadow-xs animate-rise
{{ $worst === App\Support\OpenWork::SOLD ? 'border-danger/30 bg-danger/5' : 'border-line bg-surface' }}">
<x-ui.icon :name="$worstIcon" class="{{ $worstText }}" />
<p class="text-lg font-semibold {{ $worstText }}">
{{ trans_choice('open_work.summary', $total, ['n' => $total]) }}
</p>
</div>
@foreach ($groups as $state => $items)
@php([$icon, $text] = $tone[$state])
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise">
<div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<h2 class="font-semibold text-ink">{{ __('open_work.state.'.$state) }}</h2>
<span class="text-xs tabular-nums text-muted">{{ count($items) }}</span>
</div>
<p class="mt-1 text-sm text-muted">{{ __('open_work.state_hint.'.$state) }}</p>
<ul class="mt-3 divide-y divide-line">
@foreach ($items as $item)
<li class="flex flex-wrap items-start gap-3 py-3">
<x-ui.icon :name="$icon" class="mt-0.5 size-4 shrink-0 {{ $text }}" />
<div class="min-w-0 flex-1">
{{-- Keine Zustands-Plakette an der Zeile: die
Karte darüber sagt den Zustand schon, und
alle Zeilen darin teilen ihn. Sie stand
hier und wiederholte dasselbe Wort dreimal
pro Bildschirm, ohne eine Frage zu
beantworten. --}}
<p class="font-medium text-ink">{{ $item['title'] }}</p>
{{-- Was fehlt, dann die Folge. Die Folge zuletzt,
weil sie den Satz ist, der über die
Reihenfolge entscheidet und der letzte
bleibt hängen. --}}
<p class="mt-1 max-w-[76ch] text-sm text-muted">{{ $item['missing'] }}</p>
<p class="mt-1 max-w-[76ch] text-sm {{ $state === App\Support\OpenWork::SOLD ? 'text-danger' : 'text-body' }}">
<span class="text-muted">{{ __('open_work.risk') }}:</span> {{ $item['risk'] }}
</p>
<p class="mt-1 font-mono text-xs text-faint break-all">{{ $item['where'] }}</p>
</div>
</li>
@endforeach
</ul>
</div>
@endforeach
@else
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise">
<p class="text-sm text-muted">{{ __('open_work.empty') }}</p>
</div>
@endif
@forelse ($items as $item)
<article class="rounded-xl border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<div class="flex flex-wrap items-start justify-between gap-3">
<h2 class="min-w-0 text-md font-bold tracking-[-0.01em] text-ink">{{ $item['title'] }}</h2>
{{-- „Verkauft, nicht geliefert" bekommt als einziger Zustand die
Warnfarbe. Wenn alles auffällt, fällt nichts auf. --}}
<span class="shrink-0 rounded-pill border px-2.5 py-1 text-xs font-medium
{{ $item['state'] === 'sold'
? 'border-danger/30 bg-danger/5 text-danger'
: 'border-line bg-surface-2 text-muted' }}"
title="{{ __('open_work.state_hint.'.$item['state']) }}">
{{ __('open_work.state.'.$item['state']) }}
</span>
</div>
<dl class="mt-4 space-y-3">
@foreach (['where', 'missing', 'risk'] as $field)
<div class="grid gap-x-6 gap-y-1 sm:grid-cols-[minmax(0,150px)_minmax(0,1fr)] sm:items-baseline">
<dt class="text-xs text-muted">{{ __('open_work.'.$field) }}</dt>
<dd class="text-sm leading-relaxed {{ $field === 'where' ? 'font-mono text-xs text-body break-all' : 'text-body' }}">
{{ $item[$field] }}
</dd>
</div>
@endforeach
</dl>
</article>
@empty
<p class="rounded-xl border border-line bg-surface p-6 text-sm leading-relaxed text-muted shadow-xs">
{{ __('open_work.empty') }}
</p>
@endforelse
</div>

View File

@ -2,6 +2,7 @@
use App\Support\Navigation;
use App\Support\OpenWork;
use Illuminate\Support\Facades\File;
/**
* Die Seite mit den offenen Punkten.
@ -56,3 +57,43 @@ it('steht jedem offen, der die Konsole öffnen darf', function () {
expect($entry)->not->toBeNull()
->and($entry[3])->toBeNull();
});
it('stellt das nach vorne, was gerade Schaden anrichtet', function () {
// Nicht in der Reihenfolge, in der es geschrieben wurde — sondern danach,
// was ein Punkt HEUTE anrichtet. „Verkauft, nicht geliefert" kostet in
// diesem Moment Geld; „beschlossen" tut gar nichts, bis jemand anfängt.
expect(array_keys(OpenWork::grouped()))
->toBe(array_values(array_filter(
OpenWork::ORDER,
fn (string $s) => (OpenWork::counts()[$s] ?? 0) > 0,
)));
// Und der Sammelsatz oben nennt den dringendsten Zustand, der wirklich
// vorkommt — nicht einen festen.
expect(OpenWork::worst())->toBe(array_key_first(OpenWork::grouped()));
});
it('lässt keine leere Gruppe stehen', function () {
// Eine Überschrift ohne Inhalt liest sich wie ein Ladefehler. Derselbe
// Fehler, der in der Seitenleiste schon einmal zuschlug.
foreach (OpenWork::grouped() as $state => $items) {
expect($items)->not->toBeEmpty("Gruppe {$state} ist leer.");
}
});
it('folgt der Formensprache der Bereitschaftsseite, nicht der eines Formulars', function () {
// Die erste Fassung nahm das Muster der Anlegen-Seite: `lbl`-Etikett,
// große Kopfzeile, pro Punkt eine eigene Karte. Für eine Liste mit
// Zuständen gibt es in dieser Konsole längst ein Muster, und zwei Muster
// für ein Problem sind zwei Sprachen.
$page = File::get(resource_path('views/livewire/admin/open-work.blade.php'));
expect($page)->toContain('divide-y divide-line')
// Und keine Zustands-Plakette je Zeile: die Karte darüber sagt den
// Zustand, alle Zeilen darin teilen ihn. Sie wiederholte dasselbe Wort
// dreimal pro Bildschirm.
->and($page)->not->toContain('<x-ui.badge')
// Kein Etikett und keine Formular-Kopfzeile.
->and($page)->not->toContain('lbl">')
->and($page)->not->toContain('min-[901px]:text-[30px]');
});