405 lines
16 KiB
TypeScript
405 lines
16 KiB
TypeScript
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>
|
||
)
|
||
}
|