v1.12: Dokumente-CRUD fuer die Verwaltung (Mietvertraege & Co. im Dokumentenmodul)
This commit is contained in:
@@ -189,6 +189,19 @@ tar -czf mp-dist.tar.gz -C dist .
|
||||
|
||||
## 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.
|
||||
Kette: Mieter-Ticket → mp-api `kiBenachrichtigen()` (POST an n8n, fire-and-
|
||||
forget) → n8n-Workflow `mpKiAntwort0001` (Webhook POST `mp-schadensmeldung`,
|
||||
|
||||
@@ -482,6 +482,70 @@ app.patch('/api/meters/:id', auth, adminOnly, async (req, res, next) => {
|
||||
res.json({ meter: mapMeter(aktualisiert[0]) })
|
||||
} 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) ────────────────
|
||||
function pruefeHaus(b) {
|
||||
const fehler = []
|
||||
|
||||
@@ -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 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 apiDelete = <T>(pfad: string) => api<T>(pfad, { method: 'DELETE' })
|
||||
|
||||
+158
-4
@@ -1,7 +1,24 @@
|
||||
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 { Button } from '@/components/ui/button'
|
||||
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 {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -11,6 +28,9 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { useStore } from '../store/store'
|
||||
import type { DocumentItem, Unit } from '../types'
|
||||
|
||||
const KATEGORIEN: DocumentItem['category'][] = ['Mietvertrag', 'Nebenkostenabrechnung', 'Hausordnung', 'Protokoll', 'Sonstiges']
|
||||
|
||||
const catIcon = (cat: string) => {
|
||||
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() {
|
||||
const { documents, role, units, meineEinheit } = useStore()
|
||||
const { documents, role, units, meineEinheit, deleteDokument } = useStore()
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const rows = useMemo(() => {
|
||||
@@ -45,12 +181,17 @@ export default function Documents() {
|
||||
(units.find((u) => u.id === d.unitId)?.building ?? '').toLowerCase().includes(q),
|
||||
)
|
||||
: base
|
||||
}, [documents, role, query, units])
|
||||
}, [documents, role, query, units, meineEinheit.id])
|
||||
|
||||
const cats = ['Alle', ...Array.from(new Set(rows.map((d) => d.category)))]
|
||||
const [cat, setCat] = useState('Alle')
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
{role === 'tenant'
|
||||
? '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>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Dokument suchen …"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
{role === 'admin' && <DokumentDialog einheiten={units} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -132,9 +276,19 @@ export default function Documents() {
|
||||
<TableCell>{d.date}</TableCell>
|
||||
<TableCell>{d.size}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<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">
|
||||
<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>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
+42
-1
@@ -33,7 +33,7 @@ import type {
|
||||
ZaehlerArt,
|
||||
Building,
|
||||
} 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'
|
||||
|
||||
/** Demo-Einheit als Fallback, falls ein Mieter noch keine Wohnung zugewiesen hat. */
|
||||
@@ -124,6 +124,19 @@ interface Store extends Bootstrap {
|
||||
status: 'vermietet' | 'frei'
|
||||
}) => 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)
|
||||
@@ -348,6 +361,34 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
||||
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])
|
||||
|
||||
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
|
||||
|
||||
Reference in New Issue
Block a user