feat: F025 button & action logic audit — global toast, confirm dialog, wired mutations

- Add ToastProvider (global MUI Snackbar stack) + toastStore (Zustand)
- Add ConfirmDialog reusable component for destructive actions
- Wire toast feedback to all async mutations: review status, AI output status,
  match approval, shortlist add/remove/title/finalize, signal review actions
- Fix dead UserMenu buttons (Profil, Einstellungen) — show info toast
- Migrate local Snackbar instances in AddToShortlistDialog and
  FutureSignalDetailPanel to global toast store

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-17 13:42:30 +02:00
parent 30e840489d
commit 2da62a4861
14 changed files with 226 additions and 38 deletions
@@ -1,4 +1,4 @@
import { Box, Button, Chip, CircularProgress, Divider, IconButton, LinearProgress, Paper, Snackbar, Typography } from '@mui/material'
import { Box, Button, Chip, CircularProgress, Divider, IconButton, LinearProgress, Paper, Typography } from '@mui/material'
import { X } from 'lucide-react'
import { useState } from 'react'
import { SignalTypeBadge } from './SignalTypeBadge'
@@ -7,6 +7,7 @@ import { SignalReviewStatusBadge } from './SignalReviewStatusBadge'
import { FutureSignalDisclaimer } from './FutureSignalDisclaimer'
import { useUpdateSignalReviewStatus } from '../../hooks/useFutureSignals'
import { useShortlistStore } from '../../stores/shortlistStore'
import { useToastStore } from '../../stores/toastStore'
import { reviewService } from '../../services/reviewService'
import { ReviewStatus } from '../../domain/enums'
import type { FutureSignal } from '../../domain/futureSignal'
@@ -51,22 +52,37 @@ interface Props {
export function FutureSignalDetailPanel({ signal, onClose }: Props) {
const updateStatus = useUpdateSignalReviewStatus()
const { openAddDialog } = useShortlistStore()
const [snackbar, setSnackbar] = useState<string | null>(null)
const showToast = useToastStore((s) => s.showToast)
const [reviewTaskSent, setReviewTaskSent] = useState(false)
const reviewStatus = signal.reviewStatus ?? ReviewStatus.UNREVIEWED
const isRejected = reviewStatus === ReviewStatus.REJECTED
const isApproved = reviewStatus === ReviewStatus.APPROVED
const STATUS_TOAST: Record<string, string> = {
IN_REVIEW: 'Signal zur Prüfung markiert.',
APPROVED: 'Signal genehmigt.',
REJECTED: 'Signal abgelehnt.',
FLAGGED: 'Signal markiert.',
}
async function handleStatus(status: typeof ReviewStatus[keyof typeof ReviewStatus]) {
await updateStatus.mutateAsync({ id: signal.id, status })
setSnackbar(`Status aktualisiert: ${status}`)
try {
await updateStatus.mutateAsync({ id: signal.id, status })
showToast(STATUS_TOAST[status] ?? 'Status aktualisiert.')
} catch {
showToast('Statusänderung fehlgeschlagen.', 'error')
}
}
async function handleSendReview() {
await reviewService.createReviewTask(signal.id)
setReviewTaskSent(true)
setSnackbar('Prüfungsaufgabe erstellt')
try {
await reviewService.createReviewTask(signal.id)
setReviewTaskSent(true)
showToast('Prüfungsaufgabe erstellt.')
} catch {
showToast('Prüfungsaufgabe konnte nicht erstellt werden.', 'error')
}
}
function handleShortlist() {
@@ -242,13 +258,6 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) {
</Box>
</Box>
<Snackbar
open={!!snackbar}
autoHideDuration={2500}
onClose={() => setSnackbar(null)}
message={snackbar}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
/>
</Box>
)
}
+2
View File
@@ -41,6 +41,7 @@ import { RightContextPanel } from './RightContextPanel'
import { CompareTray } from './CompareTray'
import { GlobalAIAssistantDrawer, GlobalAIAssistantButton } from '../assistant'
import { useAssistantStore } from '../../stores/assistantStore'
import { ToastProvider } from '../ui'
// ---------------------------------------------------------------------------
// Types
@@ -546,6 +547,7 @@ export function AppShell() {
<CompareTray />
<GlobalAIAssistantDrawer />
<GlobalAIAssistantButton />
<ToastProvider />
</Box>
)
}
+4 -2
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router'
import { Avatar, Box, Divider, IconButton, ListItemIcon, Menu, MenuItem, Typography } from '@mui/material'
import { LogOut, Settings, User } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore'
import { useToastStore } from '../../stores/toastStore'
import { DemoRoleSwitcher } from '../auth/DemoRoleSwitcher'
const ROLE_LABELS: Record<string, string> = {
@@ -27,6 +28,7 @@ export function UserMenu() {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const { currentUser, logout } = useSessionStore()
const navigate = useNavigate()
const showToast = useToastStore((s) => s.showToast)
function handleOpen(e: React.MouseEvent<HTMLElement>) {
setAnchorEl(e.currentTarget)
@@ -75,11 +77,11 @@ export function UserMenu() {
<Divider />
<MenuItem onClick={handleClose}>
<MenuItem onClick={() => { handleClose(); showToast('Profilseite ist in Kürze verfügbar.', 'info') }}>
<ListItemIcon><User size={16} /></ListItemIcon>
Profil
</MenuItem>
<MenuItem onClick={handleClose}>
<MenuItem onClick={() => { handleClose(); showToast('Einstellungen sind in Kürze verfügbar.', 'info') }}>
<ListItemIcon><Settings size={16} /></ListItemIcon>
Einstellungen
</MenuItem>
@@ -4,6 +4,7 @@ import { useMatchesByProperty, useApproveMatch } from '../../hooks/useMatches'
import { usePropertyById } from '../../hooks/useProperties'
import { useMatchCenterStore } from '../../stores/matchCenterStore'
import { useCompareStore } from '../../stores/compareStore'
import { useToastStore } from '../../stores/toastStore'
import { MatchCardExpanded } from '../match-card/MatchCardExpanded'
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
import { reviewService } from '../../services/reviewService'
@@ -17,6 +18,7 @@ export function MatchBriefingPanel() {
const { selectedPropertyId, selectedNeedId } = useMatchCenterStore()
const { addToCompare } = useCompareStore()
const approveMatch = useApproveMatch()
const showToast = useToastStore((s) => s.showToast)
const { data: matches = [], isLoading: matchesLoading } = useMatchesByProperty(selectedPropertyId ?? '')
const { data: property = null, isLoading: propLoading } = usePropertyById(selectedPropertyId)
@@ -52,7 +54,10 @@ export function MatchBriefingPanel() {
label: 'Genehmigen',
actionType: 'APPROVE',
variant: 'primary',
onClick: () => approveMatch.mutate(match.id),
onClick: () => approveMatch.mutate(match.id, {
onSuccess: () => showToast('Match genehmigt.'),
onError: () => showToast('Genehmigung fehlgeschlagen.', 'error'),
}),
},
{
id: 'review',
@@ -10,7 +10,6 @@ import {
FormControlLabel,
Radio,
RadioGroup,
Snackbar,
TextField,
Typography,
} from '@mui/material'
@@ -18,6 +17,7 @@ import { Plus } from 'lucide-react'
import { ShortlistStatusBadge } from './ShortlistStatusBadge'
import { useShortlists, useCreateShortlist, useAddToShortlist } from '../../hooks/useShortlists'
import { useShortlistStore } from '../../stores/shortlistStore'
import { useToastStore } from '../../stores/toastStore'
import { ShortlistStatus } from '../../domain/enums'
export function AddToShortlistDialog() {
@@ -26,11 +26,11 @@ export function AddToShortlistDialog() {
const createShortlist = useCreateShortlist()
const addToShortlist = useAddToShortlist()
const showToast = useToastStore((s) => s.showToast)
const [selectedId, setSelectedId] = useState<string>('')
const [creatingNew, setCreatingNew] = useState(false)
const [newTitle, setNewTitle] = useState('')
const [note, setNote] = useState('')
const [snackbar, setSnackbar] = useState<string | null>(null)
function handleClose() {
closeAddDialog()
@@ -65,12 +65,12 @@ export function AddToShortlistDialog() {
await addToShortlist.mutateAsync({ shortlistId: targetId, item: itemWithNote })
if (alreadyExists) {
setSnackbar('Bereits in dieser Shortlist vorhanden')
} else {
setSnackbar('Zur Shortlist hinzugefügt')
}
handleClose()
if (alreadyExists) {
showToast('Bereits in dieser Shortlist vorhanden.', 'warning')
} else {
showToast('Zur Shortlist hinzugefügt.')
}
}
const isBusy = createShortlist.isPending || addToShortlist.isPending
@@ -161,14 +161,6 @@ export function AddToShortlistDialog() {
</Button>
</DialogActions>
</Dialog>
<Snackbar
open={!!snackbar}
autoHideDuration={3000}
onClose={() => setSnackbar(null)}
message={snackbar}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
/>
</>
)
}
+23 -3
View File
@@ -7,6 +7,7 @@ import { ShortlistItemCard } from './ShortlistItemCard'
import { ShortlistEmptyState } from './ShortlistEmptyState'
import { useUpdateShortlist } from '../../hooks/useShortlists'
import { useCompareStore } from '../../stores/compareStore'
import { useToastStore } from '../../stores/toastStore'
import { ShortlistStatus } from '../../domain/enums'
import type { Shortlist } from '../../domain/shortlist'
@@ -18,6 +19,7 @@ export function ShortlistDetail({ shortlist }: Props) {
const navigate = useNavigate()
const updateShortlist = useUpdateShortlist()
const { addToCompare, compareItems } = useCompareStore()
const showToast = useToastStore((s) => s.showToast)
const [editingTitle, setEditingTitle] = useState(false)
const [titleDraft, setTitleDraft] = useState(shortlist.title)
@@ -28,17 +30,35 @@ export function ShortlistDetail({ shortlist }: Props) {
function handleTitleSave() {
if (titleDraft.trim() && titleDraft.trim() !== shortlist.title) {
updateShortlist.mutate({ id: shortlist.id, data: { title: titleDraft.trim() } })
updateShortlist.mutate(
{ id: shortlist.id, data: { title: titleDraft.trim() } },
{
onSuccess: () => showToast('Titel gespeichert.'),
onError: () => showToast('Titel konnte nicht gespeichert werden.', 'error'),
}
)
}
setEditingTitle(false)
}
function handleMarkReviewReady() {
updateShortlist.mutate({ id: shortlist.id, data: { status: ShortlistStatus.REVIEW_READY } })
updateShortlist.mutate(
{ id: shortlist.id, data: { status: ShortlistStatus.REVIEW_READY } },
{
onSuccess: () => showToast('Shortlist als prüfbereit markiert.'),
onError: () => showToast('Statusänderung fehlgeschlagen.', 'error'),
}
)
}
function handleFinalize() {
updateShortlist.mutate({ id: shortlist.id, data: { status: ShortlistStatus.FINALIZED } })
updateShortlist.mutate(
{ id: shortlist.id, data: { status: ShortlistStatus.FINALIZED } },
{
onSuccess: () => showToast('Shortlist finalisiert.'),
onError: () => showToast('Finalisierung fehlgeschlagen.', 'error'),
}
)
}
function handleOpenCompare() {
@@ -1,6 +1,7 @@
import { Box, Chip, IconButton, Typography } from '@mui/material'
import { X } from 'lucide-react'
import { useRemoveFromShortlist } from '../../hooks/useShortlists'
import { useToastStore } from '../../stores/toastStore'
import type { ShortlistItem } from '../../domain/shortlist'
const RESULT_TYPE_LABEL: Record<string, string> = {
@@ -19,6 +20,7 @@ interface Props {
export function ShortlistItemCard({ item, shortlistId, isFinalized }: Props) {
const removeItem = useRemoveFromShortlist()
const showToast = useToastStore((s) => s.showToast)
const addedDate = new Date(item.addedAt).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
@@ -58,7 +60,13 @@ export function ShortlistItemCard({ item, shortlistId, isFinalized }: Props) {
<IconButton
size="small"
disabled={removeItem.isPending}
onClick={() => removeItem.mutate({ shortlistId, resultId: item.resultId })}
onClick={() => removeItem.mutate(
{ shortlistId, resultId: item.resultId },
{
onSuccess: () => showToast('Objekt aus Shortlist entfernt.'),
onError: () => showToast('Entfernen fehlgeschlagen.', 'error'),
}
)}
sx={{ color: '#94a3b8', '&:hover': { color: '#c0392b' }, flexShrink: 0 }}
>
<X size={14} />
+52
View File
@@ -0,0 +1,52 @@
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from '@mui/material'
import { AlertTriangle } from 'lucide-react'
interface Props {
open: boolean
title: string
message: string
confirmLabel?: string
cancelLabel?: string
destructive?: boolean
onConfirm: () => void
onCancel: () => void
}
export function ConfirmDialog({
open,
title,
message,
confirmLabel = 'Bestätigen',
cancelLabel = 'Abbrechen',
destructive = false,
onConfirm,
onCancel,
}: Props) {
return (
<Dialog open={open} onClose={onCancel} maxWidth="xs" fullWidth>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{destructive && <AlertTriangle size={18} color="#c0392b" />}
{title}
</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary">
{message}
</Typography>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button variant="outlined" size="small" onClick={onCancel}>
{cancelLabel}
</Button>
<Button
variant="contained"
size="small"
color={destructive ? 'error' : 'primary'}
onClick={onConfirm}
autoFocus
>
{confirmLabel}
</Button>
</DialogActions>
</Dialog>
)
}
+33
View File
@@ -0,0 +1,33 @@
import { Alert, Snackbar, Stack } from '@mui/material'
import { useToastStore } from '../../stores/toastStore'
export function ToastProvider() {
const { toasts, dismissToast } = useToastStore()
return (
<Stack
spacing={1}
sx={{ position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)', zIndex: 9999, alignItems: 'center' }}
>
{toasts.map((toast) => (
<Snackbar
key={toast.id}
open
autoHideDuration={toast.duration ?? 4000}
onClose={() => dismissToast(toast.id)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
sx={{ position: 'relative', transform: 'none', left: 'auto', bottom: 'auto' }}
>
<Alert
onClose={() => dismissToast(toast.id)}
severity={toast.severity}
variant="filled"
sx={{ minWidth: 320, boxShadow: 3 }}
>
{toast.message}
</Alert>
</Snackbar>
))}
</Stack>
)
}
+2
View File
@@ -8,3 +8,5 @@ export { CardSkeleton } from './CardSkeleton'
export { PanelLoadingState } from './PanelLoadingState'
export { UnauthorizedState } from './UnauthorizedState'
export { RestrictedState } from './RestrictedState'
export { ToastProvider } from './ToastProvider'
export { ConfirmDialog } from './ConfirmDialog'
+17 -1
View File
@@ -8,6 +8,7 @@ import {
AIMonitoringEmptyState,
} from '../../components/ai-monitoring'
import { useAIOutputs, useUpdateAIOutputReviewStatus } from '../../hooks/useAIMonitoring'
import { useToastStore } from '../../stores/toastStore'
import type { AIOutput, AIOutputType } from '../../domain/aiOutput'
import type { ReviewStatus } from '../../domain/enums'
@@ -58,6 +59,7 @@ export default function AIMonitoring() {
const [filters, setFilters] = useState<Filters>({ type: '', reviewStatus: '', hasError: null })
const [selectedOutput, setSelectedOutput] = useState<AIOutput | null>(null)
const showToast = useToastStore((s) => s.showToast)
const { data: allOutputs = [], isLoading } = useAIOutputs()
const updateStatus = useUpdateAIOutputReviewStatus()
@@ -66,11 +68,25 @@ export default function AIMonitoring() {
const failedCount = allOutputs.filter(o => !!o.error).length
const pendingCount = allOutputs.filter(o => o.reviewStatus === 'UNREVIEWED' || o.reviewStatus === 'FLAGGED').length
const STATUS_TOAST: Record<ReviewStatus, string> = {
UNREVIEWED: 'Status zurückgesetzt.',
IN_REVIEW: 'Output zur Prüfung markiert.',
APPROVED: 'Output genehmigt.',
REJECTED: 'Output abgelehnt.',
FLAGGED: 'Output markiert.',
}
const handleUpdateStatus = (status: ReviewStatus) => {
if (!selectedOutput) return
updateStatus.mutate(
{ id: selectedOutput.id, status },
{ onSuccess: (res) => setSelectedOutput(res.data) }
{
onSuccess: (res) => {
setSelectedOutput(res.data)
showToast(STATUS_TOAST[status] ?? 'Status aktualisiert.')
},
onError: () => showToast('Statusänderung fehlgeschlagen.', 'error'),
}
)
}
+15
View File
@@ -9,6 +9,7 @@ import {
} from '../../components/review'
import { useReviewQueue, useUpdateReviewStatus, useAddReviewNote } from '../../hooks/useReviewQueue'
import { useSessionStore } from '../../stores/sessionStore'
import { useToastStore } from '../../stores/toastStore'
import type { ReviewTask, ReviewTaskStatus } from '../../domain/review'
import type { ReviewFilters } from '../../provider/IReviewProvider'
@@ -24,6 +25,7 @@ function filterTasks(tasks: ReviewTask[], filters: ReviewFilters): ReviewTask[]
export default function ReviewQueue() {
const { currentUser } = useSessionStore()
const showToast = useToastStore((s) => s.showToast)
const userRole = currentUser?.role ?? 'REVIEWER'
const [filters, setFilters] = useState<ReviewFilters>({})
@@ -40,6 +42,15 @@ export default function ReviewQueue() {
const escalatedCount = allTasks.filter(t => t.status === 'ESCALATED').length
const criticalCount = allTasks.filter(t => t.priority === 'CRITICAL' && (t.status === 'PENDING' || t.status === 'IN_REVIEW')).length
const STATUS_TOAST: Record<ReviewTaskStatus, string> = {
PENDING: 'Status auf "Ausstehend" gesetzt.',
IN_REVIEW: 'Aufgabe zur Prüfung übernommen.',
APPROVED: 'Aufgabe genehmigt.',
REJECTED: 'Aufgabe abgelehnt.',
ESCALATED: 'Aufgabe eskaliert.',
NEEDS_MORE_DATA: 'Weitere Daten angefordert.',
}
const handleAction = (status: ReviewTaskStatus) => {
if (!selectedTask) return
updateStatus.mutate(
@@ -47,7 +58,9 @@ export default function ReviewQueue() {
{
onSuccess: (res) => {
setSelectedTask(res.data)
showToast(STATUS_TOAST[status] ?? 'Status aktualisiert.')
},
onError: () => showToast('Statusänderung fehlgeschlagen.', 'error'),
}
)
}
@@ -59,7 +72,9 @@ export default function ReviewQueue() {
{
onSuccess: (res) => {
setSelectedTask(res.data)
showToast('Notiz hinzugefügt.')
},
onError: () => showToast('Notiz konnte nicht gespeichert werden.', 'error'),
}
)
}
+6 -1
View File
@@ -13,6 +13,7 @@ import { useMatches, useApproveMatch } from '../../hooks/useMatches'
import { useProperties } from '../../hooks/useProperties'
import { useNeeds } from '../../hooks/useNeeds'
import { useMatchCenterStore } from '../../stores/matchCenterStore'
import { useToastStore } from '../../stores/toastStore'
import { MatchListCard, MatchBriefingPanel, MatchCenterSkeleton } from '../../components/match-center'
import type { Match } from '../../domain/match'
@@ -36,6 +37,7 @@ export default function MatchCenter() {
const { data: needs = [] } = useNeeds()
const { setSelectedProperty, setSelectedNeed } = useMatchCenterStore()
const approveMatch = useApproveMatch()
const showToast = useToastStore((s) => s.showToast)
const [selectedMatchId, setSelectedMatchId] = useState<string | null>(null)
const [filterStrength, setFilterStrength] = useState('')
@@ -152,7 +154,10 @@ export default function MatchCenter() {
property={propMap.get(match.propertyId)}
need={needMap.get(match.needId)}
onSelect={() => handleSelectMatch(match)}
onApprove={() => approveMatch.mutate(match.id)}
onApprove={() => approveMatch.mutate(match.id, {
onSuccess: () => showToast('Match genehmigt.'),
onError: () => showToast('Genehmigung fehlgeschlagen.', 'error'),
})}
/>
))}
</Box>
+27
View File
@@ -0,0 +1,27 @@
import { create } from 'zustand'
export type ToastSeverity = 'success' | 'error' | 'warning' | 'info'
export interface ToastMessage {
id: string
message: string
severity: ToastSeverity
duration?: number
}
interface ToastState {
toasts: ToastMessage[]
showToast: (message: string, severity?: ToastSeverity, duration?: number) => void
dismissToast: (id: string) => void
}
export const useToastStore = create<ToastState>((set) => ({
toasts: [],
showToast: (message, severity = 'success', duration = 4000) => {
const id = crypto.randomUUID()
set((s) => ({ toasts: [...s.toasts, { id, message, severity, duration }] }))
},
dismissToast: (id) => {
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }))
},
}))