1
0
mirror of https://github.com/pcvolkmer/grz-metadata-processor.git synced 2025-07-03 04:42:54 +00:00

Initial commit

This commit is contained in:
2025-06-08 22:01:08 +02:00
commit 9d5293328e
40 changed files with 2645 additions and 0 deletions

View File

@ -0,0 +1,11 @@
package dev.pcvolkmer.onco.grzmetadataprocessor
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class GrzMetadataProcessorApplication
fun main(args: Array<String>) {
runApplication<GrzMetadataProcessorApplication>(*args)
}

View File

@ -0,0 +1,52 @@
package dev.pcvolkmer.onco.grzmetadataprocessor.data
import org.springframework.data.annotation.Id
import org.springframework.data.relational.core.mapping.Table
import org.springframework.data.repository.CrudRepository
@Table("tbl_case")
data class Case(
@Id val id: Long? = null,
var localCaseId: String = "",
var coverageType: CoverageType = CoverageType.UNK,
var submitterId: String = "",
var genomicDataCenterId: String = "",
var clinicalDataCenterId: String = "",
var diseaseType: DiseaseType? = null,
var genomicStudyType: GenomicStudyType? = null,
var genomicStudySubtype: GenomicStudySubtype? = null,
var labName: String = "",
)
enum class CoverageType {
GKV,
PKV,
BG,
SEL,
SOZ,
GPV,
PPV,
BEI,
SKT,
UNK
}
enum class DiseaseType(value: String) {
ONCOLOGICAL("oncological"),
RARE("rare"),
HEREDITARY("hereditary"),
}
enum class GenomicStudyType(value: String) {
SINGLE("single"),
DUO("duo"),
TRIO("trio"),
}
enum class GenomicStudySubtype(value: String) {
TUMOR_ONLY("tumor-only"),
TUMOR_GERMLINE("tumor+germline"),
GERMLINE_ONLY("germline-only"),
}
interface CaseRepository : CrudRepository<Case, Long>

View File

@ -0,0 +1,35 @@
package dev.pcvolkmer.onco.grzmetadataprocessor.data
import org.springframework.data.annotation.Id
import org.springframework.data.relational.core.mapping.Table
import org.springframework.data.repository.CrudRepository
@Table("tbl_donor")
data class Donor(
@Id val id: Long? = null,
val caseId: Long?,
val donorPseudonym: String = "",
val gender: Gender? = null,
val relation: Relation? = null,
)
enum class Gender(val value: String) {
MALE("male"),
FEMALE("female"),
OTHER("other"),
UNKNOWN("unknown")
}
enum class Relation(val value: String) {
MOTHER("mother"),
FATHER("father"),
BROTHER("brother"),
SISTER("sister"),
CHILD("child"),
INDEX("index"),
OTHER("other")
}
interface DonorRepository : CrudRepository<Donor, Long> {
fun findByCaseId(caseId: Long): MutableList<Donor>
}

View File

@ -0,0 +1,63 @@
package dev.pcvolkmer.onco.grzmetadataprocessor.data
import org.apache.tomcat.util.buf.HexUtils
import org.springframework.data.annotation.Id
import org.springframework.data.relational.core.mapping.Table
import org.springframework.data.repository.CrudRepository
import java.nio.file.Path
import java.security.DigestInputStream
import java.security.MessageDigest
import java.util.*
import kotlin.io.path.fileSize
import kotlin.io.path.inputStream
@Table("tbl_file")
data class File(
@Id val id: Long? = null,
val labDataId: Long?,
val filePath: String? = null,
val fileType: FileType? = null,
var fileChecksum: String? = null,
var fileSizeInBytes: Long? = null,
) {
init {
if (fileChecksum.isNullOrBlank()) {
fileChecksum = calcFileChecksum()
}
if (fileSizeInBytes?.or(0)!! < 1) {
fileSizeInBytes = calcFileSize()
}
}
fun calcFileChecksum(): String {
if (filePath == null) {
return ""
}
val path = Path.of(filePath)
val messageDigest = MessageDigest.getInstance("SHA-256")
val digestInputStream = DigestInputStream(path.inputStream(), messageDigest)
digestInputStream.readAllBytes()
return HexUtils.toHexString(messageDigest.digest())
}
fun calcFileSize(): Long {
if (filePath == null) {
return 0
}
return Path.of(filePath).fileSize()
}
}
enum class FileType(val value: String) {
BAM("bam"),
VCF("vcf"),
BED("bed"),
FASTQ("fastq")
}
interface FileRepository : CrudRepository<File, Long> {
fun findByLabDataId(labDataId: Long): MutableList<File>
fun findByLabDataIdIsNull(): List<File>
}

View File

