v1.13: PDF-Upload und -Download fuer Dokumente (multer, Upload-Volume, Rechtepruefung)

This commit is contained in:
2026-10-04 12:08:18 +02:00
parent 8bef23665a
commit 39eb247456
11 changed files with 287 additions and 18 deletions
+92 -10
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { Download, FileText, FolderOpen, Scale, ReceiptText, FileSignature, FileIcon, Plus, Pencil, Trash2 } from 'lucide-react'
import { useMemo, useRef, useState } from 'react'
import { Download, FileText, FolderOpen, Scale, ReceiptText, FileSignature, FileIcon, Plus, Pencil, Trash2, Upload } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -28,6 +28,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { useStore } from '../store/store'
import { apiUpload, ApiFehler } from '../lib/api'
import type { DocumentItem, Unit } from '../types'
const KATEGORIEN: DocumentItem['category'][] = ['Mietvertrag', 'Nebenkostenabrechnung', 'Hausordnung', 'Protokoll', 'Sonstiges']
@@ -50,7 +51,7 @@ const catIcon = (cat: string) => {
const heute = () => new Date().toLocaleDateString('de-DE')
function DokumentDialog({ dokument, einheiten }: { dokument?: DocumentItem; einheiten: Unit[] }) {
const { addDokument, updateDokument } = useStore()
const { addDokument, updateDokument, syncDokument } = useStore()
const bearbeiten = Boolean(dokument)
const [open, setOpen] = useState(false)
const [titel, setTitel] = useState(dokument?.title ?? '')
@@ -58,9 +59,26 @@ function DokumentDialog({ dokument, einheiten }: { dokument?: DocumentItem; einh
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)
@@ -72,9 +90,28 @@ function DokumentDialog({ dokument, einheiten }: { dokument?: DocumentItem; einh
date: datum.trim() || heute(),
...(groesse.trim() ? { size: groesse.trim() } : {}),
}
const err = bearbeiten ? await updateDokument(dokument!.id, payload) : await addDokument(payload)
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)
if (err) return setFehler(err)
setOpen(false)
}
@@ -145,9 +182,41 @@ function DokumentDialog({ dokument, einheiten }: { dokument?: DocumentItem; einh
</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)} />
<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>
@@ -277,9 +346,22 @@ export default function Documents() {
<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>
{d.hasFile ? (
<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} />
@@ -299,7 +381,7 @@ export default function Documents() {
<p className="text-xs text-slate-400">
Alle Dokumente werden verschlüsselt gespeichert und sind nur für berechtigte Konten
einsehbar. Die Download-Funktion ist in dieser Demo dargestellt.
einsehbar. Die Verwaltung lädt Vertrags-PDFs hoch; Mieter laden sie hier direkt herunter.
</p>
</div>
)