-
+
{transactions.length === 0 && (
diff --git a/components/transactions/fields-selector.tsx b/components/transactions/fields-selector.tsx
new file mode 100644
index 0000000..4db1134
--- /dev/null
+++ b/components/transactions/fields-selector.tsx
@@ -0,0 +1,65 @@
+"use client"
+
+import { updateFieldVisibilityAction } from "@/app/transactions/actions"
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Field } from "@prisma/client"
+import { ColumnsIcon } from "lucide-react"
+import { useRouter } from "next/navigation"
+import { useState } from "react"
+
+export function ColumnSelector({ fields, onChange }: { fields: Field[]; onChange?: () => void }) {
+ const router = useRouter()
+ const [isLoading, setIsLoading] = useState<{ [key: string]: boolean }>({})
+
+ const handleToggle = async (fieldCode: string, isCurrentlyVisible: boolean) => {
+ setIsLoading((prev) => ({ ...prev, [fieldCode]: true }))
+
+ try {
+ await updateFieldVisibilityAction(fieldCode, !isCurrentlyVisible)
+
+ // Refresh the page to reflect changes
+ if (onChange) {
+ onChange()
+ } else {
+ router.refresh()
+ }
+ } catch (error) {
+ console.error("Failed to toggle column visibility:", error)
+ } finally {
+ setIsLoading((prev) => ({ ...prev, [fieldCode]: false }))
+ }
+ }
+
+ return (
+
+
+
+
+
+ Show Columns
+
+ {fields.map((field) => (
+ handleToggle(field.code, field.isVisibleInList)}
+ disabled={isLoading[field.code]}
+ >
+ {field.name}
+ {isLoading[field.code] && Saving...}
+
+ ))}
+
+
+ )
+}
diff --git a/components/transactions/filters.tsx b/components/transactions/filters.tsx
index 4d52308..3d5d01a 100644
--- a/components/transactions/filters.tsx
+++ b/components/transactions/filters.tsx
@@ -1,15 +1,24 @@
"use client"
import { DateRangePicker } from "@/components/forms/date-range-picker"
+import { ColumnSelector } from "@/components/transactions/fields-selector"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { isFiltered, useTransactionFilters } from "@/hooks/use-transaction-filters"
import { TransactionFilters } from "@/models/transactions"
-import { Category, Project } from "@prisma/client"
+import { Category, Field, Project } from "@prisma/client"
import { X } from "lucide-react"
-export function TransactionSearchAndFilters({ categories, projects }: { categories: Category[]; projects: Project[] }) {
+export function TransactionSearchAndFilters({
+ categories,
+ projects,
+ fields,
+}: {
+ categories: Category[]
+ projects: Project[]
+ fields: Field[]
+}) {
const [filters, setFilters] = useTransactionFilters()
const handleFilterChange = (name: keyof TransactionFilters, value: any) => {
@@ -95,6 +104,8 @@ export function TransactionSearchAndFilters({ categories, projects }: { categori
)}
+
+
)
diff --git a/components/transactions/list.tsx b/components/transactions/list.tsx
index 0b9ed9f..bcbe72a 100644
--- a/components/transactions/list.tsx
+++ b/components/transactions/list.tsx
@@ -6,36 +6,47 @@ import { Checkbox } from "@/components/ui/checkbox"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { calcTotalPerCurrency } from "@/lib/stats"
import { cn, formatCurrency } from "@/lib/utils"
-import { Category, Project, Transaction } from "@prisma/client"
+import { Category, Field, Project, Transaction } from "@prisma/client"
import { formatDate } from "date-fns"
import { ArrowDownIcon, ArrowUpIcon, File } from "lucide-react"
import { useRouter, useSearchParams } from "next/navigation"
-import { useEffect, useState } from "react"
+import { useEffect, useMemo, useState } from "react"
-export const transactionsTable = [
- {
+type FieldRenderer = {
+ name: string
+ code: string
+ classes?: string
+ sortable: boolean
+ formatValue?: (transaction: Transaction & any) => React.ReactNode
+ footerValue?: (transactions: Transaction[]) => React.ReactNode
+}
+
+export const standardFieldRenderers: Record = {
+ name: {
name: "Name",
- db: "name",
+ code: "name",
classes: "font-medium max-w-[300px] min-w-[120px] overflow-hidden",
sortable: true,
},
- {
+ merchant: {
name: "Merchant",
- db: "merchant",
- classes: "max-w-[200px] max-h-[20px] min-w-[120px] overflow-hidden",
+ code: "merchant",
+ classes: "max-w-[200px] max-h-[20px] min-w-[120px] overflow-hidden",
sortable: true,
},
- {
+ issuedAt: {
name: "Date",
- db: "issuedAt",
+ code: "issuedAt",
classes: "min-w-[100px]",
- format: (transaction: Transaction) => (transaction.issuedAt ? formatDate(transaction.issuedAt, "yyyy-MM-dd") : ""),
sortable: true,
+ formatValue: (transaction: Transaction) =>
+ transaction.issuedAt ? formatDate(transaction.issuedAt, "yyyy-MM-dd") : "",
},
- {
+ projectCode: {
name: "Project",
- db: "projectCode",
- format: (transaction: Transaction & { project: Project }) =>
+ code: "projectCode",
+ sortable: true,
+ formatValue: (transaction: Transaction & { project: Project }) =>
transaction.projectCode ? (
{transaction.project?.name || ""}
@@ -43,12 +54,12 @@ export const transactionsTable = [
) : (
"-"
),
- sortable: true,
},
- {
+ categoryCode: {
name: "Category",
- db: "categoryCode",
- format: (transaction: Transaction & { category: Category }) =>
+ code: "categoryCode",
+ sortable: true,
+ formatValue: (transaction: Transaction & { category: Category }) =>
transaction.categoryCode ? (
{transaction.category?.name || ""}
@@ -56,24 +67,24 @@ export const transactionsTable = [
) : (
"-"
),
- sortable: true,
},
- {
+ files: {
name: "Files",
- db: "files",
- format: (transaction: Transaction) => (
+ code: "files",
+ sortable: false,
+ formatValue: (transaction: Transaction) => (
{(transaction.files as string[]).length}
),
- sortable: false,
},
- {
+ total: {
name: "Total",
- db: "total",
+ code: "total",
classes: "text-right",
- format: (transaction: Transaction) => (
+ sortable: true,
+ formatValue: (transaction: Transaction) => (
),
- sortable: true,
- footer: (transactions: Transaction[]) => {
+ footerValue: (transactions: Transaction[]) => {
const totalPerCurrency = calcTotalPerCurrency(transactions)
return (
@@ -110,12 +120,26 @@ export const transactionsTable = [
)
},
},
-]
+}
-export function TransactionList({ transactions }: { transactions: Transaction[] }) {
+const getFieldRenderer = (field: Field): FieldRenderer => {
+ if (standardFieldRenderers[field.code as keyof typeof standardFieldRenderers]) {
+ return standardFieldRenderers[field.code as keyof typeof standardFieldRenderers]
+ } else {
+ return {
+ name: field.name,
+ code: field.code,
+ classes: "",
+ sortable: true,
+ }
+ }
+}
+
+export function TransactionList({ transactions, fields = [] }: { transactions: Transaction[]; fields?: Field[] }) {
const [selectedIds, setSelectedIds] = useState
([])
const router = useRouter()
const searchParams = useSearchParams()
+
const [sorting, setSorting] = useState<{ field: string | null; direction: "asc" | "desc" | null }>(() => {
const ordering = searchParams.get("ordering")
if (!ordering) return { field: null, direction: null }
@@ -126,7 +150,18 @@ export function TransactionList({ transactions }: { transactions: Transaction[]
}
})
- const toggleAll = () => {
+ const visibleFields = useMemo(
+ () =>
+ fields
+ .filter((field) => field.isVisibleInList)
+ .map((field) => ({
+ ...field,
+ renderer: getFieldRenderer(field),
+ })),
+ [fields]
+ )
+
+ const toggleAllRows = () => {
if (selectedIds.length === transactions.length) {
setSelectedIds([])
} else {
@@ -134,7 +169,7 @@ export function TransactionList({ transactions }: { transactions: Transaction[]
}
}
- const toggleOne = (e: React.MouseEvent, id: string) => {
+ const toggleOneRow = (e: React.MouseEvent, id: string) => {
e.stopPropagation()
if (selectedIds.includes(id)) {
setSelectedIds(selectedIds.filter((item) => item !== id))
@@ -187,16 +222,19 @@ export function TransactionList({ transactions }: { transactions: Transaction[]
-
+
- {transactionsTable.map((field) => (
+ {visibleFields.map((field) => (
field.sortable && handleSort(field.db)}
+ key={field.code}
+ className={cn(
+ field.renderer.classes,
+ field.renderer.sortable && "hover:cursor-pointer hover:bg-accent select-none"
+ )}
+ onClick={() => field.renderer.sortable && handleSort(field.code)}
>
- {field.name}
- {field.sortable && getSortIcon(field.db)}
+ {field.renderer.name}
+ {field.renderer.sortable && getSortIcon(field.code)}
))}
@@ -213,14 +251,14 @@ export function TransactionList({ transactions }: { transactions: Transaction[]
checked={selectedIds.includes(transaction.id)}
onCheckedChange={(checked) => {
if (checked !== "indeterminate") {
- toggleOne({ stopPropagation: () => {} } as React.MouseEvent, transaction.id)
+ toggleOneRow({ stopPropagation: () => {} } as React.MouseEvent, transaction.id)
}
}}
/>
- {transactionsTable.map((field) => (
-
- {field.format ? field.format(transaction) : transaction[field.db]}
+ {visibleFields.map((field) => (
+
+ {field.renderer.formatValue ? field.renderer.formatValue(transaction) : transaction[field.code]}
))}
@@ -229,9 +267,9 @@ export function TransactionList({ transactions }: { transactions: Transaction[]
- {transactionsTable.map((field) => (
-
- {field.footer ? field.footer(transactions) : ""}
+ {visibleFields.map((field) => (
+
+ {field.renderer.footerValue ? field.renderer.footerValue(transactions) : ""}
))}
diff --git a/models/export_and_import.ts b/models/export_and_import.ts
index b55cee0..5a95b22 100644
--- a/models/export_and_import.ts
+++ b/models/export_and_import.ts
@@ -9,20 +9,27 @@ export type ExportFilters = TransactionFilters
export type ExportFields = string[]
-export const exportImportFields = [
- {
+export type ExportImportFieldSettings = {
+ code: string
+ type: string
+ export?: (value: any) => Promise
+ import?: (value: any) => Promise
+}
+
+export const exportImportFieldsMapping: Record = {
+ name: {
code: "name",
type: "string",
},
- {
+ description: {
code: "description",
type: "string",
},
- {
+ merchant: {
code: "merchant",
type: "string",
},
- {
+ total: {
code: "total",
type: "number",
export: async function (value: number) {
@@ -33,11 +40,11 @@ export const exportImportFields = [
return isNaN(num) ? 0.0 : num * 100
},
},
- {
+ currencyCode: {
code: "currencyCode",
type: "string",
},
- {
+ convertedTotal: {
code: "convertedTotal",
type: "number",
export: async function (value: number | null) {
@@ -51,19 +58,19 @@ export const exportImportFields = [
return isNaN(num) ? 0.0 : num * 100
},
},
- {
+ convertedCurrencyCode: {
code: "convertedCurrencyCode",
type: "string",
},
- {
+ type: {
code: "type",
type: "string",
},
- {
+ note: {
code: "note",
type: "string",
},
- {
+ categoryCode: {
code: "categoryCode",
type: "string",
export: async function (value: string | null) {
@@ -78,7 +85,7 @@ export const exportImportFields = [
return category?.code
},
},
- {
+ projectCode: {
code: "projectCode",
type: "string",
export: async function (value: string | null) {
@@ -93,7 +100,7 @@ export const exportImportFields = [
return project?.code
},
},
- {
+ issuedAt: {
code: "issuedAt",
type: "date",
export: async function (value: Date | null) {
@@ -115,12 +122,7 @@ export const exportImportFields = [
}
},
},
-]
-
-export const exportImportFieldsMapping = exportImportFields.reduce((acc, field) => {
- acc[field.code] = field
- return acc
-}, {} as Record)
+}
export const importProject = async (name: string) => {
const code = codeFromName(name)
diff --git a/prisma/migrations/20250323104012_field_visibility_options/migration.sql b/prisma/migrations/20250323104012_field_visibility_options/migration.sql
new file mode 100644
index 0000000..0886226
--- /dev/null
+++ b/prisma/migrations/20250323104012_field_visibility_options/migration.sql
@@ -0,0 +1,19 @@
+-- RedefineTables
+PRAGMA defer_foreign_keys=ON;
+PRAGMA foreign_keys=OFF;
+CREATE TABLE "new_fields" (
+ "code" TEXT NOT NULL PRIMARY KEY,
+ "name" TEXT NOT NULL,
+ "type" TEXT NOT NULL DEFAULT 'string',
+ "llm_prompt" TEXT,
+ "options" JSONB,
+ "is_visible_in_list" BOOLEAN NOT NULL DEFAULT false,
+ "is_visible_in_analysis" BOOLEAN NOT NULL DEFAULT false,
+ "is_required" BOOLEAN NOT NULL DEFAULT false,
+ "is_extra" BOOLEAN NOT NULL DEFAULT true
+);
+INSERT INTO "new_fields" ("code", "is_extra", "is_required", "llm_prompt", "name", "options", "type") SELECT "code", "is_extra", "is_required", "llm_prompt", "name", "options", "type" FROM "fields";
+DROP TABLE "fields";
+ALTER TABLE "new_fields" RENAME TO "fields";
+PRAGMA foreign_keys=ON;
+PRAGMA defer_foreign_keys=OFF;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 1ed95fe..9d85f16 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -42,13 +42,15 @@ model Project {
}
model Field {
- code String @id
- name String
- type String @default("string")
- llm_prompt String?
- options Json?
- isRequired Boolean @default(false) @map("is_required")
- isExtra Boolean @default(true) @map("is_extra")
+ code String @id
+ name String
+ type String @default("string")
+ llm_prompt String?
+ options Json?
+ isVisibleInList Boolean @default(false) @map("is_visible_in_list")
+ isVisibleInAnalysis Boolean @default(false) @map("is_visible_in_analysis")
+ isRequired Boolean @default(false) @map("is_required")
+ isExtra Boolean @default(true) @map("is_extra")
@@map("fields")
}
diff --git a/prisma/seed.js b/prisma/seed.js
index 64373a8..877fc9a 100644
--- a/prisma/seed.js
+++ b/prisma/seed.js
@@ -1,6 +1,6 @@
import { PrismaClient } from "@prisma/client"
-const DATABASE_URL = process.env.DATABASE_URL || "file:./db.sqlite"
+const DATABASE_URL = process.env.DATABASE_URL
const prisma = new PrismaClient({
datasources: {
db: {
@@ -306,6 +306,8 @@ const fields = [
name: "Name",
type: "string",
llm_prompt: "human readable name, summarize what is bought in the invoice",
+ isVisibleInList: true,
+ isVisibleInAnalysis: true,
isRequired: true,
isExtra: false,
},
@@ -314,6 +316,8 @@ const fields = [
name: "Description",
type: "string",
llm_prompt: "description of the transaction",
+ isVisibleInList: false,
+ isVisibleInAnalysis: true,
isRequired: false,
isExtra: false,
},
@@ -322,54 +326,8 @@ const fields = [
name: "Merchant",
type: "string",
llm_prompt: "merchant name, use the original spelling and language",
- isRequired: false,
- isExtra: false,
- },
- {
- code: "type",
- name: "Type",
- type: "string",
- llm_prompt: "",
- isRequired: false,
- isExtra: false,
- },
- {
- code: "total",
- name: "Total",
- type: "number",
- llm_prompt: "total total of the transaction",
- isRequired: false,
- isExtra: false,
- },
- {
- code: "currencyCode",
- name: "Currency",
- type: "string",
- llm_prompt: "currency code, ISO 4217 three letter code like USD, EUR, including crypto codes like BTC, ETH, etc",
- isRequired: false,
- isExtra: false,
- },
- {
- code: "convertedTotal",
- name: "Converted Total",
- type: "number",
- llm_prompt: "",
- isRequired: false,
- isExtra: false,
- },
- {
- code: "convertedCurrencyCode",
- name: "Converted Currency Code",
- type: "string",
- llm_prompt: "",
- isRequired: false,
- isExtra: false,
- },
- {
- code: "note",
- name: "Note",
- type: "string",
- llm_prompt: "",
+ isVisibleInList: true,
+ isVisibleInAnalysis: true,
isRequired: false,
isExtra: false,
},
@@ -378,6 +336,8 @@ const fields = [
name: "Category",
type: "string",
llm_prompt: "category code, one of: {categories.code}",
+ isVisibleInList: true,
+ isVisibleInAnalysis: true,
isRequired: false,
isExtra: false,
},
@@ -386,6 +346,8 @@ const fields = [
name: "Project",
type: "string",
llm_prompt: "project code, one of: {projects.code}",
+ isVisibleInList: true,
+ isVisibleInAnalysis: true,
isRequired: false,
isExtra: false,
},
@@ -394,14 +356,68 @@ const fields = [
name: "Issued At",
type: "string",
llm_prompt: "issued at date (YYYY-MM-DD format)",
+ isVisibleInList: true,
+ isVisibleInAnalysis: true,
isRequired: false,
isExtra: false,
},
{
- code: "text",
- name: "Extracted Text",
+ code: "total",
+ name: "Total",
+ type: "number",
+ llm_prompt: "total total of the transaction",
+ isVisibleInList: true,
+ isVisibleInAnalysis: true,
+ isRequired: false,
+ isExtra: false,
+ },
+ {
+ code: "currencyCode",
+ name: "Currency",
type: "string",
- llm_prompt: "extract all recognised text from the invoice",
+ llm_prompt: "currency code, ISO 4217 three letter code like USD, EUR, including crypto codes like BTC, ETH, etc",
+ isVisibleInList: false,
+ isVisibleInAnalysis: true,
+ isRequired: false,
+ isExtra: false,
+ },
+ {
+ code: "convertedTotal",
+ name: "Converted Total",
+ type: "number",
+ llm_prompt: "",
+ isVisibleInList: false,
+ isVisibleInAnalysis: false,
+ isRequired: false,
+ isExtra: false,
+ },
+ {
+ code: "convertedCurrencyCode",
+ name: "Converted Currency Code",
+ type: "string",
+ llm_prompt: "",
+ isVisibleInList: false,
+ isVisibleInAnalysis: false,
+ isRequired: false,
+ isExtra: false,
+ },
+ {
+ code: "type",
+ name: "Type",
+ type: "string",
+ llm_prompt: "",
+ isVisibleInList: false,
+ isVisibleInAnalysis: true,
+ isRequired: false,
+ isExtra: false,
+ },
+ {
+ code: "note",
+ name: "Note",
+ type: "string",
+ llm_prompt: "",
+ isVisibleInList: false,
+ isVisibleInAnalysis: false,
isRequired: false,
isExtra: false,
},
@@ -410,9 +426,19 @@ const fields = [
name: "VAT Amount",
type: "number",
llm_prompt: "total VAT total in currency of the invoice",
+ isVisibleInList: false,
+ isVisibleInAnalysis: false,
isRequired: false,
isExtra: true,
},
+ {
+ code: "text",
+ name: "Extracted Text",
+ type: "string",
+ llm_prompt: "extract all recognised text from the invoice",
+ isRequired: false,
+ isExtra: false,
+ },
]
async function isDatabaseEmpty() {
@@ -464,6 +490,8 @@ async function main() {
name: field.name,
type: field.type,
llm_prompt: field.llm_prompt,
+ isVisibleInList: field.isVisibleInList,
+ isVisibleInAnalysis: field.isVisibleInAnalysis,
isRequired: field.isRequired,
isExtra: field.isExtra,
},