Added balance time series, fixed file selector behavior.
This commit is contained in:
parent
dedc7b742f
commit
85c5cee598
|
|
@ -0,0 +1,20 @@
|
|||
meta {
|
||||
name: Get Account Balance Time Series
|
||||
type: http
|
||||
seq: 5
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/profiles/:profile/accounts/:accountId/balance-time-series
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
params:path {
|
||||
profile: {{current_profile}}
|
||||
accountId: 1
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
}
|
||||
|
|
@ -6,6 +6,10 @@ auth:bearer {
|
|||
token: {{access_token}}
|
||||
}
|
||||
|
||||
vars:pre-request {
|
||||
current_profile: test-profile-0
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const axios = require("axios");
|
||||
const baseUrl = bru.getEnvVar("base_url");
|
||||
|
|
@ -16,7 +20,6 @@ script:pre-request {
|
|||
await checkAuth();
|
||||
}
|
||||
|
||||
net = require("net")
|
||||
console.log("Testing GET /me");
|
||||
try {
|
||||
const resp = await axios.get(baseUrl + "/me", {headers: {"Authorization": "Bearer " + bru.getEnvVar("access_token")}});
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
This is a sample text attachment.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
|
|
@ -0,0 +1,9 @@
|
|||
Food,#8ceb34,Anything food-related.,
|
||||
Groceries,#26c92c,Groceries for home-cooked meals.,Food
|
||||
Restaurants,#bfd166,Restaurants and fast food meals.,
|
||||
Travel,#453ddb,"Cars, transit, and airfare.",
|
||||
Health,#db3d6f,Personal healthcare.,
|
||||
Home,#f59e11,Home maintenance and upkeep.,
|
||||
Income,#20ab16,Any source of income.,
|
||||
Salary,#16ab40,Regular income from a salary.,Income
|
||||
Interest,#8b16ab,Income from account interest.,Income
|
||||
|
|
|
@ -138,6 +138,14 @@ void handleGetTotalBalances(ref ServerHttpRequest request, ref ServerHttpRespons
|
|||
writeJsonBody(response, balances);
|
||||
}
|
||||
|
||||
void handleGetAccountBalanceTimeSeries(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
auto ds = getProfileDataSource(request);
|
||||
ulong accountId = request.getPathParamOrThrow!ulong("accountId");
|
||||
int timeZoneOffset = request.getParamAs!int("time-zone-offset", 0);
|
||||
auto series = getBalanceTimeSeries(ds, accountId, timeZoneOffset);
|
||||
writeJsonBody(response, series);
|
||||
}
|
||||
|
||||
// Value records:
|
||||
|
||||
const PageRequest VALUE_RECORD_DEFAULT_PAGE_REQUEST = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]);
|
||||
|
|
|
|||
|
|
@ -77,7 +77,33 @@ CurrencyBalance[] getTotalBalanceForAllAccounts(ProfileDataSource ds, SysTime ti
|
|||
}
|
||||
}
|
||||
return balances;
|
||||
}
|
||||
|
||||
struct BalanceTimeSeriesPoint {
|
||||
long balance;
|
||||
string timestamp;
|
||||
}
|
||||
|
||||
BalanceTimeSeriesPoint[] getBalanceTimeSeries(ProfileDataSource ds, ulong accountId, int timeZoneOffsetMinutes) {
|
||||
BalanceTimeSeriesPoint[] points;
|
||||
immutable TimeZone tz = new immutable SimpleTimeZone(minutes(timeZoneOffsetMinutes));
|
||||
SysTime now = Clock.currTime(tz);
|
||||
SysTime endOfToday = SysTime(
|
||||
DateTime(now.year, now.month, now.day, 23, 59, 59),
|
||||
tz
|
||||
);
|
||||
SysTime timestamp = endOfToday.toOtherTZ(UTC());
|
||||
for (int i = 0; i < 30; i++) {
|
||||
auto balance = getBalance(ds, accountId, timestamp);
|
||||
if (!balance.isNull) {
|
||||
points ~= BalanceTimeSeriesPoint(
|
||||
balance.value,
|
||||
timestamp.toISOExtString()
|
||||
);
|
||||
}
|
||||
timestamp = timestamp - days(1);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
|
|||
a.map(HttpMethod.GET, ACCOUNT_PATH ~ "/value-records/:valueRecordId:ulong", &handleGetValueRecord);
|
||||
a.map(HttpMethod.POST, ACCOUNT_PATH ~ "/value-records", &handleCreateValueRecord);
|
||||
a.map(HttpMethod.DELETE, ACCOUNT_PATH ~ "/value-records/:valueRecordId:ulong", &handleDeleteValueRecord);
|
||||
a.map(HttpMethod.GET, ACCOUNT_PATH ~ "/balance-time-series", &handleGetAccountBalanceTimeSeries);
|
||||
|
||||
import transaction.api;
|
||||
// Transaction vendor endpoints:
|
||||
|
|
|
|||
|
|
@ -39,12 +39,10 @@ class FileSystemProfileRepository : ProfileRepository {
|
|||
if (!exists(getProfilesDir())) mkdir(getProfilesDir());
|
||||
ProfileDataSource ds = new SqliteProfileDataSource(path);
|
||||
import std.datetime;
|
||||
import std.conv;
|
||||
auto propsRepo = ds.getPropertiesRepository();
|
||||
propsRepo.setProperty("name", name);
|
||||
propsRepo.setProperty("createdAt", Clock.currTime(UTC()).toISOExtString());
|
||||
propsRepo.setProperty("user", username);
|
||||
propsRepo.setProperty("database-schema-version", SCHEMA_VERSION.to!string());
|
||||
return new Profile(name);
|
||||
}
|
||||
|
||||
|
|
@ -137,6 +135,7 @@ class SqlitePropertiesRepository : PropertiesRepository {
|
|||
|
||||
private const SCHEMA = import("sql/schema.sql");
|
||||
private const uint SCHEMA_VERSION = 1;
|
||||
private const SCHEMA_VERSION_PROPERTY = "database-schema-version";
|
||||
|
||||
/**
|
||||
* An SQLite implementation of the ProfileDataSource that uses a single
|
||||
|
|
@ -160,8 +159,12 @@ class SqliteProfileDataSource : ProfileDataSource {
|
|||
this.db = Database(path);
|
||||
db.run("PRAGMA foreign_keys = ON");
|
||||
if (needsInit) {
|
||||
infoF!"Initializing database: %s"(dbPath);
|
||||
infoF!"Initializing database: %s with schema version %d."(dbPath, SCHEMA_VERSION);
|
||||
db.run(SCHEMA);
|
||||
// Set the schema version property right away:
|
||||
import std.conv;
|
||||
auto propRepo = new SqlitePropertiesRepository(db);
|
||||
propRepo.setProperty(SCHEMA_VERSION_PROPERTY, SCHEMA_VERSION.to!string);
|
||||
}
|
||||
migrateSchema();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ interface TransactionVendorRepository {
|
|||
|
||||
interface TransactionCategoryRepository {
|
||||
Optional!TransactionCategory findById(ulong id);
|
||||
Optional!TransactionCategory findByName(string name);
|
||||
bool existsById(ulong id);
|
||||
bool existsByName(string name);
|
||||
TransactionCategory[] findAll();
|
||||
|
|
|
|||
|
|
@ -81,6 +81,10 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
|
|||
return util.sqlite.findById(db, "transaction_category", &parseCategory, id);
|
||||
}
|
||||
|
||||
Optional!TransactionCategory findByName(string name) {
|
||||
return util.sqlite.findOne(db, "SELECT * FROM transaction_category WHERE name = ?", &parseCategory, name);
|
||||
}
|
||||
|
||||
bool existsById(ulong id) {
|
||||
return util.sqlite.exists(db, "SELECT id FROM transaction_category WHERE id = ?", id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@ struct TransactionVendor {
|
|||
}
|
||||
|
||||
struct TransactionCategory {
|
||||
immutable ulong id;
|
||||
immutable Optional!ulong parentId;
|
||||
immutable string name;
|
||||
immutable string description;
|
||||
immutable string color;
|
||||
ulong id;
|
||||
Optional!ulong parentId;
|
||||
string name;
|
||||
string description;
|
||||
string color;
|
||||
}
|
||||
|
||||
struct Transaction {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
module util.sample_data;
|
||||
|
||||
import slf4d;
|
||||
import handy_http_primitives : Optional;
|
||||
import handy_http_primitives : Optional, mapIfPresent;
|
||||
|
||||
import auth;
|
||||
import profile;
|
||||
|
|
@ -46,7 +46,7 @@ void generateRandomUser(int idx, UserRepository userRepo) {
|
|||
infoF!"Generating random user %s, password: %s."(username, password);
|
||||
User user = createNewUser(userRepo, username, password);
|
||||
ProfileRepository profileRepo = new FileSystemProfileRepository(username);
|
||||
const int profileCount = uniform(1, 5);
|
||||
const int profileCount = uniform(1, 3);
|
||||
for (int i = 0; i < profileCount; i++) {
|
||||
generateRandomProfile(i, profileRepo);
|
||||
}
|
||||
|
|
@ -59,16 +59,16 @@ void generateRandomProfile(int idx, ProfileRepository profileRepo) {
|
|||
ProfileDataSource ds = profileRepo.getDataSource(profile);
|
||||
|
||||
ds.getPropertiesRepository().setProperty("sample-data-idx", idx.to!string);
|
||||
Currency preferredCurrency = choice(ALL_CURRENCIES);
|
||||
Currency preferredCurrency = Currencies.USD;
|
||||
|
||||
const int accountCount = uniform(3, 10);
|
||||
const int accountCount = uniform(3, 8);
|
||||
for (int i = 0; i < accountCount; i++) {
|
||||
generateRandomAccount(i, ds, preferredCurrency);
|
||||
}
|
||||
|
||||
ds.doTransaction(() {
|
||||
generateVendors(ds);
|
||||
generateCategories(ds.getTransactionCategoryRepository(), Optional!ulong.empty);
|
||||
generateCategories(ds);
|
||||
});
|
||||
generateRandomTransactions(ds);
|
||||
}
|
||||
|
|
@ -81,31 +81,27 @@ void generateVendors(ProfileDataSource ds) {
|
|||
vendorRepo.insert(record[0], record[1]);
|
||||
vendorCount++;
|
||||
}
|
||||
infoF!" Generated %d random vendors."(vendorCount);
|
||||
infoF!" Generated %d vendors."(vendorCount);
|
||||
}
|
||||
|
||||
void generateCategories(TransactionCategoryRepository repo, Optional!ulong parentId, size_t depth = 0) {
|
||||
const int categoryCount = uniform(5, 10);
|
||||
for (int i = 0; i < categoryCount; i++) {
|
||||
string name = "Test Category " ~ to!string(i);
|
||||
if (parentId) {
|
||||
name ~= " (child of " ~ parentId.value.to!string ~ ")";
|
||||
}
|
||||
TransactionCategory category = repo.insert(
|
||||
parentId,
|
||||
name,
|
||||
"Testing category.",
|
||||
"FFFFFF"
|
||||
);
|
||||
infoF!" Generating child categories for %d, depth = %d"(i, depth);
|
||||
if (depth < 2) {
|
||||
generateCategories(repo, Optional!ulong.of(category.id), depth + 1);
|
||||
void generateCategories(ProfileDataSource ds) {
|
||||
auto categoryRepo = ds.getTransactionCategoryRepository();
|
||||
string categoriesCsv = readText("sample-data/categories.csv");
|
||||
uint categoryCount = 0;
|
||||
foreach (record; csvReader!(Tuple!(string, string, string, string))(categoriesCsv)) {
|
||||
string parentName = record[3];
|
||||
Optional!ulong parentId = Optional!ulong.empty;
|
||||
if (parentName !is null && parentName.length > 0) {
|
||||
parentId = categoryRepo.findByName(parentName).mapIfPresent!(c => c.id);
|
||||
}
|
||||
categoryRepo.insert(parentId, record[0], record[2], record[1][1..$]);
|
||||
}
|
||||
infoF!" Generated %d categories."(categoryCount);
|
||||
}
|
||||
|
||||
void generateRandomAccount(int idx, ProfileDataSource ds, Currency preferredCurrency) {
|
||||
AccountRepository accountRepo = ds.getAccountRepository();
|
||||
AccountValueRecordRepository valueRecordRepo = ds.getAccountValueRecordRepository();
|
||||
string idxStr = idx.to!string;
|
||||
string numberSuffix = "0".replicate(4 - idxStr.length) ~ idxStr;
|
||||
string name = "Test Account " ~ idxStr;
|
||||
|
|
@ -122,6 +118,16 @@ void generateRandomAccount(int idx, ProfileDataSource ds, Currency preferredCurr
|
|||
currency,
|
||||
description
|
||||
);
|
||||
long balance = uniform(-1_000_000, 1_000_000);
|
||||
ulong accountAge = uniform(5, 365*10);
|
||||
SysTime accountOpenedAt = Clock.currTime(UTC()) - days(accountAge);
|
||||
valueRecordRepo.insert(
|
||||
accountOpenedAt,
|
||||
account.id,
|
||||
AccountValueRecordType.BALANCE,
|
||||
balance,
|
||||
account.currency
|
||||
);
|
||||
infoF!" Generated random account: %s, #%s, %s"(name, numberSuffix, currency.code);
|
||||
}
|
||||
|
||||
|
|
@ -202,7 +208,33 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
|||
}
|
||||
}
|
||||
|
||||
auto txn = addTransaction(ds, data, []);
|
||||
// Add attachments:
|
||||
MultipartFile[] attachments;
|
||||
if (uniform01 < 0.5) {
|
||||
for (int k = 0; k < uniform(1, 3); k++) {
|
||||
if (k == 0) {
|
||||
attachments ~= MultipartFile(
|
||||
"1.txt",
|
||||
"text/plain",
|
||||
cast(ubyte[]) std.file.read("sample-data/attachments/1.txt")
|
||||
);
|
||||
} else if (k == 1) {
|
||||
attachments ~= MultipartFile(
|
||||
"2.jpg",
|
||||
"image/jpg",
|
||||
cast(ubyte[]) std.file.read("sample-data/attachments/2.jpg")
|
||||
);
|
||||
} else if (k == 2) {
|
||||
attachments ~= MultipartFile(
|
||||
"3.png",
|
||||
"image/png",
|
||||
cast(ubyte[]) std.file.read("sample-data/attachments/3.png")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto txn = addTransaction(ds, data, attachments);
|
||||
infoF!" Generated transaction %d"(txn.id);
|
||||
timestamp -= seconds(uniform(10, 1_000_000));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@
|
|||
"@fortawesome/vue-fontawesome": "^3.1.2",
|
||||
"@idle-observer/vue3": "^0.2.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.18",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
|
|
@ -2618,6 +2621,16 @@
|
|||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chartjs-adapter-date-fns": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
|
||||
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": ">=2.8.0",
|
||||
"date-fns": ">=2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
|
|
@ -2701,6 +2714,25 @@
|
|||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns-tz": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
|
||||
"integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"date-fns": "^3.0.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@
|
|||
"@fortawesome/vue-fontawesome": "^3.1.2",
|
||||
"@idle-observer/vue3": "^0.2.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.18",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
|
|
|
|||
|
|
@ -143,6 +143,11 @@ export interface CurrencyBalance {
|
|||
balance: number
|
||||
}
|
||||
|
||||
export interface BalanceTimeSeriesPoint {
|
||||
balance: number
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export class AccountApiClient extends ApiClient {
|
||||
readonly path: string
|
||||
readonly profileName: string
|
||||
|
|
@ -189,6 +194,15 @@ export class AccountApiClient extends ApiClient {
|
|||
return super.getJson(`/profiles/${this.profileName}/account-balances`)
|
||||
}
|
||||
|
||||
getBalanceTimeSeries(
|
||||
accountId: number,
|
||||
timeZoneOffsetMinutes: number,
|
||||
): Promise<BalanceTimeSeriesPoint[]> {
|
||||
return super.getJson(
|
||||
`/profiles/${this.profileName}/accounts/${accountId}/balance-time-series?time-zone-offset=${timeZoneOffsetMinutes}`,
|
||||
)
|
||||
}
|
||||
|
||||
getValueRecords(accountId: number, pageRequest: PageRequest): Promise<Page<AccountValueRecord>> {
|
||||
return super.getJsonPage(this.path + '/' + accountId + '/value-records', pageRequest)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,3 +31,7 @@ export function formatMoney(amount: number, currency: Currency) {
|
|||
export function floatMoneyToInteger(amount: number, currency: Currency) {
|
||||
return Math.round(amount * Math.pow(10, currency.fractionalDigits ?? 0))
|
||||
}
|
||||
|
||||
export function integerMoneyToFloat(amount: number, currency: Currency) {
|
||||
return amount / Math.pow(10, currency.fractionalDigits ?? 0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, ref, useTemplateRef, watch, type Ref } from 'vue'
|
||||
import { onMounted, ref, shallowRef, useTemplateRef, watch, type Ref } from 'vue'
|
||||
import AppButton from '@/components/common/AppButton.vue'
|
||||
import AttachmentRow from './AttachmentRow.vue'
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ abstract class FileListItem {
|
|||
public readonly filename: string,
|
||||
public readonly contentType: string,
|
||||
public readonly size: number,
|
||||
) {}
|
||||
) { }
|
||||
}
|
||||
|
||||
class ExistingFileListItem extends FileListItem {
|
||||
|
|
@ -43,32 +43,33 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
disabled: false,
|
||||
initialFiles: () => [],
|
||||
})
|
||||
const previousInitialFiles: Ref<ExistingFile[]> = ref([])
|
||||
const previousInitialFiles: Ref<string> = ref('{}')
|
||||
const fileInput = useTemplateRef('fileInput')
|
||||
|
||||
const uploadedFiles = defineModel<File[]>('uploaded-files', { default: [] })
|
||||
const removedFiles = defineModel<number[]>('removed-files', { default: [] })
|
||||
|
||||
// Internal file list model:
|
||||
const files: Ref<FileListItem[]> = ref([])
|
||||
const files = shallowRef<FileListItem[]>([])
|
||||
|
||||
onMounted(() => {
|
||||
files.value = props.initialFiles.map((f) => new ExistingFileListItem(f))
|
||||
previousInitialFiles.value = [...props.initialFiles]
|
||||
previousInitialFiles.value = JSON.stringify(props.initialFiles)
|
||||
// If input initial files change, reset the file selector to just those.
|
||||
watch(
|
||||
() => props.initialFiles,
|
||||
() => {
|
||||
if (previousInitialFiles.value !== props.initialFiles) {
|
||||
const prevJson = previousInitialFiles.value
|
||||
const newJson = JSON.stringify(props.initialFiles)
|
||||
if (prevJson !== newJson) {
|
||||
files.value = props.initialFiles.map((f) => new ExistingFileListItem(f))
|
||||
previousInitialFiles.value = [...props.initialFiles]
|
||||
previousInitialFiles.value = newJson
|
||||
}
|
||||
},
|
||||
)
|
||||
// When our internal model changes, update the defined uploaded/removed files models.
|
||||
watch(
|
||||
() => files,
|
||||
() => {
|
||||
})
|
||||
|
||||
function syncModelsWithInternalFileList() {
|
||||
// Compute the set of uploaded files as just any newly uploaded file list item.
|
||||
uploadedFiles.value = files.value
|
||||
.filter((f) => f instanceof NewFileListItem)
|
||||
|
|
@ -80,16 +81,15 @@ onMounted(() => {
|
|||
removedFiles.value = props.initialFiles
|
||||
.filter((f) => !retainedExistingFileIds.includes(f.id))
|
||||
.map((f) => f.id)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function onFileInputChanged(e: Event) {
|
||||
if (props.disabled) return
|
||||
const inputElement = e.target as HTMLInputElement
|
||||
const fileList = inputElement.files
|
||||
if (fileList === null) {
|
||||
files.value = []
|
||||
syncModelsWithInternalFileList()
|
||||
return
|
||||
}
|
||||
for (let i = 0; i < fileList?.length; i++) {
|
||||
|
|
@ -100,47 +100,30 @@ function onFileInputChanged(e: Event) {
|
|||
}
|
||||
// Reset the input element after we've consumed the selected files.
|
||||
inputElement.value = ''
|
||||
syncModelsWithInternalFileList()
|
||||
}
|
||||
|
||||
function onFileDeleteClicked(idx: number) {
|
||||
if (props.disabled) return
|
||||
if (idx === 0 && files.value.length === 1) {
|
||||
files.value = []
|
||||
return
|
||||
}
|
||||
} else {
|
||||
files.value.splice(idx, 1)
|
||||
}
|
||||
syncModelsWithInternalFileList()
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="file-selector">
|
||||
<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>
|
||||
<input
|
||||
id="fileInput"
|
||||
type="file"
|
||||
capture="environment"
|
||||
accept="image/*,text/*,.pdf,.doc,.odt,.docx,.xlsx"
|
||||
multiple
|
||||
@change="onFileInputChanged"
|
||||
style="display: none"
|
||||
ref="fileInput"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
<input 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">
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@
|
|||
import ProfileModule from './home/ProfileModule.vue'
|
||||
import AccountsModule from './home/AccountsModule.vue'
|
||||
import TransactionsModule from './home/TransactionsModule.vue'
|
||||
import AnalyticsModule from './home/AnalyticsModule.vue';
|
||||
</script>
|
||||
<template>
|
||||
<div class="app-module-container">
|
||||
<ProfileModule />
|
||||
<AccountsModule />
|
||||
<TransactionsModule />
|
||||
<AnalyticsModule />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
<script setup lang="ts">
|
||||
import { AccountApiClient } from '@/api/account';
|
||||
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 { 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';
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const chartData = ref<ChartData<"line"> | undefined>()
|
||||
const chartOptions = ref<ChartOptions<"line"> | undefined>()
|
||||
|
||||
Chart.register(Title, Tooltip, Legend, LineElement, CategoryScale, LinearScale, PointElement, TimeScale, Filler)
|
||||
|
||||
const COLORS = [
|
||||
[255, 69, 69],
|
||||
[255, 160, 69],
|
||||
[255, 223, 69],
|
||||
[118, 255, 69],
|
||||
[69, 255, 188],
|
||||
[69, 237, 255],
|
||||
[69, 125, 255],
|
||||
[139, 69, 255],
|
||||
[251, 69, 255],
|
||||
[255, 69, 167]
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
const api = new AccountApiClient(route)
|
||||
const accounts = await api.getAccounts()
|
||||
const datasets: ChartDataset<"line">[] = []
|
||||
const timeZoneOffset = -(new Date().getTimezoneOffset())
|
||||
let colorIdx = 0
|
||||
for (const account of accounts) {
|
||||
if (account.currency.code !== 'USD') continue
|
||||
const points = await api.getBalanceTimeSeries(account.id, timeZoneOffset)
|
||||
const color = COLORS[colorIdx++]
|
||||
datasets.push({
|
||||
label: "Account #" + account.numberSuffix,
|
||||
data: points.map(p => {
|
||||
return { x: getTime(p.timestamp), y: integerMoneyToFloat(p.balance, account.currency) }
|
||||
}),
|
||||
cubicInterpolationMode: "monotone",
|
||||
borderColor: `rgb(${color[0]}, ${color[1]}, ${color[2]})`,
|
||||
backgroundColor: `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.25)`
|
||||
})
|
||||
}
|
||||
|
||||
chartData.value = {
|
||||
datasets: datasets
|
||||
}
|
||||
chartOptions.value = {
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: "Account Balances"
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: "time",
|
||||
time: {
|
||||
unit: "day"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<HomeModule title="Analytics" style="min-width: 500px;">
|
||||
<Line v-if="chartData && chartOptions" :data="chartData" :options="chartOptions" />
|
||||
</HomeModule>
|
||||
</template>
|
||||
Loading…
Reference in New Issue