syncova-policies/components/print-policy.tsx
Weapie 3633e8a4cf
All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 1m33s
Responsiv machen und Barrierefreiheit nach WCAG 2.1 AA herstellen
Rechtsrahmen: § 13 Abs. 3 LBGG SH i. V. m. §§ 3 und 4 BITV 2.0. Eine
Erklaerung "nicht barrierefrei" ersetzt die Herstellungspflicht nicht,
sie dokumentiert den Stand nur voruebergehend.

Farben, alle Werte gegen die WCAG-Formel nachgerechnet:
- Sekundaertext 4.40:1 auf muted-Flaechen -> 4.55:1
- Rahmen von Eingabefeldern 1.24:1 -> 3:1 (WCAG 1.4.11); dekorative
  Trenner bleiben unveraendert
- Badge-Farben scheiterten auf der eigenen 10-Prozent-Flaeche
  (brand 4.20, success 4.18, destructive 3.88) -> jeweils ueber 4.5
- Warnfarbe im Admin 3.11:1 -> amber-700 mit 4.90:1

Fokus war faktisch unsichtbar: Der Ring wurde durchgaengig mit halber
Deckkraft gezeichnet (ring-ring/50), effektiv rund 1.5:1. Jetzt volle
Deckkraft in Markenfarbe, 12 Dateien betroffen.

Weiter: Sprungmarke zum Inhalt auf allen Seiten, Suchfelder beschriftet,
Druck-Schaltflaechen hatten als reine Icon-Buttons gar keinen
zugaenglichen Namen, Liste als echte Liste ausgezeichnet, doppelter Link
je Zeile entfernt, Trefferzahl als Live-Region.

Responsiv: Aussenabstaende und Kopfzeilen ab 320 Pixel, Listenzeilen
brechen um.

Neu nach § 4 BITV 2.0: Seiten fuer Leichte Sprache und Gebaerdensprache,
im Admin pflegbar. Leer gelassen und damit noch nicht online - die
Inhalte kommen vom Amt. In der Erklaerung als bekannte Luecke benannt.

Geprueft mit axe-core (WCAG 2.1 A und AA) ueber alle oeffentlichen Seiten
und den Adminbereich, hell und dunkel, dazu Tastaturbedienung, 200
Prozent Vergroesserung und 320 Pixel Breite: keine Verstoesse, kein
waagerechter Ueberlauf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:44:30 +02:00

