(squash) init

feat: filters, settings, backups

fix: ts compile errors

feat: new dashboard, webp previews and settings

feat: use webp for pdfs

feat: use webp

fix: analyze resets old data

fix: switch to corsproxy

fix: switch to free cors

fix: max upload limit

fix: currency conversion

feat: transaction export

fix: currency conversion

feat: refactor settings actions

feat: new loader

feat: README + LICENSE

doc: update readme

doc: update readme

doc: update readme

doc: update screenshots

ci: bump prisma
This commit is contained in:
Vasily Zubarev
2025-03-13 00:30:47 +01:00
commit 0b98a2c307
153 changed files with 17271 additions and 0 deletions

139
app/ai/analyze.ts Normal file
View File

@@ -0,0 +1,139 @@
import { Category, Field, File, Project } from "@prisma/client"
import OpenAI from "openai"
import { ChatCompletion } from "openai/resources/index.mjs"
import { buildLLMPrompt } from "./prompt"
const MAX_PAGES_TO_ANALYZE = 3
type AnalyzeAttachment = {
contentType: string
base64: string
}
export const retrieveAllAttachmentsForAI = async (file: File): Promise<AnalyzeAttachment[]> => {
const attachments: AnalyzeAttachment[] = []
for (let i = 1; i < MAX_PAGES_TO_ANALYZE; i++) {
try {
const attachment = await retrieveFileContentForAI(file, i)
attachments.push(attachment)
} catch (error) {
break
}
}
return attachments
}
export const retrieveFileContentForAI = async (file: File, page: number): Promise<AnalyzeAttachment> => {
const response = await fetch(`/files/preview/${file.id}?page=${page}`)
if (!response.ok) throw new Error("Failed to retrieve file")
const blob = await response.blob()
const buffer = await blob.arrayBuffer()
const base64 = Buffer.from(buffer).toString("base64")
return { contentType: response.headers.get("Content-Type") || file.mimetype, base64: base64 }
}
export async function analyzeTransaction(
promptTemplate: string,
settings: Record<string, string>,
fields: Field[],
categories: Category[] = [],
projects: Project[] = [],
attachments: AnalyzeAttachment[] = []
): Promise<{ success: boolean; data?: Record<string, any>; error?: string }> {
const openai = new OpenAI({
apiKey: settings.openai_api_key,
dangerouslyAllowBrowser: true,
})
const prompt = buildLLMPrompt(promptTemplate, fields, categories, projects)
console.log("PROMPT:", prompt)
try {
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{
role: "user",
content: [
{ type: "text", text: prompt || "" },
...attachments.slice(0, MAX_PAGES_TO_ANALYZE).map((attachment) => ({
type: "image_url" as const,
image_url: {
url: `data:${attachment.contentType};base64,${attachment.base64}`,
},
})),
],
},
],
})
console.log("ChatGPT response:", response.choices[0].message)
const cleanedJson = extractAndParseJSON(response)
return { success: true, data: cleanedJson }
} catch (error) {
console.error("AI Analysis error:", error)
return {
success: false,
error: error instanceof Error ? error.message : "Failed to analyze invoice",
}
}
}
function extractAndParseJSON(response: ChatCompletion) {
try {
const content = response.choices?.[0]?.message?.content
if (!content) {
throw new Error("No response content from AI")
}
// Check for JSON in code blocks (handles ```json, ``` json, or just ```)
let jsonText = content.trim()
const codeBlockRegex = /```(?:json)?\s*([\s\S]*?)\s*```/
const jsonMatch = content.match(codeBlockRegex)
if (jsonMatch && jsonMatch[1]) {
jsonText = jsonMatch[1].trim()
}
// Try to parse the JSON
try {
return JSON.parse(jsonText)
} catch (parseError) {
// JSON might have unescaped characters, try to fix them
const fixedJsonText = escapeJsonString(jsonText)
return JSON.parse(fixedJsonText)
}
} catch (error) {
console.error("Error processing AI response:", error)
throw new Error(`Failed to extract valid JSON: ${error instanceof Error ? error.message : "Unknown error"}`)
}
}
function escapeJsonString(jsonStr: string) {
// This is a black magic to fix some AI-generated JSONs
if (jsonStr.trim().startsWith("{") && jsonStr.trim().endsWith("}")) {
return jsonStr.replace(/"([^"]*?)":(\s*)"(.*?)"/g, (match, key, space, value) => {
const escapedValue = value
.replace(/\\/g, "\\\\") // backslash
.replace(/"/g, '\\"') // double quotes
.replace(/\n/g, "\\n") // newline
.replace(/\r/g, "\\r") // carriage return
.replace(/\t/g, "\\t") // tab
.replace(/\f/g, "\\f") // form feed
.replace(/[\x00-\x1F\x7F-\x9F]/g, (c: string) => {
return "\\u" + ("0000" + c.charCodeAt(0).toString(16)).slice(-4)
})
return `"${key}":${space}"${escapedValue}"`
})
}
return jsonStr
}

49
app/ai/prompt.ts Normal file
View File

@@ -0,0 +1,49 @@
import { Category, Field, Project } from "@prisma/client"
export function buildLLMPrompt(
promptTemplate: string,
fields: Field[],
categories: Category[] = [],
projects: Project[] = []
) {
let prompt = promptTemplate
prompt = prompt.replace(
"{fields}",
fields
.filter((field) => field.llm_prompt)
.map((field) => `- ${field.code}: ${field.llm_prompt}`)
.join("\n")
)
prompt = prompt.replace(
"{categories}",
categories
.filter((category) => category.llm_prompt)
.map((category) => `- ${category.code}: for ${category.llm_prompt}`)
.join("\n")
)
prompt = prompt.replace(
"{projects}",
projects
.filter((project) => project.llm_prompt)
.map((project) => `- ${project.code}: for ${project.llm_prompt}`)
.join("\n")
)
prompt = prompt.replace("{categories.code}", categories.map((category) => `${category.code}`).join(", "))
prompt = prompt.replace("{projects.code}", projects.map((project) => `${project.code}`).join(", "))
prompt = prompt.replace(
"{json_structure}",
"{ " +
fields
.filter((field) => field.llm_prompt)
.map((field) => `${field.code}: ${field.type}`)
.join(", ") +
" }"
)
return prompt
}

