mirror of
https://github.com/spliit-app/spliit.git
synced 2026-02-11 10:06:13 +01:00
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:
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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 ?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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ś?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"createFirst": "Создать первый расход",
|
||||
"noExpenses": "У вашей группы пока что нет расходов.",
|
||||
"exportJson": "Экспортировать в JSON",
|
||||
"exportCsv": "Экспортировать в CSV",
|
||||
"searchPlaceholder": "Поиск расходов…",
|
||||
"ActiveUserModal": {
|
||||
"title": "Кто вы?",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"createFirst": "Створіть першу витрату",
|
||||
"noExpenses": "У вашій групі ще немає витрат",
|
||||
"exportJson": "Експортувати у JSON",
|
||||
"exportCsv": "Експортувати у CSV",
|
||||
"searchPlaceholder": "Пошук витрат...",
|
||||
"ActiveUserModal": {
|
||||
"title": "Хто ви?",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"createFirst": "创建首个消费",
|
||||
"noExpenses": "你的群组内目前没有任何消费。",
|
||||
"exportJson": "导出到JSON",
|
||||
"exportCsv": "导出到CSV",
|
||||
"searchPlaceholder": "查找消费……",
|
||||
"ActiveUserModal": {
|
||||
"title": "你是哪位?",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"createFirst": "新增第一筆消費紀錄",
|
||||
"noExpenses": "你的群組內目前沒有任何消費紀錄。",
|
||||
"exportJson": "匯出為 JSON",
|
||||
"exportCsv": "匯出為 CSV",
|
||||
"searchPlaceholder": "搜尋消費紀錄……",
|
||||
"ActiveUserModal": {
|
||||
"title": "你是誰?",
|
||||
|
||||
23
package-lock.json
generated
23
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
142
src/app/groups/[groupId]/expenses/export/csv/route.ts
Normal file
142
src/app/groups/[groupId]/expenses/export/csv/route.ts
Normal 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),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
53
src/app/groups/[groupId]/export-button.tsx
Normal file
53
src/app/groups/[groupId]/export-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user