@ -0,0 +1,109 @@
package dev.pcvolkmer.onco.grzmetadataprocessor.data
import org.springframework.data.annotation.Id
import org.springframework.data.relational.core.mapping.Table
import org.springframework.data.repository.CrudRepository
@Table("tbl_lab_data")
data class LabData(
@Id val id: Long? = null,
val donorId: Long?,
val einsendenummer: String = "",
val labDataName: String = "",
val sampleDate: String = "",
val sampleConservation: SampleConservation? = null,
val sequenceType: SequenceType? = null,
val sequenceSubtype: SequenceSubtype? = null,
val fragmentationMethod: FragmentationMethod? = null,
val libraryType: LibraryType? = null,
val libraryPrepKit: String = "",
val libraryPrepKitManufacturer: String = "",
val sequencerModel: String = "",
val sequencerManufacturer: String = "",
val kitName: String = "",
val kitManufacturer: String = "",
val enrichmentKit: String = "",
val enrichmentKitManufacturer: EnrichmentKitManufacturer? = null,
val sequencingLayout: SequencingLayout? = null,
val tumorCellCount: Int = 0,
val tumorCellCountMethod: TumorCellCountMethod? = null,
val bioinformaticsPipelineName: String = "",
val bioinformaticsPipelineVersion: String = "",
val referenceGenome: ReferenceGenome? = null,
)
enum class SampleConservation(val value: String) {
FRESH_TISSUE("fresh-tissue"),
CRYO_FROZEN("cryo-frozen"),
FFPE("ffpe"),
OTHER("other"),
UNKNOWN("unknown")
}
enum class SequenceType(val value: String) {
DNA("dna"),
RNA("rna")
}
enum class SequenceSubtype(val value: String) {
GERMLINE("germline"),
SOMATIC("somatic"),
OTHER("other"),
UNKNOWN("unknown")
}
enum class FragmentationMethod(val value: String) {
SONICATION("sonication"),
ENZYMATIC("enzymatic"),
NONE("none"),
OTHER("other"),
UNKNOWN("unknown")
}
enum class LibraryType(val value: String) {
PANEL("panel"),
PANEL_LR("panel_lr"),
WES("wes"),
WES_LR("wes_lr"),
WGS("wgs"),
WGS_LR("wgs_lr"),
WXS("wxs"),
WXS_LR("wxs_lr"),
OTHER("other"),
UNKNOWN("unknown")
}
enum class EnrichmentKitManufacturer(val value: String) {
ILLUMINA("Illumina"),
AGILENT("Agilent"),
TWIST("Twist"),
NEB("NEB"),
OTHER("other"),
UNKNOWN("unknown"),
NONE("none")
}
enum class SequencingLayout(val value: String) {
SINGLE_END("single-end"),
PAIRED_END("paired-end"),
REVERSE("reverse"),
OTHER("other"),
}
enum class TumorCellCountMethod(val value: String) {
PATHOLOGY("pathology"),
BIOINFORMATICS("bioinformatics"),
OTHER("other"),
UNKNOWN("unknown")
}
enum class ReferenceGenome(val value: String) {
GRCH37("GRCh37"),
GRCH38("GRCh38"),
}
interface LabDataRepository : CrudRepository<LabData, Long> {
fun findByDonorId(donorId: Long): MutableList<LabData>
fun countLabDataByDonorIdIsNull(): Long
fun findByEinsendenummer(einsendenummer: String): LabData
}

View File

@ -0,0 +1,3 @@
package dev.pcvolkmer.onco.grzmetadataprocessor.exceptions
class NotFoundException: RuntimeException()

View File

@ -0,0 +1,65 @@
package dev.pcvolkmer.onco.grzmetadataprocessor.web
import dev.pcvolkmer.onco.grzmetadataprocessor.data.Case
import dev.pcvolkmer.onco.grzmetadataprocessor.data.CaseRepository
import dev.pcvolkmer.onco.grzmetadataprocessor.exceptions.NotFoundException
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.*
@Controller
@RequestMapping(path = ["/cases"])
class CaseController(
private val repository: CaseRepository
) {
@GetMapping
fun getAllCase(model: Model): String {
model.addAttribute("cases", repository.findAll())
return "cases"
}
@GetMapping(path = ["{caseId}"])
fun getCase(@PathVariable caseId: Long, model: Model): String {
repository.findById(caseId).ifPresentOrElse({
model.addAttribute("case", it)
}, {
throw NotFoundException()
})
return "case"
}
@PostMapping
fun postCase(model: Model): ResponseEntity<Unit> {
val createdCase = repository.save(Case())
model.addAttribute("cases", repository.findAll())
return ResponseEntity
.noContent()
.headers { headers ->
headers.add("HX-Redirect", "/cases/${createdCase.id}")
}
.build()
}
@PutMapping(path = ["{caseId}"], consumes = [MediaType.APPLICATION_FORM_URLENCODED_VALUE])
fun putCase(@PathVariable caseId: Long, case: Case, model: Model): String {
val savedCase = repository.save(case)
model.addAttribute("case", savedCase)
return "case"
}
@DeleteMapping(path = ["{caseId}"])
fun deleteCase(@PathVariable caseId: Long, model: Model): ResponseEntity<Unit> {
repository.deleteById(caseId)
model.addAttribute("cases", repository.findAll())
return ResponseEntity
.noContent()
.headers { headers ->
headers.add("HX-Redirect", "/")
}
.build()
}
}

View File

@ -0,0 +1,15 @@
package dev.pcvolkmer.onco.grzmetadataprocessor.web
import dev.pcvolkmer.onco.grzmetadataprocessor.exceptions.NotFoundException
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
@ControllerAdvice
class ControllerAdvices {
@ExceptionHandler(NotFoundException::class)
fun handleNotFoundException(e: NotFoundException): String {
return "error/notfound"
}
}

View File

@ -0,0 +1,45 @@
package dev.pcvolkmer.onco.grzmetadataprocessor.web
import dev.pcvolkmer.onco.grzmetadataprocessor.data.Donor
import dev.pcvolkmer.onco.grzmetadataprocessor.data.DonorRepository
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.*
@Controller
@RequestMapping("/cases/{caseId}/donors")
class DonorController(
private val repository: DonorRepository
) {
@GetMapping
fun getAllDonors(@PathVariable caseId: Long, model: Model): String {
model.addAttribute("caseId", caseId)
model.addAttribute("donors", repository.findByCaseId(caseId))
return "donors"
}
@PostMapping
fun postDonor(@PathVariable caseId: Long, model: Model): String {
repository.save(Donor(caseId = caseId))
model.addAttribute("caseId", caseId)
model.addAttribute("donors", repository.findByCaseId(caseId))
return "donors"
}
@PutMapping(path = ["{donorId}"])
fun postDonor(@PathVariable caseId: Long, @PathVariable donorId: Long, donor: Donor, model: Model): String {
repository.save(donor)
model.addAttribute("caseId", caseId)
model.addAttribute("donors", repository.findByCaseId(caseId))
return "donors"
}
@DeleteMapping(path = ["{donorId}"])
fun deleteDonor(@PathVariable caseId: Long, @PathVariable donorId: Long, model: Model): String {
repository.deleteById(donorId)
model.addAttribute("donors", repository.findByCaseId(caseId))
return "donors"
}
}