32
app/context.tsx Normal file
View File

@@ -0,0 +1,32 @@
"use client"
import { createContext, ReactNode, useContext, useState } from "react"
type Notification = {
code: string
message: string
}
type NotificationContextType = {
notification: Notification | null
showNotification: (notification: Notification) => void
}
const NotificationContext = createContext<NotificationContextType>({
notification: null,
showNotification: () => {},
})
export function NotificationProvider({ children }: { children: ReactNode }) {
const [notification, setNotification] = useState<Notification | null>(null)
const showNotification = (notification: Notification) => {
setNotification(notification)
}
return (
<NotificationContext.Provider value={{ notification, showNotification }}>{children}</NotificationContext.Provider>
)
}
export const useNotification = () => useContext(NotificationContext)

View File

@@ -0,0 +1,104 @@
import { ExportFields, ExportFilters } from "@/data/export"
import { getFields } from "@/data/fields"
import { getFilesByTransactionId } from "@/data/files"
import { getTransactions } from "@/data/transactions"
import { format } from "@fast-csv/format"
import { formatDate } from "date-fns"
import fs from "fs"
import JSZip from "jszip"
import { NextResponse } from "next/server"
import path from "path"
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 transactions = await getTransactions(filters)
const existingFields = await getFields()
try {
const fieldKeys = fields.filter((field) => existingFields.some((f) => f.code === field))
const writeHeaders = fieldKeys.map((field) => existingFields.find((f) => f.code === field)?.name)
// Generate CSV file with all transactions
let csvContent = ""
const csvStream = format({ headers: fieldKeys, writeBOM: true, writeHeaders: false })
csvStream.on("data", (chunk) => {
csvContent += chunk
})
csvStream.write(writeHeaders)
transactions.forEach((transaction) => {
const row = fieldKeys.reduce((acc, key) => {
acc[key] = transaction[key as keyof typeof transaction] ?? ""
return acc
}, {} as Record<string, any>)
csvStream.write(row)
})
csvStream.end()
// Wait for CSV generation to complete
await new Promise((resolve) => csvStream.on("end", resolve))
if (!includeAttachments) {
return new NextResponse(csvContent, {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="transactions.csv"`,
},
})
}
// If includeAttachments is true, create a ZIP file with the CSV and attachments
const zip = new JSZip()
zip.file("transactions.csv", csvContent)
const filesFolder = zip.folder("files")
if (!filesFolder) {
console.error("Failed to create zip folder")
return new NextResponse("Internal Server Error", { status: 500 })
}
for (const transaction of transactions) {
const transactionFiles = await getFilesByTransactionId(transaction.id)
const transactionFolder = filesFolder.folder(
path.join(
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 fileData = fs.readFileSync(file.path)
const fileExtension = path.extname(file.path)
transactionFolder.file(
`${formatDate(transaction.issuedAt || new Date(), "yyyy-MM-dd")} - ${
transaction.name || transaction.id
}${fileExtension}`,
fileData
)
}
}
const zipContent = await zip.generateAsync({ type: "uint8array" })
return new NextResponse(zipContent, {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="transactions.zip"`,
},
})
} catch (error) {
console.error("Error exporting transactions:", error)
return new NextResponse("Internal Server Error", { status: 500 })
}
}

52
app/files/actions.ts Normal file
View File

@@ -0,0 +1,52 @@
"use server"
import { createFile } from "@/data/files"
import { FILE_UNSORTED_UPLOAD_PATH, getUnsortedFileUploadPath } from "@/lib/files"
import { existsSync } from "fs"
import { mkdir, writeFile } from "fs/promises"
import { revalidatePath } from "next/cache"
export async function uploadFilesAction(prevState: any, formData: FormData) {
const files = formData.getAll("files")
// Make sure upload dir exists
if (!existsSync(FILE_UNSORTED_UPLOAD_PATH)) {
await mkdir(FILE_UNSORTED_UPLOAD_PATH, { recursive: true })
}
// Process each file
const uploadedFiles = await Promise.all(
files.map(async (file) => {
if (!(file instanceof File)) {
return { success: false, error: "Invalid file" }
}
// Save file to filesystem
const { fileUuid, filePath } = await getUnsortedFileUploadPath(file.name)
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
await writeFile(filePath, buffer)
// Create file record in database
const fileRecord = await createFile({
id: fileUuid,
filename: file.name,
path: filePath,
mimetype: file.type,
metadata: {
size: file.size,
lastModified: file.lastModified,
},
})
return fileRecord
})
)
console.log("uploadedFiles", uploadedFiles)
revalidatePath("/unsorted")
return { success: true, error: null }
}

View File

@@ -0,0 +1,41 @@
import { getFileById } from "@/data/files"
import fs from "fs/promises"
import { NextResponse } from "next/server"
export async function GET(request: Request, { params }: { params: Promise<{ fileId: string }> }) {
const { fileId } = await params
if (!fileId) {
return new NextResponse("No fileId provided", { status: 400 })
}
try {
// Find file in database
const file = await getFileById(fileId)
if (!file) {
return new NextResponse("File not found", { status: 404 })
}
// Check if file exists
try {
await fs.access(file.path)
} catch {
return new NextResponse("File not found on disk", { status: 404 })
}
// Read file
const fileBuffer = await fs.readFile(file.path)
// Return file with proper content type
return new NextResponse(fileBuffer, {
headers: {
"Content-Type": file.mimetype,
"Content-Disposition": `attachment; filename="${file.filename}"`,
},
})
} catch (error) {
console.error("Error serving file:", error)
return new NextResponse("Internal Server Error", { status: 500 })
}
}

