More progress on transaction editing, also removed some debug logging for hikari and set the docker compose file to use postgres
This commit is contained in:
parent
64e7cb9d52
commit
59aa48817a
12 changed files with 326 additions and 113 deletions
|
@ -1,7 +1,6 @@
|
||||||
package com.wbrawner.twigs.server
|
package com.wbrawner.twigs.server
|
||||||
|
|
||||||
import at.favre.lib.crypto.bcrypt.BCrypt
|
import at.favre.lib.crypto.bcrypt.BCrypt
|
||||||
import ch.qos.logback.classic.Level
|
|
||||||
import com.github.mustachejava.DefaultMustacheFactory
|
import com.github.mustachejava.DefaultMustacheFactory
|
||||||
import com.wbrawner.twigs.*
|
import com.wbrawner.twigs.*
|
||||||
import com.wbrawner.twigs.db.*
|
import com.wbrawner.twigs.db.*
|
||||||
|
@ -38,7 +37,6 @@ import io.ktor.server.response.*
|
||||||
import io.ktor.server.sessions.*
|
import io.ktor.server.sessions.*
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.slf4j.LoggerFactory
|
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
fun main() {
|
fun main() {
|
||||||
|
@ -72,7 +70,6 @@ fun Application.module() {
|
||||||
throw RuntimeException("Unsupported DB type: $dbType")
|
throw RuntimeException("Unsupported DB type: $dbType")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(LoggerFactory.getLogger("com.zaxxer.hikari") as ch.qos.logback.classic.Logger).level = Level.DEBUG
|
|
||||||
HikariDataSource(HikariConfig().apply {
|
HikariDataSource(HikariConfig().apply {
|
||||||
setJdbcUrl(jdbcUrl)
|
setJdbcUrl(jdbcUrl)
|
||||||
username = dbUser
|
username = dbUser
|
||||||
|
|
|
@ -9,6 +9,7 @@ services:
|
||||||
- db
|
- db
|
||||||
environment:
|
environment:
|
||||||
TWIGS_DB_HOST: db
|
TWIGS_DB_HOST: db
|
||||||
|
TWIGS_DB_TYPE: postgresql
|
||||||
networks:
|
networks:
|
||||||
- twigs
|
- twigs
|
||||||
|
|
||||||
|
|
|
@ -31,6 +31,7 @@ fun Application.webRoutes(
|
||||||
try {
|
try {
|
||||||
userService.session(it.token)
|
userService.session(it.token)
|
||||||
} catch (e: HttpException) {
|
} catch (e: HttpException) {
|
||||||
|
application.environment.log.debug("Failed to retrieve session for user", e)
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,8 +24,7 @@ data class TransactionFormPage(
|
||||||
val transaction: TransactionResponse,
|
val transaction: TransactionResponse,
|
||||||
val amountLabel: String,
|
val amountLabel: String,
|
||||||
val budget: BudgetResponse,
|
val budget: BudgetResponse,
|
||||||
val incomeCategories: List<CategoryResponse>,
|
val categoryOptions: List<CategoryOption>,
|
||||||
val expenseCategories: List<CategoryResponse>,
|
|
||||||
override val user: UserResponse,
|
override val user: UserResponse,
|
||||||
override val error: String? = null
|
override val error: String? = null
|
||||||
) : AuthenticatedPage {
|
) : AuthenticatedPage {
|
||||||
|
@ -34,4 +33,20 @@ data class TransactionFormPage(
|
||||||
} else {
|
} else {
|
||||||
"Edit Transaction"
|
"Edit Transaction"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class CategoryOption(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val isSelected: Boolean = false,
|
||||||
|
val isDisabled: Boolean = false
|
||||||
|
) {
|
||||||
|
val selected: String
|
||||||
|
get() = if (isSelected) "selected" else ""
|
||||||
|
|
||||||
|
val disabled: String
|
||||||
|
get() = if (isDisabled) "disabled" else ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun CategoryResponse.asOption(selectedCategoryId: String) =
|
||||||
|
TransactionFormPage.CategoryOption(id, title, id == selectedCategoryId)
|
|
@ -7,6 +7,7 @@ import com.wbrawner.twigs.service.requireSession
|
||||||
import com.wbrawner.twigs.service.transaction.TransactionRequest
|
import com.wbrawner.twigs.service.transaction.TransactionRequest
|
||||||
import com.wbrawner.twigs.service.transaction.TransactionResponse
|
import com.wbrawner.twigs.service.transaction.TransactionResponse
|
||||||
import com.wbrawner.twigs.service.transaction.TransactionService
|
import com.wbrawner.twigs.service.transaction.TransactionService
|
||||||
|
import com.wbrawner.twigs.service.user.UserResponse
|
||||||
import com.wbrawner.twigs.service.user.UserService
|
import com.wbrawner.twigs.service.user.UserService
|
||||||
import com.wbrawner.twigs.toInstant
|
import com.wbrawner.twigs.toInstant
|
||||||
import com.wbrawner.twigs.web.NotFoundPage
|
import com.wbrawner.twigs.web.NotFoundPage
|
||||||
|
@ -21,6 +22,7 @@ import io.ktor.server.response.*
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
import io.ktor.server.util.*
|
import io.ktor.server.util.*
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
import java.text.DecimalFormat
|
import java.text.DecimalFormat
|
||||||
import java.text.NumberFormat
|
import java.text.NumberFormat
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
@ -36,6 +38,7 @@ private val decimalFormat = DecimalFormat.getNumberInstance(Locale.US).apply {
|
||||||
with(this as DecimalFormat) {
|
with(this as DecimalFormat) {
|
||||||
decimalFormatSymbols = decimalFormatSymbols.apply {
|
decimalFormatSymbols = decimalFormatSymbols.apply {
|
||||||
currencySymbol = ""
|
currencySymbol = ""
|
||||||
|
isGroupingUsed = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -59,35 +62,25 @@ fun Application.transactionWebRoutes(
|
||||||
get {
|
get {
|
||||||
val user = userService.user(requireSession().userId)
|
val user = userService.user(requireSession().userId)
|
||||||
val budgetId = call.parameters.getOrFail("budgetId")
|
val budgetId = call.parameters.getOrFail("budgetId")
|
||||||
|
val transaction = TransactionResponse(
|
||||||
|
id = "",
|
||||||
|
title = "",
|
||||||
|
description = "",
|
||||||
|
amount = 0,
|
||||||
|
budgetId = budgetId,
|
||||||
|
expense = true,
|
||||||
|
date = Instant.now().toHtmlInputString(),
|
||||||
|
categoryId = null,
|
||||||
|
createdBy = user.id
|
||||||
|
)
|
||||||
call.respond(
|
call.respond(
|
||||||
MustacheContent(
|
MustacheContent(
|
||||||
"transaction-form.mustache",
|
"transaction-form.mustache",
|
||||||
TransactionFormPage(
|
TransactionFormPage(
|
||||||
TransactionResponse(
|
transaction = transaction,
|
||||||
id = "",
|
|
||||||
title = "",
|
|
||||||
description = "",
|
|
||||||
amount = 0,
|
|
||||||
budgetId = budgetId,
|
|
||||||
expense = true,
|
|
||||||
date = Instant.now().toHtmlInputString(),
|
|
||||||
categoryId = null,
|
|
||||||
createdBy = user.id
|
|
||||||
),
|
|
||||||
amountLabel = currencyFormat.format(0L),
|
amountLabel = currencyFormat.format(0L),
|
||||||
budget = budgetService.budget(budgetId = budgetId, userId = user.id),
|
budget = budgetService.budget(budgetId = budgetId, userId = user.id),
|
||||||
incomeCategories = categoryService.categories(
|
categoryOptions = categoryOptions(transaction, categoryService, budgetId, user),
|
||||||
budgetIds = listOf(budgetId),
|
|
||||||
userId = user.id,
|
|
||||||
expense = false,
|
|
||||||
archived = false
|
|
||||||
),
|
|
||||||
expenseCategories = categoryService.categories(
|
|
||||||
budgetIds = listOf(budgetId),
|
|
||||||
userId = user.id,
|
|
||||||
expense = true,
|
|
||||||
archived = false
|
|
||||||
),
|
|
||||||
user = user
|
user = user
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -101,7 +94,7 @@ fun Application.transactionWebRoutes(
|
||||||
val request = call.receiveParameters().toTransactionRequest()
|
val request = call.receiveParameters().toTransactionRequest()
|
||||||
.run {
|
.run {
|
||||||
copy(
|
copy(
|
||||||
date = date + 'Z',
|
date = "$date:00Z",
|
||||||
expense = categoryService.category(
|
expense = categoryService.category(
|
||||||
categoryId = requireNotNull(categoryId),
|
categoryId = requireNotNull(categoryId),
|
||||||
userId = user.id
|
userId = user.id
|
||||||
|
@ -112,35 +105,30 @@ fun Application.transactionWebRoutes(
|
||||||
val transaction = transactionService.save(request, user.id)
|
val transaction = transactionService.save(request, user.id)
|
||||||
call.respondRedirect("/budgets/${transaction.budgetId}/transactions/${transaction.id}")
|
call.respondRedirect("/budgets/${transaction.budgetId}/transactions/${transaction.id}")
|
||||||
} catch (e: HttpException) {
|
} catch (e: HttpException) {
|
||||||
|
val transaction = TransactionResponse(
|
||||||
|
id = "",
|
||||||
|
title = call.parameters["title"],
|
||||||
|
description = call.parameters["description"],
|
||||||
|
amount = 0L,
|
||||||
|
budgetId = urlBudgetId,
|
||||||
|
expense = call.parameters["expense"]?.toBoolean() ?: true,
|
||||||
|
date = call.parameters["date"].orEmpty(),
|
||||||
|
categoryId = call.parameters["categoryId"],
|
||||||
|
createdBy = user.id
|
||||||
|
)
|
||||||
call.respond(
|
call.respond(
|
||||||
status = e.statusCode,
|
status = e.statusCode,
|
||||||
MustacheContent(
|
MustacheContent(
|
||||||
"transaction-form.mustache",
|
"transaction-form.mustache",
|
||||||
TransactionFormPage(
|
TransactionFormPage(
|
||||||
TransactionResponse(
|
transaction = transaction,
|
||||||
id = "",
|
|
||||||
title = call.parameters["title"],
|
|
||||||
description = call.parameters["description"],
|
|
||||||
amount = 0L,
|
|
||||||
budgetId = urlBudgetId,
|
|
||||||
expense = call.parameters["expense"]?.toBoolean() ?: true,
|
|
||||||
date = call.parameters["date"].orEmpty(),
|
|
||||||
categoryId = call.parameters["categoryId"],
|
|
||||||
createdBy = user.id
|
|
||||||
),
|
|
||||||
amountLabel = call.parameters["amount"].orEmpty(),
|
amountLabel = call.parameters["amount"].orEmpty(),
|
||||||
budget = budgetService.budget(budgetId = urlBudgetId, userId = user.id),
|
budget = budgetService.budget(budgetId = urlBudgetId, userId = user.id),
|
||||||
incomeCategories = categoryService.categories(
|
categoryOptions = categoryOptions(
|
||||||
budgetIds = listOf(urlBudgetId),
|
transaction,
|
||||||
userId = user.id,
|
categoryService,
|
||||||
expense = false,
|
urlBudgetId,
|
||||||
archived = false
|
user
|
||||||
),
|
|
||||||
expenseCategories = categoryService.categories(
|
|
||||||
budgetIds = listOf(urlBudgetId),
|
|
||||||
userId = user.id,
|
|
||||||
expense = true,
|
|
||||||
archived = false
|
|
||||||
),
|
),
|
||||||
user = user,
|
user = user,
|
||||||
error = e.message
|
error = e.message
|
||||||
|
@ -197,8 +185,82 @@ fun Application.transactionWebRoutes(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
post {
|
route("/edit") {
|
||||||
|
get {
|
||||||
|
val user = userService.user(requireSession().userId)
|
||||||
|
val budgetId = call.parameters.getOrFail("budgetId")
|
||||||
|
val transaction = transactionService.transaction(
|
||||||
|
transactionId = call.parameters.getOrFail("id"),
|
||||||
|
userId = user.id
|
||||||
|
)
|
||||||
|
call.respond(
|
||||||
|
MustacheContent(
|
||||||
|
"transaction-form.mustache",
|
||||||
|
TransactionFormPage(
|
||||||
|
transaction = transaction.copy(
|
||||||
|
date = transaction.date.toInstant().toHtmlInputString()
|
||||||
|
),
|
||||||
|
amountLabel = transaction.amount.toDecimalString(),
|
||||||
|
budget = budgetService.budget(budgetId = budgetId, userId = user.id),
|
||||||
|
categoryOptions = categoryOptions(transaction, categoryService, budgetId, user),
|
||||||
|
user = user
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
val user = userService.user(requireSession().userId)
|
||||||
|
val transactionId = call.parameters.getOrFail("id")
|
||||||
|
val urlBudgetId = call.parameters.getOrFail("budgetId")
|
||||||
|
try {
|
||||||
|
val request = call.receiveParameters().toTransactionRequest()
|
||||||
|
.run {
|
||||||
|
copy(
|
||||||
|
date = "$date:00Z",
|
||||||
|
expense = categoryService.category(
|
||||||
|
categoryId = requireNotNull(categoryId),
|
||||||
|
userId = user.id
|
||||||
|
).expense,
|
||||||
|
budgetId = urlBudgetId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val transaction =
|
||||||
|
transactionService.save(request, userId = user.id, transactionId = transactionId)
|
||||||
|
call.respondRedirect("/budgets/${transaction.budgetId}/transactions/${transaction.id}")
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
val transaction = TransactionResponse(
|
||||||
|
id = transactionId,
|
||||||
|
title = call.parameters["title"],
|
||||||
|
description = call.parameters["description"],
|
||||||
|
amount = 0L,
|
||||||
|
budgetId = urlBudgetId,
|
||||||
|
expense = call.parameters["expense"]?.toBoolean() ?: true,
|
||||||
|
date = call.parameters["date"].orEmpty(),
|
||||||
|
categoryId = call.parameters["categoryId"],
|
||||||
|
createdBy = user.id
|
||||||
|
)
|
||||||
|
call.respond(
|
||||||
|
status = e.statusCode,
|
||||||
|
MustacheContent(
|
||||||
|
"transaction-form.mustache",
|
||||||
|
TransactionFormPage(
|
||||||
|
transaction = transaction,
|
||||||
|
amountLabel = call.parameters["amount"].orEmpty(),
|
||||||
|
budget = budgetService.budget(budgetId = urlBudgetId, userId = user.id),
|
||||||
|
categoryOptions = categoryOptions(
|
||||||
|
transaction,
|
||||||
|
categoryService,
|
||||||
|
urlBudgetId,
|
||||||
|
user
|
||||||
|
),
|
||||||
|
user = user,
|
||||||
|
error = e.message
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -206,6 +268,48 @@ fun Application.transactionWebRoutes(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun categoryOptions(
|
||||||
|
transaction: TransactionResponse,
|
||||||
|
categoryService: CategoryService,
|
||||||
|
budgetId: String,
|
||||||
|
user: UserResponse
|
||||||
|
): List<TransactionFormPage.CategoryOption> {
|
||||||
|
val selectedCategoryId = transaction.categoryId.orEmpty()
|
||||||
|
val categoryOptions = listOf(
|
||||||
|
TransactionFormPage.CategoryOption(
|
||||||
|
"",
|
||||||
|
"Select a category",
|
||||||
|
isSelected = transaction.categoryId.isNullOrBlank(),
|
||||||
|
isDisabled = true
|
||||||
|
),
|
||||||
|
TransactionFormPage.CategoryOption("income", "Income", isDisabled = true),
|
||||||
|
)
|
||||||
|
.plus(
|
||||||
|
categoryService.categories(
|
||||||
|
budgetIds = listOf(budgetId),
|
||||||
|
userId = user.id,
|
||||||
|
expense = false,
|
||||||
|
archived = false
|
||||||
|
).map { category ->
|
||||||
|
category.asOption(selectedCategoryId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.plus(
|
||||||
|
TransactionFormPage.CategoryOption("expense", "Expense", isDisabled = true),
|
||||||
|
)
|
||||||
|
.plus(
|
||||||
|
categoryService.categories(
|
||||||
|
budgetIds = listOf(budgetId),
|
||||||
|
userId = user.id,
|
||||||
|
expense = true,
|
||||||
|
archived = false
|
||||||
|
).map { category ->
|
||||||
|
category.asOption(selectedCategoryId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return categoryOptions
|
||||||
|
}
|
||||||
|
|
||||||
private fun Parameters.toTransactionRequest() = TransactionRequest(
|
private fun Parameters.toTransactionRequest() = TransactionRequest(
|
||||||
title = get("title"),
|
title = get("title"),
|
||||||
description = get("description"),
|
description = get("description"),
|
||||||
|
@ -216,4 +320,9 @@ private fun Parameters.toTransactionRequest() = TransactionRequest(
|
||||||
budgetId = get("budgetId"),
|
budgetId = get("budgetId"),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun Instant.toHtmlInputString() = truncatedTo(ChronoUnit.SECONDS).toString().substringBefore('Z')
|
private fun Instant.toHtmlInputString() = truncatedTo(ChronoUnit.MINUTES).toString().substringBefore(":00Z")
|
||||||
|
|
||||||
|
private fun Long?.toDecimalString(): String {
|
||||||
|
if (this == null) return ""
|
||||||
|
return decimalFormat.format(toBigDecimal().divide(BigDecimal(100), 2, RoundingMode.HALF_UP))
|
||||||
|
}
|
|
@ -71,6 +71,11 @@ main {
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#hamburger {
|
||||||
|
color: var(--color-on-background);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.stacked-label {
|
.stacked-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
@ -100,9 +105,19 @@ main {
|
||||||
justify-content: space-evenly;
|
justify-content: space-evenly;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
header.row {
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card header .button {
|
||||||
|
margin: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--background-color-primary);
|
background: var(--background-color-primary);
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
|
@ -146,12 +161,15 @@ main {
|
||||||
width: 200px;
|
width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.center form {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
|
||||||
max-width: 1200px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input, select, textarea {
|
input, select, textarea {
|
||||||
|
@ -201,6 +219,30 @@ a {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media all and (max-width: 600px) {
|
||||||
|
.hide-small {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media all and (max-width: 900px) {
|
||||||
|
#sidebar {
|
||||||
|
background-color: var(--background-color-primary);
|
||||||
|
position: fixed;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar:target {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media all and (min-width: 900px) {
|
||||||
|
#hamburger {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media all and (max-width: 400px) {
|
@media all and (max-width: 400px) {
|
||||||
button {
|
button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
@ -2,30 +2,60 @@
|
||||||
<div id="app">
|
<div id="app">
|
||||||
{{>partials/sidebar}}
|
{{>partials/sidebar}}
|
||||||
<main>
|
<main>
|
||||||
<h1>{{budget.name}}</h1>
|
<header class="row">
|
||||||
|
<a id="hamburger" href="#sidebar">☰</a>
|
||||||
|
<h1>{{title}}</h1>
|
||||||
|
<a href="/budgets/{{budget.id}}/transactions/new"
|
||||||
|
class="button button-secondary">
|
||||||
|
<!-- TODO: Hide text on small widths -->
|
||||||
|
<span aria-description="New Transaction">+</span> <span class="hide-small" aria-hidden="true">New Transaction</span>
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
<p>{{budget.description}}</p>
|
<p>{{budget.description}}</p>
|
||||||
<div class="row">
|
<div class="card">
|
||||||
<div class="stacked-label">
|
<div class="row">
|
||||||
<p class="body-small">Month</p>
|
<div class="stacked-label">
|
||||||
<p class="body-large">{{monthAndYear}}</p>
|
<p class="body-small">Month</p>
|
||||||
</div>
|
<p class="body-large">{{monthAndYear}}</p>
|
||||||
<div class="stacked-label">
|
</div>
|
||||||
<p class="body-small">Cash Flow</p>
|
<div class="stacked-label">
|
||||||
<p class="body-large">{{balances.cashFlow}}</p>
|
<p class="body-small">Cash Flow</p>
|
||||||
</div>
|
<p class="body-large">{{balances.cashFlow}}</p>
|
||||||
<div class="stacked-label">
|
</div>
|
||||||
<p class="body-small">Transactions</p>
|
<div class="stacked-label">
|
||||||
<p class="body-large">{{transactionCount}}</p>
|
<p class="body-small">Transactions</p>
|
||||||
|
<p class="body-large">{{transactionCount}}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Income</h3>
|
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<p>{{balances.actualIncomeLabel}} earned of {{balances.expectedIncomeLabel}} expected</p>
|
<p>Expected income: {{balances.expectedIncomeLabel}}</p>
|
||||||
|
<progress style="margin: 0 0.5rem;"
|
||||||
|
value="{{balances.expectedIncome}}"
|
||||||
|
max="{{balances.maxProgressBarValue}}">{{balances.expectedIncomeLabel}}</progress>
|
||||||
|
<p>Actual Income: {{balances.actualIncomeLabel}}</p>
|
||||||
<progress style="margin: 0 0.5rem;"
|
<progress style="margin: 0 0.5rem;"
|
||||||
value="{{balances.actualIncome}}"
|
value="{{balances.actualIncome}}"
|
||||||
max="{{balances.maxProgressBarValue}}">{{balances.actualIncomeLabel}}</progress>
|
max="{{balances.maxProgressBarValue}}">{{balances.actualIncomeLabel}}</progress>
|
||||||
|
<p>Expected expenses: {{balances.expectedExpensesLabel}}</p>
|
||||||
|
<progress style="margin: 0 0.5rem;"
|
||||||
|
value="{{balances.expectedExpenses}}"
|
||||||
|
max="{{balances.maxProgressBarValue}}">{{balances.expectedExpensesLabel}}</progress>
|
||||||
|
<p>Actual Income: {{balances.actualIncomeLabel}}</p>
|
||||||
|
<progress style="margin: 0 0.5rem;"
|
||||||
|
value="{{balances.actualExpenses}}"
|
||||||
|
max="{{balances.maxProgressBarValue}}">{{balances.actualExpensesLabel}}</progress>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<header class="row">
|
||||||
|
<h3>Income</h3>
|
||||||
|
<a href="/budgets/{{budget.id}}/categories/new?expense=false" class="button button-secondary">
|
||||||
|
<!-- TODO: Hide text on small widths -->
|
||||||
|
<span aria-description="New Category">+</span> <span class="hide-small" aria-hidden="true">New Category</span>
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
<ul>
|
<ul>
|
||||||
{{#incomeCategories}}
|
{{#incomeCategories}}
|
||||||
{{>partials/category-list}}
|
{{>partials/category-list}}
|
||||||
|
@ -39,13 +69,15 @@
|
||||||
{{/archivedIncomeCategories}}
|
{{/archivedIncomeCategories}}
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
<h3>Expenses</h3>
|
</div>
|
||||||
<div class="column">
|
<div class="card">
|
||||||
<p>{{balances.actualExpensesLabel}} spent of {{balances.expectedExpensesLabel}} expected</p>
|
<header class="row">
|
||||||
<progress style="margin: 0 0.5rem;"
|
<h3>Expenses</h3>
|
||||||
value="{{balances.actualExpenses}}"
|
<a href="/budgets/{{budget.id}}/categories/new?expense=true" class="button button-secondary">
|
||||||
max="{{balances.maxProgressBarValue}}">{{balances.actualExpensesLabel}}</progress>
|
<!-- TODO: Hide text on small widths -->
|
||||||
</div>
|
<span aria-description="New Category">+</span> <span class="hide-small" aria-hidden="true">New Category</span>
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
<ul>
|
<ul>
|
||||||
{{#expenseCategories}}
|
{{#expenseCategories}}
|
||||||
{{>partials/category-list}}
|
{{>partials/category-list}}
|
||||||
|
|
|
@ -3,8 +3,18 @@
|
||||||
{{>partials/sidebar}}
|
{{>partials/sidebar}}
|
||||||
<main>
|
<main>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<h1>{{category.category.title}}</h1>
|
<header class="row">
|
||||||
|
<a id="hamburger" href="#sidebar">☰</a>
|
||||||
|
<h1>{{title}}</h1>
|
||||||
|
<a href="/budgets/{{budget.id}}/transactions/new?categoryId={{category.category.id}}"
|
||||||
|
class="button button-secondary">
|
||||||
|
<!-- TODO: Hide text on small widths -->
|
||||||
|
<span aria-description="New Transaction">+</span> <span aria-hidden="true">New Transaction</span>
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
<p>{{category.category.description}}</p>
|
<p>{{category.category.description}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="stacked-label">
|
<div class="stacked-label">
|
||||||
<p class="body-small">Budgeted</p>
|
<p class="body-small">Budgeted</p>
|
||||||
|
@ -22,8 +32,8 @@
|
||||||
<progress value="{{category.balance}}"
|
<progress value="{{category.balance}}"
|
||||||
max="{{category.category.amount}}">{{category.balanceLabel}}</progress>
|
max="{{category.category.amount}}">{{category.balanceLabel}}</progress>
|
||||||
</div>
|
</div>
|
||||||
<!-- TODO: Add a search bar to filter transactions by name/description -->
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<!-- TODO: Add a search bar to filter transactions by name/description -->
|
||||||
<h3>Transactions</h3>
|
<h3>Transactions</h3>
|
||||||
<ul>
|
<ul>
|
||||||
{{#transactions}}
|
{{#transactions}}
|
||||||
|
|
|
@ -7,6 +7,11 @@
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{{/budgets}}
|
{{/budgets}}
|
||||||
|
<li class="list-item">
|
||||||
|
<a href="/budgets/new">
|
||||||
|
<span class="body-medium">+ New Budget</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
{{^budgets}}
|
{{^budgets}}
|
||||||
<p style="text-align: center;">Welcome to Twigs! It looks like you haven't created any budgets yet. Budgets are the
|
<p style="text-align: center;">Welcome to Twigs! It looks like you haven't created any budgets yet. Budgets are the
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<aside class="sidebar">
|
<aside id="sidebar" class="sidebar">
|
||||||
<div class="logo" style="height: 100px; width: 100px;"></div>
|
<div class="logo" style="height: 100px; width: 100px;"></div>
|
||||||
<ul>
|
<ul>
|
||||||
<li class="list-item">
|
<li class="list-item">
|
||||||
|
|
|
@ -2,7 +2,14 @@
|
||||||
<div id="app">
|
<div id="app">
|
||||||
{{>partials/sidebar}}
|
{{>partials/sidebar}}
|
||||||
<main>
|
<main>
|
||||||
<h1>{{title}}</h1>
|
<header class="row">
|
||||||
|
<a id="hamburger" href="#sidebar">☰</a>
|
||||||
|
<h1>{{title}}</h1>
|
||||||
|
<a href="/budgets/{{budget.id}}/transactions/{{transaction.id}}/edit"
|
||||||
|
class="button button-secondary">
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<span class="body-large">{{dateLabel}}</span>
|
<span class="body-large">{{dateLabel}}</span>
|
||||||
|
|
|
@ -2,40 +2,34 @@
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<main>
|
<main>
|
||||||
<h1>{{title}}</h1>
|
<h1>{{title}}</h1>
|
||||||
<div class="center">
|
{{#error }}
|
||||||
{{#error }}
|
<p class="error">{{error}}</p>
|
||||||
<p class="error">{{error}}</p>
|
{{/error}}
|
||||||
{{/error}}
|
<form method="post">
|
||||||
<form method="post">
|
<label for="title">Name</label>
|
||||||
<label for="title">Name</label>
|
<input id="title" type="text" name="title" value="{{ transaction.title }}" required/>
|
||||||
<input id="title" type="text" name="title" value="{{ transaction.title }}" required/>
|
<label for="description">Description</label>
|
||||||
<label for="description">Description</label>
|
<textarea id="description" name="description">{{ transaction.description }}</textarea>
|
||||||
<textarea id="description" name="description">{{ transaction.description }}</textarea>
|
<label for="date">Date</label>
|
||||||
<label for="date">Date</label>
|
<input id="date" type="datetime-local" name="date" value="{{ transaction.date }}" required/>
|
||||||
<input id="date" type="datetime-local" name="date" value="{{ transaction.date }}" required/>
|
<label for="amount">Amount</label>
|
||||||
<label for="amount">Amount</label>
|
<input id="amount" type="number" name="amount" value="{{ amountLabel }}" step="0.01" required/>
|
||||||
<input id="amount" type="number" name="amount" value="{{ amountLabel }}" required/>
|
<label for="categoryId">Category</label>
|
||||||
<label for="categoryId">Category</label>
|
<select id="categoryId" name="categoryId" required>
|
||||||
<select id="categoryId" name="categoryId" required>
|
{{#categoryOptions}}
|
||||||
<option selected disabled>Select a category</option>
|
<option value="{{id}}" {{selected}} {{disabled}}>{{title}}</option>
|
||||||
<option disabled>Income</option>
|
{{/categoryOptions}}
|
||||||
{{#incomeCategories}}
|
</select>
|
||||||
<option value="{{id}}">{{title}}</option>
|
<input id="submit" type="submit" class="button button-primary" value="Save"/>
|
||||||
{{/incomeCategories}}
|
</form>
|
||||||
<option disabled>Expense</option>
|
|
||||||
{{#expenseCategories}}
|
|
||||||
<option value="{{id}}">{{title}}</option>
|
|
||||||
{{/expenseCategories}}
|
|
||||||
</select>
|
|
||||||
<input id="submit" type="submit" class="button button-primary" value="Save"/>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
const dateField = document.querySelector('#date')
|
if (window.location.pathname.endsWith("new")) {
|
||||||
let localDateTime = dateField.valueAsDate
|
const dateField = document.querySelector('#date')
|
||||||
const localTimeMs = localDateTime.setMinutes(localDateTime.getMinutes() - localDateTime.getTimezoneOffset())
|
let localDateTime = dateField.valueAsDate
|
||||||
dateField.value = new Date(localTimeMs).toISOString().slice(0, -1)
|
const localTimeMs = localDateTime.setMinutes(localDateTime.getMinutes() - localDateTime.getTimezoneOffset())
|
||||||
|
dateField.value = new Date(localTimeMs).toISOString().slice(0, -1)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{{>partials/foot}}
|
{{>partials/foot}}
|
||||||
|
|
Loading…
Reference in a new issue