- Rebrand: Kunden-Logo, Joseph-Hinterstein-Portrait, Kontakt-Banner, Texte - Login-Pflicht auf /login (Route-Guard), Gruppen Verwaltung/Mieter - Mieter-Registrierung per einmaligem Registrierungscode - Benutzerverwaltung als 6. Modul (/benutzer, nur Verwaltung) - Demo-Zugänge mit Passwort 0815 (j.hinterstein, a.berger, m.wolf) - Mail-Server-API: Token-Auth (/send, X-Api-Token), Mail-Stack live auf Server - Store-Key hinterstein-mieterportal-v2, Doku (kimi.md, README) nachgeführt
238 lines
9.4 KiB
TypeScript
238 lines
9.4 KiB
TypeScript
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 { meineEinheit: 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">
|
||
{demoUnit.building} · 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 />
|
||
}
|