- 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
358 lines
15 KiB
TypeScript
358 lines
15 KiB
TypeScript
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 />
|
||
}
|