syncova-policies/components/policy-defaults-form.tsx
Weapie 265ca8ac38
All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 5m15s
Improve accessibility and access controls; verify production and database recovery
2026-09-14 11:44:41 +02:00

240 lines
7.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
)
}