mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 13:25:18 +00:00
BREAKING: postgres + saas
This commit is contained in:
32
app/(app)/context.tsx
Normal file
32
app/(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)
|
||||
39
app/(app)/dashboard/page.tsx
Normal file
39
app/(app)/dashboard/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
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 { getCurrentUser } from "@/lib/auth"
|
||||
import { APP_DESCRIPTION } from "@/lib/constants"
|
||||
import { getUnsortedFiles } from "@/models/files"
|
||||
import { getSettings } from "@/models/settings"
|
||||
import { TransactionFilters } from "@/models/transactions"
|
||||
import { Metadata } from "next"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dashboard",
|
||||
description: APP_DESCRIPTION,
|
||||
}
|
||||
|
||||
export default async function Dashboard({ searchParams }: { searchParams: Promise<TransactionFilters> }) {
|
||||
const filters = await searchParams
|
||||
const user = await getCurrentUser()
|
||||
const unsortedFiles = await getUnsortedFiles(user.id)
|
||||
const settings = await getSettings(user.id)
|
||||
|
||||
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 h-full">
|
||||
<DashboardDropZoneWidget />
|
||||
|
||||
<DashboardUnsortedWidget files={unsortedFiles} />
|
||||
</div>
|
||||
|
||||
{settings.is_welcome_message_hidden !== "true" && <WelcomeWidget />}
|
||||
|
||||
<Separator />
|
||||
|
||||
<StatsWidget filters={filters} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
119
app/(app)/export/transactions/route.ts
Normal file
119
app/(app)/export/transactions/route.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { fileExists, fullPathForFile } from "@/lib/files"
|
||||
import { EXPORT_AND_IMPORT_FIELD_MAP, ExportFields, ExportFilters } from "@/models/export_and_import"
|
||||
import { getFields } from "@/models/fields"
|
||||
import { getFilesByTransactionId } from "@/models/files"
|
||||
import { getTransactions } from "@/models/transactions"
|
||||
import { format } from "@fast-csv/format"
|
||||
import { formatDate } from "date-fns"
|
||||
import fs from "fs/promises"
|
||||
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 user = await getCurrentUser()
|
||||
const { transactions } = await getTransactions(user.id, filters)
|
||||
const existingFields = await getFields(user.id)
|
||||
|
||||
// Generate CSV file with all transactions
|
||||
try {
|
||||
const fieldKeys = fields.filter((field) => existingFields.some((f) => f.code === field))
|
||||
|
||||
let csvContent = ""
|
||||
const csvStream = format({ headers: fieldKeys, writeBOM: true, writeHeaders: false })
|
||||
|
||||
csvStream.on("data", (chunk) => {
|
||||
csvContent += chunk
|
||||
})
|
||||
|
||||
// Custom CSV headers
|
||||
const headers = fieldKeys.map((field) => existingFields.find((f) => f.code === field)?.name ?? "UNKNOWN")
|
||||
csvStream.write(headers)
|
||||
|
||||
// CSV rows
|
||||
for (const transaction of transactions) {
|
||||
const row: Record<string, any> = {}
|
||||
for (const key of fieldKeys) {
|
||||
const value = transaction[key as keyof typeof transaction] ?? ""
|
||||
const exportFieldSettings = EXPORT_AND_IMPORT_FIELD_MAP[key]
|
||||
if (exportFieldSettings && exportFieldSettings.export) {
|
||||
row[key] = await exportFieldSettings.export(user.id, value)
|
||||
} else {
|
||||
row[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
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, user.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 fullFilePath = await fullPathForFile(user, file)
|
||||
if (await fileExists(fullFilePath)) {
|
||||
const fileData = await fs.readFile(fullFilePath)
|
||||
const fileExtension = path.extname(fullFilePath)
|
||||
transactionFolder.file(
|
||||
`${formatDate(transaction.issuedAt || new Date(), "yyyy-MM-dd")} - ${
|
||||
transaction.name || transaction.id
|
||||
}${fileExtension}`,
|
||||
fileData
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const zipContent = await zip.generateAsync({ type: "uint8array" })
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
57
app/(app)/files/actions.ts
Normal file
57
app/(app)/files/actions.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
"use server"
|
||||
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getUserUploadsDirectory, unsortedFilePath } from "@/lib/files"
|
||||
import { createFile } from "@/models/files"
|
||||
import { randomUUID } from "crypto"
|
||||
import { mkdir, writeFile } from "fs/promises"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import path from "path"
|
||||
|
||||
export async function uploadFilesAction(prevState: any, formData: FormData) {
|
||||
const user = await getCurrentUser()
|
||||
const files = formData.getAll("files")
|
||||
|
||||
// Make sure upload dir exists
|
||||
const userUploadsDirectory = await getUserUploadsDirectory(user)
|
||||
|
||||
// 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 = randomUUID()
|
||||
const relativeFilePath = await unsortedFilePath(fileUuid, file.name)
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
|
||||
const fullFilePath = path.join(userUploadsDirectory, relativeFilePath)
|
||||
await mkdir(path.dirname(fullFilePath), { recursive: true })
|
||||
|
||||
await writeFile(fullFilePath, buffer)
|
||||
|
||||
// Create file record in database
|
||||
const fileRecord = await createFile(user.id, {
|
||||
id: fileUuid,
|
||||
filename: file.name,
|
||||
path: relativeFilePath,
|
||||
mimetype: file.type,
|
||||
metadata: {
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
},
|
||||
})
|
||||
|
||||
return fileRecord
|
||||
})
|
||||
)
|
||||
|
||||
console.log("uploadedFiles", uploadedFiles)
|
||||
|
||||
revalidatePath("/unsorted")
|
||||
|
||||
return { success: true, error: null }
|
||||
}
|
||||
44
app/(app)/files/download/[fileId]/route.ts
Normal file
44
app/(app)/files/download/[fileId]/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { fileExists, fullPathForFile } from "@/lib/files"
|
||||
import { getFileById } from "@/models/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
|
||||
const user = await getCurrentUser()
|
||||
|
||||
if (!fileId) {
|
||||
return new NextResponse("No fileId provided", { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Find file in database
|
||||
const file = await getFileById(fileId, user.id)
|
||||
|
||||
if (!file || file.userId !== user.id) {
|
||||
return new NextResponse("File not found or does not belong to the user", { status: 404 })
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
const fullFilePath = await fullPathForFile(user, file)
|
||||
const isFileExists = await fileExists(fullFilePath)
|
||||
if (!isFileExists) {
|
||||
return new NextResponse(`File not found on disk: ${file.path}`, { status: 404 })
|
||||
}
|
||||
|
||||
// Read file
|
||||
const fileBuffer = await fs.readFile(fullFilePath)
|
||||
|
||||
// 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/(app)/files/page.tsx
Normal file
10
app/(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()
|
||||
}
|
||||
56
app/(app)/files/preview/[fileId]/route.ts
Normal file
56
app/(app)/files/preview/[fileId]/route.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { fileExists, fullPathForFile } from "@/lib/files"
|
||||
import { generateFilePreviews } from "@/lib/previews/generate"
|
||||
import { getFileById } from "@/models/files"
|
||||
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
|
||||
const user = await getCurrentUser()
|
||||
|
||||
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, user.id)
|
||||
|
||||
if (!file || file.userId !== user.id) {
|
||||
return new NextResponse("File not found or does not belong to the user", { status: 404 })
|
||||
}
|
||||
|
||||
// Check if file exists on disk
|
||||
const fullFilePath = await fullPathForFile(user, file)
|
||||
const isFileExists = await fileExists(fullFilePath)
|
||||
if (!isFileExists) {
|
||||
return new NextResponse(`File not found on disk: ${file.path}`, { status: 404 })
|
||||
}
|
||||
|
||||
// Generate previews
|
||||
const { contentType, previews } = await generateFilePreviews(user, fullFilePath, file.mimetype)
|
||||
if (page > previews.length) {
|
||||
return new NextResponse("Page not found", { status: 404 })
|
||||
}
|
||||
const previewPath = previews[page - 1] || fullFilePath
|
||||
|
||||
// Read file
|
||||
const fileBuffer = await fs.readFile(previewPath)
|
||||
|
||||
// Return file with proper content type
|
||||
return new NextResponse(fileBuffer, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Disposition": `inline; filename="${path.basename(previewPath)}"`,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error serving file:", error)
|
||||
return new NextResponse("Internal Server Error", { status: 500 })
|
||||
}
|
||||
}
|
||||
68
app/(app)/import/csv/actions.tsx
Normal file
68
app/(app)/import/csv/actions.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use server"
|
||||
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { EXPORT_AND_IMPORT_FIELD_MAP } from "@/models/export_and_import"
|
||||
import { createTransaction } from "@/models/transactions"
|
||||
import { parse } from "@fast-csv/parse"
|
||||
import { revalidatePath } from "next/cache"
|
||||
|
||||
export async function parseCSVAction(prevState: any, formData: FormData) {
|
||||
const file = formData.get("file") as File
|
||||
if (!file) {
|
||||
return { success: false, error: "No file uploaded" }
|
||||
}
|
||||
|
||||
if (!file.name.toLowerCase().endsWith(".csv")) {
|
||||
return { success: false, error: "Only CSV files are allowed" }
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
const rows: string[][] = []
|
||||
|
||||
const parser = parse()
|
||||
.on("data", (row) => rows.push(row))
|
||||
.on("error", (error) => {
|
||||
throw error
|
||||
})
|
||||
parser.write(buffer)
|
||||
parser.end()
|
||||
|
||||
// Wait for parsing to complete
|
||||
await new Promise((resolve) => parser.on("end", resolve))
|
||||
|
||||
return { success: true, data: rows }
|
||||
} catch (error) {
|
||||
console.error("Error parsing CSV:", error)
|
||||
return { success: false, error: "Failed to parse CSV file" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveTransactionsAction(prevState: any, formData: FormData) {
|
||||
const user = await getCurrentUser()
|
||||
try {
|
||||
const rows = JSON.parse(formData.get("rows") as string) as Record<string, unknown>[]
|
||||
|
||||
for (const row of rows) {
|
||||
const transactionData: Record<string, unknown> = {}
|
||||
for (const [fieldCode, value] of Object.entries(row)) {
|
||||
const fieldDef = EXPORT_AND_IMPORT_FIELD_MAP[fieldCode]
|
||||
if (fieldDef?.import) {
|
||||
transactionData[fieldCode] = await fieldDef.import(user.id, value as string)
|
||||
} else {
|
||||
transactionData[fieldCode] = value as string
|
||||
}
|
||||
}
|
||||
|
||||
await createTransaction(user.id, transactionData)
|
||||
}
|
||||
|
||||
revalidatePath("/import/csv")
|
||||
revalidatePath("/transactions")
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("Error saving transactions:", error)
|
||||
return { success: false, error: "Failed to save transactions: " + error }
|
||||
}
|
||||
}
|
||||
13
app/(app)/import/csv/page.tsx
Normal file
13
app/(app)/import/csv/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ImportCSVTable } from "@/components/import/csv"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getFields } from "@/models/fields"
|
||||
|
||||
export default async function CSVImportPage() {
|
||||
const user = await getCurrentUser()
|
||||
const fields = await getFields(user.id)
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<ImportCSVTable fields={fields} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
57
app/(app)/layout.tsx
Normal file
57
app/(app)/layout.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
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 { getCurrentUser } from "@/lib/auth"
|
||||
import { APP_DESCRIPTION, APP_TITLE } from "@/lib/constants"
|
||||
import { getUnsortedFilesCount } from "@/models/files"
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import "../globals.css"
|
||||
import { NotificationProvider } from "./context"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: "%s | TaxHacker",
|
||||
default: APP_TITLE,
|
||||
},
|
||||
description: APP_DESCRIPTION,
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
shortcut: "/favicon.ico",
|
||||
apple: "/apple-touch-icon.png",
|
||||
},
|
||||
manifest: "/site.webmanifest",
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#ffffff",
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await getCurrentUser()
|
||||
const unsortedFilesCount = await getUnsortedFilesCount(user.id)
|
||||
|
||||
return (
|
||||
<NotificationProvider>
|
||||
<ScreenDropArea>
|
||||
<SidebarProvider>
|
||||
<MobileMenu unsortedFilesCount={unsortedFilesCount} />
|
||||
<AppSidebar
|
||||
unsortedFilesCount={unsortedFilesCount}
|
||||
profile={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
email: user.email,
|
||||
avatar: user.avatar || undefined,
|
||||
}}
|
||||
/>
|
||||
<SidebarInset className="w-full h-full mt-[60px] md:mt-0 overflow-auto">{children}</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<Toaster />
|
||||
</ScreenDropArea>
|
||||
</NotificationProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
232
app/(app)/settings/actions.ts
Normal file
232
app/(app)/settings/actions.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
"use server"
|
||||
|
||||
import {
|
||||
categoryFormSchema,
|
||||
currencyFormSchema,
|
||||
fieldFormSchema,
|
||||
projectFormSchema,
|
||||
settingsFormSchema,
|
||||
} from "@/forms/settings"
|
||||
import { userFormSchema } from "@/forms/users"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { codeFromName, randomHexColor } from "@/lib/utils"
|
||||
import { createCategory, deleteCategory, updateCategory } from "@/models/categories"
|
||||
import { createCurrency, deleteCurrency, updateCurrency } from "@/models/currencies"
|
||||
import { createField, deleteField, updateField } from "@/models/fields"
|
||||
import { createProject, deleteProject, updateProject } from "@/models/projects"
|
||||
import { updateSettings } from "@/models/settings"
|
||||
import { updateUser } from "@/models/users"
|
||||
import { Prisma } from "@prisma/client"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export async function saveSettingsAction(prevState: any, formData: FormData) {
|
||||
const user = await getCurrentUser()
|
||||
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(user.id, key, validatedForm.data[key as keyof typeof validatedForm.data])
|
||||
}
|
||||
|
||||
revalidatePath("/settings")
|
||||
redirect("/settings")
|
||||
// return { success: true }
|
||||
}
|
||||
|
||||
export async function saveProfileAction(prevState: any, formData: FormData) {
|
||||
const user = await getCurrentUser()
|
||||
const validatedForm = userFormSchema.safeParse(Object.fromEntries(formData))
|
||||
|
||||
if (!validatedForm.success) {
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
await updateUser(user.id, {
|
||||
name: validatedForm.data.name,
|
||||
})
|
||||
|
||||
revalidatePath("/settings/profile")
|
||||
redirect("/settings/profile")
|
||||
}
|
||||
|
||||
export async function addProjectAction(userId: string, data: Prisma.ProjectCreateInput) {
|
||||
const validatedForm = projectFormSchema.safeParse(data)
|
||||
|
||||
if (!validatedForm.success) {
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
const project = await createProject(userId, {
|
||||
code: codeFromName(validatedForm.data.name),
|
||||
name: validatedForm.data.name,
|
||||
llm_prompt: validatedForm.data.llm_prompt || null,
|
||||
color: validatedForm.data.color || randomHexColor(),
|
||||
})
|
||||
revalidatePath("/settings/projects")
|
||||
|
||||
return { success: true, project }
|
||||
}
|
||||
|
||||
export async function editProjectAction(userId: string, code: string, data: Prisma.ProjectUpdateInput) {
|
||||
const validatedForm = projectFormSchema.safeParse(data)
|
||||
|
||||
if (!validatedForm.success) {
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
const project = await updateProject(userId, code, {
|
||||
name: validatedForm.data.name,
|
||||
llm_prompt: validatedForm.data.llm_prompt,
|
||||
color: validatedForm.data.color || "",
|
||||
})
|
||||
revalidatePath("/settings/projects")
|
||||
|
||||
return { success: true, project }
|
||||
}
|
||||
|
||||
export async function deleteProjectAction(userId: string, code: string) {
|
||||
try {
|
||||
await deleteProject(userId, code)
|
||||
} catch (error) {
|
||||
return { success: false, error: "Failed to delete project" + error }
|
||||
}
|
||||
revalidatePath("/settings/projects")
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function addCurrencyAction(userId: string, data: Prisma.CurrencyCreateInput) {
|
||||
const validatedForm = currencyFormSchema.safeParse(data)
|
||||
|
||||
if (!validatedForm.success) {
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
const currency = await createCurrency(userId, {
|
||||
code: validatedForm.data.code,
|
||||
name: validatedForm.data.name,
|
||||
})
|
||||
revalidatePath("/settings/currencies")
|
||||
|
||||
return { success: true, currency }
|
||||
}
|
||||
|
||||
export async function editCurrencyAction(userId: string, code: string, data: Prisma.CurrencyUpdateInput) {
|
||||
const validatedForm = currencyFormSchema.safeParse(data)
|
||||
|
||||
if (!validatedForm.success) {
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
const currency = await updateCurrency(userId, code, { name: validatedForm.data.name })
|
||||
revalidatePath("/settings/currencies")
|
||||
return { success: true, currency }
|
||||
}
|
||||
|
||||
export async function deleteCurrencyAction(userId: string, code: string) {
|
||||
try {
|
||||
await deleteCurrency(userId, code)
|
||||
} catch (error) {
|
||||
return { success: false, error: "Failed to delete currency" + error }
|
||||
}
|
||||
revalidatePath("/settings/currencies")
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function addCategoryAction(userId: string, data: Prisma.CategoryCreateInput) {
|
||||
const validatedForm = categoryFormSchema.safeParse(data)
|
||||
|
||||
if (!validatedForm.success) {
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
const category = await createCategory(userId, {
|
||||
code: codeFromName(validatedForm.data.name),
|
||||
name: validatedForm.data.name,
|
||||
llm_prompt: validatedForm.data.llm_prompt,
|
||||
color: validatedForm.data.color || "",
|
||||
})
|
||||
revalidatePath("/settings/categories")
|
||||
|
||||
return { success: true, category }
|
||||
}
|
||||
|
||||
export async function editCategoryAction(userId: string, code: string, data: Prisma.CategoryUpdateInput) {
|
||||
const validatedForm = categoryFormSchema.safeParse(data)
|
||||
|
||||
if (!validatedForm.success) {
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
const category = await updateCategory(userId, code, {
|
||||
name: validatedForm.data.name,
|
||||
llm_prompt: validatedForm.data.llm_prompt,
|
||||
color: validatedForm.data.color || "",
|
||||
})
|
||||
revalidatePath("/settings/categories")
|
||||
|
||||
return { success: true, category }
|
||||
}
|
||||
|
||||
export async function deleteCategoryAction(code: string, userId: string) {
|
||||
try {
|
||||
await deleteCategory(userId, code)
|
||||
} catch (error) {
|
||||
return { success: false, error: "Failed to delete category" + error }
|
||||
}
|
||||
revalidatePath("/settings/categories")
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function addFieldAction(userId: string, data: Prisma.FieldCreateInput) {
|
||||
const validatedForm = fieldFormSchema.safeParse(data)
|
||||
|
||||
if (!validatedForm.success) {
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
const field = await createField(userId, {
|
||||
code: codeFromName(validatedForm.data.name),
|
||||
name: validatedForm.data.name,
|
||||
type: validatedForm.data.type,
|
||||
llm_prompt: validatedForm.data.llm_prompt,
|
||||
isVisibleInList: validatedForm.data.isVisibleInList,
|
||||
isVisibleInAnalysis: validatedForm.data.isVisibleInAnalysis,
|
||||
isExtra: true,
|
||||
})
|
||||
revalidatePath("/settings/fields")
|
||||
|
||||
return { success: true, field }
|
||||
}
|
||||
|
||||
export async function editFieldAction(userId: string, code: string, data: Prisma.FieldUpdateInput) {
|
||||
const validatedForm = fieldFormSchema.safeParse(data)
|
||||
|
||||
if (!validatedForm.success) {
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
const field = await updateField(userId, code, {
|
||||
name: validatedForm.data.name,
|
||||
type: validatedForm.data.type,
|
||||
llm_prompt: validatedForm.data.llm_prompt,
|
||||
isVisibleInList: validatedForm.data.isVisibleInList,
|
||||
isVisibleInAnalysis: validatedForm.data.isVisibleInAnalysis,
|
||||
})
|
||||
revalidatePath("/settings/fields")
|
||||
|
||||
return { success: true, field }
|
||||
}
|
||||
|
||||
export async function deleteFieldAction(userId: string, code: string) {
|
||||
try {
|
||||
await deleteField(userId, code)
|
||||
} catch (error) {
|
||||
return { success: false, error: "Failed to delete field" + error }
|
||||
}
|
||||
revalidatePath("/settings/fields")
|
||||
return { success: true }
|
||||
}
|
||||
145
app/(app)/settings/backups/actions.ts
Normal file
145
app/(app)/settings/backups/actions.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
"use server"
|
||||
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { getUserUploadsDirectory } from "@/lib/files"
|
||||
import { MODEL_BACKUP, modelFromJSON } from "@/models/backups"
|
||||
import fs from "fs/promises"
|
||||
import JSZip from "jszip"
|
||||
import path from "path"
|
||||
|
||||
const SUPPORTED_BACKUP_VERSIONS = ["1.0"]
|
||||
const REMOVE_EXISTING_DATA = true
|
||||
|
||||
export async function restoreBackupAction(prevState: any, formData: FormData) {
|
||||
const user = await getCurrentUser()
|
||||
const userUploadsDirectory = await getUserUploadsDirectory(user)
|
||||
const file = formData.get("file") as File
|
||||
|
||||
if (!file || file.size === 0) {
|
||||
return { success: false, error: "No file provided" }
|
||||
}
|
||||
|
||||
// Read zip archive
|
||||
let zip: JSZip
|
||||
try {
|
||||
const fileBuffer = await file.arrayBuffer()
|
||||
const fileData = Buffer.from(fileBuffer)
|
||||
zip = await JSZip.loadAsync(fileData)
|
||||
} catch (error) {
|
||||
return { success: false, error: "Bad zip archive" }
|
||||
}
|
||||
|
||||
if (REMOVE_EXISTING_DATA) {
|
||||
await cleanupUserTables(user.id)
|
||||
await fs.rm(userUploadsDirectory, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
// Check metadata and start restoring
|
||||
try {
|
||||
const metadataFile = zip.file("data/metadata.json")
|
||||
if (metadataFile) {
|
||||
const metadataContent = await metadataFile.async("string")
|
||||
try {
|
||||
const metadata = JSON.parse(metadataContent)
|
||||
if (!metadata.version || !SUPPORTED_BACKUP_VERSIONS.includes(metadata.version)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Incompatible backup version: ${
|
||||
metadata.version || "unknown"
|
||||
}. Supported versions: ${SUPPORTED_BACKUP_VERSIONS.join(", ")}`,
|
||||
}
|
||||
}
|
||||
console.log(`Restoring backup version ${metadata.version} created at ${metadata.timestamp}`)
|
||||
} catch (error) {
|
||||
console.warn("Could not parse backup metadata:", error)
|
||||
}
|
||||
} else {
|
||||
console.warn("No metadata found in backup, assuming legacy format")
|
||||
}
|
||||
|
||||
const counters: Record<string, number> = {}
|
||||
|
||||
// Restore tables
|
||||
for (const backup of MODEL_BACKUP) {
|
||||
try {
|
||||
const jsonFile = zip.file(`data/${backup.filename}`)
|
||||
if (jsonFile) {
|
||||
const jsonContent = await jsonFile.async("string")
|
||||
const restoredCount = await modelFromJSON(user.id, backup, jsonContent)
|
||||
console.log(`Restored ${restoredCount} records from ${backup.filename}`)
|
||||
counters[backup.filename] = restoredCount
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error restoring model from ${backup.filename}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore files
|
||||
try {
|
||||
let restoredFilesCount = 0
|
||||
const files = await prisma.file.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
|
||||
const userUploadsDirectory = await getUserUploadsDirectory(user)
|
||||
|
||||
for (const file of files) {
|
||||
const filePathWithoutPrefix = file.path.replace(/^.*\/uploads\//, "")
|
||||
const zipFilePath = path.join("data/uploads", filePathWithoutPrefix)
|
||||
const zipFile = zip.file(zipFilePath)
|
||||
if (!zipFile) {
|
||||
console.log(`File ${file.path} not found in backup`)
|
||||
continue
|
||||
}
|
||||
|
||||
const fullFilePath = path.join(userUploadsDirectory, filePathWithoutPrefix)
|
||||
const fileContent = await zipFile.async("nodebuffer")
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.dirname(fullFilePath), { recursive: true })
|
||||
await fs.writeFile(fullFilePath, fileContent)
|
||||
restoredFilesCount++
|
||||
} catch (error) {
|
||||
console.error(`Error writing file ${fullFilePath}:`, error)
|
||||
continue
|
||||
}
|
||||
|
||||
await prisma.file.update({
|
||||
where: { id: file.id },
|
||||
data: {
|
||||
path: filePathWithoutPrefix,
|
||||
},
|
||||
})
|
||||
}
|
||||
counters["Uploaded attachments"] = restoredFilesCount
|
||||
} catch (error) {
|
||||
console.error("Error restoring uploaded files:", error)
|
||||
return {
|
||||
success: false,
|
||||
error: `Error restoring uploaded files: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, message: "Restore completed successfully", counters }
|
||||
} catch (error) {
|
||||
console.error("Error restoring from backup:", error)
|
||||
return {
|
||||
success: false,
|
||||
error: `Error restoring from backup: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupUserTables(userId: string) {
|
||||
// Delete in reverse order to handle foreign key constraints
|
||||
for (const { model } of [...MODEL_BACKUP].reverse()) {
|
||||
try {
|
||||
await model.deleteMany({ where: { userId } })
|
||||
} catch (error) {
|
||||
console.error(`Error clearing table:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
112
app/(app)/settings/backups/data/route.ts
Normal file
112
app/(app)/settings/backups/data/route.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { fileExists, getUserUploadsDirectory } from "@/lib/files"
|
||||
import { MODEL_BACKUP, modelToJSON } from "@/models/backups"
|
||||
import fs from "fs/promises"
|
||||
import JSZip from "jszip"
|
||||
import { NextResponse } from "next/server"
|
||||
import path from "path"
|
||||
|
||||
const MAX_FILE_SIZE = 64 * 1024 * 1024 // 64MB
|
||||
const BACKUP_VERSION = "1.0"
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const user = await getCurrentUser()
|
||||
const userUploadsDirectory = await getUserUploadsDirectory(user)
|
||||
|
||||
try {
|
||||
const zip = new JSZip()
|
||||
const rootFolder = zip.folder("data")
|
||||
if (!rootFolder) {
|
||||
console.error("Failed to create zip folder")
|
||||
return new NextResponse("Internal Server Error", { status: 500 })
|
||||
}
|
||||
|
||||
// Add metadata with version information
|
||||
rootFolder.file(
|
||||
"metadata.json",
|
||||
JSON.stringify(
|
||||
{
|
||||
version: BACKUP_VERSION,
|
||||
timestamp: new Date().toISOString(),
|
||||
models: MODEL_BACKUP.map((m) => m.filename),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
// Backup models
|
||||
for (const backup of MODEL_BACKUP) {
|
||||
try {
|
||||
const jsonContent = await modelToJSON(user.id, backup)
|
||||
rootFolder.file(backup.filename, jsonContent)
|
||||
} catch (error) {
|
||||
console.error(`Error exporting table ${backup.filename}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
const uploadsFolder = rootFolder.folder("uploads")
|
||||
if (!uploadsFolder) {
|
||||
console.error("Failed to create uploads folder")
|
||||
return new NextResponse("Internal Server Error", { status: 500 })
|
||||
}
|
||||
|
||||
const uploadedFiles = await getAllFilePaths(userUploadsDirectory)
|
||||
for (const file of uploadedFiles) {
|
||||
try {
|
||||
// Check file size before reading
|
||||
const stats = await fs.stat(file)
|
||||
if (stats.size > MAX_FILE_SIZE) {
|
||||
console.warn(
|
||||
`Skipping large file ${file} (${Math.round(stats.size / 1024 / 1024)}MB > ${
|
||||
MAX_FILE_SIZE / 1024 / 1024
|
||||
}MB limit)`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const fileContent = await fs.readFile(file)
|
||||
uploadsFolder.file(file.replace(userUploadsDirectory, ""), fileContent)
|
||||
} catch (error) {
|
||||
console.error(`Error reading file ${file}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
const archive = await zip.generateAsync({ type: "blob" })
|
||||
|
||||
return new NextResponse(archive, {
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="data.zip"`,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error exporting database:", error)
|
||||
return new NextResponse("Internal Server Error", { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
async function getAllFilePaths(dirPath: string): Promise<string[]> {
|
||||
let filePaths: string[] = []
|
||||
|
||||
async function readDirectoryRecursively(currentPath: string) {
|
||||
const isDirExists = await fileExists(currentPath)
|
||||
if (!isDirExists) {
|
||||
return
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(currentPath, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentPath, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await readDirectoryRecursively(fullPath)
|
||||
} else {
|
||||
filePaths.push(fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await readDirectoryRecursively(dirPath)
|
||||
|
||||
return filePaths
|
||||
}
|
||||
72
app/(app)/settings/backups/page.tsx
Normal file
72
app/(app)/settings/backups/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import { FormError } from "@/components/forms/error"
|
||||
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/data">
|
||||
<Button>
|
||||
<Download /> Download Data Archive
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground max-w-xl">
|
||||
Inside the archive you will find all the uploaded files, as well as JSON files for transactions, categories,
|
||||
projects, fields, currencies, and settings. You can view, edit or migrate your data to another service.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="flex flex-col gap-2 mt-16 p-5 bg-red-100 max-w-xl">
|
||||
<h2 className="text-xl font-semibold">Restore from a backup</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
⚠️ This action will delete all existing data from your current database and remove all uploaded files. Be
|
||||
careful and make a backup first!
|
||||
</p>
|
||||
<form action={restoreBackup}>
|
||||
<div className="flex flex-col gap-4 pt-4">
|
||||
<input type="hidden" name="removeExistingData" value="true" />
|
||||
<label>
|
||||
<input type="file" name="file" />
|
||||
</label>
|
||||
<Button type="submit" variant="destructive" disabled={restorePending}>
|
||||
{restorePending ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" /> Restoring from backup...
|
||||
</>
|
||||
) : (
|
||||
"Delete existing data and restore from backup"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{restoreState?.error && <FormError>{restoreState.error}</FormError>}
|
||||
</Card>
|
||||
|
||||
{restoreState?.success && (
|
||||
<Card className="flex flex-col gap-2 p-5 bg-green-100 max-w-xl">
|
||||
<h2 className="text-xl font-semibold">Backup restored successfully</h2>
|
||||
<p className="text-sm text-muted-foreground">You can now continue using the app. Import stats:</p>
|
||||
<ul className="list-disc list-inside">
|
||||
{Object.entries(restoreState.counters || {}).map(([key, value]) => (
|
||||
<li key={key}>
|
||||
<span className="font-bold">{key}</span>: {value} items
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
app/(app)/settings/categories/page.tsx
Normal file
47
app/(app)/settings/categories/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { addCategoryAction, deleteCategoryAction, editCategoryAction } from "@/app/(app)/settings/actions"
|
||||
import { CrudTable } from "@/components/settings/crud"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { randomHexColor } from "@/lib/utils"
|
||||
import { getCategories } from "@/models/categories"
|
||||
import { Prisma } from "@prisma/client"
|
||||
|
||||
export default async function CategoriesSettingsPage() {
|
||||
const user = await getCurrentUser()
|
||||
const categories = await getCategories(user.id)
|
||||
const categoriesWithActions = categories.map((category) => ({
|
||||
...category,
|
||||
isEditable: true,
|
||||
isDeletable: true,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h1 className="text-2xl font-bold mb-2">Categories</h1>
|
||||
<p className="text-sm text-gray-500 mb-6 max-w-prose">
|
||||
Create your own categories that better reflect the type of income and expenses you have. Define an LLM Prompt so
|
||||
that AI can determine this category automatically.
|
||||
</p>
|
||||
|
||||
<CrudTable
|
||||
items={categoriesWithActions}
|
||||
columns={[
|
||||
{ key: "name", label: "Name", editable: true },
|
||||
{ key: "llm_prompt", label: "LLM Prompt", editable: true },
|
||||
{ key: "color", label: "Color", defaultValue: randomHexColor(), editable: true },
|
||||
]}
|
||||
onDelete={async (code) => {
|
||||
"use server"
|
||||
return await deleteCategoryAction(user.id, code)
|
||||
}}
|
||||
onAdd={async (data) => {
|
||||
"use server"
|
||||
return await addCategoryAction(user.id, data as Prisma.CategoryCreateInput)
|
||||
}}
|
||||
onEdit={async (code, data) => {
|
||||
"use server"
|
||||
return await editCategoryAction(user.id, code, data as Prisma.CategoryUpdateInput)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
app/(app)/settings/currencies/page.tsx
Normal file
42
app/(app)/settings/currencies/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { addCurrencyAction, deleteCurrencyAction, editCurrencyAction } from "@/app/(app)/settings/actions"
|
||||
import { CrudTable } from "@/components/settings/crud"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getCurrencies } from "@/models/currencies"
|
||||
|
||||
export default async function CurrenciesSettingsPage() {
|
||||
const user = await getCurrentUser()
|
||||
const currencies = await getCurrencies(user.id)
|
||||
const currenciesWithActions = currencies.map((currency) => ({
|
||||
...currency,
|
||||
isEditable: true,
|
||||
isDeletable: true,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h1 className="text-2xl font-bold mb-2">Currencies</h1>
|
||||
<p className="text-sm text-gray-500 mb-6 max-w-prose">
|
||||
Custom currencies would not be automatically converted but you still can have them.
|
||||
</p>
|
||||
<CrudTable
|
||||
items={currenciesWithActions}
|
||||
columns={[
|
||||
{ key: "code", label: "Code", editable: true },
|
||||
{ key: "name", label: "Name", editable: true },
|
||||
]}
|
||||
onDelete={async (code) => {
|
||||
"use server"
|
||||
return await deleteCurrencyAction(user.id, code)
|
||||
}}
|
||||
onAdd={async (data) => {
|
||||
"use server"
|
||||
return await addCurrencyAction(user.id, data as { code: string; name: string })
|
||||
}}
|
||||
onEdit={async (code, data) => {
|
||||
"use server"
|
||||
return await editCurrencyAction(user.id, code, data as { name: string })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
66
app/(app)/settings/fields/page.tsx
Normal file
66
app/(app)/settings/fields/page.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { addFieldAction, deleteFieldAction, editFieldAction } from "@/app/(app)/settings/actions"
|
||||
import { CrudTable } from "@/components/settings/crud"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getFields } from "@/models/fields"
|
||||
import { Prisma } from "@prisma/client"
|
||||
|
||||
export default async function FieldsSettingsPage() {
|
||||
const user = await getCurrentUser()
|
||||
const fields = await getFields(user.id)
|
||||
const fieldsWithActions = fields.map((field) => ({
|
||||
...field,
|
||||
isEditable: true,
|
||||
isDeletable: field.isExtra,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h1 className="text-2xl font-bold mb-2">Custom Fields</h1>
|
||||
<p className="text-sm text-gray-500 mb-6 max-w-prose">
|
||||
You can add new fields to your transactions. Standard fields can't be removed but you can tweak their prompts or
|
||||
hide them. If you don't want a field to be analyzed by AI but filled in by hand, leave the "LLM prompt" empty.
|
||||
</p>
|
||||
<CrudTable
|
||||
items={fieldsWithActions}
|
||||
columns={[
|
||||
{ key: "name", label: "Name", editable: true },
|
||||
{
|
||||
key: "type",
|
||||
label: "Type",
|
||||
type: "select",
|
||||
options: ["string", "number", "boolean"],
|
||||
defaultValue: "string",
|
||||
editable: true,
|
||||
},
|
||||
{ key: "llm_prompt", label: "LLM Prompt", editable: true },
|
||||
{
|
||||
key: "isVisibleInList",
|
||||
label: "Show in transactions table",
|
||||
type: "checkbox",
|
||||
defaultValue: false,
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
key: "isVisibleInAnalysis",
|
||||
label: "Show in analysis form",
|
||||
type: "checkbox",
|
||||
defaultValue: false,
|
||||
editable: true,
|
||||
},
|
||||
]}
|
||||
onDelete={async (code) => {
|
||||
"use server"
|
||||
return await deleteFieldAction(user.id, code)
|
||||
}}
|
||||
onAdd={async (data) => {
|
||||
"use server"
|
||||
return await addFieldAction(user.id, data as Prisma.FieldCreateInput)
|
||||
}}
|
||||
onEdit={async (code, data) => {
|
||||
"use server"
|
||||
return await editFieldAction(user.id, code, data as Prisma.FieldUpdateInput)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
63
app/(app)/settings/layout.tsx
Normal file
63
app/(app)/settings/layout.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
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: "My Profile",
|
||||
href: "/settings/profile",
|
||||
},
|
||||
{
|
||||
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="space-y-6 p-10 pb-16">
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
18
app/(app)/settings/llm/page.tsx
Normal file
18
app/(app)/settings/llm/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import LLMSettingsForm from "@/components/settings/llm-settings-form"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getFields } from "@/models/fields"
|
||||
import { getSettings } from "@/models/settings"
|
||||
|
||||
export default async function LlmSettingsPage() {
|
||||
const user = await getCurrentUser()
|
||||
const settings = await getSettings(user.id)
|
||||
const fields = await getFields(user.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full max-w-2xl">
|
||||
<LLMSettingsForm settings={settings} fields={fields} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
10
app/(app)/settings/loading.tsx
Normal file
10
app/(app)/settings/loading.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<Skeleton className="h-10 w-56" />
|
||||
<Skeleton className="w-full h-[350px]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
app/(app)/settings/page.tsx
Normal file
20
app/(app)/settings/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import GlobalSettingsForm from "@/components/settings/global-settings-form"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getCategories } from "@/models/categories"
|
||||
import { getCurrencies } from "@/models/currencies"
|
||||
import { getSettings } from "@/models/settings"
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const user = await getCurrentUser()
|
||||
const settings = await getSettings(user.id)
|
||||
const currencies = await getCurrencies(user.id)
|
||||
const categories = await getCategories(user.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full max-w-2xl">
|
||||
<GlobalSettingsForm settings={settings} currencies={currencies} categories={categories} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
14
app/(app)/settings/profile/page.tsx
Normal file
14
app/(app)/settings/profile/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import ProfileSettingsForm from "@/components/settings/profile-settings-form copy"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
|
||||
export default async function ProfileSettingsPage() {
|
||||
const user = await getCurrentUser()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full max-w-2xl">
|
||||
<ProfileSettingsForm user={user} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
46
app/(app)/settings/projects/page.tsx
Normal file
46
app/(app)/settings/projects/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { addProjectAction, deleteProjectAction, editProjectAction } from "@/app/(app)/settings/actions"
|
||||
import { CrudTable } from "@/components/settings/crud"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { randomHexColor } from "@/lib/utils"
|
||||
import { getProjects } from "@/models/projects"
|
||||
import { Prisma } from "@prisma/client"
|
||||
|
||||
export default async function ProjectsSettingsPage() {
|
||||
const user = await getCurrentUser()
|
||||
const projects = await getProjects(user.id)
|
||||
const projectsWithActions = projects.map((project) => ({
|
||||
...project,
|
||||
isEditable: true,
|
||||
isDeletable: true,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h1 className="text-2xl font-bold mb-2">Projects</h1>
|
||||
<p className="text-sm text-gray-500 mb-6 max-w-prose">
|
||||
Use projects to differentiate between the type of activities you do For example: Freelancing, YouTube channel,
|
||||
Blogging. Projects are just a convenient way to separate statistics.
|
||||
</p>
|
||||
<CrudTable
|
||||
items={projectsWithActions}
|
||||
columns={[
|
||||
{ key: "name", label: "Name", editable: true },
|
||||
{ key: "llm_prompt", label: "LLM Prompt", editable: true },
|
||||
{ key: "color", label: "Color", defaultValue: randomHexColor(), editable: true },
|
||||
]}
|
||||
onDelete={async (code) => {
|
||||
"use server"
|
||||
return await deleteProjectAction(user.id, code)
|
||||
}}
|
||||
onAdd={async (data) => {
|
||||
"use server"
|
||||
return await addProjectAction(user.id, data as Prisma.ProjectCreateInput)
|
||||
}}
|
||||
onEdit={async (code, data) => {
|
||||
"use server"
|
||||
return await editProjectAction(user.id, code, data as Prisma.ProjectUpdateInput)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
app/(app)/transactions/[transactionId]/layout.tsx
Normal file
30
app/(app)/transactions/[transactionId]/layout.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getTransactionById } from "@/models/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 user = await getCurrentUser()
|
||||
const transaction = await getTransactionById(transactionId, user.id)
|
||||
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
10
app/(app)/transactions/[transactionId]/loading.tsx
Normal file
10
app/(app)/transactions/[transactionId]/loading.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex flex-wrap flex-row items-start justify-center gap-4 max-w-6xl">
|
||||
<Skeleton className="w-full h-[800px]" />
|
||||
<Skeleton className="w-1/3 max-w-[380px]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
67
app/(app)/transactions/[transactionId]/page.tsx
Normal file
67
app/(app)/transactions/[transactionId]/page.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
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 { getCurrentUser } from "@/lib/auth"
|
||||
import { getCategories } from "@/models/categories"
|
||||
import { getCurrencies } from "@/models/currencies"
|
||||
import { getFields } from "@/models/fields"
|
||||
import { getFilesByTransactionId } from "@/models/files"
|
||||
import { getProjects } from "@/models/projects"
|
||||
import { getSettings } from "@/models/settings"
|
||||
import { getTransactionById } from "@/models/transactions"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
export default async function TransactionPage({ params }: { params: Promise<{ transactionId: string }> }) {
|
||||
const { transactionId } = await params
|
||||
const user = await getCurrentUser()
|
||||
const transaction = await getTransactionById(transactionId, user.id)
|
||||
if (!transaction) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const files = await getFilesByTransactionId(transactionId, user.id)
|
||||
const categories = await getCategories(user.id)
|
||||
const currencies = await getCurrencies(user.id)
|
||||
const settings = await getSettings(user.id)
|
||||
const fields = await getFields(user.id)
|
||||
const projects = await getProjects(user.id)
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap flex-row items-start justify-center gap-4 max-w-6xl">
|
||||
<Card className="w-full flex-1 flex flex-col flex-wrap justify-center items-start gap-10 p-5 bg-accent">
|
||||
<div className="w-full">
|
||||
<TransactionEditForm
|
||||
transaction={transaction}
|
||||
categories={categories}
|
||||
currencies={currencies}
|
||||
settings={settings}
|
||||
fields={fields}
|
||||
projects={projects}
|
||||
/>
|
||||
|
||||
{transaction.text && (
|
||||
<details className="mt-10">
|
||||
<summary className="cursor-pointer text-sm font-medium">Recognized Text</summary>
|
||||
<Card className="flex items-stretch p-2 max-w-6xl">
|
||||
<div className="flex-1">
|
||||
<FormTextarea
|
||||
title=""
|
||||
name="text"
|
||||
defaultValue={transaction.text || ""}
|
||||
hideIfEmpty={true}
|
||||
className="w-full h-[400px]"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="w-1/2 max-w-[400px] space-y-4">
|
||||
<TransactionFiles transaction={transaction} files={files} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
192
app/(app)/transactions/actions.ts
Normal file
192
app/(app)/transactions/actions.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
"use server"
|
||||
|
||||
import { transactionFormSchema } from "@/forms/transactions"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getTransactionFileUploadPath, getUserUploadsDirectory } from "@/lib/files"
|
||||
import { updateField } from "@/models/fields"
|
||||
import { createFile, deleteFile } from "@/models/files"
|
||||
import {
|
||||
bulkDeleteTransactions,
|
||||
createTransaction,
|
||||
deleteTransaction,
|
||||
getTransactionById,
|
||||
updateTransaction,
|
||||
updateTransactionFiles,
|
||||
} from "@/models/transactions"
|
||||
import { randomUUID } from "crypto"
|
||||
import { mkdir, writeFile } from "fs/promises"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import path from "path"
|
||||
|
||||
export async function createTransactionAction(prevState: any, formData: FormData) {
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
const validatedForm = transactionFormSchema.safeParse(Object.fromEntries(formData.entries()))
|
||||
|
||||
if (!validatedForm.success) {
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
const transaction = await createTransaction(user.id, 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 user = await getCurrentUser()
|
||||
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, user.id, 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 user = await getCurrentUser()
|
||||
const transaction = await getTransactionById(transactionId, user.id)
|
||||
if (!transaction) throw new Error("Transaction not found")
|
||||
|
||||
await deleteTransaction(transaction.id, user.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 user = await getCurrentUser()
|
||||
const transaction = await getTransactionById(transactionId, user.id)
|
||||
if (!transaction) {
|
||||
return { success: false, error: "Transaction not found" }
|
||||
}
|
||||
|
||||
await updateTransactionFiles(
|
||||
transactionId,
|
||||
user.id,
|
||||
transaction.files ? (transaction.files as string[]).filter((id) => id !== fileId) : []
|
||||
)
|
||||
|
||||
await deleteFile(fileId, user.id)
|
||||
revalidatePath(`/transactions/${transactionId}`)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function uploadTransactionFilesAction(formData: FormData): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const transactionId = formData.get("transactionId") as string
|
||||
const files = formData.getAll("files") as File[]
|
||||
|
||||
if (!files || !transactionId) {
|
||||
return { success: false, error: "No files or transaction ID provided" }
|
||||
}
|
||||
|
||||
const user = await getCurrentUser()
|
||||
const transaction = await getTransactionById(transactionId, user.id)
|
||||
if (!transaction) {
|
||||
return { success: false, error: "Transaction not found" }
|
||||
}
|
||||
|
||||
const userUploadsDirectory = await getUserUploadsDirectory(user)
|
||||
|
||||
const fileRecords = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const fileUuid = randomUUID()
|
||||
const relativeFilePath = await getTransactionFileUploadPath(fileUuid, file.name, transaction)
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
|
||||
const fullFilePath = path.join(userUploadsDirectory, relativeFilePath)
|
||||
await mkdir(path.dirname(fullFilePath), { recursive: true })
|
||||
|
||||
console.log("userUploadsDirectory", userUploadsDirectory)
|
||||
console.log("relativeFilePath", relativeFilePath)
|
||||
console.log("fullFilePath", fullFilePath)
|
||||
|
||||
await writeFile(fullFilePath, buffer)
|
||||
|
||||
// Create file record in database
|
||||
const fileRecord = await createFile(user.id, {
|
||||
id: fileUuid,
|
||||
filename: file.name,
|
||||
path: relativeFilePath,
|
||||
mimetype: file.type,
|
||||
isReviewed: true,
|
||||
metadata: {
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
},
|
||||
})
|
||||
|
||||
return fileRecord
|
||||
})
|
||||
)
|
||||
|
||||
// Update invoice with the new file ID
|
||||
await updateTransactionFiles(
|
||||
transactionId,
|
||||
user.id,
|
||||
transaction.files
|
||||
? [...(transaction.files as string[]), ...fileRecords.map((file) => file.id)]
|
||||
: fileRecords.map((file) => file.id)
|
||||
)
|
||||
|
||||
revalidatePath(`/transactions/${transactionId}`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("Upload error:", error)
|
||||
return { success: false, error: `File upload failed: ${error}` }
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkDeleteTransactionsAction(transactionIds: string[]) {
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
await bulkDeleteTransactions(transactionIds, user.id)
|
||||
revalidatePath("/transactions")
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("Failed to delete transactions:", error)
|
||||
return { success: false, error: "Failed to delete transactions" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateFieldVisibilityAction(fieldCode: string, isVisible: boolean) {
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
await updateField(user.id, fieldCode, {
|
||||
isVisibleInList: isVisible,
|
||||
})
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("Failed to update field visibility:", error)
|
||||
return { success: false, error: "Failed to update field visibility" }
|
||||
}
|
||||
}
|
||||
3
app/(app)/transactions/layout.tsx
Normal file
3
app/(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>
|
||||
}
|
||||
41
app/(app)/transactions/loading.tsx
Normal file
41
app/(app)/transactions/loading.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
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="flex flex-row gap-3 md:gap-5">
|
||||
<span className="text-3xl font-bold tracking-tight">Transactions</span>
|
||||
<Skeleton className="h-10 w-16" />
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline">
|
||||
<Download />
|
||||
Export
|
||||
</Button>
|
||||
<Button>
|
||||
<Plus /> Add Transaction
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-row gap-2 w-full">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
{[...Array(15)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-8" />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
91
app/(app)/transactions/page.tsx
Normal file
91
app/(app)/transactions/page.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
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 { Pagination } from "@/components/transactions/pagination"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getCategories } from "@/models/categories"
|
||||
import { getFields } from "@/models/fields"
|
||||
import { getProjects } from "@/models/projects"
|
||||
import { getTransactions, TransactionFilters } from "@/models/transactions"
|
||||
import { Download, Plus, Upload } from "lucide-react"
|
||||
import { Metadata } from "next"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Transactions",
|
||||
description: "Manage your transactions",
|
||||
}
|
||||
|
||||
const TRANSACTIONS_PER_PAGE = 1000
|
||||
|
||||
export default async function TransactionsPage({ searchParams }: { searchParams: Promise<TransactionFilters> }) {
|
||||
const { page, ...filters } = await searchParams
|
||||
const user = await getCurrentUser()
|
||||
const { transactions, total } = await getTransactions(user.id, filters, {
|
||||
limit: TRANSACTIONS_PER_PAGE,
|
||||
offset: ((page ?? 1) - 1) * TRANSACTIONS_PER_PAGE,
|
||||
})
|
||||
const categories = await getCategories(user.id)
|
||||
const projects = await getProjects(user.id)
|
||||
const fields = await getFields(user.id)
|
||||
|
||||
// Reset page if user clicks a filter and no transactions are found
|
||||
if (page && page > 1 && transactions.length === 0) {
|
||||
const params = new URLSearchParams(filters as Record<string, string>)
|
||||
redirect(`?${params.toString()}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="flex flex-wrap items-center justify-between gap-2 mb-8">
|
||||
<h2 className="flex flex-row gap-3 md:gap-5">
|
||||
<span className="text-3xl font-bold tracking-tight">Transactions</span>
|
||||
<span className="text-3xl tracking-tight opacity-20">{total}</span>
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<ExportTransactionsDialog 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} fields={fields} />
|
||||
|
||||
<main>
|
||||
<TransactionList transactions={transactions} fields={fields} />
|
||||
|
||||
{total > TRANSACTIONS_PER_PAGE && <Pagination totalItems={total} itemsPerPage={TRANSACTIONS_PER_PAGE} />}
|
||||
|
||||
{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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
117
app/(app)/unsorted/actions.ts
Normal file
117
app/(app)/unsorted/actions.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
"use server"
|
||||
|
||||
import { analyzeTransaction } from "@/ai/analyze"
|
||||
import { AnalyzeAttachment, loadAttachmentsForAI } from "@/ai/attachments"
|
||||
import { buildLLMPrompt } from "@/ai/prompt"
|
||||
import { fieldsToJsonSchema } from "@/ai/schema"
|
||||
import { transactionFormSchema } from "@/forms/transactions"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { IS_SELF_HOSTED_MODE } from "@/lib/constants"
|
||||
import { getTransactionFileUploadPath, getUserUploadsDirectory } from "@/lib/files"
|
||||
import { DEFAULT_PROMPT_ANALYSE_NEW_FILE } from "@/models/defaults"
|
||||
import { deleteFile, getFileById, updateFile } from "@/models/files"
|
||||
import { createTransaction, updateTransactionFiles } from "@/models/transactions"
|
||||
import { Category, Field, File, Project } from "@prisma/client"
|
||||
import { mkdir, rename } from "fs/promises"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import path from "path"
|
||||
|
||||
export async function analyzeFileAction(
|
||||
file: File,
|
||||
settings: Record<string, string>,
|
||||
fields: Field[],
|
||||
categories: Category[],
|
||||
projects: Project[]
|
||||
): Promise<{ success: boolean; data?: Record<string, any>; error?: string }> {
|
||||
const user = await getCurrentUser()
|
||||
|
||||
if (!file || file.userId !== user.id) {
|
||||
return { success: false, error: "File not found or does not belong to the user" }
|
||||
}
|
||||
|
||||
let attachments: AnalyzeAttachment[] = []
|
||||
try {
|
||||
attachments = await loadAttachmentsForAI(user, file)
|
||||
} catch (error) {
|
||||
console.error("Failed to retrieve files:", error)
|
||||
return { success: false, error: "Failed to retrieve files: " + error }
|
||||
}
|
||||
|
||||
const prompt = buildLLMPrompt(
|
||||
settings.prompt_analyse_new_file || DEFAULT_PROMPT_ANALYSE_NEW_FILE,
|
||||
fields,
|
||||
categories,
|
||||
projects
|
||||
)
|
||||
|
||||
const schema = fieldsToJsonSchema(fields)
|
||||
|
||||
const results = await analyzeTransaction(
|
||||
prompt,
|
||||
schema,
|
||||
attachments,
|
||||
IS_SELF_HOSTED_MODE ? settings.openai_api_key : process.env.OPENAI_API_KEY || ""
|
||||
)
|
||||
|
||||
console.log("Analysis results:", results)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
export async function saveFileAsTransactionAction(prevState: any, formData: FormData) {
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
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, user.id)
|
||||
if (!file) throw new Error("File not found")
|
||||
|
||||
// Create transaction
|
||||
const transaction = await createTransaction(user.id, validatedForm.data)
|
||||
|
||||
// Move file to processed location
|
||||
const userUploadsDirectory = await getUserUploadsDirectory(user)
|
||||
const originalFileName = path.basename(file.path)
|
||||
const newRelativeFilePath = await getTransactionFileUploadPath(file.id, originalFileName, transaction)
|
||||
|
||||
// Move file to new location and name
|
||||
const oldFullFilePath = path.join(userUploadsDirectory, file.path)
|
||||
const newFullFilePath = path.join(userUploadsDirectory, newRelativeFilePath)
|
||||
await mkdir(path.dirname(newFullFilePath), { recursive: true })
|
||||
await rename(path.resolve(oldFullFilePath), path.resolve(newFullFilePath))
|
||||
|
||||
// Update file record
|
||||
await updateFile(file.id, user.id, {
|
||||
path: newRelativeFilePath,
|
||||
isReviewed: true,
|
||||
})
|
||||
|
||||
await updateTransactionFiles(transaction.id, user.id, [file.id])
|
||||
|
||||
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 {
|
||||
const user = await getCurrentUser()
|
||||
await deleteFile(fileId, user.id)
|
||||
revalidatePath("/unsorted")
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("Failed to delete file:", error)
|
||||
return { success: false, error: "Failed to delete file" }
|
||||
}
|
||||
}
|
||||
3
app/(app)/unsorted/layout.tsx
Normal file
3
app/(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>
|
||||
}
|
||||
31
app/(app)/unsorted/loading.tsx
Normal file
31
app/(app)/unsorted/loading.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex items-center justify-between">
|
||||
<h2 className="text-3xl font-bold tracking-tight flex flex-row gap-2">
|
||||
<Loader2 className="h-10 w-10 animate-spin" /> <span>Loading unsorted files...</span>
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<Skeleton className="w-full h-[800px] flex flex-row flex-wrap md:flex-nowrap justify-center items-start gap-5 p-6">
|
||||
<Skeleton className="w-full h-full" />
|
||||
<div className="w-full flex flex-col gap-5">
|
||||
<Skeleton className="w-full h-12 mb-7" />
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
<Skeleton className="w-[120px] h-4" />
|
||||
<Skeleton className="w-full h-9" />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-row justify-end gap-2 mt-2">
|
||||
<Skeleton className="w-[80px] h-9" />
|
||||
<Skeleton className="w-[130px] h-9" />
|
||||
</div>
|
||||
</div>
|
||||
</Skeleton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
106
app/(app)/unsorted/page.tsx
Normal file
106
app/(app)/unsorted/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
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 AnalyzeForm from "@/components/unsorted/analyze-form"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { IS_SELF_HOSTED_MODE } from "@/lib/constants"
|
||||
import { getCategories } from "@/models/categories"
|
||||
import { getCurrencies } from "@/models/currencies"
|
||||
import { getFields } from "@/models/fields"
|
||||
import { getUnsortedFiles } from "@/models/files"
|
||||
import { getProjects } from "@/models/projects"
|
||||
import { getSettings } from "@/models/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 user = await getCurrentUser()
|
||||
const files = await getUnsortedFiles(user.id)
|
||||
const categories = await getCategories(user.id)
|
||||
const projects = await getProjects(user.id)
|
||||
const currencies = await getCurrencies(user.id)
|
||||
const fields = await getFields(user.id)
|
||||
const settings = await getSettings(user.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="flex items-center justify-between">
|
||||
<h2 className="text-3xl font-bold tracking-tight">You have {files.length} unsorted files</h2>
|
||||
</header>
|
||||
|
||||
{IS_SELF_HOSTED_MODE && !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