Files
TaxHacker_s23/components/unsorted/analyze-form.tsx

338 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useNotification } from "@/app/(app)/context"
import { analyzeFileAction, deleteUnsortedFileAction, saveFileAsTransactionAction } from "@/app/(app)/unsorted/actions"
import { FormConvertCurrency } from "@/components/forms/convert-currency"
import { FormError } from "@/components/forms/error"
import { FormSelectCategory } from "@/components/forms/select-category"
import { FormSelectCurrency } from "@/components/forms/select-currency"
import { FormSelectProject } from "@/components/forms/select-project"
import { FormSelectType } from "@/components/forms/select-type"
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, Trash2, ArrowDownToLine } from "lucide-react"
import { startTransition, useActionState, useMemo, useState } from "react"
import ToolWindow from "../agents/tool-window"
export default function AnalyzeForm({
file,
categories,
projects,
currencies,
fields,
settings,
}: {
file: File
categories: Category[]
projects: Project[]
currencies: Currency[]
fields: Field[]
settings: Record<string, string>
}) {
const { showNotification } = useNotification()
const [isAnalyzing, setIsAnalyzing] = useState(false)
const [analyzeStep, setAnalyzeStep] = useState<string>("")
const [analyzeError, setAnalyzeError] = useState<string>("")
const [deleteState, deleteAction, isDeleting] = useActionState(deleteUnsortedFileAction, null)
const [isSaving, setIsSaving] = useState(false)
const [saveError, setSaveError] = useState("")
const fieldMap = useMemo(() => {
return fields.reduce(
(acc, field) => {
acc[field.code] = field
return acc
},
{} as Record<string, Field>
)
}, [fields])
const extraFields = useMemo(() => fields.filter((field) => field.isExtra), [fields])
const initialFormState = useMemo(() => {
const baseState = {
name: file.filename,
merchant: "",
description: "",
type: settings.default_type,
total: 0.0,
currencyCode: settings.default_currency,
convertedTotal: 0.0,
convertedCurrencyCode: settings.default_currency,
categoryCode: settings.default_category,
projectCode: settings.default_project,
issuedAt: "",
note: "",
text: "",
}
// Add extra fields
const extraFieldsState = extraFields.reduce(
(acc, field) => {
acc[field.code] = ""
return acc
},
{} 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)
async function saveAsTransaction(formData: FormData) {
setSaveError("")
setIsSaving(true)
startTransition(async () => {
const result = await saveFileAsTransactionAction(null, formData)
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" })
}
})
}
const startAnalyze = async () => {
setIsAnalyzing(true)
setAnalyzeError("")
try {
setAnalyzeStep("Analyzing...")
const results = await analyzeFileAction(file, settings, fields, categories, projects)
console.log("Analysis results:", results)
if (!results.success) {
setAnalyzeError(results.error ? results.error : "Something went wrong...")
} else {
const nonEmptyFields = Object.fromEntries(
Object.entries(results.data?.output || {}).filter(
([_, value]) => value !== null && value !== undefined && value !== ""
)
)
setFormData({ ...formData, ...nonEmptyFields })
}
} catch (error) {
console.error("Analysis failed:", error)
setAnalyzeError(error instanceof Error ? error.message : "Analysis failed")
} finally {
setIsAnalyzing(false)
setAnalyzeStep("")
}
}
return (
<>
<Button className="w-full mb-6 py-6 text-lg" onClick={startAnalyze} disabled={isAnalyzing} data-analyze-button>
{isAnalyzing ? (
<>
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
<span>{analyzeStep}</span>
</>
) : (
<>
<Brain className="mr-1 h-4 w-4" />
<span>Analyze with AI</span>
</>
)}
</Button>
{analyzeError && <FormError> {analyzeError}</FormError>}
<form className="space-y-4" action={saveAsTransaction}>
<input type="hidden" name="fileId" value={file.id} />
<FormInput
title={fieldMap.name.name}
name="name"
value={formData.name}
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
required={fieldMap.name.isRequired}
/>
<FormInput
title={fieldMap.merchant.name}
name="merchant"
value={formData.merchant}
onChange={(e) => setFormData((prev) => ({ ...prev, merchant: e.target.value }))}
hideIfEmpty={!fieldMap.merchant.isVisibleInAnalysis}
required={fieldMap.merchant.isRequired}
/>
<FormInput
title={fieldMap.description.name}
name="description"
value={formData.description}
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
hideIfEmpty={!fieldMap.description.isVisibleInAnalysis}
required={fieldMap.description.isRequired}
/>
<div className="flex flex-wrap gap-4">
<FormInput
title={fieldMap.total.name}
name="total"
type="number"
step="0.01"
value={formData.total || ""}
onChange={(e) => {
const newValue = parseFloat(e.target.value || "0")
!isNaN(newValue) && setFormData((prev) => ({ ...prev, total: newValue }))
}}
className="w-32"
required={fieldMap.total.isRequired}
/>
<FormSelectCurrency
title={fieldMap.currencyCode.name}
currencies={currencies}
name="currencyCode"
value={formData.currencyCode}
onValueChange={(value) => setFormData((prev) => ({ ...prev, currencyCode: value }))}
hideIfEmpty={!fieldMap.currencyCode.isVisibleInAnalysis}
required={fieldMap.currencyCode.isRequired}
/>
<FormSelectType
title={fieldMap.type.name}
name="type"
value={formData.type}
onValueChange={(value) => setFormData((prev) => ({ ...prev, type: value }))}
hideIfEmpty={!fieldMap.type.isVisibleInAnalysis}
required={fieldMap.type.isRequired}
/>
</div>
{formData.total != 0 && formData.currencyCode && formData.currencyCode !== settings.default_currency && (
<ToolWindow title={`Exchange rate on ${format(new Date(formData.issuedAt || Date.now()), "LLLL dd, yyyy")}`}>
<FormConvertCurrency
originalTotal={formData.total}
originalCurrencyCode={formData.currencyCode}
targetCurrencyCode={settings.default_currency}
date={new Date(formData.issuedAt || Date.now())}
onChange={(value) => setFormData((prev) => ({ ...prev, convertedTotal: value }))}
/>
<input type="hidden" name="convertedCurrencyCode" value={settings.default_currency} />
</ToolWindow>
)}
<div className="flex flex-row gap-4">
<FormInput
title={fieldMap.issuedAt.name}
type="date"
name="issuedAt"
value={formData.issuedAt}
onChange={(e) => setFormData((prev) => ({ ...prev, issuedAt: e.target.value }))}
hideIfEmpty={!fieldMap.issuedAt.isVisibleInAnalysis}
required={fieldMap.issuedAt.isRequired}
/>
</div>
<div className="flex flex-row gap-4">
<FormSelectCategory
title={fieldMap.categoryCode.name}
categories={categories}
name="categoryCode"
value={formData.categoryCode}
onValueChange={(value) => setFormData((prev) => ({ ...prev, categoryCode: value }))}
placeholder="Select Category"
hideIfEmpty={!fieldMap.categoryCode.isVisibleInAnalysis}
required={fieldMap.categoryCode.isRequired}
/>
{projects.length > 0 && (
<FormSelectProject
title={fieldMap.projectCode.name}
projects={projects}
name="projectCode"
value={formData.projectCode}
onValueChange={(value) => setFormData((prev) => ({ ...prev, projectCode: value }))}
placeholder="Select Project"
hideIfEmpty={!fieldMap.projectCode.isVisibleInAnalysis}
required={fieldMap.projectCode.isRequired}
/>
)}
</div>
<FormInput
title={fieldMap.note.name}
name="note"
value={formData.note}
onChange={(e) => setFormData((prev) => ({ ...prev, note: e.target.value }))}
hideIfEmpty={!fieldMap.note.isVisibleInAnalysis}
required={fieldMap.note.isRequired}
/>
{extraFields.map((field) => (
<FormInput
key={field.code}
type="text"
title={field.name}
name={field.code}
value={formData[field.code as keyof typeof formData]}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.code]: e.target.value }))}
hideIfEmpty={!field.isVisibleInAnalysis}
required={field.isRequired}
/>
))}
<div className="hidden">
<FormTextarea
title={fieldMap.text.name}
name="text"
value={formData.text}
onChange={(e) => setFormData((prev) => ({ ...prev, text: e.target.value }))}
hideIfEmpty={!fieldMap.text.isVisibleInAnalysis}
/>
</div>
<div className="flex justify-between gap-4 pt-6">
<Button
type="button"
onClick={() => startTransition(() => deleteAction(file.id))}
variant="destructive"
disabled={isDeleting}
>
<Trash2 className="h-4 w-4" />
{isDeleting ? "⏳ Deleting..." : "Delete"}
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<ArrowDownToLine className="h-4 w-4" />
Save as Transaction
</>
)}
</Button>
{deleteState?.error && <FormError> {deleteState.error}</FormError>}
{saveError && <FormError> {saveError}</FormError>}
</div>
</form>
</>
)
}