feat(reflexion): ToDo-Titel inline editierbar
ToDos in der Reflexion lassen sich per Klick auf den Titel korrigieren (z. B. Tippfehler), Enter speichert / Escape bricht ab. Neuer Endpoint dayEntry.editTodoTitle — nur eigene ToDos vom heutigen Berlin-Tag, ändert den Titel in-place (Status/Reihenfolge/IDs bleiben). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
804b7637e7
commit
8933169ba9
3 files changed
+124
-2
No files matched your search
@@ -209,6 +209,37 @@ export const dayEntryRouter = router({
|
||||
return result;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Einzelnen ToDo-Titel korrigieren (z. B. Tippfehler) — auch noch in der
|
||||
* Reflexion. Erlaubt, solange das ToDo der eigenen Person gehört und vom
|
||||
* heutigen Berlin-Tag ist (gleiche Editierbarkeits-Regel wie die Reflexion).
|
||||
*/
|
||||
editTodoTitle: protectedProcedure
|
||||
.input(DayEntrySchemas.editTodoTitleInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const rows = await ctx.db
|
||||
.select()
|
||||
.from(todo)
|
||||
.where(and(eq(todo.id, input.todoId), eq(todo.userId, ctx.user.id)))
|
||||
.limit(1);
|
||||
const existing = rows[0];
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'ToDo nicht gefunden.' });
|
||||
}
|
||||
if (existing.date !== todayIso(new Date())) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'ToDos eines Vortags können nicht mehr bearbeitet werden.',
|
||||
});
|
||||
}
|
||||
const [updated] = await ctx.db
|
||||
.update(todo)
|
||||
.set({ title: input.title })
|
||||
.where(eq(todo.id, input.todoId))
|
||||
.returning();
|
||||
return updated;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Abend-Reflexion. Setzt Ampel + Trend, optional Blocker.
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,7 @@ 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';
|
||||
import { Check, X, Minus, TrendingUp, TrendingDown, ArrowRight, Pencil } from 'lucide-react';
|
||||
|
||||
type Ampel = 'green' | 'amber' | 'red';
|
||||
type Trend = 'besser' | 'gleich' | 'schlechter';
|
||||
@@ -128,7 +128,7 @@ function ReflectForm({
|
||||
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>
|
||||
<EditableTodoTitle id={t.id} title={t.title} />
|
||||
<div className="flex gap-1">
|
||||
<StatusBtn
|
||||
active={status === 'done'}
|
||||
@@ -269,6 +269,91 @@ function ReflectForm({
|
||||
);
|
||||
}
|
||||
|
||||
function EditableTodoTitle({ id, title }: { id: string; title: string }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(title);
|
||||
const utils = trpc.useUtils();
|
||||
const edit = trpc.dayEntry.editTodoTitle.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.dayEntry.today.invalidate();
|
||||
setEditing(false);
|
||||
},
|
||||
});
|
||||
|
||||
function save() {
|
||||
const v = draft.trim();
|
||||
if (!v || v === title) {
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
edit.mutate({ todoId: id, title: v });
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center gap-1">
|
||||
<input
|
||||
ref={(el) => el?.focus()}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
save();
|
||||
} else if (e.key === 'Escape') {
|
||||
setDraft(title);
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
maxLength={200}
|
||||
aria-label="ToDo-Titel bearbeiten"
|
||||
className="flex-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm focus:border-[var(--color-ink)] focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={edit.isPending}
|
||||
aria-label="Titel speichern"
|
||||
title="Speichern"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-ink)] bg-[var(--color-ink)] text-[var(--color-brand-fg)]"
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDraft(title);
|
||||
setEditing(false);
|
||||
}}
|
||||
aria-label="Bearbeiten abbrechen"
|
||||
title="Abbrechen"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-border-strong)]"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDraft(title);
|
||||
setEditing(true);
|
||||
}}
|
||||
title="Zum Bearbeiten klicken"
|
||||
className="group flex flex-1 items-center gap-1.5 text-left text-sm"
|
||||
>
|
||||
<span>{title}</span>
|
||||
<Pencil
|
||||
size={12}
|
||||
className="text-[var(--color-ink-faint)] opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBtn({
|
||||
active,
|
||||
onClick,
|
||||
|
||||
@@ -51,6 +51,12 @@ export const todoMutationInput = z.object({
|
||||
});
|
||||
export type TodoMutationInput = z.infer<typeof todoMutationInput>;
|
||||
|
||||
export const editTodoTitleInput = z.object({
|
||||
todoId: z.string().uuid(),
|
||||
title: z.string().min(1).max(200),
|
||||
});
|
||||
export type EditTodoTitleInput = z.infer<typeof editTodoTitleInput>;
|
||||
|
||||
export const markAbsentInput = z.object({
|
||||
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
|
||||
Reference in new issue
Block a user