10
app/files/page.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { Metadata } from "next"
import { notFound } from "next/navigation"
export const metadata: Metadata = {
title: "Uploading...",
}
export default function UploadStatusPage() {
notFound()
}

View File

@@ -0,0 +1,66 @@
import { getFileById } from "@/data/files"
import { resizeImage } from "@/lib/images"
import { pdfToImages } from "@/lib/pdf"
import fs from "fs/promises"
import { NextResponse } from "next/server"
import path from "path"
export async function GET(request: Request, { params }: { params: Promise<{ fileId: string }> }) {
const { fileId } = await params
if (!fileId) {
return new NextResponse("No fileId provided", { status: 400 })
}
const url = new URL(request.url)
const page = parseInt(url.searchParams.get("page") || "1", 10)
try {
// Find file in database
const file = await getFileById(fileId)
if (!file) {
return new NextResponse("File not found", { status: 404 })
}
// Check if file exists
try {
await fs.access(file.path)
} catch {
return new NextResponse("File not found on disk", { status: 404 })
}
let previewPath = file.path
let previewType = file.mimetype
if (file.mimetype === "application/pdf") {
const { contentType, pages } = await pdfToImages(file.path)
if (page > pages.length) {
return new NextResponse("Page not found", { status: 404 })
}
previewPath = pages[page - 1] || file.path
previewType = contentType
} else if (file.mimetype.startsWith("image/")) {
const { contentType, resizedPath } = await resizeImage(file.path)
previewPath = resizedPath
previewType = contentType
} else {
previewPath = file.path
previewType = file.mimetype
}
// Read filex
const fileBuffer = await fs.readFile(previewPath)
// Return file with proper content type
return new NextResponse(fileBuffer, {
headers: {
"Content-Type": previewType,
"Content-Disposition": `inline; filename="${path.basename(previewPath)}"`,
},
})
} catch (error) {
console.error("Error serving file:", error)
return new NextResponse("Internal Server Error", { status: 500 })
}
}

85
app/globals.css Normal file
View File

@@ -0,0 +1,85 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

56
app/layout.tsx Normal file
View File

@@ -0,0 +1,56 @@
import ScreenDropArea from "@/components/files/screen-drop-area"
import MobileMenu from "@/components/sidebar/mobile-menu"
import { AppSidebar } from "@/components/sidebar/sidebar"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { Toaster } from "@/components/ui/sonner"
import { getUnsortedFilesCount } from "@/data/files"
import { getSettings } from "@/data/settings"
import type { Metadata, Viewport } from "next"
import { NotificationProvider } from "./context"
import "./globals.css"
export const metadata: Metadata = {
title: {
template: "%s | TaxHacker",
default: "TaxHacker",
},
description: "Your personal AI accountant",
icons: {
icon: "/favicon.ico",
shortcut: "/favicon.ico",
apple: "/apple-touch-icon.png",
},
manifest: "/site.webmanifest",
}
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const unsortedFilesCount = await getUnsortedFilesCount()
const settings = await getSettings()
return (
<html lang="en">
<head>
<meta name="theme-color" content="#ffffff" />
</head>
<body>
<NotificationProvider>
<ScreenDropArea>
<SidebarProvider>
<MobileMenu settings={settings} unsortedFilesCount={unsortedFilesCount} />
<AppSidebar settings={settings} unsortedFilesCount={unsortedFilesCount} />
<SidebarInset className="w-screen mt-[60px] md:mt-0">{children}</SidebarInset>
</SidebarProvider>
<Toaster />
</ScreenDropArea>
</NotificationProvider>
</body>
</html>
)
}
export const dynamic = "force-dynamic"

9
app/loading.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { Loader2 } from "lucide-react"
export default function AppLoading() {
return (
<div className="flex items-center justify-center h-full w-full">
<Loader2 className="w-10 h-10 animate-spin text-muted-foreground" />
</div>
)
}

30
app/page.tsx Normal file
View File

@@ -0,0 +1,30 @@
import DashboardDropZoneWidget from "@/components/dashboard/drop-zone-widget"
import { StatsWidget } from "@/components/dashboard/stats-widget"
import DashboardUnsortedWidget from "@/components/dashboard/unsorted-widget"
import { WelcomeWidget } from "@/components/dashboard/welcome-widget"
import { Separator } from "@/components/ui/separator"
import { getUnsortedFiles } from "@/data/files"
import { getSettings } from "@/data/settings"
import { StatsFilters } from "@/data/stats"
export default async function Home({ searchParams }: { searchParams: Promise<StatsFilters> }) {
const filters = await searchParams
const unsortedFiles = await getUnsortedFiles()
const settings = await getSettings()
return (
<div className="flex flex-col gap-5 p-5 w-full max-w-7xl self-center">
<div className="flex flex-col sm:flex-row gap-5 items-stretch">
<DashboardDropZoneWidget />
<DashboardUnsortedWidget files={unsortedFiles} />
</div>
{!settings.is_welcome_message_hidden && <WelcomeWidget />}
<Separator />
<StatsWidget filters={filters} />
</div>
)
}

131
app/settings/actions.ts Normal file
View File

