Historie: Task-Notizen + Link sichtbar, Navigation zu Tagen mit Einträgen
- Notizfeld pro Task in der Reflexion (optional, jeder Status) — nutzt das bestehende dropReason-Feld (Limit 200->500). Wird in der Historie bei JEDEM Status angezeigt (bisher nur bei "verworfen"). - Optionaler Tages-Link wird in der Historie jetzt angezeigt. - Historie-Pfeile springen zum naechsten/vorherigen Tag MIT Eintraegen statt starr ueber Werktage — Wochenend-Arbeit (z. B. Sa 6.6.) ist nun erreichbar. Default-Tag = juengster Tag vor heute mit Eintrag. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
6e7a910ed3
commit
6acf9977ec
4 files changed
+116
-56
No files matched your search
@@ -1,12 +1,11 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { and, eq, gte, isNull, lt, lte, asc, desc, inArray } from 'drizzle-orm';
|
||||
import { and, eq, gt, gte, isNull, lt, lte, asc, desc, inArray } from 'drizzle-orm';
|
||||
import { dayEntry, todo, blocker, user, type Db, type DayEntry, type Todo } from '@cockpit/db';
|
||||
import { DayEntrySchemas } from '@cockpit/shared';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
import {
|
||||
todayIso,
|
||||
previousWerktag,
|
||||
nextWerktag,
|
||||
addCalendarDays,
|
||||
workdayCutoffMs,
|
||||
isWorkdaySealed,
|
||||
@@ -618,7 +617,18 @@ export const dayEntryRouter = router({
|
||||
.input(DayEntrySchemas.historyForDateInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const today = todayIso();
|
||||
const date = input.date ?? previousWerktag(today);
|
||||
// Default-Tag = der jüngste Tag VOR heute, an dem es überhaupt Einträge gibt
|
||||
// (nicht starr der vorige Werktag — sonst sieht man Wochenend-Arbeit nicht).
|
||||
const fallbackDate =
|
||||
(
|
||||
await ctx.db
|
||||
.select({ d: dayEntry.date })
|
||||
.from(dayEntry)
|
||||
.where(lt(dayEntry.date, today))
|
||||
.orderBy(desc(dayEntry.date))
|
||||
.limit(1)
|
||||
)[0]?.d ?? previousWerktag(today);
|
||||
const date = input.date ?? fallbackDate;
|
||||
const isToday = date === today;
|
||||
|
||||
const entries = await ctx.db.select().from(dayEntry).where(eq(dayEntry.date, date));
|
||||
@@ -669,11 +679,28 @@ export const dayEntryRouter = router({
|
||||
.map((u) => u.name)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const nw = nextWerktag(date);
|
||||
// Pfeil-Navigation springt zum nächsten/vorherigen Tag MIT Einträgen
|
||||
// (überspringt leere Tage, erreicht aber Wochenend-Arbeit).
|
||||
const prevRow = (
|
||||
await ctx.db
|
||||
.select({ d: dayEntry.date })
|
||||
.from(dayEntry)
|
||||
.where(lt(dayEntry.date, date))
|
||||
.orderBy(desc(dayEntry.date))
|
||||
.limit(1)
|
||||
)[0];
|
||||
const nextRow = (
|
||||
await ctx.db
|
||||
.select({ d: dayEntry.date })
|
||||
.from(dayEntry)
|
||||
.where(and(gt(dayEntry.date, date), lte(dayEntry.date, today)))
|
||||
.orderBy(asc(dayEntry.date))
|
||||
.limit(1)
|
||||
)[0];
|
||||
return {
|
||||
date,
|
||||
prevDate: previousWerktag(date),
|
||||
nextDate: nw < today ? nw : null,
|
||||
prevDate: prevRow?.d ?? null,
|
||||
nextDate: nextRow?.d ?? null,
|
||||
entries: list,
|
||||
ohneEintrag,
|
||||
};
|
||||
|
||||
@@ -68,9 +68,10 @@ export function HistorieRoute() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDate(prevDate)}
|
||||
aria-label="Vorheriger Werktag"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-ink)] hover:text-[var(--color-ink)]"
|
||||
onClick={() => prevDate && setDate(prevDate)}
|
||||
disabled={!prevDate}
|
||||
aria-label="Vorheriger Tag mit Eintrag"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-ink)] hover:text-[var(--color-ink)] disabled:opacity-40 disabled:hover:border-[var(--color-border)]"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
@@ -79,7 +80,7 @@ export function HistorieRoute() {
|
||||
type="button"
|
||||
onClick={() => nextDate && setDate(nextDate)}
|
||||
disabled={!nextDate}
|
||||
aria-label="Nächster Werktag"
|
||||
aria-label="Nächster Tag mit Eintrag"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-ink)] hover:text-[var(--color-ink)] disabled:opacity-40 disabled:hover:border-[var(--color-border)]"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
@@ -154,21 +155,25 @@ function PersonCard({ person: p }: { person: HistoryPerson }) {
|
||||
: 'bg-[var(--color-border-strong)]',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={clsx(
|
||||
t.status === 'dropped' && 'text-[var(--color-ink-muted)] line-through',
|
||||
)}
|
||||
>
|
||||
{t.title}
|
||||
{t.unplanned && (
|
||||
<span className="ml-2 rounded-full bg-black/5 px-1.5 py-0.5 text-[10px] text-[var(--color-ink-muted)]">
|
||||
ungeplant
|
||||
<div className="flex-1">
|
||||
<span
|
||||
className={clsx(
|
||||
t.status === 'dropped' && 'text-[var(--color-ink-muted)] line-through',
|
||||
)}
|
||||
>
|
||||
{t.title}
|
||||
{t.unplanned && (
|
||||
<span className="ml-2 rounded-full bg-black/5 px-1.5 py-0.5 text-[10px] text-[var(--color-ink-muted)]">
|
||||
ungeplant
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{t.dropReason && (
|
||||
<span className="mt-0.5 block whitespace-pre-wrap text-xs text-[var(--color-ink-muted)]">
|
||||
{t.dropReason}
|
||||
</span>
|
||||
)}
|
||||
{t.status === 'dropped' && t.dropReason && (
|
||||
<span className="text-xs text-[var(--color-ink-faint)]"> — {t.dropReason}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -177,7 +182,17 @@ function PersonCard({ person: p }: { person: HistoryPerson }) {
|
||||
{p.reflectionText && (
|
||||
<p className="whitespace-pre-wrap text-sm text-[var(--color-ink)]">{p.reflectionText}</p>
|
||||
)}
|
||||
{p.todos.length === 0 && !p.reflectionText && (
|
||||
{p.url && (
|
||||
<a
|
||||
href={p.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block truncate text-sm text-[var(--color-ink-muted)] underline hover:text-[var(--color-ink)]"
|
||||
>
|
||||
{p.url}
|
||||
</a>
|
||||
)}
|
||||
{p.todos.length === 0 && !p.reflectionText && !p.url && (
|
||||
<p className="text-xs text-[var(--color-ink-faint)]">Keine Details.</p>
|
||||
)}
|
||||
</Card.Body>
|
||||
|
||||
@@ -51,12 +51,13 @@ function ReflectForm({
|
||||
status: TodoStatus;
|
||||
bezugKind: 'project' | 'deal' | 'campaign' | 'allgemein' | null;
|
||||
unplanned?: boolean;
|
||||
dropReason?: string | null;
|
||||
}>;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [todoStates, setTodoStates] = useState<
|
||||
Record<string, { status: TodoStatus; reason?: string }>
|
||||
>(Object.fromEntries(todos.map((t) => [t.id, { status: t.status }])));
|
||||
>(Object.fromEntries(todos.map((t) => [t.id, { status: t.status, reason: t.dropReason ?? '' }])));
|
||||
const [reflectionText, setReflectionText] = useState('');
|
||||
const [ampel, setAmpel] = useState<Ampel | null>(null);
|
||||
const [trend, setTrend] = useState<Trend | null>(null);
|
||||
@@ -86,6 +87,10 @@ function ReflectForm({
|
||||
setTodoStates((prev) => ({ ...prev, [id]: { ...prev[id], status } }));
|
||||
}
|
||||
|
||||
function setTodoReason(id: string, reason: string) {
|
||||
setTodoStates((prev) => ({ ...prev, [id]: { status: prev[id]?.status ?? 'open', reason } }));
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!ampel) {
|
||||
@@ -107,7 +112,7 @@ function ReflectForm({
|
||||
todos: todos.map((t) => ({
|
||||
id: t.id,
|
||||
status: todoStates[t.id]?.status ?? t.status,
|
||||
dropReason: todoStates[t.id]?.reason,
|
||||
dropReason: todoStates[t.id]?.reason?.trim() || undefined,
|
||||
})),
|
||||
reflectionText,
|
||||
ampel,
|
||||
@@ -138,36 +143,47 @@ function ReflectForm({
|
||||
{todos.map((t) => {
|
||||
const status = todoStates[t.id]?.status ?? t.status;
|
||||
return (
|
||||
<li key={t.id} className="flex items-center gap-2">
|
||||
<EditableTodoTitle id={t.id} title={t.title} />
|
||||
{t.unplanned && (
|
||||
<span className="rounded-full bg-black/5 px-1.5 py-0.5 text-[10px] text-[var(--color-ink-muted)]">
|
||||
ungeplant
|
||||
</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>
|
||||
<li key={t.id} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<EditableTodoTitle id={t.id} title={t.title} />
|
||||
{t.unplanned && (
|
||||
<span className="rounded-full bg-black/5 px-1.5 py-0.5 text-[10px] text-[var(--color-ink-muted)]">
|
||||
ungeplant
|
||||
</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>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={todoStates[t.id]?.reason ?? ''}
|
||||
onChange={(e) => setTodoReason(t.id, e.target.value)}
|
||||
placeholder="Notiz (optional) — Ergebnis, Kontext, Link …"
|
||||
maxLength={500}
|
||||
aria-label={`Notiz zu ${t.title}`}
|
||||
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1 text-xs focus:border-[var(--color-ink)] focus:outline-none"
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -28,7 +28,9 @@ export const submitReflectionInput = z.object({
|
||||
z.object({
|
||||
id: z.string().uuid(),
|
||||
status: todoStatusSchema,
|
||||
dropReason: z.string().max(200).optional(),
|
||||
// Optionale Notiz pro Task (frühere „Verworfen-Grund"-Spalte, jetzt für
|
||||
// jeden Status als freie Beschreibung nutzbar).
|
||||
dropReason: z.string().max(500).optional(),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
|
||||
Reference in new issue
Block a user