v1.1: Rebrand Hinterstein Immobilien + echtes Auth-System
- 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
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { KeyRound, Plus, RefreshCcw, Search, ShieldCheck, UserCheck, UsersRound } 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 { useStore } from '../store/store'
|
||||
import type { BenutzerRolle } from '../types'
|
||||
|
||||
function Chip({ label, value, tone = 'slate' }: { label: string; value: number | string; tone?: 'slate' | 'amber' | 'teal' | 'blue' }) {
|
||||
const tones = {
|
||||
slate: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-200',
|
||||
amber: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300',
|
||||
teal: 'bg-teal-100 text-teal-800 dark:bg-teal-950 dark:text-teal-300',
|
||||
blue: 'bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ${tones[tone]}`}>
|
||||
{label}: {value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function NeuerBenutzerDialog() {
|
||||
const { benutzerAnlegen, units } = useStore()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [bn, setBn] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [rolle, setRolle] = useState<BenutzerRolle>('mieter')
|
||||
const [pw, setPw] = useState('0815')
|
||||
const [einheitId, setEinheitId] = useState('hs-7')
|
||||
const [fehler, setFehler] = useState<string | null>(null)
|
||||
|
||||
const submit = async () => {
|
||||
setFehler(null)
|
||||
const err = await benutzerAnlegen(bn, name, rolle, email, pw, rolle === 'mieter' ? einheitId : null)
|
||||
if (err) return setFehler(err)
|
||||
setName(''); setBn(''); setEmail(''); setPw('0815')
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2 bg-amber-500 text-slate-900 hover:bg-amber-400">
|
||||
<Plus className="h-4 w-4" /> Benutzer anlegen
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Neuen Benutzer anlegen</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="nb-name">Name</Label>
|
||||
<Input id="nb-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Vor- und Nachname" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="nb-bn">Benutzername</Label>
|
||||
<Input id="nb-bn" value={bn} onChange={(e) => setBn(e.target.value)} placeholder="z. B. m.mueller" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="nb-email">E-Mail</Label>
|
||||
<Input id="nb-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Gruppe</Label>
|
||||
<div className="flex gap-2">
|
||||
{(['mieter', 'verwaltung'] as const).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => setRolle(r)}
|
||||
className={`flex-1 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
||||
rolle === r ? 'bg-amber-500 text-slate-900' : 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{r === 'mieter' ? 'Mieter' : 'Verwaltung'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{rolle === 'mieter' && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="nb-einheit">Wohnung zuweisen</Label>
|
||||
<select
|
||||
id="nb-einheit"
|
||||
value={einheitId}
|
||||
onChange={(e) => setEinheitId(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{units.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.building}, Whg. {u.number} ({u.size} m²)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="nb-pw">Passwort (Initial)</Label>
|
||||
<Input id="nb-pw" value={pw} onChange={(e) => setPw(e.target.value)} />
|
||||
<p className="text-xs text-slate-500">Der Benutzer sollte das Passwort nach dem ersten Login ändern.</p>
|
||||
</div>
|
||||
{fehler && (
|
||||
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950 dark:text-red-300">{fehler}</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
|
||||
<Button onClick={submit} disabled={!name.trim() || !bn.trim() || !email.trim() || !pw.trim()} className="bg-amber-500 text-slate-900 hover:bg-amber-400">
|
||||
Anlegen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BenutzerVerwaltung() {
|
||||
const { benutzer, codes, units, setAktiv, setPasswort, weiseEinheitZu, generiereCodes } = useStore()
|
||||
const [suche, setSuche] = useState('')
|
||||
const [gruppe, setGruppe] = useState<'alle' | BenutzerRolle>('alle')
|
||||
const [anzahl, setAnzahl] = useState(5)
|
||||
const [codeEinheit, setCodeEinheit] = useState('')
|
||||
const [kopiert, setKopiert] = useState<string | null>(null)
|
||||
|
||||
const gefiltert = useMemo(() => {
|
||||
const q = suche.trim().toLowerCase()
|
||||
return benutzer
|
||||
.filter((b) => gruppe === 'alle' || b.rolle === gruppe)
|
||||
.filter(
|
||||
(b) =>
|
||||
!q ||
|
||||
b.name.toLowerCase().includes(q) ||
|
||||
b.benutzername.toLowerCase().includes(q) ||
|
||||
b.email.toLowerCase().includes(q),
|
||||
)
|
||||
}, [benutzer, suche, gruppe])
|
||||
|
||||
const freieCodes = codes.filter((c) => !c.verwendetAm)
|
||||
|
||||
const passwortZuruecksetzen = async (id: string, name: string) => {
|
||||
if (!window.confirm(`Passwort von ${name} auf „0815“ zurücksetzen?`)) return
|
||||
await setPasswort(id, '0815')
|
||||
}
|
||||
|
||||
const kopieren = (text: string) => {
|
||||
navigator.clipboard?.writeText(text).catch(() => {})
|
||||
setKopiert(text)
|
||||
setTimeout(() => setKopiert(null), 1500)
|
||||
}
|
||||
|
||||
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">Benutzerverwaltung</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
Konten der Gruppen Verwaltung und Mieter – Registrierungscodes für Neumieter
|
||||
</p>
|
||||
</div>
|
||||
<NeuerBenutzerDialog />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Chip label="Benutzer gesamt" value={benutzer.length} />
|
||||
<Chip label="Verwaltung" value={benutzer.filter((b) => b.rolle === 'verwaltung').length} tone="amber" />
|
||||
<Chip label="Mieter" value={benutzer.filter((b) => b.rolle === 'mieter').length} tone="teal" />
|
||||
<Chip label="Aktive Codes" value={freieCodes.length} tone="blue" />
|
||||
</div>
|
||||
|
||||
{/* Registrierungscodes */}
|
||||
<div className="rounded-2xl border bg-white p-5 shadow-sm dark:bg-slate-900">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 font-semibold">
|
||||
<KeyRound className="h-4.5 w-4.5 text-amber-600" /> Registrierungscodes für Mieter
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={codeEinheit}
|
||||
onChange={(e) => setCodeEinheit(e.target.value)}
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
||||
aria-label="Wohnung für Code"
|
||||
>
|
||||
<option value="">Wohnung wählen …</option>
|
||||
{units.filter((u) => u.status === 'frei').map((u) => (
|
||||
<option key={u.id} value={u.id}>{u.building}, Whg. {u.number}</option>
|
||||
))}
|
||||
{units.filter((u) => u.status === 'vermietet').map((u) => (
|
||||
<option key={u.id} value={u.id}>{u.building}, Whg. {u.number}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={anzahl}
|
||||
onChange={(e) => setAnzahl(Number(e.target.value))}
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
||||
aria-label="Anzahl Codes"
|
||||
>
|
||||
{[1, 5, 10, 20].map((n) => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
onClick={() => generiereCodes(anzahl, codeEinheit || null)}
|
||||
className="gap-2 bg-amber-500 text-slate-900 hover:bg-amber-400"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" /> Codes erzeugen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{codes.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">Noch keine Codes erzeugt.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{codes.slice(0, 20).map((c) => {
|
||||
const einheit = units.find((u) => u.id === c.einheitId)
|
||||
return (
|
||||
<button
|
||||
key={c.code}
|
||||
onClick={() => kopieren(c.code)}
|
||||
title="Klicken zum Kopieren"
|
||||
className={`rounded-lg px-3 py-1.5 font-mono text-xs transition ${
|
||||
c.verwendetAm
|
||||
? 'bg-slate-100 text-slate-400 line-through dark:bg-slate-800'
|
||||
: kopiert === c.code
|
||||
? 'bg-emerald-100 text-emerald-800 ring-2 ring-emerald-400 dark:bg-emerald-950 dark:text-emerald-300'
|
||||
: 'bg-amber-50 text-amber-900 ring-1 ring-amber-200 hover:ring-amber-400 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-800'
|
||||
}`}
|
||||
>
|
||||
{c.code}
|
||||
{einheit && <span className="ml-1.5 font-sans text-[10px] text-slate-500">({einheit.building.split(' ')[0]} {einheit.number})</span>}
|
||||
{c.verwendetAm && <span className="ml-1 font-sans text-[10px]">verwendet</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-slate-400">
|
||||
Mieter registrieren sich auf der Startseite mit einem Code – der Code wird dabei verbraucht
|
||||
und die Wohnung automatisch zugeordnet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Benutzerliste */}
|
||||
<div className="rounded-2xl border bg-white p-5 shadow-sm dark:bg-slate-900">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 font-semibold">
|
||||
<UsersRound className="h-4.5 w-4.5 text-amber-600" /> Benutzerkonten
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
value={suche}
|
||||
onChange={(e) => setSuche(e.target.value)}
|
||||
placeholder="Suchen …"
|
||||
className="w-56 pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{(['alle', 'mieter', 'verwaltung'] as const).map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
onClick={() => setGruppe(g)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition ${
|
||||
gruppe === g
|
||||
? 'bg-amber-500 text-slate-900'
|
||||
: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{g === 'alle' ? 'Alle' : g === 'mieter' ? 'Mieter' : 'Verwaltung'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{gefiltert.map((b) => {
|
||||
const einheit = units.find((u) => u.id === b.einheitId)
|
||||
return (
|
||||
<div
|
||||
key={b.id}
|
||||
className={`flex flex-wrap items-center gap-3 rounded-xl border p-3 transition ${
|
||||
b.aktiv ? 'bg-white dark:bg-slate-900' : 'bg-slate-50 opacity-60 dark:bg-slate-950'
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-flex h-9 w-9 items-center justify-center rounded-full text-sm font-bold ${
|
||||
b.rolle === 'verwaltung' ? 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300' : 'bg-teal-100 text-teal-800 dark:bg-teal-950 dark:text-teal-300'
|
||||
}`}>
|
||||
{b.name.split(' ').map((t) => t[0]).slice(0, 2).join('')}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold">{b.name}</span>
|
||||
<Badge variant="secondary" className={b.rolle === 'verwaltung' ? 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300' : 'bg-teal-100 text-teal-800 dark:bg-teal-950 dark:text-teal-300'}>
|
||||
{b.rolle === 'verwaltung' ? (
|
||||
<><ShieldCheck className="mr-1 h-3 w-3" /> Verwaltung</>
|
||||
) : (
|
||||
<><UserCheck className="mr-1 h-3 w-3" /> Mieter</>
|
||||
)}
|
||||
</Badge>
|
||||
{!b.aktiv && <Badge variant="secondary" className="bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300">deaktiviert</Badge>}
|
||||
</div>
|
||||
<div className="truncate text-xs text-slate-500">
|
||||
@{b.benutzername} · {b.email}
|
||||
{einheit && ` · ${einheit.building}, Whg. ${einheit.number}`}
|
||||
{b.letzterLogin && ` · zuletzt: ${b.letzterLogin}`}
|
||||
</div>
|
||||
</div>
|
||||
{b.rolle === 'mieter' && (
|
||||
<select
|
||||
value={b.einheitId ?? ''}
|
||||
onChange={(e) => weiseEinheitZu(b.id, e.target.value || null)}
|
||||
className="rounded-md border border-input bg-background px-2 py-1.5 text-xs"
|
||||
aria-label={`Wohnung für ${b.name}`}
|
||||
>
|
||||
<option value="">Keine Wohnung</option>
|
||||
{units.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.building}, Whg. {u.number}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={() => passwortZuruecksetzen(b.id, b.name)}
|
||||
className="rounded-lg bg-slate-100 px-2.5 py-1.5 text-xs font-medium text-slate-600 transition hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
>
|
||||
Passwort → 0815
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAktiv(b.id, !b.aktiv)}
|
||||
className={`rounded-lg px-2.5 py-1.5 text-xs font-semibold transition ${
|
||||
b.aktiv
|
||||
? 'bg-red-50 text-red-600 hover:bg-red-100 dark:bg-red-950 dark:text-red-300'
|
||||
: 'bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:bg-emerald-950 dark:text-emerald-300'
|
||||
}`}
|
||||
>
|
||||
{b.aktiv ? 'Deaktivieren' : 'Aktivieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{gefiltert.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-slate-500">Keine Benutzer für diesen Filter.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -58,7 +58,7 @@ function StatCard({
|
||||
}
|
||||
|
||||
function TenantDashboard() {
|
||||
const { demoUnit, transactions, tickets, threads, announcements } = useStore()
|
||||
const { meineEinheit: demoUnit, me, 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)
|
||||
@@ -73,9 +73,9 @@ function TenantDashboard() {
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Hallo, {me?.name}</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
Hauptstraße 12 · Wohnung {demoUnit.number} · {demoUnit.size} m²
|
||||
{demoUnit.building} · Wohnung {demoUnit.number} · {demoUnit.size} m²
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-amber-50 px-4 py-2 text-sm text-amber-800">
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { useStore, DEMO_UNIT_ID } from '../store/store'
|
||||
import { useStore } from '../store/store'
|
||||
|
||||
const catIcon = (cat: string) => {
|
||||
switch (cat) {
|
||||
@@ -28,13 +28,13 @@ const catIcon = (cat: string) => {
|
||||
}
|
||||
|
||||
export default function Documents() {
|
||||
const { documents, role, units } = useStore()
|
||||
const { documents, role, units, meineEinheit } = useStore()
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const base =
|
||||
role === 'tenant'
|
||||
? documents.filter((d) => d.unitId === DEMO_UNIT_ID || d.unitId === null)
|
||||
? documents.filter((d) => d.unitId === meineEinheit.id || d.unitId === null)
|
||||
: documents
|
||||
const q = query.trim().toLowerCase()
|
||||
return q
|
||||
|
||||
+199
-49
@@ -1,9 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { KeyRound, ShieldCheck, Sparkles } from 'lucide-react'
|
||||
import logo from '../assets/logo.png'
|
||||
import { KeyRound, MailPlus, Sparkles, UserCheck } from 'lucide-react'
|
||||
import logo from '../assets/hinterstein/logo.png'
|
||||
import hero from '../assets/hero.jpg'
|
||||
import modules from '../assets/modules.jpg'
|
||||
import { useStore } from '../store/store'
|
||||
import joseph from '../assets/hinterstein/joseph-hinterstein.jpg'
|
||||
import kontakt from '../assets/hinterstein/kontakt.jpg'
|
||||
import { useStore, DEMO_PASSWORT } from '../store/store'
|
||||
|
||||
const features = [
|
||||
'Mietkonto & Nebenkostenabrechnungen',
|
||||
@@ -13,83 +15,231 @@ const features = [
|
||||
'Aktuelle Ankündigungen Ihres Wohnhauses',
|
||||
]
|
||||
|
||||
const demoZugaenge = [
|
||||
{ benutzername: 'j.hinterstein', label: 'Joseph Hinterstein (Verwaltung)' },
|
||||
{ benutzername: 'a.berger', label: 'Anna Berger (Mieterin)' },
|
||||
]
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
const { dispatch } = useStore()
|
||||
const { login, registrieren } = useStore()
|
||||
|
||||
const enter = (role: 'tenant' | 'admin') => {
|
||||
dispatch({ type: 'setRole', role })
|
||||
navigate('/')
|
||||
const [modus, setModus] = useState<'anmelden' | 'registrieren'>('anmelden')
|
||||
const [bn, setBn] = useState('')
|
||||
const [pw, setPw] = useState('')
|
||||
const [fehler, setFehler] = useState<string | null>(null)
|
||||
const [lade, setLade] = useState(false)
|
||||
|
||||
// Registrierung
|
||||
const [code, setCode] = useState('')
|
||||
const [rName, setRName] = useState('')
|
||||
const [rEmail, setREmail] = useState('')
|
||||
const [rBn, setRBn] = useState('')
|
||||
const [rPw, setRPw] = useState('')
|
||||
const [rPw2, setRPw2] = useState('')
|
||||
|
||||
const anmelden = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLade(true)
|
||||
setFehler(null)
|
||||
const err = await login(bn, pw)
|
||||
setLade(false)
|
||||
if (err) setFehler(err)
|
||||
else navigate('/')
|
||||
}
|
||||
|
||||
const registrierenSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setFehler(null)
|
||||
if (rPw.length < 4) return setFehler('Das Passwort muss mindestens 4 Zeichen haben.')
|
||||
if (rPw !== rPw2) return setFehler('Die Passwörter stimmen nicht überein.')
|
||||
setLade(true)
|
||||
const err = await registrieren(code, rBn, rName, rEmail, rPw)
|
||||
setLade(false)
|
||||
if (err) setFehler(err)
|
||||
else navigate('/')
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
'w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-amber-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0b3a44]">
|
||||
<div className="min-h-screen bg-[#22333b]">
|
||||
<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" />
|
||||
<img src={logo} alt="Logo Hinterstein Immobilien" className="h-14 w-14 rounded-full bg-white/95 object-contain p-1" />
|
||||
<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 className="text-xl font-bold text-white">Hinterstein Immobilien</div>
|
||||
<div className="text-sm text-amber-200/80">Mieterportal · 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 className="grid flex-1 items-start gap-8 px-6 pb-10 lg:grid-cols-2">
|
||||
{/* Linke Spalte: Hero + Ansprechpartner */}
|
||||
<div className="space-y-6">
|
||||
<div className="relative overflow-hidden rounded-3xl shadow-2xl">
|
||||
<img src={hero} alt="Wohnanlage von Hinterstein Immobilien" className="h-64 w-full object-cover lg:h-[340px]" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#22333b]/85 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-slate-200">3 Wohnanlagen · 50 Wohnungen · persönliche Verwaltung</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 rounded-3xl bg-white/10 p-5 backdrop-blur">
|
||||
<img
|
||||
src={joseph}
|
||||
alt="Joseph Hinterstein, Ihr Ansprechpartner"
|
||||
className="h-32 w-24 rounded-2xl object-cover object-top"
|
||||
/>
|
||||
<div className="text-sm text-slate-100">
|
||||
<div className="text-base font-semibold text-white">Joseph Hinterstein</div>
|
||||
<div className="text-amber-200/90">Ihr Ansprechpartner · Immobilienverwaltung</div>
|
||||
<p className="mt-2 text-slate-300">
|
||||
Persönlich erreichbar über das Nachrichten-Modul oder per E-Mail –
|
||||
wir kümmern uns um Ihr Anliegen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img src={kontakt} alt="Kontakt: joseph@hinterstein-immo.de" className="w-full max-w-md rounded-2xl shadow-lg" />
|
||||
</div>
|
||||
|
||||
<div className="order-1 lg:order-2">
|
||||
{/* Rechte Spalte: Login-/Registrier-Karte */}
|
||||
<div>
|
||||
<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 className="mt-3 max-w-lg text-slate-300">
|
||||
Alle Mietangelegenheiten digital an einem Ort – schnell, transparent
|
||||
und ohne Papierkram. Melden Sie sich mit Ihrem Konto an.
|
||||
</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" />
|
||||
<li key={f} className="flex items-center gap-2.5 text-sm text-slate-100">
|
||||
<Sparkles className="h-4 w-4 shrink-0 text-amber-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>
|
||||
<div className="mt-8 rounded-2xl bg-white p-6 shadow-2xl dark:bg-slate-900">
|
||||
<div className="mb-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => { setModus('anmelden'); setFehler(null) }}
|
||||
className={`flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-semibold transition ${
|
||||
modus === 'anmelden' ? 'bg-amber-500 text-slate-900' : 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<KeyRound className="h-4 w-4" /> Anmelden
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setModus('registrieren'); setFehler(null) }}
|
||||
className={`flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-semibold transition ${
|
||||
modus === 'registrieren' ? 'bg-amber-500 text-slate-900' : 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<MailPlus className="h-4 w-4" /> Registrieren (Mieter)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={modules}
|
||||
alt="Illustration der Portalmodule"
|
||||
className="mt-8 hidden w-full max-w-md rounded-2xl opacity-90 lg:block"
|
||||
/>
|
||||
{modus === 'anmelden' ? (
|
||||
<form onSubmit={anmelden} className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="bn" className="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Benutzername
|
||||
</label>
|
||||
<input id="bn" className={inputCls} value={bn} onChange={(e) => setBn(e.target.value)} autoComplete="username" required />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="pw" className="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Passwort
|
||||
</label>
|
||||
<input id="pw" type="password" className={inputCls} value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="current-password" required />
|
||||
</div>
|
||||
{fehler && (
|
||||
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950 dark:text-red-300" role="alert">
|
||||
{fehler}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={lade || !bn || !pw}
|
||||
className="w-full rounded-xl bg-amber-500 px-6 py-3 font-semibold text-slate-900 shadow transition hover:bg-amber-400 disabled:opacity-50"
|
||||
>
|
||||
{lade ? 'Bitte warten …' : 'Anmelden'}
|
||||
</button>
|
||||
|
||||
<div className="rounded-xl bg-slate-50 p-3 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-300">
|
||||
<div className="mb-1.5 font-semibold">Demo-Zugänge (Passwort: {DEMO_PASSWORT})</div>
|
||||
{demoZugaenge.map((d) => (
|
||||
<button
|
||||
key={d.benutzername}
|
||||
type="button"
|
||||
onClick={() => { setBn(d.benutzername); setPw(DEMO_PASSWORT); setFehler(null) }}
|
||||
className="mr-2 mb-1.5 inline-flex items-center gap-1 rounded-lg bg-white px-2.5 py-1 font-medium text-slate-700 ring-1 ring-slate-200 transition hover:ring-amber-400 dark:bg-slate-700 dark:text-slate-200 dark:ring-slate-600"
|
||||
>
|
||||
<UserCheck className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400" />
|
||||
{d.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={registrierenSubmit} className="space-y-3">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Mieter registrieren sich mit dem persönlichen Registrierungscode aus dem
|
||||
Anschreiben der Verwaltung. Demo-Code: <code className="rounded bg-slate-100 px-1.5 py-0.5 font-mono dark:bg-slate-800">MP-2026-0815</code>
|
||||
</p>
|
||||
<div>
|
||||
<label htmlFor="code" className="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Registrierungscode</label>
|
||||
<input id="code" className={inputCls} value={code} onChange={(e) => setCode(e.target.value)} placeholder="MP-XXXX-XXXX" required />
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="rname" className="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Name</label>
|
||||
<input id="rname" className={inputCls} value={rName} onChange={(e) => setRName(e.target.value)} placeholder="Vor- und Nachname" required />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="remail" className="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">E-Mail</label>
|
||||
<input id="remail" type="email" className={inputCls} value={rEmail} onChange={(e) => setREmail(e.target.value)} required />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="rbn" className="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Benutzername (frei wählbar)</label>
|
||||
<input id="rbn" className={inputCls} value={rBn} onChange={(e) => setRBn(e.target.value)} autoComplete="username" required />
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="rpw" className="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Passwort</label>
|
||||
<input id="rpw" type="password" className={inputCls} value={rPw} onChange={(e) => setRPw(e.target.value)} autoComplete="new-password" required />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="rpw2" className="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Passwort wiederholen</label>
|
||||
<input id="rpw2" type="password" className={inputCls} value={rPw2} onChange={(e) => setRPw2(e.target.value)} autoComplete="new-password" required />
|
||||
</div>
|
||||
</div>
|
||||
{fehler && (
|
||||
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950 dark:text-red-300" role="alert">
|
||||
{fehler}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={lade || !code || !rName || !rEmail || !rBn || !rPw || !rPw2}
|
||||
className="w-full rounded-xl bg-amber-500 px-6 py-3 font-semibold text-slate-900 shadow transition hover:bg-amber-400 disabled:opacity-50"
|
||||
>
|
||||
{lade ? 'Bitte warten …' : 'Registrieren & anmelden'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="px-6 pb-6 text-center text-xs text-teal-200/60">
|
||||
© 2026 Hocks Verwaltung · Impressum · Datenschutz
|
||||
<footer className="px-6 pb-6 text-center text-xs text-slate-400">
|
||||
© 2026 Hinterstein Immobilien · joseph@hinterstein-immo.de · Impressum · Datenschutz
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+14
-10
@@ -12,14 +12,16 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useStore, DEMO_TENANT, DEMO_UNIT_ID } from '../store/store'
|
||||
import { useStore } from '../store/store'
|
||||
|
||||
function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
|
||||
const { dispatch, role } = useStore()
|
||||
const { dispatch, role, me, meineEinheit } = useStore()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [subject, setSubject] = useState('')
|
||||
const [text, setText] = useState('')
|
||||
|
||||
const verwaltungsName = 'Joseph Hinterstein'
|
||||
|
||||
const submit = () => {
|
||||
if (!subject.trim() || !text.trim()) return
|
||||
const id = `m-${Date.now()}`
|
||||
@@ -27,8 +29,8 @@ function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
|
||||
type: 'newThread',
|
||||
thread: {
|
||||
id,
|
||||
unitId: role === 'tenant' ? DEMO_UNIT_ID : 'hs-1',
|
||||
tenant: role === 'tenant' ? DEMO_TENANT : 'Hocks Verwaltung',
|
||||
unitId: role === 'tenant' ? meineEinheit.id : 'hs-1',
|
||||
tenant: role === 'tenant' ? (me?.name ?? 'Mieter') : verwaltungsName,
|
||||
subject: subject.trim(),
|
||||
unreadByAdmin: true,
|
||||
unreadByTenant: false,
|
||||
@@ -36,7 +38,7 @@ function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
|
||||
{
|
||||
id: `${id}-1`,
|
||||
from: role,
|
||||
author: role === 'tenant' ? DEMO_TENANT : 'Hocks Verwaltung',
|
||||
author: role === 'tenant' ? (me?.name ?? 'Mieter') : verwaltungsName,
|
||||
text: text.trim(),
|
||||
at: new Date().toLocaleString('de-DE'),
|
||||
},
|
||||
@@ -82,9 +84,11 @@ function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
|
||||
}
|
||||
|
||||
export default function Messages() {
|
||||
const { threads, role, dispatch, units } = useStore()
|
||||
const { threads, role, dispatch, units, me, meineEinheit } = useStore()
|
||||
const meinName = me?.name ?? 'Mieter'
|
||||
const verwaltungsName = 'Joseph Hinterstein'
|
||||
const visible = role === 'tenant'
|
||||
? threads.filter((t) => t.unitId === DEMO_UNIT_ID)
|
||||
? threads.filter((t) => t.unitId === meineEinheit.id)
|
||||
: threads
|
||||
const [activeId, setActiveId] = useState(visible[0]?.id ?? '')
|
||||
const active = visible.find((t) => t.id === activeId) ?? visible[0]
|
||||
@@ -105,7 +109,7 @@ export default function Messages() {
|
||||
message: {
|
||||
id: `${active.id}-${Date.now()}`,
|
||||
from: role,
|
||||
author: role === 'tenant' ? DEMO_TENANT : 'Hocks Verwaltung',
|
||||
author: role === 'tenant' ? meinName : verwaltungsName,
|
||||
text: draft.trim(),
|
||||
at: new Date().toLocaleString('de-DE'),
|
||||
},
|
||||
@@ -120,7 +124,7 @@ export default function Messages() {
|
||||
<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'
|
||||
? 'Direktdraht zur Verwaltung – Antwort meist innerhalb eines Werktags'
|
||||
: 'Mieter-Kommunikation über alle 50 Wohnungen'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -172,7 +176,7 @@ export default function Messages() {
|
||||
<div className="text-xs text-slate-500">
|
||||
{role === 'admin'
|
||||
? `${active.tenant}`
|
||||
: 'Hocks Verwaltung · Hauptstraße 12'}
|
||||
: 'Joseph Hinterstein · Hinterstein Immobilien'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[420px] flex-1 space-y-4 overflow-y-auto p-5">
|
||||
|
||||
@@ -28,7 +28,7 @@ const statusStyle: Record<string, string> = {
|
||||
}
|
||||
|
||||
function TenantPayments() {
|
||||
const { demoUnit, transactions } = useStore()
|
||||
const { meineEinheit: demoUnit, transactions } = useStore()
|
||||
const myTx = useMemo(
|
||||
() =>
|
||||
transactions
|
||||
@@ -48,7 +48,7 @@ function TenantPayments() {
|
||||
<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
|
||||
{demoUnit.building} · Whg. {demoUnit.number} – alle Buchungen auf einen Blick
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useStore, DEMO_TENANT, DEMO_UNIT_ID } from '../store/store'
|
||||
import { useStore } from '../store/store'
|
||||
import type { TicketPriority, TicketStatus } from '../types'
|
||||
|
||||
const statusStyle: Record<TicketStatus, string> = {
|
||||
@@ -46,7 +46,7 @@ const categories = [
|
||||
]
|
||||
|
||||
function NewTicketDialog() {
|
||||
const { dispatch, role } = useStore()
|
||||
const { dispatch, role, me, meineEinheit } = useStore()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [title, setTitle] = useState('')
|
||||
const [category, setCategory] = useState('Sanitär')
|
||||
@@ -59,8 +59,8 @@ function NewTicketDialog() {
|
||||
type: 'addTicket',
|
||||
ticket: {
|
||||
id: `t-${Date.now()}`,
|
||||
unitId: role === 'tenant' ? DEMO_UNIT_ID : 'hs-1',
|
||||
tenant: role === 'tenant' ? DEMO_TENANT : 'Hocks Verwaltung (intern)',
|
||||
unitId: role === 'tenant' ? meineEinheit.id : 'hs-1',
|
||||
tenant: role === 'tenant' ? (me?.name ?? 'Mieter') : 'Hinterstein Immobilien (intern)',
|
||||
title: title.trim(),
|
||||
description: description.trim() || '–',
|
||||
category,
|
||||
@@ -144,16 +144,16 @@ function NewTicketDialog() {
|
||||
}
|
||||
|
||||
export default function Tickets() {
|
||||
const { tickets, units, role, dispatch } = useStore()
|
||||
const { tickets, units, role, dispatch, meineEinheit } = useStore()
|
||||
const [filter, setFilter] = useState<'alle' | TicketStatus>('alle')
|
||||
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
(role === 'tenant'
|
||||
? tickets.filter((t) => t.unitId === DEMO_UNIT_ID)
|
||||
? tickets.filter((t) => t.unitId === meineEinheit.id)
|
||||
: tickets
|
||||
).filter((t) => filter === 'alle' || t.status === filter),
|
||||
[tickets, role, filter],
|
||||
[tickets, role, filter, meineEinheit],
|
||||
)
|
||||
|
||||
const counts = {
|
||||
|
||||
Reference in New Issue
Block a user