Skip to content

Commit

Permalink
refactor(frontend): restructure transaction csv export
Browse files Browse the repository at this point in the history
  • Loading branch information
mikonse committed Jan 14, 2024
1 parent ffded9c commit 15f4ad2
Show file tree
Hide file tree
Showing 7 changed files with 121 additions and 92 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@

[Compare the full difference.](https://github.com/SFTtech/abrechnung/compare/v0.12.1...HEAD)

- Add CSV exports for transactions in web by @ymeiron

## 0.12.1 (2024-01-05)

[Compare the full difference.](https://github.com/SFTtech/abrechnung/compare/v0.12.0...v0.12.1)

### Fixed

- Correctly filter out deleted transactions in balance computations
- Correctly filter out deleted transactions in balance computations
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { TransactionSortMode } from "@abrechnung/core";
import { TransactionSortMode, transactionCsvDump } from "@abrechnung/core";
import {
createTransaction,
selectCurrentUserPermissions,
selectGroupById,
selectSortedTransactions,
selectAccountIdToAccountMap,
selectGroupAccounts,
selectTransactionBalanceEffects,
} from "@abrechnung/redux";
import { Add, Clear } from "@mui/icons-material";
import SearchIcon from "@mui/icons-material/Search";
Expand Down Expand Up @@ -37,9 +38,10 @@ import { TagSelector } from "@/components/TagSelector";
import { PurchaseIcon, TransferIcon } from "@/components/style/AbrechnungIcons";
import { MobilePaper } from "@/components/style/mobile";
import { useTitle } from "@/core/utils";
import { selectGroupSlice, useAppDispatch, useAppSelector, selectAccountSlice } from "@/store";
import { selectGroupSlice, useAppDispatch, useAppSelector, selectAccountSlice, selectTransactionSlice } from "@/store";
import { TransactionListItem } from "./TransactionListItem";
import { useTranslation } from "react-i18next";
import { Transaction } from "@abrechnung/types";

interface Props {
groupId: number;
Expand All @@ -48,78 +50,28 @@ interface Props {
const emptyList = [];
const MAX_ITEMS_PER_PAGE = 40;

function exportCsv(groupId) {
const accounts = useAppSelector((state) =>
selectAccountIdToAccountMap({ state: selectAccountSlice(state), groupId })
);

let transactionsSorted = useAppSelector((state) =>
selectSortedTransactions({ state, groupId, searchTerm: "", sortMode: "billed_at", tags: [] })
);
transactionsSorted = [...transactionsSorted].reverse();

const accountIds = Object.keys(accounts).filter(id => !accounts[id].deleted);
const accountNames = accountIds.map(id => accounts[id].name);
const accountIndexById = Object.fromEntries(accountIds.map((id, index) => [id, index]));

let exportedCsv = "ID,Date,Payer,Name,Tags,Value," + accountNames.join(",") + ",Description\n";
for (const transaction of transactionsSorted) {
if (transaction.is_wip) continue;

const creditorId = Object.entries(transaction.creditor_shares)[0][0];
const creditorName = accounts[creditorId].name;
let tags = "";
if (transaction.tags.length == 1) {
tags = transaction.tags[0];
} else if (transaction.tags.length > 1) {
tags = JSON.stringify(transaction.tags.join(","));
}

let value = transaction.value;
let total = accountIds.map(() => 0);

if (transaction.type == "transfer") {
total[accountIndexById[creditorId]] = transaction.value;
const debitorId = Object.entries(transaction.debitor_shares)[0][0];
total[accountIndexById[debitorId]] = -transaction.value;
value = 0
} else {
let extraFromPositions = 0;
let totalPositions = 0;
for (let position of Object.values(transaction.positions)) {
const totalShares = Object.values(position.usages).reduce((a, b) => a + b, 0) + position.communist_shares;
for (let [accountId, shares] of Object.entries(position.usages)) {
let value = position.price*shares/totalShares;
total[accountIndexById[accountId]] += value;
totalPositions += value;
}
extraFromPositions += position.price*position.communist_shares/totalShares;
}
totalPositions += extraFromPositions;
const valueMinusPositions = transaction.value - totalPositions;
const totalShares = Object.values(transaction.debitor_shares).reduce((a, b) => a + b, 0);
const numberOfDebitors = Object.values(transaction.debitor_shares).length;
for (let [accountId, debitorShares] of Object.entries(transaction.debitor_shares)) {
total[accountIndexById[accountId]] += valueMinusPositions*debitorShares/totalShares + extraFromPositions/numberOfDebitors;
}
}
exportedCsv += `${transaction.id},${transaction.billed_at},${creditorName},${JSON.stringify(transaction.name)},${tags},${value.toFixed(2)},`;
exportedCsv += total.map((value) => value.toFixed(2)).join(",");
exportedCsv += "," + JSON.stringify(transaction.description) + "\n";
}
return exportedCsv;
}

function downloadCsv(str, filename) {
let blob = new Blob([str], {type: "text/csv;charset=utf-8"});
let url = URL.createObjectURL(blob);
let link = document.createElement("a");
const downloadFile = (content: string, filename: string, mimetype: string) => {
const blob = new Blob([content], { type: `${mimetype};charset=utf-8` });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.download = filename;
link.href = url;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
};

const useDownloadCsv = (groupId: number, transactions: Transaction[]) => {
const accounts = useAppSelector((state) => selectGroupAccounts({ state: selectAccountSlice(state), groupId }));
const balanceEffects = useAppSelector((state) =>
selectTransactionBalanceEffects({ state: selectTransactionSlice(state), groupId })
);

return React.useCallback(() => {
const csv = transactionCsvDump(transactions, balanceEffects, accounts);
downloadFile(csv, "transactions.csv", "text/csv");
}, [accounts, balanceEffects, transactions]);
};

export const TransactionList: React.FC<Props> = ({ groupId }) => {
const { t } = useTranslation();
Expand Down Expand Up @@ -173,7 +125,7 @@ export const TransactionList: React.FC<Props> = ({ groupId }) => {

const handleChangeTagFilter = (newTags: string[]) => setTagFilter(newTags);

const exportedCsv = exportCsv(groupId);
const downloadCsv = useDownloadCsv(groupId, transactions);

return (
<>
Expand Down Expand Up @@ -239,28 +191,30 @@ export const TransactionList: React.FC<Props> = ({ groupId }) => {
/>
</FormControl>
</Box>
<Box sx={{ display: "flex-item" }}>
<div style={{ padding: "8px" }}>
<Tooltip title="Export CSV">
<IconButton size="small" color="primary" onClick={() => {downloadCsv(exportedCsv, "transactions.csv");}}><SaveAlt /></IconButton>
</Tooltip>
</div>
</Box>
{!isSmallScreen && permissions.canWrite && (
<Box sx={{ display: "flex-item" }}>
<div style={{ padding: "8px" }}>
<Add color="primary" />
</div>
<Tooltip title={t("transactions.createPurchase")}>
<IconButton color="primary" onClick={onCreatePurchase}>
<PurchaseIcon />
</IconButton>
</Tooltip>
<Tooltip title={t("transactions.createTransfer")}>
<IconButton color="primary" onClick={onCreateTransfer}>
<TransferIcon />
<Tooltip title={t("common.exportAsCsv")}>
<IconButton size="small" color="primary" onClick={downloadCsv}>
<SaveAlt />
</IconButton>
</Tooltip>
{permissions.canWrite && (
<>
<div style={{ padding: "8px" }}>
<Add color="primary" />
</div>
<Tooltip title={t("transactions.createPurchase")}>
<IconButton color="primary" onClick={onCreatePurchase}>
<PurchaseIcon />
</IconButton>
</Tooltip>
<Tooltip title={t("transactions.createTransfer")}>
<IconButton color="primary" onClick={onCreateTransfer}>
<TransferIcon />
</IconButton>
</Tooltip>
</>
)}
</Box>
)}
</Box>
Expand Down
57 changes: 55 additions & 2 deletions frontend/libs/core/src/lib/transactions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Transaction, TransactionBalanceEffect } from "@abrechnung/types";
import { fromISOString } from "@abrechnung/utils";
import { Account, Transaction, TransactionBalanceEffect } from "@abrechnung/types";
import { buildCsv, fromISOString } from "@abrechnung/utils";

export type TransactionSortMode = "last_changed" | "value" | "name" | "description" | "billed_at";

Expand Down Expand Up @@ -104,3 +104,56 @@ export const computeTransactionBalanceEffect = (transaction: Transaction): Trans

return accountBalances;
};

export const transactionCsvDump = (
transactions: Transaction[],
balanceEffects: { [id: number]: TransactionBalanceEffect },
accounts: Account[]
): string => {
const transactionsSorted = [...transactions]
.filter((t) => !t.is_wip)
.sort((t1, t2) => t1.billed_at.localeCompare(t2.billed_at));

const accountMap = Object.fromEntries(
accounts.filter((acc) => !acc.deleted).map((acc) => [`account-${acc.id}`, acc.name])
);

const csvHeaders = {
id: "ID",
date: "Date",
payer: "Payer",
name: "Name",
description: "Description",
currency_symbol: "Currency",
currency_conversion_rate: "Currency Conversion Rate",
tags: "Tags",
value: "Value",
...accountMap,
};

const data = [];

for (const transaction of transactionsSorted) {
const balanceEffect = balanceEffects[transaction.id];
const creditorId = Object.keys(transaction.creditor_shares)[0];
const creditorName = accountMap[`account-${creditorId}`];
const tags = transaction.tags.join(",");

const rowData = {
id: transaction.id,
date: transaction.billed_at,
payer: creditorName,
name: transaction.name,
description: transaction.description,
currency_symbol: transaction.currency_symbol,
currency_conversion_rate: transaction.currency_conversion_rate,
tags: tags,
value: transaction.value.toFixed(2),
...Object.fromEntries(
accounts.map((acc) => [`account-${acc.id}`, balanceEffect[acc.id]?.total.toFixed(2) ?? ""])
),
};
data.push(rowData);
}
return buildCsv(csvHeaders, data);
};
1 change: 1 addition & 0 deletions frontend/libs/translations/src/lib/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const translations = {
send: "Senden",
currency: "Währung",
addNewTag: "Neuen Tag hinzufügen",
exportAsCsv: "Als CSV Datei exportieren",
},
shareSelect: {
selectedPeople_one: "{{count}} Person",
Expand Down
1 change: 1 addition & 0 deletions frontend/libs/translations/src/lib/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const translations = {
send: "Send",
currency: "Currency",
addNewTag: "Add new Tag",
exportAsCsv: "Export as CSV",
},
shareSelect: {
selectedPeople_one: "{{count}} Person",
Expand Down
1 change: 1 addition & 0 deletions frontend/libs/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from "./lib/utils";
export * from "./lib/validators";
export * from "./lib/event-emitter";
export * from "./lib/floats";
export * from "./lib/csv";
17 changes: 17 additions & 0 deletions frontend/libs/utils/src/lib/csv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export type CSVHeaders<T extends object> = {
[K in keyof T]: string;
};

export const buildCsv = <T extends object>(headers: CSVHeaders<T>, data: T[]): string => {
const header = Object.values(headers).join(",") + "\n";

const stringifiedData = data
.map((row) =>
Object.keys(headers)
.map((headerKey) => String(row[headerKey as keyof T] ?? ""))
.join(",")
)
.join("\n");

return header + stringifiedData;
};

0 comments on commit 15f4ad2

Please sign in to comment.