Initial commit: everythingbutwork MVP
ci / quality (push) Has been cancelled
ci / build (push) Has been cancelled

Internes Project-Management-Tool für ToolCTRL — ergänzend zu Plane (Tickets)
und Outline (Doku). Umfasst Puls-Übersicht, Tagesrhythmus (Lock-in + Reflexion),
Bereichs-Detail-Views (Entwicklung/Vertrieb/Marketing), Pflicht-Minimum-Check
und Blocker-Lifecycle gemäß Projektmanagement-Leitfaden v3.0.

Stack: pnpm-Monorepo · Vite + React 18 + Tailwind · Fastify + tRPC v11 +
Drizzle ORM · Postgres 16 · Authentik OIDC (Phase 2, Dev-Auto-Login aktiv).

Planungs-Artefakte unter _bmad-output/planning-artifacts/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 4.7 committed 2026-05-25 22:47:26 +02:00
commit 4f33509a0f
131 files changed
+34339

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
# --- builder ---
FROM node:22-alpine AS builder
WORKDIR /repo
RUN corepack enable && corepack prepare pnpm@10.28.0 --activate
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.base.json ./
COPY apps/web/package.json ./apps/web/
COPY apps/api/package.json ./apps/api/
COPY packages/db/package.json ./packages/db/
COPY packages/shared/package.json ./packages/shared/
RUN pnpm install --frozen-lockfile
COPY apps/web ./apps/web
COPY apps/api ./apps/api
COPY packages/db ./packages/db
COPY packages/shared ./packages/shared
RUN pnpm --filter web build
# --- runtime (static via Caddy in production compose) ---
FROM caddy:2-alpine AS runtime
COPY --from=builder /repo/apps/web/dist /srv
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/toolctrl-emblem.svg" />
<link rel="alternate icon" type="image/png" href="/favicon.png" />
<title>everythingbutwork — ToolCTRL</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --port 5173",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@cockpit/api": "workspace:*",
"@cockpit/db": "workspace:*",
"@cockpit/shared": "workspace:*",
"@tanstack/react-query": "^5.62.7",
"@trpc/client": "^11.0.0-rc.660",
"@trpc/react-query": "^11.0.0-rc.660",
"@trpc/server": "^11.0.0-rc.660",
"@fontsource/inter": "^5.1.1",
"@fontsource/jetbrains-mono": "^5.1.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router": "^7.1.1",
"sonner": "^1.7.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^5.4.11",
"vitest": "^2.1.8"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+55
View File
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 713.32 678.61">
<defs>
<style>
.cls-1 {
fill: url(#linear-gradient-5);
}
.cls-2 {
fill: url(#linear-gradient-6);
}
.cls-3 {
fill: url(#linear-gradient-4);
}
.cls-4 {
fill: url(#linear-gradient-3);
}
.cls-5 {
fill: url(#linear-gradient-2);
}
.cls-6 {
fill: url(#linear-gradient);
}
</style>
<linearGradient id="linear-gradient" x1="658.96" y1="31.96" x2="297.7" y2="458.6" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#000"/>
<stop offset="1" stop-color="#333"/>
</linearGradient>
<linearGradient id="linear-gradient-2" x1="1684.3" y1="1142.75" x2="2206.09" y2="1553.51" gradientTransform="translate(-1833.74 -1108.96) rotate(5.06) skewX(14.25)" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#b2b2b2"/>
<stop offset=".78" stop-color="#fff"/>
</linearGradient>
<linearGradient id="linear-gradient-3" x1="1719.13" y1="1098.51" x2="2240.91" y2="1509.28" xlink:href="#linear-gradient-2"/>
<linearGradient id="linear-gradient-4" x1="1764.82" y1="1040.47" x2="2286.6" y2="1451.24" xlink:href="#linear-gradient-2"/>
<linearGradient id="linear-gradient-5" x1="1806.27" y1="987.82" x2="2328.05" y2="1398.59" xlink:href="#linear-gradient-2"/>
<linearGradient id="linear-gradient-6" x1="345.03" y1="-10.15" x2="1014.55" y2="516.91" gradientTransform="matrix(1,0,0,1,0,0)" xlink:href="#linear-gradient-2"/>
</defs>
<g>
<path d="M688.7,572.09l-160,98.95c-9.14,5.73-19.99,8.35-30.77,7.37l-380.68-34.36c-22.48-2.01-40.76-19.01-44.35-41.31L.64,159.31c-3.17-19.56,5.6-39.12,22.3-49.72L189.46,8.08c-13.95,9.75,23.77,31.55,26.88,49.95l91.3,361.13c6.21,36.68,36.37,64.58,73.42,67.93l278.64,42.9c8.65.79,21.87,45.68,28.94,42.03l.06.06Z"/>
<path class="cls-6" d="M688.64,572.03c-7.07,3.66-15.23,5.36-23.88,4.57l-349-31.5c-37.04-3.35-67.21-31.26-73.42-67.93L170.39,53.35c-3.11-18.4,5.12-35.52,19.07-45.27l1.95-1.16c7.98-5.06,17.61-7.62,27.91-6.76l358.56,30.34c29.49,2.5,53.56,24.62,58.61,53.8l76.16,440.39c3.53,20.29-7.19,38.99-24.01,47.34Z"/>
</g>
<g>
<g>
<path class="cls-5" d="M300.22,343.93l-15.01,4.26,15.73,96.05c4.23,25.83,21.88,42.59,47.21,44.83l20.42,1.81-2.48-15.14-20.42-1.81c-15.81-1.4-26.04-11.44-28.79-28.28l-8.24-50.34,44.93,3.97-2.45-14.94-44.93-3.97-5.97-36.43Z"/>
<path class="cls-4" d="M421.96,387.97c-31.88-2.82-51.26,18.1-46.08,49.73,5.18,31.64,32.08,56.65,63.97,59.47,31.88,2.82,51.26-18.1,46.08-49.73-5.18-31.64-32.08-56.65-63.97-59.47ZM437.36,482.03c-22.9-2.03-41.54-19.67-45.35-42.9-3.8-23.23,9.53-38.04,32.43-36.02,22.9,2.03,41.54,19.66,45.35,42.9,3.8,23.23-9.53,38.04-32.42,36.02Z"/>
<path class="cls-3" d="M541.37,398.53c-31.88-2.82-51.26,18.1-46.08,49.73,5.18,31.64,32.08,56.65,63.97,59.47,31.88,2.82,51.26-18.1,46.08-49.73-5.18-31.64-32.08-56.65-63.97-59.47ZM556.77,492.59c-22.9-2.03-41.54-19.67-45.35-42.9-3.8-23.23,9.53-38.04,32.43-36.02,22.9,2.03,41.54,19.66,45.35,42.9,3.8,23.23-9.53,38.04-32.42,36.02Z"/>
<polygon class="cls-1" points="607.67 368.41 631.1 511.46 647.04 512.87 622.68 364.15 607.67 368.41"/>
</g>
<path class="cls-2" d="M582.39,164.07l18.3,1.62-56.29-75.12c-3.15-4.2-7.93-6.86-13.16-7.33h0c-7.61-.67-14.86,3.41-18.22,10.27l-30.3,61.75,19.2,1.7,27.57-56.2c.99-2.01,3.75-2.27,5.1-.48l47.81,63.79Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

+12
View File
@@ -0,0 +1,12 @@
import { Toaster } from 'sonner';
import { TrpcProvider } from './lib/trpc-provider';
import { Router } from './lib/router';
export function App() {
return (
<TrpcProvider>
<Router />
<Toaster position="bottom-right" richColors={false} />
</TrpcProvider>
);
}
@@ -0,0 +1,71 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { trpc } from '../../lib/trpc';
import { voice } from '../../lib/voice';
import { Button } from '../ui/Button';
export function BlockerDialog({ onClose }: { onClose: () => void }) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const utils = trpc.useUtils();
const mutation = trpc.blocker.create.useMutation({
onSuccess: async () => {
toast(voice.toast.blockerOpened);
await utils.puls.overview.invalidate();
onClose();
},
});
function submit(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) return;
mutation.mutate({ title: title.trim(), description: description.trim() || undefined });
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-semibold">Neuer Blocker</h2>
<form onSubmit={submit} className="mt-4 space-y-3">
<label className="block">
<span className="text-sm font-medium">Was klemmt?</span>
<input
required
ref={(el) => {
if (el && !title) el.focus();
}}
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={200}
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
</label>
<label className="block">
<span className="text-sm font-medium">Mehr Kontext (optional)</span>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
maxLength={1000}
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
</label>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={mutation.isPending}>
Erstellen
</Button>
</div>
</form>
</div>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { Outlet, useNavigate } from 'react-router';
import { useEffect, useState } from 'react';
import { TopNav } from './TopNav';
import { useCurrentUser } from '../../hooks/useCurrentUser';
import { BlockerDialog } from '../blocker/BlockerDialog';
const SHORTCUT_MAP: Record<string, string> = {
p: '/puls',
h: '/heute',
b: '/bereich',
e: '/bereich/entwicklung',
v: '/bereich/vertrieb',
m: '/bereich/marketing',
a: '/archiv',
};
export function AppShell() {
const me = useCurrentUser();
const navigate = useNavigate();
const [blockerOpen, setBlockerOpen] = useState(false);
// Dev-Mode: bei fehlender Session sofort in den Auto-Login. Auf prod hingegen
// zeigen wir die Login-Aufforderung mit dem Authentik-Link.
useEffect(() => {
if (import.meta.env.DEV && me === null) {
// Kleiner Delay, damit der useQuery sicher fertig ist und wir nicht
// mehrmals redirecten.
const t = setTimeout(() => {
if (window.location.pathname !== '/login') {
window.location.replace('/api/auth/dev-login');
}
}, 150);
return () => clearTimeout(t);
}
return undefined;
}, [me]);
// Keyboard-Shortcuts: `g <key>` → Navigation, `n` → Neuer Blocker.
useEffect(() => {
let waitingForSecond = false;
let timer: ReturnType<typeof setTimeout> | null = null;
function reset() {
waitingForSecond = false;
if (timer) clearTimeout(timer);
timer = null;
}
function onKey(e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
) {
return;
}
if (e.key === '?' && !waitingForSecond) {
// eslint-disable-next-line no-alert
alert(
'Shortcuts:\n g p — Puls\n g b — Bereich\n g e/v/m — Entwicklung/Vertrieb/Marketing\n g h — Heute\n g a — Archiv\n n — Neuer Blocker\n ? — diese Hilfe',
);
return;
}
if (e.key.toLowerCase() === 'n' && !waitingForSecond) {
setBlockerOpen(true);
return;
}
if (!waitingForSecond && e.key.toLowerCase() === 'g') {
waitingForSecond = true;
timer = setTimeout(reset, 1500);
return;
}
if (waitingForSecond) {
const target = SHORTCUT_MAP[e.key.toLowerCase()];
if (target) navigate(target);
reset();
}
}
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('keydown', onKey);
reset();
};
}, [navigate]);
return (
<div className="min-h-screen bg-[var(--color-bg)]">
<a href="#main" className="skip-link">
Zum Hauptinhalt springen
</a>
<TopNav />
<main id="main" className="mx-auto max-w-[1280px] px-6 py-8">
{me ? <Outlet /> : <NotLoggedInHint />}
</main>
{blockerOpen && <BlockerDialog onClose={() => setBlockerOpen(false)} />}
</div>
);
}
function NotLoggedInHint() {
return (
<div className="mx-auto mt-16 max-w-md rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] p-8 text-center shadow-[var(--shadow-sm)]">
<p className="text-[var(--color-ink-muted)]">Du bist nicht angemeldet.</p>
<a
href="/api/auth/login"
className="mt-4 inline-flex h-9 items-center rounded-[var(--radius-md)] bg-[var(--color-brand)] px-4 text-sm font-medium text-[var(--color-brand-fg)] hover:bg-black/85"
>
Über Authentik anmelden
</a>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { NavLink, useLocation } from 'react-router';
import { clsx } from 'clsx';
import { Flame } from 'lucide-react';
import { ToolCtrlLogo } from '../ui/ToolCtrlLogo';
import { voice } from '../../lib/voice';
import { useCurrentUser } from '../../hooks/useCurrentUser';
const TABS = [
{ to: '/puls', label: 'Puls', shortcut: 'g p', matchPrefix: '/puls' },
{ to: '/bereich', label: 'Bereich', shortcut: 'g b', matchPrefix: '/bereich' },
{ to: '/heute', label: 'Heute', shortcut: 'g h', matchPrefix: '/heute' },
{ to: '/archiv', label: 'Archiv', shortcut: 'g a', matchPrefix: '/archiv' },
] as const;
function getInitials(name: string): string {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? '')
.join('');
}
export function TopNav() {
const me = useCurrentUser();
const location = useLocation();
return (
<header
className="sticky top-0 z-30 bg-[var(--color-bg)] border-b border-[var(--color-border)]"
role="banner"
>
<nav
className="mx-auto flex h-14 max-w-[1280px] items-center gap-2 px-6"
aria-label="Hauptnavigation"
>
<NavLink to="/puls" className="flex items-center gap-1.5 font-bold text-[var(--color-ink)]">
<span>{voice.appName}</span>
<ToolCtrlLogo size={18} />
</NavLink>
<ul className="flex items-center gap-0.5 ml-6">
{TABS.map((tab) => {
const isActive = location.pathname.startsWith(tab.matchPrefix);
return (
<li key={tab.to}>
<NavLink
to={tab.to}
className={clsx(
'inline-flex h-9 items-center rounded-[var(--radius-md)] px-3 text-sm font-medium',
'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-black/5',
isActive &&
'text-[var(--color-ink)] border-b-2 border-[var(--color-ink)] rounded-b-none',
)}
aria-keyshortcuts={tab.shortcut}
>
{tab.label}
</NavLink>
</li>
);
})}
</ul>
<div className="ml-auto flex items-center gap-3">
{me ? (
<>
<span
className={clsx(
'inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium',
me.streak > 0
? 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]'
: 'bg-black/5 text-[var(--color-ink-muted)]',
)}
title={`Aktuelle Update-Serie: ${me.streak} ${me.streak === 1 ? 'Tag' : 'Tage'}`}
>
<Flame size={12} fill={me.streak > 0 ? 'currentColor' : 'none'} />
<span className="font-mono tabular-nums">{me.streak}</span>
</span>
<div
className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--color-brand)] text-xs font-semibold text-[var(--color-brand-fg)]"
aria-label={me.name}
title={me.name}
>
{getInitials(me.name)}
</div>
</>
) : (
<a
href="/api/auth/login"
className="text-sm font-medium text-[var(--color-ink)] hover:underline"
>
Login
</a>
)}
</div>
</nav>
</header>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { clsx } from 'clsx';
import { forwardRef, type ButtonHTMLAttributes } from 'react';
type Variant = 'primary' | 'secondary' | 'ghost' | 'destructive';
type Size = 'sm' | 'md' | 'lg';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
}
const base =
'inline-flex items-center justify-center gap-2 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
const variantStyles: Record<Variant, string> = {
primary: 'bg-[var(--color-brand)] text-[var(--color-brand-fg)] hover:bg-black/85',
secondary: 'border border-[var(--color-ink)] text-[var(--color-ink)] hover:bg-black/5',
ghost: 'text-[var(--color-ink)] hover:bg-black/5',
destructive: 'bg-[var(--color-ampel-red-fg)] text-white hover:bg-red-800',
};
const sizeStyles: Record<Size, string> = {
sm: 'h-7 px-3 text-xs rounded-[var(--radius-md)]',
md: 'h-9 px-4 text-sm rounded-[var(--radius-md)]',
lg: 'h-11 px-6 text-base rounded-[var(--radius-md)]',
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ variant = 'primary', size = 'md', className, ...rest },
ref,
) {
return (
<button
ref={ref}
className={clsx(base, variantStyles[variant], sizeStyles[size], className)}
{...rest}
/>
);
});
+33
View File
@@ -0,0 +1,33 @@
import { clsx } from 'clsx';
import type { HTMLAttributes } from 'react';
function Root({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={clsx(
'bg-[var(--color-surface)] border border-[var(--color-border)] rounded-[var(--radius-lg)] shadow-[var(--shadow-sm)]',
className,
)}
{...rest}
/>
);
}
function Header({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
return <div className={clsx('px-6 pt-6 pb-3', className)} {...rest} />;
}
function Body({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
return <div className={clsx('px-6 py-3', className)} {...rest} />;
}
function Footer({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={clsx('px-6 pt-3 pb-6 border-t border-[var(--color-border)]', className)}
{...rest}
/>
);
}
export const Card = { Root, Header, Body, Footer };
+28
View File
@@ -0,0 +1,28 @@
// macOS-Ctrl-Caret als Brand-Akzent. Skalierbar via currentColor.
export function KeyCaret({
size = 16,
className,
'aria-hidden': ariaHidden = true,
}: {
size?: number;
className?: string;
'aria-hidden'?: boolean;
}) {
return (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden={ariaHidden}
>
<polyline points="3 10 8 5 13 10" />
</svg>
);
}
@@ -0,0 +1,24 @@
import { clsx } from 'clsx';
export function SkeletonCard({ className, lines = 3 }: { className?: string; lines?: number }) {
return (
<div
className={clsx(
'bg-[var(--color-surface)] border border-[var(--color-border)] rounded-[var(--radius-lg)] p-6 animate-pulse',
className,
)}
aria-hidden
>
<div className="h-4 w-1/3 bg-[var(--color-border)] rounded" />
<div className="mt-3 space-y-2">
{Array.from({ length: lines }).map((_, i) => (
<div
key={i}
className="h-3 bg-[var(--color-border)] rounded"
style={{ width: `${100 - i * 12}%` }}
/>
))}
</div>
</div>
);
}
@@ -0,0 +1,27 @@
// Brand-Emblem (das Tool-Ctrl-Tastenkappensymbol). Ersetzt den früher
// verwendeten Caret als Marken-Anker im Logo-Lockup und in Streaks.
export function ToolCtrlLogo({
size = 16,
className,
'aria-hidden': ariaHidden = true,
alt = 'ToolCTRL',
}: {
size?: number;
className?: string;
'aria-hidden'?: boolean;
alt?: string;
}) {
return (
<img
src="/toolctrl-emblem.svg"
width={size}
height={(size * 678) / 713}
className={className}
aria-hidden={ariaHidden}
alt={ariaHidden ? '' : alt}
draggable={false}
style={{ display: 'inline-block' }}
/>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { trpc } from '../lib/trpc';
export function useCurrentUser() {
const q = trpc.user.me.useQuery(undefined, {
retry: false,
refetchOnWindowFocus: false,
});
return q.data ?? null;
}
+51
View File
@@ -0,0 +1,51 @@
import { createBrowserRouter, RouterProvider } from 'react-router';
import { lazy, Suspense } from 'react';
import { AppShell } from '../components/layout/AppShell';
import { SkeletonCard } from '../components/ui/SkeletonCard';
const Puls = lazy(() => import('../routes/puls').then((m) => ({ default: m.PulsRoute })));
const Heute = lazy(() =>
import('../routes/heute').then((m) => ({ default: m.HeuteRoute })),
);
const Reflexion = lazy(() =>
import('../routes/reflexion').then((m) => ({ default: m.ReflexionRoute })),
);
const Bereich = lazy(() => import('../routes/bereich').then((m) => ({ default: m.BereichRoute })));
const Projekt = lazy(() => import('../routes/projekt').then((m) => ({ default: m.ProjektRoute })));
const Archiv = lazy(() => import('../routes/archiv').then((m) => ({ default: m.ArchivRoute })));
const NotFound = lazy(() =>
import('../routes/not-found').then((m) => ({ default: m.NotFoundRoute })),
);
const Login = lazy(() => import('../routes/login').then((m) => ({ default: m.LoginRoute })));
function withSuspense(node: React.ReactNode) {
return (
<Suspense fallback={<SkeletonCard className="mx-auto mt-12 max-w-2xl" />}>{node}</Suspense>
);
}
const router = createBrowserRouter([
{
path: '/login',
element: withSuspense(<Login />),
},
{
path: '/',
element: <AppShell />,
children: [
{ index: true, element: withSuspense(<Puls />) },
{ path: 'puls', element: withSuspense(<Puls />) },
{ path: 'heute', element: withSuspense(<Heute />) },
{ path: 'reflexion', element: withSuspense(<Reflexion />) },
{ path: 'bereich', element: withSuspense(<Bereich />) },
{ path: 'bereich/:slug', element: withSuspense(<Bereich />) },
{ path: 'projekt/:id', element: withSuspense(<Projekt />) },
{ path: 'archiv', element: withSuspense(<Archiv />) },
{ path: '*', element: withSuspense(<NotFound />) },
],
},
]);
export function Router() {
return <RouterProvider router={router} />;
}
+51
View File
@@ -0,0 +1,51 @@
import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { toast } from 'sonner';
import { trpc } from './trpc';
import { voice } from './voice';
export function TrpcProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
mutations: {
onError(err) {
const msg =
err &&
typeof err === 'object' &&
'message' in err &&
typeof err.message === 'string'
? err.message
: voice.error.generic;
toast(msg);
},
},
},
}),
);
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: '/trpc',
fetch(url, options) {
return fetch(url, { ...options, credentials: 'include' });
},
}),
],
}),
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
}
+4
View File
@@ -0,0 +1,4 @@
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@cockpit/api/router';
export const trpc = createTRPCReact<AppRouter>();
+59
View File
@@ -0,0 +1,59 @@
// Zentrale UI-Mikrocopy — NFR-12/13 Voice & Tone.
// Locker, direkt, deutsch. Keine Anglizismen, keine Corporate-Floskeln.
export const voice = {
appName: 'everythingbutwork',
caret: '^',
toast: {
checkedIn: 'Locked in.',
reflected: 'Reflektiert.',
blockerOpened: 'Blocker offen.',
blockerResolved: 'Blocker erledigt.',
projectCreated: 'Projekt angelegt.',
projectArchived: 'Projekt archiviert.',
saved: 'Gespeichert.',
strong: 'Stark.',
},
cta: {
checkIn: 'Lock in',
reflect: 'Reflektieren',
newBlocker: 'Neuer Blocker',
newProject: 'Neues Projekt',
},
hint: {
notCheckedInToday: 'Du hast heute noch nicht gepingt.',
reflectionPending: 'Reflexion offen',
notReflectedYet: 'Du hast heute noch nicht reflektiert.',
howIsItGoing: 'Wie läuft es?',
},
empty: {
feed: 'Hier erscheinen die Updates des Tages, sobald jemand reflektiert hat.',
blockers: 'Keine offenen Blocker.',
projects: 'Noch keine Projekte. Leg eines an.',
archive: 'Noch nichts archiviert.',
},
error: {
generic: 'Da ist etwas schiefgelaufen. Bitte nochmal versuchen.',
notLoggedIn: 'Du bist nicht angemeldet.',
notAllowed: 'Dafür hast du keine Berechtigung.',
notFound: 'Nicht gefunden.',
},
label: {
bereich: 'Bereich',
bezug: 'Bezug',
ampel: {
green: 'Im Plan',
amber: 'Gefährdet',
red: 'Blockiert',
},
trend: {
better: 'Besser',
flat: 'Gleich',
worse: 'Schlechter',
},
projektart: {
kundenprojekt: 'Kundenprojekt',
internesProjekt: 'Internes Projekt',
},
},
} as const;
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import '@fontsource/inter/latin-300.css';
import '@fontsource/inter/latin-400.css';
import '@fontsource/inter/latin-500.css';
import '@fontsource/inter/latin-600.css';
import '@fontsource/inter/latin-700.css';
import '@fontsource/jetbrains-mono/latin-400.css';
import './styles/globals.css';
import { App } from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+38
View File
@@ -0,0 +1,38 @@
import { Link } from 'react-router';
import { trpc } from '../lib/trpc';
import { Card } from '../components/ui/Card';
import { SkeletonCard } from '../components/ui/SkeletonCard';
import { voice } from '../lib/voice';
export function ArchivRoute() {
const q = trpc.project.listArchived.useQuery();
if (q.isPending) return <SkeletonCard lines={4} />;
const items = q.data ?? [];
return (
<div className="space-y-4">
<h1 className="text-3xl font-bold">Archiv</h1>
{items.length === 0 ? (
<Card.Root>
<Card.Body>
<p className="text-sm text-[var(--color-ink-muted)]">{voice.empty.archive}</p>
</Card.Body>
</Card.Root>
) : (
<div className="space-y-2">
{items.map((p) => (
<Card.Root key={p.id}>
<Card.Body className="flex items-center justify-between">
<Link to={`/projekt/${p.id}`} className="font-medium hover:underline">
{p.name}
</Link>
<span className="text-xs text-[var(--color-ink-muted)]">
archiviert: {p.archiviertAm ? p.archiviertAm.toString().slice(0, 10) : '—'}
</span>
</Card.Body>
</Card.Root>
))}
</div>
)}
</div>
);
}
+320
View File
@@ -0,0 +1,320 @@
import { Link, NavLink, useParams } from 'react-router';
import { useState } from 'react';
import { trpc } from '../lib/trpc';
import { voice } from '../lib/voice';
import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { SkeletonCard } from '../components/ui/SkeletonCard';
import { toast } from 'sonner';
import { clsx } from 'clsx';
const BEREICHE: Array<{ slug: 'entwicklung' | 'vertrieb' | 'marketing'; label: string }> = [
{ slug: 'entwicklung', label: 'Entwicklung' },
{ slug: 'vertrieb', label: 'Vertrieb' },
{ slug: 'marketing', label: 'Marketing' },
];
function BereichTabs({ active }: { active: string }) {
return (
<nav aria-label="Bereich wählen" className="flex gap-1 border-b border-[var(--color-border)]">
{BEREICHE.map((b) => (
<NavLink
key={b.slug}
to={`/bereich/${b.slug}`}
className={clsx(
'inline-flex h-10 items-center rounded-t-[var(--radius-md)] px-4 text-sm font-medium',
active === b.slug
? 'border-b-2 border-[var(--color-ink)] text-[var(--color-ink)]'
: 'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-black/5',
)}
>
{b.label}
</NavLink>
))}
</nav>
);
}
export function BereichRoute() {
const { slug } = useParams<{ slug?: string }>();
// Default: Entwicklung, wenn /bereich ohne Slug.
const effective = slug && BEREICHE.some((b) => b.slug === slug) ? slug : 'entwicklung';
return (
<div className="space-y-6">
<BereichTabs active={effective} />
{effective === 'entwicklung' && <EntwicklungDetail />}
{effective === 'vertrieb' && <VertriebDetail />}
{effective === 'marketing' && <MarketingDetail />}
</div>
);
}
function EntwicklungDetail() {
const q = trpc.project.byAreaSlug.useQuery({ slug: 'entwicklung' });
const areas = trpc.area.list.useQuery();
const [open, setOpen] = useState(false);
if (q.isPending) return <SkeletonCard lines={5} />;
const projects = q.data ?? [];
const kunden = projects.filter((p) => p.kind === 'kundenprojekt');
const intern = projects.filter((p) => p.kind === 'internes_projekt');
return (
<div className="space-y-6">
<div className="flex items-center justify-end">
<Button onClick={() => setOpen(true)}>{voice.cta.newProject}</Button>
</div>
<Section title="Kundenprojekte" projects={kunden} />
<Section title="Interne Projekte" projects={intern} />
{open && (
<ProjectCreateDialog
onClose={() => setOpen(false)}
areas={areas.data ?? []}
defaultSlug="entwicklung"
/>
)}
</div>
);
}
function Section({
title,
projects,
}: {
title: string;
projects: Array<{
id: string;
name: string;
statusCache: 'green' | 'amber' | 'red' | null;
deadline: string | null;
pflichtMinimumState: Record<string, boolean>;
}>;
}) {
return (
<section>
<h2 className="mb-2 text-lg font-semibold">{title}</h2>
{projects.length === 0 ? (
<Card.Root>
<Card.Body>
<p className="text-sm text-[var(--color-ink-muted)]">{voice.empty.projects}</p>
</Card.Body>
</Card.Root>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{projects.map((p) => (
<Link key={p.id} to={`/projekt/${p.id}`} className="contents">
<Card.Root className="cursor-pointer hover:shadow-[var(--shadow-md)]">
<Card.Body className="flex items-center justify-between">
<div>
<p className="font-medium">{p.name}</p>
<p className="text-xs text-[var(--color-ink-muted)]">
{p.deadline ? `bis ${p.deadline}` : 'kein Deadline'}
</p>
</div>
<PflichtMinimumIndicator state={p.pflichtMinimumState} />
</Card.Body>
</Card.Root>
</Link>
))}
</div>
)}
</section>
);
}
function PflichtMinimumIndicator({ state }: { state: Record<string, boolean> }) {
const points = [
state.steckbrief_freigegeben,
state.verantwortlicher,
state.plane_board,
state.meilensteine,
state.status_aktuell,
state.retrospektive,
];
const filled = points.filter(Boolean).length;
return (
<div className="flex items-center gap-2" title={`Pflicht-Minimum ${filled}/6`}>
<div className="flex gap-0.5">
{points.map((p, i) => (
<span
key={i}
className={clsx(
'h-2 w-2 rounded-sm',
p ? 'bg-[var(--color-ink)]' : 'bg-[var(--color-border)]',
)}
/>
))}
</div>
<span className="text-xs text-[var(--color-ink-muted)]">{filled}/6</span>
</div>
);
}
function ProjectCreateDialog({
onClose,
areas,
defaultSlug,
}: {
onClose: () => void;
areas: Array<{ id: string; slug: string; name: string }>;
defaultSlug: string;
}) {
const defaultArea = areas.find((a) => a.slug === defaultSlug);
const [name, setName] = useState('');
const [areaId, setAreaId] = useState(defaultArea?.id ?? '');
const [kind, setKind] = useState<'kundenprojekt' | 'internes_projekt'>('internes_projekt');
const [arbeitsmodus, setArbeitsmodus] = useState<'cycle' | 'kanban'>('kanban');
const [steckbrief, setSteckbrief] = useState('');
const [plane, setPlane] = useState('');
const utils = trpc.useUtils();
const create = trpc.project.create.useMutation({
onSuccess: async () => {
toast(voice.toast.projectCreated);
await utils.project.byAreaSlug.invalidate();
await utils.puls.overview.invalidate();
onClose();
},
});
function submit(e: React.FormEvent) {
e.preventDefault();
if (!areaId) return;
create.mutate({
areaId,
name,
kind,
arbeitsmodus,
outlineSteckbriefUrl: steckbrief,
planeProjectUrl: plane,
milestones: [],
});
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-semibold">Neues Projekt</h2>
<form onSubmit={submit} className="mt-4 space-y-3">
<Field label="Name">
<input
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
</Field>
<Field label="Bereich">
<select
required
value={areaId}
onChange={(e) => setAreaId(e.target.value)}
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
>
{areas.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
</Field>
<Field label="Projektart">
<select
value={kind}
onChange={(e) => setKind(e.target.value as 'kundenprojekt' | 'internes_projekt')}
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
>
<option value="kundenprojekt">Kundenprojekt</option>
<option value="internes_projekt">Internes Projekt</option>
</select>
</Field>
<Field label="Arbeitsmodus">
<select
value={arbeitsmodus}
onChange={(e) => setArbeitsmodus(e.target.value as 'cycle' | 'kanban')}
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
>
<option value="kanban">Kanban</option>
<option value="cycle">Cycle</option>
</select>
</Field>
<Field label="Outline-Steckbrief-URL">
<input
required
type="url"
value={steckbrief}
onChange={(e) => setSteckbrief(e.target.value)}
placeholder="https://outline.tool-ctrl.de/..."
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
</Field>
<Field label="Plane-Board-URL">
<input
required
type="url"
value={plane}
onChange={(e) => setPlane(e.target.value)}
placeholder="https://plane.tool-ctrl.de/..."
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
</Field>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={create.isPending}>
Anlegen
</Button>
</div>
</form>
</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="text-sm font-medium">{label}</span>
<div className="mt-1">{children}</div>
</label>
);
}
function VertriebDetail() {
return <ProjektListeFuerBereich slug="vertrieb" />;
}
function MarketingDetail() {
return <ProjektListeFuerBereich slug="marketing" />;
}
function ProjektListeFuerBereich({ slug }: { slug: 'vertrieb' | 'marketing' }) {
const q = trpc.project.byAreaSlug.useQuery({ slug });
const areas = trpc.area.list.useQuery();
const [open, setOpen] = useState(false);
if (q.isPending) return <SkeletonCard lines={5} />;
const projects = q.data ?? [];
const kunden = projects.filter((p) => p.kind === 'kundenprojekt');
const intern = projects.filter((p) => p.kind === 'internes_projekt');
return (
<div className="space-y-6">
<div className="flex items-center justify-end">
<Button onClick={() => setOpen(true)}>{voice.cta.newProject}</Button>
</div>
<Section title="Kundenprojekte" projects={kunden} />
<Section title="Interne Projekte" projects={intern} />
{open && (
<ProjectCreateDialog
onClose={() => setOpen(false)}
areas={areas.data ?? []}
defaultSlug={slug}
/>
)}
</div>
);
}
+218
View File
@@ -0,0 +1,218 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { trpc } from '../lib/trpc';
import { voice } from '../lib/voice';
import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { SkeletonCard } from '../components/ui/SkeletonCard';
import { toast } from 'sonner';
import { X, Plus, RotateCw } from 'lucide-react';
export function HeuteRoute() {
const navigate = useNavigate();
const today = trpc.dayEntry.today.useQuery();
if (today.isPending) return <SkeletonCard lines={4} />;
if (today.data?.entry) {
return <EinstempeltView todos={today.data.todos} entry={today.data.entry} />;
}
return <CheckInForm onDone={() => navigate('/puls')} />;
}
function CheckInForm({ onDone }: { onDone: () => void }) {
const carryQuery = trpc.dayEntry.pendingCarryOver.useQuery();
const [todos, setTodos] = useState<{ title: string; carried?: boolean }[]>([{ title: '' }]);
const [prefilledFromCarry, setPrefilledFromCarry] = useState(false);
// Wenn Carryover-ToDos geladen sind und das Formular noch leer ist, vorbefüllen.
if (
!prefilledFromCarry &&
carryQuery.data &&
carryQuery.data.length > 0 &&
todos.length === 1 &&
!todos[0]!.title
) {
setPrefilledFromCarry(true);
setTodos([...carryQuery.data.map((c) => ({ title: c.title, carried: true })), { title: '' }]);
}
const utils = trpc.useUtils();
const mutation = trpc.dayEntry.checkIn.useMutation({
onSuccess: async (result) => {
const msg =
result.carriedOver > 0
? `Locked in. ${result.carriedOver} offene ToDos vom Vortag übernommen.`
: voice.toast.checkedIn;
toast(msg);
await utils.dayEntry.today.invalidate();
await utils.dayEntry.pendingCarryOver.invalidate();
onDone();
},
});
function updateAt(i: number, value: string) {
setTodos((arr) => arr.map((t, idx) => (idx === i ? { ...t, title: value } : t)));
}
function addRow() {
setTodos((arr) => [...arr, { title: '' }]);
}
function removeAt(i: number) {
setTodos((arr) => arr.filter((_, idx) => idx !== i));
}
function handleKey(e: React.KeyboardEvent<HTMLInputElement>, i: number) {
if (e.key === 'Enter') {
e.preventDefault();
if (i === todos.length - 1 && todos[i]?.title.trim()) {
addRow();
// Fokus auf neue Zeile in nächstem Tick
setTimeout(() => {
const inputs = document.querySelectorAll<HTMLInputElement>('input[data-todo-input]');
inputs[i + 1]?.focus();
}, 0);
}
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const filtered = todos.filter((t) => t.title.trim().length > 0);
if (filtered.length === 0) {
toast('Mindestens eine ToDo brauchst du.');
return;
}
mutation.mutate({ todos: filtered.map((t) => ({ title: t.title.trim() })) });
}
return (
<div className="mx-auto max-w-2xl space-y-6">
<header>
<h1 className="text-3xl font-bold">Heute</h1>
<p className="mt-1 text-[var(--color-ink-muted)]">{voice.hint.howIsItGoing}</p>
</header>
<Card.Root>
<form onSubmit={handleSubmit}>
<Card.Body className="space-y-3">
<p className="text-sm font-medium">Was nimmst du dir heute vor?</p>
{carryQuery.data && carryQuery.data.length > 0 && (
<p className="text-xs text-[var(--color-ink-muted)]">
<RotateCw size={12} className="inline -mt-0.5 mr-1" />
{carryQuery.data.length} offene ToDos vom Vortag wurden für dich vorgemerkt.
Behalte, ändere oder lösche sie.
</p>
)}
<ul className="space-y-2">
{todos.map((t, i) => (
<li key={i} className="flex items-center gap-2">
{t.carried && (
<span
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-black/5 text-[var(--color-ink-muted)]"
title="vom Vortag übernommen"
aria-label="vom Vortag übernommen"
>
<RotateCw size={12} />
</span>
)}
<input
type="text"
value={t.title}
onChange={(e) => updateAt(i, e.target.value)}
onKeyDown={(e) => handleKey(e, i)}
placeholder="ToDo …"
maxLength={200}
data-todo-input
ref={(el) => {
if (i === 0 && el && el.value === '') el.focus();
}}
className="flex-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm focus:border-[var(--color-ink)] focus:outline-none"
/>
{todos.length > 1 && (
<button
type="button"
onClick={() => removeAt(i)}
aria-label="ToDo entfernen"
className="rounded-[var(--radius-md)] p-2 text-[var(--color-ink-muted)] hover:bg-black/5"
>
<X size={16} />
</button>
)}
</li>
))}
</ul>
<button
type="button"
onClick={addRow}
className="inline-flex items-center gap-1 text-sm text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]"
>
<Plus size={14} /> Weitere
</button>
</Card.Body>
<Card.Footer className="flex justify-end gap-2">
<Button variant="ghost" type="button" onClick={() => history.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={mutation.isPending}>
{voice.cta.checkIn}
</Button>
</Card.Footer>
</form>
</Card.Root>
</div>
);
}
function EinstempeltView({
todos,
entry,
}: {
todos: Array<{ id: string; title: string; status: 'open' | 'done' | 'dropped' }>;
entry: { status: 'begun' | 'full' | 'absent' };
}) {
return (
<div className="mx-auto max-w-2xl space-y-6">
<header>
<h1 className="text-3xl font-bold">Heute</h1>
<p className="mt-1 text-[var(--color-ink-muted)]">
{entry.status === 'full'
? 'Tag abgeschlossen.'
: 'Locked in. Heute Abend reflektieren.'}
</p>
</header>
<Card.Root>
<Card.Body>
<ul className="space-y-2 text-sm">
{todos.map((t) => (
<li key={t.id} className="flex items-center gap-2">
<span
className={
t.status === 'done'
? 'h-2 w-2 rounded-full bg-[var(--color-ampel-green-fg)]'
: t.status === 'dropped'
? 'h-2 w-2 rounded-full bg-[var(--color-ink-faint)]'
: 'h-2 w-2 rounded-full bg-[var(--color-border-strong)]'
}
/>
<span
className={
t.status === 'dropped' ? 'line-through text-[var(--color-ink-muted)]' : ''
}
>
{t.title}
</span>
</li>
))}
</ul>
</Card.Body>
{entry.status !== 'full' && (
<Card.Footer className="flex justify-end">
<Button onClick={() => (location.href = '/reflexion')}>{voice.cta.reflect}</Button>
</Card.Footer>
)}
</Card.Root>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { useEffect } from 'react';
import { useSearchParams } from 'react-router';
import { ToolCtrlLogo } from '../components/ui/ToolCtrlLogo';
const ERROR_MESSAGES: Record<string, string> = {
session_expired: 'Deine Sitzung ist abgelaufen. Bitte erneut anmelden.',
oidc_failed: 'Anmeldung über Authentik ist fehlgeschlagen. Bitte erneut versuchen.',
};
export function LoginRoute() {
const [params] = useSearchParams();
const error = params.get('error');
// Dev: automatisch in den Dev-Login-Endpoint redirecten — kein Klick nötig.
// Authentik kommt später in der Roadmap (siehe docs/operations.md).
useEffect(() => {
if (import.meta.env.DEV && !error) {
window.location.replace('/api/auth/dev-login');
}
}, [error]);
return (
<div className="grid min-h-screen place-items-center bg-[var(--color-bg)] px-4">
<div className="w-full max-w-md text-center">
<h1 className="inline-flex items-center gap-2 text-4xl font-bold">
everythingbutwork <ToolCtrlLogo size={32} />
</h1>
<p className="mt-3 text-[var(--color-ink-muted)]">
Internes ToolCTRL-Projektmanagement.
</p>
{error && (
<p
className="mt-4 rounded-[var(--radius-md)] border border-[var(--color-ampel-red-bg)] bg-[var(--color-ampel-red-bg)] px-4 py-2 text-sm text-[var(--color-ampel-red-fg)]"
role="alert"
>
{ERROR_MESSAGES[error] ?? 'Anmeldung fehlgeschlagen.'}
</p>
)}
{!import.meta.env.DEV && (
<a
href="/api/auth/login"
className="mt-6 inline-flex h-11 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-brand)] px-6 text-base font-medium text-[var(--color-brand-fg)] hover:bg-black/85"
>
Über Authentik anmelden
</a>
)}
{import.meta.env.DEV && (
<p className="mt-4 text-xs text-[var(--color-ink-muted)]">
Dev-Modus: Auto-Login wird vorgenommen…
</p>
)}
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { Link } from 'react-router';
export function NotFoundRoute() {
return (
<div className="mx-auto mt-16 max-w-md text-center">
<h1 className="text-3xl font-bold">Nicht gefunden.</h1>
<p className="mt-2 text-[var(--color-ink-muted)]">
Diese Seite gibt es nicht. Vielleicht meintest du den Puls?
</p>
<Link
to="/puls"
className="mt-6 inline-flex h-9 items-center rounded-[var(--radius-md)] bg-[var(--color-brand)] px-4 text-sm font-medium text-[var(--color-brand-fg)] hover:bg-black/85"
>
Zum Puls
</Link>
</div>
);
}
+231
View File
@@ -0,0 +1,231 @@
import { useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router';
import { ArrowLeft } from 'lucide-react';
import { trpc } from '../lib/trpc';
import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { SkeletonCard } from '../components/ui/SkeletonCard';
import { useCurrentUser } from '../hooks/useCurrentUser';
import { voice } from '../lib/voice';
import { toast } from 'sonner';
import { clsx } from 'clsx';
export function ProjektRoute() {
const { id = '' } = useParams<{ id: string }>();
const me = useCurrentUser();
const navigate = useNavigate();
const q = trpc.project.byId.useQuery({ id });
const [abschlussOpen, setAbschlussOpen] = useState(false);
if (q.isPending) return <SkeletonCard lines={6} />;
if (!q.data) return null;
const { project: p, area: a, milestones } = q.data;
const isP1 = me?.id === p.verantwortlicheUserId;
const pflichtKeys: Array<{ key: string; label: string }> = [
{ key: 'steckbrief_freigegeben', label: 'Steckbrief in Outline freigegeben' },
{ key: 'verantwortlicher', label: 'Projektverantwortliche/r gesetzt' },
{ key: 'plane_board', label: 'Plane-Board verlinkt' },
{ key: 'meilensteine', label: 'Meilensteine vorhanden' },
{ key: 'status_aktuell', label: 'Status-Ampel aktuell (≤ 7 Werktage)' },
{ key: 'retrospektive', label: 'Abschluss-Retrospektive (bei beendeten Projekten)' },
];
function goBack() {
// Wenn der History-Stack einen Vorgänger hat, dorthin; sonst auf den Bereich.
if (window.history.length > 1) navigate(-1);
else navigate(`/bereich/${a.slug}`);
}
return (
<div className="space-y-6">
<header>
<div className="flex items-center gap-3">
<button
type="button"
onClick={goBack}
aria-label="Zurück"
className="inline-flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-ink)] hover:text-[var(--color-ink)]"
>
<ArrowLeft size={16} />
</button>
<p className="text-sm text-[var(--color-ink-muted)]">
<Link to="/puls" className="hover:underline">
Puls
</Link>
{' › '}
<Link to={`/bereich/${a.slug}`} className="hover:underline">
{a.name}
</Link>
</p>
</div>
<div className="mt-2 flex items-start justify-between gap-4">
<h1 className="text-3xl font-bold">{p.name}</h1>
{isP1 && !p.archiviert && (
<Button variant="secondary" onClick={() => setAbschlussOpen(true)}>
Projekt abschließen
</Button>
)}
{p.archiviert && (
<span className="rounded-full bg-black/5 px-2.5 py-1 text-xs">archiviert</span>
)}
</div>
<p className="text-[var(--color-ink-muted)]">
{p.kind === 'kundenprojekt' ? 'Kundenprojekt' : 'Internes Projekt'} ·{' '}
{p.arbeitsmodus ?? '—'} · {p.deadline ? `Deadline ${p.deadline}` : 'kein Deadline'}
</p>
</header>
{abschlussOpen && (
<AbschlussDialog projectId={p.id} onClose={() => setAbschlussOpen(false)} />
)}
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card.Root>
<Card.Header>
<h2 className="text-lg font-semibold">Verlinkungen</h2>
</Card.Header>
<Card.Body className="space-y-2 text-sm">
<LinkRow label="Outline-Steckbrief" url={p.outlineSteckbriefUrl} />
<LinkRow label="Plane-Board" url={p.planeProjectUrl} />
<LinkRow label="Risikoliste" url={p.risikolisteUrl} />
<LinkRow label="Lessons Learned" url={p.lessonsLearnedUrl} />
</Card.Body>
</Card.Root>
<Card.Root>
<Card.Header>
<h2 className="text-lg font-semibold">Pflicht-Minimum</h2>
</Card.Header>
<Card.Body>
<ul className="space-y-2 text-sm">
{pflichtKeys.map(({ key, label }) => {
const ok = p.pflichtMinimumState[key] === true;
return (
<li key={key} className="flex items-center gap-2">
<span
className={clsx(
'inline-block h-2.5 w-2.5 rounded-full',
ok ? 'bg-[var(--color-ampel-green-fg)]' : 'bg-[var(--color-border-strong)]',
)}
/>
<span className={ok ? '' : 'text-[var(--color-ink-muted)]'}>{label}</span>
</li>
);
})}
</ul>
</Card.Body>
</Card.Root>
</section>
<section>
<h2 className="mb-2 text-lg font-semibold">Meilensteine</h2>
{milestones.length === 0 ? (
<Card.Root>
<Card.Body>
<p className="text-sm text-[var(--color-ink-muted)]">Noch keine Meilensteine.</p>
</Card.Body>
</Card.Root>
) : (
<Card.Root>
<Card.Body>
<ul className="space-y-2 text-sm">
{milestones.map((m) => (
<li key={m.id} className="flex items-center justify-between">
<span
className={clsx(
m.status === 'done' && 'line-through text-[var(--color-ink-muted)]',
)}
>
{m.title}
</span>
<span className="text-xs text-[var(--color-ink-muted)]">
{m.due ?? '—'} · {m.status}
</span>
</li>
))}
</ul>
</Card.Body>
</Card.Root>
)}
</section>
{p.dodText && (
<Card.Root>
<Card.Header>
<h2 className="text-lg font-semibold">Definition of Done</h2>
</Card.Header>
<Card.Body>
<p className="whitespace-pre-wrap text-sm">{p.dodText}</p>
</Card.Body>
</Card.Root>
)}
</div>
);
}
function AbschlussDialog({ projectId, onClose }: { projectId: string; onClose: () => void }) {
const [url, setUrl] = useState('');
const navigate = useNavigate();
const utils = trpc.useUtils();
const mutation = trpc.project.archive.useMutation({
onSuccess: async () => {
toast(voice.toast.projectArchived);
await utils.project.byId.invalidate({ id: projectId });
await utils.project.listArchived.invalidate();
await utils.puls.overview.invalidate();
navigate('/archiv');
},
});
function submit(e: React.FormEvent) {
e.preventDefault();
mutation.mutate({ projectId, lessonsLearnedUrl: url });
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-semibold">Projekt abschließen</h2>
<p className="mt-1 text-sm text-[var(--color-ink-muted)]">
Lessons-Learned-URL ist Pflicht (Leitfaden §9 Punkt 6).
</p>
<form onSubmit={submit} className="mt-4 space-y-3">
<input
required
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://outline.tool-ctrl.de/lessons-learned/..."
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={mutation.isPending}>
Abschließen
</Button>
</div>
</form>
</div>
</div>
);
}
function LinkRow({ label, url }: { label: string; url: string | null }) {
return (
<p className="flex items-center justify-between">
<span className="text-[var(--color-ink-muted)]">{label}</span>
{url ? (
<a href={url} target="_blank" rel="noreferrer" className="truncate hover:underline">
{url}
</a>
) : (
<span className="text-[var(--color-ink-faint)]">—</span>
)}
</p>
);
}
+301
View File
@@ -0,0 +1,301 @@
import { Link } from 'react-router';
import { trpc } from '../lib/trpc';
import { voice } from '../lib/voice';
import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { SkeletonCard } from '../components/ui/SkeletonCard';
import { clsx } from 'clsx';
type Ampel = 'green' | 'amber' | 'red' | 'grey';
export function PulsRoute() {
const q = trpc.puls.overview.useQuery();
const today = trpc.dayEntry.today.useQuery();
if (q.isPending) {
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</div>
);
}
if (!q.data) return null;
const isCheckedIn =
today.data?.entry?.status !== undefined && today.data.entry.status !== 'absent';
return (
<div className="space-y-8">
<header className="flex items-end justify-between">
<div>
<h1 className="text-3xl font-bold">Unternehmens-Puls</h1>
<p className="text-[var(--color-ink-muted)]">
Stand: {formatDate(q.data.date)} · Jourfixe {q.data.jourfixe.label}
</p>
</div>
{!isCheckedIn && (
<Link
to="/heute"
className="inline-flex h-11 items-center rounded-[var(--radius-md)] bg-[var(--color-brand)] px-5 text-sm font-medium text-[var(--color-brand-fg)] hover:bg-black/85"
>
{voice.hint.notCheckedInToday} → {voice.cta.checkIn}
</Link>
)}
</header>
<section aria-labelledby="bereiche-heading">
<h2 id="bereiche-heading" className="sr-only">
Bereiche
</h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{q.data.areas.map((a) => (
<Link key={a.id} to={`/bereich/${a.slug}`} className="contents">
<Card.Root className="cursor-pointer hover:shadow-[var(--shadow-md)] transition-shadow">
<Card.Body className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">{a.name}</h3>
<AmpelDot ampel={a.ampel as Ampel} />
</div>
<div className="flex gap-3 text-xs text-[var(--color-ink-muted)]">
<span>{a.counts.green} grün</span>
<span>{a.counts.amber} gelb</span>
<span>{a.counts.red} rot</span>
{a.counts.grey > 0 && <span>{a.counts.grey} ohne Status</span>}
</div>
</Card.Body>
</Card.Root>
</Link>
))}
</div>
</section>
<section className="grid grid-cols-1 gap-6 md:grid-cols-3">
<Card.Root>
<Card.Header>
<h2 className="text-lg font-semibold">Heute</h2>
</Card.Header>
<Card.Body>
<p className="text-2xl font-bold">
{q.data.heuteQuote.gepingt} / {q.data.heuteQuote.gesamt}
</p>
<p className="text-sm text-[var(--color-ink-muted)]">Locked in</p>
{q.data.heuteQuote.fehlende.length > 0 && (
<details className="mt-3">
<summary className="cursor-pointer text-sm text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]">
{q.data.heuteQuote.fehlende.length} fehlt heute noch
</summary>
<ul className="mt-2 space-y-1 text-sm">
{q.data.heuteQuote.fehlende.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
</details>
)}
</Card.Body>
</Card.Root>
<Card.Root className="md:col-span-2">
<Card.Header>
<h2 className="text-lg font-semibold">Bereichsübergreifend offen</h2>
</Card.Header>
<Card.Body>
{q.data.blockers.length === 0 ? (
<p className="text-sm text-[var(--color-ink-muted)]">{voice.empty.blockers}</p>
) : (
<ul className="space-y-2">
{q.data.blockers.map((b) => (
<li
key={b.id}
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--color-border)] p-3"
>
<span className="text-sm font-medium">{b.title}</span>
<span className="text-xs text-[var(--color-ink-muted)]">
seit {formatDate(b.openedAt.toString())}
</span>
</li>
))}
</ul>
)}
</Card.Body>
</Card.Root>
</section>
{q.data.pflichtMinimumOffen.length > 0 && (
<Card.Root>
<Card.Header>
<h2 className="text-lg font-semibold">Pflicht-Minimum offen</h2>
</Card.Header>
<Card.Body>
<ul className="space-y-1 text-sm">
{q.data.pflichtMinimumOffen.map((p) => (
<li key={p.id}>
<Link to={`/projekt/${p.id}`} className="hover:underline">
{p.name}
</Link>
</li>
))}
</ul>
</Card.Body>
</Card.Root>
)}
<TeamHeuteSection />
<section aria-labelledby="feed-heading">
<h2 id="feed-heading" className="mb-3 text-lg font-semibold">
Gestern im Team
</h2>
{q.data.feed.length === 0 ? (
<Card.Root>
<Card.Body>
<p className="text-sm text-[var(--color-ink-muted)]">{voice.empty.feed}</p>
</Card.Body>
</Card.Root>
) : (
<div className="space-y-2">
{q.data.feed.map((entry) => (
<Card.Root key={entry.id}>
<Card.Body>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold">{entry.userName}</p>
<p className="text-xs text-[var(--color-ink-muted)]">{entry.date}</p>
</div>
{entry.ampel && <AmpelBadge ampel={entry.ampel} />}
</div>
{entry.reflectionText && <p className="mt-2 text-sm">{entry.reflectionText}</p>}
</Card.Body>
</Card.Root>
))}
</div>
)}
</section>
</div>
);
}
function AmpelDot({ ampel }: { ampel: Ampel }) {
const colorClass = {
green: 'bg-[var(--color-ampel-green-fg)]',
amber: 'bg-[var(--color-ampel-amber-fg)]',
red: 'bg-[var(--color-ampel-red-fg)]',
grey: 'bg-[var(--color-ink-faint)]',
}[ampel];
return <span className={clsx('inline-block h-3 w-3 rounded-full', colorClass)} aria-hidden />;
}
function AmpelBadge({ ampel }: { ampel: 'green' | 'amber' | 'red' }) {
const cls = {
green: 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]',
amber: 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]',
red: 'bg-[var(--color-ampel-red-bg)] text-[var(--color-ampel-red-fg)]',
}[ampel];
const label = { green: 'Im Plan', amber: 'Gefährdet', red: 'Blockiert' }[ampel];
return (
<span
className={clsx(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium',
cls,
)}
>
<span className="h-1.5 w-1.5 rounded-full bg-current" />
{label}
</span>
);
}
function TeamHeuteSection() {
const team = trpc.dayEntry.todayTeam.useQuery();
const users = trpc.user.list.useQuery();
if (team.isPending) return null;
const data = team.data ?? [];
const userMap = new Map((users.data ?? []).map((u) => [u.id, u.name]));
if (data.length === 0) {
return (
<section>
<h2 className="mb-3 text-lg font-semibold">Heute im Team</h2>
<Card.Root>
<Card.Body>
<p className="text-sm text-[var(--color-ink-muted)]">
Heute hat sich noch niemand locked in.
</p>
</Card.Body>
</Card.Root>
</section>
);
}
return (
<section>
<h2 className="mb-3 text-lg font-semibold">Heute im Team</h2>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{data.map((u) => (
<Card.Root key={u.userId}>
<Card.Body>
<div className="flex items-center justify-between">
<p className="text-sm font-semibold">{userMap.get(u.userId) ?? '—'}</p>
<span
className={clsx(
'rounded-full px-2 py-0.5 text-xs',
u.entryStatus === 'full'
? 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]'
: u.entryStatus === 'begun'
? 'bg-black/5 text-[var(--color-ink-muted)]'
: 'bg-black/5 text-[var(--color-ink-muted)]',
)}
>
{u.entryStatus === 'full'
? 'reflektiert'
: u.entryStatus === 'begun'
? 'locked in'
: 'abwesend'}
</span>
</div>
{u.todos.length === 0 ? (
<p className="mt-2 text-xs text-[var(--color-ink-faint)]">Keine ToDos eingetragen.</p>
) : (
<ul className="mt-2 space-y-1.5 text-sm">
{u.todos.map((t) => (
<li key={t.id} className="flex items-start gap-2">
<span
className={clsx(
'mt-1 inline-block h-2 w-2 rounded-full shrink-0',
t.status === 'done'
? 'bg-[var(--color-ampel-green-fg)]'
: t.status === 'dropped'
? 'bg-[var(--color-ink-faint)]'
: 'bg-[var(--color-border-strong)]',
)}
/>
<span
className={clsx(
t.status === 'dropped' && 'line-through text-[var(--color-ink-muted)]',
)}
>
{t.title}
</span>
</li>
))}
</ul>
)}
</Card.Body>
</Card.Root>
))}
</div>
</section>
);
}
function formatDate(input: string | Date): string {
const dt = input instanceof Date ? input : new Date(input);
if (Number.isNaN(dt.getTime())) return '—';
return new Intl.DateTimeFormat('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'long',
}).format(dt);
}
// Button als referenz behalten (Linter)
void Button;
+366
View File
@@ -0,0 +1,366 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { trpc } from '../lib/trpc';
import { voice } from '../lib/voice';
import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { SkeletonCard } from '../components/ui/SkeletonCard';
import { toast } from 'sonner';
import { clsx } from 'clsx';
import { Check, X, Minus, TrendingUp, TrendingDown, ArrowRight } from 'lucide-react';
type Ampel = 'green' | 'amber' | 'red';
type Trend = 'besser' | 'gleich' | 'schlechter';
type TodoStatus = 'open' | 'done' | 'dropped';
export function ReflexionRoute() {
const today = trpc.dayEntry.today.useQuery();
const navigate = useNavigate();
if (today.isPending) return <SkeletonCard lines={6} />;
if (!today.data?.entry) {
return (
<div className="mx-auto max-w-2xl text-center">
<p className="text-[var(--color-ink-muted)]">
Du bist heute noch nicht locked in. {voice.hint.notCheckedInToday}
</p>
<Button className="mt-4" onClick={() => navigate('/heute')}>
Zum Lock-in
</Button>
</div>
);
}
return (
<ReflectForm
entry={today.data.entry}
todos={today.data.todos}
onDone={() => navigate('/puls')}
/>
);
}
function ReflectForm({
entry,
todos,
onDone,
}: {
entry: { id: string };
todos: Array<{
id: string;
title: string;
status: TodoStatus;
bezugKind: 'project' | 'deal' | 'campaign' | 'allgemein' | null;
}>;
onDone: () => void;
}) {
const [todoStates, setTodoStates] = useState<
Record<string, { status: TodoStatus; reason?: string }>
>(Object.fromEntries(todos.map((t) => [t.id, { status: t.status }])));
const [reflectionText, setReflectionText] = useState('');
const [ampel, setAmpel] = useState<Ampel | null>(null);
const [trend, setTrend] = useState<Trend | null>(null);
const [blockerEnabled, setBlockerEnabled] = useState(false);
const [blockerTitle, setBlockerTitle] = useState('');
const [blockerDescription, setBlockerDescription] = useState('');
const hasProjectBezug = todos.some((t) => t.bezugKind === 'project');
const utils = trpc.useUtils();
const mutation = trpc.dayEntry.submitReflection.useMutation({
onSuccess: async () => {
toast(voice.toast.reflected);
await utils.dayEntry.today.invalidate();
onDone();
},
});
function setTodoStatus(id: string, status: TodoStatus) {
setTodoStates((prev) => ({ ...prev, [id]: { ...prev[id], status } }));
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!ampel) {
toast('Bitte Ampel setzen.');
return;
}
if (hasProjectBezug && !trend) {
toast('Bei Projektbezug ist der Trend Pflicht.');
return;
}
if (blockerEnabled && !blockerTitle.trim()) {
toast('Blocker braucht einen Titel.');
return;
}
mutation.mutate({
dayEntryId: entry.id,
todos: Object.entries(todoStates).map(([id, s]) => ({
id,
status: s.status,
dropReason: s.reason,
})),
reflectionText,
ampel,
trend: trend ?? null,
...(blockerEnabled
? { blocker: { title: blockerTitle, description: blockerDescription || undefined } }
: {}),
});
}
return (
<div className="mx-auto max-w-2xl space-y-6">
<header>
<h1 className="text-3xl font-bold">Reflexion</h1>
<p className="mt-1 text-[var(--color-ink-muted)]">{voice.hint.howIsItGoing}</p>
</header>
<form onSubmit={handleSubmit} className="space-y-4">
<Card.Root>
<Card.Header>
<h2 className="text-lg font-semibold">Was ist heute passiert?</h2>
</Card.Header>
<Card.Body className="space-y-3">
<p className="text-sm font-medium text-[var(--color-ink-muted)]">
Deine ToDos vom Morgen
</p>
<ul className="space-y-2">
{todos.map((t) => {
const status = todoStates[t.id]?.status ?? 'open';
return (
<li key={t.id} className="flex items-center gap-2">
<span className="flex-1 text-sm">{t.title}</span>
<div className="flex gap-1">
<StatusBtn
active={status === 'done'}
onClick={() => setTodoStatus(t.id, 'done')}
label="Erledigt"
>
<Check size={14} />
</StatusBtn>
<StatusBtn
active={status === 'open'}
onClick={() => setTodoStatus(t.id, 'open')}
label="Offen"
>
<Minus size={14} />
</StatusBtn>
<StatusBtn
active={status === 'dropped'}
onClick={() => setTodoStatus(t.id, 'dropped')}
label="Verworfen"
>
<X size={14} />
</StatusBtn>
</div>
</li>
);
})}
</ul>
</Card.Body>
</Card.Root>
<Card.Root>
<Card.Body className="space-y-3">
<label className="block text-sm font-medium" htmlFor="reflection">
Sonst noch was?
</label>
<textarea
id="reflection"
value={reflectionText}
onChange={(e) => setReflectionText(e.target.value)}
maxLength={1000}
rows={4}
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm focus:border-[var(--color-ink)] focus:outline-none"
/>
<p className="text-xs text-[var(--color-ink-faint)] text-right">
{reflectionText.length} / 1000
</p>
</Card.Body>
</Card.Root>
<Card.Root>
<Card.Body className="space-y-4">
<fieldset>
<legend className="text-sm font-medium">Ampel</legend>
<div className="mt-2 grid grid-cols-3 gap-2">
<AmpelBtn
value="green"
current={ampel}
onChange={setAmpel}
label={voice.label.ampel.green}
/>
<AmpelBtn
value="amber"
current={ampel}
onChange={setAmpel}
label={voice.label.ampel.amber}
/>
<AmpelBtn
value="red"
current={ampel}
onChange={setAmpel}
label={voice.label.ampel.red}
/>
</div>
</fieldset>
<fieldset>
<legend className="text-sm font-medium">
Trend {hasProjectBezug && <span aria-hidden>*</span>}
<span className="ml-1 text-xs text-[var(--color-ink-faint)]">
{hasProjectBezug ? '(Pflicht bei Projektbezug)' : '(optional)'}
</span>
</legend>
<div className="mt-2 grid grid-cols-3 gap-2">
<TrendBtn value="besser" current={trend} onChange={setTrend}>
<TrendingUp size={14} /> Besser
</TrendBtn>
<TrendBtn value="gleich" current={trend} onChange={setTrend}>
<ArrowRight size={14} /> Gleich
</TrendBtn>
<TrendBtn value="schlechter" current={trend} onChange={setTrend}>
<TrendingDown size={14} /> Schlechter
</TrendBtn>
</div>
</fieldset>
</Card.Body>
</Card.Root>
<Card.Root>
<Card.Body className="space-y-3">
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
checked={blockerEnabled}
onChange={(e) => setBlockerEnabled(e.target.checked)}
/>
Blocker?
</label>
{blockerEnabled && (
<div className="space-y-2">
<input
type="text"
value={blockerTitle}
onChange={(e) => setBlockerTitle(e.target.value)}
placeholder="Was klemmt?"
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
<textarea
value={blockerDescription}
onChange={(e) => setBlockerDescription(e.target.value)}
placeholder="Mehr Kontext (optional)"
rows={2}
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
</div>
)}
</Card.Body>
</Card.Root>
<div className="flex justify-end gap-2">
<Button variant="ghost" type="button" onClick={() => history.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={mutation.isPending}>
{voice.cta.reflect}
</Button>
</div>
</form>
</div>
);
}
function StatusBtn({
active,
onClick,
children,
label,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
label: string;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
className={clsx(
'inline-flex h-7 w-7 items-center justify-center rounded-[var(--radius-md)] border',
active
? 'border-[var(--color-ink)] bg-[var(--color-ink)] text-[var(--color-brand-fg)]'
: 'border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-border-strong)]',
)}
>
{children}
</button>
);
}
function AmpelBtn({
value,
current,
onChange,
label,
}: {
value: Ampel;
current: Ampel | null;
onChange: (a: Ampel) => void;
label: string;
}) {
const bgMap: Record<Ampel, string> = {
green: 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]',
amber: 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]',
red: 'bg-[var(--color-ampel-red-bg)] text-[var(--color-ampel-red-fg)]',
};
const dotMap: Record<Ampel, string> = {
green: 'bg-[var(--color-ampel-green-fg)]',
amber: 'bg-[var(--color-ampel-amber-fg)]',
red: 'bg-[var(--color-ampel-red-fg)]',
};
const active = current === value;
return (
<button
type="button"
onClick={() => onChange(value)}
className={clsx(
'flex h-12 items-center gap-2 rounded-[var(--radius-md)] px-3 text-sm font-medium border',
active
? bgMap[value] + ' border-[var(--color-ink)]'
: 'border-[var(--color-border)] hover:border-[var(--color-border-strong)]',
)}
>
<span className={clsx('h-2.5 w-2.5 rounded-full', dotMap[value])} />
{label}
</button>
);
}
function TrendBtn({
value,
current,
onChange,
children,
}: {
value: Trend;
current: Trend | null;
onChange: (t: Trend) => void;
children: React.ReactNode;
}) {
const active = current === value;
return (
<button
type="button"
onClick={() => onChange(value)}
className={clsx(
'flex h-10 items-center justify-center gap-1.5 rounded-[var(--radius-md)] border px-3 text-sm font-medium',
active
? 'border-[var(--color-ink)] bg-[var(--color-ink)] text-[var(--color-brand-fg)]'
: 'border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-border-strong)]',
)}
>
{children}
</button>
);
}
+90
View File
@@ -0,0 +1,90 @@
@import 'tailwindcss';
/* ------ Brand Tokens (UX-Spec § Visual Design Foundation) ------- */
@theme {
/* Neutrals */
--color-bg: #ffffff;
--color-surface: #fafafa;
--color-surface-elevated: #ffffff;
--color-border: #e5e5e5;
--color-border-strong: #d4d4d4;
--color-ink: #0a0a0a;
--color-ink-muted: #525252;
--color-ink-faint: #a3a3a3;
--color-brand: #0a0a0a;
--color-brand-fg: #ffffff;
/* Ampel */
--color-ampel-green-fg: #15803d;
--color-ampel-green-bg: #dcfce7;
--color-ampel-amber-fg: #a16207;
--color-ampel-amber-bg: #fef3c7;
--color-ampel-red-fg: #b91c1c;
--color-ampel-red-bg: #fee2e2;
/* Trend */
--color-trend-up: #15803d;
--color-trend-flat: #525252;
--color-trend-down: #b91c1c;
/* Typografie */
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', 'SF Mono', Menlo, monospace;
/* Radii */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--radius-xl: 24px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(10, 10, 10, 0.04);
--shadow-md: 0 4px 12px rgba(10, 10, 10, 0.06);
--shadow-lg: 0 12px 32px rgba(10, 10, 10, 0.1);
}
/* ------ Base styles ------- */
html {
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body {
background: var(--color-bg);
color: var(--color-ink);
margin: 0;
}
*:focus-visible {
outline: 2px solid var(--color-ink);
outline-offset: 2px;
border-radius: 2px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0ms !important;
transition-duration: 0ms !important;
}
}
/* Reusable utility classes (NFR-9 — ≤ 3 Klicks Workflow). */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: var(--color-ink);
color: var(--color-brand-fg);
padding: 8px 16px;
z-index: 100;
text-decoration: none;
}
.skip-link:focus {
top: 0;
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"useDefineForClassFields": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "vite.config.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'node:path';
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 7173,
strictPort: true,
proxy: {
'/api': 'http://localhost:7100',
'/trpc': 'http://localhost:7100',
},
},
});