diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c98e25a..500de52 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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 ( - - + + + + ); } diff --git a/apps/web/src/components/ui/ConfirmProvider.tsx b/apps/web/src/components/ui/ConfirmProvider.tsx new file mode 100644 index 0000000..f528463 --- /dev/null +++ b/apps/web/src/components/ui/ConfirmProvider.tsx @@ -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; + +const ConfirmContext = createContext(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 stehen.'); + return ctx; +} + +export function ConfirmProvider({ children }: { children: ReactNode }) { + const [opts, setOpts] = useState(null); + const resolver = useRef<((v: boolean) => void) | null>(null); + + const confirm = useCallback((options) => { + setOpts(options); + return new Promise((resolve) => { + resolver.current = resolve; + }); + }, []); + + const settle = useCallback((result: boolean) => { + resolver.current?.(result); + resolver.current = null; + setOpts(null); + }, []); + + return ( + + {children} + settle(false)} title={opts?.title} size="sm"> + {opts && ( + <> + {opts.message != null && ( +
{opts.message}
+ )} +
+ + +
+ + )} +
+
+ ); +} diff --git a/apps/web/src/components/ui/Modal.tsx b/apps/web/src/components/ui/Modal.tsx new file mode 100644 index 0000000..c5fb592 --- /dev/null +++ b/apps/web/src/components/ui/Modal.tsx @@ -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 = { + 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(null); + const restoreFocus = useRef(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(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(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. +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {title != null && ( +

+ {title} +

+ )} + {children} +
+
, + document.body, + ); +} diff --git a/apps/web/src/routes/heute.tsx b/apps/web/src/routes/heute.tsx index 8635fc3..ce2c151 100644 --- a/apps/web/src/routes/heute.tsx +++ b/apps/web/src/routes/heute.tsx @@ -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 ( -
- - Arbeitsantritt {hhmm(checkedInAt)} - - {checkedOutAt ? ( - <> - - · Feierabend {hhmm(checkedOutAt)} - {duration && ` · ${duration}`} - + <> +
+ + Arbeitsantritt {hhmm(checkedInAt)} + + {checkedOutAt ? ( + <> + + · Feierabend {hhmm(checkedOutAt)} + {duration && ` · ${duration}`} + + + + ) : ( - - ) : ( - + )} +
+ 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 ( + +

+ Kurzer Blick auf den Tag, bevor du abschließt. +

+ +
+ + {done.length} erledigt + + + {offen.length} offen + + {dropped.length > 0 && ( + + {dropped.length} verworfen + + )} +
+ + {done.length > 0 && ( +
    + {done.map((t) => ( +
  • + + {t.title} +
  • + ))} +
)} -
+ + {offen.length > 0 && ( +
+ Noch offen — nicht reflektiert: +
    + {offen.map((t) => ( +
  • + · {t.title} +
  • + ))} +
+
+ )} + + {!reflected && ( +

+ Du hast heute noch nicht reflektiert. +

+ )} + +
+ + {reflexionEmpfohlen && ( + + )} + +
+ ); } @@ -455,7 +565,12 @@ function EinstempeltView({

{subtitle}

- + {reflectionDue && (