Wer zum dritten Mal denselben Brief tippt, tippt ihn beim vierten Mal
falsch. Neu unter Einstellungen -> Schreibvorlagen: eigene Texte
hinterlegen, beim freien Schreiben auswaehlen, Platzhalter fuellen sich
aus den Daten, die ohnehin im System stehen.
Aufbau:
- Die Ersetzung als reine Funktionen in schreiben/platzhalter.ts. Drei
Faelle, keiner davon still: Wert einsetzen, sichtbare Luecke
[Aktenzeichen des Gerichts], unbekannter Platzhalter markiert. Ein
Tippfehler in der Vorlage darf das Schreiben nicht verhindern - aber
er muss ins Auge springen.
- Die Bestellungsabsatz-Warnung ist zweistufig: Der erste Speicherversuch
ohne ihn kommt mit einer Rueckfrage zurueck, erst die Bestaetigung
speichert. Ohne den Nachweis der Vertretungsmacht kommt keine Auskunft,
und das faellt erst nach vier Wochen auf. Ein ausformulierter Absatz
zaehlt so viel wie der Platzhalter, ein halber Nachweis ("Beschluss der
Eigentuemerversammlung") nicht.
- Vier mitgelieferte Vorlagen bleiben Code und damit unveraenderlich;
"Als Kopie uebernehmen" legt eine eigene Fassung an. Fuer Rueckkaufswert
und Kuendigung fuehrt die Oberflaeche weiter zuerst zu den
fallbezogenen Erzeugern - die rechnen Kuendigungstermine aus, was eine
Vorlage nicht kann.
- Vorlagen haengen an der betreuten Person, nicht am Benutzer: Sie
beziehen sich auf ihren Fall, liegen im Papierkorb wie alles andere und
sind im Vollexport ihrer Akte enthalten.
Der Editor haelt Betreff und Text als eigenen State. Beim Wechsel der
Vorlage ueberlebte der alte Entwurf die Navigation, die Vorlage kam nie
im Textfeld an - ein key auf dem Editor setzt ihn neu auf. Gefunden hat
das erst der End-to-End-Test.
Geprueft: dreizehn Einheitstests fuer die Ersetzung, sechs
End-to-End-Tests fuer Warnung, Kopie, Aufloesung und Papierkorb.
Refs #7
375 lines
13 KiB
TypeScript
375 lines
13 KiB
TypeScript
'use client'
|
|
|
|
import * as React from 'react'
|
|
import { useActionState } from 'react'
|
|
import { AlertCircle, AlertTriangle, CheckCircle2, Copy, Pencil, Plus, Trash2 } from 'lucide-react'
|
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { Feld } from '@/components/feld'
|
|
import { Absendeknopf } from '@/components/finanzen/eingaben'
|
|
import {
|
|
vorlageLoeschen,
|
|
vorlageSpeichern,
|
|
type VorlagenZustand,
|
|
} from '@/server/actions/vorlagen'
|
|
import { PLATZHALTER, benutztePlatzhalter } from '@/lib/schreiben/platzhalter'
|
|
|
|
export type VorlagenZeile = {
|
|
id: string
|
|
name: string
|
|
betreff: string
|
|
text: string
|
|
}
|
|
|
|
export type MitgelieferteVorlage = {
|
|
schluessel: string
|
|
name: string
|
|
beschreibung: string
|
|
betreff: string
|
|
text: string
|
|
}
|
|
|
|
/**
|
|
* Verwaltung der eigenen Schreibvorlagen.
|
|
*
|
|
* Zwei Listen, ein Editor. Die mitgelieferten Vorlagen sind Code und
|
|
* unveränderlich — von ihnen führt nur ein Weg hierher: „Als Kopie
|
|
* übernehmen". Damit kann nichts kaputtgehen, was die Anwendung braucht,
|
|
* und „wiederherstellen" ist schlicht: noch einmal kopieren.
|
|
*/
|
|
export function VorlagenVerwaltung({
|
|
betreuteId,
|
|
eigene,
|
|
mitgelieferte,
|
|
}: {
|
|
betreuteId: string
|
|
eigene: VorlagenZeile[]
|
|
mitgelieferte: MitgelieferteVorlage[]
|
|
}) {
|
|
const [offen, setOffen] = React.useState(false)
|
|
const [bearbeitet, setBearbeitet] = React.useState<VorlagenZeile | null>(null)
|
|
const [name, setName] = React.useState('')
|
|
const [betreff, setBetreff] = React.useState('')
|
|
const [text, setText] = React.useState('')
|
|
|
|
function neuBeginnen(vorlage?: Partial<VorlagenZeile>) {
|
|
setBearbeitet(null)
|
|
setName(vorlage?.name ?? '')
|
|
setBetreff(vorlage?.betreff ?? '')
|
|
setText(vorlage?.text ?? '')
|
|
setOffen(true)
|
|
}
|
|
|
|
function bearbeiten(vorlage: VorlagenZeile) {
|
|
setBearbeitet(vorlage)
|
|
setName(vorlage.name)
|
|
setBetreff(vorlage.betreff)
|
|
setText(vorlage.text)
|
|
setOffen(true)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* --- Eigene Vorlagen ---------------------------------------------- */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base">Eigene Vorlagen</CardTitle>
|
|
<CardDescription>
|
|
Formulierungen, die sich bewährt haben — einmal hinterlegt, beim Schreiben wählbar.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{eigene.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
Noch keine eigene Vorlage. Fang mit einer Kopie einer mitgelieferten an — oder mit
|
|
einem leeren Blatt.
|
|
</p>
|
|
) : (
|
|
<ul className="divide-y rounded-md border">
|
|
{eigene.map((vorlage) => (
|
|
<li key={vorlage.id} className="flex flex-wrap items-center gap-2 p-3">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium">{vorlage.name}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{benutztePlatzhalter(vorlage.text).length} Platzhalter
|
|
{vorlage.betreff ? ` · Betreff: ${vorlage.betreff}` : ''}
|
|
</p>
|
|
</div>
|
|
<Button type="button" variant="ghost" size="sm" onClick={() => bearbeiten(vorlage)}>
|
|
<Pencil aria-hidden="true" />
|
|
Bearbeiten
|
|
</Button>
|
|
<VorlageLoeschKnopf betreuteId={betreuteId} vorlage={vorlage} />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
<Button type="button" variant="outline" onClick={() => neuBeginnen()}>
|
|
<Plus aria-hidden="true" />
|
|
Neue Vorlage
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* --- Mitgelieferte ------------------------------------------------- */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base">Mitgelieferte Vorlagen</CardTitle>
|
|
<CardDescription>
|
|
Unveränderlich — sie sind Teil der Anwendung und lassen sich nicht kaputtmachen. Als
|
|
Grundlage für eigene Fassungen: kopieren und anpassen.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ul className="divide-y rounded-md border">
|
|
{mitgelieferte.map((vorlage) => (
|
|
<li key={vorlage.schluessel} className="flex flex-wrap items-center gap-2 p-3">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium">{vorlage.name}</p>
|
|
<p className="text-xs text-muted-foreground">{vorlage.beschreibung}</p>
|
|
</div>
|
|
<Badge variant="secondary">mitgeliefert</Badge>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() =>
|
|
neuBeginnen({
|
|
name: `${vorlage.name} (eigene Fassung)`,
|
|
betreff: vorlage.betreff,
|
|
text: vorlage.text,
|
|
})
|
|
}
|
|
>
|
|
<Copy aria-hidden="true" />
|
|
Als Kopie übernehmen
|
|
</Button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* --- Editor --------------------------------------------------------- */}
|
|
{offen && (
|
|
<VorlagenEditor
|
|
betreuteId={betreuteId}
|
|
vorlageId={bearbeitet?.id ?? ''}
|
|
name={name}
|
|
betreff={betreff}
|
|
text={text}
|
|
onName={setName}
|
|
onBetreff={setBetreff}
|
|
onText={setText}
|
|
onSchliessen={() => setOffen(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function VorlageLoeschKnopf({
|
|
betreuteId,
|
|
vorlage,
|
|
}: {
|
|
betreuteId: string
|
|
vorlage: VorlagenZeile
|
|
}) {
|
|
const [zustand, absenden] = useActionState<VorlagenZustand, FormData>(vorlageLoeschen, {})
|
|
const [gefragt, setGefragt] = React.useState(false)
|
|
|
|
if (!gefragt) {
|
|
return (
|
|
<>
|
|
{zustand.fehler && <span className="text-xs text-destructive">{zustand.fehler}</span>}
|
|
<Button type="button" variant="ghost" size="sm" onClick={() => setGefragt(true)}>
|
|
<Trash2 aria-hidden="true" />
|
|
Löschen
|
|
</Button>
|
|
</>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<form action={absenden} className="flex items-center gap-2">
|
|
<input type="hidden" name="betreuteId" value={betreuteId} />
|
|
<input type="hidden" name="vorlageId" value={vorlage.id} />
|
|
<Absendeknopf variant="destructive" size="sm" wartetext="…">
|
|
In den Papierkorb
|
|
</Absendeknopf>
|
|
<Button type="button" variant="ghost" size="sm" onClick={() => setGefragt(false)}>
|
|
Abbrechen
|
|
</Button>
|
|
</form>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Der Editor mit der Platzhalterliste daneben.
|
|
*
|
|
* Die Warnung zum Bestellungsabsatz kommt vom Server (zweistufig): Der
|
|
* erste Speicherversuch ohne ihn liefert die Warnung zurück, erst die
|
|
* Bestätigung speichert wirklich. So bleibt die Prüfung an EINER Stelle —
|
|
* eine zweite Prüfung im Browser könnte von der Serverfassung abweichen.
|
|
*/
|
|
function VorlagenEditor({
|
|
betreuteId,
|
|
vorlageId,
|
|
name,
|
|
betreff,
|
|
text,
|
|
onName,
|
|
onBetreff,
|
|
onText,
|
|
onSchliessen,
|
|
}: {
|
|
betreuteId: string
|
|
vorlageId: string
|
|
name: string
|
|
betreff: string
|
|
text: string
|
|
onName: (wert: string) => void
|
|
onBetreff: (wert: string) => void
|
|
onText: (wert: string) => void
|
|
onSchliessen: () => void
|
|
}) {
|
|
const [zustand, absenden] = useActionState<VorlagenZustand, FormData>(vorlageSpeichern, {})
|
|
const [trotzdem, setTrotzdem] = React.useState(false)
|
|
|
|
const textfeld = React.useRef<HTMLTextAreaElement>(null)
|
|
|
|
function platzhalterEinfuegen(platzhalterName: string) {
|
|
const feld = textfeld.current
|
|
const einschub = `{{${platzhalterName}}}`
|
|
if (!feld) {
|
|
onText(`${text}${einschub}`)
|
|
return
|
|
}
|
|
const start = feld.selectionStart ?? text.length
|
|
const ende = feld.selectionEnd ?? text.length
|
|
onText(`${text.slice(0, start)}${einschub}${text.slice(ende)}`)
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base">
|
|
{vorlageId ? 'Vorlage bearbeiten' : 'Neue Vorlage'}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form action={absenden} className="grid gap-6 lg:grid-cols-[1fr_260px]">
|
|
<div className="space-y-4">
|
|
<input type="hidden" name="betreuteId" value={betreuteId} />
|
|
<input type="hidden" name="vorlageId" value={vorlageId} />
|
|
<input type="hidden" name="ohneBestellungsabsatz" value={trotzdem ? 'ja' : ''} />
|
|
|
|
{zustand.fehler && (
|
|
<Alert variant="fehler">
|
|
<AlertCircle aria-hidden="true" />
|
|
<AlertDescription>{zustand.fehler}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{zustand.warnung && (
|
|
<Alert variant="warnung">
|
|
<AlertTriangle aria-hidden="true" />
|
|
<AlertDescription>
|
|
<p>{zustand.warnung}</p>
|
|
<label className="mt-2 flex items-center gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={trotzdem}
|
|
onChange={(ereignis) => setTrotzdem(ereignis.target.checked)}
|
|
/>
|
|
Bewusst ohne Bestellungsabsatz speichern
|
|
</label>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{zustand.erfolg && (
|
|
<Alert variant="erfolg">
|
|
<CheckCircle2 aria-hidden="true" />
|
|
<AlertDescription>{zustand.erfolg}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<Feld id="name" beschriftung="Name der Vorlage" fehler={zustand.felder?.name} pflicht>
|
|
<Input
|
|
name="name"
|
|
value={name}
|
|
onChange={(ereignis) => onName(ereignis.target.value)}
|
|
maxLength={120}
|
|
placeholder="z. B. Auskunftsersuchen Krankenkasse"
|
|
/>
|
|
</Feld>
|
|
|
|
<Feld
|
|
id="betreff"
|
|
beschriftung="Betreff"
|
|
erklaerung="Darf Platzhalter enthalten. Wird beim Erzeugen zum Betreff des Schreibens."
|
|
>
|
|
<Input
|
|
name="betreff"
|
|
value={betreff}
|
|
onChange={(ereignis) => onBetreff(ereignis.target.value)}
|
|
maxLength={200}
|
|
/>
|
|
</Feld>
|
|
|
|
<Feld id="text" beschriftung="Text der Vorlage" fehler={zustand.felder?.text} pflicht>
|
|
<Textarea
|
|
ref={textfeld}
|
|
name="text"
|
|
value={text}
|
|
onChange={(ereignis) => onText(ereignis.target.value)}
|
|
rows={18}
|
|
className="font-mono text-sm leading-relaxed"
|
|
/>
|
|
</Feld>
|
|
|
|
<div className="flex gap-2">
|
|
<Absendeknopf wartetext="Wird gespeichert …">Speichern</Absendeknopf>
|
|
<Button type="button" variant="ghost" onClick={onSchliessen}>
|
|
Schließen
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Die Platzhalterliste - niemand soll Namen raten muessen. */}
|
|
<aside className="space-y-2 rounded-md border p-3">
|
|
<p className="text-sm font-medium">Platzhalter</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Klick fügt an der Schreibmarke ein. Fehlt beim Erzeugen der Wert, bleibt eine
|
|
sichtbare Lücke — nichts verschwindet still.
|
|
</p>
|
|
<ul className="space-y-1">
|
|
{PLATZHALTER.map((platzhalter) => (
|
|
<li key={platzhalter.name}>
|
|
<button
|
|
type="button"
|
|
onClick={() => platzhalterEinfuegen(platzhalter.name)}
|
|
className="w-full rounded px-2 py-1 text-left text-xs hover:bg-accent"
|
|
title={platzhalter.quelle}
|
|
>
|
|
<code className="text-primary">{'{{'}{platzhalter.name}{'}}'}</code>
|
|
<span className="block text-muted-foreground">
|
|
{platzhalter.beschriftung}
|
|
</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</aside>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|