diff --git a/src/components/supply/ReminderFeed.tsx b/src/components/supply/ReminderFeed.tsx index 3e3d574..9170b3d 100644 --- a/src/components/supply/ReminderFeed.tsx +++ b/src/components/supply/ReminderFeed.tsx @@ -1,5 +1,5 @@ import { useMemo, useCallback } from 'react' -import { Box, Typography } from '@mui/material' +import { Box, Chip, Typography } from '@mui/material' import { useReminders } from '../../hooks/useReminders' import { useShallow } from 'zustand/react/shallow' import { useReminderStore } from '../../stores/reminderStore' @@ -8,24 +8,47 @@ import { ReminderCard } from './ReminderCard' import { ReminderSkeleton } from './ReminderSkeleton' import { ReminderEmptyState } from './ReminderEmptyState' 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( reminders: Reminder[], filterType: string, filterStatus: string, - filterPriority: string, + filterHorizon: string, searchQuery: string, ): Reminder[] { 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 (filterStatus !== 'ALL' && r.status !== filterStatus) return false - if (filterPriority !== 'ALL' && r.priority !== filterPriority) return false + if (filterHorizon !== 'ALL' && getHorizon(r.dueDate) !== filterHorizon) return false if (searchQuery) { const q = searchQuery.toLowerCase() - const haystack = `${r.propertyTitle} ${r.propertyCity} ${r.tenantName}`.toLowerCase() - if (!haystack.includes(q)) return false + const hay = `${r.propertyTitle} ${r.propertyCity} ${r.tenantName}`.toLowerCase() + if (!hay.includes(q)) return false } return true }) @@ -34,59 +57,83 @@ function applyFilters( export function ReminderFeed() { const { data, isLoading } = useReminders() const { - filterType, filterStatus, filterPriority, searchQuery, viewMode, - setFilterType, setFilterStatus, setFilterPriority, setSearchQuery, + filterType, filterStatus, filterHorizon, searchQuery, viewMode, + setFilterType, setFilterStatus, setFilterHorizon, setSearchQuery, } = useReminderStore(useShallow(s => ({ filterType: s.filterType, filterStatus: s.filterStatus, - filterPriority: s.filterPriority, searchQuery: s.searchQuery, + filterHorizon: s.filterHorizon, searchQuery: s.searchQuery, viewMode: s.viewMode, setFilterType: s.setFilterType, - setFilterStatus: s.setFilterStatus, setFilterPriority: s.setFilterPriority, + setFilterStatus: s.setFilterStatus, setFilterHorizon: s.setFilterHorizon, setSearchQuery: s.setSearchQuery, }))) const reminders = data ?? [] const filtered = useMemo( - () => applyFilters(reminders, filterType, filterStatus, filterPriority, searchQuery), - [reminders, filterType, filterStatus, filterPriority, searchQuery], + () => applyFilters(reminders, filterType, filterStatus, filterHorizon, searchQuery), + [reminders, filterType, filterStatus, filterHorizon, searchQuery], ) + const grouped = useMemo(() => { + const map: Record = { + OVERDUE: [], THIS_WEEK: [], THIS_MONTH: [], LATER: [], + } + filtered.forEach(r => map[getHorizon(r.dueDate)].push(r)) + return map + }, [filtered]) + const resetFilters = useCallback(() => { setFilterType('ALL') - setFilterStatus('ALL') - setFilterPriority('ALL') + setFilterStatus('ACTIVE') + setFilterHorizon('ALL') setSearchQuery('') - }, [setFilterType, setFilterStatus, setFilterPriority, setSearchQuery]) + }, [setFilterType, setFilterStatus, setFilterHorizon, setSearchQuery]) if (isLoading) return - - if (filtered.length === 0) { - return - } + if (filtered.length === 0) return if (viewMode === 'card') { return ( - - {filtered.map(r => ( - - ))} + + {HORIZON_CONFIG.map(({ key, label, color, bg, border }) => { + const group = grouped[key] + if (!group || group.length === 0) return null + return ( + + + + + {label} + + + + + {group.map(r => )} + + + ) + })} ) } return ( - + {/* Table header */} - {filtered.map(r => ( - - ))} + {/* Grouped rows */} + {HORIZON_CONFIG.map(({ key, label, color, bg, border }) => { + const group = grouped[key] + if (!group || group.length === 0) return null + return ( + + {/* Section divider */} + + + {label} + + + + + {/* Rows */} + {group.map(r => )} + + ) + })} ) } diff --git a/src/components/supply/ReminderFilterBar.tsx b/src/components/supply/ReminderFilterBar.tsx index b9fe3ec..ffda803 100644 --- a/src/components/supply/ReminderFilterBar.tsx +++ b/src/components/supply/ReminderFilterBar.tsx @@ -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 { useShallow } from 'zustand/react/shallow' import { useReminderStore } from '../../stores/reminderStore' -import { ReminderType, ReminderStatus, ReminderPriority } from '../../domain/reminder' -import type { ReminderType as ReminderTypeType, ReminderStatus as ReminderStatusType, ReminderPriority as ReminderPriorityType } from '../../domain/reminder' +import { ReminderType } from '../../domain/reminder' +import type { ReminderType as ReminderTypeType } from '../../domain/reminder' const TYPE_LABELS: Record = { LEASE_EXPIRY: 'Mietablauf', @@ -16,127 +16,89 @@ const TYPE_LABELS: Record = { CUSTOM: 'Individuell', } -const STATUS_LABELS: Record = { - ACTIVE: 'Aktiv', - SNOOZED: 'Schlummern', - COMPLETED: 'Erledigt', - DISMISSED: 'Verworfen', -} - -const PRIORITY_LABELS: Record = { - URGENT: 'Dringend', - HIGH: 'Hoch', - MEDIUM: 'Mittel', - LOW: 'Niedrig', -} - export function ReminderFilterBar() { const { filterType, setFilterType, filterStatus, setFilterStatus, - filterPriority, setFilterPriority, searchQuery, setSearchQuery, viewMode, setViewMode, } = useReminderStore(useShallow(s => ({ filterType: s.filterType, setFilterType: s.setFilterType, filterStatus: s.filterStatus, setFilterStatus: s.setFilterStatus, - filterPriority: s.filterPriority, setFilterPriority: s.setFilterPriority, searchQuery: s.searchQuery, setSearchQuery: s.setSearchQuery, viewMode: s.viewMode, setViewMode: s.setViewMode, }))) + const showArchive = filterStatus === 'ALL' + return ( - - - {/* Search */} - setSearchQuery(e.target.value)} - slotProps={{ - input: { - startAdornment: ( - - - - ), - }, - }} - sx={{ width: 220, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }} - /> + + {/* Search */} + setSearchQuery(e.target.value)} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + sx={{ width: 200, '& .MuiInputBase-root': { fontSize: '0.8125rem' } }} + /> - {/* View mode */} - - Ansicht: - v && setViewMode(v)} - size="small" - > - Liste - Karten - - - - - - {/* Type filter */} - - Typ: - + Typ: + + + - - {/* Status filter */} - - Status: - v && setFilterStatus(v)} + {/* Archive toggle */} + - Alle - {Object.values(ReminderStatus).map(s => ( - - {STATUS_LABELS[s]} - - ))} - - + checked={showArchive} + onChange={e => setFilterStatus(e.target.checked ? 'ALL' : 'ACTIVE')} + /> + } + label={ + + Abgeschlossene anzeigen + + } + sx={{ m: 0 }} + /> - {/* Priority filter */} - - Priorität: - v && setFilterPriority(v)} - size="small" - > - Alle - {Object.values(ReminderPriority).map(p => ( - - {PRIORITY_LABELS[p]} - - ))} - - + {/* View mode */} + + Ansicht: + v && setViewMode(v)} + size="small" + > + Liste + Karten + ) diff --git a/src/components/supply/ReminderKpiBar.tsx b/src/components/supply/ReminderKpiBar.tsx index 2602ea2..0aedb46 100644 --- a/src/components/supply/ReminderKpiBar.tsx +++ b/src/components/supply/ReminderKpiBar.tsx @@ -1,32 +1,46 @@ -import { Box, Paper, Typography, Skeleton } from '@mui/material' -import { AlertTriangle, Calendar, CalendarDays, Eye } from 'lucide-react' +import { Box, Typography, Skeleton } from '@mui/material' +import { AlertOctagon, Calendar, CalendarDays, Eye } from 'lucide-react' +import { useShallow } from 'zustand/react/shallow' 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 label: string value: number | string color: string + active: boolean + onClick: () => void } -function KpiItem({ icon, label, value, color }: KpiItemProps) { +function KpiCard({ icon, label, value, color, active, onClick }: KpiCardProps) { return ( - {value} - + {label} - + ) } export function ReminderKpiBar() { 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) { 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 ( - } - label="Dringend" - value={insights.urgentCount} + } + label="Überfällig" + value={ins.overdueCount} color="#dc2626" + active={filterHorizon === 'OVERDUE'} + onClick={() => toggleHorizon('OVERDUE')} /> - } label="Diese Woche" - value={insights.dueThisWeek} + value={ins.dueThisWeek} color="#ea580c" + active={filterHorizon === 'THIS_WEEK'} + onClick={() => toggleHorizon('THIS_WEEK')} /> - } label="Dieser Monat" - value={insights.dueThisMonth} + value={ins.dueThisMonth} color="#0369a1" + active={filterHorizon === 'THIS_MONTH'} + onClick={() => toggleHorizon('THIS_MONTH')} /> - } label="Pre-Market Risiko" - value={insights.schattenmarktReadyCount} + value={ins.schattenmarktReadyCount} color="#be185d" + active={filterType === ReminderType.SCHATTENMARKT_RELEASE} + onClick={togglePreMarket} /> ) diff --git a/src/components/supply/ReminderListRow.tsx b/src/components/supply/ReminderListRow.tsx index 14136a6..4a90d75 100644 --- a/src/components/supply/ReminderListRow.tsx +++ b/src/components/supply/ReminderListRow.tsx @@ -7,7 +7,14 @@ import { ReminderTypeBadge } from './ReminderTypeBadge' import { ReminderDaysIndicator } from './ReminderDaysIndicator' import { useCompleteReminder, useDismissReminder, useSnoozeReminder } from '../../hooks/useReminders' import { useReminderStore } from '../../stores/reminderStore' -import { ReminderStatus } from '../../domain/reminder' +import { ReminderStatus, ReminderPriority } from '../../domain/reminder' + +const PRIORITY_BORDER: Record = { + [ReminderPriority.URGENT]: '#dc2626', + [ReminderPriority.HIGH]: '#ea580c', + [ReminderPriority.MEDIUM]: '#ca8a04', + [ReminderPriority.LOW]: 'transparent', +} const STATUS_CHIP_COLOR: Record = { ACTIVE: 'info', @@ -56,6 +63,8 @@ export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props snooze.mutate({ id: reminder.id, until: '2026-05-27' }) } + const borderColor = PRIORITY_BORDER[reminder.priority] ?? 'transparent' + return ( daysDiff(r.dueDate) <= 0).length + const activeDays = active .filter(r => daysDiff(r.dueDate) > 0) .map(r => daysDiff(r.dueDate)) @@ -60,6 +62,7 @@ export const reminderService = { dueThisWeek, dueThisMonth, schattenmarktReadyCount, + overdueCount, avgDaysToAction, } }, diff --git a/src/stores/reminderStore.ts b/src/stores/reminderStore.ts index 083638b..6b20653 100644 --- a/src/stores/reminderStore.ts +++ b/src/stores/reminderStore.ts @@ -1,6 +1,8 @@ import { create } from 'zustand' import type { ReminderType, ReminderStatus, ReminderPriority } from '../domain/reminder' +export type FilterHorizon = 'ALL' | 'OVERDUE' | 'THIS_WEEK' | 'THIS_MONTH' | 'LATER' + interface ReminderStore { selectedId: string | null setSelectedId: (id: string | null) => void @@ -8,10 +10,12 @@ interface ReminderStore { setDrawerOpen: (open: boolean) => void filterType: ReminderType | 'ALL' setFilterType: (t: ReminderType | 'ALL') => void - filterStatus: ReminderStatus | 'ALL' - setFilterStatus: (s: ReminderStatus | 'ALL') => void + filterStatus: ReminderStatus | 'ALL' | 'ACTIVE' + setFilterStatus: (s: ReminderStatus | 'ALL' | 'ACTIVE') => void filterPriority: ReminderPriority | 'ALL' setFilterPriority: (p: ReminderPriority | 'ALL') => void + filterHorizon: FilterHorizon + setFilterHorizon: (h: FilterHorizon) => void searchQuery: string setSearchQuery: (q: string) => void viewMode: 'list' | 'card' @@ -25,10 +29,12 @@ export const useReminderStore = create((set) => ({ setDrawerOpen: (open) => set({ drawerOpen: open }), filterType: 'ALL', setFilterType: (t) => set({ filterType: t }), - filterStatus: 'ALL', + filterStatus: 'ACTIVE', setFilterStatus: (s) => set({ filterStatus: s }), filterPriority: 'ALL', setFilterPriority: (p) => set({ filterPriority: p }), + filterHorizon: 'ALL', + setFilterHorizon: (h) => set({ filterHorizon: h }), searchQuery: '', setSearchQuery: (q) => set({ searchQuery: q }), viewMode: 'list',