diff --git a/ai/schema.ts b/ai/schema.ts index 74e6009..e6d70b2 100644 --- a/ai/schema.ts +++ b/ai/schema.ts @@ -2,16 +2,30 @@ import { Field } from "@/prisma/client" export const fieldsToJsonSchema = (fields: Field[]) => { const fieldsWithPrompt = fields.filter((field) => field.llm_prompt) + const schemaProperties = fieldsWithPrompt.reduce( + (acc, field) => { + acc[field.code] = { type: field.type, description: field.llm_prompt || "" } + return acc + }, + {} as Record + ) + const schema = { type: "object", - properties: fieldsWithPrompt.reduce( - (acc, field) => { - acc[field.code] = { type: field.type, description: field.llm_prompt || "" } - return acc + properties: { + ...schemaProperties, + items: { + type: "array", + description: "Included items or products in the transaction which have own name and price", + items: { + type: "object", + properties: schemaProperties, + required: [...Object.keys(schemaProperties)], + additionalProperties: false, + }, }, - {} as Record - ), - required: fieldsWithPrompt.map((field) => field.code), + }, + required: [...Object.keys(schemaProperties), "items"], additionalProperties: false, } diff --git a/app/(app)/files/actions.ts b/app/(app)/files/actions.ts index c8d69cd..deeda57 100644 --- a/app/(app)/files/actions.ts +++ b/app/(app)/files/actions.ts @@ -70,7 +70,6 @@ export async function uploadFilesAction(formData: FormData): Promise | null, + formData: FormData +): Promise> { + try { + const user = await getCurrentUser() + const fileId = formData.get("fileId") as string + const items = JSON.parse(formData.get("items") as string) as TransactionData[] + + if (!fileId || !items || items.length === 0) { + return { success: false, error: "File ID and items are required" } + } + + // Get the original file + const originalFile = await getFileById(fileId, user.id) + if (!originalFile) { + return { success: false, error: "Original file not found" } + } + + // Get the original file's content + const userUploadsDirectory = getUserUploadsDirectory(user) + const originalFilePath = safePathJoin(userUploadsDirectory, originalFile.path) + const fileContent = await readFile(originalFilePath) + + // Create a new file for each item + for (const item of items) { + const fileUuid = randomUUID() + const fileName = `${originalFile.filename}-part-${item.name}` + const relativeFilePath = unsortedFilePath(fileUuid, fileName) + const fullFilePath = safePathJoin(userUploadsDirectory, relativeFilePath) + + // Create directory if it doesn't exist + await mkdir(path.dirname(fullFilePath), { recursive: true }) + + // Copy the original file content + await writeFile(fullFilePath, fileContent) + + // Create file record in database with the item data cached + await createFile(user.id, { + id: fileUuid, + filename: fileName, + path: relativeFilePath, + mimetype: originalFile.mimetype, + metadata: originalFile.metadata, + isSplitted: true, + cachedParseResult: { + name: item.name, + merchant: item.merchant, + description: item.description, + total: item.total, + currencyCode: item.currencyCode, + categoryCode: item.categoryCode, + projectCode: item.projectCode, + type: item.type, + issuedAt: item.issuedAt, + note: item.note, + text: item.text, + }, + }) + } + + // Delete the original file + await deleteFile(fileId, user.id) + + // Update user storage used + const storageUsed = await getDirectorySize(getUserUploadsDirectory(user)) + await updateUser(user.id, { storageUsed }) + + revalidatePath("/unsorted") + return { success: true } + } catch (error) { + console.error("Failed to split file into items:", error) + return { success: false, error: `Failed to split file into items: ${error}` } + } +} diff --git a/components/forms/convert-currency.tsx b/components/agents/currency-converter.tsx similarity index 99% rename from components/forms/convert-currency.tsx rename to components/agents/currency-converter.tsx index f391fe8..58e3d59 100644 --- a/components/forms/convert-currency.tsx +++ b/components/agents/currency-converter.tsx @@ -19,7 +19,7 @@ async function getCurrencyRate(currencyCodeFrom: string, currencyCodeTo: string, return data.rate } -export const FormConvertCurrency = ({ +export const CurrencyConverterTool = ({ originalTotal, originalCurrencyCode, targetCurrencyCode, diff --git a/components/agents/items-detect.tsx b/components/agents/items-detect.tsx new file mode 100644 index 0000000..51e6b93 --- /dev/null +++ b/components/agents/items-detect.tsx @@ -0,0 +1,79 @@ +import { formatCurrency } from "@/lib/utils" +import { Save, Split } from "lucide-react" +import { Button } from "../ui/button" +import { TransactionData } from "@/models/transactions" +import { splitFileIntoItemsAction } from "@/app/(app)/unsorted/actions" +import { useNotification } from "@/app/(app)/context" +import { useState } from "react" +import { Loader2 } from "lucide-react" +import { File } from "@/prisma/client" + +export const ItemsDetectTool = ({ file, data }: { file?: File; data: TransactionData }) => { + const { showNotification } = useNotification() + const [isSplitting, setIsSplitting] = useState(false) + + const handleSplit = async () => { + if (!file) { + console.error("No file selected") + return + } + + setIsSplitting(true) + try { + const formData = new FormData() + formData.append("fileId", file.id) + formData.append("items", JSON.stringify(data.items)) + + const result = await splitFileIntoItemsAction(null, formData) + if (result.success) { + showNotification({ code: "global.banner", message: "Split successful!", type: "success" }) + showNotification({ code: "sidebar.unsorted", message: "new" }) + setTimeout(() => showNotification({ code: "sidebar.unsorted", message: "" }), 3000) + } else { + showNotification({ code: "global.banner", message: result.error || "Failed to split", type: "failed" }) + } + } catch (error) { + console.error("Failed to split items:", error) + showNotification({ code: "global.banner", message: "Failed to split items", type: "failed" }) + } finally { + setIsSplitting(false) + } + } + + return ( +
+
+ {data.items?.map((item, index) => ( +
+
+
{item.name}
+
{item.description}
+
+
+ {formatCurrency((item.total || 0) * 100, item.currencyCode || data.currencyCode || "USD")} +
+
+ ))} +
+ + {file && data.items && data.items.length > 1 && ( + + )} +
+ ) +} diff --git a/components/agents/tool-window.tsx b/components/agents/tool-window.tsx index 41ef5ad..a9500f4 100644 --- a/components/agents/tool-window.tsx +++ b/components/agents/tool-window.tsx @@ -2,10 +2,10 @@ import { Bot } from "lucide-react" export default function ToolWindow({ title, children }: { title: string; children: React.ReactNode }) { return ( -
+
- Tool called: {title} + {title}
{children}
diff --git a/components/files/upload-button.tsx b/components/files/upload-button.tsx index 4a50ce3..9fe3fe2 100644 --- a/components/files/upload-button.tsx +++ b/components/files/upload-button.tsx @@ -7,6 +7,7 @@ import config from "@/lib/config" import { Loader2 } from "lucide-react" import { useRouter } from "next/navigation" import { ComponentProps, startTransition, useRef, useState } from "react" +import { FormError } from "../forms/error" export function UploadButton({ children, ...props }: { children: React.ReactNode } & ComponentProps) { const router = useRouter() @@ -69,7 +70,7 @@ export function UploadButton({ children, ...props }: { children: React.ReactNode )} - {uploadError && ⚠️ {uploadError}} + {uploadError && {uploadError}}
) } diff --git a/components/forms/error.tsx b/components/forms/error.tsx index 4a762c6..0efa3a2 100644 --- a/components/forms/error.tsx +++ b/components/forms/error.tsx @@ -1,5 +1,16 @@ import { cn } from "@/lib/utils" +import { AlertCircle } from "lucide-react" export function FormError({ children, className }: { children: React.ReactNode; className?: string }) { - return

{children}

+ return ( +
+ +

{children}

+
+ ) } diff --git a/components/transactions/create.tsx b/components/transactions/create.tsx index 058d8b0..7fef7a5 100644 --- a/components/transactions/create.tsx +++ b/components/transactions/create.tsx @@ -129,9 +129,9 @@ export default function TransactionCreateForm({ "Create and Add Files" )} - - {createState?.error && ⚠️ {createState.error}} + + {createState?.error && {createState.error}} ) } diff --git a/components/transactions/edit.tsx b/components/transactions/edit.tsx index d2b0f8e..d81cb24 100644 --- a/components/transactions/edit.tsx +++ b/components/transactions/edit.tsx @@ -13,6 +13,9 @@ import { format } from "date-fns" import { Loader2, Save, Trash2 } from "lucide-react" import { useRouter } from "next/navigation" import { startTransition, useActionState, useEffect, useMemo, useState } from "react" +import ToolWindow from "@/components/agents/tool-window" +import { ItemsDetectTool } from "@/components/agents/items-detect" +import { TransactionData } from "@/models/transactions" export default function TransactionEditForm({ transaction, @@ -47,6 +50,7 @@ export default function TransactionEditForm({ projectCode: transaction.projectCode || settings.default_project, issuedAt: transaction.issuedAt ? format(transaction.issuedAt, "yyyy-MM-dd") : "", note: transaction.note || "", + items: transaction.items || [], ...extraFields.reduce( (acc, field) => { acc[field.code] = transaction.extra?.[field.code as keyof typeof transaction.extra] || "" @@ -205,13 +209,19 @@ export default function TransactionEditForm({ type="text" title={field.name} name={field.code} - defaultValue={formData[field.code as keyof typeof formData] || ""} + defaultValue={(formData[field.code as keyof typeof formData] as string) || ""} isRequired={field.isRequired} className={field.type === "number" ? "max-w-36" : "max-w-full"} /> ))} + {formData.items && Array.isArray(formData.items) && formData.items.length > 0 && ( + + + + )} +
+
- {deleteState?.error && ⚠️ {deleteState.error}} - {saveState?.error && ⚠️ {saveState.error}} +
+ {deleteState?.error && {deleteState.error}} + {saveState?.error && {saveState.error}}
) diff --git a/components/unsorted/analyze-all-button.tsx b/components/unsorted/analyze-all-button.tsx index 25e803d..a3325e0 100644 --- a/components/unsorted/analyze-all-button.tsx +++ b/components/unsorted/analyze-all-button.tsx @@ -1,19 +1,35 @@ "use client" import { Button } from "@/components/ui/button" -import { Swords } from "lucide-react" +import { Save, Swords } from "lucide-react" export function AnalyzeAllButton() { const handleAnalyzeAll = () => { - document.querySelectorAll("button[data-analyze-button]").forEach((button) => { - ;(button as HTMLButtonElement).click() - }) + if (typeof document !== "undefined") { + document.querySelectorAll("button[data-analyze-button]").forEach((button) => { + ;(button as HTMLButtonElement).click() + }) + } + } + + const handleSaveAll = () => { + if (typeof document !== "undefined") { + document.querySelectorAll("button[data-save-button]").forEach((button) => { + ;(button as HTMLButtonElement).click() + }) + } } return ( - +
+ + +
) } diff --git a/components/unsorted/analyze-form.tsx b/components/unsorted/analyze-form.tsx index e14fc4d..a555041 100644 --- a/components/unsorted/analyze-form.tsx +++ b/components/unsorted/analyze-form.tsx @@ -2,7 +2,7 @@ import { useNotification } from "@/app/(app)/context" import { analyzeFileAction, deleteUnsortedFileAction, saveFileAsTransactionAction } from "@/app/(app)/unsorted/actions" -import { FormConvertCurrency } from "@/components/forms/convert-currency" +import { CurrencyConverterTool } from "@/components/agents/currency-converter" import { FormError } from "@/components/forms/error" import { FormSelectCategory } from "@/components/forms/select-category" import { FormSelectCurrency } from "@/components/forms/select-currency" @@ -14,7 +14,9 @@ import { Category, Currency, Field, File, Project } from "@/prisma/client" import { format } from "date-fns" import { Brain, Loader2, Trash2, ArrowDownToLine } from "lucide-react" import { startTransition, useActionState, useMemo, useState } from "react" -import ToolWindow from "../agents/tool-window" +import ToolWindow from "@/components/agents/tool-window" +import { ItemsDetectTool } from "@/components/agents/items-detect" +import { Badge } from "@/components/ui/badge" export default function AnalyzeForm({ file, @@ -65,6 +67,7 @@ export default function AnalyzeForm({ issuedAt: "", note: "", text: "", + items: [], } // Add extra fields @@ -141,21 +144,27 @@ export default function AnalyzeForm({ return ( <> - + {file.isSplitted ? ( +
+ This file has been split +
+ ) : ( + + )} - {analyzeError && ⚠️ {analyzeError}} +
{analyzeError && {analyzeError}}
@@ -222,7 +231,7 @@ export default function AnalyzeForm({ {formData.total != 0 && formData.currencyCode && formData.currencyCode !== settings.default_currency && ( - ))} + {formData.items && formData.items.length > 0 && ( + + + + )} +
+ - +
- {deleteState?.error && ⚠️ {deleteState.error}} - {saveError && ⚠️ {saveError}} +
+ {deleteState?.error && {deleteState.error}} + {saveError && {saveError}}
diff --git a/forms/transactions.ts b/forms/transactions.ts index 25cdb83..c574797 100644 --- a/forms/transactions.ts +++ b/forms/transactions.ts @@ -47,5 +47,16 @@ export const transactionFormSchema = z .optional(), text: z.string().optional(), note: z.string().optional(), + items: z + .string() + .optional() + .transform((val) => { + if (!val || val.trim() === '') return [] + try { + return JSON.parse(val) + } catch (e) { + throw new z.ZodError([{ message: "Invalid items JSON", path: ["items"], code: z.ZodIssueCode.custom }]) + } + }), }) .catchall(z.string()) diff --git a/lib/utils.ts b/lib/utils.ts index d6dfdec..01745cb 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -8,7 +8,7 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } -export function formatCurrency(total: number, currency: string, separator: string = "") { +export function formatCurrency(total: number, currency: string) { try { return new Intl.NumberFormat(LOCALE, { style: "currency", diff --git a/models/transactions.ts b/models/transactions.ts index f57d8f2..ed87453 100644 --- a/models/transactions.ts +++ b/models/transactions.ts @@ -5,6 +5,22 @@ import { getFields } from "./fields" import { deleteFile } from "./files" export type TransactionData = { + name?: string | null + description?: string | null + merchant?: string | null + total?: number | null + currencyCode?: string | null + convertedTotal?: number | null + convertedCurrencyCode?: string | null + type?: string | null + items?: TransactionData[] | null + note?: string | null + files?: string[] | null + extra?: Record + categoryCode?: string | null + projectCode?: string | null + issuedAt?: Date | string | null + text?: string | null [key: string]: unknown } @@ -105,6 +121,12 @@ export const getTransactionById = cache(async (id: string, userId: string): Prom }) }) +export const getTransactionsByFileId = cache(async (fileId: string, userId: string): Promise => { + return await prisma.transaction.findMany({ + where: { files: { array_contains: [fileId] }, userId }, + }) +}) + export const createTransaction = async (userId: string, data: TransactionData): Promise => { const { standard, extra } = await splitTransactionDataExtraFields(data, userId) @@ -112,8 +134,11 @@ export const createTransaction = async (userId: string, data: TransactionData): data: { ...standard, extra: extra, - userId, - }, + items: data.items as Prisma.InputJsonValue, + user: { + connect: { id: userId } + } + } as Prisma.TransactionCreateInput, }) } @@ -125,7 +150,8 @@ export const updateTransaction = async (id: string, userId: string, data: Transa data: { ...standard, extra: extra, - }, + items: data.items ? data.items as Prisma.InputJsonValue : [], + } as Prisma.TransactionUpdateInput, }) } @@ -143,7 +169,9 @@ export const deleteTransaction = async (id: string, userId: string): Promise ) - const standard: Omit, "extra"> = {} + const standard: TransactionData = {} const extra: Record = {} Object.entries(data).forEach(([key, value]) => { @@ -180,7 +208,7 @@ const splitTransactionDataExtraFields = async ( if (fieldDef.isExtra) { extra[key] = value } else { - standard[key as keyof Omit] = value as any + standard[key] = value } } }) diff --git a/prisma/migrations/20250523104130_split_tx_items/migration.sql b/prisma/migrations/20250523104130_split_tx_items/migration.sql new file mode 100644 index 0000000..f365f93 --- /dev/null +++ b/prisma/migrations/20250523104130_split_tx_items/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "files" ADD COLUMN "is_splitted" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "transactions" ADD COLUMN "items" JSONB NOT NULL DEFAULT '[]'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e909791..65aa0f5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -160,6 +160,7 @@ model File { mimetype String metadata Json? isReviewed Boolean @default(false) @map("is_reviewed") + isSplitted Boolean @default(false) @map("is_splitted") cachedParseResult Json? @map("cached_parse_result") createdAt DateTime @default(now()) @map("created_at") @@ -178,6 +179,7 @@ model Transaction { convertedTotal Int? @map("converted_total") convertedCurrencyCode String? @map("converted_currency_code") type String? @default("expense") + items Json @default("[]") note String? files Json @default("[]") extra Json?