UI: Modal-Basis + Confirm-Infrastruktur + Feierabend-Tagesabschluss
Fundament für Paket B (UI/UX-Härtung):
- Modal: barrierearmer Basis-Dialog (role/aria-modal, Fokus-Trap, Escape,
Fokus-Rückgabe, Body-Scroll-Lock, Portal). Basis für alle App-Dialoge.
- ConfirmProvider/useConfirm: imperative Bestaetigung (await confirm({...}))
statt window.confirm(); wird als Naechstes an die Loeschpfade gehaengt.
- Feierabend-Dialog: Tagesabschluss mit Zusammenfassung (erledigt/offen/
verworfen) + Hinweis auf offene/unreflektierte ToDos und fehlende Reflexion.
Check-Out feuert erst nach Bestaetigung; bleibt reversibel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7285ed9cae
commit
00b611fa3b
4 files changed
+325
-29
No files matched your search
@@ -1,12 +1,15 @@
|
||||
import { Toaster } from 'sonner';
|
||||
import { TrpcProvider } from './lib/trpc-provider';
|
||||
import { Router } from './lib/router';
|
||||
import { ConfirmProvider } from './components/ui/ConfirmProvider';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<TrpcProvider>
|
||||
<Router />
|
||||
<Toaster position="bottom-right" richColors={false} />
|
||||
<ConfirmProvider>
|
||||
<Router />
|
||||
<Toaster position="bottom-right" richColors={false} />
|
||||
</ConfirmProvider>
|
||||
</TrpcProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import { Button } from './Button';
|
||||
|
||||
export type ConfirmOptions = {
|
||||
title: string;
|
||||
message?: ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
destructive?: boolean;
|
||||
};
|
||||
|
||||
type ConfirmFn = (opts: ConfirmOptions) => Promise<boolean>;
|
||||
|
||||
const ConfirmContext = createContext<ConfirmFn | null>(null);
|
||||
|
||||
/**
|
||||
* Imperative Bestätigung: `const confirm = useConfirm(); if (await confirm({...})) …`.
|
||||
* Ersetzt window.confirm() durch einen gestylten, barrierearmen Dialog.
|
||||
*/
|
||||
export function useConfirm(): ConfirmFn {
|
||||
const ctx = useContext(ConfirmContext);
|
||||
if (!ctx) throw new Error('useConfirm muss innerhalb von <ConfirmProvider> stehen.');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
const [opts, setOpts] = useState<ConfirmOptions | null>(null);
|
||||
const resolver = useRef<((v: boolean) => void) | null>(null);
|
||||
|
||||
const confirm = useCallback<ConfirmFn>((options) => {
|
||||
setOpts(options);
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolver.current = resolve;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const settle = useCallback((result: boolean) => {
|
||||
resolver.current?.(result);
|
||||
resolver.current = null;
|
||||
setOpts(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={confirm}>
|
||||
{children}
|
||||
<Modal open={opts !== null} onClose={() => settle(false)} title={opts?.title} size="sm">
|
||||
{opts && (
|
||||
<>
|
||||
{opts.message != null && (
|
||||
<div className="mt-2 text-sm text-[var(--color-ink-muted)]">{opts.message}</div>
|
||||
)}
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => settle(false)}>
|
||||
{opts.cancelLabel ?? 'Abbrechen'}
|
||||
</Button>
|
||||
<Button
|
||||
variant={opts.destructive ? 'destructive' : 'primary'}
|
||||
onClick={() => settle(true)}
|
||||
>
|
||||
{opts.confirmLabel ?? 'OK'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useId, useRef, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
type Size = 'sm' | 'md' | 'lg';
|
||||
|
||||
const maxWidth: Record<Size, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-2xl',
|
||||
};
|
||||
|
||||
/**
|
||||
* Barrierearmer Basis-Dialog: role=dialog/aria-modal, Fokus in den Dialog beim
|
||||
* Öffnen, Fokus-Trap (Tab zyklisch), Escape zum Schließen, Fokus-Rückgabe ans
|
||||
* vorher fokussierte Element, Body-Scroll-Lock. Klick aufs Overlay schließt.
|
||||
* Alle App-Dialoge sollen hierauf aufsetzen.
|
||||
*/
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
size = 'md',
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: ReactNode;
|
||||
children: ReactNode;
|
||||
size?: Size;
|
||||
}) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const restoreFocus = useRef<HTMLElement | null>(null);
|
||||
const titleId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
restoreFocus.current = document.activeElement as HTMLElement | null;
|
||||
|
||||
const panel = panelRef.current;
|
||||
const focusableSel =
|
||||
'input:not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
|
||||
const first = panel?.querySelector<HTMLElement>(focusableSel);
|
||||
(first ?? panel)?.focus();
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Tab' || !panel) return;
|
||||
const nodes = Array.from(panel.querySelectorAll<HTMLElement>(focusableSel));
|
||||
if (nodes.length === 0) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const firstEl = nodes[0]!;
|
||||
const lastEl = nodes[nodes.length - 1]!;
|
||||
if (e.shiftKey && document.activeElement === firstEl) {
|
||||
e.preventDefault();
|
||||
lastEl.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === lastEl) {
|
||||
e.preventDefault();
|
||||
firstEl.focus();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
restoreFocus.current?.focus?.();
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
// Overlay-Klick schließt (reiner Maus-Komfort); Tastatur/Screenreader nutzen
|
||||
// Escape und den Abbrechen-Button — daher ist die A11y-Regel hier ein false-positive.
|
||||
// Overlay-Klick (nur direkt aufs Overlay, nicht aufs Panel) schließt — reiner
|
||||
// Maus-Komfort; Tastatur/Screenreader nutzen Escape und den Abbrechen-Button.
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title != null ? titleId : undefined}
|
||||
tabIndex={-1}
|
||||
className={`w-full ${maxWidth[size]} rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)] focus:outline-none`}
|
||||
>
|
||||
{title != null && (
|
||||
<h2 id={titleId} className="text-xl font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
+142
-27
@@ -4,6 +4,7 @@ import { trpc } from '../lib/trpc';
|
||||
import { voice } from '../lib/voice';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
import { SkeletonCard } from '../components/ui/SkeletonCard';
|
||||
import { toast } from 'sonner';
|
||||
import { X, Plus, RotateCw, BellRing, Play, Clock, Check, LogOut } from 'lucide-react';
|
||||
@@ -320,13 +321,19 @@ function AddTodoInline() {
|
||||
function CheckInOutBar({
|
||||
checkedInAt,
|
||||
checkedOutAt,
|
||||
todos,
|
||||
reflected,
|
||||
}: {
|
||||
checkedInAt: string | Date | null;
|
||||
checkedOutAt: string | Date | null;
|
||||
todos: Array<{ id: string; title: string; status: 'open' | 'done' | 'dropped' }>;
|
||||
reflected: boolean;
|
||||
}) {
|
||||
const utils = trpc.useUtils();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const checkOut = trpc.dayEntry.checkOut.useMutation({
|
||||
onSuccess: async () => {
|
||||
setConfirmOpen(false);
|
||||
toast('Feierabend — Check-Out gespeichert.');
|
||||
await utils.dayEntry.today.invalidate();
|
||||
await utils.dashboard.overview.invalidate();
|
||||
@@ -356,38 +363,141 @@ function CheckInOutBar({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-[var(--radius-md)] border border-[var(--color-border)] px-4 py-2 text-sm">
|
||||
<span>
|
||||
Arbeitsantritt <span className="font-mono">{hhmm(checkedInAt)}</span>
|
||||
</span>
|
||||
{checkedOutAt ? (
|
||||
<>
|
||||
<span className="text-[var(--color-ink-muted)]">
|
||||
· Feierabend <span className="font-mono">{hhmm(checkedOutAt)}</span>
|
||||
{duration && ` · ${duration}`}
|
||||
</span>
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-[var(--radius-md)] border border-[var(--color-border)] px-4 py-2 text-sm">
|
||||
<span>
|
||||
Arbeitsantritt <span className="font-mono">{hhmm(checkedInAt)}</span>
|
||||
</span>
|
||||
{checkedOutAt ? (
|
||||
<>
|
||||
<span className="text-[var(--color-ink-muted)]">
|
||||
· Feierabend <span className="font-mono">{hhmm(checkedOutAt)}</span>
|
||||
{duration && ` · ${duration}`}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="ml-auto"
|
||||
disabled={reopen.isPending}
|
||||
onClick={() => reopen.mutate()}
|
||||
>
|
||||
<Play size={14} /> Arbeit fortsetzen
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
variant="secondary"
|
||||
className="ml-auto"
|
||||
disabled={reopen.isPending}
|
||||
onClick={() => reopen.mutate()}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
>
|
||||
<Play size={14} /> Arbeit fortsetzen
|
||||
<LogOut size={14} /> Feierabend / Check-Out
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="ml-auto"
|
||||
disabled={checkOut.isPending}
|
||||
onClick={() => checkOut.mutate()}
|
||||
>
|
||||
<LogOut size={14} /> Feierabend / Check-Out
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<FeierabendDialog
|
||||
open={confirmOpen}
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
todos={todos}
|
||||
reflected={reflected}
|
||||
pending={checkOut.isPending}
|
||||
onConfirm={() => checkOut.mutate()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tagesabschluss vor dem Feierabend: kurze Zusammenfassung (erledigt/offen/
|
||||
* verworfen) und ein Hinweis, falls noch ToDos offen oder die Reflexion fehlt.
|
||||
* Feierabend ist reversibel ("Arbeit fortsetzen") — der Dialog ist daher Info,
|
||||
* keine harte Sperre.
|
||||
*/
|
||||
function FeierabendDialog({
|
||||
open,
|
||||
onClose,
|
||||
todos,
|
||||
reflected,
|
||||
pending,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
todos: Array<{ id: string; title: string; status: 'open' | 'done' | 'dropped' }>;
|
||||
reflected: boolean;
|
||||
pending: boolean;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const done = todos.filter((t) => t.status === 'done');
|
||||
const dropped = todos.filter((t) => t.status === 'dropped');
|
||||
const offen = todos.filter((t) => t.status === 'open');
|
||||
const reflexionEmpfohlen = !reflected || offen.length > 0;
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Feierabend?" size="md">
|
||||
<p className="mt-1 text-sm text-[var(--color-ink-muted)]">
|
||||
Kurzer Blick auf den Tag, bevor du abschließt.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-x-4 gap-y-1 text-sm">
|
||||
<span>
|
||||
<strong>{done.length}</strong> erledigt
|
||||
</span>
|
||||
<span>
|
||||
<strong>{offen.length}</strong> offen
|
||||
</span>
|
||||
{dropped.length > 0 && (
|
||||
<span>
|
||||
<strong>{dropped.length}</strong> verworfen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{done.length > 0 && (
|
||||
<ul className="mt-3 space-y-1 text-sm">
|
||||
{done.map((t) => (
|
||||
<li key={t.id} className="flex items-center gap-2 text-[var(--color-ink-muted)]">
|
||||
<Check size={14} className="shrink-0 text-[var(--color-ampel-green-fg)]" />
|
||||
<span className="truncate">{t.title}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{offen.length > 0 && (
|
||||
<div className="mt-4 rounded-[var(--radius-md)] border border-[var(--color-ampel-amber-fg)] bg-[var(--color-ampel-amber-bg)] px-3 py-2 text-sm">
|
||||
<strong className="block">Noch offen — nicht reflektiert:</strong>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{offen.map((t) => (
|
||||
<li key={t.id} className="truncate">
|
||||
· {t.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!reflected && (
|
||||
<p className="mt-3 text-sm text-[var(--color-ink-muted)]">
|
||||
Du hast heute noch nicht reflektiert.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex flex-wrap justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
{reflexionEmpfohlen && (
|
||||
<Button variant="secondary" onClick={() => navigate('/reflexion')}>
|
||||
Zur Reflexion
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="primary" disabled={pending} onClick={onConfirm}>
|
||||
Tag abschließen
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -455,7 +565,12 @@ function EinstempeltView({
|
||||
<p className="mt-1 text-[var(--color-ink-muted)]">{subtitle}</p>
|
||||
</header>
|
||||
|
||||
<CheckInOutBar checkedInAt={entry.checkedInAt} checkedOutAt={entry.checkedOutAt} />
|
||||
<CheckInOutBar
|
||||
checkedInAt={entry.checkedInAt}
|
||||
checkedOutAt={entry.checkedOutAt}
|
||||
todos={todos}
|
||||
reflected={entry.status === 'full'}
|
||||
/>
|
||||
|
||||
{reflectionDue && (
|
||||
<div
|
||||
|
||||
Reference in new issue
Block a user