Hocks Mieterportal: 5 Module (Dashboard, Mietkonto, Schadensmeldungen, Dokumente, Nachrichten), Docker-Setup, eigenes Branding
This commit is contained in:
@@ -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 />
|
||||
}
|
||||
Reference in New Issue
Block a user