mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 13:25:18 +00:00
feat: use structured output, import CSV, bugfixes
This commit is contained in:
@@ -6,6 +6,7 @@ import { FILE_ACCEPTED_MIMETYPES } from "@/lib/files"
|
||||
import { Camera, Loader2 } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { startTransition, useState } from "react"
|
||||
import { FormError } from "../forms/error"
|
||||
|
||||
export default function DashboardDropZoneWidget() {
|
||||
const router = useRouter()
|
||||
@@ -65,7 +66,7 @@ export default function DashboardDropZoneWidget() {
|
||||
upload receipts, invoices and any other documents for me to scan
|
||||
</p>
|
||||
)}
|
||||
{uploadError && <p className="text-red-500">{uploadError}</p>}
|
||||
{uploadError && <FormError>{uploadError}</FormError>}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function StatsWidget({ filters }: { filters: TransactionFilters })
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{Object.entries(stats.totalIncomePerCurrency).map(([currency, total]) => (
|
||||
<div key={currency} className="flex gap-2 items-center font-bold text-base first:text-2xl">
|
||||
<div key={currency} className="flex gap-2 items-center font-bold text-base first:text-2xl text-green-500">
|
||||
{formatCurrency(total, currency)}
|
||||
</div>
|
||||
))}
|
||||
@@ -46,12 +46,7 @@ export async function StatsWidget({ filters }: { filters: TransactionFilters })
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{Object.entries(stats.totalExpensesPerCurrency).map(([currency, total]) => (
|
||||
<div
|
||||
key={currency}
|
||||
className={`flex gap-2 items-center font-bold text-base first:text-2xl ${
|
||||
total >= 0 ? "text-green-500" : "text-red-500"
|
||||
}`}
|
||||
>
|
||||
<div key={currency} className="flex gap-2 items-center font-bold text-base first:text-2xl text-red-500">
|
||||
{formatCurrency(total, currency)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
3
components/forms/error.tsx
Normal file
3
components/forms/error.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export function FormError({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-red-500 mt-4 overflow-hidden">{children}</p>
|
||||
}
|
||||
193
components/import/csv.tsx
Normal file
193
components/import/csv.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field } from "@prisma/client"
|
||||
import { Upload } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { startTransition, useActionState, useEffect, useState } from "react"
|
||||
import { parseCSVAction, saveTransactionsAction } from "../../app/import/csv/actions"
|
||||
import { FormError } from "../forms/error"
|
||||
|
||||
const MAX_PREVIEW_ROWS = 100
|
||||
|
||||
export function ImportCSVTable({ fields }: { fields: Field[] }) {
|
||||
const router = useRouter()
|
||||
const [parseState, parseAction] = useActionState(parseCSVAction, null)
|
||||
const [saveState, saveAction] = useActionState(saveTransactionsAction, null)
|
||||
|
||||
const [csvSettings, setCSVSettings] = useState({
|
||||
skipHeader: true,
|
||||
})
|
||||
const [csvData, setCSVData] = useState<string[][]>([])
|
||||
const [columnMappings, setColumnMappings] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (parseState?.success && parseState.data) {
|
||||
setCSVData(parseState.data)
|
||||
if (parseState.data.length > 0) {
|
||||
setColumnMappings(
|
||||
parseState.data[0].map((value) => {
|
||||
const field = fields.find((field) => field.code === value || field.name === value)
|
||||
return field?.code || ""
|
||||
})
|
||||
)
|
||||
} else {
|
||||
setColumnMappings([])
|
||||
}
|
||||
}
|
||||
}, [parseState])
|
||||
|
||||
useEffect(() => {
|
||||
if (saveState?.success) {
|
||||
router.push("/transactions")
|
||||
}
|
||||
}, [saveState, router])
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
|
||||
startTransition(async () => {
|
||||
await parseAction(formData)
|
||||
})
|
||||
}
|
||||
|
||||
const handleMappingChange = (columnIndex: number, fieldCode: string) => {
|
||||
setColumnMappings((prev) => {
|
||||
const state = [...prev]
|
||||
state[columnIndex] = fieldCode
|
||||
return state
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (csvData.length === 0) return
|
||||
|
||||
if (!isAtLeastOneFieldMapped(columnMappings)) {
|
||||
alert("Please map at least one column to a field")
|
||||
return
|
||||
}
|
||||
|
||||
const startIndex = csvSettings.skipHeader ? 1 : 0
|
||||
const processedRows = csvData.slice(startIndex).map((row) => {
|
||||
const processedRow: Record<string, unknown> = {}
|
||||
|
||||
columnMappings.forEach((fieldCode, columnIndex) => {
|
||||
if (!fieldCode || !row[columnIndex]) return
|
||||
processedRow[fieldCode] = row[columnIndex]
|
||||
})
|
||||
|
||||
return processedRow
|
||||
})
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("rows", JSON.stringify(processedRows))
|
||||
|
||||
startTransition(async () => {
|
||||
await saveAction(formData)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{csvData.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 h-full min-h-[400px]">
|
||||
<p className="text-muted-foreground">Upload your CSV file to import transactions</p>
|
||||
<div className="flex flex-row gap-5 mt-8">
|
||||
<div>
|
||||
<input type="file" accept=".csv" className="hidden" id="csv-file" onChange={handleFileChange} />
|
||||
<Button type="button" onClick={() => document.getElementById("csv-file")?.click()}>
|
||||
<Upload className="mr-2" /> Import from CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{parseState?.error && <FormError>{parseState.error}</FormError>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{csvData.length > 0 && (
|
||||
<div>
|
||||
<header className="flex flex-wrap items-center justify-between gap-2 mb-8">
|
||||
<h2 className="flex flex-row gap-3 md:gap-5">
|
||||
<span className="text-3xl font-bold tracking-tight">Import {csvData.length} items from CSV</span>
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} disabled={saveState?.success}>
|
||||
Import Transactions
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4"
|
||||
id="skip-header"
|
||||
defaultChecked={csvSettings.skipHeader}
|
||||
onChange={(e) => setCSVSettings({ ...csvSettings, skipHeader: e.target.checked })}
|
||||
/>
|
||||
<span>First row is a header</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table className="w-full caption-bottom text-sm">
|
||||
<thead className="[&_tr]:border-b">
|
||||
<tr className="border-b transition-colors hover:bg-muted/50">
|
||||
{csvData[0].map((_, index) => (
|
||||
<th key={index} className="h-12 min-w-[200px] px-4 text-left align-middle font-medium">
|
||||
<select
|
||||
className="w-full p-2 border rounded-md"
|
||||
value={columnMappings[index] || ""}
|
||||
onChange={(e) => handleMappingChange(index, e.target.value)}
|
||||
>
|
||||
<option value="">Skip column</option>
|
||||
{fields.map((field) => (
|
||||
<option key={field.code} value={field.code}>
|
||||
{field.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="[&_tr:last-child]:border-0">
|
||||
{csvData.slice(0, MAX_PREVIEW_ROWS).map((row, rowIndex) => (
|
||||
<tr
|
||||
key={rowIndex}
|
||||
className={`border-b transition-colors hover:bg-muted/50 ${
|
||||
rowIndex === 0 && csvSettings.skipHeader ? "line-through text-muted-foreground" : ""
|
||||
}`}
|
||||
>
|
||||
{csvData[0].map((_, colIndex) => (
|
||||
<td key={colIndex} className="p-4 align-middle">
|
||||
{(row[colIndex] || "").toString().slice(0, 256)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{csvData.length > MAX_PREVIEW_ROWS && (
|
||||
<p className="text-muted-foreground mt-4">and {csvData.length - MAX_PREVIEW_ROWS} more entries...</p>
|
||||
)}
|
||||
|
||||
{saveState?.error && <FormError>{saveState.error}</FormError>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function isAtLeastOneFieldMapped(columnMappings: string[]) {
|
||||
return columnMappings.some((mapping) => mapping !== "")
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button"
|
||||
import { Category, Currency } from "@prisma/client"
|
||||
import { CircleCheckBig } from "lucide-react"
|
||||
import { useActionState } from "react"
|
||||
import { FormError } from "../forms/error"
|
||||
|
||||
export default function GlobalSettingsForm({
|
||||
settings,
|
||||
@@ -53,7 +54,7 @@ export default function GlobalSettingsForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{saveState?.error && <p className="text-red-500">{saveState.error}</p>}
|
||||
{saveState?.error && <FormError>{saveState.error}</FormError>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,45 +1,75 @@
|
||||
"use client"
|
||||
|
||||
import { fieldsToJsonSchema } from "@/app/ai/schema"
|
||||
import { saveSettingsAction } from "@/app/settings/actions"
|
||||
import { FormInput, FormTextarea } from "@/components/forms/simple"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { CircleCheckBig } from "lucide-react"
|
||||
import { CircleCheckBig, Edit } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useActionState } from "react"
|
||||
import { FormError } from "../forms/error"
|
||||
import { Card, CardTitle } from "../ui/card"
|
||||
|
||||
export default function LLMSettingsForm({ settings }: { settings: Record<string, string> }) {
|
||||
export default function LLMSettingsForm({ settings, fields }: { settings: Record<string, string>; fields: Field[] }) {
|
||||
const [saveState, saveAction, pending] = useActionState(saveSettingsAction, null)
|
||||
|
||||
return (
|
||||
<form action={saveAction} className="space-y-4">
|
||||
<FormInput title="OpenAI API Key" name="openai_api_key" defaultValue={settings.openai_api_key} />
|
||||
<>
|
||||
<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>
|
||||
<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"
|
||||
defaultValue={settings.prompt_analyse_new_file}
|
||||
className="h-96"
|
||||
/>
|
||||
<FormTextarea
|
||||
title="Prompt for Analyze Transaction"
|
||||
name="prompt_analyse_new_file"
|
||||
defaultValue={settings.prompt_analyse_new_file}
|
||||
className="h-96"
|
||||
/>
|
||||
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Saving..." : "Save Settings"}
|
||||
</Button>
|
||||
{saveState?.success && (
|
||||
<p className="text-green-500 flex flex-row items-center gap-2">
|
||||
<CircleCheckBig />
|
||||
Saved!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Saving..." : "Save Settings"}
|
||||
</Button>
|
||||
{saveState?.success && (
|
||||
<p className="text-green-500 flex flex-row items-center gap-2">
|
||||
<CircleCheckBig />
|
||||
Saved!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{saveState?.error && <p className="text-red-500">{saveState.error}</p>}
|
||||
</form>
|
||||
{saveState?.error && <FormError>{saveState.error}</FormError>}
|
||||
</form>
|
||||
|
||||
<Card className="flex flex-col gap-4 p-4 bg-accent mt-20">
|
||||
<CardTitle className="flex flex-row justify-between items-center gap-2">
|
||||
<span className="text-md font-medium">
|
||||
Current JSON Schema for{" "}
|
||||
<a
|
||||
href="https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses&lang=javascript"
|
||||
target="_blank"
|
||||
className="underline"
|
||||
>
|
||||
structured output
|
||||
</a>
|
||||
</span>
|
||||
<Link
|
||||
href="/settings/fields"
|
||||
className="text-xs underline inline-flex flex-row items-center gap-1 text-muted-foreground"
|
||||
>
|
||||
<Edit className="w-4 h-4" /> Edit Fields
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<pre className="text-xs overflow-hidden text-ellipsis">
|
||||
{JSON.stringify(fieldsToJsonSchema(fields), null, 2)}
|
||||
</pre>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export function SideNav({ className, items, ...props }: SidebarNavProps) {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<nav className={cn("flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1", className)} {...props}>
|
||||
<nav className={cn("flex flex-wrap space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1", className)} {...props}>
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { ClockArrowUp, FileText, LayoutDashboard, Settings, Sparkles, Upload } from "lucide-react"
|
||||
import { ClockArrowUp, FileText, Import, LayoutDashboard, Settings, Sparkles, Upload } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
@@ -31,7 +31,7 @@ export function AppSidebar({
|
||||
settings: Record<string, string>
|
||||
unsortedFilesCount: number
|
||||
}) {
|
||||
const { setOpenMobile } = useSidebar()
|
||||
const { open, setOpenMobile } = useSidebar()
|
||||
const pathname = usePathname()
|
||||
const { notification } = useNotification()
|
||||
|
||||
@@ -44,23 +44,31 @@ export function AppSidebar({
|
||||
<>
|
||||
<Sidebar variant="inset" collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<Link href="/" className="flex items-center gap-2 p-2">
|
||||
<Avatar className="h-12 w-12 rounded-lg">
|
||||
<AvatarImage src="/logo/256.png" />
|
||||
<AvatarFallback className="rounded-lg">AI</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left leading-tight">
|
||||
<span className="truncate font-semibold">{settings.app_title}</span>
|
||||
<span className="truncate text-xs">Beta</span>
|
||||
</div>
|
||||
<SidebarTrigger className="md:hidden" />
|
||||
</Link>
|
||||
{open ? (
|
||||
<Link href="/" className="flex items-center gap-2 p-2">
|
||||
<Avatar className="h-12 w-12 rounded-lg">
|
||||
<AvatarImage src="/logo/256.png" />
|
||||
<AvatarFallback className="rounded-lg">AI</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left leading-tight">
|
||||
<span className="truncate font-semibold">{settings.app_title}</span>
|
||||
<span className="truncate text-xs">Beta</span>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href="/">
|
||||
<Avatar className="h-10 w-10 rounded-lg">
|
||||
<AvatarImage src="/logo/256.png" />
|
||||
<AvatarFallback className="rounded-lg">AI</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<UploadButton className="w-full mt-4 mb-2">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
<span>Upload</span>
|
||||
<Upload className="h-4 w-4" />
|
||||
{open ? <span>Upload</span> : ""}
|
||||
</UploadButton>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
@@ -121,6 +129,14 @@ export function AppSidebar({
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link href="/import/csv">
|
||||
<Import />
|
||||
Import from CSV
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link href="https://vas3k.com/donate/" target="_blank">
|
||||
@@ -129,6 +145,11 @@ export function AppSidebar({
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{!open && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarTrigger />
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
@@ -9,9 +9,11 @@ import { FormInput, FormTextarea } from "@/components/forms/simple"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Category, Currency, Project } from "@prisma/client"
|
||||
import { format } from "date-fns"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { Import, Loader2 } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useActionState, useEffect, useState } from "react"
|
||||
import { FormError } from "../forms/error"
|
||||
|
||||
export default function TransactionCreateForm({
|
||||
categories,
|
||||
@@ -110,7 +112,13 @@ export default function TransactionCreateForm({
|
||||
|
||||
<FormTextarea title="Note" name="note" defaultValue={formData.note} />
|
||||
|
||||
<div className="flex justify-end space-x-4 pt-6">
|
||||
<div className="flex justify-between space-x-4 pt-6">
|
||||
<Button type="button" variant="outline" className="aspect-square">
|
||||
<Link href="/import/csv">
|
||||
<Import className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isCreating}>
|
||||
{isCreating ? (
|
||||
<>
|
||||
@@ -122,7 +130,7 @@ export default function TransactionCreateForm({
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{createState?.error && <span className="text-red-500">⚠️ {createState.error}</span>}
|
||||
{createState?.error && <FormError>⚠️ {createState.error}</FormError>}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { format } from "date-fns"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { startTransition, useActionState, useEffect, useState } from "react"
|
||||
import { FormError } from "../forms/error"
|
||||
|
||||
export default function TransactionEditForm({
|
||||
transaction,
|
||||
@@ -164,8 +165,8 @@ export default function TransactionEditForm({
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{deleteState?.error && <span className="text-red-500">⚠️ {deleteState.error}</span>}
|
||||
{saveState?.error && <span className="text-red-500">⚠️ {saveState.error}</span>}
|
||||
{deleteState?.error && <FormError>⚠️ {deleteState.error}</FormError>}
|
||||
{saveState?.error && <FormError>⚠️ {saveState.error}</FormError>}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button"
|
||||
import { Category, Currency, Field, File, Project } from "@prisma/client"
|
||||
import { Brain, Loader2 } from "lucide-react"
|
||||
import { startTransition, useActionState, useMemo, useState } from "react"
|
||||
import { FormError } from "../forms/error"
|
||||
|
||||
export default function AnalyzeForm({
|
||||
file,
|
||||
@@ -133,7 +134,7 @@ export default function AnalyzeForm({
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{analyzeError && <div className="mb-6 p-4 text-red-500 bg-red-50 rounded-md">⚠️ {analyzeError}</div>}
|
||||
{analyzeError && <FormError>⚠️ {analyzeError}</FormError>}
|
||||
|
||||
<form className="space-y-4" action={saveAsTransaction}>
|
||||
<input type="hidden" name="fileId" value={file.id} />
|
||||
@@ -288,8 +289,8 @@ export default function AnalyzeForm({
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{deleteState?.error && <span className="text-red-500">⚠️ {deleteState.error}</span>}
|
||||
{saveError && <span className="text-red-500">⚠️ {saveError}</span>}
|
||||
{deleteState?.error && <FormError>⚠️ {deleteState.error}</FormError>}
|
||||
{saveError && <FormError>⚠️ {saveError}</FormError>}
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user