View File

@ -0,0 +1,69 @@
package dev.pcvolkmer.onco.grzmetadataprocessor.web
import dev.pcvolkmer.onco.grzmetadataprocessor.data.File
import dev.pcvolkmer.onco.grzmetadataprocessor.data.FileRepository
import dev.pcvolkmer.onco.grzmetadataprocessor.data.LabDataRepository
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.*
@Controller
@RequestMapping
class FileController(
private val fileRepository: FileRepository,
private val labDataRepository: LabDataRepository
) {
@GetMapping(path = ["/labdatas/{labDataId}/files"])
fun getAllFiles(@PathVariable labDataId: Long, model: Model): String {
model.addAttribute("labDataId", labDataId)
model.addAttribute("files", fileRepository.findByLabDataId(labDataId))
return "files"
}
@GetMapping(path = ["/files/unused"])
fun getAllUnusedFiles(model: Model): String {
model.addAttribute("labDatas", labDataRepository.findAll().filterNot { it.einsendenummer.isBlank() })
model.addAttribute("files", fileRepository.findByLabDataIdIsNull())
return "unusedfiles"
}
@PostMapping(path = ["/labdatas/{labDataId}/files"])
fun postFile(@PathVariable labDataId: Long, model: Model): String {
fileRepository.save(File(labDataId = labDataId))
model.addAttribute("labDataId", labDataId)
model.addAttribute("files", fileRepository.findByLabDataId(labDataId))
return "files"
}
@PutMapping(path = ["/labdatas/{labDataId}/files/{fileId}"])
fun putFile(@PathVariable labDataId: Long, @PathVariable fileId: Long, file: File, model: Model): String {
fileRepository.save(file)
model.addAttribute("labDataId", labDataId)
model.addAttribute("files", fileRepository.findByLabDataId(labDataId))
return "files"
}
@PutMapping(path = ["/files/unused/{fileId}"])
fun putUnusedFile(@PathVariable fileId: Long, file: File, model: Model): String {
fileRepository.save(file)
model.addAttribute("files", fileRepository.findByLabDataIdIsNull())
return "unusedfiles"
}
@DeleteMapping(path = ["/labdatas/{labDataId}/files/{fileId}"])
fun deleteFile(@PathVariable fileId: Long, @PathVariable labDataId: Long, model: Model): String {
fileRepository.deleteById(fileId)
model.addAttribute("fileId", fileId)
model.addAttribute("files", fileRepository.findByLabDataId(labDataId))
return "files"
}
@DeleteMapping(path = ["/files/unused/{fileId}"])
fun deleteUnusedFile(@PathVariable fileId: Long, model: Model): String {
fileRepository.deleteById(fileId)
model.addAttribute("files", fileRepository.findByLabDataIdIsNull())
return "unusedfiles"
}
}

View File

@ -0,0 +1,42 @@
package dev.pcvolkmer.onco.grzmetadataprocessor.web
import dev.pcvolkmer.onco.grzmetadataprocessor.data.LabData
import dev.pcvolkmer.onco.grzmetadataprocessor.data.LabDataRepository
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.*
@Controller
@RequestMapping("/donors/{donorId}/labdatas")
class LabDataController(
private val repository: LabDataRepository
) {
@GetMapping
fun getAllLabData(@PathVariable donorId: Long, model: Model): String {
model.addAttribute("labdatas", repository.findByDonorId(donorId))
return "labdatas"
}
@PostMapping
fun postLabData(@PathVariable donorId: Long, model: Model): String {
repository.save(LabData(donorId = donorId))
model.addAttribute("labdatas", repository.findByDonorId(donorId))
return "labdatas"
}
@PutMapping(path = ["{labdataId}"])
fun putLabData(@PathVariable donorId: Long, labData: LabData, model: Model): String {
repository.save(labData)
model.addAttribute("labdatas", repository.findByDonorId(donorId))
return "labdatas"
}
@DeleteMapping(path = ["{labdataId}"])
fun deleteLabData(@PathVariable donorId: Long, @PathVariable labdataId: Long, model: Model): String {
repository.deleteById(labdataId)
model.addAttribute("labdatas", repository.findByDonorId(donorId))
return "labdatas"
}
}

View File

@ -0,0 +1,25 @@
package dev.pcvolkmer.onco.grzmetadataprocessor.web
import dev.pcvolkmer.onco.grzmetadataprocessor.data.CaseRepository
import dev.pcvolkmer.onco.grzmetadataprocessor.data.FileRepository
import dev.pcvolkmer.onco.grzmetadataprocessor.data.LabDataRepository
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
@Controller
class MenuController(
private val caseRepository: CaseRepository,
private val labDataRepository: LabDataRepository,
private val fileRepository: FileRepository
) {
@GetMapping("/cases/menu")
fun getCaseMenus(model: Model): String {
model.addAttribute("cases", caseRepository.findAll().count())
model.addAttribute("unusedLabData", labDataRepository.countLabDataByDonorIdIsNull())
model.addAttribute("unusedFiles", fileRepository.findByLabDataIdIsNull().size)
return "casemenu"
}
}

View File

@ -0,0 +1,4 @@
spring:
docker:
compose:
file: compose-dev.yml

View File

@ -0,0 +1,5 @@
spring:
application:
name: grz-metadata-processor
flyway:
locations: "classpath:db/migrations/{vendor}"

View File

