Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions app/export/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Metadata } from 'next'

import { NAV_TITLE } from '@/config/constants/navigation'

import {
getCachedAllTransactions,
getCachedAuthSession,
getTransactionsForExport,
} from '../lib/actions'
import type { TTransaction } from '../lib/types'
import ExportTransactions from '../ui/home/export-transactions'
import NoTransactionsPlug from '../ui/no-transactions-plug'
import WithSidebar from '../ui/sidebar/with-sidebar'

export const metadata: Metadata = {
title: NAV_TITLE.EXPORT,
}

export default async function Page() {
getCachedAuthSession()
const session = await getCachedAuthSession()
const userId = session?.user?.email

getCachedAllTransactions(userId)
const transactions = await getCachedAllTransactions(userId)

const handleExport = async (
startDate?: Date,
endDate?: Date,
): Promise<TTransaction[]> => {
'use server'
return await getTransactionsForExport(userId, startDate, endDate)
}

const content = (
<>
<h1 className='mb-8 text-center text-2xl font-semibold'>
{NAV_TITLE.EXPORT}
</h1>
{transactions.length === 0 ? (
<div className='mx-auto max-w-3xl'>
<NoTransactionsPlug />
</div>
) : (
<div className='mx-auto max-w-2xl'>
<ExportTransactions
transactions={transactions}
onExport={handleExport}
/>
</div>
)}
</>
)

return <WithSidebar contentNearby={content} />
}
37 changes: 37 additions & 0 deletions app/lib/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -969,3 +969,40 @@ export async function getAnalyzedReceiptAI(file: Blob): Promise<string> {
throw err
}
}

export async function getTransactionsForExport(
userId: TUserId,
startDate?: Date,
endDate?: Date,
): Promise<TTransaction[]> {
if (!userId) {
throw new Error('User ID is required to export transactions.')
}
try {
await dbConnect()
const query: Record<string, unknown> = { userId }

if (startDate && endDate) {
query.createdAt = {
$gte: startDate,
$lte: endDate,
}
}

const transactions = await TransactionModel.find(query)
.sort({ createdAt: -1 })
.lean<TRawTransaction[]>({
transform: (doc: TRawTransaction) => {
doc.id = formatObjectIdToString(doc?._id)
delete doc?._id
delete doc?.__v

return doc
},
})

return transactions as unknown as TTransaction[]
} catch (err) {
throw err
}
}
88 changes: 88 additions & 0 deletions app/lib/export-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { format } from 'date-fns'

import type { TTransaction } from './types'

export type ExportFormat = 'csv' | 'json'

export const formatTransactionForExport = (transaction: TTransaction) => {
return {
date: format(new Date(transaction.createdAt), 'yyyy-MM-dd HH:mm:ss'),
category: transaction.category,
description: transaction.description,
amount: transaction.amount,
type: transaction.isIncome ? 'income' : 'expense',
balance: transaction.balance,
currency: transaction.currency.code,
isSubscription: transaction.isSubscription,
isEdited: transaction.isEdited,
}
}

export const generateCSV = (transactions: TTransaction[]): string => {
if (transactions.length === 0) {
return 'No transactions to export'
}

const headers = [
'Date',
'Category',
'Description',
'Amount',
'Type',
'Balance',
'Currency',
'Subscription',
'Edited',
]

const rows = transactions.map((transaction) => {
const formatted = formatTransactionForExport(transaction)
return [
formatted.date,
formatted.category,
`"${formatted.description.replace(/"/g, '""')}"`,
formatted.amount,
formatted.type,
formatted.balance,
formatted.currency,
formatted.isSubscription ? 'Yes' : 'No',
formatted.isEdited ? 'Yes' : 'No',
].join(',')
})

return [headers.join(','), ...rows].join('\n')
}

export const generateJSON = (transactions: TTransaction[]): string => {
const formattedTransactions = transactions.map(formatTransactionForExport)
return JSON.stringify(formattedTransactions, null, 2)
}

export const downloadFile = (
content: string,
filename: string,
mimeType: string,
) => {
const blob = new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}

export const getExportFilename = (format: ExportFormat): string => {
const timestamp = format(new Date(), 'yyyy-MM-dd_HH-mm-ss')
return `explend_transactions_${timestamp}.${format}`
}

export const getMimeType = (format: ExportFormat): string => {
const mimeTypes: Record<ExportFormat, string> = {
csv: 'text/csv;charset=utf-8;',
json: 'application/json;charset=utf-8;',
}
return mimeTypes[format]
}
156 changes: 156 additions & 0 deletions app/ui/home/export-transactions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
'use client'

import { useState } from 'react'

import toast from 'react-hot-toast'
import { PiDownloadSimpleFill } from 'react-icons/pi'

import {
Button,
Card,
CardBody,
CardHeader,
DateRangePicker,
Select,
SelectItem,
} from '@heroui/react'
import { parseDate } from '@internationalized/date'
import { format, subMonths } from 'date-fns'

