This commit is contained in:
parent
24f14da9b2
commit
dde21c3ac5
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
public/node_modules
|
||||
public/dist
|
||||
27
.gitea/workflows/deploy.yaml
Normal file
27
.gitea/workflows/deploy.yaml
Normal 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
2
.gitignore
vendored
@ -1,2 +1,4 @@
|
||||
.directory
|
||||
.idea
|
||||
node_modules
|
||||
package-lock.json
|
||||
29
Dockerfile
Normal file
29
Dockerfile
Normal 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
12
Dockerfile.old
Normal 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
4
docker-compose.yml
Normal file
@ -0,0 +1,4 @@
|
||||
name: "Miniplan"
|
||||
|
||||
services:
|
||||
app:
|
||||
3
private/minis-backend/.dockerignore
Normal file
3
private/minis-backend/.dockerignore
Normal file
@ -0,0 +1,3 @@
|
||||
build
|
||||
local
|
||||
public
|
||||
13
private/minis-backend/.env
Normal file
13
private/minis-backend/.env
Normal 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
|
||||
7
private/minis-backend/.gitignore
vendored
7
private/minis-backend/.gitignore
vendored
@ -33,4 +33,9 @@ out/
|
||||
/.nb-gradle/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
.vscode/
|
||||
|
||||
.env
|
||||
|
||||
local/
|
||||
db.mv.db
|
||||
25
private/minis-backend/Dockerfile
Normal file
25
private/minis-backend/Dockerfile
Normal 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"]
|
||||
@ -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}"
|
||||
}
|
||||
27
private/minis-backend/application_docker.conf
Normal file
27
private/minis-backend/application_docker.conf
Normal 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}
|
||||
}
|
||||
30
private/minis-backend/application_docker_copy.conf
Normal file
30
private/minis-backend/application_docker_copy.conf
Normal 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}"
|
||||
}
|
||||
@ -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}"
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
1
private/minis-backend/public
Symbolic link
1
private/minis-backend/public
Symbolic link
@ -0,0 +1 @@
|
||||
./../../public/dist/
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(",")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() }
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ fun Application.configureSerialization() {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
// }
|
||||
}
|
||||
// }
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
1
public/src/assets/transitions.less
Normal file
1
public/src/assets/transitions.less
Normal file
@ -0,0 +1 @@
|
||||
|
||||
212
public/src/components/GroupView.vue
Normal file
212
public/src/components/GroupView.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
37
public/src/components/dialog/AlertDialog.vue
Normal file
37
public/src/components/dialog/AlertDialog.vue
Normal 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>
|
||||
39
public/src/components/dialog/ConfirmDialog.vue
Normal file
39
public/src/components/dialog/ConfirmDialog.vue
Normal 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>
|
||||
81
public/src/components/dialog/CreateGottesdienstDialog.vue
Normal file
81
public/src/components/dialog/CreateGottesdienstDialog.vue
Normal 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>
|
||||
107
public/src/components/dialog/CreateMinistrantDialog.vue
Normal file
107
public/src/components/dialog/CreateMinistrantDialog.vue
Normal 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>
|
||||
66
public/src/components/dialog/CreatePlanDialog.vue
Normal file
66
public/src/components/dialog/CreatePlanDialog.vue
Normal 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>
|
||||
26
public/src/components/dialog/Dialog.vue
Normal file
26
public/src/components/dialog/Dialog.vue
Normal 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>
|
||||
76
public/src/components/dialog/DialogHost.vue
Normal file
76
public/src/components/dialog/DialogHost.vue
Normal 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>
|
||||
18
public/src/components/dialog/dialog.ts
Normal file
18
public/src/components/dialog/dialog.ts
Normal 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
|
||||
}
|
||||
20
public/src/composables/enter.ts
Normal file
20
public/src/composables/enter.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import './assets/main.css'
|
||||
import './assets/main.less'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
@ -24,3 +24,9 @@ export interface PlanModel {
|
||||
ministranten: SimplifiedMinistrant[],
|
||||
marks: Mark[]
|
||||
}
|
||||
|
||||
export interface GottesdienstGroup {
|
||||
from: string,
|
||||
to: string,
|
||||
id: number
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
42
public/src/services/DialogService.ts
Normal file
42
public/src/services/DialogService.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user