v1.13: PDF-Upload und -Download fuer Dokumente (multer, Upload-Volume, Rechtepruefung)
This commit is contained in:
@@ -53,3 +53,23 @@ 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' })
|
||||
|
||||
/** Datei-Upload (multipart/form-data) – Content-Type NICHT setzen, das setzt der Browser samt Boundary. */
|
||||
export async function apiUpload<T>(pfad: string, datei: File): Promise<T> {
|
||||
const formular = new FormData()
|
||||
formular.append('file', datei)
|
||||
const res = await fetch(pfad, { method: 'POST', credentials: 'same-origin', body: formular })
|
||||
let daten: T | null = null
|
||||
try {
|
||||
daten = (await res.json()) as T
|
||||
} catch {
|
||||
/* leerer Body */
|
||||
}
|
||||
if (!res.ok || daten === null) {
|
||||
const fehler =
|
||||
(daten as { fehler?: string } | null)?.fehler ??
|
||||
(res.status === 401 ? 'Sitzung abgelaufen – bitte erneut anmelden.' : `Fehler (${res.status}).`)
|
||||
throw new ApiFehler(res.status, fehler)
|
||||
}
|
||||
return daten
|
||||
}
|
||||
|
||||
+92
-10
@@ -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>
|
||||
)
|
||||
|
||||
+13
-3
@@ -131,12 +131,14 @@ interface Store extends Bootstrap {
|
||||
unitId: string | null
|
||||
date: string
|
||||
size?: string
|
||||
}) => Promise<string | null>
|
||||
}) => Promise<{ fehler: string | null; id?: string }>
|
||||
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>
|
||||
/** Ersetzt ein Dokument im State durch die Server-Fassung (z. B. nach PDF-Upload). */
|
||||
syncDokument: (dokument: DocumentItem) => void
|
||||
}
|
||||
|
||||
const StoreContext = createContext<Store | null>(null)
|
||||
@@ -366,9 +368,9 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
const { dokument } = await apiPost<{ dokument: DocumentItem }>('/api/documents', d)
|
||||
setDaten((x) => ({ ...x, documents: [dokument, ...x.documents] }))
|
||||
return null
|
||||
return { fehler: null, id: dokument.id }
|
||||
} catch (e) {
|
||||
return fehlerNachricht(e)
|
||||
return { fehler: fehlerNachricht(e) }
|
||||
}
|
||||
},
|
||||
async updateDokument(id, d) {
|
||||
@@ -389,6 +391,14 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
||||
return fehlerNachricht(e)
|
||||
}
|
||||
},
|
||||
syncDokument(dokument) {
|
||||
setDaten((x) => ({
|
||||
...x,
|
||||
documents: x.documents.some((m) => m.id === dokument.id)
|
||||
? x.documents.map((m) => (m.id === dokument.id ? dokument : m))
|
||||
: [dokument, ...x.documents],
|
||||
}))
|
||||
},
|
||||
}), [daten, bereit, me, role, meineEinheit, ladeAlles])
|
||||
|
||||
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
|
||||
|
||||
@@ -68,6 +68,8 @@ export interface DocumentItem {
|
||||
category: 'Mietvertrag' | 'Nebenkostenabrechnung' | 'Hausordnung' | 'Protokoll' | 'Sonstiges'
|
||||
size: string
|
||||
date: string
|
||||
fileName?: string | null // Originalname der hinterlegten PDF
|
||||
hasFile?: boolean
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
|
||||
Reference in New Issue
Block a user