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 c4db01c..544642e 100644 --- a/frontend/apps/web/src/pages/transactions/TransactionList/TransactionList.tsx +++ b/frontend/apps/web/src/pages/transactions/TransactionList/TransactionList.tsx @@ -1,9 +1,11 @@ -import { TransactionSortMode } from "@abrechnung/core"; +import { TransactionSortMode, transactionCsvDump } from "@abrechnung/core"; import { createTransaction, selectCurrentUserPermissions, selectGroupById, selectSortedTransactions, + selectGroupAccounts, + selectTransactionBalanceEffects, } from "@abrechnung/redux"; import { Add, Clear } from "@mui/icons-material"; import SearchIcon from "@mui/icons-material/Search"; @@ -29,15 +31,17 @@ 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, selectTransactionSlice } from "@/store"; import { TransactionListItem } from "./TransactionListItem"; import { useTranslation } from "react-i18next"; +import { Transaction } from "@abrechnung/types"; interface Props { groupId: number; @@ -46,6 +50,29 @@ interface Props { const emptyList = []; const MAX_ITEMS_PER_PAGE = 40; +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(); const theme: Theme = useTheme(); @@ -98,6 +125,8 @@ export const TransactionList: React.FC = ({ groupId }) => { const handleChangeTagFilter = (newTags: string[]) => setTagFilter(newTags); + const downloadCsv = useDownloadCsv(groupId, transactions); + return ( <> @@ -164,19 +193,28 @@ export const TransactionList: React.FC = ({ groupId }) => { {!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; +};