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:
2026-10-04 21:30:34 +02:00
parent 9d14d367ee
commit 6eec3ac437
16 changed files with 540 additions and 645 deletions
+6
View File
@@ -4,6 +4,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/logo.png" /> <link rel="icon" type="image/png" href="/logo.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Sora:wght@500;600;700&display=swap"
rel="stylesheet"
/>
<title>Hinterstein Immobilien · Mieterportal</title> <title>Hinterstein Immobilien · Mieterportal</title>
</head> </head>
<body> <body>
+26 -2
View File
@@ -223,8 +223,32 @@ N8N: importierbarer Workflow n8n/assist-workflow.json (Webhook → AI-Agent mit
Kimi OpenAI-kompatibel https://api.moonshot.ai/v1, Modell kimi-k2.6 → 6 Kimi OpenAI-kompatibel https://api.moonshot.ai/v1, Modell kimi-k2.6 → 6
HTTP-Tools → Respond {antwort}); Setup-Doku n8n/README-assistent.md. HTTP-Tools → Respond {antwort}); Setup-Doku n8n/README-assistent.md.
Deployt, E2E-getestet (Tools 401/200, /api/assist sauberer 503 ohne Webhook). Deployt, E2E-getestet (Tools 401/200, /api/assist sauberer 503 ohne Webhook).
**Offen:** Workflow in N8N importieren + Kimi-Credential + Header-Auth +
Webhook-URL in portal.env eintragen (Schritte in n8n/README-assistent.md). **Erledigt 04.10. Abend (v1.37 fertig):** N8N-Workflow „Mieterportal – KI-Assistent
(Kimi)" serverseitig vollständig eingerichtet und aktiviert – direkt in die
n8n-SQLite-DB eingespielt (n8n 2.x API-Import unzuverlässig, 401). Finaler Aufbau
(Code-Node-Ansatz statt langchain, keine Credentials): Webhook POST mp-assist
(responseNode, wichtig: Property webhookId = Node-ID auf Root-Ebene, sonst 404
„not registered") → Code-Node prüft x-assist-secret, Kimi-Tool-Calling per
this.helpers.httpRequest (max. 6 Runden, kein temperature-Param – k2.6 erlaubt
nur 1, kein URLSearchParams in der Sandbox, 429 wird abgefangen) → Respond
{antwort}. PORTAL im Code = http://mp-api:3000 (internes npm_default-Netz).
Drei Geheimnisse als Literale im Code-Node (nicht im Repo, siehe
n8n/README-assistent.md). N8N_API_KEY aus der n8n-Compose entfernt (ungenutzt).
E2E live: „Wie hoch sind die monatlichen Mieteinnahmen?" → korrekte Antwort
(Warm 18.684 € / Kalt 17.686 €) via Portal-Login.
**Erledigt 04.10. Abend (Design):** Alle Formulare 2-spaltig auf shared Layout
umgebaut: Neue Komponente src/components/forms/FormSection.tsx (FormSection =
Karte mit Teal-Icon-Chip + Sora-Überschrift, Kind-Elemente in
grid md:grid-cols-2; Wide = md:col-span-2 für Checkboxen/Textareas/Uploads;
FormFehler). Umgebaut: Bestand (Haus+Wohnung), MieterVerwaltung,
Zaehler (Zähler+Zählerstand), Kosten, Tickets, BenutzerVerwaltung (Benutzer+
E-Mail), Mietvertraege (Vermieterdaten+Vertrag), Documents, Profil (Karten-
Raster 2-spaltig). Dialoge auf sm:max-w-2xl verbreitert. Fonts: Inter (Body) +
Sora (Headings, font-display) via Google Fonts in index.html + tailwind.config
fontFamily. Deployt (dist-Tausch auf Server, mp-portal-Neustart nötig wegen
Bind-Mount-Inode) und verifiziert (CSS/JS 200, Fonts im HTML, Login 200).
**Erledigt 04.10. (v1.35):** Mietvertragsmodul (Verwaltung, Navigation **Erledigt 04.10. (v1.35):** Mietvertragsmodul (Verwaltung, Navigation
„Mietverträge", Admin-only). Workflow: anlegen → Entwurf-PDF → Versand an „Mietverträge", Admin-only). Workflow: anlegen → Entwurf-PDF → Versand an
+59 -26
View File
@@ -2,51 +2,84 @@
Der Assistent läuft so: **Portal-Chat → N8N-Webhook → Kimi (Moonshot API) mit 6 Portal-Tools → Antwort zurück ins Portal.** Der Assistent läuft so: **Portal-Chat → N8N-Webhook → Kimi (Moonshot API) mit 6 Portal-Tools → Antwort zurück ins Portal.**
## 1. Voraussetzungen > **Stand: Der Workflow ist auf dem Produktivsystem (hocks.eu) bereits serverseitig eingerichtet und aktiv.**
> Die Datei `assist-workflow.json` in diesem Ordner ist die dokumentierte Referenz-Version.
> Der produktive Workflow wurde direkt in die N8N-Datenbank eingespielt, weil die lokale
> N8N-API in Version 2.x keinen zuverlässigen programmatischen Import erlaubt (401-Problem).
> Bei einem Re-Import auf einem anderen System müssen die drei Platzhalter im Code-Node belegt werden.
- N8N läuft auf dem VPS (Container `n8n`). ## 1. Architektur des Workflows
- Kimi-API-Key von <https://platform.moonshot.ai> (Aufladung ab ~5 $; Verbrauch des Assistenten liegt typisch unter 5 €/Monat bei 20 Fragen/Tag, Modell `kimi-k2.6`: ~0,4 US-Cent pro Frage).
## 2. N8N-Workflow importieren Ein einzelner Workflow **„Mieterportal – KI-Assistent (Kimi)"**, Webhook `POST mp-assist`, drei Nodes:
1. N8N öffnen → **Workflows → Import from File** → `assist-workflow.json` aus diesem Ordner. 1. **Webhook Portal** (`n8n-nodes-base.webhook`, typeVersion 2, responseMode `responseNode`)
2. Nach dem Import drei Platzhalter anpassen: – produktive URL: `http://n8n:5678/webhook/mp-assist` (internes Docker-Netz `npm_default`).
– Wichtig: Der Node braucht die Property **`webhookId`** (gleich der Node-ID) auf Root-Ebene,
sonst registriert n8n den Webhook bei `responseMode: responseNode` nicht (404 „not registered").
2. **Assistent (Kimi + Tools)** (`n8n-nodes-base.code`, typeVersion 2, runOnceForAllItems)
– Prüft den Header `x-assist-secret` selbst gegen das Shared Secret.
– Ruft `https://api.moonshot.ai/v1/chat/completions` (Modell `kimi-k2.6`) per
`this.helpers.httpRequest` auf, mit OpenAI-kompatiblem **Tool-Calling** für genau die
6 Portal-Endpunkte unter `/api/assist/tools/…` (Schleife, max. 6 Runden).
– **Kein `temperature`-Parameter** senden – `kimi-k2.6` akzeptiert nur `temperature: 1`,
sonst 400 „invalid temperature".
– **Kein `URLSearchParams`** im Sandbox-Code verwenden – nicht verfügbar; Query-Strings
manuell mit `encodeURIComponent` bauen.
– 429 (Rate-Limit) wird abgefangen und als freundliche Meldung zurückgegeben.
3. **Antwort an Portal** (`n8n-nodes-base.respondToWebhook`, typeVersion 1.1)
– `respondWith: json`, Body `={{ { antwort: $json.antwort } }}`.
| Wo | Was | Der langchain-basierte Entwurf (AI-Agent-Node) wurde verworfen: Die `@n8n/n8n-nodes-langchain`-
|---|---| Nodes liefen auf diesem Server nicht zuverlässig. Der Code-Node-Ansatz hat keine externen
| Webhook-Node → Credentials | **Header Auth** anlegen: Name `x-assist-secret`, Wert = `ASSIST_SHARED_SECRET` | Abhängigkeiten und keine Credentials (alle Geheimnisse als Literale im Code-Node).
| Kimi-Node → Credentials | **OpenAI API**-Credential anlegen: API Key = Moonshot-Key, Base URL `https://api.moonshot.ai/v1` |
| Alle 6 Tool-Nodes | Im Header `x-assist-key` den Wert `ASSIST_API_KEY` durch das echte Geheimnis ersetzen |
3. Workflow **aktivieren** (Toggle oben rechts). Die Webhook-URL steht dann im Webhook-Node, z. B. ## 2. Geheimnisse (Platzhalter im Workflow-JSON)
`https://n8n.hocks.eu/webhook/mp-assist` – sie wird in Schritt 3 gebraucht.
## 3. Portal-Env auf dem Server ergänzen | Platzhalter im Code-Node | Bedeutung | Wo konfiguriert |
|---|---|---|
| `<ASSIST_SHARED_SECRET>` | Header `x-assist-secret` zwischen Portal und n8n | `portal.env` (ASSIST_SHARED_SECRET) |
| `<ASSIST_KEY>` | Header `x-assist-key` für die 6 Tool-Endpunkte | `portal.env` (ASSIST_API_KEY) |
| `<KIMI_API_KEY>` | Moonshot-API-Key (`sk-…`) | Code-Node, Konstante ganz oben |
In `/opt/mieterportal/portal.env` (Datei auf dem VPS): ## 3. Portal-Env auf dem Server
In `/opt/mieterportal/portal.env` (nur auf dem VPS, gitignored):
```bash ```bash
N8N_ASSIST_WEBHOOK=https://n8n.hocks.eu/webhook/mp-assist N8N_ASSIST_WEBHOOK=http://n8n:5678/webhook/mp-assist
ASSIST_SHARED_SECRET=<langes-zufaelliges-geheimnis-1> ASSIST_SHARED_SECRET=<langes-zufaelliges-geheimnis-1>
ASSIST_API_KEY=<langes-zufaelliges-geheimnis-2> ASSIST_API_KEY=<langes-zufaelliges-geheimnis-2>
``` ```
Danach: `cd /opt/mieterportal && docker compose up -d` (nimmt die neuen Env-Variablen mit). Danach: `cd /opt/mieterportal && docker compose up -d`.
## 4. Test ## 4. Kosten
Im Portal als Verwaltung: **KI-Assistent** in der Navigation → „Welche Wohnungen stehen leer?“ Kimi `kimi-k2.6`: ~0,4 US-Cent pro Frage (Systemprompt + Tool-Schleife). Bei 20 Fragen/Tag
Der erste Aufruf dauert ggf. 10–30 s (Agent lädt Tools). typisch unter 5 €/Monat. Aufladung des Moonshot-Kontos ab ~5 $.
## 5. Test
Im Portal als Verwaltung: **KI-Assistent** in der Navigation → „Welche Wohnungen stehen leer?"
Der erste Aufruf dauert ggf. 10–60 s (Tool-Schleife).
Serverseitig direkt testbar:
```bash
docker exec mp-api node -e "fetch('http://n8n:5678/webhook/mp-assist',{method:'POST',headers:{'Content-Type':'application/json','x-assist-secret':'<SECRET1>'},body:JSON.stringify({frage:'Welche Wohnungen stehen leer?',verlauf:[],benutzer:'E2E'})}).then(async r=>console.log(r.status,await r.text()))"
```
## Fehlerbilder ## Fehlerbilder
| Fehlermeldung im Chat | Ursache | | Fehlermeldung | Ursache |
|---|---| |---|---|
| „noch nicht konfiguriert (N8N_ASSIST_WEBHOOK fehlt)" | Env-Variable fehlt / Container nicht neu gestartet | | „not registered" (404) | Workflow nicht aktiv, `webhookId` am Webhook-Node fehlt, oder n8n neu gestartet ohne Outbox-Eintrag |
| „Assistent antwortet nicht (N8N Status …)" | Webhook-URL falsch, Workflow nicht aktiv, oder Header-Secret passt nicht | | „invalid temperature" (400) | `temperature != 1` im Kimi-Payload |
| „Assistent hat keine Antwort geliefert" | N8N liefert kein JSON `{ "antwort": "…" }` – Respond-Node prüfen | | „Assistent antwortet nicht (N8N Status 500)" | Kimi 429 (Rate-Limit, kurz warten) oder Code-Fehler – Executions in n8n prüfen |
| „braucht zu lange" | Kimi-Timeout > 90 s – Modell/Credential prüfen | | Antwort „Tool … nicht erreichbar" | Portal-Tool-Endpunkte nicht erreichbar (Netz `npm_default`, `x-assist-key`) |
## Datenschutz-Hinweis ## Datenschutz-Hinweis
Das Tool `mieter_kontakt` liefert personenbezogene Daten an Kimi (Serverstandort Singapur, Training laut Anbieter-AGB möglich). Wer das vermeiden will, entfernt das Tool einfach aus dem AI-Agenten – alle anderen Tools liefern nur Bestands- und Zahlen-Daten. Das Tool `mieter_kontakt` liefert personenbezogene Daten an Kimi (Serverstandort Singapur,
Training laut Anbieter-AGB möglich). Wer das vermeiden will, entfernt den Tool-Eintrag aus
dem `TOOLS`-Array im Code-Node – alle anderen Tools liefern nur Bestands- und Zahlen-Daten.
+29 -239
View File
File diff suppressed because one or more lines are too long
+62
View File
@@ -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>
)
}
+5
View File
@@ -71,6 +71,11 @@
} }
body { body {
@apply bg-background text-foreground; @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;
} }
} }
+64 -63
View File
@@ -15,6 +15,7 @@ import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { useStore } from '../store/store' import { useStore } from '../store/store'
import { fmtDatumZeit } from '../lib/format' import { fmtDatumZeit } from '../lib/format'
import { FormSection, Wide, FormFehler } from '@/components/forms/FormSection'
import type { Benutzer, BenutzerRolle } from '../types' import type { Benutzer, BenutzerRolle } from '../types'
function Chip({ label, value, tone = 'slate' }: { label: string; value: number | string; tone?: 'slate' | 'amber' | 'teal' | 'blue' }) { 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 <Plus className="h-4 w-4" /> Benutzer anlegen
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent className="sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Neuen Benutzer anlegen</DialogTitle> <DialogTitle>Neuen Benutzer anlegen</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <FormSection titel="Benutzerdaten" icon={UserCheck}>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="nb-name">Name</Label> <Label htmlFor="nb-name">Name</Label>
<Input id="nb-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Vor- und Nachname" /> <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> <Label htmlFor="nb-bn">Benutzername</Label>
<Input id="nb-bn" value={bn} onChange={(e) => setBn(e.target.value)} placeholder="z. B. m.mueller" /> <Input id="nb-bn" value={bn} onChange={(e) => setBn(e.target.value)} placeholder="z. B. m.mueller" />
</div> </div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="nb-email">E-Mail</Label> <Label htmlFor="nb-email">E-Mail</Label>
<Input id="nb-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} /> <Input id="nb-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
@@ -94,32 +93,30 @@ function NeuerBenutzerDialog() {
))} ))}
</div> </div>
</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"> <div className="space-y-1.5">
<Label htmlFor="nb-einheit">Wohnung zuweisen</Label> <Label htmlFor="nb-pw">Passwort (Initial)</Label>
<select <Input id="nb-pw" value={pw} onChange={(e) => setPw(e.target.value)} />
id="nb-einheit" <p className="text-xs text-slate-500">Der Benutzer sollte das Passwort nach dem ersten Login ändern.</p>
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>
)} </FormSection>
<div className="space-y-1.5"> <FormFehler fehler={fehler} />
<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> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button> <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 <Mail className="h-3.5 w-3.5" /> E-Mail
</button> </button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent className="sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>E-Mail an {b.name}</DialogTitle> <DialogTitle>E-Mail an {b.name}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1.5"> <FormSection titel="Nachricht" icon={Mail}>
<Label>Empfänger</Label> <Wide>
<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"> <div className="space-y-1.5">
{b.name} · {hatEmail ? b.email : 'keine E-Mail hinterlegt'} <Label>Empfänger</Label>
{einheitLabel && <span className="block text-xs text-slate-400">{einheitLabel}</span>} <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">
</div> {b.name} · {hatEmail ? b.email : 'keine E-Mail hinterlegt'}
<p className="text-xs text-slate-400"> {einheitLabel && <span className="block text-xs text-slate-400">{einheitLabel}</span>}
Die Verwaltung erhält automatisch eine Kopie (CC). </div>
</p> <p className="text-xs text-slate-400">
</div> Die Verwaltung erhält automatisch eine Kopie (CC).
<div className="space-y-1.5"> </p>
<Label htmlFor="m-betreff">Betreff</Label> </div>
<Input </Wide>
id="m-betreff" <Wide>
placeholder="z. B. Terminabsprache Hausmeisterservice" <div className="space-y-1.5">
value={betreff} <Label htmlFor="m-betreff">Betreff</Label>
onChange={(e) => setBetreff(e.target.value)} <Input
/> id="m-betreff"
</div> placeholder="z. B. Terminabsprache Hausmeisterservice"
<div className="space-y-1.5"> value={betreff}
<Label htmlFor="m-text">Nachricht</Label> onChange={(e) => setBetreff(e.target.value)}
<Textarea />
id="m-text" </div>
rows={6} </Wide>
placeholder="Ihre Nachricht an den Mieter …" <Wide>
value={text} <div className="space-y-1.5">
onChange={(e) => setText(e.target.value)} <Label htmlFor="m-text">Nachricht</Label>
/> <Textarea
</div> id="m-text"
{fehler && ( rows={6}
<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"> placeholder="Ihre Nachricht an den Mieter …"
{fehler} value={text}
</div> onChange={(e) => setText(e.target.value)}
)} />
</div>
</Wide>
</FormSection>
<FormFehler fehler={fehler} />
{gesendet && ( {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"> <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). E-Mail wurde versendet (mit CC an die Verwaltung).
+54 -69
View File
@@ -1,7 +1,7 @@
import { Fragment, useMemo, useState } from 'react' import { Fragment, useMemo, useState } from 'react'
import { import {
Building2, Home, MapPin, CalendarDays, ArrowUpDown, LayoutGrid, Ruler, Pencil, 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' } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -25,22 +25,11 @@ import {
import { useStore } from '../store/store' import { useStore } from '../store/store'
import type { Building, HausForm, Unit } from '../types' import type { Building, HausForm, Unit } from '../types'
import { HEIZUNGSARTEN } from '../types' import { HEIZUNGSARTEN } from '../types'
import { FormSection, Wide, FormFehler } from '@/components/forms/FormSection'
/** Komma oder Punkt als Dezimaltrenner akzeptieren. */ /** Komma oder Punkt als Dezimaltrenner akzeptieren. */
const zahl = (s: string) => Number(s.replace(',', '.')) 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 }: { function Feld({ id, label, hint, ...input }: {
id: string id: string
label: string label: string
@@ -119,7 +108,7 @@ function HausDialog({ haus }: { haus?: Building }) {
</Button> </Button>
)} )}
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto"> <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{bearbeiten ? `Haus „${haus!.adresse}" bearbeiten` : 'Neues Haus anlegen'} {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"> <p className="text-xs text-slate-500 dark:text-slate-400">
Hausname und Adresszeile werden automatisch aus Straße und Hausnummer gebildet. Hausname und Adresszeile werden automatisch aus Straße und Hausnummer gebildet.
</p> </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-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-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-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-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)} /> <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> </FormSection>
<Abschnitt titel="Gebäude"> <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-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-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)} /> <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> </Select>
<p className="text-xs text-slate-400 dark:text-slate-500">Optional</p> <p className="text-xs text-slate-400 dark:text-slate-500">Optional</p>
</div> </div>
</Abschnitt> </FormSection>
<Abschnitt titel="Flächen"> <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-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)} /> <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> </FormSection>
<Abschnitt titel="Zuordnung Belege"> <FormSection titel="Zuordnung Belege" icon={FileText}>
<label className="flex cursor-pointer items-start gap-2.5 text-sm"> <Wide>
<input <label className="flex cursor-pointer items-start gap-2.5 text-sm">
id="h-eigenobjekt" <input
type="checkbox" id="h-eigenobjekt"
checked={eigenobjekt} type="checkbox"
onChange={(e) => setEigenobjekt(e.target.checked)} checked={eigenobjekt}
className="mt-0.5 h-4 w-4 rounded border-slate-300 accent-teal-600" 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>
<span className="block text-xs text-slate-500 dark:text-slate-400"> <span className="font-medium text-slate-800 dark:text-slate-100">Eigenobjekt / Rechnungsempfänger</span>
Diese Adresse steht auf eingehenden Rechnungen als Empfänger (z. B. die eigene Verwaltungsadresse). <span className="block text-xs text-slate-500 dark:text-slate-400">
Eigenobjekte werden bei der automatischen Beleg-Zuordnung übersprungen – die Zuordnung erfolgt dann Diese Adresse steht auf eingehenden Rechnungen als Empfänger (z. B. die eigene Verwaltungsadresse).
über die Adresse bzw. Stromzählernummer des Mietobjekts. Eigenobjekte werden bei der automatischen Beleg-Zuordnung übersprungen – die Zuordnung erfolgt dann
über die Adresse bzw. Stromzählernummer des Mietobjekts.
</span>
</span> </span>
</span> </label>
</label> </Wide>
</Abschnitt> </FormSection>
{fehler && ( <FormFehler fehler={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>
)}
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button> <Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
@@ -248,7 +235,7 @@ function WohnungDialog({ wohnung, gebaeude, festesHaus }: { wohnung?: Unit; geba
</Button> </Button>
)} )}
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto"> <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{bearbeiten {bearbeiten
@@ -259,7 +246,7 @@ function WohnungDialog({ wohnung, gebaeude, festesHaus }: { wohnung?: Unit; geba
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<Abschnitt titel="Zuordnung"> <FormSection titel="Zuordnung" icon={Home}>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Haus</Label> <Label>Haus</Label>
<Select value={building} onValueChange={waehleHaus} disabled={Boolean(festesHaus)}> <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>} {festesHaus && <p className="text-xs text-slate-400 dark:text-slate-500">Die Wohnung gehört fest zu diesem Haus.</p>}
</div> </div>
<Feld id="w-nr" label="Wohnungsnummer" placeholder="z. B. 12" value={number} onChange={(e) => setNumber(e.target.value)} /> <Feld id="w-nr" label="Wohnungsnummer" placeholder="z. B. 12" value={number} onChange={(e) => setNumber(e.target.value)} />
</Abschnitt> </FormSection>
<Abschnitt titel="Ausstattung"> <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-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)} /> <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"> <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'}. Optional{!bearbeiten && ' – wird bei Neuanlage automatisch aus dem Haus übernommen'}.
</p> </p>
</div> </div>
</Abschnitt> </FormSection>
<Abschnitt titel="Status"> <FormSection titel="Status" icon={DoorOpen}>
<div className="space-y-1.5"> <Wide>
<Label>Status</Label> <div className="space-y-1.5">
<Select value={status} onValueChange={(v) => setStatus(v as Unit['status'])}> <Label>Status</Label>
<SelectTrigger><SelectValue /></SelectTrigger> <Select value={status} onValueChange={(v) => setStatus(v as Unit['status'])}>
<SelectContent> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectItem value="vermietet">Vermietet</SelectItem> <SelectContent>
<SelectItem value="leerstand">Leerstand</SelectItem> <SelectItem value="vermietet">Vermietet</SelectItem>
<SelectItem value="unklar">Unklar</SelectItem> <SelectItem value="leerstand">Leerstand</SelectItem>
</SelectContent> <SelectItem value="unklar">Unklar</SelectItem>
</Select> </SelectContent>
<p className="text-xs text-slate-400 dark:text-slate-500"> </Select>
Vermietet/Leerstand wird bei Anlage oder Löschung eines Mieters in der Mieterverwaltung automatisch gesetzt. <p className="text-xs text-slate-400 dark:text-slate-500">
</p> Vermietet/Leerstand wird bei Anlage oder Löschung eines Mieters in der Mieterverwaltung automatisch gesetzt.
</div> </p>
</Abschnitt> </div>
{fehler && ( </Wide>
<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"> </FormSection>
{fehler} <FormFehler fehler={fehler} />
</div>
)}
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button> <Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
+50 -47
View File
@@ -31,6 +31,7 @@ import { useStore } from '../store/store'
import { fmtDatum } from '../lib/format' import { fmtDatum } from '../lib/format'
import PdfLightbox from '../components/PdfLightbox' import PdfLightbox from '../components/PdfLightbox'
import { apiUpload, ApiFehler } from '../lib/api' import { apiUpload, ApiFehler } from '../lib/api'
import { FormSection, Wide, FormFehler } from '@/components/forms/FormSection'
import type { DocumentItem, Unit } from '../types' import type { DocumentItem, Unit } from '../types'
const KATEGORIEN: DocumentItem['category'][] = ['Mietvertrag', 'Nebenkostenabrechnung', 'Hausordnung', 'Protokoll', 'Sonstiges'] const KATEGORIEN: DocumentItem['category'][] = ['Mietvertrag', 'Nebenkostenabrechnung', 'Hausordnung', 'Protokoll', 'Sonstiges']
@@ -130,21 +131,23 @@ function DokumentDialog({ dokument, einheiten }: { dokument?: DocumentItem; einh
</Button> </Button>
)} )}
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent className="sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{bearbeiten ? 'Dokument bearbeiten' : 'Neues Dokument anlegen'}</DialogTitle> <DialogTitle>{bearbeiten ? 'Dokument bearbeiten' : 'Neues Dokument anlegen'}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1.5"> <FormSection titel="Dokument" icon={FileText}>
<Label htmlFor="d-titel">Titel</Label> <Wide>
<Input <div className="space-y-1.5">
id="d-titel" <Label htmlFor="d-titel">Titel</Label>
placeholder="z. B. Mietvertrag – Blücherstr. 59, Whg. 1" <Input
value={titel} id="d-titel"
onChange={(e) => setTitel(e.target.value)} placeholder="z. B. Mietvertrag – Blücherstr. 59, Whg. 1"
/> value={titel}
</div> onChange={(e) => setTitel(e.target.value)}
<div className="grid gap-4 sm:grid-cols-2"> />
</div>
</Wide>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Kategorie</Label> <Label>Kategorie</Label>
<Select value={kategorie} onValueChange={(v) => setKategorie(v as DocumentItem['category'])}> <Select value={kategorie} onValueChange={(v) => setKategorie(v as DocumentItem['category'])}>
@@ -176,8 +179,6 @@ function DokumentDialog({ dokument, einheiten }: { dokument?: DocumentItem; einh
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="d-datum">Datum</Label> <Label htmlFor="d-datum">Datum</Label>
<Input id="d-datum" placeholder="TT.MM.JJJJ" value={datum} onChange={(e) => setDatum(e.target.value)} /> <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> <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)} /> <Input id="d-groesse" placeholder="wird beim Upload gesetzt" value={groesse} onChange={(e) => setGroesse(e.target.value)} />
</div> </div>
</div> <Wide>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="d-datei">PDF-Datei (optional, max. 25 MB)</Label> <Label htmlFor="d-datei">PDF-Datei (optional, max. 25 MB)</Label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
id="d-datei" id="d-datei"
ref={dateiInput} ref={dateiInput}
type="file" type="file"
accept="application/pdf,.pdf" accept="application/pdf,.pdf"
onChange={(e) => dateiWaehlen(e.target.files?.[0] ?? null)} onChange={(e) => dateiWaehlen(e.target.files?.[0] ?? null)}
/> />
{datei && ( {datei && (
<button <button
type="button" type="button"
title="Datei entfernen" title="Datei entfernen"
onClick={() => { setDatei(null); if (dateiInput.current) dateiInput.current.value = '' }} 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" 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 Entfernen
</button> </button>
)} )}
</div> </div>
{bearbeiten && dokument?.hasFile && !datei && ( {bearbeiten && dokument?.hasFile && !datei && (
<p className="text-xs text-slate-400"> <p className="text-xs text-slate-400">
Bereits hinterlegt: {dokument.fileName ?? 'PDF'} – ein neuer Upload ersetzt die Datei. Bereits hinterlegt: {dokument.fileName ?? 'PDF'} – ein neuer Upload ersetzt die Datei.
</p> </p>
)} )}
{datei && ( {datei && (
<p className="flex items-center gap-1 text-xs text-teal-700"> <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. <Upload className="h-3.5 w-3.5" /> {datei.name} wird beim Speichern hochgeladen.
</p> </p>
)} )}
</div> </div>
{fehler && <p className="text-sm font-medium text-red-600">{fehler}</p>} </Wide>
</FormSection>
<FormFehler fehler={fehler} />
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}> <Button variant="outline" onClick={() => setOpen(false)}>
+13 -12
View File
@@ -31,6 +31,7 @@ import { useStore, fmtEUR } from '../store/store'
import { fmtDatum } from '../lib/format' import { fmtDatum } from '../lib/format'
import type { Beleg, DocumentItem, KostenItem } from '../types' import type { Beleg, DocumentItem, KostenItem } from '../types'
import PdfLightbox from '../components/PdfLightbox' import PdfLightbox from '../components/PdfLightbox'
import { FormSection, Wide, FormFehler } from '@/components/forms/FormSection'
const KOSTEN_ARTEN = [ const KOSTEN_ARTEN = [
'Wasser/Abwasser', 'Müllabfuhr', 'Straßenreinigung/Winterdienst', 'Gartenpflege', 'Wasser/Abwasser', 'Müllabfuhr', 'Straßenreinigung/Winterdienst', 'Gartenpflege',
@@ -96,12 +97,12 @@ function KostenDialog({ position, vorbefuellung, onGespeichert, triggerLabel }:
</Button> </Button>
)} )}
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent className="sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{bearbeiten ? 'Kostenposition bearbeiten' : 'Neue Kostenposition erfassen'}</DialogTitle> <DialogTitle>{bearbeiten ? 'Kostenposition bearbeiten' : 'Neue Kostenposition erfassen'}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <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"> <div className="space-y-1.5">
<Label>Kostenart</Label> <Label>Kostenart</Label>
<Select value={kostenart} onValueChange={setKostenart}> <Select value={kostenart} onValueChange={setKostenart}>
@@ -129,8 +130,6 @@ function KostenDialog({ position, vorbefuellung, onGespeichert, triggerLabel }:
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="k-jahr">Jahr</Label> <Label htmlFor="k-jahr">Jahr</Label>
<Input id="k-jahr" inputMode="numeric" placeholder="z. B. 2026" value={jahr} onChange={(e) => setJahr(e.target.value)} /> <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> <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)} /> <Input id="k-betrag" inputMode="decimal" placeholder="z. B. 1.250,00" value={betrag} onChange={(e) => setBetrag(e.target.value)} />
</div> </div>
</div> </FormSection>
<div className="grid gap-4 sm:grid-cols-2"> <FormSection titel="Zuordnung" icon={Landmark}>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Haus</Label> <Label>Haus</Label>
<Select value={haus} onValueChange={setHaus} disabled={einheit !== '__haus'}> <Select value={haus} onValueChange={setHaus} disabled={einheit !== '__haus'}>
@@ -175,12 +174,14 @@ function KostenDialog({ position, vorbefuellung, onGespeichert, triggerLabel }:
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div> <Wide>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="k-notiz">Notiz (optional)</Label> <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)} /> <Input id="k-notiz" placeholder="z. B. Rechnungs-Nr., Abrechnungszeitraum …" value={notiz} onChange={(e) => setNotiz(e.target.value)} />
</div> </div>
{fehler && <p className="text-sm font-medium text-red-600">{fehler}</p>} </Wide>
</FormSection>
<FormFehler fehler={fehler} />
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}> <Button variant="outline" onClick={() => setOpen(false)}>
+43 -54
View File
@@ -1,6 +1,6 @@
import { useMemo, useRef, useState } from 'react' import { useMemo, useRef, useState } from 'react'
import { 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' } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -15,6 +15,7 @@ import {
import { useStore, fmtEUR } from '../store/store' import { useStore, fmtEUR } from '../store/store'
import { fmtDatum } from '../lib/format' import { fmtDatum } from '../lib/format'
import PdfLightbox from '../components/PdfLightbox' import PdfLightbox from '../components/PdfLightbox'
import { FormSection, Wide, FormFehler } from '@/components/forms/FormSection'
import type { DocumentItem, KautionArt, Mieter, Unit, Vertragsstatus } from '../types' import type { DocumentItem, KautionArt, Mieter, Unit, Vertragsstatus } from '../types'
/** Komma oder Punkt als Dezimaltrenner akzeptieren. */ /** Komma oder Punkt als Dezimaltrenner akzeptieren. */
@@ -44,18 +45,6 @@ const KAUTION_ART_LABEL: Record<KautionArt, string> = {
sonstiges: 'Andere', 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 }: { function Feld({ id, label, hint, ...input }: {
id: string id: string
label: string label: string
@@ -226,40 +215,42 @@ function MieterDialog({ mieter }: { mieter?: Mieter }) {
</Button> </Button>
)} )}
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto"> <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{bearbeiten ? `Mieter „${mieter!.vorname} ${mieter!.name}" bearbeiten` : 'Neuen Mieter anlegen'} {bearbeiten ? `Mieter „${mieter!.vorname} ${mieter!.name}" bearbeiten` : 'Neuen Mieter anlegen'}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <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-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-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-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)} /> <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> </FormSection>
<Abschnitt titel="Wohnung"> <FormSection titel="Wohnung" icon={Home}>
<div className="space-y-1.5"> <Wide>
<Label>Wohneinheit</Label> <div className="space-y-1.5">
<Select value={unitId} onValueChange={setUnitId}> <Label>Wohneinheit</Label>
<SelectTrigger><SelectValue placeholder="Wohnung wählen …" /></SelectTrigger> <Select value={unitId} onValueChange={setUnitId}>
<SelectContent> <SelectTrigger><SelectValue placeholder="Wohnung wählen …" /></SelectTrigger>
{wohnungen.map((u) => ( <SelectContent>
<SelectItem key={u.id} value={u.id}>{einheitLabel(u)}</SelectItem> {wohnungen.map((u) => (
))} <SelectItem key={u.id} value={u.id}>{einheitLabel(u)}</SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
<p className="text-xs text-slate-400 dark:text-slate-500"> </Select>
Pro Wohnung kann genau ein Mieter geführt werden. Beim Speichern wird die Wohnung auf „Vermietet" gesetzt. <p className="text-xs text-slate-400 dark:text-slate-500">
</p> Pro Wohnung kann genau ein Mieter geführt werden. Beim Speichern wird die Wohnung auf „Vermietet" gesetzt.
</div> </p>
</Abschnitt> </div>
<Abschnitt titel="Miete"> </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-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)} /> <Feld id="m-warm" label="Warmmiete (€/Monat)" inputMode="decimal" placeholder="z. B. 690" value={warmmiete} onChange={(e) => setWarmmiete(e.target.value)} />
</Abschnitt> </FormSection>
<Abschnitt titel="Vertrag"> <FormSection titel="Vertrag" icon={CalendarRange}>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Vertragsstatus</Label> <Label>Vertragsstatus</Label>
<Select value={vertragsstatus} onValueChange={(v) => setVertragsstatus(v as Vertragsstatus)}> <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-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)} /> <Feld id="m-ende" label="Mietende" hint="Leer lassen bei unbefristetem Vertrag" type="date" value={mietendeAm} onChange={(e) => setMietendeAm(e.target.value)} />
</Abschnitt> </FormSection>
<Abschnitt titel="Kaution"> <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)} /> <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"> <div className="space-y-1.5">
<Label>Kaution Art</Label> <Label>Kaution Art</Label>
@@ -319,25 +310,23 @@ function MieterDialog({ mieter }: { mieter?: Mieter }) {
{kautionszahlung === 'raten' && ( {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)} /> <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 && ( {bearbeiten && (
<Abschnitt titel="Dokumente"> <FormSection titel="Dokumente" icon={FileText}>
<p className="text-xs text-slate-400 dark:text-slate-500"> <Wide>
Personalausweis, SCHUFA-Auskunft, Bankauszug u. a. – Klick auf die Vorschau öffnet Druck- und <p className="text-xs text-slate-400 dark:text-slate-500">
E-Mail-Optionen. Pro Kategorie wird genau eine Datei (PDF oder Bild) geführt. Personalausweis, SCHUFA-Auskunft, Bankauszug u. a. – Klick auf die Vorschau öffnet Druck- und
</p> E-Mail-Optionen. Pro Kategorie wird genau eine Datei (PDF oder Bild) geführt.
<div className="grid gap-3 sm:grid-cols-2"> </p>
{DOKUMENT_KATEGORIEN.map((k) => ( <div className="mt-3 grid gap-3 sm:grid-cols-2">
<DokumentSlot key={k} mieter={mieter!} kategorie={k} /> {DOKUMENT_KATEGORIEN.map((k) => (
))} <DokumentSlot key={k} mieter={mieter!} kategorie={k} />
</div> ))}
</Abschnitt> </div>
)} </Wide>
{fehler && ( </FormSection>
<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> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button> <Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
+69 -82
View File
@@ -11,7 +11,7 @@
import { useMemo, useRef, useState } from 'react' import { useMemo, useRef, useState } from 'react'
import { import {
FileSignature, Plus, Pencil, Trash2, Mail, FileText, Upload, UserPlus, FileSignature, Plus, Pencil, Trash2, Mail, FileText, Upload, UserPlus,
Settings, Search, TriangleAlert, Eye, Settings, Search, TriangleAlert, Eye, BadgeEuro,
} from 'lucide-react' } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -26,6 +26,7 @@ import {
import { useStore, fmtEUR } from '../store/store' import { useStore, fmtEUR } from '../store/store'
import { fmtDatum } from '../lib/format' import { fmtDatum } from '../lib/format'
import PdfLightbox from '../components/PdfLightbox' import PdfLightbox from '../components/PdfLightbox'
import { FormSection, Wide, FormFehler } from '@/components/forms/FormSection'
import type { DocumentItem, Mietvertrag, MietvertragForm, Unit, VermieterDaten } from '../types' import type { DocumentItem, Mietvertrag, MietvertragForm, Unit, VermieterDaten } from '../types'
/** Komma oder Punkt als Dezimaltrenner akzeptieren. */ /** Komma oder Punkt als Dezimaltrenner akzeptieren. */
@@ -77,20 +78,22 @@ function VermieterDialog() {
<Settings className="h-4 w-4" /> Vermieterdaten <Settings className="h-4 w-4" /> Vermieterdaten
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto"> <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Vermieterdaten</DialogTitle> <DialogTitle>Vermieterdaten</DialogTitle>
</DialogHeader> </DialogHeader>
<p className="text-xs text-slate-500 dark:text-slate-400"> <div className="space-y-4">
Diese Angaben erscheinen im Briefkopf und in den Vertragsklauseln der automatisch erzeugten <p className="text-xs text-slate-500 dark:text-slate-400">
Mietvertrags-Entwürfe (Name, Anschrift, Kontoverbindung). Diese Angaben erscheinen im Briefkopf und in den Vertragsklauseln der automatisch erzeugten
</p> Mietvertrags-Entwürfe (Name, Anschrift, Kontoverbindung).
<div className="space-y-3"> </p>
<div className="space-y-1.5"> <FormSection titel="Vermieter" icon={Settings}>
<Label htmlFor="v-name">Name des Vermieters</Label> <Wide>
<Input id="v-name" placeholder="z. B. Hinterstein Immobilien, Inhaber Joseph Hinterstein" value={f.name} onChange={set('name')} /> <div className="space-y-1.5">
</div> <Label htmlFor="v-name">Name des Vermieters</Label>
<div className="grid gap-3 sm:grid-cols-2"> <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"> <div className="space-y-1.5">
<Label htmlFor="v-strasse">Straße</Label> <Label htmlFor="v-strasse">Straße</Label>
<Input id="v-strasse" value={f.strasse} onChange={set('strasse')} /> <Input id="v-strasse" value={f.strasse} onChange={set('strasse')} />
@@ -99,8 +102,6 @@ function VermieterDialog() {
<Label htmlFor="v-nr">Hausnummer</Label> <Label htmlFor="v-nr">Hausnummer</Label>
<Input id="v-nr" value={f.hausnummer} onChange={set('hausnummer')} /> <Input id="v-nr" value={f.hausnummer} onChange={set('hausnummer')} />
</div> </div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="v-plz">PLZ</Label> <Label htmlFor="v-plz">PLZ</Label>
<Input id="v-plz" value={f.plz} onChange={set('plz')} /> <Input id="v-plz" value={f.plz} onChange={set('plz')} />
@@ -109,8 +110,6 @@ function VermieterDialog() {
<Label htmlFor="v-ort">Ort</Label> <Label htmlFor="v-ort">Ort</Label>
<Input id="v-ort" value={f.ort} onChange={set('ort')} /> <Input id="v-ort" value={f.ort} onChange={set('ort')} />
</div> </div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="v-tel">Telefon</Label> <Label htmlFor="v-tel">Telefon</Label>
<Input id="v-tel" value={f.telefon} onChange={set('telefon')} /> <Input id="v-tel" value={f.telefon} onChange={set('telefon')} />
@@ -119,8 +118,6 @@ function VermieterDialog() {
<Label htmlFor="v-mail">E-Mail</Label> <Label htmlFor="v-mail">E-Mail</Label>
<Input id="v-mail" type="email" value={f.email} onChange={set('email')} /> <Input id="v-mail" type="email" value={f.email} onChange={set('email')} />
</div> </div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="v-bank">Bank</Label> <Label htmlFor="v-bank">Bank</Label>
<Input id="v-bank" value={f.bankname} onChange={set('bankname')} /> <Input id="v-bank" value={f.bankname} onChange={set('bankname')} />
@@ -129,10 +126,8 @@ function VermieterDialog() {
<Label htmlFor="v-iban">IBAN</Label> <Label htmlFor="v-iban">IBAN</Label>
<Input id="v-iban" placeholder="DE00 0000 0000 0000 0000 00" value={f.iban} onChange={set('iban')} /> <Input id="v-iban" placeholder="DE00 0000 0000 0000 0000 00" value={f.iban} onChange={set('iban')} />
</div> </div>
</div> </FormSection>
{fehler && ( <FormFehler fehler={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>
)}
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button> <Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
@@ -207,78 +202,70 @@ function MietvertragDialog({ vertrag, einheiten }: { vertrag?: Mietvertrag; einh
</Button> </Button>
)} )}
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto"> <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{bearbeiten ? 'Mietvertrag bearbeiten' : 'Neuen Mietvertrag anlegen'}</DialogTitle> <DialogTitle>{bearbeiten ? 'Mietvertrag bearbeiten' : 'Neuen Mietvertrag anlegen'}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1.5"> <FormSection titel="Wohnung" icon={FileSignature}>
<Label>Wohnung</Label> <Wide>
<Select value={unitId} onValueChange={einheitWechseln}> <div className="space-y-1.5">
<SelectTrigger><SelectValue placeholder="Wohnung wählen …" /></SelectTrigger> <Label>Wohnung</Label>
<SelectContent> <Select value={unitId} onValueChange={einheitWechseln}>
{einheiten.map((u) => ( <SelectTrigger><SelectValue placeholder="Wohnung wählen …" /></SelectTrigger>
<SelectItem key={u.id} value={u.id}> <SelectContent>
{u.building}, Whg. {u.number} (Etage {u.floor}, {u.size} m²{u.status === 'leerstand' ? ', Leerstand' : ''}) {einheiten.map((u) => (
</SelectItem> <SelectItem key={u.id} value={u.id}>
))} {u.building}, Whg. {u.number} (Etage {u.floor}, {u.size} m²{u.status === 'leerstand' ? ', Leerstand' : ''})
</SelectContent> </SelectItem>
</Select> ))}
</div> </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"> <FormSection titel="Zukünftige Mieterin / zukünftiger Mieter" icon={UserPlus}>
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"> <Wide>
Zukünftige Mieterin / zukünftiger Mieter <div className="space-y-1.5">
</h4> <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"> <div className="space-y-1.5">
<Label htmlFor="m-name">Name (Vor- und Nachname)</Label> <Label htmlFor="m-mail">E-Mail</Label>
<Input id="m-name" placeholder="z. B. Anna Berger" value={mieterName} onChange={(e) => setMieterName(e.target.value)} /> <Input id="m-mail" type="email" placeholder="für Versand des Entwurfs" value={mieterEmail} onChange={(e) => setMieterEmail(e.target.value)} />
</div> </div>
<div className="grid gap-3 sm:grid-cols-2"> <div className="space-y-1.5">
<div className="space-y-1.5"> <Label htmlFor="m-tel">Telefon</Label>
<Label htmlFor="m-mail">E-Mail</Label> <Input id="m-tel" value={mieterTelefon} onChange={(e) => setMieterTelefon(e.target.value)} />
<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> </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"> <FormSection titel="Mietkonditionen" icon={BadgeEuro}>
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"> <div className="space-y-1.5">
Mietkonditionen <Label htmlFor="m-beginn">Mietbeginn</Label>
</h4> <Input id="m-beginn" placeholder="TT.MM.JJJJ" value={beginnAm} onChange={(e) => setBeginnAm(e.target.value)} />
<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>
</div> </div>
<div className="grid gap-3 sm:grid-cols-3"> <div className="space-y-1.5">
<div className="space-y-1.5"> <Label htmlFor="m-ende">Mietende</Label>
<Label htmlFor="m-kalt">Kaltmiete (€/Monat)</Label> <Input id="m-ende" placeholder="leer = unbefristet" value={endeAm} onChange={(e) => setEndeAm(e.target.value)} />
<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> </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 && ( <FormFehler fehler={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>
)}
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button> <Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
+18 -9
View File
@@ -63,10 +63,13 @@ export default function Profil() {
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
{/* Feste Daten (nur Ansicht) */} {/* Feste Daten (nur Ansicht) */}
<div className="rounded-2xl border bg-white p-5 shadow-sm"> <div className="rounded-2xl border bg-white p-5 shadow-sm">
<div className="mb-4 flex items-center gap-2 font-semibold"> <div className="mb-4 flex items-center gap-2.5 border-b border-slate-100 pb-3">
<IdCard className="h-4 w-4 text-teal-600" /> Konto & Wohnung <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> </div>
<dl className="space-y-3 text-sm"> <dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div> <div>
<dt className="text-xs uppercase tracking-wide text-slate-400">Name</dt> <dt className="text-xs uppercase tracking-wide text-slate-400">Name</dt>
<dd className="font-medium">{me.name}</dd> <dd className="font-medium">{me.name}</dd>
@@ -96,10 +99,13 @@ export default function Profil() {
{/* Kontaktdaten (editierbar) */} {/* Kontaktdaten (editierbar) */}
<div className="rounded-2xl border bg-white p-5 shadow-sm"> <div className="rounded-2xl border bg-white p-5 shadow-sm">
<div className="mb-4 flex items-center gap-2 font-semibold"> <div className="mb-4 flex items-center gap-2.5 border-b border-slate-100 pb-3">
<Phone className="h-4 w-4 text-teal-600" /> Kontaktdaten <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>
<div className="space-y-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="p-email">E-Mail-Adresse</Label> <Label htmlFor="p-email">E-Mail-Adresse</Label>
<div className="relative"> <div className="relative">
@@ -135,13 +141,16 @@ export default function Profil() {
{/* Bankverbindung (editierbar; Mieter) */} {/* Bankverbindung (editierbar; Mieter) */}
{role === 'tenant' && ( {role === 'tenant' && (
<div className="rounded-2xl border bg-white p-5 shadow-sm lg:col-span-2"> <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"> <div className="mb-4 flex items-center gap-2.5 border-b border-slate-100 pb-3">
<Landmark className="h-4 w-4 text-teal-600" /> Bankverbindung <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"> <span className="text-xs font-normal text-slate-400">
(für Rückzahlungen, z. B. Kaution) (für Rückzahlungen, z. B. Kaution)
</span> </span>
</div> </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"> <div className="space-y-1.5">
<Label htmlFor="p-konto">Kontoinhaber</Label> <Label htmlFor="p-konto">Kontoinhaber</Label>
<Input <Input
+27 -22
View File
@@ -23,6 +23,7 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import { useStore } from '../store/store' import { useStore } from '../store/store'
import { fmtDatum, fmtDatumZeit } from '../lib/format' import { fmtDatum, fmtDatumZeit } from '../lib/format'
import { FormSection, Wide } from '@/components/forms/FormSection'
import type { TicketPriority, TicketStatus } from '../types' import type { TicketPriority, TicketStatus } from '../types'
const statusStyle: Record<TicketStatus, string> = { const statusStyle: Record<TicketStatus, string> = {
@@ -80,21 +81,23 @@ function NewTicketDialog() {
<Plus className="h-4 w-4" /> Neue Schadensmeldung <Plus className="h-4 w-4" /> Neue Schadensmeldung
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent className="sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Schaden oder Störung melden</DialogTitle> <DialogTitle>Schaden oder Störung melden</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1.5"> <FormSection titel="Meldung" icon={Wrench}>
<Label htmlFor="t-title">Kurzbeschreibung</Label> <Wide>
<Input <div className="space-y-1.5">
id="t-title" <Label htmlFor="t-title">Kurzbeschreibung</Label>
placeholder="z. B. Wasserhahn tropft" <Input
value={title} id="t-title"
onChange={(e) => setTitle(e.target.value)} placeholder="z. B. Wasserhahn tropft"
/> value={title}
</div> onChange={(e) => setTitle(e.target.value)}
<div className="grid grid-cols-2 gap-4"> />
</div>
</Wide>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Kategorie</Label> <Label>Kategorie</Label>
<Select value={category} onValueChange={setCategory}> <Select value={category} onValueChange={setCategory}>
@@ -121,17 +124,19 @@ function NewTicketDialog() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div> <Wide>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="t-desc">Beschreibung</Label> <Label htmlFor="t-desc">Beschreibung</Label>
<Textarea <Textarea
id="t-desc" id="t-desc"
rows={4} rows={4}
placeholder="Was ist passiert? Seit wann? ..." placeholder="Was ist passiert? Seit wann? ..."
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
/> />
</div> </div>
</Wide>
</FormSection>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button> <Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
+11 -20
View File
@@ -21,6 +21,7 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import { useStore } from '../store/store' import { useStore } from '../store/store'
import { fmtDatum } from '../lib/format' import { fmtDatum } from '../lib/format'
import { FormSection, FormFehler } from '@/components/forms/FormSection'
import type { Meter, MeterReading, ZaehlerArt } from '../types' import type { Meter, MeterReading, ZaehlerArt } from '../types'
const ARTEN: ZaehlerArt[] = ['Strom', 'Wasser', 'Gas', 'Wärme', 'Sonstiges'] const ARTEN: ZaehlerArt[] = ['Strom', 'Wasser', 'Gas', 'Wärme', 'Sonstiges']
@@ -100,12 +101,12 @@ function ZaehlerDialog({ meter }: { meter?: Meter }) {
</Button> </Button>
)} )}
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent className="sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{bearbeiten ? `Zähler ${meter!.nummer} bearbeiten` : 'Neuen Zähler anlegen'}</DialogTitle> <DialogTitle>{bearbeiten ? `Zähler ${meter!.nummer} bearbeiten` : 'Neuen Zähler anlegen'}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <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"> <div className="space-y-1.5">
<Label htmlFor="z-nr">Zählernummer</Label> <Label htmlFor="z-nr">Zählernummer</Label>
<Input <Input
@@ -126,8 +127,6 @@ function ZaehlerDialog({ meter }: { meter?: Meter }) {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="z-stand">Aktueller Zählerstand</Label> <Label htmlFor="z-stand">Aktueller Zählerstand</Label>
<Input <Input
@@ -149,8 +148,8 @@ function ZaehlerDialog({ meter }: { meter?: Meter }) {
/> />
</div> </div>
)} )}
</div> </FormSection>
<div className="grid gap-4 sm:grid-cols-2"> <FormSection titel="Zuordnung" icon={Building2}>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Wohneinheit</Label> <Label>Wohneinheit</Label>
<Select value={unitId} onValueChange={setUnitId}> <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> <p className="text-xs text-slate-400">Nur nötig für Zähler ohne Wohnungsbezug.</p>
</div> </div>
)} )}
</div> </FormSection>
{fehler && ( <FormFehler fehler={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>
)}
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button> <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 <Plus className="h-3.5 w-3.5" /> Stand eintragen
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent className="sm:max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
Stand eintragen · Zähler {meter.nummer} Stand eintragen · Zähler {meter.nummer}
@@ -344,7 +339,7 @@ function StandEintragenDialog({ meter }: { meter: Meter }) {
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <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"> <div className="space-y-1.5">
<Label htmlFor="zr-stand">Neuer Zählerstand</Label> <Label htmlFor="zr-stand">Neuer Zählerstand</Label>
<Input <Input
@@ -364,12 +359,8 @@ function StandEintragenDialog({ meter }: { meter: Meter }) {
onChange={(e) => setDatum(e.target.value)} onChange={(e) => setDatum(e.target.value)}
/> />
</div> </div>
</div> </FormSection>
{fehler && ( <FormFehler fehler={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>
)}
{warnung && ( {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"> <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} {warnung}
+4
View File
@@ -56,6 +56,10 @@ module.exports = {
sm: "calc(var(--radius) - 4px)", sm: "calc(var(--radius) - 4px)",
xs: "calc(var(--radius) - 6px)", xs: "calc(var(--radius) - 6px)",
}, },
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
display: ['Sora', 'Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
},
boxShadow: { boxShadow: {
xs: "0 1px 2px 0 rgb(0 0 0 / 0.05)", xs: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
}, },