docs: README, Datenschutztexte, Dockerfile und Sicherungsskripte
Damit ist Etappe 1 abgeschlossen und betriebsfertig dokumentiert. README mit beiden Betriebswegen. Docker Compose auf eigenem Server ist der Hauptweg; Vercel und Neon sind vollstaendig beschrieben, aber mit einer klaren Begruendung, warum ich davon abrate: beide Anbieter sind US-Unternehmen und unterliegen auch bei EU-Region potenziell dem CLOUD-Act. Bei Gesundheitsdaten einer nicht entscheidungsfaehigen Person ist das eine Abwaegung, die man bewusst treffen muss. Reverse-Proxy-Beispiele fuer Caddy, nginx und Traefik - der Proxy selbst ist nicht Teil des Compose-Setups, weil er auf dem Zielserver schon steht. Die Datenbank hat keine Portfreigabe. Sicherung: pg_dump schreibt direkt in die Verschluesselung, der Klartext landet nie auf der Platte. Danach eine Selbstpruefung - laesst sich die Datei nicht wieder entschluesseln, endet der Lauf mit Fehler statt mit einem stillen Scheinerfolg. Der jeweils erste Stand eines Monats bleibt dauerhaft stehen. Die README sagt ausdruecklich, was das Backup NICHT enthaelt: den Schluessel ENC_KEY_V1 und die Dateien im Objektspeicher. Dazu eine Anleitung, die Wiederherstellung einmal im Jahr zu ueben - mit dem letzten Schritt, in der Anwendung nachzusehen, ob die Diagnosen im Klartext erscheinen. Erst dann stimmt nicht nur die Datenbank, sondern auch der Schluessel. Verarbeitungsverzeichnis und Datenschutzhinweise als ausfuellbare Textbausteine. Die Hinweise sind in einfacher Sprache geschrieben - sie sollen der betreuten Person vorgelesen werden koennen. Die Seite /datenschutz liest denselben Text aus docs/, damit es nicht zwei Fassungen gibt, die auseinanderlaufen. Dockerfile in drei Stufen mit Tesseract, deutschen Sprachdaten und poppler-utils. Zeitzone Europe/Berlin ist gesetzt - ohne sie rechnet der Server in UTC und verschiebt Fristen um einen Tag. Etappe: 1 Phase: Fundament
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { ArrowDownLeft, ArrowUpRight, CircleDot, FileText, Loader2, ScanLine } from 'lucide-react'
|
||||
import type { DokumentArt, DokumentRichtung, PosteingangStatus } from '@prisma/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ART_BESCHRIFTUNG, RICHTUNG_BESCHRIFTUNG } from '@/lib/dokumente/erkennung'
|
||||
import { POSTEINGANG_BESCHRIFTUNG } from '@/lib/validierung/dokumente'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Die kleinen Kennzeichen an einem Dokument.
|
||||
*
|
||||
* Ueberall gilt: Farbe ist nie die einzige Information. Jede Ampel traegt
|
||||
* ihren Text mit, sonst waere die Liste fuer farbfehlsichtige Augen leer.
|
||||
*/
|
||||
|
||||
export function ArtAbzeichen({ art, className }: { art: DokumentArt; className?: string }) {
|
||||
return (
|
||||
<Badge variant="outline" className={cn('gap-1', className)}>
|
||||
<FileText className="size-3" aria-hidden="true" />
|
||||
{ART_BESCHRIFTUNG[art]}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export function RichtungAbzeichen({ richtung }: { richtung: DokumentRichtung }) {
|
||||
const Icon =
|
||||
richtung === 'EINGANG' ? ArrowDownLeft : richtung === 'AUSGANG' ? ArrowUpRight : CircleDot
|
||||
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Icon className="size-3" aria-hidden="true" />
|
||||
{RICHTUNG_BESCHRIFTUNG[richtung]}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const POSTEINGANG_VARIANTE: Record<
|
||||
PosteingangStatus,
|
||||
'rot' | 'orange' | 'gelb' | 'gruen' | 'secondary'
|
||||
> = {
|
||||
NEU: 'orange',
|
||||
GESICHTET: 'gelb',
|
||||
ZUGEORDNET: 'secondary',
|
||||
BEANTWORTET: 'gruen',
|
||||
}
|
||||
|
||||
export function PosteingangAbzeichen({ status }: { status: PosteingangStatus }) {
|
||||
return <Badge variant={POSTEINGANG_VARIANTE[status]}>{POSTEINGANG_BESCHRIFTUNG[status]}</Badge>
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Stand der Texterkennung. "nicht verfügbar" ist ausdruecklich kein
|
||||
* Fehler: auf einem Windows-Rechner ohne Tesseract ist das der Normalfall,
|
||||
* und das Dokument liegt trotzdem vollstaendig in der Akte.
|
||||
*/
|
||||
export function OcrAbzeichen({ status }: { status: string | null }) {
|
||||
if (!status || status === 'fertig') return null
|
||||
|
||||
const texte: Record<string, { text: string; variante: 'gelb' | 'secondary' | 'orange' }> = {
|
||||
ausstehend: { text: 'Texterkennung läuft gleich', variante: 'gelb' },
|
||||
laeuft: { text: 'Texterkennung läuft', variante: 'gelb' },
|
||||
fehlgeschlagen: { text: 'Texterkennung fehlgeschlagen', variante: 'orange' },
|
||||
nicht_verfuegbar: { text: 'ohne Volltext', variante: 'secondary' },
|
||||
}
|
||||
|
||||
const eintrag = texte[status]
|
||||
if (!eintrag) return null
|
||||
|
||||
const Icon = status === 'laeuft' || status === 'ausstehend' ? Loader2 : ScanLine
|
||||
|
||||
return (
|
||||
<Badge variant={eintrag.variante} className="gap-1">
|
||||
<Icon className={cn('size-3', status === 'laeuft' && 'animate-spin')} aria-hidden="true" />
|
||||
{eintrag.text}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
/** Frei gewaehlte Schlagworte. */
|
||||
export function TagAbzeichen({ name }: { name: string; farbe?: string }) {
|
||||
return (
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{name}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useActionState } from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import { AlertCircle, BookOpen, Download, Loader2 } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||
import { belegmappeAnfordern, type Mappenantwort } from '@/server/actions/dokumente'
|
||||
|
||||
/**
|
||||
* Belegmappe erzeugen: alle Belege eines Vorgangs oder eines Zeitraums als
|
||||
* **eine** chronologisch sortierte PDF mit Deckblatt, Inhaltsverzeichnis
|
||||
* und durchgehender Seitenzahl.
|
||||
*
|
||||
* Genau das erwarten Gericht und Bezirk zur Rechnungslegung - nicht
|
||||
* dreissig einzelne Anhaenge. Auf die Seitenzahl der Mappe kann sich der
|
||||
* Bericht dann berufen ("Beleg 7, Seite 23").
|
||||
*/
|
||||
export function BelegmappeFormular({ vorgaenge }: { vorgaenge: { id: string; titel: string }[] }) {
|
||||
const [zustand, aktion] = useActionState<Mappenantwort, FormData>(belegmappeAnfordern, {})
|
||||
const [art, setArt] = React.useState<'zeitraum' | 'vorgang'>('zeitraum')
|
||||
|
||||
const jahr = new Date().getFullYear()
|
||||
|
||||
return (
|
||||
<form action={aktion} className="space-y-4" noValidate>
|
||||
{zustand.fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{zustand.url && (
|
||||
<Alert variant="erfolg">
|
||||
<Download aria-hidden="true" />
|
||||
<AlertDescription className="space-y-2">
|
||||
<p>{zustand.hinweis}</p>
|
||||
<a
|
||||
href={zustand.url}
|
||||
className="inline-flex font-medium text-primary underline underline-offset-4"
|
||||
>
|
||||
{zustand.dateiname ?? 'Belegmappe'} herunterladen
|
||||
</a>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Die Adresse gilt zehn Minuten. Danach hier neu erzeugen.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{zustand.hinweise && zustand.hinweise.length > 0 && (
|
||||
<Alert variant="warnung">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
{zustand.hinweise.map((hinweis, i) => (
|
||||
<li key={i}>{hinweis}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld id="mappe-art" beschriftung="Was soll in die Mappe?">
|
||||
<Select
|
||||
name="art"
|
||||
value={art}
|
||||
onChange={(ereignis) => setArt(ereignis.target.value as 'zeitraum' | 'vorgang')}
|
||||
>
|
||||
<option value="zeitraum">Alle Belege eines Zeitraums</option>
|
||||
<option value="vorgang">Alle Belege eines Vorgangs</option>
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
{art === 'vorgang' ? (
|
||||
<Feld id="mappe-vorgang" beschriftung="Vorgang">
|
||||
<Select name="vorgangId" disabled={vorgaenge.length === 0}>
|
||||
{vorgaenge.map((vorgang) => (
|
||||
<option key={vorgang.id} value={vorgang.id}>
|
||||
{vorgang.titel}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
) : (
|
||||
<>
|
||||
<Feld id="mappe-von" beschriftung="Von" pflicht>
|
||||
<Input name="von" type="date" defaultValue={`${jahr}-01-01`} required />
|
||||
</Feld>
|
||||
<Feld id="mappe-bis" beschriftung="Bis" pflicht>
|
||||
<Input name="bis" type="date" defaultValue={`${jahr}-12-31`} required />
|
||||
</Feld>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MappeKnopf gesperrt={art === 'vorgang' && vorgaenge.length === 0} />
|
||||
|
||||
<Rechtshinweis rechtsgrundlage="§ 1865 BGB" kurz />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function MappeKnopf({ gesperrt }: { gesperrt: boolean }) {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" disabled={pending || gesperrt}>
|
||||
{pending ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
Wird zusammengestellt …
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BookOpen aria-hidden="true" />
|
||||
Belegmappe erzeugen
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { AlertCircle, Download, Eye, EyeOff, Loader2, Trash2 } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { dokumentLoeschen, vorschauUrl } from '@/server/actions/dokumente'
|
||||
|
||||
/**
|
||||
* Datei ansehen, herunterladen, in den Papierkorb legen.
|
||||
*
|
||||
* Die Datei wird nie ueber eine dauerhafte Adresse ausgeliefert. Bei jedem
|
||||
* Klick wird eine frische, fuenf Minuten gueltige Adresse geholt; der
|
||||
* Server prueft beim Abruf zusaetzlich, ob der angemeldete Benutzer diese
|
||||
* betreute Person betreut, und schreibt einen Eintrag ins Audit-Log.
|
||||
* Ein weitergereichter Link oeffnet deshalb nichts.
|
||||
*/
|
||||
export function DateiAktionen({
|
||||
dokumentId,
|
||||
istBild,
|
||||
hatDatei,
|
||||
schreibrecht,
|
||||
}: {
|
||||
dokumentId: string
|
||||
istBild: boolean
|
||||
hatDatei: boolean
|
||||
schreibrecht: boolean
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [laeuft, setLaeuft] = React.useState<'oeffnen' | 'vorschau' | 'loeschen' | null>(null)
|
||||
const [fehler, setFehler] = React.useState<string | null>(null)
|
||||
const [vorschau, setVorschau] = React.useState<string | null>(null)
|
||||
|
||||
async function frischeUrl(): Promise<string | null> {
|
||||
const antwort = await vorschauUrl(dokumentId)
|
||||
if (antwort.fehler || !antwort.url) {
|
||||
setFehler(antwort.fehler ?? 'Die Datei ließ sich nicht öffnen.')
|
||||
return null
|
||||
}
|
||||
setFehler(null)
|
||||
return antwort.url
|
||||
}
|
||||
|
||||
if (!hatDatei) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Zu diesem Eintrag ist keine Datei hinterlegt – nur die Angaben in der Akte.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>{fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={laeuft !== null}
|
||||
onClick={async () => {
|
||||
setLaeuft('oeffnen')
|
||||
const url = await frischeUrl()
|
||||
setLaeuft(null)
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}}
|
||||
>
|
||||
{laeuft === 'oeffnen' ? (
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Download aria-hidden="true" />
|
||||
)}
|
||||
Datei öffnen
|
||||
</Button>
|
||||
|
||||
{istBild &&
|
||||
(vorschau ? (
|
||||
<Button type="button" variant="outline" onClick={() => setVorschau(null)}>
|
||||
<EyeOff aria-hidden="true" />
|
||||
Vorschau schließen
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={laeuft !== null}
|
||||
onClick={async () => {
|
||||
setLaeuft('vorschau')
|
||||
const url = await frischeUrl()
|
||||
setLaeuft(null)
|
||||
if (url) setVorschau(url)
|
||||
}}
|
||||
>
|
||||
{laeuft === 'vorschau' ? (
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye aria-hidden="true" />
|
||||
)}
|
||||
Vorschau anzeigen
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{schreibrecht && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={laeuft !== null}
|
||||
onClick={async () => {
|
||||
const sicher = window.confirm(
|
||||
'Dieses Dokument in den Papierkorb legen? Du kannst es 30 Tage lang zurückholen.',
|
||||
)
|
||||
if (!sicher) return
|
||||
setLaeuft('loeschen')
|
||||
const ergebnis = await dokumentLoeschen(dokumentId)
|
||||
setLaeuft(null)
|
||||
if (ergebnis.fehler) setFehler(ergebnis.fehler)
|
||||
else router.push('/dokumente')
|
||||
}}
|
||||
>
|
||||
{laeuft === 'loeschen' ? (
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Trash2 aria-hidden="true" />
|
||||
)}
|
||||
In den Papierkorb
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{vorschau && (
|
||||
<figure className="overflow-hidden rounded-lg border bg-muted/30">
|
||||
{/* Bewusst ein einfaches <img>: die Datei wird als Anhang mit
|
||||
nosniff ausgeliefert und kann so nichts ausfuehren. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={vorschau}
|
||||
alt="Vorschau des hochgeladenen Dokuments"
|
||||
className="max-h-[70vh] w-full object-contain"
|
||||
onError={() =>
|
||||
setFehler(
|
||||
'Die Vorschau lässt sich in diesem Browser nicht anzeigen. Mit „Datei öffnen“ klappt es.',
|
||||
)
|
||||
}
|
||||
/>
|
||||
<figcaption className="border-t px-3 py-2 text-xs text-muted-foreground">
|
||||
Vorschau – die Adresse gilt nur wenige Minuten.
|
||||
</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import Link from 'next/link'
|
||||
import { Link2, Paperclip } from 'lucide-react'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { formatDatum } from '@/lib/utils'
|
||||
import { formatBytes } from '@/lib/speicher/dateityp'
|
||||
import type { Listeneintrag } from '@/lib/dokumente/suche'
|
||||
import {
|
||||
ArtAbzeichen,
|
||||
OcrAbzeichen,
|
||||
PosteingangAbzeichen,
|
||||
RichtungAbzeichen,
|
||||
TagAbzeichen,
|
||||
} from '@/components/dokumente/abzeichen'
|
||||
|
||||
/**
|
||||
* Die Dokumentenliste.
|
||||
*
|
||||
* Am Handy eine Karte je Dokument, ab Tablet eine Tabelle - die Tabelle
|
||||
* scrollt dabei im eigenen Rahmen, nie die ganze Seite.
|
||||
*/
|
||||
export function DokumentListe({ eintraege }: { eintraege: Listeneintrag[] }) {
|
||||
return (
|
||||
<>
|
||||
{/* Handy */}
|
||||
<ul className="space-y-3 md:hidden">
|
||||
{eintraege.map((eintrag) => (
|
||||
<li key={eintrag.id} className="rounded-lg border bg-card p-4">
|
||||
<Link
|
||||
href={`/dokumente/${eintrag.id}`}
|
||||
className="font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{eintrag.titel}
|
||||
</Link>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{eintrag.absender ?? 'Ohne Absender'} · {formatDatum(eintrag.eingangAm ?? eintrag.schreibenVom)}
|
||||
</p>
|
||||
<Ausschnitt text={eintrag.ausschnitt} />
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<ArtAbzeichen art={eintrag.art} />
|
||||
<RichtungAbzeichen richtung={eintrag.richtung} />
|
||||
<PosteingangAbzeichen status={eintrag.posteingangStatus} />
|
||||
<OcrAbzeichen status={eintrag.ocrStatus} />
|
||||
{eintrag.tags.map((tag) => (
|
||||
<TagAbzeichen key={tag.id} name={tag.name} />
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Tablet und Rechner */}
|
||||
<div className="hidden overflow-x-auto rounded-lg border md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[38%]">Dokument</TableHead>
|
||||
<TableHead>Absender</TableHead>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Art</TableHead>
|
||||
<TableHead>Posteingang</TableHead>
|
||||
<TableHead className="text-right">Datei</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{eintraege.map((eintrag) => (
|
||||
<TableRow key={eintrag.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/dokumente/${eintrag.id}`}
|
||||
className="font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{eintrag.titel}
|
||||
</Link>
|
||||
{eintrag.aktenzeichen && (
|
||||
<p className="text-xs text-muted-foreground">Az. {eintrag.aktenzeichen}</p>
|
||||
)}
|
||||
<Ausschnitt text={eintrag.ausschnitt} />
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{eintrag.tags.map((tag) => (
|
||||
<TagAbzeichen key={tag.id} name={tag.name} />
|
||||
))}
|
||||
<OcrAbzeichen status={eintrag.ocrStatus} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">{eintrag.absender ?? '–'}</TableCell>
|
||||
<TableCell className="whitespace-nowrap align-top tabular-nums">
|
||||
{formatDatum(eintrag.eingangAm ?? eintrag.schreibenVom)}
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<div className="flex flex-col gap-1">
|
||||
<ArtAbzeichen art={eintrag.art} />
|
||||
<RichtungAbzeichen richtung={eintrag.richtung} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<div className="flex flex-col gap-1">
|
||||
<PosteingangAbzeichen status={eintrag.posteingangStatus} />
|
||||
{eintrag.anzahlVerknuepfungen > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Link2 className="size-3" aria-hidden="true" />
|
||||
{eintrag.anzahlVerknuepfungen} verknüpft
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-right align-top text-xs text-muted-foreground">
|
||||
{eintrag.dateiname ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Paperclip className="size-3" aria-hidden="true" />
|
||||
{formatBytes(eintrag.groesseBytes ?? 0)}
|
||||
{eintrag.seitenzahl ? ` · ${eintrag.seitenzahl} S.` : ''}
|
||||
</span>
|
||||
) : (
|
||||
'ohne Datei'
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Fundstelle aus der Volltextsuche. Postgres liefert sie mit
|
||||
* <mark>-Auszeichnung; hier wird sie kontrolliert wieder zusammengesetzt,
|
||||
* statt fremdes HTML in die Seite zu lassen.
|
||||
*/
|
||||
function Ausschnitt({ text }: { text?: string }) {
|
||||
if (!text) return null
|
||||
|
||||
const teile = text.split(/(<mark>|<\/mark>)/)
|
||||
let hervorgehoben = false
|
||||
|
||||
return (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{teile.map((teil, i) => {
|
||||
if (teil === '<mark>') {
|
||||
hervorgehoben = true
|
||||
return null
|
||||
}
|
||||
if (teil === '</mark>') {
|
||||
hervorgehoben = false
|
||||
return null
|
||||
}
|
||||
return hervorgehoben ? (
|
||||
<mark key={i} className="rounded bg-ampel-gelb/25 px-0.5 text-foreground">
|
||||
{teil}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={i}>{teil}</span>
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useActionState } from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import { AlertCircle, CalendarClock, CheckCircle2, Loader2, ScanText, Sparkles } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Rechtshinweis } from '@/components/rechtshinweis'
|
||||
import { ART_BESCHRIFTUNG } from '@/lib/dokumente/erkennung'
|
||||
import { DOKUMENT_ARTEN } from '@/lib/validierung/dokumente'
|
||||
import {
|
||||
dokumentSpeichern,
|
||||
fristUebernehmen,
|
||||
vorschlaegeLesen,
|
||||
type Aktionszustand,
|
||||
type Vorschlagsantwort,
|
||||
} from '@/server/actions/dokumente'
|
||||
|
||||
/**
|
||||
* Die Erkennungsvorschlaege zu einem frisch hochgeladenen Dokument.
|
||||
*
|
||||
* **Nichts hiervon wird automatisch uebernommen.** Jeder Wert steht als
|
||||
* Vorschlag da, mit der Textstelle, aus der er stammt, und wird erst durch
|
||||
* einen Klick verbindlich. Bei Fristen ist das keine Bequemlichkeit,
|
||||
* sondern eine Sicherheitsentscheidung: eine falsch gerechnete Frist, auf
|
||||
* die man sich verlaesst, ist schlimmer als gar keine.
|
||||
*
|
||||
* Die Texterkennung laeuft im Hintergrund weiter, waehrend diese Ansicht
|
||||
* schon steht - deshalb wird ein paar Mal nachgefragt, bis ein Ergebnis da
|
||||
* ist. Kommt keins (kein Tesseract auf dem Rechner), sagt die Ansicht das
|
||||
* und bietet die Eingabe von Hand an.
|
||||
*/
|
||||
export function ErkennungPanel({
|
||||
dokumentId,
|
||||
vorgaenge,
|
||||
}: {
|
||||
dokumentId: string
|
||||
vorgaenge: { id: string; titel: string }[]
|
||||
}) {
|
||||
const [antwort, setAntwort] = React.useState<Vorschlagsantwort | null>(null)
|
||||
const [laeuft, setLaeuft] = React.useState(true)
|
||||
|
||||
React.useEffect(() => {
|
||||
let abgebrochen = false
|
||||
let versuche = 0
|
||||
|
||||
async function nachfragen() {
|
||||
const ergebnis = await vorschlaegeLesen(dokumentId)
|
||||
if (abgebrochen) return
|
||||
|
||||
setAntwort(ergebnis)
|
||||
versuche += 1
|
||||
|
||||
const nochUnterwegs =
|
||||
!ergebnis.fehler &&
|
||||
(ergebnis.ocrStatus === 'ausstehend' || ergebnis.ocrStatus === 'laeuft') &&
|
||||
versuche < 15
|
||||
|
||||
if (nochUnterwegs) {
|
||||
// Erst schnell, dann geduldiger - ein Scan mit zwanzig Seiten
|
||||
// braucht seine Zeit.
|
||||
window.setTimeout(nachfragen, Math.min(1500 + versuche * 750, 6000))
|
||||
} else {
|
||||
setLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
void nachfragen()
|
||||
return () => {
|
||||
abgebrochen = true
|
||||
}
|
||||
}, [dokumentId])
|
||||
|
||||
if (laeuft && !antwort?.vorschlaege) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border bg-card p-4 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
|
||||
Der Text wird gelesen. Du kannst hier warten oder die Angaben gleich von Hand eintragen.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (antwort?.fehler) {
|
||||
return (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>{antwort.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
if (!antwort?.vorschlaege) {
|
||||
return (
|
||||
<Alert variant="hinweis">
|
||||
<ScanText aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
{antwort?.ocrStatus === 'nicht_verfuegbar'
|
||||
? 'Auf diesem Rechner ist keine Texterkennung installiert (Tesseract). Das Dokument ist gespeichert – trage Absender, Datum und Aktenzeichen bitte von Hand ein.'
|
||||
: 'Aus diesem Dokument ließ sich kein Text lesen. Das kommt bei schlecht belichteten Fotos vor. Trage die Angaben bitte von Hand ein.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<MetadatenUebernehmen
|
||||
dokumentId={dokumentId}
|
||||
vorschlaege={antwort.vorschlaege}
|
||||
/>
|
||||
<Fristvorschlaege
|
||||
dokumentId={dokumentId}
|
||||
fristen={antwort.vorschlaege.fristen}
|
||||
vorgaenge={vorgaenge}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function MetadatenUebernehmen({
|
||||
dokumentId,
|
||||
vorschlaege,
|
||||
}: {
|
||||
dokumentId: string
|
||||
vorschlaege: NonNullable<Vorschlagsantwort['vorschlaege']>
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(dokumentSpeichern, {})
|
||||
|
||||
return (
|
||||
<form action={aktion} className="space-y-4 rounded-lg border bg-card p-4" noValidate>
|
||||
<input type="hidden" name="dokumentId" value={dokumentId} />
|
||||
{/* Wird durch die Zuordnung ohnehin gesetzt - hier gesichtet. */}
|
||||
<input type="hidden" name="posteingangStatus" value="GESICHTET" />
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Sparkles className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
|
||||
<div>
|
||||
<h2 className="font-semibold">Das haben wir im Schreiben gelesen</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bitte prüfen und ändern, wo es nicht stimmt. Erst mit „Übernehmen“ wird es gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{zustand.fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{zustand.hinweis && (
|
||||
<Alert variant="erfolg">
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
<AlertDescription>{zustand.hinweis}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld id="v-titel" beschriftung="Titel" fehler={zustand.felder?.titel} pflicht>
|
||||
<Input name="titel" defaultValue={vorschlaege.titel ?? ''} maxLength={200} required />
|
||||
</Feld>
|
||||
|
||||
<Feld id="v-absender" beschriftung="Absender" fehler={zustand.felder?.absender}>
|
||||
<Input name="absender" defaultValue={vorschlaege.absender ?? ''} maxLength={200} />
|
||||
</Feld>
|
||||
|
||||
<Feld id="v-art" beschriftung="Art">
|
||||
<Select name="art" defaultValue={vorschlaege.art ?? 'SONSTIGES'}>
|
||||
{DOKUMENT_ARTEN.map((art) => (
|
||||
<option key={art} value={art}>
|
||||
{ART_BESCHRIFTUNG[art]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld id="v-richtung" beschriftung="Richtung">
|
||||
<Select name="richtung" defaultValue={vorschlaege.richtung ?? 'EINGANG'}>
|
||||
<option value="EINGANG">Eingang</option>
|
||||
<option value="AUSGANG">Ausgang</option>
|
||||
<option value="INTERN">Intern</option>
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="v-schreibenVom"
|
||||
beschriftung="Datum des Schreibens"
|
||||
erklaerung="Das Datum, das oben auf dem Brief steht."
|
||||
fehler={zustand.felder?.schreibenVom}
|
||||
>
|
||||
<Input name="schreibenVom" type="date" defaultValue={vorschlaege.schreibenVom ?? ''} />
|
||||
</Feld>
|
||||
|
||||
<Feld id="v-aktenzeichen" beschriftung="Aktenzeichen / Nummer">
|
||||
<Input name="aktenzeichen" defaultValue={vorschlaege.aktenzeichen ?? ''} maxLength={100} />
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<UebernehmenKnopf />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function UebernehmenKnopf() {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <CheckCircle2 aria-hidden="true" />}
|
||||
Vorschläge übernehmen
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function Fristvorschlaege({
|
||||
dokumentId,
|
||||
fristen,
|
||||
vorgaenge,
|
||||
}: {
|
||||
dokumentId: string
|
||||
fristen: NonNullable<Vorschlagsantwort['vorschlaege']>['fristen']
|
||||
vorgaenge: { id: string; titel: string }[]
|
||||
}) {
|
||||
if (fristen.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border bg-card p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<CalendarClock className="mt-0.5 size-5 shrink-0 text-ampel-orange" aria-hidden="true" />
|
||||
<div>
|
||||
<h2 className="font-semibold">
|
||||
{fristen.length === 1 ? 'Mögliche Frist im Text' : `${fristen.length} mögliche Fristen im Text`}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nichts davon ist angelegt. Prüfe die Textstelle und übernimm nur, was wirklich eine
|
||||
Frist ist.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fristen.map((frist, i) => (
|
||||
<Fristkarte key={i} dokumentId={dokumentId} frist={frist} vorgaenge={vorgaenge} />
|
||||
))}
|
||||
|
||||
<Rechtshinweis rechtsgrundlage="§ 84 SGG, § 70 VwGO" kurz />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Fristkarte({
|
||||
dokumentId,
|
||||
frist,
|
||||
vorgaenge,
|
||||
}: {
|
||||
dokumentId: string
|
||||
frist: NonNullable<Vorschlagsantwort['vorschlaege']>['fristen'][number]
|
||||
vorgaenge: { id: string; titel: string }[]
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(fristUebernehmen, {})
|
||||
const [offen, setOffen] = React.useState(false)
|
||||
|
||||
if (zustand.hinweis) {
|
||||
return (
|
||||
<Alert variant="erfolg">
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
<AlertDescription>{zustand.hinweis}</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{frist.titel}</p>
|
||||
<p className="text-sm text-muted-foreground">{frist.begruendung}</p>
|
||||
</div>
|
||||
{frist.rechtsbehelf && <Badge variant="rot">Rechtsbehelfsfrist</Badge>}
|
||||
</div>
|
||||
|
||||
<blockquote className="mt-2 border-l-2 border-border pl-3 text-sm italic text-muted-foreground">
|
||||
{frist.textstelle}
|
||||
</blockquote>
|
||||
|
||||
{zustand.fehler && (
|
||||
<p className="mt-2 text-sm font-medium text-destructive">{zustand.fehler}</p>
|
||||
)}
|
||||
|
||||
{!offen ? (
|
||||
<Button type="button" variant="outline" className="mt-3" onClick={() => setOffen(true)}>
|
||||
<CalendarClock aria-hidden="true" />
|
||||
Als Frist übernehmen
|
||||
</Button>
|
||||
) : (
|
||||
<form action={aktion} className="mt-3 space-y-3" noValidate>
|
||||
<input type="hidden" name="dokumentId" value={dokumentId} />
|
||||
<input type="hidden" name="beschreibung" value={frist.begruendung} />
|
||||
<input type="hidden" name="typ" value={frist.rechtsbehelf ? 'GESETZLICH' : 'BEHOERDLICH'} />
|
||||
{frist.rechtsbehelf && <input type="hidden" name="ausschlussfrist" value="ja" />}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Feld id={`f-titel-${dokumentId}`} beschriftung="Titel der Frist" pflicht>
|
||||
<Input name="titel" defaultValue={frist.titel} maxLength={200} required />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id={`f-datum-${dokumentId}`}
|
||||
beschriftung="Fällig am"
|
||||
erklaerung="Bitte gegen das Schreiben prüfen. Bei Bescheiden zählt die Bekanntgabe, nicht das Briefdatum."
|
||||
pflicht
|
||||
>
|
||||
<Input name="datum" type="date" defaultValue={frist.datum ?? ''} required />
|
||||
</Feld>
|
||||
|
||||
{vorgaenge.length > 0 && (
|
||||
<Feld id={`f-vorgang-${dokumentId}`} beschriftung="Zu welchem Vorgang?">
|
||||
<Select name="vorgangId" defaultValue="">
|
||||
<option value="">Keinem Vorgang zuordnen</option>
|
||||
{vorgaenge.map((vorgang) => (
|
||||
<option key={vorgang.id} value={vorgang.id}>
|
||||
{vorgang.titel}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
)}
|
||||
|
||||
<Feld id={`f-grundlage-${dokumentId}`} beschriftung="Rechtsgrundlage (optional)">
|
||||
<Input
|
||||
name="rechtsgrundlage"
|
||||
defaultValue={frist.rechtsbehelf ? '§ 84 SGG' : ''}
|
||||
maxLength={120}
|
||||
/>
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<FristKnopf />
|
||||
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FristKnopf() {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <CalendarClock aria-hidden="true" />}
|
||||
Frist anlegen
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import Link from 'next/link'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { ART_BESCHRIFTUNG, RICHTUNG_BESCHRIFTUNG } from '@/lib/dokumente/erkennung'
|
||||
import {
|
||||
DOKUMENT_ARTEN,
|
||||
DOKUMENT_RICHTUNGEN,
|
||||
POSTEINGANG_BESCHRIFTUNG,
|
||||
POSTEINGANG_STUFEN,
|
||||
} from '@/lib/validierung/dokumente'
|
||||
|
||||
/**
|
||||
* Suche und Filter ueber der Dokumentenliste.
|
||||
*
|
||||
* Bewusst ein schlichtes GET-Formular ohne JavaScript: die Filter stehen
|
||||
* damit in der Adresszeile, lassen sich als Lesezeichen ablegen ("alle
|
||||
* offenen Bescheide vom Bezirk") und funktionieren auch, wenn im Zug das
|
||||
* Netz wackelt.
|
||||
*/
|
||||
export function FilterLeiste({
|
||||
ziel = '/dokumente',
|
||||
werte,
|
||||
tags,
|
||||
mitPosteingang = true,
|
||||
}: {
|
||||
ziel?: string
|
||||
werte: {
|
||||
suche?: string | null
|
||||
art?: string | null
|
||||
richtung?: string | null
|
||||
status?: string | null
|
||||
von?: string | null
|
||||
bis?: string | null
|
||||
tag?: string | null
|
||||
}
|
||||
tags: { id: string; name: string }[]
|
||||
mitPosteingang?: boolean
|
||||
}) {
|
||||
const etwasGesetzt = Object.values(werte).some((wert) => wert)
|
||||
|
||||
return (
|
||||
<form method="get" action={ziel} className="space-y-4 rounded-lg border bg-card p-4">
|
||||
<Feld
|
||||
id="suche"
|
||||
beschriftung="Volltextsuche"
|
||||
erklaerung="Sucht in Titel, Absender, Aktenzeichen und im erkannten Text der Dokumente. Mehrere Wörter suchen alle; „Wortgruppe“ in Anführungszeichen sucht genau so; ein Minus davor schließt aus."
|
||||
>
|
||||
<Input
|
||||
name="suche"
|
||||
type="search"
|
||||
defaultValue={werte.suche ?? ''}
|
||||
placeholder="z. B. Hilfe zur Pflege"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Feld id="art" beschriftung="Art">
|
||||
<Select name="art" defaultValue={werte.art ?? ''}>
|
||||
<option value="">Alle Arten</option>
|
||||
{DOKUMENT_ARTEN.map((art) => (
|
||||
<option key={art} value={art}>
|
||||
{ART_BESCHRIFTUNG[art]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld id="richtung" beschriftung="Richtung">
|
||||
<Select name="richtung" defaultValue={werte.richtung ?? ''}>
|
||||
<option value="">Ein- und Ausgang</option>
|
||||
{DOKUMENT_RICHTUNGEN.map((richtung) => (
|
||||
<option key={richtung} value={richtung}>
|
||||
{RICHTUNG_BESCHRIFTUNG[richtung]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
{mitPosteingang && (
|
||||
<Feld id="status" beschriftung="Posteingang">
|
||||
<Select name="status" defaultValue={werte.status ?? ''}>
|
||||
<option value="">Alle Stufen</option>
|
||||
{POSTEINGANG_STUFEN.map((stufe) => (
|
||||
<option key={stufe} value={stufe}>
|
||||
{POSTEINGANG_BESCHRIFTUNG[stufe]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
)}
|
||||
|
||||
<Feld id="von" beschriftung="Zeitraum von" erklaerung="Nach Eingangsdatum.">
|
||||
<Input name="von" type="date" defaultValue={werte.von ?? ''} />
|
||||
</Feld>
|
||||
|
||||
<Feld id="bis" beschriftung="Zeitraum bis">
|
||||
<Input name="bis" type="date" defaultValue={werte.bis ?? ''} />
|
||||
</Feld>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<Feld id="tag" beschriftung="Schlagwort">
|
||||
<Select name="tag" defaultValue={werte.tag ?? ''}>
|
||||
<option value="">Alle Schlagworte</option>
|
||||
{tags.map((tag) => (
|
||||
<option key={tag.id} value={tag.id}>
|
||||
{tag.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit">
|
||||
<Search aria-hidden="true" />
|
||||
Suchen
|
||||
</Button>
|
||||
{etwasGesetzt && (
|
||||
<Button type="button" variant="ghost" asChild>
|
||||
<Link href={ziel}>
|
||||
<X aria-hidden="true" />
|
||||
Filter zurücksetzen
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import { AlertCircle, CheckCircle2, Loader2, Save } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Feld, Feldgruppe } from '@/components/feld'
|
||||
import { ART_BESCHRIFTUNG, RICHTUNG_BESCHRIFTUNG } from '@/lib/dokumente/erkennung'
|
||||
import {
|
||||
DOKUMENT_ARTEN,
|
||||
DOKUMENT_RICHTUNGEN,
|
||||
POSTEINGANG_BESCHRIFTUNG,
|
||||
POSTEINGANG_STUFEN,
|
||||
} from '@/lib/validierung/dokumente'
|
||||
import { dokumentSpeichern, type Aktionszustand } from '@/server/actions/dokumente'
|
||||
|
||||
export type MetadatenWerte = {
|
||||
id: string
|
||||
titel: string
|
||||
art: string
|
||||
richtung: string
|
||||
schreibenVom: string
|
||||
eingangAm: string
|
||||
versandAm: string
|
||||
absender: string
|
||||
empfaenger: string
|
||||
aktenzeichen: string
|
||||
ablageortPapier: string
|
||||
wiedervorlageAm: string
|
||||
ablaufAm: string
|
||||
posteingangStatus: string
|
||||
notizen: string
|
||||
tagIds: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Metadaten eines Dokuments.
|
||||
*
|
||||
* Das Feld, das hier am leichtesten uebersehen wird und trotzdem das
|
||||
* wichtigste ist: **der Ablageort des Originals**. Diese Anwendung ersetzt
|
||||
* den Leitz-Ordner nicht - sie sagt, in welchem er liegt. Wenn das Gericht
|
||||
* das Original sehen will, muss man es finden.
|
||||
*/
|
||||
export function MetadatenFormular({
|
||||
werte,
|
||||
tags,
|
||||
schreibrecht,
|
||||
}: {
|
||||
werte: MetadatenWerte
|
||||
tags: { id: string; name: string }[]
|
||||
schreibrecht: boolean
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(dokumentSpeichern, {})
|
||||
|
||||
return (
|
||||
<form action={aktion} className="space-y-6" noValidate>
|
||||
<input type="hidden" name="dokumentId" value={werte.id} />
|
||||
|
||||
{zustand.fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{zustand.hinweis && (
|
||||
<Alert variant="erfolg">
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
<AlertDescription>{zustand.hinweis}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<fieldset disabled={!schreibrecht} className="space-y-6">
|
||||
<Feldgruppe titel="Das Schreiben">
|
||||
<Feld
|
||||
id="titel"
|
||||
beschriftung="Titel"
|
||||
erklaerung="Woran erkennst du dieses Dokument in einem Jahr wieder?"
|
||||
fehler={zustand.felder?.titel}
|
||||
pflicht
|
||||
className="sm:col-span-2"
|
||||
>
|
||||
<Input name="titel" defaultValue={werte.titel} maxLength={200} required />
|
||||
</Feld>
|
||||
|
||||
<Feld id="art" beschriftung="Art">
|
||||
<Select name="art" defaultValue={werte.art}>
|
||||
{DOKUMENT_ARTEN.map((art) => (
|
||||
<option key={art} value={art}>
|
||||
{ART_BESCHRIFTUNG[art]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld id="richtung" beschriftung="Richtung">
|
||||
<Select name="richtung" defaultValue={werte.richtung}>
|
||||
{DOKUMENT_RICHTUNGEN.map((richtung) => (
|
||||
<option key={richtung} value={richtung}>
|
||||
{RICHTUNG_BESCHRIFTUNG[richtung]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld id="absender" beschriftung="Absender" fehler={zustand.felder?.absender}>
|
||||
<Input name="absender" defaultValue={werte.absender} maxLength={200} />
|
||||
</Feld>
|
||||
|
||||
<Feld id="empfaenger" beschriftung="Empfänger">
|
||||
<Input name="empfaenger" defaultValue={werte.empfaenger} maxLength={200} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="aktenzeichen"
|
||||
beschriftung="Aktenzeichen / Nummer"
|
||||
erklaerung="Gerichtsaktenzeichen, Kundennummer, Versicherungsschein-Nr. – was das Schreiben eben trägt."
|
||||
>
|
||||
<Input name="aktenzeichen" defaultValue={werte.aktenzeichen} maxLength={100} />
|
||||
</Feld>
|
||||
</Feldgruppe>
|
||||
|
||||
<Feldgruppe
|
||||
titel="Daten"
|
||||
beschreibung="Für Rechtsbehelfsfristen zählt der Eingang, nicht das Datum auf dem Brief."
|
||||
>
|
||||
<Feld id="schreibenVom" beschriftung="Datum des Schreibens">
|
||||
<Input name="schreibenVom" type="date" defaultValue={werte.schreibenVom} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="eingangAm"
|
||||
beschriftung="Eingegangen am"
|
||||
fehler={zustand.felder?.eingangAm}
|
||||
erklaerung="Der Tag, an dem der Brief bei dir im Kasten lag."
|
||||
>
|
||||
<Input name="eingangAm" type="date" defaultValue={werte.eingangAm} />
|
||||
</Feld>
|
||||
|
||||
<Feld id="versandAm" beschriftung="Versandt am" erklaerung="Nur bei ausgehender Post.">
|
||||
<Input name="versandAm" type="date" defaultValue={werte.versandAm} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="wiedervorlageAm"
|
||||
beschriftung="Wiedervorlage am"
|
||||
erklaerung="Wann willst du dieses Blatt wiedersehen?"
|
||||
>
|
||||
<Input name="wiedervorlageAm" type="date" defaultValue={werte.wiedervorlageAm} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="ablaufAm"
|
||||
beschriftung="Gültig bis"
|
||||
erklaerung="z. B. Ablauf des Betreuerausweises oder der Zuzahlungsbefreiung."
|
||||
>
|
||||
<Input name="ablaufAm" type="date" defaultValue={werte.ablaufAm} />
|
||||
</Feld>
|
||||
</Feldgruppe>
|
||||
|
||||
<Feldgruppe titel="Ablage">
|
||||
<Feld
|
||||
id="ablageortPapier"
|
||||
beschriftung="Ablageort des Originals"
|
||||
erklaerung="Ordner und Register, in dem das Blatt in Papier liegt. Papier bleibt Papier – und das Gericht fragt danach."
|
||||
className="sm:col-span-2"
|
||||
>
|
||||
<Input
|
||||
name="ablageortPapier"
|
||||
defaultValue={werte.ablageortPapier}
|
||||
maxLength={150}
|
||||
placeholder="Ordner 2, Register Bezirk Oberfranken"
|
||||
/>
|
||||
</Feld>
|
||||
|
||||
<Feld id="posteingangStatus" beschriftung="Stand im Posteingang">
|
||||
<Select name="posteingangStatus" defaultValue={werte.posteingangStatus}>
|
||||
{POSTEINGANG_STUFEN.map((stufe) => (
|
||||
<option key={stufe} value={stufe}>
|
||||
{POSTEINGANG_BESCHRIFTUNG[stufe]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="neueTags"
|
||||
beschriftung="Neue Schlagworte"
|
||||
erklaerung="Mit Komma getrennt, z. B. „Heimkosten, wichtig“."
|
||||
>
|
||||
<Input name="neueTags" maxLength={200} placeholder="Heimkosten, wichtig" />
|
||||
</Feld>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<fieldset className="sm:col-span-2">
|
||||
<legend className="mb-2 text-sm font-medium">Vorhandene Schlagworte</legend>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{tags.map((tag) => (
|
||||
<label key={tag.id} className="flex min-h-11 items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
name="tagIds"
|
||||
value={tag.id}
|
||||
defaultChecked={werte.tagIds.includes(tag.id)}
|
||||
/>
|
||||
{tag.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<Feld
|
||||
id="notizen"
|
||||
beschriftung="Notizen"
|
||||
erklaerung="Werden verschlüsselt gespeichert."
|
||||
className="sm:col-span-2"
|
||||
>
|
||||
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} />
|
||||
</Feld>
|
||||
</Feldgruppe>
|
||||
</fieldset>
|
||||
|
||||
{schreibrecht && <SpeichernKnopf />}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function SpeichernKnopf() {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" size="lg" disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <Save aria-hidden="true" />}
|
||||
Speichern
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Check, Loader2 } from 'lucide-react'
|
||||
import type { PosteingangStatus } from '@prisma/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { posteingangSetzen } from '@/server/actions/dokumente'
|
||||
import { POSTEINGANG_BESCHRIFTUNG, POSTEINGANG_STUFEN } from '@/lib/validierung/dokumente'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Das Posteingangsbuch als Kette: NEU -> GESICHTET -> ZUGEORDNET ->
|
||||
* BEANTWORTET.
|
||||
*
|
||||
* Diese vier Stufen sind kein Selbstzweck. Sie beantworten die Frage, die
|
||||
* man sich am Kuechentisch mit dem Poststapel stellt: Was habe ich schon
|
||||
* gelesen, was noch nicht, und was wartet noch auf eine Antwort? Nichts
|
||||
* darf durchrutschen - dafuer gibt es diese Zeile.
|
||||
*/
|
||||
export function PosteingangStufen({
|
||||
dokumentId,
|
||||
status,
|
||||
schreibrecht,
|
||||
kompakt,
|
||||
}: {
|
||||
dokumentId: string
|
||||
status: PosteingangStatus
|
||||
schreibrecht: boolean
|
||||
kompakt?: boolean
|
||||
}) {
|
||||
const [laeuft, setLaeuft] = React.useState<string | null>(null)
|
||||
const [aktuell, setAktuell] = React.useState<PosteingangStatus>(status)
|
||||
|
||||
React.useEffect(() => setAktuell(status), [status])
|
||||
|
||||
const erreicht = POSTEINGANG_STUFEN.indexOf(aktuell)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
role="group"
|
||||
aria-label="Stand im Posteingang"
|
||||
>
|
||||
{POSTEINGANG_STUFEN.map((stufe, i) => {
|
||||
const istAktuell = stufe === aktuell
|
||||
const istErledigt = i < erreicht
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={stufe}
|
||||
type="button"
|
||||
size={kompakt ? 'sm' : 'default'}
|
||||
variant={istAktuell ? 'default' : istErledigt ? 'secondary' : 'outline'}
|
||||
disabled={!schreibrecht || laeuft !== null || istAktuell}
|
||||
aria-current={istAktuell ? 'step' : undefined}
|
||||
className={cn(!schreibrecht && 'pointer-events-none')}
|
||||
onClick={async () => {
|
||||
setLaeuft(stufe)
|
||||
const ergebnis = await posteingangSetzen(dokumentId, stufe)
|
||||
if (!ergebnis.fehler) setAktuell(stufe)
|
||||
setLaeuft(null)
|
||||
}}
|
||||
>
|
||||
{laeuft === stufe ? (
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
) : istErledigt ? (
|
||||
<Check aria-hidden="true" />
|
||||
) : null}
|
||||
{POSTEINGANG_BESCHRIFTUNG[stufe]}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useActionState } from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import {
|
||||
AlertCircle,
|
||||
Camera,
|
||||
CheckCircle2,
|
||||
FileUp,
|
||||
Loader2,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { ACCEPT_ATTRIBUT, formatBytes } from '@/lib/speicher/dateityp'
|
||||
import { dokumenteHochladen, type Aktionszustand } from '@/server/actions/dokumente'
|
||||
import { ErkennungPanel } from '@/components/dokumente/erkennung-panel'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Dokumente aufnehmen: per Drag&Drop, per Dateiauswahl oder direkt mit der
|
||||
* Handykamera.
|
||||
*
|
||||
* Das Ziel dieser Seite in einem Satz: **Foto hochladen, in zwei Klicks
|
||||
* einem Vorgang zuordnen, daraus eine Frist erzeugen.** Alles andere ist
|
||||
* Beiwerk. Deshalb sind Titel, Art und Absender optional - was der Brief
|
||||
* hergibt, schlaegt die Erkennung anschliessend selbst vor.
|
||||
*/
|
||||
export function UploadFormular({
|
||||
vorgaenge,
|
||||
vorgangVorgewaehlt,
|
||||
kameraZuerst,
|
||||
}: {
|
||||
vorgaenge: { id: string; titel: string }[]
|
||||
vorgangVorgewaehlt?: string | null
|
||||
/** Aufruf aus der Schnellerfassung "Beleg fotografieren". */
|
||||
kameraZuerst?: boolean
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(dokumenteHochladen, {})
|
||||
const [dateien, setDateien] = React.useState<File[]>([])
|
||||
const [ueberzogen, setUeberzogen] = React.useState(false)
|
||||
|
||||
const dateiFeld = React.useRef<HTMLInputElement>(null)
|
||||
const kameraFeld = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
/**
|
||||
* Die ausgewaehlten Dateien werden in einem eigenen Zustand gehalten und
|
||||
* vor dem Absenden in das versteckte Feld gespiegelt. Nur so lassen sich
|
||||
* Dateiauswahl, Kamera und Drag&Drop zu einem Stapel zusammenfuehren.
|
||||
*/
|
||||
const uebernehmen = React.useCallback((neue: FileList | null) => {
|
||||
if (!neue || neue.length === 0) return
|
||||
setDateien((bisher) => {
|
||||
const zusammen = [...bisher]
|
||||
for (const datei of Array.from(neue)) {
|
||||
const schonDa = zusammen.some(
|
||||
(d) => d.name === datei.name && d.size === datei.size && d.lastModified === datei.lastModified,
|
||||
)
|
||||
if (!schonDa) zusammen.push(datei)
|
||||
}
|
||||
return zusammen.slice(0, 30)
|
||||
})
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!dateiFeld.current) return
|
||||
const behaelter = new DataTransfer()
|
||||
for (const datei of dateien) behaelter.items.add(datei)
|
||||
dateiFeld.current.files = behaelter.files
|
||||
}, [dateien])
|
||||
|
||||
// Nach einem erfolgreichen Stapel die Auswahl leeren.
|
||||
React.useEffect(() => {
|
||||
if (zustand.ergebnisse && zustand.ergebnisse.some((e) => e.dokumentId)) {
|
||||
setDateien([])
|
||||
}
|
||||
}, [zustand.ergebnisse])
|
||||
|
||||
const gesamt = dateien.reduce((summe, datei) => summe + datei.size, 0)
|
||||
const einzelnHochgeladen =
|
||||
zustand.ergebnisse?.filter((e) => e.dokumentId).length === 1
|
||||
? zustand.ergebnisse.find((e) => e.dokumentId)!.dokumentId!
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<form action={aktion} className="space-y-6" noValidate>
|
||||
<input
|
||||
ref={dateiFeld}
|
||||
type="file"
|
||||
name="dateien"
|
||||
multiple
|
||||
accept={ACCEPT_ATTRIBUT}
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onChange={(ereignis) => uebernehmen(ereignis.target.files)}
|
||||
/>
|
||||
{/* Getrenntes Feld fuer die Kamera: capture darf nicht am Sammelfeld
|
||||
haengen, sonst bietet das Handy keine Dateiauswahl mehr an. */}
|
||||
<input
|
||||
ref={kameraFeld}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onChange={(ereignis) => {
|
||||
uebernehmen(ereignis.target.files)
|
||||
ereignis.target.value = ''
|
||||
}}
|
||||
/>
|
||||
|
||||
{zustand.fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div
|
||||
onDragOver={(ereignis) => {
|
||||
ereignis.preventDefault()
|
||||
setUeberzogen(true)
|
||||
}}
|
||||
onDragLeave={() => setUeberzogen(false)}
|
||||
onDrop={(ereignis) => {
|
||||
ereignis.preventDefault()
|
||||
setUeberzogen(false)
|
||||
uebernehmen(ereignis.dataTransfer.files)
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-lg border-2 border-dashed p-6 text-center transition-colors sm:p-10',
|
||||
ueberzogen ? 'border-primary bg-primary/5' : 'border-border',
|
||||
)}
|
||||
>
|
||||
<FileUp className="mx-auto mb-3 size-8 text-muted-foreground" aria-hidden="true" />
|
||||
<p className="font-medium">Dokumente hierher ziehen</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
PDF, JPEG, PNG, WEBP, TIFF oder HEIC. Mehrere auf einmal sind ausdrücklich erwünscht.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 flex flex-col justify-center gap-2 sm:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
variant={kameraZuerst ? 'default' : 'outline'}
|
||||
size="lg"
|
||||
onClick={() => kameraFeld.current?.click()}
|
||||
>
|
||||
<Camera aria-hidden="true" />
|
||||
Foto aufnehmen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={kameraZuerst ? 'outline' : 'default'}
|
||||
size="lg"
|
||||
onClick={() => dateiFeld.current?.click()}
|
||||
>
|
||||
<Upload aria-hidden="true" />
|
||||
Dateien auswählen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dateien.length > 0 && (
|
||||
<div className="rounded-lg border">
|
||||
<div className="flex items-center justify-between border-b px-4 py-2.5 text-sm">
|
||||
<span className="font-medium">
|
||||
{dateien.length} {dateien.length === 1 ? 'Datei' : 'Dateien'} · {formatBytes(gesamt)}
|
||||
</span>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setDateien([])}>
|
||||
Auswahl leeren
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="divide-y">
|
||||
{dateien.map((datei, i) => (
|
||||
<li key={`${datei.name}-${i}`} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<span className="min-w-0 flex-1 truncate text-sm">{datei.name}</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{formatBytes(datei.size)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDateien((bisher) => bisher.filter((_, index) => index !== i))}
|
||||
aria-label={`${datei.name} aus der Auswahl entfernen`}
|
||||
>
|
||||
<Trash2 aria-hidden="true" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
id="titel"
|
||||
beschriftung="Titel"
|
||||
erklaerung="Nur bei einer einzelnen Datei. Leer lassen ist in Ordnung – die Erkennung schlägt gleich einen Titel vor."
|
||||
fehler={zustand.felder?.titel}
|
||||
>
|
||||
<Input name="titel" maxLength={200} />
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="eingangAm"
|
||||
beschriftung="Eingegangen am"
|
||||
erklaerung="Maßgeblich für Rechtsbehelfsfristen – nicht das Datum auf dem Brief. Leer = heute."
|
||||
fehler={zustand.felder?.eingangAm}
|
||||
>
|
||||
<Input name="eingangAm" type="date" />
|
||||
</Feld>
|
||||
|
||||
<Feld id="vorgangId" beschriftung="Direkt einem Vorgang zuordnen">
|
||||
<Select name="vorgangId" defaultValue={vorgangVorgewaehlt ?? ''}>
|
||||
<option value="">Später zuordnen</option>
|
||||
{vorgaenge.map((vorgang) => (
|
||||
<option key={vorgang.id} value={vorgang.id}>
|
||||
{vorgang.titel}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="ablageortPapier"
|
||||
beschriftung="Ablageort des Originals"
|
||||
erklaerung="Wo liegt das Blatt in Papier? z. B. „Ordner 2, Register Bezirk“. Papier bleibt Papier."
|
||||
>
|
||||
<Input name="ablageortPapier" maxLength={150} placeholder="Ordner 2, Register Bezirk" />
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<Absendeknopf anzahl={dateien.length} />
|
||||
</form>
|
||||
|
||||
{zustand.ergebnisse && zustand.ergebnisse.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<Alert variant={zustand.ergebnisse.every((e) => e.dokumentId) ? 'erfolg' : 'warnung'}>
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
<AlertDescription>{zustand.hinweis ?? 'Verarbeitet.'}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<ul className="divide-y rounded-lg border">
|
||||
{zustand.ergebnisse.map((ergebnis, i) => (
|
||||
<li key={i} className="flex flex-wrap items-center gap-2 px-4 py-2.5 text-sm">
|
||||
<span className="min-w-0 flex-1 truncate">{ergebnis.dateiname}</span>
|
||||
{ergebnis.dokumentId ? (
|
||||
<Link
|
||||
href={`/dokumente/${ergebnis.dokumentId}`}
|
||||
className="shrink-0 font-medium text-primary hover:underline"
|
||||
>
|
||||
Öffnen
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-destructive">{ergebnis.fehler}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{einzelnHochgeladen && (
|
||||
<ErkennungPanel dokumentId={einzelnHochgeladen} vorgaenge={vorgaenge} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Absendeknopf({ anzahl }: { anzahl: number }) {
|
||||
const { pending } = useFormStatus()
|
||||
|
||||
return (
|
||||
<Button type="submit" size="lg" className="w-full sm:w-auto" disabled={pending || anzahl === 0}>
|
||||
{pending ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
Wird übernommen …
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload aria-hidden="true" />
|
||||
{anzahl <= 1 ? 'In die Akte übernehmen' : `${anzahl} Dokumente übernehmen`}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useActionState } from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import { AlertCircle, Link2, Link2Off, Loader2, Plus } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Feld } from '@/components/feld'
|
||||
import { Leerzustand } from '@/components/seiten-kopf'
|
||||
import { ZIEL_BESCHRIFTUNG, type Verknuepfungsziel } from '@/lib/validierung/dokumente'
|
||||
import {
|
||||
dokumentVerknuepfen,
|
||||
verknuepfungLoesen,
|
||||
type Aktionszustand,
|
||||
} from '@/server/actions/dokumente'
|
||||
|
||||
export type Verknuepfungseintrag = {
|
||||
id: string
|
||||
ziel: Verknuepfungsziel
|
||||
zielId: string
|
||||
bezeichnung: string
|
||||
bemerkung: string | null
|
||||
/** Wohin fuehrt der Link in der Anwendung? */
|
||||
href: string | null
|
||||
}
|
||||
|
||||
export type Zielauswahl = {
|
||||
ziel: Verknuepfungsziel
|
||||
eintraege: { id: string; titel: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Verknuepfungen eines Dokuments mit der uebrigen Akte.
|
||||
*
|
||||
* Ein Bescheid gehoert zum Vorgang "Hilfe zur Pflege", begruendet eine
|
||||
* Widerspruchsfrist und belegt eine Buchung. Erst diese Verbindungen
|
||||
* machen aus einem Stapel Dateien eine Akte, in der man einen Sachverhalt
|
||||
* von jeder Seite aus wiederfindet.
|
||||
*/
|
||||
export function Verknuepfungen({
|
||||
dokumentId,
|
||||
vorhandene,
|
||||
auswahl,
|
||||
schreibrecht,
|
||||
}: {
|
||||
dokumentId: string
|
||||
vorhandene: Verknuepfungseintrag[]
|
||||
auswahl: Zielauswahl[]
|
||||
schreibrecht: boolean
|
||||
}) {
|
||||
const [zustand, aktion] = useActionState<Aktionszustand, FormData>(dokumentVerknuepfen, {})
|
||||
const [gewaehlt, setGewaehlt] = React.useState<Verknuepfungsziel>(auswahl[0]?.ziel ?? 'vorgang')
|
||||
|
||||
const moeglich = auswahl.find((eintrag) => eintrag.ziel === gewaehlt)?.eintraege ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{vorhandene.length === 0 ? (
|
||||
<Leerzustand
|
||||
icon={Link2}
|
||||
titel="Noch nicht zugeordnet"
|
||||
beschreibung="Ordne das Dokument einem Vorgang zu – dann taucht es dort auf, wo du es suchst."
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y rounded-lg border">
|
||||
{vorhandene.map((eintrag) => (
|
||||
<li key={eintrag.id} className="flex flex-wrap items-center gap-3 px-4 py-3">
|
||||
<span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{ZIEL_BESCHRIFTUNG[eintrag.ziel]}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
{eintrag.href ? (
|
||||
<Link href={eintrag.href} className="font-medium hover:underline">
|
||||
{eintrag.bezeichnung}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-medium">{eintrag.bezeichnung}</span>
|
||||
)}
|
||||
{eintrag.bemerkung && (
|
||||
<span className="block text-sm text-muted-foreground">{eintrag.bemerkung}</span>
|
||||
)}
|
||||
</span>
|
||||
{schreibrecht && <LoesenKnopf verknuepfungId={eintrag.id} />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{schreibrecht && (
|
||||
<form action={aktion} className="space-y-4 rounded-lg border bg-card p-4" noValidate>
|
||||
<input type="hidden" name="dokumentId" value={dokumentId} />
|
||||
|
||||
{zustand.fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld id="ziel" beschriftung="Womit verknüpfen?">
|
||||
<Select
|
||||
name="ziel"
|
||||
value={gewaehlt}
|
||||
onChange={(ereignis) => setGewaehlt(ereignis.target.value as Verknuepfungsziel)}
|
||||
>
|
||||
{auswahl.map((eintrag) => (
|
||||
<option key={eintrag.ziel} value={eintrag.ziel}>
|
||||
{ZIEL_BESCHRIFTUNG[eintrag.ziel]} ({eintrag.eintraege.length})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld
|
||||
id="zielId"
|
||||
beschriftung={ZIEL_BESCHRIFTUNG[gewaehlt]}
|
||||
erklaerung={moeglich.length === 0 ? 'Hier gibt es noch nichts zum Verknüpfen.' : undefined}
|
||||
>
|
||||
<Select name="zielId" disabled={moeglich.length === 0}>
|
||||
{moeglich.map((eintrag) => (
|
||||
<option key={eintrag.id} value={eintrag.id}>
|
||||
{eintrag.titel}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Feld>
|
||||
|
||||
<Feld id="bemerkung" beschriftung="Bemerkung (optional)" className="sm:col-span-2">
|
||||
<Input name="bemerkung" maxLength={200} placeholder="z. B. Beleg zur Zahlung vom 12.03." />
|
||||
</Feld>
|
||||
</div>
|
||||
|
||||
<VerknuepfenKnopf gesperrt={moeglich.length === 0} />
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VerknuepfenKnopf({ gesperrt }: { gesperrt: boolean }) {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" disabled={pending || gesperrt}>
|
||||
{pending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <Plus aria-hidden="true" />}
|
||||
Verknüpfen
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function LoesenKnopf({ verknuepfungId }: { verknuepfungId: string }) {
|
||||
const [laeuft, setLaeuft] = React.useState(false)
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={laeuft}
|
||||
onClick={async () => {
|
||||
setLaeuft(true)
|
||||
await verknuepfungLoesen(verknuepfungId)
|
||||
setLaeuft(false)
|
||||
}}
|
||||
>
|
||||
{laeuft ? (
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Link2Off aria-hidden="true" />
|
||||
)}
|
||||
Lösen
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user