Meine Notizen ({notes.length})
{notes.length === 0 ? (
Keine offenen Notizen.
diff --git a/components/ui/Avatar.tsx b/components/ui/Avatar.tsx
index df71c66..d68be2e 100644
--- a/components/ui/Avatar.tsx
+++ b/components/ui/Avatar.tsx
@@ -18,8 +18,13 @@ export function Avatar({ firstName, lastName, color, size = "md" }: AvatarProps)
const bg = color ?? avatarColorFor(`${firstName}${lastName}`);
return (
{initials(firstName, lastName)}
diff --git a/components/ui/Card.tsx b/components/ui/Card.tsx
new file mode 100644
index 0000000..58c29e5
--- /dev/null
+++ b/components/ui/Card.tsx
@@ -0,0 +1,32 @@
+import type { ReactNode } from "react";
+
+// The same "rounded border border-border bg-white p-4" appeared at 26 call
+// sites, all of them flat: a 1px border and nothing else, so a card sat on
+// the page with no separation from it. Collecting them here is what makes it
+// possible to give the whole app depth by changing one line.
+
+export const CARD_CLASS = "rounded-md border border-border bg-white shadow-[var(--shadow-card)]";
+
+export function Card({
+ children,
+ className = "",
+ padding = "md",
+}: {
+ children: ReactNode;
+ className?: string;
+ /** `none` for cards that manage their own inner spacing (tables, lists). */
+ padding?: "none" | "sm" | "md" | "lg";
+}) {
+ const pad = { none: "", sm: "p-3", md: "p-4", lg: "p-6" }[padding];
+ return
{children}
;
+}
+
+/**
+ * Section titles were all `text-sm font-bold text-ink` — the same weight and
+ * size as emphasised body text, so headings did not read as a level of their
+ * own. Slightly smaller, letter-spaced and muted separates them without
+ * shouting.
+ */
+export function CardTitle({ children, className = "" }: { children: ReactNode; className?: string }) {
+ return
{children}
;
+}
diff --git a/components/ui/CountryPicker.tsx b/components/ui/CountryPicker.tsx
index e05ffa2..5207645 100644
--- a/components/ui/CountryPicker.tsx
+++ b/components/ui/CountryPicker.tsx
@@ -132,7 +132,7 @@ export function CountryPicker({
ref={listRef}
id={listId}
role="listbox"
- className="absolute z-20 mt-1 max-h-56 w-full overflow-y-auto rounded border border-border bg-white shadow-lg"
+ className="absolute z-20 mt-1 max-h-56 w-full overflow-y-auto rounded border border-border bg-white shadow-[var(--shadow-card-hover)]"
>
{filtered.length === 0 &&
Keine Treffer
}
{filtered.map((c, i) => (
diff --git a/components/ui/Lookup.tsx b/components/ui/Lookup.tsx
index a723d7c..f979138 100644
--- a/components/ui/Lookup.tsx
+++ b/components/ui/Lookup.tsx
@@ -159,7 +159,7 @@ export function Lookup
({
ref={listRef}
id={listId}
role="listbox"
- className="absolute z-20 mt-1 max-h-64 w-full overflow-y-auto rounded border border-border bg-white shadow-lg"
+ className="absolute z-20 mt-1 max-h-64 w-full overflow-y-auto rounded border border-border bg-white shadow-[var(--shadow-card-hover)]"
>
{loading && Suche…
}
{!loading && visibleResults.length === 0 && Keine Treffer
}
diff --git a/components/ui/Modal.tsx b/components/ui/Modal.tsx
index c3aa468..392b2a9 100644
--- a/components/ui/Modal.tsx
+++ b/components/ui/Modal.tsx
@@ -33,7 +33,7 @@ export function Modal({ open, onClose, title, children, footer, widthClassName =
// the accessible name cannot drift from what is on screen.
aria-labelledby={titleId}
tabIndex={-1}
- className={`flex max-h-[92dvh] w-full flex-col rounded-t-xl bg-white shadow-xl outline-none sm:max-h-[85dvh] sm:rounded ${widthClassName}`}
+ className={`flex max-h-[92dvh] w-full flex-col rounded-t-xl bg-white shadow-[var(--shadow-overlay)] outline-none sm:max-h-[85dvh] sm:rounded ${widthClassName}`}
>
diff --git a/components/ui/SlideOver.tsx b/components/ui/SlideOver.tsx
index 98aec74..8042a09 100644
--- a/components/ui/SlideOver.tsx
+++ b/components/ui/SlideOver.tsx
@@ -36,7 +36,7 @@ export function SlideOver({ open, onClose, title, subtitle, children, footer }:
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
- className={`absolute right-0 top-0 flex h-dvh w-full max-w-md flex-col bg-white shadow-xl outline-none transition-transform duration-200 ${
+ className={`absolute right-0 top-0 flex h-dvh w-full max-w-md flex-col bg-white shadow-[var(--shadow-overlay)] outline-none transition-transform duration-200 ${
open ? "translate-x-0" : "translate-x-full"
}`}
>
diff --git a/lib/employee-status-filter.ts b/lib/employee-status-filter.ts
new file mode 100644
index 0000000..cdc811b
--- /dev/null
+++ b/lib/employee-status-filter.ts
@@ -0,0 +1,76 @@
+import type { EmploymentStatus } from "./supabase/types";
+
+// The SQL counterpart of deriveStatusAsOf() in lib/reports.ts.
+//
+// The employee list is paged in the database, so it cannot derive status in
+// JavaScript the way the reports and the dashboard do — it has to filter on
+// the server. Filtering on the `employees.status` column instead was the
+// reason a dashboard tile and the list it links to could disagree: that
+// column holds whatever the last mutation or cron run wrote, while every
+// other surface computes status from entry/exit/karenz dates.
+//
+// Kept deliberately close to deriveStatusAsOf, clause for clause:
+//
+// entry_date > asOf -> Geplant
+// exit_date <= asOf -> Ausgetreten
+// karenz window covers asOf -> Karenz
+// otherwise -> Aktiv
+//
+// tests/integration/employee-status-filter.test.ts asserts the two agree
+// against a real database, which is the only place that can prove it.
+
+type Filterable = {
+ gt: (column: string, value: string) => Filterable;
+ lte: (column: string, value: string) => Filterable;
+ gte: (column: string, value: string) => Filterable;
+ or: (filters: string) => Filterable;
+ is: (column: string, value: null) => Filterable;
+ not: (column: string, operator: string, value: null) => Filterable;
+};
+
+/** True once the person has started and has not left yet. */
+function employed(query: Q, asOf: string): Q {
+ return query.lte("entry_date", asOf).or(`exit_date.is.null,exit_date.gt.${asOf}`) as Q;
+}
+
+/**
+ * Narrows a PostgREST query to the employees whose *derived* status on
+ * `asOf` is one of `statuses`. Only the combinations the UI offers are
+ * supported; anything else is left unfiltered rather than silently applying
+ * a wrong one.
+ */
+export function applyDerivedStatusFilter(query: Q, statuses: EmploymentStatus[], asOf: string): Q {
+ const wanted = new Set(statuses);
+ if (wanted.size === 0) return query;
+
+ // A single non-employed status is a straight date comparison.
+ if (wanted.size === 1 && wanted.has("Geplant")) return query.gt("entry_date", asOf) as Q;
+ if (wanted.size === 1 && wanted.has("Ausgetreten")) return query.not("exit_date", "is", null).lte("exit_date", asOf) as Q;
+
+ const wantsAktiv = wanted.has("Aktiv");
+ const wantsKarenz = wanted.has("Karenz");
+
+ if (wantsAktiv && wantsKarenz && wanted.size === 2) {
+ // Everyone employed today, whether or not they are on leave.
+ return employed(query, asOf);
+ }
+
+ if (wantsKarenz && !wantsAktiv && wanted.size === 1) {
+ return employed(query, asOf)
+ .not("karenz_start_date", "is", null)
+ .lte("karenz_start_date", asOf)
+ .or(`karenz_return_date.is.null,karenz_return_date.gt.${asOf}`) as Q;
+ }
+
+ if (wantsAktiv && !wantsKarenz && wanted.size === 1) {
+ // Employed but *not* inside a karenz window: either no start date, a
+ // start still ahead, or a return that has already happened.
+ return employed(query, asOf).or(
+ `karenz_start_date.is.null,karenz_start_date.gt.${asOf},karenz_return_date.lte.${asOf}`
+ ) as Q;
+ }
+
+ // Mixed selections spanning employed and non-employed states have no UI
+ // path today; filtering on a guess would be worse than not filtering.
+ return query;
+}
diff --git a/tests/integration/employee-status-filter.test.ts b/tests/integration/employee-status-filter.test.ts
new file mode 100644
index 0000000..9bca6e0
--- /dev/null
+++ b/tests/integration/employee-status-filter.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from "vitest";
+import { applyDerivedStatusFilter } from "@/lib/employee-status-filter";
+import { todayIso } from "@/lib/format";
+import { deriveStatusAsOf } from "@/lib/reports";
+import type { EmploymentStatus } from "@/lib/supabase/types";
+import { adminClient } from "./helpers";
+
+// lib/employee-status-filter.ts is a SQL restatement of deriveStatusAsOf():
+// the employee list pages in the database and cannot derive status in JS, so
+// the same rule exists twice. Two copies of a rule drift, and the drift is
+// invisible — a dashboard tile and the list it links to just quietly show
+// different numbers.
+//
+// Only a real database can settle it, so this runs both over the whole
+// seeded roster and demands the same set of ids.
+describe("derived status filter matches deriveStatusAsOf", () => {
+ const asOf = todayIso();
+
+ async function idsFromDatabase(statuses: EmploymentStatus[]): Promise> {
+ const query = adminClient.from("employees").select("id");
+ const { data, error } = await applyDerivedStatusFilter(query, statuses, asOf);
+ if (error) throw new Error(error.message);
+ return new Set((data ?? []).map((r) => r.id));
+ }
+
+ async function idsFromDerivation(statuses: EmploymentStatus[]): Promise> {
+ const { data, error } = await adminClient
+ .from("employees")
+ .select("id, entry_date, exit_date, karenz_start_date, karenz_return_date");
+ if (error) throw new Error(error.message);
+ return new Set((data ?? []).filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))).map((e) => e.id));
+ }
+
+ async function expectSameSet(statuses: EmploymentStatus[]) {
+ const [fromDb, fromJs] = await Promise.all([idsFromDatabase(statuses), idsFromDerivation(statuses)]);
+
+ const onlyInDb = [...fromDb].filter((id) => !fromJs.has(id));
+ const onlyInJs = [...fromJs].filter((id) => !fromDb.has(id));
+
+ expect({ statuses, onlyInDb: onlyInDb.slice(0, 5), onlyInJs: onlyInJs.slice(0, 5) }).toEqual({
+ statuses,
+ onlyInDb: [],
+ onlyInJs: [],
+ });
+ // Guards against a filter so narrow it matches nothing and passes
+ // trivially.
+ expect(fromJs.size).toBeGreaterThan(0);
+ }
+
+ it("agrees for Aktiv + Karenz — the definition the dashboard headcount uses", async () => {
+ await expectSameSet(["Aktiv", "Karenz"]);
+ });
+
+ it("agrees for Aktiv alone", async () => {
+ await expectSameSet(["Aktiv"]);
+ });
+
+ it("agrees for Karenz alone", async () => {
+ await expectSameSet(["Karenz"]);
+ });
+
+ it("agrees for Geplant", async () => {
+ await expectSameSet(["Geplant"]);
+ });
+
+ it("agrees for Ausgetreten", async () => {
+ await expectSameSet(["Ausgetreten"]);
+ });
+
+ it("returns everyone when no status is selected", async () => {
+ const { count: total } = await adminClient.from("employees").select("id", { count: "exact", head: true });
+ const { count: filtered } = await applyDerivedStatusFilter(
+ adminClient.from("employees").select("id", { count: "exact", head: true }),
+ [],
+ asOf
+ );
+ expect(filtered).toBe(total);
+ });
+});