(squash) init

feat: filters, settings, backups

fix: ts compile errors

feat: new dashboard, webp previews and settings

feat: use webp for pdfs

feat: use webp

fix: analyze resets old data

fix: switch to corsproxy

fix: switch to free cors

fix: max upload limit

fix: currency conversion

feat: transaction export

fix: currency conversion

feat: refactor settings actions

feat: new loader

feat: README + LICENSE

doc: update readme

doc: update readme

doc: update readme

doc: update screenshots

ci: bump prisma
This commit is contained in:
Vasily Zubarev
2025-03-13 00:30:47 +01:00
commit 0b98a2c307
153 changed files with 17271 additions and 0 deletions

View File

@@ -0,0 +1,288 @@
"use client"
import { analyzeTransaction, retrieveAllAttachmentsForAI } from "@/app/ai/analyze"
import { useNotification } from "@/app/context"
import { deleteUnsortedFileAction, saveFileAsTransactionAction } from "@/app/unsorted/actions"
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 { Brain, Loader2 } from "lucide-react"
import { startTransition, useActionState, useState } from "react"
import { FormConvertCurrency } from "../forms/convert-currency"
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 extraFields = fields.filter((field) => field.isExtra)
const [formData, setFormData] = useState({
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: "",
...extraFields.reduce((acc, field) => {
acc[field.code] = ""
return acc
}, {} as Record<string, string>),
})
async function saveAsTransaction(formData: FormData) {
setSaveError("")
setIsSaving(true)
startTransition(async () => {
const result = await saveFileAsTransactionAction(null, formData)
setIsSaving(false)
if (result.success) {
showNotification({ code: "sidebar.transactions", message: "new" })
setTimeout(() => showNotification({ code: "sidebar.transactions", message: "" }), 3000)
} else {
setSaveError(result.error ? result.error : "Something went wrong...")
}
})
}
const startAnalyze = async () => {
setIsAnalyzing(true)
setAnalyzeStep("Retrieving files...")
setAnalyzeError("")
try {
const attachments = await retrieveAllAttachmentsForAI(file)
setAnalyzeStep("Analyzing...")
const results = await analyzeTransaction(
settings.prompt_analyse_new_file || process.env.PROMPT_ANALYSE_NEW_FILE || "",
settings,
fields,
categories,
projects,
attachments
)
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 || {}).filter(
([_, value]) => value !== null && value !== undefined && value !== ""
)
)
console.log("Setting form data:", nonEmptyFields)
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}>
{isAnalyzing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span>{analyzeStep}</span>
</>
) : (
<>
<Brain className="mr-2 h-4 w-4" />
<span>Analyze with AI</span>
</>
)}
</Button>
{analyzeError && <div className="mb-6 p-4 text-red-500 bg-red-50 rounded-md"> {analyzeError}</div>}
<form className="space-y-4" action={saveAsTransaction}>
<input type="hidden" name="fileId" value={file.id} />
<FormInput
title="Name"
name="name"
value={formData.name}
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
/>
<FormInput
title="Merchant"
name="merchant"
value={formData.merchant}
onChange={(e) => setFormData((prev) => ({ ...prev, merchant: e.target.value }))}
/>
<FormInput
title="Description"
name="description"
value={formData.description}
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
hideIfEmpty={true}
/>
<div className="flex flex-wrap gap-4">
<FormInput
title="Total"
name="total"
type="number"
step="0.01"
value={formData.total.toFixed(2)}
onChange={(e) => setFormData((prev) => ({ ...prev, total: parseFloat(e.target.value) }))}
className="w-32"
/>
<FormSelectCurrency
title="Currency"
currencies={currencies}
name="currencyCode"
value={formData.currencyCode}
onValueChange={(value) => setFormData((prev) => ({ ...prev, currencyCode: value }))}
/>
<FormSelectType
title="Type"
name="type"
value={formData.type}
onValueChange={(value) => setFormData((prev) => ({ ...prev, type: value }))}
/>
</div>
{formData.total != 0 && formData.currencyCode && formData.currencyCode !== settings.default_currency && (
<>
<FormConvertCurrency
originalTotal={formData.total}
originalCurrencyCode={formData.currencyCode}
targetCurrencyCode={settings.default_currency}
date={formData.issuedAt ? new Date(formData.issuedAt) : undefined}
onChange={(value) => setFormData((prev) => ({ ...prev, convertedTotal: value }))}
/>
<input type="hidden" name="convertedCurrencyCode" value={settings.default_currency} />
</>
)}
<div className="flex flex-row gap-4">
<FormInput
title="Issued At"
type="date"
name="issuedAt"
value={formData.issuedAt}
onChange={(e) => setFormData((prev) => ({ ...prev, issuedAt: e.target.value }))}
hideIfEmpty={true}
/>
</div>
<div className="flex flex-row gap-4">
<FormSelectCategory
title="Category"
categories={categories}
name="categoryCode"
value={formData.categoryCode}
onValueChange={(value) => setFormData((prev) => ({ ...prev, categoryCode: value }))}
placeholder="Select Category"
/>
{projects.length >= 0 && (
<FormSelectProject
title="Project"
projects={projects}
name="projectCode"
value={formData.projectCode}
onValueChange={(value) => setFormData((prev) => ({ ...prev, projectCode: value }))}
placeholder="Select Project"
/>
)}
</div>
<FormInput
title="Note"
name="note"
value={formData.note}
onChange={(e) => setFormData((prev) => ({ ...prev, note: e.target.value }))}
hideIfEmpty={true}
/>
{extraFields.map((field) => (
<FormInput
key={field.code}
type={field.type}
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={true}
/>
))}
<div className="hidden">
<FormTextarea
title="Recognized Text"
name="text"
value={formData.text}
onChange={(e) => setFormData((prev) => ({ ...prev, text: e.target.value }))}
hideIfEmpty={true}
/>
</div>
<div className="flex justify-end space-x-4 pt-6">
<Button
type="button"
onClick={() => startTransition(() => deleteAction(file.id))}
variant="outline"
disabled={isDeleting}
>
{isDeleting ? "⏳ Deleting..." : "Delete"}
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
"Save as Transaction"
)}
</Button>
{deleteState?.error && <span className="text-red-500"> {deleteState.error}</span>}
{saveError && <span className="text-red-500"> {saveError}</span>}
</div>
</form>
</>
)
}