Initial commit: everythingbutwork MVP
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:
commit
4f33509a0f
131 files changed
+34339
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createTRPCReact } from '@trpc/react-query';
|
||||
import type { AppRouter } from '@cockpit/api/router';
|
||||
|
||||
export const trpc = createTRPCReact<AppRouter>();
|
||||
@@ -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;
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user