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)
|
||||
}
|
||||
session<CookieSession>(TWIGS_SESSION_COOKIE) {
|
||||
challenge {
|
||||
call.respond(HttpStatusCode.Unauthorized)
|
||||
}
|
||||
validate(sessionValidator)
|
||||
}
|
||||
}
|
||||
install(Sessions) {
|
||||
header<Session>("Authorization") {
|
||||
|
@ -254,7 +260,7 @@ fun Application.moduleWithDependencies(
|
|||
recurringTransactionRoutes(recurringTransactionService)
|
||||
transactionRoutes(transactionService)
|
||||
userRoutes(userService)
|
||||
webRoutes(budgetService, userService)
|
||||
webRoutes(budgetService, categoryService, transactionService, userService)
|
||||
launch {
|
||||
while (currentCoroutineContext().isActive) {
|
||||
jobs.forEach { it.run() }
|
||||
|
|
|
@ -39,19 +39,19 @@ class DefaultBudgetService(
|
|||
budgetPermissionRepository.budgetWithPermission(userId, budgetId, Permission.READ)
|
||||
|
||||
override suspend fun save(request: BudgetRequest, userId: String, budgetId: String?): BudgetResponse {
|
||||
val budget = budgetId?.let {
|
||||
val budget = if (budgetId?.isNotBlank() == true) {
|
||||
budgetPermissionRepository.budgetWithPermission(
|
||||
budgetId = it,
|
||||
budgetId = budgetId,
|
||||
userId = userId,
|
||||
permission = Permission.MANAGE
|
||||
).run {
|
||||
Budget(
|
||||
id = it,
|
||||
id = budgetId,
|
||||
name = request.name ?: name,
|
||||
description = request.description ?: description
|
||||
)
|
||||
}
|
||||
} ?: run {
|
||||
} else {
|
||||
if (request.name.isNullOrBlank()) {
|
||||
throw HttpException(HttpStatusCode.BadRequest, message = "Name cannot be empty or null")
|
||||
}
|
||||
|
|
|
@ -17,21 +17,21 @@ import java.time.Instant
|
|||
interface TransactionService {
|
||||
suspend fun transactions(
|
||||
budgetIds: List<String>,
|
||||
categoryIds: List<String>?,
|
||||
from: Instant?,
|
||||
to: Instant?,
|
||||
expense: Boolean?,
|
||||
categoryIds: List<String>? = null,
|
||||
from: Instant? = null,
|
||||
to: Instant? = null,
|
||||
expense: Boolean? = null,
|
||||
userId: String,
|
||||
): List<TransactionResponse>
|
||||
|
||||
suspend fun transaction(transactionId: String, userId: String): TransactionResponse
|
||||
|
||||
suspend fun sum(
|
||||
budgetId: String?,
|
||||
categoryId: String?,
|
||||
from: Instant?,
|
||||
to: Instant?,
|
||||
userId: String,
|
||||
budgetId: String? = null,
|
||||
categoryId: String? = null,
|
||||
from: Instant? = null,
|
||||
to: Instant? = null,
|
||||
): Long
|
||||
|
||||
suspend fun save(
|
||||
|
@ -78,11 +78,11 @@ class DefaultTransactionService(
|
|||
}
|
||||
|
||||
override suspend fun sum(
|
||||
userId: String,
|
||||
budgetId: String?,
|
||||
categoryId: String?,
|
||||
from: Instant?,
|
||||
to: Instant?,
|
||||
userId: String
|
||||
to: Instant?
|
||||
): Long {
|
||||
if (budgetId.isNullOrBlank() && categoryId.isNullOrBlank()) {
|
||||
throw HttpException(HttpStatusCode.BadRequest, message = "budgetId or categoryId must be provided to sum")
|
||||
|
|
|
@ -1,3 +1,12 @@
|
|||
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.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.web.budget.budgetWebRoutes
|
||||
import com.wbrawner.twigs.web.user.userWebRoutes
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.http.content.*
|
||||
|
@ -13,6 +16,8 @@ import io.ktor.server.sessions.*
|
|||
|
||||
fun Application.webRoutes(
|
||||
budgetService: BudgetService,
|
||||
categoryService: CategoryService,
|
||||
transactionService: TransactionService,
|
||||
userService: UserService
|
||||
) {
|
||||
routing {
|
||||
|
@ -30,5 +35,6 @@ fun Application.webRoutes(
|
|||
} ?: call.respond(MustacheContent("index.mustache", null))
|
||||
}
|
||||
}
|
||||
budgetWebRoutes(budgetService, categoryService, transactionService, 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
|
||||
|
||||
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 }}
|
||||
<body>
|
||||
<div id="app">
|
||||
<h1>{{title}}</h1>
|
||||
<div class="center">
|
||||
<div class="logo"></div>
|
||||
{{#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 }}
|
||||
<body>
|
||||
<div id="app">
|
||||
<h1>{{title}}</h1>
|
||||
<div class="center">
|
||||
<div class="logo"></div>
|
||||
{{#error }}
|
||||
|
|
Loading…
Reference in a new issue