@@ -0,0 +1,131 @@
"use server"
import { createCategory, deleteCategory, updateCategory } from "@/data/categories"
import { createCurrency, deleteCurrency, updateCurrency } from "@/data/currencies"
import { createField, deleteField, updateField } from "@/data/fields"
import { createProject, deleteProject, updateProject } from "@/data/projects"
import { updateSettings } from "@/data/settings"
import { settingsFormSchema } from "@/forms/settings"
import { codeFromName } from "@/lib/utils"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
export async function saveSettingsAction(prevState: any, formData: FormData) {
const validatedForm = settingsFormSchema.safeParse(Object.fromEntries(formData))
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
for (const key in validatedForm.data) {
await updateSettings(key, validatedForm.data[key as keyof typeof validatedForm.data] || "")
}
revalidatePath("/settings")
redirect("/settings")
// return { success: true }
}
export async function addProjectAction(data: { name: string; llm_prompt?: string; color?: string }) {
const project = await createProject({
code: codeFromName(data.name),
name: data.name,
llm_prompt: data.llm_prompt || null,
color: data.color || "#000000",
})
revalidatePath("/settings/projects")
return project
}
export async function editProjectAction(code: string, data: { name: string; llm_prompt?: string; color?: string }) {
const project = await updateProject(code, {
name: data.name,
llm_prompt: data.llm_prompt,
color: data.color,
})
revalidatePath("/settings/projects")
return project
}
export async function deleteProjectAction(code: string) {
await deleteProject(code)
revalidatePath("/settings/projects")
}
export async function addCurrencyAction(data: { code: string; name: string }) {
const currency = await createCurrency({
code: data.code,
name: data.name,
})
revalidatePath("/settings/currencies")
return currency
}
export async function editCurrencyAction(code: string, data: { name: string }) {
const currency = await updateCurrency(code, { name: data.name })
revalidatePath("/settings/currencies")
return currency
}
export async function deleteCurrencyAction(code: string) {
await deleteCurrency(code)
revalidatePath("/settings/currencies")
}
export async function addCategoryAction(data: { name: string; llm_prompt?: string; color?: string }) {
const category = await createCategory({
code: codeFromName(data.name),
name: data.name,
llm_prompt: data.llm_prompt,
color: data.color,
})
revalidatePath("/settings/categories")
return category
}
export async function editCategoryAction(code: string, data: { name: string; llm_prompt?: string; color?: string }) {
const category = await updateCategory(code, {
name: data.name,
llm_prompt: data.llm_prompt,
color: data.color,
})
revalidatePath("/settings/categories")
return category
}
export async function deleteCategoryAction(code: string) {
await deleteCategory(code)
revalidatePath("/settings/categories")
}
export async function addFieldAction(data: { name: string; type: string; llm_prompt?: string; isRequired?: boolean }) {
const field = await createField({
code: codeFromName(data.name),
name: data.name,
type: data.type,
llm_prompt: data.llm_prompt,
isRequired: data.isRequired,
isExtra: true,
})
revalidatePath("/settings/fields")
return field
}
export async function editFieldAction(
code: string,
data: { name: string; type: string; llm_prompt?: string; isRequired?: boolean }
) {
const field = await updateField(code, {
name: data.name,
type: data.type,
llm_prompt: data.llm_prompt,
isRequired: data.isRequired,
})
revalidatePath("/settings/fields")
return field
}
export async function deleteFieldAction(code: string) {
await deleteField(code)
revalidatePath("/settings/fields")
}

View File

@@ -0,0 +1,21 @@
"use server"
import { DATABASE_FILE } from "@/lib/db"
import fs from "fs"
export async function restoreBackupAction(prevState: any, formData: FormData) {
const file = formData.get("file") as File
if (!file) {
return { success: false, error: "No file provided" }
}
try {
const fileBuffer = await file.arrayBuffer()
const fileData = Buffer.from(fileBuffer)
fs.writeFileSync(DATABASE_FILE, fileData)
} catch (error) {
return { success: false, error: "Failed to restore backup" }
}
return { success: true }
}

View File

@@ -0,0 +1,18 @@
import { DATABASE_FILE } from "@/lib/db"
import fs from "fs"
import { NextResponse } from "next/server"
export async function GET(request: Request) {
try {
const file = fs.readFileSync(DATABASE_FILE)
return new NextResponse(file, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="database.sqlite"`,
},
})
} catch (error) {
console.error("Error exporting database:", error)
return new NextResponse("Internal Server Error", { status: 500 })
}
}

View File

@@ -0,0 +1,52 @@
import { FILE_UPLOAD_PATH } from "@/lib/files"
import fs, { readdirSync } from "fs"
import JSZip from "jszip"
import { NextResponse } from "next/server"
import path from "path"
export async function GET(request: Request) {
try {
const zip = new JSZip()
const folder = zip.folder("uploads")
if (!folder) {
console.error("Failed to create zip folder")
return new NextResponse("Internal Server Error", { status: 500 })
}
const files = getAllFilePaths(FILE_UPLOAD_PATH)
files.forEach((file) => {
folder.file(file.replace(FILE_UPLOAD_PATH, ""), fs.readFileSync(file))
})
const archive = await zip.generateAsync({ type: "blob" })
return new NextResponse(archive, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="uploads.zip"`,
},
})
} catch (error) {
console.error("Error exporting database:", error)
return new NextResponse("Internal Server Error", { status: 500 })
}
}
function getAllFilePaths(dirPath: string): string[] {
let filePaths: string[] = []
function readDirectory(currentPath: string) {
const entries = readdirSync(currentPath, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name)
if (entry.isDirectory()) {
readDirectory(fullPath)
} else {
filePaths.push(fullPath)
}
}
}
readDirectory(dirPath)
return filePaths
}

View File

