v1.37: KI-Assistent N8N-Workflow fertig eingerichtet; alle Formulare 2-spaltig mit neuem Design
- N8N: Workflow serverseitig in DB eingespielt (Webhook mp-assist, Code-Node mit Kimi-Tool-Calling, keine Credentials), E2E live verifiziert - Formulare: shared FormSection-Komponente (2 gleich breite Spalten, Wide fuer Volllbreiten-Felder), 10 Formulare umgebaut, Dialoge verbreitert - Design: Inter + Sora Fonts, Teal-Akzente, Icons in Sektionskoepfen
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Shared Form-Layout für das Mieterportal.
|
||||
* Jede FormSection rendert ihre Felder in zwei gleich breiten Spalten
|
||||
* (mobil: eine Spalte). Elemente, die über die volle Breite laufen sollen
|
||||
* (Checkbox-Blöcke, Textareas, Uploads, Hinweistexte), in <Wide> packen.
|
||||
*/
|
||||
|
||||
export function FormSection({
|
||||
titel,
|
||||
icon: Icon,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
titel: string
|
||||
icon?: LucideIcon
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'rounded-xl border border-slate-200 bg-white p-4 shadow-sm sm:p-5',
|
||||
'dark:border-slate-700/60 dark:bg-slate-900/60',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<header className="mb-4 flex items-center gap-2.5 border-b border-slate-100 pb-3 dark:border-slate-800">
|
||||
{Icon && (
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-teal-600/10 text-teal-700 dark:bg-teal-400/10 dark:text-teal-300">
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
<h3 className="font-display text-[13px] font-semibold uppercase tracking-wider text-teal-800 dark:text-teal-300">
|
||||
{titel}
|
||||
</h3>
|
||||
</header>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** Volle Breite innerhalb einer 2-spaltigen FormSection. */
|
||||
export function Wide({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <div className={cn('md:col-span-2', className)}>{children}</div>
|
||||
}
|
||||
|
||||
/** Fehlerzeile am Ende eines Formulars. */
|
||||
export function FormFehler({ fehler }: { fehler: string | null }) {
|
||||
if (!fehler) return null
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -71,6 +71,11 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
h1, h2, h3 {
|
||||
font-family: 'Sora', 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useStore } from '../store/store'
|
||||
import { fmtDatumZeit } from '../lib/format'
|
||||
import { FormSection, Wide, FormFehler } from '@/components/forms/FormSection'
|
||||
import type { Benutzer, BenutzerRolle } from '../types'
|
||||
|
||||
function Chip({ label, value, tone = 'slate' }: { label: string; value: number | string; tone?: 'slate' | 'amber' | 'teal' | 'blue' }) {
|
||||
@@ -57,12 +58,12 @@ function NeuerBenutzerDialog() {
|
||||
<Plus className="h-4 w-4" /> Benutzer anlegen
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Neuen Benutzer anlegen</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormSection titel="Benutzerdaten" icon={UserCheck}>
|
||||
<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" />
|
||||
@@ -71,8 +72,6 @@ function NeuerBenutzerDialog() {
|
||||
<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)} />
|
||||
@@ -94,32 +93,30 @@ function NeuerBenutzerDialog() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{rolle === 'mieter' && (
|
||||
{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-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>
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
</FormSection>
|
||||
<FormFehler fehler={fehler} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
|
||||
@@ -172,45 +169,49 @@ function EmailDialog({ benutzer: b, einheitLabel }: { benutzer: Benutzer; einhei
|
||||
<Mail className="h-3.5 w-3.5" /> E-Mail
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>E-Mail an {b.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Empfänger</Label>
|
||||
<div className="rounded-lg border bg-slate-50 px-3 py-2 text-sm text-slate-600 dark:bg-slate-900 dark:text-slate-300">
|
||||
{b.name} · {hatEmail ? b.email : 'keine E-Mail hinterlegt'}
|
||||
{einheitLabel && <span className="block text-xs text-slate-400">{einheitLabel}</span>}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
Die Verwaltung erhält automatisch eine Kopie (CC).
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="m-betreff">Betreff</Label>
|
||||
<Input
|
||||
id="m-betreff"
|
||||
placeholder="z. B. Terminabsprache Hausmeisterservice"
|
||||
value={betreff}
|
||||
onChange={(e) => setBetreff(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="m-text">Nachricht</Label>
|
||||
<Textarea
|
||||
id="m-text"
|
||||
rows={6}
|
||||
placeholder="Ihre Nachricht an den Mieter …"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
<FormSection titel="Nachricht" icon={Mail}>
|
||||
<Wide>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Empfänger</Label>
|
||||
<div className="rounded-lg border bg-slate-50 px-3 py-2 text-sm text-slate-600 dark:bg-slate-900 dark:text-slate-300">
|
||||
{b.name} · {hatEmail ? b.email : 'keine E-Mail hinterlegt'}
|
||||
{einheitLabel && <span className="block text-xs text-slate-400">{einheitLabel}</span>}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
Die Verwaltung erhält automatisch eine Kopie (CC).
|
||||
</p>
|
||||
</div>
|
||||
</Wide>
|
||||
<Wide>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="m-betreff">Betreff</Label>
|
||||
<Input
|
||||
id="m-betreff"
|
||||
placeholder="z. B. Terminabsprache Hausmeisterservice"
|
||||
value={betreff}
|
||||
onChange={(e) => setBetreff(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Wide>
|
||||
<Wide>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="m-text">Nachricht</Label>
|
||||
<Textarea
|
||||
id="m-text"
|
||||
rows={6}
|
||||
placeholder="Ihre Nachricht an den Mieter …"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Wide>
|
||||
</FormSection>
|
||||
<FormFehler fehler={fehler} />
|
||||
{gesendet && (
|
||||
<div className="rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
|
||||
E-Mail wurde versendet (mit CC an die Verwaltung).
|
||||
|
||||
+54
-69
@@ -1,7 +1,7 @@
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
import {
|
||||
Building2, Home, MapPin, CalendarDays, ArrowUpDown, LayoutGrid, Ruler, Pencil,
|
||||
Plus, Users, DoorOpen, Search, List, ChevronDown, ChevronRight, Flame, Trash2,
|
||||
Plus, Users, DoorOpen, Search, List, ChevronDown, ChevronRight, Flame, Trash2, FileText,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -25,22 +25,11 @@ import {
|
||||
import { useStore } from '../store/store'
|
||||
import type { Building, HausForm, Unit } from '../types'
|
||||
import { HEIZUNGSARTEN } from '../types'
|
||||
import { FormSection, Wide, FormFehler } from '@/components/forms/FormSection'
|
||||
|
||||
/** Komma oder Punkt als Dezimaltrenner akzeptieren. */
|
||||
const zahl = (s: string) => Number(s.replace(',', '.'))
|
||||
|
||||
/** Optischer Abschnitt im einspaltigen Haus-Formular. */
|
||||
function Abschnitt({ titel, children }: { titel: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-3 rounded-xl border bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800/50">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
{titel}
|
||||
</h4>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Feld({ id, label, hint, ...input }: {
|
||||
id: string
|
||||
label: string
|
||||
@@ -119,7 +108,7 @@ function HausDialog({ haus }: { haus?: Building }) {
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{bearbeiten ? `Haus „${haus!.adresse}" bearbeiten` : 'Neues Haus anlegen'}
|
||||
@@ -129,14 +118,14 @@ function HausDialog({ haus }: { haus?: Building }) {
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Hausname und Adresszeile werden automatisch aus Straße und Hausnummer gebildet.
|
||||
</p>
|
||||
<Abschnitt titel="Adresse">
|
||||
<FormSection titel="Adresse" icon={MapPin}>
|
||||
<Feld id="h-strasse" label="Straße" placeholder="z. B. Lindenallee" value={strasse} onChange={(e) => setStrasse(e.target.value)} />
|
||||
<Feld id="h-nr" label="Hausnummer" placeholder="z. B. 8" value={hausnummer} onChange={(e) => setHausnummer(e.target.value)} />
|
||||
<Feld id="h-plz" label="PLZ" inputMode="numeric" placeholder="z. B. 47839" value={plz} onChange={(e) => setPlz(e.target.value)} />
|
||||
<Feld id="h-ort" label="Ort" placeholder="z. B. Krefeld" value={ort} onChange={(e) => setOrt(e.target.value)} />
|
||||
<Feld id="h-lage" label="Lage" hint={'Optional, z. B. Stadtteil („Krefeld-Hüls")'} placeholder="z. B. Stadtteil Hüls" value={lage} onChange={(e) => setLage(e.target.value)} />
|
||||
</Abschnitt>
|
||||
<Abschnitt titel="Gebäude">
|
||||
</FormSection>
|
||||
<FormSection titel="Gebäude" icon={Building2}>
|
||||
<Feld id="h-bau" label="Baujahr" inputMode="numeric" placeholder="z. B. 1998" value={baujahr} onChange={(e) => setBaujahr(e.target.value)} />
|
||||
<Feld id="h-stock" label="Anzahl Stockwerke" inputMode="numeric" placeholder="z. B. 3" value={stockwerke} onChange={(e) => setStockwerke(e.target.value)} />
|
||||
<Feld id="h-hoehe" label="Etagenhöhe (m)" inputMode="decimal" placeholder="2,65" value={etagenhoehe} onChange={(e) => setEtagenhoehe(e.target.value)} />
|
||||
@@ -154,35 +143,33 @@ function HausDialog({ haus }: { haus?: Building }) {
|
||||
</Select>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">Optional</p>
|
||||
</div>
|
||||
</Abschnitt>
|
||||
<Abschnitt titel="Flächen">
|
||||
</FormSection>
|
||||
<FormSection titel="Flächen" icon={Ruler}>
|
||||
<Feld id="h-wohnfl" label="Wohnfläche gesamt (m²)" inputMode="decimal" placeholder="z. B. 980" value={wohnflaeche} onChange={(e) => setWohnflaeche(e.target.value)} />
|
||||
<Feld id="h-grundfl" label="Grundstücksfläche (m²)" hint="Optional" inputMode="decimal" placeholder="z. B. 1.250" value={grundstuecksflaeche} onChange={(e) => setGrundstuecksflaeche(e.target.value)} />
|
||||
</Abschnitt>
|
||||
<Abschnitt titel="Zuordnung Belege">
|
||||
<label className="flex cursor-pointer items-start gap-2.5 text-sm">
|
||||
<input
|
||||
id="h-eigenobjekt"
|
||||
type="checkbox"
|
||||
checked={eigenobjekt}
|
||||
onChange={(e) => setEigenobjekt(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-slate-300 accent-teal-600"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium text-slate-800 dark:text-slate-100">Eigenobjekt / Rechnungsempfänger</span>
|
||||
<span className="block text-xs text-slate-500 dark:text-slate-400">
|
||||
Diese Adresse steht auf eingehenden Rechnungen als Empfänger (z. B. die eigene Verwaltungsadresse).
|
||||
Eigenobjekte werden bei der automatischen Beleg-Zuordnung übersprungen – die Zuordnung erfolgt dann
|
||||
über die Adresse bzw. Stromzählernummer des Mietobjekts.
|
||||
</FormSection>
|
||||
<FormSection titel="Zuordnung Belege" icon={FileText}>
|
||||
<Wide>
|
||||
<label className="flex cursor-pointer items-start gap-2.5 text-sm">
|
||||
<input
|
||||
id="h-eigenobjekt"
|
||||
type="checkbox"
|
||||
checked={eigenobjekt}
|
||||
onChange={(e) => setEigenobjekt(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-slate-300 accent-teal-600"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium text-slate-800 dark:text-slate-100">Eigenobjekt / Rechnungsempfänger</span>
|
||||
<span className="block text-xs text-slate-500 dark:text-slate-400">
|
||||
Diese Adresse steht auf eingehenden Rechnungen als Empfänger (z. B. die eigene Verwaltungsadresse).
|
||||
Eigenobjekte werden bei der automatischen Beleg-Zuordnung übersprungen – die Zuordnung erfolgt dann
|
||||
über die Adresse bzw. Stromzählernummer des Mietobjekts.
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</Abschnitt>
|
||||
{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>
|
||||
)}
|
||||
</label>
|
||||
</Wide>
|
||||
</FormSection>
|
||||
<FormFehler fehler={fehler} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
|
||||
@@ -248,7 +235,7 @@ function WohnungDialog({ wohnung, gebaeude, festesHaus }: { wohnung?: Unit; geba
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{bearbeiten
|
||||
@@ -259,7 +246,7 @@ function WohnungDialog({ wohnung, gebaeude, festesHaus }: { wohnung?: Unit; geba
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Abschnitt titel="Zuordnung">
|
||||
<FormSection titel="Zuordnung" icon={Home}>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Haus</Label>
|
||||
<Select value={building} onValueChange={waehleHaus} disabled={Boolean(festesHaus)}>
|
||||
@@ -273,8 +260,8 @@ function WohnungDialog({ wohnung, gebaeude, festesHaus }: { wohnung?: Unit; geba
|
||||
{festesHaus && <p className="text-xs text-slate-400 dark:text-slate-500">Die Wohnung gehört fest zu diesem Haus.</p>}
|
||||
</div>
|
||||
<Feld id="w-nr" label="Wohnungsnummer" placeholder="z. B. 12" value={number} onChange={(e) => setNumber(e.target.value)} />
|
||||
</Abschnitt>
|
||||
<Abschnitt titel="Ausstattung">
|
||||
</FormSection>
|
||||
<FormSection titel="Ausstattung" icon={LayoutGrid}>
|
||||
<Feld id="w-etage" label="Etage" hint="0 = Erdgeschoss, negative Werte = Keller" inputMode="numeric" placeholder="z. B. 1" value={floor} onChange={(e) => setFloor(e.target.value)} />
|
||||
<Feld id="w-qm" label="Wohnfläche (m²)" inputMode="decimal" placeholder="z. B. 68,5" value={size} onChange={(e) => setSize(e.target.value)} />
|
||||
<div className="space-y-1.5">
|
||||
@@ -292,28 +279,26 @@ function WohnungDialog({ wohnung, gebaeude, festesHaus }: { wohnung?: Unit; geba
|
||||
Optional{!bearbeiten && ' – wird bei Neuanlage automatisch aus dem Haus übernommen'}.
|
||||
</p>
|
||||
</div>
|
||||
</Abschnitt>
|
||||
<Abschnitt titel="Status">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Status</Label>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as Unit['status'])}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="vermietet">Vermietet</SelectItem>
|
||||
<SelectItem value="leerstand">Leerstand</SelectItem>
|
||||
<SelectItem value="unklar">Unklar</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">
|
||||
Vermietet/Leerstand wird bei Anlage oder Löschung eines Mieters in der Mieterverwaltung automatisch gesetzt.
|
||||
</p>
|
||||
</div>
|
||||
</Abschnitt>
|
||||
{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>
|
||||
)}
|
||||
</FormSection>
|
||||
<FormSection titel="Status" icon={DoorOpen}>
|
||||
<Wide>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Status</Label>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as Unit['status'])}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="vermietet">Vermietet</SelectItem>
|
||||
<SelectItem value="leerstand">Leerstand</SelectItem>
|
||||
<SelectItem value="unklar">Unklar</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">
|
||||
Vermietet/Leerstand wird bei Anlage oder Löschung eines Mieters in der Mieterverwaltung automatisch gesetzt.
|
||||
</p>
|
||||
</div>
|
||||
</Wide>
|
||||
</FormSection>
|
||||
<FormFehler fehler={fehler} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
|
||||
|
||||
+50
-47
@@ -31,6 +31,7 @@ import { useStore } from '../store/store'
|
||||
import { fmtDatum } from '../lib/format'
|
||||
import PdfLightbox from '../components/PdfLightbox'
|
||||
import { apiUpload, ApiFehler } from '../lib/api'
|
||||
import { FormSection, Wide, FormFehler } from '@/components/forms/FormSection'
|
||||
import type { DocumentItem, Unit } from '../types'
|
||||
|
||||
const KATEGORIEN: DocumentItem['category'][] = ['Mietvertrag', 'Nebenkostenabrechnung', 'Hausordnung', 'Protokoll', 'Sonstiges']
|
||||
@@ -130,21 +131,23 @@ function DokumentDialog({ dokument, einheiten }: { dokument?: DocumentItem; einh
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{bearbeiten ? 'Dokument bearbeiten' : 'Neues Dokument anlegen'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="d-titel">Titel</Label>
|
||||
<Input
|
||||
id="d-titel"
|
||||
placeholder="z. B. Mietvertrag – Blücherstr. 59, Whg. 1"
|
||||
value={titel}
|
||||
onChange={(e) => setTitel(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormSection titel="Dokument" icon={FileText}>
|
||||
<Wide>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="d-titel">Titel</Label>
|
||||
<Input
|
||||
id="d-titel"
|
||||
placeholder="z. B. Mietvertrag – Blücherstr. 59, Whg. 1"
|
||||
value={titel}
|
||||
onChange={(e) => setTitel(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Wide>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Kategorie</Label>
|
||||
<Select value={kategorie} onValueChange={(v) => setKategorie(v as DocumentItem['category'])}>
|
||||
@@ -176,8 +179,6 @@ function DokumentDialog({ dokument, einheiten }: { dokument?: DocumentItem; einh
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="d-datum">Datum</Label>
|
||||
<Input id="d-datum" placeholder="TT.MM.JJJJ" value={datum} onChange={(e) => setDatum(e.target.value)} />
|
||||
@@ -186,40 +187,42 @@ function DokumentDialog({ dokument, einheiten }: { dokument?: DocumentItem; einh
|
||||
<Label htmlFor="d-groesse">Dateigröße (optional)</Label>
|
||||
<Input id="d-groesse" placeholder="wird beim Upload gesetzt" value={groesse} onChange={(e) => setGroesse(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="d-datei">PDF-Datei (optional, max. 25 MB)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="d-datei"
|
||||
ref={dateiInput}
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
onChange={(e) => dateiWaehlen(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{datei && (
|
||||
<button
|
||||
type="button"
|
||||
title="Datei entfernen"
|
||||
onClick={() => { setDatei(null); if (dateiInput.current) dateiInput.current.value = '' }}
|
||||
className="shrink-0 rounded-lg bg-slate-100 px-2.5 py-2 text-xs font-semibold text-slate-600 hover:bg-slate-200"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{bearbeiten && dokument?.hasFile && !datei && (
|
||||
<p className="text-xs text-slate-400">
|
||||
Bereits hinterlegt: {dokument.fileName ?? 'PDF'} – ein neuer Upload ersetzt die Datei.
|
||||
</p>
|
||||
)}
|
||||
{datei && (
|
||||
<p className="flex items-center gap-1 text-xs text-teal-700">
|
||||
<Upload className="h-3.5 w-3.5" /> {datei.name} wird beim Speichern hochgeladen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{fehler && <p className="text-sm font-medium text-red-600">{fehler}</p>}
|
||||
<Wide>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="d-datei">PDF-Datei (optional, max. 25 MB)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="d-datei"
|
||||
ref={dateiInput}
|
||||
type="file"
|
||||
accept="application/pdf,.pdf"
|
||||
onChange={(e) => dateiWaehlen(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{datei && (
|
||||
<button
|
||||
type="button"
|
||||
title="Datei entfernen"
|
||||
onClick={() => { setDatei(null); if (dateiInput.current) dateiInput.current.value = '' }}
|
||||
className="shrink-0 rounded-lg bg-slate-100 px-2.5 py-2 text-xs font-semibold text-slate-600 hover:bg-slate-200"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{bearbeiten && dokument?.hasFile && !datei && (
|
||||
<p className="text-xs text-slate-400">
|
||||
Bereits hinterlegt: {dokument.fileName ?? 'PDF'} – ein neuer Upload ersetzt die Datei.
|
||||
</p>
|
||||
)}
|
||||
{datei && (
|
||||
<p className="flex items-center gap-1 text-xs text-teal-700">
|
||||
<Upload className="h-3.5 w-3.5" /> {datei.name} wird beim Speichern hochgeladen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Wide>
|
||||
</FormSection>
|
||||
<FormFehler fehler={fehler} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
|
||||
+13
-12
@@ -31,6 +31,7 @@ import { useStore, fmtEUR } from '../store/store'
|
||||
import { fmtDatum } from '../lib/format'
|
||||
import type { Beleg, DocumentItem, KostenItem } from '../types'
|
||||
import PdfLightbox from '../components/PdfLightbox'
|
||||
import { FormSection, Wide, FormFehler } from '@/components/forms/FormSection'
|
||||
|
||||
const KOSTEN_ARTEN = [
|
||||
'Wasser/Abwasser', 'Müllabfuhr', 'Straßenreinigung/Winterdienst', 'Gartenpflege',
|
||||
@@ -96,12 +97,12 @@ function KostenDialog({ position, vorbefuellung, onGespeichert, triggerLabel }:
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{bearbeiten ? 'Kostenposition bearbeiten' : 'Neue Kostenposition erfassen'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormSection titel="Position" icon={Receipt}>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Kostenart</Label>
|
||||
<Select value={kostenart} onValueChange={setKostenart}>
|
||||
@@ -129,8 +130,6 @@ function KostenDialog({ position, vorbefuellung, onGespeichert, triggerLabel }:
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="k-jahr">Jahr</Label>
|
||||
<Input id="k-jahr" inputMode="numeric" placeholder="z. B. 2026" value={jahr} onChange={(e) => setJahr(e.target.value)} />
|
||||
@@ -139,8 +138,8 @@ function KostenDialog({ position, vorbefuellung, onGespeichert, triggerLabel }:
|
||||
<Label htmlFor="k-betrag">Betrag (€)</Label>
|
||||
<Input id="k-betrag" inputMode="decimal" placeholder="z. B. 1.250,00" value={betrag} onChange={(e) => setBetrag(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
</FormSection>
|
||||
<FormSection titel="Zuordnung" icon={Landmark}>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Haus</Label>
|
||||
<Select value={haus} onValueChange={setHaus} disabled={einheit !== '__haus'}>
|
||||
@@ -175,12 +174,14 @@ function KostenDialog({ position, vorbefuellung, onGespeichert, triggerLabel }:
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="k-notiz">Notiz (optional)</Label>
|
||||
<Input id="k-notiz" placeholder="z. B. Rechnungs-Nr., Abrechnungszeitraum …" value={notiz} onChange={(e) => setNotiz(e.target.value)} />
|
||||
</div>
|
||||
{fehler && <p className="text-sm font-medium text-red-600">{fehler}</p>}
|
||||
<Wide>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="k-notiz">Notiz (optional)</Label>
|
||||
<Input id="k-notiz" placeholder="z. B. Rechnungs-Nr., Abrechnungszeitraum …" value={notiz} onChange={(e) => setNotiz(e.target.value)} />
|
||||
</div>
|
||||
</Wide>
|
||||
</FormSection>
|
||||
<FormFehler fehler={fehler} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
BadgeEuro, CalendarRange, FileText, Pencil, Phone, Plus, Search, Trash2, Upload, Users,
|
||||
BadgeEuro, CalendarRange, FileText, Home, Pencil, Phone, Plus, Search, ShieldCheck, Trash2, Upload, Users,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { useStore, fmtEUR } from '../store/store'
|
||||
import { fmtDatum } from '../lib/format'
|
||||
import PdfLightbox from '../components/PdfLightbox'
|
||||
import { FormSection, Wide, FormFehler } from '@/components/forms/FormSection'
|
||||
import type { DocumentItem, KautionArt, Mieter, Unit, Vertragsstatus } from '../types'
|
||||
|
||||
/** Komma oder Punkt als Dezimaltrenner akzeptieren. */
|
||||
@@ -44,18 +45,6 @@ const KAUTION_ART_LABEL: Record<KautionArt, string> = {
|
||||
sonstiges: 'Andere',
|
||||
}
|
||||
|
||||
/** Optischer Abschnitt im einspaltigen Formular. */
|
||||
function Abschnitt({ titel, children }: { titel: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-3 rounded-xl border bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800/50">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
{titel}
|
||||
</h4>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Feld({ id, label, hint, ...input }: {
|
||||
id: string
|
||||
label: string
|
||||
@@ -226,40 +215,42 @@ function MieterDialog({ mieter }: { mieter?: Mieter }) {
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{bearbeiten ? `Mieter „${mieter!.vorname} ${mieter!.name}" bearbeiten` : 'Neuen Mieter anlegen'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Abschnitt titel="Person">
|
||||
<FormSection titel="Person" icon={Users}>
|
||||
<Feld id="m-name" label="Name" placeholder="z. B. Berger" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Feld id="m-vorname" label="Vorname" placeholder="z. B. Anna" value={vorname} onChange={(e) => setVorname(e.target.value)} />
|
||||
<Feld id="m-tel" label="Telefon" placeholder="z. B. 0151 23456789" value={telefon} onChange={(e) => setTelefon(e.target.value)} />
|
||||
<Feld id="m-mail" label="E-Mail" type="email" placeholder="z. B. a.berger@mail.de" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</Abschnitt>
|
||||
<Abschnitt titel="Wohnung">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Wohneinheit</Label>
|
||||
<Select value={unitId} onValueChange={setUnitId}>
|
||||
<SelectTrigger><SelectValue placeholder="Wohnung wählen …" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{wohnungen.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>{einheitLabel(u)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">
|
||||
Pro Wohnung kann genau ein Mieter geführt werden. Beim Speichern wird die Wohnung auf „Vermietet" gesetzt.
|
||||
</p>
|
||||
</div>
|
||||
</Abschnitt>
|
||||
<Abschnitt titel="Miete">
|
||||
</FormSection>
|
||||
<FormSection titel="Wohnung" icon={Home}>
|
||||
<Wide>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Wohneinheit</Label>
|
||||
<Select value={unitId} onValueChange={setUnitId}>
|
||||
<SelectTrigger><SelectValue placeholder="Wohnung wählen …" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{wohnungen.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>{einheitLabel(u)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">
|
||||
Pro Wohnung kann genau ein Mieter geführt werden. Beim Speichern wird die Wohnung auf „Vermietet" gesetzt.
|
||||
</p>
|
||||
</div>
|
||||
</Wide>
|
||||
</FormSection>
|
||||
<FormSection titel="Miete" icon={BadgeEuro}>
|
||||
<Feld id="m-kalt" label="Kaltmiete (€/Monat)" inputMode="decimal" placeholder="z. B. 520" value={kaltmiete} onChange={(e) => setKaltmiete(e.target.value)} />
|
||||
<Feld id="m-warm" label="Warmmiete (€/Monat)" inputMode="decimal" placeholder="z. B. 690" value={warmmiete} onChange={(e) => setWarmmiete(e.target.value)} />
|
||||
</Abschnitt>
|
||||
<Abschnitt titel="Vertrag">
|
||||
</FormSection>
|
||||
<FormSection titel="Vertrag" icon={CalendarRange}>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Vertragsstatus</Label>
|
||||
<Select value={vertragsstatus} onValueChange={(v) => setVertragsstatus(v as Vertragsstatus)}>
|
||||
@@ -289,8 +280,8 @@ function MieterDialog({ mieter }: { mieter?: Mieter }) {
|
||||
)}
|
||||
<Feld id="m-beginn" label="Mietbeginn" type="date" value={mietbeginnAm} onChange={(e) => setMietbeginnAm(e.target.value)} />
|
||||
<Feld id="m-ende" label="Mietende" hint="Leer lassen bei unbefristetem Vertrag" type="date" value={mietendeAm} onChange={(e) => setMietendeAm(e.target.value)} />
|
||||
</Abschnitt>
|
||||
<Abschnitt titel="Kaution">
|
||||
</FormSection>
|
||||
<FormSection titel="Kaution" icon={ShieldCheck}>
|
||||
<Feld id="m-kaut-hoehe" label="Kaution Höhe (€)" inputMode="decimal" placeholder="z. B. 1.500" value={kautionHoehe} onChange={(e) => setKautionHoehe(e.target.value)} />
|
||||
<div className="space-y-1.5">
|
||||
<Label>Kaution Art</Label>
|
||||
@@ -319,25 +310,23 @@ function MieterDialog({ mieter }: { mieter?: Mieter }) {
|
||||
{kautionszahlung === 'raten' && (
|
||||
<Feld id="m-kaut-raten" label="Anzahl der Raten" inputMode="numeric" placeholder="z. B. 3" value={kautionRaten} onChange={(e) => setKautionRaten(e.target.value)} />
|
||||
)}
|
||||
</Abschnitt>
|
||||
</FormSection>
|
||||
{bearbeiten && (
|
||||
<Abschnitt titel="Dokumente">
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">
|
||||
Personalausweis, SCHUFA-Auskunft, Bankauszug u. a. – Klick auf die Vorschau öffnet Druck- und
|
||||
E-Mail-Optionen. Pro Kategorie wird genau eine Datei (PDF oder Bild) geführt.
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{DOKUMENT_KATEGORIEN.map((k) => (
|
||||
<DokumentSlot key={k} mieter={mieter!} kategorie={k} />
|
||||
))}
|
||||
</div>
|
||||
</Abschnitt>
|
||||
)}
|
||||
{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>
|
||||
<FormSection titel="Dokumente" icon={FileText}>
|
||||
<Wide>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">
|
||||
Personalausweis, SCHUFA-Auskunft, Bankauszug u. a. – Klick auf die Vorschau öffnet Druck- und
|
||||
E-Mail-Optionen. Pro Kategorie wird genau eine Datei (PDF oder Bild) geführt.
|
||||
</p>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
{DOKUMENT_KATEGORIEN.map((k) => (
|
||||
<DokumentSlot key={k} mieter={mieter!} kategorie={k} />
|
||||
))}
|
||||
</div>
|
||||
</Wide>
|
||||
</FormSection>
|
||||
)}
|
||||
<FormFehler fehler={fehler} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
|
||||
|
||||
+69
-82
@@ -11,7 +11,7 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
FileSignature, Plus, Pencil, Trash2, Mail, FileText, Upload, UserPlus,
|
||||
Settings, Search, TriangleAlert, Eye,
|
||||
Settings, Search, TriangleAlert, Eye, BadgeEuro,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { useStore, fmtEUR } from '../store/store'
|
||||
import { fmtDatum } from '../lib/format'
|
||||
import PdfLightbox from '../components/PdfLightbox'
|
||||
import { FormSection, Wide, FormFehler } from '@/components/forms/FormSection'
|
||||
import type { DocumentItem, Mietvertrag, MietvertragForm, Unit, VermieterDaten } from '../types'
|
||||
|
||||
/** Komma oder Punkt als Dezimaltrenner akzeptieren. */
|
||||
@@ -77,20 +78,22 @@ function VermieterDialog() {
|
||||
<Settings className="h-4 w-4" /> Vermieterdaten
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Vermieterdaten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Diese Angaben erscheinen im Briefkopf und in den Vertragsklauseln der automatisch erzeugten
|
||||
Mietvertrags-Entwürfe (Name, Anschrift, Kontoverbindung).
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="v-name">Name des Vermieters</Label>
|
||||
<Input id="v-name" placeholder="z. B. Hinterstein Immobilien, Inhaber Joseph Hinterstein" value={f.name} onChange={set('name')} />
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Diese Angaben erscheinen im Briefkopf und in den Vertragsklauseln der automatisch erzeugten
|
||||
Mietvertrags-Entwürfe (Name, Anschrift, Kontoverbindung).
|
||||
</p>
|
||||
<FormSection titel="Vermieter" icon={Settings}>
|
||||
<Wide>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="v-name">Name des Vermieters</Label>
|
||||
<Input id="v-name" placeholder="z. B. Hinterstein Immobilien, Inhaber Joseph Hinterstein" value={f.name} onChange={set('name')} />
|
||||
</div>
|
||||
</Wide>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="v-strasse">Straße</Label>
|
||||
<Input id="v-strasse" value={f.strasse} onChange={set('strasse')} />
|
||||
@@ -99,8 +102,6 @@ function VermieterDialog() {
|
||||
<Label htmlFor="v-nr">Hausnummer</Label>
|
||||
<Input id="v-nr" value={f.hausnummer} onChange={set('hausnummer')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="v-plz">PLZ</Label>
|
||||
<Input id="v-plz" value={f.plz} onChange={set('plz')} />
|
||||
@@ -109,8 +110,6 @@ function VermieterDialog() {
|
||||
<Label htmlFor="v-ort">Ort</Label>
|
||||
<Input id="v-ort" value={f.ort} onChange={set('ort')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="v-tel">Telefon</Label>
|
||||
<Input id="v-tel" value={f.telefon} onChange={set('telefon')} />
|
||||
@@ -119,8 +118,6 @@ function VermieterDialog() {
|
||||
<Label htmlFor="v-mail">E-Mail</Label>
|
||||
<Input id="v-mail" type="email" value={f.email} onChange={set('email')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="v-bank">Bank</Label>
|
||||
<Input id="v-bank" value={f.bankname} onChange={set('bankname')} />
|
||||
@@ -129,10 +126,8 @@ function VermieterDialog() {
|
||||
<Label htmlFor="v-iban">IBAN</Label>
|
||||
<Input id="v-iban" placeholder="DE00 0000 0000 0000 0000 00" value={f.iban} onChange={set('iban')} />
|
||||
</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>
|
||||
)}
|
||||
</FormSection>
|
||||
<FormFehler fehler={fehler} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
|
||||
@@ -207,78 +202,70 @@ function MietvertragDialog({ vertrag, einheiten }: { vertrag?: Mietvertrag; einh
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{bearbeiten ? 'Mietvertrag bearbeiten' : 'Neuen Mietvertrag anlegen'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Wohnung</Label>
|
||||
<Select value={unitId} onValueChange={einheitWechseln}>
|
||||
<SelectTrigger><SelectValue placeholder="Wohnung wählen …" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{einheiten.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>
|
||||
{u.building}, Whg. {u.number} (Etage {u.floor}, {u.size} m²{u.status === 'leerstand' ? ', Leerstand' : ''})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<FormSection titel="Wohnung" icon={FileSignature}>
|
||||
<Wide>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Wohnung</Label>
|
||||
<Select value={unitId} onValueChange={einheitWechseln}>
|
||||
<SelectTrigger><SelectValue placeholder="Wohnung wählen …" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{einheiten.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>
|
||||
{u.building}, Whg. {u.number} (Etage {u.floor}, {u.size} m²{u.status === 'leerstand' ? ', Leerstand' : ''})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Wide>
|
||||
</FormSection>
|
||||
|
||||
<div className="space-y-3 rounded-xl border bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800/50">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Zukünftige Mieterin / zukünftiger Mieter
|
||||
</h4>
|
||||
<FormSection titel="Zukünftige Mieterin / zukünftiger Mieter" icon={UserPlus}>
|
||||
<Wide>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="m-name">Name (Vor- und Nachname)</Label>
|
||||
<Input id="m-name" placeholder="z. B. Anna Berger" value={mieterName} onChange={(e) => setMieterName(e.target.value)} />
|
||||
</div>
|
||||
</Wide>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="m-name">Name (Vor- und Nachname)</Label>
|
||||
<Input id="m-name" placeholder="z. B. Anna Berger" value={mieterName} onChange={(e) => setMieterName(e.target.value)} />
|
||||
<Label htmlFor="m-mail">E-Mail</Label>
|
||||
<Input id="m-mail" type="email" placeholder="für Versand des Entwurfs" value={mieterEmail} onChange={(e) => setMieterEmail(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="m-mail">E-Mail</Label>
|
||||
<Input id="m-mail" type="email" placeholder="für Versand des Entwurfs" value={mieterEmail} onChange={(e) => setMieterEmail(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="m-tel">Telefon</Label>
|
||||
<Input id="m-tel" value={mieterTelefon} onChange={(e) => setMieterTelefon(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="m-tel">Telefon</Label>
|
||||
<Input id="m-tel" value={mieterTelefon} onChange={(e) => setMieterTelefon(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<div className="space-y-3 rounded-xl border bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800/50">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Mietkonditionen
|
||||
</h4>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="m-beginn">Mietbeginn</Label>
|
||||
<Input id="m-beginn" placeholder="TT.MM.JJJJ" value={beginnAm} onChange={(e) => setBeginnAm(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="m-ende">Mietende</Label>
|
||||
<Input id="m-ende" placeholder="leer = unbefristet" value={endeAm} onChange={(e) => setEndeAm(e.target.value)} />
|
||||
</div>
|
||||
<FormSection titel="Mietkonditionen" icon={BadgeEuro}>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="m-beginn">Mietbeginn</Label>
|
||||
<Input id="m-beginn" placeholder="TT.MM.JJJJ" value={beginnAm} onChange={(e) => setBeginnAm(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="m-kalt">Kaltmiete (€/Monat)</Label>
|
||||
<Input id="m-kalt" inputMode="decimal" value={kaltmiete} onChange={(e) => setKaltmiete(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="m-nk">Nebenkosten (€/Monat)</Label>
|
||||
<Input id="m-nk" inputMode="decimal" value={nebenkosten} onChange={(e) => setNebenkosten(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="m-kaution">Kaution (€)</Label>
|
||||
<Input id="m-kaution" inputMode="decimal" value={kaution} onChange={(e) => setKaution(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="m-ende">Mietende</Label>
|
||||
<Input id="m-ende" placeholder="leer = unbefristet" value={endeAm} onChange={(e) => setEndeAm(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="m-kalt">Kaltmiete (€/Monat)</Label>
|
||||
<Input id="m-kalt" inputMode="decimal" value={kaltmiete} onChange={(e) => setKaltmiete(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="m-nk">Nebenkosten (€/Monat)</Label>
|
||||
<Input id="m-nk" inputMode="decimal" value={nebenkosten} onChange={(e) => setNebenkosten(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="m-kaution">Kaution (€)</Label>
|
||||
<Input id="m-kaution" inputMode="decimal" value={kaution} onChange={(e) => setKaution(e.target.value)} />
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{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>
|
||||
)}
|
||||
<FormFehler fehler={fehler} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
|
||||
|
||||
+18
-9
@@ -63,10 +63,13 @@ export default function Profil() {
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* Feste Daten (nur Ansicht) */}
|
||||
<div className="rounded-2xl border bg-white p-5 shadow-sm">
|
||||
<div className="mb-4 flex items-center gap-2 font-semibold">
|
||||
<IdCard className="h-4 w-4 text-teal-600" /> Konto & Wohnung
|
||||
<div className="mb-4 flex items-center gap-2.5 border-b border-slate-100 pb-3">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-teal-600/10 text-teal-700 dark:bg-teal-400/10 dark:text-teal-300">
|
||||
<IdCard className="h-4 w-4" />
|
||||
</span>
|
||||
<h2 className="font-display text-[13px] font-semibold uppercase tracking-wider text-teal-800 dark:text-teal-300">Konto & Wohnung</h2>
|
||||
</div>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-slate-400">Name</dt>
|
||||
<dd className="font-medium">{me.name}</dd>
|
||||
@@ -96,10 +99,13 @@ export default function Profil() {
|
||||
|
||||
{/* Kontaktdaten (editierbar) */}
|
||||
<div className="rounded-2xl border bg-white p-5 shadow-sm">
|
||||
<div className="mb-4 flex items-center gap-2 font-semibold">
|
||||
<Phone className="h-4 w-4 text-teal-600" /> Kontaktdaten
|
||||
<div className="mb-4 flex items-center gap-2.5 border-b border-slate-100 pb-3">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-teal-600/10 text-teal-700 dark:bg-teal-400/10 dark:text-teal-300">
|
||||
<Phone className="h-4 w-4" />
|
||||
</span>
|
||||
<h2 className="font-display text-[13px] font-semibold uppercase tracking-wider text-teal-800 dark:text-teal-300">Kontaktdaten</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="p-email">E-Mail-Adresse</Label>
|
||||
<div className="relative">
|
||||
@@ -135,13 +141,16 @@ export default function Profil() {
|
||||
{/* Bankverbindung (editierbar; Mieter) */}
|
||||
{role === 'tenant' && (
|
||||
<div className="rounded-2xl border bg-white p-5 shadow-sm lg:col-span-2">
|
||||
<div className="mb-4 flex items-center gap-2 font-semibold">
|
||||
<Landmark className="h-4 w-4 text-teal-600" /> Bankverbindung
|
||||
<div className="mb-4 flex items-center gap-2.5 border-b border-slate-100 pb-3">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-teal-600/10 text-teal-700 dark:bg-teal-400/10 dark:text-teal-300">
|
||||
<Landmark className="h-4 w-4" />
|
||||
</span>
|
||||
<h2 className="font-display text-[13px] font-semibold uppercase tracking-wider text-teal-800 dark:text-teal-300">Bankverbindung</h2>
|
||||
<span className="text-xs font-normal text-slate-400">
|
||||
(für Rückzahlungen, z. B. Kaution)
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="p-konto">Kontoinhaber</Label>
|
||||
<Input
|
||||
|
||||
+27
-22
@@ -23,6 +23,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { useStore } from '../store/store'
|
||||
import { fmtDatum, fmtDatumZeit } from '../lib/format'
|
||||
import { FormSection, Wide } from '@/components/forms/FormSection'
|
||||
import type { TicketPriority, TicketStatus } from '../types'
|
||||
|
||||
const statusStyle: Record<TicketStatus, string> = {
|
||||
@@ -80,21 +81,23 @@ function NewTicketDialog() {
|
||||
<Plus className="h-4 w-4" /> Neue Schadensmeldung
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<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">
|
||||
<FormSection titel="Meldung" icon={Wrench}>
|
||||
<Wide>
|
||||
<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>
|
||||
</Wide>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Kategorie</Label>
|
||||
<Select value={category} onValueChange={setCategory}>
|
||||
@@ -121,17 +124,19 @@ function NewTicketDialog() {
|
||||
</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>
|
||||
<Wide>
|
||||
<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>
|
||||
</Wide>
|
||||
</FormSection>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
|
||||
|
||||
+11
-20
@@ -21,6 +21,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { useStore } from '../store/store'
|
||||
import { fmtDatum } from '../lib/format'
|
||||
import { FormSection, FormFehler } from '@/components/forms/FormSection'
|
||||
import type { Meter, MeterReading, ZaehlerArt } from '../types'
|
||||
|
||||
const ARTEN: ZaehlerArt[] = ['Strom', 'Wasser', 'Gas', 'Wärme', 'Sonstiges']
|
||||
@@ -100,12 +101,12 @@ function ZaehlerDialog({ meter }: { meter?: Meter }) {
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{bearbeiten ? `Zähler ${meter!.nummer} bearbeiten` : 'Neuen Zähler anlegen'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormSection titel="Zählerdaten" icon={Gauge}>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="z-nr">Zählernummer</Label>
|
||||
<Input
|
||||
@@ -126,8 +127,6 @@ function ZaehlerDialog({ meter }: { meter?: Meter }) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="z-stand">Aktueller Zählerstand</Label>
|
||||
<Input
|
||||
@@ -149,8 +148,8 @@ function ZaehlerDialog({ meter }: { meter?: Meter }) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
</FormSection>
|
||||
<FormSection titel="Zuordnung" icon={Building2}>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Wohneinheit</Label>
|
||||
<Select value={unitId} onValueChange={setUnitId}>
|
||||
@@ -182,12 +181,8 @@ function ZaehlerDialog({ meter }: { meter?: Meter }) {
|
||||
<p className="text-xs text-slate-400">Nur nötig für Zähler ohne Wohnungsbezug.</p>
|
||||
</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>
|
||||
)}
|
||||
</FormSection>
|
||||
<FormFehler fehler={fehler} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
|
||||
@@ -336,7 +331,7 @@ function StandEintragenDialog({ meter }: { meter: Meter }) {
|
||||
<Plus className="h-3.5 w-3.5" /> Stand eintragen
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Stand eintragen · Zähler {meter.nummer}
|
||||
@@ -344,7 +339,7 @@ function StandEintragenDialog({ meter }: { meter: Meter }) {
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormSection titel="Neue Ablesung" icon={CalendarDays}>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="zr-stand">Neuer Zählerstand</Label>
|
||||
<Input
|
||||
@@ -364,12 +359,8 @@ function StandEintragenDialog({ meter }: { meter: Meter }) {
|
||||
onChange={(e) => setDatum(e.target.value)}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</FormSection>
|
||||
<FormFehler fehler={fehler} />
|
||||
{warnung && (
|
||||
<div className="rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:bg-amber-950 dark:text-amber-200" role="alert">
|
||||
{warnung}
|
||||
|
||||
Reference in New Issue
Block a user