From c3954ee2f4b6561c08002ba56726843345d8e4c7 Mon Sep 17 00:00:00 2001 From: Yuvaraj Sai Thadishetty Date: Tue, 7 Jan 2025 00:14:36 +0530 Subject: [PATCH 1/4] install json2csv package --- package-lock.json | 23 +++++++++++++++++++++++ package.json | 1 + 2 files changed, 24 insertions(+) diff --git a/package-lock.json b/package-lock.json index ec87fac3..867dee2b 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 842e03e2..7cbf1330 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", From 696df66fe0edfd81a75d63eee7daa819e88c5d06 Mon Sep 17 00:00:00 2001 From: Yuvaraj Sai Thadishetty Date: Tue, 7 Jan 2025 00:15:01 +0530 Subject: [PATCH 2/4] add necessary labels --- messages/en-US.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/messages/en-US.json b/messages/en-US.json index 0e42ec97..493b9e3e 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?", From 692cf7b5b7c19b3750521458a9d4bca3d1882cfa Mon Sep 17 00:00:00 2001 From: Yuvaraj Sai Thadishetty Date: Tue, 7 Jan 2025 00:16:25 +0530 Subject: [PATCH 3/4] add support convert the JSON to redable CSV format and export --- .../[groupId]/expenses/export/csv/route.ts | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 src/app/groups/[groupId]/expenses/export/csv/route.ts 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 00000000..b942abfc --- /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), + }, + }) +} From 97fcccee85732c4cdb24be288650509a734b8fa4 Mon Sep 17 00:00:00 2001 From: Yuvaraj Sai Thadishetty Date: Tue, 7 Jan 2025 00:23:49 +0530 Subject: [PATCH 4/4] add a popover to export btton and provide options for exporting to JSON and CSV --- .../groups/[groupId]/expenses/page.client.tsx | 14 ++--- src/app/groups/[groupId]/export-button.tsx | 52 +++++++++++++++++++ 2 files changed, 55 insertions(+), 11 deletions(-) create mode 100644 src/app/groups/[groupId]/export-button.tsx diff --git a/src/app/groups/[groupId]/expenses/page.client.tsx b/src/app/groups/[groupId]/expenses/page.client.tsx index 2a9888e3..55479860 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 && } + + + + + + + ) +}