@@ -0,0 +1,58 @@
"use client"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
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)
return (
<div className="container flex flex-col gap-4">
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Download backup</h1>
<div className="flex flex-row gap-4">
<Link href="/settings/backups/database">
<Button>
<Download /> Download database.sqlite
</Button>
</Link>
<Link href="/settings/backups/files">
<Button>
<Download /> Download files archive
</Button>
</Link>
</div>
<div className="text-sm text-muted-foreground">
You can use any SQLite client to view the database.sqlite file contents
</div>
</div>
<Card className="flex flex-col gap-4 mt-24 p-5 bg-red-100 max-w-xl">
<h2 className="text-xl font-semibold">Restore database from backup</h2>
<div className="text-sm text-muted-foreground">
Warning: This will overwrite your current database and destroy all the data! Don't forget to download backup
first.
</div>
<form action={restoreBackup}>
<label>
<input type="file" name="file" />
</label>
<Button type="submit" variant="destructive" disabled={restorePending}>
{restorePending ? (
<>
<Loader2 className="animate-spin" /> Uploading new database...
</>
) : (
"Restore"
)}
</Button>
</form>
{restoreState?.error && <p className="text-red-500">{restoreState.error}</p>}
</Card>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { DATABASE_FILE } from "@/lib/db"
import fs from "fs"
import { NextResponse } from "next/server"
export async function POST(request: Request) {
try {
const formData = await request.formData()
const file = formData.get("file") as File
if (!file) {
return new NextResponse("No file provided", { status: 400 })
}
const fileBuffer = await file.arrayBuffer()
const fileData = Buffer.from(fileBuffer)
fs.writeFileSync(DATABASE_FILE, fileData)
return new NextResponse("File restored", { status: 200 })
} catch (error) {
console.error("Error restoring from backup:", error)
return new NextResponse("Internal Server Error", { status: 500 })
}
}

View File

@@ -0,0 +1,52 @@
import { addCategoryAction, deleteCategoryAction, editCategoryAction } from "@/app/settings/actions"
import { CrudTable } from "@/components/settings/crud"
import { getCategories } from "@/data/categories"
export default async function CategoriesSettingsPage() {
const categories = await getCategories()
const categoriesWithActions = categories.map((category) => ({
...category,
isEditable: true,
isDeletable: true,
}))
return (
<div className="container">
<h1 className="text-2xl font-bold mb-6">Categories</h1>
<CrudTable
items={categoriesWithActions}
columns={[
{ key: "name", label: "Name", editable: true },
{ key: "llm_prompt", label: "LLM Prompt", editable: true },
{ key: "color", label: "Color", editable: true },
]}
onDelete={async (code) => {
"use server"
await deleteCategoryAction(code)
}}
onAdd={async (data) => {
"use server"
await addCategoryAction(
data as {
code: string
name: string
llm_prompt?: string
color: string
}
)
}}
onEdit={async (code, data) => {
"use server"
await editCategoryAction(
code,
data as {
name: string
llm_prompt?: string
color?: string
}
)
}}
/>
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { addCurrencyAction, deleteCurrencyAction, editCurrencyAction } from "@/app/settings/actions"
import { CrudTable } from "@/components/settings/crud"
import { getCurrencies } from "@/data/currencies"
export default async function CurrenciesSettingsPage() {
const currencies = await getCurrencies()
const currenciesWithActions = currencies.map((currency) => ({
...currency,
isEditable: true,
isDeletable: true,
}))
return (
<div className="container">
<h1 className="text-2xl font-bold mb-6">Currencies</h1>
<CrudTable
items={currenciesWithActions}
columns={[
{ key: "code", label: "Code" },
{ key: "name", label: "Name", editable: true },
]}
onDelete={async (code) => {
"use server"
await deleteCurrencyAction(code)
}}
onAdd={async (data) => {
"use server"
await addCurrencyAction(data as { code: string; name: string })
}}
onEdit={async (code, data) => {
"use server"
await editCurrencyAction(code, data as { name: string })
}}
/>
</div>
)
}

View File

@@ -0,0 +1,51 @@
import { addFieldAction, deleteFieldAction, editFieldAction } from "@/app/settings/actions"
import { CrudTable } from "@/components/settings/crud"
import { getFields } from "@/data/fields"
export default async function FieldsSettingsPage() {
const fields = await getFields()
const fieldsWithActions = fields.map((field) => ({
...field,
isEditable: true,
isDeletable: field.isExtra,
}))
return (
<div className="container">
<h1 className="text-2xl font-bold mb-6">Custom Fields</h1>
<CrudTable
items={fieldsWithActions}
columns={[
{ key: "name", label: "Name", editable: true },
{ key: "type", label: "Type", editable: true },
{ key: "llm_prompt", label: "LLM Prompt", editable: true },
]}
onDelete={async (code) => {
"use server"
await deleteFieldAction(code)
}}
onAdd={async (data) => {
"use server"
await addFieldAction(
data as {
name: string
type: string
llm_prompt?: string
}
)
}}
onEdit={async (code, data) => {
"use server"
await editFieldAction(
code,
data as {
name: string
type: string
llm_prompt?: string
}
)
}}
/>
</div>
)
}

59
app/settings/layout.tsx Normal file
View File

@@ -0,0 +1,59 @@
import { SideNav } from "@/components/settings/side-nav"
import { Separator } from "@/components/ui/separator"
import { Metadata } from "next"
export const metadata: Metadata = {
title: "Settings",
description: "Customize your settings here",
}
const settingsCategories = [
{
title: "General",
href: "/settings",
},
{
title: "LLM settings",
href: "/settings/llm",
},
{
title: "Fields",
href: "/settings/fields",
},
{
title: "Categories",
href: "/settings/categories",
},
{
title: "Projects",
href: "/settings/projects",
},
{
title: "Currencies",
href: "/settings/currencies",
},
{
title: "Backups",
href: "/settings/backups",
},
]
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
return (
<>
<div className="hidden space-y-6 p-10 pb-16 md:block">
<div className="space-y-0.5">
<h2 className="text-2xl font-bold tracking-tight">Settings</h2>
<p className="text-muted-foreground">Customize your settings here</p>
</div>
<Separator className="my-6" />
<div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
<aside className="-mx-4 lg:w-1/5">
<SideNav items={settingsCategories} />
</aside>
<div className="flex w-full">{children}</div>
</div>
</div>
</>
)
}

14
app/settings/llm/page.tsx Normal file
View File

@@ -0,0 +1,14 @@
import LLMSettingsForm from "@/components/settings/llm-settings-form"
import { getSettings } from "@/data/settings"
export default async function LlmSettingsPage() {
const settings = await getSettings()
return (
<>
<div className="w-full max-w-2xl">
<LLMSettingsForm settings={settings} />
</div>
</>
)
}

18
app/settings/page.tsx Normal file
View File

@@ -0,0 +1,18 @@
import GlobalSettingsForm from "@/components/settings/global-settings-form"
import { getCategories } from "@/data/categories"
import { getCurrencies } from "@/data/currencies"
import { getSettings } from "@/data/settings"
export default async function SettingsPage() {
const settings = await getSettings()
const currencies = await getCurrencies()
const categories = await getCategories()
return (
<>
<div className="w-full max-w-2xl">
<GlobalSettingsForm settings={settings} currencies={currencies} categories={categories} />
</div>
</>
)
}

View File

@@ -0,0 +1,38 @@
import { addProjectAction, deleteProjectAction, editProjectAction } from "@/app/settings/actions"
import { CrudTable } from "@/components/settings/crud"
import { getProjects } from "@/data/projects"
export default async function ProjectsSettingsPage() {
const projects = await getProjects()
const projectsWithActions = projects.map((project) => ({
...project,
isEditable: true,
isDeletable: true,
}))
return (
<div className="container">
<h1 className="text-2xl font-bold mb-6">Projects</h1>
<CrudTable
items={projectsWithActions}
columns={[
{ key: "name", label: "Name", editable: true },
{ key: "llm_prompt", label: "LLM Prompt", editable: true },
{ key: "color", label: "Color", editable: true },
]}
onDelete={async (code) => {
"use server"
await deleteProjectAction(code)
}}
onAdd={async (data) => {
"use server"
await addProjectAction(data as { code: string; name: string; llm_prompt: string; color: string })
}}
onEdit={async (code, data) => {
"use server"
await editProjectAction(code, data as { name: string; llm_prompt: string; color: string })
}}
/>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import { getTransactionById } from "@/data/transactions"
import { notFound } from "next/navigation"
export default async function TransactionLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ transactionId: string }>
}) {
const { transactionId } = await params
const transaction = await getTransactionById(transactionId)
if (!transaction) {
notFound()
}
return (
<>
<header className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight">Transaction Details</h2>
</header>
<main>
<div className="flex flex-1 flex-col gap-4 pt-0">{children}</div>
</main>
</>
)
}

View File

@@ -0,0 +1,7 @@
import { Skeleton } from "@/components/ui/skeleton"
export default function Loading() {
return (
<Skeleton className="flex flex-row flex-wrap md:flex-nowrap justify-center items-start gap-10 p-5 bg-accent max-w-[1200px] min-h-[800px]" />
)
}

View File

@@ -0,0 +1,62 @@
import { FormTextarea } from "@/components/forms/simple"
import TransactionEditForm from "@/components/transactions/edit"
import TransactionFiles from "@/components/transactions/transaction-files"
import { Card } from "@/components/ui/card"
import { getCategories } from "@/data/categories"
import { getCurrencies } from "@/data/currencies"
import { getFields } from "@/data/fields"
import { getFilesByTransactionId } from "@/data/files"
import { getProjects } from "@/data/projects"
import { getSettings } from "@/data/settings"
import { getTransactionById } from "@/data/transactions"
import { notFound } from "next/navigation"
export default async function TransactionPage({ params }: { params: Promise<{ transactionId: string }> }) {
const { transactionId } = await params
const transaction = await getTransactionById(transactionId)
if (!transaction) {
notFound()
}
const files = await getFilesByTransactionId(transactionId)
const categories = await getCategories()
const currencies = await getCurrencies()
const settings = await getSettings()
const fields = await getFields()
const projects = await getProjects()
return (
<>
<Card className="flex flex-col md:flex-row flex-wrap justify-center items-start gap-10 p-5 bg-accent max-w-6xl">
<div className="flex-1">
<TransactionEditForm
transaction={transaction}
categories={categories}
currencies={currencies}
settings={settings}
fields={fields}
projects={projects}
/>
</div>
<div className="max-w-[320px] space-y-4">
<TransactionFiles transaction={transaction} files={files} />
</div>
</Card>
{transaction.text && (
<Card className="flex items-stretch p-5 mt-10 max-w-6xl">
<div className="flex-1">
<FormTextarea
title="Recognized Text"
name="text"
defaultValue={transaction.text || ""}
hideIfEmpty={true}
className="w-full h-[400px]"
/>
</div>
</Card>
)}
</>
)
}

143
app/transactions/actions.ts Normal file
View File

@@ -0,0 +1,143 @@
"use server"
import { createFile, deleteFile } from "@/data/files"
import {
createTransaction,
deleteTransaction,
getTransactionById,
updateTransaction,
updateTransactionFiles,
} from "@/data/transactions"
import { transactionFormSchema } from "@/forms/transactions"
import { FILE_UPLOAD_PATH, getTransactionFileUploadPath } from "@/lib/files"
import { existsSync } from "fs"
import { mkdir, writeFile } from "fs/promises"
import { revalidatePath } from "next/cache"
export async function createTransactionAction(prevState: any, formData: FormData) {
try {
const validatedForm = transactionFormSchema.safeParse(Object.fromEntries(formData.entries()))
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const transaction = await createTransaction(validatedForm.data)
revalidatePath("/transactions")
return { success: true, transactionId: transaction.id }
} catch (error) {
console.error("Failed to create transaction:", error)
return { success: false, error: "Failed to create transaction" }
}
}
export async function saveTransactionAction(prevState: any, formData: FormData) {
try {
const transactionId = formData.get("transactionId") as string
const validatedForm = transactionFormSchema.safeParse(Object.fromEntries(formData.entries()))
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const transaction = await updateTransaction(transactionId, validatedForm.data)
revalidatePath("/transactions")
return { success: true, transactionId: transaction.id }
} catch (error) {
console.error("Failed to update transaction:", error)
return { success: false, error: "Failed to save transaction" }
}
}
export async function deleteTransactionAction(prevState: any, transactionId: string) {
try {
const transaction = await getTransactionById(transactionId)
if (!transaction) throw new Error("Transaction not found")
await deleteTransaction(transaction.id)
revalidatePath("/transactions")
return { success: true, transactionId: transaction.id }
} catch (error) {
console.error("Failed to delete transaction:", error)
return { success: false, error: "Failed to delete transaction" }
}
}
export async function deleteTransactionFileAction(
transactionId: string,
fileId: string
): Promise<{ success: boolean; error?: string }> {
if (!fileId || !transactionId) {
return { success: false, error: "File ID and transaction ID are required" }
}
const transaction = await getTransactionById(transactionId)
if (!transaction) {
return { success: false, error: "Transaction not found" }
}
await updateTransactionFiles(
transactionId,
transaction.files ? (transaction.files as string[]).filter((id) => id !== fileId) : []
)
await deleteFile(fileId)
revalidatePath(`/transactions/${transactionId}`)
return { success: true }
}
export async function uploadTransactionFileAction(formData: FormData): Promise<{ success: boolean; error?: string }> {
try {
const transactionId = formData.get("transactionId") as string
const file = formData.get("file") as File
if (!file || !transactionId) {
return { success: false, error: "No file or transaction ID provided" }
}
const transaction = await getTransactionById(transactionId)
if (!transaction) {
return { success: false, error: "Transaction not found" }
}
// Make sure upload dir exists
if (!existsSync(FILE_UPLOAD_PATH)) {
await mkdir(FILE_UPLOAD_PATH, { recursive: true })
}
// Save file to filesystem
const { fileUuid, filePath } = await getTransactionFileUploadPath(file.name, transaction)
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
await writeFile(filePath, buffer)
// Create file record in database
const fileRecord = await createFile({
id: fileUuid,
filename: file.name,
path: filePath,
mimetype: file.type,
isReviewed: true,
metadata: {
size: file.size,
lastModified: file.lastModified,
},
})
// Update invoice with the new file ID
await updateTransactionFiles(
transactionId,
transaction.files ? [...(transaction.files as string[]), fileRecord.id] : [fileRecord.id]
)
revalidatePath(`/transactions/${transactionId}`)
return { success: true }
} catch (error) {
console.error("Upload error:", error)
return { success: false, error: `File upload failed: ${error}` }
}
}

View File

@@ -0,0 +1,3 @@
export default async function TransactionsLayout({ children }: { children: React.ReactNode }) {
return <div className="flex flex-col gap-4 p-4">{children}</div>
}

View File

@@ -0,0 +1,30 @@
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Download, Plus } from "lucide-react"
export default function Loading() {
return (
<>
<header className="flex items-center justify-between mb-12">
<h2 className="text-3xl font-bold tracking-tight">Transactions</h2>
<div className="flex gap-2">
<Button variant="outline">
<Download />
Export
</Button>
<Button>
<Plus /> Add Transaction
</Button>
</div>
</header>
<main>
<div className="flex flex-col gap-3 w-full">
{[...Array(20)].map((_, i) => (
<Skeleton key={i} className="h-8" />
))}
</div>
</main>
</>
)
}

