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 fields = (url.searchParams.get("fields")?.split(",") ?? []) as ExportFields
|
||||||
const includeAttachments = url.searchParams.get("includeAttachments") === "true"
|
const includeAttachments = url.searchParams.get("includeAttachments") === "true"
|
||||||
|
|
||||||
const transactions = await getTransactions(filters)
|
const { transactions } = await getTransactions(filters)
|
||||||
const existingFields = await getFields()
|
const existingFields = await getFields()
|
||||||
|
|
||||||
// Generate CSV file with all transactions
|
// Generate CSV file with all transactions
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
"use server"
|
"use server"
|
||||||
|
|
||||||
import { settingsFormSchema } from "@/forms/settings"
|
import {
|
||||||
import { codeFromName } from "@/lib/utils"
|
categoryFormSchema,
|
||||||
|
currencyFormSchema,
|
||||||
|
fieldFormSchema,
|
||||||
|
projectFormSchema,
|
||||||
|
settingsFormSchema,
|
||||||
|
} from "@/forms/settings"
|
||||||
|
import { codeFromName, randomHexColor } from "@/lib/utils"
|
||||||
import { createCategory, deleteCategory, updateCategory } from "@/models/categories"
|
import { createCategory, deleteCategory, updateCategory } from "@/models/categories"
|
||||||
import { createCurrency, deleteCurrency, updateCurrency } from "@/models/currencies"
|
import { createCurrency, deleteCurrency, updateCurrency } from "@/models/currencies"
|
||||||
import { createField, deleteField, updateField } from "@/models/fields"
|
import { createField, deleteField, updateField } from "@/models/fields"
|
||||||
import { createProject, deleteProject, updateProject } from "@/models/projects"
|
import { createProject, deleteProject, updateProject } from "@/models/projects"
|
||||||
import { updateSettings } from "@/models/settings"
|
import { updateSettings } from "@/models/settings"
|
||||||
|
import { Prisma } from "@prisma/client"
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
@@ -26,106 +33,180 @@ export async function saveSettingsAction(prevState: any, formData: FormData) {
|
|||||||
// return { success: true }
|
// return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addProjectAction(data: { name: string; llm_prompt?: string; color?: string }) {
|
export async function addProjectAction(data: Prisma.ProjectCreateInput) {
|
||||||
const project = await createProject({
|
const validatedForm = projectFormSchema.safeParse(data)
|
||||||
code: codeFromName(data.name),
|
|
||||||
name: data.name,
|
if (!validatedForm.success) {
|
||||||
llm_prompt: data.llm_prompt || null,
|
return { success: false, error: validatedForm.error.message }
|
||||||
color: data.color || "#000000",
|
|
||||||
})
|
|
||||||
revalidatePath("/settings/projects")
|
|
||||||
return project
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function editProjectAction(code: string, data: { name: string; llm_prompt?: string; color?: string }) {
|
const project = await createProject({
|
||||||
const project = await updateProject(code, {
|
code: codeFromName(validatedForm.data.name),
|
||||||
name: data.name,
|
name: validatedForm.data.name,
|
||||||
llm_prompt: data.llm_prompt,
|
llm_prompt: validatedForm.data.llm_prompt || null,
|
||||||
color: data.color,
|
color: validatedForm.data.color || randomHexColor(),
|
||||||
})
|
})
|
||||||
revalidatePath("/settings/projects")
|
revalidatePath("/settings/projects")
|
||||||
return project
|
|
||||||
|
return { success: true, project }
|
||||||
|
}
|
||||||
|
|
||||||
|
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: validatedForm.data.name,
|
||||||
|
llm_prompt: validatedForm.data.llm_prompt,
|
||||||
|
color: validatedForm.data.color || "",
|
||||||
|
})
|
||||||
|
revalidatePath("/settings/projects")
|
||||||
|
|
||||||
|
return { success: true, project }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteProjectAction(code: string) {
|
export async function deleteProjectAction(code: string) {
|
||||||
|
try {
|
||||||
await deleteProject(code)
|
await deleteProject(code)
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: "Failed to delete project" + error }
|
||||||
|
}
|
||||||
revalidatePath("/settings/projects")
|
revalidatePath("/settings/projects")
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addCurrencyAction(data: Prisma.CurrencyCreateInput) {
|
||||||
|
const validatedForm = currencyFormSchema.safeParse(data)
|
||||||
|
|
||||||
|
if (!validatedForm.success) {
|
||||||
|
return { success: false, error: validatedForm.error.message }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addCurrencyAction(data: { code: string; name: string }) {
|
|
||||||
const currency = await createCurrency({
|
const currency = await createCurrency({
|
||||||
code: data.code,
|
code: validatedForm.data.code,
|
||||||
name: data.name,
|
name: validatedForm.data.name,
|
||||||
})
|
})
|
||||||
revalidatePath("/settings/currencies")
|
revalidatePath("/settings/currencies")
|
||||||
return currency
|
|
||||||
|
return { success: true, currency }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function editCurrencyAction(code: string, data: { name: string }) {
|
export async function editCurrencyAction(code: string, data: Prisma.CurrencyUpdateInput) {
|
||||||
const currency = await updateCurrency(code, { name: data.name })
|
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")
|
revalidatePath("/settings/currencies")
|
||||||
return currency
|
return { success: true, currency }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCurrencyAction(code: string) {
|
export async function deleteCurrencyAction(code: string) {
|
||||||
|
try {
|
||||||
await deleteCurrency(code)
|
await deleteCurrency(code)
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: "Failed to delete currency" + error }
|
||||||
|
}
|
||||||
revalidatePath("/settings/currencies")
|
revalidatePath("/settings/currencies")
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addCategoryAction(data: Prisma.CategoryCreateInput) {
|
||||||
|
const validatedForm = categoryFormSchema.safeParse(data)
|
||||||
|
|
||||||
|
if (!validatedForm.success) {
|
||||||
|
return { success: false, error: validatedForm.error.message }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addCategoryAction(data: { name: string; llm_prompt?: string; color?: string }) {
|
|
||||||
const category = await createCategory({
|
const category = await createCategory({
|
||||||
code: codeFromName(data.name),
|
code: codeFromName(validatedForm.data.name),
|
||||||
name: data.name,
|
name: validatedForm.data.name,
|
||||||
llm_prompt: data.llm_prompt,
|
llm_prompt: validatedForm.data.llm_prompt,
|
||||||
color: data.color,
|
color: validatedForm.data.color || "",
|
||||||
})
|
})
|
||||||
revalidatePath("/settings/categories")
|
revalidatePath("/settings/categories")
|
||||||
return category
|
|
||||||
|
return { success: true, category }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editCategoryAction(code: string, data: Prisma.CategoryUpdateInput) {
|
||||||
|
const validatedForm = categoryFormSchema.safeParse(data)
|
||||||
|
|
||||||
|
if (!validatedForm.success) {
|
||||||
|
return { success: false, error: validatedForm.error.message }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function editCategoryAction(code: string, data: { name: string; llm_prompt?: string; color?: string }) {
|
|
||||||
const category = await updateCategory(code, {
|
const category = await updateCategory(code, {
|
||||||
name: data.name,
|
name: validatedForm.data.name,
|
||||||
llm_prompt: data.llm_prompt,
|
llm_prompt: validatedForm.data.llm_prompt,
|
||||||
color: data.color,
|
color: validatedForm.data.color || "",
|
||||||
})
|
})
|
||||||
revalidatePath("/settings/categories")
|
revalidatePath("/settings/categories")
|
||||||
return category
|
|
||||||
|
return { success: true, category }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCategoryAction(code: string) {
|
export async function deleteCategoryAction(code: string) {
|
||||||
|
try {
|
||||||
await deleteCategory(code)
|
await deleteCategory(code)
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: "Failed to delete category" + error }
|
||||||
|
}
|
||||||
revalidatePath("/settings/categories")
|
revalidatePath("/settings/categories")
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addFieldAction(data: Prisma.FieldCreateInput) {
|
||||||
|
const validatedForm = fieldFormSchema.safeParse(data)
|
||||||
|
|
||||||
|
if (!validatedForm.success) {
|
||||||
|
return { success: false, error: validatedForm.error.message }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addFieldAction(data: { name: string; type: string; llm_prompt?: string; isRequired?: boolean }) {
|
|
||||||
const field = await createField({
|
const field = await createField({
|
||||||
code: codeFromName(data.name),
|
code: codeFromName(validatedForm.data.name),
|
||||||
name: data.name,
|
name: validatedForm.data.name,
|
||||||
type: data.type,
|
type: validatedForm.data.type,
|
||||||
llm_prompt: data.llm_prompt,
|
llm_prompt: validatedForm.data.llm_prompt,
|
||||||
isRequired: data.isRequired,
|
isVisibleInList: validatedForm.data.isVisibleInList,
|
||||||
|
isVisibleInAnalysis: validatedForm.data.isVisibleInAnalysis,
|
||||||
isExtra: true,
|
isExtra: true,
|
||||||
})
|
})
|
||||||
revalidatePath("/settings/fields")
|
revalidatePath("/settings/fields")
|
||||||
return field
|
|
||||||
|
return { success: true, field }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editFieldAction(code: string, data: Prisma.FieldUpdateInput) {
|
||||||
|
const validatedForm = fieldFormSchema.safeParse(data)
|
||||||
|
|
||||||
|
if (!validatedForm.success) {
|
||||||
|
return { success: false, error: validatedForm.error.message }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function editFieldAction(
|
|
||||||
code: string,
|
|
||||||
data: { name: string; type: string; llm_prompt?: string; isRequired?: boolean }
|
|
||||||
) {
|
|
||||||
const field = await updateField(code, {
|
const field = await updateField(code, {
|
||||||
name: data.name,
|
name: validatedForm.data.name,
|
||||||
type: data.type,
|
type: validatedForm.data.type,
|
||||||
llm_prompt: data.llm_prompt,
|
llm_prompt: validatedForm.data.llm_prompt,
|
||||||
isRequired: data.isRequired,
|
isVisibleInList: validatedForm.data.isVisibleInList,
|
||||||
|
isVisibleInAnalysis: validatedForm.data.isVisibleInAnalysis,
|
||||||
})
|
})
|
||||||
revalidatePath("/settings/fields")
|
revalidatePath("/settings/fields")
|
||||||
return field
|
|
||||||
|
return { success: true, field }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteFieldAction(code: string) {
|
export async function deleteFieldAction(code: string) {
|
||||||
|
try {
|
||||||
await deleteField(code)
|
await deleteField(code)
|
||||||
revalidatePath("/settings/fields")
|
} 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 { CrudTable } from "@/components/settings/crud"
|
||||||
import { randomHexColor } from "@/lib/utils"
|
import { randomHexColor } from "@/lib/utils"
|
||||||
import { getCategories } from "@/models/categories"
|
import { getCategories } from "@/models/categories"
|
||||||
|
import { Prisma } from "@prisma/client"
|
||||||
|
|
||||||
export default async function CategoriesSettingsPage() {
|
export default async function CategoriesSettingsPage() {
|
||||||
const categories = await getCategories()
|
const categories = await getCategories()
|
||||||
@@ -13,7 +14,12 @@ export default async function CategoriesSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<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
|
<CrudTable
|
||||||
items={categoriesWithActions}
|
items={categoriesWithActions}
|
||||||
columns={[
|
columns={[
|
||||||
@@ -23,29 +29,15 @@ export default async function CategoriesSettingsPage() {
|
|||||||
]}
|
]}
|
||||||
onDelete={async (code) => {
|
onDelete={async (code) => {
|
||||||
"use server"
|
"use server"
|
||||||
await deleteCategoryAction(code)
|
return await deleteCategoryAction(code)
|
||||||
}}
|
}}
|
||||||
onAdd={async (data) => {
|
onAdd={async (data) => {
|
||||||
"use server"
|
"use server"
|
||||||
await addCategoryAction(
|
return await addCategoryAction(data as Prisma.CategoryCreateInput)
|
||||||
data as {
|
|
||||||
code: string
|
|
||||||
name: string
|
|
||||||
llm_prompt?: string
|
|
||||||
color: string
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
onEdit={async (code, data) => {
|
onEdit={async (code, data) => {
|
||||||
"use server"
|
"use server"
|
||||||
await editCategoryAction(
|
return await editCategoryAction(code, data as Prisma.CategoryUpdateInput)
|
||||||
code,
|
|
||||||
data as {
|
|
||||||
name: string
|
|
||||||
llm_prompt?: string
|
|
||||||
color?: string
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ export default async function CurrenciesSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<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
|
<CrudTable
|
||||||
items={currenciesWithActions}
|
items={currenciesWithActions}
|
||||||
columns={[
|
columns={[
|
||||||
@@ -21,15 +24,15 @@ export default async function CurrenciesSettingsPage() {
|
|||||||
]}
|
]}
|
||||||
onDelete={async (code) => {
|
onDelete={async (code) => {
|
||||||
"use server"
|
"use server"
|
||||||
await deleteCurrencyAction(code)
|
return await deleteCurrencyAction(code)
|
||||||
}}
|
}}
|
||||||
onAdd={async (data) => {
|
onAdd={async (data) => {
|
||||||
"use server"
|
"use server"
|
||||||
await addCurrencyAction(data as { code: string; name: string })
|
return await addCurrencyAction(data as { code: string; name: string })
|
||||||
}}
|
}}
|
||||||
onEdit={async (code, data) => {
|
onEdit={async (code, data) => {
|
||||||
"use server"
|
"use server"
|
||||||
await editCurrencyAction(code, data as { name: string })
|
return await editCurrencyAction(code, data as { name: string })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { addFieldAction, deleteFieldAction, editFieldAction } from "@/app/settings/actions"
|
import { addFieldAction, deleteFieldAction, editFieldAction } from "@/app/settings/actions"
|
||||||
import { CrudTable } from "@/components/settings/crud"
|
import { CrudTable } from "@/components/settings/crud"
|
||||||
import { getFields } from "@/models/fields"
|
import { getFields } from "@/models/fields"
|
||||||
|
import { Prisma } from "@prisma/client"
|
||||||
|
|
||||||
export default async function FieldsSettingsPage() {
|
export default async function FieldsSettingsPage() {
|
||||||
const fields = await getFields()
|
const fields = await getFields()
|
||||||
@@ -12,38 +13,50 @@ export default async function FieldsSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<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
|
<CrudTable
|
||||||
items={fieldsWithActions}
|
items={fieldsWithActions}
|
||||||
columns={[
|
columns={[
|
||||||
{ key: "name", label: "Name", editable: true },
|
{ 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: "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) => {
|
onDelete={async (code) => {
|
||||||
"use server"
|
"use server"
|
||||||
await deleteFieldAction(code)
|
return await deleteFieldAction(code)
|
||||||
}}
|
}}
|
||||||
onAdd={async (data) => {
|
onAdd={async (data) => {
|
||||||
"use server"
|
"use server"
|
||||||
await addFieldAction(
|
return await addFieldAction(data as Prisma.FieldCreateInput)
|
||||||
data as {
|
|
||||||
name: string
|
|
||||||
type: string
|
|
||||||
llm_prompt?: string
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
onEdit={async (code, data) => {
|
onEdit={async (code, data) => {
|
||||||
"use server"
|
"use server"
|
||||||
await editFieldAction(
|
return await editFieldAction(code, data as Prisma.FieldUpdateInput)
|
||||||
code,
|
|
||||||
data as {
|
|
||||||
name: string
|
|
||||||
type: string
|
|
||||||
llm_prompt?: string
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { addProjectAction, deleteProjectAction, editProjectAction } from "@/app/
|
|||||||
import { CrudTable } from "@/components/settings/crud"
|
import { CrudTable } from "@/components/settings/crud"
|
||||||
import { randomHexColor } from "@/lib/utils"
|
import { randomHexColor } from "@/lib/utils"
|
||||||
import { getProjects } from "@/models/projects"
|
import { getProjects } from "@/models/projects"
|
||||||
|
import { Prisma } from "@prisma/client"
|
||||||
|
|
||||||
export default async function ProjectsSettingsPage() {
|
export default async function ProjectsSettingsPage() {
|
||||||
const projects = await getProjects()
|
const projects = await getProjects()
|
||||||
@@ -13,7 +14,11 @@ export default async function ProjectsSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<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
|
<CrudTable
|
||||||
items={projectsWithActions}
|
items={projectsWithActions}
|
||||||
columns={[
|
columns={[
|
||||||
@@ -23,15 +28,15 @@ export default async function ProjectsSettingsPage() {
|
|||||||
]}
|
]}
|
||||||
onDelete={async (code) => {
|
onDelete={async (code) => {
|
||||||
"use server"
|
"use server"
|
||||||
await deleteProjectAction(code)
|
return await deleteProjectAction(code)
|
||||||
}}
|
}}
|
||||||
onAdd={async (data) => {
|
onAdd={async (data) => {
|
||||||
"use server"
|
"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) => {
|
onEdit={async (code, data) => {
|
||||||
"use server"
|
"use server"
|
||||||
await editProjectAction(code, data as { name: string; llm_prompt: string; color: string })
|
return await editProjectAction(code, data as Prisma.ProjectUpdateInput)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default async function TransactionPage({ params }: { params: Promise<{ tr
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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} />
|
<TransactionFiles transaction={transaction} files={files} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { UploadButton } from "@/components/files/upload-button"
|
|||||||
import { TransactionSearchAndFilters } from "@/components/transactions/filters"
|
import { TransactionSearchAndFilters } from "@/components/transactions/filters"
|
||||||
import { TransactionList } from "@/components/transactions/list"
|
import { TransactionList } from "@/components/transactions/list"
|
||||||
import { NewTransactionDialog } from "@/components/transactions/new"
|
import { NewTransactionDialog } from "@/components/transactions/new"
|
||||||
|
import { Pagination } from "@/components/transactions/pagination"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { getCategories } from "@/models/categories"
|
import { getCategories } from "@/models/categories"
|
||||||
import { getFields } from "@/models/fields"
|
import { getFields } from "@/models/fields"
|
||||||
@@ -10,25 +11,37 @@ import { getProjects } from "@/models/projects"
|
|||||||
import { getTransactions, TransactionFilters } from "@/models/transactions"
|
import { getTransactions, TransactionFilters } from "@/models/transactions"
|
||||||
import { Download, Plus, Upload } from "lucide-react"
|
import { Download, Plus, Upload } from "lucide-react"
|
||||||
import { Metadata } from "next"
|
import { Metadata } from "next"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Transactions",
|
title: "Transactions",
|
||||||
description: "Manage your transactions",
|
description: "Manage your transactions",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TRANSACTIONS_PER_PAGE = 1000
|
||||||
|
|
||||||
export default async function TransactionsPage({ searchParams }: { searchParams: Promise<TransactionFilters> }) {
|
export default async function TransactionsPage({ searchParams }: { searchParams: Promise<TransactionFilters> }) {
|
||||||
const filters = await searchParams
|
const { page, ...filters } = await searchParams
|
||||||
const transactions = await getTransactions(filters)
|
const { transactions, total } = await getTransactions(filters, {
|
||||||
|
limit: TRANSACTIONS_PER_PAGE,
|
||||||
|
offset: ((page ?? 1) - 1) * TRANSACTIONS_PER_PAGE,
|
||||||
|
})
|
||||||
const categories = await getCategories()
|
const categories = await getCategories()
|
||||||
const projects = await getProjects()
|
const projects = await getProjects()
|
||||||
const fields = await getFields()
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="flex flex-wrap items-center justify-between gap-2 mb-8">
|
<header className="flex flex-wrap items-center justify-between gap-2 mb-8">
|
||||||
<h2 className="flex flex-row gap-3 md:gap-5">
|
<h2 className="flex flex-row gap-3 md:gap-5">
|
||||||
<span className="text-3xl font-bold tracking-tight">Transactions</span>
|
<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>
|
</h2>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<ExportTransactionsDialog fields={fields} categories={categories} projects={projects}>
|
<ExportTransactionsDialog fields={fields} categories={categories} projects={projects}>
|
||||||
@@ -50,6 +63,8 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
|
|||||||
<main>
|
<main>
|
||||||
<TransactionList transactions={transactions} fields={fields} />
|
<TransactionList transactions={transactions} fields={fields} />
|
||||||
|
|
||||||
|
{total > TRANSACTIONS_PER_PAGE && <Pagination totalItems={total} itemsPerPage={TRANSACTIONS_PER_PAGE} />}
|
||||||
|
|
||||||
{transactions.length === 0 && (
|
{transactions.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center gap-2 h-full min-h-[400px]">
|
<div className="flex flex-col items-center justify-center gap-2 h-full min-h-[400px]">
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
|
|||||||
@@ -43,7 +43,12 @@ export function ExportTransactionsDialog({
|
|||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
router.push(
|
router.push(
|
||||||
`/export/transactions?${new URLSearchParams({
|
`/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(","),
|
fields: exportFields.join(","),
|
||||||
includeAttachments: includeAttachments.toString(),
|
includeAttachments: includeAttachments.toString(),
|
||||||
}).toString()}`
|
}).toString()}`
|
||||||
|
|||||||
@@ -41,12 +41,12 @@ export function FilePreview({ file }: { file: File }) {
|
|||||||
<p className="text-sm overflow-ellipsis">
|
<p className="text-sm overflow-ellipsis">
|
||||||
<strong>Type:</strong> {file.mimetype}
|
<strong>Type:</strong> {file.mimetype}
|
||||||
</p>
|
</p>
|
||||||
|
{/* <p className="text-sm overflow-ellipsis">
|
||||||
|
<strong>Uploaded:</strong> {format(file.createdAt, "MMM d, yyyy")}
|
||||||
|
</p> */}
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
<strong>Size:</strong> {fileSize < 1 ? (fileSize * 1024).toFixed(2) + " KB" : fileSize.toFixed(2) + " MB"}
|
<strong>Size:</strong> {fileSize < 1 ? (fileSize * 1024).toFixed(2) + " KB" : fileSize.toFixed(2) + " MB"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs overflow-ellipsis">
|
|
||||||
<strong>Path:</strong> {file.path}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -10,11 +10,27 @@ export const FormSelectCategory = ({
|
|||||||
categories,
|
categories,
|
||||||
emptyValue,
|
emptyValue,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
hideIfEmpty = false,
|
||||||
...props
|
...props
|
||||||
}: { title: string; categories: Category[]; emptyValue?: string; placeholder?: string } & SelectProps) => {
|
}: {
|
||||||
|
title: string
|
||||||
|
categories: Category[]
|
||||||
|
emptyValue?: string
|
||||||
|
placeholder?: string
|
||||||
|
hideIfEmpty?: boolean
|
||||||
|
} & SelectProps) => {
|
||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
() => categories.map((category) => ({ code: category.code, name: category.name, color: category.color })),
|
() => categories.map((category) => ({ code: category.code, name: category.name, color: category.color })),
|
||||||
[categories]
|
[categories]
|
||||||
)
|
)
|
||||||
return <FormSelect title={title} items={items} emptyValue={emptyValue} placeholder={placeholder} {...props} />
|
return (
|
||||||
|
<FormSelect
|
||||||
|
title={title}
|
||||||
|
items={items}
|
||||||
|
emptyValue={emptyValue}
|
||||||
|
placeholder={placeholder}
|
||||||
|
hideIfEmpty={hideIfEmpty}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,27 @@ export const FormSelectCurrency = ({
|
|||||||
currencies,
|
currencies,
|
||||||
emptyValue,
|
emptyValue,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
hideIfEmpty = false,
|
||||||
...props
|
...props
|
||||||
}: { title: string; currencies: Currency[]; emptyValue?: string; placeholder?: string } & SelectProps) => {
|
}: {
|
||||||
|
title: string
|
||||||
|
currencies: Currency[]
|
||||||
|
emptyValue?: string
|
||||||
|
placeholder?: string
|
||||||
|
hideIfEmpty?: boolean
|
||||||
|
} & SelectProps) => {
|
||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
() => currencies.map((currency) => ({ code: currency.code, name: `${currency.code} - ${currency.name}` })),
|
() => currencies.map((currency) => ({ code: currency.code, name: `${currency.code} - ${currency.name}` })),
|
||||||
[currencies]
|
[currencies]
|
||||||
)
|
)
|
||||||
return <FormSelect title={title} items={items} emptyValue={emptyValue} placeholder={placeholder} {...props} />
|
return (
|
||||||
|
<FormSelect
|
||||||
|
title={title}
|
||||||
|
items={items}
|
||||||
|
emptyValue={emptyValue}
|
||||||
|
placeholder={placeholder}
|
||||||
|
hideIfEmpty={hideIfEmpty}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,22 @@ export const FormSelectProject = ({
|
|||||||
projects,
|
projects,
|
||||||
emptyValue,
|
emptyValue,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
hideIfEmpty = false,
|
||||||
...props
|
...props
|
||||||
}: { title: string; projects: Project[]; emptyValue?: string; placeholder?: string } & SelectProps) => {
|
}: {
|
||||||
|
title: string
|
||||||
|
projects: Project[]
|
||||||
|
emptyValue?: string
|
||||||
|
placeholder?: string
|
||||||
|
hideIfEmpty?: boolean
|
||||||
|
} & SelectProps) => {
|
||||||
return (
|
return (
|
||||||
<FormSelect
|
<FormSelect
|
||||||
title={title}
|
title={title}
|
||||||
items={projects.map((project) => ({ code: project.code, name: project.name, color: project.color }))}
|
items={projects.map((project) => ({ code: project.code, name: project.name, color: project.color }))}
|
||||||
emptyValue={emptyValue}
|
emptyValue={emptyValue}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
hideIfEmpty={hideIfEmpty}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ export const FormSelectType = ({
|
|||||||
title,
|
title,
|
||||||
emptyValue,
|
emptyValue,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
hideIfEmpty = false,
|
||||||
...props
|
...props
|
||||||
}: { title: string; emptyValue?: string; placeholder?: string } & SelectProps) => {
|
}: { title: string; emptyValue?: string; placeholder?: string; hideIfEmpty?: boolean } & SelectProps) => {
|
||||||
const items = [
|
const items = [
|
||||||
{ code: "expense", name: "Expense" },
|
{ code: "expense", name: "Expense" },
|
||||||
{ code: "income", name: "Income" },
|
{ code: "income", name: "Income" },
|
||||||
@@ -14,5 +15,14 @@ export const FormSelectType = ({
|
|||||||
{ code: "other", name: "Other" },
|
{ code: "other", name: "Other" },
|
||||||
]
|
]
|
||||||
|
|
||||||
return <FormSelect title={title} items={items} emptyValue={emptyValue} placeholder={placeholder} {...props} />
|
return (
|
||||||
|
<FormSelect
|
||||||
|
title={title}
|
||||||
|
items={items}
|
||||||
|
emptyValue={emptyValue}
|
||||||
|
placeholder={placeholder}
|
||||||
|
hideIfEmpty={hideIfEmpty}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,13 +53,19 @@ export const FormSelect = ({
|
|||||||
items,
|
items,
|
||||||
emptyValue,
|
emptyValue,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
hideIfEmpty = false,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
items: Array<{ code: string; name: string; color?: string }>
|
items: Array<{ code: string; name: string; color?: string }>
|
||||||
emptyValue?: string
|
emptyValue?: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
|
hideIfEmpty?: boolean
|
||||||
} & SelectProps) => {
|
} & SelectProps) => {
|
||||||
|
if (hideIfEmpty && (!props.defaultValue || props.defaultValue.toString().trim() === "") && !props.value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="flex flex-col gap-1">
|
<span className="flex flex-col gap-1">
|
||||||
<span className="text-sm font-medium">{title}</span>
|
<span className="text-sm font-medium">{title}</span>
|
||||||
|
|||||||
@@ -3,35 +3,146 @@
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
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"
|
import { useOptimistic, useState } from "react"
|
||||||
|
|
||||||
|
interface CrudColumn<T> {
|
||||||
|
key: keyof T
|
||||||
|
label: string
|
||||||
|
type?: "text" | "number" | "checkbox" | "select"
|
||||||
|
options?: string[]
|
||||||
|
defaultValue?: string | boolean
|
||||||
|
editable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface CrudProps<T> {
|
interface CrudProps<T> {
|
||||||
items: T[]
|
items: T[]
|
||||||
columns: {
|
columns: CrudColumn<T>[]
|
||||||
key: keyof T
|
onDelete: (id: string) => Promise<{ success: boolean; error?: string }>
|
||||||
label: string
|
onAdd: (data: Partial<T>) => Promise<{ success: boolean; error?: string }>
|
||||||
type?: "text" | "number" | "checkbox"
|
onEdit?: (id: string, data: Partial<T>) => Promise<{ success: boolean; error?: string }>
|
||||||
defaultValue?: string
|
|
||||||
editable?: boolean
|
|
||||||
}[]
|
|
||||||
onDelete: (id: string) => Promise<void>
|
|
||||||
onAdd: (data: Partial<T>) => Promise<void>
|
|
||||||
onEdit?: (id: string, data: Partial<T>) => Promise<void>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CrudTable<T extends { [key: string]: any }>({ items, columns, onDelete, onAdd, onEdit }: CrudProps<T>) {
|
export function CrudTable<T extends { [key: string]: any }>({ items, columns, onDelete, onAdd, onEdit }: CrudProps<T>) {
|
||||||
const [isAdding, setIsAdding] = useState(false)
|
const [isAdding, setIsAdding] = useState(false)
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [newItem, setNewItem] = useState<Partial<T>>({})
|
const [newItem, setNewItem] = useState<Partial<T>>(itemDefaults(columns))
|
||||||
const [editingItem, setEditingItem] = useState<Partial<T>>({})
|
const [editingItem, setEditingItem] = useState<Partial<T>>(itemDefaults(columns))
|
||||||
const [optimisticItems, addOptimisticItem] = useOptimistic(items, (state, newItem: T) => [...state, newItem])
|
const [optimisticItems, addOptimisticItem] = useOptimistic(items, (state, newItem: T) => [...state, newItem])
|
||||||
|
|
||||||
|
const FormCell = (item: T, column: CrudColumn<T>) => {
|
||||||
|
if (column.type === "checkbox") {
|
||||||
|
return item[column.key] ? <Check /> : ""
|
||||||
|
}
|
||||||
|
return item[column.key]
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditFormCell = (item: T, column: CrudColumn<T>) => {
|
||||||
|
if (column.type === "checkbox") {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editingItem[column.key]}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingItem({
|
||||||
|
...editingItem,
|
||||||
|
[column.key]: e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else if (column.type === "select") {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={editingItem[column.key]}
|
||||||
|
className="p-2 rounded-md border bg-transparent"
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingItem({
|
||||||
|
...editingItem,
|
||||||
|
[column.key]: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{column.options?.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={editingItem[column.key] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingItem({
|
||||||
|
...editingItem,
|
||||||
|
[column.key]: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddFormCell = (column: CrudColumn<T>) => {
|
||||||
|
if (column.type === "checkbox") {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(newItem[column.key] || column.defaultValue)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewItem({
|
||||||
|
...newItem,
|
||||||
|
[column.key]: e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else if (column.type === "select") {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={String(newItem[column.key] || column.defaultValue || "")}
|
||||||
|
className="p-2 rounded-md border bg-transparent"
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewItem({
|
||||||
|
...newItem,
|
||||||
|
[column.key]: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{column.options?.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type={column.type || "text"}
|
||||||
|
value={String(newItem[column.key] || column.defaultValue || "")}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewItem({
|
||||||
|
...newItem,
|
||||||
|
[column.key]: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
try {
|
try {
|
||||||
await onAdd(newItem)
|
const result = await onAdd(newItem)
|
||||||
|
if (result.success) {
|
||||||
setIsAdding(false)
|
setIsAdding(false)
|
||||||
setNewItem({})
|
setNewItem(itemDefaults(columns))
|
||||||
|
} else {
|
||||||
|
alert(result.error)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to add item:", error)
|
console.error("Failed to add item:", error)
|
||||||
}
|
}
|
||||||
@@ -40,9 +151,13 @@ export function CrudTable<T extends { [key: string]: any }>({ items, columns, on
|
|||||||
const handleEdit = async (id: string) => {
|
const handleEdit = async (id: string) => {
|
||||||
if (!onEdit) return
|
if (!onEdit) return
|
||||||
try {
|
try {
|
||||||
await onEdit(id, editingItem)
|
const result = await onEdit(id, editingItem)
|
||||||
|
if (result.success) {
|
||||||
setEditingId(null)
|
setEditingId(null)
|
||||||
setEditingItem({})
|
setEditingItem({})
|
||||||
|
} else {
|
||||||
|
alert(result.error)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to edit item:", error)
|
console.error("Failed to edit item:", error)
|
||||||
}
|
}
|
||||||
@@ -55,7 +170,10 @@ export function CrudTable<T extends { [key: string]: any }>({ items, columns, on
|
|||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await onDelete(id)
|
const result = await onDelete(id)
|
||||||
|
if (!result.success) {
|
||||||
|
alert(result.error)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete item:", error)
|
console.error("Failed to delete item:", error)
|
||||||
}
|
}
|
||||||
@@ -77,26 +195,9 @@ export function CrudTable<T extends { [key: string]: any }>({ items, columns, on
|
|||||||
<TableRow key={index}>
|
<TableRow key={index}>
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
<TableCell key={String(column.key)} className="first:font-semibold">
|
<TableCell key={String(column.key)} className="first:font-semibold">
|
||||||
{editingId === (item.code || item.id) && column.editable ? (
|
{editingId === (item.code || item.id) && column.editable
|
||||||
<Input
|
? EditFormCell(item, column)
|
||||||
type={column.type || "text"}
|
: FormCell(item, column)}
|
||||||
value={editingItem[column.key] || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditingItem({
|
|
||||||
...editingItem,
|
|
||||||
[column.key]: column.type === "checkbox" ? e.target.checked : e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : column.type === "checkbox" ? (
|
|
||||||
item[column.key] ? (
|
|
||||||
<CircleCheck />
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
item[column.key]
|
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -113,7 +214,14 @@ export function CrudTable<T extends { [key: string]: any }>({ items, columns, on
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<Button variant="ghost" size="icon" onClick={() => startEditing(item)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
startEditing(item)
|
||||||
|
setIsAdding(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Edit />
|
<Edit />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -132,18 +240,7 @@ export function CrudTable<T extends { [key: string]: any }>({ items, columns, on
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
<TableCell key={String(column.key)} className="first:font-semibold">
|
<TableCell key={String(column.key)} className="first:font-semibold">
|
||||||
{column.editable && (
|
{column.editable && AddFormCell(column)}
|
||||||
<Input
|
|
||||||
type={column.type || "text"}
|
|
||||||
value={newItem[column.key] || column.defaultValue || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewItem({
|
|
||||||
...newItem,
|
|
||||||
[column.key]: column.type === "checkbox" ? e.target.checked : e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -160,7 +257,23 @@ export function CrudTable<T extends { [key: string]: any }>({ items, columns, on
|
|||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
{!isAdding && <Button onClick={() => setIsAdding(true)}>Add New</Button>}
|
{!isAdding && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setIsAdding(true)
|
||||||
|
setEditingId(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add New
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function itemDefaults<T>(columns: CrudColumn<T>[]) {
|
||||||
|
return columns.reduce((acc, column) => {
|
||||||
|
acc[column.key] = column.defaultValue as T[keyof T]
|
||||||
|
return acc
|
||||||
|
}, {} as Partial<T>)
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function LLMSettingsForm({ settings, fields }: { settings: Record
|
|||||||
</small>
|
</small>
|
||||||
|
|
||||||
<FormTextarea
|
<FormTextarea
|
||||||
title="Prompt for Analyze Transaction"
|
title="Prompt for File Analysis Form"
|
||||||
name="prompt_analyse_new_file"
|
name="prompt_analyse_new_file"
|
||||||
defaultValue={settings.prompt_analyse_new_file}
|
defaultValue={settings.prompt_analyse_new_file}
|
||||||
className="h-96"
|
className="h-96"
|
||||||
|
|||||||
114
components/transactions/pagination.tsx
Normal file
114
components/transactions/pagination.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
Pagination as PaginationRoot,
|
||||||
|
} from "@/components/ui/pagination"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
|
||||||
|
const MAX_VISIBLE_PAGES = 5
|
||||||
|
|
||||||
|
export function Pagination({ totalItems, itemsPerPage = 1000 }: { totalItems: number; itemsPerPage: number }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalItems / itemsPerPage)
|
||||||
|
const currentPage = parseInt(searchParams.get("page") || "1")
|
||||||
|
|
||||||
|
const onPageChange = (page: number) => {
|
||||||
|
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 (
|
||||||
|
<div className="flex justify-center w-full mt-4">
|
||||||
|
<PaginationRoot>
|
||||||
|
<PaginationContent>
|
||||||
|
{/* <PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
onClick={() => currentPage > 1 && onPageChange(currentPage - 1)}
|
||||||
|
className={currentPage <= 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||||
|
/>
|
||||||
|
</PaginationItem> */}
|
||||||
|
|
||||||
|
{getPageNumbers().map((pageNumber, index) =>
|
||||||
|
pageNumber === "ellipsis-start" || pageNumber === "ellipsis-end" ? (
|
||||||
|
<PaginationItem key={`ellipsis-${index}`}>
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
) : (
|
||||||
|
<PaginationItem key={pageNumber}>
|
||||||
|
<PaginationLink
|
||||||
|
isActive={currentPage === pageNumber}
|
||||||
|
onClick={() => onPageChange(pageNumber as number)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{pageNumber}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* <PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
onClick={() => currentPage < totalPages && onPageChange(currentPage + 1)}
|
||||||
|
className={currentPage >= totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||||
|
/>
|
||||||
|
</PaginationItem> */}
|
||||||
|
</PaginationContent>
|
||||||
|
</PaginationRoot>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
import { Check } from "lucide-react"
|
import { Check } from "lucide-react"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@@ -13,14 +13,12 @@ const Checkbox = React.forwardRef<
|
|||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
"flex peer size-4 justify-center items-center shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<CheckboxPrimitive.Indicator
|
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
||||||
className={cn("flex items-center justify-center text-current")}
|
|
||||||
>
|
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>
|
||||||
|
|||||||
117
components/ui/pagination.tsx
Normal file
117
components/ui/pagination.tsx
Normal file
@@ -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">) => (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
aria-label="pagination"
|
||||||
|
className={cn("mx-auto flex w-full justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
Pagination.displayName = "Pagination"
|
||||||
|
|
||||||
|
const PaginationContent = React.forwardRef<
|
||||||
|
HTMLUListElement,
|
||||||
|
React.ComponentProps<"ul">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ul
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-row items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
PaginationContent.displayName = "PaginationContent"
|
||||||
|
|
||||||
|
const PaginationItem = React.forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
React.ComponentProps<"li">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<li ref={ref} className={cn("", className)} {...props} />
|
||||||
|
))
|
||||||
|
PaginationItem.displayName = "PaginationItem"
|
||||||
|
|
||||||
|
type PaginationLinkProps = {
|
||||||
|
isActive?: boolean
|
||||||
|
} & Pick<ButtonProps, "size"> &
|
||||||
|
React.ComponentProps<"a">
|
||||||
|
|
||||||
|
const PaginationLink = ({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: PaginationLinkProps) => (
|
||||||
|
<a
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: isActive ? "outline" : "ghost",
|
||||||
|
size,
|
||||||
|
}),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
PaginationLink.displayName = "PaginationLink"
|
||||||
|
|
||||||
|
const PaginationPrevious = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 pl-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
<span>Previous</span>
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
PaginationPrevious.displayName = "PaginationPrevious"
|
||||||
|
|
||||||
|
const PaginationNext = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to next page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 pr-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span>Next</span>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
PaginationNext.displayName = "PaginationNext"
|
||||||
|
|
||||||
|
const PaginationEllipsis = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) => (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">More pages</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
PaginationEllipsis.displayName = "PaginationEllipsis"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationPrevious,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationEllipsis,
|
||||||
|
}
|
||||||
@@ -38,6 +38,14 @@ export default function AnalyzeForm({
|
|||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [saveError, setSaveError] = useState("")
|
const [saveError, setSaveError] = useState("")
|
||||||
|
|
||||||
|
const fieldsMap = useMemo(
|
||||||
|
() =>
|
||||||
|
fields.reduce((acc, field) => {
|
||||||
|
acc[field.code] = field
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, Field>),
|
||||||
|
[fields]
|
||||||
|
)
|
||||||
const extraFields = useMemo(() => fields.filter((field) => field.isExtra), [fields])
|
const extraFields = useMemo(() => fields.filter((field) => field.isExtra), [fields])
|
||||||
const initialFormState = useMemo(
|
const initialFormState = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -151,6 +159,7 @@ export default function AnalyzeForm({
|
|||||||
name="merchant"
|
name="merchant"
|
||||||
value={formData.merchant}
|
value={formData.merchant}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, merchant: e.target.value }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, merchant: e.target.value }))}
|
||||||
|
hideIfEmpty={!fieldsMap["merchant"]?.isVisibleInAnalysis}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormInput
|
<FormInput
|
||||||
@@ -158,7 +167,7 @@ export default function AnalyzeForm({
|
|||||||
name="description"
|
name="description"
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
|
||||||
hideIfEmpty={true}
|
hideIfEmpty={!fieldsMap["description"]?.isVisibleInAnalysis}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
@@ -182,6 +191,7 @@ export default function AnalyzeForm({
|
|||||||
name="currencyCode"
|
name="currencyCode"
|
||||||
value={formData.currencyCode}
|
value={formData.currencyCode}
|
||||||
onValueChange={(value) => setFormData((prev) => ({ ...prev, currencyCode: value }))}
|
onValueChange={(value) => setFormData((prev) => ({ ...prev, currencyCode: value }))}
|
||||||
|
hideIfEmpty={!fieldsMap["currencyCode"]?.isVisibleInAnalysis}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormSelectType
|
<FormSelectType
|
||||||
@@ -189,6 +199,7 @@ export default function AnalyzeForm({
|
|||||||
name="type"
|
name="type"
|
||||||
value={formData.type}
|
value={formData.type}
|
||||||
onValueChange={(value) => setFormData((prev) => ({ ...prev, type: value }))}
|
onValueChange={(value) => setFormData((prev) => ({ ...prev, type: value }))}
|
||||||
|
hideIfEmpty={!fieldsMap["type"]?.isVisibleInAnalysis}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -212,7 +223,7 @@ export default function AnalyzeForm({
|
|||||||
name="issuedAt"
|
name="issuedAt"
|
||||||
value={formData.issuedAt}
|
value={formData.issuedAt}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, issuedAt: e.target.value }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, issuedAt: e.target.value }))}
|
||||||
hideIfEmpty={true}
|
hideIfEmpty={!fieldsMap["issuedAt"]?.isVisibleInAnalysis}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -224,9 +235,10 @@ export default function AnalyzeForm({
|
|||||||
value={formData.categoryCode}
|
value={formData.categoryCode}
|
||||||
onValueChange={(value) => setFormData((prev) => ({ ...prev, categoryCode: value }))}
|
onValueChange={(value) => setFormData((prev) => ({ ...prev, categoryCode: value }))}
|
||||||
placeholder="Select Category"
|
placeholder="Select Category"
|
||||||
|
hideIfEmpty={!fieldsMap["categoryCode"]?.isVisibleInAnalysis}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{projects.length >= 0 && (
|
{projects.length > 0 && (
|
||||||
<FormSelectProject
|
<FormSelectProject
|
||||||
title="Project"
|
title="Project"
|
||||||
projects={projects}
|
projects={projects}
|
||||||
@@ -234,6 +246,7 @@ export default function AnalyzeForm({
|
|||||||
value={formData.projectCode}
|
value={formData.projectCode}
|
||||||
onValueChange={(value) => setFormData((prev) => ({ ...prev, projectCode: value }))}
|
onValueChange={(value) => setFormData((prev) => ({ ...prev, projectCode: value }))}
|
||||||
placeholder="Select Project"
|
placeholder="Select Project"
|
||||||
|
hideIfEmpty={!fieldsMap["projectCode"]?.isVisibleInAnalysis}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -243,7 +256,7 @@ export default function AnalyzeForm({
|
|||||||
name="note"
|
name="note"
|
||||||
value={formData.note}
|
value={formData.note}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, note: e.target.value }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, note: e.target.value }))}
|
||||||
hideIfEmpty={true}
|
hideIfEmpty={!fieldsMap["note"]?.isVisibleInAnalysis}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{extraFields.map((field) => (
|
{extraFields.map((field) => (
|
||||||
@@ -254,7 +267,7 @@ export default function AnalyzeForm({
|
|||||||
name={field.code}
|
name={field.code}
|
||||||
value={formData[field.code as keyof typeof formData]}
|
value={formData[field.code as keyof typeof formData]}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, [field.code]: e.target.value }))}
|
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"
|
name="text"
|
||||||
value={formData.text}
|
value={formData.text}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, text: e.target.value }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, text: e.target.value }))}
|
||||||
hideIfEmpty={true}
|
hideIfEmpty={!fieldsMap["text"]?.isVisibleInAnalysis}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomHexColor } from "@/lib/utils"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
export const settingsFormSchema = z.object({
|
export const settingsFormSchema = z.object({
|
||||||
@@ -10,3 +11,28 @@ export const settingsFormSchema = z.object({
|
|||||||
prompt_analyse_new_file: z.string().optional(),
|
prompt_analyse_new_file: z.string().optional(),
|
||||||
is_welcome_message_hidden: z.boolean().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(),
|
||||||
|
})
|
||||||
|
|||||||
@@ -15,9 +15,22 @@ export type TransactionFilters = {
|
|||||||
ordering?: string
|
ordering?: string
|
||||||
categoryCode?: string
|
categoryCode?: string
|
||||||
projectCode?: string
|
projectCode?: string
|
||||||
|
page?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getTransactions = cache(async (filters?: TransactionFilters): Promise<Transaction[]> => {
|
export type TransactionPagination = {
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTransactions = cache(
|
||||||
|
async (
|
||||||
|
filters?: TransactionFilters,
|
||||||
|
pagination?: TransactionPagination
|
||||||
|
): Promise<{
|
||||||
|
transactions: Transaction[]
|
||||||
|
total: number
|
||||||
|
}> => {
|
||||||
const where: Prisma.TransactionWhereInput = {}
|
const where: Prisma.TransactionWhereInput = {}
|
||||||
let orderBy: Prisma.TransactionOrderByWithRelationInput = { issuedAt: "desc" }
|
let orderBy: Prisma.TransactionOrderByWithRelationInput = { issuedAt: "desc" }
|
||||||
|
|
||||||
@@ -54,7 +67,21 @@ export const getTransactions = cache(async (filters?: TransactionFilters): Promi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await prisma.transaction.findMany({
|
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,
|
where,
|
||||||
include: {
|
include: {
|
||||||
category: true,
|
category: true,
|
||||||
@@ -62,7 +89,10 @@ export const getTransactions = cache(async (filters?: TransactionFilters): Promi
|
|||||||
},
|
},
|
||||||
orderBy,
|
orderBy,
|
||||||
})
|
})
|
||||||
})
|
return { transactions, total: transactions.length }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export const getTransactionById = cache(async (id: string): Promise<Transaction | null> => {
|
export const getTransactionById = cache(async (id: string): Promise<Transaction | null> => {
|
||||||
return await prisma.transaction.findUnique({
|
return await prisma.transaction.findUnique({
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "taxhacker",
|
"name": "taxhacker",
|
||||||
"version": "0.1.0",
|
"version": "0.2.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "taxhacker",
|
"name": "taxhacker",
|
||||||
"version": "0.1.0",
|
"version": "0.2.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fast-csv/format": "^5.0.2",
|
"@fast-csv/format": "^5.0.2",
|
||||||
"@fast-csv/parse": "^5.0.2",
|
"@fast-csv/parse": "^5.0.2",
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ const fields = [
|
|||||||
type: "string",
|
type: "string",
|
||||||
llm_prompt: "description of the transaction",
|
llm_prompt: "description of the transaction",
|
||||||
isVisibleInList: false,
|
isVisibleInList: false,
|
||||||
isVisibleInAnalysis: true,
|
isVisibleInAnalysis: false,
|
||||||
isRequired: false,
|
isRequired: false,
|
||||||
isExtra: false,
|
isExtra: false,
|
||||||
},
|
},
|
||||||
@@ -431,11 +431,21 @@ const fields = [
|
|||||||
isRequired: false,
|
isRequired: false,
|
||||||
isExtra: 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",
|
code: "vat",
|
||||||
name: "VAT Amount",
|
name: "VAT Amount",
|
||||||
type: "number",
|
type: "number",
|
||||||
llm_prompt: "total VAT total in currency of the invoice",
|
llm_prompt: "total VAT in currency of the invoice",
|
||||||
isVisibleInList: false,
|
isVisibleInList: false,
|
||||||
isVisibleInAnalysis: false,
|
isVisibleInAnalysis: false,
|
||||||
isRequired: false,
|
isRequired: false,
|
||||||
@@ -446,6 +456,8 @@ const fields = [
|
|||||||
name: "Extracted Text",
|
name: "Extracted Text",
|
||||||
type: "string",
|
type: "string",
|
||||||
llm_prompt: "extract all recognised text from the invoice",
|
llm_prompt: "extract all recognised text from the invoice",
|
||||||
|
isVisibleInList: false,
|
||||||
|
isVisibleInAnalysis: false,
|
||||||
isRequired: false,
|
isRequired: false,
|
||||||
isExtra: false,
|
isExtra: false,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user