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 <sebastien@castiel.me>
This commit is contained in:
Yuvaraj Sai
2025-04-19 23:41:38 +05:30
committed by GitHub
parent f9307fd22d
commit 6c5c9d5bed
17 changed files with 235 additions and 11 deletions

View File

@@ -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),
},
})
}

View File

@@ -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({
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardHeader className="p-4 sm:p-6 flex flex-row space-y-0 gap-2">
<Button variant="secondary" size="icon" asChild>
<Link
prefetch={false}
href={`/groups/${groupId}/expenses/export/json`}
target="_blank"
title={t('exportJson')}
>
<Download className="w-4 h-4" />
</Link>
</Button>
<ExportButton groupId={groupId} />
{enableReceiptExtract && <CreateFromReceiptButton />}
<Button asChild size="icon">
<Link

View File

@@ -0,0 +1,53 @@
'use client'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Download, FileDown, FileJson } from 'lucide-react'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
export default function ExportButton({ groupId }: { groupId: string }) {
const t = useTranslations('Expenses')
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button title={t('export')} variant="secondary" size="icon">
<Download className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem asChild>
<Link
prefetch={false}
href={`/groups/${groupId}/expenses/export/json`}
target="_blank"
title={t('exportJson')}
>
<div className="flex items-center gap-2">
<FileJson className="w-4 h-4" />
<p>{t('exportJson')}</p>
</div>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
prefetch={false}
href={`/groups/${groupId}/expenses/export/csv`}
target="_blank"
title={t('exportCsv')}
>
<div className="flex items-center gap-2">
<FileDown className="w-4 h-4" />
<p>{t('exportCsv')}</p>
</div>
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}