71
app/transactions/page.tsx Normal file
View File

@@ -0,0 +1,71 @@
import { ExportTransactionsDialog } from "@/components/export/transactions"
import { UploadButton } from "@/components/files/upload-button"
import { TransactionSearchAndFilters } from "@/components/transactions/filters"
import { TransactionList } from "@/components/transactions/list"
import { NewTransactionDialog } from "@/components/transactions/new"
import { Button } from "@/components/ui/button"
import { getCategories } from "@/data/categories"
import { getFields } from "@/data/fields"
import { getProjects } from "@/data/projects"
import { getTransactions, TransactionFilters } from "@/data/transactions"
import { Download, Plus, Upload } from "lucide-react"
import { Metadata } from "next"
export const metadata: Metadata = {
title: "Transactions",
description: "Manage your transactions",
}
export default async function TransactionsPage({ searchParams }: { searchParams: Promise<TransactionFilters> }) {
const filters = await searchParams
const transactions = await getTransactions(filters)
const categories = await getCategories()
const projects = await getProjects()
const fields = await getFields()
return (
<>
<header className="flex flex-wrap items-center justify-between gap-2 mb-8">
<h2 className="text-3xl font-bold tracking-tight">Transactions</h2>
<div className="flex gap-2">
<ExportTransactionsDialog filters={filters} fields={fields} categories={categories} projects={projects}>
<Button variant="outline">
<Download />
<span className="hidden md:block">Export</span>
</Button>
</ExportTransactionsDialog>
<NewTransactionDialog>
<Button>
<Plus /> <span className="hidden md:block">Add Transaction</span>
</Button>
</NewTransactionDialog>
</div>
</header>
<TransactionSearchAndFilters categories={categories} projects={projects} />
<main>
<TransactionList transactions={transactions} />
{transactions.length === 0 && (
<div className="flex flex-col items-center justify-center gap-2 h-full min-h-[400px]">
<p className="text-muted-foreground">
You don't seem to have any transactions yet. Let's start and create the first one!
</p>
<div className="flex flex-row gap-5 mt-8">
<UploadButton>
<Upload /> Analyze New Invoice
</UploadButton>
<NewTransactionDialog>
<Button variant="outline">
<Plus />
Add Manually
</Button>
</NewTransactionDialog>
</div>
</div>
)}
</main>
</>
)
}

