Im Schreiben-Editor gibt es jetzt den Umschalter Brief/E-Mail. Die E-Mail-Fassung wird aus dem - womoeglich bearbeiteten - Brieftext abgeleitet: ohne Absenderblock, Anschrift und Datumszeile, aber mit dem Bestellungsabsatz. Betreff und Text einzeln kopierbar. Die offene Entscheidung aus dem Issue (mailto oder .eml) ist nach Rueckfrage mit BEIDES beantwortet: - mailto fuer kurze Texte. Der Knopf verschwindet, sobald der kodierte Link 1800 Zeichen uebersteigt - mailto uebergibt den Text in der Adresszeile, deren Laenge nirgends zugesichert ist, und ein halbierter Brief faellt erst beim Empfaenger auf. Die Grenze wird NACH der URL-Kodierung gerechnet; 900 Umlaute sind roh harmlos und kodiert weit drueber. - .eml immer: RFC 5322 mit X-Unsent: 1, quoted-printable fuer den Rumpf, RFC-2047-Kodierung fuer Umlaute im Betreff. Bewusst ohne From-Kopf - ein erfundener Absender wuerde je nach Programm uebernommen. Beide Knoepfe erscheinen nur mit E-Mail-Adresse (aus dem Kontakt uebernommen oder von Hand). Ohne Adresse bleibt das Kopieren. Die Ablage kennt den Unterschied: Eine E-Mail heisst in der Akte "E-Mail: ...", und abgelegt wird die E-Mail-Fassung samt Empfaengeradresse im Kopf der PDF - in der Akte soll stehen, was tatsaechlich hinausging. Sicherung in emailFassung(): Findet sich keine Anrede (frei umgebauter Text), wird NICHTS entfernt. Lieber ein Briefkopf zu viel in der Mail als stillschweigend verlorener Inhalt. Geprueft: 20 Einheitstests, vier End-to-End-Tests auf beiden Geraeteprofilen. 590 Unit-Tests, 81 e2e, null Typfehler. Refs #5
514 lines
18 KiB
TypeScript
514 lines
18 KiB
TypeScript
'use client'
|
||
|
||
import * as React from 'react'
|
||
import { useActionState } from 'react'
|
||
import Link from 'next/link'
|
||
import {
|
||
AlertCircle,
|
||
CheckCircle2,
|
||
Copy,
|
||
FileDown,
|
||
FolderInput,
|
||
Info,
|
||
Mail,
|
||
UserPlus,
|
||
} from 'lucide-react'
|
||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||
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 { Select } from '@/components/ui/select'
|
||
import { Feld } from '@/components/feld'
|
||
import { Absendeknopf } from '@/components/finanzen/eingaben'
|
||
import {
|
||
kontaktAusAnschrift,
|
||
schreibenAblegen,
|
||
type SchreibenZustand,
|
||
} from '@/server/actions/schreiben'
|
||
import type { SchreibenArt } from '@/lib/schreiben/arten'
|
||
import { emailFassung, mailtoLink, siehtAusWieEmail } from '@/lib/schreiben/email'
|
||
|
||
export type KontaktAuswahl = {
|
||
id: string
|
||
name: string
|
||
organisation: string | null
|
||
strasse: string | null
|
||
plz: string | null
|
||
ort: string | null
|
||
email: string | null
|
||
}
|
||
|
||
/**
|
||
* Schreiben bearbeiten, herunterladen, ablegen.
|
||
*
|
||
* Der Text steht in einem gewöhnlichen Textfeld und ist vollständig
|
||
* bearbeitbar. Das ist Absicht: Jeder Fall hat eine Besonderheit, die in
|
||
* den Brief gehört. Ein Formulartext, den man nicht mehr ändern kann,
|
||
* wird früher oder später falsch abgeschickt.
|
||
*/
|
||
export function SchreibenEditor({
|
||
betreuteId,
|
||
art,
|
||
entwurf,
|
||
kontakte,
|
||
vertragId,
|
||
vorgangId,
|
||
fristBegruendung,
|
||
warnungen,
|
||
}: {
|
||
betreuteId: string
|
||
art: SchreibenArt
|
||
entwurf: { betreff: string; text: string; empfaengerName: string; wiedervorlageAm: string }
|
||
kontakte: KontaktAuswahl[]
|
||
vertragId: string | null
|
||
vorgangId: string | null
|
||
fristBegruendung: string | null
|
||
warnungen: string[]
|
||
}) {
|
||
const [text, setText] = React.useState(entwurf.text)
|
||
const [betreff, setBetreff] = React.useState(entwurf.betreff)
|
||
const [empfaengerName, setEmpfaengerName] = React.useState(entwurf.empfaengerName)
|
||
const [empfaengerAnschrift, setEmpfaengerAnschrift] = React.useState('')
|
||
const [empfaengerEmail, setEmpfaengerEmail] = React.useState('')
|
||
const [versandweg, setVersandweg] = React.useState<'BRIEF' | 'EMAIL'>('BRIEF')
|
||
const [betreffKopiert, setBetreffKopiert] = React.useState(false)
|
||
const [mailKopiert, setMailKopiert] = React.useState(false)
|
||
const [versandAm, setVersandAm] = React.useState(new Date().toISOString().slice(0, 10))
|
||
const [wiedervorlageAm, setWiedervorlageAm] = React.useState(entwurf.wiedervorlageAm)
|
||
const [kopiert, setKopiert] = React.useState(false)
|
||
|
||
const [ablageZustand, ablegen] = useActionState<SchreibenZustand, FormData>(
|
||
schreibenAblegen,
|
||
{},
|
||
)
|
||
const [kontaktZustand, kontaktAnlegen] = useActionState<SchreibenZustand, FormData>(
|
||
kontaktAusAnschrift,
|
||
{},
|
||
)
|
||
|
||
/** Gemeinsame versteckte Felder für beide Formulare und den Download. */
|
||
function VersteckteFelder() {
|
||
return (
|
||
<>
|
||
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||
<input type="hidden" name="art" value={art} />
|
||
<input type="hidden" name="betreff" value={betreff} />
|
||
<input type="hidden" name="text" value={text} />
|
||
<input type="hidden" name="empfaengerName" value={empfaengerName} />
|
||
<input type="hidden" name="empfaengerAnschrift" value={empfaengerAnschrift} />
|
||
<input type="hidden" name="versandAm" value={versandAm} />
|
||
<input type="hidden" name="wiedervorlageAm" value={wiedervorlageAm} />
|
||
<input type="hidden" name="vertragId" value={vertragId ?? ''} />
|
||
<input type="hidden" name="vorgangId" value={vorgangId ?? ''} />
|
||
<input type="hidden" name="versandweg" value={versandweg} />
|
||
<input type="hidden" name="empfaengerEmail" value={empfaengerEmail} />
|
||
</>
|
||
)
|
||
}
|
||
|
||
function kontaktUebernehmen(id: string) {
|
||
const kontakt = kontakte.find((k) => k.id === id)
|
||
if (!kontakt) return
|
||
setEmpfaengerName(kontakt.organisation ?? kontakt.name)
|
||
setEmpfaengerAnschrift(
|
||
[kontakt.strasse, [kontakt.plz, kontakt.ort].filter(Boolean).join(' ')]
|
||
.filter(Boolean)
|
||
.join('\n'),
|
||
)
|
||
setEmpfaengerEmail(kontakt.email ?? '')
|
||
}
|
||
|
||
async function textKopieren() {
|
||
try {
|
||
await navigator.clipboard.writeText(text)
|
||
setKopiert(true)
|
||
window.setTimeout(() => setKopiert(false), 2500)
|
||
} catch {
|
||
/* Zwischenablage gesperrt - dann markiert man eben von Hand. */
|
||
}
|
||
}
|
||
|
||
const abgelegt = Boolean(ablageZustand.erfolg)
|
||
|
||
/**
|
||
* Die E-Mail-Fassung: Betreff und Rumpf getrennt, ohne Briefkopf.
|
||
*
|
||
* Abgeleitet aus dem AKTUELLEN Text im Feld - wer den Brief bearbeitet
|
||
* hat, bekommt die bearbeitete Fassung, nicht den Ursprungsentwurf.
|
||
*/
|
||
function EmailAusgabe() {
|
||
const fassung = emailFassung(text, betreff)
|
||
const adresseGueltig = siehtAusWieEmail(empfaengerEmail)
|
||
const mailto = adresseGueltig ? mailtoLink(empfaengerEmail, fassung) : null
|
||
|
||
async function kopieren(inhalt: string, merker: (wert: boolean) => void) {
|
||
try {
|
||
await navigator.clipboard.writeText(inhalt)
|
||
merker(true)
|
||
window.setTimeout(() => merker(false), 2500)
|
||
} catch {
|
||
/* Zwischenablage gesperrt - dann markiert man eben von Hand. */
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4 rounded-md border p-4">
|
||
<Feld
|
||
id="emailBetreff"
|
||
beschriftung="Betreff der E-Mail"
|
||
erklaerung="Aus der Betreffzeile des Briefes übernommen."
|
||
>
|
||
<div className="flex gap-2">
|
||
<Input id="emailBetreff" value={fassung.betreff} readOnly className="flex-1" />
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => void kopieren(fassung.betreff, setBetreffKopiert)}
|
||
>
|
||
<Copy aria-hidden="true" />
|
||
{betreffKopiert ? 'Kopiert' : 'Kopieren'}
|
||
</Button>
|
||
</div>
|
||
</Feld>
|
||
|
||
<Feld
|
||
id="emailText"
|
||
beschriftung="Text der E-Mail"
|
||
erklaerung="Ohne Absenderblock, Anschrift und Datumszeile — die gehören in den Brief, nicht in die Mail. Der Nachweis der Bestellung bleibt drin."
|
||
>
|
||
<div className="space-y-2">
|
||
<Textarea
|
||
id="emailText"
|
||
value={fassung.text}
|
||
readOnly
|
||
rows={14}
|
||
className="font-mono text-sm leading-relaxed"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => void kopieren(fassung.text, setMailKopiert)}
|
||
>
|
||
<Copy aria-hidden="true" />
|
||
{mailKopiert ? 'Kopiert' : 'Text kopieren'}
|
||
</Button>
|
||
</div>
|
||
</Feld>
|
||
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{mailto && (
|
||
<Button asChild variant="outline">
|
||
<a href={mailto}>
|
||
<Mail aria-hidden="true" />
|
||
Im Mailprogramm öffnen
|
||
</a>
|
||
</Button>
|
||
)}
|
||
|
||
{adresseGueltig && (
|
||
<form action="/schreiben/eml" method="post" className="inline">
|
||
<VersteckteFelder />
|
||
<Button type="submit" variant="outline">
|
||
<FileDown aria-hidden="true" />
|
||
Als E-Mail-Entwurf (.eml)
|
||
</Button>
|
||
</form>
|
||
)}
|
||
</div>
|
||
|
||
{!adresseGueltig && (
|
||
<p className="text-sm text-muted-foreground">
|
||
Trag oben eine E-Mail-Adresse ein, dann erscheinen hier die Knöpfe fürs
|
||
Mailprogramm. Kopieren geht auch ohne.
|
||
</p>
|
||
)}
|
||
|
||
{adresseGueltig && !mailto && (
|
||
<p className="text-sm text-muted-foreground">
|
||
Der Text ist zu lang für einen mailto-Link — manche Mailprogramme würden ihn
|
||
stillschweigend abschneiden. Nimm die .eml-Datei, sie trägt den vollen Text.
|
||
</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{warnungen.length > 0 && (
|
||
<Alert variant="warnung">
|
||
<AlertCircle aria-hidden="true" />
|
||
<AlertDescription>
|
||
<p className="font-medium">Vor dem Abschicken klären:</p>
|
||
<ul className="mt-1 list-disc space-y-1 pl-4">
|
||
{warnungen.map((warnung) => (
|
||
<li key={warnung}>{warnung}</li>
|
||
))}
|
||
</ul>
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{/* --- Empfänger ---------------------------------------------------- */}
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-base">An wen geht das Schreiben?</CardTitle>
|
||
<CardDescription>
|
||
Der Empfänger steht im Brieftext darunter — was du hier einträgst, landet am Dokument
|
||
in der Akte und im Dateinamen.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{kontakte.length > 0 && (
|
||
<Feld
|
||
id="kontaktAuswahl"
|
||
beschriftung="Aus den Kontakten übernehmen"
|
||
erklaerung="Spart das Abtippen, wenn die Stelle schon im System steht."
|
||
>
|
||
<Select
|
||
id="kontaktAuswahl"
|
||
defaultValue=""
|
||
onChange={(ereignis) => kontaktUebernehmen(ereignis.target.value)}
|
||
>
|
||
<option value="">– Kontakt wählen –</option>
|
||
{kontakte.map((kontakt) => (
|
||
<option key={kontakt.id} value={kontakt.id}>
|
||
{kontakt.organisation ?? kontakt.name}
|
||
{kontakt.ort ? ` · ${kontakt.ort}` : ''}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</Feld>
|
||
)}
|
||
|
||
<Feld id="empfaengerName" beschriftung="Name oder Gesellschaft" pflicht>
|
||
<Input
|
||
id="empfaengerName"
|
||
value={empfaengerName}
|
||
onChange={(ereignis) => setEmpfaengerName(ereignis.target.value)}
|
||
maxLength={200}
|
||
placeholder="z. B. Allianz Lebensversicherungs-AG"
|
||
/>
|
||
</Feld>
|
||
|
||
<Feld
|
||
id="empfaengerAnschrift"
|
||
beschriftung="Anschrift"
|
||
erklaerung="Straße, dann PLZ und Ort — je Zeile eine Angabe. Nur nötig, wenn du daraus einen Kontakt anlegen willst; in den Brieftext trägst du sie unten selbst ein."
|
||
>
|
||
<Textarea
|
||
id="empfaengerAnschrift"
|
||
value={empfaengerAnschrift}
|
||
onChange={(ereignis) => setEmpfaengerAnschrift(ereignis.target.value)}
|
||
rows={3}
|
||
maxLength={500}
|
||
/>
|
||
</Feld>
|
||
|
||
<Feld
|
||
id="empfaengerEmail"
|
||
beschriftung="E-Mail-Adresse"
|
||
erklaerung="Nur nötig, wenn das Schreiben als E-Mail hinausgehen soll. Wird aus dem gewählten Kontakt übernommen."
|
||
>
|
||
<Input
|
||
id="empfaengerEmail"
|
||
type="email"
|
||
value={empfaengerEmail}
|
||
onChange={(ereignis) => setEmpfaengerEmail(ereignis.target.value)}
|
||
maxLength={200}
|
||
placeholder="service@versicherung.de"
|
||
/>
|
||
</Feld>
|
||
|
||
{empfaengerName.trim() !== '' && empfaengerAnschrift.trim() !== '' && (
|
||
<form action={kontaktAnlegen}>
|
||
<VersteckteFelder />
|
||
{kontaktZustand.fehler && (
|
||
<Alert variant="fehler" className="mb-2">
|
||
<AlertCircle aria-hidden="true" />
|
||
<AlertDescription>{kontaktZustand.fehler}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
{kontaktZustand.erfolg && (
|
||
<Alert variant="erfolg" className="mb-2">
|
||
<CheckCircle2 aria-hidden="true" />
|
||
<AlertDescription>{kontaktZustand.erfolg}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
<Absendeknopf variant="outline" size="sm" wartetext="Wird angelegt …">
|
||
<UserPlus aria-hidden="true" />
|
||
Als Kontakt merken
|
||
</Absendeknopf>
|
||
</form>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* --- Der Brief ---------------------------------------------------- */}
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-base">Der Brief</CardTitle>
|
||
<CardDescription>
|
||
Vollständig bearbeitbar. Was hier steht, steht auch in der Word-Datei und in der
|
||
Fassung für die Akte.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<Feld
|
||
id="betreff"
|
||
beschriftung="Betreff"
|
||
erklaerung="Wird zum Titel des Dokuments in der Akte."
|
||
pflicht
|
||
>
|
||
<Input
|
||
id="betreff"
|
||
value={betreff}
|
||
onChange={(ereignis) => setBetreff(ereignis.target.value)}
|
||
maxLength={200}
|
||
/>
|
||
</Feld>
|
||
|
||
<Feld id="text" beschriftung="Brieftext" pflicht>
|
||
<Textarea
|
||
id="text"
|
||
value={text}
|
||
onChange={(ereignis) => setText(ereignis.target.value)}
|
||
rows={26}
|
||
className="font-mono text-sm leading-relaxed"
|
||
spellCheck
|
||
/>
|
||
</Feld>
|
||
|
||
<Button type="button" variant="ghost" size="sm" onClick={() => void textKopieren()}>
|
||
<Copy aria-hidden="true" />
|
||
{kopiert ? 'Kopiert' : 'Text kopieren'}
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* --- Ausgabe ------------------------------------------------------ */}
|
||
<Card>
|
||
<CardHeader className="pb-3">
|
||
<CardTitle className="text-base">Und jetzt?</CardTitle>
|
||
<CardDescription>
|
||
Wie geht es hinaus? Die Wahl bestimmt auch, wie das Schreiben in der Akte
|
||
gekennzeichnet wird.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-5">
|
||
<div className="flex gap-2" role="group" aria-label="Versandweg">
|
||
<Button
|
||
type="button"
|
||
variant={versandweg === 'BRIEF' ? 'default' : 'outline'}
|
||
onClick={() => setVersandweg('BRIEF')}
|
||
>
|
||
<FileDown aria-hidden="true" />
|
||
Als Brief
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant={versandweg === 'EMAIL' ? 'default' : 'outline'}
|
||
onClick={() => setVersandweg('EMAIL')}
|
||
>
|
||
<Mail aria-hidden="true" />
|
||
Als E-Mail
|
||
</Button>
|
||
</div>
|
||
|
||
{versandweg === 'EMAIL' && <EmailAusgabe />}
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<Feld
|
||
id="versandAm"
|
||
beschriftung="Versandt am"
|
||
erklaerung="Steht am Dokument in der Akte."
|
||
>
|
||
<Input
|
||
id="versandAm"
|
||
type="date"
|
||
value={versandAm}
|
||
onChange={(ereignis) => setVersandAm(ereignis.target.value)}
|
||
/>
|
||
</Feld>
|
||
|
||
<Feld
|
||
id="wiedervorlageAm"
|
||
beschriftung="Wiedervorlage am"
|
||
erklaerung={fristBegruendung ?? 'Leer lassen, wenn du keine Antwort erwartest.'}
|
||
>
|
||
<Input
|
||
id="wiedervorlageAm"
|
||
type="date"
|
||
value={wiedervorlageAm}
|
||
onChange={(ereignis) => setWiedervorlageAm(ereignis.target.value)}
|
||
/>
|
||
</Feld>
|
||
</div>
|
||
|
||
{versandweg === 'BRIEF' && (
|
||
<>
|
||
{/* Word-Datei: eigene Route, damit der Browser einen echten
|
||
Download bekommt. */}
|
||
<form action="/schreiben/docx" method="post" className="inline">
|
||
<VersteckteFelder />
|
||
<Button type="submit" variant="outline">
|
||
<FileDown aria-hidden="true" />
|
||
Als Word-Datei
|
||
</Button>
|
||
</form>
|
||
|
||
<Alert variant="hinweis">
|
||
<Info aria-hidden="true" />
|
||
<AlertDescription>
|
||
In die Akte kommt eine <strong>PDF-Fassung</strong> dieses Textes — sie ist der
|
||
Nachweis, was hinausgegangen ist, und lässt sich nicht mehr ändern. Die
|
||
Word-Datei ist die Arbeitsfassung zum Ausdrucken und Unterschreiben.{' '}
|
||
<strong>Änderst du sie danach noch in Word</strong>, weicht die Akte davon ab —
|
||
dann lieber erst dort fertig schreiben, den Text hierher zurückkopieren und dann
|
||
ablegen.
|
||
</AlertDescription>
|
||
</Alert>
|
||
</>
|
||
)}
|
||
|
||
{ablageZustand.fehler && (
|
||
<Alert variant="fehler">
|
||
<AlertCircle aria-hidden="true" />
|
||
<AlertDescription>{ablageZustand.fehler}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{abgelegt && (
|
||
<Alert variant="erfolg">
|
||
<CheckCircle2 aria-hidden="true" />
|
||
<AlertDescription>
|
||
<p className="font-medium">{ablageZustand.erfolg}</p>
|
||
{ablageZustand.meldung && (
|
||
<ul className="mt-1 list-disc space-y-1 pl-4">
|
||
{ablageZustand.meldung.map((zeile) => (
|
||
<li key={zeile}>{zeile}</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
<p className="mt-2">
|
||
<Link href="/dokumente?richtung=AUSGANG" className="underline">
|
||
Zu den ausgehenden Dokumenten
|
||
</Link>
|
||
</p>
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
<form action={ablegen}>
|
||
<VersteckteFelder />
|
||
<Absendeknopf disabled={abgelegt} wartetext="Wird abgelegt …">
|
||
<FolderInput aria-hidden="true" />
|
||
{abgelegt ? 'Liegt in der Akte' : 'In die Akte legen'}
|
||
</Absendeknopf>
|
||
</form>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|