@ -0,0 +1,61 @@
CREATE TABLE IF NOT EXISTS tbl_case
(
id int auto_increment primary key,
local_case_id varchar(255) not null,
coverage_type varchar(255) not null,
submitter_id varchar(16) not null,
genomic_data_center_id varchar(16) not null,
clinical_data_center_id varchar(16) not null,
disease_type varchar(24),
genomic_study_type varchar(24),
genomic_study_subtype varchar(24),
lab_name varchar(255) not null
);
CREATE TABLE IF NOT EXISTS tbl_donor
(
id int auto_increment primary key,
case_id int,
donor_pseudonym varchar(255) not null,
gender varchar(16),
relation varchar(16)
);
CREATE TABLE IF NOT EXISTS tbl_lab_data
(
id int auto_increment primary key,
donor_id int,
einsendenummer varchar(255) unique,
lab_data_name varchar(255) not null,
sample_date varchar(16) not null,
sample_conservation varchar(255),
sequence_type varchar(255),
sequence_subtype varchar(255),
fragmentation_method varchar(255),
library_type varchar(255),
library_prep_kit varchar(255) not null,
library_prep_kit_manufacturer varchar(255) not null,
sequencer_model varchar(255) not null,
sequencer_manufacturer varchar(255) not null,
kit_name varchar(255) not null,
kit_manufacturer varchar(255) not null,
enrichment_kit varchar(255) not null,
enrichment_kit_manufacturer varchar(255),
sequencing_layout varchar(255),
tumor_cell_count int,
tumor_cell_count_method varchar(255),
bioinformatics_pipeline_name varchar(255) not null,
bioinformatics_pipeline_version varchar(255) not null,
reference_genome varchar(8)
);
CREATE TABLE IF NOT EXISTS tbl_file
(
id int auto_increment primary key,
lab_data_id int,
file_path varchar(255) unique,
file_type varchar(8),
file_checksum varchar(64) unique,
file_size_in_bytes int
);

View File

@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/html" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
<link th:href="@{/main.css}" rel="stylesheet" />
<base href="@{/}">
</head>
<body>
<aside th:hx-get="@{/cases/menu}" hx-trigger="load"></aside>
<main>
<div>
<h1>Fall <tt th:text="${case.localCaseId}"></tt></h1>
<form class="case">
<input type="hidden" name="id" th:value="${case.id}" />
<div>
<label>
Lokale Fall-ID
<input type="text" name="localCaseId" th:value="${case.localCaseId}" />
</label>
</div>
<div>
<label>
Versicherungsart
<select name="coverageType" th:field="${case.coverageType}">
<option th:value="'GKV'">GKV</option>
<option th:value="'PKV'">PKV</option>
<option th:value="'BG'">BG</option>
<option th:value="'SEL'">SEL</option>
<option th:value="'SOZ'">SOZ</option>
<option th:value="'GPV'">GPV</option>
<option th:value="'PPV'">PPV</option>
<option th:value="'BEI'">BEI</option>
<option th:value="'SKT'">SKT</option>
<option th:value="'UNK'">UNK</option>
</select>
</label>
</div>
<div>
<label>
Submitter-ID
<input type="text" name="submitterId" th:value="${case.submitterId}" pattern="[0-9]{9}" />
</label>
</div>
<div>
<label>
GRZ-ID
<input type="text" name="genomicDataCenterId" th:value="${case.genomicDataCenterId}" pattern="^(GRZ)[A-Z0-9]{3}[0-9]{3}$" />
</label>
</div>
<div>
<label>
KDK-ID
<input type="text" name="clinicalDataCenterId" th:value="${case.clinicalDataCenterId}" pattern="^(GRZ)[A-Z0-9]{3}[0-9]{3}$" />
</label>
</div>
<div>
<label>
Erkrankungsart
<select name="diseaseType" th:field="${case.diseaseType}">
<option th:value="ONCOLOGICAL">oncological</option>
<option th:value="RARE">rare</option>
<option th:value="HEREDITARY">hereditary</option>
</select>
</label>
</div>
<div>
<label>
Genomische Studienart
<select name="genomicStudyType" th:field="${case.genomicStudyType}">
<option th:value="SINGLE">single</option>
<option th:value="DUO">duo</option>
<option th:value="TRIO">trio</option>
</select>
</label>
</div>
<div>
<label>
Genomische Unterstudienart
<select name="genomicStudySubtype" th:field="${case.genomicStudySubtype}">
<option th:value="TUMOR_ONLY">tumor-only</option>
<option th:value="TUMOR_GERMLINE">tumor+germline</option>
<option th:value="GERMLINE_ONLY">germline-only</option>
</select>
</label>
</div>
<div>
<label>
Laborname
<input name="labName" th:value="${case.labName}" type="text" />
</label>
</div>
<div>
<button class="save" th:hx-put="@{/cases/{caseId}(caseId=${case.id})}" hx-target="body">Änderungen übernehmen</button>
<button class="new" th:hx-post="@{/cases}">Neu</button>
<button class="delete" th:hx-delete="@{/cases/{caseId}(caseId=${case.id})}">Löschen</button>
</div>
<div th:hx-get="@{/cases/{caseId}/donors(caseId=${case.id})}" hx-trigger="load"></div>
</form>
</div>
</main>
<script th:src="@{/main.js}"></script>
</body>
</html>

View File

@ -0,0 +1,25 @@
<div class="title">GRZ-Metadaten</div>
<nav>
<ul>
<li>
<a th:href="@{/}">
<span>Fallübersicht</span>
<span class="counter">[[ ${cases} ]]</span>
</a>
</li>
<li>
<a th:href="@{/}">
<span>Ungenutzte Proben</span>
<span th:if="${unusedLabData > 0}" class="counter">[[ ${unusedLabData} ]]</span>
</a>
</li>
<li>
<a th:href="@{/files/unused}">
<span>Ungenutzte Dateien</span>
<span th:if="${unusedFiles}" class="counter">[[ ${unusedFiles} ]]</span>
</a>
</li>
</ul>
</nav>

View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/html" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div>
<h1>
[[ ${cases.size()} ]] Fälle
</h1>
<form class="case">
<ul class="case-list">
<li th:each="case : ${cases}">
<a th:href="@{/cases/{caseId}(caseId=${case.id})}">#[[ ${case.id} ]] - Fallnummer: [[ ${case.localCaseId} ]]</a>
<button class="delete left" th:hx-delete="@{/cases/{caseId}(caseId=${case.id})}" hx-target="#cases">Eintrag löschen</button>
</li>
</ul>
<button class="new" th:hx-post="@{/cases}">Neuen Fall anlegen</button>
</form>
</div>
</body>
</html>

