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
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:
parent
030eb41429
commit
456ead72b6
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
}
|
|
||||||
|
|
||||||
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 | 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)} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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, '&')
|
const marginL = 22, marginR = 22, marginTop = 28, pageW = 210
|
||||||
.replace(/</g, '<')
|
const contentW = pageW - marginL - marginR
|
||||||
.replace(/>/g, '>')
|
const bottomLimit = 277
|
||||||
.replace(/"/g, '"')
|
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 | 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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user