[asset] add support for sorting table

This commit is contained in:
Anantha Kumaran 2024-02-04 22:35:39 +05:30
parent 26fb7618ef
commit de0ba74b95
11 changed files with 345 additions and 127 deletions

20
flake/node-package.nix generated
View File

@ -1912,6 +1912,15 @@ let
sha512 = "7BUT1sEFSNBIcc0wlwKn2l3l3OnYJdjsrlruDbAp6hpOK3HbpgMjLVH4ql6xXwD+qYy+XEHrb2EMkIpo9kWZ+Q==";
};
};
"@types/tabulator-tables-5.5.7" = {
name = "_at_types_slash_tabulator-tables";
packageName = "@types/tabulator-tables";
version = "5.5.7";
src = fetchurl {
url = "https://registry.npmjs.org/@types/tabulator-tables/-/tabulator-tables-5.5.7.tgz";
sha512 = "2G6i6QhJ/L+8Xk3KAfFZ91qADS9MEu6ve1uT59iaA7fpA6h6AswbFP/5dl3yg8lUhMsP4Zcst073FhbK7Y0TJA==";
};
};
"@types/tar-6.1.10" = {
name = "_at_types_slash_tar";
packageName = "@types/tar";
@ -8610,6 +8619,15 @@ let
sha512 = "C2PqiSdxDA0v+OH9SP8UxyyfTRLzdxtdwgMjeX/5fvPPYbFixaUXp0hQw3aDN2RrLrwE2vmRJK3sAOICk+0wHA==";
};
};
"tabulator-tables-5.5.4" = {
name = "tabulator-tables";
packageName = "tabulator-tables";
version = "5.5.4";
src = fetchurl {
url = "https://registry.npmjs.org/tabulator-tables/-/tabulator-tables-5.5.4.tgz";
sha512 = "hVcITAfO2G3gm2ILW9GN2ORgcmNsbVmC+Q+2E3xfthIE9xtFxGKSbhbsNk39h11Uzm9GNUvjGfos1IVKrfeeOA==";
};
};
"tailwindcss-3.4.0" = {
name = "tailwindcss";
packageName = "tailwindcss";
@ -9762,6 +9780,7 @@ let
sources."@types/sprintf-js-1.1.4"
sources."@types/svg2ttf-5.0.3"
sources."@types/svgicons2svgfont-10.0.5"
sources."@types/tabulator-tables-5.5.7"
sources."@types/tar-6.1.10"
sources."@types/ttf2eot-2.0.2"
sources."@types/ttf2woff-2.0.4"
@ -10731,6 +10750,7 @@ let
sources."svgo-3.0.3"
sources."svgpath-2.6.0"
sources."svgtofont-4.1.1"
sources."tabulator-tables-5.5.4"
(sources."tailwindcss-3.4.0" // {
dependencies = [
(sources."postcss-load-config-4.0.2" // {

24
package-lock.json generated
View File

@ -49,6 +49,7 @@
"svelte-local-storage-store": "^0.6.0",
"svelte-select": "^5.8.0",
"svelte-tiny-virtual-list": "^2.0.5",
"tabulator-tables": "^5.5.4",
"textures": "^1.2.3",
"tippy.js": "^6.3.7",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.19.3/xlsx-0.19.3.tgz",
@ -71,6 +72,7 @@
"@types/lodash": "^4.14.194",
"@types/papaparse": "^5.3.7",
"@types/sprintf-js": "^1.1.2",
"@types/tabulator-tables": "^5.5.7",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"autoprefixer": "^10.4.14",
@ -2639,6 +2641,12 @@
"@types/node": "*"
}
},
"node_modules/@types/tabulator-tables": {
"version": "5.5.7",
"resolved": "https://registry.npmjs.org/@types/tabulator-tables/-/tabulator-tables-5.5.7.tgz",
"integrity": "sha512-2G6i6QhJ/L+8Xk3KAfFZ91qADS9MEu6ve1uT59iaA7fpA6h6AswbFP/5dl3yg8lUhMsP4Zcst073FhbK7Y0TJA==",
"dev": true
},
"node_modules/@types/tar": {
"version": "6.1.10",
"resolved": "https://registry.npmjs.org/@types/tar/-/tar-6.1.10.tgz",
@ -11795,6 +11803,11 @@
"url": "https://jaywcjlove.github.io/#/sponsor"
}
},
"node_modules/tabulator-tables": {
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/tabulator-tables/-/tabulator-tables-5.5.4.tgz",
"integrity": "sha512-hVcITAfO2G3gm2ILW9GN2ORgcmNsbVmC+Q+2E3xfthIE9xtFxGKSbhbsNk39h11Uzm9GNUvjGfos1IVKrfeeOA=="
},
"node_modules/tailwindcss": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.0.tgz",
@ -14957,6 +14970,12 @@
"@types/node": "*"
}
},
"@types/tabulator-tables": {
"version": "5.5.7",
"resolved": "https://registry.npmjs.org/@types/tabulator-tables/-/tabulator-tables-5.5.7.tgz",
"integrity": "sha512-2G6i6QhJ/L+8Xk3KAfFZ91qADS9MEu6ve1uT59iaA7fpA6h6AswbFP/5dl3yg8lUhMsP4Zcst073FhbK7Y0TJA==",
"dev": true
},
"@types/tar": {
"version": "6.1.10",
"resolved": "https://registry.npmjs.org/@types/tar/-/tar-6.1.10.tgz",
@ -21796,6 +21815,11 @@
"yargs": "~17.7.1"
}
},
"tabulator-tables": {
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/tabulator-tables/-/tabulator-tables-5.5.4.tgz",
"integrity": "sha512-hVcITAfO2G3gm2ILW9GN2ORgcmNsbVmC+Q+2E3xfthIE9xtFxGKSbhbsNk39h11Uzm9GNUvjGfos1IVKrfeeOA=="
},
"tailwindcss": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.0.tgz",

