Files
everythingbutwork/apps/web/src/routes/reflexion.tsx
T
dejanandClaude Opus 4.7 4f33509a0f
ci / quality (push) Has been cancelled
ci / build (push) Has been cancelled
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>
2026-05-25 22:47:26 +02:00

367 lines
12 KiB
TypeScript

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>
);
}