feat: cache ai results on server + show success banner

This commit is contained in:
vas3k
2025-05-20 22:32:38 +02:00
parent c352f5eadd
commit f5c5bf75f6
11 changed files with 142 additions and 84 deletions

View File

@@ -4,6 +4,7 @@ import { ActionState } from "@/lib/actions"
import config from "@/lib/config" import config from "@/lib/config"
import OpenAI from "openai" import OpenAI from "openai"
import { AnalyzeAttachment } from "./attachments" import { AnalyzeAttachment } from "./attachments"
import { updateFile } from "@/models/files"
export type AnalysisResult = { export type AnalysisResult = {
output: Record<string, string> output: Record<string, string>
@@ -14,7 +15,9 @@ export async function analyzeTransaction(
prompt: string, prompt: string,
schema: Record<string, unknown>, schema: Record<string, unknown>,
attachments: AnalyzeAttachment[], attachments: AnalyzeAttachment[],
apiKey: string apiKey: string,
fileId: string,
userId: string
): Promise<ActionState<AnalysisResult>> { ): Promise<ActionState<AnalysisResult>> {
const openai = new OpenAI({ const openai = new OpenAI({
apiKey, apiKey,
@@ -54,6 +57,9 @@ export async function analyzeTransaction(
console.log("ChatGPT tokens used:", response.usage) console.log("ChatGPT tokens used:", response.usage)
const result = JSON.parse(response.output_text) 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 } } return { success: true, data: { output: result, tokensUsed: response.usage?.total_tokens || 0 } }
} catch (error) { } catch (error) {
console.error("AI Analysis error:", error) console.error("AI Analysis error:", error)

View File

@@ -1,10 +1,14 @@
"use client" "use client"
import { Check, X, Trash2 } from "lucide-react"
import { createContext, ReactNode, useContext, useState } from "react" import { createContext, ReactNode, useContext, useState } from "react"
type BannerType = "success" | "deleted" | "failed" | "default"
type Notification = { type Notification = {
code: string code: string
message: string message: string
type?: BannerType
} }
type NotificationContextType = { type NotificationContextType = {
@@ -22,10 +26,51 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
const showNotification = (notification: Notification) => { const showNotification = (notification: Notification) => {
setNotification(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 <Check className="h-10 w-10 animate-bounce" />
case "deleted":
return <Trash2 className="h-10 w-10 animate-bounce" />
case "failed":
return <X className="h-10 w-10 animate-bounce" />
case "default":
return null
}
} }
return ( return (
<NotificationContext.Provider value={{ notification, showNotification }}>{children}</NotificationContext.Provider> <NotificationContext.Provider value={{ notification, showNotification }}>
{children}
{notification?.code === "global.banner" && (
<div className="fixed inset-0 flex items-center justify-center z-50">
<div
className={`border rounded-lg p-8 flex flex-col items-center justify-center gap-4 shadow-lg h-[160px] w-[160px] ${getBannerStyles(notification.type)}`}
>
{getBannerIcon(notification.type)}
<p className="text-xl font-medium">{notification.message}</p>
</div>
</div>
)}
</NotificationContext.Provider>
) )
} }

View File

@@ -67,7 +67,7 @@ export async function analyzeFileAction(
const schema = fieldsToJsonSchema(fields) 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) console.log("Analysis results:", results)

View File

@@ -3,7 +3,7 @@ import { Loader2 } from "lucide-react"
export default function Loading() { export default function Loading() {
return ( return (
<div className="flex flex-col gap-4 p-4 w-full max-w-6xl"> <div className="flex flex-col gap-6 p-4 w-full max-w-6xl">
<header className="flex items-center justify-between"> <header className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight flex flex-row gap-2"> <h2 className="text-3xl font-bold tracking-tight flex flex-row gap-2">
<span>Loading unsorted files...</span> <span>Loading unsorted files...</span>

View File

@@ -31,7 +31,7 @@ export default async function UnsortedPage() {
const settings = await getSettings(user.id) const settings = await getSettings(user.id)
return ( return (
<div className="flex flex-col gap-4 p-4 w-full max-w-6xl"> <div className="flex flex-col gap-6 p-4 w-full max-w-6xl">
<header className="flex items-center justify-between"> <header className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight">You have {files.length} unsorted files</h2> <h2 className="text-3xl font-bold tracking-tight">You have {files.length} unsorted files</h2>
</header> </header>

View File

@@ -42,7 +42,7 @@ export const viewport: Viewport = {
userScalable: false, userScalable: false,
} }
export default async function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en"> <html lang="en">
<body className="min-h-screen bg-white antialiased">{children}</body> <body className="min-h-screen bg-white antialiased">{children}</body>

View File

@@ -2,22 +2,9 @@
import { bulkDeleteTransactionsAction } from "@/app/(app)/transactions/actions" import { bulkDeleteTransactionsAction } from "@/app/(app)/transactions/actions"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { Trash2 } from "lucide-react"
import { ChevronUp, Trash2 } from "lucide-react"
import { useState } from "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 { interface BulkActionsMenuProps {
selectedIds: string[] selectedIds: string[]
onActionComplete?: () => void onActionComplete?: () => void
@@ -26,24 +13,21 @@ interface BulkActionsMenuProps {
export function BulkActionsMenu({ selectedIds, onActionComplete }: BulkActionsMenuProps) { export function BulkActionsMenu({ selectedIds, onActionComplete }: BulkActionsMenuProps) {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const handleAction = async (actionId: string) => { const handleDelete = async () => {
const action = bulkActions.find((a) => a.id === actionId) const confirmMessage =
if (!action) return "Are you sure you want to delete these transactions and all their files? This action cannot be undone."
if (!confirm(confirmMessage)) return
if (action.confirmMessage) {
if (!confirm(action.confirmMessage)) return
}
try { try {
setIsLoading(true) setIsLoading(true)
const result = await action.action(selectedIds) const result = await bulkDeleteTransactionsAction(selectedIds)
if (!result.success) { if (!result.success) {
throw new Error(result.error) throw new Error(result.error)
} }
onActionComplete?.() onActionComplete?.()
} catch (error) { } catch (error) {
console.error(`Failed to execute bulk action ${actionId}:`, error) console.error("Failed to delete transactions:", error)
alert(`Failed to execute action: ${error}`) alert(`Failed to delete transactions: ${error}`)
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -51,27 +35,10 @@ export function BulkActionsMenu({ selectedIds, onActionComplete }: BulkActionsMe
return ( return (
<div className="fixed bottom-4 right-4 z-50"> <div className="fixed bottom-4 right-4 z-50">
<DropdownMenu> <Button variant="destructive" className="min-w-48 gap-2" disabled={isLoading} onClick={handleDelete}>
<DropdownMenuTrigger asChild> <Trash2 className="h-4 w-4" />
<Button className="min-w-48" disabled={isLoading}> Delete {selectedIds.length} transactions
{selectedIds.length} transactions </Button>
<ChevronUp className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{bulkActions.map((action) => (
<DropdownMenuItem
key={action.id}
onClick={() => handleAction(action.id)}
className="gap-2"
disabled={isLoading}
>
<action.icon className="h-4 w-4" />
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
) )
} }

View File

@@ -10,7 +10,7 @@ import { FormInput, FormTextarea } from "@/components/forms/simple"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Category, Currency, Field, Project, Transaction } from "@/prisma/client" import { Category, Currency, Field, Project, Transaction } from "@/prisma/client"
import { format } from "date-fns" import { format } from "date-fns"
import { Loader2 } from "lucide-react" import { Loader2, Save, Trash2 } from "lucide-react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { startTransition, useActionState, useEffect, useMemo, useState } from "react" import { startTransition, useActionState, useEffect, useMemo, useState } from "react"
@@ -212,17 +212,23 @@ export default function TransactionEditForm({
<div className="flex justify-between space-x-4 pt-6"> <div className="flex justify-between space-x-4 pt-6">
<Button type="button" onClick={handleDelete} variant="destructive" disabled={isDeleting}> <Button type="button" onClick={handleDelete} variant="destructive" disabled={isDeleting}>
{isDeleting ? "⏳ Deleting..." : "Delete "} <>
<Trash2 className="h-4 w-4" />
{isDeleting ? "⏳ Deleting..." : "Delete "}
</>
</Button> </Button>
<Button type="submit" disabled={isSaving}> <Button type="submit" disabled={isSaving}>
{isSaving ? ( {isSaving ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Saving... Saving...
</> </>
) : ( ) : (
"Save Transaction" <>
<Save className="h-4 w-4" />
Save Transaction
</>
)} )}
</Button> </Button>

View File

@@ -12,7 +12,7 @@ import { FormInput, FormTextarea } from "@/components/forms/simple"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Category, Currency, Field, File, Project } from "@/prisma/client" import { Category, Currency, Field, File, Project } from "@/prisma/client"
import { format } from "date-fns" 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 { startTransition, useActionState, useMemo, useState } from "react"
import ToolWindow from "../agents/tool-window" import ToolWindow from "../agents/tool-window"
@@ -50,8 +50,8 @@ export default function AnalyzeForm({
}, [fields]) }, [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(() => {
() => ({ const baseState = {
name: file.filename, name: file.filename,
merchant: "", merchant: "",
description: "", description: "",
@@ -65,16 +65,32 @@ export default function AnalyzeForm({
issuedAt: "", issuedAt: "",
note: "", note: "",
text: "", text: "",
...extraFields.reduce( }
(acc, field) => {
acc[field.code] = "" // Add extra fields
return acc const extraFieldsState = extraFields.reduce(
}, (acc, field) => {
{} as Record<string, string> acc[field.code] = ""
), return acc
}), },
[file.filename, settings, extraFields] {} as Record<string, string>
) )
// Load cached results if they exist
const cachedResults = file.cachedParseResult
? Object.fromEntries(
Object.entries(file.cachedParseResult as Record<string, string>).filter(
([_, value]) => value !== null && value !== undefined && value !== ""
)
)
: {}
return {
...baseState,
...extraFieldsState,
...cachedResults,
}
}, [file.filename, settings, extraFields, file.cachedParseResult])
const [formData, setFormData] = useState(initialFormState) const [formData, setFormData] = useState(initialFormState)
async function saveAsTransaction(formData: FormData) { async function saveAsTransaction(formData: FormData) {
@@ -85,10 +101,12 @@ export default function AnalyzeForm({
setIsSaving(false) setIsSaving(false)
if (result.success) { if (result.success) {
showNotification({ code: "global.banner", message: "Saved!", type: "success" })
showNotification({ code: "sidebar.transactions", message: "new" }) showNotification({ code: "sidebar.transactions", message: "new" })
setTimeout(() => showNotification({ code: "sidebar.transactions", message: "" }), 3000) setTimeout(() => showNotification({ code: "sidebar.transactions", message: "" }), 3000)
} else { } else {
setSaveError(result.error ? result.error : "Something went wrong...") 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({
<Button className="w-full mb-6 py-6 text-lg" onClick={startAnalyze} disabled={isAnalyzing}> <Button className="w-full mb-6 py-6 text-lg" onClick={startAnalyze} disabled={isAnalyzing}>
{isAnalyzing ? ( {isAnalyzing ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-1 h-4 w-4 animate-spin" />
<span>{analyzeStep}</span> <span>{analyzeStep}</span>
</> </>
) : ( ) : (
<> <>
<Brain className="mr-2 h-4 w-4" /> <Brain className="mr-1 h-4 w-4" />
<span>Analyze with AI</span> <span>Analyze with AI</span>
</> </>
)} )}
@@ -146,7 +164,7 @@ export default function AnalyzeForm({
name="name" name="name"
value={formData.name} value={formData.name}
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))} onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
required={true} required={fieldMap.name.isRequired}
/> />
<FormInput <FormInput
@@ -155,6 +173,7 @@ export default function AnalyzeForm({
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={!fieldMap.merchant.isVisibleInAnalysis} hideIfEmpty={!fieldMap.merchant.isVisibleInAnalysis}
required={fieldMap.merchant.isRequired}
/> />
<FormInput <FormInput
@@ -163,6 +182,7 @@ export default function AnalyzeForm({
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={!fieldMap.description.isVisibleInAnalysis} hideIfEmpty={!fieldMap.description.isVisibleInAnalysis}
required={fieldMap.description.isRequired}
/> />
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
@@ -177,7 +197,7 @@ export default function AnalyzeForm({
!isNaN(newValue) && setFormData((prev) => ({ ...prev, total: newValue })) !isNaN(newValue) && setFormData((prev) => ({ ...prev, total: newValue }))
}} }}
className="w-32" className="w-32"
required={true} required={fieldMap.total.isRequired}
/> />
<FormSelectCurrency <FormSelectCurrency
@@ -187,6 +207,7 @@ export default function AnalyzeForm({
value={formData.currencyCode} value={formData.currencyCode}
onValueChange={(value) => setFormData((prev) => ({ ...prev, currencyCode: value }))} onValueChange={(value) => setFormData((prev) => ({ ...prev, currencyCode: value }))}
hideIfEmpty={!fieldMap.currencyCode.isVisibleInAnalysis} hideIfEmpty={!fieldMap.currencyCode.isVisibleInAnalysis}
required={fieldMap.currencyCode.isRequired}
/> />
<FormSelectType <FormSelectType
@@ -195,6 +216,7 @@ export default function AnalyzeForm({
value={formData.type} value={formData.type}
onValueChange={(value) => setFormData((prev) => ({ ...prev, type: value }))} onValueChange={(value) => setFormData((prev) => ({ ...prev, type: value }))}
hideIfEmpty={!fieldMap.type.isVisibleInAnalysis} hideIfEmpty={!fieldMap.type.isVisibleInAnalysis}
required={fieldMap.type.isRequired}
/> />
</div> </div>
@@ -219,6 +241,7 @@ export default function AnalyzeForm({
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={!fieldMap.issuedAt.isVisibleInAnalysis} hideIfEmpty={!fieldMap.issuedAt.isVisibleInAnalysis}
required={fieldMap.issuedAt.isRequired}
/> />
</div> </div>
@@ -231,6 +254,7 @@ export default function AnalyzeForm({
onValueChange={(value) => setFormData((prev) => ({ ...prev, categoryCode: value }))} onValueChange={(value) => setFormData((prev) => ({ ...prev, categoryCode: value }))}
placeholder="Select Category" placeholder="Select Category"
hideIfEmpty={!fieldMap.categoryCode.isVisibleInAnalysis} hideIfEmpty={!fieldMap.categoryCode.isVisibleInAnalysis}
required={fieldMap.categoryCode.isRequired}
/> />
{projects.length > 0 && ( {projects.length > 0 && (
@@ -242,6 +266,7 @@ export default function AnalyzeForm({
onValueChange={(value) => setFormData((prev) => ({ ...prev, projectCode: value }))} onValueChange={(value) => setFormData((prev) => ({ ...prev, projectCode: value }))}
placeholder="Select Project" placeholder="Select Project"
hideIfEmpty={!fieldMap.projectCode.isVisibleInAnalysis} hideIfEmpty={!fieldMap.projectCode.isVisibleInAnalysis}
required={fieldMap.projectCode.isRequired}
/> />
)} )}
</div> </div>
@@ -252,6 +277,7 @@ export default function AnalyzeForm({
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={!fieldMap.note.isVisibleInAnalysis} hideIfEmpty={!fieldMap.note.isVisibleInAnalysis}
required={fieldMap.note.isRequired}
/> />
{extraFields.map((field) => ( {extraFields.map((field) => (
@@ -263,6 +289,7 @@ export default function AnalyzeForm({
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={!field.isVisibleInAnalysis} hideIfEmpty={!field.isVisibleInAnalysis}
required={field.isRequired}
/> />
))} ))}
@@ -276,13 +303,14 @@ export default function AnalyzeForm({
/> />
</div> </div>
<div className="flex justify-end space-x-4 pt-6"> <div className="flex justify-between gap-4 pt-6">
<Button <Button
type="button" type="button"
onClick={() => startTransition(() => deleteAction(file.id))} onClick={() => startTransition(() => deleteAction(file.id))}
variant="outline" variant="destructive"
disabled={isDeleting} disabled={isDeleting}
> >
<Trash2 className="h-4 w-4" />
{isDeleting ? "⏳ Deleting..." : "Delete"} {isDeleting ? "⏳ Deleting..." : "Delete"}
</Button> </Button>
@@ -293,7 +321,10 @@ export default function AnalyzeForm({
Saving... Saving...
</> </>
) : ( ) : (
"Save as Transaction" <>
<ArrowDownToLine className="h-4 w-4" />
Save as Transaction
</>
)} )}
</Button> </Button>

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "files" ADD COLUMN "cached_parse_result" JSONB;

View File

@@ -152,15 +152,16 @@ model Field {
} }
model File { model File {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid userId String @map("user_id") @db.Uuid
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
filename String filename String
path String path String
mimetype String mimetype String
metadata Json? metadata Json?
isReviewed Boolean @default(false) @map("is_reviewed") isReviewed Boolean @default(false) @map("is_reviewed")
createdAt DateTime @default(now()) @map("created_at") cachedParseResult Json? @map("cached_parse_result")
createdAt DateTime @default(now()) @map("created_at")
@@map("files") @@map("files")
} }