chore: bump version to 0.8.96 and enhance legal document features
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 59s
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 59s
- Updated app version to 0.8.96 with a new build date of 2026-05-12. - Improved legal documents functionality with a live preview feature alongside the editor. - Added modal for full document preview and updated CSS styles for better layout. - Enhanced the AdminLegalDocumentsPage to support rendering previews of legal documents. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
81b9e8f601
commit
98edb282ed
|
|
@ -1,11 +1,11 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.95"
|
||||
APP_VERSION = "0.8.96"
|
||||
BUILD_DATE = "2026-05-12"
|
||||
DB_SCHEMA_VERSION = "20260511053"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"legal_documents": "1.3.0", # P-01: Ausgabe §-Nummerierung pro Abschnitt; Markdown im Fließtext + PDF; gem. legalPdfExport
|
||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||
"auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm
|
||||
"profiles": "1.7.0", # exercise_list_prefs JSONB (Standard Übungsfilter); Patch via ProfileUpdate + Json()
|
||||
"tenant_context": "1.0.5", # Plattform-Admin: effective_club ohne Header aus Profil active_club_id wenn Verein existiert
|
||||
|
|
@ -34,6 +34,13 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.96",
|
||||
"date": "2026-05-12",
|
||||
"changes": [
|
||||
"P-01 Admin Rechtstexte: Live-Vorschau je Abschnitt (Markdown) neben dem Editor; modale „Vollständige Vorschau“ aus dem Formular; Augen-Symbol in der Dokumentenliste für die gerenderte Ansicht (API-Laden).",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.95",
|
||||
"date": "2026-05-12",
|
||||
|
|
|
|||
|
|
@ -368,6 +368,82 @@ ul > li.card + li.card,
|
|||
margin: 0.85em 0;
|
||||
}
|
||||
|
||||
/* Admin: Rechtstext Editor — Live-Vorschau neben Textarea */
|
||||
.legal-section-split {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
.legal-section-split {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.legal-section-split__editor {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.legal-section-split__preview {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
.legal-section-preview-box {
|
||||
padding: 12px;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
min-height: 100px;
|
||||
}
|
||||
.legal-section-preview-box h4 {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.45rem;
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
/* Modales Vorschau-Overlay (Rechtstexte Admin) */
|
||||
.legal-preview-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: max(16px, env(safe-area-inset-top, 0px)) 16px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.legal-preview-modal {
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
margin-top: 4vh;
|
||||
margin-bottom: 4vh;
|
||||
background: var(--surface);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
|
||||
max-height: min(92vh, 960px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.legal-preview-modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.legal-preview-modal__body {
|
||||
padding: 16px 18px 20px;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
|
|
|||
111
frontend/src/components/LegalDocumentPreview.jsx
Normal file
111
frontend/src/components/LegalDocumentPreview.jsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { useEffect } from 'react'
|
||||
import LegalDocumentBody from './LegalDocumentBody'
|
||||
import { legalSectionNumber } from '../utils/legalPdfExport'
|
||||
|
||||
/** Inhalt wie auf der öffentlichen Rechtstextseite (inkl. §-Nummerierung). */
|
||||
export function LegalDocumentPublicPreviewContent({
|
||||
title,
|
||||
sections,
|
||||
showDraftNotice = true,
|
||||
metaLine,
|
||||
}) {
|
||||
const safeTitle = (title || '').trim() || 'Ohne Titel'
|
||||
|
||||
return (
|
||||
<div className="legal-admin-preview-inner">
|
||||
{showDraftNotice && (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
marginBottom: '1.25rem',
|
||||
borderLeft: '4px solid var(--warn)',
|
||||
background: 'var(--surface)',
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: 'var(--text1)' }}>Vorschau</strong>
|
||||
<p style={{ margin: '0.35rem 0 0', fontSize: '0.88rem', color: 'var(--text2)' }}>
|
||||
So erscheint der Text für Besucher nach Veröffentlichung (Markdown wird gerendert, §-Nummern wie online).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{metaLine && (
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--text3)', margin: '0 0 1rem' }}>{metaLine}</p>
|
||||
)}
|
||||
<h1 style={{ margin: '0 0 1.5rem', color: 'var(--text1)', fontSize: '1.5rem' }}>{safeTitle}</h1>
|
||||
{(sections || []).map((section, i) => (
|
||||
<div key={i} style={{ marginBottom: '1.75rem' }}>
|
||||
<h2 style={{ fontSize: '1.05rem', marginBottom: '0.4rem', color: 'var(--text1)' }}>
|
||||
{section.heading?.trim()
|
||||
? `${legalSectionNumber(i)} ${section.heading}`
|
||||
: legalSectionNumber(i)}
|
||||
</h2>
|
||||
<LegalDocumentBody content={section.content} />
|
||||
</div>
|
||||
))}
|
||||
{sections?.length === 0 && (
|
||||
<p style={{ color: 'var(--text3)', fontSize: '0.9rem' }}>Noch keine Abschnitte.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal: gerenderte Rechtstext-Vorschau (Editor oder gespeicherte Version).
|
||||
*/
|
||||
export function LegalPreviewModal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
sections,
|
||||
metaLine,
|
||||
loading,
|
||||
showDraftNotice = true,
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="legal-preview-modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="legal-preview-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="legal-preview-modal-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="legal-preview-modal__header">
|
||||
<h3 id="legal-preview-modal-title" style={{ margin: 0, fontSize: '1.05rem' }}>
|
||||
Öffentliche Darstellung
|
||||
</h3>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
<div className="legal-preview-modal__body">
|
||||
{loading ? (
|
||||
<div className="spinner" style={{ margin: '2rem auto' }} />
|
||||
) : (
|
||||
<LegalDocumentPublicPreviewContent
|
||||
title={title}
|
||||
sections={sections}
|
||||
metaLine={metaLine}
|
||||
showDraftNotice={showDraftNotice}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { FileText, Plus, Edit2, Archive, CheckCircle, Clock, Copy, Download, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { FileText, Plus, Edit2, Archive, CheckCircle, Clock, Copy, Download, ChevronUp, ChevronDown, Eye } from 'lucide-react'
|
||||
import api from '../utils/api'
|
||||
import { generateLegalPdf } from '../utils/legalPdfExport'
|
||||
import { generateLegalPdf, legalSectionNumber } from '../utils/legalPdfExport'
|
||||
import LegalDocumentBody from '../components/LegalDocumentBody'
|
||||
import { LegalPreviewModal } from '../components/LegalDocumentPreview'
|
||||
|
||||
const PDF_STATUS_META = { published: 'Gültig seit', draft: 'Entwurf — Stand', archived: 'Archiviert — Stand' }
|
||||
|
||||
|
|
@ -109,18 +111,35 @@ function SectionEditor({ sections, onChange }) {
|
|||
placeholder="Abschnittsüberschrift"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">
|
||||
Inhalt
|
||||
<span className="form-sub">
|
||||
Einfache Markdown-Formatierung: **fett**, *kursiv*, Listen (- oder 1.), [Link](https://…), Zeilenumbruch mit Leerzeile.
|
||||
<div className="legal-section-split">
|
||||
<div className="legal-section-split__editor">
|
||||
<label className="form-label">
|
||||
Inhalt
|
||||
<span className="form-sub">
|
||||
Einfache Markdown-Formatierung: **fett**, *kursiv*, Listen (- oder 1.), [Link](https://…), Zeilenumbruch mit Leerzeile.
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
className="form-input" rows={6} value={sec.content}
|
||||
onChange={e => update(i, 'content', e.target.value)}
|
||||
placeholder="Textinhalt des Abschnitts"
|
||||
/>
|
||||
</div>
|
||||
<div className="legal-section-split__preview">
|
||||
<span className="form-label" style={{ fontSize: '0.8rem', display: 'block', marginBottom: '6px' }}>
|
||||
Live-Vorschau
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
className="form-input" rows={4} value={sec.content}
|
||||
onChange={e => update(i, 'content', e.target.value)}
|
||||
placeholder="Textinhalt des Abschnitts"
|
||||
/>
|
||||
<div className="legal-section-preview-box" style={{ marginBottom: 0 }}>
|
||||
<h4>
|
||||
{sec.heading?.trim()
|
||||
? `${legalSectionNumber(i)} ${sec.heading}`
|
||||
: legalSectionNumber(i)}
|
||||
</h4>
|
||||
{sec.content
|
||||
? <LegalDocumentBody content={sec.content} />
|
||||
: <span style={{ color: 'var(--text3)', fontSize: '0.85rem' }}>(noch kein Text)</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InsertButton afterIndex={i} />
|
||||
|
|
@ -135,7 +154,7 @@ function SectionEditor({ sections, onChange }) {
|
|||
)
|
||||
}
|
||||
|
||||
function DocumentRow({ doc, onPublish, onArchive, onEdit, onCopy, onDownload, onViewAudit }) {
|
||||
function DocumentRow({ doc, onPublish, onArchive, onEdit, onCopy, onDownload, onViewAudit, onRenderedPreview }) {
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
|
||||
const handleDownload = async () => {
|
||||
|
|
@ -183,6 +202,10 @@ function DocumentRow({ doc, onPublish, onArchive, onEdit, onCopy, onDownload, on
|
|||
<Archive size={13} />
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-secondary" style={{ padding: '4px 10px', fontSize: '0.78rem' }}
|
||||
onClick={() => onRenderedPreview(doc)} title="Gerenderte Vorschau">
|
||||
<Eye size={13} />
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ padding: '4px 10px', fontSize: '0.78rem' }}
|
||||
onClick={() => onCopy(doc)} title="Als neuen Entwurf kopieren">
|
||||
<Copy size={13} />
|
||||
|
|
@ -200,7 +223,7 @@ function DocumentRow({ doc, onPublish, onArchive, onEdit, onCopy, onDownload, on
|
|||
)
|
||||
}
|
||||
|
||||
function EditForm({ docType, editDoc, onSaved, onCancel }) {
|
||||
function EditForm({ docType, editDoc, onSaved, onCancel, onShowRenderedPreview }) {
|
||||
const [title, setTitle] = useState(editDoc ? editDoc.title : docType.defaultTitle)
|
||||
const [sections, setSections] = useState([])
|
||||
const [changeNote, setChangeNote] = useState('')
|
||||
|
|
@ -266,10 +289,23 @@ function EditForm({ docType, editDoc, onSaved, onCancel }) {
|
|||
onChange={e => setChangeNote(e.target.value)}
|
||||
placeholder="z. B. Erste Version nach juristischer Prüfung" />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !title.trim()}>
|
||||
{saving ? 'Speichern…' : 'Entwurf speichern'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() =>
|
||||
onShowRenderedPreview?.({
|
||||
title,
|
||||
sections,
|
||||
metaLine: 'Aktueller Editorstand (nicht automatisch gespeichert)',
|
||||
})}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}
|
||||
>
|
||||
<Eye size={15} /> Vollständige Vorschau
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={onCancel}>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -329,6 +365,13 @@ export default function AdminLegalDocumentsPage() {
|
|||
const [editDoc, setEditDoc] = useState(null)
|
||||
const [auditDocId, setAuditDocId] = useState(null)
|
||||
const [confirmPublish, setConfirmPublish] = useState(null)
|
||||
const [legalPreview, setLegalPreview] = useState({
|
||||
open: false,
|
||||
loading: false,
|
||||
title: '',
|
||||
sections: [],
|
||||
metaLine: '',
|
||||
})
|
||||
|
||||
const activeDocType = DOC_TYPES.find(d => d.key === activeType)
|
||||
|
||||
|
|
@ -376,6 +419,42 @@ export default function AdminLegalDocumentsPage() {
|
|||
catch (e) { alert('Fehler: ' + e.message) }
|
||||
}
|
||||
|
||||
const closeLegalPreview = () =>
|
||||
setLegalPreview({ open: false, loading: false, title: '', sections: [], metaLine: '' })
|
||||
|
||||
const openLegalPreviewFromEditor = (payload) => {
|
||||
setLegalPreview({
|
||||
open: true,
|
||||
loading: false,
|
||||
title: payload.title,
|
||||
sections: payload.sections,
|
||||
metaLine: payload.metaLine || '',
|
||||
})
|
||||
}
|
||||
|
||||
const openLegalPreviewFromList = async (doc) => {
|
||||
setLegalPreview({
|
||||
open: true,
|
||||
loading: true,
|
||||
title: '',
|
||||
sections: [],
|
||||
metaLine: 'Laden…',
|
||||
})
|
||||
try {
|
||||
const full = await api.getLegalDocument(doc.id)
|
||||
setLegalPreview({
|
||||
open: true,
|
||||
loading: false,
|
||||
title: full.title,
|
||||
sections: full.content_sections || [],
|
||||
metaLine: `Version ${full.version} · ${STATUS_LABELS[full.status]?.label || full.status}`,
|
||||
})
|
||||
} catch (e) {
|
||||
closeLegalPreview()
|
||||
alert('Fehler: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = async (doc) => {
|
||||
const full = await api.getLegalDocument(doc.id)
|
||||
const dateStr = full.published_at
|
||||
|
|
@ -473,6 +552,7 @@ export default function AdminLegalDocumentsPage() {
|
|||
onCopy={handleCopy}
|
||||
onDownload={handleDownload}
|
||||
onViewAudit={handleViewAudit}
|
||||
onRenderedPreview={openLegalPreviewFromList}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
|
@ -483,9 +563,19 @@ export default function AdminLegalDocumentsPage() {
|
|||
editDoc={editDoc}
|
||||
onSaved={handleSaved}
|
||||
onCancel={() => { setShowForm(false); setEditDoc(null) }}
|
||||
onShowRenderedPreview={openLegalPreviewFromEditor}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LegalPreviewModal
|
||||
open={legalPreview.open}
|
||||
onClose={closeLegalPreview}
|
||||
title={legalPreview.title}
|
||||
sections={legalPreview.sections}
|
||||
metaLine={legalPreview.metaLine}
|
||||
loading={legalPreview.loading}
|
||||
/>
|
||||
|
||||
{auditDocId && (
|
||||
<AuditLog docId={auditDocId} onClose={() => setAuditDocId(null)} />
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user