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