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 # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.73" APP_VERSION = "0.8.74"
BUILD_DATE = "2026-05-10" BUILD_DATE = "2026-05-10"
DB_SCHEMA_VERSION = "20260510047" DB_SCHEMA_VERSION = "20260510047"
MODULE_VERSIONS = { 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 "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() "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 "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 = [ 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", "version": "0.8.73",
"date": "2026-05-10", "date": "2026-05-10",

View File

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

View File

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

View File

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

View File

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