63
app/unsorted/actions.ts Normal file
View File

@@ -0,0 +1,63 @@
"use server"
import { deleteFile, getFileById, updateFile } from "@/data/files"
import { createTransaction, updateTransactionFiles } from "@/data/transactions"
import { transactionFormSchema } from "@/forms/transactions"
import { getTransactionFileUploadPath } from "@/lib/files"
import { mkdir, rename } from "fs/promises"
import { revalidatePath } from "next/cache"
import path from "path"
export async function saveFileAsTransactionAction(prevState: any, formData: FormData) {
try {
const validatedForm = transactionFormSchema.safeParse(Object.fromEntries(formData.entries()))
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
// Get the file record
const fileId = formData.get("fileId") as string
const file = await getFileById(fileId)
if (!file) throw new Error("File not found")
// Create transaction
const transaction = await createTransaction(validatedForm.data)
// Move file to processed location
const originalFileName = path.basename(file.path)
const { fileUuid, filePath: newFilePath } = await getTransactionFileUploadPath(originalFileName, transaction)
// Move file to new location and name
await mkdir(path.dirname(newFilePath), { recursive: true })
await rename(path.resolve(file.path), path.resolve(newFilePath))
// Update file record
await updateFile(file.id, {
id: fileUuid,
path: newFilePath,
isReviewed: true,
})
await updateTransactionFiles(transaction.id, [fileUuid])
revalidatePath("/unsorted")
revalidatePath("/transactions")
return { success: true, transactionId: transaction.id }
} catch (error) {
console.error("Failed to save transaction:", error)
return { success: false, error: `Failed to save transaction: ${error}` }
}
}
export async function deleteUnsortedFileAction(prevState: any, fileId: string) {
try {
await deleteFile(fileId)
revalidatePath("/unsorted")
return { success: true }
} catch (error) {
console.error("Failed to delete file:", error)
return { success: false, error: "Failed to delete file" }
}
}

