Build out more frontend views
Signed-off-by: William Brawner <me@wbrawner.com>
This commit is contained in:
parent
a5e349e776
commit
69c21ad37b
18 changed files with 745 additions and 79 deletions
|
@ -1,5 +1,8 @@
|
|||
module.exports = {
|
||||
extends: [
|
||||
'nextcloud'
|
||||
]
|
||||
],
|
||||
"rules": {
|
||||
"indent": ["error", 4]
|
||||
}
|
||||
};
|
||||
|
|
|
@ -23,7 +23,7 @@ return [
|
|||
[
|
||||
'name' => 'transaction#sum',
|
||||
'url' => '/api/v1.0/transactions/sum',
|
||||
'verb' => 'POST',
|
||||
'verb' => 'GET',
|
||||
]
|
||||
]
|
||||
];
|
||||
|
|
|
@ -1,3 +1,22 @@
|
|||
#hello {
|
||||
color: red;
|
||||
:root {
|
||||
--good-color: green;
|
||||
--warn-color: yellow;
|
||||
--danger-color: red;
|
||||
}
|
||||
|
||||
h2, h3 {
|
||||
margin: 0;
|
||||
padding: 0.25em 0.5em;
|
||||
}
|
||||
|
||||
.good {
|
||||
color: var(--good-color);
|
||||
}
|
||||
|
||||
.warn {
|
||||
color: var(--warn-color);
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: var(--danger-color);
|
||||
}
|
|
@ -176,4 +176,14 @@ class BudgetController extends Controller
|
|||
$this->budgetMapper->delete($budget);
|
||||
return new DataResponse($budget);
|
||||
}
|
||||
|
||||
public function stats(int $budgetId) {
|
||||
try {
|
||||
$userPermission = $this->userPermissionMapper->find($id, $this->userId);
|
||||
$budget = $this->budgetMapper->find($userPermission->getBudgetId());
|
||||
} catch (Exception $e) {
|
||||
return new DataResponse([], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,6 +25,8 @@ class TransactionController extends Controller
|
|||
private $transactionMapper;
|
||||
private $userPermissionMapper;
|
||||
private $logger;
|
||||
private $DATE_FORMAT = DateTime::RFC3339_EXTENDED;
|
||||
private const DATE_FORMAT = "Y-m-d\TH:i:s.v\Z";
|
||||
|
||||
public function __construct(
|
||||
$AppName,
|
||||
|
@ -49,16 +51,19 @@ class TransactionController extends Controller
|
|||
* @NoAdminRequired
|
||||
* @NoCSRFRequired
|
||||
*/
|
||||
public function index()
|
||||
public function index(?int $budgetId, ?int $categoryId, ?int $count)
|
||||
{
|
||||
$budgetId = $_GET['budgetId'];
|
||||
$categoryId = $_GET['categoryId'];
|
||||
if ($budgetId == null) {
|
||||
try {
|
||||
if ($budgetId != null) {
|
||||
$this->userPermissionMapper->find($budgetId, $this->userId);
|
||||
} else if ($categoryId != null) {
|
||||
$category = $this->categoryMapper->find($categoryId);
|
||||
$budgetId = $category->getBudgetId();
|
||||
$this->userPermissionMapper->find($budgetId, $this->userId);
|
||||
} else {
|
||||
return new DataResponse([], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
try {
|
||||
$this->userPermissionMapper->find($budgetId, $this->userId);
|
||||
return new DataResponse($this->transactionMapper->findAll($budgetId, $categoryId));
|
||||
return new DataResponse($this->transactionMapper->findAll($budgetId, $categoryId, $count));
|
||||
} catch (Exception $e) {
|
||||
return new DataResponse([], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
@ -95,7 +100,7 @@ class TransactionController extends Controller
|
|||
*/
|
||||
public function create(
|
||||
string $name,
|
||||
string $description,
|
||||
?string $description,
|
||||
int $amount,
|
||||
string $date,
|
||||
bool $expense,
|
||||
|
@ -115,9 +120,9 @@ class TransactionController extends Controller
|
|||
$transaction->setDescription($description);
|
||||
$transaction->setAmount($amount);
|
||||
$transaction->setExpense($expense);
|
||||
$dateTime = DateTime::createFromFormat(DateTime::ATOM, $date);
|
||||
$dateTime = DateTime::createFromFormat($this->DATE_FORMAT, $date);
|
||||
if (!$dateTime) {
|
||||
return new DataResponse([], Http::STATUS_BAD_REQUEST);
|
||||
return new DataResponse(["message" => "Invalid date format: '$date'"], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
$transaction->setDate($dateTime->getTimestamp());
|
||||
$this->logger->error("Setting category $categoryId for new transaction");
|
||||
|
@ -168,7 +173,7 @@ class TransactionController extends Controller
|
|||
$transaction->setDescription($description);
|
||||
$transaction->setAmount($amount);
|
||||
$transaction->setExpense($expense);
|
||||
$dateTime = DateTime::createFromFormat(DateTime::ATOM, $date);
|
||||
$dateTime = DateTime::createFromFormat($this->DATE_FORMAT, $date);
|
||||
if (!$dateTime) {
|
||||
return new DataResponse([], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
@ -235,7 +240,7 @@ class TransactionController extends Controller
|
|||
);
|
||||
$startDateTime->setTime(0, 0, 0, 0);
|
||||
} else {
|
||||
$startDateTime = DateTime::createFromFormat(DateTime::ATOM, $startDate);
|
||||
$startDateTime = DateTime::createFromFormat($this->DATE_FORMAT, $startDate);
|
||||
}
|
||||
if (!$startDateTime) {
|
||||
return new DataResponse([], Http::STATUS_BAD_REQUEST);
|
||||
|
@ -250,7 +255,7 @@ class TransactionController extends Controller
|
|||
);
|
||||
$endDateTime->setTime(23, 59, 59, 999);
|
||||
} else {
|
||||
$endDateTime = DateTime::createFromFormat(DateTime::ATOM, $endDate);
|
||||
$endDateTime = DateTime::createFromFormat($this->DATE_FORMAT, $endDate);
|
||||
}
|
||||
if (!$endDateTime) {
|
||||
return new DataResponse([], Http::STATUS_BAD_REQUEST);
|
||||
|
|
|
@ -29,8 +29,11 @@ class TransactionMapper extends QBMapper
|
|||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
public function findAll(int $budgetId, ?int $categoryId)
|
||||
{
|
||||
public function findAll(
|
||||
int $budgetId,
|
||||
?int $categoryId,
|
||||
?int $count
|
||||
) {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
|
||||
$qb->select('*')
|
||||
|
@ -45,6 +48,12 @@ class TransactionMapper extends QBMapper
|
|||
);
|
||||
}
|
||||
|
||||
$qb->orderBy('date', 'desc');
|
||||
|
||||
if ($count) {
|
||||
$qb->setMaxResults($count);
|
||||
}
|
||||
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
|
@ -115,4 +124,8 @@ class TransactionMapper extends QBMapper
|
|||
$statement->execute();
|
||||
return (int) $statement->fetch(FetchMode::COLUMN);
|
||||
}
|
||||
|
||||
public function countByBudgetId(int $budgetId) {
|
||||
|
||||
}
|
||||
}
|
||||
|
|
10
src/App.vue
10
src/App.vue
|
@ -25,3 +25,13 @@ export default {
|
|||
},
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
.app-twigs {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.app-twigs > div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
</style>
|
100
src/components/AddEditTransaction.vue
Normal file
100
src/components/AddEditTransaction.vue
Normal file
|
@ -0,0 +1,100 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="!loading && transaction" class="add-edit-transaction">
|
||||
<h2>{{ transaction.id ? 'Edit' : 'Add' }} Transaction</h2>
|
||||
<input v-model="transaction.name" type="text" placeholder="Name" title="Name" />
|
||||
<textarea v-model="transaction.description" placeholder="Description" title="Description"></textarea>
|
||||
<input v-model.number="transaction.amount" type="number" placeholder="Amount" title="Amount" />
|
||||
<DatetimePicker :value="transaction.date" type="datetime" />
|
||||
<div class="radio-container">
|
||||
<input v-model="transaction.expense" type="radio" id="expense" :value="true" />
|
||||
<label for="expense">Expense</label>
|
||||
<input v-model="transaction.expense" type="radio" id="income" :value="false" />
|
||||
<label for="income">Income</label>
|
||||
</div>
|
||||
<select v-model="transaction.budgetId" v-on:change="updateCategories()">
|
||||
<option disabled value>Select a budget</option>
|
||||
<option v-for="budget in budgets" :key="budget.id" :value="budget.id">{{ budget.name }}</option>
|
||||
</select>
|
||||
<select v-model="transaction.categoryId">
|
||||
<option disabled value>Select a category</option>
|
||||
<option
|
||||
v-for="category in filteredCategories"
|
||||
:key="category.id"
|
||||
:value="category.id"
|
||||
>{{ category.name }}</option>
|
||||
</select>
|
||||
<button @click="saveTransaction()">Save Transaction</button>
|
||||
</div>
|
||||
<div v-if="loading" class="icon-loading"></div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { DatetimePicker } from "@nextcloud/vue/dist/Components/DatetimePicker";
|
||||
import { mapGetters } from "vuex";
|
||||
|
||||
export default {
|
||||
name: "add-edit-transaction",
|
||||
components: {
|
||||
DatetimePicker
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
transaction: Object,
|
||||
loading: true
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(["budgets"]),
|
||||
filteredCategories: function(state) {
|
||||
return this.$store.getters.categories.filter(function(category) {
|
||||
return category.expense === state.transaction.expense;
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateCategories() {
|
||||
this.$store.dispatch(
|
||||
"addEditTransactionBudgetSelected",
|
||||
this.transaction.budgetId
|
||||
);
|
||||
},
|
||||
saveTransaction() {
|
||||
this.loading = true
|
||||
this.$store.dispatch('addEditTransactionSaveClicked', this.transaction)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.$route.params.id) {
|
||||
this.transaction = this.$store.getters.transaction(this.$route.params.id);
|
||||
} else {
|
||||
this.transaction = {
|
||||
date: new Date(),
|
||||
expense: true,
|
||||
budgetId: this.$store.state.currentBudget,
|
||||
categoryId: this.$store.state.currentCategory
|
||||
};
|
||||
}
|
||||
this.loading = false;
|
||||
this.updateCategories();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.add-edit-transaction > * {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
.radio-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.radio-container label {
|
||||
margin-right: 1em;
|
||||
}
|
||||
.icon-loading {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
|
@ -1,39 +1,110 @@
|
|||
<template>
|
||||
<div v-if="budget">
|
||||
<h1 v-text="budget.name"></h1>
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<div class="header-info">
|
||||
<h2>{{ budget.name }}</h2>
|
||||
<h3
|
||||
v-if="balance"
|
||||
>Balance: {{ balance.toLocaleString(undefined, {style: 'currency', currency: 'USD'}) }}</h3>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button @click="addTransaction()"><span class="icon-add"></span> Add Transaction</button>
|
||||
<button><span class="icon-add"></span> Add Category</button>
|
||||
<Actions>
|
||||
<ActionButton icon="icon-edit" text="Edit" @click="alert('Delete')">Edit</ActionButton>
|
||||
<ActionButton icon="icon-delete" text="Delete" @click="alert('Delete')">Delete</ActionButton>
|
||||
</Actions>
|
||||
</div>
|
||||
</div>
|
||||
<div class="budget-details">
|
||||
<div class="card income">
|
||||
<h3>Income</h3>
|
||||
<CategoryList v-bind:budget-id="budget.id" v-bind:expense="false"></CategoryList>
|
||||
</div>
|
||||
<div class="card expenses">
|
||||
<h3>Expenses</h3>
|
||||
<CategoryList v-bind:budget-id="budget.id" v-bind:expense="true"></CategoryList>
|
||||
</div>
|
||||
<div class="card">
|
||||
<CategoryList v-bind:budget-id="budget.id" v-bind:expense="false"></CategoryList>
|
||||
<div class="card transactions">
|
||||
<h3>Recent Transactions</h3>
|
||||
<TransactionList :budget-id="budget.id"></TransactionList>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from "vuex";
|
||||
import { mapGetters, mapState } from "vuex";
|
||||
import { Actions } from "@nextcloud/vue/dist/Components/Actions";
|
||||
import { ActionButton } from "@nextcloud/vue/dist/Components/ActionButton";
|
||||
import CategoryList from "./CategoryList";
|
||||
import TransactionList from "./TransactionList";
|
||||
|
||||
export default {
|
||||
name: "budget-details",
|
||||
components: {
|
||||
CategoryList
|
||||
Actions,
|
||||
ActionButton,
|
||||
CategoryList,
|
||||
TransactionList
|
||||
},
|
||||
computed: {
|
||||
...mapState(["budgets", "currentBudget"]),
|
||||
budget: function(state) {
|
||||
const budgetId = this.$route.params.id;
|
||||
const budget = this.$store.getters.budgets.find(
|
||||
budget => budget.id === budgetId
|
||||
);
|
||||
return budget;
|
||||
if (state.budgets.length === 0 || !state.currentBudget) {
|
||||
return false;
|
||||
}
|
||||
return state.budgets.find(budget => budget.id === state.currentBudget);
|
||||
},
|
||||
balance: function(state) {
|
||||
if (!state.currentBudget) {
|
||||
return 0;
|
||||
}
|
||||
return this.$store.getters.budgetBalance(state.currentBudget) / 100;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
load() {
|
||||
this.$store.dispatch("budgetDetailsViewed", this.$route.params.id);
|
||||
},
|
||||
addTransaction() {
|
||||
this.$store.dispatch('addTransactionClicked')
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.load()
|
||||
this.load();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.budget-details {
|
||||
display: grid;
|
||||
justify-content: space-between;
|
||||
grid-gap: 0.5em;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.card {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.income {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.expenses {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.transactions {
|
||||
grid-column: 1;
|
||||
}
|
||||
</style>
|
|
@ -1,13 +1,13 @@
|
|||
<template>
|
||||
<div>
|
||||
<ul>
|
||||
<AppNavigationNew text="New Budget"></AppNavigationNew>
|
||||
<AppNavigationItem
|
||||
v-for="budget in budgets"
|
||||
:key="budget.id"
|
||||
:title="budget.name"
|
||||
v-on:click="view(budget.id)"
|
||||
></AppNavigationItem>
|
||||
<AppNavigationNew text="New Budget"></AppNavigationNew>
|
||||
</div>
|
||||
:to="{ name: 'budgetDetails', params: { id: budget.id } }"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
<script>
|
||||
import { AppNavigationItem } from "@nextcloud/vue/dist/Components/AppNavigationItem";
|
||||
|
@ -27,9 +27,6 @@ export default {
|
|||
load: function() {
|
||||
this.$store.dispatch('budgetListViewed')
|
||||
},
|
||||
view: function(id) {
|
||||
this.$router.push({ name: "budgetDetails", params: { id: id } })
|
||||
},
|
||||
new: function() {}
|
||||
},
|
||||
mounted() {
|
||||
|
|
43
src/components/CategoryDetails.vue
Normal file
43
src/components/CategoryDetails.vue
Normal file
|
@ -0,0 +1,43 @@
|
|||
<template>
|
||||
<div v-if="category" class="category-details">
|
||||
<h2>{{ category.name }}</h2>
|
||||
<h3 v-if="balance">Balance: {{ balance.toLocaleString(undefined, {style: 'currency', currency: 'USD'}) }}</h3>
|
||||
<h3>Transactions</h3>
|
||||
<TransactionList :category-id="category.id"></TransactionList>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters, mapState } from "vuex";
|
||||
import TransactionList from './TransactionList'
|
||||
|
||||
export default {
|
||||
name: "category-details",
|
||||
components: {
|
||||
TransactionList
|
||||
},
|
||||
computed: {
|
||||
...mapState(["categories", "currentCategory"]),
|
||||
category: function(state) {
|
||||
if (state.categories.length === 0 || !state.currentCategory) {
|
||||
return false;
|
||||
}
|
||||
return state.categories.find((category) => category.id === Number.parseInt(state.currentCategory));
|
||||
},
|
||||
balance: function(state) {
|
||||
if (!state.currentCategory) {
|
||||
return 0;
|
||||
}
|
||||
return this.$store.getters.categoryBalance(state.currentCategory) / 100;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
load() {
|
||||
this.$store.dispatch("categoryDetailsViewed", this.$route.params.id);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
console.log("CategoryDetails mounted")
|
||||
this.load();
|
||||
}
|
||||
};
|
||||
</script>
|
|
@ -1,29 +1,73 @@
|
|||
<template>
|
||||
<ul>
|
||||
<li v-for="category in categories" :key="category.id">
|
||||
<p>{{ category.name }}</p>
|
||||
<li v-for="category in filteredCategories" :key="category.id">
|
||||
<a v-on:click="view(category.id)" class="category-summary">
|
||||
<div class="category-info">
|
||||
<p class="category-name">{{ category.name }}</p>
|
||||
<p
|
||||
class="category-balance"
|
||||
>{{ category.expense ? 'Remaining' : 'Pending' }}: {{ (categoryRemainingBalance(category) / 100).toLocaleString(undefined, {style: 'currency', currency: 'USD'}) }}</p>
|
||||
</div>
|
||||
<ProgressBar
|
||||
:max="category.amount"
|
||||
:value="Math.abs(categoryBalance(category.id))"
|
||||
:invert-colors="!category.expense"
|
||||
></ProgressBar>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from "vuex";
|
||||
import { mapGetters, mapState } from "vuex";
|
||||
import ProgressBar from "./ProgressBar";
|
||||
|
||||
export default {
|
||||
name: "category-list",
|
||||
components: {},
|
||||
components: {
|
||||
ProgressBar
|
||||
},
|
||||
props: {
|
||||
budgetId: Number,
|
||||
expense: Boolean
|
||||
},
|
||||
computed: {
|
||||
categories: function(state) {
|
||||
const categories = this.$store.getters.categories(this.budgetId)
|
||||
if (categories) {
|
||||
return categories.filter(category => category.expense === this.expense)
|
||||
} else {
|
||||
return [];
|
||||
...mapState(["categories", "currentCategory"]),
|
||||
...mapGetters(["categoryBalance", "categoryRemainingBalance"]),
|
||||
filteredCategories: function(state) {
|
||||
return state.categories.filter(
|
||||
category => category.expense === this.expense
|
||||
);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
view: function(id) {
|
||||
this.$store.dispatch("categoryClicked", id);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
.category-summary {
|
||||
padding: 0.5em;
|
||||
height: 4em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.category-summary * {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.category-summary:hover {
|
||||
background: var(--color-background-hover);
|
||||
}
|
||||
|
||||
.category-summary .category-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
|
||||
</style>
|
57
src/components/ProgressBar.vue
Normal file
57
src/components/ProgressBar.vue
Normal file
|
@ -0,0 +1,57 @@
|
|||
<template>
|
||||
<div class="progress-bar">
|
||||
<div class="progress" :class="status" :style="{ width: progress + '%' }"></div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: "progress-bar",
|
||||
props: {
|
||||
value: Number,
|
||||
max: Number,
|
||||
invertColors: Boolean
|
||||
},
|
||||
computed: {
|
||||
progress: function(state) {
|
||||
return Math.round((this.value / this.max) * 100);
|
||||
},
|
||||
status: function(state) {
|
||||
if (this.progress <= 33) {
|
||||
return this.invertColors ? "danger" : "good";
|
||||
}
|
||||
|
||||
if (this.progress <= 66) {
|
||||
return "warn";
|
||||
}
|
||||
|
||||
if (this.progress <= 100) {
|
||||
return this.invertColors ? "good" : "danger";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.progress-bar {
|
||||
height: 0.5em;
|
||||
background: #f1f1f1;
|
||||
border-radius: 1em;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
transition: width ease-in-out 0.5s;
|
||||
}
|
||||
.progress.good {
|
||||
background: var(--good-color);
|
||||
}
|
||||
.progress.warn {
|
||||
background: var(--warn-color);
|
||||
}
|
||||
.progress.danger {
|
||||
background: var(--danger-color);
|
||||
}
|
||||
</style>
|
64
src/components/TransactionDetails.vue
Normal file
64
src/components/TransactionDetails.vue
Normal file
|
@ -0,0 +1,64 @@
|
|||
<template>
|
||||
<div v-if="transaction" class="transaction-details">
|
||||
<h2>{{ transaction.name }}</h2>
|
||||
<h3
|
||||
:class="transaction.expense ? 'danger' : 'good'"
|
||||
>{{ (transaction.amount / 100).toLocaleString(undefined, {style: 'currency', currency: 'USD'}) }} {{ transaction.expense ? 'Expense' : 'Income' }}</h3>
|
||||
<p class="transaction-info date">{{ new Date(transaction.date).toLocaleDateString() }}</p>
|
||||
<p class="transaction-info description">{{ transaction.description }}</p>
|
||||
<p v-if="category" class="transaction-info category">Category: {{ category.name }}</p>
|
||||
<p v-if="budget" class="transaction-info budget">Budget: {{ budget.name }}</p>
|
||||
<p class="transaction-info registered-by">
|
||||
Registered By:
|
||||
<UserBubble
|
||||
:user="transaction.createdBy"
|
||||
:display-name="transaction.createdBy"
|
||||
></UserBubble>
|
||||
{{ new Date(transaction.createdDate).toLocaleDateString() }}
|
||||
</p>
|
||||
<p v-if="transaction.updatedBy" class="transaction-info updated-by">
|
||||
Updated By:
|
||||
<UserBubble
|
||||
:user="transaction.updatedBy"
|
||||
:display-name="transaction.updatedBy"
|
||||
></UserBubble>
|
||||
{{ new Date(transaction.updatedDate).toLocaleDateString() }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters, mapState } from "vuex";
|
||||
import { UserBubble } from "@nextcloud/vue/dist/Components/UserBubble";
|
||||
|
||||
export default {
|
||||
name: "transaction-details",
|
||||
components: {
|
||||
UserBubble
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(["transaction"]),
|
||||
category: function(state, getters) {
|
||||
const transaction = this.$store.getters.transaction
|
||||
if (!transaction || !transaction.categoryId) {
|
||||
return undefined;
|
||||
}
|
||||
return this.$store.getters.category(transaction.categoryId)
|
||||
},
|
||||
budget: function(state) {
|
||||
const transaction = this.$store.getters.transaction
|
||||
if (!transaction || !transaction.budgetId) {
|
||||
return undefined;
|
||||
}
|
||||
return this.$store.getters.budget(transaction.budgetId)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
load() {
|
||||
this.$store.dispatch("transactionDetailsViewed", this.$route.params.id);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.load();
|
||||
}
|
||||
};
|
||||
</script>
|
74
src/components/TransactionList.vue
Normal file
74
src/components/TransactionList.vue
Normal file
|
@ -0,0 +1,74 @@
|
|||
<template>
|
||||
<ul>
|
||||
<li v-for="transaction in filteredTransactions" :key="transaction.id">
|
||||
<a v-on:click="view(transaction.id)" class="transaction">
|
||||
<div class="transaction-details">
|
||||
<p class="transaction-name">{{ transaction.name }}</p>
|
||||
<p class="transaction-date">{{ new Date(transaction.date).toLocaleDateString() }}</p>
|
||||
</div>
|
||||
<p
|
||||
class="transaction-amount"
|
||||
:class="transaction.expense ? 'danger' : 'good'"
|
||||
>{{ (transaction.amount / 100).toLocaleString(undefined, {style: 'currency', currency: 'USD'}) }}</p>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters, mapState } from "vuex";
|
||||
|
||||
export default {
|
||||
name: "transaction-list",
|
||||
components: {
|
||||
},
|
||||
props: {
|
||||
budgetId: Number,
|
||||
categoryId: Number,
|
||||
},
|
||||
computed: {
|
||||
...mapState(["transactions", "currentTransaction"]),
|
||||
filteredTransactions: function(state) {
|
||||
return state.transactions.filter(function(transaction) {
|
||||
console.log(transaction.date)
|
||||
if (state.budgetId) {
|
||||
return transaction.budgetId === state.budgetId
|
||||
}
|
||||
if (state.categoryId) {
|
||||
return transaction.categoryId === state.categoryId
|
||||
}
|
||||
return false
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
view: function(id) {
|
||||
this.$store.dispatch("transactionClicked", id);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
.transaction {
|
||||
padding: 0.5em;
|
||||
height: 4em;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.transaction * {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.transaction:hover {
|
||||
background: var(--color-background-hover);
|
||||
}
|
||||
|
||||
.transaction-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
|
@ -1,6 +1,9 @@
|
|||
import VueRouter from 'vue-router'
|
||||
import Vue from 'vue'
|
||||
import BudgetDetails from '../components/BudgetDetails'
|
||||
import CategoryDetails from '../components/CategoryDetails'
|
||||
import AddEditTransaction from '../components/AddEditTransaction'
|
||||
import TransactionDetails from '../components/TransactionDetails'
|
||||
|
||||
Vue.use(VueRouter)
|
||||
|
||||
|
@ -9,7 +12,27 @@ const routes = [
|
|||
path: '/budgets/:id',
|
||||
name: 'budgetDetails',
|
||||
component: BudgetDetails,
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/categories/:id',
|
||||
name: 'categoryDetails',
|
||||
component: CategoryDetails,
|
||||
},
|
||||
{
|
||||
path: '/transactions/new',
|
||||
name: 'newTransaction',
|
||||
component: AddEditTransaction,
|
||||
},
|
||||
{
|
||||
path: '/transactions/:id',
|
||||
name: 'transactionDetails',
|
||||
component: TransactionDetails,
|
||||
},
|
||||
{
|
||||
path: '/transactions/:id/edit',
|
||||
name: 'editTransaction',
|
||||
component: AddEditTransaction,
|
||||
},
|
||||
]
|
||||
|
||||
export default new VueRouter({
|
||||
|
|
|
@ -1,56 +1,189 @@
|
|||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
import axios from '@nextcloud/axios'
|
||||
import router from '../router'
|
||||
|
||||
Vue.use(Vuex)
|
||||
|
||||
export default new Vuex.Store({
|
||||
state: {
|
||||
budgets: [],
|
||||
budgetBalances: {},
|
||||
currentBudget: 0,
|
||||
categories: {},
|
||||
categories: [],
|
||||
categoryBalances: {},
|
||||
currentCategory: 0,
|
||||
transactions: [],
|
||||
currentTransaction: 0,
|
||||
},
|
||||
getters: {
|
||||
budgets: (state) => {
|
||||
return state.budgets
|
||||
budgets: (state) => state.budgets,
|
||||
budget: (state) => (id) => state.budgets.find(budget => budget.id === id),
|
||||
budgetBalance: (state) => (id) => state.budgetBalances[id],
|
||||
categories: (state) => state.categories,
|
||||
category: (state) => (id) => {
|
||||
return state.categories.find(category => category.id === id)
|
||||
},
|
||||
budget: (state) => (id) => {
|
||||
return state.budgets.find(budget => budget.id === id)
|
||||
categoryBalance: (state) => (categoryId) => {
|
||||
return state.categoryBalances[categoryId];
|
||||
},
|
||||
categories: (state) => (budgetId) => {
|
||||
return state.categories[budgetId]
|
||||
categoryRemainingBalance: (state, getters) => (category) => {
|
||||
const modifier = category.expense ? -1 : 1;
|
||||
return category.amount - (getters.categoryBalance(category.id) * modifier);
|
||||
},
|
||||
transactions: (state) => state.transactions,
|
||||
transaction: (state) => state.transactions.find(transaction => transaction.id === state.currentTransaction),
|
||||
},
|
||||
actions: {
|
||||
budgetListViewed({ commit }) {
|
||||
axios.get(OC.generateUrl('/apps/twigs/api/v1.0/budgets'))
|
||||
.then(function (response) {
|
||||
commit('setBudgets', response.data)
|
||||
})
|
||||
},
|
||||
budgetDetailsViewed({ commit }, budgetId) {
|
||||
axios.get(OC.generateUrl(`/apps/twigs/api/v1.0/categories?budgetId=${budgetId}`))
|
||||
response.data.forEach(budget => {
|
||||
axios.get(OC.generateUrl(`/apps/twigs/api/v1.0/transactions/sum?budgetId=${budget.id}`))
|
||||
.then(function (response) {
|
||||
commit({
|
||||
type: 'setCategories',
|
||||
budgetId: budgetId,
|
||||
categories: response.data
|
||||
type: 'setBudgetBalance',
|
||||
...response.data
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
budgetClicked({ commit }, budgetId) {
|
||||
router.push({ name: "budgetDetails", params: { id: budgetId } })
|
||||
},
|
||||
budgetDetailsViewed({ commit }, budgetId) {
|
||||
commit('setCurrentBudget', budgetId)
|
||||
commit('setCategories', [])
|
||||
commit('setTransactions', [])
|
||||
commit('setCurrentCategory', undefined)
|
||||
axios.get(OC.generateUrl(`/apps/twigs/api/v1.0/categories?budgetId=${budgetId}`))
|
||||
.then(function (response) {
|
||||
commit('setCategories', response.data)
|
||||
response.data.forEach(category => {
|
||||
axios.get(OC.generateUrl(`/apps/twigs/api/v1.0/transactions/sum?categoryId=${category.id}`))
|
||||
.then(function (response) {
|
||||
commit({
|
||||
type: 'setCategoryBalance',
|
||||
...response.data
|
||||
})
|
||||
})
|
||||
});
|
||||
})
|
||||
axios.get(OC.generateUrl(`/apps/twigs/api/v1.0/transactions?budgetId=${budgetId}?count=10`))
|
||||
.then((response) => commit('setTransactions', response.data))
|
||||
},
|
||||
categoryClicked({ commit }, categoryId) {
|
||||
router.push({ name: "categoryDetails", params: { id: categoryId } })
|
||||
},
|
||||
categoryDetailsViewed({ commit, state }, categoryId) {
|
||||
commit('setCurrentCategory', categoryId)
|
||||
if (state.categories.length === 0) {
|
||||
axios.get(OC.generateUrl(`/apps/twigs/api/v1.0/categories/${categoryId}`))
|
||||
.then((response) => {
|
||||
commit('setCategories', [response.data])
|
||||
})
|
||||
}
|
||||
axios.get(OC.generateUrl(`/apps/twigs/api/v1.0/transactions?categoryId=${categoryId}`))
|
||||
.then((response) => commit('setTransactions', response.data))
|
||||
},
|
||||
addTransactionClicked({ commit }) {
|
||||
router.push({ name: "newTransaction" })
|
||||
},
|
||||
addEditTransactionViewed({ commit, state, getters }, transactionId) {
|
||||
if (transactionId && getters.transaction(transactionId) === undefined) {
|
||||
axios.get(OC.generateUrl(`/apps/twigs/api/v1.0/transactions/${transactionId}`))
|
||||
.then((response) => {
|
||||
commit('setTransactions', [response.data])
|
||||
})
|
||||
}
|
||||
},
|
||||
addEditTransactionBudgetSelected({ commit, state }, budgetId) {
|
||||
commit('setCategories', [])
|
||||
if (!budgetId) return;
|
||||
axios.get(OC.generateUrl(`/apps/twigs/api/v1.0/categories?budgetId=${budgetId}`))
|
||||
.then(function (response) {
|
||||
commit('setCategories', response.data)
|
||||
})
|
||||
},
|
||||
addEditTransactionSaveClicked({ commit }, transaction) {
|
||||
let request;
|
||||
if (transaction.id) {
|
||||
request = axios.put(OC.generateUrl(`/apps/twigs/api/v1.0/transactions/${transaction.id}`), transaction)
|
||||
} else {
|
||||
request = axios.post(OC.generateUrl(`/apps/twigs/api/v1.0/transactions`), transaction)
|
||||
}
|
||||
request.then(response => {
|
||||
commit('addTransaction', response.data)
|
||||
router.push({ name: "transactionDetails", params: { id: response.data.id } })
|
||||
})
|
||||
},
|
||||
transactionClicked({ commit }, transactionId) {
|
||||
router.push({ name: "transactionDetails", params: { id: transactionId } })
|
||||
},
|
||||
transactionDetailsViewed({ commit, state }, transactionId) {
|
||||
commit('setCurrentTransaction', transactionId)
|
||||
|
||||
if (state.transactions.length === 0) {
|
||||
axios.get(OC.generateUrl(`/apps/twigs/api/v1.0/transactions/${transactionId}`))
|
||||
.then((response) => {
|
||||
commit('setTransactions', [response.data])
|
||||
if (state.categories.length === 0) {
|
||||
axios.get(OC.generateUrl(`/apps/twigs/api/v1.0/categories?budgetId=${response.data.budgetId}`))
|
||||
.then(function (response) {
|
||||
commit('setCategories', response.data)
|
||||
response.data.forEach(category => {
|
||||
axios.get(OC.generateUrl(`/apps/twigs/api/v1.0/transactions/sum?categoryId=${category.id}`))
|
||||
.then(function (response) {
|
||||
commit({
|
||||
type: 'setCategoryBalance',
|
||||
...response.data
|
||||
})
|
||||
})
|
||||
});
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
setCurrentBudget(state, budgetId) {
|
||||
state.currentBudget = Number.parseInt(budgetId)
|
||||
},
|
||||
setBudgetBalance(state, data) {
|
||||
state.budgetBalances = {
|
||||
...state.budgetBalances,
|
||||
[data.budgetId]: data.sum
|
||||
}
|
||||
},
|
||||
setBudgets(state, budgets) {
|
||||
state.budgets = budgets
|
||||
},
|
||||
setCurrentCategory(state, categoryId) {
|
||||
state.currentCategory = Number.parseInt(categoryId)
|
||||
},
|
||||
setCategories(state, data) {
|
||||
state.categories = {
|
||||
...state.categories,
|
||||
[data.budgetId]: data.categories
|
||||
}
|
||||
state.categories = data
|
||||
},
|
||||
setCategoryBalance(state, data) {
|
||||
state.categoryBalances = {
|
||||
...state.categoryBalances,
|
||||
[data.categoryId]: data.sum
|
||||
}
|
||||
},
|
||||
addTransaction(state, transaction) {
|
||||
state.transactions = [
|
||||
...state.transactions.filter(t => t.id !== transaction.id),
|
||||
transaction
|
||||
]
|
||||
},
|
||||
setTransactions(state, data) {
|
||||
state.transactions = data
|
||||
},
|
||||
setCurrentTransaction(state, transactionId) {
|
||||
state.currentTransaction = Number.parseInt(transactionId)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue