mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 13:25:18 +00:00
fix: better transactions export UX
This commit is contained in:
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user