BREAKING: postgres + saas

This commit is contained in:
Vasily Zubarev
2025-04-03 13:07:54 +02:00
parent 54a892ddb0
commit f523b1f8ba
136 changed files with 3971 additions and 1563 deletions

View 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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" }
}
}

View File

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

View File

@@ -0,0 +1,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>
</>
)
}

View 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>
</>
)
}