Files
Mieterportal/src/pages/Documents.tsx
T

405 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useRef, useState } from 'react'
import { Download, FileText, FolderOpen, Scale, ReceiptText, FileSignature, FileIcon, Plus, Pencil, Trash2, Upload, Eye } 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,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { useStore } from '../store/store'
import PdfLightbox from '../components/PdfLightbox'
import { apiUpload, ApiFehler } from '../lib/api'
import type { DocumentItem, Unit } from '../types'
const KATEGORIEN: DocumentItem['category'][] = ['Mietvertrag', 'Nebenkostenabrechnung', 'Hausordnung', 'Protokoll', 'Sonstiges']
const catIcon = (cat: string) => {
switch (cat) {
case 'Mietvertrag':
return <FileSignature className="h-4.5 w-4.5" />
case 'Nebenkostenabrechnung':
return <ReceiptText className="h-4.5 w-4.5" />
case 'Hausordnung':
return <Scale className="h-4.5 w-4.5" />
case 'Protokoll':
return <FileText className="h-4.5 w-4.5" />
default:
return <FileIcon className="h-4.5 w-4.5" />
}
}
const heute = () => new Date().toLocaleDateString('de-DE')
function DokumentDialog({ dokument, einheiten }: { dokument?: DocumentItem; einheiten: Unit[] }) {
const { addDokument, updateDokument, syncDokument } = 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 [datei, setDatei] = useState<File | null>(null)
const dateiInput = useRef<HTMLInputElement>(null)
const [sende, setSende] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
const dateiWaehlen = (f: File | null) => {
setFehler(null)
if (!f) return setDatei(null)
const istPdf = f.type === 'application/pdf' || /\.pdf$/i.test(f.name)
if (!istPdf) {
dateiInput.current && (dateiInput.current.value = '')
return setFehler('Nur PDF-Dateien sind erlaubt.')
}
if (f.size > 25 * 1024 * 1024) {
dateiInput.current && (dateiInput.current.value = '')
return setFehler('Die Datei ist größer als 25 MB.')
}
setDatei(f)
}
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() } : {}),
}
let dokId: string
if (bearbeiten) {
const err = await updateDokument(dokument!.id, payload)
if (err) { setSende(false); return setFehler(err) }
dokId = dokument!.id
} else {
const r = await addDokument(payload)
if (r.fehler) { setSende(false); return setFehler(r.fehler) }
dokId = r.id!
}
if (datei) {
try {
const { dokument: hochgeladen } = await apiUpload<{ dokument: DocumentItem }>(
`/api/documents/${dokId}/upload`, datei,
)
syncDokument(hochgeladen) // Größe/Dateiname kommen aus der Upload-Antwort
} catch (e) {
setSende(false)
return setFehler(e instanceof ApiFehler ? e.message : 'Der PDF-Upload ist fehlgeschlagen.')
}
}
setSende(false)
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 – Blücherstr. 59, Whg. 1"
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="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>}
</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, deleteDokument } = useStore()
const [query, setQuery] = useState('')
const rows = useMemo(() => {
const base =
role === 'tenant'
? documents.filter((d) => d.unitId === meineEinheit.id || d.unitId === null)
: documents
const q = query.trim().toLowerCase()
return q
? base.filter(
(d) =>
d.title.toLowerCase().includes(q) ||
d.category.toLowerCase().includes(q) ||
(units.find((u) => u.id === d.unitId)?.building ?? '').toLowerCase().includes(q),
)
: base
}, [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">
<div>
<h1 className="text-2xl font-bold tracking-tight">Dokumente</h1>
<p className="text-sm text-slate-500">
{role === 'tenant'
? 'Verträge, Abrechnungen und Unterlagen für Ihre Wohnung'
: '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">
{cats.map((c) => (
<button
key={c}
onClick={() => setCat(c)}
className={`rounded-full px-4 py-1.5 text-sm font-medium transition ${
cat === c
? 'bg-teal-600 text-white'
: 'bg-white text-slate-600 ring-1 ring-slate-200 hover:ring-teal-400'
}`}
>
{c}
</button>
))}
</div>
<div className="rounded-2xl border bg-white p-5 shadow-sm">
{filtered.length === 0 ? (
<div className="py-10 text-center text-sm text-slate-500">
<FolderOpen className="mx-auto mb-3 h-8 w-8 text-slate-300" />
Keine Dokumente gefunden.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10" />
<TableHead>Dokument</TableHead>
{role === 'admin' && <TableHead>Wohnung</TableHead>}
<TableHead>Kategorie</TableHead>
<TableHead>Datum</TableHead>
<TableHead>Größe</TableHead>
<TableHead className="text-right">Aktion</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((d) => (
<TableRow key={d.id}>
<TableCell>
<span className="inline-flex rounded-lg bg-teal-50 p-2 text-teal-700">
{catIcon(d.category)}
</span>
</TableCell>
<TableCell className="font-medium">{d.title}</TableCell>
{role === 'admin' && (
<TableCell className="text-slate-500">
{d.unitId
? (() => {
const u = units.find((x) => x.id === d.unitId)
return u ? `${u.building}, Whg. ${u.number}` : d.unitId
})()
: 'Alle Häuser'}
</TableCell>
)}
<TableCell>
<Badge variant="secondary" className="bg-slate-100 text-slate-700">
{d.category}
</Badge>
</TableCell>
<TableCell>{d.date}</TableCell>
<TableCell>{d.size}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
{d.hasFile ? (
<>
<PdfLightbox
dokument={d}
trigger={
<Button
variant="outline"
size="sm"
className="gap-1.5"
title={d.fileName ? `${d.fileName} anzeigen` : 'PDF anzeigen'}
>
<Eye className="h-3.5 w-3.5" /> Vorschau
</Button>
}
/>
<a
href={`/api/documents/${d.id}/download`}
title={d.fileName ? `${d.fileName} herunterladen` : 'PDF herunterladen'}
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
</a>
</>
) : (
<span
title={role === 'admin' ? 'Noch keine Datei – über „Bearbeiten" hochladen' : 'Noch keine Datei hinterlegt'}
className="inline-flex cursor-not-allowed items-center gap-1 rounded-lg bg-slate-50 px-3 py-1.5 text-xs font-semibold text-slate-300"
>
<Download className="h-3.5 w-3.5" /> PDF
</span>
)}
{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>
))}
</TableBody>
</Table>
)}
</div>
<p className="text-xs text-slate-400">
Alle Dokumente werden verschlüsselt gespeichert und sind nur für berechtigte Konten
einsehbar. Die Verwaltung lädt Vertrags-PDFs hoch; Mieter laden sie hier direkt herunter.
</p>
</div>
)
}