mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 13:25:18 +00:00
feat: pagination + hide fields in settings
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user