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

@@ -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?",

View File

@@ -20,7 +20,9 @@
"create": "Create expense",
"createFirst": "Create the first one",
"noExpenses": "Your group doesnt contain any expense yet.",
"export": "Export",
"exportJson": "Export to JSON",
"exportCsv": "Export to CSV",
"searchPlaceholder": "Search for an expense…",
"ActiveUserModal": {
"title": "Who are you?",

View File

@@ -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?",

View File

@@ -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?",

View File

@@ -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 ?",

View File

@@ -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?",

View File

@@ -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ś?",

View File

@@ -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?",

View File

@@ -21,6 +21,7 @@
"createFirst": "Создать первый расход",
"noExpenses": "У вашей группы пока что нет расходов.",
"exportJson": "Экспортировать в JSON",
"exportCsv": "Экспортировать в CSV",
"searchPlaceholder": "Поиск расходов…",
"ActiveUserModal": {
"title": "Кто вы?",

View File

@@ -21,6 +21,7 @@
"createFirst": "Створіть першу витрату",
"noExpenses": "У вашій групі ще немає витрат",
"exportJson": "Експортувати у JSON",
"exportCsv": "Експортувати у CSV",
"searchPlaceholder": "Пошук витрат...",
"ActiveUserModal": {
"title": "Хто ви?",

View File

@@ -21,6 +21,7 @@
"createFirst": "创建首个消费",
"noExpenses": "你的群组内目前没有任何消费。",
"exportJson": "导出到JSON",
"exportCsv": "导出到CSV",
"searchPlaceholder": "查找消费……",
"ActiveUserModal": {
"title": "你是哪位?",

View File

@@ -21,6 +21,7 @@
"createFirst": "新增第一筆消費紀錄",
"noExpenses": "你的群組內目前沒有任何消費紀錄。",
"exportJson": "匯出為 JSON",
"exportCsv": "匯出為 CSV",
"searchPlaceholder": "搜尋消費紀錄……",
"ActiveUserModal": {
"title": "你是誰?",

23
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

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