diff --git a/app/export/page.tsx b/app/export/page.tsx new file mode 100644 index 00000000..0ef32b97 --- /dev/null +++ b/app/export/page.tsx @@ -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 => { + 'use server' + return await getTransactionsForExport(userId, startDate, endDate) + } + + const content = ( + <> +

+ {NAV_TITLE.EXPORT} +

+ {transactions.length === 0 ? ( +
+ +
+ ) : ( +
+ +
+ )} + + ) + + return +} diff --git a/app/lib/actions.ts b/app/lib/actions.ts index a7456927..35bc7ac5 100644 --- a/app/lib/actions.ts +++ b/app/lib/actions.ts @@ -969,3 +969,40 @@ export async function getAnalyzedReceiptAI(file: Blob): Promise { throw err } } + +export async function getTransactionsForExport( + userId: TUserId, + startDate?: Date, + endDate?: Date, +): Promise { + if (!userId) { + throw new Error('User ID is required to export transactions.') + } + try { + await dbConnect() + const query: Record = { userId } + + if (startDate && endDate) { + query.createdAt = { + $gte: startDate, + $lte: endDate, + } + } + + const transactions = await TransactionModel.find(query) + .sort({ createdAt: -1 }) + .lean({ + 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 + } +} diff --git a/app/lib/export-utils.ts b/app/lib/export-utils.ts new file mode 100644 index 00000000..43215d7d --- /dev/null +++ b/app/lib/export-utils.ts @@ -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 = { + csv: 'text/csv;charset=utf-8;', + json: 'application/json;charset=utf-8;', + } + return mimeTypes[format] +} diff --git a/app/ui/home/export-transactions.tsx b/app/ui/home/export-transactions.tsx new file mode 100644 index 00000000..ba5f063c --- /dev/null +++ b/app/ui/home/export-transactions.tsx @@ -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 +} + +export default function ExportTransactions({ + transactions, + onExport, +}: ExportTransactionsProps) { + const [format, setFormat] = useState('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 ( + + +

Export Transactions

+
+ + + + { + if (value) { + setDateRange({ + start: value.start.toString(), + end: value.end.toString(), + }) + } else { + setDateRange(null) + } + }} + /> + + + +

+ {dateRange + ? `Exporting transactions from ${dateRange.start} to ${dateRange.end}` + : `Ready to export all ${transactions.length} transaction${transactions.length !== 1 ? 's' : ''}`} +

+
+
+ ) +} diff --git a/app/ui/sidebar/navbar.tsx b/app/ui/sidebar/navbar.tsx index d91f77f5..e257d7ed 100644 --- a/app/ui/sidebar/navbar.tsx +++ b/app/ui/sidebar/navbar.tsx @@ -5,6 +5,8 @@ import { PiBugBeetleFill, PiChatText, PiChatTextFill, + PiDownloadSimple, + PiDownloadSimpleFill, PiEscalatorUp, PiEscalatorUpFill, PiGearSix, @@ -70,6 +72,12 @@ const topNavLinks: TNavLink[] = [ icon: , hoverIcon: , }, + { + title: NAV_TITLE.EXPORT, + url: ROUTE.EXPORT, + icon: , + hoverIcon: , + }, { title: NAV_TITLE.SETTINGS, url: ROUTE.SETTINGS, diff --git a/config/constants/navigation.ts b/config/constants/navigation.ts index cdb5f3f3..d46a709e 100644 --- a/config/constants/navigation.ts +++ b/config/constants/navigation.ts @@ -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', diff --git a/config/constants/routes.ts b/config/constants/routes.ts index 93a441b0..84a48917 100644 --- a/config/constants/routes.ts +++ b/config/constants/routes.ts @@ -6,6 +6,7 @@ export const enum ROUTE { LIMITS = '/limits', SUBSCRIPTIONS = '/subscriptions', CATEGORIES = '/categories', + EXPORT = '/export', SETTINGS = '/settings', FEEDBACK = '/feedback', ISSUE = '/issue',