More work on budget details page
This commit is contained in:
parent
7e49dbeb31
commit
978de59d36
17 changed files with 328 additions and 74 deletions
|
@ -10,3 +10,8 @@ interface Page {
|
||||||
interface AuthenticatedPage : Page {
|
interface AuthenticatedPage : Page {
|
||||||
val user: UserResponse
|
val user: UserResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
object NotFoundPage : Page {
|
||||||
|
override val title: String = "404 Not Found"
|
||||||
|
override val error: String? = null
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
package com.wbrawner.twigs.web
|
package com.wbrawner.twigs.web
|
||||||
|
|
||||||
import com.wbrawner.twigs.model.CookieSession
|
import com.wbrawner.twigs.model.CookieSession
|
||||||
|
import com.wbrawner.twigs.service.HttpException
|
||||||
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.category.CategoryService
|
||||||
import com.wbrawner.twigs.service.transaction.TransactionService
|
import com.wbrawner.twigs.service.transaction.TransactionService
|
||||||
|
@ -24,7 +25,13 @@ fun Application.webRoutes(
|
||||||
staticResources("/", "static")
|
staticResources("/", "static")
|
||||||
get("/") {
|
get("/") {
|
||||||
call.sessions.get(CookieSession::class)
|
call.sessions.get(CookieSession::class)
|
||||||
?.let { userService.session(it.token) }
|
?.let {
|
||||||
|
try {
|
||||||
|
userService.session(it.token)
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
?.let { session ->
|
?.let { session ->
|
||||||
application.environment.log.info("Session found!")
|
application.environment.log.info("Session found!")
|
||||||
budgetService.budgetsForUser(session.userId)
|
budgetService.budgetsForUser(session.userId)
|
||||||
|
|
|
@ -2,12 +2,11 @@ package com.wbrawner.twigs.web.budget
|
||||||
|
|
||||||
import com.wbrawner.twigs.service.budget.BudgetResponse
|
import com.wbrawner.twigs.service.budget.BudgetResponse
|
||||||
import com.wbrawner.twigs.service.category.CategoryResponse
|
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.service.user.UserResponse
|
||||||
import com.wbrawner.twigs.web.AuthenticatedPage
|
import com.wbrawner.twigs.web.AuthenticatedPage
|
||||||
|
|
||||||
data class BudgetListPage(
|
data class BudgetListPage(
|
||||||
val budgets: List<BudgetResponse>,
|
val budgets: List<BudgetListItem>,
|
||||||
override val user: UserResponse,
|
override val user: UserResponse,
|
||||||
override val error: String? = null
|
override val error: String? = null
|
||||||
) : AuthenticatedPage {
|
) : AuthenticatedPage {
|
||||||
|
@ -15,17 +14,26 @@ data class BudgetListPage(
|
||||||
}
|
}
|
||||||
|
|
||||||
data class BudgetDetailsPage(
|
data class BudgetDetailsPage(
|
||||||
|
val budgets: List<BudgetListItem>,
|
||||||
val budget: BudgetResponse,
|
val budget: BudgetResponse,
|
||||||
val balance: BalanceResponse,
|
val balances: BudgetBalances,
|
||||||
val categories: List<CategoryWithBalanceResponse>,
|
val incomeCategories: List<CategoryWithBalanceResponse>,
|
||||||
val archivedCategories: List<CategoryWithBalanceResponse>,
|
val expenseCategories: List<CategoryWithBalanceResponse>,
|
||||||
val transactionCount: Long,
|
val archivedIncomeCategories: List<CategoryWithBalanceResponse>,
|
||||||
|
val archivedExpenseCategories: List<CategoryWithBalanceResponse>,
|
||||||
|
val transactionCount: String,
|
||||||
override val user: UserResponse,
|
override val user: UserResponse,
|
||||||
override val error: String? = null
|
override val error: String? = null
|
||||||
) : AuthenticatedPage {
|
) : AuthenticatedPage {
|
||||||
override val title: String = "Budgets"
|
override val title: String = "Budgets"
|
||||||
|
|
||||||
data class CategoryWithBalanceResponse(val category: CategoryResponse, val balance: BalanceResponse)
|
data class CategoryWithBalanceResponse(
|
||||||
|
val category: CategoryResponse,
|
||||||
|
val amountLabel: String,
|
||||||
|
val balance: Long,
|
||||||
|
val balanceLabel: String,
|
||||||
|
val remainingAmountLabel: String,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class BudgetFormPage(
|
data class BudgetFormPage(
|
||||||
|
|
|
@ -8,10 +8,10 @@ import com.wbrawner.twigs.service.budget.BudgetResponse
|
||||||
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.category.CategoryService
|
||||||
import com.wbrawner.twigs.service.requireSession
|
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.transaction.TransactionService
|
||||||
import com.wbrawner.twigs.service.user.UserService
|
import com.wbrawner.twigs.service.user.UserService
|
||||||
import com.wbrawner.twigs.toInstantOrNull
|
import com.wbrawner.twigs.toInstantOrNull
|
||||||
|
import com.wbrawner.twigs.web.NotFoundPage
|
||||||
import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE
|
import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
|
@ -21,6 +21,11 @@ import io.ktor.server.request.*
|
||||||
import io.ktor.server.response.*
|
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.RoundingMode
|
||||||
|
import java.text.NumberFormat
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
fun Application.budgetWebRoutes(
|
fun Application.budgetWebRoutes(
|
||||||
budgetService: BudgetService,
|
budgetService: BudgetService,
|
||||||
|
@ -33,7 +38,7 @@ fun Application.budgetWebRoutes(
|
||||||
route("/budgets") {
|
route("/budgets") {
|
||||||
get {
|
get {
|
||||||
val user = userService.user(requireSession().userId)
|
val user = userService.user(requireSession().userId)
|
||||||
val budgets = budgetService.budgetsForUser(user.id)
|
val budgets = budgetService.budgetsForUser(user.id).map { it.toBudgetListItem() }
|
||||||
call.respond(MustacheContent("budgets.mustache", BudgetListPage(budgets, user)))
|
call.respond(MustacheContent("budgets.mustache", BudgetListPage(budgets, user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -84,33 +89,73 @@ fun Application.budgetWebRoutes(
|
||||||
}
|
}
|
||||||
|
|
||||||
route("/{id}") {
|
route("/{id}") {
|
||||||
|
|
||||||
get {
|
get {
|
||||||
val user = userService.user(requireSession().userId)
|
val user = userService.user(requireSession().userId)
|
||||||
val budget = budgetService.budget(budgetId = call.parameters.getOrFail("id"), userId = user.id)
|
val budgetId = call.parameters.getOrFail("id")
|
||||||
val balance = BalanceResponse(transactionService.sum(budgetId = budget.id, userId = user.id))
|
val budgets = budgetService.budgetsForUser(userId = user.id).toMutableList()
|
||||||
|
val budget = budgets.firstOrNull { it.id == budgetId }
|
||||||
|
?: run {
|
||||||
|
call.respond(MustacheContent("404.mustache", NotFoundPage))
|
||||||
|
return@get
|
||||||
|
}
|
||||||
|
val numberFormat = NumberFormat.getCurrencyInstance(Locale.US)
|
||||||
val categories = categoryService.categories(budgetIds = listOf(budget.id), userId = user.id)
|
val categories = categoryService.categories(budgetIds = listOf(budget.id), userId = user.id)
|
||||||
.map { category ->
|
.map { category ->
|
||||||
|
val categoryBalance =
|
||||||
|
abs(transactionService.sum(categoryId = category.id, userId = user.id))
|
||||||
BudgetDetailsPage.CategoryWithBalanceResponse(
|
BudgetDetailsPage.CategoryWithBalanceResponse(
|
||||||
category,
|
category = category,
|
||||||
BalanceResponse(transactionService.sum(categoryId = category.id, userId = user.id))
|
amountLabel = category.amount.toCurrencyString(numberFormat),
|
||||||
|
balance = categoryBalance,
|
||||||
|
balanceLabel = categoryBalance.toCurrencyString(numberFormat),
|
||||||
|
remainingAmountLabel = (category.amount - categoryBalance).toCurrencyString(
|
||||||
|
numberFormat
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// TODO: Add a count method so we don't have to do this
|
.toMutableSet()
|
||||||
|
val incomeCategories = categories.extractIf { !it.category.expense && !it.category.archived }
|
||||||
|
val archivedIncomeCategories =
|
||||||
|
categories.extractIf { !it.category.expense && it.category.archived }
|
||||||
|
val expenseCategories = categories.extractIf { it.category.expense && !it.category.archived }
|
||||||
|
val archivedExpenseCategories =
|
||||||
|
categories.extractIf { it.category.expense && it.category.archived }
|
||||||
val transactions = transactionService.transactions(
|
val transactions = transactionService.transactions(
|
||||||
budgetIds = listOf(budget.id),
|
budgetIds = listOf(budget.id),
|
||||||
from = call.parameters["from"]?.toInstantOrNull() ?: firstOfMonth,
|
from = call.parameters["from"]?.toInstantOrNull() ?: firstOfMonth,
|
||||||
to = call.parameters["to"]?.toInstantOrNull() ?: endOfMonth,
|
to = call.parameters["to"]?.toInstantOrNull() ?: endOfMonth,
|
||||||
userId = user.id
|
userId = user.id
|
||||||
)
|
)
|
||||||
|
// TODO: Allow user-configurable locale
|
||||||
|
val budgetBalance = transactionService.sum(budgetId = budget.id, userId = user.id)
|
||||||
|
.toCurrencyString(numberFormat)
|
||||||
|
val expectedIncome = incomeCategories.sumOf { it.category.amount }
|
||||||
|
val actualIncome = transactions.sumOf { if (it.expense == false) it.amount ?: 0L else 0L }
|
||||||
|
val expectedExpenses = expenseCategories.sumOf { it.category.amount }
|
||||||
|
val actualExpenses = transactions.sumOf { if (it.expense == true) it.amount ?: 0L else 0L }
|
||||||
|
val balances = BudgetBalances(
|
||||||
|
cashFlow = budgetBalance,
|
||||||
|
expectedIncome = expectedIncome,
|
||||||
|
expectedIncomeLabel = expectedIncome.toCurrencyString(numberFormat),
|
||||||
|
actualIncome = actualIncome,
|
||||||
|
actualIncomeLabel = actualIncome.toCurrencyString(numberFormat),
|
||||||
|
expectedExpenses = expectedExpenses,
|
||||||
|
expectedExpensesLabel = expectedExpenses.toCurrencyString(numberFormat),
|
||||||
|
actualExpenses = actualExpenses,
|
||||||
|
actualExpensesLabel = actualExpenses.toCurrencyString(numberFormat),
|
||||||
|
)
|
||||||
call.respond(
|
call.respond(
|
||||||
MustacheContent(
|
MustacheContent(
|
||||||
"budget-details.mustache", BudgetDetailsPage(
|
"budget-details.mustache", BudgetDetailsPage(
|
||||||
|
budgets = budgets.map { it.toBudgetListItem(budgetId) }.sortedBy { it.name },
|
||||||
budget = budget,
|
budget = budget,
|
||||||
balance = balance,
|
balances = balances,
|
||||||
categories = categories.filter { !it.category.archived },
|
incomeCategories = incomeCategories,
|
||||||
archivedCategories = categories.filter { it.category.archived },
|
archivedIncomeCategories = archivedIncomeCategories,
|
||||||
transactionCount = transactions.size.toLong(),
|
expenseCategories = expenseCategories,
|
||||||
|
archivedExpenseCategories = archivedExpenseCategories,
|
||||||
|
transactionCount = NumberFormat.getNumberInstance(Locale.US)
|
||||||
|
.format(transactions.size),
|
||||||
user = user
|
user = user
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -138,8 +183,48 @@ fun Application.budgetWebRoutes(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class BudgetBalances(
|
||||||
|
val cashFlow: String,
|
||||||
|
val expectedIncome: Long,
|
||||||
|
val expectedIncomeLabel: String,
|
||||||
|
val actualIncome: Long,
|
||||||
|
val actualIncomeLabel: String,
|
||||||
|
val expectedExpenses: Long,
|
||||||
|
val expectedExpensesLabel: String,
|
||||||
|
val actualExpenses: Long,
|
||||||
|
val actualExpensesLabel: String,
|
||||||
|
) {
|
||||||
|
val maxProgressBarValue: Long = maxOf(expectedExpenses, expectedIncome, actualIncome, actualExpenses)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BudgetListItem(val id: String, val name: String, val description: String, val selected: Boolean)
|
||||||
|
|
||||||
|
private fun BudgetResponse.toBudgetListItem(selectedId: String? = null) = BudgetListItem(
|
||||||
|
id = id,
|
||||||
|
name = name.orEmpty(),
|
||||||
|
description = description.orEmpty(),
|
||||||
|
selected = id == selectedId
|
||||||
|
)
|
||||||
|
|
||||||
private fun Parameters.toBudgetRequest() = BudgetRequest(
|
private fun Parameters.toBudgetRequest() = BudgetRequest(
|
||||||
name = get("name"),
|
name = get("name"),
|
||||||
description = get("description"),
|
description = get("description"),
|
||||||
users = setOf() // TODO: Enable adding users at budget creation
|
users = setOf() // TODO: Enable adding users at budget creation
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun <T> MutableCollection<T>.extractIf(predicate: (T) -> Boolean): List<T> {
|
||||||
|
val extracted = mutableListOf<T>()
|
||||||
|
val iterator = iterator()
|
||||||
|
while (iterator.hasNext()) {
|
||||||
|
val item = iterator.next()
|
||||||
|
if (predicate(item)) {
|
||||||
|
extracted.add(item)
|
||||||
|
iterator.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return extracted
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Long.toCurrencyString(formatter: NumberFormat): String = formatter.format(
|
||||||
|
this.toBigDecimal().divide(BigDecimal(100), 2, RoundingMode.HALF_UP)
|
||||||
|
)
|
|
@ -1,3 +1,7 @@
|
||||||
|
* {
|
||||||
|
transition: 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
@ -30,15 +34,75 @@ html, body {
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-image: linear-gradient(var(--background-color-primary), var(--background-color-secondary));
|
background-image: linear-gradient(var(--background-color-primary), var(--background-color-secondary));
|
||||||
|
background-attachment: fixed;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6, p, ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6, p, summary {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media all and (min-width: 1200px) {
|
||||||
|
.columns {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.columns > * {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--background-color-primary);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
border: 1px solid var(--color-accent);
|
border: 1px solid var(--color-accent);
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
|
@ -75,6 +139,7 @@ body {
|
||||||
.logo {
|
.logo {
|
||||||
background-image: var(--logo);
|
background-image: var(--logo);
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
height: 200px;
|
height: 200px;
|
||||||
width: 200px;
|
width: 200px;
|
||||||
}
|
}
|
||||||
|
@ -103,6 +168,34 @@ a {
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item > a {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item > a:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-large {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-medium {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-small {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media all and (max-width: 400px) {
|
@media all and (max-width: 400px) {
|
||||||
button {
|
button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
8
web/src/main/resources/templates/404.mustache
Normal file
8
web/src/main/resources/templates/404.mustache
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{{> partials/head }}
|
||||||
|
<div id="app">
|
||||||
|
<div class="center">
|
||||||
|
<div class="logo"></div>
|
||||||
|
<p>Looks like you got a little lost in the woods. Take a few steps back and try again!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{>partials/foot}}
|
|
@ -1,14 +1,65 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
{{> partials/head }}
|
{{> partials/head }}
|
||||||
<body>
|
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<div class="center">
|
{{>partials/sidebar}}
|
||||||
<div class="flex-full-width">
|
<main>
|
||||||
{{>partials/budget-list}}
|
<h1>{{budget.name}}</h1>
|
||||||
|
<p>{{budget.description}}</p>
|
||||||
|
<div class="row">
|
||||||
|
<div class="stacked-label">
|
||||||
|
<p class="body-small">Month</p>
|
||||||
|
<p class="body-large">TODO</p>
|
||||||
|
</div>
|
||||||
|
<div class="stacked-label">
|
||||||
|
<p class="body-small">Cash Flow</p>
|
||||||
|
<p class="body-large">{{balances.cashFlow}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="stacked-label">
|
||||||
|
<p class="body-small">Transactions</p>
|
||||||
|
<p class="body-large">{{transactionCount}}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Income</h3>
|
||||||
|
<div class="column">
|
||||||
|
<p>{{balances.actualIncomeLabel}} earned of {{balances.expectedIncomeLabel}} expected</p>
|
||||||
|
<progress style="margin: 0 0.5rem;"
|
||||||
|
value="{{balances.actualIncome}}"
|
||||||
|
max="{{balances.maxProgressBarValue}}">{{balances.actualIncomeLabel}}</progress>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{{#incomeCategories}}
|
||||||
|
{{>partials/category-list}}
|
||||||
|
{{/incomeCategories}}
|
||||||
|
</ul>
|
||||||
|
<details>
|
||||||
|
<summary>Archived</summary>
|
||||||
|
<ul>
|
||||||
|
{{#archivedIncomeCategories}}
|
||||||
|
{{>partials/category-list}}
|
||||||
|
{{/archivedIncomeCategories}}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
<h3>Expenses</h3>
|
||||||
|
<div class="column">
|
||||||
|
<p>{{balances.actualExpensesLabel}} spent of {{balances.expectedExpensesLabel}} expected</p>
|
||||||
|
<progress style="margin: 0 0.5rem;"
|
||||||
|
value="{{balances.actualExpenses}}"
|
||||||
|
max="{{balances.maxProgressBarValue}}">{{balances.actualExpensesLabel}}</progress>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{{#expenseCategories}}
|
||||||
|
{{>partials/category-list}}
|
||||||
|
{{/expenseCategories}}
|
||||||
|
</ul>
|
||||||
|
<details>
|
||||||
|
<summary>Archived</summary>
|
||||||
|
<ul>
|
||||||
|
{{#archivedExpenseCategories}}
|
||||||
|
{{>partials/category-list}}
|
||||||
|
{{/archivedExpenseCategories}}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{{>partials/foot}}
|
{{>partials/foot}}
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -1,7 +1,4 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
{{> partials/head }}
|
{{> partials/head }}
|
||||||
<body>
|
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<h1>{{title}}</h1>
|
<h1>{{title}}</h1>
|
||||||
<div class="center">
|
<div class="center">
|
||||||
|
@ -18,5 +15,3 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{>partials/foot}}
|
{{>partials/foot}}
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -1,7 +1,4 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
{{> partials/head }}
|
{{> partials/head }}
|
||||||
<body>
|
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<div class="center">
|
<div class="center">
|
||||||
<div class="flex-full-width">
|
<div class="flex-full-width">
|
||||||
|
@ -10,5 +7,3 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{>partials/foot}}
|
{{>partials/foot}}
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -1,7 +1,4 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
{{> partials/head }}
|
{{> partials/head }}
|
||||||
<body>
|
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<div class="center">
|
<div class="center">
|
||||||
<div class="logo"></div>
|
<div class="logo"></div>
|
||||||
|
@ -12,5 +9,3 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{>partials/foot}}
|
{{>partials/foot}}
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -1,7 +1,4 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
{{> partials/head }}
|
{{> partials/head }}
|
||||||
<body>
|
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<h1>{{title}}</h1>
|
<h1>{{title}}</h1>
|
||||||
<div class="center">
|
<div class="center">
|
||||||
|
@ -21,5 +18,3 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{>partials/foot}}
|
{{>partials/foot}}
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -1,8 +1,10 @@
|
||||||
<ul>
|
<ul>
|
||||||
{{#budgets}}
|
{{#budgets}}
|
||||||
<li>
|
<li class="list-item">
|
||||||
<span class="budget-name">{{name}}</span>
|
<a class="{{#selected}}selected{{/selected}}" href="/budgets/{{id}}">
|
||||||
<span class="budget-description">{{description}}</span>
|
<span class="body-large">{{name}}</span>
|
||||||
|
<span class="body-small">{{description}}</span>
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{{/budgets}}
|
{{/budgets}}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
<li class="list-item">
|
||||||
|
<a href="/categories/{{category.id}}">
|
||||||
|
<div class="row" style="justify-content: space-between">
|
||||||
|
<span class="body-large">{{category.title}}</span>
|
||||||
|
<span class="body-small">{{remainingAmountLabel}} remaining</span>
|
||||||
|
</div>
|
||||||
|
<progress value="{{balance}}" max="{{category.amount}}">{{balanceLabel}}</progress>
|
||||||
|
</a>
|
||||||
|
</li>
|
|
@ -1 +1,3 @@
|
||||||
<script type="text/javascript" async src="/js/index.js"></script>
|
<script type="text/javascript" async src="/js/index.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -1,3 +1,6 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en-US"> <!-- TODO: Localization -->
|
||||||
|
<head>
|
||||||
<link rel="stylesheet" href="/style.css"/>
|
<link rel="stylesheet" href="/style.css"/>
|
||||||
<title>{{ title }}</title>
|
<title>{{ title }}</title>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
@ -11,3 +14,5 @@
|
||||||
<meta name="application-name" content="Twigs">
|
<meta name="application-name" content="Twigs">
|
||||||
<meta name="theme-color" content="#FFFFFF">
|
<meta name="theme-color" content="#FFFFFF">
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
|
</head>
|
||||||
|
<body>
|
|
@ -0,0 +1,4 @@
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="logo" style="height: 100px; width: 100px;"></div>
|
||||||
|
{{>budget-list}}
|
||||||
|
</aside>
|
|
@ -1,7 +1,4 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
{{> partials/head }}
|
{{> partials/head }}
|
||||||
<body>
|
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<h1>{{title}}</h1>
|
<h1>{{title}}</h1>
|
||||||
<div class="center">
|
<div class="center">
|
||||||
|
@ -24,5 +21,3 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{>partials/foot}}
|
{{>partials/foot}}
|
||||||
</body>
|
|
||||||
</html>
|
|
Loading…
Reference in a new issue