pipeline and improvements
Some checks failed
Deploy Miniplan / build (push) Failing after 2m23s

This commit is contained in:
walamana 2024-08-07 20:08:52 +02:00
parent 24f14da9b2
commit dde21c3ac5
54 changed files with 1651 additions and 237 deletions

3
.dockerignore Normal file
View File

@ -0,0 +1,3 @@
node_modules
public/node_modules
public/dist

View File

@ -0,0 +1,27 @@
name: "Deploy Miniplan"
on:
push:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v2
with:
registry: git.walamana.de
username: ${{vars.actions_user}}
password: ${{secrets.PACKAGES_TOKEN}}
- name: Build Docker image
run: docker build -t git.walamana.de/walamana/miniplan .
- name: Push Docker image to registry
run: docker push git.walamana.de/walamana/miniplan

2
.gitignore vendored
View File

@ -1,2 +1,4 @@
.directory
.idea
node_modules
package-lock.json

29
Dockerfile Normal file
View File

@ -0,0 +1,29 @@
FROM gradle:latest as build_backend
COPY --chown=gradle:gradle /private/minis-backend /home/gradle/src
WORKDIR /home/gradle/src
RUN gradle buildFatJar --no-daemon
FROM node:18 as build_frontend
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app
WORKDIR /home/node/app
COPY --chown=node:node /public ./
USER node
WORKDIR /home/node/app
RUN npm install
RUN npm run build-only
FROM openjdk:latest as miniplan
LABEL authors="walamana"
RUN mkdir -p /app/public
COPY --from=build_backend /home/gradle/src/build/libs/*.jar /app/backend.jar
COPY --from=build_frontend /home/node/app/dist /app/public
COPY /private/minis-backend/application_docker.conf /app/application.conf
WORKDIR /app
ENTRYPOINT ["java", "-jar", "backend.jar", "-config=application.conf"]

12
Dockerfile.old Normal file
View File

@ -0,0 +1,12 @@
FROM openjdk:latest
LABEL authors="walamana"
RUN mkdir /miniplan
COPY ./private/minis-backend/build/libs/minis-backend-all.jar /miniplan/backend.jar
COPY ./private/minis-backend/application_docker.conf /miniplan/application.conf
ADD ./public/dist /miniplan/public
WORKDIR /miniplan
ENTRYPOINT ["java", "-jar", "backend.jar", "-config=application.conf"]

4
docker-compose.yml Normal file
View File

@ -0,0 +1,4 @@
name: "Miniplan"
services:
app:

View File

@ -0,0 +1,3 @@
build
local
public

View File

@ -0,0 +1,13 @@
KTOR_DEPLOYMENT_PORT=8080
KTOR_DEVELOPMENT=false
JWT_SECRET=def
JWT_ISSUER=localhost
JWT_AUDIENCE=localhost
JWT_REALM=miniplan
DATABASE_URL=jdbc:h2:file:./db
DATABASE_DRIVER=org.h2.Driver
DATABASE_USER=abc
DATABASE_PASSWORD=abc
ADMIN_PASSWORD=123
FRONTEND_PATH=./public
APPLICATION_NAME=Miniplan\ Hl.\ Familie

View File

@ -33,4 +33,9 @@ out/
/.nb-gradle/
### VS Code ###
.vscode/
.vscode/
.env
local/
db.mv.db

View File

@ -0,0 +1,25 @@
FROM gradle:latest as build_backend
COPY --chownn=gradle:gradle . /home/gradle/src
WORKDIR /home/gradle/src
RUN gradle buildFatJar --no-daemon
FROM node:18 as build_frontend
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app
WORKDIR /home/node/app
COPY --chown=node:node /pub
FROM openjdk:latest
LABEL authors="walamana"
RUN mkdir /miniplan
COPY ./build/libs/minis-backend-all.jar /miniplan/backend.jar
COPY ./application_docker.conf /miniplan/application.conf
ADD ./../../public/dist /miniplan/public
WORKDIR /miniplan
ENTRYPOINT ["java", "-jar", "backend.jar", "-config=application.conf"]

View File

@ -1,7 +1,7 @@
ktor {
development = true
deployment {
port = 8080
port = "${PORT}"
}
application {
modules = [ de.walamana.ApplicationKt.module ]
@ -9,12 +9,17 @@ ktor {
}
jwt {
secret = ${SECRET}
secret = "${SECRET}"
issuer = "http://0.0.0.0:8080/"
audience = "http://0.0.0.0:8080/"
realm = "mini-data"
}
database {
url = "jdbc:h2:file:./db"
driver = "org.h2.Driver"
}
admin {
password = ${ADMIN}
}
password = "${ADMIN}"
}

View File

@ -0,0 +1,27 @@
ktor {
development = ${KTOR_DEVELOPMENT}
deployment {
port = ${KTOR_DEPLOYMENT_PORT}
}
application {
modules = [ de.walamana.ApplicationKt.module ]
}
}
jwt {
secret = ${JWT_SECRET}
issuer = ${JWT_ISSUER}
audience = ${JWT_AUDIENCE}
realm = ${JWT_REALM}
}
database {
url = ${DATABASE_URL}
driver = ${DATABASE_DRIVER}
user = ${DATABASE_USER}
password = ${DATABASE_PASSWORD}
}
admin {
password = ${ADMIN_PASSWORD}
}

View File

@ -0,0 +1,30 @@
ktor {
deployment {
port = ${KTOR_DEPLOYMENT_PORT}
}
application {
modules = [ de.walamana.ApplicationKt.module ]
}
}
jwt {
secret = "${JWT_SECRET}"
issuer = "${JWT_ISSUER}"
audience = "${JWT_AUDIENCE}"
realm = "${JWT_REALM}"
}
database {
url = "jdbc:postgresql://localhost:5432/miniplan"
driver = "org.postgresql.Driver"
user = "miniplan"
password = "${DB_PASSWORD}"
}
admin {
password = "${ADMIN}"
}
application {
name = "${APPLICATION_NAME}"
}

View File

@ -8,6 +8,7 @@ plugins {
kotlin("jvm") version "1.9.0"
id("io.ktor.plugin") version "2.3.3"
kotlin("plugin.serialization") version "1.9.0"
id("com.palantir.docker") version "0.35.0"
}
group = "de.walamana"
@ -35,12 +36,19 @@ dependencies {
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm")
implementation("org.jetbrains.exposed:exposed-core:$exposed_version")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposed_version")
implementation("com.h2database:h2:$h2_version")
implementation("io.ktor:ktor-server-netty-jvm")
implementation("ch.qos.logback:logback-classic:$logback_version")
// implementation("org.postgresql:postgresql:42.7.2")
implementation("com.h2database:h2:$h2_version")
implementation("io.github.cdimascio:dotenv-kotlin:6.4.1")
implementation("at.favre.lib:bcrypt:0.10.2")
testImplementation("io.ktor:ktor-server-tests-jvm")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
}
docker {
name = "${project.name}:${project.version}"
}

View File

@ -1,6 +1,6 @@
ktor_version=2.3.3
kotlin_version=1.9.0
logback_version=1.2.11
logback_version=1.4.14
kotlin.code.style=official
exposed_version=0.43.0
h2_version=2.1.214
h2_version=2.2.224

View File

@ -0,0 +1 @@
./../../public/dist/

View File

@ -2,8 +2,10 @@ package de.walamana
import de.walamana.plugins.*
import de.walamana.views.*
import io.github.cdimascio.dotenv.dotenv
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.http.content.*
import io.ktor.server.netty.*
import io.ktor.server.routing.*
@ -12,7 +14,14 @@ import io.ktor.server.routing.*
// .start(wait = true)
//}
fun main(args: Array<String>): Unit = EngineMain.main(args)
fun main(args: Array<String>): Unit {
dotenv {
ignoreIfMissing = true
ignoreIfMalformed = true
systemProperties = true
}
EngineMain.main(args)
}
fun Application.module() {
configureSecurity()
@ -21,6 +30,9 @@ fun Application.module() {
configureDatabases()
routing {
singlePageApplication {
vue(System.getenv("FRONTEND_PATH"))
}
route("/api") {
configureMinistrantenRoutes()
configureGottesdiensteRoutes()

View File

@ -0,0 +1,59 @@
package de.walamana.models
import de.walamana.plugins.DateAsLong
import de.walamana.plugins.dbQuery
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import java.util.Date
@Serializable
data class GottesdienstGroup (
val id: Int,
val from: DateAsLong,
val to: DateAsLong
)
object GottesdienstGroups : Table() {
val id = integer("id").autoIncrement()
val from = long("date_from")
val to = long("date_to")
override val primaryKey = PrimaryKey(id)
}
object GottesdienstGroupDao {
fun ResultRow.toPlan() = GottesdienstGroup(
this[GottesdienstGroups.id],
Date(this[GottesdienstGroups.from]),
Date(this[GottesdienstGroups.to])
)
suspend fun getPlans(amount: Int = 10, offset: Long = 0) = dbQuery {
GottesdienstGroups.selectAll()
.orderBy(GottesdienstGroups.from to SortOrder.DESC)
.limit(amount, offset)
.map { it.toPlan() }
}
suspend fun createPlan(from: Date, to: Date) = dbQuery {
GottesdienstGroups.insert {
it[GottesdienstGroups.from] = from.time
it[GottesdienstGroups.to] = to.time
}.resultedValues?.firstOrNull()?.toPlan()
}
suspend fun updatePlan(group: GottesdienstGroup): Boolean = dbQuery {
GottesdienstGroups.upsert {
it[id]= group.id
it[from] = group.from.time
it[to] = group.to.time
}.resultedValues?.isNotEmpty() ?: false
}
suspend fun deletePlan(id: Int) = dbQuery {
GottesdienstGroups.deleteWhere { GottesdienstGroups.id eq id } > 0
}
}

View File

@ -22,8 +22,9 @@ object Gottesdienste : Table() {
val date = long("date")
val attendance = long("attendance")
val planId = integer("planId")
.references(GottesdienstGroups.id, onDelete = ReferenceOption.CASCADE)
override val primaryKey = PrimaryKey(id, planId)
override val primaryKey = PrimaryKey(id)
}
object GottesdiensteDao {
@ -72,4 +73,4 @@ object GottesdiensteDao {
if (planId != null) it[Gottesdienste.planId] = planId
} > 0
}
}
}

View File

@ -4,6 +4,7 @@ import de.walamana.plugins.dbQuery
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.transaction
@Serializable
data class Mark(
@ -14,9 +15,9 @@ data class Mark(
object Marks : Table() {
val mid = integer("mid")
.references(Ministranten.id);
.references(Ministranten.id, onDelete = ReferenceOption.CASCADE);
val gid = integer("gid")
.references(Gottesdienste.id);
.references(Gottesdienste.id, onDelete = ReferenceOption.CASCADE);
val value = integer("value");
override val primaryKey = PrimaryKey(mid, gid)
@ -51,4 +52,4 @@ object MarksDao {
} > 0
}
}
}

View File

@ -17,7 +17,7 @@ import java.util.*
data class Ministrant(
val id: Int,
val username: String,
val passwordHash: String,
val passwordHash: String? = "",
val firstname: String,
val lastname: String,
val birthday: DateAsLong,
@ -56,7 +56,12 @@ object MinistrantenDao {
)
suspend fun allMinistranten(): List<Ministrant> = dbQuery {
Ministranten.selectAll().map(::resultRowToMinistrant)
Ministranten.selectAll()
.orderBy(
Ministranten.lastname to SortOrder.ASC,
Ministranten.firstname to SortOrder.ASC
)
.map(::resultRowToMinistrant)
}
suspend fun simplifiedMinistranten(): List<SimplifiedMinistrant> = dbQuery {
@ -65,14 +70,19 @@ object MinistrantenDao {
Ministranten.username,
Ministranten.firstname,
Ministranten.lastname
).selectAll().map { row ->
SimplifiedMinistrant(
row[Ministranten.id],
row[Ministranten.username],
row[Ministranten.firstname],
row[Ministranten.lastname]
).selectAll()
.orderBy(
Ministranten.lastname to SortOrder.ASC,
Ministranten.firstname to SortOrder.ASC
)
}
.map { row ->
SimplifiedMinistrant(
row[Ministranten.id],
row[Ministranten.username],
row[Ministranten.firstname],
row[Ministranten.lastname]
)
}
}
suspend fun getMinistrant(username: String, showPasswordHash: Boolean = false): Ministrant? = dbQuery {
@ -123,4 +133,4 @@ object MinistrantenDao {
if (privileges != null) it[Ministranten.privileges] = privileges.joinToString(",")
}
}
}
}

View File

@ -1,5 +1,6 @@
package de.walamana.plugins
import de.walamana.models.GottesdienstGroups
import de.walamana.models.Gottesdienste
import de.walamana.models.Marks
import de.walamana.models.Ministranten
@ -11,16 +12,25 @@ import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransacti
import org.jetbrains.exposed.sql.transactions.transaction
fun Application.configureDatabases() {
val driverClassName = "org.h2.Driver"
val jdbcURL = "jdbc:h2:file:./build/db"
val database = Database.connect(jdbcURL, driverClassName)
val driverClassName = environment.config.property("database.driver").getString()
val jdbcURL = environment.config.property("database.url").getString()
val user = environment.config.propertyOrNull("database.user")?.getString()
val password = environment.config.propertyOrNull("database.password")?.getString()
println("Using database driver $driverClassName")
val database = if(user != null && password != null )
Database.connect(jdbcURL, driverClassName, user, password)
else
Database.connect(jdbcURL, driverClassName)
transaction {
SchemaUtils.create(Gottesdienste)
SchemaUtils.create(Ministranten)
SchemaUtils.create(GottesdienstGroups)
SchemaUtils.create(Gottesdienste)
SchemaUtils.create(Marks)
}
}
suspend fun <T> dbQuery(block: suspend () -> T): T =
newSuspendedTransaction(Dispatchers.IO) { block() }
suspend fun <T> dbQuery(block: suspend Transaction.() -> T): T =
newSuspendedTransaction(Dispatchers.IO) { block() }

View File

@ -62,10 +62,15 @@ object Security {
fun DEFAULT_EXPIRY() = Date(System.currentTimeMillis() + 1000 * 60 * 60);
suspend fun authenticateUser(application: Application, username: String, password: String): Ministrant? {
println("Username $username password $password")
if (username == "admin") {
println("Test")
val adminPw = application.environment.config.property("admin.password").getString()
println("Test $adminPw")
if (adminPw == password) {
val allMinis = MinistrantenDao.allMinistranten().map { it.username }
println(allMinis)
return Ministrant(
0, "admin", "", "admin", "admin", Date(), allMinis
)
@ -103,4 +108,4 @@ object Security {
.withClaim("privileges", ministrant.privileges)
.withExpiresAt(DEFAULT_EXPIRY())
.sign(Algorithm.HMAC256(jwtEnv.secret))
}
}

View File

@ -21,6 +21,7 @@ fun Application.configureSerialization() {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
ignoreUnknownKeys = true
})
}

View File

@ -13,12 +13,17 @@ data class Plan (
object PlanDao {
suspend fun constructPlan(planId: Int): Plan {
suspend fun constructPlan(planId: Int, isAnonymous: Boolean): Plan {
val gottesdienste = GottesdiensteDao.getGottesdiensteForPlan(planId)
val ministranten = MinistrantenDao.simplifiedMinistranten()
var ministranten = MinistrantenDao.simplifiedMinistranten()
if(isAnonymous) {
ministranten = ministranten.map {
SimplifiedMinistrant(it.id, it.username, it.firstname, if(it.lastname.isNotEmpty()) it.lastname[0].toString() + "." else "")
}
}
val marks = MarksDao.allMarksForPlan(planId)
return Plan(gottesdienste, ministranten, marks)
}
}
}

View File

@ -81,9 +81,12 @@ fun Route.configureAuthenticationRoutes() {
return@post
}
val request = call.receive<PasswordResetRequest>()
val newPassword = BCrypt.withDefaults()
.hash(Math.random().toInt() * 3 + 4, Math.random().toString().toCharArray())
.toString().substring(3..10);
var newPassword = "";
for(i in 0..2) {
newPassword += BCrypt.withDefaults()
.hash(Math.random().toInt() * 3 + 4, (Math.random().toString() + Math.random().toString()).toCharArray())
};
newPassword = newPassword.substring(3..10)
Security.setPassword(request.username, newPassword)
@ -94,4 +97,4 @@ fun Route.configureAuthenticationRoutes() {
}
}
}
}
}

View File

@ -2,6 +2,7 @@ package de.walamana.views
import de.walamana.models.Ministrant
import de.walamana.models.MinistrantenDao
import de.walamana.models.SimplifiedMinistrant
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
@ -17,9 +18,10 @@ fun Route.configureMinistrantenRoutes() {
}
put {
val data = call.receive<Ministrant>()
val passwordHash = data.passwordHash ?: return@put
val ministrant = MinistrantenDao.createMinistrant(
data.username,
data.passwordHash,
passwordHash,
data.firstname,
data.lastname,
data.birthday,
@ -29,16 +31,27 @@ fun Route.configureMinistrantenRoutes() {
}
patch {
// TODO: Access only by admin
val data = call.receive<Ministrant>()
val changed = MinistrantenDao.updateMinistrant(
data.id,
data.username,
data.passwordHash,
data.firstname,
data.lastname,
data.birthday,
data.privileges
)
val simple = false
if(simple) {
val data = call.receive<SimplifiedMinistrant>()
val changed = MinistrantenDao.updateMinistrant(
id = data.id,
username = data.username,
firstname = data.firstname,
lastname = data.lastname
)
}else{
val data = call.receive<Ministrant>()
val changed = MinistrantenDao.updateMinistrant(
data.id,
data.username,
null,
data.firstname,
data.lastname,
data.birthday,
data.privileges
)
}
call.respond(HttpStatusCode.OK)
}
delete {
@ -47,4 +60,4 @@ fun Route.configureMinistrantenRoutes() {
call.respond(HttpStatusCode.OK)
}
}
}
}

View File

@ -1,7 +1,12 @@
package de.walamana.views
import de.walamana.models.GottesdienstGroup
import de.walamana.models.GottesdienstGroupDao
import de.walamana.models.GottesdienstGroups
import de.walamana.service.PlanDao
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
@ -10,9 +15,34 @@ import io.ktor.server.util.*
fun Route.configurePlanRoutes() {
route("/plan") {
get {
val principal = call.principal<JWTPrincipal>()
val isAnonymous = principal == null
val id = call.parameters.getOrFail("id").toInt()
val plan = PlanDao.constructPlan(id);
val plan = PlanDao.constructPlan(id, isAnonymous = isAnonymous);
call.respond(plan)
}
}
}
route("/groups") {
get {
val offset = call.parameters.get("offset")?.toLong() ?: 0
val amount = call.parameters.get("amount")?.toInt() ?: 10
val groups = GottesdienstGroupDao.getPlans(amount, offset)
call.respond(groups)
}
post {
val group = call.receive<GottesdienstGroup>()
val res = GottesdienstGroupDao.createPlan(group.from, group.to) ?: TODO("fail")
call.respond(res)
}
patch {
val group = call.receive<GottesdienstGroup>()
val res = GottesdienstGroupDao.updatePlan(group) ?: TODO("fail")
call.respond(res)
}
delete {
val id = call.parameters.getOrFail("id").toInt()
val res = GottesdienstGroupDao.deletePlan(id)
call.respond(hashMapOf("success" to res))
}
}
}

View File

@ -8,8 +8,8 @@ import io.ktor.server.testing.*
import kotlin.test.*
class ApplicationTest {
@Test
fun testRoot() = testApplication {
// @Test
// fun testRoot() = testApplication {
// application {
// configureRouting()
// }
@ -17,5 +17,5 @@ class ApplicationTest {
// assertEquals(HttpStatusCode.OK, status)
// assertEquals("Hello World!", bodyAsText())
// }
}
// }
}

View File

@ -4,6 +4,7 @@ import HelloWorld from './components/HelloWorld.vue'
import LoginPanel from "@/components/LoginPanel.vue";
import {onMounted, ref} from "vue";
import {Auth} from "@/services/auth";
import DialogHost from "@/components/dialog/DialogHost.vue";
let showPopup = ref(false)
let loggedIn = ref(false)
@ -16,6 +17,10 @@ function logout(){
Auth.logout()
}
function print(){
window.print()
}
onMounted(() => {
Auth.checkForToken()
})
@ -23,11 +28,12 @@ onMounted(() => {
</script>
<template>
<nav>
<nav class="no-print">
<div class="left">
Miniplan
Miniplan App
</div>
<div class="right">
<button class="flat" @click="print"><i>print</i>Drucken</button>
<button v-if="!loggedIn" class="flat" @click="showPopup = true"><i>login</i> Einloggen</button>
<button v-if="loggedIn" class="flat" @click="logout"><i>logout</i> Abmelden</button>
</div>
@ -37,6 +43,8 @@ onMounted(() => {
<div class="popup-container" :class="{show: showPopup}" @click.self="showPopup = false">
<LoginPanel :active="showPopup" @success="showPopup = false"/>
</div>
<DialogHost/>
</template>
<style scoped lang="less">
@ -54,6 +62,9 @@ nav {
.right {
flex-shrink: 0;
}
@media print{
display: none;
}
}
.popup-container {
@ -75,13 +86,16 @@ nav {
pointer-events: none;
}
&.show{
&.show {
pointer-events: auto;
* {
pointer-events: auto;
}
opacity: 1;
}
}
</style>

View File

@ -1,4 +1,5 @@
@import './base.css';
@import 'base';
@import "transitions";
html, body {
margin: 0;
@ -26,6 +27,9 @@ html, body {
}
button {
--color-btn-background: #d7eaf3;
--color-btn-text: #0e2c48;
--color-btn-border: #bed4e0;
display: inline-flex;
align-items: center;
justify-content: center;
@ -34,10 +38,10 @@ button {
margin: 0 4px;
border-radius: 6px;
font-weight: 600;
background: #d7eaf3;
color: #0e2c48;
border: 1px solid #bed4e0;
transition: 100ms border-color;
background: var(--color-btn-background);
color: var(--color-btn-text);
border: 1px solid var(--color-btn-border);
transition: 100ms border-color, 100ms box-shadow;
}
button.flat {
@ -45,20 +49,40 @@ button.flat {
border: 1px solid transparent;
}
button.red {
--color-btn-background: #f3d7d7;
--color-btn-text: #480e0e;
--color-btn-border: #dbb0b0;
}
button i {
margin-right: 10px;
color: #0e2c48;
color: var(--color-btn-text);
padding: 0;
}
button.icon{
padding-right: 10px;
i{
margin-right: 0;
}
}
button.flat:hover{
border-color: #e5e5e5;
}
button:not(.flat):hover {
background: #e4eff6;
box-shadow: 0 1px 2px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.18);
}
button:not(.flat):active {
background: #d0e3f1;
}
/*button:not(.flat):active {*/
/* background: #d0e3f1;*/
/*}*/
@media print {
.no-print {
display: none !important;
}
}

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,212 @@
<script setup lang="ts">
import {onMounted, reactive, ref, watch} from "vue";
import Input from "@/components/Input.vue";
import {useRouter} from "vue-router";
const props = defineProps<{
groups: any[],
admin: boolean
}>()
const emit = defineEmits(["change", "new", "delete", "edit"])
const router = useRouter()
const prev = ref<number | null>(null)
const cur = ref(0)
const next = ref<number | null>(null)
const newPlan = reactive({
id: 0,
from: null,
to: null
})
// watch(props, (value, oldValue, onCleanup) => {
// setToCurrentDate()
// })
onMounted(() => {
setToCurrentDate()
})
function getGroups() {
if (newPlan.id != 0) {
return [newPlan].concat(props.groups)
} else {
return props.groups
}
}
function setToCurrentDate() {
const date = new Date().getTime()
let g = props.groups.findIndex(g => date >= g.from && date <= g.to)
if (g == -1) {
prev.value = 1;
cur.value = 0;
next.value = null
g = props.groups[0]
} else {
prev.value = g + 1
cur.value = g
next.value = g - 1
}
emit("change", get(cur.value)?.id ?? 0)
}
function forward() {
prev.value--;
cur.value--;
next.value--;
// emit("change", get(cur.value).id)
router.push("/" + get(cur.value).id)
}
function back() {
prev.value++;
cur.value++;
next.value++;
// emit("change", get(cur.value).id)
router.push("/" + get(cur.value).id)
}
function two(s) {
return (s < 10 ? "0" : "") + s
}
function formatDate(time) {
const date = new Date(time)
return two(date.getDate()) + ". " + getNameOfMonth(date.getMonth())
}
function formatDateShort(time) {
const date = new Date(time)
return two(date.getDate()) + "." + two(date.getMonth() + 1) + "."
}
function getNameOfMonth(month): string {
switch (month) {
case 0:
return "Januar";
case 1:
return "Februar";
case 2:
return "März";
case 3:
return "April";
case 4:
return "Mai";
case 5:
return "Juni";
case 6:
return "Juli";
case 7:
return "August";
case 8:
return "September";
case 9:
return "Oktober";
case 10:
return "November";
case 11:
return "Dezember";
}
return "?"
}
function get(i) {
if (i < 0) return null;
return getGroups()[i]
}
function index(id) {
return id
}
function dateToValueString(time) {
const date = new Date(time)
return date.getFullYear() + "-" + two(date.getMonth() + 1) + "-" + two(date.getDate())
}
</script>
<template>
<div class="bar">
<div class="controls">
<button class="flat left" v-if="prev != null && prev <= groups.length - 1" @click="back">
<i>arrow_left_alt</i>{{ formatDateShort(get(prev).from) }} - {{ formatDateShort(get(prev).to) }}
</button>
<div class="width: 100%"/>
<button class="flat right" v-if="next != null && next > 0" @click="forward">
{{ formatDateShort(get(next).from) }} - {{ formatDateShort(get(next).to) }}<i>arrow_right_alt</i>
</button>
<button class="flat right" v-if="(next == null || next <= 0) && admin" style="margin-right: 20px"
@click="$emit('new')">
<i>add</i> Neuer plan
</button>
</div>
<template v-if="groups.length > 0">
<span style="z-index: 1">Miniplan vom {{ formatDate(get(cur).from) }} bis {{ formatDate(get(cur).to) }}</span>
<span v-if="admin" style="display: flex; align-items: center; z-index: 1">
<button class="icon flat" @click="$emit('delete', get(cur).id)"><i>delete</i></button>
<button class="icon flat" @click="$emit('edit', get(cur).id)"><i>edit</i></button>
</span>
</template>
<span v-else>
Keine Gottesdienstgruppen vorhanden
</span>
</div>
</template>
<style scoped lang="less">
.bar {
display: flex;
width: calc(100% - 30px);
align-items: center;
justify-content: center;
border-bottom: 1px solid #d7d5d5;
z-index: 10;
padding: 15px;
position: relative;
.controls {
display: flex;
justify-content: space-between;
position: absolute;
top: 0;
left: 0;
width: calc(100% - 10px);
height: calc(100% - 10px);
padding: 5px;
}
.left, .right{
flex-shrink: 0;
}
.left {
margin-left: 10px;
}
.right {
i {
margin-left: 10px;
}
}
span {
font-weight: 700;
}
button {
margin-right: 10px;
}
.input {
margin: 0 10px
}
}
</style>

