diff --git a/ai/analyze.ts b/ai/analyze.ts index 3f4aca9..7850080 100644 --- a/ai/analyze.ts +++ b/ai/analyze.ts @@ -4,6 +4,7 @@ import { ActionState } from "@/lib/actions" import config from "@/lib/config" import OpenAI from "openai" import { AnalyzeAttachment } from "./attachments" +import { updateFile } from "@/models/files" export type AnalysisResult = { output: Record @@ -14,7 +15,9 @@ export async function analyzeTransaction( prompt: string, schema: Record, attachments: AnalyzeAttachment[], - apiKey: string + apiKey: string, + fileId: string, + userId: string ): Promise> { const openai = new OpenAI({ apiKey, @@ -54,6 +57,9 @@ export async function analyzeTransaction( console.log("ChatGPT tokens used:", response.usage) const result = JSON.parse(response.output_text) + + await updateFile(fileId, userId, { cachedParseResult: result }) + return { success: true, data: { output: result, tokensUsed: response.usage?.total_tokens || 0 } } } catch (error) { console.error("AI Analysis error:", error) diff --git a/app/(app)/context.tsx b/app/(app)/context.tsx index ad47fed..54f8e18 100644 --- a/app/(app)/context.tsx +++ b/app/(app)/context.tsx @@ -1,10 +1,14 @@ "use client" +import { Check, X, Trash2 } from "lucide-react" import { createContext, ReactNode, useContext, useState } from "react" +type BannerType = "success" | "deleted" | "failed" | "default" + type Notification = { code: string message: string + type?: BannerType } type NotificationContextType = { @@ -22,10 +26,51 @@ export function NotificationProvider({ children }: { children: ReactNode }) { const showNotification = (notification: Notification) => { setNotification(notification) + if (notification.code === "global.banner") { + setTimeout(() => setNotification(null), 2000) + } + } + + const getBannerStyles = (type: BannerType = "default") => { + switch (type) { + case "success": + return "bg-green-500 text-teal-50" + case "deleted": + return "bg-black text-white" + case "failed": + return "bg-red-500 text-white" + case "default": + return "bg-white text-black" + } + } + + const getBannerIcon = (type: BannerType = "default") => { + switch (type) { + case "success": + return + case "deleted": + return + case "failed": + return + case "default": + return null + } } return ( - {children} + + {children} + {notification?.code === "global.banner" && ( +
+
+ {getBannerIcon(notification.type)} +

{notification.message}

+
+
+ )} +
) } diff --git a/app/(app)/unsorted/actions.ts b/app/(app)/unsorted/actions.ts index 0bfc40b..a028b4f 100644 --- a/app/(app)/unsorted/actions.ts +++ b/app/(app)/unsorted/actions.ts @@ -67,7 +67,7 @@ export async function analyzeFileAction( const schema = fieldsToJsonSchema(fields) - const results = await analyzeTransaction(prompt, schema, attachments, apiKey) + const results = await analyzeTransaction(prompt, schema, attachments, apiKey, file.id, user.id) console.log("Analysis results:", results) diff --git a/app/(app)/unsorted/loading.tsx b/app/(app)/unsorted/loading.tsx index a317f48..28e9bdb 100644 --- a/app/(app)/unsorted/loading.tsx +++ b/app/(app)/unsorted/loading.tsx @@ -3,7 +3,7 @@ import { Loader2 } from "lucide-react" export default function Loading() { return ( -
+

Loading unsorted files... diff --git a/app/(app)/unsorted/page.tsx b/app/(app)/unsorted/page.tsx index e98b06a..8911bdc 100644 --- a/app/(app)/unsorted/page.tsx +++ b/app/(app)/unsorted/page.tsx @@ -31,7 +31,7 @@ export default async function UnsortedPage() { const settings = await getSettings(user.id) return ( -
+

You have {files.length} unsorted files

diff --git a/app/layout.tsx b/app/layout.tsx index 8bd34f5..b1d7322 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -42,7 +42,7 @@ export const viewport: Viewport = { userScalable: false, } -export default async function RootLayout({ children }: { children: React.ReactNode }) { +export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} diff --git a/components/transactions/bulk-actions.tsx b/components/transactions/bulk-actions.tsx index 56ebf76..34ccc5d 100644 --- a/components/transactions/bulk-actions.tsx +++ b/components/transactions/bulk-actions.tsx @@ -2,22 +2,9 @@ import { bulkDeleteTransactionsAction } from "@/app/(app)/transactions/actions" import { Button } from "@/components/ui/button" -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" -import { ChevronUp, Trash2 } from "lucide-react" +import { Trash2 } from "lucide-react" import { useState } from "react" -const bulkActions = [ - { - id: "delete", - label: "Bulk Delete", - icon: Trash2, - variant: "destructive" as const, - action: bulkDeleteTransactionsAction, - confirmMessage: - "Are you sure you want to delete these transactions and all their files? This action cannot be undone.", - }, -] - interface BulkActionsMenuProps { selectedIds: string[] onActionComplete?: () => void @@ -26,24 +13,21 @@ interface BulkActionsMenuProps { export function BulkActionsMenu({ selectedIds, onActionComplete }: BulkActionsMenuProps) { const [isLoading, setIsLoading] = useState(false) - const handleAction = async (actionId: string) => { - const action = bulkActions.find((a) => a.id === actionId) - if (!action) return - - if (action.confirmMessage) { - if (!confirm(action.confirmMessage)) return - } + const handleDelete = async () => { + const confirmMessage = + "Are you sure you want to delete these transactions and all their files? This action cannot be undone." + if (!confirm(confirmMessage)) return try { setIsLoading(true) - const result = await action.action(selectedIds) + const result = await bulkDeleteTransactionsAction(selectedIds) if (!result.success) { throw new Error(result.error) } onActionComplete?.() } catch (error) { - console.error(`Failed to execute bulk action ${actionId}:`, error) - alert(`Failed to execute action: ${error}`) + console.error("Failed to delete transactions:", error) + alert(`Failed to delete transactions: ${error}`) } finally { setIsLoading(false) } @@ -51,27 +35,10 @@ export function BulkActionsMenu({ selectedIds, onActionComplete }: BulkActionsMe return (
- - - - - - {bulkActions.map((action) => ( - handleAction(action.id)} - className="gap-2" - disabled={isLoading} - > - - {action.label} - - ))} - - +
) } diff --git a/components/transactions/edit.tsx b/components/transactions/edit.tsx index c169031..44ed37b 100644 --- a/components/transactions/edit.tsx +++ b/components/transactions/edit.tsx @@ -10,7 +10,7 @@ import { FormInput, FormTextarea } from "@/components/forms/simple" import { Button } from "@/components/ui/button" import { Category, Currency, Field, Project, Transaction } from "@/prisma/client" import { format } from "date-fns" -import { Loader2 } from "lucide-react" +import { Loader2, Save, Trash2 } from "lucide-react" import { useRouter } from "next/navigation" import { startTransition, useActionState, useEffect, useMemo, useState } from "react" @@ -212,17 +212,23 @@ export default function TransactionEditForm({
diff --git a/components/unsorted/analyze-form.tsx b/components/unsorted/analyze-form.tsx index ea23937..9547208 100644 --- a/components/unsorted/analyze-form.tsx +++ b/components/unsorted/analyze-form.tsx @@ -12,7 +12,7 @@ import { FormInput, FormTextarea } from "@/components/forms/simple" import { Button } from "@/components/ui/button" import { Category, Currency, Field, File, Project } from "@/prisma/client" import { format } from "date-fns" -import { Brain, Loader2 } from "lucide-react" +import { Brain, Loader2, Trash2, ArrowDownToLine } from "lucide-react" import { startTransition, useActionState, useMemo, useState } from "react" import ToolWindow from "../agents/tool-window" @@ -50,8 +50,8 @@ export default function AnalyzeForm({ }, [fields]) const extraFields = useMemo(() => fields.filter((field) => field.isExtra), [fields]) - const initialFormState = useMemo( - () => ({ + const initialFormState = useMemo(() => { + const baseState = { name: file.filename, merchant: "", description: "", @@ -65,16 +65,32 @@ export default function AnalyzeForm({ issuedAt: "", note: "", text: "", - ...extraFields.reduce( - (acc, field) => { - acc[field.code] = "" - return acc - }, - {} as Record - ), - }), - [file.filename, settings, extraFields] - ) + } + + // Add extra fields + const extraFieldsState = extraFields.reduce( + (acc, field) => { + acc[field.code] = "" + return acc + }, + {} as Record + ) + + // Load cached results if they exist + const cachedResults = file.cachedParseResult + ? Object.fromEntries( + Object.entries(file.cachedParseResult as Record).filter( + ([_, value]) => value !== null && value !== undefined && value !== "" + ) + ) + : {} + + return { + ...baseState, + ...extraFieldsState, + ...cachedResults, + } + }, [file.filename, settings, extraFields, file.cachedParseResult]) const [formData, setFormData] = useState(initialFormState) async function saveAsTransaction(formData: FormData) { @@ -85,10 +101,12 @@ export default function AnalyzeForm({ setIsSaving(false) if (result.success) { + showNotification({ code: "global.banner", message: "Saved!", type: "success" }) showNotification({ code: "sidebar.transactions", message: "new" }) setTimeout(() => showNotification({ code: "sidebar.transactions", message: "" }), 3000) } else { setSaveError(result.error ? result.error : "Something went wrong...") + showNotification({ code: "global.banner", message: "Failed to save", type: "failed" }) } }) } @@ -126,12 +144,12 @@ export default function AnalyzeForm({
@@ -231,6 +254,7 @@ export default function AnalyzeForm({ onValueChange={(value) => setFormData((prev) => ({ ...prev, categoryCode: value }))} placeholder="Select Category" hideIfEmpty={!fieldMap.categoryCode.isVisibleInAnalysis} + required={fieldMap.categoryCode.isRequired} /> {projects.length > 0 && ( @@ -242,6 +266,7 @@ export default function AnalyzeForm({ onValueChange={(value) => setFormData((prev) => ({ ...prev, projectCode: value }))} placeholder="Select Project" hideIfEmpty={!fieldMap.projectCode.isVisibleInAnalysis} + required={fieldMap.projectCode.isRequired} /> )}
@@ -252,6 +277,7 @@ export default function AnalyzeForm({ value={formData.note} onChange={(e) => setFormData((prev) => ({ ...prev, note: e.target.value }))} hideIfEmpty={!fieldMap.note.isVisibleInAnalysis} + required={fieldMap.note.isRequired} /> {extraFields.map((field) => ( @@ -263,6 +289,7 @@ export default function AnalyzeForm({ value={formData[field.code as keyof typeof formData]} onChange={(e) => setFormData((prev) => ({ ...prev, [field.code]: e.target.value }))} hideIfEmpty={!field.isVisibleInAnalysis} + required={field.isRequired} /> ))} @@ -276,13 +303,14 @@ export default function AnalyzeForm({ />
-
+
@@ -293,7 +321,10 @@ export default function AnalyzeForm({ Saving... ) : ( - "Save as Transaction" + <> + + Save as Transaction + )} diff --git a/prisma/migrations/20250520185247_add_cached_parse_result/migration.sql b/prisma/migrations/20250520185247_add_cached_parse_result/migration.sql new file mode 100644 index 0000000..657cb06 --- /dev/null +++ b/prisma/migrations/20250520185247_add_cached_parse_result/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "files" ADD COLUMN "cached_parse_result" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f813f1c..e909791 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -152,15 +152,16 @@ model Field { } model File { - id String @id @default(uuid()) @db.Uuid - userId String @map("user_id") @db.Uuid - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - filename String - path String - mimetype String - metadata Json? - isReviewed Boolean @default(false) @map("is_reviewed") - createdAt DateTime @default(now()) @map("created_at") + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + filename String + path String + mimetype String + metadata Json? + isReviewed Boolean @default(false) @map("is_reviewed") + cachedParseResult Json? @map("cached_parse_result") + createdAt DateTime @default(now()) @map("created_at") @@map("files") }