3
app/unsorted/layout.tsx Normal file
View File

@@ -0,0 +1,3 @@
export default function UnsortedLayout({ children }: { children: React.ReactNode }) {
return <div className="flex flex-col gap-4 p-4 max-w-6xl">{children}</div>
}

103
app/unsorted/page.tsx Normal file
View File

@@ -0,0 +1,103 @@
import AnalyzeForm from "@/components/unsorted/analyze-form"
import { FilePreview } from "@/components/files/preview"
import { UploadButton } from "@/components/files/upload-button"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { getCategories } from "@/data/categories"
import { getCurrencies } from "@/data/currencies"
import { getFields } from "@/data/fields"
import { getUnsortedFiles } from "@/data/files"
import { getProjects } from "@/data/projects"
import { getSettings } from "@/data/settings"
import { FileText, PartyPopper, Settings, Upload } from "lucide-react"
import { Metadata } from "next"
import Link from "next/link"
export const metadata: Metadata = {
title: "Unsorted",
description: "Analyze unsorted files",
}
export default async function UnsortedPage() {
const files = await getUnsortedFiles()
const categories = await getCategories()
const projects = await getProjects()
const currencies = await getCurrencies()
const fields = await getFields()
const settings = await getSettings()
return (
<>
<header className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight">You have {files.length} unsorted files</h2>
</header>
{!settings.openai_api_key && (
<Alert>
<Settings className="h-4 w-4 mt-2" />
<div className="flex flex-row justify-between pt-2">
<div className="flex flex-col">
<AlertTitle>ChatGPT API Key is required for analyzing files</AlertTitle>
<AlertDescription>
Please set your OpenAI API key in the settings to use the analyze form.
</AlertDescription>
</div>
<Link href="/settings/llm">
<Button>Go to Settings</Button>
</Link>
</div>
</Alert>
)}
<main className="flex flex-col gap-5">
{files.map((file) => (
<Card
key={file.id}
id={file.id}
className="flex flex-row flex-wrap md:flex-nowrap justify-center items-start gap-5 p-5 bg-accent"
>
<div className="w-full max-w-[500px]">
<Card>
<FilePreview file={file} />
</Card>
</div>
<div className="w-full">
<AnalyzeForm
file={file}
categories={categories}
projects={projects}
currencies={currencies}
fields={fields}
settings={settings}
/>
</div>
</Card>
))}
{files.length == 0 && (
<div className="flex flex-col items-center justify-center gap-2 h-full min-h-[600px]">
<PartyPopper className="w-12 h-12 text-muted-foreground" />
<p className="pt-4 text-muted-foreground">Everything is clear! Congrats!</p>
<p className="flex flex-row gap-2 text-muted-foreground">
<span>Drag and drop new files here to analyze</span>
<Upload />
</p>
<div className="flex flex-row gap-5 mt-8">
<UploadButton>
<Upload /> Upload New File
</UploadButton>
<Button variant="outline" asChild>
<Link href="/transactions">
<FileText />
Go to Transactions
</Link>
</Button>
</div>
</div>
)}
</main>
</>
)
}