View File

@ -1,29 +1,71 @@
<script setup lang="ts">
import {ref} from "vue";
import {onMounted, ref} from "vue";
const props = defineProps<{
const props = withDefaults(defineProps<{
value: any,
label?: string,
disabled?: boolean,
type?: string
}>()
type?: string,
dateFormat?: "string" | "number",
focus?: boolean
}>(), {
dateFormat: "string"
})
const emit = defineEmits(["update:value"])
const inputEl = ref<HTMLInputElement | undefined>()
const focus = ref(false)
onMounted(() => {
if(props.focus) {
inputEl.value?.focus()
}
})
function update($event) {
if(props.type == "date" && props.dateFormat == "number") {
console.log($event.target.value)
emit("update:value", new Date($event.target.value).getTime())
}else{
emit('update:value', $event.target.value)
}
}
function zeros(val) {
return val < 10 ? "0" + val : val + ""
}
function leading(val) {
let leading = ""
if(val < 10) leading = "000"
else if(val < 100) leading = "00"
else if(val < 1000) leading = "0"
return leading + val
}
function getValue(){
if(props.type == "date" && props.dateFormat == "number") {
console.log(props.value)
const date = new Date(props.value)
return leading(date.getFullYear()) + "-" + zeros(date.getMonth() + 1) + "-" + zeros(date.getDate())
}else{
return props.value
}
}
</script>
<template>
<div class="input">
<label v-if="label" :class="{up: props.value != '' || focus, focus}">{{ label }}</label>
<label v-if="label" :class="{up: props.value != '' || focus || type == 'date', focus}">{{ label }}</label>
<input
:value="value"
@input="$emit('update:value', $event.target.value)"
:value="getValue()"
@input="update"
:type="props.type ? props.type : 'text'"
:disabled="disabled"
@focusin="focus = true"
@focusout="focus = false">
@focusout="focus = false"
ref="inputEl">
</div>
</template>

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import Input from "@/components/Input.vue";
import {onMounted, ref} from "vue";
import {onMounted, ref, watch} from "vue";
import {API} from "@/services/api";
import {Auth} from "@/services/auth";
@ -21,12 +21,23 @@ onMounted(() => {
const username = ref("")
const password = ref("")
const invalid = ref(false)
watch(props, (value, oldValue, onCleanup) => {
if(!value.active) {
username.value = ""
password.value = ""
invalid.value = false
}
})
async function attemptLogin() {
let login = await Auth.login(username.value, password.value)
if(login.success){
console.log("success", login)
emit("success", login)
}else{
invalid.value = true
}
}
</script>
@ -37,6 +48,7 @@ async function attemptLogin() {
<span class="title">Anmelden</span>
<Input class="input" v-model:value="username" label="Nutzername"/>
<Input class="input" v-model:value="password" label="Passwort" type="password"/>
<span v-if="invalid" style="color: red; text-align: center; margin-bottom: 10px">Benutzername oder<br> Passwort falsch.</span>
<button @click="attemptLogin"><i>login</i>Anmelden</button>
</div>

View File

@ -59,7 +59,7 @@ function two(s) {
function formatDay(time) {
let date = new Date(time)
return two(date.getDate()) + "." + two(date.getMonth()) + "."
return two(date.getDate()) + "." + two(date.getMonth() + 1) + "."
}
function formatTime(time) {
@ -99,7 +99,7 @@ function getMinistrantClasses(mini: SimplifiedMinistrant) {
}
function getMinis() {
return props.ministranten.filter(m => props.editable.includes(m.username))
return props.ministranten.filter(m => props.editable.length === 0 || props.editable.includes(m.username))
}
function getMiniName(mini) {
return mini.firstname + " " + mini.lastname

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
const emit = defineEmits(["addPlan", "save"])
const emit = defineEmits(["addPlan", "addGodi", "addMini", "save"])
const props = defineProps<{
save: boolean,
plan: boolean,
@ -12,6 +12,8 @@ const props = defineProps<{
<div class="action-bar" :class="{save: props.save}">
<div class="other-action">
<button class="add-plan" :class="{show: props.plan}" @click="$emit('addPlan')"> <i class="icon">add_box</i> Neuer Plan</button>
<button class="add-godi" :class="{show: props.godi}" @click="$emit('addGodi')"> <i class="icon">add</i> Gottesdienst</button>
<button class="add-mini" :class="{show: props.godi}" @click="$emit('addMini')"> <i class="icon">add</i> Ministrant</button>
</div>
<button class="save" :class="{show: props.save}" @click="$emit('save')"><i class="icon">save</i> Änderungen speichern </button>
</div>
@ -58,7 +60,11 @@ button {
}
}
button{
@media print {
.action-bar{
display: none;
}
}
</style>

View File

@ -2,9 +2,10 @@
import {API} from "@/services/api";
import {onMounted, reactive, ref} from "vue";
import {onMounted, reactive, ref, toRaw} from "vue";
import type {Gottesdienst, Mark, PlanModel, SimplifiedMinistrant} from "@/models/models";
import Input from "@/components/Input.vue";
import GroupView from "@/components/GroupView.vue";
const props = defineProps<{
gottesdienste: Gottesdienst[],
@ -14,22 +15,13 @@ const props = defineProps<{
edit: boolean,
smallMode: boolean
}>()
const emit = defineEmits(["toggleMark", "added", "delete", "endEdit", "resetPassword"])
const emit = defineEmits(["toggleMark", "added", "delete", "endEdit", "resetPassword", "deleteMinistrant", "createMinistrant", "editMinistrant"])
const openEditUser = ref<number>(-1)
const miniCopy = reactive<{ data?: SimplifiedMinistrant }>({})
const data = reactive({
godi: {}
})
onMounted(() => {
window.addEventListener("keypress", ev => {
if(ev.key == "Enter" && props.edit){
emit("added", data.godi, () => {
data.godi = {}
})
}
})
})
function getIconForMark(gid, mid) {
const mark = getMark(gid, mid).value
@ -45,7 +37,6 @@ function getIconForMark(gid, mid) {
return ""
}
function getClassForMark(gid, mid) {
const mark = getMark(gid, mid).value
return {
@ -74,7 +65,7 @@ function two(s) {
function formatDay(time) {
let date = new Date(time)
return two(date.getDate()) + "." + two(date.getMonth()) + "."
return two(date.getDate()) + "." + two(date.getMonth() + 1) + "."
}
function formatTime(time) {
@ -112,82 +103,103 @@ function getMinistrantClasses(mini: SimplifiedMinistrant) {
edit: props.editable.includes(mini.username)
}
}
function toggleEditMinistrant(mini: SimplifiedMinistrant) {
console.log("Toggled", miniCopy.data, mini, props.ministranten, openEditUser)
if (openEditUser.value == mini.id) {
miniCopy.data = undefined
openEditUser.value = -1;
} else {
openEditUser.value = mini.id
miniCopy.data = toRaw(mini)
}
}
</script>
<template>
<table>
<div class="container">
<thead>
<table>
<tr v-if="props.edit">
<th></th>
<th v-for="godi in props.gottesdienste"><i @click="$emit('delete', godi.id)">delete</i></th>
<th><i @click="$emit('endEdit')">close</i></th>
</tr>
<thead>
<tr>
<th></th>
<th v-for="godi in props.gottesdienste">{{ godi.name }}</th>
<th class="edit" v-if="props.edit"><Input v-model:value="data.godi.name"/></th>
</tr>
<tr class="bold">
<th>Datum</th>
<th v-for="godi in props.gottesdienste">{{ formatDay(godi.date) }}</th>
<th class="edit" v-if="props.edit"><Input v-model:value="data.godi.date" type="date"/></th>
</tr>
<tr>
<th>Uhrzeit</th>
<th v-for="godi in props.gottesdienste">{{ formatTime(godi.date) }}</th>
<th class="edit" v-if="props.edit"><Input v-model:value="data.godi.time" type="time"/></th>
</tr>
<tr class="bold">
<th>Anwesenheit</th>
<th v-for="godi in props.gottesdienste">{{ formatTime(godi.attendance) }}</th>
<th class="edit" v-if="props.edit"><Input v-model:value="data.godi.attendance" type="time"/></th>
</tr>
<tr>
<th>Wochentag</th>
<th v-for="godi in props.gottesdienste">{{ formatWeekday(godi.date) }}</th>
<th class="edit" v-if="props.edit"></th>
</tr>
<tr v-if="props.edit" class="no-print">
<th></th>
<th v-for="godi in props.gottesdienste"><i @click="$emit('delete', godi.id)">delete</i></th>
</tr>
</thead>
<tbody>
<tr>
<th></th>
<th v-for="godi in props.gottesdienste" class="name">{{ godi.name }}</th>
</tr>
<tr class="bold">
<th>Datum</th>
<th v-for="godi in props.gottesdienste">{{ formatDay(godi.date) }}</th>
</tr>
<tr>
<th>Uhrzeit</th>
<th v-for="godi in props.gottesdienste">{{ formatTime(godi.date) }}</th>
</tr>
<tr class="bold">
<th>Anwesenheit</th>
<th v-for="godi in props.gottesdienste">{{ formatTime(godi.attendance) }}</th>
</tr>
<tr>
<th>Wochentag</th>
<th v-for="godi in props.gottesdienste">{{ formatWeekday(godi.date) }}</th>
</tr>
<tr v-for="mini in props.ministranten" class="ministrant" :class="getMinistrantClasses(mini)">
<td class="name"><i v-if="edit" style="margin-right: 10px" @click="$emit('resetPassword', mini.username)">lock_reset</i>{{ mini.id }} {{ mini.firstname }} {{ mini.lastname }}</td>
<td
v-for="godi in props.gottesdienste"
class="mark"
:class="getClassForMark(godi.id, mini.id)"
@click="$emit('toggleMark', godi.id, mini.id)">
<i class="icon"> {{ getIconForMark(godi.id, mini.id) }} </i><br>
<span class="hint">{{ getHintForMark(godi.id, mini.id) }}</span>
</td>
<td class="edit" v-if="props.edit"></td>
</thead>
<tbody>
<tr v-for="mini in props.ministranten" class="ministrant" :class="getMinistrantClasses(mini)">
<td class="name">
<div class="center">
<i class="edit-button no-print"
v-if="edit"
style="margin-right: 10px; font-size: 18px"
@click="$emit('editMinistrant', mini.id)">edit</i>
{{ mini.firstname }}
{{ mini.lastname }}
</div>
</td>
<td
v-for="godi in props.gottesdienste"
class="mark"
:class="getClassForMark(godi.id, mini.id)"
@click="$emit('toggleMark', godi.id, mini.id)">
<i class="icon"> {{ getIconForMark(godi.id, mini.id) }} </i><br>
<span class="hint no-print">{{ getHintForMark(godi.id, mini.id) }}</span>
</td>
</tr>
</tr>
</tbody>
</table>
</tbody>
</table>
</div>
</template>
<style scoped lang="less">
table {
border-spacing: 0;
min-width: 100%;
}
tr{
th{
tr {
th {
font-weight: 400;
&.edit{
&.edit {
text-align: start;
}
padding: 10px;
}
&.bold th{
&.bold th {
font-weight: 700;
}
}
@ -199,9 +211,10 @@ td {
td:first-child, th:first-child {
padding: 6px 30px 6px 12px;
text-align: left;
min-width: 150px;
}
td:nth-child(2n), th:nth-child(2n){
td:nth-child(2n), th:nth-child(2n) {
background: #8ce081;
}
@ -222,19 +235,15 @@ td:nth-child(2n), th:nth-child(2n){
margin: 2px;
}
&.minus {
background: #fdd5d5;
color: #690b0b;
i {
@media not print {
&.minus {
background: #fdd5d5;
color: #690b0b;
}
}
&.cross {
background: #d1fcd1;
color: #045b04;
i {
&.cross {
background: #d1fcd1;
color: #045b04;
}
}
@ -253,34 +262,70 @@ td:nth-child(2n), th:nth-child(2n){
//mix-blend-mode: difference;
}
&:not(.showIcon){
&:not(.showIcon) {
padding: 0 !important;
.hint, br{
.hint, br {
display: none !important;
}
}
}
.ministrant {
.center {
display: flex;
align-items: center;
.edit-button {
cursor: pointer;
user-select: none;
}
}
.controls {
display: flex;
flex-direction: column;
&:not(.show) {
display: none;
}
padding: 20px 10px;
.input {
padding-bottom: 8px;
}
}
}
.ministrant.edit {
box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
z-index: 10;
position: relative;
td {
padding-top: 10px;
padding-bottom: 10px;
}
.name{
.name {
align-items: center;
height: 100%;
}
.mark{
cursor: pointer;
&.neutral i {
i {
opacity: 0.5;
}
}
.mark {
cursor: pointer;
@media not print {
&.neutral i {
opacity: 0.5;
}
}
.hint {
display: inline-block;
}

View File

@ -0,0 +1,37 @@
<script setup lang="ts">
import Dialog from "@/components/dialog/Dialog.vue";
import type {DialogControls} from "@/components/dialog/dialog";
interface AlertDialogProps extends DialogControls {
title: string,
text: string,
positive: string,
icon?: string
}
const props = defineProps<AlertDialogProps>()
</script>
<template>
<Dialog class="dialog">
<h3 style="margin-bottom: 10px"><i v-if="icon">{{icon}}</i>{{title}}</h3>
<p>{{text}}</p>
<div class="buttons" style="display: flex; justify-content: end; margin-top: 20px;">
<button @click="onPositive" colored>{{positive}}</button>
</div>
</Dialog>
</template>
<style scoped lang="less">
.dialog {
display: flex;
flex-direction: column;
width: 500px
}
</style>

View File

@ -0,0 +1,39 @@
<script setup lang="ts">
import Dialog from "@/components/dialog/Dialog.vue";
import type {DialogControls} from "@/components/dialog/dialog";
interface ConfirmDialogProps extends DialogControls {
title: string,
text: string,
positive: string,
negative: string,
icon?: string
}
const props = defineProps<ConfirmDialogProps>()
</script>
<template>
<Dialog class="dialog">
<h3 style="margin-bottom: 10px"><i v-if="icon">{{icon}}</i>{{title}}</h3>
<p>{{text}}</p>
<div class="buttons" style="display: flex; justify-content: end; margin-top: 20px;">
<button @click="onNegative">{{negative}}</button>
<button @click="onPositive" colored>{{positive}}</button>
</div>
</Dialog>
</template>
<style scoped lang="less">
.dialog {
display: flex;
flex-direction: column;
width: 500px
}
</style>

View File

@ -0,0 +1,81 @@
<script setup lang="ts">
import Dialog from "@/components/dialog/Dialog.vue";
import type {DialogControls} from "@/components/dialog/dialog";
import Input from "@/components/Input.vue";
import {onMounted, ref, toRaw} from "vue";
import type {Gottesdienst, GottesdienstGroup} from "@/models/models";
import {onKeyPress} from "@/composables/enter";
interface CreateGottesdienstDialogProps extends DialogControls {
onCreate: (Gottesdienst) => (Promise<any> | undefined),
godi?: Gottesdienst,
planId: number
}
onKeyPress("Enter", create)
const props = defineProps<CreateGottesdienstDialogProps>()
const date = ref("")
const time = ref("")
const godi = ref(props.godi ?? {
planId: props.planId,
date: "",
attendance: "",
name: "",
id: -1
})
let submitted = false
async function create(){
if(submitted) return;
submitted = true
await props.onCreate({
planId: godi.value.planId,
date: new Date(date.value + "T" + time.value),
attendance: new Date(date.value + "T" + godi.value.attendance),
name: godi.value.name,
id: godi.value.id
})
props.onDismiss()
submitted = false
}
</script>
<template>
<Dialog class="dialog">
<h3>Gottesdienst {{ godi.id == -1 ? "erstellen" : "bearbeiten"}}</h3>
<Input class="input" v-model:value="godi.name" label="Name" focus/>
<Input class="input" v-model:value="date" type="date" label="Datum"/>
<Input class="input" v-model:value="time" type="time" label="Um"/>
<Input class="input" v-model:value="godi.attendance" type="time" label="Anwesenheit"/>
<div class="buttons" style="display: flex; justify-content: end; margin-top: 20px;">
<button @click="onDismiss">Abbrechen</button>
<button @click="create">{{ godi?.id == -1 ? "Erstellen" : "Speichern"}}</button>
</div>
</Dialog>
</template>
<style scoped lang="less">
.dialog {
display: flex;
flex-direction: column;
width: 500px;
h3{
margin-bottom: 30px;
}
.input {
margin-bottom: 16px;
}
}
</style>

View File

@ -0,0 +1,107 @@
<script setup lang="ts">
import Dialog from "@/components/dialog/Dialog.vue";
import type {DialogControls} from "@/components/dialog/dialog";
import Input from "@/components/Input.vue";
import {ref, toRaw} from "vue";
import {onKeyPress} from "@/composables/enter";
import {API} from "@/services/api";
import {Dialogs} from "@/services/DialogService";
interface CreateMinistrantDialogProps extends DialogControls {
onCreate: (any) => (Promise<any> | undefined),
onDelete: (id) => (Promise<any> | undefined),
ministrant?: any
}
onKeyPress("Enter", create)
const props = defineProps<CreateMinistrantDialogProps>()
const date = ref("")
const time = ref("")
let submitted = false
const ministrant = ref(props.ministrant ?? {
id: -1,
username: "",
firstname: "",
lastname: "",
birthday: "",
privileges: "",
})
async function create(){
if(submitted) return;
submitted = true
const { id, username, firstname, lastname, privileges, birthday } = toRaw(ministrant.value)
await props.onCreate({
id, username, firstname, lastname, privileges,
birthday: new Date(birthday)
})
props.onDismiss()
submitted = false
}
async function deleteMinistrant() {
if(submitted) return;
submitted = true
const mini = ministrant.value
const shouldDelete = confirm("Möchtest du wirklich " + mini.firstname + " " + mini.lastname + " löschen? Der Ministrant und alle Eintragungen können nicht wiederhergestellt werden!")
if(!shouldDelete) return;
await props.onDelete(mini.id);
props.onDismiss()
submitted = false
}
async function resetPassword(username: string) {
const result = await API.resetPassword(username)
alert("Neues Passwort für " + username + "\n\n" + result.password)
console.log(result)
}
</script>
<template>
<Dialog class="dialog">
<h3>Ministrant {{ ministrant.id == -1 ? "erstellen" : "bearbeiten"}}</h3>
<Input class="input" v-model:value="ministrant.username" label="Nutzername" focus/>
<Input class="input" v-model:value="ministrant.firstname" label="Vorname"/>
<Input class="input" v-model:value="ministrant.lastname" label="Nachname"/>
<Input class="input" v-model:value="ministrant.birthday" type="date" label="Geburtstag"/>
<Input class="input" v-model:value="ministrant.privileges" label="Privilegien"/>
<div class="actions" v-if="ministrant.id != -1">
<button @click="resetPassword(ministrant.username)"><i>lock_reset</i>Passwort zurücksetzen
</button>
<button class="red" @click="deleteMinistrant()"><i>delete</i>Entfernen</button>
</div>
<div class="buttons" style="display: flex; justify-content: end; margin-top: 20px;">
<button @click="onDismiss">Abbrechen</button>
<button @click="create">{{ ministrant.id == -1 ? "Erstellen" : "Speichern"}}</button>
</div>
</Dialog>
</template>
<style scoped lang="less">
.dialog {
display: flex;
flex-direction: column;
width: 500px;
h3{
margin-bottom: 30px;
}
.input {
margin-bottom: 16px;
}
}
</style>

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import Dialog from "@/components/dialog/Dialog.vue";
import type {DialogControls} from "@/components/dialog/dialog";
import Input from "@/components/Input.vue";
import {ref, toRaw} from "vue";
import type {GottesdienstGroup} from "@/models/models";
import {onKeyPress} from "@/composables/enter";
interface CreatePlanDialogProps extends DialogControls {
onCreate: (GottesdienstGroup) => (Promise<any> | undefined),
group?: GottesdienstGroup
}
onKeyPress("Enter", create)
const props = defineProps<CreatePlanDialogProps>()
let submitted = false;
const plan = ref<GottesdienstGroup>(props.group ?? {
from: "",
to: "",
id: -1
})
async function create() {
if(submitted) return;
submitted = true
await props.onCreate(toRaw(plan.value))
props.onDismiss()
submitted = false
}
</script>
<template>
<Dialog class="dialog">
<h3>Neuen Plan {{ plan.id == -1 ? "erstellen" : "bearbeiten" }}</h3>
<Input class="input" v-model:value="plan.from" type="date" label="Von" focus/>
<Input class="input" v-model:value="plan.to" type="date" label="Bis"/>
<div class="buttons" style="display: flex; justify-content: end; margin-top: 20px;">
<button @click="onDismiss">Abbrechen</button>
<button @click="create">{{ plan.id == -1 ? "Erstellen" : "Speichern" }}</button>
</div>
</Dialog>
</template>
<style scoped lang="less">
.dialog {
display: flex;
flex-direction: column;
width: 500px;
h3{
margin-bottom: 30px;
}
.input {
margin-bottom: 16px;
}
}
</style>

View File

@ -0,0 +1,26 @@
<script setup lang="ts">
import type {DialogOptions} from "@/components/dialog/dialog";
const props = defineProps<DialogOptions>()
</script>
<template>
<div class="dialog">
<slot></slot>
</div>
</template>
<style scoped lang="less">
.dialog {
padding: 10px 30px 30px 30px;
background: white;
border-radius: 8px;
box-shadow: 0 3px 6px rgba(0,0,0,0.08), 0 3px 6px rgba(0,0,0,0.16);
}
</style>

View File

@ -0,0 +1,76 @@
<script setup lang="ts">
import type {Component} from "vue";
import {createApp, ref} from "vue";
import type {DialogOptions} from "@/components/dialog/dialog";
import {Dialogs} from "@/services/DialogService";
const host = ref<HTMLDivElement>()
const active = ref(false)
let mountedApplication = null
Dialogs.subject.subscribe(({component, controls, props}) => {
createDialog(component, controls, props)
})
function createDialog(component: Component, controls: DialogOptions, props: any) {
const mergedProps = {
onPositive: (...args) => { controls?.onPositive?.call(this, ...args); closeDialog()},
onNegative: (...args) => { controls?.onNegative?.call(this, ...args); closeDialog()},
onDismiss: closeDialog,
...props
}
closeDialog()
const app = createApp(component, mergedProps)
app.mount(host.value!!)
mountedApplication = app
active.value = true
}
function closeDialog() {
if(mountedApplication != null){
mountedApplication.unmount()
mountedApplication = null
active.value = false
}
}
</script>
<template>
<div class="host" ref="host" :class="{active}" @mousedown.self="closeDialog()">
</div>
</template>
<style scoped lang="less">
.host {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
top: 0;
left: 0;
position: fixed;
background: rgba(0, 0, 0, 0);
z-index: 100;
pointer-events: none;
transition: 200ms opacity;
opacity: 0;
&.active{
pointer-events: auto;
background: rgba(0, 0, 0, 0.3);
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,18 @@
export interface DialogControls {
onPositive: () => void,
onNegative: () => void,
onDismiss: () => void,
}
export interface DialogOptions {
onPositive?: () => void,
onNegative?: () => void,
}
export interface ConfirmDialogOptions {
title: string,
text: string,
positive: string,
negative: string,
icon?: string
}

View File

@ -0,0 +1,20 @@
import {onMounted, onUnmounted} from "vue";
export function useWindowEvent<K extends keyof WindowEventMap>(event: K, listener: (this: Window, ev: WindowEventMap[K]) => any, options?: boolean | AddEventListenerOptions | undefined){
onMounted(() => {
window.addEventListener(event, listener, options as any)
})
onUnmounted(() => {
window.removeEventListener(event, listener)
})
}
export function onKeyPress(key: string, listener: () => void) {
useWindowEvent("keydown", (event) => {
if(event.key == key) {
listener()
}
})
}

View File

@ -1,4 +1,4 @@
import './assets/main.css'
import './assets/main.less'
import { createApp } from 'vue'
import App from './App.vue'

View File

@ -24,3 +24,9 @@ export interface PlanModel {
ministranten: SimplifiedMinistrant[],
marks: Mark[]
}
export interface GottesdienstGroup {
from: string,
to: string,
id: number
}

View File

@ -1,14 +1,30 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/PlanView.vue'
import {API} from "@/services/api";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
path: '/:id',
name: 'home',
component: HomeView
},
{
path: "/",
name: "home-redirect",
component: null,
beforeEnter: async (to, from) => {
const groups = (await API.getPlans())
.sort((a, b) => a.id - b.id)
return {
name: "home",
params: {
id: groups.length > 0 ? groups[groups.length - 1].id : 0
}
}
}
}
]
})

View File

@ -0,0 +1,42 @@
import type {Component} from "vue";
import {Subject} from "rxjs";
import type {ConfirmDialogOptions, DialogControls} from "@/components/dialog/dialog";
import ConfirmDialog from "@/components/dialog/ConfirmDialog.vue";
export namespace Dialogs {
interface NextDialog {
component: Component,
controls: DialogControls,
props: any
}
export const subject = new Subject<NextDialog>()
export function createDialog(component: Component, controls: DialogControls, props: any) {
subject.next({component, controls, props})
}
export async function confirm(options: ConfirmDialogOptions) {
return new Promise<boolean | void>((resolve, reject) => {
const controls: DialogControls = {
onPositive() {
resolve(true)
},
onNegative() {
resolve(false)
},
onDismiss() {
resolve()
}
}
const next: NextDialog = {
component: ConfirmDialog,
controls,
props: options
}
subject.next(next)
})
}
}

View File

@ -1,8 +1,11 @@
import type {Gottesdienst, Mark} from "@/models/models";
import type {Gottesdienst, GottesdienstGroup, Mark} from "@/models/models";
import {Auth} from "@/services/auth";
const API_ENDPOINT = "http://0.0.0.0:8080/api"
const API_ENDPOINT = import.meta.env.MODE == "development"
? "http://0.0.0.0:8080/api"
: window.location.origin + "/api"
export async function api(endpoint: string, method: string = "GET", body?: any ) {
let isJson = (typeof body == "object")
@ -40,6 +43,26 @@ export namespace API {
})
}
export async function getPlans(): Promise<GottesdienstGroup[]> {
return api("/groups").then(res => res.json())
}
export async function createPlan(from, to) {
return api("/groups", "POST", {
id: -1,
from,
to
}).then(res => res.json())
}
export async function deletePlan(id) {
return api("/groups?id=" + id, "DELETE").then(res => res.json())
}
export async function updatePlan(group) {
return api("/groups", "PATCH", group).then(res => res.json())
}
function formatGottesdienste(data: any): Array<Gottesdienst>{
return data.map(json => {
json["date"] = new Date(json["date"])
@ -64,10 +87,26 @@ export namespace API {
})
}
export async function getMinistranten() {
return api("/ministranten", "GET").then(res => res.json())
}
export async function deleteGottesdienst(id) {
return api("/gottesdienste?id=" + id, "DELETE")
.then(data => data.status == 200)
}
export async function deleteMinistrant(id) {
return api("/ministranten?id=" + id, "DELETE")
.then(data => data.status == 200)
}
export async function createMinistrant(ministrant) {
return api("/ministranten", "PUT", ministrant).then(data => data.json())
}
export async function updateMinistrant(ministrant) {
return api("/ministranten", "PATCH", ministrant)
.then(data => data.status == 200)
}
export async function setMarks(marks: Mark[]): Promise<boolean> {
return api("/marks", "PATCH", marks)

View File

@ -1,37 +1,110 @@
<script setup lang="ts">
import {onMounted, reactive, ref, toRaw, computed} from "vue";
import {computed, onMounted, reactive, ref, toRaw, watch} from "vue";
import TablePlan from "@/components/TablePlan.vue";
import {API} from "@/services/api";
import type {Gottesdienst, Mark, PlanModel, SimplifiedMinistrant} from "@/models/models";
import type {Gottesdienst, GottesdienstGroup, Mark, SimplifiedMinistrant} from "@/models/models";
import MobilePlan from "@/components/MobilePlan.vue";
import PlanActionBar from "@/components/PlanActionBar.vue";
import {Auth} from "@/services/auth";
import GroupView from "@/components/GroupView.vue";
import {Dialogs} from "@/services/DialogService";
import ConfirmDialog from "@/components/dialog/ConfirmDialog.vue";
import CreatePlanDialog from "@/components/dialog/CreatePlanDialog.vue";
import CreateGottesdienstDialog from "@/components/dialog/CreateGottesdienstDialog.vue";
import CreateMinistrantDialog from "@/components/dialog/CreateMinistrantDialog.vue";
import {useRoute, useRouter} from "vue-router";
import {min} from "rxjs";
const MAX_WIDTH_MOBILE = 600;
const router = useRouter()
const route = useRoute()
const plan = reactive<{
gottesdienste: Gottesdienst[],
ministranten: SimplifiedMinistrant[],
marks: Mark[],
editable: string[]
editable: string[],
groups: GottesdienstGroup[]
}>({
gottesdienste: [],
ministranten: [],
marks: [],
editable: []
editable: [],
groups: []
})
const mobile = ref(window.innerWidth <= MAX_WIDTH_MOBILE)
const editedMarks = reactive<Mark[]>([]);
const editPlanAdmin = ref(false)
const planId = ref(parseInt(route.params.id as string))
onMounted(async () => {
const groups = await API.getPlans()
console.log("Groups", groups)
plan.groups = groups
loadPlan()
})
watch(() => route.params.id, async (value, oldValue) => {
planId.value = parseInt(value as string)
await loadPlan()
})
async function loadPlan() {
const { ministranten, gottesdienste, marks} = await API.getPlan(planId.value)
plan.ministranten = ministranten
plan.gottesdienste = gottesdienste
plan.marks = marks
Auth.checkForToken()
}
Auth.loggedInSubject.subscribe(async (loggedIn) => {
console.log("logged in " + loggedIn)
if (loggedIn) {
plan.editable = Auth.getPrivileges()
if (Auth.getUser() == "admin") {
editPlanAdmin.value = true
plan.ministranten = await API.getMinistranten();
console.log("Plan", plan.ministranten)
}
} else {
editPlanAdmin.value = false
plan.editable = []
}
})
window.addEventListener("resize", (ev) => {
mobile.value = window.innerWidth <= MAX_WIDTH_MOBILE
})
const sortedGottesdienste = computed(() => {
return plan.gottesdienste.sort((a, b) => {
console.log(a, b)
return a.date - b.date
})
})
async function createGottesdienst(){
Dialogs.createDialog(CreateGottesdienstDialog, {
onPositive() {},
onNegative() {},
onDismiss() {}
}, {
planId: parseInt(planId.value as string),
async onCreate(godi: Gottesdienst) {
const newGodi = await API.addGottesdienst(
godi.name,
godi.date,
godi.attendance,
godi.planId
)
plan.gottesdienste.push(newGodi)
}
})
}
async function addGodi(data, validate) {
console.log("Test")
console.log(data)
@ -44,7 +117,7 @@ async function addGodi(data, validate) {
data.name,
new Date(date),
new Date(attendance),
0
parseInt(planId.value as string)
)
console.log(newGodi)
plan.gottesdienste.push(newGodi);
@ -60,30 +133,6 @@ async function deleteGottedienst(id) {
}
onMounted(async () => {
let fetchedPlan = await API.getPlan(0)
plan.gottesdienste = fetchedPlan.gottesdienste
plan.ministranten = fetchedPlan.ministranten
plan.marks = fetchedPlan.marks
Auth.checkForToken()
})
Auth.loggedInSubject.subscribe(loggedIn => {
if (loggedIn) {
plan.editable = Auth.getPrivileges()
if(Auth.getUser() == "admin"){
editPlanAdmin.value = true
}
}else {
editPlanAdmin.value = false
plan.editable = []
}
})
window.addEventListener("resize", (ev) => {
mobile.value = window.innerWidth <= MAX_WIDTH_MOBILE
})
function getMarks(): Mark[] {
return plan.marks.filter((mark: Mark) => {
let difMark = editedMarks.find((m: Mark) => m.gid == mark.gid && m.mid == mark.mid)
@ -100,7 +149,7 @@ function getDif(): Mark[] {
async function saveChanges() {
const saved = await API.setMarks(getDif())
if(saved) {
if (saved) {
plan.marks = getMarks()
editedMarks.length = 0
}
@ -133,60 +182,152 @@ function toggleMark(gid, mid) {
}
async function createNewPlan() {
Dialogs.createDialog(CreatePlanDialog, {
onPositive: () => {},
onNegative: () => {},
onDismiss: () => {},
}, {
onCreate: async (group: GottesdienstGroup) => {
const newPlan = await API.createPlan(new Date(group.from).getTime(), new Date(group.to).getTime());
plan.groups = [newPlan].concat(toRaw(plan.groups))
router.replace("/" + newPlan.id)
}
})
}
async function deletePlan(id) {
if(!confirm("Möchtest du den Plan wirklich löschen?")) return
await API.deletePlan(id)
location.reload()
plan.groups.splice(plan.groups.findIndex(g => g.id == id), 1)
}
async function editPlan(id) {
const group = Object.assign({}, toRaw(plan.groups.find(g => g.id == id)))
group.from = new Date(group.from).toISOString().substring(0,10)
group.to = new Date(group.to).toISOString().substring(0,10)
Dialogs.createDialog(CreatePlanDialog, {
onPositive() {},
onNegative() {},
onDismiss() {}
}, {
group,
async onCreate(group) {
group.from = new Date(group.from).getTime()
group.to = new Date(group.to).getTime()
await API.updatePlan(group)
location.reload()
}
})
}
async function createMinistrant(ministrantId?: number) {
let ministrantRef = plan.ministranten.find(ministrant => ministrant.id == ministrantId)
const ministrant = ministrantRef ? Object.assign({}, toRaw(ministrantRef)) : null;
console.log("Found mini ref?", ministrant)
Dialogs.createDialog(CreateMinistrantDialog, {
onPositive() {},
onNegative() {},
onDismiss() {}
}, {
ministrant,
async onCreate(ministrant) {
ministrant.birthday = ministrant.birthday.getTime()
if(ministrant.id == -1) {
const newMinistrant = await API.createMinistrant(ministrant)
ministrant.id = newMinistrant.id
plan.ministranten.push(ministrant)
}else {
await API.updateMinistrant(ministrant)
const index = plan.ministranten.findIndex(m => m.id == ministrant.id)
plan.ministranten.splice(index, 1)
plan.ministranten.push(ministrant)
}
},
async onDelete(id) {
let deleted = await API.deleteMinistrant(id)
if (deleted) {
let index = plan.ministranten.findIndex(godi => godi.id == id)
plan.ministranten.splice(index, 1)
}
}
})
}
</script>
<template>
<main>
<TablePlan
:gottesdienste="sortedGottesdienste"
:ministranten="plan.ministranten"
:marks="getMarks()"
:editable="plan.editable"
:edit="editPlanAdmin"
:small-mode="editPlanAdmin"
@added="addGodi"
@delete="deleteGottedienst"
@toggle-mark="toggleMark"
@end-edit="editPlanAdmin = false"
@reset-password="resetPassword"
class="plan table"
v-if="!mobile">
<div class="container">
</TablePlan>
<GroupView :groups="plan.groups" :admin="editPlanAdmin"
@new="createNewPlan" @delete="deletePlan" @edit="editPlan" class="no-print"/>
<TablePlan
:gottesdienste="sortedGottesdienste"
:ministranten="plan.ministranten"
:marks="getMarks()"
:editable="plan.editable"
:edit="editPlanAdmin"
:small-mode="editPlanAdmin"
@added="addGodi"
@delete="deleteGottedienst"
@toggle-mark="toggleMark"
@end-edit="editPlanAdmin = false"
@reset-password="resetPassword"
@create-ministrant="createMinistrant"
@edit-ministrant="createMinistrant"
class="plan table"
v-if="!mobile">
<MobilePlan
:gottesdienste="sortedGottesdienste"
:ministranten="plan.ministranten"
:marks="getMarks()"
:editable="plan.editable"
@toggle-mark="toggleMark"
class="plan mobile"
v-else>
</TablePlan>
</MobilePlan>
<PlanActionBar
class="action-bar"
:save="getDif().length > 0"
:plan="false"
:godi="true"
@save="saveChanges()"
/>
<MobilePlan
:gottesdienste="sortedGottesdienste"
:ministranten="plan.ministranten"
:marks="getMarks()"
:editable="plan.editable"
@toggle-mark="toggleMark"
class="plan mobile"
v-else>
</MobilePlan>
<PlanActionBar
class="action-bar no-print"
:save="getDif().length > 0"
:plan="false"
:godi="true"
@save="saveChanges()"
@add-godi="createGottesdienst()"
@add-mini="createMinistrant()"
v-if="editPlanAdmin"
/>
</div>
</main>
</template>
<style scoped lang="less">
.container {
width: 100%;
overflow-x: auto;
@media print {
overflow-x: unset;
}
}
.plan {
padding-bottom: 100px;
}
.plan.table {
width: 100%;
//width: 100%;
}
.action-bar {