v1.12: Dokumente-CRUD fuer die Verwaltung (Mietvertraege & Co. im Dokumentenmodul)

This commit is contained in:
2026-10-04 11:58:27 +02:00
parent bedbda640b
commit 8bef23665a
5 changed files with 287 additions and 14 deletions
+13
View File
@@ -189,6 +189,19 @@ tar -czf mp-dist.tar.gz -C dist .
## 8. Verlauf / offene Punkte ## 8. Verlauf / offene Punkte
**Erledigt 04.10. (v1.12):** Dokumente-CRUD für die Verwaltung (Schwerpunkt
Mietverträge). API (adminOnly): POST/PATCH/DELETE /api/documents(/:id) mit
Validierung (Titel nicht leer, Kategorie aus den 5 erlaubten Werten, unitId
null oder vorhandene Unit, Datum default `deutschesDatum()`, size default '–',
id `d-${Date.now()}`). Frontend: Admin-Dialog auf /dokumente (Titel, Kategorie,
Wohnung inkl. „Alle Häuser", Datum, Größe optional; Radix-Select braucht für
„Alle Häuser" den Sentinel '__alle' – leerer value ist unzulässig), pro Zeile
Bearbeiten/Löschen (window.confirm), Store-Aktionen addDokument/updateDokument/
deleteDokument + apiDelete. Mieter-Ansicht unverändert. E2E verifiziert:
Anlegen/Patchen/Löschen (Umlaute sauber), Validierung 400, Mieter 403,
Testdaten aufgeräumt. Bewusst NICHT gebaut: echter PDF-Upload (kein Storage
im Stack, Demo-Charakter – Download-Button bleibt dekorativ).
**Erledigt 04.10. (v1.11):** KI-Antworten auf Schadenmeldungen via n8n/Ollama. **Erledigt 04.10. (v1.11):** KI-Antworten auf Schadenmeldungen via n8n/Ollama.
Kette: Mieter-Ticket → mp-api `kiBenachrichtigen()` (POST an n8n, fire-and- Kette: Mieter-Ticket → mp-api `kiBenachrichtigen()` (POST an n8n, fire-and-
forget) → n8n-Workflow `mpKiAntwort0001` (Webhook POST `mp-schadensmeldung`, forget) → n8n-Workflow `mpKiAntwort0001` (Webhook POST `mp-schadensmeldung`,
+64
View File
@@ -482,6 +482,70 @@ app.patch('/api/meters/:id', auth, adminOnly, async (req, res, next) => {
res.json({ meter: mapMeter(aktualisiert[0]) }) res.json({ meter: mapMeter(aktualisiert[0]) })
} catch (e) { next(e) } } catch (e) { next(e) }
}) })
// ── Dokumente (Verwaltung) ───────────────────────────────────
const DOKUMENT_KATEGORIEN = ['Mietvertrag', 'Nebenkostenabrechnung', 'Hausordnung', 'Protokoll', 'Sonstiges']
async function dokumentGueltig(b, res) {
const titel = String(b.title || '').trim()
const kategorie = String(b.category || '').trim()
if (!titel) { res.status(400).json({ fehler: 'Titel fehlt.' }); return null }
if (!DOKUMENT_KATEGORIEN.includes(kategorie)) { res.status(400).json({ fehler: 'Ungültige Kategorie.' }); return null }
let einheitId = b.unitId !== undefined ? (b.unitId || null) : undefined
if (einheitId) {
const { rows: u } = await pool.query('SELECT id FROM units WHERE id = $1', [einheitId])
if (!u.length) { res.status(400).json({ fehler: 'Wohnung unbekannt.' }); return null }
}
return { titel, kategorie, einheitId }
}
app.post('/api/documents', auth, adminOnly, async (req, res, next) => {
try {
const g = await dokumentGueltig(req.body || {}, res)
if (!g) return
const { rows } = await pool.query(
`INSERT INTO documents (id, unit_id, title, category, size, date)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
[`d-${Date.now()}`, g.einheitId ?? null, g.titel, g.kategorie,
String(req.body.size || '').trim() || '–', String(req.body.date || '').trim() || deutschesDatum()],
)
res.json({ dokument: mapDocument(rows[0]) })
} catch (e) { next(e) }
})
app.patch('/api/documents/:id', auth, adminOnly, async (req, res, next) => {
try {
const { rows } = await pool.query('SELECT * FROM documents WHERE id = $1', [req.params.id])
if (!rows.length) return res.status(404).json({ fehler: 'Dokument nicht gefunden.' })
const d = rows[0]
const b = req.body || {}
const kandidat = {
title: b.title !== undefined ? b.title : d.title,
category: b.category !== undefined ? b.category : d.category,
unitId: b.unitId !== undefined ? b.unitId : d.unit_id,
}
const g = await dokumentGueltig(kandidat, res)
if (!g) return
const einheitId = g.einheitId === undefined ? d.unit_id : g.einheitId
const { rows: aktualisiert } = await pool.query(
`UPDATE documents SET unit_id = $1, title = $2, category = $3, size = $4, date = $5
WHERE id = $6 RETURNING *`,
[einheitId, g.titel, g.kategorie,
b.size !== undefined ? (String(b.size).trim() || '–') : d.size,
b.date !== undefined ? (String(b.date).trim() || d.date) : d.date,
req.params.id],
)
res.json({ dokument: mapDocument(aktualisiert[0]) })
} catch (e) { next(e) }
})
app.delete('/api/documents/:id', auth, adminOnly, async (req, res, next) => {
try {
const { rowCount } = await pool.query('DELETE FROM documents WHERE id = $1', [req.params.id])
if (!rowCount) return res.status(404).json({ fehler: 'Dokument nicht gefunden.' })
res.json({ ok: true })
} catch (e) { next(e) }
})
// ── Bestand: Häuser & Wohnungen (Verwaltung) ──────────────── // ── Bestand: Häuser & Wohnungen (Verwaltung) ────────────────
function pruefeHaus(b) { function pruefeHaus(b) {
const fehler = [] const fehler = []
+1
View File
@@ -52,3 +52,4 @@ export async function api<T>(pfad: string, init?: RequestInit, json?: unknown):
export const apiGet = <T>(pfad: string) => api<T>(pfad) export const apiGet = <T>(pfad: string) => api<T>(pfad)
export const apiPost = <T>(pfad: string, body?: unknown) => api<T>(pfad, { method: 'POST' }, body ?? {}) export const apiPost = <T>(pfad: string, body?: unknown) => api<T>(pfad, { method: 'POST' }, body ?? {})
export const apiPatch = <T>(pfad: string, body?: unknown) => api<T>(pfad, { method: 'PATCH' }, body ?? {}) export const apiPatch = <T>(pfad: string, body?: unknown) => api<T>(pfad, { method: 'PATCH' }, body ?? {})
export const apiDelete = <T>(pfad: string) => api<T>(pfad, { method: 'DELETE' })
+167 -13
View File
@@ -1,7 +1,24 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Download, FileText, FolderOpen, Scale, ReceiptText, FileSignature, FileIcon } from 'lucide-react' import { Download, FileText, FolderOpen, Scale, ReceiptText, FileSignature, FileIcon, Plus, Pencil, Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { import {
Table, Table,
TableBody, TableBody,
@@ -11,6 +28,9 @@ import {
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { useStore } from '../store/store' import { useStore } from '../store/store'
import type { DocumentItem, Unit } from '../types'
const KATEGORIEN: DocumentItem['category'][] = ['Mietvertrag', 'Nebenkostenabrechnung', 'Hausordnung', 'Protokoll', 'Sonstiges']
const catIcon = (cat: string) => { const catIcon = (cat: string) => {
switch (cat) { switch (cat) {
@@ -27,8 +47,124 @@ const catIcon = (cat: string) => {
} }
} }
const heute = () => new Date().toLocaleDateString('de-DE')
function DokumentDialog({ dokument, einheiten }: { dokument?: DocumentItem; einheiten: Unit[] }) {
const { addDokument, updateDokument } = useStore()
const bearbeiten = Boolean(dokument)
const [open, setOpen] = useState(false)
const [titel, setTitel] = useState(dokument?.title ?? '')
const [kategorie, setKategorie] = useState<DocumentItem['category']>(dokument?.category ?? 'Mietvertrag')
const [einheit, setEinheit] = useState(dokument?.unitId ?? '__alle') // '__alle' = Alle Häuser
const [datum, setDatum] = useState(dokument?.date ?? heute())
const [groesse, setGroesse] = useState(dokument?.size === '–' ? '' : (dokument?.size ?? ''))
const [sende, setSende] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
const submit = async () => {
if (sende) return
setSende(true)
setFehler(null)
const payload = {
title: titel.trim(),
category: kategorie,
unitId: einheit === '__alle' ? null : einheit,
date: datum.trim() || heute(),
...(groesse.trim() ? { size: groesse.trim() } : {}),
}
const err = bearbeiten ? await updateDokument(dokument!.id, payload) : await addDokument(payload)
setSende(false)
if (err) return setFehler(err)
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{bearbeiten ? (
<Button variant="outline" size="sm" className="gap-1.5">
<Pencil className="h-3.5 w-3.5" /> Bearbeiten
</Button>
) : (
<Button className="gap-2">
<Plus className="h-4 w-4" /> Neues Dokument
</Button>
)}
</DialogTrigger>
<DialogContent>
<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 – Hauptstraße 12, Whg. 7"
value={titel}
onChange={(e) => setTitel(e.target.value)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>Kategorie</Label>
<Select value={kategorie} onValueChange={(v) => setKategorie(v as DocumentItem['category'])}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{KATEGORIEN.map((k) => (
<SelectItem key={k} value={k}>
{k}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Wohnung</Label>
<Select value={einheit} onValueChange={setEinheit}>
<SelectTrigger>
<SelectValue placeholder="Alle Häuser" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__alle">Alle Häuser</SelectItem>
{einheiten.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.building}, Whg. {u.number}
</SelectItem>
))}
</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)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="d-groesse">Dateigröße (optional)</Label>
<Input id="d-groesse" placeholder="z. B. 1,8 MB" value={groesse} onChange={(e) => setGroesse(e.target.value)} />
</div>
</div>
{fehler && <p className="text-sm font-medium text-red-600">{fehler}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Abbrechen
</Button>
<Button onClick={submit} disabled={sende || !titel.trim()}>
{sende ? 'Speichere …' : bearbeiten ? 'Speichern' : 'Anlegen'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default function Documents() { export default function Documents() {
const { documents, role, units, meineEinheit } = useStore() const { documents, role, units, meineEinheit, deleteDokument } = useStore()
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const rows = useMemo(() => { const rows = useMemo(() => {
@@ -45,12 +181,17 @@ export default function Documents() {
(units.find((u) => u.id === d.unitId)?.building ?? '').toLowerCase().includes(q), (units.find((u) => u.id === d.unitId)?.building ?? '').toLowerCase().includes(q),
) )
: base : base
}, [documents, role, query, units]) }, [documents, role, query, units, meineEinheit.id])
const cats = ['Alle', ...Array.from(new Set(rows.map((d) => d.category)))] const cats = ['Alle', ...Array.from(new Set(rows.map((d) => d.category)))]
const [cat, setCat] = useState('Alle') const [cat, setCat] = useState('Alle')
const filtered = cat === 'Alle' ? rows : rows.filter((d) => d.category === cat) const filtered = cat === 'Alle' ? rows : rows.filter((d) => d.category === cat)
const loeschen = async (d: DocumentItem) => {
if (!window.confirm(`Dokument „${d.title}" wirklich löschen?`)) return
await deleteDokument(d.id)
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-3"> <div className="flex flex-wrap items-end justify-between gap-3">
@@ -59,15 +200,18 @@ export default function Documents() {
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
{role === 'tenant' {role === 'tenant'
? 'Verträge, Abrechnungen und Unterlagen für Ihre Wohnung' ? 'Verträge, Abrechnungen und Unterlagen für Ihre Wohnung'
: 'Zentraler Dokumentenspeicher des gesamten Bestands'} : 'Zentraler Dokumentenspeicher des gesamten Bestands – Verträge, Abrechnungen und Unterlagen'}
</p> </p>
</div> </div>
<Input <div className="flex flex-wrap items-center gap-2">
placeholder="Dokument suchen …" <Input
value={query} placeholder="Dokument suchen …"
onChange={(e) => setQuery(e.target.value)} value={query}
className="w-64" onChange={(e) => setQuery(e.target.value)}
/> className="w-64"
/>
{role === 'admin' && <DokumentDialog einheiten={units} />}
</div>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -132,9 +276,19 @@ export default function Documents() {
<TableCell>{d.date}</TableCell> <TableCell>{d.date}</TableCell>
<TableCell>{d.size}</TableCell> <TableCell>{d.size}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<button className="inline-flex items-center gap-1 rounded-lg bg-teal-50 px-3 py-1.5 text-xs font-semibold text-teal-700 transition hover:bg-teal-100"> <div className="flex items-center justify-end gap-2">
<Download className="h-3.5 w-3.5" /> PDF <button className="inline-flex items-center gap-1 rounded-lg bg-teal-50 px-3 py-1.5 text-xs font-semibold text-teal-700 transition hover:bg-teal-100">
</button> <Download className="h-3.5 w-3.5" /> PDF
</button>
{role === 'admin' && (
<>
<DokumentDialog dokument={d} einheiten={units} />
<Button variant="outline" size="sm" className="gap-1.5 text-red-600 hover:text-red-700" onClick={() => loeschen(d)}>
<Trash2 className="h-3.5 w-3.5" /> Löschen
</Button>
</>
)}
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
+42 -1
View File
@@ -33,7 +33,7 @@ import type {
ZaehlerArt, ZaehlerArt,
Building, Building,
} from '../types' } from '../types'
import { apiGet, apiPost, apiPatch, ApiFehler } from '../lib/api' import { apiGet, apiPost, apiPatch, apiDelete, ApiFehler } from '../lib/api'
import { sha256, DEMO_PASSWORT } from '../lib/auth' import { sha256, DEMO_PASSWORT } from '../lib/auth'
/** Demo-Einheit als Fallback, falls ein Mieter noch keine Wohnung zugewiesen hat. */ /** Demo-Einheit als Fallback, falls ein Mieter noch keine Wohnung zugewiesen hat. */
@@ -124,6 +124,19 @@ interface Store extends Bootstrap {
status: 'vermietet' | 'frei' status: 'vermietet' | 'frei'
}) => Promise<string | null> }) => Promise<string | null>
updateWohnung: (id: string, u: Partial<Unit>) => Promise<string | null> updateWohnung: (id: string, u: Partial<Unit>) => Promise<string | null>
// ── Dokumente (Verwaltung pflegt, Mieter lesen) ─────────
addDokument: (d: {
title: string
category: DocumentItem['category']
unitId: string | null
date: string
size?: string
}) => Promise<string | null>
updateDokument: (
id: string,
d: Partial<{ title: string; category: DocumentItem['category']; unitId: string | null; date: string; size: string }>,
) => Promise<string | null>
deleteDokument: (id: string) => Promise<string | null>
} }
const StoreContext = createContext<Store | null>(null) const StoreContext = createContext<Store | null>(null)
@@ -348,6 +361,34 @@ export function StoreProvider({ children }: { children: ReactNode }) {
return fehlerNachricht(e) return fehlerNachricht(e)
} }
}, },
// ── Dokumente ───────────────────────────────────────────
async addDokument(d) {
try {
const { dokument } = await apiPost<{ dokument: DocumentItem }>('/api/documents', d)
setDaten((x) => ({ ...x, documents: [dokument, ...x.documents] }))
return null
} catch (e) {
return fehlerNachricht(e)
}
},
async updateDokument(id, d) {
try {
const { dokument } = await apiPatch<{ dokument: DocumentItem }>(`/api/documents/${id}`, d)
setDaten((x) => ({ ...x, documents: x.documents.map((m) => (m.id === id ? dokument : m)) }))
return null
} catch (e) {
return fehlerNachricht(e)
}
},
async deleteDokument(id) {
try {
await apiDelete(`/api/documents/${id}`)
setDaten((x) => ({ ...x, documents: x.documents.filter((m) => m.id !== id) }))
return null
} catch (e) {
return fehlerNachricht(e)
}
},
}), [daten, bereit, me, role, meineEinheit, ladeAlles]) }), [daten, bereit, me, role, meineEinheit, ladeAlles])
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider> return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>