import { DEFAULT_ICON_SIZE } from '@/config/constants/main'

import {
type ExportFormat,
downloadFile,
generateCSV,
generateJSON,
getExportFilename,
getMimeType,
} from '@/app/lib/export-utils'
import type { TTransaction } from '@/app/lib/types'

type ExportTransactionsProps = {
transactions: TTransaction[]
onExport: (startDate?: Date, endDate?: Date) => Promise<TTransaction[]>
}

export default function ExportTransactions({
transactions,
onExport,
}: ExportTransactionsProps) {
const [format, setFormat] = useState<ExportFormat>('csv')
const [isLoading, setIsLoading] = useState(false)
const [dateRange, setDateRange] = useState<{
start: string
end: string
} | null>(null)

const defaultStart = format(subMonths(new Date(), 1), 'yyyy-MM-dd')
const defaultEnd = format(new Date(), 'yyyy-MM-dd')

const handleExport = async () => {
try {
setIsLoading(true)

let transactionsToExport = transactions

if (dateRange) {
const startDate = new Date(dateRange.start)
const endDate = new Date(dateRange.end)
endDate.setHours(23, 59, 59, 999)

transactionsToExport = await onExport(startDate, endDate)
}

if (transactionsToExport.length === 0) {
toast.error('No transactions to export')
return
}

let content: string
if (format === 'csv') {
content = generateCSV(transactionsToExport)
} else {
content = generateJSON(transactionsToExport)
}

const filename = getExportFilename(format)
const mimeType = getMimeType(format)

downloadFile(content, filename, mimeType)

toast.success(
`Exported ${transactionsToExport.length} transaction${transactionsToExport.length !== 1 ? 's' : ''}`,
)
} catch (error) {
toast.error('Failed to export transactions')
console.error('Export error:', error)
} finally {
setIsLoading(false)
}
}

return (
<Card>
<CardHeader>
<h2 className='text-lg font-semibold'>Export Transactions</h2>
</CardHeader>
<CardBody className='gap-4'>
<Select
label='Export Format'
selectedKeys={[format]}
onChange={(e) => setFormat(e.target.value as ExportFormat)}
className='max-w-xs'
>
<SelectItem key='csv' value='csv'>
CSV (Spreadsheet)
</SelectItem>
<SelectItem key='json' value='json'>
JSON (Data)
</SelectItem>
</Select>

<DateRangePicker
label='Date Range (Optional)'
className='max-w-xs'
defaultValue={
dateRange
? {
start: parseDate(dateRange.start),
end: parseDate(dateRange.end),
}
: undefined
}
onChange={(value) => {
if (value) {
setDateRange({
start: value.start.toString(),
end: value.end.toString(),
})
} else {
setDateRange(null)
}
}}
/>

<Button
color='primary'
startContent={
isLoading ? null : <PiDownloadSimpleFill size={DEFAULT_ICON_SIZE} />
}
onPress={handleExport}
isLoading={isLoading}
className='max-w-xs'
>
{isLoading ? 'Exporting...' : 'Export'}
</Button>

<p className='text-sm text-default-500'>
{dateRange
? `Exporting transactions from ${dateRange.start} to ${dateRange.end}`
: `Ready to export all ${transactions.length} transaction${transactions.length !== 1 ? 's' : ''}`}
</p>
</CardBody>
</Card>
)
}
8 changes: 8 additions & 0 deletions app/ui/sidebar/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
PiBugBeetleFill,
PiChatText,
PiChatTextFill,
PiDownloadSimple,
PiDownloadSimpleFill,
PiEscalatorUp,
PiEscalatorUpFill,
PiGearSix,
Expand Down Expand Up @@ -70,6 +72,12 @@ const topNavLinks: TNavLink[] = [
icon: <PiStack size={NAV_ICON_SIZE} />,
hoverIcon: <PiStackFill size={NAV_ICON_SIZE} />,
},
{
title: NAV_TITLE.EXPORT,
url: ROUTE.EXPORT,
icon: <PiDownloadSimple size={NAV_ICON_SIZE} />,
hoverIcon: <PiDownloadSimpleFill size={NAV_ICON_SIZE} />,
},
{
title: NAV_TITLE.SETTINGS,
url: ROUTE.SETTINGS,
Expand Down
1 change: 1 addition & 0 deletions config/constants/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const enum NAV_TITLE {
LIMITS = 'Limits',
SUBSCRIPTIONS = 'Subscriptions',
CATEGORIES = 'Categories',
EXPORT = 'Export',
SETTINGS = 'Settings',
FEEDBACK = 'Give Feedback',
ISSUE = 'Report Issue',
Expand Down
1 change: 1 addition & 0 deletions config/constants/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const enum ROUTE {
LIMITS = '/limits',
SUBSCRIPTIONS = '/subscriptions',
CATEGORIES = '/categories',
EXPORT = '/export',
SETTINGS = '/settings',
FEEDBACK = '/feedback',
ISSUE = '/issue',
Expand Down
Loading