diff --git a/src/components/future-signals/FutureSignalDetailPanel.tsx b/src/components/future-signals/FutureSignalDetailPanel.tsx index 7663dd9..f7b3895 100644 --- a/src/components/future-signals/FutureSignalDetailPanel.tsx +++ b/src/components/future-signals/FutureSignalDetailPanel.tsx @@ -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(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 = { + 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) { - setSnackbar(null)} - message={snackbar} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - /> ) } diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 59ad870..d27e3a6 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -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() { + ) } diff --git a/src/components/layout/UserMenu.tsx b/src/components/layout/UserMenu.tsx index fecbafe..6e4e5c4 100644 --- a/src/components/layout/UserMenu.tsx +++ b/src/components/layout/UserMenu.tsx @@ -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 = { @@ -27,6 +28,7 @@ export function UserMenu() { const [anchorEl, setAnchorEl] = useState(null) const { currentUser, logout } = useSessionStore() const navigate = useNavigate() + const showToast = useToastStore((s) => s.showToast) function handleOpen(e: React.MouseEvent) { setAnchorEl(e.currentTarget) @@ -75,11 +77,11 @@ export function UserMenu() { - + { handleClose(); showToast('Profilseite ist in Kürze verfügbar.', 'info') }}> Profil - + { handleClose(); showToast('Einstellungen sind in Kürze verfügbar.', 'info') }}> Einstellungen diff --git a/src/components/match-center/MatchBriefingPanel.tsx b/src/components/match-center/MatchBriefingPanel.tsx index 5e0af90..4d68f0a 100644 --- a/src/components/match-center/MatchBriefingPanel.tsx +++ b/src/components/match-center/MatchBriefingPanel.tsx @@ -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', diff --git a/src/components/shortlist/AddToShortlistDialog.tsx b/src/components/shortlist/AddToShortlistDialog.tsx index 75bf1bf..8ea9258 100644 --- a/src/components/shortlist/AddToShortlistDialog.tsx +++ b/src/components/shortlist/AddToShortlistDialog.tsx @@ -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('') const [creatingNew, setCreatingNew] = useState(false) const [newTitle, setNewTitle] = useState('') const [note, setNote] = useState('') - const [snackbar, setSnackbar] = useState(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() { - - setSnackbar(null)} - message={snackbar} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - /> ) } diff --git a/src/components/shortlist/ShortlistDetail.tsx b/src/components/shortlist/ShortlistDetail.tsx index 2eeb0d4..9cbacfe 100644 --- a/src/components/shortlist/ShortlistDetail.tsx +++ b/src/components/shortlist/ShortlistDetail.tsx @@ -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() { diff --git a/src/components/shortlist/ShortlistItemCard.tsx b/src/components/shortlist/ShortlistItemCard.tsx index 10deedd..6c5e994 100644 --- a/src/components/shortlist/ShortlistItemCard.tsx +++ b/src/components/shortlist/ShortlistItemCard.tsx @@ -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 = { @@ -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) { 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 }} > diff --git a/src/components/ui/ConfirmDialog.tsx b/src/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..08c8bee --- /dev/null +++ b/src/components/ui/ConfirmDialog.tsx @@ -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 ( + + + {destructive && } + {title} + + + + {message} + + + + + + + + ) +} diff --git a/src/components/ui/ToastProvider.tsx b/src/components/ui/ToastProvider.tsx new file mode 100644 index 0000000..6b9d463 --- /dev/null +++ b/src/components/ui/ToastProvider.tsx @@ -0,0 +1,33 @@ +import { Alert, Snackbar, Stack } from '@mui/material' +import { useToastStore } from '../../stores/toastStore' + +export function ToastProvider() { + const { toasts, dismissToast } = useToastStore() + + return ( + + {toasts.map((toast) => ( + dismissToast(toast.id)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + sx={{ position: 'relative', transform: 'none', left: 'auto', bottom: 'auto' }} + > + dismissToast(toast.id)} + severity={toast.severity} + variant="filled" + sx={{ minWidth: 320, boxShadow: 3 }} + > + {toast.message} + + + ))} + + ) +} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 841d800..d4ee06c 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -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' diff --git a/src/pages/ops/AIMonitoring.tsx b/src/pages/ops/AIMonitoring.tsx index 8e9ef09..96ba529 100644 --- a/src/pages/ops/AIMonitoring.tsx +++ b/src/pages/ops/AIMonitoring.tsx @@ -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({ type: '', reviewStatus: '', hasError: null }) const [selectedOutput, setSelectedOutput] = useState(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 = { + 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'), + } ) } diff --git a/src/pages/ops/ReviewQueue.tsx b/src/pages/ops/ReviewQueue.tsx index 47f4d86..8878380 100644 --- a/src/pages/ops/ReviewQueue.tsx +++ b/src/pages/ops/ReviewQueue.tsx @@ -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({}) @@ -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 = { + 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'), } ) } diff --git a/src/pages/supply/MatchCenter.tsx b/src/pages/supply/MatchCenter.tsx index 2f4b843..2dded20 100644 --- a/src/pages/supply/MatchCenter.tsx +++ b/src/pages/supply/MatchCenter.tsx @@ -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(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'), + })} /> ))} diff --git a/src/stores/toastStore.ts b/src/stores/toastStore.ts new file mode 100644 index 0000000..c825961 --- /dev/null +++ b/src/stores/toastStore.ts @@ -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((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) })) + }, +}))