All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 1m23s
Die öffentliche API gab bei ?admin=true alle Datensätze heraus, auch die nicht veröffentlichten: von 117 Erklärungen waren 112 Entwürfe, die jeder Besucher abrufen konnte. Der Parameter setzt jetzt eine Admin-Sitzung voraus, sonst kommt die öffentliche Liste. Der Einzelabruf behandelt Entwürfe wie nicht vorhanden, damit sich über die fortlaufende ID nicht abklopfen lässt, welche Datensätze existieren. Markdown wurde mit sanitize: false gerendert und per dangerouslySetInnerHTML eingesetzt. Das Standard-Schema lässt jetzt Überschriften, Listen, Tabellen und http(s)/mailto-Links durch und entfernt Skripte, Event-Attribute, eingebettete Rahmen und javascript:-Ziele. Der Titel in der Druckfassung war an drei Stellen unmaskiert. Standardtexte - Zehn Bausteine, die für alle Erklärungen gelten, werden einmal zentral gepflegt. In der Erklärung steht nur eine Abweichung; ein leeres Feld bedeutet "folgt dem Standard". Ein Wechsel in der Amtsleitung wirkt damit sofort auf alle Erklärungen, die nicht bewusst abweichen. - Die Migration übernimmt den häufigsten Bestandswert als Standard und setzt übereinstimmende Felder auf NULL. Von 117 Erklärungen folgen danach 116 dem Standard, eine weicht ab (Online-Verfahren mit ZIT-SH als zusätzlichem Verantwortlichen). Pflichtangaben nach Art. 13/14 DSGVO - Neue Felder für Drittlandübermittlung (Art. 13 Abs. 1 lit. f), automatisierte Entscheidungsfindung (Art. 13 Abs. 2 lit. f), Datenkategorien (Art. 14 Abs. 1 lit. d) und berechtigte Interessen (Art. 13 Abs. 1 lit. d). Auf die ersten beiden muss auch dann hingewiesen werden, wenn es sie nicht gibt, deshalb sind sie Standardtexte mit ausdrücklichem "findet nicht statt". - Der Hinweis auf das Widerspruchsrecht steht nach Art. 21 Abs. 4 in einem eigenen, abgesetzten Kasten vor allen Abschnitten, nicht als Zeile im Fließtext der Betroffenenrechte. - Zweck, Rechtsgrundlage und Speicherdauer sind Pflicht. Geprüft wird beim Veröffentlichen, nicht beim Speichern: Ein Entwurf darf unvollständig sein, eine öffentlich sichtbare Erklärung nicht. So bleiben Bestandsdaten bearbeitbar, ohne dass die Anwendung Angaben erfindet, die nur die Fachabteilung kennt. Die Admin-Übersicht markiert unvollständige Erklärungen und nennt die fehlenden Angaben. Öffentliche Seiten - Jede Erklärung hat eine dauerhafte Adresse /erklaerung/<bezeichner>, serverseitig gerendert, zum Verlinken aus Antragsformularen. Der Bezeichner wird aus dem Titel abgeleitet und bleibt beim Bearbeiten erhalten, solange der Titel gleich bleibt. Entwürfe liefern 404. - Impressum, Datenschutzerklärung des Portals und Erklärung zur Barrierefreiheit werden im Admin als Markdown gepflegt und in der Fußzeile verlinkt. Die Fußzeile rendert serverseitig, weil das Impressum ohne JavaScript erreichbar sein muss. Ein leerer Text nimmt die Seite vom Netz, statt eine leere Seite auszuliefern. - Die Übersicht nutzt echte Links statt Klick-Handler und wird serverseitig vorbefüllt. APP_COMPANY_NAME ersetzt den Namen der Anwendung in Kopf- und Fußzeile, Seitentiteln, Anmeldeseite, Druckfassung und Startmeldung des Containers. Der Wert wird zur Laufzeit gelesen, ein Neubau des Images ist zum Umbenennen nicht nötig. Getestet gegen die 117 Bestandsdatensätze: Migrationen angewendet, der angezeigte Text blieb bei allen 117 x 7 Baustein-Feldern identisch. Alle 117 Bezeichner sind eindeutig und stimmen mit lib/slug.ts überein (SQLites LOWER() kennt nur ASCII, Großumlaute werden deshalb vorher ersetzt). ?admin=true liefert ohne Anmeldung 5 statt 117 Datensätze, Entwürfe 404. Über die API mit Anmeldung geprüft: unvollständig als Entwurf wird angelegt, dieselben Angaben mit isPublic abgelehnt, ebenso fehlende Datenkategorien bei angegebener Herkunft. Ohne APP_COMPANY_NAME erscheint "Syncova Policies", mit gesetztem Wert kein einziges Vorkommen mehr. Die Änderungen liegen in einem Commit, weil Schema, API-Routen und Formulare von allen Teilen berührt werden und eine thematische Aufteilung nicht lauffähige Zwischenstände ergäbe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
521 lines
20 KiB
TypeScript
521 lines
20 KiB
TypeScript
"use client"
|
||
|
||
import { useEffect, useState } from "react"
|
||
import { AlertCircle } from "lucide-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<DefaultedField, TabId> = {
|
||
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<FormData>(initialFormData)
|
||
const [errors, setErrors] = useState<string[]>([])
|
||
const [invalidFields, setInvalidFields] = useState<Set<string>>(new Set())
|
||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||
const [tab, setTab] = useState<TabId>("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 = <K extends keyof FormData>(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 (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="max-h-[88vh] sm:max-w-4xl">
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
{policy ? "Datenschutzerklärung bearbeiten" : "Neue Datenschutzerklärung"}
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
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.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4">
|
||
<Tabs
|
||
value={tab}
|
||
onValueChange={(value) => setTab(value as TabId)}
|
||
className="min-h-0 flex-1 gap-3"
|
||
>
|
||
<div className="flex items-center justify-between gap-4">
|
||
<TabsList>
|
||
<TabsTrigger value="base">Stammdaten</TabsTrigger>
|
||
<TabsTrigger value="processing">Verarbeitung</TabsTrigger>
|
||
<TabsTrigger value="rights">Rechte</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<Label htmlFor="isPublic" className="cursor-pointer text-muted-foreground">
|
||
Öffentlich sichtbar
|
||
</Label>
|
||
<Switch
|
||
id="isPublic"
|
||
checked={formData.isPublic}
|
||
onCheckedChange={(checked) => set("isPublic", checked)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{errors.length > 0 && (
|
||
<div className="flex gap-2.5 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
|
||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||
<ul className="space-y-1">
|
||
{errors.map((error, index) => (
|
||
<li key={index}>{error}</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
<DialogBody className="pt-1">
|
||
<TabsContent value="base" className="space-y-5">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="title" className="gap-1">
|
||
Titel der Datenschutzerklärung
|
||
<span className="text-destructive">*</span>
|
||
</Label>
|
||
<Input
|
||
id="title"
|
||
value={formData.title}
|
||
onChange={(e) => set("title", e.target.value)}
|
||
placeholder="z. B. Datenschutzerklärung für die Website"
|
||
aria-invalid={isInvalid("title")}
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid gap-5 md:grid-cols-2">
|
||
{(["responsible", "contactDPO"] as DefaultedField[]).map((field) => (
|
||
<DefaultableField
|
||
key={`${formKey}-${field}`}
|
||
id={field}
|
||
label={DEFAULTED_FIELD_LABELS[field]}
|
||
defaultValue={defaults[field]}
|
||
value={formData[field] ?? ""}
|
||
onChange={(value) => set(field, value)}
|
||
invalid={isInvalid(field)}
|
||
required
|
||
/>
|
||
))}
|
||
</div>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="processing" className="grid gap-5 md:grid-cols-2">
|
||
<MarkdownInput
|
||
id="processingDescription"
|
||
label="Bezeichnung der Verarbeitungstätigkeit"
|
||
value={formData.processingDescription}
|
||
onChange={(value) => set("processingDescription", value)}
|
||
placeholder={placeholders.processingDescription}
|
||
invalid={isInvalid("processingDescription")}
|
||
required
|
||
/>
|
||
<MarkdownInput
|
||
id="processingPurposes"
|
||
label="Zweck der Datenerhebung"
|
||
value={formData.processingPurposes || ""}
|
||
onChange={(value) => set("processingPurposes", value)}
|
||
placeholder={placeholders.processingPurposes}
|
||
invalid={isInvalid("processingPurposes")}
|
||
required
|
||
/>
|
||
<MarkdownInput
|
||
id="legalBasis"
|
||
label="Rechtsgrundlage der Datenerhebung"
|
||
value={formData.legalBasis || ""}
|
||
onChange={(value) => set("legalBasis", value)}
|
||
placeholder={placeholders.legalBasis}
|
||
invalid={isInvalid("legalBasis")}
|
||
required
|
||
/>
|
||
<MarkdownInput
|
||
id="legitimateInterests"
|
||
label="Berechtigte Interessen (nur bei Art. 6 Abs. 1 lit. f)"
|
||
value={formData.legitimateInterests || ""}
|
||
onChange={(value) => set("legitimateInterests", value)}
|
||
placeholder={placeholders.legitimateInterests}
|
||
rows={4}
|
||
/>
|
||
<MarkdownInput
|
||
id="recipients"
|
||
label="Geplante Empfänger der Daten"
|
||
value={formData.recipients}
|
||
onChange={(value) => set("recipients", value)}
|
||
placeholder={placeholders.recipients}
|
||
invalid={isInvalid("recipients")}
|
||
required
|
||
/>
|
||
<MarkdownInput
|
||
id="storageDuration"
|
||
label="Speicherdauer"
|
||
value={formData.storageDuration || ""}
|
||
onChange={(value) => set("storageDuration", value)}
|
||
placeholder={placeholders.storageDuration}
|
||
invalid={isInvalid("storageDuration")}
|
||
required
|
||
/>
|
||
<MarkdownInput
|
||
id="dataSource"
|
||
label="Herkunft der Daten (optional)"
|
||
value={formData.dataSource || ""}
|
||
onChange={(value) => set("dataSource", value)}
|
||
placeholder={placeholders.dataSource}
|
||
/>
|
||
<MarkdownInput
|
||
id="dataCategories"
|
||
label={
|
||
// Art. 14 greift nur, wenn die Daten woanders herkommen.
|
||
formData.dataSource?.trim()
|
||
? "Kategorien der verarbeiteten Daten"
|
||
: "Kategorien der verarbeiteten Daten (optional)"
|
||
}
|
||
value={formData.dataCategories || ""}
|
||
onChange={(value) => set("dataCategories", value)}
|
||
placeholder={placeholders.dataCategories}
|
||
invalid={isInvalid("dataCategories")}
|
||
required={!!formData.dataSource?.trim()}
|
||
/>
|
||
|
||
<div className="grid gap-5 md:col-span-2 md:grid-cols-2">
|
||
<p className="text-sm text-muted-foreground md:col-span-2">
|
||
Auf Drittländer und automatisierte Entscheidungen muss
|
||
ausdrücklich hingewiesen werden – auch dann, wenn es beides
|
||
nicht gibt. Beide Bausteine kommen aus den Standardtexten.
|
||
</p>
|
||
{(["thirdCountryTransfer", "automatedDecision"] as DefaultedField[]).map(
|
||
(field) => (
|
||
<DefaultableField
|
||
key={`${formKey}-${field}`}
|
||
id={field}
|
||
label={DEFAULTED_FIELD_LABELS[field]}
|
||
defaultValue={defaults[field]}
|
||
value={formData[field] ?? ""}
|
||
onChange={(value) => set(field, value)}
|
||
invalid={isInvalid(field)}
|
||
rows={5}
|
||
required
|
||
/>
|
||
)
|
||
)}
|
||
</div>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="rights" className="grid gap-5 md:grid-cols-2">
|
||
<DefaultableField
|
||
key={`${formKey}-dataSubjectsRights`}
|
||
id="dataSubjectsRights"
|
||
label={DEFAULTED_FIELD_LABELS.dataSubjectsRights}
|
||
defaultValue={defaults.dataSubjectsRights}
|
||
value={formData.dataSubjectsRights ?? ""}
|
||
onChange={(value) => set("dataSubjectsRights", value)}
|
||
invalid={isInvalid("dataSubjectsRights")}
|
||
rows={9}
|
||
required
|
||
/>
|
||
<div className="space-y-5">
|
||
{(
|
||
[
|
||
["complaintRight", 6, true],
|
||
["withdrawalRight", 6, true],
|
||
["objectionRight", 7, true],
|
||
["provisionObligation", 4, false],
|
||
["provisionConsequences", 4, false],
|
||
] as [DefaultedField, number, boolean][]
|
||
).map(([field, rows, required]) => (
|
||
<DefaultableField
|
||
key={`${formKey}-${field}`}
|
||
id={field}
|
||
label={DEFAULTED_FIELD_LABELS[field]}
|
||
defaultValue={defaults[field]}
|
||
value={formData[field] ?? ""}
|
||
onChange={(value) => set(field, value)}
|
||
invalid={isInvalid(field)}
|
||
rows={rows}
|
||
required={required}
|
||
/>
|
||
))}
|
||
</div>
|
||
</TabsContent>
|
||
</DialogBody>
|
||
</Tabs>
|
||
|
||
<DialogFooter className="border-t pt-4">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => onOpenChange(false)}
|
||
disabled={isSubmitting}
|
||
>
|
||
Abbrechen
|
||
</Button>
|
||
<Button type="submit" disabled={isSubmitting}>
|
||
{isSubmitting
|
||
? "Speichern…"
|
||
: policy
|
||
? "Änderungen speichern"
|
||
: "Erstellen"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|