View File

@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/html" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div th:id="'cases_' + ${caseId} + '_donors'">
<div th:if="${donors.isEmpty()}">
<h2>Probenspender</h2>
<form class="donor" th:hx-post="@{/cases/{caseId}/donors(caseId=${caseId})}" th:hx-target="'#cases_' + ${caseId} + '_donors'">
<div>Keine Probenspender gefunden</div>
<button>Neuen Probenspender hinzufügen</button>
</form>
</div>
<!-- Probenspender -->
<div th:each="donor : ${donors}">
<h2>Probenspender</h2>
<form class="donor" th:id="'cases_' + ${caseId} + '_donors'">
<input type="hidden" name="id" th:value="${donor.id}">
<div>
<label>
Spender-Pseudonym
<input type="text" name="donorPseudonym" th:value="${donor.donorPseudonym}" />
</label>
</div>
<div>
<label>
Gender
<select name="gender">
<option th:selected="${donor.gender != null and donor.gender.value == 'male'}" value="MALE">male</option>
<option th:selected="${donor.gender != null and donor.gender.value == 'female'}" value="FEMALE">female</option>
<option th:selected="${donor.gender != null and donor.gender.value == 'other'}" value="OTHER">other</option>
<option th:selected="${donor.gender != null and donor.gender.value == 'unknown'}" value="UNKNOWN">unknown</option>
</select>
</label>
</div>
<div>
<label>
Verwandschaft
<select name="relation">
<option th:selected="${donor.relation != null and donor.relation.value == 'mother'}" value="MOTHER">mother</option>
<option th:selected="${donor.relation != null and donor.relation.value == 'father'}" value="FATHER">father</option>
<option th:selected="${donor.relation != null and donor.relation.value == 'brother'}" value="BROTHER">brother</option>
<option th:selected="${donor.relation != null and donor.relation.value == 'sister'}" value="SISTER">sister</option>
<option th:selected="${donor.relation != null and donor.relation.value == 'child'}" value="CHILD">child</option>
<option th:selected="${donor.relation != null and donor.relation.value == 'index'}" value="INDEX">index</option>
<option th:selected="${donor.relation != null and donor.relation.value == 'other'}" value="OTHER">other</option>
</select>
</label>
</div>
<div>
<button class="save" th:hx-put="@{/cases/{caseId}/donors/{donorId}(caseId=${caseId},donorId=${donor.id})}" th:hx-target="'#cases_' + ${caseId} + '_donors'">Änderungen übernehmen</button>
<button class="new" th:hx-post="@{/cases/{caseId}/donors(caseId=${caseId})}" th:hx-target="'#cases_' + ${caseId} + '_donors'">Neu</button>
<button class="delete" th:hx-delete="@{/cases/{caseId}/donors/{donorId}(caseId=${caseId},donorId=${donor.id})}" th:hx-target="'#cases_' + ${caseId} + '_donors'">Löschen</button>
</div>
<div th:hx-get="@{/donors/{donorId}/labdatas(donorId=${donor.id})}" hx-trigger="load"></div>
</form>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/html" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Fehler</title>
</head>
<body>
<a th:href="@{/}">Zurück</a>
</body>
</html>

View File

@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/html" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div th:id="'labdatas_' + ${labDataId} + '_files'">
<div th:if="${files.isEmpty()}">
<h4>Datei</h4>
<form class="file" th:hx-post="@{/labdatas/{labDataId}/files(labDataId=${labDataId})}" th:hx-target="'#labdatas_' + ${labDataId} + '_files'">
<div>Keine Datei gefunden</div>
<button>Neuen Datei hinzufügen</button>
</form>
</div>
<div th:each="file : ${files}">
<!-- File -->
<h4>Datei</h4>
<form class="file">
<input type="hidden" name="id" th:value="${file.id}" />
<div>
<label>
Datei
<input type="text" name="filePath" th:value="${file.filePath}" />
</label>
</div>
<div>
<label>
Dateityp
<select name="fileType">
<option th:selected="${file.fileType != null and file.fileType.value == 'bam'}">BAM</option>
<option th:selected="${file.fileType != null and file.fileType.value == 'vcf'}">Vcf</option>
<option th:selected="${file.fileType != null and file.fileType.value == 'bed'}">BED</option>
<option th:selected="${file.fileType != null and file.fileType.value == 'fastq'}">FASTQ</option>
</select>
</label>
</div>
<div>
<label>
Prüfsumme - SHA256
<input type="text" pattern="[A-Fa-f0-9]{64}" name="fileChecksum" th:value="${file.fileChecksum} " />
</label>
</div>
<div>
<label>
Dateigröße in Bytes
<input type="number" min="0" name="fileSizeInBytes" th:value="${file.fileSizeInBytes} " />
</label>
</div>
<div>
<button class="save" th:hx-put="@{/labdatas/{labDataId}/files/{fileId}(labDataId=${labDataId},fileId=${file.id})}" th:hx-target="'#labdatas_' + ${labDataId} + '_files'">Änderungen übernehmen</button>
<button class="new" th:hx-post="@{/labdatas/{labDataId}/files(labDataId=${labDataId})}" th:hx-target="'#labdatas_' + ${labDataId} + '_files'">Neu</button>
<button class="delete" th:hx-delete="@{/labdatas/{labDataId}/files/{fileId}(labDataId=${labDataId},fileId=${file.id})}" th:hx-target="'#labdatas_' + ${labDataId} + '_files'">Löschen</button>
</div>
</form>
</div>
</div>
<script th:src="@{/main.js}"></script>
</body>
</html>

View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/html" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
<link th:href="@{/main.css}" rel="stylesheet" />
</head>
<body>
<aside th:hx-get="@{/cases/menu}" hx-trigger="load"></aside>
<main>
<div id="cases" th:hx-get="@{/cases}" hx-trigger="load"></div>
</main>
<script th:src="@{/main.js}"></script>
</body>
</html>

