Eigene Vorlagen fuer Anschreiben
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
This commit is contained in:
@@ -105,6 +105,14 @@ function eintragsarten(): Record<string, Eintragsart> {
|
||||
),
|
||||
Immobilie: einfach('Immobilie', 'Immobilie', 'Immobilien', 'IMMOBILIE', z.immobilie, 'bezeichnung'),
|
||||
Vertrag: einfach('Vertrag', 'Vertrag', 'Verträge', 'VERTRAEGE', z.vertrag, 'bezeichnung'),
|
||||
Schreibvorlage: einfach(
|
||||
'Schreibvorlage',
|
||||
'Vorlage',
|
||||
'Vorlagen',
|
||||
'DOKUMENTE',
|
||||
z.schreibvorlage,
|
||||
'name',
|
||||
),
|
||||
Kontakt: einfach('Kontakt', 'Kontakt', 'Kontakte', 'KONTAKTE', z.kontakt, 'name'),
|
||||
Kontaktprotokoll: einfach(
|
||||
'Kontaktprotokoll',
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { z } from 'zod'
|
||||
import { db, nichtGeloescht } from '@/lib/db'
|
||||
import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung'
|
||||
import { protokollieren, aenderungErmitteln } from '@/lib/audit'
|
||||
import { bestellungsabsatzFehlt } from '@/lib/schreiben/platzhalter'
|
||||
|
||||
/**
|
||||
* Server Actions fuer eigene Schreibvorlagen.
|
||||
*
|
||||
* Die Warnung beim fehlenden Bestellungsabsatz ist zweistufig: Der erste
|
||||
* Speicherversuch ohne ihn kommt mit einer Warnung zurueck; erst die
|
||||
* ausdrueckliche Bestaetigung ("ohneBestellungsabsatz") speichert. So
|
||||
* laesst sich der Absatz weglassen - aber nur wissentlich. Ohne den
|
||||
* Nachweis der Bestellung antwortet keine Versicherung, und das faellt
|
||||
* erst nach vier Wochen auf.
|
||||
*/
|
||||
|
||||
export type VorlagenZustand = {
|
||||
fehler?: string
|
||||
/** Die Bestellungsabsatz-Warnung - Speichern erst nach Bestaetigung. */
|
||||
warnung?: string
|
||||
erfolg?: string
|
||||
felder?: Record<string, string>
|
||||
}
|
||||
|
||||
function zugriffsfehler(ausnahme: unknown): VorlagenZustand | null {
|
||||
if (ausnahme instanceof ZugriffVerweigert) return { fehler: ausnahme.message }
|
||||
return null
|
||||
}
|
||||
|
||||
const vorlageSchema = z.object({
|
||||
betreuteId: z.string().min(1),
|
||||
vorlageId: z.string().trim().default(''),
|
||||
name: z.string().trim().min(1, 'Bitte einen Namen vergeben.').max(120),
|
||||
betreff: z.string().trim().max(200).default(''),
|
||||
text: z.string().trim().min(10, 'Der Text ist zu kurz für eine Vorlage.').max(50_000),
|
||||
ohneBestellungsabsatz: z.string().default(''),
|
||||
})
|
||||
|
||||
export async function vorlageSpeichern(
|
||||
_zustand: VorlagenZustand,
|
||||
daten: FormData,
|
||||
): Promise<VorlagenZustand> {
|
||||
const gelesen = vorlageSchema.safeParse({
|
||||
betreuteId: daten.get('betreuteId'),
|
||||
vorlageId: daten.get('vorlageId') ?? '',
|
||||
name: daten.get('name'),
|
||||
betreff: daten.get('betreff') ?? '',
|
||||
text: daten.get('text'),
|
||||
ohneBestellungsabsatz: daten.get('ohneBestellungsabsatz') ?? '',
|
||||
})
|
||||
|
||||
if (!gelesen.success) {
|
||||
const felder = gelesen.error.flatten().fieldErrors
|
||||
return {
|
||||
fehler: 'Bitte prüfe die Eingaben.',
|
||||
felder: Object.fromEntries(
|
||||
Object.entries(felder).map(([name, meldungen]) => [name, meldungen?.[0] ?? '']),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const werte = gelesen.data
|
||||
const bestaetigt = werte.ohneBestellungsabsatz === 'ja'
|
||||
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(werte.betreuteId, 'DOKUMENTE', 'schreiben')
|
||||
|
||||
// Die zweistufige Warnung: erst warnen, dann - bestaetigt - speichern.
|
||||
if (bestellungsabsatzFehlt(werte.text) && !bestaetigt) {
|
||||
return {
|
||||
warnung:
|
||||
'In dieser Vorlage fehlt der Bestellungsabsatz — der Nachweis, dass du zur Vertretung ' +
|
||||
'befugt bist. Ohne ihn antworten Versicherungen und Behörden mit „aus ' +
|
||||
'datenschutzrechtlichen Gründen keine Auskunft", und das fällt erst nach Wochen auf. ' +
|
||||
'Füge {{bestellungsabsatz}} ein — oder speichere bewusst ohne.',
|
||||
}
|
||||
}
|
||||
|
||||
if (werte.vorlageId) {
|
||||
const vorhanden = await db.schreibvorlage.findFirst({
|
||||
where: { id: werte.vorlageId, betreuteId: werte.betreuteId, ...nichtGeloescht },
|
||||
})
|
||||
if (!vorhanden) return { fehler: 'Diese Vorlage gibt es nicht (mehr).' }
|
||||
|
||||
const nachher = await db.schreibvorlage.update({
|
||||
where: { id: vorhanden.id },
|
||||
data: {
|
||||
name: werte.name,
|
||||
betreff: werte.betreff,
|
||||
text: werte.text,
|
||||
ohneBestellungsabsatz: bestaetigt,
|
||||
},
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GEAENDERT',
|
||||
entitaet: 'Schreibvorlage',
|
||||
entitaetId: nachher.id,
|
||||
bezeichnung: nachher.name,
|
||||
betreuteId: werte.betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
aenderung: aenderungErmitteln(
|
||||
{ name: vorhanden.name, betreff: vorhanden.betreff, text: vorhanden.text },
|
||||
{ name: nachher.name, betreff: nachher.betreff, text: nachher.text },
|
||||
),
|
||||
})
|
||||
} else {
|
||||
const neu = await db.schreibvorlage.create({
|
||||
data: {
|
||||
betreuteId: werte.betreuteId,
|
||||
name: werte.name,
|
||||
betreff: werte.betreff,
|
||||
text: werte.text,
|
||||
ohneBestellungsabsatz: bestaetigt,
|
||||
},
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'ERSTELLT',
|
||||
entitaet: 'Schreibvorlage',
|
||||
entitaetId: neu.id,
|
||||
bezeichnung: neu.name,
|
||||
betreuteId: werte.betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
}
|
||||
} catch (ausnahme) {
|
||||
const verweigert = zugriffsfehler(ausnahme)
|
||||
if (verweigert) return verweigert
|
||||
console.error('[vorlagen] Speichern fehlgeschlagen:', ausnahme)
|
||||
return { fehler: 'Die Vorlage konnte nicht gespeichert werden.' }
|
||||
}
|
||||
|
||||
revalidatePath('/einstellungen/vorlagen')
|
||||
return { erfolg: `„${werte.name}“ ist gespeichert.` }
|
||||
}
|
||||
|
||||
/** Papierkorb: geloeschtAm setzen, 30 Tage rueckholbar wie ueberall. */
|
||||
export async function vorlageLoeschen(
|
||||
_zustand: VorlagenZustand,
|
||||
daten: FormData,
|
||||
): Promise<VorlagenZustand> {
|
||||
const betreuteId = String(daten.get('betreuteId') ?? '')
|
||||
const vorlageId = String(daten.get('vorlageId') ?? '')
|
||||
|
||||
try {
|
||||
const { benutzer } = await erfordereZugang(betreuteId, 'DOKUMENTE', 'schreiben')
|
||||
|
||||
const vorlage = await db.schreibvorlage.findFirst({
|
||||
where: { id: vorlageId, betreuteId, ...nichtGeloescht },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
if (!vorlage) return { fehler: 'Diese Vorlage gibt es nicht (mehr).' }
|
||||
|
||||
await db.schreibvorlage.update({
|
||||
where: { id: vorlage.id },
|
||||
data: { geloeschtAm: new Date() },
|
||||
})
|
||||
|
||||
await protokollieren({
|
||||
aktion: 'GELOESCHT',
|
||||
entitaet: 'Schreibvorlage',
|
||||
entitaetId: vorlage.id,
|
||||
bezeichnung: vorlage.name,
|
||||
betreuteId,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
})
|
||||
} catch (ausnahme) {
|
||||
const verweigert = zugriffsfehler(ausnahme)
|
||||
if (verweigert) return verweigert
|
||||
console.error('[vorlagen] Löschen fehlgeschlagen:', ausnahme)
|
||||
return { fehler: 'Die Vorlage konnte nicht gelöscht werden.' }
|
||||
}
|
||||
|
||||
revalidatePath('/einstellungen/vorlagen')
|
||||
return { erfolg: 'In den Papierkorb gelegt — 30 Tage rückholbar.' }
|
||||
}
|
||||
Reference in New Issue
Block a user