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>
412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|