Files
Mieterportal/src/pages/Dashboard.tsx
T
ah-admin fbe033ebd6 v1.1: Rebrand Hinterstein Immobilien + echtes Auth-System
- Rebrand: Kunden-Logo, Joseph-Hinterstein-Portrait, Kontakt-Banner, Texte
- Login-Pflicht auf /login (Route-Guard), Gruppen Verwaltung/Mieter
- Mieter-Registrierung per einmaligem Registrierungscode
- Benutzerverwaltung als 6. Modul (/benutzer, nur Verwaltung)
- Demo-Zugänge mit Passwort 0815 (j.hinterstein, a.berger, m.wolf)
- Mail-Server-API: Token-Auth (/send, X-Api-Token), Mail-Stack live auf Server
- Store-Key hinterstein-mieterportal-v2, Doku (kimi.md, README) nachgeführt
2026-10-02 12:11:43 +02:00

358 lines
15 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'
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 } = 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 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={fmtEUR.format(demoUnit.warmRent)}
sub={`Kaltmiete ${fmtEUR.format(demoUnit.coldRent)} + NK`}
/>
<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="März 2024"
sub="Kaution: 1.500,00 € hinterlegt"
/>
</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">{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} · {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 } = useStore()
const rented = units.filter((u) => u.status === 'vermietet')
const occupancy = Math.round((rented.length / units.length) * 100)
const monthlyIncome = rented.reduce((s, u) => s + u.warmRent, 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 = ['Hauptstraße 12', 'Ringweg 3', 'Am Park 5'].map((b) => {
const us = units.filter((u) => u.building === b)
return {
name: b,
Vermietet: us.filter((u) => u.status === 'vermietet').length,
Frei: us.filter((u) => u.status === 'frei').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: 3 Häuser · 50 Wohnungen · {rented.length} vermietet
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard icon={Building2} label="Wohnungen gesamt" value="50" sub={`${units.filter((u) => u.status === 'frei').length} aktuell frei`} />
<StatCard icon={Users} label="Vermietungsquote" value={`${occupancy} %`} sub={`${rented.length} von 50 Wohnungen`} />
<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="Frei" 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" /> frei</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 />
}