Files
everythingbutwork/apps/web/src/components/time/TimelineSection.tsx
T
Raffael SheikhandClaude Opus 4.8 f600c9f187 UI: alle 8 Dialoge auf barrierearme Modal-Basis umgestellt
Bisher waren alle Overlay-Dialoge plain divs ohne role/Fokus-Trap/Escape.
Jetzt setzen sie auf <Modal> auf (role=dialog, aria-modal, Fokus-Trap +
-Rueckgabe, Escape, Body-Scroll-Lock, konsistenter X-Button, Scroll bei langem
Inhalt).

- Modal: X-Schliessen-Button + max-h-[90vh]/overflow-y-auto ergaenzt
- BlockerDialog, ManualDialog (Timeline)
- leads: CSV-Import + Neuer Lead
- bereich: Neues Projekt
- projekt: Gate-Review + Projekt abschliessen
- lead: Zu Deal machen

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:49:05 +02:00

412 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { Pencil, Trash2, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { clsx } from 'clsx';
import { trpc } from '../../lib/trpc';
import { Card } from '../ui/Card';
import { Button } from '../ui/Button';
import { Modal } from '../ui/Modal';
import { formatDuration, formatDurationCompact } from '../../lib/format-duration';
import { TIME_CATEGORIES, type TimeCategory } from '../../lib/time-categories';
const CATEGORY_LABEL: Record<string, string> = Object.fromEntries(
TIME_CATEGORIES.map((c) => [c.value, c.label]),
);
function pad(n: number): string {
return String(n).padStart(2, '0');
}
function toLocalInput(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function fromLocalInput(s: string): Date {
return new Date(s);
}
export function TimelineSection() {
const listQuery = trpc.time.listToday.useQuery(undefined, { refetchInterval: 30_000 });
const todayQuery = trpc.dayEntry.today.useQuery();
const [manualOpen, setManualOpen] = useState(false);
const [editing, setEditing] = useState<string | null>(null);
if (listQuery.isPending) return null;
// Aufsteigend sortieren: Vormittag oben, Abend unten.
const entries = [...(listQuery.data?.entries ?? [])].sort(
(a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime(),
);
const totalSeconds = listQuery.data?.totalSeconds ?? 0;
const todoTitles = new Map((todayQuery.data?.todos ?? []).map((t) => [t.id, t.title]));
return (
<section className="space-y-3">
<header className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Zeit heute</h2>
<p className="text-xs text-[var(--color-ink-muted)]">
Gesamt: <span className="font-mono">{formatDurationCompact(totalSeconds)}</span>
</p>
</div>
<Button size="sm" variant="secondary" onClick={() => setManualOpen(true)}>
<Plus size={14} /> Block nachtragen
</Button>
</header>
{entries.length === 0 ? (
<Card.Root>
<Card.Body>
<p className="text-sm text-[var(--color-ink-muted)]">
Noch keine Zeit erfasst. Starte einen Timer oder trag einen Block nach.
</p>
</Card.Body>
</Card.Root>
) : (
<Card.Root>
<Card.Body className="space-y-2">
{entries.map((e) => (
<TimelineRow
key={e.id}
entry={e}
todoTitle={e.todoTitle ?? (e.todoId ? todoTitles.get(e.todoId) : undefined)}
todos={todayQuery.data?.todos ?? []}
isEditing={editing === e.id}
onEdit={() => setEditing(e.id)}
onCancelEdit={() => setEditing(null)}
onSaved={() => setEditing(null)}
/>
))}
</Card.Body>
</Card.Root>
)}
{manualOpen && (
<ManualDialog onClose={() => setManualOpen(false)} todos={todayQuery.data?.todos ?? []} />
)}
</section>
);
}
type TimeEntryRow = {
id: string;
startedAt: string | Date;
endedAt: string | Date | null;
durationSeconds: number | null;
todoId: string | null;
todoTitle?: string | null;
category: string;
description: string | null;
};
function TimelineRow({
entry,
todoTitle,
todos,
isEditing,
onEdit,
onCancelEdit,
onSaved,
}: {
entry: TimeEntryRow;
todoTitle?: string;
todos: Array<{ id: string; title: string }>;
isEditing: boolean;
onEdit: () => void;
onCancelEdit: () => void;
onSaved: () => void;
}) {
const utils = trpc.useUtils();
const deleteMutation = trpc.time.delete.useMutation({
onSuccess: async () => {
await utils.time.listToday.invalidate();
await utils.time.now.invalidate();
toast('Eintrag gelöscht.');
},
});
const updateMutation = trpc.time.update.useMutation({
onSuccess: async () => {
await utils.time.listToday.invalidate();
onSaved();
toast('Aktualisiert.');
},
});
const start = new Date(entry.startedAt);
const end = entry.endedAt ? new Date(entry.endedAt) : null;
const duration = end
? (entry.durationSeconds ?? Math.round((end.getTime() - start.getTime()) / 1000))
: Math.round((Date.now() - start.getTime()) / 1000);
const label = todoTitle || entry.description || CATEGORY_LABEL[entry.category] || 'Eintrag';
const isRunning = !end;
const [editStart, setEditStart] = useState(toLocalInput(start));
const [editEnd, setEditEnd] = useState(end ? toLocalInput(end) : '');
const [editDescription, setEditDescription] = useState(entry.description ?? '');
const [editCategory, setEditCategory] = useState<TimeCategory>(entry.category as TimeCategory);
const [editTodoId, setEditTodoId] = useState<string>(entry.todoId ?? '');
if (isEditing) {
return (
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] p-2.5 text-sm">
<div className="grid grid-cols-2 gap-2">
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Start</span>
<input
type="datetime-local"
value={editStart}
onChange={(e) => setEditStart(e.target.value)}
className="mt-0.5 w-full rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm"
/>
</label>
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Ende</span>
<input
type="datetime-local"
value={editEnd}
onChange={(e) => setEditEnd(e.target.value)}
className="mt-0.5 w-full rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm"
/>
</label>
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">ToDo (optional)</span>
<select
value={editTodoId}
onChange={(e) => setEditTodoId(e.target.value)}
className="mt-0.5 w-full rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm"
>
<option value="">— kein ToDo —</option>
{todos.map((t) => (
<option key={t.id} value={t.id}>
{t.title}
</option>
))}
</select>
</label>
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Kategorie</span>
<select
value={editCategory}
onChange={(e) => setEditCategory(e.target.value as TimeCategory)}
className="mt-0.5 w-full rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm"
>
{TIME_CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</label>
</div>
<label className="mt-2 block">
<span className="text-xs text-[var(--color-ink-muted)]">Beschreibung</span>
<input
type="text"
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
maxLength={200}
className="mt-0.5 w-full rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm"
/>
</label>
<div className="mt-2 flex justify-end gap-2">
<Button size="sm" variant="ghost" onClick={onCancelEdit}>
Abbrechen
</Button>
<Button
size="sm"
onClick={() => {
const startedAt = fromLocalInput(editStart).toISOString();
const endedAt = editEnd ? fromLocalInput(editEnd).toISOString() : null;
updateMutation.mutate({
id: entry.id,
startedAt,
endedAt,
todoId: editTodoId || null,
category: editCategory,
description: editDescription.trim() || null,
});
}}
disabled={updateMutation.isPending}
>
Speichern
</Button>
</div>
</div>
);
}
return (
<div className="group flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] p-2.5 text-sm">
<div className="w-28 shrink-0 font-mono text-xs text-[var(--color-ink-muted)]">
{start.toDateString() !== new Date().toDateString() && (
<span className="mr-1 text-[var(--color-ink-faint)]">
{pad(start.getDate())}.{pad(start.getMonth() + 1)}.
</span>
)}
{pad(start.getHours())}:{pad(start.getMinutes())}
{end && (
<>
{' – '}
{pad(end.getHours())}:{pad(end.getMinutes())}
</>
)}
</div>
<div className="flex-1 truncate">
<p className="truncate">{label}</p>
<p className="text-xs text-[var(--color-ink-muted)]">
{CATEGORY_LABEL[entry.category]}
{entry.description &&
!todoTitle &&
entry.description !== label &&
` · ${entry.description}`}
</p>
</div>
<div
className={clsx(
'shrink-0 font-mono text-sm tabular-nums',
isRunning && 'text-[var(--color-ampel-red-fg)]',
)}
>
{formatDuration(duration)}
</div>
{!isRunning && (
<div className="flex shrink-0 gap-1 opacity-0 group-hover:opacity-100">
<button
type="button"
onClick={onEdit}
aria-label="Bearbeiten"
className="rounded-[var(--radius-sm)] p-1 text-[var(--color-ink-muted)] hover:bg-black/5"
>
<Pencil size={14} />
</button>
<button
type="button"
onClick={() => {
if (confirm('Eintrag wirklich löschen?')) deleteMutation.mutate({ id: entry.id });
}}
aria-label="Löschen"
className="rounded-[var(--radius-sm)] p-1 text-[var(--color-ink-muted)] hover:bg-black/5"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
);
}
export function ManualDialog({
onClose,
todos,
defaultTodoId,
}: {
onClose: () => void;
todos: Array<{ id: string; title: string }>;
defaultTodoId?: string;
}) {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
const [startedAt, setStartedAt] = useState(toLocalInput(oneHourAgo));
const [endedAt, setEndedAt] = useState(toLocalInput(now));
const [todoId, setTodoId] = useState<string>(defaultTodoId ?? '');
const [category, setCategory] = useState<TimeCategory>('focus');
const [description, setDescription] = useState('');
const utils = trpc.useUtils();
const mutation = trpc.time.upsertManual.useMutation({
onSuccess: async () => {
await utils.time.listToday.invalidate();
onClose();
toast('Block nachgetragen.');
},
});
function submit(e: React.FormEvent) {
e.preventDefault();
mutation.mutate({
startedAt: fromLocalInput(startedAt).toISOString(),
endedAt: fromLocalInput(endedAt).toISOString(),
todoId: todoId || undefined,
category,
description: description.trim() || undefined,
});
}
return (
<Modal open onClose={onClose} title="Block nachtragen">
<form onSubmit={submit} className="mt-4 space-y-3">
<div className="grid grid-cols-2 gap-2">
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Start</span>
<input
type="datetime-local"
required
value={startedAt}
onChange={(e) => setStartedAt(e.target.value)}
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
/>
</label>
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Ende</span>
<input
type="datetime-local"
required
value={endedAt}
onChange={(e) => setEndedAt(e.target.value)}
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
/>
</label>
</div>
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Bezug auf ToDo (optional)</span>
<select
value={todoId}
onChange={(e) => setTodoId(e.target.value)}
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
>
<option value="">— kein ToDo —</option>
{todos.map((t) => (
<option key={t.id} value={t.id}>
{t.title}
</option>
))}
</select>
</label>
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Kategorie</span>
<select
value={category}
onChange={(e) => setCategory(e.target.value as TimeCategory)}
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
>
{TIME_CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Beschreibung (optional)</span>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={200}
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 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}>
Speichern
</Button>
</div>
</form>
</Modal>
);
}