From 0cceb9a2d11d097ffc7166b7f7636add3d602c3d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 16:36:29 +0000 Subject: [PATCH 01/13] feat(app): design tokens and base styles for the pilot UI (#52) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- src/app/globals.css | 798 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 782 insertions(+), 16 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 46a705a..ec049f0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,16 +1,782 @@ -/* Minimal pilot styles: readable, keyboard-visible, status never by colour alone. */ -body { font-family: system-ui, sans-serif; margin: 0 auto; max-width: 72rem; padding: 1rem; line-height: 1.5; } -table { border-collapse: collapse; width: 100%; } -th, td { border-bottom: 1px solid #ccc; padding: 0.4rem; text-align: left; vertical-align: top; } -:focus-visible { outline: 3px solid #1a5fb4; outline-offset: 2px; } -.badge { border-radius: 0.3rem; padding: 0.1rem 0.4rem; font-size: 0.9em; white-space: nowrap; } -.badge-found { background: #e6f4ea; color: #1e4620; } -.badge-missing { background: #eee; color: #333; } -.badge-corrected { background: #e8eefc; color: #1a2f66; } -.badge-uncertain, .badge-unverified { background: #fff3cd; color: #5c3c00; font-weight: 700; border: 1px solid #b58100; } -tr.attention, td.attention { background: #fffaf0; } -.source { border: 1px solid #ccc; border-radius: 0.4rem; padding: 0.5rem 1rem; margin-top: 1rem; } -.source-lines li.cited { background: #fff8c5; } -.line-label { color: #555; font-size: 0.85em; } -mark { background: #ffd33d; } -.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } +/* + * Pilot design system (#52): tokens once, plain CSS, system fonts (no third-party requests). + * Rules that are not style: status is never shown by colour alone, the focus outline stays visible, + * every text/background pair below meets WCAG AA (≥ 4.5:1). + */ +:root { + --bg: #f5f6f8; + --surface: #ffffff; + --surface-muted: #f9fafb; + --text: #1a1f26; + --text-muted: #5a6472; /* 5.9:1 on --surface */ + --border: #dfe3e8; + --border-strong: #c3c9d1; + --brand: #1f4fd1; /* 6.3:1 on white */ + --brand-hover: #173ea6; + --brand-soft: #ebf0fd; + --focus: #1a5fb4; + --success-text: #17603a; + --success-bg: #e5f4ea; + --info-text: #1d3f8f; + --info-bg: #e8eefc; + --neutral-text: #3d4550; + --neutral-bg: #eceff3; + --warn-text: #5c3c00; + --warn-bg: #fff3cd; + --warn-border: #b58100; + --danger: #b42318; /* 6.5:1 on white */ + --danger-hover: #912018; + --danger-text: #8f1c13; + --danger-bg: #fdeceb; + --mark: #ffd33d; + --cited: #fff8d6; + --radius: 10px; + --radius-sm: 6px; + --shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 8%); + --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 15px; + line-height: 1.55; +} + +a { + color: var(--brand); + text-underline-offset: 2px; +} +a:hover { + color: var(--brand-hover); +} + +:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 2px; +} + +h1, +h2, +h3 { + line-height: 1.25; + margin: 0 0 0.75rem; +} +h1 { + font-size: 1.6rem; + letter-spacing: -0.01em; +} +h2 { + font-size: 1.15rem; +} +h3 { + font-size: 1rem; +} +p { + margin: 0 0 0.75rem; +} +small { + color: var(--text-muted); +} +code { + font-family: var(--mono); + font-size: 0.9em; + background: var(--neutral-bg); + border-radius: 4px; + padding: 0.1rem 0.35rem; + word-break: break-all; +} + +/* ---------- App shell ---------- */ + +.app-header { + background: var(--surface); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 10; +} +.app-header-inner { + max-width: 78rem; + margin: 0 auto; + padding: 0.6rem 1.25rem; + display: flex; + align-items: center; + gap: 1.5rem; + flex-wrap: wrap; +} +.brand { + display: inline-flex; + align-items: center; + gap: 0.55rem; + font-weight: 700; + font-size: 1.05rem; + color: var(--text); + text-decoration: none; +} +.brand:hover { + color: var(--text); +} +.brand-mark { + width: 1.6rem; + height: 1.6rem; + border-radius: 7px; + background: linear-gradient(135deg, var(--brand) 0%, #4f7cf0 100%); + display: inline-grid; + place-items: center; + color: #fff; + font-size: 0.85rem; + font-weight: 800; +} +.env-tag { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--warn-text); + background: var(--warn-bg); + border-radius: 999px; + padding: 0.1rem 0.5rem; +} +.app-nav { + display: flex; + gap: 0.25rem; + flex: 1; +} +.app-nav a { + color: var(--text-muted); + text-decoration: none; + font-weight: 500; + padding: 0.4rem 0.75rem; + border-radius: var(--radius-sm); +} +.app-nav a:hover { + color: var(--text); + background: var(--neutral-bg); +} +.app-nav a[aria-current="page"] { + color: var(--brand); + background: var(--brand-soft); +} +.account { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.9rem; + color: var(--text-muted); + margin-left: auto; +} +.account strong { + color: var(--text); + font-weight: 600; +} + +main { + max-width: 78rem; + margin: 0 auto; + padding: 1.75rem 1.25rem 3rem; +} + +.page-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1.25rem; +} +.page-head h1 { + margin-bottom: 0.35rem; +} +.page-head .meta { + color: var(--text-muted); + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin: 0; +} +.back { + display: inline-block; + margin-bottom: 0.75rem; + font-size: 0.9rem; + color: var(--text-muted); + text-decoration: none; +} +.back:hover { + color: var(--brand); +} + +/* ---------- Surfaces ---------- */ + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 1.25rem 1.4rem; + margin-bottom: 1.25rem; +} +.card > :last-child { + margin-bottom: 0; +} +.card-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 0.9rem; +} +.card-head h2 { + margin: 0; +} +.card-head p { + margin: 0; + color: var(--text-muted); + font-size: 0.9rem; +} +.card-flush { + padding: 0; + overflow: hidden; +} +.card-flush > .card-head { + padding: 1.1rem 1.4rem 0; +} + +.tiles { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); + gap: 1rem; +} +.tile { + display: block; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 1.1rem 1.25rem; + color: var(--text); + text-decoration: none; + transition: border-color 0.15s, box-shadow 0.15s; +} +.tile:hover { + color: var(--text); + border-color: var(--brand); + box-shadow: 0 4px 12px rgb(31 79 209 / 12%); +} +.tile strong { + display: block; + font-size: 1.05rem; + margin-bottom: 0.25rem; + color: var(--brand); +} +.tile span { + color: var(--text-muted); + font-size: 0.9rem; +} + +.auth { + max-width: 26rem; + margin: 3rem auto 0; +} +.auth h1 { + font-size: 1.4rem; +} +.auth .lead, +.lead { + color: var(--text-muted); +} + +/* ---------- Callouts (role-based, so every message is styled without extra markup) ---------- */ + +[role="status"], +[role="alert"], +[role="note"], +.callout { + border-radius: var(--radius-sm); + padding: 0.65rem 0.9rem; + margin: 0 0 1rem; + border: 1px solid transparent; +} +[role="status"] { + background: var(--success-bg); + color: var(--success-text); + border-color: #b9dfc6; +} +[role="alert"] { + background: var(--danger-bg); + color: var(--danger-text); + border-color: #f3c1bd; +} +[role="note"], +.callout-warn { + background: var(--warn-bg); + color: var(--warn-text); + border-color: var(--warn-border); +} +.callout-warn h2 { + font-size: 1.05rem; +} +.callout-warn a { + color: var(--warn-text); + font-weight: 600; +} +.callout-info { + background: var(--info-bg); + color: var(--info-text); +} + +/* ---------- Forms and buttons ---------- */ + +label { + font-weight: 500; +} +input:not([type="checkbox"], [type="radio"], [type="hidden"], [type="file"]), +select { + font: inherit; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + padding: 0.4rem 0.6rem; + min-height: 2.25rem; + max-width: 100%; +} +input:not([type="checkbox"], [type="radio"], [type="hidden"], [type="file"]):hover, +select:hover { + border-color: var(--text-muted); +} +input[type="file"] { + font: inherit; + max-width: 100%; +} +input[type="file"]::file-selector-button { + font: inherit; + font-weight: 500; + margin-right: 0.75rem; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface); + padding: 0.35rem 0.8rem; + cursor: pointer; +} +input[type="checkbox"] { + width: 1rem; + height: 1rem; + accent-color: var(--brand); + vertical-align: -2px; +} +.field { + margin-bottom: 0.9rem; +} +.field label { + display: block; + margin-bottom: 0.3rem; +} +.field input, +.field select { + width: 100%; +} + +button { + font: inherit; + font-weight: 500; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + padding: 0.4rem 0.9rem; + min-height: 2.25rem; + cursor: pointer; + white-space: nowrap; +} +button:hover { + background: var(--neutral-bg); +} +button:disabled { + opacity: 0.6; + cursor: progress; +} +button.btn-primary { + background: var(--brand); + border-color: var(--brand); + color: #fff; +} +button.btn-primary:hover { + background: var(--brand-hover); + border-color: var(--brand-hover); +} +button.btn-danger { + background: var(--surface); + border-color: var(--danger); + color: var(--danger); +} +button.btn-danger:hover { + background: var(--danger); + color: #fff; +} +button.btn-ghost { + border-color: transparent; + background: transparent; + color: var(--text-muted); +} +button.btn-ghost:hover { + background: var(--neutral-bg); + color: var(--text); +} +button.btn-small { + min-height: 1.9rem; + padding: 0.2rem 0.65rem; + font-size: 0.875rem; +} + +.inline-form { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin: 0; +} +.inline-form label { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} +.toolbar { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + padding: 0.9rem 1.4rem; + border-bottom: 1px solid var(--border); + background: var(--surface-muted); + margin: 0; +} +.toolbar label { + display: inline-flex; + align-items: center; + gap: 0.45rem; +} +.actions { + display: flex; + align-items: flex-end; + gap: 1.5rem; + flex-wrap: wrap; +} +.actions form { + margin: 0; +} +.upload { + display: flex; + align-items: flex-end; + gap: 1rem; + flex-wrap: wrap; +} +.upload .dropzone { + flex: 1; + min-width: 16rem; + border: 1.5px dashed var(--border-strong); + border-radius: var(--radius); + background: var(--surface-muted); + padding: 0.9rem 1rem; + margin: 0; +} +.upload .dropzone label { + display: block; + margin-bottom: 0.4rem; +} +.upload [role="status"] { + flex-basis: 100%; + margin: 0; +} + +/* ---------- Tables ---------- */ + +.table-wrap { + overflow-x: auto; +} +table { + border-collapse: collapse; + width: 100%; + font-size: 0.925rem; +} +th, +td { + border-bottom: 1px solid var(--border); + padding: 0.65rem 0.8rem; + text-align: left; + vertical-align: middle; +} +thead th { + background: var(--surface-muted); + color: var(--text-muted); + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; +} +tbody tr:last-child > * { + border-bottom: 0; +} +tbody tr:hover { + background: #fafbfc; +} +tbody th { + font-weight: 600; +} +td.num, +th.num { + text-align: right; + font-variant-numeric: tabular-nums; +} +td.nowrap { + white-space: nowrap; +} +.muted { + color: var(--text-muted); +} +.empty { + padding: 2rem 1.4rem; + text-align: center; + color: var(--text-muted); + margin: 0; +} +tr.attention, +td.attention { + background: #fffbeb; +} +tr.attention:hover { + background: #fff6d9; +} +.card > .table-wrap:first-child { + margin-top: -0.25rem; +} +.card:not(.card-flush) > .table-wrap { + margin-left: -1.4rem; + margin-right: -1.4rem; +} +.card:not(.card-flush) > .table-wrap th:first-child, +.card:not(.card-flush) > .table-wrap td:first-child { + padding-left: 1.4rem; +} + +/* ---------- Status ---------- */ + +.badge, +.pill { + display: inline-flex; + align-items: center; + gap: 0.25rem; + border-radius: 999px; + padding: 0.08rem 0.55rem; + font-size: 0.78rem; + font-weight: 600; + line-height: 1.5; + white-space: nowrap; + border: 1px solid transparent; + vertical-align: middle; +} +.badge-found { + background: var(--success-bg); + color: var(--success-text); +} +.badge-missing { + background: var(--neutral-bg); + color: var(--neutral-text); +} +.badge-corrected { + background: var(--info-bg); + color: var(--info-text); +} +.badge-uncertain, +.badge-unverified { + background: var(--warn-bg); + color: var(--warn-text); + border-color: var(--warn-border); + font-weight: 700; +} +.pill { + font-size: 0.8rem; + padding: 0.12rem 0.65rem; +} +.pill::before { + content: ""; + width: 0.45rem; + height: 0.45rem; + border-radius: 50%; + background: currentColor; + opacity: 0.8; +} +.pill-neutral { + background: var(--neutral-bg); + color: var(--neutral-text); +} +.pill-info { + background: var(--info-bg); + color: var(--info-text); +} +.pill-warn { + background: var(--warn-bg); + color: var(--warn-text); +} +.pill-success { + background: var(--success-bg); + color: var(--success-text); +} +.pill-danger { + background: var(--danger-bg); + color: var(--danger-text); +} +.stage { + display: block; + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 0.15rem; +} +.hint { + font-size: 0.85rem; +} + +/* ---------- Review: values, source ---------- */ + +.value-cell a { + font-weight: 500; +} +.item-value { + display: inline-flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} +.source { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-muted); + padding: 1rem 1.2rem; + margin-top: 1rem; +} +.source h3 { + font-size: 0.95rem; +} +.source-lines { + font-family: var(--mono); + font-size: 0.85rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.5rem 0.75rem 0.5rem 2.75rem; + margin: 0 0 0.75rem; + overflow-x: auto; +} +.source-lines li { + padding: 0.1rem 0.4rem; + border-radius: 4px; +} +.source-lines li::marker { + color: var(--text-muted); +} +.source-lines li.cited { + background: var(--cited); + box-shadow: inset 3px 0 0 var(--mark); +} +.line-label { + color: var(--text-muted); + font-size: 0.8em; + font-family: var(--font); +} +mark { + background: var(--mark); + color: var(--text); + border-radius: 3px; + padding: 0 0.15rem; +} +.panel { + border-top: 1px solid var(--border); + margin-top: 1rem; + padding-top: 1rem; +} +.panel h3 { + font-size: 1.05rem; +} +.doc-list { + list-style: none; + padding: 0; + margin: 0; +} +.doc-list > li { + padding: 0.55rem 0; + border-bottom: 1px solid var(--border); +} +.doc-list > li:last-child { + border-bottom: 0; +} +.doc-list ul { + margin: 0.5rem 0 0; + padding: 0; + list-style: none; +} +.doc-list [role="note"] { + margin: 0.25rem 0 0; +} +.facts { + display: flex; + gap: 1.5rem; + flex-wrap: wrap; + margin: 0 0 1.25rem; + padding: 0; + list-style: none; + color: var(--text-muted); + font-size: 0.9rem; +} +.facts strong { + color: var(--text); +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +@media (max-width: 640px) { + body { + font-size: 14.5px; + } + main { + padding: 1.25rem 0.9rem 2rem; + } + .card { + padding: 1rem; + } + .card:not(.card-flush) > .table-wrap { + margin-left: -1rem; + margin-right: -1rem; + } + .account span { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + } +} From 53e9f30366968e5ad3230211170619c239f23fba Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 16:39:09 +0000 Subject: [PATCH 02/13] feat(app): app shell, status pills, cards and action styles for all pages (#52) Shared header with navigation, company and role; request and field status as pills with text; primary/danger actions; tables in scroll containers. Review page parts moved to review-parts.tsx. Error messages from the login and upload forms use role=alert instead of role=status. No flow, permission or test change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- src/app/_components/app-header.tsx | 42 +++ src/app/_components/auth-form.tsx | 29 +- src/app/_components/nav-link.tsx | 16 ++ src/app/globals.css | 17 ++ src/app/invite/page.tsx | 53 ++-- src/app/layout.tsx | 6 +- src/app/login/page.tsx | 13 +- src/app/page.tsx | 57 ++-- src/app/requests/[id]/page.tsx | 350 +++++++++++-------------- src/app/requests/[id]/review-parts.tsx | 91 +++++++ src/app/requests/page.tsx | 177 +++++++------ src/app/requests/status-pill.tsx | 20 ++ src/app/requests/upload-form.tsx | 12 +- src/app/signup/page.tsx | 26 +- src/app/users/page.tsx | 132 ++++++---- 15 files changed, 628 insertions(+), 413 deletions(-) create mode 100644 src/app/_components/app-header.tsx create mode 100644 src/app/_components/nav-link.tsx create mode 100644 src/app/requests/[id]/review-parts.tsx create mode 100644 src/app/requests/status-pill.tsx diff --git a/src/app/_components/app-header.tsx b/src/app/_components/app-header.tsx new file mode 100644 index 0000000..d1eba1f --- /dev/null +++ b/src/app/_components/app-header.tsx @@ -0,0 +1,42 @@ +import Link from "next/link"; +import { headers } from "next/headers"; +import { currentActor, getRuntime } from "@/app/_server/runtime"; +import { getCompany } from "@/features/identity"; +import { SignOutButton } from "./auth-form"; +import { NavLink } from "./nav-link"; + +const ROLE_LABEL = { admin: "Administration", clerk: "Sachbearbeitung" } as const; + +// App shell (#52): brand on every page; navigation, company, role and sign-out only when signed in. +// Navigation mirrors the server-side permissions – it hides links, it never grants access. +export async function AppHeader() { + const actor = await currentActor(await headers()); + const company = actor ? await getCompany(getRuntime().database.db, actor.companyId) : null; + return ( +
+
+ + + RequestFlow + Pilot + + {actor && ( + <> + +
+ + {company?.name} · Rolle: {ROLE_LABEL[actor.role]} + + +
+ + )} +
+
+ ); +} diff --git a/src/app/_components/auth-form.tsx b/src/app/_components/auth-form.tsx index 06df4af..f22016f 100644 --- a/src/app/_components/auth-form.tsx +++ b/src/app/_components/auth-form.tsx @@ -8,12 +8,14 @@ import { useState, type FormEvent } from "react"; export function AuthForm({ mode, invitationId }: { mode: "sign-in" | "sign-up"; invitationId?: string }) { const router = useRouter(); const [message, setMessage] = useState(null); + const [failed, setFailed] = useState(false); const [busy, setBusy] = useState(false); async function submit(event: FormEvent) { event.preventDefault(); setBusy(true); setMessage(null); + setFailed(false); const form = new FormData(event.currentTarget); const body = { email: String(form.get("email") ?? ""), @@ -30,12 +32,14 @@ export function AuthForm({ mode, invitationId }: { mode: "sign-in" | "sign-up"; if (response.ok) { router.push("/"); router.refresh(); + return; } - else if (response.status === 429) setMessage("Zu viele Versuche – bitte später erneut versuchen."); - else setMessage("Anmeldung fehlgeschlagen. Bitte E-Mail und Passwort prüfen."); + setFailed(true); + setMessage(response.status === 429 ? "Zu viele Versuche – bitte später erneut versuchen." : "Anmeldung fehlgeschlagen. Bitte E-Mail und Passwort prüfen."); return; } // Same answer for invited and uninvited addresses (no enumeration). + setFailed(!response.ok); setMessage( response.ok ? "Falls für diese Adresse eine Einladung vorliegt, ist das Konto jetzt angelegt. Bitte anmelden." @@ -46,20 +50,17 @@ export function AuthForm({ mode, invitationId }: { mode: "sign-in" | "sign-up"; return (
{mode === "sign-up" && ( -

+

-

)} -

+

-

-

+

-

- - {message &&

{message}

} +

+ +

+ {message &&

{message}

}
); } @@ -85,7 +88,7 @@ export function SignOutButton() { router.refresh(); } return ( - ); diff --git a/src/app/_components/nav-link.tsx b/src/app/_components/nav-link.tsx new file mode 100644 index 0000000..ba1a584 --- /dev/null +++ b/src/app/_components/nav-link.tsx @@ -0,0 +1,16 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import type { ReactNode } from "react"; + +/** Navigation link that marks the current section with `aria-current="page"` (styled, and announced). */ +export function NavLink({ href, children }: { href: string; children: ReactNode }) { + const pathname = usePathname(); + const current = pathname === href || pathname.startsWith(`${href}/`); + return ( + + {children} + + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index ec049f0..f8a60d8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -444,6 +444,23 @@ button.btn-small { font-size: 0.875rem; } +a.btn-link { + display: inline-flex; + align-items: center; + min-height: 2.25rem; + padding: 0.4rem 0.9rem; + border-radius: var(--radius-sm); + background: var(--brand); + color: #fff; + font-weight: 500; + text-decoration: none; + white-space: nowrap; +} +a.btn-link:hover { + background: var(--brand-hover); + color: #fff; +} + .inline-form { display: flex; align-items: center; diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx index 00ffdb9..1650f82 100644 --- a/src/app/invite/page.tsx +++ b/src/app/invite/page.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; import { headers } from "next/headers"; import { notFound, redirect } from "next/navigation"; import { z } from "zod"; @@ -37,29 +38,35 @@ export default async function InvitePage({ searchParams }: { searchParams: Promi const link = params.invitation ? `${getRuntime().config.auth.baseURL}/signup?invitation=${encodeURIComponent(params.invitation)}` : null; return (
-

Mitarbeitende einladen

- {link && ( -

- Einladung angelegt (7 Tage gültig). Diesen Link an die eingeladene Person weitergeben: {link} -

- )} - {params.error &&

Bitte eine gültige E-Mail-Adresse und Rolle angeben.

} -
-

- -
- -

-

- -
- -

- -
+ + ← Benutzer + +
+

Mitarbeitende einladen

+

Die Einladung erzeugt einen Link, den Sie selbst an die Person weitergeben.

+ {link && ( +

+ Einladung angelegt (7 Tage gültig). Diesen Link an die eingeladene Person weitergeben: {link} +

+ )} + {params.error &&

Bitte eine gültige E-Mail-Adresse und Rolle angeben.

} +
+

+ + +

+

+ + +

+ +
+
); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c04de8e..63acdda 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import type { ReactNode } from "react"; +import { AppHeader } from "./_components/app-header"; import "./globals.css"; export const metadata: Metadata = { @@ -10,7 +11,10 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: ReactNode }) { return ( - {children} + + + {children} + ); } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index ddb9ef5..67c3c90 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -4,11 +4,14 @@ import { AuthForm } from "@/app/_components/auth-form"; export default function LoginPage() { return (
-

Anmelden

- -

- Eingeladen worden? Konto anlegen -

+
+

Anmelden

+

Mit Ihrem RequestFlow-Konto anmelden.

+ +

+ Eingeladen worden? Konto anlegen +

+
); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 25e5aeb..8c6b689 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,41 +1,52 @@ import Link from "next/link"; import { headers } from "next/headers"; -import { SignOutButton } from "@/app/_components/auth-form"; -import { currentActor, getRuntime } from "@/app/_server/runtime"; -import { getCompany } from "@/features/identity"; +import { currentActor } from "@/app/_server/runtime"; export const dynamic = "force-dynamic"; +// Start page: company, role and sign-out live in the app header (#52); this page points to the work. export default async function HomePage() { const actor = await currentActor(await headers()); if (!actor) { return (
-

RequestFlow

-

Angebotsanfragen erfassen, neben der Quelle prüfen und genau einmal ans ERP übergeben.

-

- Anmelden · Konto mit Einladung anlegen -

-

Pilot – alle Daten sind synthetisch.

+
+

RequestFlow

+

Angebotsanfragen erfassen, neben der Quelle prüfen und genau einmal ans ERP übergeben.

+

+ Anmelden · Konto mit Einladung anlegen +

+

Pilot – alle Daten sind synthetisch.

+
); } - const company = await getCompany(getRuntime().database.db, actor.companyId); return (
-

RequestFlow

-

- Firma: {company?.name} · Rolle: {actor.role === "admin" ? "Administration" : "Sachbearbeitung"} -

-

- Anfragen -

- {actor.role === "admin" && ( -

- Benutzer verwalten · Mitarbeitende einladen -

- )} - +
+
+

Willkommen

+

Angebotsanfragen hochladen, neben der Quelle prüfen und genau einmal ans ERP übergeben.

+
+
+
); } diff --git a/src/app/requests/[id]/page.tsx b/src/app/requests/[id]/page.tsx index cb011e1..7e50e58 100644 --- a/src/app/requests/[id]/page.tsx +++ b/src/app/requests/[id]/page.tsx @@ -2,72 +2,16 @@ import Link from "next/link"; import { headers } from "next/headers"; import { notFound, redirect } from "next/navigation"; import { currentActor, getRuntime } from "@/app/_server/runtime"; -import { duplicateDecidable, loadReview, REJECTION_REASON_MAX, type ReviewField, type ReviewStatus } from "@/features/review"; -import { requestStatusLabel } from "../status-labels"; +import { duplicateDecidable, loadReview, REJECTION_REASON_MAX } from "@/features/review"; +import { StatusPill } from "../status-pill"; import { DONE_MESSAGES, ERROR_MESSAGES, messageFor } from "./messages"; import { approveAction, confirmNotDuplicateAction, correctFieldAction, rejectAction, rejectAsDuplicateAction } from "./actions"; +import { DocumentList, needsAttention, Source, STATUS_LABEL, StatusBadge } from "./review-parts"; export const dynamic = "force-dynamic"; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -const ATTACHMENT_ERROR: Record = { - unsupported_media_type: "Format nicht unterstützt", - document_unparseable: "Datei beschädigt oder unlesbar", - document_too_long: "zu umfangreich", - nesting_too_deep: "zu tief verschachtelt", - too_many_attachments: "zu viele Anhänge", - not_attached_by_value: "nur verlinkt, nicht angehängt", - budget_exceeded: "Verarbeitungsgrenze der Nachricht erreicht", -}; - -const STATUS_LABEL: Record = { - corrected: "korrigiert", - found: "belegt", - uncertain: "unsicher", - missing: "fehlt", - unverified: "nicht bestätigt", -}; - -function StatusBadge({ status }: { status: ReviewStatus }) { - // Uncertain and unverified are the ones a clerk must look at: prominent colour + text, never colour only. - return ( - - {status === "unverified" || status === "uncertain" ? "⚠ " : ""} - {STATUS_LABEL[status]} - - ); -} - -function Source({ field }: { field: ReviewField }) { - if (!field.source) return

Für diesen Wert gibt es keine Fundstelle.

; - const { source } = field; - return ( -
-

- Quelle: {source.filename} – {source.heading} -

- {source.ocr && ( -

- ⚠ Texterkennung (OCR): Der Text stammt aus einem gescannten Dokument – bitte mit dem Original vergleichen. -

- )} - {field.corrected &&

Fundstelle des erkannten Werts „{field.extractedValue ?? "–"}“ – der aktuelle Wert wurde manuell korrigiert.

} -
    - {source.lines.map((line) => ( -
  1. - {line.label}{" "} - {line.parts.map((part, index) => (part.mark ? {part.text} : {part.text}))} -
  2. - ))} -
-

- Original öffnen -

-
- ); -} - export default async function RequestPage({ params, searchParams, @@ -94,143 +38,167 @@ export default async function RequestPage({ const canDecideDuplicate = duplicateDecidable(request); const done = messageFor(DONE_MESSAGES, query.done); const error = messageFor(ERROR_MESSAGES, query.error); + const openItems = lineItems.reduce((count, item) => count + item.fields.filter(needsAttention).length, fields.filter(needsAttention).length); return (
-

- ← Anfragen -

-

{request.subject ?? "(ohne Betreff)"}

-

- Status: {requestStatusLabel(request.status)} -

+ + ← Anfragen + +
+
+

{request.subject ?? "(ohne Betreff)"}

+

+ Status: + {exportRecord?.erpReference && ( + + · ERP-Referenz: {exportRecord.erpReference} + + )} + {inReview && openItems > 0 && · {openItems === 1 ? "1 Wert braucht Aufmerksamkeit" : `${openItems} Werte brauchen Aufmerksamkeit`}} +

+
+
{done &&

{done}

} {error &&

{error}

} {request.status === "ERROR" && request.errorMessage &&

Fehler: {request.errorMessage}

} - {request.status === "REJECTED" && request.rejectionReason &&

Abgelehnt: {request.rejectionReason}

} - {exportRecord?.erpReference && ( -

- ERP-Referenz: {exportRecord.erpReference} -

- )} + {request.status === "REJECTED" && request.rejectionReason &&

Abgelehnt: {request.rejectionReason}

} {request.status === "APPROVED" && exportRecord?.lastError && (

Export wird wiederholt ({exportRecord.attempts} Versuche bisher): {exportRecord.lastError}

)} {request.possibleDuplicate && request.duplicateOfId && ( -
-

Mögliches Duplikat

+
+

⚠ Mögliches Duplikat

Gleiche E-Mail oder gleiche Dateien wie diese Anfrage. {request.duplicateDecision === "distinct" && " Entscheidung: eigenständige Anfrage."} {request.duplicateDecision === "duplicate" && " Entscheidung: als Duplikat abgelehnt."}

{request.duplicateDecision === null && canDecideDuplicate && ( - <> +
-
+ {" "} - + +
- +
)}
)} {fields.length > 0 && ( - <> -

Erkannte Angaben

- - - - - - - - {inReview && } - - - - {fields.map((field) => ( - - - - - - {inReview && ( +
+
+

Erkannte Angaben

+

Klick auf „Quelle anzeigen“ zeigt die Fundstelle im Original.

+
+
+
FeldWertStatusQuelleKorrektur
{field.label} - {field.value ?? "–"} - {field.corrected && (korrigiert; erkannt: {field.extractedValue ?? "–"})} - - - {field.corrected && (erkannt: {STATUS_LABEL[field.status]})} - {field.source ? Quelle anzeigen : "–"}
+ + + + + + + {inReview && } + + + + {fields.map((field) => ( + + + - )} - - ))} - -
FeldWertStatusQuelleKorrektur
{field.label} + {field.value ?? –} + {field.corrected && (korrigiert; erkannt: {field.extractedValue ?? "–"})} + -
- - - {" "} - -
+ + {field.corrected && (erkannt: {STATUS_LABEL[field.status]})}
+ + {field.source ? Quelle anzeigen : –} + + {inReview && ( + +
+ + + + +
+ + )} + + ))} + + + {selected && selected.itemIndex === null && } - +
)} {lineItems.length > 0 && ( - <> -

Positionen

- - - - - {lineItems[0]!.fields.map((field) => ( - - ))} - - - - {lineItems.map((item) => ( - - - {item.fields.map((field) => ( - +
+
+

Positionen

+

Klick auf einen Wert zeigt die Fundstelle und erlaubt die Korrektur.

+
+
+
Pos. - {field.label} -
{item.itemIndex + 1} - {/* The accessible name contains the visible value (WCAG 2.5.3 label in name). */} - - {field.value ?? "–"} - {" "} - -
+ + + + {lineItems[0]!.fields.map((field) => ( + ))} - ))} - -
Pos. + {field.label} +
+ + + {lineItems.map((item) => ( + + {item.itemIndex + 1} + {item.fields.map((field) => ( + + + {/* The accessible name contains the visible value (WCAG 2.5.3 label in name). */} + + {field.value ?? "–"} + + + + + ))} + + ))} + + + {selected && selected.itemIndex !== null && ( -
+

Position {selected.itemIndex + 1}: {selected.label}

@@ -239,63 +207,53 @@ export default async function RequestPage({ {selected.corrected && (erkannt: {selected.extractedValue ?? "–"}, {STATUS_LABEL[selected.status]})}

{inReview && ( -
+ {" "} +
)}
)} - +
)} {inReview && ( -
-

Entscheidung

-
- - -
-
- - {" "} - -
+
+
+

Entscheidung

+

Nach der Freigabe wird die Anfrage genau einmal ans ERP übergeben.

+
+
+
+ + +
+
+ + + +
+
)} -

Dokumente

-
    - {documents.map((document) => { - const skipped = skippedDocuments.find((entry) => entry.documentId === document.id); - const notes = documentNotes.find((entry) => entry.documentId === document.id); - return ( -
  • - {document.filename} ({Math.ceil(document.sizeBytes / 1024)} KB) - {skipped && – nicht automatisch ausgewertet} - {notes && notes.warnings.includes("ocr_pages_skipped") && – nur die ersten Scan-Seiten wurden per Texterkennung gelesen} - {notes && notes.failedAttachments.length > 0 && ( -
      - {notes.failedAttachments.map((attachment, index) => ( -
    • - ⚠ Anhang „{attachment.name ?? `Nr. ${index + 1}`}“ konnte nicht gelesen werden ({ATTACHMENT_ERROR[attachment.error ?? ""] ?? "unbekannter Grund"}) – bitte im Original prüfen. -
    • - ))} -
    - )} -
  • - ); - })} -
+
+

Dokumente

+ +
); } diff --git a/src/app/requests/[id]/review-parts.tsx b/src/app/requests/[id]/review-parts.tsx new file mode 100644 index 0000000..937ed39 --- /dev/null +++ b/src/app/requests/[id]/review-parts.tsx @@ -0,0 +1,91 @@ +import type { ReviewField, ReviewStatus, ReviewView } from "@/features/review"; + +// Presentational parts of the review page (#8, #23, #25), split out of page.tsx (#52). + +const ATTACHMENT_ERROR: Record = { + unsupported_media_type: "Format nicht unterstützt", + document_unparseable: "Datei beschädigt oder unlesbar", + document_too_long: "zu umfangreich", + nesting_too_deep: "zu tief verschachtelt", + too_many_attachments: "zu viele Anhänge", + not_attached_by_value: "nur verlinkt, nicht angehängt", + budget_exceeded: "Verarbeitungsgrenze der Nachricht erreicht", +}; + +export const STATUS_LABEL: Record = { + corrected: "korrigiert", + found: "belegt", + uncertain: "unsicher", + missing: "fehlt", + unverified: "nicht bestätigt", +}; + +export const needsAttention = (field: ReviewField): boolean => field.reviewStatus === "unverified" || field.reviewStatus === "uncertain"; + +export function StatusBadge({ status }: { status: ReviewStatus }) { + // Uncertain and unverified are the ones a clerk must look at: prominent colour + text, never colour only. + return ( + + {status === "unverified" || status === "uncertain" ? "⚠ " : ""} + {STATUS_LABEL[status]} + + ); +} + +export function Source({ field }: { field: ReviewField }) { + if (!field.source) return

Für diesen Wert gibt es keine Fundstelle.

; + const { source } = field; + return ( +
+

+ Quelle: {source.filename} – {source.heading} +

+ {source.ocr && ( +

+ ⚠ Texterkennung (OCR): Der Text stammt aus einem gescannten Dokument – bitte mit dem Original vergleichen. +

+ )} + {field.corrected &&

Fundstelle des erkannten Werts „{field.extractedValue ?? "–"}“ – der aktuelle Wert wurde manuell korrigiert.

} +
    + {source.lines.map((line) => ( +
  1. + {line.label}{" "} + {line.parts.map((part, index) => (part.mark ? {part.text} : {part.text}))} +
  2. + ))} +
+

+ Original öffnen +

+
+ ); +} + +type DocumentsProps = Pick; + +export function DocumentList({ documents, skippedDocuments, documentNotes }: DocumentsProps) { + return ( +
    + {documents.map((document) => { + const skipped = skippedDocuments.find((entry) => entry.documentId === document.id); + const notes = documentNotes.find((entry) => entry.documentId === document.id); + return ( +
  • + {document.filename} ({Math.ceil(document.sizeBytes / 1024)} KB) + {skipped && – nicht automatisch ausgewertet} + {notes && notes.warnings.includes("ocr_pages_skipped") && – nur die ersten Scan-Seiten wurden per Texterkennung gelesen} + {notes && notes.failedAttachments.length > 0 && ( +
      + {notes.failedAttachments.map((attachment, index) => ( +
    • + ⚠ Anhang „{attachment.name ?? `Nr. ${index + 1}`}“ konnte nicht gelesen werden ({ATTACHMENT_ERROR[attachment.error ?? ""] ?? "unbekannter Grund"}) – bitte im Original prüfen. +
    • + ))} +
    + )} +
  • + ); + })} +
+ ); +} diff --git a/src/app/requests/page.tsx b/src/app/requests/page.tsx index 017185f..e91723c 100644 --- a/src/app/requests/page.tsx +++ b/src/app/requests/page.tsx @@ -6,7 +6,8 @@ import { listExportRecords } from "@/features/export"; import { listRequests, type RequestFilter } from "@/features/requests"; import { reprocessAction } from "./actions"; import { requestRowView } from "./row-view"; -import { REQUEST_STATUS_LABEL, requestStatusLabel } from "./status-labels"; +import { REQUEST_STATUS_LABEL } from "./status-labels"; +import { StatusPill } from "./status-pill"; import { UploadForm } from "./upload-form"; export const dynamic = "force-dynamic"; @@ -41,85 +42,107 @@ export default async function RequestsPage({ searchParams }: { searchParams: Pro return (
-

Anfragen

- +
+
+

Anfragen

+

Neue Anfrage als E-Mail oder Dateien hochladen – sie wird automatisch ausgelesen und erscheint danach zur Prüfung.

+
+
+
+ +
{done &&

{done}

} {error &&

{error}

} -
- {" "} - {" "} - -
- {requests.length === 0 ? ( -

{filter.status || filter.possibleDuplicate ? "Keine Anfragen für diesen Filter." : "Noch keine Anfragen."}

- ) : ( - - - - - - - - - - - - - - - {requests.map((request) => { - const row = requestRowView(request, exports.get(request.id)); - return ( - - - - - - {/* The stage appears once: in the status for ERROR, as a prefix while retrying. */} - - - - +
+
+ + + + {requests.length === 1 ? "1 Anfrage" : `${requests.length} Anfragen`} + + {requests.length === 0 ? ( +

{filter.status || filter.possibleDuplicate ? "Keine Anfragen für diesen Filter." : "Noch keine Anfragen."}

+ ) : ( +
+
EingangBetreffStatusVersucheLetzter FehlerNächster VersuchHinweisAktion
{dateFormat.format(request.createdAt)} - {request.subject ?? "(ohne Betreff)"} - - {requestStatusLabel(request.status)} - {request.status === "ERROR" && row.stage ? ` (${STAGE_LABEL[row.stage]})` : ""} - {row.attempts > 0 ? row.attempts : "–"}{row.error ? `${request.status !== "ERROR" && row.stage ? `${STAGE_LABEL[row.stage]}: ` : ""}${row.error}` : "–"}{row.nextRetryAt ? dateFormat.format(row.nextRetryAt) : "–"} - {request.possibleDuplicate - ? request.duplicateDecision === "distinct" - ? "Duplikat geprüft: eigenständig" - : request.duplicateDecision === "duplicate" - ? "Als Duplikat abgelehnt" - : "Mögliches Duplikat – Entscheidung offen" - : ""} - - {request.status === "ERROR" && ( -
- - -
- )} -
+ + + + + + + + + + - ); - })} - -
EingangBetreffStatus + Versuche + Letzter FehlerNächster VersuchHinweis + Aktion +
- )} + + + {requests.map((request) => { + const row = requestRowView(request, exports.get(request.id)); + return ( + + {dateFormat.format(request.createdAt)} + + {request.subject ?? "(ohne Betreff)"} + + + + {request.status === "ERROR" && row.stage && {STAGE_LABEL[row.stage]}} + + {row.attempts > 0 ? row.attempts : "–"} + {/* The stage appears once: in the status for ERROR, as a prefix while retrying. */} + {row.error ? `${request.status !== "ERROR" && row.stage ? `${STAGE_LABEL[row.stage]}: ` : ""}${row.error}` : "–"} + {row.nextRetryAt ? dateFormat.format(row.nextRetryAt) : "–"} + + {request.possibleDuplicate ? ( + request.duplicateDecision === "distinct" ? ( + "Duplikat geprüft: eigenständig" + ) : request.duplicateDecision === "duplicate" ? ( + "Als Duplikat abgelehnt" + ) : ( + ⚠ Mögliches Duplikat – Entscheidung offen + ) + ) : ( + "" + )} + + + {request.status === "ERROR" && ( +
+ + +
+ )} + + + ); + })} + + + + )} +
); } diff --git a/src/app/requests/status-pill.tsx b/src/app/requests/status-pill.tsx new file mode 100644 index 0000000..760706b --- /dev/null +++ b/src/app/requests/status-pill.tsx @@ -0,0 +1,20 @@ +import { requestStatusLabel } from "./status-labels"; + +// Colour tone per request status (#52). The label text is always shown – colour only supports it. +const TONE: Record = { + NEW: "neutral", + PROCESSING: "info", + REVIEW: "warn", + APPROVED: "info", + EXPORTED: "success", + REJECTED: "neutral", + ERROR: "danger", +}; + +export function StatusPill({ status, testId }: { status: string; testId?: string }) { + return ( + + {requestStatusLabel(status)} + + ); +} diff --git a/src/app/requests/upload-form.tsx b/src/app/requests/upload-form.tsx index 3fe7e1e..bbdbeaf 100644 --- a/src/app/requests/upload-form.tsx +++ b/src/app/requests/upload-form.tsx @@ -8,6 +8,7 @@ import { useState, type FormEvent } from "react"; export function UploadForm() { const router = useRouter(); const [message, setMessage] = useState(null); + const [failed, setFailed] = useState(false); const [busy, setBusy] = useState(false); async function submit(event: FormEvent) { @@ -15,6 +16,7 @@ export function UploadForm() { const formElement = event.currentTarget; setBusy(true); setMessage(null); + setFailed(false); const response = await fetch("/api/requests", { method: "POST", body: new FormData(formElement) }); setBusy(false); if (response.ok) { @@ -25,20 +27,20 @@ export function UploadForm() { return; } const body = (await response.json().catch(() => null)) as { error?: { title?: string } } | null; + setFailed(true); setMessage(body?.error?.title ?? "Upload fehlgeschlagen."); } return ( -
-

+ +

-

- - {message &&

{message}

} + {message &&

{message}

}
); } diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index 9f049f1..157854f 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -6,18 +6,20 @@ export default async function SignupPage({ searchParams }: { searchParams: Promi const { invitation } = await searchParams; return (
-

Konto anlegen

- {invitation ? ( - <> -

Verwenden Sie die E-Mail-Adresse, an die die Einladung ging.

- - - ) : ( -

Ein Konto kann nur über einen Einladungslink angelegt werden. Bitte wenden Sie sich an Ihre Administration.

- )} -

- Zur Anmeldung -

+
+

Konto anlegen

+ {invitation ? ( + <> +

Verwenden Sie die E-Mail-Adresse, an die die Einladung ging.

+ + + ) : ( +

Ein Konto kann nur über einen Einladungslink angelegt werden. Bitte wenden Sie sich an Ihre Administration.

+ )} +

+ Zur Anmeldung +

+
); } diff --git a/src/app/users/page.tsx b/src/app/users/page.tsx index 6468a99..8934cd0 100644 --- a/src/app/users/page.tsx +++ b/src/app/users/page.tsx @@ -39,66 +39,82 @@ export default async function UsersPage({ searchParams }: { searchParams: Promis return (
-

- ← Start -

-

Benutzer

+
+
+

Benutzer

+

Rollen ändern und Zugänge deaktivieren. Der letzte aktive Admin bleibt immer erhalten.

+
+ + Person einladen + +
{done &&

{done}

} {error &&

{error}

} -

- Person einladen -

- - - - - - - - - - - - {view.users.map((user) => ( - - - - - - - - ))} - -
NameE-MailRolleStatusAktionen
{user.name}{user.email} -
- - {" "} - -
-
{user.active ? "aktiv" : "deaktiviert"} -
- - -
-
-

Offene Einladungen

- {view.invitations.length === 0 ? ( -

Keine offenen Einladungen.

- ) : ( -
    - {view.invitations.map((invitation) => ( -
  • - {invitation.email} – {ROLE_LABEL[invitation.role] ?? invitation.role}, gültig bis {dateFormat.format(invitation.expiresAt)} -
  • - ))} -
- )} +
+
+ + + + + + + + + + + + {view.users.map((user) => ( + + + + + + + + ))} + +
NameE-MailRolleStatus + Aktionen +
{user.name}{user.email} +
+ + + +
+
+ {user.active ? "aktiv" : "deaktiviert"} + +
+ + +
+
+
+
+
+

Offene Einladungen

+ {view.invitations.length === 0 ? ( +

Keine offenen Einladungen.

+ ) : ( +
    + {view.invitations.map((invitation) => ( +
  • + {invitation.email} – {ROLE_LABEL[invitation.role] ?? invitation.role}, gültig bis {dateFormat.format(invitation.expiresAt)} +
  • + ))} +
+ )} +
); } From 15d58a787a29eb248cb3d96bb0224cde0974198a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 16:42:48 +0000 Subject: [PATCH 03/13] fix(app): keep hidden table labels inside the scroll container; wider subject column and duplicate reason (#52) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- src/app/globals.css | 24 +++++++++++++++++++++++- src/app/requests/page.tsx | 6 ++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index f8a60d8..a286e00 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -331,6 +331,9 @@ main { .callout-warn h2 { font-size: 1.05rem; } +.callout-warn input { + min-width: min(20rem, 100%); +} .callout-warn a { color: var(--warn-text); font-weight: 600; @@ -525,6 +528,9 @@ a.btn-link:hover { .table-wrap { overflow-x: auto; + /* Containing block for the absolutely positioned .visually-hidden labels inside cells – without + it they escape the scroll container and widen the page on narrow screens. */ + position: relative; } table { border-collapse: collapse; @@ -668,6 +674,15 @@ tr.attention:hover { .hint { font-size: 0.85rem; } +.hint .badge { + white-space: normal; +} +.subject { + min-width: 15rem; +} +.subject a { + font-weight: 500; +} /* ---------- Review: values, source ---------- */ @@ -768,8 +783,12 @@ mark { position: absolute; width: 1px; height: 1px; + margin: -1px; + padding: 0; + border: 0; overflow: hidden; clip: rect(0 0 0 0); + clip-path: inset(50%); white-space: nowrap; } @@ -780,9 +799,12 @@ mark { main { padding: 1.25rem 0.9rem 2rem; } - .card { + .card:not(.card-flush) { padding: 1rem; } + .toolbar { + padding: 0.75rem 1rem; + } .card:not(.card-flush) > .table-wrap { margin-left: -1rem; margin-right: -1rem; diff --git a/src/app/requests/page.tsx b/src/app/requests/page.tsx index e91723c..72696e4 100644 --- a/src/app/requests/page.tsx +++ b/src/app/requests/page.tsx @@ -82,7 +82,9 @@ export default async function RequestsPage({ searchParams }: { searchParams: Pro Eingang - Betreff + + Betreff + Status Versuche @@ -101,7 +103,7 @@ export default async function RequestsPage({ searchParams }: { searchParams: Pro return ( {dateFormat.format(request.createdAt)} - + {request.subject ?? "(ohne Betreff)"} From 3537b440aad20b5e4650118767c7795d95bfe3f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 16:44:07 +0000 Subject: [PATCH 04/13] fix(app): duplicate reason field no longer overlaps its button; changelog (#52) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- CHANGELOG.md | 5 +++++ src/app/globals.css | 7 +++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d461102..ffb5600 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ This file records what changes **in the product** – process and session state ## [Unreleased] ### Added +- Visual design for the pilot UI: a shared header with navigation, company and role on every page; + request and field status as labelled pills; cards, distinct primary and destructive actions, and tables + that scroll inside their container on narrow screens. Colours are design tokens in `globals.css` + (WCAG AA), system fonts only (no third-party requests). Login and upload errors are now announced as + alerts. - Observability: structured JSON logs (pino) with IDs and codes only, correlated by the request id from web through worker to the AI service; `/api/health` also shows whether the AI service is reachable and how many jobs are waiting. diff --git a/src/app/globals.css b/src/app/globals.css index a286e00..8dc8aa4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -331,8 +331,11 @@ main { .callout-warn h2 { font-size: 1.05rem; } -.callout-warn input { - min-width: min(20rem, 100%); +.callout-warn .inline-form input { + /* Fixed width: a percentage max-width inside the inline-flex label resolves cyclically and lets + the field overlap the button next to it. */ + width: min(20rem, 55vw); + max-width: none; } .callout-warn a { color: var(--warn-text); From f11994072bf01507921f34f268b0260cb145e51c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 16:49:43 +0000 Subject: [PATCH 05/13] fix(app): one session lookup per page shared by header and page; header falls back to the brand on errors (#52 review) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- src/app/_components/app-header.tsx | 30 ++++++++++++++++++++++-------- src/app/_server/runtime.ts | 8 ++++++++ src/app/page.tsx | 5 ++--- src/app/requests/[id]/page.tsx | 5 ++--- src/app/requests/page.tsx | 5 ++--- src/app/users/page.tsx | 5 ++--- 6 files changed, 38 insertions(+), 20 deletions(-) diff --git a/src/app/_components/app-header.tsx b/src/app/_components/app-header.tsx index d1eba1f..79c7d21 100644 --- a/src/app/_components/app-header.tsx +++ b/src/app/_components/app-header.tsx @@ -1,17 +1,31 @@ import Link from "next/link"; -import { headers } from "next/headers"; -import { currentActor, getRuntime } from "@/app/_server/runtime"; -import { getCompany } from "@/features/identity"; +import { unstable_rethrow } from "next/navigation"; +import { getRuntime, requestActor } from "@/app/_server/runtime"; +import { getCompany, type Actor } from "@/features/identity"; +import { logEvent } from "@/features/observability"; import { SignOutButton } from "./auth-form"; import { NavLink } from "./nav-link"; const ROLE_LABEL = { admin: "Administration", clerk: "Sachbearbeitung" } as const; +async function signedIn(): Promise<{ actor: Actor; companyName: string | undefined } | null> { + try { + const actor = await requestActor(); + if (!actor) return null; + const company = await getCompany(getRuntime().database.db, actor.companyId); + return { actor, companyName: company?.name }; + } catch (error) { + unstable_rethrow(error); // Next.js control flow (dynamic rendering, redirects) is not a failure. + // The header must never take a page down (e.g. database unreachable): show the brand only. + logEvent("error", "app_header.failed", {}, { code: error instanceof Error ? error.name : "unknown" }); + return null; + } +} + // App shell (#52): brand on every page; navigation, company, role and sign-out only when signed in. // Navigation mirrors the server-side permissions – it hides links, it never grants access. export async function AppHeader() { - const actor = await currentActor(await headers()); - const company = actor ? await getCompany(getRuntime().database.db, actor.companyId) : null; + const session = await signedIn(); return (
@@ -22,15 +36,15 @@ export async function AppHeader() { RequestFlow Pilot - {actor && ( + {session && ( <>
- {company?.name} · Rolle: {ROLE_LABEL[actor.role]} + {session.companyName} · Rolle: {ROLE_LABEL[session.actor.role]}
diff --git a/src/app/_server/runtime.ts b/src/app/_server/runtime.ts index 243f4f6..7ecef70 100644 --- a/src/app/_server/runtime.ts +++ b/src/app/_server/runtime.ts @@ -1,3 +1,5 @@ +import { headers } from "next/headers"; +import { cache } from "react"; import { loadConfig, type AppConfig } from "@/config/env"; import { createDatabase, type DatabaseHandle } from "@/db"; import { createJobQueue } from "@/db/job-queue-client"; @@ -49,3 +51,9 @@ export async function currentActor(headers: Headers): Promise { const { auth, database } = getRuntime(); return getActor(auth, database.db, headers); } + +/** + * The signed-in actor of the page being rendered, looked up once per request and shared by the app + * header (layout) and the page (#52). Server actions and route handlers keep calling `currentActor`. + */ +export const requestActor = cache(async (): Promise => currentActor(await headers())); diff --git a/src/app/page.tsx b/src/app/page.tsx index 8c6b689..5f14e8b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,12 +1,11 @@ import Link from "next/link"; -import { headers } from "next/headers"; -import { currentActor } from "@/app/_server/runtime"; +import { requestActor } from "@/app/_server/runtime"; export const dynamic = "force-dynamic"; // Start page: company, role and sign-out live in the app header (#52); this page points to the work. export default async function HomePage() { - const actor = await currentActor(await headers()); + const actor = await requestActor(); if (!actor) { return (
diff --git a/src/app/requests/[id]/page.tsx b/src/app/requests/[id]/page.tsx index 7e50e58..ce97596 100644 --- a/src/app/requests/[id]/page.tsx +++ b/src/app/requests/[id]/page.tsx @@ -1,7 +1,6 @@ import Link from "next/link"; -import { headers } from "next/headers"; import { notFound, redirect } from "next/navigation"; -import { currentActor, getRuntime } from "@/app/_server/runtime"; +import { getRuntime, requestActor } from "@/app/_server/runtime"; import { duplicateDecidable, loadReview, REJECTION_REASON_MAX } from "@/features/review"; import { StatusPill } from "../status-pill"; import { DONE_MESSAGES, ERROR_MESSAGES, messageFor } from "./messages"; @@ -19,7 +18,7 @@ export default async function RequestPage({ params: Promise<{ id: string }>; searchParams: Promise>; }) { - const actor = await currentActor(await headers()); + const actor = await requestActor(); if (!actor) redirect("/login"); const { id } = await params; if (!UUID.test(id)) notFound(); diff --git a/src/app/requests/page.tsx b/src/app/requests/page.tsx index 72696e4..6e781f2 100644 --- a/src/app/requests/page.tsx +++ b/src/app/requests/page.tsx @@ -1,7 +1,6 @@ import Link from "next/link"; -import { headers } from "next/headers"; import { redirect } from "next/navigation"; -import { currentActor, getRuntime } from "@/app/_server/runtime"; +import { getRuntime, requestActor } from "@/app/_server/runtime"; import { listExportRecords } from "@/features/export"; import { listRequests, type RequestFilter } from "@/features/requests"; import { reprocessAction } from "./actions"; @@ -29,7 +28,7 @@ function filterOf(query: Record): RequestFilter { // Request list (#26): status, attempts, last error with its stage, next retry; reprocess for ERROR. export default async function RequestsPage({ searchParams }: { searchParams: Promise> }) { - const actor = await currentActor(await headers()); + const actor = await requestActor(); if (!actor) redirect("/login"); const query = await searchParams; const filter = filterOf(query); diff --git a/src/app/users/page.tsx b/src/app/users/page.tsx index 8934cd0..a5c349f 100644 --- a/src/app/users/page.tsx +++ b/src/app/users/page.tsx @@ -1,7 +1,6 @@ import Link from "next/link"; -import { headers } from "next/headers"; import { notFound, redirect } from "next/navigation"; -import { currentActor, getRuntime } from "@/app/_server/runtime"; +import { getRuntime, requestActor } from "@/app/_server/runtime"; import { AuthorizationError, listCompanyUsers } from "@/features/identity"; import { changeRoleAction, deactivateAction, reactivateAction } from "./actions"; @@ -24,7 +23,7 @@ const pick = (table: Record, code: string | undefined) => (code // User management for company admins (#30). Clerks get 404 – server-side, on render and in every action. export default async function UsersPage({ searchParams }: { searchParams: Promise> }) { - const actor = await currentActor(await headers()); + const actor = await requestActor(); if (!actor) redirect("/login"); let view: Awaited>; try { From 7cf7365d83b10d44922c333c3e981b3514583b35 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 16:49:59 +0000 Subject: [PATCH 06/13] =?UTF-8?q?refactor(app):=20split=20the=20stylesheet?= =?UTF-8?q?=20=E2=80=93=20tables,=20status=20and=20review=20styles=20in=20?= =?UTF-8?q?components.css=20(#52=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- src/app/components.css | 298 +++++++++++++++++++++++++++++++++++++++++ src/app/globals.css | 296 ---------------------------------------- src/app/layout.tsx | 1 + 3 files changed, 299 insertions(+), 296 deletions(-) create mode 100644 src/app/components.css diff --git a/src/app/components.css b/src/app/components.css new file mode 100644 index 0000000..b085467 --- /dev/null +++ b/src/app/components.css @@ -0,0 +1,298 @@ +/* Pilot design system (#52), part 2: tables, status, review views and responsive rules. + * Tokens live in globals.css (imported first by the root layout). */ + +/* ---------- Tables ---------- */ + +.table-wrap { + overflow-x: auto; + /* Containing block for the absolutely positioned .visually-hidden labels inside cells – without + it they escape the scroll container and widen the page on narrow screens. */ + position: relative; +} +table { + border-collapse: collapse; + width: 100%; + font-size: 0.925rem; +} +th, +td { + border-bottom: 1px solid var(--border); + padding: 0.65rem 0.8rem; + text-align: left; + vertical-align: middle; +} +thead th { + background: var(--surface-muted); + color: var(--text-muted); + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; +} +tbody tr:last-child > * { + border-bottom: 0; +} +tbody tr:hover { + background: #fafbfc; +} +tbody th { + font-weight: 600; +} +td.num, +th.num { + text-align: right; + font-variant-numeric: tabular-nums; +} +td.nowrap { + white-space: nowrap; +} +.muted { + color: var(--text-muted); +} +.empty { + padding: 2rem 1.4rem; + text-align: center; + color: var(--text-muted); + margin: 0; +} +tr.attention, +td.attention { + background: #fffbeb; +} +tr.attention:hover { + background: #fff6d9; +} +.card > .table-wrap:first-child { + margin-top: -0.25rem; +} +.card:not(.card-flush) > .table-wrap { + margin-left: -1.4rem; + margin-right: -1.4rem; +} +.card:not(.card-flush) > .table-wrap th:first-child, +.card:not(.card-flush) > .table-wrap td:first-child { + padding-left: 1.4rem; +} + +/* ---------- Status ---------- */ + +.badge, +.pill { + display: inline-flex; + align-items: center; + gap: 0.25rem; + border-radius: 999px; + padding: 0.08rem 0.55rem; + font-size: 0.78rem; + font-weight: 600; + line-height: 1.5; + white-space: nowrap; + border: 1px solid transparent; + vertical-align: middle; +} +.badge-found { + background: var(--success-bg); + color: var(--success-text); +} +.badge-missing { + background: var(--neutral-bg); + color: var(--neutral-text); +} +.badge-corrected { + background: var(--info-bg); + color: var(--info-text); +} +.badge-uncertain, +.badge-unverified { + background: var(--warn-bg); + color: var(--warn-text); + border-color: var(--warn-border); + font-weight: 700; +} +.pill { + font-size: 0.8rem; + padding: 0.12rem 0.65rem; +} +.pill::before { + content: ""; + width: 0.45rem; + height: 0.45rem; + border-radius: 50%; + background: currentColor; + opacity: 0.8; +} +.pill-neutral { + background: var(--neutral-bg); + color: var(--neutral-text); +} +.pill-info { + background: var(--info-bg); + color: var(--info-text); +} +.pill-warn { + background: var(--warn-bg); + color: var(--warn-text); +} +.pill-success { + background: var(--success-bg); + color: var(--success-text); +} +.pill-danger { + background: var(--danger-bg); + color: var(--danger-text); +} +.stage { + display: block; + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 0.15rem; +} +.hint { + font-size: 0.85rem; +} +.hint .badge { + white-space: normal; +} +.subject { + min-width: 15rem; +} +.subject a { + font-weight: 500; +} + +/* ---------- Review: values, source ---------- */ + +.value-cell a { + font-weight: 500; +} +.item-value { + display: inline-flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} +.source { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-muted); + padding: 1rem 1.2rem; + margin-top: 1rem; +} +.source h3 { + font-size: 0.95rem; +} +.source-lines { + font-family: var(--mono); + font-size: 0.85rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.5rem 0.75rem 0.5rem 2.75rem; + margin: 0 0 0.75rem; + overflow-x: auto; +} +.source-lines li { + padding: 0.1rem 0.4rem; + border-radius: 4px; +} +.source-lines li::marker { + color: var(--text-muted); +} +.source-lines li.cited { + background: var(--cited); + box-shadow: inset 3px 0 0 var(--mark); +} +.line-label { + color: var(--text-muted); + font-size: 0.8em; + font-family: var(--font); +} +mark { + background: var(--mark); + color: var(--text); + border-radius: 3px; + padding: 0 0.15rem; +} +.panel { + border-top: 1px solid var(--border); + margin-top: 1rem; + padding-top: 1rem; +} +.panel h3 { + font-size: 1.05rem; +} +.doc-list { + list-style: none; + padding: 0; + margin: 0; +} +.doc-list > li { + padding: 0.55rem 0; + border-bottom: 1px solid var(--border); +} +.doc-list > li:last-child { + border-bottom: 0; +} +.doc-list ul { + margin: 0.5rem 0 0; + padding: 0; + list-style: none; +} +.doc-list [role="note"] { + margin: 0.25rem 0 0; +} +.facts { + display: flex; + gap: 1.5rem; + flex-wrap: wrap; + margin: 0 0 1.25rem; + padding: 0; + list-style: none; + color: var(--text-muted); + font-size: 0.9rem; +} +.facts strong { + color: var(--text); +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} + +@media (max-width: 640px) { + body { + font-size: 14.5px; + } + main { + padding: 1.25rem 0.9rem 2rem; + } + .card:not(.card-flush) { + padding: 1rem; + } + .toolbar { + padding: 0.75rem 1rem; + } + .card:not(.card-flush) > .table-wrap { + margin-left: -1rem; + margin-right: -1rem; + } + .account span { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + * { + transition: none !important; + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 8dc8aa4..f4e1904 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -526,299 +526,3 @@ a.btn-link:hover { flex-basis: 100%; margin: 0; } - -/* ---------- Tables ---------- */ - -.table-wrap { - overflow-x: auto; - /* Containing block for the absolutely positioned .visually-hidden labels inside cells – without - it they escape the scroll container and widen the page on narrow screens. */ - position: relative; -} -table { - border-collapse: collapse; - width: 100%; - font-size: 0.925rem; -} -th, -td { - border-bottom: 1px solid var(--border); - padding: 0.65rem 0.8rem; - text-align: left; - vertical-align: middle; -} -thead th { - background: var(--surface-muted); - color: var(--text-muted); - font-size: 0.78rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; - white-space: nowrap; -} -tbody tr:last-child > * { - border-bottom: 0; -} -tbody tr:hover { - background: #fafbfc; -} -tbody th { - font-weight: 600; -} -td.num, -th.num { - text-align: right; - font-variant-numeric: tabular-nums; -} -td.nowrap { - white-space: nowrap; -} -.muted { - color: var(--text-muted); -} -.empty { - padding: 2rem 1.4rem; - text-align: center; - color: var(--text-muted); - margin: 0; -} -tr.attention, -td.attention { - background: #fffbeb; -} -tr.attention:hover { - background: #fff6d9; -} -.card > .table-wrap:first-child { - margin-top: -0.25rem; -} -.card:not(.card-flush) > .table-wrap { - margin-left: -1.4rem; - margin-right: -1.4rem; -} -.card:not(.card-flush) > .table-wrap th:first-child, -.card:not(.card-flush) > .table-wrap td:first-child { - padding-left: 1.4rem; -} - -/* ---------- Status ---------- */ - -.badge, -.pill { - display: inline-flex; - align-items: center; - gap: 0.25rem; - border-radius: 999px; - padding: 0.08rem 0.55rem; - font-size: 0.78rem; - font-weight: 600; - line-height: 1.5; - white-space: nowrap; - border: 1px solid transparent; - vertical-align: middle; -} -.badge-found { - background: var(--success-bg); - color: var(--success-text); -} -.badge-missing { - background: var(--neutral-bg); - color: var(--neutral-text); -} -.badge-corrected { - background: var(--info-bg); - color: var(--info-text); -} -.badge-uncertain, -.badge-unverified { - background: var(--warn-bg); - color: var(--warn-text); - border-color: var(--warn-border); - font-weight: 700; -} -.pill { - font-size: 0.8rem; - padding: 0.12rem 0.65rem; -} -.pill::before { - content: ""; - width: 0.45rem; - height: 0.45rem; - border-radius: 50%; - background: currentColor; - opacity: 0.8; -} -.pill-neutral { - background: var(--neutral-bg); - color: var(--neutral-text); -} -.pill-info { - background: var(--info-bg); - color: var(--info-text); -} -.pill-warn { - background: var(--warn-bg); - color: var(--warn-text); -} -.pill-success { - background: var(--success-bg); - color: var(--success-text); -} -.pill-danger { - background: var(--danger-bg); - color: var(--danger-text); -} -.stage { - display: block; - font-size: 0.8rem; - color: var(--text-muted); - margin-top: 0.15rem; -} -.hint { - font-size: 0.85rem; -} -.hint .badge { - white-space: normal; -} -.subject { - min-width: 15rem; -} -.subject a { - font-weight: 500; -} - -/* ---------- Review: values, source ---------- */ - -.value-cell a { - font-weight: 500; -} -.item-value { - display: inline-flex; - align-items: center; - gap: 0.4rem; - flex-wrap: wrap; -} -.source { - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--surface-muted); - padding: 1rem 1.2rem; - margin-top: 1rem; -} -.source h3 { - font-size: 0.95rem; -} -.source-lines { - font-family: var(--mono); - font-size: 0.85rem; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: 0.5rem 0.75rem 0.5rem 2.75rem; - margin: 0 0 0.75rem; - overflow-x: auto; -} -.source-lines li { - padding: 0.1rem 0.4rem; - border-radius: 4px; -} -.source-lines li::marker { - color: var(--text-muted); -} -.source-lines li.cited { - background: var(--cited); - box-shadow: inset 3px 0 0 var(--mark); -} -.line-label { - color: var(--text-muted); - font-size: 0.8em; - font-family: var(--font); -} -mark { - background: var(--mark); - color: var(--text); - border-radius: 3px; - padding: 0 0.15rem; -} -.panel { - border-top: 1px solid var(--border); - margin-top: 1rem; - padding-top: 1rem; -} -.panel h3 { - font-size: 1.05rem; -} -.doc-list { - list-style: none; - padding: 0; - margin: 0; -} -.doc-list > li { - padding: 0.55rem 0; - border-bottom: 1px solid var(--border); -} -.doc-list > li:last-child { - border-bottom: 0; -} -.doc-list ul { - margin: 0.5rem 0 0; - padding: 0; - list-style: none; -} -.doc-list [role="note"] { - margin: 0.25rem 0 0; -} -.facts { - display: flex; - gap: 1.5rem; - flex-wrap: wrap; - margin: 0 0 1.25rem; - padding: 0; - list-style: none; - color: var(--text-muted); - font-size: 0.9rem; -} -.facts strong { - color: var(--text); -} - -.visually-hidden { - position: absolute; - width: 1px; - height: 1px; - margin: -1px; - padding: 0; - border: 0; - overflow: hidden; - clip: rect(0 0 0 0); - clip-path: inset(50%); - white-space: nowrap; -} - -@media (max-width: 640px) { - body { - font-size: 14.5px; - } - main { - padding: 1.25rem 0.9rem 2rem; - } - .card:not(.card-flush) { - padding: 1rem; - } - .toolbar { - padding: 0.75rem 1rem; - } - .card:not(.card-flush) > .table-wrap { - margin-left: -1rem; - margin-right: -1rem; - } - .account span { - display: none; - } -} - -@media (prefers-reduced-motion: reduce) { - * { - transition: none !important; - } -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 63acdda..5dcb637 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import type { ReactNode } from "react"; import { AppHeader } from "./_components/app-header"; import "./globals.css"; +import "./components.css"; export const metadata: Metadata = { title: "RequestFlow", From e514448c6fe192fcfe8ad1c63e1cea5065dfe5be Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 16:50:29 +0000 Subject: [PATCH 07/13] =?UTF-8?q?fix(app):=20review=20findings=20=E2=80=93?= =?UTF-8?q?=20scroll=20padding=20under=20the=20sticky=20header,=20company?= =?UTF-8?q?=20and=20role=20visible=20on=20phones,=20AA=20field=20borders,?= =?UTF-8?q?=20export=20retry=20as=20warning=20(#52=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- src/app/components.css | 192 ++++++++++++++++++++++++++++++++- src/app/globals.css | 191 ++------------------------------ src/app/requests/[id]/page.tsx | 2 +- 3 files changed, 197 insertions(+), 188 deletions(-) diff --git a/src/app/components.css b/src/app/components.css index b085467..50f13f1 100644 --- a/src/app/components.css +++ b/src/app/components.css @@ -1,6 +1,188 @@ -/* Pilot design system (#52), part 2: tables, status, review views and responsive rules. +/* Pilot design system (#52), part 2: forms, buttons, tables, status, review views, responsive rules. * Tokens live in globals.css (imported first by the root layout). */ +/* ---------- Forms and buttons ---------- */ + +label { + font-weight: 500; +} +input:not([type="checkbox"], [type="radio"], [type="hidden"], [type="file"]), +select { + font: inherit; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border-input); + border-radius: var(--radius-sm); + padding: 0.4rem 0.6rem; + min-height: 2.25rem; + max-width: 100%; +} +input:not([type="checkbox"], [type="radio"], [type="hidden"], [type="file"]):hover, +select:hover { + border-color: var(--text-muted); +} +input[type="file"] { + font: inherit; + max-width: 100%; +} +input[type="file"]::file-selector-button { + font: inherit; + font-weight: 500; + margin-right: 0.75rem; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface); + padding: 0.35rem 0.8rem; + cursor: pointer; +} +input[type="checkbox"] { + width: 1rem; + height: 1rem; + accent-color: var(--brand); + vertical-align: -2px; +} +.field { + margin-bottom: 0.9rem; +} +.field label { + display: block; + margin-bottom: 0.3rem; +} +.field input, +.field select { + width: 100%; +} + +button { + font: inherit; + font-weight: 500; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + padding: 0.4rem 0.9rem; + min-height: 2.25rem; + cursor: pointer; + white-space: nowrap; +} +button:hover { + background: var(--neutral-bg); +} +button:disabled { + opacity: 0.6; + cursor: progress; +} +button.btn-primary { + background: var(--brand); + border-color: var(--brand); + color: #fff; +} +button.btn-primary:hover { + background: var(--brand-hover); + border-color: var(--brand-hover); +} +button.btn-danger { + background: var(--surface); + border-color: var(--danger); + color: var(--danger); +} +button.btn-danger:hover { + background: var(--danger); + color: #fff; +} +button.btn-ghost { + border-color: transparent; + background: transparent; + color: var(--text-muted); +} +button.btn-ghost:hover { + background: var(--neutral-bg); + color: var(--text); +} +button.btn-small { + min-height: 1.9rem; + padding: 0.2rem 0.65rem; + font-size: 0.875rem; +} + +a.btn-link { + display: inline-flex; + align-items: center; + min-height: 2.25rem; + padding: 0.4rem 0.9rem; + border-radius: var(--radius-sm); + background: var(--brand); + color: #fff; + font-weight: 500; + text-decoration: none; + white-space: nowrap; +} +a.btn-link:hover { + background: var(--brand-hover); + color: #fff; +} + +.inline-form { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin: 0; +} +.inline-form label { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} +.toolbar { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + padding: 0.9rem 1.4rem; + border-bottom: 1px solid var(--border); + background: var(--surface-muted); + margin: 0; +} +.toolbar label { + display: inline-flex; + align-items: center; + gap: 0.45rem; +} +.actions { + display: flex; + align-items: flex-end; + gap: 1.5rem; + flex-wrap: wrap; +} +.actions form { + margin: 0; +} +.upload { + display: flex; + align-items: flex-end; + gap: 1rem; + flex-wrap: wrap; +} +.upload .dropzone { + flex: 1; + min-width: 16rem; + border: 1.5px dashed var(--border-strong); + border-radius: var(--radius); + background: var(--surface-muted); + padding: 0.9rem 1rem; + margin: 0; +} +.upload .dropzone label { + display: block; + margin-bottom: 0.4rem; +} +.upload [role="status"], +.upload [role="alert"] { + flex-basis: 100%; + margin: 0; +} + /* ---------- Tables ---------- */ .table-wrap { @@ -286,8 +468,12 @@ mark { margin-left: -1rem; margin-right: -1rem; } - .account span { - display: none; + /* Company and role stay visible on phones: the account block takes its own row. */ + .account { + margin-left: 0; + width: 100%; + justify-content: space-between; + font-size: 0.85rem; } } diff --git a/src/app/globals.css b/src/app/globals.css index f4e1904..0fa4c53 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -8,10 +8,11 @@ --surface: #ffffff; --surface-muted: #f9fafb; --text: #1a1f26; - --text-muted: #5a6472; /* 5.9:1 on --surface */ + --text-muted: #5a6472; /* 6.0:1 on --surface */ --border: #dfe3e8; --border-strong: #c3c9d1; - --brand: #1f4fd1; /* 6.3:1 on white */ + --border-input: #8a939f; /* 3.2:1 on white – form field boundaries (WCAG 1.4.11) */ + --brand: #1f4fd1; /* 6.8:1 on white */ --brand-hover: #173ea6; --brand-soft: #ebf0fd; --focus: #1a5fb4; @@ -45,6 +46,8 @@ html { -webkit-text-size-adjust: 100%; + /* The sticky header must not cover the focused element (WCAG 2.4.11); it wraps on narrow screens. */ + scroll-padding-top: 4.5rem; } body { @@ -174,7 +177,8 @@ code { .account { display: flex; align-items: center; - gap: 0.75rem; + flex-wrap: wrap; + gap: 0.25rem 0.75rem; font-size: 0.9rem; color: var(--text-muted); margin-left: auto; @@ -345,184 +349,3 @@ main { background: var(--info-bg); color: var(--info-text); } - -/* ---------- Forms and buttons ---------- */ - -label { - font-weight: 500; -} -input:not([type="checkbox"], [type="radio"], [type="hidden"], [type="file"]), -select { - font: inherit; - color: var(--text); - background: var(--surface); - border: 1px solid var(--border-strong); - border-radius: var(--radius-sm); - padding: 0.4rem 0.6rem; - min-height: 2.25rem; - max-width: 100%; -} -input:not([type="checkbox"], [type="radio"], [type="hidden"], [type="file"]):hover, -select:hover { - border-color: var(--text-muted); -} -input[type="file"] { - font: inherit; - max-width: 100%; -} -input[type="file"]::file-selector-button { - font: inherit; - font-weight: 500; - margin-right: 0.75rem; - border: 1px solid var(--border-strong); - border-radius: var(--radius-sm); - background: var(--surface); - padding: 0.35rem 0.8rem; - cursor: pointer; -} -input[type="checkbox"] { - width: 1rem; - height: 1rem; - accent-color: var(--brand); - vertical-align: -2px; -} -.field { - margin-bottom: 0.9rem; -} -.field label { - display: block; - margin-bottom: 0.3rem; -} -.field input, -.field select { - width: 100%; -} - -button { - font: inherit; - font-weight: 500; - color: var(--text); - background: var(--surface); - border: 1px solid var(--border-strong); - border-radius: var(--radius-sm); - padding: 0.4rem 0.9rem; - min-height: 2.25rem; - cursor: pointer; - white-space: nowrap; -} -button:hover { - background: var(--neutral-bg); -} -button:disabled { - opacity: 0.6; - cursor: progress; -} -button.btn-primary { - background: var(--brand); - border-color: var(--brand); - color: #fff; -} -button.btn-primary:hover { - background: var(--brand-hover); - border-color: var(--brand-hover); -} -button.btn-danger { - background: var(--surface); - border-color: var(--danger); - color: var(--danger); -} -button.btn-danger:hover { - background: var(--danger); - color: #fff; -} -button.btn-ghost { - border-color: transparent; - background: transparent; - color: var(--text-muted); -} -button.btn-ghost:hover { - background: var(--neutral-bg); - color: var(--text); -} -button.btn-small { - min-height: 1.9rem; - padding: 0.2rem 0.65rem; - font-size: 0.875rem; -} - -a.btn-link { - display: inline-flex; - align-items: center; - min-height: 2.25rem; - padding: 0.4rem 0.9rem; - border-radius: var(--radius-sm); - background: var(--brand); - color: #fff; - font-weight: 500; - text-decoration: none; - white-space: nowrap; -} -a.btn-link:hover { - background: var(--brand-hover); - color: #fff; -} - -.inline-form { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; - margin: 0; -} -.inline-form label { - display: inline-flex; - align-items: center; - gap: 0.5rem; -} -.toolbar { - display: flex; - align-items: center; - gap: 1rem; - flex-wrap: wrap; - padding: 0.9rem 1.4rem; - border-bottom: 1px solid var(--border); - background: var(--surface-muted); - margin: 0; -} -.toolbar label { - display: inline-flex; - align-items: center; - gap: 0.45rem; -} -.actions { - display: flex; - align-items: flex-end; - gap: 1.5rem; - flex-wrap: wrap; -} -.actions form { - margin: 0; -} -.upload { - display: flex; - align-items: flex-end; - gap: 1rem; - flex-wrap: wrap; -} -.upload .dropzone { - flex: 1; - min-width: 16rem; - border: 1.5px dashed var(--border-strong); - border-radius: var(--radius); - background: var(--surface-muted); - padding: 0.9rem 1rem; - margin: 0; -} -.upload .dropzone label { - display: block; - margin-bottom: 0.4rem; -} -.upload [role="status"] { - flex-basis: 100%; - margin: 0; -} diff --git a/src/app/requests/[id]/page.tsx b/src/app/requests/[id]/page.tsx index ce97596..a64a6f1 100644 --- a/src/app/requests/[id]/page.tsx +++ b/src/app/requests/[id]/page.tsx @@ -63,7 +63,7 @@ export default async function RequestPage({ {request.status === "ERROR" && request.errorMessage &&

Fehler: {request.errorMessage}

} {request.status === "REJECTED" && request.rejectionReason &&

Abgelehnt: {request.rejectionReason}

} {request.status === "APPROVED" && exportRecord?.lastError && ( -

+

Export wird wiederholt ({exportRecord.attempts} Versuche bisher): {exportRecord.lastError}

)} From 2cea3ee8bbdecfa22d5ecc4ed8eaee2a6fdef0e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 19:49:11 +0000 Subject: [PATCH 08/13] =?UTF-8?q?feat(app):=20quieter,=20more=20profession?= =?UTF-8?q?al=20design=20=E2=80=93=20neutral=20palette,=20dark=20primary?= =?UTF-8?q?=20actions,=20flat=20cards,=20status=20tags=20only=20where=20at?= =?UTF-8?q?tention=20is=20needed=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- src/app/_components/app-header.tsx | 3 - src/app/components.css | 223 +++++++++++-------------- src/app/globals.css | 213 +++++++++++------------ src/app/requests/[id]/page.tsx | 2 +- src/app/requests/[id]/review-parts.tsx | 8 +- src/app/requests/page.tsx | 4 +- 6 files changed, 208 insertions(+), 245 deletions(-) diff --git a/src/app/_components/app-header.tsx b/src/app/_components/app-header.tsx index 79c7d21..92c630c 100644 --- a/src/app/_components/app-header.tsx +++ b/src/app/_components/app-header.tsx @@ -30,9 +30,6 @@ export async function AppHeader() {
- RequestFlow Pilot diff --git a/src/app/components.css b/src/app/components.css index 50f13f1..7c10d11 100644 --- a/src/app/components.css +++ b/src/app/components.css @@ -13,8 +13,8 @@ select { background: var(--surface); border: 1px solid var(--border-input); border-radius: var(--radius-sm); - padding: 0.4rem 0.6rem; - min-height: 2.25rem; + padding: 0.35rem 0.6rem; + min-height: 2.125rem; max-width: 100%; } input:not([type="checkbox"], [type="radio"], [type="hidden"], [type="file"]):hover, @@ -24,25 +24,27 @@ select:hover { input[type="file"] { font: inherit; max-width: 100%; + color: var(--text-muted); } input[type="file"]::file-selector-button { font: inherit; font-weight: 500; + color: var(--text); margin-right: 0.75rem; - border: 1px solid var(--border-strong); + border: 1px solid var(--border-input); border-radius: var(--radius-sm); background: var(--surface); - padding: 0.35rem 0.8rem; + padding: 0.3rem 0.75rem; cursor: pointer; } input[type="checkbox"] { - width: 1rem; - height: 1rem; - accent-color: var(--brand); + width: 0.95rem; + height: 0.95rem; + accent-color: var(--primary); vertical-align: -2px; } .field { - margin-bottom: 0.9rem; + margin-bottom: 1rem; } .field label { display: block; @@ -60,8 +62,8 @@ button { background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); - padding: 0.4rem 0.9rem; - min-height: 2.25rem; + padding: 0.35rem 0.85rem; + min-height: 2.125rem; cursor: pointer; white-space: nowrap; } @@ -73,53 +75,53 @@ button:disabled { cursor: progress; } button.btn-primary { - background: var(--brand); - border-color: var(--brand); + background: var(--primary); + border-color: var(--primary); color: #fff; } button.btn-primary:hover { - background: var(--brand-hover); - border-color: var(--brand-hover); + background: var(--primary-hover); + border-color: var(--primary-hover); } button.btn-danger { - background: var(--surface); - border-color: var(--danger); color: var(--danger); + border-color: var(--border-strong); } button.btn-danger:hover { - background: var(--danger); - color: #fff; + background: var(--danger-bg); + border-color: #fca5a5; } button.btn-ghost { border-color: transparent; background: transparent; color: var(--text-muted); + padding-left: 0.5rem; + padding-right: 0.5rem; } button.btn-ghost:hover { background: var(--neutral-bg); color: var(--text); } button.btn-small { - min-height: 1.9rem; + min-height: 1.875rem; padding: 0.2rem 0.65rem; - font-size: 0.875rem; + font-size: 0.8125rem; } - a.btn-link { display: inline-flex; align-items: center; - min-height: 2.25rem; - padding: 0.4rem 0.9rem; + min-height: 2.125rem; + padding: 0.35rem 0.85rem; border-radius: var(--radius-sm); - background: var(--brand); + background: var(--primary); color: #fff; font-weight: 500; - text-decoration: none; white-space: nowrap; } a.btn-link:hover { - background: var(--brand-hover); + background: var(--primary-hover); color: #fff; + text-decoration: none; } .inline-form { @@ -139,9 +141,8 @@ a.btn-link:hover { align-items: center; gap: 1rem; flex-wrap: wrap; - padding: 0.9rem 1.4rem; + padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--border); - background: var(--surface-muted); margin: 0; } .toolbar label { @@ -149,10 +150,13 @@ a.btn-link:hover { align-items: center; gap: 0.45rem; } +.toolbar .hint { + margin-left: auto; +} .actions { display: flex; - align-items: flex-end; - gap: 1.5rem; + align-items: center; + gap: 1rem 1.5rem; flex-wrap: wrap; } .actions form { @@ -167,10 +171,6 @@ a.btn-link:hover { .upload .dropzone { flex: 1; min-width: 16rem; - border: 1.5px dashed var(--border-strong); - border-radius: var(--radius); - background: var(--surface-muted); - padding: 0.9rem 1rem; margin: 0; } .upload .dropzone label { @@ -194,32 +194,25 @@ a.btn-link:hover { table { border-collapse: collapse; width: 100%; - font-size: 0.925rem; } th, td { border-bottom: 1px solid var(--border); - padding: 0.65rem 0.8rem; + padding: 0.6rem 0.75rem; text-align: left; vertical-align: middle; } thead th { - background: var(--surface-muted); color: var(--text-muted); - font-size: 0.78rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; + font-size: 0.8125rem; + font-weight: 500; white-space: nowrap; } tbody tr:last-child > * { border-bottom: 0; } -tbody tr:hover { - background: #fafbfc; -} tbody th { - font-weight: 600; + font-weight: 500; } td.num, th.num { @@ -233,53 +226,59 @@ td.nowrap { color: var(--text-muted); } .empty { - padding: 2rem 1.4rem; + padding: 2.5rem 1.25rem; text-align: center; color: var(--text-muted); margin: 0; } tr.attention, td.attention { - background: #fffbeb; -} -tr.attention:hover { - background: #fff6d9; -} -.card > .table-wrap:first-child { - margin-top: -0.25rem; + background: var(--cited); } .card:not(.card-flush) > .table-wrap { - margin-left: -1.4rem; - margin-right: -1.4rem; + margin-left: -1.5rem; + margin-right: -1.5rem; } .card:not(.card-flush) > .table-wrap th:first-child, -.card:not(.card-flush) > .table-wrap td:first-child { - padding-left: 1.4rem; +.card:not(.card-flush) > .table-wrap td:first-child, +.card-flush th:first-child, +.card-flush td:first-child { + padding-left: 1.5rem; +} +.card:not(.card-flush) > .table-wrap th:last-child, +.card:not(.card-flush) > .table-wrap td:last-child, +.card-flush th:last-child, +.card-flush td:last-child { + padding-right: 1.5rem; +} +.card:not(.card-flush) > .table-wrap thead th { + border-top: 1px solid var(--border); } /* ---------- Status ---------- */ -.badge, -.pill { - display: inline-flex; - align-items: center; - gap: 0.25rem; - border-radius: 999px; - padding: 0.08rem 0.55rem; - font-size: 0.78rem; - font-weight: 600; - line-height: 1.5; +/* Field status: quiet for the normal cases, a tag only where the clerk must look. */ +.badge { + font-size: 0.75rem; + font-weight: 500; white-space: nowrap; - border: 1px solid transparent; vertical-align: middle; } .badge-found { - background: var(--success-bg); color: var(--success-text); } .badge-missing { - background: var(--neutral-bg); - color: var(--neutral-text); + color: var(--text-muted); +} +.badge-corrected, +.badge-uncertain, +.badge-unverified, +.pill { + display: inline-block; + border-radius: 4px; + padding: 0.05rem 0.4rem; + line-height: 1.5; + border: 1px solid transparent; } .badge-corrected { background: var(--info-bg); @@ -290,19 +289,14 @@ tr.attention:hover { background: var(--warn-bg); color: var(--warn-text); border-color: var(--warn-border); - font-weight: 700; + font-weight: 600; } +/* Request status */ .pill { - font-size: 0.8rem; - padding: 0.12rem 0.65rem; -} -.pill::before { - content: ""; - width: 0.45rem; - height: 0.45rem; - border-radius: 50%; - background: currentColor; - opacity: 0.8; + font-size: 0.75rem; + font-weight: 500; + white-space: nowrap; + vertical-align: middle; } .pill-neutral { background: var(--neutral-bg); @@ -326,12 +320,12 @@ tr.attention:hover { } .stage { display: block; - font-size: 0.8rem; + font-size: 0.75rem; color: var(--text-muted); margin-top: 0.15rem; } .hint { - font-size: 0.85rem; + font-size: 0.8125rem; } .hint .badge { white-space: normal; @@ -340,17 +334,18 @@ tr.attention:hover { min-width: 15rem; } .subject a { + color: var(--text); font-weight: 500; } +.subject a:hover { + color: var(--accent); +} /* ---------- Review: values, source ---------- */ -.value-cell a { - font-weight: 500; -} .item-value { display: inline-flex; - align-items: center; + align-items: baseline; gap: 0.4rem; flex-wrap: wrap; } @@ -358,15 +353,15 @@ tr.attention:hover { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-muted); - padding: 1rem 1.2rem; + padding: 1rem 1.25rem; margin-top: 1rem; } .source h3 { - font-size: 0.95rem; + font-size: 0.875rem; } .source-lines { font-family: var(--mono); - font-size: 0.85rem; + font-size: 0.8125rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); @@ -376,14 +371,13 @@ tr.attention:hover { } .source-lines li { padding: 0.1rem 0.4rem; - border-radius: 4px; + border-radius: 3px; } .source-lines li::marker { color: var(--text-muted); } .source-lines li.cited { background: var(--cited); - box-shadow: inset 3px 0 0 var(--mark); } .line-label { color: var(--text-muted); @@ -393,7 +387,7 @@ tr.attention:hover { mark { background: var(--mark); color: var(--text); - border-radius: 3px; + border-radius: 2px; padding: 0 0.15rem; } .panel { @@ -401,16 +395,13 @@ mark { margin-top: 1rem; padding-top: 1rem; } -.panel h3 { - font-size: 1.05rem; -} .doc-list { list-style: none; padding: 0; margin: 0; } .doc-list > li { - padding: 0.55rem 0; + padding: 0.5rem 0; border-bottom: 1px solid var(--border); } .doc-list > li:last-child { @@ -424,19 +415,6 @@ mark { .doc-list [role="note"] { margin: 0.25rem 0 0; } -.facts { - display: flex; - gap: 1.5rem; - flex-wrap: wrap; - margin: 0 0 1.25rem; - padding: 0; - list-style: none; - color: var(--text-muted); - font-size: 0.9rem; -} -.facts strong { - color: var(--text); -} .visually-hidden { position: absolute; @@ -452,11 +430,12 @@ mark { } @media (max-width: 640px) { - body { - font-size: 14.5px; - } main { - padding: 1.25rem 0.9rem 2rem; + padding: 1.25rem 1rem 2rem; + } + .app-header-inner { + padding: 0 1rem; + gap: 0 1.25rem; } .card:not(.card-flush) { padding: 1rem; @@ -468,17 +447,17 @@ mark { margin-left: -1rem; margin-right: -1rem; } + .card:not(.card-flush) > .table-wrap th:first-child, + .card:not(.card-flush) > .table-wrap td:first-child, + .card-flush th:first-child, + .card-flush td:first-child { + padding-left: 1rem; + } /* Company and role stay visible on phones: the account block takes its own row. */ .account { margin-left: 0; width: 100%; justify-content: space-between; - font-size: 0.85rem; - } -} - -@media (prefers-reduced-motion: reduce) { - * { - transition: none !important; + padding-bottom: 0.5rem; } } diff --git a/src/app/globals.css b/src/app/globals.css index 0fa4c53..54d7d9e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,39 +1,39 @@ /* * Pilot design system (#52): tokens once, plain CSS, system fonts (no third-party requests). + * Quiet by default: neutral greys, one accent, colour only where something needs attention. * Rules that are not style: status is never shown by colour alone, the focus outline stays visible, - * every text/background pair below meets WCAG AA (≥ 4.5:1). + * every text/background pair meets WCAG AA (≥ 4.5:1), form field borders ≥ 3:1. */ :root { - --bg: #f5f6f8; + --bg: #f7f7f8; --surface: #ffffff; - --surface-muted: #f9fafb; - --text: #1a1f26; - --text-muted: #5a6472; /* 6.0:1 on --surface */ - --border: #dfe3e8; - --border-strong: #c3c9d1; - --border-input: #8a939f; /* 3.2:1 on white – form field boundaries (WCAG 1.4.11) */ - --brand: #1f4fd1; /* 6.8:1 on white */ - --brand-hover: #173ea6; - --brand-soft: #ebf0fd; - --focus: #1a5fb4; - --success-text: #17603a; - --success-bg: #e5f4ea; - --info-text: #1d3f8f; - --info-bg: #e8eefc; - --neutral-text: #3d4550; - --neutral-bg: #eceff3; - --warn-text: #5c3c00; - --warn-bg: #fff3cd; - --warn-border: #b58100; - --danger: #b42318; /* 6.5:1 on white */ - --danger-hover: #912018; - --danger-text: #8f1c13; - --danger-bg: #fdeceb; - --mark: #ffd33d; - --cited: #fff8d6; - --radius: 10px; + --surface-muted: #fafafa; + --text: #18181b; /* 17.7:1 on white */ + --text-muted: #5f5f67; /* 6.3:1 on white, 5.9:1 on --bg */ + --border: #e4e4e7; + --border-strong: #d4d4d8; + --border-input: #8b8b93; /* 3.4:1 on white – form field boundaries (WCAG 1.4.11) */ + --primary: #18181b; /* primary buttons: white text 17.7:1 */ + --primary-hover: #3f3f46; + --accent: #1d4ed8; /* links, focus, active nav: 6.7:1 on white */ + --accent-hover: #1e3a8a; + --focus: #1d4ed8; + --success-text: #166534; /* 7.1:1 on white, 6.8:1 on --success-bg */ + --success-bg: #f0fdf4; + --info-text: #1e40af; + --info-bg: #eff6ff; /* 8.0:1 */ + --neutral-text: #3f3f46; + --neutral-bg: #f4f4f5; /* 9.5:1 */ + --warn-text: #854d0e; + --warn-bg: #fefce8; /* 6.6:1 */ + --warn-border: #eab308; + --danger: #b91c1c; /* 6.5:1 on white */ + --danger-text: #991b1b; + --danger-bg: #fef2f2; /* 7.6:1 */ + --mark: #fde68a; + --cited: #fffbeb; + --radius: 8px; --radius-sm: 6px; - --shadow: 0 1px 2px rgb(16 24 40 / 6%), 0 1px 3px rgb(16 24 40 / 8%); --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } @@ -55,38 +55,42 @@ body { background: var(--bg); color: var(--text); font-family: var(--font); - font-size: 15px; + font-size: 14px; line-height: 1.55; + -webkit-font-smoothing: antialiased; } a { - color: var(--brand); - text-underline-offset: 2px; + color: var(--accent); + text-decoration: none; } a:hover { - color: var(--brand-hover); + color: var(--accent-hover); + text-decoration: underline; + text-underline-offset: 2px; } :focus-visible { - outline: 3px solid var(--focus); + outline: 2px solid var(--focus); outline-offset: 2px; } h1, h2, h3 { - line-height: 1.25; + line-height: 1.3; margin: 0 0 0.75rem; + font-weight: 600; + letter-spacing: -0.01em; } h1 { - font-size: 1.6rem; - letter-spacing: -0.01em; + font-size: 1.375rem; } h2 { - font-size: 1.15rem; + font-size: 1rem; } h3 { - font-size: 1rem; + font-size: 0.9375rem; } p { margin: 0 0 0.75rem; @@ -113,97 +117,86 @@ code { z-index: 10; } .app-header-inner { - max-width: 78rem; + max-width: 76rem; margin: 0 auto; - padding: 0.6rem 1.25rem; + padding: 0 1.5rem; + min-height: 3.5rem; display: flex; - align-items: center; - gap: 1.5rem; + align-items: stretch; + gap: 2rem; flex-wrap: wrap; } .brand { display: inline-flex; align-items: center; - gap: 0.55rem; - font-weight: 700; - font-size: 1.05rem; + gap: 0.5rem; + font-weight: 650; + font-size: 0.975rem; color: var(--text); - text-decoration: none; } .brand:hover { color: var(--text); -} -.brand-mark { - width: 1.6rem; - height: 1.6rem; - border-radius: 7px; - background: linear-gradient(135deg, var(--brand) 0%, #4f7cf0 100%); - display: inline-grid; - place-items: center; - color: #fff; - font-size: 0.85rem; - font-weight: 800; + text-decoration: none; } .env-tag { - font-size: 0.72rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--warn-text); - background: var(--warn-bg); - border-radius: 999px; - padding: 0.1rem 0.5rem; + font-size: 0.75rem; + font-weight: 500; + color: var(--text-muted); + border-left: 1px solid var(--border-strong); + padding-left: 0.5rem; } .app-nav { display: flex; - gap: 0.25rem; + align-items: stretch; + gap: 1.5rem; flex: 1; } .app-nav a { + display: inline-flex; + align-items: center; color: var(--text-muted); - text-decoration: none; font-weight: 500; - padding: 0.4rem 0.75rem; - border-radius: var(--radius-sm); + border-bottom: 2px solid transparent; + margin-bottom: -1px; } .app-nav a:hover { color: var(--text); - background: var(--neutral-bg); + text-decoration: none; } .app-nav a[aria-current="page"] { - color: var(--brand); - background: var(--brand-soft); + color: var(--text); + border-bottom-color: var(--text); } .account { display: flex; align-items: center; flex-wrap: wrap; - gap: 0.25rem 0.75rem; - font-size: 0.9rem; + gap: 0.25rem 1rem; + font-size: 0.8125rem; color: var(--text-muted); margin-left: auto; } .account strong { color: var(--text); - font-weight: 600; + font-weight: 500; } main { - max-width: 78rem; + max-width: 76rem; margin: 0 auto; - padding: 1.75rem 1.25rem 3rem; + padding: 2rem 1.5rem 3rem; } .page-head { display: flex; - align-items: flex-start; + align-items: flex-end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; - margin-bottom: 1.25rem; + margin-bottom: 1.5rem; } .page-head h1 { - margin-bottom: 0.35rem; + margin-bottom: 0.25rem; } .page-head .meta { color: var(--text-muted); @@ -216,12 +209,11 @@ main { .back { display: inline-block; margin-bottom: 0.75rem; - font-size: 0.9rem; + font-size: 0.8125rem; color: var(--text-muted); - text-decoration: none; } .back:hover { - color: var(--brand); + color: var(--text); } /* ---------- Surfaces ---------- */ @@ -230,8 +222,7 @@ main { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); - box-shadow: var(--shadow); - padding: 1.25rem 1.4rem; + padding: 1.25rem 1.5rem; margin-bottom: 1.25rem; } .card > :last-child { @@ -243,7 +234,7 @@ main { justify-content: space-between; gap: 1rem; flex-wrap: wrap; - margin-bottom: 0.9rem; + margin-bottom: 1rem; } .card-head h2 { margin: 0; @@ -251,15 +242,12 @@ main { .card-head p { margin: 0; color: var(--text-muted); - font-size: 0.9rem; + font-size: 0.8125rem; } .card-flush { padding: 0; overflow: hidden; } -.card-flush > .card-head { - padding: 1.1rem 1.4rem 0; -} .tiles { display: grid; @@ -271,69 +259,66 @@ main { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); - box-shadow: var(--shadow); - padding: 1.1rem 1.25rem; + padding: 1rem 1.25rem; color: var(--text); - text-decoration: none; - transition: border-color 0.15s, box-shadow 0.15s; } .tile:hover { color: var(--text); - border-color: var(--brand); - box-shadow: 0 4px 12px rgb(31 79 209 / 12%); + text-decoration: none; + border-color: var(--border-input); } .tile strong { display: block; - font-size: 1.05rem; - margin-bottom: 0.25rem; - color: var(--brand); + font-weight: 600; + margin-bottom: 0.2rem; } .tile span { color: var(--text-muted); - font-size: 0.9rem; + font-size: 0.8125rem; } .auth { - max-width: 26rem; + max-width: 24rem; margin: 3rem auto 0; } .auth h1 { - font-size: 1.4rem; + font-size: 1.25rem; } -.auth .lead, .lead { color: var(--text-muted); } -/* ---------- Callouts (role-based, so every message is styled without extra markup) ---------- */ +/* ---------- Messages (role-based, so every message is styled without extra markup) ---------- */ [role="status"], [role="alert"], [role="note"], .callout { border-radius: var(--radius-sm); - padding: 0.65rem 0.9rem; + padding: 0.6rem 0.85rem; margin: 0 0 1rem; border: 1px solid transparent; + font-size: 0.875rem; } [role="status"] { background: var(--success-bg); color: var(--success-text); - border-color: #b9dfc6; + border-color: #bbf7d0; } [role="alert"] { background: var(--danger-bg); color: var(--danger-text); - border-color: #f3c1bd; + border-color: #fecaca; } [role="note"], .callout-warn { background: var(--warn-bg); color: var(--warn-text); - border-color: var(--warn-border); + border-color: #fde68a; } .callout-warn h2 { - font-size: 1.05rem; + font-size: 0.9375rem; + margin-bottom: 0.35rem; } .callout-warn .inline-form input { /* Fixed width: a percentage max-width inside the inline-flex label resolves cyclically and lets @@ -343,9 +328,11 @@ main { } .callout-warn a { color: var(--warn-text); - font-weight: 600; + text-decoration: underline; + font-weight: 500; } .callout-info { - background: var(--info-bg); - color: var(--info-text); + background: var(--neutral-bg); + color: var(--neutral-text); + border-color: var(--border); } diff --git a/src/app/requests/[id]/page.tsx b/src/app/requests/[id]/page.tsx index a64a6f1..787a0d8 100644 --- a/src/app/requests/[id]/page.tsx +++ b/src/app/requests/[id]/page.tsx @@ -69,7 +69,7 @@ export default async function RequestPage({ )} {request.possibleDuplicate && request.duplicateOfId && (
-

⚠ Mögliches Duplikat

+

Mögliches Duplikat

Gleiche E-Mail oder gleiche Dateien wie diese Anfrage. {request.duplicateDecision === "distinct" && " Entscheidung: eigenständige Anfrage."} diff --git a/src/app/requests/[id]/review-parts.tsx b/src/app/requests/[id]/review-parts.tsx index 937ed39..3522cd8 100644 --- a/src/app/requests/[id]/review-parts.tsx +++ b/src/app/requests/[id]/review-parts.tsx @@ -23,10 +23,10 @@ export const STATUS_LABEL: Record = { export const needsAttention = (field: ReviewField): boolean => field.reviewStatus === "unverified" || field.reviewStatus === "uncertain"; export function StatusBadge({ status }: { status: ReviewStatus }) { - // Uncertain and unverified are the ones a clerk must look at: prominent colour + text, never colour only. + // Uncertain and unverified are the ones a clerk must look at: a highlighted tag, the others stay + // quiet text (#52) – always with the label, never colour only. return ( - {status === "unverified" || status === "uncertain" ? "⚠ " : ""} {STATUS_LABEL[status]} ); @@ -42,7 +42,7 @@ export function Source({ field }: { field: ReviewField }) { {source.ocr && (

- ⚠ Texterkennung (OCR): Der Text stammt aus einem gescannten Dokument – bitte mit dem Original vergleichen. + Texterkennung (OCR): Der Text stammt aus einem gescannten Dokument – bitte mit dem Original vergleichen.

)} {field.corrected &&

Fundstelle des erkannten Werts „{field.extractedValue ?? "–"}“ – der aktuelle Wert wurde manuell korrigiert.

} @@ -78,7 +78,7 @@ export function DocumentList({ documents, skippedDocuments, documentNotes }: Doc
    {notes.failedAttachments.map((attachment, index) => (
  • - ⚠ Anhang „{attachment.name ?? `Nr. ${index + 1}`}“ konnte nicht gelesen werden ({ATTACHMENT_ERROR[attachment.error ?? ""] ?? "unbekannter Grund"}) – bitte im Original prüfen. + Anhang „{attachment.name ?? `Nr. ${index + 1}`}“ konnte nicht gelesen werden ({ATTACHMENT_ERROR[attachment.error ?? ""] ?? "unbekannter Grund"}) – bitte im Original prüfen.
  • ))}
diff --git a/src/app/requests/page.tsx b/src/app/requests/page.tsx index 6e781f2..60739c5 100644 --- a/src/app/requests/page.tsx +++ b/src/app/requests/page.tsx @@ -44,7 +44,7 @@ export default async function RequestsPage({ searchParams }: { searchParams: Pro

Anfragen

-

Neue Anfrage als E-Mail oder Dateien hochladen – sie wird automatisch ausgelesen und erscheint danach zur Prüfung.

+

Hochgeladene Anfragen werden automatisch ausgelesen und erscheinen danach zur Prüfung.

@@ -120,7 +120,7 @@ export default async function RequestsPage({ searchParams }: { searchParams: Pro ) : request.duplicateDecision === "duplicate" ? ( "Als Duplikat abgelehnt" ) : ( - ⚠ Mögliches Duplikat – Entscheidung offen + Mögliches Duplikat – Entscheidung offen ) ) : ( "" From 7a47534d9b6b0e063cf2b452ffd40c626923a517 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 19:50:46 +0000 Subject: [PATCH 09/13] =?UTF-8?q?feat(app):=20calmer=20status=20display=20?= =?UTF-8?q?=E2=80=93=20field=20status=20as=20muted=20text,=20tinted=20bord?= =?UTF-8?q?ers=20on=20status=20tags=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- CHANGELOG.md | 11 ++++++----- src/app/components.css | 11 ++++++++--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffb5600..680c2b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,12 @@ This file records what changes **in the product** – process and session state ## [Unreleased] ### Added -- Visual design for the pilot UI: a shared header with navigation, company and role on every page; - request and field status as labelled pills; cards, distinct primary and destructive actions, and tables - that scroll inside their container on narrow screens. Colours are design tokens in `globals.css` - (WCAG AA), system fonts only (no third-party requests). Login and upload errors are now announced as - alerts. +- Visual design for the pilot UI: a quiet, neutral look with a shared header (navigation, company, role) + on every page; request status as labelled tags, field status as plain text – highlighted only where a + clerk must look (uncertain, unverified, corrected); flat cards, a dark primary action, distinct + destructive actions, and tables that scroll inside their container on narrow screens. Colours are + design tokens in `globals.css` (WCAG AA), system fonts only (no third-party requests). Login and upload + errors are now announced as alerts. - Observability: structured JSON logs (pino) with IDs and codes only, correlated by the request id from web through worker to the AI service; `/api/health` also shows whether the AI service is reachable and how many jobs are waiting. diff --git a/src/app/components.css b/src/app/components.css index 7c10d11..80fca5e 100644 --- a/src/app/components.css +++ b/src/app/components.css @@ -264,11 +264,10 @@ td.attention { white-space: nowrap; vertical-align: middle; } -.badge-found { - color: var(--success-text); -} +.badge-found, .badge-missing { color: var(--text-muted); + font-weight: 400; } .badge-corrected, .badge-uncertain, @@ -283,6 +282,7 @@ td.attention { .badge-corrected { background: var(--info-bg); color: var(--info-text); + border-color: #bfdbfe; } .badge-uncertain, .badge-unverified { @@ -301,22 +301,27 @@ td.attention { .pill-neutral { background: var(--neutral-bg); color: var(--neutral-text); + border-color: var(--border); } .pill-info { background: var(--info-bg); color: var(--info-text); + border-color: #bfdbfe; } .pill-warn { background: var(--warn-bg); color: var(--warn-text); + border-color: #fde68a; } .pill-success { background: var(--success-bg); color: var(--success-text); + border-color: #bbf7d0; } .pill-danger { background: var(--danger-bg); color: var(--danger-text); + border-color: #fecaca; } .stage { display: block; From b30413ee0211675a33bfa8c5271ca15cae50d287 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 08:26:15 +0000 Subject: [PATCH 10/13] chore(deps): self-hosted IBM Plex Sans/Mono (@fontsource 5.3.0, OFL-1.1) for the design prototype (#52) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- package.json | 2 ++ pnpm-lock.yaml | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/package.json b/package.json index 7f1282e..25eb039 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "dependencies": { "@aws-sdk/client-s3": "3.1138.0", "@better-auth/drizzle-adapter": "1.7.5", + "@fontsource/ibm-plex-mono": "5.3.0", + "@fontsource/ibm-plex-sans": "5.3.0", "better-auth": "1.7.5", "drizzle-orm": "0.45.3", "next": "16.3.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d92ecd4..cf50eae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: '@better-auth/drizzle-adapter': specifier: 1.7.5 version: 1.7.5(@better-auth/core@1.7.5(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.2)(better-call@1.4.0(zod@4.6.5))(jose@6.2.12)(kysely@0.29.6)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0)) + '@fontsource/ibm-plex-mono': + specifier: 5.3.0 + version: 5.3.0 + '@fontsource/ibm-plex-sans': + specifier: 5.3.0 + version: 5.3.0 better-auth: specifier: 1.7.5 version: 1.7.5(drizzle-kit@0.31.11)(drizzle-orm@0.45.3(@types/pg@8.23.1)(kysely@0.29.6)(pg@8.23.0))(next@16.3.6(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@24.13.6)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@5.0.1(@types/node@24.13.6)(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(tsx@4.23.15))) @@ -813,6 +819,12 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fontsource/ibm-plex-mono@5.3.0': + resolution: {integrity: sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==} + + '@fontsource/ibm-plex-sans@5.3.0': + resolution: {integrity: sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w==} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -3980,6 +3992,10 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@fontsource/ibm-plex-mono@5.3.0': {} + + '@fontsource/ibm-plex-sans@5.3.0': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 From a54fde8cab258a2f0d78c67ac303f91748981326 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 08:31:24 +0000 Subject: [PATCH 11/13] =?UTF-8?q?feat(app):=20implement=20design=20prototy?= =?UTF-8?q?pe=20A=20=E2=80=93=20warm=20neutral=20palette,=20IBM=20Plex,=20?= =?UTF-8?q?app=20header=20with=20name=20and=20role,=20two-column=20review?= =?UTF-8?q?=20with=20sticky=20source=20panel=20and=20decision=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DJ5vaKvTYiMvdngT4d3xo1 --- src/app/_components/app-header.tsx | 50 ++- src/app/_components/auth-form.tsx | 33 +- src/app/_components/copy-button.tsx | 21 + src/app/_components/nav-link.tsx | 4 +- src/app/components.css | 579 +++++++++---------------- src/app/globals.css | 469 ++++++++++++-------- src/app/invite/page.tsx | 55 ++- src/app/layout.tsx | 8 + src/app/login/page.tsx | 17 +- src/app/page.tsx | 40 +- src/app/requests/[id]/page.tsx | 413 ++++++++++-------- src/app/requests/[id]/review-parts.tsx | 63 +-- src/app/requests/page.tsx | 83 ++-- src/app/requests/status-pill.tsx | 2 +- src/app/requests/upload-form.tsx | 35 +- src/app/review.css | 297 +++++++++++++ src/app/signup/page.tsx | 28 +- src/app/users/page.tsx | 53 ++- src/features/identity/actor.ts | 2 +- src/features/identity/authorize.ts | 2 + 20 files changed, 1300 insertions(+), 954 deletions(-) create mode 100644 src/app/_components/copy-button.tsx create mode 100644 src/app/review.css diff --git a/src/app/_components/app-header.tsx b/src/app/_components/app-header.tsx index 92c630c..8361a87 100644 --- a/src/app/_components/app-header.tsx +++ b/src/app/_components/app-header.tsx @@ -6,7 +6,7 @@ import { logEvent } from "@/features/observability"; import { SignOutButton } from "./auth-form"; import { NavLink } from "./nav-link"; -const ROLE_LABEL = { admin: "Administration", clerk: "Sachbearbeitung" } as const; +export const ROLE_LABEL = { admin: "Administration", clerk: "Sachbearbeitung" } as const; async function signedIn(): Promise<{ actor: Actor; companyName: string | undefined } | null> { try { @@ -16,37 +16,45 @@ async function signedIn(): Promise<{ actor: Actor; companyName: string | undefin return { actor, companyName: company?.name }; } catch (error) { unstable_rethrow(error); // Next.js control flow (dynamic rendering, redirects) is not a failure. - // The header must never take a page down (e.g. database unreachable): show the brand only. + // The header must never take a page down (e.g. database unreachable): render no header. logEvent("error", "app_header.failed", {}, { code: error instanceof Error ? error.name : "unknown" }); return null; } } -// App shell (#52): brand on every page; navigation, company, role and sign-out only when signed in. -// Navigation mirrors the server-side permissions – it hides links, it never grants access. +/** The brand mark + name – also used above the sign-in cards. */ +export function Brand({ className }: { className?: string }) { + return ( + +
); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5dcb637..57193eb 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,8 +1,16 @@ import type { Metadata } from "next"; import type { ReactNode } from "react"; import { AppHeader } from "./_components/app-header"; +// Self-hosted IBM Plex (Latin subset, only the weights the design uses) – no third-party requests. +import "@fontsource/ibm-plex-sans/latin-400.css"; +import "@fontsource/ibm-plex-sans/latin-500.css"; +import "@fontsource/ibm-plex-sans/latin-600.css"; +import "@fontsource/ibm-plex-sans/latin-700.css"; +import "@fontsource/ibm-plex-mono/latin-400.css"; +import "@fontsource/ibm-plex-mono/latin-500.css"; import "./globals.css"; import "./components.css"; +import "./review.css"; export const metadata: Metadata = { title: "RequestFlow", diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 67c3c90..3d483b5 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,15 +1,18 @@ import Link from "next/link"; +import { Brand } from "@/app/_components/app-header"; import { AuthForm } from "@/app/_components/auth-form"; export default function LoginPage() { return ( -
-
-

Anmelden

-

Mit Ihrem RequestFlow-Konto anmelden.

- -

- Eingeladen worden? Konto anlegen +

+
+ +
+

Anmelden

+ +
+

+ Eingeladen? Konto anlegen

diff --git a/src/app/page.tsx b/src/app/page.tsx index 5f14e8b..87e7134 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,37 +1,41 @@ import Link from "next/link"; -import { requestActor } from "@/app/_server/runtime"; +import { Brand } from "@/app/_components/app-header"; +import { getRuntime, requestActor } from "@/app/_server/runtime"; +import { listRequests } from "@/features/requests"; export const dynamic = "force-dynamic"; -// Start page: company, role and sign-out live in the app header (#52); this page points to the work. +// Start page (#52, design prototype A): entry tiles; the request tile shows what needs work. export default async function HomePage() { const actor = await requestActor(); if (!actor) { return ( -
-
-

RequestFlow

-

Angebotsanfragen erfassen, neben der Quelle prüfen und genau einmal ans ERP übergeben.

-

- Anmelden · Konto mit Einladung anlegen -

-

Pilot – alle Daten sind synthetisch.

+
+
+ +
+

RequestFlow

+

Angebotsanfragen erfassen, neben der Quelle prüfen und genau einmal ans ERP übergeben.

+

+ Anmelden · Konto mit Einladung anlegen +

+
+

Pilot – alle Daten sind synthetisch.

); } + // Pilot volumes (20–50 requests a day): counting the tenant's list in memory is enough. + const requests = await getRuntime().tenancy.withTenant(actor.companyId, (tx) => listRequests(tx, {})); + const count = (status: string) => requests.filter((request) => request.status === status).length; + const stats = requests.length ? `${count("REVIEW")} zur Prüfung · ${count("ERROR")} mit Fehler · ${requests.length} insgesamt` : "Noch keine Anfragen"; return (
-
-
-

Willkommen

-

Angebotsanfragen hochladen, neben der Quelle prüfen und genau einmal ans ERP übergeben.

-
-
+

Start