Add transaction details page
This commit is contained in:
parent
e8d3719adc
commit
fed06cd155
12 changed files with 329 additions and 8 deletions
|
@ -8,6 +8,7 @@ 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.budget.budgetWebRoutes
|
||||||
import com.wbrawner.twigs.web.category.categoryWebRoutes
|
import com.wbrawner.twigs.web.category.categoryWebRoutes
|
||||||
|
import com.wbrawner.twigs.web.transaction.transactionWebRoutes
|
||||||
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.*
|
||||||
|
@ -45,5 +46,6 @@ fun Application.webRoutes(
|
||||||
}
|
}
|
||||||
budgetWebRoutes(budgetService, categoryService, transactionService, userService)
|
budgetWebRoutes(budgetService, categoryService, transactionService, userService)
|
||||||
categoryWebRoutes(budgetService, categoryService, transactionService, userService)
|
categoryWebRoutes(budgetService, categoryService, transactionService, userService)
|
||||||
|
transactionWebRoutes(budgetService, categoryService, transactionService, userService)
|
||||||
userWebRoutes(userService)
|
userWebRoutes(userService)
|
||||||
}
|
}
|
|
@ -22,10 +22,11 @@ data class BudgetDetailsPage(
|
||||||
val archivedIncomeCategories: List<CategoryWithBalanceResponse>,
|
val archivedIncomeCategories: List<CategoryWithBalanceResponse>,
|
||||||
val archivedExpenseCategories: List<CategoryWithBalanceResponse>,
|
val archivedExpenseCategories: List<CategoryWithBalanceResponse>,
|
||||||
val transactionCount: String,
|
val transactionCount: String,
|
||||||
|
val monthAndYear: 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 = budget.name.orEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
data class BudgetFormPage(
|
data class BudgetFormPage(
|
||||||
|
|
|
@ -25,6 +25,8 @@ import io.ktor.server.util.*
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.math.RoundingMode
|
import java.math.RoundingMode
|
||||||
import java.text.NumberFormat
|
import java.text.NumberFormat
|
||||||
|
import java.time.YearMonth
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
@ -157,6 +159,7 @@ fun Application.budgetWebRoutes(
|
||||||
archivedExpenseCategories = archivedExpenseCategories,
|
archivedExpenseCategories = archivedExpenseCategories,
|
||||||
transactionCount = NumberFormat.getNumberInstance(Locale.US)
|
transactionCount = NumberFormat.getNumberInstance(Locale.US)
|
||||||
.format(transactions.size),
|
.format(transactions.size),
|
||||||
|
monthAndYear = YearMonth.now().format(DateTimeFormatter.ofPattern("MMMM yyyy")),
|
||||||
user = user
|
user = user
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
@ -10,6 +10,7 @@ import java.text.NumberFormat
|
||||||
|
|
||||||
data class CategoryDetailsPage(
|
data class CategoryDetailsPage(
|
||||||
val category: CategoryWithBalanceResponse,
|
val category: CategoryWithBalanceResponse,
|
||||||
|
val budget: BudgetResponse,
|
||||||
val budgets: List<BudgetResponse>,
|
val budgets: List<BudgetResponse>,
|
||||||
val transactionCount: String,
|
val transactionCount: String,
|
||||||
val transactions: List<Map.Entry<String, List<TransactionListItem>>>,
|
val transactions: List<Map.Entry<String, List<TransactionListItem>>>,
|
||||||
|
@ -39,6 +40,7 @@ fun TransactionResponse.toListItem(numberFormat: NumberFormat) = TransactionList
|
||||||
|
|
||||||
data class CategoryFormPage(
|
data class CategoryFormPage(
|
||||||
val category: CategoryResponse,
|
val category: CategoryResponse,
|
||||||
|
val budget: BudgetResponse,
|
||||||
override val user: UserResponse,
|
override val user: UserResponse,
|
||||||
override val error: String? = null
|
override val error: String? = null
|
||||||
) : AuthenticatedPage {
|
) : AuthenticatedPage {
|
||||||
|
|
|
@ -60,7 +60,8 @@ fun Application.categoryWebRoutes(
|
||||||
expense = true,
|
expense = true,
|
||||||
archived = false,
|
archived = false,
|
||||||
),
|
),
|
||||||
user
|
budget = budgetService.budget(budgetId = budgetId, userId = user.id),
|
||||||
|
user = user
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -88,8 +89,9 @@ fun Application.categoryWebRoutes(
|
||||||
archived = call.parameters["archived"]?.toBoolean() ?: false,
|
archived = call.parameters["archived"]?.toBoolean() ?: false,
|
||||||
budgetId = budgetId
|
budgetId = budgetId
|
||||||
),
|
),
|
||||||
user,
|
budget = budgetService.budget(budgetId = budgetId, userId = user.id),
|
||||||
e.message
|
user = user,
|
||||||
|
error = e.message
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -130,13 +132,17 @@ fun Application.categoryWebRoutes(
|
||||||
.mapValues { (_, transactions) -> transactions.map { it.toListItem(numberFormat) } }
|
.mapValues { (_, transactions) -> transactions.map { it.toListItem(numberFormat) } }
|
||||||
.entries
|
.entries
|
||||||
.sortedBy { it.key }
|
.sortedBy { it.key }
|
||||||
|
val budgets = budgetService.budgetsForUser(user.id)
|
||||||
|
val budgetId = call.parameters.getOrFail("budgetId")
|
||||||
|
val budget = budgets.first { it.id == budgetId }
|
||||||
call.respond(
|
call.respond(
|
||||||
MustacheContent(
|
MustacheContent(
|
||||||
"category-details.mustache", CategoryDetailsPage(
|
"category-details.mustache", CategoryDetailsPage(
|
||||||
category = categoryWithBalance,
|
category = categoryWithBalance,
|
||||||
transactions = transactionsByDate,
|
transactions = transactionsByDate,
|
||||||
transactionCount = transactionCount,
|
transactionCount = transactionCount,
|
||||||
budgets = budgetService.budgetsForUser(user.id),
|
budgets = budgets,
|
||||||
|
budget = budget,
|
||||||
user = user
|
user = user
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -156,10 +162,12 @@ fun Application.categoryWebRoutes(
|
||||||
categoryId = call.parameters.getOrFail("id"),
|
categoryId = call.parameters.getOrFail("id"),
|
||||||
userId = user.id
|
userId = user.id
|
||||||
)
|
)
|
||||||
|
val budgetId = call.parameters.getOrFail("budgetId")
|
||||||
|
val budget = budgetService.budget(budgetId = budgetId, userId = user.id)
|
||||||
call.respond(
|
call.respond(
|
||||||
MustacheContent(
|
MustacheContent(
|
||||||
"category-form.mustache",
|
"category-form.mustache",
|
||||||
CategoryFormPage(category, user)
|
CategoryFormPage(category, budget, user)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
package com.wbrawner.twigs.web.transaction
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.service.budget.BudgetResponse
|
||||||
|
import com.wbrawner.twigs.service.category.CategoryResponse
|
||||||
|
import com.wbrawner.twigs.service.transaction.TransactionResponse
|
||||||
|
import com.wbrawner.twigs.service.user.UserResponse
|
||||||
|
import com.wbrawner.twigs.web.AuthenticatedPage
|
||||||
|
|
||||||
|
data class TransactionDetailsPage(
|
||||||
|
val transaction: TransactionResponse,
|
||||||
|
val category: CategoryResponse?,
|
||||||
|
val budget: BudgetResponse,
|
||||||
|
val amountLabel: String,
|
||||||
|
val dateLabel: String,
|
||||||
|
val budgets: List<BudgetResponse>,
|
||||||
|
val createdBy: UserResponse,
|
||||||
|
override val user: UserResponse,
|
||||||
|
override val error: String? = null
|
||||||
|
) : AuthenticatedPage {
|
||||||
|
override val title: String = transaction.title.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class TransactionFormPage(
|
||||||
|
val transaction: TransactionResponse,
|
||||||
|
val budget: BudgetResponse,
|
||||||
|
val incomeCategories: List<CategoryResponse>,
|
||||||
|
val expenseCategories: List<CategoryResponse>,
|
||||||
|
override val user: UserResponse,
|
||||||
|
override val error: String? = null
|
||||||
|
) : AuthenticatedPage {
|
||||||
|
override val title: String = if (transaction.id.isBlank()) {
|
||||||
|
"New Category"
|
||||||
|
} else {
|
||||||
|
"Edit Category"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,190 @@
|
||||||
|
package com.wbrawner.twigs.web.transaction
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.service.HttpException
|
||||||
|
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.TransactionRequest
|
||||||
|
import com.wbrawner.twigs.service.transaction.TransactionResponse
|
||||||
|
import com.wbrawner.twigs.service.transaction.TransactionService
|
||||||
|
import com.wbrawner.twigs.service.user.UserService
|
||||||
|
import com.wbrawner.twigs.toInstant
|
||||||
|
import com.wbrawner.twigs.web.NotFoundPage
|
||||||
|
import com.wbrawner.twigs.web.budget.toCurrencyString
|
||||||
|
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.*
|
||||||
|
import java.text.NumberFormat
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneOffset.UTC
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
private val dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT)
|
||||||
|
private val numberFormat = NumberFormat.getCurrencyInstance(Locale.US)
|
||||||
|
|
||||||
|
fun Application.transactionWebRoutes(
|
||||||
|
budgetService: BudgetService,
|
||||||
|
categoryService: CategoryService,
|
||||||
|
transactionService: TransactionService,
|
||||||
|
userService: UserService
|
||||||
|
) {
|
||||||
|
routing {
|
||||||
|
authenticate(TWIGS_SESSION_COOKIE) {
|
||||||
|
route("/budgets/{budgetId}/transactions") {
|
||||||
|
get {
|
||||||
|
// TODO: Show transaction list here
|
||||||
|
val budgetId = call.parameters.getOrFail("budgetId")
|
||||||
|
call.respondRedirect("/budgets/$budgetId")
|
||||||
|
}
|
||||||
|
|
||||||
|
route("/new") {
|
||||||
|
get {
|
||||||
|
val user = userService.user(requireSession().userId)
|
||||||
|
val budgetId = call.parameters.getOrFail("budgetId")
|
||||||
|
call.respond(
|
||||||
|
MustacheContent(
|
||||||
|
"transaction-form.mustache",
|
||||||
|
TransactionFormPage(
|
||||||
|
TransactionResponse(
|
||||||
|
id = "",
|
||||||
|
title = "",
|
||||||
|
description = "",
|
||||||
|
amount = 0,
|
||||||
|
budgetId = budgetId,
|
||||||
|
expense = true,
|
||||||
|
date = dateTimeFormatter.format(Instant.now()),
|
||||||
|
categoryId = null,
|
||||||
|
createdBy = user.id
|
||||||
|
),
|
||||||
|
budget = budgetService.budget(budgetId = budgetId, userId = user.id),
|
||||||
|
incomeCategories = categoryService.categories(
|
||||||
|
budgetIds = listOf(budgetId),
|
||||||
|
userId = user.id,
|
||||||
|
expense = false,
|
||||||
|
archived = false
|
||||||
|
),
|
||||||
|
expenseCategories = categoryService.categories(
|
||||||
|
budgetIds = listOf(budgetId),
|
||||||
|
userId = user.id,
|
||||||
|
expense = true,
|
||||||
|
archived = false
|
||||||
|
),
|
||||||
|
user
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
val user = userService.user(requireSession().userId)
|
||||||
|
val budgetId = call.parameters.getOrFail("budgetId")
|
||||||
|
try {
|
||||||
|
val request = call.receiveParameters().toTransactionRequest()
|
||||||
|
val transaction = transactionService.save(request, user.id)
|
||||||
|
call.respondRedirect("/budgets/${transaction.budgetId}/categories/${transaction.id}")
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
call.respond(
|
||||||
|
status = e.statusCode,
|
||||||
|
MustacheContent(
|
||||||
|
"transaction-form.mustache",
|
||||||
|
TransactionFormPage(
|
||||||
|
TransactionResponse(
|
||||||
|
id = "",
|
||||||
|
title = call.parameters["title"],
|
||||||
|
description = call.parameters["description"],
|
||||||
|
amount = call.parameters["amount"]?.toLongOrNull(),
|
||||||
|
budgetId = budgetId,
|
||||||
|
expense = call.parameters["expense"]?.toBoolean() ?: true,
|
||||||
|
date = call.parameters["date"].orEmpty(),
|
||||||
|
categoryId = call.parameters["categoryId"],
|
||||||
|
createdBy = user.id
|
||||||
|
),
|
||||||
|
budget = budgetService.budget(budgetId = budgetId, userId = user.id),
|
||||||
|
incomeCategories = categoryService.categories(
|
||||||
|
budgetIds = listOf(budgetId),
|
||||||
|
userId = user.id,
|
||||||
|
expense = false,
|
||||||
|
archived = false
|
||||||
|
),
|
||||||
|
expenseCategories = categoryService.categories(
|
||||||
|
budgetIds = listOf(budgetId),
|
||||||
|
userId = user.id,
|
||||||
|
expense = true,
|
||||||
|
archived = false
|
||||||
|
),
|
||||||
|
user
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
route("/{id}") {
|
||||||
|
get {
|
||||||
|
val user = userService.user(requireSession().userId)
|
||||||
|
val transactionId = call.parameters.getOrFail("id")
|
||||||
|
val budgetId = call.parameters.getOrFail("budgetId")
|
||||||
|
// TODO: Allow user-configurable locale
|
||||||
|
try {
|
||||||
|
val transaction = transactionService.transaction(
|
||||||
|
transactionId = transactionId,
|
||||||
|
userId = user.id
|
||||||
|
)
|
||||||
|
check(transaction.budgetId == budgetId) {
|
||||||
|
// TODO: redirect instead of error?
|
||||||
|
"Attempted to fetch transaction from wrong budget"
|
||||||
|
}
|
||||||
|
val category = transaction.categoryId?.let {
|
||||||
|
categoryService.category(categoryId = it, userId = user.id)
|
||||||
|
}
|
||||||
|
val budgets = budgetService.budgetsForUser(user.id)
|
||||||
|
val budget = budgets.first { it.id == budgetId }
|
||||||
|
val dateFormat = DateTimeFormatter.ofPattern("H:mm a 'on' MMMM d, yyyy")
|
||||||
|
val transactionInstant = transaction.date.toInstant()
|
||||||
|
val transactionOffset = transactionInstant.atOffset(UTC)
|
||||||
|
val dateLabel = transactionOffset.format(dateFormat)
|
||||||
|
call.respond(
|
||||||
|
MustacheContent(
|
||||||
|
"transaction-details.mustache", TransactionDetailsPage(
|
||||||
|
transaction = transaction,
|
||||||
|
category = category,
|
||||||
|
budget = budget,
|
||||||
|
budgets = budgets,
|
||||||
|
amountLabel = transaction.amount?.toCurrencyString(numberFormat).orEmpty(),
|
||||||
|
dateLabel = dateLabel,
|
||||||
|
createdBy = userService.user(transaction.createdBy),
|
||||||
|
user = user
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
call.respond(
|
||||||
|
status = e.statusCode,
|
||||||
|
MustacheContent("404.mustache", NotFoundPage)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Parameters.toTransactionRequest() = TransactionRequest(
|
||||||
|
title = get("title"),
|
||||||
|
description = get("description"),
|
||||||
|
amount = get("amount")?.toLongOrNull(),
|
||||||
|
expense = get("expense")?.toBoolean(),
|
||||||
|
date = get("date"),
|
||||||
|
categoryId = get("categoryId"),
|
||||||
|
budgetId = get("budgetId"),
|
||||||
|
)
|
|
@ -180,6 +180,7 @@ a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
|
color: var(--color-on-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-item > a:hover {
|
.list-item > a:hover {
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="stacked-label">
|
<div class="stacked-label">
|
||||||
<p class="body-small">Month</p>
|
<p class="body-small">Month</p>
|
||||||
<p class="body-large">TODO</p>
|
<p class="body-large">{{monthAndYear}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="stacked-label">
|
<div class="stacked-label">
|
||||||
<p class="body-small">Cash Flow</p>
|
<p class="body-small">Cash Flow</p>
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
{{#budgets}}
|
{{#budgets}}
|
||||||
<li class="list-item">
|
<li class="list-item">
|
||||||
<a class="{{#selected}}selected{{/selected}}" href="/budgets/{{id}}">
|
<a class="{{#selected}}selected{{/selected}}" href="/budgets/{{id}}">
|
||||||
<span class="body-large">{{name}}</span>
|
<span class="body-medium">{{name}}</span>
|
||||||
<span class="body-small">{{description}}</span>
|
<span class="body-small">{{description}}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
@ -1,4 +1,45 @@
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="logo" style="height: 100px; width: 100px;"></div>
|
<div class="logo" style="height: 100px; width: 100px;"></div>
|
||||||
|
<ul>
|
||||||
|
<li class="list-item">
|
||||||
|
<a href="/budgets/{{budget.id}}">
|
||||||
|
Overview
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="list-item">
|
||||||
|
<a href="/budgets/{{budget.id}}/transactions">
|
||||||
|
Transactions
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="list-item">
|
||||||
|
<a href="/budgets/{{budget.id}}/recurring">
|
||||||
|
Recurring Transactions
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<hr/>
|
||||||
{{>budget-list}}
|
{{>budget-list}}
|
||||||
|
<hr/>
|
||||||
|
<ul>
|
||||||
|
<li class="list-item">
|
||||||
|
<a href="/profile">
|
||||||
|
Profile
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="list-item">
|
||||||
|
<a href="/settings">
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="list-item">
|
||||||
|
<a href="/about">
|
||||||
|
About Twigs
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="list-item">
|
||||||
|
<a href="https://github.com/wbrawner/twigs">
|
||||||
|
Source Code
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</aside>
|
</aside>
|
|
@ -0,0 +1,37 @@
|
||||||
|
{{> partials/head }}
|
||||||
|
<div id="app">
|
||||||
|
{{>partials/sidebar}}
|
||||||
|
<main>
|
||||||
|
<h1>{{title}}</h1>
|
||||||
|
<div class="card">
|
||||||
|
<div class="row">
|
||||||
|
<span class="body-large">{{dateLabel}}</span>
|
||||||
|
<span class="{{#transaction.expense}}expense{{/transaction.expense}}{{^transaction.expense}}income{{/transaction.expense}} body-large">{{amountLabel}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<p>{{transaction.description}}</p>
|
||||||
|
<ul>
|
||||||
|
<li class="list-item">
|
||||||
|
<a href="/budgets/{{budget.id}}/categories/{{category.id}}">
|
||||||
|
<span class="body-small">Category</span>
|
||||||
|
<span class="body-large">{{category.title}}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="list-item">
|
||||||
|
<a href="/budgets/{{budget.id}}">
|
||||||
|
<span class="body-small">Budget</span>
|
||||||
|
<span class="body-large">{{budget.name}}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="list-item">
|
||||||
|
<a href="/budgets/{{budget.id}}">
|
||||||
|
<span class="body-small">Created by</span>
|
||||||
|
<span class="body-large">{{createdBy.username}}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{{>partials/foot}}
|
Loading…
Reference in a new issue