mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 13:25:18 +00:00
feat: cache ai results on server + show success banner
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "files" ADD COLUMN "cached_parse_result" JSONB;
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user