fix: better transactions export UX

This commit is contained in:
Vasily Zubarev
2025-05-19 11:01:50 +02:00
parent ee368180f6
commit b7475ca57d
3 changed files with 130 additions and 74 deletions

View File

@@ -10,6 +10,10 @@ import fs from "fs/promises"
import JSZip from "jszip" import JSZip from "jszip"
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import path from "path" import path from "path"
import { Readable } from "stream"
const TRANSACTIONS_CHUNK_SIZE = 300
const FILES_CHUNK_SIZE = 50
export async function GET(request: Request) { export async function GET(request: Request) {
const url = new URL(request.url) const url = new URL(request.url)
@@ -21,50 +25,45 @@ export async function GET(request: Request) {
const { transactions } = await getTransactions(user.id, filters) const { transactions } = await getTransactions(user.id, filters)
const existingFields = await getFields(user.id) const existingFields = await getFields(user.id)
// Generate CSV file with all transactions
try { try {
const fieldKeys = fields.filter((field) => existingFields.some((f) => f.code === field)) const fieldKeys = fields.filter((field) => existingFields.some((f) => f.code === field))
let csvContent = "" // Create a transform stream for CSV generation
const csvStream = format({ headers: fieldKeys, writeBOM: true, writeHeaders: false }) const csvStream = format({ headers: fieldKeys, writeBOM: true, writeHeaders: false })
csvStream.on("data", (chunk) => {
csvContent += chunk
})
// Custom CSV headers // Custom CSV headers
const headers = fieldKeys.map((field) => existingFields.find((f) => f.code === field)?.name ?? "UNKNOWN") const headers = fieldKeys.map((field) => existingFields.find((f) => f.code === field)?.name ?? "UNKNOWN")
csvStream.write(headers) csvStream.write(headers)
// CSV rows // Process transactions in chunks to avoid memory issues
for (const transaction of transactions) { for (let i = 0; i < transactions.length; i += TRANSACTIONS_CHUNK_SIZE) {
const row: Record<string, unknown> = {} const chunk = transactions.slice(i, i + TRANSACTIONS_CHUNK_SIZE)
for (const field of existingFields) {
let value
if (field.isExtra) {
value = transaction.extra?.[field.code as keyof typeof transaction.extra] ?? ""
} else {
value = transaction[field.code as keyof typeof transaction] ?? ""
}
// Check if the field has a special export rules for (const transaction of chunk) {
const exportFieldSettings = EXPORT_AND_IMPORT_FIELD_MAP[field.code] const row: Record<string, unknown> = {}
if (exportFieldSettings && exportFieldSettings.export) { for (const field of existingFields) {
row[field.code] = await exportFieldSettings.export(user.id, value) let value
} else { if (field.isExtra) {
row[field.code] = value value = transaction.extra?.[field.code as keyof typeof transaction.extra] ?? ""
} else {
value = transaction[field.code as keyof typeof transaction] ?? ""
}
const exportFieldSettings = EXPORT_AND_IMPORT_FIELD_MAP[field.code]
if (exportFieldSettings && exportFieldSettings.export) {
row[field.code] = await exportFieldSettings.export(user.id, value)
} else {
row[field.code] = value
}
} }
csvStream.write(row)
} }
csvStream.write(row)
} }
csvStream.end() csvStream.end()
// Wait for CSV generation to complete
await new Promise((resolve) => csvStream.on("end", resolve))
if (!includeAttachments) { if (!includeAttachments) {
return new NextResponse(csvContent, { const stream = Readable.from(csvStream)
return new NextResponse(stream as any, {
headers: { headers: {
"Content-Type": "text/csv", "Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="transactions.csv"`, "Content-Disposition": `attachment; filename="transactions.csv"`,
@@ -72,46 +71,64 @@ export async function GET(request: Request) {
}) })
} }
// If includeAttachments is true, create a ZIP file with the CSV and attachments // For ZIP files, we'll use a more memory-efficient approach
const zip = new JSZip() const zip = new JSZip()
// Add CSV to zip
const csvContent = await new Promise<string>((resolve) => {
let content = ""
csvStream.on("data", (chunk) => {
content += chunk
})
csvStream.on("end", () => resolve(content))
})
zip.file("transactions.csv", csvContent) zip.file("transactions.csv", csvContent)
// Process files in chunks
const filesFolder = zip.folder("files") const filesFolder = zip.folder("files")
if (!filesFolder) { if (!filesFolder) {
console.error("Failed to create zip folder") throw new Error("Failed to create zip folder")
return new NextResponse("Internal Server Error", { status: 500 })
} }
for (const transaction of transactions) { for (let i = 0; i < transactions.length; i += FILES_CHUNK_SIZE) {
const transactionFiles = await getFilesByTransactionId(transaction.id, user.id) const chunk = transactions.slice(i, i + FILES_CHUNK_SIZE)
const transactionFolder = filesFolder.folder( for (const transaction of chunk) {
path.join( const transactionFiles = await getFilesByTransactionId(transaction.id, user.id)
transaction.issuedAt ? formatDate(transaction.issuedAt, "yyyy/MM") : "",
transactionFiles.length > 1 ? transaction.name || transaction.id : ""
)
)
if (!transactionFolder) {
console.error(`Failed to create transaction folder for ${transaction.name}`)
continue
}
for (const file of transactionFiles) { const transactionFolder = filesFolder.folder(
const fullFilePath = fullPathForFile(user, file) path.join(
if (await fileExists(fullFilePath)) { transaction.issuedAt ? formatDate(transaction.issuedAt, "yyyy/MM") : "",
const fileData = await fs.readFile(fullFilePath) transactionFiles.length > 1 ? transaction.name || transaction.id : ""
const fileExtension = path.extname(fullFilePath)
transactionFolder.file(
`${formatDate(transaction.issuedAt || new Date(), "yyyy-MM-dd")} - ${
transaction.name || transaction.id
}${fileExtension}`,
fileData
) )
)
if (!transactionFolder) continue
for (const file of transactionFiles) {
const fullFilePath = fullPathForFile(user, file)
if (await fileExists(fullFilePath)) {
const fileData = await fs.readFile(fullFilePath)
const fileExtension = path.extname(fullFilePath)
transactionFolder.file(
`${formatDate(transaction.issuedAt || new Date(), "yyyy-MM-dd")} - ${
transaction.name || transaction.id
}${fileExtension}`,
fileData
)
}
} }
} }
} }
const zipContent = await zip.generateAsync({ type: "uint8array" }) // Generate zip with progress tracking
const zipContent = await zip.generateAsync({
type: "uint8array",
compression: "DEFLATE",
compressionOptions: {
level: 6,
},
})
return new NextResponse(zipContent, { return new NextResponse(zipContent, {
headers: { headers: {

View File

@@ -46,7 +46,7 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
<span className="text-3xl tracking-tight opacity-20">{total}</span> <span className="text-3xl tracking-tight opacity-20">{total}</span>
</h2> </h2>
<div className="flex gap-2"> <div className="flex gap-2">
<ExportTransactionsDialog fields={fields} categories={categories} projects={projects}> <ExportTransactionsDialog fields={fields} categories={categories} projects={projects} total={total}>
<Button variant="outline"> <Button variant="outline">
<Download /> <Download />
<span className="hidden md:block">Export</span> <span className="hidden md:block">Export</span>

View File

@@ -17,7 +17,7 @@ import { Separator } from "@/components/ui/separator"
import { useTransactionFilters } from "@/hooks/use-transaction-filters" import { useTransactionFilters } from "@/hooks/use-transaction-filters"
import { Category, Field, Project } from "@/prisma/client" import { Category, Field, Project } from "@/prisma/client"
import { formatDate } from "date-fns" import { formatDate } from "date-fns"
import { useRouter } from "next/navigation" import { Download, Loader2 } from "lucide-react"
import { useState } from "react" import { useState } from "react"
const deselectedFields = ["files", "text"] const deselectedFields = ["files", "text"]
@@ -26,14 +26,15 @@ export function ExportTransactionsDialog({
fields, fields,
categories, categories,
projects, projects,
total,
children, children,
}: { }: {
fields: Field[] fields: Field[]
categories: Category[] categories: Category[]
projects: Project[] projects: Project[]
total: number
children: React.ReactNode children: React.ReactNode
}) { }) {
const router = useRouter()
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [exportFilters, setExportFilters] = useTransactionFilters() const [exportFilters, setExportFilters] = useTransactionFilters()
const [exportFields, setExportFields] = useState<string[]>( const [exportFields, setExportFields] = useState<string[]>(
@@ -41,23 +42,49 @@ export function ExportTransactionsDialog({
) )
const [includeAttachments, setIncludeAttachments] = useState(true) const [includeAttachments, setIncludeAttachments] = useState(true)
const handleSubmit = () => { const handleSubmit = async () => {
setIsLoading(true) setIsLoading(true)
router.push(
`/export/transactions?${new URLSearchParams({ try {
search: exportFilters?.search || "", const response = await fetch(
dateFrom: exportFilters?.dateFrom || "", `/export/transactions?${new URLSearchParams({
dateTo: exportFilters?.dateTo || "", search: exportFilters?.search || "",
ordering: exportFilters?.ordering || "", dateFrom: exportFilters?.dateFrom || "",
categoryCode: exportFilters?.categoryCode || "", dateTo: exportFilters?.dateTo || "",
projectCode: exportFilters?.projectCode || "", ordering: exportFilters?.ordering || "",
fields: exportFields.join(","), categoryCode: exportFilters?.categoryCode || "",
includeAttachments: includeAttachments.toString(), projectCode: exportFilters?.projectCode || "",
}).toString()}` fields: exportFields.join(","),
) includeAttachments: includeAttachments.toString(),
setTimeout(() => { }).toString()}`
)
if (!response.ok) {
throw new Error("Export failed")
}
// Get the filename from the Content-Disposition header
const contentDisposition = response.headers.get("Content-Disposition")
const filename = contentDisposition?.split("filename=")[1]?.replace(/"/g, "") || "transactions.zip"
// Create a blob from the response
const blob = await response.blob()
// Create a download link and trigger it
const url = window.URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (error) {
console.error("Export failed:", error)
// You might want to show an error message to the user here
} finally {
setIsLoading(false) setIsLoading(false)
}, 3000) }
} }
return ( return (
@@ -65,7 +92,9 @@ export function ExportTransactionsDialog({
<DialogTrigger asChild>{children}</DialogTrigger> <DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-w-xl"> <DialogContent className="max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-2xl font-bold">Export Transactions</DialogTitle> <DialogTitle className="text-2xl font-bold">
Export {total} Transaction{total !== 1 ? "s" : ""}
</DialogTitle>
<DialogDescription>Export selected transactions and files as a CSV file or a ZIP archive</DialogDescription> <DialogDescription>Export selected transactions and files as a CSV file or a ZIP archive</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -161,7 +190,17 @@ export function ExportTransactionsDialog({
</div> </div>
<DialogFooter className="sm:justify-end"> <DialogFooter className="sm:justify-end">
<Button type="button" onClick={handleSubmit} disabled={isLoading}> <Button type="button" onClick={handleSubmit} disabled={isLoading}>
{isLoading ? "Exporting..." : "Export Transactions"} {isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Exporting...</span>
</div>
) : (
<div className="flex items-center gap-2">
<Download className="h-4 w-4" />
<span>Export Transactions</span>
</div>
)}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>