diff --git a/app/(app)/export/transactions/route.ts b/app/(app)/export/transactions/route.ts
index 7899daa..07106fd 100644
--- a/app/(app)/export/transactions/route.ts
+++ b/app/(app)/export/transactions/route.ts
@@ -3,6 +3,7 @@ import { fileExists, fullPathForFile } from "@/lib/files"
import { EXPORT_AND_IMPORT_FIELD_MAP, ExportFields, ExportFilters } from "@/models/export_and_import"
import { getFields } from "@/models/fields"
import { getFilesByTransactionId } from "@/models/files"
+import { incrementProgress, updateProgress } from "@/models/progress"
import { getTransactions } from "@/models/transactions"
import { format } from "@fast-csv/format"
import { formatDate } from "date-fns"
@@ -14,12 +15,14 @@ import { Readable } from "stream"
const TRANSACTIONS_CHUNK_SIZE = 300
const FILES_CHUNK_SIZE = 50
+const PROGRESS_UPDATE_INTERVAL = 10 // files
export async function GET(request: Request) {
const url = new URL(request.url)
const filters = Object.fromEntries(url.searchParams.entries()) as ExportFilters
const fields = (url.searchParams.get("fields")?.split(",") ?? []) as ExportFields
const includeAttachments = url.searchParams.get("includeAttachments") === "true"
+ const progressId = url.searchParams.get("progressId")
const user = await getCurrentUser()
const { transactions } = await getTransactions(user.id, filters)
@@ -102,6 +105,11 @@ export async function GET(request: Request) {
totalFilesToProcess += transactionFiles.length
}
+ // Update progress with total files if progressId is provided
+ if (progressId) {
+ await updateProgress(user.id, progressId, { total: totalFilesToProcess })
+ }
+
console.log(`Starting to process ${totalFilesToProcess} files in total`)
for (let i = 0; i < transactions.length; i += FILES_CHUNK_SIZE) {
@@ -136,6 +144,11 @@ export async function GET(request: Request) {
}${fileExtension}`,
fileData
)
+
+ // Update progress every PROGRESS_UPDATE_INTERVAL files
+ if (progressId && totalFilesProcessed % PROGRESS_UPDATE_INTERVAL === 0) {
+ await incrementProgress(user.id, progressId)
+ }
} else {
console.log(`Skipping missing file: ${file.filename} for transaction ${transaction.id}`)
}
@@ -143,6 +156,11 @@ export async function GET(request: Request) {
}
}
+ // Final progress update
+ if (progressId) {
+ await updateProgress(user.id, progressId, { current: totalFilesToProcess })
+ }
+
console.log(`Finished processing all ${totalFilesProcessed} files`)
// Generate zip with progress tracking
diff --git a/app/(app)/settings/backups/data/route.ts b/app/(app)/settings/backups/data/route.ts
index e1bbfd3..e055bf0 100644
--- a/app/(app)/settings/backups/data/route.ts
+++ b/app/(app)/settings/backups/data/route.ts
@@ -1,6 +1,7 @@
import { getCurrentUser } from "@/lib/auth"
import { fileExists, getUserUploadsDirectory } from "@/lib/files"
import { MODEL_BACKUP, modelToJSON } from "@/models/backups"
+import { incrementProgress, updateProgress } from "@/models/progress"
import fs from "fs/promises"
import JSZip from "jszip"
import { NextResponse } from "next/server"
@@ -8,10 +9,13 @@ import path from "path"
const MAX_FILE_SIZE = 64 * 1024 * 1024 // 64MB
const BACKUP_VERSION = "1.0"
+const PROGRESS_UPDATE_INTERVAL = 10 // files
-export async function GET() {
+export async function GET(request: Request) {
const user = await getCurrentUser()
const userUploadsDirectory = getUserUploadsDirectory(user)
+ const url = new URL(request.url)
+ const progressId = url.searchParams.get("progressId")
try {
const zip = new JSZip()
@@ -52,6 +56,13 @@ export async function GET() {
}
const uploadedFiles = await getAllFilePaths(userUploadsDirectory)
+
+ // Update progress with total files if progressId is provided
+ if (progressId) {
+ await updateProgress(user.id, progressId, { total: uploadedFiles.length })
+ }
+
+ let processedFiles = 0
for (const file of uploadedFiles) {
try {
// Check file size before reading
@@ -62,22 +73,33 @@ export async function GET() {
MAX_FILE_SIZE / 1024 / 1024
}MB limit)`
)
- return
+ continue
}
const fileContent = await fs.readFile(file)
uploadsFolder.file(file.replace(userUploadsDirectory, ""), fileContent)
+
+ processedFiles++
+ // Update progress every PROGRESS_UPDATE_INTERVAL files
+ if (progressId && processedFiles % PROGRESS_UPDATE_INTERVAL === 0) {
+ await incrementProgress(user.id, progressId)
+ }
} catch (error) {
console.error(`Error reading file ${file}:`, error)
}
}
+ // Final progress update
+ if (progressId) {
+ await updateProgress(user.id, progressId, { current: uploadedFiles.length })
+ }
+
const archive = await zip.generateAsync({ type: "blob" })
return new NextResponse(archive, {
headers: {
"Content-Type": "application/octet-stream",
- "Content-Disposition": `attachment; filename="data.zip"`,
+ "Content-Disposition": `attachment; filename="taxhacker-backup.zip"`,
},
})
} catch (error) {
diff --git a/app/(app)/settings/backups/page.tsx b/app/(app)/settings/backups/page.tsx
index 63b73e3..b5b26de 100644
--- a/app/(app)/settings/backups/page.tsx
+++ b/app/(app)/settings/backups/page.tsx
@@ -3,24 +3,57 @@
import { FormError } from "@/components/forms/error"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
+import { useDownload } from "@/hooks/use-download"
+import { useProgress } from "@/hooks/use-progress"
import { Download, Loader2 } from "lucide-react"
-import Link from "next/link"
import { useActionState } from "react"
import { restoreBackupAction } from "./actions"
export default function BackupSettingsPage() {
const [restoreState, restoreBackup, restorePending] = useActionState(restoreBackupAction, null)
+ const { isLoading, startProgress, progress } = useProgress({
+ onError: (error) => {
+ console.error("Backup progress error:", error)
+ },
+ })
+
+ const { download, isDownloading } = useDownload({
+ onError: (error) => {
+ console.error("Download error:", error)
+ },
+ })
+
+ const handleDownload = async () => {
+ try {
+ const progressId = await startProgress("backup")
+ const downloadUrl = `/settings/backups/data?progressId=${progressId || ""}`
+ await download(downloadUrl, "taxhacker-backup.zip")
+ } catch (error) {
+ console.error("Failed to start backup:", error)
+ }
+ }
+
return (
Download backup
-
-
-
+
Inside the archive you will find all the uploaded files, as well as JSON files for transactions, categories,
diff --git a/app/api/progress/[progressId]/route.ts b/app/api/progress/[progressId]/route.ts
new file mode 100644
index 0000000..7dc248d
--- /dev/null
+++ b/app/api/progress/[progressId]/route.ts
@@ -0,0 +1,64 @@
+import { getSession } from "@/lib/auth"
+import { getOrCreateProgress, getProgressById } from "@/models/progress"
+import { NextRequest, NextResponse } from "next/server"
+
+const POLL_INTERVAL_MS = 2000 // 2 seconds
+
+export async function GET(req: NextRequest, { params }: { params: Promise<{ progressId: string }> }) {
+ const session = await getSession()
+ if (!session || !session.user) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
+ }
+
+ const userId = session.user.id
+ const { progressId } = await params
+ const url = new URL(req.url)
+ const type = url.searchParams.get("type") || "unknown"
+
+ await getOrCreateProgress(userId, progressId, type)
+
+ const encoder = new TextEncoder()
+ const stream = new ReadableStream({
+ async start(controller) {
+ let lastSent: any = null
+ let stopped = false
+
+ req.signal.addEventListener("abort", () => {
+ stopped = true
+ controller.close()
+ })
+
+ while (!stopped) {
+ const progress = await getProgressById(userId, progressId)
+ if (!progress) {
+ controller.enqueue(encoder.encode(`event: error\ndata: {"error":"Not found"}\n\n`))
+ controller.close()
+ break
+ }
+
+ // Only send if progress has changed
+ if (JSON.stringify(progress) !== JSON.stringify(lastSent)) {
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(progress)}\n\n`))
+ lastSent = progress
+
+ // If progress is complete, close the connection
+ if (progress.current === progress.total && progress.total > 0) {
+ controller.close()
+ break
+ }
+ }
+
+ await new Promise((res) => setTimeout(res, POLL_INTERVAL_MS))
+ }
+ },
+ })
+
+ return new Response(stream, {
+ headers: {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache, no-transform",
+ Connection: "keep-alive",
+ "Access-Control-Allow-Origin": "*",
+ },
+ })
+}
diff --git a/components/export/transactions.tsx b/components/export/transactions.tsx
index 2ce78ea..b2b9059 100644
--- a/components/export/transactions.tsx
+++ b/components/export/transactions.tsx
@@ -14,6 +14,8 @@ import {
DialogTrigger,
} from "@/components/ui/dialog"
import { Separator } from "@/components/ui/separator"
+import { useDownload } from "@/hooks/use-download"
+import { useProgress } from "@/hooks/use-progress"
import { useTransactionFilters } from "@/hooks/use-transaction-filters"
import { Category, Field, Project } from "@/prisma/client"
import { formatDate } from "date-fns"
@@ -34,17 +36,28 @@ export function ExportTransactionsDialog({
total: number
children: React.ReactNode
}) {
- const [isLoading, setIsLoading] = useState(false)
const [exportFilters, setExportFilters] = useTransactionFilters()
const [exportFields, setExportFields] = useState(
fields.map((field) => (deselectedFields.includes(field.code) ? "" : field.code))
)
const [includeAttachments, setIncludeAttachments] = useState(true)
+ const { isLoading, startProgress, progress } = useProgress({
+ onError: (error) => {
+ console.error("Export progress error:", error)
+ },
+ })
- const handleSubmit = () => {
- setIsLoading(true)
- const exportWindow = window.open(
- `/export/transactions?${new URLSearchParams({
+ const { download, isDownloading } = useDownload({
+ onError: (error) => {
+ console.error("Download error:", error)
+ },
+ })
+
+ const handleSubmit = async () => {
+ try {
+ const progressId = await startProgress("transactions-export")
+
+ const exportUrl = `/export/transactions?${new URLSearchParams({
search: exportFilters?.search || "",
dateFrom: exportFilters?.dateFrom || "",
dateTo: exportFilters?.dateTo || "",
@@ -53,22 +66,12 @@ export function ExportTransactionsDialog({
projectCode: exportFilters?.projectCode || "",
fields: exportFields.join(","),
includeAttachments: includeAttachments.toString(),
+ progressId: progressId || "",
}).toString()}`
- )
-
- // Check if window was opened successfully
- if (!exportWindow) {
- setIsLoading(false)
- return
+ await download(exportUrl, "transactions.zip")
+ } catch (error) {
+ console.error("Failed to start export:", error)
}
-
- // Monitor the export window
- const checkWindow = setInterval(() => {
- if (exportWindow.closed) {
- clearInterval(checkWindow)
- setIsLoading(false)
- }
- }, 1000)
}
return (
@@ -171,8 +174,14 @@ export function ExportTransactionsDialog({
-
diff --git a/hooks/use-download.tsx b/hooks/use-download.tsx
new file mode 100644
index 0000000..c83ed38
--- /dev/null
+++ b/hooks/use-download.tsx
@@ -0,0 +1,48 @@
+import { useState } from "react"
+
+interface UseDownloadOptions {
+ onSuccess?: () => void
+ onError?: (error: Error) => void
+}
+
+export function useDownload(options: UseDownloadOptions = {}) {
+ const [isDownloading, setIsDownloading] = useState(false)
+
+ const download = async (url: string, defaultName: string) => {
+ try {
+ setIsDownloading(true)
+
+ const response = await fetch(url)
+ if (!response.ok) throw new Error("Download failed")
+
+ // Get the filename from the Content-Disposition header
+ const contentDisposition = response.headers.get("Content-Disposition")
+ const filename = contentDisposition ? contentDisposition.split("filename=")[1].replace(/"/g, "") : defaultName
+
+ // Create a blob from the response
+ const blob = await response.blob()
+
+ // Create a download link and trigger it
+ const downloadLink = window.URL.createObjectURL(blob)
+ const a = document.createElement("a")
+ a.href = downloadLink
+ a.download = filename
+ document.body.appendChild(a)
+ a.click()
+ window.URL.revokeObjectURL(downloadLink)
+ document.body.removeChild(a)
+
+ options.onSuccess?.()
+ } catch (error) {
+ console.error("Download error:", error)
+ options.onError?.(error instanceof Error ? error : new Error("Download failed"))
+ } finally {
+ setIsDownloading(false)
+ }
+ }
+
+ return {
+ download,
+ isDownloading,
+ }
+}
diff --git a/hooks/use-progress.tsx b/hooks/use-progress.tsx
new file mode 100644
index 0000000..9243708
--- /dev/null
+++ b/hooks/use-progress.tsx
@@ -0,0 +1,85 @@
+import { useEffect, useState } from "react"
+
+interface Progress {
+ id: string
+ current: number
+ total: number
+ type: string
+ data: any
+ createdAt: string
+}
+
+interface UseProgressOptions {
+ onSuccess?: (progress: Progress) => void
+ onError?: (error: Error) => void
+}
+
+export function useProgress(options: UseProgressOptions = {}) {
+ const [isLoading, setIsLoading] = useState(false)
+ const [eventSource, setEventSource] = useState
(null)
+ const [progress, setProgress] = useState