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 />
}
+152
View File
@@ -0,0 +1,152 @@
import { useMemo, useState } from 'react'
import { Download, FileText, FolderOpen, Scale, ReceiptText, FileSignature, FileIcon } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { useStore, DEMO_UNIT_ID } from '../store/store'
const catIcon = (cat: string) => {
switch (cat) {
case 'Mietvertrag':
return <FileSignature className="h-4.5 w-4.5" />
case 'Nebenkostenabrechnung':
return <ReceiptText className="h-4.5 w-4.5" />
case 'Hausordnung':
return <Scale className="h-4.5 w-4.5" />
case 'Protokoll':
return <FileText className="h-4.5 w-4.5" />
default:
return <FileIcon className="h-4.5 w-4.5" />
}
}
export default function Documents() {
const { documents, role, units } = useStore()
const [query, setQuery] = useState('')
const rows = useMemo(() => {
const base =
role === 'tenant'
? documents.filter((d) => d.unitId === DEMO_UNIT_ID || d.unitId === null)
: documents
const q = query.trim().toLowerCase()
return q
? base.filter(
(d) =>
d.title.toLowerCase().includes(q) ||
d.category.toLowerCase().includes(q) ||
(units.find((u) => u.id === d.unitId)?.building ?? '').toLowerCase().includes(q),
)
: base
}, [documents, role, query, units])
const cats = ['Alle', ...Array.from(new Set(rows.map((d) => d.category)))]
const [cat, setCat] = useState('Alle')
const filtered = cat === 'Alle' ? rows : rows.filter((d) => d.category === cat)
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">Dokumente</h1>
<p className="text-sm text-slate-500">
{role === 'tenant'
? 'Verträge, Abrechnungen und Unterlagen für Ihre Wohnung'
: 'Zentraler Dokumentenspeicher des gesamten Bestands'}
</p>
</div>
<Input
placeholder="Dokument suchen …"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-64"
/>
</div>
<div className="flex flex-wrap gap-2">
{cats.map((c) => (
<button
key={c}
onClick={() => setCat(c)}
className={`rounded-full px-4 py-1.5 text-sm font-medium transition ${
cat === c
? 'bg-teal-600 text-white'
: 'bg-white text-slate-600 ring-1 ring-slate-200 hover:ring-teal-400'
}`}
>
{c}
</button>
))}
</div>
<div className="rounded-2xl border bg-white p-5 shadow-sm">
{filtered.length === 0 ? (
<div className="py-10 text-center text-sm text-slate-500">
<FolderOpen className="mx-auto mb-3 h-8 w-8 text-slate-300" />
Keine Dokumente gefunden.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10" />
<TableHead>Dokument</TableHead>
{role === 'admin' && <TableHead>Wohnung</TableHead>}
<TableHead>Kategorie</TableHead>
<TableHead>Datum</TableHead>
<TableHead>Größe</TableHead>
<TableHead className="text-right">Aktion</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((d) => (
<TableRow key={d.id}>
<TableCell>
<span className="inline-flex rounded-lg bg-teal-50 p-2 text-teal-700">
{catIcon(d.category)}
</span>
</TableCell>
<TableCell className="font-medium">{d.title}</TableCell>
{role === 'admin' && (
<TableCell className="text-slate-500">
{d.unitId
? (() => {
const u = units.find((x) => x.id === d.unitId)
return u ? `${u.building}, Whg. ${u.number}` : d.unitId
})()
: 'Alle Häuser'}
</TableCell>
)}
<TableCell>
<Badge variant="secondary" className="bg-slate-100 text-slate-700">
{d.category}
</Badge>
</TableCell>
<TableCell>{d.date}</TableCell>
<TableCell>{d.size}</TableCell>
<TableCell className="text-right">
<button className="inline-flex items-center gap-1 rounded-lg bg-teal-50 px-3 py-1.5 text-xs font-semibold text-teal-700 transition hover:bg-teal-100">
<Download className="h-3.5 w-3.5" /> PDF
</button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<p className="text-xs text-slate-400">
Alle Dokumente werden verschlüsselt gespeichert und sind nur für berechtigte Konten
einsehbar. Die Download-Funktion ist in dieser Demo dargestellt.
</p>
</div>
)
}
+97
View File
@@ -0,0 +1,97 @@
import { useNavigate } from 'react-router'
import { KeyRound, ShieldCheck, Sparkles } from 'lucide-react'
import logo from '../assets/logo.png'
import hero from '../assets/hero.jpg'
import modules from '../assets/modules.jpg'
import { useStore } from '../store/store'
const features = [
'Mietkonto & Nebenkostenabrechnungen',
'Schäden online melden & Status verfolgen',
'Verträge & Dokumente jederzeit griffbereit',
'Direkte Nachrichten an die Verwaltung',
'Aktuelle Ankündigungen Ihres Wohnhauses',
]
export default function Login() {
const navigate = useNavigate()
const { dispatch } = useStore()
const enter = (role: 'tenant' | 'admin') => {
dispatch({ type: 'setRole', role })
navigate('/')
}
return (
<div className="min-h-screen bg-[#0b3a44]">
<div className="mx-auto flex min-h-screen max-w-6xl flex-col">
<header className="flex items-center gap-3 px-6 py-6">
<img src={logo} alt="Hocks Logo" className="h-12 w-12" />
<div>
<div className="text-xl font-bold text-white">Hocks Mieterportal</div>
<div className="text-sm text-teal-200/80">mieterportal.hocks.eu</div>
</div>
</header>
<div className="grid flex-1 items-center gap-8 px-6 pb-10 lg:grid-cols-2">
<div className="relative order-2 overflow-hidden rounded-3xl shadow-2xl lg:order-1">
<img src={hero} alt="Wohnanlage der Hocks Verwaltung" className="h-72 w-full object-cover lg:h-[460px]" />
<div className="absolute inset-0 bg-gradient-to-t from-[#0b3a44]/80 via-transparent to-transparent" />
<div className="absolute bottom-0 p-6 text-white">
<div className="text-lg font-semibold">Ihr Zuhause. Ihr Portal.</div>
<div className="text-sm text-teal-100">
3 Wohnanlagen · 50 Wohnungen · eine Verwaltung
</div>
</div>
</div>
<div className="order-1 lg:order-2">
<h1 className="text-3xl font-bold text-white lg:text-4xl">
Willkommen im Mieterportal
</h1>
<p className="mt-3 max-w-lg text-teal-100/90">
Analog zum Schulportal der Hocks-Gruppe: alle Mietangelegenheiten
digital an einem Ort – schnell, transparent und ohne Papierkram.
</p>
<ul className="mt-6 space-y-2.5">
{features.map((f) => (
<li key={f} className="flex items-center gap-2.5 text-sm text-teal-50">
<Sparkles className="h-4 w-4 shrink-0 text-teal-300" />
{f}
</li>
))}
</ul>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<button
onClick={() => enter('tenant')}
className="flex items-center justify-center gap-2 rounded-xl bg-teal-500 px-6 py-3 font-semibold text-white shadow-lg transition hover:bg-teal-400"
>
<KeyRound className="h-4.5 w-4.5" />
Als Mieterin anmelden (Demo)
</button>
<button
onClick={() => enter('admin')}
className="flex items-center justify-center gap-2 rounded-xl border border-teal-300/40 bg-white/10 px-6 py-3 font-semibold text-white transition hover:bg-white/20"
>
<ShieldCheck className="h-4.5 w-4.5" />
Als Verwaltung anmelden
</button>
</div>
<img
src={modules}
alt="Illustration der Portalmodule"
className="mt-8 hidden w-full max-w-md rounded-2xl opacity-90 lg:block"
/>
</div>
</div>
<footer className="px-6 pb-6 text-center text-xs text-teal-200/60">
© 2026 Hocks Verwaltung · Impressum · Datenschutz
</footer>
</div>
</div>
)
}
+233
View File
@@ -0,0 +1,233 @@
import { useEffect, useRef, useState } from 'react'
import { MailPlus, SendHorizonal, UserRound } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { useStore, DEMO_TENANT, DEMO_UNIT_ID } from '../store/store'
function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
const { dispatch, role } = useStore()
const [open, setOpen] = useState(false)
const [subject, setSubject] = useState('')
const [text, setText] = useState('')
const submit = () => {
if (!subject.trim() || !text.trim()) return
const id = `m-${Date.now()}`
dispatch({
type: 'newThread',
thread: {
id,
unitId: role === 'tenant' ? DEMO_UNIT_ID : 'hs-1',
tenant: role === 'tenant' ? DEMO_TENANT : 'Hocks Verwaltung',
subject: subject.trim(),
unreadByAdmin: true,
unreadByTenant: false,
messages: [
{
id: `${id}-1`,
from: role,
author: role === 'tenant' ? DEMO_TENANT : 'Hocks Verwaltung',
text: text.trim(),
at: new Date().toLocaleString('de-DE'),
},
],
},
})
setSubject('')
setText('')
setOpen(false)
onCreated(id)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="gap-2">
<MailPlus className="h-4 w-4" /> Neue Nachricht
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{role === 'tenant' ? 'Nachricht an die Verwaltung' : 'Nachricht an Mieter'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="s">Betreff</Label>
<Input id="s" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="z. B. Frage zur Abrechnung" />
</div>
<div className="space-y-1.5">
<Label htmlFor="b">Nachricht</Label>
<Textarea id="b" rows={5} value={text} onChange={(e) => setText(e.target.value)} placeholder="Ihre Nachricht …" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button onClick={submit} disabled={!subject.trim() || !text.trim()}>Absenden</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default function Messages() {
const { threads, role, dispatch, units } = useStore()
const visible = role === 'tenant'
? threads.filter((t) => t.unitId === DEMO_UNIT_ID)
: threads
const [activeId, setActiveId] = useState(visible[0]?.id ?? '')
const active = visible.find((t) => t.id === activeId) ?? visible[0]
const [draft, setDraft] = useState('')
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!active) return
dispatch({ type: 'markThreadRead', threadId: active.id, role })
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [active?.id, active?.messages.length, dispatch, role])
const send = () => {
if (!active || !draft.trim()) return
dispatch({
type: 'addMessage',
threadId: active.id,
message: {
id: `${active.id}-${Date.now()}`,
from: role,
author: role === 'tenant' ? DEMO_TENANT : 'Hocks Verwaltung',
text: draft.trim(),
at: new Date().toLocaleString('de-DE'),
},
})
setDraft('')
}
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">Nachrichten</h1>
<p className="text-sm text-slate-500">
{role === 'tenant'
? 'Direktdraht zur Hocks Verwaltung – Antwort meist innerhalb eines Werktags'
: 'Mieter-Kommunikation über alle 50 Wohnungen'}
</p>
</div>
<NewMessageDialog onCreated={setActiveId} />
</div>
<div className="grid gap-4 lg:grid-cols-3">
<div className="space-y-2 lg:col-span-1">
{visible.length === 0 && (
<div className="rounded-2xl border border-dashed bg-white p-8 text-center text-sm text-slate-500">
Noch keine Nachrichten.
</div>
)}
{visible.map((t) => {
const unread = role === 'tenant' ? t.unreadByTenant : t.unreadByAdmin
const unit = units.find((u) => u.id === t.unitId)
return (
<button
key={t.id}
onClick={() => setActiveId(t.id)}
className={`w-full rounded-2xl border p-4 text-left transition ${
active?.id === t.id
? 'border-teal-500 bg-teal-50 ring-2 ring-teal-200'
: 'bg-white hover:border-slate-300'
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-semibold">{t.subject}</span>
{unread && <span className="h-2.5 w-2.5 shrink-0 rounded-full bg-red-500" />}
</div>
<div className="mt-1 truncate text-xs text-slate-500">
{role === 'admin'
? `${t.tenant} · ${unit ? `${unit.building}, Whg. ${unit.number}` : ''}`
: t.messages[t.messages.length - 1]?.author}
</div>
<div className="mt-0.5 truncate text-xs text-slate-400">
{t.messages[t.messages.length - 1]?.text}
</div>
</button>
)
})}
</div>
<div className="flex flex-col rounded-2xl border bg-white shadow-sm lg:col-span-2">
{active ? (
<>
<div className="border-b px-5 py-4">
<div className="font-semibold">{active.subject}</div>
<div className="text-xs text-slate-500">
{role === 'admin'
? `${active.tenant}`
: 'Hocks Verwaltung · Hauptstraße 12'}
</div>
</div>
<div className="max-h-[420px] flex-1 space-y-4 overflow-y-auto p-5">
{active.messages.map((m) => {
const mine = m.from === role
return (
<div key={m.id} className={`flex ${mine ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[75%] rounded-2xl px-4 py-3 text-sm ${
mine
? 'rounded-br-sm bg-teal-600 text-white'
: 'rounded-bl-sm bg-slate-100 text-slate-800'
}`}
>
{!mine && (
<div className="mb-1 flex items-center gap-1.5 text-xs font-semibold text-teal-700">
<UserRound className="h-3.5 w-3.5" /> {m.author}
</div>
)}
<div className="whitespace-pre-wrap">{m.text}</div>
<div className={`mt-1 text-[10px] ${mine ? 'text-teal-100/80' : 'text-slate-400'}`}>
{m.at}
</div>
</div>
</div>
)
})}
<div ref={bottomRef} />
</div>
<div className="flex items-end gap-2 border-t p-4">
<Textarea
rows={2}
placeholder="Antwort schreiben …"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}}
className="resize-none"
/>
<Button onClick={send} disabled={!draft.trim()} className="gap-2">
<SendHorizonal className="h-4 w-4" /> Senden
</Button>
</div>
</>
) : (
<div className="flex flex-1 items-center justify-center p-10 text-sm text-slate-500">
Wählen Sie links eine Unterhaltung aus.
</div>
)}
</div>
</div>
</div>
)
}
+237
View File
@@ -0,0 +1,237 @@
import { useMemo, useState } from 'react'
import {
BadgeEuro,
CalendarDays,
CircleCheck,
CircleAlert,
Clock,
Download,
ReceiptText,
} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useStore, fmtEUR } from '../store/store'
const statusStyle: Record<string, string> = {
bezahlt: 'bg-emerald-100 text-emerald-800',
offen: 'bg-amber-100 text-amber-800',
'überfällig': 'bg-red-100 text-red-700',
}
function TenantPayments() {
const { demoUnit, transactions } = useStore()
const myTx = useMemo(
() =>
transactions
.filter((t) => t.unitId === demoUnit.id)
.slice()
.reverse(),
[transactions, demoUnit],
)
const [filter, setFilter] = useState('alle')
const rows = filter === 'alle' ? myTx : myTx.filter((t) => t.status === filter)
const openSum = myTx.filter((t) => t.status !== 'bezahlt').reduce((s, t) => s + t.amount, 0)
const nk2025 = 86.4 // Guthaben laut Abrechnung
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Mietkonto & Zahlungen</h1>
<p className="text-sm text-slate-500">
Hauptstraße 12 · Whg. {demoUnit.number} – alle Buchungen auf einen Blick
</p>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="rounded-2xl border bg-white p-5 shadow-sm">
<div className="mb-2 flex items-center gap-2 text-sm text-slate-500">
<CalendarDays className="h-4 w-4" /> Fällig am 03.10.2026
</div>
<div className="text-xl font-bold">{fmtEUR.format(demoUnit.warmRent)}</div>
<div className="mt-0.5 text-xs text-slate-500">Warmmiete Oktober (überwiesen ausstehend)</div>
</div>
<div className="rounded-2xl border bg-white p-5 shadow-sm">
<div className="mb-2 flex items-center gap-2 text-sm text-slate-500">
<Clock className="h-4 w-4" /> Offen gesamt
</div>
<div className={`text-xl font-bold ${openSum > 0 ? 'text-amber-600' : ''}`}>
{fmtEUR.format(openSum)}
</div>
<div className="mt-0.5 text-xs text-slate-500">{myTx.filter((t) => t.status !== 'bezahlt').length} offene Posten</div>
</div>
<div className="rounded-2xl border bg-white p-5 shadow-sm">
<div className="mb-2 flex items-center gap-2 text-sm text-slate-500">
<ReceiptText className="h-4 w-4" /> Nebenkosten 2025
</div>
<div className="text-xl font-bold text-emerald-600">+ {fmtEUR.format(nk2025)} Guthaben</div>
<div className="mt-0.5 text-xs text-slate-500">Ausgeglichen mit der Oktober-Miete</div>
</div>
</div>
<div className="rounded-2xl border bg-white p-5 shadow-sm">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="font-semibold">Buchungsverlauf 2026</h2>
<Tabs value={filter} onValueChange={setFilter}>
<TabsList>
<TabsTrigger value="alle">Alle</TabsTrigger>
<TabsTrigger value="bezahlt">Bezahlt</TabsTrigger>
<TabsTrigger value="offen">Offen</TabsTrigger>
<TabsTrigger value="überfällig">Überfällig</TabsTrigger>
</TabsList>
</Tabs>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Monat</TableHead>
<TableHead>Vorgang</TableHead>
<TableHead>Betrag</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Beleg</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.month} {t.year}</TableCell>
<TableCell>{t.type}</TableCell>
<TableCell>{fmtEUR.format(t.amount)}</TableCell>
<TableCell>
<Badge className={statusStyle[t.status]} variant="secondary">
{t.status === 'bezahlt' && <CircleCheck className="mr-1 h-3 w-3" />}
{t.status === 'überfällig' && <CircleAlert className="mr-1 h-3 w-3" />}
{t.status}
</Badge>
</TableCell>
<TableCell className="text-right">
{t.status === 'bezahlt' && (
<button className="inline-flex items-center gap-1 text-xs font-medium text-teal-700 hover:underline">
<Download className="h-3.5 w-3.5" /> PDF
</button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex flex-wrap items-center gap-3 rounded-2xl border border-teal-200 bg-teal-50 p-5 text-sm text-teal-900">
<BadgeEuro className="h-5 w-5 shrink-0" />
<div>
<span className="font-semibold">Zahlungsdaten:</span> IBAN DE44 5001 0517 5407 3249 31 ·
Verwendungszweck: „Miete {demoUnit.number}/HS 10-2026“ · Zahlungsziel jeweils bis zum 3. des Monats.
</div>
</div>
</div>
)
}
function AdminPayments() {
const { units, transactions } = useStore()
const [building, setBuilding] = useState('alle')
const list = units
.filter((u) => u.status === 'vermietet')
.filter((u) => building === 'alle' || u.building === building)
.map((u) => {
const tx = transactions.filter((t) => t.unitId === u.id)
const overdue = tx.filter((t) => t.status === 'überfällig')
const lastPaid = tx.filter((t) => t.status === 'bezahlt').slice(-1)[0]
return { unit: u, overdue, lastPaid }
})
const totals = {
soll: list.reduce((s, r) => s + r.unit.warmRent, 0),
rueckstand: list.reduce((s, r) => s + r.overdue.reduce((a, t) => a + 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">Mietkonto & Zahlungen</h1>
<p className="text-sm text-slate-500">Forderungsmanagement für 50 Wohnungen</p>
</div>
<Select value={building} onValueChange={setBuilding}>
<SelectTrigger className="w-56">
<SelectValue placeholder="Haus wählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Häuser</SelectItem>
<SelectItem value="Hauptstraße 12">Hauptstraße 12</SelectItem>
<SelectItem value="Ringweg 3">Ringweg 3</SelectItem>
<SelectItem value="Am Park 5">Am Park 5</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-2xl border bg-white p-5 shadow-sm">
<div className="text-sm text-slate-500">Monatlicher Miet-Soll</div>
<div className="mt-1 text-2xl font-bold">{fmtEUR.format(totals.soll)}</div>
</div>
<div className="rounded-2xl border bg-white p-5 shadow-sm">
<div className="text-sm text-slate-500">Gesamtrückstand (überfällig)</div>
<div className={`mt-1 text-2xl font-bold ${totals.rueckstand > 0 ? 'text-red-600' : ''}`}>
{fmtEUR.format(totals.rueckstand)}
</div>
</div>
</div>
<div className="rounded-2xl border bg-white p-5 shadow-sm">
<h2 className="mb-4 font-semibold">Zahlungsstatus der Mieter</h2>
<Table>
<TableHeader>
<TableRow>
<TableHead>Wohnung</TableHead>
<TableHead>Mieter</TableHead>
<TableHead>Warmmiete</TableHead>
<TableHead>Letzte Zahlung</TableHead>
<TableHead>Rückstand</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map(({ unit, overdue, lastPaid }) => (
<TableRow key={unit.id}>
<TableCell className="font-medium">
{unit.building}, Whg. {unit.number}
</TableCell>
<TableCell>{unit.tenant}</TableCell>
<TableCell>{fmtEUR.format(unit.warmRent)}</TableCell>
<TableCell>{lastPaid ? `${lastPaid.month} (${lastPaid.paidAt})` : '–'}</TableCell>
<TableCell className={overdue.length ? 'font-semibold text-red-600' : 'text-slate-400'}>
{overdue.length ? fmtEUR.format(overdue.reduce((a, t) => a + t.amount, 0)) : '–'}
</TableCell>
<TableCell>
<Badge
className={overdue.length ? statusStyle['überfällig'] : statusStyle['bezahlt']}
variant="secondary"
>
{overdue.length ? 'Mahnung prüfen' : 'im Soll'}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
export default function Payments() {
const { role } = useStore()
return role === 'tenant' ? <TenantPayments /> : <AdminPayments />
}
+253
View File
@@ -0,0 +1,253 @@
import { useMemo, useState } from 'react'
import { Plus, Wrench, CalendarDays, MapPin } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useStore, DEMO_TENANT, DEMO_UNIT_ID } from '../store/store'
import type { TicketPriority, TicketStatus } from '../types'
const statusStyle: Record<TicketStatus, string> = {
offen: 'bg-amber-100 text-amber-800',
'in Bearbeitung': 'bg-blue-100 text-blue-800',
erledigt: 'bg-emerald-100 text-emerald-800',
}
const prioStyle: Record<TicketPriority, string> = {
hoch: 'bg-red-100 text-red-700',
mittel: 'bg-amber-100 text-amber-700',
niedrig: 'bg-slate-100 text-slate-600',
}
const categories = [
'Sanitär',
'Elektrik',
'Heizung',
'Fenster / Rollladen',
'Balkon / Außenanlage',
'Schlüssel',
'Sonstiges',
]
function NewTicketDialog() {
const { dispatch, role } = useStore()
const [open, setOpen] = useState(false)
const [title, setTitle] = useState('')
const [category, setCategory] = useState('Sanitär')
const [priority, setPriority] = useState<TicketPriority>('mittel')
const [description, setDescription] = useState('')
const submit = () => {
if (!title.trim()) return
dispatch({
type: 'addTicket',
ticket: {
id: `t-${Date.now()}`,
unitId: role === 'tenant' ? DEMO_UNIT_ID : 'hs-1',
tenant: role === 'tenant' ? DEMO_TENANT : 'Hocks Verwaltung (intern)',
title: title.trim(),
description: description.trim() || '–',
category,
priority,
status: 'offen',
createdAt: new Date().toLocaleDateString('de-DE'),
},
})
setTitle('')
setDescription('')
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus className="h-4 w-4" /> Neue Schadensmeldung
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Schaden oder Störung melden</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="t-title">Kurzbeschreibung</Label>
<Input
id="t-title"
placeholder="z. B. Wasserhahn tropft"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Kategorie</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Dringlichkeit</Label>
<Select value={priority} onValueChange={(v) => setPriority(v as TicketPriority)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="niedrig">Niedrig</SelectItem>
<SelectItem value="mittel">Mittel</SelectItem>
<SelectItem value="hoch">Hoch</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="t-desc">Beschreibung</Label>
<Textarea
id="t-desc"
rows={4}
placeholder="Was ist passiert? Seit wann? ..."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button onClick={submit} disabled={!title.trim()}>Meldung absenden</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default function Tickets() {
const { tickets, units, role, dispatch } = useStore()
const [filter, setFilter] = useState<'alle' | TicketStatus>('alle')
const visible = useMemo(
() =>
(role === 'tenant'
? tickets.filter((t) => t.unitId === DEMO_UNIT_ID)
: tickets
).filter((t) => filter === 'alle' || t.status === filter),
[tickets, role, filter],
)
const counts = {
offen: tickets.filter((t) => t.status === 'offen').length,
'in Bearbeitung': tickets.filter((t) => t.status === 'in Bearbeitung').length,
erledigt: tickets.filter((t) => t.status === 'erledigt').length,
}
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">Schadensmeldungen</h1>
<p className="text-sm text-slate-500">
{role === 'tenant'
? 'Meldungen für Ihre Wohnung – vom Eingang bis zur Erledigung'
: 'Alle Meldungen des Bestands – Priorisierung und Statuspflege'}
</p>
</div>
<NewTicketDialog />
</div>
<div className="grid gap-4 sm:grid-cols-3">
{(['offen', 'in Bearbeitung', 'erledigt'] as const).map((s) => (
<button
key={s}
onClick={() => setFilter(filter === s ? 'alle' : s)}
className={`rounded-2xl border p-5 text-left shadow-sm transition ${
filter === s ? 'border-teal-500 ring-2 ring-teal-200' : 'bg-white hover:border-slate-300'
}`}
>
<div className="text-sm text-slate-500">{s}</div>
<div className="mt-1 text-2xl font-bold">{counts[s]}</div>
</button>
))}
</div>
{visible.length === 0 ? (
<div className="rounded-2xl border border-dashed bg-white p-10 text-center text-sm text-slate-500">
<Wrench className="mx-auto mb-3 h-8 w-8 text-slate-300" />
Keine Meldungen für diesen Filter.
</div>
) : (
<div className="space-y-3">
{visible.map((t) => {
const unit = units.find((u) => u.id === t.unitId)
return (
<div key={t.id} className="rounded-2xl border bg-white p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-semibold">{t.title}</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500">
<span className="inline-flex items-center gap-1">
<MapPin className="h-3.5 w-3.5" />
{unit ? `${unit.building}, Whg. ${unit.number}` : t.unitId}
{role === 'admin' && ` · ${t.tenant}`}
</span>
<span className="inline-flex items-center gap-1">
<CalendarDays className="h-3.5 w-3.5" /> {t.createdAt}
</span>
<span>Kategorie: {t.category}</span>
</div>
<p className="mt-2 text-sm text-slate-600">{t.description}</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
<Badge className={prioStyle[t.priority]} variant="secondary">
{t.priority}
</Badge>
{role === 'admin' ? (
<Select
value={t.status}
onValueChange={(v) =>
dispatch({ type: 'setTicketStatus', id: t.id, status: v as TicketStatus })
}
>
<SelectTrigger className="h-8 w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="offen">offen</SelectItem>
<SelectItem value="in Bearbeitung">in Bearbeitung</SelectItem>
<SelectItem value="erledigt">erledigt</SelectItem>
</SelectContent>
</Select>
) : (
<Badge className={statusStyle[t.status]} variant="secondary">{t.status}</Badge>
)}
</div>
</div>
</div>
)
})}
</div>
)}
</div>
)
}