diff --git a/app/(app)/error.tsx b/app/(app)/error.tsx new file mode 100644 index 0000000..8de43ca --- /dev/null +++ b/app/(app)/error.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { AlertTriangle, RotateCcw } from "lucide-react"; +import Link from "next/link"; +import { useEffect } from "react"; +import { Button, LINK_BUTTON_CLASS } from "@/components/ui/Button"; + +// Without this file a failed render drops the user on Next.js's own error +// screen — no navigation, no way back, and in production just "a client-side +// exception occurred". `reset()` re-renders the segment, which is enough for +// the common case of a transient Supabase timeout. +export default function AppError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + useEffect(() => { + console.error("Route error:", error); + }, [error]); + + return ( +
+ + + +
+

Diese Ansicht konnte nicht geladen werden

+

+ Die Daten wurden nicht verändert. Meist hilft ein erneuter Versuch; bleibt der Fehler, wenden Sie sich bitte an die + IT-Betreuung. +

+
+ {/* The digest is the only handle on the server-side stack trace, which + is deliberately not sent to the browser in production. */} + {error.digest && ( +

+ Fehlerkennung: {error.digest} +

+ )} +
+ + + Zur Übersicht + +
+
+ ); +} diff --git a/app/(app)/loading.tsx b/app/(app)/loading.tsx new file mode 100644 index 0000000..e71724a --- /dev/null +++ b/app/(app)/loading.tsx @@ -0,0 +1,30 @@ +// Every page in this group is server-rendered per request (they all read +// from Supabase), so without this the browser sits on the previous page with +// no feedback until the server answers — on the employee list, long enough +// to look broken. +export default function Loading() { + return ( +
+
+
+ {[240, 160, 160].map((w, i) => ( +
+ ))} +
+
+ {Array.from({ length: 8 }, (_, i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ Daten werden geladen… +
+ ); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index e97ea68..58e84b7 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,4 +1,6 @@ import { login, logout } from "@/actions/auth"; +import { Button } from "@/components/ui/Button"; +import { CONTROL_CLASS } from "@/components/ui/Field"; // The query string is attacker-controlled, so the login page renders a message // looked up by code rather than whatever text ?error= carries. Reflecting the @@ -50,7 +52,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) { type="email" required autoComplete="username" - className="w-full rounded border border-border px-3 py-2 text-sm text-ink outline-none focus:border-brand-500" + className={CONTROL_CLASS} />
@@ -63,15 +65,12 @@ export default async function LoginPage({ searchParams }: LoginPageProps) { type="password" required autoComplete="current-password" - className="w-full rounded border border-border px-3 py-2 text-sm text-ink outline-none focus:border-brand-500" + className={CONTROL_CLASS} />
- +
diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 0000000..0e7feaf --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useEffect } from "react"; + +// Last resort: catches errors thrown by the root layout itself, where +// (app)/error.tsx is not mounted yet. It replaces , so it cannot use +// the app's fonts, Tailwind layer or shared components — hence the inline +// styles. Kept deliberately plain; anything clever here can fail too. +export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + useEffect(() => { + console.error("Global error:", error); + }, [error]); + + return ( + + +
+

Die Anwendung konnte nicht geladen werden

+

+ Es ist ein unerwarteter Fehler aufgetreten. Ihre Daten sind davon nicht betroffen. +

+ {error.digest && ( +

Fehlerkennung: {error.digest}

+ )} + +
+ + + ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..83ef56d --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,19 @@ +import Link from "next/link"; +import { LINK_BUTTON_CLASS } from "@/components/ui/Button"; + +export default function NotFound() { + return ( +
+
+

404

+

Seite nicht gefunden

+

+ Die aufgerufene Adresse existiert nicht. Möglicherweise wurde der Datensatz gelöscht oder der Link ist veraltet. +

+ + Zur Übersicht + +
+
+ ); +} diff --git a/components/audit/AuditFilters.tsx b/components/audit/AuditFilters.tsx index 2c3f1f4..96d147e 100644 --- a/components/audit/AuditFilters.tsx +++ b/components/audit/AuditFilters.tsx @@ -1,8 +1,9 @@ "use client"; -import { Search } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; +import { FILTER_SELECT_CLASS } from "@/components/ui/Field"; +import { SearchInput } from "@/components/ui/SearchInput"; const ACTIONS = [ "Neueinstellung", @@ -50,19 +51,12 @@ export function AuditFilters() { return (
-
- - setQ(e.target.value)} - placeholder="Objekt, Details, Benutzer:in…" - className="w-full text-sm text-ink outline-none placeholder:text-ink-muted" - /> -
+ setEffectiveDate(e.target.value)} - className="w-full rounded border border-border px-3 py-2 text-sm" - /> -
+
-
- - setFirstName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" /> -
-
- - setLastName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" /> -
-
-
- - setSvNummer(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" /> -
-
- - setBirthDate(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" /> -
-
- - + +
+ + + setRelationship(v as RelationshipType)} + options={RELATIONSHIPS.map((r) => ({ value: r, label: r }))} + />
); diff --git a/components/employees/AngehoerigeSection.tsx b/components/employees/AngehoerigeSection.tsx index 140c4a6..1bfdccc 100644 --- a/components/employees/AngehoerigeSection.tsx +++ b/components/employees/AngehoerigeSection.tsx @@ -4,6 +4,7 @@ import { Plus, Trash2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { deleteEmployeeDependent } from "@/actions/employees"; +import { Button } from "@/components/ui/Button"; import { useToast } from "@/components/ui/Toast"; import { fmtDate, todayIso } from "@/lib/format"; import type { Database } from "@/lib/supabase/types"; @@ -38,13 +39,9 @@ export function AngehoerigeSection({ employeeId, dependents, effectiveDate }: {

Angehörige

- + {dependents.length} Personen
@@ -73,15 +70,15 @@ export function AngehoerigeSection({ employeeId, dependents, effectiveDate }: { {d.sv_nummer ?? "–"} {fmtDate(d.birth_date)} - + ))} diff --git a/components/employees/EmployeeDetail.tsx b/components/employees/EmployeeDetail.tsx index db3d575..cad1578 100644 --- a/components/employees/EmployeeDetail.tsx +++ b/components/employees/EmployeeDetail.tsx @@ -4,6 +4,7 @@ import { ArrowLeft, ArrowRightLeft, Clock, Pencil, RotateCcw, TrendingUp, XCircl import Link from "next/link"; import { useState } from "react"; import { Avatar } from "@/components/ui/Avatar"; +import { Button } from "@/components/ui/Button"; import { StatusChip } from "@/components/ui/StatusChip"; import { fmtFullName, tenure } from "@/lib/format"; import type { Database } from "@/lib/supabase/types"; @@ -97,31 +98,34 @@ export function EmployeeDetail(props: EmployeeDetailProps) { )} {canEditData && setPanel("daten")} />} {isActive && ( - + )} {employee.status === "Ausgetreten" && ( - + )}
-
+ {/* Tabs, so the list gets the tablist role and each button says + whether it is the selected one. */} +
{TABS.map((t) => ( + ); } diff --git a/components/employees/EmployeeFilters.tsx b/components/employees/EmployeeFilters.tsx index 17ff347..78e78b8 100644 --- a/components/employees/EmployeeFilters.tsx +++ b/components/employees/EmployeeFilters.tsx @@ -1,8 +1,9 @@ "use client"; -import { Search } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; +import { FILTER_SELECT_CLASS } from "@/components/ui/Field"; +import { SearchInput } from "@/components/ui/SearchInput"; type EmployeeFiltersProps = { divisions: { id: string; name: string }[]; @@ -40,19 +41,15 @@ export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps) return (
-
- - setQ(e.target.value)} - placeholder="Name, Pers.-Nr., Titel…" - className="w-full text-sm text-ink outline-none placeholder:text-ink-muted" - /> -
+ + {/* aria-label rather than a visible label: the filter bar is a single + horizontal row, and each select's first option already names it on + screen. */} onChange({ workerType: e.target.value as WorkerType })} - className="w-full rounded border border-border px-3 py-2 text-sm" - > - - - -
-
- - -
+ onChange({ workerType: v as WorkerType })} + options={[ + { value: "Angestellte:r", label: "Angestellte:r" }, + { value: "Arbeiter:in", label: "Arbeiter:in" }, + ]} + /> + onChange({ collectiveAgreement: v as CollectiveAgreement })} + options={[ + { value: "Handel", label: "Handel" }, + { value: "Süßwaren", label: "Süßwaren" }, + ]} + />
-
- + {/* Toggle group, not a set of fields: a fieldset names the group, and + aria-pressed is what tells a screen reader a day is selected — + colour alone does not. */} +
+ Arbeitstage
{WEEKDAYS.map((day) => ( ))}
-
+