feat: bugfixes, spedup, bulk actions,

This commit is contained in:
Vasily Zubarev
2025-03-17 18:36:25 +01:00
parent b27f07043e
commit 14967e1c85
34 changed files with 433 additions and 225 deletions

View File

@@ -1,40 +1,18 @@
"use client"
import { StatsFilters } from "@/data/stats"
import { TransactionFilters } from "@/data/transactions"
import { useTransactionFilters } from "@/hooks/use-transaction-filters"
import { format } from "date-fns"
import { useRouter, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react"
import { DateRangePicker } from "../forms/date-range-picker"
export function FiltersWidget({
defaultFilters,
defaultRange = "last-12-months",
}: {
defaultFilters: StatsFilters
defaultFilters: TransactionFilters
defaultRange?: string
}) {
const searchParams = useSearchParams()
const router = useRouter()
const [filters, setFilters] = useState<StatsFilters>(defaultFilters)
const applyFilters = () => {
const params = new URLSearchParams(searchParams.toString())
if (filters?.dateFrom) {
params.set("dateFrom", format(new Date(filters.dateFrom), "yyyy-MM-dd"))
} else {
params.delete("dateFrom")
}
if (filters?.dateTo) {
params.set("dateTo", format(new Date(filters.dateTo), "yyyy-MM-dd"))
} else {
params.delete("dateTo")
}
router.push(`?${params.toString()}`)
}
useEffect(() => {
applyFilters()
}, [filters])
const [filters, setFilters] = useTransactionFilters(defaultFilters)
return (
<DateRangePicker

View File

@@ -1,12 +1,13 @@
import { getProjects } from "@/data/projects"
import { getDashboardStats, getProjectStats, StatsFilters } from "@/data/stats"
import { getDashboardStats, getProjectStats } from "@/data/stats"
import { TransactionFilters } from "@/data/transactions"
import { formatCurrency } from "@/lib/utils"
import { ArrowDown, ArrowUp, BicepsFlexed } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
import { FiltersWidget } from "./filters-widget"
import { ProjectsWidget } from "./projects-widget"
export async function StatsWidget({ filters }: { filters: StatsFilters }) {
export async function StatsWidget({ filters }: { filters: TransactionFilters }) {
const projects = await getProjects()
const stats = await getDashboardStats(filters)
const statsPerProject = Object.fromEntries(
@@ -45,7 +46,12 @@ export async function StatsWidget({ filters }: { filters: StatsFilters }) {
</CardHeader>
<CardContent>
{Object.entries(stats.totalExpensesPerCurrency).map(([currency, total]) => (
<div key={currency} className="flex gap-2 items-center font-bold text-red-500 text-base first:text-2xl">
<div
key={currency}
className={`flex gap-2 items-center font-bold text-base first:text-2xl ${
total >= 0 ? "text-green-500" : "text-red-500"
}`}
>
{formatCurrency(total, currency)}
</div>
))}
@@ -59,7 +65,12 @@ export async function StatsWidget({ filters }: { filters: StatsFilters }) {
</CardHeader>
<CardContent>
{Object.entries(stats.profitPerCurrency).map(([currency, total]) => (
<div key={currency} className="flex gap-2 items-center font-bold text-green-500 text-base first:text-2xl">
<div
key={currency}
className={`flex gap-2 items-center font-bold text-base first:text-2xl ${
total >= 0 ? "text-green-500" : "text-red-500"
}`}
>
{formatCurrency(total, currency)}
</div>
))}

View File

@@ -11,7 +11,7 @@ import {
DialogTrigger,
} from "@/components/ui/dialog"
import { Separator } from "@/components/ui/separator"
import { TransactionFilters } from "@/data/transactions"
import { useTransactionFilters } from "@/hooks/use-transaction-filters"
import { Category, Field, Project } from "@prisma/client"
import { formatDate } from "date-fns"
import { useRouter } from "next/navigation"
@@ -23,20 +23,18 @@ import { FormSelectProject } from "../forms/select-project"
const deselectedFields = ["files", "text"]
export function ExportTransactionsDialog({
filters,
fields,
categories,
projects,
children,
}: {
filters: TransactionFilters
fields: Field[]
categories: Category[]
projects: Project[]
children: React.ReactNode
}) {
const router = useRouter()
const [exportFilters, setExportFilters] = useState<TransactionFilters>(filters)
const [exportFilters, setExportFilters] = useTransactionFilters()
const [exportFields, setExportFields] = useState<string[]>(
fields.map((field) => (deselectedFields.includes(field.code) ? "" : field.code))
)
@@ -62,10 +60,10 @@ export function ExportTransactionsDialog({
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4">
{filters.search && (
{exportFilters.search && (
<div className="flex flex-row items-center gap-2">
<span className="text-sm font-medium">Search query:</span>
<span className="text-sm">{filters.search}</span>
<span className="text-sm">{exportFilters.search}</span>
</div>
)}
@@ -74,8 +72,8 @@ export function ExportTransactionsDialog({
<DateRangePicker
defaultDate={{
from: filters?.dateFrom ? new Date(filters.dateFrom) : undefined,
to: filters?.dateTo ? new Date(filters.dateTo) : undefined,
from: exportFilters?.dateFrom ? new Date(exportFilters.dateFrom) : undefined,
to: exportFilters?.dateTo ? new Date(exportFilters.dateTo) : undefined,
}}
defaultRange="all-time"
onChange={(date) => {

View File

@@ -22,6 +22,7 @@ export function FilePreview({ file }: { file: File }) {
alt={file.filename}
width={300}
height={400}
loading="lazy"
className={`${
isEnlarged
? "fixed inset-0 z-50 m-auto w-screen h-screen object-contain cursor-zoom-out"

View File

@@ -2,8 +2,9 @@
import { useNotification } from "@/app/context"
import { uploadFilesAction } from "@/app/files/actions"
import { uploadTransactionFilesAction } from "@/app/transactions/actions"
import { AlertCircle, CloudUpload, Loader2 } from "lucide-react"
import { useRouter } from "next/navigation"
import { useParams, useRouter } from "next/navigation"
import { startTransition, useEffect, useRef, useState } from "react"
export default function ScreenDropArea({ children }: { children: React.ReactNode }) {
@@ -13,6 +14,7 @@ export default function ScreenDropArea({ children }: { children: React.ReactNode
const [isUploading, setIsUploading] = useState(false)
const [uploadError, setUploadError] = useState("")
const dragCounter = useRef(0)
const { transactionId } = useParams()
const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
@@ -53,16 +55,24 @@ export default function ScreenDropArea({ children }: { children: React.ReactNode
if (files && files.length > 0) {
try {
const formData = new FormData()
if (transactionId) {
formData.append("transactionId", transactionId as string)
}
for (let i = 0; i < files.length; i++) {
formData.append("files", files[i])
}
startTransition(async () => {
const result = await uploadFilesAction(null, formData)
const result = transactionId
? await uploadTransactionFilesAction(formData)
: await uploadFilesAction(null, formData)
if (result.success) {
showNotification({ code: "sidebar.unsorted", message: "new" })
setTimeout(() => showNotification({ code: "sidebar.unsorted", message: "" }), 3000)
router.push("/unsorted")
if (!transactionId) {
router.push("/unsorted")
}
} else {
setUploadError(result.error ? result.error : "Something went wrong...")
}
@@ -105,7 +115,9 @@ export default function ScreenDropArea({ children }: { children: React.ReactNode
>
<div className="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-xl text-center">
<CloudUpload className="h-16 w-16 mx-auto mb-4 text-primary" />
<h3 className="text-xl font-semibold mb-2">Drop Files to Upload</h3>
<h3 className="text-xl font-semibold mb-2">
{transactionId ? "Drop Files to Add to Transaction" : "Drop Files to Upload"}
</h3>
<p className="text-gray-600 dark:text-gray-400">Drop anywhere on the screen</p>
</div>
</div>
@@ -115,7 +127,9 @@ export default function ScreenDropArea({ children }: { children: React.ReactNode
<div className="fixed inset-0 bg-opacity-20 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-xl text-center">
<Loader2 className="h-16 w-16 mx-auto mb-4 text-primary animate-spin" />
<h3 className="text-xl font-semibold mb-2">Uploading...</h3>
<h3 className="text-xl font-semibold mb-2">
{transactionId ? "Adding files to transaction..." : "Uploading..."}
</h3>
</div>
</div>
)}

View File

@@ -2,6 +2,7 @@
import { Category } from "@prisma/client"
import { SelectProps } from "@radix-ui/react-select"
import { useMemo } from "react"
import { FormSelect } from "./simple"
export const FormSelectCategory = ({
@@ -11,13 +12,9 @@ export const FormSelectCategory = ({
placeholder,
...props
}: { title: string; categories: Category[]; emptyValue?: string; placeholder?: string } & SelectProps) => {
return (
<FormSelect
title={title}
items={categories.map((category) => ({ code: category.code, name: category.name, color: category.color }))}
emptyValue={emptyValue}
placeholder={placeholder}
{...props}
/>
const items = useMemo(
() => categories.map((category) => ({ code: category.code, name: category.name, color: category.color })),
[categories]
)
return <FormSelect title={title} items={items} emptyValue={emptyValue} placeholder={placeholder} {...props} />
}

View File

@@ -1,5 +1,6 @@
import { Currency } from "@prisma/client"
import { SelectProps } from "@radix-ui/react-select"
import { useMemo } from "react"
import { FormSelect } from "./simple"
export const FormSelectCurrency = ({
@@ -9,13 +10,9 @@ export const FormSelectCurrency = ({
placeholder,
...props
}: { title: string; currencies: Currency[]; emptyValue?: string; placeholder?: string } & SelectProps) => {
return (
<FormSelect
title={title}
items={currencies.map((currency) => ({ code: currency.code, name: `${currency.code} - ${currency.name}` }))}
emptyValue={emptyValue}
placeholder={placeholder}
{...props}
/>
const items = useMemo(
() => currencies.map((currency) => ({ code: currency.code, name: `${currency.code} - ${currency.name}` })),
[currencies]
)
return <FormSelect title={title} items={items} emptyValue={emptyValue} placeholder={placeholder} {...props} />
}

View File

@@ -12,6 +12,7 @@ interface CrudProps<T> {
key: keyof T
label: string
type?: "text" | "number" | "checkbox"
defaultValue?: string
editable?: boolean
}[]
onDelete: (id: string) => Promise<void>
@@ -134,7 +135,7 @@ export function CrudTable<T extends { [key: string]: any }>({ items, columns, on
{column.editable && (
<Input
type={column.type || "text"}
value={newItem[column.key] || ""}
value={newItem[column.key] || column.defaultValue || ""}
onChange={(e) =>
setNewItem({
...newItem,

View File

@@ -13,6 +13,13 @@ export default function LLMSettingsForm({ settings }: { settings: Record<string,
<form action={saveAction} className="space-y-4">
<FormInput title="OpenAI API Key" name="openai_api_key" defaultValue={settings.openai_api_key} />
<small className="text-muted-foreground">
Get your API key from{" "}
<a href="https://platform.openai.com/settings/organization/api-keys" target="_blank" className="underline">
OpenAI Platform Console
</a>
</small>
<FormTextarea
title="Prompt for Analyze Transaction"
name="prompt_analyse_new_file"

View File

@@ -0,0 +1,77 @@
"use client"
import { bulkDeleteTransactionsAction } from "@/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 { 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
}
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
}
try {
setIsLoading(true)
const result = await action.action(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}`)
} finally {
setIsLoading(false)
}
}
return (
<div className="fixed bottom-4 right-4 z-50">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-w-48" disabled={isLoading}>
{selectedIds.length} transactions
<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>
)
}

View File

@@ -55,18 +55,13 @@ export default function TransactionEditForm({
const handleDelete = async () => {
startTransition(async () => {
await deleteAction(transaction.id)
router.back()
})
}
useEffect(() => {
if (deleteState?.success) {
router.push("/transactions")
}
}, [deleteState, router])
useEffect(() => {
if (saveState?.success) {
router.push("/transactions")
router.back()
}
}, [saveState, router])
@@ -152,7 +147,7 @@ export default function TransactionEditForm({
))}
<div className="flex justify-end space-x-4 pt-6">
<Button type="button" onClick={handleDelete} variant="outline" disabled={isDeleting}>
<Button type="button" onClick={handleDelete} variant="destructive" disabled={isDeleting}>
{isDeleting ? "⏳ Deleting..." : "Delete Transaction"}
</Button>

View File

@@ -4,22 +4,11 @@ import { DateRangePicker } from "@/components/forms/date-range-picker"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { TransactionFilters } from "@/data/transactions"
import { useTransactionFilters } from "@/hooks/use-transaction-filters"
import { Category, Project } from "@prisma/client"
import { format } from "date-fns"
import { useRouter, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react"
export function TransactionSearchAndFilters({ categories, projects }: { categories: Category[]; projects: Project[] }) {
const searchParams = useSearchParams()
const router = useRouter()
const [filters, setFilters] = useState<TransactionFilters>({
search: searchParams.get("search") || "",
dateFrom: searchParams.get("dateFrom") || "",
dateTo: searchParams.get("dateTo") || "",
categoryCode: searchParams.get("categoryCode") || "",
projectCode: searchParams.get("projectCode") || "",
})
const [filters, setFilters] = useTransactionFilters()
const handleFilterChange = (name: keyof TransactionFilters, value: any) => {
setFilters((prev) => ({
@@ -28,45 +17,6 @@ export function TransactionSearchAndFilters({ categories, projects }: { categori
}))
}
const applyFilters = () => {
const params = new URLSearchParams(searchParams.toString())
if (filters.search) {
params.set("search", filters.search)
} else {
params.delete("search")
}
if (filters.dateFrom) {
params.set("dateFrom", format(new Date(filters.dateFrom), "yyyy-MM-dd"))
} else {
params.delete("dateFrom")
}
if (filters.dateTo) {
params.set("dateTo", format(new Date(filters.dateTo), "yyyy-MM-dd"))
} else {
params.delete("dateTo")
}
if (filters.categoryCode && filters.categoryCode !== "-") {
params.set("categoryCode", filters.categoryCode)
} else {
params.delete("categoryCode")
}
if (filters.projectCode && filters.projectCode !== "-") {
params.set("projectCode", filters.projectCode)
} else {
params.delete("projectCode")
}
router.push(`/transactions?${params.toString()}`)
}
useEffect(() => {
applyFilters()
}, [filters])
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-4">

View File

@@ -1,5 +1,6 @@
"use client"
import { BulkActionsMenu } from "@/components/transactions/bulk-actions"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -236,6 +237,9 @@ export function TransactionList({ transactions }: { transactions: Transaction[]
</TableRow>
</TableFooter>
</Table>
{selectedIds.length > 0 && (
<BulkActionsMenu selectedIds={selectedIds} onActionComplete={() => setSelectedIds([])} />
)}
</div>
)
}

View File

@@ -1,12 +1,12 @@
"use client"
import { deleteTransactionFileAction, uploadTransactionFileAction } from "@/app/transactions/actions"
import { deleteTransactionFileAction, uploadTransactionFilesAction } from "@/app/transactions/actions"
import { FilePreview } from "@/components/files/preview"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/files"
import { File, Transaction } from "@prisma/client"
import { Loader2, Upload } from "lucide-react"
import { Loader2, Upload, X } from "lucide-react"
import { useState } from "react"
export default function TransactionFiles({ transaction, files }: { transaction: Transaction; files: File[] }) {
@@ -21,8 +21,10 @@ export default function TransactionFiles({ transaction, files }: { transaction:
if (e.target.files && e.target.files.length > 0) {
const formData = new FormData()
formData.append("transactionId", transaction.id)
formData.append("file", e.target.files[0])
await uploadTransactionFileAction(formData)
for (let i = 0; i < e.target.files.length; i++) {
formData.append("files", e.target.files[i])
}
await uploadTransactionFilesAction(formData)
setIsUploading(false)
}
}
@@ -30,19 +32,24 @@ export default function TransactionFiles({ transaction, files }: { transaction:
return (
<>
{files.map((file) => (
<Card key={file.id} className="p-4">
<FilePreview file={file} />
<Button type="button" onClick={() => handleDeleteFile(file.id)} variant="destructive" className="w-full">
Delete File
<Card key={file.id} className="p-4 relative">
<Button
type="button"
onClick={() => handleDeleteFile(file.id)}
variant="destructive"
size="icon"
className="absolute -right-2 -top-2 rounded-full w-6 h-6 z-10"
>
<X className="h-4 w-4" />
</Button>
<FilePreview file={file} />
</Card>
))}
<Card className="relative h-32 p-4">
<Card className="relative min-h-32 p-4">
<input type="hidden" name="transactionId" value={transaction.id} />
<label
className="h-full w-full flex flex-col items-center justify-center p-4 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:border-primary transition-colors"
className="h-full w-full flex flex-col gap-2 items-center justify-center p-4 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:border-primary transition-colors"
onDragEnter={(e) => {
e.currentTarget.classList.add("border-primary")
}}
@@ -56,9 +63,11 @@ export default function TransactionFiles({ transaction, files }: { transaction:
<>
<Upload className="w-8 h-8 text-gray-400" />
<p className="text-sm text-gray-500">Add more files to this invoice</p>
<p className="text-xs text-gray-500">(or just drop them on this page)</p>
</>
)}
<input
multiple
type="file"
name="file"
className="absolute inset-0 top-0 left-0 w-full h-full opacity-0"

View File

@@ -0,0 +1,45 @@
"use client"
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@@ -3,6 +3,7 @@
import { analyzeTransaction, retrieveAllAttachmentsForAI } from "@/app/ai/analyze"
import { useNotification } from "@/app/context"
import { deleteUnsortedFileAction, saveFileAsTransactionAction } from "@/app/unsorted/actions"
import { FormConvertCurrency } from "@/components/forms/convert-currency"
import { FormSelectCategory } from "@/components/forms/select-category"
import { FormSelectCurrency } from "@/components/forms/select-currency"
import { FormSelectProject } from "@/components/forms/select-project"
@@ -11,8 +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 { Brain, Loader2 } from "lucide-react"
import { startTransition, useActionState, useState } from "react"
import { FormConvertCurrency } from "../forms/convert-currency"
import { startTransition, useActionState, useMemo, useState } from "react"
export default function AnalyzeForm({
file,
@@ -37,26 +37,30 @@ export default function AnalyzeForm({
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>),
})
const extraFields = useMemo(() => fields.filter((field) => field.isExtra), [fields])
const initialFormState = useMemo(
() => ({
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>),
}),
[file.filename, settings, extraFields]
)
const [formData, setFormData] = useState(initialFormState)
async function saveAsTransaction(formData: FormData) {
setSaveError("")
@@ -138,6 +142,7 @@ export default function AnalyzeForm({
name="name"
value={formData.name}
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
required={true}
/>
<FormInput
@@ -161,9 +166,13 @@ export default function AnalyzeForm({
name="total"
type="number"
step="0.01"
value={formData.total.toFixed(2)}
onChange={(e) => setFormData((prev) => ({ ...prev, total: parseFloat(e.target.value) }))}
value={formData.total || ""}
onChange={(e) => {
const newValue = parseFloat(e.target.value || "0")
!isNaN(newValue) && setFormData((prev) => ({ ...prev, total: newValue }))
}}
className="w-32"
required={true}
/>
<FormSelectCurrency