Added vendor page.
Build and Deploy Web App / build-and-deploy (push) Successful in 34s
Details
Build and Deploy Web App / build-and-deploy (push) Successful in 34s
Details
This commit is contained in:
parent
0e3b615684
commit
6415311925
|
|
@ -10,6 +10,14 @@
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mt-0 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-0 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.mx-1 {
|
.mx-1 {
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,25 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TransactionVendor } from '@/api/transaction'
|
import type { TransactionVendor } from '@/api/transaction'
|
||||||
import AppButton from './common/AppButton.vue'
|
import AppButton from './common/AppButton.vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { getSelectedProfile } from '@/api/profile'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
defineProps<{ vendor: TransactionVendor }>()
|
const props = defineProps<{ vendor: TransactionVendor }>()
|
||||||
defineEmits<{ edit: []; delete: [] }>()
|
defineEmits<{ edit: []; delete: [] }>()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const routeToVendor = computed(() => {
|
||||||
|
const profile = getSelectedProfile(route)
|
||||||
|
return `/profiles/${profile}/vendors/${props.vendor.id}`
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="vendor-card">
|
<div class="vendor-card">
|
||||||
<div style="flex-shrink: 1">
|
<div style="flex-shrink: 1">
|
||||||
<div>
|
<div>
|
||||||
{{ vendor.name }}
|
<RouterLink :to="routeToVendor">{{ vendor.name }}</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="vendor.description !== null"
|
v-if="vendor.description !== null"
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ abstract class FileListItem {
|
||||||
public readonly filename: string,
|
public readonly filename: string,
|
||||||
public readonly contentType: string,
|
public readonly contentType: string,
|
||||||
public readonly size: number,
|
public readonly size: number,
|
||||||
) { }
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ExistingFileListItem extends FileListItem {
|
class ExistingFileListItem extends FileListItem {
|
||||||
|
|
@ -71,9 +71,7 @@ onMounted(() => {
|
||||||
|
|
||||||
function syncModelsWithInternalFileList() {
|
function syncModelsWithInternalFileList() {
|
||||||
// Compute the set of uploaded files as just any newly uploaded file list item.
|
// Compute the set of uploaded files as just any newly uploaded file list item.
|
||||||
uploadedFiles.value = files.value
|
uploadedFiles.value = files.value.filter((f) => f instanceof NewFileListItem).map((f) => f.file)
|
||||||
.filter((f) => f instanceof NewFileListItem)
|
|
||||||
.map((f) => f.file)
|
|
||||||
// Compute the set of removed files as those from the set of initial files whose ID is no longer present.
|
// Compute the set of removed files as those from the set of initial files whose ID is no longer present.
|
||||||
const retainedExistingFileIds = files.value
|
const retainedExistingFileIds = files.value
|
||||||
.filter((f) => f instanceof ExistingFileListItem)
|
.filter((f) => f instanceof ExistingFileListItem)
|
||||||
|
|
@ -116,14 +114,33 @@ function onFileDeleteClicked(idx: number) {
|
||||||
<template>
|
<template>
|
||||||
<div class="file-selector">
|
<div class="file-selector">
|
||||||
<div @click.prevent="">
|
<div @click.prevent="">
|
||||||
<AttachmentRow v-for="(file, idx) in files" :key="idx" :attachment="file" @deleted="onFileDeleteClicked(idx)" />
|
<AttachmentRow
|
||||||
|
v-for="(file, idx) in files"
|
||||||
|
:key="idx"
|
||||||
|
:attachment="file"
|
||||||
|
@deleted="onFileDeleteClicked(idx)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<input id="fileInput" type="file" capture="environment" accept="image/*,text/*,.pdf,.doc,.odt,.docx,.xlsx"
|
<input
|
||||||
multiple @change="onFileInputChanged" style="display: none" ref="fileInput" :disabled="disabled" />
|
id="fileInput"
|
||||||
|
type="file"
|
||||||
|
capture="environment"
|
||||||
|
accept="image/*,text/*,.pdf,.doc,.odt,.docx,.xlsx"
|
||||||
|
multiple
|
||||||
|
@change="onFileInputChanged"
|
||||||
|
style="display: none"
|
||||||
|
ref="fileInput"
|
||||||
|
:disabled="disabled"
|
||||||
|
/>
|
||||||
<label for="fileInput">
|
<label for="fileInput">
|
||||||
<AppButton icon="upload" type="button" @click="fileInput?.click()" :disabled="disabled">Select a File
|
<AppButton
|
||||||
|
icon="upload"
|
||||||
|
type="button"
|
||||||
|
@click="fileInput?.click()"
|
||||||
|
:disabled="disabled"
|
||||||
|
>Select a File
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,14 @@ async function deleteTransaction() {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onVendorClicked() {
|
||||||
|
if (transaction.value && transaction.value.vendor) {
|
||||||
|
await router.push(
|
||||||
|
`/profiles/${getSelectedProfile(route)}/vendors/${transaction.value.vendor.id}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<AppPage
|
<AppPage
|
||||||
|
|
@ -102,6 +110,8 @@ async function deleteTransaction() {
|
||||||
<AppBadge
|
<AppBadge
|
||||||
size="md"
|
size="md"
|
||||||
v-if="transaction.vendor"
|
v-if="transaction.vendor"
|
||||||
|
style="cursor: pointer"
|
||||||
|
@click="onVendorClicked()"
|
||||||
>
|
>
|
||||||
{{ transaction.vendor.name }}
|
{{ transaction.vendor.name }}
|
||||||
</AppBadge>
|
</AppBadge>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import ProfileModule from './home/ProfileModule.vue'
|
import ProfileModule from './home/ProfileModule.vue'
|
||||||
import AccountsModule from './home/AccountsModule.vue'
|
import AccountsModule from './home/AccountsModule.vue'
|
||||||
import TransactionsModule from './home/TransactionsModule.vue'
|
import TransactionsModule from './home/TransactionsModule.vue'
|
||||||
import AnalyticsModule from './home/AnalyticsModule.vue';
|
import AnalyticsModule from './home/AnalyticsModule.vue'
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="app-module-container">
|
<div class="app-module-container">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { defaultPage, type Page, type PageRequest } from '@/api/pagination'
|
||||||
|
import { getSelectedProfile } from '@/api/profile'
|
||||||
|
import {
|
||||||
|
TransactionApiClient,
|
||||||
|
type TransactionsListItem,
|
||||||
|
type TransactionVendor,
|
||||||
|
} from '@/api/transaction'
|
||||||
|
import AppPage from '@/components/common/AppPage.vue'
|
||||||
|
import PaginationControls from '@/components/common/PaginationControls.vue'
|
||||||
|
import TransactionCard from '@/components/TransactionCard.vue'
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const vendor = ref<TransactionVendor | undefined>()
|
||||||
|
const relatedTransactionsPage = ref<Page<TransactionsListItem>>(defaultPage())
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const vendorId = parseInt(route.params.id as string)
|
||||||
|
try {
|
||||||
|
const api = new TransactionApiClient(getSelectedProfile(route))
|
||||||
|
vendor.value = await api.getVendor(vendorId)
|
||||||
|
await fetchPage(1)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
await router.replace('/')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchPage(pg: number) {
|
||||||
|
if (!vendor.value) return
|
||||||
|
const pageRequest: PageRequest = {
|
||||||
|
page: pg,
|
||||||
|
size: 10,
|
||||||
|
sorts: [
|
||||||
|
{
|
||||||
|
attribute: 'txn.timestamp',
|
||||||
|
dir: 'DESC',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.append('vendor', vendor.value?.id + '')
|
||||||
|
const api = new TransactionApiClient(getSelectedProfile(route))
|
||||||
|
relatedTransactionsPage.value = await api.searchTransactions(params, pageRequest)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<AppPage
|
||||||
|
v-if="vendor"
|
||||||
|
:title="'Vendor - ' + vendor.name"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
{{ vendor.description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-0">Transactions</h3>
|
||||||
|
<p class="text-muted font-size-small mt-0">
|
||||||
|
Below is a list of all transactions recorded with this vendor.
|
||||||
|
</p>
|
||||||
|
<PaginationControls
|
||||||
|
:page="relatedTransactionsPage"
|
||||||
|
@update="(pr) => fetchPage(pr.page)"
|
||||||
|
class="align-right"
|
||||||
|
/>
|
||||||
|
<TransactionCard
|
||||||
|
v-for="txn in relatedTransactionsPage.items"
|
||||||
|
:key="txn.id"
|
||||||
|
:tx="txn"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AppPage>
|
||||||
|
</template>
|
||||||
|
|
@ -7,7 +7,7 @@ import ButtonBar from '@/components/common/ButtonBar.vue'
|
||||||
import EditVendorModal from '@/components/EditVendorModal.vue'
|
import EditVendorModal from '@/components/EditVendorModal.vue'
|
||||||
import VendorCard from '@/components/VendorCard.vue'
|
import VendorCard from '@/components/VendorCard.vue'
|
||||||
import { showConfirm } from '@/util/alert'
|
import { showConfirm } from '@/util/alert'
|
||||||
import { onMounted, ref, useTemplateRef, type Ref } from 'vue'
|
import { nextTick, onMounted, ref, useTemplateRef, type Ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
@ -27,6 +27,7 @@ async function loadVendors() {
|
||||||
|
|
||||||
async function addVendor() {
|
async function addVendor() {
|
||||||
editedVendor.value = undefined
|
editedVendor.value = undefined
|
||||||
|
await nextTick()
|
||||||
const result = await editVendorModal.value?.show()
|
const result = await editVendorModal.value?.show()
|
||||||
if (result === 'saved') {
|
if (result === 'saved') {
|
||||||
await loadVendors()
|
await loadVendors()
|
||||||
|
|
@ -35,6 +36,7 @@ async function addVendor() {
|
||||||
|
|
||||||
async function editVendor(vendor: TransactionVendor) {
|
async function editVendor(vendor: TransactionVendor) {
|
||||||
editedVendor.value = vendor
|
editedVendor.value = vendor
|
||||||
|
await nextTick()
|
||||||
const result = await editVendorModal.value?.show()
|
const result = await editVendorModal.value?.show()
|
||||||
if (result === 'saved') {
|
if (result === 'saved') {
|
||||||
await loadVendors()
|
await loadVendors()
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,45 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { AccountApiClient } from '@/api/account';
|
import { AccountApiClient } from '@/api/account'
|
||||||
import HomeModule from '@/components/HomeModule.vue';
|
import HomeModule from '@/components/HomeModule.vue'
|
||||||
import { CategoryScale, Chart, Filler, Legend, LinearScale, LineElement, PointElement, TimeScale, Title, Tooltip, type ChartData, type ChartDataset, type ChartOptions } from 'chart.js';
|
import {
|
||||||
import { getTime } from 'date-fns';
|
CategoryScale,
|
||||||
import { onMounted, ref } from 'vue';
|
Chart,
|
||||||
import { Line } from 'vue-chartjs';
|
Filler,
|
||||||
import { useRoute } from 'vue-router';
|
Legend,
|
||||||
import 'chartjs-adapter-date-fns';
|
LinearScale,
|
||||||
import { integerMoneyToFloat } from '@/api/data';
|
LineElement,
|
||||||
import { AnalyticsApiClient } from '@/api/analytics';
|
PointElement,
|
||||||
|
TimeScale,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
type ChartData,
|
||||||
|
type ChartDataset,
|
||||||
|
type ChartOptions,
|
||||||
|
} from 'chart.js'
|
||||||
|
import { getTime } from 'date-fns'
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { Line } from 'vue-chartjs'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import 'chartjs-adapter-date-fns'
|
||||||
|
import { integerMoneyToFloat } from '@/api/data'
|
||||||
|
import { AnalyticsApiClient } from '@/api/analytics'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const chartData = ref<ChartData<"line"> | undefined>()
|
const chartData = ref<ChartData<'line'> | undefined>()
|
||||||
const chartOptions = ref<ChartOptions<"line"> | undefined>()
|
const chartOptions = ref<ChartOptions<'line'> | undefined>()
|
||||||
|
|
||||||
Chart.register(Title, Tooltip, Legend, LineElement, CategoryScale, LinearScale, PointElement, TimeScale, Filler)
|
Chart.register(
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
LineElement,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
TimeScale,
|
||||||
|
Filler,
|
||||||
|
)
|
||||||
|
|
||||||
const COLORS = [
|
const COLORS = [
|
||||||
[255, 69, 69],
|
[255, 69, 69],
|
||||||
|
|
@ -27,7 +51,7 @@ const COLORS = [
|
||||||
[69, 125, 255],
|
[69, 125, 255],
|
||||||
[139, 69, 255],
|
[139, 69, 255],
|
||||||
[251, 69, 255],
|
[251, 69, 255],
|
||||||
[255, 69, 167]
|
[255, 69, 167],
|
||||||
]
|
]
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|
@ -35,29 +59,29 @@ onMounted(async () => {
|
||||||
const analyticsApi = new AnalyticsApiClient(route)
|
const analyticsApi = new AnalyticsApiClient(route)
|
||||||
const accounts = await api.getAccounts()
|
const accounts = await api.getAccounts()
|
||||||
const timeSeriesData = await analyticsApi.getBalanceTimeSeries()
|
const timeSeriesData = await analyticsApi.getBalanceTimeSeries()
|
||||||
const datasets: ChartDataset<"line">[] = []
|
const datasets: ChartDataset<'line'>[] = []
|
||||||
// const timeZoneOffset = -(new Date().getTimezoneOffset())
|
// const timeZoneOffset = -(new Date().getTimezoneOffset())
|
||||||
let colorIdx = 0
|
let colorIdx = 0
|
||||||
|
|
||||||
for (const accountData of timeSeriesData.accounts) {
|
for (const accountData of timeSeriesData.accounts) {
|
||||||
if (accountData.currencyCode !== 'USD') continue
|
if (accountData.currencyCode !== 'USD') continue
|
||||||
const account = accounts.find(a => a.id === accountData.accountId)
|
const account = accounts.find((a) => a.id === accountData.accountId)
|
||||||
if (!account) {
|
if (!account) {
|
||||||
console.warn("Couldn't find account id " + accountData.accountId)
|
console.warn("Couldn't find account id " + accountData.accountId)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const color = COLORS[colorIdx++]
|
const color = COLORS[colorIdx++]
|
||||||
const points = accountData.balanceTimeSeries.map(p => {
|
const points = accountData.balanceTimeSeries.map((p) => {
|
||||||
return {
|
return {
|
||||||
x: getTime(p.timestamp),
|
x: getTime(p.timestamp),
|
||||||
y: integerMoneyToFloat(p.balance, account.currency)
|
y: integerMoneyToFloat(p.balance, account.currency),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
datasets.push({
|
datasets.push({
|
||||||
label: "Account #" + account.numberSuffix,
|
label: 'Account #' + account.numberSuffix,
|
||||||
data: points,
|
data: points,
|
||||||
cubicInterpolationMode: "monotone",
|
cubicInterpolationMode: 'monotone',
|
||||||
borderColor: `rgb(${color[0]}, ${color[1]}, ${color[2]})`,
|
borderColor: `rgb(${color[0]}, ${color[1]}, ${color[2]})`,
|
||||||
backgroundColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.25)`,
|
backgroundColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.25)`,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
|
|
@ -68,28 +92,35 @@ onMounted(async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
chartData.value = {
|
chartData.value = {
|
||||||
datasets: datasets
|
datasets: datasets,
|
||||||
}
|
}
|
||||||
chartOptions.value = {
|
chartOptions.value = {
|
||||||
plugins: {
|
plugins: {
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: "Account Balances"
|
text: 'Account Balances',
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
type: "time",
|
type: 'time',
|
||||||
time: {
|
time: {
|
||||||
unit: "day"
|
unit: 'day',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<HomeModule title="Analytics" style="min-width: 500px;">
|
<HomeModule
|
||||||
<Line v-if="chartData && chartOptions" :data="chartData" :options="chartOptions" />
|
title="Analytics"
|
||||||
|
style="min-width: 500px"
|
||||||
|
>
|
||||||
|
<Line
|
||||||
|
v-if="chartData && chartOptions"
|
||||||
|
:data="chartData"
|
||||||
|
:options="chartOptions"
|
||||||
|
/>
|
||||||
</HomeModule>
|
</HomeModule>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,11 @@ const router = createRouter({
|
||||||
component: () => import('@/pages/VendorsPage.vue'),
|
component: () => import('@/pages/VendorsPage.vue'),
|
||||||
meta: { title: 'Vendors' },
|
meta: { title: 'Vendors' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'vendors/:id',
|
||||||
|
component: () => import('@/pages/VendorPage.vue'),
|
||||||
|
meta: { title: 'Vendor' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'categories',
|
path: 'categories',
|
||||||
component: () => import('@/pages/CategoriesPage.vue'),
|
component: () => import('@/pages/CategoriesPage.vue'),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue