UI/Routing - Tab + Route: /puls → /dashboard, Heading „Unternehmens-Puls" → „Dashboard" - Shortcut: g p → g d (Cheatsheet entsprechend) - Breadcrumbs, 404-Hinweistext, alle internen Links angepasst Code-Layout - apps/web/src/routes/puls.tsx → dashboard.tsx (Komponente DashboardRoute) - apps/api/src/routers/puls.ts → dashboard.ts (dashboardRouter) - tRPC-Aufrufe: trpc.puls.* / utils.puls.* → trpc.dashboard.* / utils.dashboard.* - Auth-Redirects (/api/auth/login, /callback, dev-login) zeigen jetzt auf /dashboard Projekt-Beschreibung - Migration 0007_project_description: project.description text (nullable) - Shared Zod: ProjectSchemas.createProjectInput.description (max 2000, optional) - Backend: project.create mappt input.description - Frontend Dialog „Neues Projekt": Textarea (3 Zeilen, optional) - Projekt-Detail: zeigt Beschreibung als whitespace-pre-wrap unter Subtitle README - Routen-Tabelle, Shortcut-Liste, Verzeichnis-Baum, DB-Tabellen, Migration-Range, Frontend-Routenliste aktualisiert Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
367 lines
12 KiB
TypeScript
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('/dashboard')}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|