feat: Reminder Manager UX — timeline-first grouped layout
Replace 3-row filter panel with a grouped feed (Überfällig / Diese Woche / Dieser Monat / Später). KPI cards are now clickable shortcuts that filter to their time horizon or type (Pre-Market). Filter bar condenses to one row: search + type dropdown + archive toggle. Rows get a left priority-color border (red/orange/yellow). Default status filter is ACTIVE+SNOOZED. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useCallback } from 'react'
|
import { useMemo, useCallback } from 'react'
|
||||||
import { Box, Typography } from '@mui/material'
|
import { Box, Chip, Typography } from '@mui/material'
|
||||||
import { useReminders } from '../../hooks/useReminders'
|
import { useReminders } from '../../hooks/useReminders'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { useReminderStore } from '../../stores/reminderStore'
|
import { useReminderStore } from '../../stores/reminderStore'
|
||||||
@@ -8,24 +8,47 @@ import { ReminderCard } from './ReminderCard'
|
|||||||
import { ReminderSkeleton } from './ReminderSkeleton'
|
import { ReminderSkeleton } from './ReminderSkeleton'
|
||||||
import { ReminderEmptyState } from './ReminderEmptyState'
|
import { ReminderEmptyState } from './ReminderEmptyState'
|
||||||
import type { Reminder } from '../../domain/reminder'
|
import type { Reminder } from '../../domain/reminder'
|
||||||
|
import { ReminderStatus } from '../../domain/reminder'
|
||||||
|
import type { FilterHorizon } from '../../stores/reminderStore'
|
||||||
|
|
||||||
const LIST_HEADER_COLS = '100px 130px 1fr 140px 90px 100px 90px'
|
const MOCK_TODAY = new Date('2026-05-20')
|
||||||
|
|
||||||
|
function getHorizon(dueDate: string): FilterHorizon {
|
||||||
|
const days = Math.ceil((new Date(dueDate).getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
if (days <= 0) return 'OVERDUE'
|
||||||
|
if (days <= 7) return 'THIS_WEEK'
|
||||||
|
if (days <= 30) return 'THIS_MONTH'
|
||||||
|
return 'LATER'
|
||||||
|
}
|
||||||
|
|
||||||
|
const HORIZON_CONFIG: { key: FilterHorizon; label: string; color: string; bg: string; border: string }[] = [
|
||||||
|
{ key: 'OVERDUE', label: 'Überfällig', color: '#dc2626', bg: '#fef2f2', border: '#fecaca' },
|
||||||
|
{ key: 'THIS_WEEK', label: 'Diese Woche', color: '#ea580c', bg: '#fff7ed', border: '#fed7aa' },
|
||||||
|
{ key: 'THIS_MONTH', label: 'Dieser Monat', color: '#0369a1', bg: '#f0f9ff', border: '#bae6fd' },
|
||||||
|
{ key: 'LATER', label: 'Später', color: '#64748b', bg: '#f8fafc', border: '#e2e8f0' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const LIST_COLS = '100px 130px 1fr 140px 90px 100px 90px'
|
||||||
|
|
||||||
function applyFilters(
|
function applyFilters(
|
||||||
reminders: Reminder[],
|
reminders: Reminder[],
|
||||||
filterType: string,
|
filterType: string,
|
||||||
filterStatus: string,
|
filterStatus: string,
|
||||||
filterPriority: string,
|
filterHorizon: string,
|
||||||
searchQuery: string,
|
searchQuery: string,
|
||||||
): Reminder[] {
|
): Reminder[] {
|
||||||
return reminders.filter(r => {
|
return reminders.filter(r => {
|
||||||
|
if (filterStatus === 'ACTIVE') {
|
||||||
|
if (r.status !== ReminderStatus.ACTIVE && r.status !== ReminderStatus.SNOOZED) return false
|
||||||
|
} else if (filterStatus !== 'ALL') {
|
||||||
|
if (r.status !== filterStatus) return false
|
||||||
|
}
|
||||||
if (filterType !== 'ALL' && r.type !== filterType) return false
|
if (filterType !== 'ALL' && r.type !== filterType) return false
|
||||||
if (filterStatus !== 'ALL' && r.status !== filterStatus) return false
|
if (filterHorizon !== 'ALL' && getHorizon(r.dueDate) !== filterHorizon) return false
|
||||||
if (filterPriority !== 'ALL' && r.priority !== filterPriority) return false
|
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
const q = searchQuery.toLowerCase()
|
const q = searchQuery.toLowerCase()
|
||||||
const haystack = `${r.propertyTitle} ${r.propertyCity} ${r.tenantName}`.toLowerCase()
|
const hay = `${r.propertyTitle} ${r.propertyCity} ${r.tenantName}`.toLowerCase()
|
||||||
if (!haystack.includes(q)) return false
|
if (!hay.includes(q)) return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -34,59 +57,83 @@ function applyFilters(
|
|||||||
export function ReminderFeed() {
|
export function ReminderFeed() {
|
||||||
const { data, isLoading } = useReminders()
|
const { data, isLoading } = useReminders()
|
||||||
const {
|
const {
|
||||||
filterType, filterStatus, filterPriority, searchQuery, viewMode,
|
filterType, filterStatus, filterHorizon, searchQuery, viewMode,
|
||||||
setFilterType, setFilterStatus, setFilterPriority, setSearchQuery,
|
setFilterType, setFilterStatus, setFilterHorizon, setSearchQuery,
|
||||||
} = useReminderStore(useShallow(s => ({
|
} = useReminderStore(useShallow(s => ({
|
||||||
filterType: s.filterType, filterStatus: s.filterStatus,
|
filterType: s.filterType, filterStatus: s.filterStatus,
|
||||||
filterPriority: s.filterPriority, searchQuery: s.searchQuery,
|
filterHorizon: s.filterHorizon, searchQuery: s.searchQuery,
|
||||||
viewMode: s.viewMode, setFilterType: s.setFilterType,
|
viewMode: s.viewMode, setFilterType: s.setFilterType,
|
||||||
setFilterStatus: s.setFilterStatus, setFilterPriority: s.setFilterPriority,
|
setFilterStatus: s.setFilterStatus, setFilterHorizon: s.setFilterHorizon,
|
||||||
setSearchQuery: s.setSearchQuery,
|
setSearchQuery: s.setSearchQuery,
|
||||||
})))
|
})))
|
||||||
|
|
||||||
const reminders = data ?? []
|
const reminders = data ?? []
|
||||||
|
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(
|
||||||
() => applyFilters(reminders, filterType, filterStatus, filterPriority, searchQuery),
|
() => applyFilters(reminders, filterType, filterStatus, filterHorizon, searchQuery),
|
||||||
[reminders, filterType, filterStatus, filterPriority, searchQuery],
|
[reminders, filterType, filterStatus, filterHorizon, searchQuery],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map: Record<string, Reminder[]> = {
|
||||||
|
OVERDUE: [], THIS_WEEK: [], THIS_MONTH: [], LATER: [],
|
||||||
|
}
|
||||||
|
filtered.forEach(r => map[getHorizon(r.dueDate)].push(r))
|
||||||
|
return map
|
||||||
|
}, [filtered])
|
||||||
|
|
||||||
const resetFilters = useCallback(() => {
|
const resetFilters = useCallback(() => {
|
||||||
setFilterType('ALL')
|
setFilterType('ALL')
|
||||||
setFilterStatus('ALL')
|
setFilterStatus('ACTIVE')
|
||||||
setFilterPriority('ALL')
|
setFilterHorizon('ALL')
|
||||||
setSearchQuery('')
|
setSearchQuery('')
|
||||||
}, [setFilterType, setFilterStatus, setFilterPriority, setSearchQuery])
|
}, [setFilterType, setFilterStatus, setFilterHorizon, setSearchQuery])
|
||||||
|
|
||||||
if (isLoading) return <ReminderSkeleton />
|
if (isLoading) return <ReminderSkeleton />
|
||||||
|
if (filtered.length === 0) return <ReminderEmptyState onReset={resetFilters} />
|
||||||
if (filtered.length === 0) {
|
|
||||||
return <ReminderEmptyState onReset={resetFilters} />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (viewMode === 'card') {
|
if (viewMode === 'card') {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box className="flex flex-col gap-4">
|
||||||
sx={{
|
{HORIZON_CONFIG.map(({ key, label, color, bg, border }) => {
|
||||||
display: 'grid',
|
const group = grouped[key]
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
if (!group || group.length === 0) return null
|
||||||
gap: 2,
|
return (
|
||||||
}}
|
<Box key={key}>
|
||||||
>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5, px: 0.5 }}>
|
||||||
{filtered.map(r => (
|
<Box sx={{ width: 3, height: 16, borderRadius: 1, bgcolor: color, flexShrink: 0 }} />
|
||||||
<ReminderCard key={r.id} reminder={r} />
|
<Typography variant="body2" sx={{ fontWeight: 700, color, fontSize: '0.8125rem' }}>
|
||||||
))}
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={group.length}
|
||||||
|
size="small"
|
||||||
|
sx={{ height: 18, fontSize: '0.65rem', bgcolor: color, color: 'white', ml: 0.25 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{group.map(r => <ReminderCard key={r.id} reminder={r} />)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1, overflow: 'hidden' }}>
|
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden' }}>
|
||||||
{/* Table header */}
|
{/* Table header */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: LIST_HEADER_COLS,
|
gridTemplateColumns: LIST_COLS,
|
||||||
gap: 1,
|
gap: 1,
|
||||||
px: 2,
|
px: 2,
|
||||||
py: 1,
|
py: 1,
|
||||||
@@ -101,9 +148,43 @@ export function ReminderFeed() {
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{filtered.map(r => (
|
{/* Grouped rows */}
|
||||||
<ReminderListRow key={r.id} reminder={r} />
|
{HORIZON_CONFIG.map(({ key, label, color, bg, border }) => {
|
||||||
))}
|
const group = grouped[key]
|
||||||
|
if (!group || group.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Box key={key}>
|
||||||
|
{/* Section divider */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 0.625,
|
||||||
|
bgcolor: bg,
|
||||||
|
borderTop: `1px solid ${border}`,
|
||||||
|
borderBottom: `1px solid ${border}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ fontWeight: 700, color, textTransform: 'uppercase', letterSpacing: 0.6, fontSize: '0.65rem' }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={group.length}
|
||||||
|
size="small"
|
||||||
|
sx={{ height: 16, fontSize: '0.6rem', bgcolor: color, color: 'white', px: 0 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Rows */}
|
||||||
|
{group.map(r => <ReminderListRow key={r.id} reminder={r} />)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Box, ToggleButtonGroup, ToggleButton, TextField, InputAdornment, Typography } from '@mui/material'
|
import { Box, ToggleButtonGroup, ToggleButton, TextField, InputAdornment, Typography, Select, MenuItem, FormControl, FormControlLabel, Switch } from '@mui/material'
|
||||||
import { Search } from 'lucide-react'
|
import { Search } from 'lucide-react'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { useReminderStore } from '../../stores/reminderStore'
|
import { useReminderStore } from '../../stores/reminderStore'
|
||||||
import { ReminderType, ReminderStatus, ReminderPriority } from '../../domain/reminder'
|
import { ReminderType } from '../../domain/reminder'
|
||||||
import type { ReminderType as ReminderTypeType, ReminderStatus as ReminderStatusType, ReminderPriority as ReminderPriorityType } from '../../domain/reminder'
|
import type { ReminderType as ReminderTypeType } from '../../domain/reminder'
|
||||||
|
|
||||||
const TYPE_LABELS: Record<ReminderTypeType, string> = {
|
const TYPE_LABELS: Record<ReminderTypeType, string> = {
|
||||||
LEASE_EXPIRY: 'Mietablauf',
|
LEASE_EXPIRY: 'Mietablauf',
|
||||||
@@ -16,127 +16,89 @@ const TYPE_LABELS: Record<ReminderTypeType, string> = {
|
|||||||
CUSTOM: 'Individuell',
|
CUSTOM: 'Individuell',
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<ReminderStatusType, string> = {
|
|
||||||
ACTIVE: 'Aktiv',
|
|
||||||
SNOOZED: 'Schlummern',
|
|
||||||
COMPLETED: 'Erledigt',
|
|
||||||
DISMISSED: 'Verworfen',
|
|
||||||
}
|
|
||||||
|
|
||||||
const PRIORITY_LABELS: Record<ReminderPriorityType, string> = {
|
|
||||||
URGENT: 'Dringend',
|
|
||||||
HIGH: 'Hoch',
|
|
||||||
MEDIUM: 'Mittel',
|
|
||||||
LOW: 'Niedrig',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReminderFilterBar() {
|
export function ReminderFilterBar() {
|
||||||
const {
|
const {
|
||||||
filterType, setFilterType,
|
filterType, setFilterType,
|
||||||
filterStatus, setFilterStatus,
|
filterStatus, setFilterStatus,
|
||||||
filterPriority, setFilterPriority,
|
|
||||||
searchQuery, setSearchQuery,
|
searchQuery, setSearchQuery,
|
||||||
viewMode, setViewMode,
|
viewMode, setViewMode,
|
||||||
} = useReminderStore(useShallow(s => ({
|
} = useReminderStore(useShallow(s => ({
|
||||||
filterType: s.filterType, setFilterType: s.setFilterType,
|
filterType: s.filterType, setFilterType: s.setFilterType,
|
||||||
filterStatus: s.filterStatus, setFilterStatus: s.setFilterStatus,
|
filterStatus: s.filterStatus, setFilterStatus: s.setFilterStatus,
|
||||||
filterPriority: s.filterPriority, setFilterPriority: s.setFilterPriority,
|
|
||||||
searchQuery: s.searchQuery, setSearchQuery: s.setSearchQuery,
|
searchQuery: s.searchQuery, setSearchQuery: s.setSearchQuery,
|
||||||
viewMode: s.viewMode, setViewMode: s.setViewMode,
|
viewMode: s.viewMode, setViewMode: s.setViewMode,
|
||||||
})))
|
})))
|
||||||
|
|
||||||
|
const showArchive = filterStatus === 'ALL'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col gap-3">
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||||
<Box className="flex items-center gap-3 flex-wrap">
|
{/* Search */}
|
||||||
{/* Search */}
|
<TextField
|
||||||
<TextField
|
size="small"
|
||||||
size="small"
|
placeholder="Suchen…"
|
||||||
placeholder="Suchen…"
|
value={searchQuery}
|
||||||
value={searchQuery}
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
slotProps={{
|
||||||
slotProps={{
|
input: {
|
||||||
input: {
|
startAdornment: (
|
||||||
startAdornment: (
|
<InputAdornment position="start">
|
||||||
<InputAdornment position="start">
|
<Search size={16} color="#94a3b8" />
|
||||||
<Search size={16} color="#94a3b8" />
|
</InputAdornment>
|
||||||
</InputAdornment>
|
),
|
||||||
),
|
},
|
||||||
},
|
}}
|
||||||
}}
|
sx={{ width: 200, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
|
||||||
sx={{ width: 220, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
{/* View mode */}
|
{/* Type selector */}
|
||||||
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Typography variant="caption" color="text.secondary">Ansicht:</Typography>
|
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>Typ:</Typography>
|
||||||
<ToggleButtonGroup
|
<FormControl size="small">
|
||||||
value={viewMode}
|
<Select
|
||||||
exclusive
|
|
||||||
onChange={(_, v) => v && setViewMode(v)}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<ToggleButton value="list" sx={{ textTransform: 'none', fontSize: '0.75rem', px: 1.5, py: 0.5 }}>Liste</ToggleButton>
|
|
||||||
<ToggleButton value="card" sx={{ textTransform: 'none', fontSize: '0.75rem', px: 1.5, py: 0.5 }}>Karten</ToggleButton>
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="flex flex-wrap gap-3">
|
|
||||||
{/* Type filter */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Typ:</Typography>
|
|
||||||
<ToggleButtonGroup
|
|
||||||
value={filterType}
|
value={filterType}
|
||||||
exclusive
|
onChange={e => setFilterType(e.target.value as ReminderTypeType | 'ALL')}
|
||||||
onChange={(_, v) => v && setFilterType(v)}
|
sx={{ fontSize: '0.8125rem', minWidth: 150 }}
|
||||||
size="small"
|
|
||||||
>
|
>
|
||||||
<ToggleButton value="ALL" sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>Alle</ToggleButton>
|
<MenuItem value="ALL" sx={{ fontSize: '0.8125rem' }}>Alle Typen</MenuItem>
|
||||||
{Object.values(ReminderType).map(t => (
|
{Object.values(ReminderType).map(t => (
|
||||||
<ToggleButton key={t} value={t} sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>
|
<MenuItem key={t} value={t} sx={{ fontSize: '0.8125rem' }}>
|
||||||
{TYPE_LABELS[t]}
|
{TYPE_LABELS[t]}
|
||||||
</ToggleButton>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</ToggleButtonGroup>
|
</Select>
|
||||||
</Box>
|
</FormControl>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box className="flex flex-wrap gap-3">
|
{/* Archive toggle */}
|
||||||
{/* Status filter */}
|
<FormControlLabel
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
control={
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Status:</Typography>
|
<Switch
|
||||||
<ToggleButtonGroup
|
|
||||||
value={filterStatus}
|
|
||||||
exclusive
|
|
||||||
onChange={(_, v) => v && setFilterStatus(v)}
|
|
||||||
size="small"
|
size="small"
|
||||||
>
|
checked={showArchive}
|
||||||
<ToggleButton value="ALL" sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>Alle</ToggleButton>
|
onChange={e => setFilterStatus(e.target.checked ? 'ALL' : 'ACTIVE')}
|
||||||
{Object.values(ReminderStatus).map(s => (
|
/>
|
||||||
<ToggleButton key={s} value={s} sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>
|
}
|
||||||
{STATUS_LABELS[s]}
|
label={
|
||||||
</ToggleButton>
|
<Typography variant="caption" color="text.secondary">
|
||||||
))}
|
Abgeschlossene anzeigen
|
||||||
</ToggleButtonGroup>
|
</Typography>
|
||||||
</Box>
|
}
|
||||||
|
sx={{ m: 0 }}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Priority filter */}
|
{/* View mode */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Priorität:</Typography>
|
<Typography variant="caption" color="text.secondary">Ansicht:</Typography>
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
value={filterPriority}
|
value={viewMode}
|
||||||
exclusive
|
exclusive
|
||||||
onChange={(_, v) => v && setFilterPriority(v)}
|
onChange={(_, v) => v && setViewMode(v)}
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
<ToggleButton value="ALL" sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>Alle</ToggleButton>
|
<ToggleButton value="list" sx={{ textTransform: 'none', fontSize: '0.75rem', px: 1.5, py: 0.5 }}>Liste</ToggleButton>
|
||||||
{Object.values(ReminderPriority).map(p => (
|
<ToggleButton value="card" sx={{ textTransform: 'none', fontSize: '0.75rem', px: 1.5, py: 0.5 }}>Karten</ToggleButton>
|
||||||
<ToggleButton key={p} value={p} sx={{ textTransform: 'none', fontSize: '0.7rem', px: 1, py: 0.375 }}>
|
</ToggleButtonGroup>
|
||||||
{PRIORITY_LABELS[p]}
|
|
||||||
</ToggleButton>
|
|
||||||
))}
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,32 +1,46 @@
|
|||||||
import { Box, Paper, Typography, Skeleton } from '@mui/material'
|
import { Box, Typography, Skeleton } from '@mui/material'
|
||||||
import { AlertTriangle, Calendar, CalendarDays, Eye } from 'lucide-react'
|
import { AlertOctagon, Calendar, CalendarDays, Eye } from 'lucide-react'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { useReminderInsights } from '../../hooks/useReminders'
|
import { useReminderInsights } from '../../hooks/useReminders'
|
||||||
|
import { useReminderStore } from '../../stores/reminderStore'
|
||||||
|
import type { FilterHorizon } from '../../stores/reminderStore'
|
||||||
|
import { ReminderType } from '../../domain/reminder'
|
||||||
|
|
||||||
interface KpiItemProps {
|
interface KpiCardProps {
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
label: string
|
label: string
|
||||||
value: number | string
|
value: number | string
|
||||||
color: string
|
color: string
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function KpiItem({ icon, label, value, color }: KpiItemProps) {
|
function KpiCard({ icon, label, value, color, active, onClick }: KpiCardProps) {
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Box
|
||||||
variant="outlined"
|
onClick={onClick}
|
||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
p: 1.5,
|
p: 1.5,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 1.5,
|
gap: 1.5,
|
||||||
borderColor: '#e2e8f0',
|
border: active ? `2px solid ${color}` : '1px solid #e2e8f0',
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: active ? `${color}10` : 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s',
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: `${color}0d`,
|
||||||
|
borderColor: color,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: 36,
|
width: 34,
|
||||||
height: 36,
|
height: 34,
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
bgcolor: `${color}18`,
|
bgcolor: `${color}18`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -41,16 +55,32 @@ function KpiItem({ icon, label, value, color }: KpiItemProps) {
|
|||||||
<Typography variant="h6" sx={{ fontWeight: 700, color, lineHeight: 1.2, fontSize: '1.25rem' }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, color, lineHeight: 1.2, fontSize: '1.25rem' }}>
|
||||||
{value}
|
{value}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
|
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap', fontSize: '0.7rem' }}>
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReminderKpiBar() {
|
export function ReminderKpiBar() {
|
||||||
const { data, isLoading } = useReminderInsights()
|
const { data, isLoading } = useReminderInsights()
|
||||||
|
const { filterHorizon, setFilterHorizon, filterType, setFilterType } = useReminderStore(
|
||||||
|
useShallow(s => ({
|
||||||
|
filterHorizon: s.filterHorizon,
|
||||||
|
setFilterHorizon: s.setFilterHorizon,
|
||||||
|
filterType: s.filterType,
|
||||||
|
setFilterType: s.setFilterType,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
function toggleHorizon(h: FilterHorizon) {
|
||||||
|
setFilterHorizon(filterHorizon === h ? 'ALL' : h)
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePreMarket() {
|
||||||
|
setFilterType(filterType === ReminderType.SCHATTENMARKT_RELEASE ? 'ALL' : ReminderType.SCHATTENMARKT_RELEASE)
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -62,33 +92,41 @@ export function ReminderKpiBar() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const insights = data ?? { urgentCount: 0, dueThisWeek: 0, dueThisMonth: 0, schattenmarktReadyCount: 0 }
|
const ins = data ?? { overdueCount: 0, dueThisWeek: 0, dueThisMonth: 0, schattenmarktReadyCount: 0 }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex gap-3">
|
<Box className="flex gap-3">
|
||||||
<KpiItem
|
<KpiCard
|
||||||
icon={<AlertTriangle size={18} color="#dc2626" />}
|
icon={<AlertOctagon size={18} color="#dc2626" />}
|
||||||
label="Dringend"
|
label="Überfällig"
|
||||||
value={insights.urgentCount}
|
value={ins.overdueCount}
|
||||||
color="#dc2626"
|
color="#dc2626"
|
||||||
|
active={filterHorizon === 'OVERDUE'}
|
||||||
|
onClick={() => toggleHorizon('OVERDUE')}
|
||||||
/>
|
/>
|
||||||
<KpiItem
|
<KpiCard
|
||||||
icon={<Calendar size={18} color="#ea580c" />}
|
icon={<Calendar size={18} color="#ea580c" />}
|
||||||
label="Diese Woche"
|
label="Diese Woche"
|
||||||
value={insights.dueThisWeek}
|
value={ins.dueThisWeek}
|
||||||
color="#ea580c"
|
color="#ea580c"
|
||||||
|
active={filterHorizon === 'THIS_WEEK'}
|
||||||
|
onClick={() => toggleHorizon('THIS_WEEK')}
|
||||||
/>
|
/>
|
||||||
<KpiItem
|
<KpiCard
|
||||||
icon={<CalendarDays size={18} color="#0369a1" />}
|
icon={<CalendarDays size={18} color="#0369a1" />}
|
||||||
label="Dieser Monat"
|
label="Dieser Monat"
|
||||||
value={insights.dueThisMonth}
|
value={ins.dueThisMonth}
|
||||||
color="#0369a1"
|
color="#0369a1"
|
||||||
|
active={filterHorizon === 'THIS_MONTH'}
|
||||||
|
onClick={() => toggleHorizon('THIS_MONTH')}
|
||||||
/>
|
/>
|
||||||
<KpiItem
|
<KpiCard
|
||||||
icon={<Eye size={18} color="#be185d" />}
|
icon={<Eye size={18} color="#be185d" />}
|
||||||
label="Pre-Market Risiko"
|
label="Pre-Market Risiko"
|
||||||
value={insights.schattenmarktReadyCount}
|
value={ins.schattenmarktReadyCount}
|
||||||
color="#be185d"
|
color="#be185d"
|
||||||
|
active={filterType === ReminderType.SCHATTENMARKT_RELEASE}
|
||||||
|
onClick={togglePreMarket}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ import { ReminderTypeBadge } from './ReminderTypeBadge'
|
|||||||
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
|
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
|
||||||
import { useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders'
|
import { useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders'
|
||||||
import { useReminderStore } from '../../stores/reminderStore'
|
import { useReminderStore } from '../../stores/reminderStore'
|
||||||
import { ReminderStatus } from '../../domain/reminder'
|
import { ReminderStatus, ReminderPriority } from '../../domain/reminder'
|
||||||
|
|
||||||
|
const PRIORITY_BORDER: Record<string, string> = {
|
||||||
|
[ReminderPriority.URGENT]: '#dc2626',
|
||||||
|
[ReminderPriority.HIGH]: '#ea580c',
|
||||||
|
[ReminderPriority.MEDIUM]: '#ca8a04',
|
||||||
|
[ReminderPriority.LOW]: 'transparent',
|
||||||
|
}
|
||||||
|
|
||||||
const STATUS_CHIP_COLOR: Record<string, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
|
const STATUS_CHIP_COLOR: Record<string, 'default' | 'success' | 'warning' | 'error' | 'info'> = {
|
||||||
ACTIVE: 'info',
|
ACTIVE: 'info',
|
||||||
@@ -56,6 +63,8 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
|
|||||||
snooze.mutate({ id: reminder.id, until: '2026-05-27' })
|
snooze.mutate({ id: reminder.id, until: '2026-05-27' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const borderColor = PRIORITY_BORDER[reminder.priority] ?? 'transparent'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
onClick={handleRowClick}
|
onClick={handleRowClick}
|
||||||
@@ -64,9 +73,11 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props
|
|||||||
gridTemplateColumns: '100px 130px 1fr 140px 90px 100px 90px',
|
gridTemplateColumns: '100px 130px 1fr 140px 90px 100px 90px',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 1,
|
gap: 1,
|
||||||
px: 2,
|
pl: 1.5,
|
||||||
|
pr: 2,
|
||||||
py: 1.25,
|
py: 1.25,
|
||||||
borderBottom: '1px solid #f1f5f9',
|
borderBottom: '1px solid #f1f5f9',
|
||||||
|
borderLeft: `3px solid ${borderColor}`,
|
||||||
bgcolor: 'white',
|
bgcolor: 'white',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
'&:hover': { bgcolor: '#f8fafc' },
|
'&:hover': { bgcolor: '#f8fafc' },
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export const reminderService = {
|
|||||||
(r.shadowMarketRisk === ShadowMarketRisk.HIGH),
|
(r.shadowMarketRisk === ShadowMarketRisk.HIGH),
|
||||||
).length
|
).length
|
||||||
|
|
||||||
|
const overdueCount = active.filter(r => daysDiff(r.dueDate) <= 0).length
|
||||||
|
|
||||||
const activeDays = active
|
const activeDays = active
|
||||||
.filter(r => daysDiff(r.dueDate) > 0)
|
.filter(r => daysDiff(r.dueDate) > 0)
|
||||||
.map(r => daysDiff(r.dueDate))
|
.map(r => daysDiff(r.dueDate))
|
||||||
@@ -60,6 +62,7 @@ export const reminderService = {
|
|||||||
dueThisWeek,
|
dueThisWeek,
|
||||||
dueThisMonth,
|
dueThisMonth,
|
||||||
schattenmarktReadyCount,
|
schattenmarktReadyCount,
|
||||||
|
overdueCount,
|
||||||
avgDaysToAction,
|
avgDaysToAction,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { ReminderType, ReminderStatus, ReminderPriority } from '../domain/reminder'
|
import type { ReminderType, ReminderStatus, ReminderPriority } from '../domain/reminder'
|
||||||
|
|
||||||
|
export type FilterHorizon = 'ALL' | 'OVERDUE' | 'THIS_WEEK' | 'THIS_MONTH' | 'LATER'
|
||||||
|
|
||||||
interface ReminderStore {
|
interface ReminderStore {
|
||||||
selectedId: string | null
|
selectedId: string | null
|
||||||
setSelectedId: (id: string | null) => void
|
setSelectedId: (id: string | null) => void
|
||||||
@@ -8,10 +10,12 @@ interface ReminderStore {
|
|||||||
setDrawerOpen: (open: boolean) => void
|
setDrawerOpen: (open: boolean) => void
|
||||||
filterType: ReminderType | 'ALL'
|
filterType: ReminderType | 'ALL'
|
||||||
setFilterType: (t: ReminderType | 'ALL') => void
|
setFilterType: (t: ReminderType | 'ALL') => void
|
||||||
filterStatus: ReminderStatus | 'ALL'
|
filterStatus: ReminderStatus | 'ALL' | 'ACTIVE'
|
||||||
setFilterStatus: (s: ReminderStatus | 'ALL') => void
|
setFilterStatus: (s: ReminderStatus | 'ALL' | 'ACTIVE') => void
|
||||||
filterPriority: ReminderPriority | 'ALL'
|
filterPriority: ReminderPriority | 'ALL'
|
||||||
setFilterPriority: (p: ReminderPriority | 'ALL') => void
|
setFilterPriority: (p: ReminderPriority | 'ALL') => void
|
||||||
|
filterHorizon: FilterHorizon
|
||||||
|
setFilterHorizon: (h: FilterHorizon) => void
|
||||||
searchQuery: string
|
searchQuery: string
|
||||||
setSearchQuery: (q: string) => void
|
setSearchQuery: (q: string) => void
|
||||||
viewMode: 'list' | 'card'
|
viewMode: 'list' | 'card'
|
||||||
@@ -25,10 +29,12 @@ export const useReminderStore = create<ReminderStore>((set) => ({
|
|||||||
setDrawerOpen: (open) => set({ drawerOpen: open }),
|
setDrawerOpen: (open) => set({ drawerOpen: open }),
|
||||||
filterType: 'ALL',
|
filterType: 'ALL',
|
||||||
setFilterType: (t) => set({ filterType: t }),
|
setFilterType: (t) => set({ filterType: t }),
|
||||||
filterStatus: 'ALL',
|
filterStatus: 'ACTIVE',
|
||||||
setFilterStatus: (s) => set({ filterStatus: s }),
|
setFilterStatus: (s) => set({ filterStatus: s }),
|
||||||
filterPriority: 'ALL',
|
filterPriority: 'ALL',
|
||||||
setFilterPriority: (p) => set({ filterPriority: p }),
|
setFilterPriority: (p) => set({ filterPriority: p }),
|
||||||
|
filterHorizon: 'ALL',
|
||||||
|
setFilterHorizon: (h) => set({ filterHorizon: h }),
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
setSearchQuery: (q) => set({ searchQuery: q }),
|
setSearchQuery: (q) => set({ searchQuery: q }),
|
||||||
viewMode: 'list',
|
viewMode: 'list',
|
||||||
|
|||||||
Reference in New Issue
Block a user