From ffded9ca9bcfe1d3e2d453cab7cc28cbd545b3fe Mon Sep 17 00:00:00 2001 From: Yohai Meiron Date: Wed, 10 Jan 2024 13:25:57 -0500 Subject: [PATCH 1/2] Export transactions as CSV --- .../TransactionList/TransactionList.tsx | 86 ++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/frontend/apps/web/src/pages/transactions/TransactionList/TransactionList.tsx b/frontend/apps/web/src/pages/transactions/TransactionList/TransactionList.tsx index c4db01c..c321265 100644 --- a/frontend/apps/web/src/pages/transactions/TransactionList/TransactionList.tsx +++ b/frontend/apps/web/src/pages/transactions/TransactionList/TransactionList.tsx @@ -4,6 +4,7 @@ import { selectCurrentUserPermissions, selectGroupById, selectSortedTransactions, + selectAccountIdToAccountMap, } from "@abrechnung/redux"; import { Add, Clear } from "@mui/icons-material"; import SearchIcon from "@mui/icons-material/Search"; @@ -29,13 +30,14 @@ import { useMediaQuery, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; +import { SaveAlt } from "@mui/icons-material"; import React, { useState } from "react"; import { useNavigate } from "react-router-dom"; 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 } from "@/store"; +import { selectGroupSlice, useAppDispatch, useAppSelector, selectAccountSlice } from "@/store"; import { TransactionListItem } from "./TransactionListItem"; import { useTranslation } from "react-i18next"; @@ -46,6 +48,79 @@ 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"); + link.download = filename; + link.href = url; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + export const TransactionList: React.FC = ({ groupId }) => { const { t } = useTranslation(); const theme: Theme = useTheme(); @@ -98,6 +173,8 @@ export const TransactionList: React.FC = ({ groupId }) => { const handleChangeTagFilter = (newTags: string[]) => setTagFilter(newTags); + const exportedCsv = exportCsv(groupId); + return ( <> @@ -162,6 +239,13 @@ export const TransactionList: React.FC = ({ groupId }) => { /> + +
+ + {downloadCsv(exportedCsv, "transactions.csv");}}> + +
+
{!isSmallScreen && permissions.canWrite && (
From 15f4ad22aaf2da9de4d9eb089c706970b6b8a2da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Loipf=C3=BChrer?= Date: Sun, 14 Jan 2024 11:33:34 +0100 Subject: [PATCH 2/2] refactor(frontend): restructure transaction csv export --- CHANGELOG.md | 4 +- .../TransactionList/TransactionList.tsx | 132 ++++++------------ frontend/libs/core/src/lib/transactions.ts | 57 +++++++- frontend/libs/translations/src/lib/de.ts | 1 + frontend/libs/translations/src/lib/en.ts | 1 + frontend/libs/utils/src/index.ts | 1 + frontend/libs/utils/src/lib/csv.ts | 17 +++ 7 files changed, 121 insertions(+), 92 deletions(-) create mode 100644 frontend/libs/utils/src/lib/csv.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 016eb35..ec202ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 \ No newline at end of file +- Correctly filter out deleted transactions in balance computations diff --git a/frontend/apps/web/src/pages/transactions/TransactionList/TransactionList.tsx b/frontend/apps/web/src/pages/transactions/TransactionList/TransactionList.tsx index c321265..544642e 100644 --- a/frontend/apps/web/src/pages/transactions/TransactionList/TransactionList.tsx +++ b/frontend/apps/web/src/pages/transactions/TransactionList/TransactionList.tsx @@ -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"; @@ -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; @@ -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 = ({ groupId }) => { const { t } = useTranslation(); @@ -173,7 +125,7 @@ export const TransactionList: React.FC = ({ groupId }) => { const handleChangeTagFilter = (newTags: string[]) => setTagFilter(newTags); - const exportedCsv = exportCsv(groupId); + const downloadCsv = useDownloadCsv(groupId, transactions); return ( <> @@ -239,28 +191,30 @@ export const TransactionList: React.FC = ({ groupId }) => { /> - -
- - {downloadCsv(exportedCsv, "transactions.csv");}}> - -
-
{!isSmallScreen && permissions.canWrite && ( -
- -
- - - - - - - - + + + + {permissions.canWrite && ( + <> +
+ +
+ + + + + + + + + + + + )}
)} diff --git a/frontend/libs/core/src/lib/transactions.ts b/frontend/libs/core/src/lib/transactions.ts index b7d03a0..560e1b8 100644 --- a/frontend/libs/core/src/lib/transactions.ts +++ b/frontend/libs/core/src/lib/transactions.ts @@ -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"; @@ -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); +}; diff --git a/frontend/libs/translations/src/lib/de.ts b/frontend/libs/translations/src/lib/de.ts index b76aab8..b488eb4 100644 --- a/frontend/libs/translations/src/lib/de.ts +++ b/frontend/libs/translations/src/lib/de.ts @@ -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", diff --git a/frontend/libs/translations/src/lib/en.ts b/frontend/libs/translations/src/lib/en.ts index 0334e17..3348465 100644 --- a/frontend/libs/translations/src/lib/en.ts +++ b/frontend/libs/translations/src/lib/en.ts @@ -39,6 +39,7 @@ const translations = { send: "Send", currency: "Currency", addNewTag: "Add new Tag", + exportAsCsv: "Export as CSV", }, shareSelect: { selectedPeople_one: "{{count}} Person", diff --git a/frontend/libs/utils/src/index.ts b/frontend/libs/utils/src/index.ts index 9001147..1ce455a 100644 --- a/frontend/libs/utils/src/index.ts +++ b/frontend/libs/utils/src/index.ts @@ -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"; diff --git a/frontend/libs/utils/src/lib/csv.ts b/frontend/libs/utils/src/lib/csv.ts new file mode 100644 index 0000000..79faae2 --- /dev/null +++ b/frontend/libs/utils/src/lib/csv.ts @@ -0,0 +1,17 @@ +export type CSVHeaders = { + [K in keyof T]: string; +}; + +export const buildCsv = (headers: CSVHeaders, 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; +};