Skip to content

Commit

Permalink
Merge pull request #198 from SFTtech/milo/csvexport
Browse files Browse the repository at this point in the history
csvexport
  • Loading branch information
mikonse authored Jan 14, 2024
2 parents 2263bb9 + 15f4ad2 commit e4207b4
Show file tree
Hide file tree
Showing 7 changed files with 129 additions and 16 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,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";
Expand All @@ -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;
Expand All @@ -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<Props> = ({ groupId }) => {
const { t } = useTranslation();
const theme: Theme = useTheme();
Expand Down Expand Up @@ -98,6 +125,8 @@ export const TransactionList: React.FC<Props> = ({ groupId }) => {

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

const downloadCsv = useDownloadCsv(groupId, transactions);

return (
<>
<MobilePaper>
Expand Down Expand Up @@ -164,19 +193,28 @@ export const TransactionList: React.FC<Props> = ({ groupId }) => {
</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 e4207b4

Please sign in to comment.