Hocks Mieterportal: 5 Module (Dashboard, Mietkonto, Schadensmeldungen, Dokumente, Nachrichten), Docker-Setup, eigenes Branding

This commit is contained in:
2026-10-02 10:02:19 +02:00
commit 38d5099a2c
91 changed files with 17062 additions and 0 deletions
+357
View File
@@ -0,0 +1,357 @@
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 { demoUnit, 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, Frau Berger</h1>
<p className="text-sm text-slate-500">
Hauptstraße 12 · 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]} />
</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} />
</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]} />
<Bar dataKey="Frei" stackId="a" fill="#f59e0b" radius={[0, 6, 6, 0]} />
</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 />
}