Files
Mieterportal/src/pages/Dashboard.tsx
T

360 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import {
ArrowRight,
Building2,
CalendarClock,
CircleAlert,
Euro,
Megaphone,
TicketCheck,
Users,
Wallet,
Wrench,
} from 'lucide-react'
import { Link } from 'react-router'
import { useStore, fmtEUR } from '../store/store'
import { fmtDatum } from '../lib/format'
const monthShort = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
function StatCard({
icon: Icon,
label,
value,
sub,
tone = 'teal',
}: {
icon: React.ElementType
label: string
value: string
sub?: string
tone?: 'teal' | 'amber' | 'red' | 'blue'
}) {
const tones = {
teal: 'bg-teal-50 text-teal-700',
amber: 'bg-amber-50 text-amber-700',
red: 'bg-red-50 text-red-600',
blue: 'bg-blue-50 text-blue-700',
}
return (
<div className="rounded-2xl border bg-white p-5 shadow-sm">
<div className={`mb-3 inline-flex rounded-xl p-2.5 ${tones[tone]}`}>
<Icon className="h-5 w-5" />
</div>
<div className="text-2xl font-bold tracking-tight">{value}</div>
<div className="mt-0.5 text-sm text-slate-500">{label}</div>
{sub && <div className="mt-1 text-xs text-slate-400">{sub}</div>}
</div>
)
}
function TenantDashboard() {
const { meineEinheit: demoUnit, me, transactions, tickets, threads, announcements, mieter } = useStore()
const myTx = transactions.filter((t) => t.unitId === demoUnit.id)
const openTx = myTx.filter((t) => t.status !== 'bezahlt')
const myTickets = tickets.filter((t) => t.unitId === demoUnit.id)
const unread = threads.filter((t) => t.unreadByTenant)
const meinMieter = mieter.find((m) => m.unitId === demoUnit.id)
const chartData = monthShort.slice(0, 10).map((m, i) => ({
m,
Betrag: myTx.filter((t) => t.year === 2026 && myTx.indexOf(t) % 12 === i).reduce((s, t) => s + (t.status === 'bezahlt' ? t.amount : 0), 0),
}))
return (
<div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">Hallo, {me?.name}</h1>
<p className="text-sm text-slate-500">
{demoUnit.building} · Wohnung {demoUnit.number} · {demoUnit.size} m²
</p>
</div>
<div className="rounded-xl bg-amber-50 px-4 py-2 text-sm text-amber-800">
<CalendarClock className="mr-2 inline h-4 w-4" />
Nächste Zahlung: Oktober-Miete bis 03.10.2026
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
icon={Wallet}
label="Monatliche Warmmiete"
value={meinMieter ? fmtEUR.format(meinMieter.warmmiete) : '–'}
sub={meinMieter ? `Kaltmiete ${fmtEUR.format(meinMieter.kaltmiete)} + NK` : 'Mieterdaten folgen'}
/>
<StatCard
icon={Euro}
label={openTx.length > 0 ? 'Offene Forderungen' : 'Keine offenen Forderungen'}
value={openTx.length > 0 ? fmtEUR.format(openTx.reduce((s, t) => s + t.amount, 0)) : '0,00 €'}
tone={openTx.length > 0 ? 'amber' : 'teal'}
sub={openTx.length > 0 ? `${openTx.length} Posten zu zahlen` : 'Alles bezahlt – danke!'}
/>
<StatCard
icon={Wrench}
label="Meine Schadensmeldungen"
value={`${myTickets.length}`}
tone="blue"
sub={`${myTickets.filter((t) => t.status !== 'erledigt').length} in Bearbeitung`}
/>
<StatCard
icon={TicketCheck}
label="Mietverhältnis seit"
value={meinMieter?.mietbeginnAm ? fmtDatum(meinMieter.mietbeginnAm) : '–'}
sub={meinMieter ? `Kaution: ${fmtEUR.format(meinMieter.kautionHoehe)} hinterlegt` : 'Mieterdaten folgen'}
/>
</div>
<div className="grid gap-4 lg:grid-cols-3">
<div className="rounded-2xl border bg-white p-5 shadow-sm lg:col-span-2">
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold">Gezahlte Miete 2026</h2>
<Link to="/mietkonto" className="flex items-center gap-1 text-sm font-medium text-teal-700 hover:underline">
Zum Mietkonto <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="m" tick={{ fontSize: 12 }} stroke="#94a3b8" />
<YAxis tick={{ fontSize: 12 }} stroke="#94a3b8" width={70} />
<Tooltip formatter={(v) => fmtEUR.format(Number(v))} />
<Bar dataKey="Betrag" fill="#0d9488" radius={[6, 6, 0, 0]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="rounded-2xl border bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<Megaphone className="h-4.5 w-4.5 text-teal-700" />
<h2 className="font-semibold">Ankündigungen</h2>
</div>
<ul className="space-y-3">
{announcements.slice(0, 3).map((a) => (
<li key={a.id} className="rounded-xl bg-slate-50 p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-teal-700">
{a.tag}
</span>
<span className="text-xs text-slate-400">{fmtDatum(a.date)}</span>
</div>
<div className="mt-1 text-sm font-medium">{a.title}</div>
<div className="mt-0.5 line-clamp-2 text-xs text-slate-500">{a.text}</div>
</li>
))}
</ul>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h2 className="font-semibold">Offene Schadensmeldungen</h2>
<Link to="/schadensmeldungen" className="flex items-center gap-1 text-sm font-medium text-teal-700 hover:underline">
Alle anzeigen <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
{myTickets.filter((t) => t.status !== 'erledigt').length === 0 ? (
<p className="text-sm text-slate-500">Aktuell keine offenen Meldungen.</p>
) : (
<ul className="space-y-2">
{myTickets
.filter((t) => t.status !== 'erledigt')
.map((t) => (
<li key={t.id} className="flex items-center justify-between rounded-xl bg-slate-50 px-4 py-3">
<div>
<div className="text-sm font-medium">{t.title}</div>
<div className="text-xs text-slate-400">{t.category} · {fmtDatum(t.createdAt)}</div>
</div>
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${
t.status === 'offen' ? 'bg-amber-100 text-amber-800' : 'bg-blue-100 text-blue-800'
}`}>
{t.status}
</span>
</li>
))}
</ul>
)}
</div>
<div className="rounded-2xl border bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h2 className="font-semibold">Nachrichten</h2>
<Link to="/nachrichten" className="flex items-center gap-1 text-sm font-medium text-teal-700 hover:underline">
Posteingang <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
{unread.length === 0 ? (
<p className="text-sm text-slate-500">Keine ungelesenen Nachrichten.</p>
) : (
<ul className="space-y-2">
{unread.map((t) => (
<li key={t.id} className="rounded-xl bg-teal-50 px-4 py-3">
<div className="text-sm font-medium">{t.subject}</div>
<div className="mt-0.5 line-clamp-1 text-xs text-slate-500">
{t.messages[t.messages.length - 1]?.text}
</div>
</li>
))}
</ul>
)}
</div>
</div>
</div>
)
}
function AdminDashboard() {
const { units, transactions, tickets, announcements, buildings: gebaeude, mieter } = useStore()
const rented = units.filter((u) => u.status === 'vermietet')
const occupancy = units.length ? Math.round((rented.length / units.length) * 100) : 0
const monthlyIncome = mieter.reduce((s, m) => s + m.warmmiete, 0)
const openTickets = tickets.filter((t) => t.status === 'offen')
const overdue = transactions.filter((t) => t.status === 'überfällig')
const incomeData = monthShort.slice(0, 10).map((m, i) => ({
m,
Einnahmen: transactions
.filter((t) => t.month === ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober'][i] && t.status === 'bezahlt')
.reduce((s, t) => s + t.amount, 0),
}))
const buildings = gebaeude.map((g) => {
const us = units.filter((u) => u.building === g.name)
return {
name: g.name,
Vermietet: us.filter((u) => u.status === 'vermietet').length,
Leerstand: us.filter((u) => u.status === 'leerstand').length,
}
})
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Verwaltungs-Dashboard</h1>
<p className="text-sm text-slate-500">
Bestand: {buildings.length} {buildings.length === 1 ? 'Haus' : 'Häuser'} · {units.length} Wohneinheiten · {rented.length} vermietet
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard icon={Building2} label="Wohneinheiten gesamt" value={String(units.length)} sub={`${units.filter((u) => u.status === 'leerstand').length} aktuell im Leerstand`} />
<StatCard icon={Users} label="Vermietungsquote" value={`${occupancy} %`} sub={`${rented.length} von ${units.length} Wohneinheiten`} />
<StatCard icon={Euro} label="Monatliche Warmmiete (Soll)" value={fmtEUR.format(monthlyIncome)} sub="Summe aller vermieteten Einheiten" />
<StatCard
icon={CircleAlert}
label="Aufmerksam erforderlich"
value={`${openTickets.length} Meldungen · ${overdue.length} überfällig`}
tone={openTickets.length + overdue.length > 0 ? 'red' : 'teal'}
sub="Offene Schäden und rückständige Zahlungen"
/>
</div>
<div className="grid gap-4 lg:grid-cols-3">
<div className="rounded-2xl border bg-white p-5 shadow-sm lg:col-span-2">
<h2 className="mb-4 font-semibold">Eingänge 2026 (bezahlte Mieten)</h2>
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={incomeData}>
<defs>
<linearGradient id="gIncome" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#0d9488" stopOpacity={0.35} />
<stop offset="100%" stopColor="#0d9488" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="m" tick={{ fontSize: 12 }} stroke="#94a3b8" />
<YAxis tick={{ fontSize: 12 }} stroke="#94a3b8" width={80} />
<Tooltip formatter={(v) => fmtEUR.format(Number(v))} />
<Area type="monotone" dataKey="Einnahmen" stroke="#0d9488" fill="url(#gIncome)" strokeWidth={2.5} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
<div className="rounded-2xl border bg-white p-5 shadow-sm">
<h2 className="mb-4 font-semibold">Belegung pro Haus</h2>
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={buildings} layout="vertical" margin={{ left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 11 }} stroke="#94a3b8" />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} stroke="#94a3b8" width={95} />
<Tooltip />
<Bar dataKey="Vermietet" stackId="a" fill="#0d9488" radius={[0, 0, 0, 0]} isAnimationActive={false} />
<Bar dataKey="Leerstand" stackId="a" fill="#f59e0b" radius={[0, 6, 6, 0]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="mt-2 flex gap-4 text-xs text-slate-500">
<span className="flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-teal-600" /> vermietet</span>
<span className="flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-amber-500" /> Leerstand</span>
</div>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h2 className="font-semibold">Neueste Schadensmeldungen</h2>
<Link to="/schadensmeldungen" className="flex items-center gap-1 text-sm font-medium text-teal-700 hover:underline">
Alle anzeigen <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<ul className="space-y-2">
{tickets.slice(0, 4).map((t) => (
<li key={t.id} className="flex items-center justify-between gap-3 rounded-xl bg-slate-50 px-4 py-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{t.title}</div>
<div className="text-xs text-slate-400">
{units.find((u) => u.id === t.unitId)?.building}, Whg. {units.find((u) => u.id === t.unitId)?.number} · {t.tenant}
</div>
</div>
<span className={`shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold ${
t.status === 'offen' ? 'bg-amber-100 text-amber-800'
: t.status === 'in Bearbeitung' ? 'bg-blue-100 text-blue-800'
: 'bg-emerald-100 text-emerald-800'
}`}>
{t.status}
</span>
</li>
))}
</ul>
</div>
<div className="rounded-2xl border bg-white p-5 shadow-sm">
<h2 className="mb-3 font-semibold">Letzte Ankündigung</h2>
<div className="rounded-xl bg-teal-50 p-4">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wide text-teal-700">{announcements[0].tag}</span>
<span className="text-xs text-slate-400">{announcements[0].date}</span>
</div>
<div className="mt-1 font-medium">{announcements[0].title}</div>
<p className="mt-1 text-sm text-slate-600">{announcements[0].text}</p>
</div>
<p className="mt-3 text-xs text-slate-400">
Ankündigungen sind im Mieter-Dashboard sowie als Nachricht sichtbar.
</p>
</div>
</div>
</div>
)
}
export default function Dashboard() {
const { role } = useStore()
return role === 'tenant' ? <TenantDashboard /> : <AdminDashboard />
}