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
+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 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' })
+167 -13
View File
@@ -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>
<Input
placeholder="Dokument suchen …"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-64"
/>
<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">
<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>
<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
View File
@@ -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>