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'