All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 5m15s
240 lines
7.7 KiB
TypeScript
240 lines
7.7 KiB
TypeScript
"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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||
import { MarkdownInput } from "@/components/markdown-input"
|
||
import { updatePolicyDefaults, usePolicyDefaults } from "@/hooks/use-policy-defaults"
|
||
import { ApiError } from "@/lib/api"
|
||
import {
|
||
DEFAULTED_FIELDS,
|
||
DEFAULTED_FIELD_LABELS,
|
||
DefaultedField,
|
||
REQUIRED_DEFAULTED_FIELDS,
|
||
} from "@/lib/policy-defaults"
|
||
import { PolicyDefaults } from "@/types/privacy-policy"
|
||
|
||
/** Eingabewerte des Formulars – wie PolicyDefaults, aber ohne `updatedAt`. */
|
||
type DefaultsFormData = Omit<PolicyDefaults, "updatedAt">
|
||
|
||
const emptyForm: DefaultsFormData = {
|
||
responsible: "",
|
||
contactDPO: "",
|
||
dataSubjectsRights: "",
|
||
complaintRight: "",
|
||
withdrawalRight: "",
|
||
objectionRight: "",
|
||
thirdCountryTransfer: "",
|
||
automatedDecision: "",
|
||
provisionObligation: "",
|
||
provisionConsequences: "",
|
||
}
|
||
|
||
type TabId = "base" | "rights" | "processing"
|
||
|
||
/** Aufteilung der Bausteine auf die Reiter des Dialogs. */
|
||
const tabFields: Record<TabId, DefaultedField[]> = {
|
||
base: ["responsible", "contactDPO"],
|
||
processing: ["thirdCountryTransfer", "automatedDecision"],
|
||
rights: [
|
||
"dataSubjectsRights",
|
||
"complaintRight",
|
||
"withdrawalRight",
|
||
"objectionRight",
|
||
"provisionObligation",
|
||
"provisionConsequences",
|
||
],
|
||
}
|
||
|
||
/** Zeilenhöhe der Textfelder – die Rechtetexte sind deutlich länger. */
|
||
const fieldRows: Partial<Record<DefaultedField, number>> = {
|
||
dataSubjectsRights: 12,
|
||
complaintRight: 8,
|
||
withdrawalRight: 6,
|
||
objectionRight: 8,
|
||
thirdCountryTransfer: 6,
|
||
automatedDecision: 6,
|
||
provisionObligation: 4,
|
||
provisionConsequences: 4,
|
||
}
|
||
|
||
interface PolicyDefaultsFormProps {
|
||
open: boolean
|
||
onOpenChange: (open: boolean) => void
|
||
}
|
||
|
||
/**
|
||
* Pflegt die Standardtexte, die für alle Erklärungen gelten. Eine einzelne
|
||
* Erklärung kann jeden dieser Bausteine im Sonderfall überschreiben.
|
||
*/
|
||
export function PolicyDefaultsForm({ open, onOpenChange }: PolicyDefaultsFormProps) {
|
||
const { defaults, isLoading } = usePolicyDefaults()
|
||
|
||
const [formData, setFormData] = useState<DefaultsFormData>(emptyForm)
|
||
const [errors, setErrors] = useState<string[]>([])
|
||
const [invalidFields, setInvalidFields] = useState<Set<string>>(new Set())
|
||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||
const [tab, setTab] = useState<TabId>("base")
|
||
|
||
// Beim Öffnen den gespeicherten Stand laden
|
||
useEffect(() => {
|
||
if (!open || isLoading) return
|
||
|
||
setErrors([])
|
||
setInvalidFields(new Set())
|
||
setTab("base")
|
||
// Über die Feldliste statt Feld für Feld: Ein neuer Baustein im Schema
|
||
// landet damit automatisch auch im Formular.
|
||
setFormData(
|
||
Object.fromEntries(
|
||
DEFAULTED_FIELDS.map((key) => [key, defaults[key]])
|
||
) as DefaultsFormData
|
||
)
|
||
// `defaults` bewusst nicht als Abhängigkeit: sonst würden Eingaben beim
|
||
// Hintergrund-Refresh von SWR überschrieben.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [open, isLoading])
|
||
|
||
/** Setzt ein Feld und entfernt dessen Fehlermarkierung. */
|
||
const set = (key: DefaultedField, value: string) => {
|
||
setFormData((prev) => ({ ...prev, [key]: value }))
|
||
setInvalidFields((prev) => {
|
||
if (!prev.has(key)) return prev
|
||
const next = new Set(prev)
|
||
next.delete(key)
|
||
return next
|
||
})
|
||
}
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
|
||
// Pflicht-Bausteine dürfen zentral nicht leer sein
|
||
const missing = REQUIRED_DEFAULTED_FIELDS.filter(
|
||
(field) => !formData[field].trim()
|
||
)
|
||
|
||
if (missing.length > 0) {
|
||
setErrors(missing.map((field) => `${DEFAULTED_FIELD_LABELS[field]} ist erforderlich`))
|
||
setInvalidFields(new Set(missing))
|
||
const reiter = (Object.keys(tabFields) as TabId[]).find((id) =>
|
||
tabFields[id].includes(missing[0])
|
||
)
|
||
setTab(reiter ?? "base")
|
||
return
|
||
}
|
||
|
||
setErrors([])
|
||
setInvalidFields(new Set())
|
||
setIsSubmitting(true)
|
||
|
||
try {
|
||
await updatePolicyDefaults(formData)
|
||
toast.success("Standardtexte gespeichert")
|
||
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)
|
||
}
|
||
}
|
||
|
||
/** Rendert ein Baustein-Feld mit Beschriftung und Markdown-Vorschau. */
|
||
const field = (name: DefaultedField) => (
|
||
<MarkdownInput
|
||
key={name}
|
||
id={`default-${name}`}
|
||
label={DEFAULTED_FIELD_LABELS[name]}
|
||
value={formData[name]}
|
||
onChange={(value) => set(name, value)}
|
||
rows={fieldRows[name] ?? 6}
|
||
required={REQUIRED_DEFAULTED_FIELDS.includes(name)}
|
||
invalid={invalidFields.has(name)}
|
||
/>
|
||
)
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="max-h-[calc(100dvh-2rem)] sm:max-w-4xl">
|
||
<DialogHeader>
|
||
<DialogTitle>Standardtexte</DialogTitle>
|
||
<DialogDescription>
|
||
Diese Texte gelten für alle Datenschutzerklärungen, die keinen
|
||
eigenen Text hinterlegt haben. Eine Änderung wirkt sich sofort auf
|
||
alle diese Erklärungen aus.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<form aria-label="Bearbeitungsformular" 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"
|
||
>
|
||
<TabsList>
|
||
<TabsTrigger value="base">Stammdaten</TabsTrigger>
|
||
<TabsTrigger value="processing">Verarbeitung</TabsTrigger>
|
||
<TabsTrigger value="rights">Ihre Rechte</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<FormErrors errors={errors} />
|
||
|
||
<DialogBody className="pt-1">
|
||
<TabsContent value="base" className="grid gap-5 md:grid-cols-2">
|
||
{tabFields.base.map(field)}
|
||
</TabsContent>
|
||
|
||
<TabsContent value="processing" className="space-y-4">
|
||
<p className="text-sm text-muted-foreground">
|
||
Auf beides muss ausdrücklich hingewiesen werden – auch dann,
|
||
wenn es nicht stattfindet. Ein klares „findet nicht statt“ ist
|
||
die richtige Antwort, kein leeres Feld.
|
||
</p>
|
||
<div className="grid gap-5 md:grid-cols-2">
|
||
{tabFields.processing.map(field)}
|
||
</div>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="rights" className="grid gap-5 md:grid-cols-2">
|
||
<div className="space-y-5">{tabFields.rights.slice(0, 2).map(field)}</div>
|
||
<div className="space-y-5">{tabFields.rights.slice(2).map(field)}</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 || isLoading}>
|
||
{isSubmitting ? "Speichern…" : "Standardtexte speichern"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|