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:
@@ -47,11 +47,40 @@ export function AnstehendListe({
|
||||
leerText: string;
|
||||
}) {
|
||||
const [alle, setAlle] = useState(false);
|
||||
const sichtbar = alle ? eintraege : eintraege.slice(0, VORSCHAU);
|
||||
const weitere = eintraege.length - sichtbar.length;
|
||||
// Namensfilter aus dem Workshop (Anforderung 2). Im Browser und nicht über
|
||||
// die Adresse, aus demselben Grund wie das Aufklappen: die Einträge sind
|
||||
// vollständig geladen, es gibt nichts nachzuholen.
|
||||
const [suche, setSuche] = useState("");
|
||||
const begriff = suche.trim().toLowerCase();
|
||||
// Wortweise und am Wortanfang — dieselbe Regel wie in der Mitarbeiterliste,
|
||||
// damit „gru" Gruber findet und nicht auch jede Notiz mit „Arbeitsgruppe".
|
||||
// Der Hinweis wird bewusst nicht durchsucht: gesucht wird nach Personen.
|
||||
const gefiltert = begriff
|
||||
? eintraege.filter((e) =>
|
||||
e.label
|
||||
.toLowerCase()
|
||||
.split(/[\s,;/-]+/)
|
||||
.some((wort) => wort.startsWith(begriff))
|
||||
)
|
||||
: eintraege;
|
||||
const sichtbar = alle ? gefiltert : gefiltert.slice(0, VORSCHAU);
|
||||
const weitere = gefiltert.length - sichtbar.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Erst ab neun Einträgen: darunter sieht man die ganze Liste ohnehin,
|
||||
und ein Suchfeld über acht Zeilen ist mehr Bedienelement als Hilfe. */}
|
||||
{eintraege.length > VORSCHAU && (
|
||||
<input
|
||||
type="search"
|
||||
value={suche}
|
||||
onChange={(e) => setSuche(e.target.value)}
|
||||
placeholder="Name filtern…"
|
||||
aria-label="Anstehende Ereignisse nach Name filtern"
|
||||
className="mb-2 w-full rounded border border-border-strong bg-white px-2.5 py-1.5 text-sm text-ink
|
||||
placeholder:text-ink-muted focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||
/>
|
||||
)}
|
||||
<ul className="flex flex-col divide-y divide-border-subtle">
|
||||
{sichtbar.map((item) => {
|
||||
// Überfällig gibt es nur bei Wiedervorlagen: die anderen Arten
|
||||
@@ -83,6 +112,14 @@ export function AnstehendListe({
|
||||
);
|
||||
})}
|
||||
{eintraege.length === 0 && <p className="py-2 text-sm text-ink-muted">{leerText}</p>}
|
||||
{/* Ein leeres Ergebnis mit Suchbegriff ist etwas anderes als eine
|
||||
leere Karte: der Zeitraum stimmt, nur der Name passt nicht. */}
|
||||
{eintraege.length > 0 && gefiltert.length === 0 && (
|
||||
<p className="py-2 text-sm text-ink-muted">
|
||||
Kein Name passt zu „{suche.trim()}“. {eintraege.length}{" "}
|
||||
{eintraege.length === 1 ? "Ereignis steht" : "Ereignisse stehen"} an.
|
||||
</p>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{/* Der Knopf steht nur da, wenn es etwas zu holen gibt. Ist die Liste
|
||||
|
||||
@@ -183,11 +183,12 @@ export function PrintChecklist({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Fuss: Platz für Unterschriften ─────────────────── */}
|
||||
<div className="mt-6 flex gap-10 text-[8pt] text-ink-muted">
|
||||
<div className="flex-1 border-t border-ink pt-1">Datum, Unterschrift Mitarbeiter:in</div>
|
||||
<div className="flex-1 border-t border-ink pt-1">Datum, Unterschrift Personalabteilung</div>
|
||||
</div>
|
||||
{/* Hier standen zwei Unterschriftenfelder — „Datum, Unterschrift
|
||||
Mitarbeiter:in" und „… Personalabteilung". Der Kunde hat sie
|
||||
gestrichen (Anforderung 3 aus dem Workshop): das Blatt ist eine
|
||||
Arbeitsliste für die Personalabteilung und kein Dokument, das
|
||||
jemand gegenzeichnet. Wer eine Aufgabe abgehakt hat, steht ohnehin
|
||||
an der Aufgabe selbst. */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,23 +7,23 @@ import { Button } from "@/components/ui/Button";
|
||||
import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import {
|
||||
AUSTRITTSART_LABELS,
|
||||
BEENDIGUNG_NO_SHOW,
|
||||
BEENDIGUNGSART_WERTE,
|
||||
austrittsartVon,
|
||||
beendigungsartenZu,
|
||||
type Austrittsart,
|
||||
} from "@/lib/beendigung";
|
||||
import type { Database } from "@/lib/types";
|
||||
import { fmtDate, fmtName } from "@/lib/format";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
// „No Show" steht am Ende und getrennt: es ist kein Austritt im gewohnten
|
||||
// Sinn, sondern der Fall, dass jemand nie angetreten ist.
|
||||
const NO_SHOW = "No Show";
|
||||
const EXIT_REASONS = [
|
||||
"Einvernehmliche Auflösung",
|
||||
"Kündigung AN",
|
||||
"Kündigung AG",
|
||||
"Befristungsablauf",
|
||||
"Pensionierung",
|
||||
"Entlassung",
|
||||
NO_SHOW,
|
||||
];
|
||||
// Die Liste steht in lib/beendigung.ts, nicht mehr hier: der Berichtemanager
|
||||
// braucht sie ebenso, und zwei Listen liefen auseinander.
|
||||
const NO_SHOW = BEENDIGUNG_NO_SHOW;
|
||||
|
||||
type TerminatePanelProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -38,7 +38,12 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
|
||||
// Wer noch gar nicht angefangen hat, tritt fast nie aus einem anderen Grund
|
||||
// aus. Die Vorbelegung nimmt den wahrscheinlichen Fall vorweg, ohne die
|
||||
// übrigen zu verstellen.
|
||||
const [reason, setReason] = useState(employee.status === "Geplant" ? NO_SHOW : EXIT_REASONS[0]);
|
||||
const [reason, setReason] = useState(employee.status === "Geplant" ? NO_SHOW : BEENDIGUNGSART_WERTE[0]);
|
||||
// „Alle" statt einer Vorauswahl: die Einschränkung ist eine Hilfe beim
|
||||
// Suchen, keine Pflichtangabe. Wer die gewünschte Art auswendig kennt,
|
||||
// soll sie unmittelbar wählen können.
|
||||
const [austrittsart, setAustrittsart] = useState<Austrittsart | "">("");
|
||||
const waehlbar = beendigungsartenZu(austrittsart || null);
|
||||
const [note, setNote] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
@@ -113,11 +118,36 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
|
||||
{directReportCount} direkte Berichte werden automatisch der nächsthöheren Führungskraft zugeordnet.
|
||||
</div>
|
||||
)}
|
||||
{/* Die Austrittsart wählt nicht mit, sie engt ein. Gespeichert wird
|
||||
allein die Beendigungsart; auf wessen Betreiben beendet wurde,
|
||||
leitet lib/beendigung.ts daraus ab. Als zweites freies Feld liesse
|
||||
sich „Entlassung, freiwillig" erfassen, und zwei Auswertungen über
|
||||
dieselbe Person kämen zu verschiedenen Ergebnissen. */}
|
||||
<SelectField
|
||||
label="Austrittsart"
|
||||
value={austrittsart}
|
||||
onChange={(v) => {
|
||||
const neu = (v || "") as Austrittsart | "";
|
||||
setAustrittsart(neu);
|
||||
// Passt der bisher gewählte Grund nicht mehr zur Einschränkung,
|
||||
// wandert die Auswahl auf den ersten passenden. Stehenzulassen,
|
||||
// was in der Liste nicht mehr sichtbar ist, wäre ein Formular,
|
||||
// das etwas anderes absendet, als es zeigt.
|
||||
const erlaubt = beendigungsartenZu(neu || null);
|
||||
if (!erlaubt.includes(reason)) setReason(erlaubt[0]);
|
||||
}}
|
||||
options={[
|
||||
{ value: "", label: "Alle Beendigungsarten" },
|
||||
...(Object.keys(AUSTRITTSART_LABELS) as Austrittsart[]).map((a) => ({ value: a, label: AUSTRITTSART_LABELS[a] })),
|
||||
]}
|
||||
hint="Schränkt die Auswahl darunter ein. Gespeichert wird die Beendigungsart."
|
||||
/>
|
||||
<SelectField
|
||||
label="Beendigungsart"
|
||||
value={reason}
|
||||
onChange={setReason}
|
||||
options={EXIT_REASONS.map((r) => ({ value: r, label: r === NO_SHOW ? "No Show (nicht angetreten)" : r }))}
|
||||
options={waehlbar.map((r) => ({ value: r, label: r === NO_SHOW ? "No Show (nicht angetreten)" : r }))}
|
||||
hint={austrittsartVon(reason) ? AUSTRITTSART_LABELS[austrittsartVon(reason)!] : undefined}
|
||||
/>
|
||||
<TextField
|
||||
label={istNoShow ? "Wirksam am (Eintrittstag)" : "Austrittsdatum"}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user