|
|
|
|
@ -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, '&')
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
.replace(/"/g, '"')
|
|
|
|
|
}
|
|
|
|
|
// ─── 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 | 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)} />
|
|
|
|
|
)}
|
|
|
|
|
|