237 lines
8.0 KiB
TypeScript
Raw 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 { Printer } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { useAppName } from "@/components/providers/app-config-provider"
import { PrivacyPolicy } from "@/types/privacy-policy"
import { escapeHtml, markdownToHtmlSync } from "@/lib/markdown"
interface PrintPolicyProps {
policy: PrivacyPolicy
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
size?: "default" | "sm" | "lg" | "icon" | "icon-sm"
label?: string
className?: string
}
const iconSizes = ["icon", "icon-sm"]
export function PrintPolicy({
policy,
variant = "outline",
size = "sm",
label = "Drucken",
className,
}: PrintPolicyProps) {
const appName = useAppName()
const handlePrint = () => {
const printWindow = window.open("", "_blank")
if (!printWindow) return
printWindow.document.write(generatePrintContent(policy, appName))
printWindow.document.close()
}
const isIconOnly = iconSizes.includes(size)
const button = (
<Button variant={variant} size={size} onClick={handlePrint} className={className}>
<Printer aria-hidden="true" />
{isIconOnly ? (
// Ein Tooltip ist kein zugänglicher Name – ohne diesen Text wäre die
// Schaltfläche für Screenreader namenlos. Der Titel steht mit drin,
// weil in der Liste mehrere gleich aussehende Schaltflächen stehen.
<span className="sr-only">{`${label}: ${policy.title}`}</span>
) : (
label
)}
</Button>
)
if (!isIconOnly) return button
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>Als PDF drucken</TooltipContent>
</Tooltip>
)
}
function generatePrintContent(policy: PrivacyPolicy, appName: string): string {
const formatDate = (dateString: string) =>
new Date(dateString).toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
})
const cleanMarkdown = (content: string) => markdownToHtmlSync(content)
// Art. 21 Abs. 4 verlangt den Hinweis auf das Widerspruchsrecht „von anderen
// Informationen getrennt“ – in der Druckfassung als eigener, gerahmter Block.
const objectionNotice = (content?: string | null) =>
content
? `<section class="objection">
<h2 class="objection-title">Ihr Widerspruchsrecht nach Art. 21 DSGVO</h2>
<div class="section-content">${cleanMarkdown(content)}</div>
</section>`
: ""
const section = (title: string, content?: string | null) =>
content
? `<section class="section">
<h2 class="section-title">${title}</h2>
<div class="section-content">${cleanMarkdown(content)}</div>
</section>`
: ""
return `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(policy.title)}</title>
<style>
@page { margin: 2cm 1.8cm; size: A4; }
* { box-sizing: border-box; }
body {
font-family: -apple-system, "Segoe UI", Arial, sans-serif;
font-size: 10pt;
line-height: 1.5;
color: #18181b;
margin: 0;
}
.header {
border-bottom: 1px solid #d4d4d8;
padding-bottom: 10px;
margin-bottom: 18px;
page-break-after: avoid;
}
.eyebrow {
font-size: 7.5pt;
letter-spacing: .08em;
text-transform: uppercase;
color: #71717a;
margin: 0 0 4px 0;
}
.header h1 {
margin: 0 0 4px 0;
font-size: 17pt;
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.2;
}
.meta-info { font-size: 8pt; color: #71717a; margin: 0; }
.section { margin-bottom: 14px; page-break-inside: avoid; orphans: 3; widows: 3; }
.objection {
margin: 16px 0;
padding: 10px 12px;
border: 1.5pt solid #52525b;
border-radius: 3px;
page-break-inside: avoid;
}
.objection-title {
font-size: 10pt;
font-weight: 700;
margin: 0 0 5px 0;
page-break-after: avoid;
}
.section-title {
font-size: 9pt;
font-weight: 600;
letter-spacing: .04em;
text-transform: uppercase;
color: #52525b;
margin: 0 0 5px 0;
padding-bottom: 3px;
border-bottom: 1px solid #e4e4e7;
page-break-after: avoid;
}
.section-content { font-size: 9.5pt; }
.section-content > :first-child { margin-top: 0; }
.section-content > :last-child { margin-bottom: 0; }
.section-content p { margin: 0 0 5px 0; }
.section-content h1, .section-content h2, .section-content h3 {
font-size: 9.5pt; font-weight: 600; margin: 8px 0 3px 0;
}
.section-content strong { font-weight: 600; }
.section-content code {
background: #f4f4f5; padding: 1px 3px; border-radius: 2px;
font-family: ui-monospace, "Courier New", monospace; font-size: 8.5pt;
}
.section-content ul, .section-content ol { margin: 4px 0; padding-left: 16px; }
.section-content li { margin: 2px 0; }
.section-content a { color: #2563eb; text-decoration: none; }
.footer {
border-top: 1px solid #e4e4e7;
margin-top: 24px;
padding-top: 8px;
font-size: 7.5pt;
color: #a1a1aa;
display: flex;
justify-content: space-between;
page-break-inside: avoid;
}
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
</style>
</head>
<body>
<header class="header">
<p class="eyebrow">${escapeHtml(appName)} · Datenschutzerklärung</p>
<h1>${escapeHtml(policy.title)}</h1>
<p class="meta-info">
Erstellt am ${formatDate(policy.createdAt)}${
policy.updatedAt !== policy.createdAt
? ` · Aktualisiert am ${formatDate(policy.updatedAt)}`
: ""
}
</p>
</header>
${section("Verantwortlicher im Sinne der DSGVO", policy.responsible)}
${section("Kontaktdaten des Datenschutzbeauftragten", policy.contactDPO)}
${section("Bezeichnung der Verarbeitungstätigkeit", policy.processingDescription)}
${section("Zweck der Datenerhebung", policy.processingPurposes)}
${section("Rechtsgrundlage der Datenerhebung", policy.legalBasis)}
${section("Berechtigte Interessen", policy.legitimateInterests)}
${section("Kategorien der verarbeiteten Daten", policy.dataCategories)}
${section("Geplante Empfänger der Daten", policy.recipients)}
${section("Übermittlung in Drittländer", policy.thirdCountryTransfer)}
${section("Speicherdauer der Daten", policy.storageDuration)}
${section("Herkunft der Daten", policy.dataSource)}
${section("Automatisierte Entscheidungsfindung", policy.automatedDecision)}
${section("Betroffenen-Rechte", policy.dataSubjectsRights)}
${section("Beschwerderecht bei der Aufsichtsbehörde", policy.complaintRight)}
${section("Widerrufsrecht bei Einwilligung", policy.withdrawalRight)}
${objectionNotice(policy.objectionRight)}
${section("Ihre Pflicht zur Bereitstellung der Daten", policy.provisionObligation)}
${section("Folgen, wenn Sie die Daten nicht angeben", policy.provisionConsequences)}
<footer class="footer">
<span>${escapeHtml(policy.title)}</span>
<span>Gedruckt am ${new Date().toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}</span>
</footer>
<script>
window.onload = function () {
setTimeout(function () { window.print(); }, 250);
};
</script>
</body>
</html>
`
}