feat(legal): echtes PDF-Download via jsPDF + Abschnitts-Sortierung/-Einfuegen
Some checks failed
Deploy Development / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 32s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / playwright-tests (push) Failing after 50s

- LegalPage und AdminLegalDocumentsPage: pdf.save() statt window.open/print
- AdminLegalDocumentsPage: Abschnitte per Pfeil-Buttons verschieben
- AdminLegalDocumentsPage: neuen Abschnitt an beliebiger Stelle einfuegen
- npm: jspdf installiert

version: 0.8.74
module: legal_documents 1.2.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-10 21:29:21 +02:00
parent 030eb41429
commit 456ead72b6
5 changed files with 317 additions and 326 deletions

View File

@ -1,11 +1,11 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.73"
APP_VERSION = "0.8.74"
BUILD_DATE = "2026-05-10"
DB_SCHEMA_VERSION = "20260510047"
MODULE_VERSIONS = {
"legal_documents": "1.1.0", # Als-Entwurf-kopieren: POST /api/admin/legal-documents/{id}/copy-as-draft
"legal_documents": "1.2.0", # jsPDF-Download auf LegalPage (oeffentlich) + Admin; Abschnitts-Sortierung/-Einfuegen
"auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm
"profiles": "1.7.0", # exercise_list_prefs JSONB (Standard Übungsfilter); Patch via ProfileUpdate + Json()
"tenant_context": "1.0.5", # Plattform-Admin: effective_club ohne Header aus Profil active_club_id wenn Verein existiert
@ -30,6 +30,14 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.74",
"date": "2026-05-10",
"changes": [
"Rechtstexte: echtes PDF-Download via jsPDF (pdf.save) statt Browser-Print-Dialog; LegalPage und AdminLegalDocumentsPage",
"Rechtstexte Admin: Abschnitts-Reihenfolge per Pfeil-Buttons aendern; neuen Abschnitt an beliebiger Stelle einfuegen",
],
},
{
"version": "0.8.73",
"date": "2026-05-10",

View File

@ -8,6 +8,7 @@
"preview": "vite preview"
},
"dependencies": {
"jspdf": "^4.2.1",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",

View File

@ -1,82 +1,112 @@
import { useState, useEffect, useCallback } from 'react'
import { FileText, Plus, Edit2, Archive, CheckCircle, Clock, Copy, Printer } from 'lucide-react'
import { jsPDF } from 'jspdf'
import { FileText, Plus, Edit2, Archive, CheckCircle, Clock, Copy, Download, ChevronUp, ChevronDown } from 'lucide-react'
import api from '../utils/api'
function escHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
// PDF generation
function printLegalDocument(doc) {
const STATUS_DE = { published: 'Veröffentlicht', draft: 'Entwurf', archived: 'Archiviert' }
function generateLegalPdf(doc) {
const pdf = new jsPDF({ format: 'a4', unit: 'mm' })
const marginL = 22
const marginR = 22
const marginTop = 28
const pageW = 210
const contentW = pageW - marginL - marginR
const bottomLimit = 277 // A4 297mm - 20mm bottom margin
let y = marginTop
const checkBreak = (need) => {
if (y + need > bottomLimit) {
pdf.addPage()
y = marginTop
}
}
// Title
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(20)
pdf.text(doc.title, marginL, y)
y += 10
// Meta line
const STATUS_DE = { published: 'Gültig seit', draft: 'Entwurf — Stand', archived: 'Archiviert — Stand' }
const dateStr = doc.published_at
? new Date(doc.published_at).toLocaleDateString('de-DE')
: new Date(doc.updated_at || doc.created_at).toLocaleDateString('de-DE')
const metaLine = doc.status === 'published'
? `Version ${doc.version} | Gültig seit ${dateStr}`
: `${STATUS_DE[doc.status] || doc.status} | Version ${doc.version} | Stand ${dateStr}`
const metaLine = `Version ${doc.version} | ${STATUS_DE[doc.status] || doc.status} ${dateStr}`
const sectionsHtml = (doc.content_sections || []).map(s => `
<h2>${escHtml(s.heading)}</h2>
<p>${escHtml(s.content).replace(/\n/g, '<br>')}</p>
`).join('')
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(10)
pdf.setTextColor(90, 90, 90)
pdf.text(metaLine, marginL, y)
y += 3
pdf.setDrawColor(0, 0, 0)
pdf.setLineWidth(0.4)
pdf.line(marginL, y, pageW - marginR, y)
y += 8
pdf.setTextColor(0, 0, 0)
const win = window.open('', '_blank')
win.document.write(`<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>${escHtml(doc.title)}</title>
<style>
body { font-family: Georgia, serif; max-width: 760px; margin: 40px auto; color: #111; line-height: 1.65; font-size: 14px; }
h1 { font-size: 1.7rem; margin: 0 0 0.25rem; }
h2 { font-size: 1rem; font-family: Arial, sans-serif; margin: 1.6rem 0 0.3rem; }
p { margin: 0; white-space: pre-wrap; }
.meta { color: #555; font-size: 0.88rem; padding-bottom: 1rem; border-bottom: 2px solid #111; margin-bottom: 1.5rem; }
.footer { margin-top: 3rem; padding-top: 0.75rem; border-top: 1px solid #ccc; font-size: 0.78rem; color: #777; text-align: center; }
@media print {
body { margin: 0; }
@page { margin: 20mm 22mm; }
// Sections
for (const section of (doc.content_sections || [])) {
checkBreak(14)
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(11)
pdf.text(section.heading || '', marginL, y)
y += 6
if (section.content) {
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(10)
const lines = pdf.splitTextToSize(section.content, contentW)
for (const line of lines) {
checkBreak(5)
pdf.text(line, marginL, y)
y += 5
}
}
</style>
</head>
<body>
<h1>${escHtml(doc.title)}</h1>
<div class="meta">${escHtml(metaLine)}</div>
${sectionsHtml}
<div class="footer">Shinkan Jinkendo &nbsp;|&nbsp; Exportiert am ${new Date().toLocaleDateString('de-DE')}</div>
<script>window.onload = function () { window.print(); };<\/script>
</body>
</html>`)
win.document.close()
y += 5
}
// Footer on every page
const total = pdf.getNumberOfPages()
for (let i = 1; i <= total; i++) {
pdf.setPage(i)
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(8)
pdf.setTextColor(150, 150, 150)
const fy = 289
pdf.text(
`Shinkan Jinkendo | Exportiert am ${new Date().toLocaleDateString('de-DE')}`,
marginL, fy
)
pdf.text(`Seite ${i} von ${total}`, pageW - marginR, fy, { align: 'right' })
pdf.setTextColor(0, 0, 0)
}
pdf.save(`${doc.document_type}_v${doc.version}.pdf`)
}
// Sub-components
const DOC_TYPES = [
{ key: 'impressum', label: 'Impressum', defaultTitle: 'Impressum' },
{ key: 'privacy_policy', label: 'Datenschutz', defaultTitle: 'Datenschutzerklärung' },
{ key: 'terms_of_use', label: 'Nutzungsbedingungen', defaultTitle: 'Nutzungsbedingungen' },
{ key: 'media_policy', label: 'Medienrichtlinie', defaultTitle: 'Medienrichtlinie' },
{ key: 'impressum', label: 'Impressum', defaultTitle: 'Impressum' },
{ key: 'privacy_policy', label: 'Datenschutz', defaultTitle: 'Datenschutzerklärung' },
{ key: 'terms_of_use', label: 'Nutzungsbedingungen', defaultTitle: 'Nutzungsbedingungen' },
{ key: 'media_policy', label: 'Medienrichtlinie', defaultTitle: 'Medienrichtlinie' },
]
const STATUS_LABELS = {
draft: { label: 'Entwurf', color: 'var(--text3)' },
draft: { label: 'Entwurf', color: 'var(--text3)' },
published: { label: 'Veröffentlicht', color: 'var(--accent)' },
archived: { label: 'Archiviert', color: 'var(--danger)' },
archived: { label: 'Archiviert', color: 'var(--danger)' },
}
function StatusBadge({ status }) {
const s = STATUS_LABELS[status] || { label: status, color: 'var(--text3)' }
return (
<span style={{
fontSize: '0.75rem',
fontWeight: 600,
color: s.color,
border: `1px solid ${s.color}`,
borderRadius: '4px',
padding: '1px 6px',
fontSize: '0.75rem', fontWeight: 600, color: s.color,
border: `1px solid ${s.color}`, borderRadius: '4px', padding: '1px 6px',
}}>
{s.label}
</span>
@ -84,84 +114,117 @@ function StatusBadge({ status }) {
}
function SectionEditor({ sections, onChange }) {
const addSection = () => onChange([...sections, { heading: '', content: '' }])
const removeSection = (i) => onChange(sections.filter((_, idx) => idx !== i))
const update = (i, field, val) => {
const next = sections.map((s, idx) => idx === i ? { ...s, [field]: val } : s)
const update = (i, field, val) =>
onChange(sections.map((s, idx) => idx === i ? { ...s, [field]: val } : s))
const remove = (i) => onChange(sections.filter((_, idx) => idx !== i))
const move = (i, dir) => {
const next = [...sections]
const j = i + dir
if (j < 0 || j >= next.length) return
;[next[i], next[j]] = [next[j], next[i]]
onChange(next)
}
// Insert empty section after index i (-1 = prepend)
const insertAfter = (i) => {
const next = [...sections]
next.splice(i + 1, 0, { heading: '', content: '' })
onChange(next)
}
const InsertButton = ({ afterIndex }) => (
<div style={{ display: 'flex', justifyContent: 'center', margin: '4px 0' }}>
<button
type="button"
onClick={() => insertAfter(afterIndex)}
title="Neuen Abschnitt hier einfügen"
style={{
background: 'none', border: '1px dashed var(--border)', borderRadius: '6px',
color: 'var(--text3)', cursor: 'pointer', fontSize: '0.78rem',
padding: '2px 16px', lineHeight: 1.8,
}}
>
+ Abschnitt hier einfügen
</button>
</div>
)
return (
<div>
<InsertButton afterIndex={-1} />
{sections.map((sec, i) => (
<div key={i} className="card" style={{ marginBottom: '0.75rem', padding: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.8rem', color: 'var(--text3)' }}>Abschnitt {i + 1}</span>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '0.75rem', padding: '2px 8px' }}
onClick={() => removeSection(i)}
>
Entfernen
</button>
</div>
<div className="form-row">
<label className="form-label">Überschrift</label>
<input
type="text"
className="form-input"
value={sec.heading}
onChange={e => update(i, 'heading', e.target.value)}
placeholder="Abschnittsüberschrift"
/>
</div>
<div className="form-row">
<label className="form-label">Inhalt</label>
<textarea
className="form-input"
rows={4}
value={sec.content}
onChange={e => update(i, 'content', e.target.value)}
placeholder="Textinhalt des Abschnitts"
/>
<div key={i}>
<div className="card" style={{ padding: '12px', marginBottom: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.8rem', color: 'var(--text3)', fontWeight: 600 }}>Abschnitt {i + 1}</span>
<div style={{ display: 'flex', gap: '4px' }}>
<button
type="button" onClick={() => move(i, -1)} disabled={i === 0}
title="Nach oben" className="btn btn-secondary"
style={{ padding: '2px 6px', opacity: i === 0 ? 0.3 : 1 }}
>
<ChevronUp size={13} />
</button>
<button
type="button" onClick={() => move(i, 1)} disabled={i === sections.length - 1}
title="Nach unten" className="btn btn-secondary"
style={{ padding: '2px 6px', opacity: i === sections.length - 1 ? 0.3 : 1 }}
>
<ChevronDown size={13} />
</button>
<button
type="button" onClick={() => remove(i)}
className="btn btn-secondary" style={{ fontSize: '0.75rem', padding: '2px 8px' }}
>
Entfernen
</button>
</div>
</div>
<div className="form-row">
<label className="form-label">Überschrift</label>
<input
type="text" className="form-input" value={sec.heading}
onChange={e => update(i, 'heading', e.target.value)}
placeholder="Abschnittsüberschrift"
/>
</div>
<div className="form-row">
<label className="form-label">Inhalt</label>
<textarea
className="form-input" rows={4} value={sec.content}
onChange={e => update(i, 'content', e.target.value)}
placeholder="Textinhalt des Abschnitts"
/>
</div>
</div>
<InsertButton afterIndex={i} />
</div>
))}
<button type="button" className="btn btn-secondary" onClick={addSection} style={{ width: '100%' }}>
+ Abschnitt hinzufügen
</button>
{sections.length === 0 && (
<p style={{ color: 'var(--text3)', fontSize: '0.88rem', textAlign: 'center', margin: '0.5rem 0' }}>
Noch keine Abschnitte. Klicke auf + Abschnitt hier einfügen".
</p>
)}
</div>
)
}
function DocTypeTab({ docType, active, onClick }) {
return (
<button
className={`btn ${active ? 'btn-primary' : 'btn-secondary'}`}
onClick={onClick}
style={{ minWidth: '130px' }}
>
{docType.label}
</button>
)
}
function DocumentRow({ doc, onPublish, onArchive, onEdit, onCopy, onDownload, onViewAudit }) {
const [downloading, setDownloading] = useState(false)
const handleDownload = async () => {
setDownloading(true)
try { await onDownload(doc) } finally { setDownloading(false) }
}
function DocumentRow({ doc, onPublish, onArchive, onEdit, onCopy, onPrint, onViewAudit }) {
return (
<div
className="card"
style={{
display: 'flex',
alignItems: 'center',
gap: '1rem',
padding: '12px 16px',
marginBottom: '0.5rem',
}}
>
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: '1rem', padding: '12px 16px', marginBottom: '0.5rem' }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, marginBottom: '2px' }}>
{doc.title} <span style={{ color: 'var(--text3)', fontWeight: 400, fontSize: '0.85rem' }}>v{doc.version}</span>
{doc.title}{' '}
<span style={{ color: 'var(--text3)', fontWeight: 400, fontSize: '0.85rem' }}>v{doc.version}</span>
</div>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<StatusBadge status={doc.status} />
@ -180,56 +243,32 @@ function DocumentRow({ doc, onPublish, onArchive, onEdit, onCopy, onPrint, onVie
<div style={{ display: 'flex', gap: '0.4rem', flexShrink: 0 }}>
{doc.status === 'draft' && (
<>
<button
className="btn btn-secondary"
style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onEdit(doc)}
title="Bearbeiten"
>
<button className="btn btn-secondary" style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onEdit(doc)} title="Bearbeiten">
<Edit2 size={13} />
</button>
<button
className="btn btn-primary"
style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onPublish(doc)}
title="Veröffentlichen"
>
<button className="btn btn-primary" style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onPublish(doc)} title="Veröffentlichen">
<CheckCircle size={13} />
</button>
</>
)}
{doc.status === 'published' && (
<button
className="btn btn-secondary"
style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onArchive(doc)}
title="Archivieren"
>
<button className="btn btn-secondary" style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onArchive(doc)} title="Archivieren">
<Archive size={13} />
</button>
)}
<button
className="btn btn-secondary"
style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onCopy(doc)}
title="Als neuen Entwurf kopieren"
>
<button className="btn btn-secondary" style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onCopy(doc)} title="Als neuen Entwurf kopieren">
<Copy size={13} />
</button>
<button
className="btn btn-secondary"
style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onPrint(doc)}
title="Als PDF drucken / exportieren"
>
<Printer size={13} />
<button className="btn btn-secondary" style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={handleDownload} disabled={downloading} title="Als PDF herunterladen">
{downloading ? '…' : <Download size={13} />}
</button>
<button
className="btn btn-secondary"
style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onViewAudit(doc)}
title="Änderungslog"
>
<button className="btn btn-secondary" style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onViewAudit(doc)} title="Änderungslog">
<Clock size={13} />
</button>
</div>
@ -283,47 +322,31 @@ function EditForm({ docType, editDoc, onSaved, onCancel }) {
<h3 style={{ marginBottom: '1rem' }}>
{editDoc ? 'Entwurf bearbeiten' : 'Neuen Entwurf erstellen'}
</h3>
{error && (
<div className="card" style={{ borderLeft: '4px solid var(--danger)', marginBottom: '1rem' }}>
<span style={{ color: 'var(--danger)' }}>{error}</span>
</div>
)}
<div className="form-row">
<label className="form-label">Titel *</label>
<input
type="text"
className="form-input"
value={title}
onChange={e => setTitle(e.target.value)}
required
/>
<input type="text" className="form-input" value={title}
onChange={e => setTitle(e.target.value)} required />
</div>
<div className="form-row">
<label className="form-label">Abschnitte</label>
<SectionEditor sections={sections} onChange={setSections} />
</div>
<div className="form-row">
<label className="form-label">Änderungsnotiz (optional)</label>
<input
type="text"
className="form-input"
value={changeNote}
<input type="text" className="form-input" value={changeNote}
onChange={e => setChangeNote(e.target.value)}
placeholder="z. B. Erste Version nach juristischer Prüfung"
/>
placeholder="z. B. Erste Version nach juristischer Prüfung" />
</div>
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.5rem' }}>
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !title.trim()}>
{saving ? 'Speichern…' : 'Entwurf speichern'}
</button>
<button className="btn btn-secondary" onClick={onCancel}>
Abbrechen
</button>
<button className="btn btn-secondary" onClick={onCancel}>Abbrechen</button>
</div>
</div>
)
@ -340,12 +363,7 @@ function AuditLog({ docId, onClose }) {
.finally(() => setLoading(false))
}, [docId])
const ACTION_LABELS = {
created: 'Erstellt',
updated: 'Bearbeitet',
published:'Veröffentlicht',
archived: 'Archiviert',
}
const ACTION_LABELS = { created: 'Erstellt', updated: 'Bearbeitet', published: 'Veröffentlicht', archived: 'Archiviert' }
return (
<div className="card" style={{ marginTop: '1rem' }}>
@ -355,37 +373,29 @@ function AuditLog({ docId, onClose }) {
Schließen
</button>
</div>
{loading ? (
<div className="spinner" />
) : entries.length === 0 ? (
{loading ? <div className="spinner" /> : entries.length === 0 ? (
<p style={{ color: 'var(--text3)' }}>Keine Einträge.</p>
) : (
entries.map(e => (
<div key={e.id} style={{ padding: '8px 0', borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<strong style={{ fontSize: '0.85rem' }}>{ACTION_LABELS[e.action] || e.action}</strong>
{e.previous_status && (
<span style={{ fontSize: '0.78rem', color: 'var(--text3)' }}>von {e.previous_status}</span>
)}
<span style={{ fontSize: '0.78rem', color: 'var(--text3)' }}>
{new Date(e.created_at).toLocaleString('de-DE')}
</span>
{e.changed_by_name && (
<span style={{ fontSize: '0.78rem', color: 'var(--text3)' }}>von {e.changed_by_name}</span>
)}
</div>
{e.change_note && (
<p style={{ margin: '2px 0 0', fontSize: '0.82rem', color: 'var(--text2)', fontStyle: 'italic' }}>
{e.change_note}
</p>
)}
) : entries.map(e => (
<div key={e.id} style={{ padding: '8px 0', borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<strong style={{ fontSize: '0.85rem' }}>{ACTION_LABELS[e.action] || e.action}</strong>
{e.previous_status && <span style={{ fontSize: '0.78rem', color: 'var(--text3)' }}>von {e.previous_status}</span>}
<span style={{ fontSize: '0.78rem', color: 'var(--text3)' }}>{new Date(e.created_at).toLocaleString('de-DE')}</span>
{e.changed_by_name && <span style={{ fontSize: '0.78rem', color: 'var(--text3)' }}>von {e.changed_by_name}</span>}
</div>
))
)}
{e.change_note && (
<p style={{ margin: '2px 0 0', fontSize: '0.82rem', color: 'var(--text2)', fontStyle: 'italic' }}>
{e.change_note}
</p>
)}
</div>
))}
</div>
)
}
// Main page
export default function AdminLegalDocumentsPage() {
const [activeType, setActiveType] = useState(DOC_TYPES[0].key)
const [documents, setDocuments] = useState([])
@ -415,9 +425,7 @@ export default function AdminLegalDocumentsPage() {
setConfirmPublish(null)
}, [load])
const handlePublish = async (doc) => {
setConfirmPublish(doc)
}
const handlePublish = (doc) => setConfirmPublish(doc)
const confirmAndPublish = async () => {
if (!confirmPublish) return
@ -425,61 +433,28 @@ export default function AdminLegalDocumentsPage() {
await api.publishLegalDocument(confirmPublish.id, null)
setConfirmPublish(null)
load()
} catch (e) {
alert('Fehler: ' + e.message)
}
} catch (e) { alert('Fehler: ' + e.message) }
}
const handleArchive = async (doc) => {
if (!confirm(`"${doc.title}" archivieren?`)) return
try {
await api.archiveLegalDocument(doc.id)
load()
} catch (e) {
alert('Fehler: ' + e.message)
}
try { await api.archiveLegalDocument(doc.id); load() }
catch (e) { alert('Fehler: ' + e.message) }
}
const handleEdit = (doc) => { setEditDoc(doc); setShowForm(true); setAuditDocId(null) }
const handleViewAudit = (doc) => { setAuditDocId(doc.id); setShowForm(false); setEditDoc(null) }
const handleNew = () => { setEditDoc(null); setShowForm(true); setAuditDocId(null) }
const handleSaved = () => { setShowForm(false); setEditDoc(null); load() }
const handleCopy = async (doc) => {
try {
await api.copyLegalDocumentAsDraft(doc.id)
load()
} catch (e) {
alert('Fehler: ' + e.message)
}
try { await api.copyLegalDocumentAsDraft(doc.id); load() }
catch (e) { alert('Fehler: ' + e.message) }
}
const handlePrint = async (doc) => {
try {
const full = await api.getLegalDocument(doc.id)
printLegalDocument(full)
} catch (e) {
alert('Fehler beim Laden des Dokuments: ' + e.message)
}
}
const handleEdit = (doc) => {
setEditDoc(doc)
setShowForm(true)
setAuditDocId(null)
}
const handleViewAudit = (doc) => {
setAuditDocId(doc.id)
setShowForm(false)
setEditDoc(null)
}
const handleNew = () => {
setEditDoc(null)
setShowForm(true)
setAuditDocId(null)
}
const handleSaved = () => {
setShowForm(false)
setEditDoc(null)
load()
const handleDownload = async (doc) => {
const full = await api.getLegalDocument(doc.id)
generateLegalPdf(full)
}
const publishedDoc = documents.find(d => d.status === 'published')
@ -495,7 +470,7 @@ export default function AdminLegalDocumentsPage() {
<div className="card" style={{ marginBottom: '1rem', padding: '12px', background: 'var(--surface)' }}>
<p style={{ margin: 0, fontSize: '0.88rem', color: 'var(--text2)' }}>
Hier können Superadmins versionierte Rechtstexte erstellen, bearbeiten und veröffentlichen.
Versionierte Rechtstexte erstellen, bearbeiten und veröffentlichen.
Pro Dokumententyp kann immer nur ein Text veröffentlicht sein.
</p>
</div>
@ -503,28 +478,20 @@ export default function AdminLegalDocumentsPage() {
{/* Tab-Leiste */}
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '1.25rem' }}>
{DOC_TYPES.map(dt => (
<DocTypeTab
<button
key={dt.key}
docType={dt}
active={activeType === dt.key}
className={`btn ${activeType === dt.key ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setActiveType(dt.key)}
/>
style={{ minWidth: '130px' }}
>
{dt.label}
</button>
))}
</div>
{/* Aktuell veröffentlicht */}
{publishedDoc && (
<div
className="card"
style={{
marginBottom: '1rem',
borderLeft: '3px solid var(--accent)',
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '10px 16px',
}}
>
<div className="card" style={{ marginBottom: '1rem', borderLeft: '3px solid var(--accent)', display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '10px 16px' }}>
<CheckCircle size={16} color="var(--accent)" />
<span style={{ fontSize: '0.88rem' }}>
<strong>Aktuell live:</strong> {publishedDoc.title} (v{publishedDoc.version}
@ -540,7 +507,6 @@ export default function AdminLegalDocumentsPage() {
</button>
</div>
{/* Fehler */}
{error && (
<div className="card" style={{ borderLeft: '4px solid var(--danger)', marginBottom: '1rem' }}>
<span style={{ color: 'var(--danger)' }}>{error}</span>
@ -564,9 +530,7 @@ export default function AdminLegalDocumentsPage() {
)}
{/* Dokumentenliste */}
{loading ? (
<div className="spinner" />
) : documents.length === 0 ? (
{loading ? <div className="spinner" /> : documents.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '2rem', color: 'var(--text3)' }}>
Noch keine Versionen für {activeDocType?.label}. Erstelle einen neuen Entwurf.
</div>
@ -579,13 +543,12 @@ export default function AdminLegalDocumentsPage() {
onArchive={handleArchive}
onEdit={handleEdit}
onCopy={handleCopy}
onPrint={handlePrint}
onDownload={handleDownload}
onViewAudit={handleViewAudit}
/>
))
)}
{/* Formular */}
{showForm && (
<EditForm
docType={activeDocType}
@ -595,7 +558,6 @@ export default function AdminLegalDocumentsPage() {
/>
)}
{/* Audit-Log */}
{auditDocId && (
<AuditLog docId={auditDocId} onClose={() => setAuditDocId(null)} />
)}

View File

@ -1,54 +1,74 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { jsPDF } from 'jspdf'
import api from '../utils/api'
function escHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function generateLegalPdf(doc) {
const pdf = new jsPDF({ format: 'a4', unit: 'mm' })
const marginL = 22, marginR = 22, marginTop = 28, pageW = 210
const contentW = pageW - marginL - marginR
const bottomLimit = 277
let y = marginTop
const checkBreak = (need) => {
if (y + need > bottomLimit) { pdf.addPage(); y = marginTop }
}
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(20)
pdf.text(doc.title, marginL, y)
y += 10
function printPublishedDocument(doc) {
const dateStr = doc.published_at
? new Date(doc.published_at).toLocaleDateString('de-DE')
: new Date(doc.updated_at).toLocaleDateString('de-DE')
const metaLine = `Version ${doc.version} | Gültig seit ${dateStr}`
: new Date(doc.updated_at || doc.created_at).toLocaleDateString('de-DE')
const metaLine = `Version ${doc.version} | Gueltig seit ${dateStr}`
const sectionsHtml = (doc.content_sections || []).map(s => `
<h2>${escHtml(s.heading)}</h2>
<p>${escHtml(s.content).replace(/\n/g, '<br>')}</p>
`).join('')
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(10)
pdf.setTextColor(90, 90, 90)
pdf.text(metaLine, marginL, y)
y += 3
pdf.setDrawColor(0, 0, 0)
pdf.setLineWidth(0.4)
pdf.line(marginL, y, pageW - marginR, y)
y += 8
pdf.setTextColor(0, 0, 0)
const win = window.open('', '_blank')
win.document.write(`<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>${escHtml(doc.title)}</title>
<style>
body { font-family: Georgia, serif; max-width: 760px; margin: 40px auto; color: #111; line-height: 1.65; font-size: 14px; }
h1 { font-size: 1.7rem; margin: 0 0 0.25rem; }
h2 { font-size: 1rem; font-family: Arial, sans-serif; margin: 1.6rem 0 0.3rem; }
p { margin: 0; white-space: pre-wrap; }
.meta { color: #555; font-size: 0.88rem; padding-bottom: 1rem; border-bottom: 2px solid #111; margin-bottom: 1.5rem; }
.footer { margin-top: 3rem; padding-top: 0.75rem; border-top: 1px solid #ccc; font-size: 0.78rem; color: #777; text-align: center; }
@media print {
body { margin: 0; }
@page { margin: 20mm 22mm; }
for (const section of (doc.content_sections || [])) {
checkBreak(14)
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(11)
pdf.text(section.heading || '', marginL, y)
y += 6
if (section.content) {
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(10)
const lines = pdf.splitTextToSize(section.content, contentW)
for (const line of lines) {
checkBreak(5)
pdf.text(line, marginL, y)
y += 5
}
}
</style>
</head>
<body>
<h1>${escHtml(doc.title)}</h1>
<div class="meta">${escHtml(metaLine)}</div>
${sectionsHtml}
<div class="footer">Shinkan Jinkendo &nbsp;|&nbsp; Exportiert am ${new Date().toLocaleDateString('de-DE')}</div>
<script>window.onload = function () { window.print(); };<\/script>
</body>
</html>`)
win.document.close()
y += 5
}
const total = pdf.getNumberOfPages()
for (let i = 1; i <= total; i++) {
pdf.setPage(i)
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(8)
pdf.setTextColor(150, 150, 150)
const fy = 289
pdf.text(
`Shinkan Jinkendo | Exportiert am ${new Date().toLocaleDateString('de-DE')}`,
marginL, fy
)
pdf.text(`Seite ${i} von ${total}`, pageW - marginR, fy, { align: 'right' })
}
pdf.save(`${doc.document_type}_v${doc.version}.pdf`)
}
// document_type values used in the DB / API
@ -263,11 +283,11 @@ function LegalPage({ type }) {
<h1 style={{ margin: 0, color: 'var(--text1)' }}>{title}</h1>
{apiDoc && (
<button
onClick={() => printPublishedDocument(apiDoc)}
onClick={() => generateLegalPdf(apiDoc)}
className="btn btn-secondary"
style={{ fontSize: '0.82rem', padding: '4px 12px', flexShrink: 0 }}
>
PDF / Drucken
PDF herunterladen
</button>
)}
</div>

View File

@ -1,13 +1,13 @@
// Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.8.73"
export const APP_VERSION = "0.8.74"
export const BUILD_DATE = "2026-05-10"
export const PAGE_VERSIONS = {
LoginPage: "1.0.2",
SettingsLegalPage: "1.0.0",
AdminLegalDocumentsPage: "1.2.0",
LegalPage: "1.2.0",
AdminLegalDocumentsPage: "1.3.0",
LegalPage: "1.3.0",
Dashboard: "1.0.0",
AccountSettingsPage: "1.0.1",
ExercisesPage: "1.5.0", // Fokus +/- Regeln, nur ohne Fokusbereich; Filterprefs