mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 21:35:19 +00:00
(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:
139
app/ai/analyze.ts
Normal file
139
app/ai/analyze.ts
Normal 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
49
app/ai/prompt.ts
Normal 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
32
app/context.tsx
Normal 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)
|
||||
104
app/export/transactions/route.ts
Normal file
104
app/export/transactions/route.ts
Normal 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
52
app/files/actions.ts
Normal 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 }
|
||||
}
|
||||
41
app/files/download/[fileId]/route.ts
Normal file
41
app/files/download/[fileId]/route.ts
Normal 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
10
app/files/page.tsx
Normal 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()
|
||||
}
|
||||
66
app/files/preview/[fileId]/route.ts
Normal file
66
app/files/preview/[fileId]/route.ts
Normal 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
85
app/globals.css
Normal 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
56
app/layout.tsx
Normal 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
9
app/loading.tsx
Normal 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
30
app/page.tsx
Normal 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
131
app/settings/actions.ts
Normal 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")
|
||||
}
|
||||
21
app/settings/backups/actions.ts
Normal file
21
app/settings/backups/actions.ts
Normal 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 }
|
||||
}
|
||||
18
app/settings/backups/database/route.ts
Normal file
18
app/settings/backups/database/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
52
app/settings/backups/files/route.ts
Normal file
52
app/settings/backups/files/route.ts
Normal 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
|
||||
}
|
||||
58
app/settings/backups/page.tsx
Normal file
58
app/settings/backups/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
app/settings/backups/restore/route.ts
Normal file
24
app/settings/backups/restore/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
52
app/settings/categories/page.tsx
Normal file
52
app/settings/categories/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
app/settings/currencies/page.tsx
Normal file
37
app/settings/currencies/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
app/settings/fields/page.tsx
Normal file
51
app/settings/fields/page.tsx
Normal 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
59
app/settings/layout.tsx
Normal 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
14
app/settings/llm/page.tsx
Normal 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
18
app/settings/page.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
38
app/settings/projects/page.tsx
Normal file
38
app/settings/projects/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
app/transactions/[transactionId]/layout.tsx
Normal file
28
app/transactions/[transactionId]/layout.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
7
app/transactions/[transactionId]/loading.tsx
Normal file
7
app/transactions/[transactionId]/loading.tsx
Normal 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]" />
|
||||
)
|
||||
}
|
||||
62
app/transactions/[transactionId]/page.tsx
Normal file
62
app/transactions/[transactionId]/page.tsx
Normal 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
143
app/transactions/actions.ts
Normal 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}` }
|
||||
}
|
||||
}
|
||||
3
app/transactions/layout.tsx
Normal file
3
app/transactions/layout.tsx
Normal 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>
|
||||
}
|
||||
30
app/transactions/loading.tsx
Normal file
30
app/transactions/loading.tsx
Normal 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
71
app/transactions/page.tsx
Normal 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
63
app/unsorted/actions.ts
Normal 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
3
app/unsorted/layout.tsx
Normal 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
103
app/unsorted/page.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user