View File

@ -56,6 +56,7 @@
"svelte-local-storage-store": "^0.6.0",
"svelte-select": "^5.8.0",
"svelte-tiny-virtual-list": "^2.0.5",
"tabulator-tables": "^5.5.4",
"textures": "^1.2.3",
"tippy.js": "^6.3.7",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.19.3/xlsx-0.19.3.tgz",
@ -78,6 +79,7 @@
"@types/lodash": "^4.14.194",
"@types/papaparse": "^5.3.7",
"@types/sprintf-js": "^1.1.2",
"@types/tabulator-tables": "^5.5.7",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"autoprefixer": "^10.4.14",

View File

@ -31,6 +31,45 @@ $editor-full-height: calc(100vh - 45.5px - 7px - 15.75px - 21px - 57.75px - 10.5
@import "bulma-switch/src/sass/index.sass";
@import "@cityssm/bulma-sticky-table/sticky-table";
$backgroundColor: $white;
$borderColor: none;
$textSize: 1rem;
$headerBackgroundColor: $white;
$headerTextColor: $grey-dark;
$headerBorderColor: rgba($grey-lightest, 1);
$headerSeparatorColor: rgba($grey-light, 0.5);
$headerMargin: 0.2857rem;
$sortArrowActive: $black;
$sortArrowInactive: $grey-light;
$rowBackgroundColor: $white;
$rowAltBackgroundColor: $white;
$rowBorderColor: rgba($grey-lightest, 0.5);
$rowTextColor: $grey-dark;
$rowHoverBackground: $white-bis;
$rowSelectedBackground: #9abcea;
$rowSelectedBackgroundHover: #769bcc;
$editBoxColor: #1d68cd;
$errorColor: #dd0000;
$footerBackgroundColor: #fff;
$footerTextColor: #555;
$footerBorderColor: #aaa;
$footerSeparatorColor: #999;
$footerActiveColor: #d00;
@import "tabulator-tables/src/scss/themes/tabulator_simple.scss";
.tabulator .tabulator-header .tabulator-col .tabulator-col-content .tabulator-col-title {
white-space: normal;
}
.tabulator {
border-radius: $radius;
}
.tabulator-col {
padding: 0.5rem;
}
// override message style
.message {

View File

@ -1,75 +1,65 @@
<script lang="ts">
import { iconText } from "$lib/icon";
import {
type AssetBreakdown,
depth,
lastName,
isZero,
formatCurrency,
formatFloat,
formatPercentage
} from "$lib/utils";
import { type AssetBreakdown, buildTree } from "$lib/utils";
import _ from "lodash";
import Table from "./Table.svelte";
import type { ColumnDefinition } from "tabulator-tables";
import {
accountName,
formatCurrencyChange,
indendedAssetAccountName,
nonZeroCurrency,
nonZeroFloatChange,
nonZeroPercentageChange
} from "$lib/table_formatters";
export let breakdowns: Record<string, AssetBreakdown>;
export let indent = true;
function calculateChangeClass(gain: number) {
let changeClass = "";
if (gain > 0) {
changeClass = "has-text-success";
} else if (gain < 0) {
changeClass = "has-text-danger";
const columns: ColumnDefinition[] = [
{
title: "Account",
field: "group",
formatter: indent ? indendedAssetAccountName : accountName,
frozen: true
},
{
title: "Investment Amount",
field: "investmentAmount",
hozAlign: "right",
vertAlign: "middle",
formatter: nonZeroCurrency
},
{
title: "Withdrawal Amount",
field: "withdrawalAmount",
hozAlign: "right",
formatter: nonZeroCurrency
},
{
title: "Balance Units",
field: "balanceUnits",
hozAlign: "right",
formatter: nonZeroCurrency
},
{ title: "Market Value", field: "marketAmount", hozAlign: "right", formatter: nonZeroCurrency },
{ title: "Change", field: "gainAmount", hozAlign: "right", formatter: formatCurrencyChange },
{ title: "XIRR", field: "xirr", hozAlign: "right", formatter: nonZeroFloatChange },
{
title: "Absolute Return",
field: "absoluteReturn",
hozAlign: "right",
formatter: nonZeroPercentageChange
}
return changeClass;
];
let tree: AssetBreakdown[] = [];
$: if (breakdowns) {
tree = buildTree(Object.values(breakdowns), (i) => i.group);
}
</script>
<div class="box overflow-x-auto max-h-screen max-w-fit pt-0">
<table class="table is-narrow is-hoverable is-light-border has-sticky-header">
<thead>
<tr>
<th class="py-2">Account</th>
<th class="py-2 has-text-right">Investment Amount</th>
<th class="py-2 has-text-right">Withdrawal Amount</th>
<th class="py-2 has-text-right">Balance Units</th>
<th class="py-2 has-text-right">Market Value</th>
<th class="py-2 has-text-right">Change</th>
<th class="py-2 has-text-right">XIRR</th>
<th class="py-2 has-text-right">Absolute Return</th>
</tr>
</thead>
<tbody class="has-text-grey-dark">
{#each Object.values(breakdowns) as b}
{@const indentWidth = indent ? _.repeat("&emsp;&emsp;", depth(b.group) - 1) : ""}
{@const gain = b.gainAmount}
{@const changeClass = calculateChangeClass(gain)}
<tr>
<td
class="whitespace-nowrap has-text-left"
style="max-width: max(15rem, 33.33vw); overflow: hidden;"
>{@html indentWidth}<span class="has-text-grey custom-icon">{iconText(b.group)}</span>
<a href="/assets/gain/{b.group}">{indent ? lastName(b.group) : b.group}</a></td
>
<td class="has-text-right"
>{!isZero(b.investmentAmount) ? formatCurrency(b.investmentAmount) : ""}</td
>
<td class="has-text-right"
>{!isZero(b.withdrawalAmount) ? formatCurrency(b.withdrawalAmount) : ""}</td
>
<td class="has-text-right">{b.balanceUnits > 0 ? formatFloat(b.balanceUnits, 4) : ""}</td>
<td class="has-text-right"
>{!isZero(b.marketAmount) ? formatCurrency(b.marketAmount) : ""}</td
>
<td class="{changeClass} has-text-right"
>{!isZero(b.investmentAmount) && !isZero(gain) ? formatCurrency(gain) : ""}</td
>
<td class="{changeClass} has-text-right">{!isZero(b.xirr) ? formatFloat(b.xirr) : ""}</td>
<td class="{changeClass} has-text-right"
>{!isZero(b.absoluteReturn) ? formatPercentage(b.absoluteReturn, 2) : ""}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
{#if indent}
<Table data={tree} tree {columns} />
{:else}
<Table data={Object.values(breakdowns)} {columns} />
{/if}

View File

@ -118,7 +118,7 @@
label: "More",
href: "/more",
children: [
{ label: "Configuration", href: "/config", tag: "alpha", help: "config" },
{ label: "Configuration", href: "/config", help: "config" },
{ label: "Sheets", href: "/sheets", help: "sheets", disablePreload: true },
{ label: "Goals", href: "/goals", help: "goals" },
{ label: "Doctor", href: "/doctor" },

View File

@ -0,0 +1,47 @@
<script lang="ts">
import { rem } from "$lib/utils";
import { onMount } from "svelte";
import { TabulatorFull as Tabulator, type ColumnDefinition } from "tabulator-tables";
export let data: any[];
export let columns: ColumnDefinition[];
export let tree = false;
let tableComponent: HTMLElement;
let tabulator: Tabulator;
$: if (data.length > 0) {
build();
}
async function build() {
if (data.length === 0) {
return;
}
if (tabulator) {
tabulator.replaceData(data);
} else {
tabulator = new Tabulator(tableComponent, {
dataTree: tree,
dataTreeStartExpanded: [true, true, false],
dataTreeBranchElement: false,
dataTreeChildIndent: rem(30),
dataTreeCollapseElement:
"<span class='has-text-link icon is-small mr-3'><i class='fas fa-angle-up'></i></span>",
dataTreeExpandElement:
"<span class='has-text-link icon is-small mr-3'><i class='fas fa-angle-down'></i></span>",
data: data,
columns: columns,
maxHeight: "100vh",
layout: "fitDataTable"
});
}
}
onMount(async () => {
build();
});
</script>
<div class="overflow-x-auto box py-0" style="max-width: 100%;" bind:this={tableComponent}></div>

View File

@ -0,0 +1,81 @@
import { type CellComponent } from "tabulator-tables";
import { formatCurrency, formatFloat, formatPercentage, isZero, lastName } from "./utils";
import { iconText } from "./icon";
export function indendedAssetAccountName(cell: CellComponent) {
const account = cell.getValue();
let children = "";
const data = cell.getData();
if ((data._children?.length || 0) > 0) {
children = `(${data._children?.length})`;
}
return `
<span class="whitespace-nowrap" style="max-width: max(15rem, 33.33vw); overflow: hidden;">
<span class="has-text-grey custom-icon">${iconText(account)}</span>
<a href="/assets/gain/${account}">${lastName(account)}</a>
<span class="has-text-grey-light is-size-7">${children}</span>
</span>
`;
}
export function indendedLiabilityAccountName(cell: CellComponent) {
const account = cell.getValue();
let children = "";
const data = cell.getData();
if ((data._children?.length || 0) > 0) {
children = `(${data._children?.length})`;
}
return `
<span class="whitespace-nowrap" style="max-width: max(15rem, 33.33vw); overflow: hidden;">
<span class="has-text-grey custom-icon">${iconText(account)}</span>
<span>${lastName(account)}</span>
<span class="has-text-grey-light is-size-7">${children}</span>
</span>
`;
}
export function accountName(cell: CellComponent) {
const account = cell.getValue();
return `
<span class="whitespace-nowrap" style="max-width: max(15rem, 33.33vw); overflow: hidden;">
<span class="has-text-grey custom-icon">${iconText(account)}</span>
<a href="/assets/gain/${account}">${account}</a>
</span>
`;
}
function calculateChangeClass(gain: number) {
let changeClass = "";
if (gain > 0) {
changeClass = "has-text-success";
} else if (gain < 0) {
changeClass = "has-text-danger";
}
return changeClass;
}
export function nonZeroCurrency(cell: CellComponent) {
const value = cell.getValue();
return isZero(value) ? "" : formatCurrency(value);
}
export function nonZeroFloatChange(cell: CellComponent) {
const value = cell.getValue();
return isZero(value)
? ""
: `<span class="${calculateChangeClass(value)}">${formatFloat(value)}</span>`;
}
export function nonZeroPercentageChange(cell: CellComponent) {
const value = cell.getValue();
return isZero(value)
? ""
: `<span class="${calculateChangeClass(value)}">${formatPercentage(value, 2)}</span>`;
}
export function formatCurrencyChange(cell: CellComponent) {
const value = cell.getValue();
return isZero(value)
? ""
: `<span class="${calculateChangeClass(value)}">${formatCurrency(value)}</span>`;
}

View File

@ -1276,3 +1276,30 @@ export function dueDateIcon(dueDate: dayjs.Dayjs, clearedDate: dayjs.Dayjs) {
return { icon, color, svgColor, glyph };
}
export function buildTree<I>(items: I[], accountAccessor: (item: I) => string): I[] {
const result: I[] = [];
const sorted = _.sortBy(items, accountAccessor);
for (const item of sorted) {
const account = accountAccessor(item);
const parts = account.split(":");
let current = result;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
let found: any = current.find((c) => accountAccessor(c).split(":")[i] === part);
if (!found) {
found = { ...item };
current.push(found);
}
if (i !== parts.length - 1) {
found._children = found._children || [];
current = found._children;
}
}
}
return result;
}

View File

@ -1,29 +1,18 @@
<script lang="ts">
import { iconText } from "$lib/icon";
import Table from "$lib/components/Table.svelte";
import {
ajax,
depth,
formatCurrency,
formatFloat,
lastName,
type LiabilityBreakdown
} from "$lib/utils";
indendedLiabilityAccountName,
nonZeroCurrency,
nonZeroFloatChange
} from "$lib/table_formatters";
import { ajax, buildTree, type LiabilityBreakdown } from "$lib/utils";
import _ from "lodash";
import { onMount } from "svelte";
import type { ColumnDefinition } from "tabulator-tables";
let breakdowns: LiabilityBreakdown[] = [];
let isEmpty = false;
function calculateChangeClass(gain: number) {
let changeClass = "";
if (gain > 0) {
changeClass = "has-text-success";
} else if (gain < 0) {
changeClass = "has-text-danger";
}
return changeClass;
}
onMount(async () => {
({ liability_breakdowns: breakdowns } = await ajax("/api/liabilities/balance"));
@ -31,6 +20,46 @@
isEmpty = true;
}
});
const columns: ColumnDefinition[] = [
{
title: "Account",
field: "group",
formatter: indendedLiabilityAccountName,
frozen: true
},
{
title: "Drawn Amount",
field: "drawn_amount",
hozAlign: "right",
vertAlign: "middle",
formatter: nonZeroCurrency
},
{
title: "Repaid Amount",
field: "repaid_amount",
hozAlign: "right",
formatter: nonZeroCurrency
},
{
title: "Balance Amount",
field: "balance_amount",
hozAlign: "right",
formatter: nonZeroCurrency
},
{
title: "Interest",
field: "interest_amount",
hozAlign: "right",
formatter: nonZeroCurrency
},
{ title: "APR", field: "apr", hozAlign: "right", formatter: nonZeroFloatChange }
];
let tree: LiabilityBreakdown[] = [];
$: if (breakdowns) {
tree = buildTree(Object.values(breakdowns), (i) => i.group);
}
</script>
<section class="section" class:is-hidden={!isEmpty}>
@ -51,48 +80,7 @@
<div class="container is-fluid">
<div class="columns">
<div class="column is-12 pb-0">
<div class="box overflow-x-auto max-h-screen max-w-fit pt-0">
<table class="table is-narrow is-hoverable has-sticky-header is-light-border">
<thead>
<tr>
<th class="py-2">Account</th>
<th class="py-2 has-text-right">Drawn Amount</th>
<th class="py-2 has-text-right">Repaid Amount</th>
<th class="py-2 has-text-right">Balance Amount</th>
<th class="py-2 has-text-right">Interest</th>
<th class="py-2 has-text-right">APR</th>
</tr>
</thead>
<tbody class="has-text-grey-dark">
{#each Object.values(breakdowns) as b}
{@const indent = _.repeat("&emsp;&emsp;", depth(b.group) - 1)}
{@const changeClass = calculateChangeClass(-b.interest_amount)}
<tr>
<td class="whitespace-nowrap" style="max-width: 200px; overflow: hidden;"
>{@html indent}<span class="has-text-grey custom-icon">{iconText(b.group)}</span
>
{lastName(b.group)}</td
>
<td class="has-text-right"
>{b.drawn_amount != 0 ? formatCurrency(b.drawn_amount) : ""}</td
>
<td class="has-text-right"
>{b.repaid_amount != 0 ? formatCurrency(b.repaid_amount) : ""}</td
>
<td class="has-text-right"
>{b.balance_amount != 0 ? formatCurrency(b.balance_amount) : ""}</td
>
<td class="has-text-right"
>{b.interest_amount != 0 ? formatCurrency(b.interest_amount) : ""}</td
>
<td class="{changeClass} has-text-right"
>{b.apr > 0.0001 || b.apr < -0.0001 ? formatFloat(b.apr) : ""}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
<Table data={tree} tree {columns} />
</div>
</div>
</div>

View File

@ -177,7 +177,7 @@
</div>
<BoxLabel text="Monthly Investment" />
<div class="columns">
<div class="column is-12 has-text-centered has-text-grey">
<div class="column is-12 has-text-grey">
<AssetsBalance breakdowns={balances} indent={false} />
</div>
</div>