diff --git a/kimi.md b/kimi.md index e116939..aa09acb 100644 --- a/kimi.md +++ b/kimi.md @@ -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`, diff --git a/server/src/index.js b/server/src/index.js index 3bdd48b..67ce6fd 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -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 = [] diff --git a/src/lib/api.ts b/src/lib/api.ts index 978da23..bb26dc5 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -52,3 +52,4 @@ export async function api(pfad: string, init?: RequestInit, json?: unknown): export const apiGet = (pfad: string) => api(pfad) export const apiPost = (pfad: string, body?: unknown) => api(pfad, { method: 'POST' }, body ?? {}) export const apiPatch = (pfad: string, body?: unknown) => api(pfad, { method: 'PATCH' }, body ?? {}) +export const apiDelete = (pfad: string) => api(pfad, { method: 'DELETE' }) diff --git a/src/pages/Documents.tsx b/src/pages/Documents.tsx index 1f8f6c9..e36539d 100644 --- a/src/pages/Documents.tsx +++ b/src/pages/Documents.tsx @@ -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(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(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 ( + + + {bearbeiten ? ( + + ) : ( + + )} + + + + {bearbeiten ? 'Dokument bearbeiten' : 'Neues Dokument anlegen'} + +
+
+ + setTitel(e.target.value)} + /> +
+
+
+ + +
+
+ + +
+
+
+
+ + setDatum(e.target.value)} /> +
+
+ + setGroesse(e.target.value)} /> +
+
+ {fehler &&

{fehler}

} +
+ + + + +
+
+ ) +} + 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 (
@@ -59,15 +200,18 @@ export default function Documents() {

{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'}

- setQuery(e.target.value)} - className="w-64" - /> +
+ setQuery(e.target.value)} + className="w-64" + /> + {role === 'admin' && } +
@@ -132,9 +276,19 @@ export default function Documents() { {d.date} {d.size} - +
+ + {role === 'admin' && ( + <> + + + + )} +
))} diff --git a/src/store/store.tsx b/src/store/store.tsx index 291d49c..fffe521 100644 --- a/src/store/store.tsx +++ b/src/store/store.tsx @@ -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 updateWohnung: (id: string, u: Partial) => Promise + // ── Dokumente (Verwaltung pflegt, Mieter lesen) ───────── + addDokument: (d: { + title: string + category: DocumentItem['category'] + unitId: string | null + date: string + size?: string + }) => Promise + updateDokument: ( + id: string, + d: Partial<{ title: string; category: DocumentItem['category']; unitId: string | null; date: string; size: string }>, + ) => Promise + deleteDokument: (id: string) => Promise } const StoreContext = createContext(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 {children}