diff --git a/CHANGELOG.md b/CHANGELOG.md
index d461102..8837bcc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,13 @@ This file records what changes **in the product** – process and session state
## [Unreleased]
### Added
+- Visual design for the pilot UI, implemented from the Claude Design prototype "RequestFlow A": warm
+ neutral palette with one blue accent, IBM Plex Sans/Mono (self-hosted via `@fontsource`, no
+ third-party requests), a header with company, name and role, a start page with open work, and a
+ two-column review page – fields, positions and documents on the left, the selected value with its
+ source and the decision in a sticky panel on the right. Status is always labelled (⚠ for values that
+ need attention); colours are design tokens (WCAG AA). Login and upload errors are announced as alerts;
+ after an upload the message links to the new request; invitation links can be copied.
- 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/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
diff --git a/src/app/_components/app-header.tsx b/src/app/_components/app-header.tsx
new file mode 100644
index 0000000..8361a87
--- /dev/null
+++ b/src/app/_components/app-header.tsx
@@ -0,0 +1,61 @@
+import Link from "next/link";
+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";
+
+export 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): render no header.
+ logEvent("error", "app_header.failed", {}, { code: error instanceof Error ? error.name : "unknown" });
+ return null;
+ }
+}
+
+/** The brand mark + name – also used above the sign-in cards. */
+export function Brand({ className }: { className?: string }) {
+ return (
+
+
+ RequestFlow
+
+ );
+}
+
+// App shell (#52, design prototype A): only for signed-in users – sign-in pages show the brand in the
+// card instead. Navigation mirrors the server-side permissions; it hides links, it never grants access.
+export async function AppHeader() {
+ const session = await signedIn();
+ if (!session) return null;
+ const { actor, companyName } = session;
+ return (
+
+
- 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.
}
-
+
+
+ {params.error &&
Bitte eine gültige E-Mail-Adresse und Rolle angeben.
}
+
+
+ {link && (
+
+ Einladung angelegt (7 Tage gültig).
+ Diesen Link selbst an die eingeladene Person weitergeben. Eine E-Mail wird nicht verschickt.
+
+ {link}
+
+
+
+ )}
+
);
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index c04de8e..57193eb 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,6 +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",
@@ -10,7 +20,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..3d483b5 100644
--- a/src/app/login/page.tsx
+++ b/src/app/login/page.tsx
@@ -1,14 +1,20 @@
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
-
-
- Eingeladen worden? Konto anlegen
-
+
+
+
+
+
Anmelden
+
+
+
+ Eingeladen? Konto anlegen
+
+
);
}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 25e5aeb..87e7134 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,41 +1,55 @@
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 { 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 (#52, design prototype A): entry tiles; the request tile shows what needs work.
export default async function HomePage() {
- const actor = await currentActor(await headers());
+ 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.
+
);
}
- const company = await getCompany(getRuntime().database.db, actor.companyId);
+ // 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 (
-
+ ⚠ 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..b1c540a 100644
--- a/src/app/requests/page.tsx
+++ b/src/app/requests/page.tsx
@@ -1,17 +1,17 @@
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";
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";
-const dateFormat = new Intl.DateTimeFormat("de-DE", { dateStyle: "short", timeStyle: "short", timeZone: "Europe/Berlin" });
+const dateFormat = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin" });
const STAGE_LABEL: Record = { processing: "Verarbeitung", export: "Export" };
const MESSAGES: Record = {
reprocessed: "Erneut eingeplant.",
@@ -28,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);
@@ -42,84 +42,105 @@ export default async function RequestsPage({ searchParams }: { searchParams: Pro
return (
Anfragen
-
{done &&
{done}
}
{error &&
{error}
}
-
- {requests.length === 0 ? (
-
{filter.status || filter.possibleDuplicate ? "Keine Anfragen für diesen Filter." : "Noch keine Anfragen."}
+ {filter.status || filter.possibleDuplicate ? "Keine Anfragen für diesen Filter." : "Noch keine Anfragen. Laden Sie oben eine E-Mail oder Dateien hoch."}
+