From 6c5c9d5bed63e57364d6e7bfb347796f76f12e8a Mon Sep 17 00:00:00 2001 From: Yuvaraj Sai <63961211+yuvarajsai@users.noreply.github.com> Date: Sat, 19 Apr 2025 23:41:38 +0530 Subject: [PATCH] Feat: Add export to CSV support (#292) * install json2csv package * add necessary labels * add support convert the JSON to redable CSV format and export * add a popover to export btton and provide options for exporting to JSON and CSV * Use a DropdownMenu * Translations --------- Co-authored-by: Sebastien Castiel --- messages/de-DE.json | 1 + messages/en-US.json | 2 + messages/es.json | 1 + messages/fi.json | 1 + messages/fr-FR.json | 1 + messages/it-IT.json | 1 + messages/pl-PL.json | 1 + messages/ro.json | 1 + messages/ru-RU.json | 1 + messages/ua-UA.json | 1 + messages/zh-CN.json | 1 + messages/zh-TW.json | 1 + package-lock.json | 23 +++ package.json | 1 + .../[groupId]/expenses/export/csv/route.ts | 142 ++++++++++++++++++ .../groups/[groupId]/expenses/page.client.tsx | 14 +- src/app/groups/[groupId]/export-button.tsx | 53 +++++++ 17 files changed, 235 insertions(+), 11 deletions(-) create mode 100644 src/app/groups/[groupId]/expenses/export/csv/route.ts create mode 100644 src/app/groups/[groupId]/export-button.tsx diff --git a/messages/de-DE.json b/messages/de-DE.json index 65104d0..4e720e5 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -21,6 +21,7 @@ "createFirst": "Erstelle die Erste", "noExpenses": "Deine Gruppe hat noch keine Ausgaben.", "exportJson": "Als JSON exportieren", + "exportCsv": "Als CSV exportieren", "searchPlaceholder": "Suche nach einer Ausgabe…", "ActiveUserModal": { "title": "Wer bist du?", diff --git a/messages/en-US.json b/messages/en-US.json index 0e42ec9..493b9e3 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -20,7 +20,9 @@ "create": "Create expense", "createFirst": "Create the first one", "noExpenses": "Your group doesn’t contain any expense yet.", + "export": "Export", "exportJson": "Export to JSON", + "exportCsv": "Export to CSV", "searchPlaceholder": "Search for an expense…", "ActiveUserModal": { "title": "Who are you?", diff --git a/messages/es.json b/messages/es.json index c75a217..c89709c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -21,6 +21,7 @@ "createFirst": "Crea el primero", "noExpenses": "Tu grupo aun no tiene gastos.", "exportJson": "Exportar a JSON", + "exportCsv": "Exportar a CSV", "searchPlaceholder": "Busca un gasto…", "ActiveUserModal": { "title": "¿Quién es usted?", diff --git a/messages/fi.json b/messages/fi.json index a00e6b2..db05956 100644 --- a/messages/fi.json +++ b/messages/fi.json @@ -21,6 +21,7 @@ "createFirst": "Lisää ensimmäinen kulu", "noExpenses": "Ryhmälläsi ei ole vielä yhtään kulua.", "exportJson": "Vie JSON-tiedostoon", + "exportCsv": "Vie CSV-tiedostoon", "searchPlaceholder": "Etsi kulua…", "ActiveUserModal": { "title": "Kuka olet?", diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 8b06454..d5d21c7 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -21,6 +21,7 @@ "createFirst": "Créer la première :)", "noExpenses": "Votre groupe n'a effectué aucune dépense pour le moment.", "exportJson": "Exporter en JSON", + "exportCsv": "Exporter en CSV", "searchPlaceholder": "Rechercher une dépense…", "ActiveUserModal": { "title": "Qui êtes-vous ?", diff --git a/messages/it-IT.json b/messages/it-IT.json index e984067..8a101a7 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -21,6 +21,7 @@ "createFirst": "Crea la prima", "noExpenses": "Il tuo gruppo non contiene ancora spese.", "exportJson": "Esporta file JSON", + "exportCsv": "Esporta file CSV", "searchPlaceholder": "Cerca una spesa…", "ActiveUserModal": { "title": "Chi sei?", diff --git a/messages/pl-PL.json b/messages/pl-PL.json index b936c6b..73b3208 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -21,6 +21,7 @@ "createFirst": "Stwórz swój pierwszy", "noExpenses": "Twoja grupa nie ma jeszcze żadnych wydatków.", "exportJson": "Eksportuj do JSONa", + "exportCsv": "Eksportuj do CSVa", "searchPlaceholder": "Szukaj wydatku...", "ActiveUserModal": { "title": "Kim jesteś?", diff --git a/messages/ro.json b/messages/ro.json index 718037a..76f8a18 100644 --- a/messages/ro.json +++ b/messages/ro.json @@ -21,6 +21,7 @@ "createFirst": "Adaug-o pe prima", "noExpenses": "Grupul tău nu conține nicio cheltuială încă.", "exportJson": "Salvează în JSON", + "exportCsv": "Salvează în CSV", "searchPlaceholder": "Caută o cheltuială…", "ActiveUserModal": { "title": "Cum te numești?", diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 82c48cf..91027a2 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -21,6 +21,7 @@ "createFirst": "Создать первый расход", "noExpenses": "У вашей группы пока что нет расходов.", "exportJson": "Экспортировать в JSON", + "exportCsv": "Экспортировать в CSV", "searchPlaceholder": "Поиск расходов…", "ActiveUserModal": { "title": "Кто вы?", diff --git a/messages/ua-UA.json b/messages/ua-UA.json index e6eb561..1626305 100644 --- a/messages/ua-UA.json +++ b/messages/ua-UA.json @@ -21,6 +21,7 @@ "createFirst": "Створіть першу витрату", "noExpenses": "У вашій групі ще немає витрат", "exportJson": "Експортувати у JSON", + "exportCsv": "Експортувати у CSV", "searchPlaceholder": "Пошук витрат...", "ActiveUserModal": { "title": "Хто ви?", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index ac98b8c..1ced8bf 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -21,6 +21,7 @@ "createFirst": "创建首个消费", "noExpenses": "你的群组内目前没有任何消费。", "exportJson": "导出到JSON", + "exportCsv": "导出到CSV", "searchPlaceholder": "查找消费……", "ActiveUserModal": { "title": "你是哪位?", diff --git a/messages/zh-TW.json b/messages/zh-TW.json index 3206197..037b6a8 100644 --- a/messages/zh-TW.json +++ b/messages/zh-TW.json @@ -21,6 +21,7 @@ "createFirst": "新增第一筆消費紀錄", "noExpenses": "你的群組內目前沒有任何消費紀錄。", "exportJson": "匯出為 JSON", + "exportCsv": "匯出為 CSV", "searchPlaceholder": "搜尋消費紀錄……", "ActiveUserModal": { "title": "你是誰?", diff --git a/package-lock.json b/package-lock.json index ec87fac..867dee2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@formatjs/intl-localematcher": "^0.5.4", "@hookform/resolvers": "^3.3.2", + "@json2csv/plainjs": "^7.0.6", "@prisma/client": "^5.6.0", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-collapsible": "^1.0.3", @@ -5320,6 +5321,22 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@json2csv/formatters": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@json2csv/formatters/-/formatters-7.0.6.tgz", + "integrity": "sha512-hjIk1H1TR4ydU5ntIENEPgoMGW+Q7mJ+537sDFDbsk+Y3EPl2i4NfFVjw0NJRgT+ihm8X30M67mA8AS6jPidSA==", + "license": "MIT" + }, + "node_modules/@json2csv/plainjs": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@json2csv/plainjs/-/plainjs-7.0.6.tgz", + "integrity": "sha512-4Md7RPDCSYpmW1HWIpWBOqCd4vWfIqm53S3e/uzQ62iGi7L3r34fK/8nhOMEe+/eVfCx8+gdSCt1d74SlacQHw==", + "license": "MIT", + "dependencies": { + "@json2csv/formatters": "^7.0.6", + "@streamparser/json": "^0.0.20" + } + }, "node_modules/@next/env": { "version": "14.2.5", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz", @@ -8930,6 +8947,12 @@ "node": ">=16.0.0" } }, + "node_modules/@streamparser/json": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@streamparser/json/-/json-0.0.20.tgz", + "integrity": "sha512-VqAAkydywPpkw63WQhPVKCD3SdwXuihCUVZbbiY3SfSTGQyHmwRoq27y4dmJdZuJwd5JIlQoMPyGvMbUPY0RKQ==", + "license": "MIT" + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", diff --git a/package.json b/package.json index 842e03e..7cbf133 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dependencies": { "@formatjs/intl-localematcher": "^0.5.4", "@hookform/resolvers": "^3.3.2", + "@json2csv/plainjs": "^7.0.6", "@prisma/client": "^5.6.0", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-collapsible": "^1.0.3", diff --git a/src/app/groups/[groupId]/expenses/export/csv/route.ts b/src/app/groups/[groupId]/expenses/export/csv/route.ts new file mode 100644 index 0000000..b942abf --- /dev/null +++ b/src/app/groups/[groupId]/expenses/export/csv/route.ts @@ -0,0 +1,142 @@ +import { Parser } from '@json2csv/plainjs' +import { PrismaClient } from '@prisma/client' +import contentDisposition from 'content-disposition' +import { NextResponse } from 'next/server' + +const splitModeLabel = { + EVENLY: 'Evenly', + BY_SHARES: 'Unevenly – By shares', + BY_PERCENTAGE: 'Unevenly – By percentage', + BY_AMOUNT: 'Unevenly – By amount', +} + +function formatDate(isoDateString: Date): string { + const date = new Date(isoDateString) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') // Months are zero-based + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` // YYYY-MM-DD format +} + +const prisma = new PrismaClient() + +export async function GET( + req: Request, + { params: { groupId } }: { params: { groupId: string } }, +) { + const group = await prisma.group.findUnique({ + where: { id: groupId }, + select: { + id: true, + name: true, + currency: true, + expenses: { + select: { + expenseDate: true, + title: true, + category: { select: { name: true } }, + amount: true, + paidById: true, + paidFor: { select: { participantId: true, shares: true } }, + isReimbursement: true, + splitMode: true, + }, + }, + participants: { select: { id: true, name: true } }, + }, + }) + + if (!group) { + return NextResponse.json({ error: 'Invalid group ID' }, { status: 404 }) + } + + /* + + CSV Structure: + + -------------------------------------------------------------- + | Date | Description | Category | Currency | Cost + -------------------------------------------------------------- + | Is Reimbursement | Split mode | UserA | UserB + -------------------------------------------------------------- + + Columns: + - Date: The date of the expense. + - Description: A brief description of the expense. + - Category: The category of the expense (e.g., Food, Travel, etc.). + - Currency: The currency in which the expense is recorded. + - Cost: The amount spent. + - Is Reimbursement: Whether the expense is a reimbursement or not. + - Split mode: The method used to split the expense (e.g., Evenly, By shares, By percentage, By amount). + - UserA, UserB: User-specific data or balances (e.g., amount owed or contributed by each user). + + Example Row: + ------------------------------------------------------------------------------------------ + | 2025-01-06 | Dinner with team | Food | ₹ | 5000 | No | Evenly | John | Jane + ------------------------------------------------------------------------------------------ + +*/ + + const fields = [ + { label: 'Date', value: 'date' }, + { label: 'Description', value: 'title' }, + { label: 'Category', value: 'categoryName' }, + { label: 'Currency', value: 'currency' }, + { label: 'Cost', value: 'amount' }, + { label: 'Is Reimbursement', value: 'isReimbursement' }, + { label: 'Split mode', value: 'splitMode' }, + ...group.participants.map((participant) => ({ + label: participant.name, + value: participant.name, + })), + ] + + const expenses = group.expenses.map((expense) => ({ + date: formatDate(expense.expenseDate), + title: expense.title, + categoryName: expense.category?.name || '', + currency: group.currency, + amount: (expense.amount / 100).toFixed(2), + isReimbursement: expense.isReimbursement ? 'Yes' : 'No', + splitMode: splitModeLabel[expense.splitMode], + ...Object.fromEntries( + group.participants.map((participant) => { + const { totalShares, participantShare } = expense.paidFor.reduce( + (acc, { participantId, shares }) => { + acc.totalShares += shares + if (participantId === participant.id) { + acc.participantShare = shares + } + return acc + }, + { totalShares: 0, participantShare: 0 }, + ) + + const isPaidByParticipant = expense.paidById === participant.id + const participantAmountShare = +( + ((expense.amount / totalShares) * participantShare) / + 100 + ).toFixed(2) + + return [ + participant.name, + participantAmountShare * (isPaidByParticipant ? 1 : -1), + ] + }), + ), + })) + + const json2csvParser = new Parser({ fields }) + const csv = json2csvParser.parse(expenses) + + const date = new Date().toISOString().split('T')[0] + const filename = `Spliit Export - ${group.name} - ${date}.csv` + + // \uFEFF character is added at the beginning of the CSV content to ensure that it is interpreted as UTF-8 with BOM (Byte Order Mark), which helps some applications correctly interpret the encoding. + return new NextResponse(`\uFEFF${csv}`, { + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': contentDisposition(filename), + }, + }) +} diff --git a/src/app/groups/[groupId]/expenses/page.client.tsx b/src/app/groups/[groupId]/expenses/page.client.tsx index 2a9888e..5547986 100644 --- a/src/app/groups/[groupId]/expenses/page.client.tsx +++ b/src/app/groups/[groupId]/expenses/page.client.tsx @@ -3,6 +3,7 @@ import { ActiveUserModal } from '@/app/groups/[groupId]/expenses/active-user-modal' import { CreateFromReceiptButton } from '@/app/groups/[groupId]/expenses/create-from-receipt-button' import { ExpenseList } from '@/app/groups/[groupId]/expenses/expense-list' +import ExportButton from '@/app/groups/[groupId]/export-button' import { Button } from '@/components/ui/button' import { Card, @@ -11,7 +12,7 @@ import { CardHeader, CardTitle, } from '@/components/ui/card' -import { Download, Plus } from 'lucide-react' +import { Plus } from 'lucide-react' import { Metadata } from 'next' import { useTranslations } from 'next-intl' import Link from 'next/link' @@ -40,16 +41,7 @@ export default function GroupExpensesPageClient({ {t('description')} - + {enableReceiptExtract && } + + + + +
+ +

{t('exportJson')}

+
+ +
+ + +
+ +

{t('exportCsv')}

+
+ +
+
+ + ) +}