feat(#3): Use Latex for PDF output
This commit is contained in:
@@ -23,6 +23,15 @@ FROM eclipse-temurin:21-jre-alpine AS runner
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install TeXLive and LuaLaTeX
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
texlive-luatex \
|
||||||
|
texmf-dist-plaingeneric \
|
||||||
|
texmf-dist-latexrecommended \
|
||||||
|
texmf-dist-latexextra \
|
||||||
|
texmf-dist-langgerman \
|
||||||
|
texmf-dist-fontsextra
|
||||||
|
|
||||||
RUN addgroup -S spring && adduser -S spring -G spring
|
RUN addgroup -S spring && adduser -S spring -G spring
|
||||||
USER spring:spring
|
USER spring:spring
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ repositories {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|
||||||
ext {
|
|
||||||
openHtmlVersion = '1.0.10'
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin'
|
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin'
|
||||||
implementation 'org.jetbrains.kotlin:kotlin-reflect'
|
implementation 'org.jetbrains.kotlin:kotlin-reflect'
|
||||||
@@ -39,11 +35,6 @@ dependencies {
|
|||||||
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-mail'
|
implementation 'org.springframework.boot:spring-boot-starter-mail'
|
||||||
implementation "com.openhtmltopdf:openhtmltopdf-core:$openHtmlVersion"
|
|
||||||
implementation "com.openhtmltopdf:openhtmltopdf-pdfbox:$openHtmlVersion"
|
|
||||||
implementation "com.openhtmltopdf:openhtmltopdf-java2d:$openHtmlVersion"
|
|
||||||
implementation "com.openhtmltopdf:openhtmltopdf-slf4j:$openHtmlVersion"
|
|
||||||
implementation "com.openhtmltopdf:openhtmltopdf-svg-support:$openHtmlVersion"
|
|
||||||
runtimeOnly 'com.h2database:h2'
|
runtimeOnly 'com.h2database:h2'
|
||||||
implementation 'org.postgresql:postgresql'
|
implementation 'org.postgresql:postgresql'
|
||||||
implementation 'org.springframework.boot:spring-boot-testcontainers'
|
implementation 'org.springframework.boot:spring-boot-testcontainers'
|
||||||
|
|||||||
@@ -1,32 +1,44 @@
|
|||||||
package com.betriebsratkanzlei.legalconsenthub.application_form
|
package com.betriebsratkanzlei.legalconsenthub.application_form
|
||||||
|
|
||||||
|
import com.betriebsratkanzlei.legalconsenthub.application_form.export.latex.LatexEscaper
|
||||||
|
import com.betriebsratkanzlei.legalconsenthub.application_form.export.latex.LatexExportModel
|
||||||
|
import com.betriebsratkanzlei.legalconsenthub.application_form.export.latex.LatexFormElement
|
||||||
|
import com.betriebsratkanzlei.legalconsenthub.application_form.export.latex.LatexPdfRenderer
|
||||||
|
import com.betriebsratkanzlei.legalconsenthub.application_form.export.latex.LatexSection
|
||||||
|
import com.betriebsratkanzlei.legalconsenthub.application_form.export.latex.LatexSubSection
|
||||||
|
import com.betriebsratkanzlei.legalconsenthub.application_form.export.latex.RichTextToLatexConverter
|
||||||
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElement
|
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElement
|
||||||
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementSection
|
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementSection
|
||||||
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementSubSection
|
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementSubSection
|
||||||
import com.betriebsratkanzlei.legalconsenthub.form_element.VisibilityConditionOperator
|
import com.betriebsratkanzlei.legalconsenthub.form_element.VisibilityConditionOperator
|
||||||
import com.betriebsratkanzlei.legalconsenthub.form_element.VisibilityConditionType
|
import com.betriebsratkanzlei.legalconsenthub.form_element.VisibilityConditionType
|
||||||
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.thymeleaf.TemplateEngine
|
import org.thymeleaf.TemplateEngine
|
||||||
import org.thymeleaf.context.Context
|
import org.thymeleaf.context.Context
|
||||||
import java.io.ByteArrayOutputStream
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class ApplicationFormFormatService(
|
class ApplicationFormFormatService(
|
||||||
private val templateEngine: TemplateEngine,
|
private val templateEngine: TemplateEngine,
|
||||||
|
private val richTextToLatexConverter: RichTextToLatexConverter,
|
||||||
|
private val pdfRenderer: LatexPdfRenderer,
|
||||||
) {
|
) {
|
||||||
fun generatePdf(applicationForm: ApplicationForm): ByteArray {
|
fun generatePdf(applicationForm: ApplicationForm): ByteArray {
|
||||||
val htmlContent = generateHtml(applicationForm)
|
val latexContent = generateLatex(applicationForm)
|
||||||
|
return pdfRenderer.render(latexContent)
|
||||||
|
}
|
||||||
|
|
||||||
val outputStream = ByteArrayOutputStream()
|
fun generateLatex(applicationForm: ApplicationForm): String {
|
||||||
PdfRendererBuilder()
|
val filteredForm = filterVisibleElements(applicationForm)
|
||||||
.useFastMode()
|
val exportModel = buildLatexExportModel(filteredForm)
|
||||||
.withHtmlContent(htmlContent, null)
|
|
||||||
.toStream(outputStream)
|
|
||||||
.run()
|
|
||||||
|
|
||||||
return outputStream.toByteArray()
|
val context =
|
||||||
|
Context().apply {
|
||||||
|
setVariable("applicationForm", exportModel)
|
||||||
|
}
|
||||||
|
return templateEngine.process("application_form_latex_template", context)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun generateHtml(applicationForm: ApplicationForm): String {
|
fun generateHtml(applicationForm: ApplicationForm): String {
|
||||||
@@ -38,6 +50,83 @@ class ApplicationFormFormatService(
|
|||||||
return templateEngine.process("application_form_template", context)
|
return templateEngine.process("application_form_template", context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun buildLatexExportModel(applicationForm: ApplicationForm): LatexExportModel {
|
||||||
|
val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||||
|
|
||||||
|
return LatexExportModel(
|
||||||
|
id = applicationForm.id,
|
||||||
|
name = LatexEscaper.escape(applicationForm.name),
|
||||||
|
organizationId = LatexEscaper.escape(applicationForm.organizationId),
|
||||||
|
employer = LatexEscaper.escape("Arbeitgeber der Organisation ${applicationForm.organizationId}"),
|
||||||
|
worksCouncil = LatexEscaper.escape("Betriebsrat der Organisation ${applicationForm.organizationId}"),
|
||||||
|
createdAt = applicationForm.createdAt?.format(dateFormatter) ?: "",
|
||||||
|
sections =
|
||||||
|
applicationForm.formElementSections.map { section ->
|
||||||
|
LatexSection(
|
||||||
|
title = LatexEscaper.escape(section.title),
|
||||||
|
description = LatexEscaper.escape(section.description),
|
||||||
|
subsections =
|
||||||
|
section.formElementSubSections.map { subsection ->
|
||||||
|
LatexSubSection(
|
||||||
|
title = LatexEscaper.escape(subsection.title),
|
||||||
|
subtitle = LatexEscaper.escape(subsection.subtitle),
|
||||||
|
elements =
|
||||||
|
subsection.formElements.map { element ->
|
||||||
|
LatexFormElement(
|
||||||
|
title = LatexEscaper.escape(element.title),
|
||||||
|
description = LatexEscaper.escape(element.description),
|
||||||
|
value = renderElementValue(element),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderElementValue(element: FormElement): String =
|
||||||
|
when (element.type.name) {
|
||||||
|
"TEXTFIELD", "TEXTAREA" -> {
|
||||||
|
val value = element.options.firstOrNull()?.value
|
||||||
|
if (value.isNullOrBlank()) "Keine Eingabe" else LatexEscaper.escape(value)
|
||||||
|
}
|
||||||
|
"DATE" -> {
|
||||||
|
val value = element.options.firstOrNull()?.value
|
||||||
|
if (value.isNullOrBlank()) {
|
||||||
|
"Kein Datum ausgewählt"
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
LocalDate.parse(value).format(DateTimeFormatter.ofPattern("dd.MM.yyyy"))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LatexEscaper.escape(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"RICH_TEXT" -> {
|
||||||
|
val value = element.options.firstOrNull()?.value
|
||||||
|
if (value.isNullOrBlank()) "Keine Eingabe" else richTextToLatexConverter.convertToLatex(value)
|
||||||
|
}
|
||||||
|
"SELECT", "CHECKBOX" -> {
|
||||||
|
val selected = element.options.filter { it.value == "true" }.map { it.label }
|
||||||
|
if (selected.isEmpty()) {
|
||||||
|
"Keine Auswahl getroffen"
|
||||||
|
} else {
|
||||||
|
selected.joinToString(
|
||||||
|
", ",
|
||||||
|
) { LatexEscaper.escape(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"RADIOBUTTON" -> {
|
||||||
|
val selected = element.options.firstOrNull { it.value == "true" }?.label
|
||||||
|
if (selected == null) "Keine Auswahl getroffen" else LatexEscaper.escape(selected)
|
||||||
|
}
|
||||||
|
"SWITCH" -> {
|
||||||
|
if (element.options.any { it.value == "true" }) "Ja" else "Nein"
|
||||||
|
}
|
||||||
|
else -> "Keine Auswahl getroffen"
|
||||||
|
}
|
||||||
|
|
||||||
private fun filterVisibleElements(applicationForm: ApplicationForm): ApplicationForm {
|
private fun filterVisibleElements(applicationForm: ApplicationForm): ApplicationForm {
|
||||||
val allElements = collectAllFormElements(applicationForm)
|
val allElements = collectAllFormElements(applicationForm)
|
||||||
val formElementsByRef = buildFormElementsByRefMap(allElements)
|
val formElementsByRef = buildFormElementsByRefMap(allElements)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.betriebsratkanzlei.legalconsenthub.application_form.export.latex
|
||||||
|
|
||||||
|
object LatexEscaper {
|
||||||
|
fun escape(text: String?): String {
|
||||||
|
if (text == null) return ""
|
||||||
|
return text
|
||||||
|
.replace("\\", "\\textbackslash{}")
|
||||||
|
.replace("{", "\\{")
|
||||||
|
.replace("}", "\\}")
|
||||||
|
.replace("$", "\\$")
|
||||||
|
.replace("&", "\\&")
|
||||||
|
.replace("#", "\\#")
|
||||||
|
.replace("%", "\\%")
|
||||||
|
.replace("_", "\\_")
|
||||||
|
.replace("^", "\\textasciicircum{}")
|
||||||
|
.replace("~", "\\textasciitilde{}")
|
||||||
|
.replace("\n", "\\\\")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.betriebsratkanzlei.legalconsenthub.application_form.export.latex
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class LatexExportModel(
|
||||||
|
val id: UUID?,
|
||||||
|
val name: String,
|
||||||
|
val organizationId: String,
|
||||||
|
val employer: String,
|
||||||
|
val worksCouncil: String,
|
||||||
|
val sections: List<LatexSection>,
|
||||||
|
val createdAt: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LatexSection(
|
||||||
|
val title: String,
|
||||||
|
val description: String?,
|
||||||
|
val subsections: List<LatexSubSection>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LatexSubSection(
|
||||||
|
val title: String,
|
||||||
|
val subtitle: String?,
|
||||||
|
val elements: List<LatexFormElement>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LatexFormElement(
|
||||||
|
val title: String,
|
||||||
|
val description: String?,
|
||||||
|
val value: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.betriebsratkanzlei.legalconsenthub.application_form.export.latex
|
||||||
|
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class LatexPdfRenderer {
|
||||||
|
private val logger = LoggerFactory.getLogger(LatexPdfRenderer::class.java)
|
||||||
|
|
||||||
|
fun render(latexContent: String): ByteArray {
|
||||||
|
val tempDir = Files.createTempDirectory("latex-export-").toFile()
|
||||||
|
try {
|
||||||
|
val texFile = File(tempDir, "main.tex")
|
||||||
|
texFile.writeText(latexContent)
|
||||||
|
|
||||||
|
val extraPaths = listOf("/Library/TeX/texbin", "/usr/local/bin", "/usr/bin", "/bin")
|
||||||
|
val lualatexExecutable =
|
||||||
|
extraPaths
|
||||||
|
.map { File(it, "lualatex") }
|
||||||
|
.firstOrNull { it.exists() && it.canExecute() }
|
||||||
|
?.absolutePath ?: "lualatex"
|
||||||
|
|
||||||
|
logger.info("Using lualatex executable: $lualatexExecutable")
|
||||||
|
|
||||||
|
// Run lualatex twice to resolve TOC and references
|
||||||
|
repeat(2) {
|
||||||
|
val processBuilder =
|
||||||
|
ProcessBuilder(
|
||||||
|
lualatexExecutable,
|
||||||
|
"-halt-on-error",
|
||||||
|
"-interaction=nonstopmode",
|
||||||
|
"-file-line-error",
|
||||||
|
"-output-directory=${tempDir.absolutePath}",
|
||||||
|
texFile.absolutePath,
|
||||||
|
).apply {
|
||||||
|
directory(tempDir)
|
||||||
|
redirectErrorStream(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure LaTeX binaries are in the path (common issue on macOS/local dev)
|
||||||
|
val env = processBuilder.environment()
|
||||||
|
val currentPath = env["PATH"] ?: ""
|
||||||
|
env["PATH"] = extraPaths
|
||||||
|
.filter { File(it).exists() && !currentPath.contains(it) }
|
||||||
|
.joinToString(
|
||||||
|
File.pathSeparator,
|
||||||
|
postfix = if (currentPath.isNotEmpty()) File.pathSeparator else "",
|
||||||
|
) +
|
||||||
|
currentPath
|
||||||
|
|
||||||
|
// Set TEXMFVAR and TEXMFCACHE to tempDir to avoid "no writeable cache path" error in Docker
|
||||||
|
env["TEXMFVAR"] = tempDir.absolutePath
|
||||||
|
env["TEXMFCACHE"] = tempDir.absolutePath
|
||||||
|
|
||||||
|
val process = processBuilder.start()
|
||||||
|
|
||||||
|
val completed = process.waitFor(90, TimeUnit.SECONDS)
|
||||||
|
val output = process.inputStream.bufferedReader().readText()
|
||||||
|
|
||||||
|
if (!completed) {
|
||||||
|
process.destroyForcibly()
|
||||||
|
throw RuntimeException("LaTeX compilation timed out")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.exitValue() != 0) {
|
||||||
|
logger.error("LaTeX compilation failed:\n$output")
|
||||||
|
throw RuntimeException("LaTeX compilation failed with exit code ${process.exitValue()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val pdfFile = File(tempDir, "main.pdf")
|
||||||
|
if (!pdfFile.exists()) {
|
||||||
|
throw RuntimeException("PDF file was not generated")
|
||||||
|
}
|
||||||
|
|
||||||
|
return pdfFile.readBytes()
|
||||||
|
} finally {
|
||||||
|
tempDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.betriebsratkanzlei.legalconsenthub.application_form.export.latex
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class RichTextToLatexConverter(
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
|
) {
|
||||||
|
fun convertToLatex(jsonString: String?): String {
|
||||||
|
if (jsonString.isNullOrBlank()) return ""
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val root = objectMapper.readTree(jsonString)
|
||||||
|
processNode(root)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Fallback to escaped plain text if JSON parsing fails (e.g. legacy HTML)
|
||||||
|
// In a real scenario, we might want to strip HTML tags here too.
|
||||||
|
LatexEscaper.escape(jsonString.replace(Regex("<[^>]*>"), ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processNode(node: JsonNode): String {
|
||||||
|
val type = node.get("type")?.asText() ?: return ""
|
||||||
|
val content = node.get("content")
|
||||||
|
|
||||||
|
return when (type) {
|
||||||
|
"doc" -> processContent(content)
|
||||||
|
"paragraph" -> processContent(content) + "\n\n"
|
||||||
|
"heading" -> {
|
||||||
|
val level = node.get("attrs")?.get("level")?.asInt() ?: 1
|
||||||
|
val cmd =
|
||||||
|
when (level) {
|
||||||
|
1 -> "\\subsubsection*{"
|
||||||
|
2 -> "\\paragraph*{"
|
||||||
|
else -> "\\subparagraph*{"
|
||||||
|
}
|
||||||
|
cmd + processContent(content) + "}\n\n"
|
||||||
|
}
|
||||||
|
"bulletList" -> "\\begin{itemize}\n" + processContent(content) + "\\end{itemize}\n"
|
||||||
|
"orderedList" -> "\\begin{enumerate}\n" + processContent(content) + "\\end{enumerate}\n"
|
||||||
|
"listItem" -> "\\item " + processContent(content) + "\n"
|
||||||
|
"blockquote" -> "\\begin{quotation}\n" + processContent(content) + "\\end{quotation}\n"
|
||||||
|
"codeBlock" -> "\\begin{verbatim}\n" + processContent(content) + "\\end{verbatim}\n"
|
||||||
|
"text" -> {
|
||||||
|
val text = node.get("text")?.asText() ?: ""
|
||||||
|
val escapedText = LatexEscaper.escape(text)
|
||||||
|
applyMarks(escapedText, node.get("marks"))
|
||||||
|
}
|
||||||
|
"hardBreak" -> "\\\\\n"
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processContent(content: JsonNode?): String {
|
||||||
|
if (content == null || !content.isArray) return ""
|
||||||
|
val sb = StringBuilder()
|
||||||
|
for (node in content) {
|
||||||
|
sb.append(processNode(node))
|
||||||
|
}
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyMarks(
|
||||||
|
text: String,
|
||||||
|
marks: JsonNode?,
|
||||||
|
): String {
|
||||||
|
if (marks == null || !marks.isArray) return text
|
||||||
|
var result = text
|
||||||
|
for (mark in marks) {
|
||||||
|
val markType = mark.get("type")?.asText() ?: continue
|
||||||
|
result =
|
||||||
|
when (markType) {
|
||||||
|
"bold" -> "\\textbf{$result}"
|
||||||
|
"italic" -> "\\textit{$result}"
|
||||||
|
"underline" -> "\\underline{$result}"
|
||||||
|
"strike" -> "\\sout{$result}" // Requires ulem package
|
||||||
|
"link" -> {
|
||||||
|
val href = mark.get("attrs")?.get("href")?.asText() ?: ""
|
||||||
|
"\\href{$href}{$result}"
|
||||||
|
}
|
||||||
|
else -> result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.betriebsratkanzlei.legalconsenthub.config
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
import org.thymeleaf.templatemode.TemplateMode
|
||||||
|
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
class LatexThymeleafConfig {
|
||||||
|
@Bean
|
||||||
|
fun latexTemplateResolver(): ClassLoaderTemplateResolver =
|
||||||
|
ClassLoaderTemplateResolver().apply {
|
||||||
|
prefix = "templates/"
|
||||||
|
suffix = ".tex"
|
||||||
|
templateMode = TemplateMode.TEXT
|
||||||
|
characterEncoding = "UTF-8"
|
||||||
|
order = 1
|
||||||
|
checkExistence = true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import jakarta.persistence.Embeddable
|
|||||||
|
|
||||||
@Embeddable
|
@Embeddable
|
||||||
class FormOption(
|
class FormOption(
|
||||||
@Column(nullable = false, name = "option_value")
|
@Column(nullable = false, name = "option_value", columnDefinition = "TEXT")
|
||||||
var value: String,
|
var value: String,
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
var label: String,
|
var label: String,
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ create table form_element_options
|
|||||||
processing_purpose smallint not null check (processing_purpose between 0 and 3),
|
processing_purpose smallint not null check (processing_purpose between 0 and 3),
|
||||||
form_element_id uuid not null,
|
form_element_id uuid not null,
|
||||||
label varchar(255) not null,
|
label varchar(255) not null,
|
||||||
option_value varchar(255) not null
|
option_value TEXT not null
|
||||||
);
|
);
|
||||||
|
|
||||||
create table form_element
|
create table form_element
|
||||||
@@ -136,7 +136,9 @@ alter table if exists application_form_version
|
|||||||
add constraint FKpfri4lhy9wqfsp8esabedkq6c
|
add constraint FKpfri4lhy9wqfsp8esabedkq6c
|
||||||
foreign key (application_form_id)
|
foreign key (application_form_id)
|
||||||
references application_form
|
references application_form
|
||||||
on delete cascade;
|
on
|
||||||
|
delete
|
||||||
|
cascade;
|
||||||
|
|
||||||
alter table if exists application_form_version
|
alter table if exists application_form_version
|
||||||
add constraint FKl6fbcrvh439gbwgcvvfyxaggi
|
add constraint FKl6fbcrvh439gbwgcvvfyxaggi
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
\documentclass[
|
||||||
|
fontsize=11pt,
|
||||||
|
paper=a4,
|
||||||
|
DIV=12,
|
||||||
|
BCOR=5mm,
|
||||||
|
ngerman
|
||||||
|
]{scrartcl}
|
||||||
|
|
||||||
|
\usepackage{fontspec}
|
||||||
|
\usepackage{babel}
|
||||||
|
\usepackage{microtype}
|
||||||
|
\usepackage{hyperref}
|
||||||
|
\usepackage{graphicx}
|
||||||
|
\usepackage{enumitem}
|
||||||
|
\usepackage{xcolor}
|
||||||
|
\usepackage{tcolorbox}
|
||||||
|
\usepackage[normalem]{ulem}
|
||||||
|
|
||||||
|
\hypersetup{
|
||||||
|
colorlinks=true,
|
||||||
|
linkcolor=black,
|
||||||
|
filecolor=black,
|
||||||
|
urlcolor=blue,
|
||||||
|
pdftitle={[[${applicationForm.name}]]},
|
||||||
|
}
|
||||||
|
|
||||||
|
\title{Betriebsvereinbarung}
|
||||||
|
\subtitle{[[${applicationForm.name}]]}
|
||||||
|
\date{[[${applicationForm.createdAt}]]}
|
||||||
|
|
||||||
|
\begin{document}
|
||||||
|
|
||||||
|
\maketitle
|
||||||
|
|
||||||
|
\vspace{2cm}
|
||||||
|
|
||||||
|
\section*{Zwischen}
|
||||||
|
|
||||||
|
\begin{tcolorbox}[colback=white, colframe=gray!50, title=Arbeitgeber]
|
||||||
|
[[${applicationForm.employer}]]
|
||||||
|
\end{tcolorbox}
|
||||||
|
|
||||||
|
\vspace{1cm}
|
||||||
|
|
||||||
|
\section*{und dem}
|
||||||
|
|
||||||
|
\begin{tcolorbox}[colback=white, colframe=gray!50, title=Betriebsrat]
|
||||||
|
[[${applicationForm.worksCouncil}]]
|
||||||
|
\end{tcolorbox}
|
||||||
|
|
||||||
|
\newpage
|
||||||
|
\tableofcontents
|
||||||
|
\newpage
|
||||||
|
|
||||||
|
\section{Gegenstand der Vereinbarung}
|
||||||
|
Dieses Dokument enthält die Details der Betriebsvereinbarung "[[${applicationForm.name}]]" (ID: [[${applicationForm.id}]]).
|
||||||
|
|
||||||
|
[# th:each="section : ${applicationForm.sections}"]
|
||||||
|
\section{[[${section.title}]]}
|
||||||
|
[# th:if="${section.description}"]
|
||||||
|
\textit{[[${section.description}]]}
|
||||||
|
[/]
|
||||||
|
|
||||||
|
[# th:each="subsection : ${section.subsections}"]
|
||||||
|
\subsection{[[${subsection.title}]]}
|
||||||
|
[# th:if="${subsection.subtitle}"]
|
||||||
|
\textit{[[${subsection.subtitle}]]}
|
||||||
|
[/]
|
||||||
|
|
||||||
|
[# th:each="element : ${subsection.elements}"]
|
||||||
|
\paragraph{[[${element.title}]]}
|
||||||
|
[# th:if="${element.description}"]
|
||||||
|
\textit{\small [[${element.description}]]}
|
||||||
|
[/]
|
||||||
|
|
||||||
|
\begin{tcolorbox}[colback=gray!5, colframe=gray!20, arc=0mm, boxrule=0.5pt]
|
||||||
|
[[${element.value}]]
|
||||||
|
\end{tcolorbox}
|
||||||
|
[/]
|
||||||
|
[/]
|
||||||
|
[/]
|
||||||
|
|
||||||
|
\vspace{3cm}
|
||||||
|
|
||||||
|
\begin{minipage}[t]{0.45\textwidth}
|
||||||
|
\rule{\textwidth}{0.4pt}\\
|
||||||
|
Ort, Datum\\
|
||||||
|
(Für den Arbeitgeber)
|
||||||
|
\end{minipage}
|
||||||
|
\hfill
|
||||||
|
\begin{minipage}[t]{0.45\textwidth}
|
||||||
|
\rule{\textwidth}{0.4pt}\\
|
||||||
|
Ort, Datum\\
|
||||||
|
(Für den Betriebsrat)
|
||||||
|
\end{minipage}
|
||||||
|
|
||||||
|
\end{document}
|
||||||
|
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
<div class="bg-white dark:bg-white rounded-md border border-gray-200 dark:border-gray-200 overflow-hidden">
|
<div class="bg-white dark:bg-white rounded-md border border-gray-200 dark:border-gray-200 overflow-hidden">
|
||||||
<UEditor
|
<UEditor
|
||||||
v-slot="{ editor }"
|
v-slot="{ editor }"
|
||||||
v-model="htmlContent"
|
v-model="editorContent"
|
||||||
content-type="html"
|
content-type="json"
|
||||||
:editable="!props.disabled"
|
:editable="!props.disabled"
|
||||||
:placeholder="t('applicationForms.formElements.richTextPlaceholder')"
|
:placeholder="t('applicationForms.formElements.richTextPlaceholder')"
|
||||||
:ui="{
|
:ui="{
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { EmployeeDataCategory, ProcessingPurpose, type FormOptionDto } from '~~/.api-client'
|
import { EmployeeDataCategory, ProcessingPurpose, type FormOptionDto } from '~~/.api-client'
|
||||||
|
import type { JSONContent } from '@tiptap/vue-3'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -38,13 +39,25 @@ const emit = defineEmits<{
|
|||||||
(e: 'update:formOptions', value: FormOptionDto[]): void
|
(e: 'update:formOptions', value: FormOptionDto[]): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const htmlContent = computed({
|
const editorContent = computed({
|
||||||
get: () => props.formOptions[0]?.value ?? '',
|
get: () => {
|
||||||
set: (newValue: string) => {
|
const rawValue = props.formOptions[0]?.value ?? ''
|
||||||
|
if (rawValue.trim().startsWith('{')) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawValue) as JSONContent
|
||||||
|
} catch {
|
||||||
|
return rawValue // Fallback to HTML if JSON parse fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rawValue // Treat as HTML
|
||||||
|
},
|
||||||
|
set: (newValue: string | JSONContent) => {
|
||||||
const updatedOptions: FormOptionDto[] = [...props.formOptions]
|
const updatedOptions: FormOptionDto[] = [...props.formOptions]
|
||||||
|
const stringifiedValue = typeof newValue === 'string' ? newValue : JSON.stringify(newValue)
|
||||||
|
|
||||||
if (updatedOptions.length === 0) {
|
if (updatedOptions.length === 0) {
|
||||||
const createdOption: FormOptionDto = {
|
const createdOption: FormOptionDto = {
|
||||||
value: newValue,
|
value: stringifiedValue,
|
||||||
label: '',
|
label: '',
|
||||||
processingPurpose: ProcessingPurpose.None,
|
processingPurpose: ProcessingPurpose.None,
|
||||||
employeeDataCategory: EmployeeDataCategory.None
|
employeeDataCategory: EmployeeDataCategory.None
|
||||||
@@ -52,7 +65,7 @@ const htmlContent = computed({
|
|||||||
updatedOptions.push(createdOption)
|
updatedOptions.push(createdOption)
|
||||||
} else {
|
} else {
|
||||||
const firstOption = updatedOptions[0]!
|
const firstOption = updatedOptions[0]!
|
||||||
updatedOptions[0] = { ...firstOption, value: newValue }
|
updatedOptions[0] = { ...firstOption, value: stringifiedValue }
|
||||||
}
|
}
|
||||||
emit('update:formOptions', updatedOptions)
|
emit('update:formOptions', updatedOptions)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user