Keep a note on the overview until somebody ticks it off

A note with a follow-up date is a task, and the overview is where tasks
are looked for. Until now it lived only in the employee file, which is
the one place you go when you already know who you are looking for.

Follow-ups behave differently from everything else on that card, and the
difference is the point: an entry on Monday is over on Tuesday, an
unfinished task is not. So there is no lower bound on the date — what
was due and never ticked off stays, marked overdue in red, sorted to the
top because it is sorted by date. A task that drops out of the list by
itself is a forgotten task.

Only "Erledigt" removes it. A note without a follow-up date never
appears: it is a record, not a task.

Checked against the live database — an overdue one and an upcoming one
appear, one without a date and one beyond the chosen period do not, and
ticking the overdue one off removes exactly it. The probe notes were
deleted again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 19:44:48 +02:00
parent 91b2b3406b
commit 10f8f1f2d5
4 changed files with 93 additions and 16 deletions

View File

@@ -38,7 +38,12 @@ const DOT_STYLES: Record<string, string> = {
Gehaltsanpassung: "bg-warning-text", Gehaltsanpassung: "bg-warning-text",
}; };
const KIND_LABEL = { hire: "Eintritt", exit: "Austritt", return: "Rückkehr aus Abwesenheit" } as const; const KIND_LABEL = {
hire: "Eintritt",
exit: "Austritt",
return: "Rückkehr aus Abwesenheit",
note: "Wiedervorlage",
} as const;
export default async function DashboardPage({ export default async function DashboardPage({
searchParams, searchParams,
@@ -83,6 +88,7 @@ export default async function DashboardPage({
upcomingHires, upcomingHires,
upcomingExits, upcomingExits,
upcomingReturns, upcomingReturns,
upcomingNotes,
history, history,
} = await withUser(userId, (tx) => loadDashboardData(tx, { userId, today, yearStart, yearEnd, bisIso, arten })); } = await withUser(userId, (tx) => loadDashboardData(tx, { userId, today, yearStart, yearEnd, bisIso, arten }));
// "Aktiv" means status Aktiv — somebody on Karenz is employed but not // "Aktiv" means status Aktiv — somebody on Karenz is employed but not
@@ -115,26 +121,46 @@ export default async function DashboardPage({
.sort((a, b) => b.count - a.count); .sort((a, b) => b.count - a.count);
const maxDivisionCount = Math.max(1, ...divisionBars.map((d) => d.count)); const maxDivisionCount = Math.max(1, ...divisionBars.map((d) => d.count));
type UpcomingItem = { id: string; label: string; date: string; kind: keyof typeof KIND_LABEL }; type UpcomingItem = {
id: string;
/** Ziel des Klicks — bei einer Wiedervorlage die Akte, nicht die Notiz. */
employeeId: string;
label: string;
/** Zweite Zeile: bei einer Wiedervorlage der Notiztext statt der Art. */
hinweis?: string;
date: string;
kind: keyof typeof KIND_LABEL;
};
const upcomingAlle: UpcomingItem[] = [ const upcomingAlle: UpcomingItem[] = [
...(upcomingHires).map((e) => ({ ...(upcomingHires).map((e) => ({
id: e.id, id: e.id,
employeeId: e.id,
label: fmtName(e.first_name, e.last_name), label: fmtName(e.first_name, e.last_name),
date: e.entry_date, date: e.entry_date,
kind: "hire" as const, kind: "hire" as const,
})), })),
...(upcomingExits).map((e) => ({ ...(upcomingExits).map((e) => ({
id: e.id, id: e.id,
employeeId: e.id,
label: fmtName(e.first_name, e.last_name), label: fmtName(e.first_name, e.last_name),
date: e.exit_date!, date: e.exit_date!,
kind: "exit" as const, kind: "exit" as const,
})), })),
...(upcomingReturns).map((e) => ({ ...(upcomingReturns).map((e) => ({
id: e.id, id: e.id,
employeeId: e.id,
label: fmtName(e.first_name, e.last_name), label: fmtName(e.first_name, e.last_name),
date: e.karenz_return_date!, date: e.karenz_return_date!,
kind: "return" as const, kind: "return" as const,
})), })),
...(upcomingNotes).map((n) => ({
id: n.id,
employeeId: n.employee_id!,
label: fmtName(n.first_name, n.last_name),
hinweis: n.note_text,
date: n.due_date!,
kind: "note" as const,
})),
].sort((a, b) => a.date.localeCompare(b.date)); ].sort((a, b) => a.date.localeCompare(b.date));
// Die Karte bleibt eine Übersicht, keine Liste: acht Zeilen, und darunter // Die Karte bleibt eine Übersicht, keine Liste: acht Zeilen, und darunter
@@ -233,20 +259,35 @@ export default async function DashboardPage({
<AnstehendFilter zeitraum={zeitraum} arten={arten} /> <AnstehendFilter zeitraum={zeitraum} arten={arten} />
</Suspense> </Suspense>
<ul className="flex flex-col divide-y divide-border-subtle"> <ul className="flex flex-col divide-y divide-border-subtle">
{upcoming.map((item) => ( {upcoming.map((item) => {
<li key={`${item.kind}-${item.id}`}> // Überfällig gibt es nur bei Wiedervorlagen: die anderen Arten
<Link // haben eine untere Grenze, eine offene Aufgabe nicht.
href={`/employees/${item.id}`} const ueberfaellig = item.date < today;
className="-mx-2 flex items-center justify-between gap-2 rounded px-2 py-2.5 text-sm hover:bg-surface" return (
> <li key={`${item.kind}-${item.id}`}>
<span className="min-w-0"> <Link
<span className="block truncate font-semibold text-ink">{item.label}</span> href={`/employees/${item.employeeId}`}
<span className="text-xs text-ink-muted">{KIND_LABEL[item.kind]}</span> className="-mx-2 flex items-center justify-between gap-2 rounded px-2 py-2.5 text-sm hover:bg-surface"
</span> >
<span className="shrink-0 text-xs font-semibold tabular-nums text-ink-muted">{fmtDate(item.date)}</span> <span className="min-w-0">
</Link> <span className="block truncate font-semibold text-ink">{item.label}</span>
</li> <span className="block truncate text-xs text-ink-muted">
))} {KIND_LABEL[item.kind]}
{item.hinweis ? ` — ${item.hinweis}` : ""}
</span>
</span>
<span
className={`shrink-0 text-xs font-semibold tabular-nums ${
ueberfaellig ? "text-danger-text" : "text-ink-muted"
}`}
>
{ueberfaellig ? "überfällig " : ""}
{fmtDate(item.date)}
</span>
</Link>
</li>
);
})}
{upcoming.length === 0 && ( {upcoming.length === 0 && (
<p className="py-2 text-sm text-ink-muted"> <p className="py-2 text-sm text-ink-muted">
{istEingeschraenkt(zeitraum, arten) {istEingeschraenkt(zeitraum, arten)

View File

@@ -102,6 +102,24 @@ export async function loadDashboardData(tx: Tx, p: DashboardParams) {
.where((e) => e.lit(zeigt("return"))) .where((e) => e.lit(zeigt("return")))
).as("upcomingReturns"), ).as("upcomingReturns"),
// Wiedervorlagen. Sie verhalten sich anders als der Rest dieser Karte:
// ein Eintritt am Montag ist am Dienstag vorbei, eine Wiedervorlage
// nicht. Deshalb gibt es hier **keine untere Grenze** — was fällig war
// und nicht abgehakt wurde, bleibt stehen, bis jemand „Erledigt" klickt.
// Eine Aufgabe, die von selbst aus der Liste rutscht, ist eine
// vergessene Aufgabe.
jsonArrayFrom(
eb
.selectFrom("employee_notes as n")
.innerJoin("employees as e", "e.id", "n.employee_id")
.select(["n.id", "n.employee_id", "n.due_date", "n.category", "n.note_text", "e.first_name", "e.last_name"])
.where("n.done", "=", false)
.where("n.due_date", "is not", null)
.where("n.due_date", "<=", bisIso)
.where((x) => x.lit(zeigt("note")))
.orderBy("n.due_date")
).as("upcomingNotes"),
jsonArrayFrom( jsonArrayFrom(
eb eb
.selectFrom("employee_history as h") .selectFrom("employee_history as h")
@@ -135,6 +153,7 @@ export async function loadDashboardData(tx: Tx, p: DashboardParams) {
upcomingHires: g.upcomingHires, upcomingHires: g.upcomingHires,
upcomingExits: g.upcomingExits, upcomingExits: g.upcomingExits,
upcomingReturns: g.upcomingReturns, upcomingReturns: g.upcomingReturns,
upcomingNotes: g.upcomingNotes,
history: g.history, history: g.history,
}; };
} }

View File

@@ -14,6 +14,7 @@ export const ANSTEHEND_ARTEN = [
{ value: "hire", label: "Eintritt" }, { value: "hire", label: "Eintritt" },
{ value: "exit", label: "Austritt" }, { value: "exit", label: "Austritt" },
{ value: "return", label: "Rückkehr" }, { value: "return", label: "Rückkehr" },
{ value: "note", label: "Wiedervorlage" },
] as const; ] as const;
export type AnstehendArt = (typeof ANSTEHEND_ARTEN)[number]["value"]; export type AnstehendArt = (typeof ANSTEHEND_ARTEN)[number]["value"];

View File

@@ -56,6 +56,22 @@ describe("parseArten", () => {
}); });
}); });
describe("Wiedervorlagen als eigene Art", () => {
it("steht in der Auswahl", () => {
expect(ANSTEHEND_ARTEN.map((a) => a.value)).toContain("note");
});
it("ist ohne Angabe mit dabei", () => {
// Eine offene Aufgabe mit Termin ist der Grund, warum jemand die Übersicht
// aufschlägt — sie darf nicht erst nach einem Klick sichtbar werden.
expect(parseArten(undefined)).toContain("note");
});
it("lässt sich allein auswählen", () => {
expect(parseArten("note")).toEqual(["note"]);
});
});
describe("istEingeschraenkt", () => { describe("istEingeschraenkt", () => {
const alle = ANSTEHEND_ARTEN.map((a) => a.value); const alle = ANSTEHEND_ARTEN.map((a) => a.value);