"use client" import { FormErrors } from "@/components/form-errors" import { useEffect, useState } from "react" import { toast } from "sonner" import { Button } from "@/components/ui/button" import { Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Switch } from "@/components/ui/switch" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { MarkdownInput } from "@/components/markdown-input" import { DefaultableField } from "@/components/defaultable-field" import { createPolicy, updatePolicy } from "@/hooks/use-privacy-policies" import { usePolicyDefaults } from "@/hooks/use-policy-defaults" import { ApiError } from "@/lib/api" import { DEFAULTED_FIELD_LABELS, DefaultedField, REQUIRED_DEFAULTED_FIELDS, } from "@/lib/policy-defaults" import { FormData, PrivacyPolicy } from "@/types/privacy-policy" const initialFormData: FormData = { title: "", responsible: "", contactDPO: "", dataSubjectsRights: "", complaintRight: "", withdrawalRight: "", objectionRight: "", thirdCountryTransfer: "", automatedDecision: "", processingDescription: "", processingPurposes: "", legalBasis: "", legitimateInterests: "", recipients: "", storageDuration: "", dataSource: "", dataCategories: "", provisionObligation: "", provisionConsequences: "", isPublic: true, } type TabId = "base" | "processing" | "rights" /** * Felder, die diese Erklärung selbst füllen muss. Die Bausteine aus den * Standardtexten stehen bewusst nicht hier – sie werden gegen den Standard * geprüft, nicht gegen das Formular. */ const requiredFields: { field: keyof FormData; label: string; tab: TabId }[] = [ { field: "title", label: "Titel", tab: "base" }, { field: "processingDescription", label: "Verarbeitungstätigkeit", tab: "processing" }, { field: "recipients", label: "Empfänger", tab: "processing" }, ] /** * Angaben, die erst zur Veröffentlichung vorliegen müssen. Ein Entwurf darf sie * offen lassen – die Prüfung greift nur, wenn „Öffentlich sichtbar“ gesetzt ist. */ const publicOnlyFields: { field: keyof FormData; label: string; article: string; tab: TabId }[] = [ { field: "processingPurposes", label: "Zweck der Datenerhebung", article: "Art. 13 Abs. 1 lit. c", tab: "processing" }, { field: "legalBasis", label: "Rechtsgrundlage", article: "Art. 13 Abs. 1 lit. c", tab: "processing" }, { field: "storageDuration", label: "Speicherdauer", article: "Art. 13 Abs. 2 lit. a", tab: "processing" }, ] /** Reiter, auf dem der jeweilige Standard-Baustein steht. */ const defaultedFieldTab: Record = { responsible: "base", contactDPO: "base", dataSubjectsRights: "rights", complaintRight: "rights", withdrawalRight: "rights", objectionRight: "rights", thirdCountryTransfer: "processing", automatedDecision: "processing", provisionObligation: "rights", provisionConsequences: "rights", } const placeholders = { processingDescription: `**Website-Betrieb und Nutzerinteraktion** Verarbeitung von Nutzerdaten zum Betrieb der Website und zur Bereitstellung unserer Dienstleistungen.`, processingPurposes: `1. **Website-Betrieb** – Bereitstellung der Funktionalität 2. **Kommunikation** – Beantwortung von Anfragen 3. **Verbesserung** – Analyse der Nutzung`, legalBasis: `- **Art. 6 Abs. 1 lit. a** – *Einwilligung* - **Art. 6 Abs. 1 lit. b** – *Vertragserfüllung* - **Art. 6 Abs. 1 lit. f** – *Berechtigte Interessen*`, recipients: `### Interne Empfänger - Kundenservice, IT-Abteilung ### Externe Dienstleister - **Hosting:** [Anbieter] - **E-Mail:** [Service]`, storageDuration: `- **Verträge:** 10 Jahre nach Vertragsende - **Newsletter:** bis zum Widerruf - **Website-Logs:** 30 Tage`, legitimateInterests: `Nur auszufüllen, wenn die Verarbeitung auf **Art. 6 Abs. 1 lit. f** gestützt wird – dann ist das berechtigte Interesse zu benennen, z. B.: Sicherstellung des ordnungsgemäßen Dienstbetriebs`, dataCategories: `Welche Arten von Daten verarbeitet werden, z. B.: - **Stammdaten:** Name, Anschrift, Geburtsdatum - **Kontaktdaten:** Telefonnummer, E-Mail-Adresse - **Verfahrensdaten:** Aktenzeichen, Bescheiddaten`, dataSource: `- **Direkte Eingabe:** Kontaktformulare, Registrierung - **Website-Nutzung:** Cookies, Logs - **Öffentliche Quellen:** Handelsregister`, } interface PolicyFormProps { open: boolean onOpenChange: (open: boolean) => void policy: PrivacyPolicy | null } export function PolicyForm({ open, onOpenChange, policy }: PolicyFormProps) { const { defaults } = usePolicyDefaults() const [formData, setFormData] = useState(initialFormData) const [errors, setErrors] = useState([]) const [invalidFields, setInvalidFields] = useState>(new Set()) const [isSubmitting, setIsSubmitting] = useState(false) const [tab, setTab] = useState("base") // Zählt jedes Öffnen hoch und setzt darüber die Baustein-Felder zurück – // sonst behielten sie den Standard/Abweichung-Modus der zuletzt geöffneten // Erklärung bei. const [formKey, setFormKey] = useState(0) useEffect(() => { if (!open) return setErrors([]) setInvalidFields(new Set()) setTab("base") setFormKey((previous) => previous + 1) // Die API liefert die Bausteine bereits mit eingesetztem Standardtext. // Ins Formular gehört aber nur ein tatsächlich abweichender Text – welche // Felder das sind, steht in `overrides`. const overrideOf = (field: DefaultedField) => policy?.overrides?.includes(field) ? (policy[field] ?? "") : "" setFormData( policy ? { title: policy.title, responsible: overrideOf("responsible"), contactDPO: overrideOf("contactDPO"), dataSubjectsRights: overrideOf("dataSubjectsRights"), complaintRight: overrideOf("complaintRight"), withdrawalRight: overrideOf("withdrawalRight"), objectionRight: overrideOf("objectionRight"), thirdCountryTransfer: overrideOf("thirdCountryTransfer"), automatedDecision: overrideOf("automatedDecision"), processingDescription: policy.processingDescription, processingPurposes: policy.processingPurposes || "", legalBasis: policy.legalBasis || "", legitimateInterests: policy.legitimateInterests || "", recipients: policy.recipients, storageDuration: policy.storageDuration || "", dataSource: policy.dataSource || "", dataCategories: policy.dataCategories || "", provisionObligation: overrideOf("provisionObligation"), provisionConsequences: overrideOf("provisionConsequences"), isPublic: policy.isPublic, } : initialFormData ) }, [open, policy]) const set = (key: K, value: FormData[K]) => { setFormData((prev) => ({ ...prev, [key]: value })) setInvalidFields((prev) => { if (!prev.has(key as string)) return prev const next = new Set(prev) next.delete(key as string) return next }) } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() const gefuellt = (field: keyof FormData) => String(formData[field] ?? "").trim().length > 0 // Ohne diese Angaben lässt sich nicht einmal ein Entwurf speichern const missing = requiredFields.filter(({ field }) => !gefuellt(field)) // Alles Weitere wird erst zur Veröffentlichung verlangt const fehlendeMeldungen: string[] = missing.map( ({ label }) => `${label} ist erforderlich` ) const fehlendeFelder: string[] = missing.map(({ field }) => field as string) let ersterReiter: TabId | null = missing[0]?.tab ?? null if (formData.isPublic) { for (const { field, label, article, tab } of publicOnlyFields) { if (gefuellt(field)) continue fehlendeMeldungen.push(`${label} (${article}) – zum Veröffentlichen erforderlich`) fehlendeFelder.push(field as string) ersterReiter = ersterReiter ?? tab } // Art. 14 greift nur, wenn die Daten nicht bei der betroffenen Person // selbst erhoben wurden – erkennbar an der angegebenen Herkunft. if (gefuellt("dataSource") && !gefuellt("dataCategories")) { fehlendeMeldungen.push( "Kategorien der verarbeiteten Daten (Art. 14 Abs. 1 lit. d) – erforderlich, weil eine Herkunft der Daten angegeben ist" ) fehlendeFelder.push("dataCategories") ersterReiter = ersterReiter ?? "processing" } // Ein Baustein fehlt nur, wenn weder diese Erklärung noch der Standard // einen Text dafür hat. for (const field of REQUIRED_DEFAULTED_FIELDS) { if (gefuellt(field) || defaults[field].trim()) continue fehlendeMeldungen.push( `${DEFAULTED_FIELD_LABELS[field]}: kein Standardtext hinterlegt – unter „Standardtexte“ pflegen oder hier abweichen` ) fehlendeFelder.push(field) ersterReiter = ersterReiter ?? defaultedFieldTab[field] } } if (fehlendeMeldungen.length > 0) { setErrors(fehlendeMeldungen) setInvalidFields(new Set(fehlendeFelder)) setTab(ersterReiter ?? "base") return } setErrors([]) setInvalidFields(new Set()) setIsSubmitting(true) try { if (policy) { await updatePolicy(policy.id, formData) toast.success("Datenschutzerklärung aktualisiert") } else { await createPolicy(formData) toast.success("Datenschutzerklärung erstellt") } onOpenChange(false) } catch (error) { const details = error instanceof ApiError && error.details.length > 0 ? error.details : [error instanceof Error ? error.message : "Fehler beim Speichern"] setErrors(details) } finally { setIsSubmitting(false) } } const isInvalid = (field: keyof FormData) => invalidFields.has(field as string) return ( {policy ? "Datenschutzerklärung bearbeiten" : "Neue Datenschutzerklärung"} Alle Pflichtfelder (*) ausfüllen. Die Felder unterstützen Markdown. Als „Standard“ markierte Bausteine kommen aus den zentralen Standardtexten und lassen sich hier nur im Sonderfall abweichend festlegen.
setTab(value as TabId)} className="min-h-0 flex-1 gap-3" >
Stammdaten Verarbeitung Rechte
set("isPublic", checked)} />
set("title", e.target.value)} placeholder="z. B. Datenschutzerklärung für die Website" aria-invalid={isInvalid("title")} aria-required="true" aria-describedby={isInvalid("title") ? "title-error" : undefined} /> {isInvalid("title") &&

Bitte geben Sie einen Titel ein.

}
{(["responsible", "contactDPO"] as DefaultedField[]).map((field) => ( set(field, value)} invalid={isInvalid(field)} required /> ))}
set("processingDescription", value)} placeholder={placeholders.processingDescription} invalid={isInvalid("processingDescription")} required /> set("processingPurposes", value)} placeholder={placeholders.processingPurposes} invalid={isInvalid("processingPurposes")} required /> set("legalBasis", value)} placeholder={placeholders.legalBasis} invalid={isInvalid("legalBasis")} required /> set("legitimateInterests", value)} placeholder={placeholders.legitimateInterests} rows={4} /> set("recipients", value)} placeholder={placeholders.recipients} invalid={isInvalid("recipients")} required /> set("storageDuration", value)} placeholder={placeholders.storageDuration} invalid={isInvalid("storageDuration")} required /> set("dataSource", value)} placeholder={placeholders.dataSource} /> set("dataCategories", value)} placeholder={placeholders.dataCategories} invalid={isInvalid("dataCategories")} required={!!formData.dataSource?.trim()} />

Auf Drittländer und automatisierte Entscheidungen muss ausdrücklich hingewiesen werden – auch dann, wenn es beides nicht gibt. Beide Bausteine kommen aus den Standardtexten.

{(["thirdCountryTransfer", "automatedDecision"] as DefaultedField[]).map( (field) => ( set(field, value)} invalid={isInvalid(field)} rows={5} required /> ) )}
set("dataSubjectsRights", value)} invalid={isInvalid("dataSubjectsRights")} rows={9} required />
{( [ ["complaintRight", 6, true], ["withdrawalRight", 6, true], ["objectionRight", 7, true], ["provisionObligation", 4, false], ["provisionConsequences", 4, false], ] as [DefaultedField, number, boolean][] ).map(([field, rows, required]) => ( set(field, value)} invalid={isInvalid(field)} rows={rows} required={required} /> ))}
) }