View File

@ -0,0 +1,232 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/html" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div th:id="'donors_' + ${donorId} + '_labdatas'">
<div th:if="${labdatas.isEmpty()}">
<h3>Probe</h3>
<form class="lab-data" th:hx-post="@{/donors/{donorId}/labdatas(donorId=${donorId})}" th:hx-target="'#donors_' + ${donorId} + '_labdatas'">
<div>Keine Probe gefunden</div>
<button>Neuen Probe hinzufügen</button>
</form>
</div>
<div th:each="labdata : ${labdatas}">
<h3>Probe/Sequenzierung</h3>
<form class="lab-data">
<input type="hidden" name="id" th:value="${labdata.id}" />
<div>
<label class="optional">
Einsendenummer (Optionale Angabe)
<input type="text" name="einsendenummer" th:value="${labdata.einsendenummer}" />
</label>
</div>
<div>
<label>
LabData Name
<input type="text" name="labDataName" th:value="${labdata.labDataName}"/>
</label>
</div>
<div>
<label>
Datum der Probe
<input type="date" name="sampleDate" th:value="${labdata.sampleDate}" />
</label>
</div>
<div>
<label>
Konservierung der Probe
<select name="sampleConservation">
<option th:selected="${labdata.sampleConservation != null and labdata.sampleConservation.value == 'FRESH_TISSUE'}" value="FRESH_TISSUE">fresh-tissue</option>
<option th:selected="${labdata.sampleConservation != null and labdata.sampleConservation.value == 'CRYO_FROZEN'}" value="CRYO_FROZEN">cryo-frozen</option>
<option th:selected="${labdata.sampleConservation != null and labdata.sampleConservation.value == 'FFPE'}" value="FFPE">ffpe</option>
<option th:selected="${labdata.sampleConservation != null and labdata.sampleConservation.value == 'OTHER'}" value="OTHER">other</option>
<option th:selected="${labdata.sampleConservation != null and labdata.sampleConservation.value == 'UNKNOWN'}" value="UNKNOWN">unknown</option>
</select>
</label>
</div>
<div>
<label>
Sequenzierungsart
<select name="sequenceType">
<option th:selected="${labdata.sequenceType != null and labdata.sequenceType.value == 'DNA'}" value="DNA">dna</option>
<option th:selected="${labdata.sequenceType != null and labdata.sequenceType.value == 'RNA'}" value="RNA">rna</option>
</select>
</label>
</div>
<div>
<label>
Sequenzierungsunterart
<select name="sequenceSubtype">
<option th:selected="${labdata.sequenceSubtype != null and labdata.sequenceSubtype.value == 'GERMLINE'}" value="GERMLINE">germline</option>
<option th:selected="${labdata.sequenceSubtype != null and labdata.sequenceSubtype.value == 'SOMATIC'}" value="SOMATIC">somatic</option>
<option th:selected="${labdata.sequenceSubtype != null and labdata.sequenceSubtype.value == 'OTHER'}" value="OTHER">other</option>
<option th:selected="${labdata.sequenceSubtype != null and labdata.sequenceSubtype.value == 'UNKNOWN'}" value="UNKNOWN">unknown</option>
</select>
</label>
</div>
<div>
<label>
Fragmentierungsart
<select name="fragmentationMethod">
<option th:selected="${labdata.fragmentationMethod != null and labdata.fragmentationMethod.value == 'SONICATION'}" value="SONICATION">sonication</option>
<option th:selected="${labdata.fragmentationMethod != null and labdata.fragmentationMethod.value == 'ENZYMATIC'}" value="ENZYMATIC">enzymatic</option>
<option th:selected="${labdata.fragmentationMethod != null and labdata.fragmentationMethod.value == 'NONE'}" value="NONE">none</option>
<option th:selected="${labdata.fragmentationMethod != null and labdata.fragmentationMethod.value == 'OTHER'}" value="OTHER">other</option>
<option th:selected="${labdata.fragmentationMethod != null and labdata.fragmentationMethod.value == 'UNKNOWN'}" value="UNKNOWN">unknown</option>
</select>
</label>
</div>
<div>
<label>
Library Type
<select name="libraryType">
<option th:selected="${labdata.libraryType != null and labdata.libraryType.value == 'PANEL'}" value="PANEL">panel</option>
<option th:selected="${labdata.libraryType != null and labdata.libraryType.value == 'PANEL_LR'}" value="PANEL_LR">panel_lr</option>
<option th:selected="${labdata.libraryType != null and labdata.libraryType.value == 'WES'}" value="WES">wes</option>
<option th:selected="${labdata.libraryType != null and labdata.libraryType.value == 'WES_LR'}" value="WES_LR">wes_lr</option>
<option th:selected="${labdata.libraryType != null and labdata.libraryType.value == 'WGS'}" value="WGS">wgs</option>
<option th:selected="${labdata.libraryType != null and labdata.libraryType.value == 'WGS_LR'}" value="WGS_LR">wgs_lr</option>
<option th:selected="${labdata.libraryType != null and labdata.libraryType.value == 'WXS'}" value="WXS">wxs</option>
<option th:selected="${labdata.libraryType != null and labdata.libraryType.value == 'WXS_LR'}" value="WXS_LR">wxs_lr</option>
<option th:selected="${labdata.libraryType != null and labdata.libraryType.value == 'OTHER'}" value="OTHER">other</option>
<option th:selected="${labdata.libraryType != null and labdata.libraryType.value == 'UNKNOWN'}" value="UNKNOWN">unknown</option>
</select>
</label>
</div>
<div>
<label>
Preparation Kit - Name
<input type="text" name="libraryPrepKit" th:value="${labdata.libraryPrepKit}" />
</label>
<label>
Hersteller
<input type="text" name="libraryPrepKitManufacturer" th:value="${labdata.libraryPrepKitManufacturer}" />
</label>
</div>
<div>
<label>
Sequencer - Modell
<input type="text" name="sequencerModel" th:value="${labdata.sequencerModel}" />
</label>
<label>
Hersteller
<input type="text" name="sequencerManufacturer" th:value="${labdata.sequencerManufacturer}" />
</label>
</div>
<div>
<label>
Kit - Name/Version
<input type="text" name="kitName" th:value="${labdata.kitName}" />
</label>
<label>
Hersteller
<input type="text" name="kitManufacturer" th:value="${labdata.kitManufacturer}" />
</label>
</div>
<div>
<label>
Enrichment Kit - Name und Version
<input type="text" name="enrichmentKit" th:value="${labdata.enrichmentKit}" />
</label>
<label>
Hersteller
<select name="enrichmentKitManufacturer">
<option th:selected="${labdata.enrichmentKitManufacturer != null and labdata.enrichmentKitManufacturer.value == 'ILLUMINA'}" value="ILLUMINA">Illumina</option>
<option th:selected="${labdata.enrichmentKitManufacturer != null and labdata.enrichmentKitManufacturer.value == 'AGILENT'}" value="AGILENT">Agilent</option>
<option th:selected="${labdata.enrichmentKitManufacturer != null and labdata.enrichmentKitManufacturer.value == 'TIST'}" value="TIST">Twist</option>
<option th:selected="${labdata.enrichmentKitManufacturer != null and labdata.enrichmentKitManufacturer.value == 'NEB'}" value="NEB">NEB</option>
<option th:selected="${labdata.enrichmentKitManufacturer != null and labdata.enrichmentKitManufacturer.value == 'OTHER'}" value="OTHER">other</option>
<option th:selected="${labdata.enrichmentKitManufacturer != null and labdata.enrichmentKitManufacturer.value == 'UNKNOWN'}" value="UNKNOWN">unknown</option>
<option th:selected="${labdata.enrichmentKitManufacturer != null and labdata.enrichmentKitManufacturer.value == 'NONE'}" value="NONE">none</option>
</select>
</label>
</div>
<div>
<label>
Sequencing Layout
<select name="sequencingLayout">
<option th:selected="${labdata.sequencingLayout != null and labdata.sequencingLayout.value == 'SINGLE_END'}" value="SINGLE_END">single-end</option>
<option th:selected="${labdata.sequencingLayout != null and labdata.sequencingLayout.value == 'PAIRED_END'}" value="PAIRED_END">paired-end</option>
<option th:selected="${labdata.sequencingLayout != null and labdata.sequencingLayout.value == 'REVERSE'}" value="REVERSE">reverse</option>
<option th:selected="${labdata.sequencingLayout != null and labdata.sequencingLayout.value == 'OTHER'}" value="OTHER">other</option>
</select>
</label>
</div>
<div>
<label>
Tumorzellgehalt in %
<input type="number" name="tumorCellCount" th:value="${labdata.tumorCellCount}" />
</label>
<label>
Methode
<select name="tumorCellCountMethod">
<option th:selected="${labdata.tumorCellCountMethod != null and labdata.tumorCellCountMethod.value == 'PATHOLOGY'}" value="PATHOLOGY">pathology</option>
<option th:selected="${labdata.tumorCellCountMethod != null and labdata.tumorCellCountMethod.value == 'BIOINFORMATICS'}" value="BIOINFORMATICS">bioinformatics</option>
<option th:selected="${labdata.tumorCellCountMethod != null and labdata.tumorCellCountMethod.value == 'OTHER'}" value="OTHER">other</option>
<option th:selected="${labdata.tumorCellCountMethod != null and labdata.tumorCellCountMethod.value == 'UNKNOWN'}" value="UNKNOWN">unknown</option>
</select>
</label>
</div>
<div>
<label>
Bioinformatik-Pipeline
<input type="text" name="bioinformaticsPipelineName" th:value="${labdata.bioinformaticsPipelineName}" />
</label>
<label>
Version
<input type="text" name="bioinformaticsPipelineVersion" th:value="${labdata.bioinformaticsPipelineVersion}" />
</label>
</div>
<div>
<label>
Referenz-Genom
<select name="referenceGenome">
<option th:selected="${labdata.referenceGenome != null and labdata.referenceGenome.value == 'GRCH37'}" value="GRCH37">GRCh37</option>
<option th:selected="${labdata.referenceGenome != null and labdata.referenceGenome.value == 'GRCH38'}" value="GRCH38">GRCh38</option>
</select>
</label>
</div>
<div>
<button class="save" th:hx-put="@{/donors/{donorId}/labdatas/{labdataId}(donorId=${donorId},labdataId=${labdata.id})}" th:hx-target="'#donors_' + ${donorId} + '_labdatas'">Änderungen übernehmen</button>
<button class="new" th:hx-post="@{/donors/{donorId}/labdatas(donorId=${donorId})}" th:hx-target="'#donors_' + ${donorId} + '_labdatas'">Neu</button>
<button class="delete" th:hx-delete="@{/donors/{donorId}/labdatas/{labdataId}(donorId=${donorId},labdataId=${labdata.id})}" th:hx-target="'#donors_' + ${donorId} + '_labdatas'">Löschen</button>
</div>
<div th:hx-get="@{/labdatas/{labdataId}/files(labdataId=${labdata.id})}" hx-trigger="load"></div>
</form>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/html" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
<link th:href="@{/main.css}" rel="stylesheet" />
<base href="@{/}">
</head>
<body>
<div>
<aside th:hx-get="@{/cases/menu}" hx-trigger="load"></aside>
<main>
<div id="unused_files">
<div th:if="${files.isEmpty()}">
<h4>Datei</h4>
<form class="file">
<div>Keine ungenutzte Datei gefunden</div>
</form>
</div>
<div th:each="file : ${files}">
<!-- File -->
<h4>Datei</h4>
<form class="file">
<input type="hidden" name="id" th:value="${file.id}" />
<div>
<label>
Datei
<input type="text" name="filePath" th:value="${file.filePath}" />
</label>
</div>
<div>
<label class="optional">
Zugehörige Probe (Einsendenummer)
<select name="labDataId">
<option>Keine Zuordnung</option>
<option th:each="labData : ${labDatas}" th:selected="${file.labDataId == labData.id}" th:value="${labData.id}" th:text="${labData.einsendenummer}"></option>
</select>
</label>
</div>
<div>
<label>
Dateityp
<select name="fileType">
<option th:selected="${file.fileType.value == 'bam'}">BAM</option>
<option th:selected="${file.fileType.value == 'vcf'}">Vcf</option>
<option th:selected="${file.fileType.value == 'bed'}">BED</option>
<option th:selected="${file.fileType.value == 'fastq'}">FASTQ</option>
</select>
</label>
</div>
<div>
<label>
Prüfsumme - SHA256
<input type="text" pattern="[A-Fa-f0-9]{64}" name="fileChecksum" th:value="${file.fileChecksum} " />
</label>
</div>
<div>
<label>
Dateigröße in Bytes
<input type="number" min="0" name="fileSizeInBytes" th:value="${file.fileSizeInBytes} " />
</label>
</div>
<div>
<button class="save" th:hx-put="@{/files/unused/{fileId}(fileId=${file.id})}" hx-target="body">Änderungen übernehmen</button>
</div>
</form>
</div>
</div>
</main>
</div>
<script th:src="@{/main.js}"></script>
</body>
</html>

