diff --git a/app/export/transactions/route.ts b/app/export/transactions/route.ts
index 79e8e8b..c16fc0a 100644
--- a/app/export/transactions/route.ts
+++ b/app/export/transactions/route.ts
@@ -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
diff --git a/app/settings/actions.ts b/app/settings/actions.ts
index 5892610..75070f7 100644
--- a/app/settings/actions.ts
+++ b/app/settings/actions.ts
@@ -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 }
}
diff --git a/app/settings/categories/page.tsx b/app/settings/categories/page.tsx
index 741eb32..70e528d 100644
--- a/app/settings/categories/page.tsx
+++ b/app/settings/categories/page.tsx
@@ -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 (
-
Categories
+
Categories
+
+ 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.
+
+
{
"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)
}}
/>
diff --git a/app/settings/currencies/page.tsx b/app/settings/currencies/page.tsx
index 299c9af..d68c34a 100644
--- a/app/settings/currencies/page.tsx
+++ b/app/settings/currencies/page.tsx
@@ -12,7 +12,10 @@ export default async function CurrenciesSettingsPage() {
return (
-
Currencies
+
Currencies
+
+ Custom currencies would not be automatically converted but you still can have them.
+
{
"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 })
}}
/>
diff --git a/app/settings/fields/page.tsx b/app/settings/fields/page.tsx
index baae4c5..f5b3e36 100644
--- a/app/settings/fields/page.tsx
+++ b/app/settings/fields/page.tsx
@@ -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 (
-
Custom Fields
+
Custom Fields
+
+ 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.
+
{
"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)
}}
/>
diff --git a/app/settings/projects/page.tsx b/app/settings/projects/page.tsx
index 430783d..72e83ad 100644
--- a/app/settings/projects/page.tsx
+++ b/app/settings/projects/page.tsx
@@ -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 (
-
Projects
+
Projects
+
+ 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.
+
{
"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)
}}
/>
diff --git a/app/transactions/[transactionId]/page.tsx b/app/transactions/[transactionId]/page.tsx
index fcd3323..580fae9 100644
--- a/app/transactions/[transactionId]/page.tsx
+++ b/app/transactions/[transactionId]/page.tsx
@@ -57,7 +57,7 @@ export default async function TransactionPage({ params }: { params: Promise<{ tr
-
diff --git a/app/transactions/page.tsx b/app/transactions/page.tsx
index c88fa00..867c2ec 100644
--- a/app/transactions/page.tsx
+++ b/app/transactions/page.tsx
@@ -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 }) {
- 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)
+ redirect(`?${params.toString()}`)
+ }
+
return (
<>
Transactions
- {transactions.length}
+ {total}
@@ -50,6 +63,8 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
+ {total > TRANSACTIONS_PER_PAGE && }
+
{transactions.length === 0 && (
diff --git a/components/export/transactions.tsx b/components/export/transactions.tsx
index dc51cdf..fdb1c6b 100644
--- a/components/export/transactions.tsx
+++ b/components/export/transactions.tsx
@@ -43,7 +43,12 @@ export function ExportTransactionsDialog({
const handleSubmit = () => {
router.push(
`/export/transactions?${new URLSearchParams({
- ...exportFilters,
+ search: exportFilters?.search || "",
+ dateFrom: exportFilters?.dateFrom || "",
+ dateTo: exportFilters?.dateTo || "",
+ ordering: exportFilters?.ordering || "",
+ categoryCode: exportFilters?.categoryCode || "",
+ projectCode: exportFilters?.projectCode || "",
fields: exportFields.join(","),
includeAttachments: includeAttachments.toString(),
}).toString()}`
diff --git a/components/files/preview.tsx b/components/files/preview.tsx
index 2b3ac74..c52bd4c 100644
--- a/components/files/preview.tsx
+++ b/components/files/preview.tsx
@@ -41,12 +41,12 @@ export function FilePreview({ file }: { file: File }) {
Type: {file.mimetype}
+ {/*
+ Uploaded: {format(file.createdAt, "MMM d, yyyy")}
+
*/}
Size: {fileSize < 1 ? (fileSize * 1024).toFixed(2) + " KB" : fileSize.toFixed(2) + " MB"}
-
- Path: {file.path}
-
>
diff --git a/components/forms/select-category.tsx b/components/forms/select-category.tsx
index 0a4bce1..1d0a37c 100644
--- a/components/forms/select-category.tsx
+++ b/components/forms/select-category.tsx
@@ -10,11 +10,27 @@ export const FormSelectCategory = ({
categories,
emptyValue,
placeholder,
+ hideIfEmpty = false,
...props
-}: { title: string; categories: Category[]; emptyValue?: string; placeholder?: string } & SelectProps) => {
+}: {
+ title: string
+ categories: Category[]
+ emptyValue?: string
+ placeholder?: string
+ hideIfEmpty?: boolean
+} & SelectProps) => {
const items = useMemo(
() => categories.map((category) => ({ code: category.code, name: category.name, color: category.color })),
[categories]
)
- return
+ return (
+
+ )
}
diff --git a/components/forms/select-currency.tsx b/components/forms/select-currency.tsx
index f4d2fc7..8299b10 100644
--- a/components/forms/select-currency.tsx
+++ b/components/forms/select-currency.tsx
@@ -8,11 +8,27 @@ export const FormSelectCurrency = ({
currencies,
emptyValue,
placeholder,
+ hideIfEmpty = false,
...props
-}: { title: string; currencies: Currency[]; emptyValue?: string; placeholder?: string } & SelectProps) => {
+}: {
+ title: string
+ currencies: Currency[]
+ emptyValue?: string
+ placeholder?: string
+ hideIfEmpty?: boolean
+} & SelectProps) => {
const items = useMemo(
() => currencies.map((currency) => ({ code: currency.code, name: `${currency.code} - ${currency.name}` })),
[currencies]
)
- return
+ return (
+
+ )
}
diff --git a/components/forms/select-project.tsx b/components/forms/select-project.tsx
index 56c4ffd..e578d99 100644
--- a/components/forms/select-project.tsx
+++ b/components/forms/select-project.tsx
@@ -7,14 +7,22 @@ export const FormSelectProject = ({
projects,
emptyValue,
placeholder,
+ hideIfEmpty = false,
...props
-}: { title: string; projects: Project[]; emptyValue?: string; placeholder?: string } & SelectProps) => {
+}: {
+ title: string
+ projects: Project[]
+ emptyValue?: string
+ placeholder?: string
+ hideIfEmpty?: boolean
+} & SelectProps) => {
return (
({ code: project.code, name: project.name, color: project.color }))}
emptyValue={emptyValue}
placeholder={placeholder}
+ hideIfEmpty={hideIfEmpty}
{...props}
/>
)
diff --git a/components/forms/select-type.tsx b/components/forms/select-type.tsx
index e63f67c..310e1f5 100644
--- a/components/forms/select-type.tsx
+++ b/components/forms/select-type.tsx
@@ -5,8 +5,9 @@ export const FormSelectType = ({
title,
emptyValue,
placeholder,
+ hideIfEmpty = false,
...props
-}: { title: string; emptyValue?: string; placeholder?: string } & SelectProps) => {
+}: { title: string; emptyValue?: string; placeholder?: string; hideIfEmpty?: boolean } & SelectProps) => {
const items = [
{ code: "expense", name: "Expense" },
{ code: "income", name: "Income" },
@@ -14,5 +15,14 @@ export const FormSelectType = ({
{ code: "other", name: "Other" },
]
- return
+ return (
+
+ )
}
diff --git a/components/forms/simple.tsx b/components/forms/simple.tsx
index 47ef3f3..6d72eee 100644
--- a/components/forms/simple.tsx
+++ b/components/forms/simple.tsx
@@ -53,13 +53,19 @@ export const FormSelect = ({
items,
emptyValue,
placeholder,
+ hideIfEmpty = false,
...props
}: {
title: string
items: Array<{ code: string; name: string; color?: string }>
emptyValue?: string
placeholder?: string
+ hideIfEmpty?: boolean
} & SelectProps) => {
+ if (hideIfEmpty && (!props.defaultValue || props.defaultValue.toString().trim() === "") && !props.value) {
+ return null
+ }
+
return (
{title}
diff --git a/components/settings/crud.tsx b/components/settings/crud.tsx
index f7533a3..0d9cd94 100644
--- a/components/settings/crud.tsx
+++ b/components/settings/crud.tsx
@@ -3,35 +3,146 @@
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
-import { CircleCheck, Edit, Trash2 } from "lucide-react"
+import { Check, Edit, Trash2 } from "lucide-react"
import { useOptimistic, useState } from "react"
+interface CrudColumn {
+ key: keyof T
+ label: string
+ type?: "text" | "number" | "checkbox" | "select"
+ options?: string[]
+ defaultValue?: string | boolean
+ editable?: boolean
+}
+
interface CrudProps {
items: T[]
- columns: {
- key: keyof T
- label: string
- type?: "text" | "number" | "checkbox"
- defaultValue?: string
- editable?: boolean
- }[]
- onDelete: (id: string) => Promise
- onAdd: (data: Partial) => Promise
- onEdit?: (id: string, data: Partial) => Promise
+ columns: CrudColumn[]
+ onDelete: (id: string) => Promise<{ success: boolean; error?: string }>
+ onAdd: (data: Partial) => Promise<{ success: boolean; error?: string }>
+ onEdit?: (id: string, data: Partial) => Promise<{ success: boolean; error?: string }>
}
export function CrudTable({ items, columns, onDelete, onAdd, onEdit }: CrudProps) {
const [isAdding, setIsAdding] = useState(false)
const [editingId, setEditingId] = useState(null)
- const [newItem, setNewItem] = useState>({})
- const [editingItem, setEditingItem] = useState>({})
+ const [newItem, setNewItem] = useState>(itemDefaults(columns))
+ const [editingItem, setEditingItem] = useState>(itemDefaults(columns))
const [optimisticItems, addOptimisticItem] = useOptimistic(items, (state, newItem: T) => [...state, newItem])
+ const FormCell = (item: T, column: CrudColumn) => {
+ if (column.type === "checkbox") {
+ return item[column.key] ? : ""
+ }
+ return item[column.key]
+ }
+
+ const EditFormCell = (item: T, column: CrudColumn) => {
+ if (column.type === "checkbox") {
+ return (
+
+ setEditingItem({
+ ...editingItem,
+ [column.key]: e.target.checked,
+ })
+ }
+ />
+ )
+ } else if (column.type === "select") {
+ return (
+
+ setEditingItem({
+ ...editingItem,
+ [column.key]: e.target.value,
+ })
+ }
+ >
+ {column.options?.map((option) => (
+
+ {option}
+
+ ))}
+
+ )
+ }
+
+ return (
+
+ setEditingItem({
+ ...editingItem,
+ [column.key]: e.target.value,
+ })
+ }
+ />
+ )
+ }
+
+ const AddFormCell = (column: CrudColumn) => {
+ if (column.type === "checkbox") {
+ return (
+
+ setNewItem({
+ ...newItem,
+ [column.key]: e.target.checked,
+ })
+ }
+ />
+ )
+ } else if (column.type === "select") {
+ return (
+
+ setNewItem({
+ ...newItem,
+ [column.key]: e.target.value,
+ })
+ }
+ >
+ {column.options?.map((option) => (
+
+ {option}
+
+ ))}
+
+ )
+ }
+ return (
+
+ setNewItem({
+ ...newItem,
+ [column.key]: e.target.value,
+ })
+ }
+ />
+ )
+ }
+
const handleAdd = async () => {
try {
- await onAdd(newItem)
- setIsAdding(false)
- setNewItem({})
+ const result = await onAdd(newItem)
+ if (result.success) {
+ setIsAdding(false)
+ setNewItem(itemDefaults(columns))
+ } else {
+ alert(result.error)
+ }
} catch (error) {
console.error("Failed to add item:", error)
}
@@ -40,9 +151,13 @@ export function CrudTable({ items, columns, on
const handleEdit = async (id: string) => {
if (!onEdit) return
try {
- await onEdit(id, editingItem)
- setEditingId(null)
- setEditingItem({})
+ const result = await onEdit(id, editingItem)
+ if (result.success) {
+ setEditingId(null)
+ setEditingItem({})
+ } else {
+ alert(result.error)
+ }
} catch (error) {
console.error("Failed to edit item:", error)
}
@@ -55,7 +170,10 @@ export function CrudTable({ items, columns, on
const handleDelete = async (id: string) => {
try {
- await onDelete(id)
+ const result = await onDelete(id)
+ if (!result.success) {
+ alert(result.error)
+ }
} catch (error) {
console.error("Failed to delete item:", error)
}
@@ -77,26 +195,9 @@ export function CrudTable({ items, columns, on
{columns.map((column) => (
- {editingId === (item.code || item.id) && column.editable ? (
-
- setEditingItem({
- ...editingItem,
- [column.key]: column.type === "checkbox" ? e.target.checked : e.target.value,
- })
- }
- />
- ) : column.type === "checkbox" ? (
- item[column.key] ? (
-
- ) : (
- ""
- )
- ) : (
- item[column.key]
- )}
+ {editingId === (item.code || item.id) && column.editable
+ ? EditFormCell(item, column)
+ : FormCell(item, column)}
))}
@@ -113,7 +214,14 @@ export function CrudTable({ items, columns, on
) : (
<>
{onEdit && (
- startEditing(item)}>
+ {
+ startEditing(item)
+ setIsAdding(false)
+ }}
+ >
)}
@@ -132,18 +240,7 @@ export function CrudTable({ items, columns, on
{columns.map((column) => (
- {column.editable && (
-
- setNewItem({
- ...newItem,
- [column.key]: column.type === "checkbox" ? e.target.checked : e.target.value,
- })
- }
- />
- )}
+ {column.editable && AddFormCell(column)}
))}
@@ -160,7 +257,23 @@ export function CrudTable({ items, columns, on
)}
- {!isAdding && setIsAdding(true)}>Add New }
+ {!isAdding && (
+ {
+ setIsAdding(true)
+ setEditingId(null)
+ }}
+ >
+ Add New
+
+ )}
)
}
+
+function itemDefaults(columns: CrudColumn[]) {
+ return columns.reduce((acc, column) => {
+ acc[column.key] = column.defaultValue as T[keyof T]
+ return acc
+ }, {} as Partial)
+}
diff --git a/components/settings/llm-settings-form.tsx b/components/settings/llm-settings-form.tsx
index f6d67ed..66bd4e7 100644
--- a/components/settings/llm-settings-form.tsx
+++ b/components/settings/llm-settings-form.tsx
@@ -27,7 +27,7 @@ export default function LLMSettingsForm({ settings, fields }: { settings: Record
{
+ const params = new URLSearchParams(searchParams.toString())
+ params.set("page", page.toString())
+ router.push(`?${params.toString()}`)
+ }
+
+ const getPageNumbers = () => {
+ const pageNumbers = []
+
+ // Show all page numbers if total pages is small
+ if (totalPages <= MAX_VISIBLE_PAGES) {
+ for (let i = 1; i <= totalPages; i++) {
+ pageNumbers.push(i)
+ }
+ } else {
+ // Always include the first page
+ pageNumbers.push(1)
+
+ // Calculate the range around the current page
+ let startPage = Math.max(2, currentPage - 1)
+ let endPage = Math.min(totalPages - 1, currentPage + 1)
+
+ // Adjust if we're near the start
+ if (currentPage <= 3) {
+ endPage = Math.min(totalPages - 1, 4)
+ }
+
+ // Adjust if we're near the end
+ if (currentPage >= totalPages - 2) {
+ startPage = Math.max(2, totalPages - 3)
+ }
+
+ // Add ellipsis after first page if needed
+ if (startPage > 2) {
+ pageNumbers.push("ellipsis-start")
+ }
+
+ // Add middle page numbers
+ for (let i = startPage; i <= endPage; i++) {
+ pageNumbers.push(i)
+ }
+
+ // Add ellipsis before last page if needed
+ if (endPage < totalPages - 1) {
+ pageNumbers.push("ellipsis-end")
+ }
+
+ // Always include the last page
+ pageNumbers.push(totalPages)
+ }
+
+ return pageNumbers
+ }
+
+ return (
+
+
+
+ {/*
+ currentPage > 1 && onPageChange(currentPage - 1)}
+ className={currentPage <= 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
+ />
+ */}
+
+ {getPageNumbers().map((pageNumber, index) =>
+ pageNumber === "ellipsis-start" || pageNumber === "ellipsis-end" ? (
+
+
+
+ ) : (
+
+ onPageChange(pageNumber as number)}
+ className="cursor-pointer"
+ >
+ {pageNumber}
+
+
+ )
+ )}
+
+ {/*
+ currentPage < totalPages && onPageChange(currentPage + 1)}
+ className={currentPage >= totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
+ />
+ */}
+
+
+
+ )
+}
diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx
index c6fdd07..e40d744 100644
--- a/components/ui/checkbox.tsx
+++ b/components/ui/checkbox.tsx
@@ -1,8 +1,8 @@
"use client"
-import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
+import * as React from "react"
import { cn } from "@/lib/utils"
@@ -13,14 +13,12 @@ const Checkbox = React.forwardRef<
-
+
diff --git a/components/ui/pagination.tsx b/components/ui/pagination.tsx
new file mode 100644
index 0000000..d331105
--- /dev/null
+++ b/components/ui/pagination.tsx
@@ -0,0 +1,117 @@
+import * as React from "react"
+import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { ButtonProps, buttonVariants } from "@/components/ui/button"
+
+const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
+
+)
+Pagination.displayName = "Pagination"
+
+const PaginationContent = React.forwardRef<
+ HTMLUListElement,
+ React.ComponentProps<"ul">
+>(({ className, ...props }, ref) => (
+
+))
+PaginationContent.displayName = "PaginationContent"
+
+const PaginationItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentProps<"li">
+>(({ className, ...props }, ref) => (
+
+))
+PaginationItem.displayName = "PaginationItem"
+
+type PaginationLinkProps = {
+ isActive?: boolean
+} & Pick &
+ React.ComponentProps<"a">
+
+const PaginationLink = ({
+ className,
+ isActive,
+ size = "icon",
+ ...props
+}: PaginationLinkProps) => (
+
+)
+PaginationLink.displayName = "PaginationLink"
+
+const PaginationPrevious = ({
+ className,
+ ...props
+}: React.ComponentProps) => (
+
+
+ Previous
+
+)
+PaginationPrevious.displayName = "PaginationPrevious"
+
+const PaginationNext = ({
+ className,
+ ...props
+}: React.ComponentProps) => (
+
+ Next
+
+
+)
+PaginationNext.displayName = "PaginationNext"
+
+const PaginationEllipsis = ({
+ className,
+ ...props
+}: React.ComponentProps<"span">) => (
+
+
+ More pages
+
+)
+PaginationEllipsis.displayName = "PaginationEllipsis"
+
+export {
+ Pagination,
+ PaginationContent,
+ PaginationLink,
+ PaginationItem,
+ PaginationPrevious,
+ PaginationNext,
+ PaginationEllipsis,
+}
diff --git a/components/unsorted/analyze-form.tsx b/components/unsorted/analyze-form.tsx
index d571f08..f548c2b 100644
--- a/components/unsorted/analyze-form.tsx
+++ b/components/unsorted/analyze-form.tsx
@@ -38,6 +38,14 @@ export default function AnalyzeForm({
const [isSaving, setIsSaving] = useState(false)
const [saveError, setSaveError] = useState("")
+ const fieldsMap = useMemo(
+ () =>
+ fields.reduce((acc, field) => {
+ acc[field.code] = field
+ return acc
+ }, {} as Record),
+ [fields]
+ )
const extraFields = useMemo(() => fields.filter((field) => field.isExtra), [fields])
const initialFormState = useMemo(
() => ({
@@ -151,6 +159,7 @@ export default function AnalyzeForm({
name="merchant"
value={formData.merchant}
onChange={(e) => setFormData((prev) => ({ ...prev, merchant: e.target.value }))}
+ hideIfEmpty={!fieldsMap["merchant"]?.isVisibleInAnalysis}
/>
setFormData((prev) => ({ ...prev, description: e.target.value }))}
- hideIfEmpty={true}
+ hideIfEmpty={!fieldsMap["description"]?.isVisibleInAnalysis}
/>
@@ -182,6 +191,7 @@ export default function AnalyzeForm({
name="currencyCode"
value={formData.currencyCode}
onValueChange={(value) => setFormData((prev) => ({ ...prev, currencyCode: value }))}
+ hideIfEmpty={!fieldsMap["currencyCode"]?.isVisibleInAnalysis}
/>
setFormData((prev) => ({ ...prev, type: value }))}
+ hideIfEmpty={!fieldsMap["type"]?.isVisibleInAnalysis}
/>
@@ -212,7 +223,7 @@ export default function AnalyzeForm({
name="issuedAt"
value={formData.issuedAt}
onChange={(e) => setFormData((prev) => ({ ...prev, issuedAt: e.target.value }))}
- hideIfEmpty={true}
+ hideIfEmpty={!fieldsMap["issuedAt"]?.isVisibleInAnalysis}
/>
@@ -224,9 +235,10 @@ export default function AnalyzeForm({
value={formData.categoryCode}
onValueChange={(value) => setFormData((prev) => ({ ...prev, categoryCode: value }))}
placeholder="Select Category"
+ hideIfEmpty={!fieldsMap["categoryCode"]?.isVisibleInAnalysis}
/>
- {projects.length >= 0 && (
+ {projects.length > 0 && (
setFormData((prev) => ({ ...prev, projectCode: value }))}
placeholder="Select Project"
+ hideIfEmpty={!fieldsMap["projectCode"]?.isVisibleInAnalysis}
/>
)}
@@ -243,7 +256,7 @@ export default function AnalyzeForm({
name="note"
value={formData.note}
onChange={(e) => setFormData((prev) => ({ ...prev, note: e.target.value }))}
- hideIfEmpty={true}
+ hideIfEmpty={!fieldsMap["note"]?.isVisibleInAnalysis}
/>
{extraFields.map((field) => (
@@ -254,7 +267,7 @@ export default function AnalyzeForm({
name={field.code}
value={formData[field.code as keyof typeof formData]}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.code]: e.target.value }))}
- hideIfEmpty={true}
+ hideIfEmpty={!field.isVisibleInAnalysis}
/>
))}
@@ -264,7 +277,7 @@ export default function AnalyzeForm({
name="text"
value={formData.text}
onChange={(e) => setFormData((prev) => ({ ...prev, text: e.target.value }))}
- hideIfEmpty={true}
+ hideIfEmpty={!fieldsMap["text"]?.isVisibleInAnalysis}
/>
diff --git a/forms/settings.ts b/forms/settings.ts
index 3324757..20a9e87 100644
--- a/forms/settings.ts
+++ b/forms/settings.ts
@@ -1,3 +1,4 @@
+import { randomHexColor } from "@/lib/utils"
import { z } from "zod"
export const settingsFormSchema = z.object({
@@ -10,3 +11,28 @@ export const settingsFormSchema = z.object({
prompt_analyse_new_file: z.string().optional(),
is_welcome_message_hidden: z.boolean().optional(),
})
+
+export const currencyFormSchema = z.object({
+ code: z.string().max(5),
+ name: z.string().max(32),
+})
+
+export const projectFormSchema = z.object({
+ name: z.string().max(128),
+ llm_prompt: z.string().max(512).nullable().optional(),
+ color: z.string().max(7).default(randomHexColor()).nullable().optional(),
+})
+
+export const categoryFormSchema = z.object({
+ name: z.string().max(128),
+ llm_prompt: z.string().max(512).nullable().optional(),
+ color: z.string().max(7).default(randomHexColor()).nullable().optional(),
+})
+
+export const fieldFormSchema = z.object({
+ name: z.string().max(128),
+ type: z.string().max(128).default("string"),
+ llm_prompt: z.string().max(512).nullable().optional(),
+ isVisibleInList: z.boolean().optional(),
+ isVisibleInAnalysis: z.boolean().optional(),
+})
diff --git a/models/transactions.ts b/models/transactions.ts
index b512f9a..788452e 100644
--- a/models/transactions.ts
+++ b/models/transactions.ts
@@ -15,54 +15,84 @@ export type TransactionFilters = {
ordering?: string
categoryCode?: string
projectCode?: string
+ page?: number
}
-export const getTransactions = cache(async (filters?: TransactionFilters): Promise => {
- const where: Prisma.TransactionWhereInput = {}
- let orderBy: Prisma.TransactionOrderByWithRelationInput = { issuedAt: "desc" }
+export type TransactionPagination = {
+ limit: number
+ offset: number
+}
- if (filters) {
- if (filters.search) {
- where.OR = [
- { name: { contains: filters.search } },
- { merchant: { contains: filters.search } },
- { description: { contains: filters.search } },
- { note: { contains: filters.search } },
- { text: { contains: filters.search } },
- ]
- }
+export const getTransactions = cache(
+ async (
+ filters?: TransactionFilters,
+ pagination?: TransactionPagination
+ ): Promise<{
+ transactions: Transaction[]
+ total: number
+ }> => {
+ const where: Prisma.TransactionWhereInput = {}
+ let orderBy: Prisma.TransactionOrderByWithRelationInput = { issuedAt: "desc" }
- if (filters.dateFrom || filters.dateTo) {
- where.issuedAt = {
- gte: filters.dateFrom ? new Date(filters.dateFrom) : undefined,
- lte: filters.dateTo ? new Date(filters.dateTo) : undefined,
+ if (filters) {
+ if (filters.search) {
+ where.OR = [
+ { name: { contains: filters.search } },
+ { merchant: { contains: filters.search } },
+ { description: { contains: filters.search } },
+ { note: { contains: filters.search } },
+ { text: { contains: filters.search } },
+ ]
+ }
+
+ if (filters.dateFrom || filters.dateTo) {
+ where.issuedAt = {
+ gte: filters.dateFrom ? new Date(filters.dateFrom) : undefined,
+ lte: filters.dateTo ? new Date(filters.dateTo) : undefined,
+ }
+ }
+
+ if (filters.categoryCode) {
+ where.categoryCode = filters.categoryCode
+ }
+
+ if (filters.projectCode) {
+ where.projectCode = filters.projectCode
+ }
+
+ if (filters.ordering) {
+ const isDesc = filters.ordering.startsWith("-")
+ const field = isDesc ? filters.ordering.slice(1) : filters.ordering
+ orderBy = { [field]: isDesc ? "desc" : "asc" }
}
}
- if (filters.categoryCode) {
- where.categoryCode = filters.categoryCode
- }
-
- if (filters.projectCode) {
- where.projectCode = filters.projectCode
- }
-
- if (filters.ordering) {
- const isDesc = filters.ordering.startsWith("-")
- const field = isDesc ? filters.ordering.slice(1) : filters.ordering
- orderBy = { [field]: isDesc ? "desc" : "asc" }
+ if (pagination) {
+ const total = await prisma.transaction.count({ where })
+ const transactions = await prisma.transaction.findMany({
+ where,
+ include: {
+ category: true,
+ project: true,
+ },
+ orderBy,
+ take: pagination?.limit,
+ skip: pagination?.offset,
+ })
+ return { transactions, total }
+ } else {
+ const transactions = await prisma.transaction.findMany({
+ where,
+ include: {
+ category: true,
+ project: true,
+ },
+ orderBy,
+ })
+ return { transactions, total: transactions.length }
}
}
-
- return await prisma.transaction.findMany({
- where,
- include: {
- category: true,
- project: true,
- },
- orderBy,
- })
-})
+)
export const getTransactionById = cache(async (id: string): Promise => {
return await prisma.transaction.findUnique({
diff --git a/package-lock.json b/package-lock.json
index 8690c26..9e98f88 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "taxhacker",
- "version": "0.1.0",
+ "version": "0.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "taxhacker",
- "version": "0.1.0",
+ "version": "0.2.1",
"dependencies": {
"@fast-csv/format": "^5.0.2",
"@fast-csv/parse": "^5.0.2",
diff --git a/prisma/seed.js b/prisma/seed.js
index 082416d..9717e2c 100644
--- a/prisma/seed.js
+++ b/prisma/seed.js
@@ -317,7 +317,7 @@ const fields = [
type: "string",
llm_prompt: "description of the transaction",
isVisibleInList: false,
- isVisibleInAnalysis: true,
+ isVisibleInAnalysis: false,
isRequired: false,
isExtra: false,
},
@@ -431,11 +431,21 @@ const fields = [
isRequired: false,
isExtra: false,
},
+ {
+ code: "vat_rate",
+ name: "VAT Rate",
+ type: "number",
+ llm_prompt: "VAT rate in percentage 0-100",
+ isVisibleInList: false,
+ isVisibleInAnalysis: false,
+ isRequired: false,
+ isExtra: true,
+ },
{
code: "vat",
name: "VAT Amount",
type: "number",
- llm_prompt: "total VAT total in currency of the invoice",
+ llm_prompt: "total VAT in currency of the invoice",
isVisibleInList: false,
isVisibleInAnalysis: false,
isRequired: false,
@@ -446,6 +456,8 @@ const fields = [
name: "Extracted Text",
type: "string",
llm_prompt: "extract all recognised text from the invoice",
+ isVisibleInList: false,
+ isVisibleInAnalysis: false,
isRequired: false,
isExtra: false,
},