Offene Punkte: nach Dringlichkeit gruppiert, in der Formensprache der Bereitschaftsseite
parent
566d5fc47f
commit
d7bb0f2e63
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue