Workshop-Anforderungen, erster Teil: was ohne Migration geht

Anforderung 1 — Freiwilliger vs unfreiwilliger Austritt. Die Liste der
Beendigungsarten zieht aus TerminatePanel.tsx nach lib/beendigung.ts um: der
Berichtemanager braucht sie ebenso, und zwei Listen liefen auseinander. Zwei
neue Arten (Beendigung in der Probezeit, je Seite). Auf wessen Betreiben
beendet wurde, wird aus der Art **abgeleitet** und nicht daneben gespeichert
— als zweites freies Feld liesse sich "Entlassung, freiwillig" erfassen. Das
Dropdown im Formular schraenkt die Auswahl darunter ein.

Drei Gruppen statt zwei: Befristungsablauf geschieht auf niemandes
Betreiben, ein Nichtantritt ist kein Austritt. Beide einer Seite
zuzuschlagen wuerde jede Fluktuationsquote verfaelschen.

Anforderung 2 — Namensfilter in "Anstehend", ab neun Eintraegen.

Anforderung 3 — die zwei Unterschriftenfelder im gedruckten Blatt sind weg;
"Firmenfahrzeug" steht in beiden Checklisten. has_dienstwagen sagt, ob eines
zusteht, nicht ob es uebergeben wurde.

Anforderung 4 — "+794 weitere" ist ein Knopf geworden; die Namen waren
vorher nur ueber den Export erreichbar. Stammdatenaenderung und
Gehaltsanpassung stehen nicht mehr zur Auswahl: die eine entsteht bei jeder
geaenderten Telefonnummer, die andere ist ein totes Ereignis, seit das
Gehalt in Loga liegt. Neu ist der Untertyp — Beendigungsart beim Austritt,
Art der Abwesenheit bei der Langzeitabwesenheit, im Bericht und im Export.

Anforderung 10 — zwei Kacheln. "Aktives Dienstverhaeltnis" ist nicht
dasselbe wie "Aktive Mitarbeiter:innen": dort steht, wer heute arbeitet,
hier, mit wem ein Vertrag laeuft. Sichtbar waren 806 und 10, addieren musste
man selbst.
This commit is contained in:
2026-09-15 22:35:47 +02:00
parent 1cbed1a8f5
commit 8d0c9b4b65
15 changed files with 426 additions and 48 deletions

View File

@@ -22,6 +22,8 @@ import {
AVERAGE_MEASURES,
EVENT_DATE_OPEN,
EVENT_GROUP_LABELS,
EREIGNIS_AUSWAHL,
EREIGNIS_UNTERTYP,
EVENT_REPORT_PRESETS,
EVENT_TYPE_LABELS,
GROUP_LABELS,
@@ -31,6 +33,7 @@ import {
sortKeysForDimension,
STATUS_OPTIONS,
todayIso,
untertypOptionen,
type EventGroupDimension,
type GroupDimension,
type Measure,
@@ -40,7 +43,10 @@ import {
import type { HistoryEventType } from "@/lib/types";
const SPLIT_COLORS = ["bg-brand-500", "bg-info-text", "bg-purple-text", "bg-warning-text", "bg-success-text", "bg-danger-solid"];
const EVENT_TYPES = Object.keys(EVENT_TYPE_LABELS) as HistoryEventType[];
const EVENT_TYPES = EREIGNIS_AUSWAHL;
/** Wie viele Namen eine aufgeklappte Zeile zeigt, bevor der Rest auf Klick kommt. */
const VORSCHAU_NAMEN = 12;
type SavedReport = { id: string; name: string; config: Record<string, unknown> };
type OrgOption = { id: string; name: string };
@@ -69,6 +75,8 @@ type EventsProps = CommonProps & {
eventGroup: EventGroupDimension;
eventSplit: EventGroupDimension | "";
eventType: string;
/** Beendigungsart bzw. Art der Langzeitabwesenheit; leer heisst „alle". */
eventSubtype: string;
eventFilters: { division: string; location: string; from: string; to: string };
};
@@ -227,6 +235,10 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
const searchParams = useSearchParams();
const { showToast } = useToast();
const [expandedKey, setExpandedKey] = useState<string | null>(null);
// Welche aufgeklappte Zeile ihre Namen **vollständig** zeigt. Vorher stand
// dort „+794 weitere" als blosser Text: die Auskunft, dass es mehr gibt,
// ohne einen Weg dorthin. Wer die Liste brauchte, musste sie exportieren.
const [volleListeKey, setVolleListeKey] = useState<string | null>(null);
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [newReportName, setNewReportName] = useState("");
const [savingReport, setSavingReport] = useState(false);
@@ -439,12 +451,35 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
label="Ereignistyp"
dense
value={props.eventType}
onChange={(v) => updateParams({ eventType: v || undefined })}
// Der Untertyp gehört zum Ereignistyp und fällt mit ihm weg.
// Bliebe er in der Adresse stehen, filterte eine Auswahl
// weiter, die auf dem Bildschirm nicht mehr zu sehen ist.
onChange={(v) => updateParams({ eventType: v || undefined, eventSubtype: undefined })}
options={[
{ value: "", label: "Alle Ereignistypen" },
...EVENT_TYPES.map((t) => ({ value: t, label: EVENT_TYPE_LABELS[t] })),
]}
/>
{/* Nur dort, wo es einen gibt: eine Versetzung hat keine
Beendigungsart, und ein leeres zweites Feld wäre eine Frage
ohne Antwortmöglichkeit. */}
{(() => {
const typ = props.eventType ? (props.eventType as HistoryEventType) : null;
const untertyp = typ ? EREIGNIS_UNTERTYP[typ] : undefined;
if (!untertyp) return null;
return (
<SelectField
label={untertyp.label}
dense
value={props.eventSubtype}
onChange={(v) => updateParams({ eventSubtype: v || undefined })}
options={[
{ value: "", label: untertyp.alle },
...untertypOptionen(typ).map((o) => ({ value: o, label: o })),
]}
/>
);
})()}
<SelectField
label="Gruppieren nach"
dense
@@ -677,7 +712,16 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
const isExpanded = expandedKey === row.key;
return (
<div key={row.key}>
<button type="button" onClick={() => setExpandedKey(isExpanded ? null : row.key)} className="w-full text-left">
<button
type="button"
onClick={() => {
setExpandedKey(isExpanded ? null : row.key);
// Zuklappen setzt auch die volle Liste zurück: sonst
// stünde sie beim nächsten Aufklappen unerwartet offen.
setVolleListeKey(null);
}}
className="w-full text-left"
>
<div className="mb-1 flex items-center justify-between text-sm">
<span className="font-semibold text-ink">{row.key}</span>
<span className="text-ink-body">
@@ -695,23 +739,41 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
)}
</div>
</button>
{isExpanded && (
<div className="mt-2 rounded border border-border bg-surface p-3">
<ul className="flex flex-col divide-y divide-border">
{row.people.slice(0, 12).map((p, i) => (
<li key={`${p.id}-${i}`} className="flex items-center justify-between py-1.5 text-sm">
<Link href={`/employees/${p.id}`} className="font-semibold text-ink hover:text-brand-700 hover:underline">
{p.name}
</Link>
<span className="text-xs text-ink-muted">
{p.title} · {p.team} · {fmtDate(p.entry_date)}
</span>
</li>
))}
</ul>
{row.people.length > 12 && <p className="mt-2 text-xs text-ink-muted">+{row.people.length - 12} weitere</p>}
</div>
)}
{isExpanded &&
(() => {
const alle = volleListeKey === row.key;
const sichtbar = alle ? row.people : row.people.slice(0, VORSCHAU_NAMEN);
const rest = row.people.length - VORSCHAU_NAMEN;
return (
<div className="mt-2 rounded border border-border bg-surface p-3">
{/* Bei achthundert Namen scrollt die Liste in sich,
statt die Auswertung darüber aus dem Bild zu
schieben. */}
<ul className={`flex flex-col divide-y divide-border ${alle ? "max-h-[28rem] overflow-y-auto" : ""}`}>
{sichtbar.map((p, i) => (
<li key={`${p.id}-${i}`} className="flex items-center justify-between gap-3 py-1.5 text-sm">
<Link href={`/employees/${p.id}`} className="font-semibold text-ink hover:text-brand-700 hover:underline">
{p.name}
</Link>
<span className="text-right text-xs text-ink-muted">
{p.title} · {p.team} · {fmtDate(p.entry_date)}
</span>
</li>
))}
</ul>
{rest > 0 && (
<button
type="button"
onClick={() => setVolleListeKey(alle ? null : row.key)}
className="mt-2 rounded text-xs font-semibold text-brand-700 hover:underline
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
{alle ? `Nur die ersten ${VORSCHAU_NAMEN} zeigen` : `+${rest} weitere anzeigen`}
</button>
)}
</div>
);
})()}
</div>
);
})}