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.
+27 -237
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;
} }
} }
+15 -14
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,7 +93,6 @@ function NeuerBenutzerDialog() {
))} ))}
</div> </div>
</div> </div>
</div>
{rolle === 'mieter' && ( {rolle === 'mieter' && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="nb-einheit">Wohnung zuweisen</Label> <Label htmlFor="nb-einheit">Wohnung zuweisen</Label>
@@ -117,9 +115,8 @@ function NeuerBenutzerDialog() {
<Input id="nb-pw" value={pw} onChange={(e) => setPw(e.target.value)} /> <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> <p className="text-xs text-slate-500">Der Benutzer sollte das Passwort nach dem ersten Login ändern.</p>
</div> </div>
{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">{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>
@@ -172,11 +169,13 @@ 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">
<FormSection titel="Nachricht" icon={Mail}>
<Wide>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Empfänger</Label> <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"> <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">
@@ -187,6 +186,8 @@ function EmailDialog({ benutzer: b, einheitLabel }: { benutzer: Benutzer; einhei
Die Verwaltung erhält automatisch eine Kopie (CC). Die Verwaltung erhält automatisch eine Kopie (CC).
</p> </p>
</div> </div>
</Wide>
<Wide>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="m-betreff">Betreff</Label> <Label htmlFor="m-betreff">Betreff</Label>
<Input <Input
@@ -196,6 +197,8 @@ function EmailDialog({ benutzer: b, einheitLabel }: { benutzer: Benutzer; einhei
onChange={(e) => setBetreff(e.target.value)} onChange={(e) => setBetreff(e.target.value)}
/> />
</div> </div>
</Wide>
<Wide>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="m-text">Nachricht</Label> <Label htmlFor="m-text">Nachricht</Label>
<Textarea <Textarea
@@ -206,11 +209,9 @@ function EmailDialog({ benutzer: b, einheitLabel }: { benutzer: Benutzer; einhei
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
/> />
</div> </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>
)}
{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).
+24 -39
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,12 +143,13 @@ 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}>
<Wide>
<label className="flex cursor-pointer items-start gap-2.5 text-sm"> <label className="flex cursor-pointer items-start gap-2.5 text-sm">
<input <input
id="h-eigenobjekt" id="h-eigenobjekt"
@@ -177,12 +167,9 @@ function HausDialog({ haus }: { haus?: Building }) {
</span> </span>
</span> </span>
</label> </label>
</Abschnitt> </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"> <FormFehler fehler={fehler} />
{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,8 +279,9 @@ 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}>
<Wide>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Status</Label> <Label>Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v as Unit['status'])}> <Select value={status} onValueChange={(v) => setStatus(v as Unit['status'])}>
@@ -308,12 +296,9 @@ function WohnungDialog({ wohnung, gebaeude, festesHaus }: { wohnung?: Unit; geba
Vermietet/Leerstand wird bei Anlage oder Löschung eines Mieters in der Mieterverwaltung automatisch gesetzt. Vermietet/Leerstand wird bei Anlage oder Löschung eines Mieters in der Mieterverwaltung automatisch gesetzt.
</p> </p>
</div> </div>
</Abschnitt> </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"> <FormFehler fehler={fehler} />
{fehler}
</div>
)}
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button> <Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
+9 -6
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,11 +131,13 @@ 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">
<FormSection titel="Dokument" icon={FileText}>
<Wide>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="d-titel">Titel</Label> <Label htmlFor="d-titel">Titel</Label>
<Input <Input
@@ -144,7 +147,7 @@ function DokumentDialog({ dokument, einheiten }: { dokument?: DocumentItem; einh
onChange={(e) => setTitel(e.target.value)} onChange={(e) => setTitel(e.target.value)}
/> />
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> </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,7 +187,7 @@ 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">
@@ -219,7 +220,9 @@ function DokumentDialog({ dokument, einheiten }: { dokument?: DocumentItem; einh
</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)}>
+9 -8
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)}>
+21 -32
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,20 +215,21 @@ 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}>
<Wide>
<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}>
@@ -254,12 +244,13 @@ function MieterDialog({ mieter }: { mieter?: Mieter }) {
Pro Wohnung kann genau ein Mieter geführt werden. Beim Speichern wird die Wohnung auf „Vermietet" gesetzt. Pro Wohnung kann genau ein Mieter geführt werden. Beim Speichern wird die Wohnung auf „Vermietet" gesetzt.
</p> </p>
</div> </div>
</Abschnitt> </Wide>
<Abschnitt titel="Miete"> </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}>
<Wide>
<p className="text-xs text-slate-400 dark:text-slate-500"> <p className="text-xs text-slate-400 dark:text-slate-500">
Personalausweis, SCHUFA-Auskunft, Bankauszug u. a. – Klick auf die Vorschau öffnet Druck- und 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. E-Mail-Optionen. Pro Kategorie wird genau eine Datei (PDF oder Bild) geführt.
</p> </p>
<div className="grid gap-3 sm:grid-cols-2"> <div className="mt-3 grid gap-3 sm:grid-cols-2">
{DOKUMENT_KATEGORIEN.map((k) => ( {DOKUMENT_KATEGORIEN.map((k) => (
<DokumentSlot key={k} mieter={mieter!} kategorie={k} /> <DokumentSlot key={k} mieter={mieter!} kategorie={k} />
))} ))}
</div> </div>
</Abschnitt> </Wide>
)} </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> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button> <Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
+21 -34
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>
<div className="space-y-4">
<p className="text-xs text-slate-500 dark:text-slate-400"> <p className="text-xs text-slate-500 dark:text-slate-400">
Diese Angaben erscheinen im Briefkopf und in den Vertragsklauseln der automatisch erzeugten Diese Angaben erscheinen im Briefkopf und in den Vertragsklauseln der automatisch erzeugten
Mietvertrags-Entwürfe (Name, Anschrift, Kontoverbindung). Mietvertrags-Entwürfe (Name, Anschrift, Kontoverbindung).
</p> </p>
<div className="space-y-3"> <FormSection titel="Vermieter" icon={Settings}>
<Wide>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="v-name">Name des Vermieters</Label> <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')} /> <Input id="v-name" placeholder="z. B. Hinterstein Immobilien, Inhaber Joseph Hinterstein" value={f.name} onChange={set('name')} />
</div> </div>
<div className="grid gap-3 sm:grid-cols-2"> </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,11 +202,13 @@ 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">
<FormSection titel="Wohnung" icon={FileSignature}>
<Wide>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Wohnung</Label> <Label>Wohnung</Label>
<Select value={unitId} onValueChange={einheitWechseln}> <Select value={unitId} onValueChange={einheitWechseln}>
@@ -225,16 +222,16 @@ function MietvertragDialog({ vertrag, einheiten }: { vertrag?: Mietvertrag; einh
</SelectContent> </SelectContent>
</Select> </Select>
</div> </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
</h4>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="m-name">Name (Vor- und Nachname)</Label> <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)} /> <Input id="m-name" placeholder="z. B. Anna Berger" value={mieterName} onChange={(e) => setMieterName(e.target.value)} />
</div> </div>
<div className="grid gap-3 sm:grid-cols-2"> </Wide>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="m-mail">E-Mail</Label> <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)} /> <Input id="m-mail" type="email" placeholder="für Versand des Entwurfs" value={mieterEmail} onChange={(e) => setMieterEmail(e.target.value)} />
@@ -243,14 +240,9 @@ function MietvertragDialog({ vertrag, einheiten }: { vertrag?: Mietvertrag; einh
<Label htmlFor="m-tel">Telefon</Label> <Label htmlFor="m-tel">Telefon</Label>
<Input id="m-tel" value={mieterTelefon} onChange={(e) => setMieterTelefon(e.target.value)} /> <Input id="m-tel" value={mieterTelefon} onChange={(e) => setMieterTelefon(e.target.value)} />
</div> </div>
</div> </FormSection>
</div>
<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">
Mietkonditionen
</h4>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="m-beginn">Mietbeginn</Label> <Label htmlFor="m-beginn">Mietbeginn</Label>
<Input id="m-beginn" placeholder="TT.MM.JJJJ" value={beginnAm} onChange={(e) => setBeginnAm(e.target.value)} /> <Input id="m-beginn" placeholder="TT.MM.JJJJ" value={beginnAm} onChange={(e) => setBeginnAm(e.target.value)} />
@@ -259,8 +251,6 @@ function MietvertragDialog({ vertrag, einheiten }: { vertrag?: Mietvertrag; einh
<Label htmlFor="m-ende">Mietende</Label> <Label htmlFor="m-ende">Mietende</Label>
<Input id="m-ende" placeholder="leer = unbefristet" value={endeAm} onChange={(e) => setEndeAm(e.target.value)} /> <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-kalt">Kaltmiete (€/Monat)</Label> <Label htmlFor="m-kalt">Kaltmiete (€/Monat)</Label>
<Input id="m-kalt" inputMode="decimal" value={kaltmiete} onChange={(e) => setKaltmiete(e.target.value)} /> <Input id="m-kalt" inputMode="decimal" value={kaltmiete} onChange={(e) => setKaltmiete(e.target.value)} />
@@ -273,12 +263,9 @@ function MietvertragDialog({ vertrag, einheiten }: { vertrag?: Mietvertrag; einh
<Label htmlFor="m-kaution">Kaution (€)</Label> <Label htmlFor="m-kaution">Kaution (€)</Label>
<Input id="m-kaution" inputMode="decimal" value={kaution} onChange={(e) => setKaution(e.target.value)} /> <Input id="m-kaution" inputMode="decimal" value={kaution} onChange={(e) => setKaution(e.target.value)} />
</div> </div>
</div> </FormSection>
</div>
{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
+8 -3
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,11 +81,13 @@ 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">
<FormSection titel="Meldung" icon={Wrench}>
<Wide>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="t-title">Kurzbeschreibung</Label> <Label htmlFor="t-title">Kurzbeschreibung</Label>
<Input <Input
@@ -94,7 +97,7 @@ function NewTicketDialog() {
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> </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,7 +124,7 @@ 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
@@ -132,6 +135,8 @@ function NewTicketDialog() {
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)",
}, },