feat: pagination + hide fields in settings

This commit is contained in:
Vasily Zubarev
2025-03-27 08:48:47 +01:00
parent a80684c3fb
commit 61da617f68
25 changed files with 813 additions and 220 deletions

View File

@@ -15,7 +15,7 @@ export async function GET(request: Request) {
const fields = (url.searchParams.get("fields")?.split(",") ?? []) as ExportFields
const includeAttachments = url.searchParams.get("includeAttachments") === "true"
const transactions = await getTransactions(filters)
const { transactions } = await getTransactions(filters)
const existingFields = await getFields()
// Generate CSV file with all transactions

View File

@@ -1,12 +1,19 @@
"use server"
import { settingsFormSchema } from "@/forms/settings"
import { codeFromName } from "@/lib/utils"
import {
categoryFormSchema,
currencyFormSchema,
fieldFormSchema,
projectFormSchema,
settingsFormSchema,
} from "@/forms/settings"
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 { Prisma } from "@prisma/client"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
@@ -26,106 +33,180 @@ export async function saveSettingsAction(prevState: any, formData: FormData) {
// return { success: true }
}
export async function addProjectAction(data: { name: string; llm_prompt?: string; color?: string }) {
export async function addProjectAction(data: Prisma.ProjectCreateInput) {
const validatedForm = projectFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const project = await createProject({
code: codeFromName(data.name),
name: data.name,
llm_prompt: data.llm_prompt || null,
color: data.color || "#000000",
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 project
return { success: true, project }
}
export async function editProjectAction(code: string, data: { name: string; llm_prompt?: string; color?: string }) {
export async function editProjectAction(code: string, data: Prisma.ProjectUpdateInput) {
const validatedForm = projectFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const project = await updateProject(code, {
name: data.name,
llm_prompt: data.llm_prompt,
color: data.color,
name: validatedForm.data.name,
llm_prompt: validatedForm.data.llm_prompt,
color: validatedForm.data.color || "",
})
revalidatePath("/settings/projects")
return project
return { success: true, project }
}
export async function deleteProjectAction(code: string) {
await deleteProject(code)
try {
await deleteProject(code)
} catch (error) {
return { success: false, error: "Failed to delete project" + error }
}
revalidatePath("/settings/projects")
return { success: true }
}
export async function addCurrencyAction(data: { code: string; name: string }) {
export async function addCurrencyAction(data: Prisma.CurrencyCreateInput) {
const validatedForm = currencyFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const currency = await createCurrency({
code: data.code,
name: data.name,
code: validatedForm.data.code,
name: validatedForm.data.name,
})
revalidatePath("/settings/currencies")
return currency
return { success: true, currency }
}
export async function editCurrencyAction(code: string, data: { name: string }) {
const currency = await updateCurrency(code, { name: data.name })
export async function editCurrencyAction(code: string, data: Prisma.CurrencyUpdateInput) {
const validatedForm = currencyFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const currency = await updateCurrency(code, { name: validatedForm.data.name })
revalidatePath("/settings/currencies")
return currency
return { success: true, currency }
}
export async function deleteCurrencyAction(code: string) {
await deleteCurrency(code)
try {
await deleteCurrency(code)
} catch (error) {
return { success: false, error: "Failed to delete currency" + error }
}
revalidatePath("/settings/currencies")
return { success: true }
}
export async function addCategoryAction(data: { name: string; llm_prompt?: string; color?: string }) {
export async function addCategoryAction(data: Prisma.CategoryCreateInput) {
const validatedForm = categoryFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const category = await createCategory({
code: codeFromName(data.name),
name: data.name,
llm_prompt: data.llm_prompt,
color: data.color,
code: codeFromName(validatedForm.data.name),
name: validatedForm.data.name,
llm_prompt: validatedForm.data.llm_prompt,
color: validatedForm.data.color || "",
})
revalidatePath("/settings/categories")
return category
return { success: true, category }
}
export async function editCategoryAction(code: string, data: { name: string; llm_prompt?: string; color?: string }) {
export async function editCategoryAction(code: string, data: Prisma.CategoryUpdateInput) {
const validatedForm = categoryFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const category = await updateCategory(code, {
name: data.name,
llm_prompt: data.llm_prompt,
color: data.color,
name: validatedForm.data.name,
llm_prompt: validatedForm.data.llm_prompt,
color: validatedForm.data.color || "",
})
revalidatePath("/settings/categories")
return category
return { success: true, category }
}
export async function deleteCategoryAction(code: string) {
await deleteCategory(code)
try {
await deleteCategory(code)
} catch (error) {
return { success: false, error: "Failed to delete category" + error }
}
revalidatePath("/settings/categories")
return { success: true }
}
export async function addFieldAction(data: { name: string; type: string; llm_prompt?: string; isRequired?: boolean }) {
export async function addFieldAction(data: Prisma.FieldCreateInput) {
const validatedForm = fieldFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const field = await createField({
code: codeFromName(data.name),
name: data.name,
type: data.type,
llm_prompt: data.llm_prompt,
isRequired: data.isRequired,
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 field
return { success: true, field }
}
export async function editFieldAction(
code: string,
data: { name: string; type: string; llm_prompt?: string; isRequired?: boolean }
) {
export async function editFieldAction(code: string, data: Prisma.FieldUpdateInput) {
const validatedForm = fieldFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const field = await updateField(code, {
name: data.name,
type: data.type,
llm_prompt: data.llm_prompt,
isRequired: data.isRequired,
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 field
return { success: true, field }
}
export async function deleteFieldAction(code: string) {
await deleteField(code)
try {
await deleteField(code)
} catch (error) {
return { success: false, error: "Failed to delete field" + error }
}
revalidatePath("/settings/fields")
return { success: true }
}

View File

@@ -2,6 +2,7 @@ import { addCategoryAction, deleteCategoryAction, editCategoryAction } from "@/a
import { CrudTable } from "@/components/settings/crud"
import { randomHexColor } from "@/lib/utils"
import { getCategories } from "@/models/categories"
import { Prisma } from "@prisma/client"
export default async function CategoriesSettingsPage() {
const categories = await getCategories()
@@ -13,7 +14,12 @@ export default async function CategoriesSettingsPage() {
return (
<div className="container">
<h1 className="text-2xl font-bold mb-6">Categories</h1>
<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={[
@@ -23,29 +29,15 @@ export default async function CategoriesSettingsPage() {
]}
onDelete={async (code) => {
"use server"
await deleteCategoryAction(code)
return await deleteCategoryAction(code)
}}
onAdd={async (data) => {
"use server"
await addCategoryAction(
data as {
code: string
name: string
llm_prompt?: string
color: string
}
)
return await addCategoryAction(data as Prisma.CategoryCreateInput)
}}
onEdit={async (code, data) => {
"use server"
await editCategoryAction(
code,
data as {
name: string
llm_prompt?: string
color?: string
}
)
return await editCategoryAction(code, data as Prisma.CategoryUpdateInput)
}}
/>
</div>

View File

@@ -12,7 +12,10 @@ export default async function CurrenciesSettingsPage() {
return (
<div className="container">
<h1 className="text-2xl font-bold mb-6">Currencies</h1>
<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={[
@@ -21,15 +24,15 @@ export default async function CurrenciesSettingsPage() {
]}
onDelete={async (code) => {
"use server"
await deleteCurrencyAction(code)
return await deleteCurrencyAction(code)
}}
onAdd={async (data) => {
"use server"
await addCurrencyAction(data as { code: string; name: string })
return await addCurrencyAction(data as { code: string; name: string })
}}
onEdit={async (code, data) => {
"use server"
await editCurrencyAction(code, data as { name: string })
return await editCurrencyAction(code, data as { name: string })
}}
/>
</div>

View File

@@ -1,6 +1,7 @@
import { addFieldAction, deleteFieldAction, editFieldAction } from "@/app/settings/actions"
import { CrudTable } from "@/components/settings/crud"
import { getFields } from "@/models/fields"
import { Prisma } from "@prisma/client"
export default async function FieldsSettingsPage() {
const fields = await getFields()
@@ -12,38 +13,50 @@ export default async function FieldsSettingsPage() {
return (
<div className="container">
<h1 className="text-2xl font-bold mb-6">Custom Fields</h1>
<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", defaultValue: "string", 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"
await deleteFieldAction(code)
return await deleteFieldAction(code)
}}
onAdd={async (data) => {
"use server"
await addFieldAction(
data as {
name: string
type: string
llm_prompt?: string
}
)
return await addFieldAction(data as Prisma.FieldCreateInput)
}}
onEdit={async (code, data) => {
"use server"
await editFieldAction(
code,
data as {
name: string
type: string
llm_prompt?: string
}
)
return await editFieldAction(code, data as Prisma.FieldUpdateInput)
}}
/>
</div>

View File

@@ -2,6 +2,7 @@ import { addProjectAction, deleteProjectAction, editProjectAction } from "@/app/
import { CrudTable } from "@/components/settings/crud"
import { randomHexColor } from "@/lib/utils"
import { getProjects } from "@/models/projects"
import { Prisma } from "@prisma/client"
export default async function ProjectsSettingsPage() {
const projects = await getProjects()
@@ -13,7 +14,11 @@ export default async function ProjectsSettingsPage() {
return (
<div className="container">
<h1 className="text-2xl font-bold mb-6">Projects</h1>
<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={[
@@ -23,15 +28,15 @@ export default async function ProjectsSettingsPage() {
]}
onDelete={async (code) => {
"use server"
await deleteProjectAction(code)
return await deleteProjectAction(code)
}}
onAdd={async (data) => {
"use server"
await addProjectAction(data as { code: string; name: string; llm_prompt: string; color: string })
return await addProjectAction(data as Prisma.ProjectCreateInput)
}}
onEdit={async (code, data) => {
"use server"
await editProjectAction(code, data as { name: string; llm_prompt: string; color: string })
return await editProjectAction(code, data as Prisma.ProjectUpdateInput)
}}
/>
</div>

View File

@@ -57,7 +57,7 @@ export default async function TransactionPage({ params }: { params: Promise<{ tr
</div>
</Card>
<div className="w-1/3 max-w-[380px] space-y-4">
<div className="w-1/2 max-w-[400px] space-y-4">
<TransactionFiles transaction={transaction} files={files} />
</div>
</div>

View File

@@ -3,6 +3,7 @@ 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 { getCategories } from "@/models/categories"
import { getFields } from "@/models/fields"
@@ -10,25 +11,37 @@ 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 filters = await searchParams
const transactions = await getTransactions(filters)
const { page, ...filters } = await searchParams
const { transactions, total } = await getTransactions(filters, {
limit: TRANSACTIONS_PER_PAGE,
offset: ((page ?? 1) - 1) * TRANSACTIONS_PER_PAGE,
})
const categories = await getCategories()
const projects = await getProjects()
const fields = await getFields()
// 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">{transactions.length}</span>
<span className="text-3xl tracking-tight opacity-20">{total}</span>
</h2>
<div className="flex gap-2">
<ExportTransactionsDialog fields={fields} categories={categories} projects={projects}>
@@ -50,6 +63,8 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
<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">