diff --git a/app/transactions/actions.ts b/app/transactions/actions.ts index d02ac91..88c4bb7 100644 --- a/app/transactions/actions.ts +++ b/app/transactions/actions.ts @@ -2,6 +2,7 @@ import { transactionFormSchema } from "@/forms/transactions" import { FILE_UPLOAD_PATH, getTransactionFileUploadPath } from "@/lib/files" +import { updateField } from "@/models/fields" import { createFile, deleteFile } from "@/models/files" import { bulkDeleteTransactions, @@ -160,3 +161,15 @@ export async function bulkDeleteTransactionsAction(transactionIds: string[]) { return { success: false, error: "Failed to delete transactions" } } } + +export async function updateFieldVisibilityAction(fieldCode: string, isVisible: boolean) { + try { + await updateField(fieldCode, { + isVisibleInList: isVisible, + }) + return { success: true } + } catch (error) { + console.error("Failed to update field visibility:", error) + return { success: false, error: "Failed to update field visibility" } + } +} diff --git a/app/transactions/page.tsx b/app/transactions/page.tsx index 4864822..c88fa00 100644 --- a/app/transactions/page.tsx +++ b/app/transactions/page.tsx @@ -45,10 +45,10 @@ export default async function TransactionsPage({ searchParams }: { searchParams: - +
- + {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, },