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:
@@ -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 { X } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { SignalTypeBadge } from './SignalTypeBadge'
|
import { SignalTypeBadge } from './SignalTypeBadge'
|
||||||
@@ -7,6 +7,7 @@ import { SignalReviewStatusBadge } from './SignalReviewStatusBadge'
|
|||||||
import { FutureSignalDisclaimer } from './FutureSignalDisclaimer'
|
import { FutureSignalDisclaimer } from './FutureSignalDisclaimer'
|
||||||
import { useUpdateSignalReviewStatus } from '../../hooks/useFutureSignals'
|
import { useUpdateSignalReviewStatus } from '../../hooks/useFutureSignals'
|
||||||
import { useShortlistStore } from '../../stores/shortlistStore'
|
import { useShortlistStore } from '../../stores/shortlistStore'
|
||||||
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
import { reviewService } from '../../services/reviewService'
|
import { reviewService } from '../../services/reviewService'
|
||||||
import { ReviewStatus } from '../../domain/enums'
|
import { ReviewStatus } from '../../domain/enums'
|
||||||
import type { FutureSignal } from '../../domain/futureSignal'
|
import type { FutureSignal } from '../../domain/futureSignal'
|
||||||
@@ -51,22 +52,37 @@ interface Props {
|
|||||||
export function FutureSignalDetailPanel({ signal, onClose }: Props) {
|
export function FutureSignalDetailPanel({ signal, onClose }: Props) {
|
||||||
const updateStatus = useUpdateSignalReviewStatus()
|
const updateStatus = useUpdateSignalReviewStatus()
|
||||||
const { openAddDialog } = useShortlistStore()
|
const { openAddDialog } = useShortlistStore()
|
||||||
const [snackbar, setSnackbar] = useState<string | null>(null)
|
const showToast = useToastStore((s) => s.showToast)
|
||||||
const [reviewTaskSent, setReviewTaskSent] = useState(false)
|
const [reviewTaskSent, setReviewTaskSent] = useState(false)
|
||||||
|
|
||||||
const reviewStatus = signal.reviewStatus ?? ReviewStatus.UNREVIEWED
|
const reviewStatus = signal.reviewStatus ?? ReviewStatus.UNREVIEWED
|
||||||
const isRejected = reviewStatus === ReviewStatus.REJECTED
|
const isRejected = reviewStatus === ReviewStatus.REJECTED
|
||||||
const isApproved = reviewStatus === ReviewStatus.APPROVED
|
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]) {
|
async function handleStatus(status: typeof ReviewStatus[keyof typeof ReviewStatus]) {
|
||||||
await updateStatus.mutateAsync({ id: signal.id, status })
|
try {
|
||||||
setSnackbar(`Status aktualisiert: ${status}`)
|
await updateStatus.mutateAsync({ id: signal.id, status })
|
||||||
|
showToast(STATUS_TOAST[status] ?? 'Status aktualisiert.')
|
||||||
|
} catch {
|
||||||
|
showToast('Statusänderung fehlgeschlagen.', 'error')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSendReview() {
|
async function handleSendReview() {
|
||||||
await reviewService.createReviewTask(signal.id)
|
try {
|
||||||
setReviewTaskSent(true)
|
await reviewService.createReviewTask(signal.id)
|
||||||
setSnackbar('Prüfungsaufgabe erstellt')
|
setReviewTaskSent(true)
|
||||||
|
showToast('Prüfungsaufgabe erstellt.')
|
||||||
|
} catch {
|
||||||
|
showToast('Prüfungsaufgabe konnte nicht erstellt werden.', 'error')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleShortlist() {
|
function handleShortlist() {
|
||||||
@@ -242,13 +258,6 @@ export function FutureSignalDetailPanel({ signal, onClose }: Props) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Snackbar
|
|
||||||
open={!!snackbar}
|
|
||||||
autoHideDuration={2500}
|
|
||||||
onClose={() => setSnackbar(null)}
|
|
||||||
message={snackbar}
|
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { RightContextPanel } from './RightContextPanel'
|
|||||||
import { CompareTray } from './CompareTray'
|
import { CompareTray } from './CompareTray'
|
||||||
import { GlobalAIAssistantDrawer, GlobalAIAssistantButton } from '../assistant'
|
import { GlobalAIAssistantDrawer, GlobalAIAssistantButton } from '../assistant'
|
||||||
import { useAssistantStore } from '../../stores/assistantStore'
|
import { useAssistantStore } from '../../stores/assistantStore'
|
||||||
|
import { ToastProvider } from '../ui'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -546,6 +547,7 @@ export function AppShell() {
|
|||||||
<CompareTray />
|
<CompareTray />
|
||||||
<GlobalAIAssistantDrawer />
|
<GlobalAIAssistantDrawer />
|
||||||
<GlobalAIAssistantButton />
|
<GlobalAIAssistantButton />
|
||||||
|
<ToastProvider />
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router'
|
|||||||
import { Avatar, Box, Divider, IconButton, ListItemIcon, Menu, MenuItem, Typography } from '@mui/material'
|
import { Avatar, Box, Divider, IconButton, ListItemIcon, Menu, MenuItem, Typography } from '@mui/material'
|
||||||
import { LogOut, Settings, User } from 'lucide-react'
|
import { LogOut, Settings, User } from 'lucide-react'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
import { DemoRoleSwitcher } from '../auth/DemoRoleSwitcher'
|
import { DemoRoleSwitcher } from '../auth/DemoRoleSwitcher'
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
@@ -27,6 +28,7 @@ export function UserMenu() {
|
|||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||||
const { currentUser, logout } = useSessionStore()
|
const { currentUser, logout } = useSessionStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const showToast = useToastStore((s) => s.showToast)
|
||||||
|
|
||||||
function handleOpen(e: React.MouseEvent<HTMLElement>) {
|
function handleOpen(e: React.MouseEvent<HTMLElement>) {
|
||||||
setAnchorEl(e.currentTarget)
|
setAnchorEl(e.currentTarget)
|
||||||
@@ -75,11 +77,11 @@ export function UserMenu() {
|
|||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<MenuItem onClick={handleClose}>
|
<MenuItem onClick={() => { handleClose(); showToast('Profilseite ist in Kürze verfügbar.', 'info') }}>
|
||||||
<ListItemIcon><User size={16} /></ListItemIcon>
|
<ListItemIcon><User size={16} /></ListItemIcon>
|
||||||
Profil
|
Profil
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onClick={handleClose}>
|
<MenuItem onClick={() => { handleClose(); showToast('Einstellungen sind in Kürze verfügbar.', 'info') }}>
|
||||||
<ListItemIcon><Settings size={16} /></ListItemIcon>
|
<ListItemIcon><Settings size={16} /></ListItemIcon>
|
||||||
Einstellungen
|
Einstellungen
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useMatchesByProperty, useApproveMatch } from '../../hooks/useMatches'
|
|||||||
import { usePropertyById } from '../../hooks/useProperties'
|
import { usePropertyById } from '../../hooks/useProperties'
|
||||||
import { useMatchCenterStore } from '../../stores/matchCenterStore'
|
import { useMatchCenterStore } from '../../stores/matchCenterStore'
|
||||||
import { useCompareStore } from '../../stores/compareStore'
|
import { useCompareStore } from '../../stores/compareStore'
|
||||||
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
import { MatchCardExpanded } from '../match-card/MatchCardExpanded'
|
import { MatchCardExpanded } from '../match-card/MatchCardExpanded'
|
||||||
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
|
import { buildMatchCardViewModel } from '../../features/matching/matchCardAdapter'
|
||||||
import { reviewService } from '../../services/reviewService'
|
import { reviewService } from '../../services/reviewService'
|
||||||
@@ -17,6 +18,7 @@ export function MatchBriefingPanel() {
|
|||||||
const { selectedPropertyId, selectedNeedId } = useMatchCenterStore()
|
const { selectedPropertyId, selectedNeedId } = useMatchCenterStore()
|
||||||
const { addToCompare } = useCompareStore()
|
const { addToCompare } = useCompareStore()
|
||||||
const approveMatch = useApproveMatch()
|
const approveMatch = useApproveMatch()
|
||||||
|
const showToast = useToastStore((s) => s.showToast)
|
||||||
|
|
||||||
const { data: matches = [], isLoading: matchesLoading } = useMatchesByProperty(selectedPropertyId ?? '')
|
const { data: matches = [], isLoading: matchesLoading } = useMatchesByProperty(selectedPropertyId ?? '')
|
||||||
const { data: property = null, isLoading: propLoading } = usePropertyById(selectedPropertyId)
|
const { data: property = null, isLoading: propLoading } = usePropertyById(selectedPropertyId)
|
||||||
@@ -52,7 +54,10 @@ export function MatchBriefingPanel() {
|
|||||||
label: 'Genehmigen',
|
label: 'Genehmigen',
|
||||||
actionType: 'APPROVE',
|
actionType: 'APPROVE',
|
||||||
variant: 'primary',
|
variant: 'primary',
|
||||||
onClick: () => approveMatch.mutate(match.id),
|
onClick: () => approveMatch.mutate(match.id, {
|
||||||
|
onSuccess: () => showToast('Match genehmigt.'),
|
||||||
|
onError: () => showToast('Genehmigung fehlgeschlagen.', 'error'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'review',
|
id: 'review',
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
Radio,
|
Radio,
|
||||||
RadioGroup,
|
RadioGroup,
|
||||||
Snackbar,
|
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
@@ -18,6 +17,7 @@ import { Plus } from 'lucide-react'
|
|||||||
import { ShortlistStatusBadge } from './ShortlistStatusBadge'
|
import { ShortlistStatusBadge } from './ShortlistStatusBadge'
|
||||||
import { useShortlists, useCreateShortlist, useAddToShortlist } from '../../hooks/useShortlists'
|
import { useShortlists, useCreateShortlist, useAddToShortlist } from '../../hooks/useShortlists'
|
||||||
import { useShortlistStore } from '../../stores/shortlistStore'
|
import { useShortlistStore } from '../../stores/shortlistStore'
|
||||||
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
import { ShortlistStatus } from '../../domain/enums'
|
import { ShortlistStatus } from '../../domain/enums'
|
||||||
|
|
||||||
export function AddToShortlistDialog() {
|
export function AddToShortlistDialog() {
|
||||||
@@ -26,11 +26,11 @@ export function AddToShortlistDialog() {
|
|||||||
const createShortlist = useCreateShortlist()
|
const createShortlist = useCreateShortlist()
|
||||||
const addToShortlist = useAddToShortlist()
|
const addToShortlist = useAddToShortlist()
|
||||||
|
|
||||||
|
const showToast = useToastStore((s) => s.showToast)
|
||||||
const [selectedId, setSelectedId] = useState<string>('')
|
const [selectedId, setSelectedId] = useState<string>('')
|
||||||
const [creatingNew, setCreatingNew] = useState(false)
|
const [creatingNew, setCreatingNew] = useState(false)
|
||||||
const [newTitle, setNewTitle] = useState('')
|
const [newTitle, setNewTitle] = useState('')
|
||||||
const [note, setNote] = useState('')
|
const [note, setNote] = useState('')
|
||||||
const [snackbar, setSnackbar] = useState<string | null>(null)
|
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
closeAddDialog()
|
closeAddDialog()
|
||||||
@@ -65,12 +65,12 @@ export function AddToShortlistDialog() {
|
|||||||
|
|
||||||
await addToShortlist.mutateAsync({ shortlistId: targetId, item: itemWithNote })
|
await addToShortlist.mutateAsync({ shortlistId: targetId, item: itemWithNote })
|
||||||
|
|
||||||
if (alreadyExists) {
|
|
||||||
setSnackbar('Bereits in dieser Shortlist vorhanden')
|
|
||||||
} else {
|
|
||||||
setSnackbar('Zur Shortlist hinzugefügt')
|
|
||||||
}
|
|
||||||
handleClose()
|
handleClose()
|
||||||
|
if (alreadyExists) {
|
||||||
|
showToast('Bereits in dieser Shortlist vorhanden.', 'warning')
|
||||||
|
} else {
|
||||||
|
showToast('Zur Shortlist hinzugefügt.')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isBusy = createShortlist.isPending || addToShortlist.isPending
|
const isBusy = createShortlist.isPending || addToShortlist.isPending
|
||||||
@@ -161,14 +161,6 @@ export function AddToShortlistDialog() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Snackbar
|
|
||||||
open={!!snackbar}
|
|
||||||
autoHideDuration={3000}
|
|
||||||
onClose={() => setSnackbar(null)}
|
|
||||||
message={snackbar}
|
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ShortlistItemCard } from './ShortlistItemCard'
|
|||||||
import { ShortlistEmptyState } from './ShortlistEmptyState'
|
import { ShortlistEmptyState } from './ShortlistEmptyState'
|
||||||
import { useUpdateShortlist } from '../../hooks/useShortlists'
|
import { useUpdateShortlist } from '../../hooks/useShortlists'
|
||||||
import { useCompareStore } from '../../stores/compareStore'
|
import { useCompareStore } from '../../stores/compareStore'
|
||||||
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
import { ShortlistStatus } from '../../domain/enums'
|
import { ShortlistStatus } from '../../domain/enums'
|
||||||
import type { Shortlist } from '../../domain/shortlist'
|
import type { Shortlist } from '../../domain/shortlist'
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ export function ShortlistDetail({ shortlist }: Props) {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const updateShortlist = useUpdateShortlist()
|
const updateShortlist = useUpdateShortlist()
|
||||||
const { addToCompare, compareItems } = useCompareStore()
|
const { addToCompare, compareItems } = useCompareStore()
|
||||||
|
const showToast = useToastStore((s) => s.showToast)
|
||||||
|
|
||||||
const [editingTitle, setEditingTitle] = useState(false)
|
const [editingTitle, setEditingTitle] = useState(false)
|
||||||
const [titleDraft, setTitleDraft] = useState(shortlist.title)
|
const [titleDraft, setTitleDraft] = useState(shortlist.title)
|
||||||
@@ -28,17 +30,35 @@ export function ShortlistDetail({ shortlist }: Props) {
|
|||||||
|
|
||||||
function handleTitleSave() {
|
function handleTitleSave() {
|
||||||
if (titleDraft.trim() && titleDraft.trim() !== shortlist.title) {
|
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)
|
setEditingTitle(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMarkReviewReady() {
|
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() {
|
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() {
|
function handleOpenCompare() {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Box, Chip, IconButton, Typography } from '@mui/material'
|
import { Box, Chip, IconButton, Typography } from '@mui/material'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { useRemoveFromShortlist } from '../../hooks/useShortlists'
|
import { useRemoveFromShortlist } from '../../hooks/useShortlists'
|
||||||
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
import type { ShortlistItem } from '../../domain/shortlist'
|
import type { ShortlistItem } from '../../domain/shortlist'
|
||||||
|
|
||||||
const RESULT_TYPE_LABEL: Record<string, string> = {
|
const RESULT_TYPE_LABEL: Record<string, string> = {
|
||||||
@@ -19,6 +20,7 @@ interface Props {
|
|||||||
|
|
||||||
export function ShortlistItemCard({ item, shortlistId, isFinalized }: Props) {
|
export function ShortlistItemCard({ item, shortlistId, isFinalized }: Props) {
|
||||||
const removeItem = useRemoveFromShortlist()
|
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' })
|
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
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
disabled={removeItem.isPending}
|
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 }}
|
sx={{ color: '#94a3b8', '&:hover': { color: '#c0392b' }, flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,3 +8,5 @@ export { CardSkeleton } from './CardSkeleton'
|
|||||||
export { PanelLoadingState } from './PanelLoadingState'
|
export { PanelLoadingState } from './PanelLoadingState'
|
||||||
export { UnauthorizedState } from './UnauthorizedState'
|
export { UnauthorizedState } from './UnauthorizedState'
|
||||||
export { RestrictedState } from './RestrictedState'
|
export { RestrictedState } from './RestrictedState'
|
||||||
|
export { ToastProvider } from './ToastProvider'
|
||||||
|
export { ConfirmDialog } from './ConfirmDialog'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
AIMonitoringEmptyState,
|
AIMonitoringEmptyState,
|
||||||
} from '../../components/ai-monitoring'
|
} from '../../components/ai-monitoring'
|
||||||
import { useAIOutputs, useUpdateAIOutputReviewStatus } from '../../hooks/useAIMonitoring'
|
import { useAIOutputs, useUpdateAIOutputReviewStatus } from '../../hooks/useAIMonitoring'
|
||||||
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
import type { AIOutput, AIOutputType } from '../../domain/aiOutput'
|
import type { AIOutput, AIOutputType } from '../../domain/aiOutput'
|
||||||
import type { ReviewStatus } from '../../domain/enums'
|
import type { ReviewStatus } from '../../domain/enums'
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ export default function AIMonitoring() {
|
|||||||
const [filters, setFilters] = useState<Filters>({ type: '', reviewStatus: '', hasError: null })
|
const [filters, setFilters] = useState<Filters>({ type: '', reviewStatus: '', hasError: null })
|
||||||
const [selectedOutput, setSelectedOutput] = useState<AIOutput | null>(null)
|
const [selectedOutput, setSelectedOutput] = useState<AIOutput | null>(null)
|
||||||
|
|
||||||
|
const showToast = useToastStore((s) => s.showToast)
|
||||||
const { data: allOutputs = [], isLoading } = useAIOutputs()
|
const { data: allOutputs = [], isLoading } = useAIOutputs()
|
||||||
const updateStatus = useUpdateAIOutputReviewStatus()
|
const updateStatus = useUpdateAIOutputReviewStatus()
|
||||||
|
|
||||||
@@ -66,11 +68,25 @@ export default function AIMonitoring() {
|
|||||||
const failedCount = allOutputs.filter(o => !!o.error).length
|
const failedCount = allOutputs.filter(o => !!o.error).length
|
||||||
const pendingCount = allOutputs.filter(o => o.reviewStatus === 'UNREVIEWED' || o.reviewStatus === 'FLAGGED').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) => {
|
const handleUpdateStatus = (status: ReviewStatus) => {
|
||||||
if (!selectedOutput) return
|
if (!selectedOutput) return
|
||||||
updateStatus.mutate(
|
updateStatus.mutate(
|
||||||
{ id: selectedOutput.id, status },
|
{ 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'),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '../../components/review'
|
} from '../../components/review'
|
||||||
import { useReviewQueue, useUpdateReviewStatus, useAddReviewNote } from '../../hooks/useReviewQueue'
|
import { useReviewQueue, useUpdateReviewStatus, useAddReviewNote } from '../../hooks/useReviewQueue'
|
||||||
import { useSessionStore } from '../../stores/sessionStore'
|
import { useSessionStore } from '../../stores/sessionStore'
|
||||||
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
import type { ReviewTask, ReviewTaskStatus } from '../../domain/review'
|
import type { ReviewTask, ReviewTaskStatus } from '../../domain/review'
|
||||||
import type { ReviewFilters } from '../../provider/IReviewProvider'
|
import type { ReviewFilters } from '../../provider/IReviewProvider'
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ function filterTasks(tasks: ReviewTask[], filters: ReviewFilters): ReviewTask[]
|
|||||||
|
|
||||||
export default function ReviewQueue() {
|
export default function ReviewQueue() {
|
||||||
const { currentUser } = useSessionStore()
|
const { currentUser } = useSessionStore()
|
||||||
|
const showToast = useToastStore((s) => s.showToast)
|
||||||
const userRole = currentUser?.role ?? 'REVIEWER'
|
const userRole = currentUser?.role ?? 'REVIEWER'
|
||||||
|
|
||||||
const [filters, setFilters] = useState<ReviewFilters>({})
|
const [filters, setFilters] = useState<ReviewFilters>({})
|
||||||
@@ -40,6 +42,15 @@ export default function ReviewQueue() {
|
|||||||
const escalatedCount = allTasks.filter(t => t.status === 'ESCALATED').length
|
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 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) => {
|
const handleAction = (status: ReviewTaskStatus) => {
|
||||||
if (!selectedTask) return
|
if (!selectedTask) return
|
||||||
updateStatus.mutate(
|
updateStatus.mutate(
|
||||||
@@ -47,7 +58,9 @@ export default function ReviewQueue() {
|
|||||||
{
|
{
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
setSelectedTask(res.data)
|
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) => {
|
onSuccess: (res) => {
|
||||||
setSelectedTask(res.data)
|
setSelectedTask(res.data)
|
||||||
|
showToast('Notiz hinzugefügt.')
|
||||||
},
|
},
|
||||||
|
onError: () => showToast('Notiz konnte nicht gespeichert werden.', 'error'),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useMatches, useApproveMatch } from '../../hooks/useMatches'
|
|||||||
import { useProperties } from '../../hooks/useProperties'
|
import { useProperties } from '../../hooks/useProperties'
|
||||||
import { useNeeds } from '../../hooks/useNeeds'
|
import { useNeeds } from '../../hooks/useNeeds'
|
||||||
import { useMatchCenterStore } from '../../stores/matchCenterStore'
|
import { useMatchCenterStore } from '../../stores/matchCenterStore'
|
||||||
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
import { MatchListCard, MatchBriefingPanel, MatchCenterSkeleton } from '../../components/match-center'
|
import { MatchListCard, MatchBriefingPanel, MatchCenterSkeleton } from '../../components/match-center'
|
||||||
import type { Match } from '../../domain/match'
|
import type { Match } from '../../domain/match'
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ export default function MatchCenter() {
|
|||||||
const { data: needs = [] } = useNeeds()
|
const { data: needs = [] } = useNeeds()
|
||||||
const { setSelectedProperty, setSelectedNeed } = useMatchCenterStore()
|
const { setSelectedProperty, setSelectedNeed } = useMatchCenterStore()
|
||||||
const approveMatch = useApproveMatch()
|
const approveMatch = useApproveMatch()
|
||||||
|
const showToast = useToastStore((s) => s.showToast)
|
||||||
|
|
||||||
const [selectedMatchId, setSelectedMatchId] = useState<string | null>(null)
|
const [selectedMatchId, setSelectedMatchId] = useState<string | null>(null)
|
||||||
const [filterStrength, setFilterStrength] = useState('')
|
const [filterStrength, setFilterStrength] = useState('')
|
||||||
@@ -152,7 +154,10 @@ export default function MatchCenter() {
|
|||||||
property={propMap.get(match.propertyId)}
|
property={propMap.get(match.propertyId)}
|
||||||
need={needMap.get(match.needId)}
|
need={needMap.get(match.needId)}
|
||||||
onSelect={() => handleSelectMatch(match)}
|
onSelect={() => handleSelectMatch(match)}
|
||||||
onApprove={() => approveMatch.mutate(match.id)}
|
onApprove={() => approveMatch.mutate(match.id, {
|
||||||
|
onSuccess: () => showToast('Match genehmigt.'),
|
||||||
|
onError: () => showToast('Genehmigung fehlgeschlagen.', 'error'),
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -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) }))
|
||||||
|
},
|
||||||
|
}))
|
||||||
Reference in New Issue
Block a user