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 />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user