Add some budget pages
I also added titles to the other pages, but it needs styling
This commit is contained in:
parent
a2579dbd6d
commit
7e49dbeb31
14 changed files with 294 additions and 18 deletions
|
@ -178,6 +178,12 @@ fun Application.moduleWithDependencies(
|
||||||
}
|
}
|
||||||
validate(sessionValidator)
|
validate(sessionValidator)
|
||||||
}
|
}
|
||||||
|
session<CookieSession>(TWIGS_SESSION_COOKIE) {
|
||||||
|
challenge {
|
||||||
|
call.respond(HttpStatusCode.Unauthorized)
|
||||||
|
}
|
||||||
|
validate(sessionValidator)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
install(Sessions) {
|
install(Sessions) {
|
||||||
header<Session>("Authorization") {
|
header<Session>("Authorization") {
|
||||||
|
@ -254,7 +260,7 @@ fun Application.moduleWithDependencies(
|
||||||
recurringTransactionRoutes(recurringTransactionService)
|
recurringTransactionRoutes(recurringTransactionService)
|
||||||
transactionRoutes(transactionService)
|
transactionRoutes(transactionService)
|
||||||
userRoutes(userService)
|
userRoutes(userService)
|
||||||
webRoutes(budgetService, userService)
|
webRoutes(budgetService, categoryService, transactionService, userService)
|
||||||
launch {
|
launch {
|
||||||
while (currentCoroutineContext().isActive) {
|
while (currentCoroutineContext().isActive) {
|
||||||
jobs.forEach { it.run() }
|
jobs.forEach { it.run() }
|
||||||
|
|
|
@ -39,19 +39,19 @@ class DefaultBudgetService(
|
||||||
budgetPermissionRepository.budgetWithPermission(userId, budgetId, Permission.READ)
|
budgetPermissionRepository.budgetWithPermission(userId, budgetId, Permission.READ)
|
||||||
|
|
||||||
override suspend fun save(request: BudgetRequest, userId: String, budgetId: String?): BudgetResponse {
|
override suspend fun save(request: BudgetRequest, userId: String, budgetId: String?): BudgetResponse {
|
||||||
val budget = budgetId?.let {
|
val budget = if (budgetId?.isNotBlank() == true) {
|
||||||
budgetPermissionRepository.budgetWithPermission(
|
budgetPermissionRepository.budgetWithPermission(
|
||||||
budgetId = it,
|
budgetId = budgetId,
|
||||||
userId = userId,
|
userId = userId,
|
||||||
permission = Permission.MANAGE
|
permission = Permission.MANAGE
|
||||||
).run {
|
).run {
|
||||||
Budget(
|
Budget(
|
||||||
id = it,
|
id = budgetId,
|
||||||
name = request.name ?: name,
|
name = request.name ?: name,
|
||||||
description = request.description ?: description
|
description = request.description ?: description
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} ?: run {
|
} else {
|
||||||
if (request.name.isNullOrBlank()) {
|
if (request.name.isNullOrBlank()) {
|
||||||
throw HttpException(HttpStatusCode.BadRequest, message = "Name cannot be empty or null")
|
throw HttpException(HttpStatusCode.BadRequest, message = "Name cannot be empty or null")
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,21 +17,21 @@ import java.time.Instant
|
||||||
interface TransactionService {
|
interface TransactionService {
|
||||||
suspend fun transactions(
|
suspend fun transactions(
|
||||||
budgetIds: List<String>,
|
budgetIds: List<String>,
|
||||||
categoryIds: List<String>?,
|
categoryIds: List<String>? = null,
|
||||||
from: Instant?,
|
from: Instant? = null,
|
||||||
to: Instant?,
|
to: Instant? = null,
|
||||||
expense: Boolean?,
|
expense: Boolean? = null,
|
||||||
userId: String,
|
userId: String,
|
||||||
): List<TransactionResponse>
|
): List<TransactionResponse>
|
||||||
|
|
||||||
suspend fun transaction(transactionId: String, userId: String): TransactionResponse
|
suspend fun transaction(transactionId: String, userId: String): TransactionResponse
|
||||||
|
|
||||||
suspend fun sum(
|
suspend fun sum(
|
||||||
budgetId: String?,
|
|
||||||
categoryId: String?,
|
|
||||||
from: Instant?,
|
|
||||||
to: Instant?,
|
|
||||||
userId: String,
|
userId: String,
|
||||||
|
budgetId: String? = null,
|
||||||
|
categoryId: String? = null,
|
||||||
|
from: Instant? = null,
|
||||||
|
to: Instant? = null,
|
||||||
): Long
|
): Long
|
||||||
|
|
||||||
suspend fun save(
|
suspend fun save(
|
||||||
|
@ -78,11 +78,11 @@ class DefaultTransactionService(
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sum(
|
override suspend fun sum(
|
||||||
|
userId: String,
|
||||||
budgetId: String?,
|
budgetId: String?,
|
||||||
categoryId: String?,
|
categoryId: String?,
|
||||||
from: Instant?,
|
from: Instant?,
|
||||||
to: Instant?,
|
to: Instant?
|
||||||
userId: String
|
|
||||||
): Long {
|
): Long {
|
||||||
if (budgetId.isNullOrBlank() && categoryId.isNullOrBlank()) {
|
if (budgetId.isNullOrBlank() && categoryId.isNullOrBlank()) {
|
||||||
throw HttpException(HttpStatusCode.BadRequest, message = "budgetId or categoryId must be provided to sum")
|
throw HttpException(HttpStatusCode.BadRequest, message = "budgetId or categoryId must be provided to sum")
|
||||||
|
|
|
@ -1,3 +1,12 @@
|
||||||
package com.wbrawner.twigs.web
|
package com.wbrawner.twigs.web
|
||||||
|
|
||||||
open class Page(val title: String)
|
import com.wbrawner.twigs.service.user.UserResponse
|
||||||
|
|
||||||
|
interface Page {
|
||||||
|
val title: String
|
||||||
|
val error: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthenticatedPage : Page {
|
||||||
|
val user: UserResponse
|
||||||
|
}
|
|
@ -2,7 +2,10 @@ package com.wbrawner.twigs.web
|
||||||
|
|
||||||
import com.wbrawner.twigs.model.CookieSession
|
import com.wbrawner.twigs.model.CookieSession
|
||||||
import com.wbrawner.twigs.service.budget.BudgetService
|
import com.wbrawner.twigs.service.budget.BudgetService
|
||||||
|
import com.wbrawner.twigs.service.category.CategoryService
|
||||||
|
import com.wbrawner.twigs.service.transaction.TransactionService
|
||||||
import com.wbrawner.twigs.service.user.UserService
|
import com.wbrawner.twigs.service.user.UserService
|
||||||
|
import com.wbrawner.twigs.web.budget.budgetWebRoutes
|
||||||
import com.wbrawner.twigs.web.user.userWebRoutes
|
import com.wbrawner.twigs.web.user.userWebRoutes
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
import io.ktor.server.http.content.*
|
import io.ktor.server.http.content.*
|
||||||
|
@ -13,6 +16,8 @@ import io.ktor.server.sessions.*
|
||||||
|
|
||||||
fun Application.webRoutes(
|
fun Application.webRoutes(
|
||||||
budgetService: BudgetService,
|
budgetService: BudgetService,
|
||||||
|
categoryService: CategoryService,
|
||||||
|
transactionService: TransactionService,
|
||||||
userService: UserService
|
userService: UserService
|
||||||
) {
|
) {
|
||||||
routing {
|
routing {
|
||||||
|
@ -30,5 +35,6 @@ fun Application.webRoutes(
|
||||||
} ?: call.respond(MustacheContent("index.mustache", null))
|
} ?: call.respond(MustacheContent("index.mustache", null))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
budgetWebRoutes(budgetService, categoryService, transactionService, userService)
|
||||||
userWebRoutes(userService)
|
userWebRoutes(userService)
|
||||||
}
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
package com.wbrawner.twigs.web.budget
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.service.budget.BudgetResponse
|
||||||
|
import com.wbrawner.twigs.service.category.CategoryResponse
|
||||||
|
import com.wbrawner.twigs.service.transaction.BalanceResponse
|
||||||
|
import com.wbrawner.twigs.service.user.UserResponse
|
||||||
|
import com.wbrawner.twigs.web.AuthenticatedPage
|
||||||
|
|
||||||
|
data class BudgetListPage(
|
||||||
|
val budgets: List<BudgetResponse>,
|
||||||
|
override val user: UserResponse,
|
||||||
|
override val error: String? = null
|
||||||
|
) : AuthenticatedPage {
|
||||||
|
override val title: String = "Budgets"
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BudgetDetailsPage(
|
||||||
|
val budget: BudgetResponse,
|
||||||
|
val balance: BalanceResponse,
|
||||||
|
val categories: List<CategoryWithBalanceResponse>,
|
||||||
|
val archivedCategories: List<CategoryWithBalanceResponse>,
|
||||||
|
val transactionCount: Long,
|
||||||
|
override val user: UserResponse,
|
||||||
|
override val error: String? = null
|
||||||
|
) : AuthenticatedPage {
|
||||||
|
override val title: String = "Budgets"
|
||||||
|
|
||||||
|
data class CategoryWithBalanceResponse(val category: CategoryResponse, val balance: BalanceResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BudgetFormPage(
|
||||||
|
val budget: BudgetResponse,
|
||||||
|
override val user: UserResponse,
|
||||||
|
override val error: String? = null
|
||||||
|
) : AuthenticatedPage {
|
||||||
|
override val title: String = if (budget.id.isBlank()) {
|
||||||
|
"New Budget"
|
||||||
|
} else {
|
||||||
|
"Edit Budget"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,145 @@
|
||||||
|
package com.wbrawner.twigs.web.budget
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.endOfMonth
|
||||||
|
import com.wbrawner.twigs.firstOfMonth
|
||||||
|
import com.wbrawner.twigs.service.HttpException
|
||||||
|
import com.wbrawner.twigs.service.budget.BudgetRequest
|
||||||
|
import com.wbrawner.twigs.service.budget.BudgetResponse
|
||||||
|
import com.wbrawner.twigs.service.budget.BudgetService
|
||||||
|
import com.wbrawner.twigs.service.category.CategoryService
|
||||||
|
import com.wbrawner.twigs.service.requireSession
|
||||||
|
import com.wbrawner.twigs.service.transaction.BalanceResponse
|
||||||
|
import com.wbrawner.twigs.service.transaction.TransactionService
|
||||||
|
import com.wbrawner.twigs.service.user.UserService
|
||||||
|
import com.wbrawner.twigs.toInstantOrNull
|
||||||
|
import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE
|
||||||
|
import io.ktor.http.*
|
||||||
|
import io.ktor.server.application.*
|
||||||
|
import io.ktor.server.auth.*
|
||||||
|
import io.ktor.server.mustache.*
|
||||||
|
import io.ktor.server.request.*
|
||||||
|
import io.ktor.server.response.*
|
||||||
|
import io.ktor.server.routing.*
|
||||||
|
import io.ktor.server.util.*
|
||||||
|
|
||||||
|
fun Application.budgetWebRoutes(
|
||||||
|
budgetService: BudgetService,
|
||||||
|
categoryService: CategoryService,
|
||||||
|
transactionService: TransactionService,
|
||||||
|
userService: UserService
|
||||||
|
) {
|
||||||
|
routing {
|
||||||
|
authenticate(TWIGS_SESSION_COOKIE) {
|
||||||
|
route("/budgets") {
|
||||||
|
get {
|
||||||
|
val user = userService.user(requireSession().userId)
|
||||||
|
val budgets = budgetService.budgetsForUser(user.id)
|
||||||
|
call.respond(MustacheContent("budgets.mustache", BudgetListPage(budgets, user)))
|
||||||
|
}
|
||||||
|
|
||||||
|
route("/new") {
|
||||||
|
get {
|
||||||
|
val user = userService.user(requireSession().userId)
|
||||||
|
call.respond(
|
||||||
|
MustacheContent(
|
||||||
|
"budget-form.mustache",
|
||||||
|
BudgetFormPage(
|
||||||
|
BudgetResponse(
|
||||||
|
id = "",
|
||||||
|
name = "",
|
||||||
|
description = "",
|
||||||
|
users = listOf()
|
||||||
|
),
|
||||||
|
user
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
val user = userService.user(requireSession().userId)
|
||||||
|
try {
|
||||||
|
val request = call.receiveParameters().toBudgetRequest()
|
||||||
|
val budget = budgetService.save(request, user.id)
|
||||||
|
call.respondRedirect("/budgets/${budget.id}")
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
call.respond(
|
||||||
|
status = e.statusCode,
|
||||||
|
MustacheContent(
|
||||||
|
"budget-form.mustache",
|
||||||
|
BudgetFormPage(
|
||||||
|
BudgetResponse(
|
||||||
|
id = "",
|
||||||
|
name = call.parameters["name"].orEmpty(),
|
||||||
|
description = call.parameters["description"].orEmpty(),
|
||||||
|
users = listOf()
|
||||||
|
),
|
||||||
|
user,
|
||||||
|
e.message
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
route("/{id}") {
|
||||||
|
|
||||||
|
get {
|
||||||
|
val user = userService.user(requireSession().userId)
|
||||||
|
val budget = budgetService.budget(budgetId = call.parameters.getOrFail("id"), userId = user.id)
|
||||||
|
val balance = BalanceResponse(transactionService.sum(budgetId = budget.id, userId = user.id))
|
||||||
|
val categories = categoryService.categories(budgetIds = listOf(budget.id), userId = user.id)
|
||||||
|
.map { category ->
|
||||||
|
BudgetDetailsPage.CategoryWithBalanceResponse(
|
||||||
|
category,
|
||||||
|
BalanceResponse(transactionService.sum(categoryId = category.id, userId = user.id))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// TODO: Add a count method so we don't have to do this
|
||||||
|
val transactions = transactionService.transactions(
|
||||||
|
budgetIds = listOf(budget.id),
|
||||||
|
from = call.parameters["from"]?.toInstantOrNull() ?: firstOfMonth,
|
||||||
|
to = call.parameters["to"]?.toInstantOrNull() ?: endOfMonth,
|
||||||
|
userId = user.id
|
||||||
|
)
|
||||||
|
call.respond(
|
||||||
|
MustacheContent(
|
||||||
|
"budget-details.mustache", BudgetDetailsPage(
|
||||||
|
budget = budget,
|
||||||
|
balance = balance,
|
||||||
|
categories = categories.filter { !it.category.archived },
|
||||||
|
archivedCategories = categories.filter { it.category.archived },
|
||||||
|
transactionCount = transactions.size.toLong(),
|
||||||
|
user = user
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
route("/edit") {
|
||||||
|
get {
|
||||||
|
val user = userService.user(requireSession().userId)
|
||||||
|
val budget = budgetService.budget(
|
||||||
|
budgetId = call.parameters.getOrFail("id"),
|
||||||
|
userId = user.id
|
||||||
|
)
|
||||||
|
call.respond(
|
||||||
|
MustacheContent(
|
||||||
|
"budget-form.mustache",
|
||||||
|
BudgetFormPage(budget, user)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Parameters.toBudgetRequest() = BudgetRequest(
|
||||||
|
name = get("name"),
|
||||||
|
description = get("description"),
|
||||||
|
users = setOf() // TODO: Enable adding users at budget creation
|
||||||
|
)
|
|
@ -2,6 +2,10 @@ package com.wbrawner.twigs.web.user
|
||||||
|
|
||||||
import com.wbrawner.twigs.web.Page
|
import com.wbrawner.twigs.web.Page
|
||||||
|
|
||||||
data class LoginPage(val username: String = "", val error: String? = null) : Page("Login")
|
data class LoginPage(val username: String = "", override val error: String? = null) : Page {
|
||||||
|
override val title: String = "Login"
|
||||||
|
}
|
||||||
|
|
||||||
data class RegisterPage(val username: String = "", val email: String = "", val error: String? = null) : Page("Register")
|
data class RegisterPage(val username: String = "", val email: String = "", override val error: String? = null) : Page {
|
||||||
|
override val title: String = "Register"
|
||||||
|
}
|
14
web/src/main/resources/templates/budget-details.mustache
Normal file
14
web/src/main/resources/templates/budget-details.mustache
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
{{> partials/head }}
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div class="center">
|
||||||
|
<div class="flex-full-width">
|
||||||
|
{{>partials/budget-list}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{>partials/foot}}
|
||||||
|
</body>
|
||||||
|
</html>
|
22
web/src/main/resources/templates/budget-form.mustache
Normal file
22
web/src/main/resources/templates/budget-form.mustache
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
{{> partials/head }}
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<h1>{{title}}</h1>
|
||||||
|
<div class="center">
|
||||||
|
{{#error }}
|
||||||
|
<p class="error">{{error}}</p>
|
||||||
|
{{/error}}
|
||||||
|
<form method="post">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input id="name" type="text" name="name" value="{{ budget.name }}"/>
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<textarea id="description" name="description">{{ budget.description }}</textarea>
|
||||||
|
<input id="submit" type="submit" class="button button-primary" value="Save"/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{>partials/foot}}
|
||||||
|
</body>
|
||||||
|
</html>
|
14
web/src/main/resources/templates/budgets.mustache
Normal file
14
web/src/main/resources/templates/budgets.mustache
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
{{> partials/head }}
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div class="center">
|
||||||
|
<div class="flex-full-width">
|
||||||
|
{{>partials/budget-list}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{>partials/foot}}
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -3,6 +3,7 @@
|
||||||
{{> partials/head }}
|
{{> partials/head }}
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
|
<h1>{{title}}</h1>
|
||||||
<div class="center">
|
<div class="center">
|
||||||
<div class="logo"></div>
|
<div class="logo"></div>
|
||||||
{{#error }}
|
{{#error }}
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
<ul>
|
||||||
|
{{#budgets}}
|
||||||
|
<li>
|
||||||
|
<span class="budget-name">{{name}}</span>
|
||||||
|
<span class="budget-description">{{description}}</span>
|
||||||
|
</li>
|
||||||
|
{{/budgets}}
|
||||||
|
</ul>
|
||||||
|
{{^budgets}}
|
||||||
|
<p style="text-align: center;">Welcome to Twigs! It looks like you haven't created any budgets yet. Budgets are the
|
||||||
|
foundation for planning and growing your finances. To get started with Twigs, <a href="/budgets/new">create a
|
||||||
|
new budget</a> now!</p>
|
||||||
|
{{/budgets}}
|
|
@ -3,6 +3,7 @@
|
||||||
{{> partials/head }}
|
{{> partials/head }}
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
|
<h1>{{title}}</h1>
|
||||||
<div class="center">
|
<div class="center">
|
||||||
<div class="logo"></div>
|
<div class="logo"></div>
|
||||||
{{#error }}
|
{{#error }}
|
||||||
|
|
Loading…
Reference in a new issue