View File

@ -0,0 +1,30 @@
export default {
entry: {
main: './src/main/web/script.js',
},
output: {
path: './src/main/resources/static',
chunkFilename: '[id].js'
},
module: {
rules: [
{
test: /\.css$/,
use: [{
loader: "postcss-loader",
options: {
postcssOptions: {
plugins: {
"@tailwindcss/postcss": {},
},
}
}
}],
type: "css"
},
]
},
experiments: {
css: true,
}
}

3
src/main/web/script.js Normal file
View File

@ -0,0 +1,3 @@
import 'htmx.org';
import * as styles from './style.css';

190
src/main/web/style.css Normal file
View File

@ -0,0 +1,190 @@
@import "tailwindcss";
body {
@apply bg-white dark:bg-gray-950 flex flex-col h-screen;
}
aside {
@apply fixed top-0 left-0 z-40 w-80 p-5 h-screen bg-gray-100 text-gray-600
}
aside > .title {
@apply m-2 text-2xl font-bold
}
aside > nav {
@apply mt-2
}
aside > nav ul {
@apply m-2
}
aside > nav a:before {
content: '🗎';
margin-right: 4px;
opacity: 0.8;
width: 1.2em;
text-align: center;
}
aside > nav a:hover:before {
opacity: 1;
}
aside > nav a:hover {
@apply text-gray-900 bg-gray-300
}
aside > nav a {
@apply flex p-1 rounded-sm
}
aside > nav a > span {
@apply nth-[1]:flex-1
}
aside > nav a .counter {
@apply inline-flex items-center justify-center min-w-3 h-3 p-3 font-bold text-xs text-white bg-gray-500 rounded-full
}
main {
@apply mx-auto pl-84 px-10 py-1 w-full;
}
h1, h2, h3, h4 {
@apply mt-5 text-white py-1 px-3 rounded-t-sm
}
h1 {
@apply text-3xl font-bold bg-blue-900
}
h2 {
@apply text-2xl font-bold bg-green-900
}
h3 {
@apply text-xl font-bold bg-yellow-900
}
h4 {
@apply font-bold bg-red-900
}
form {
@apply p-2 rounded-b-sm
}
label {
@apply mr-4 w-1/2 text-sm
}
input,
select {
@apply text-base w-full text-black
}
label.optional {
@apply italic
}
label.optional input,
label.optional select {
font-style: normal;
background: repeating-linear-gradient(135deg, var(--color-white), var(--color-white) 4px, transparent 4px, transparent 8px)
}
input[type=text],
input[type=date],
input[type=number],
select {
@apply block font-mono border-b-2 border-b-gray-300 bg-gray-50 focus:outline-none focus:border-gray-600 focus:bg-gray-200 h-8 p-1
}
input[type=submit],
input[type=reset],
button {
@apply cursor-pointer bg-gray-700 text-white rounded-sm px-6 py-2 mx-auto my-2 w-fit transition-colors
}
input[type=submit]:hover,
input[type=reset]:hover,
button:hover {
@apply bg-gray-900
}
div:has(> label) {
@apply my-2 flex
}
.case {
@apply border-l-4 border-l-blue-900 bg-blue-100
}
.case input[type=text],
.case input[type=date],
.case input[type=number],
.case select {
@apply focus:border-blue-600 focus:bg-blue-200
}
.donor {
@apply border-l-4 border-l-green-900 bg-green-100
}
.donor input[type=text],
.donor input[type=date],
.donor input[type=number],
.donor select {
@apply focus:border-green-600 focus:bg-green-200
}
.lab-data {
@apply border-l-4 border-l-yellow-900 bg-yellow-100
}
.lab-data input[type=text],
.lab-data input[type=date],
.lab-data input[type=number],
.lab-data select {
@apply focus:border-yellow-600 focus:bg-yellow-200
}
.file {
@apply border-l-4 border-l-red-900 bg-red-100
}
.file input[type=text],
.file input[type=date],
.file input[type=number],
.file select {
@apply focus:border-red-600 focus:bg-red-200
}
button {
@apply text-base font-normal
}
button.left {
@apply m-0 py-0 px-1 mx-1 float-right text-base font-normal
}
button.new:before {
content: '';
margin-right: .5em;
}
button.delete:before {
content: '🗑';
margin-right: .5em;
}
button.save:before {
content: '💾';
margin-right: .5em;
}
.case-list > li {
@apply my-2 p-1 odd:bg-blue-200
}
.case-list > li > a {
@apply w-full
}