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