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:
@@ -13,8 +13,4 @@ And projects are:
|
|||||||
|
|
||||||
{projects}
|
{projects}
|
||||||
|
|
||||||
If you can't find something leave it blank. Return only valid JSON with these fields:
|
If you can't find something leave it blank. Return only one object. Do not include any other text in your response!"
|
||||||
|
|
||||||
{json_structure}
|
|
||||||
|
|
||||||
Do not include any other text in your response!"
|
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Category, Field, File, Project } from "@prisma/client"
|
import { Category, Field, File, Project } from "@prisma/client"
|
||||||
import OpenAI from "openai"
|
import OpenAI from "openai"
|
||||||
import { ChatCompletion } from "openai/resources/index.mjs"
|
|
||||||
import { buildLLMPrompt } from "./prompt"
|
import { buildLLMPrompt } from "./prompt"
|
||||||
|
import { fieldsToJsonSchema } from "./schema"
|
||||||
|
|
||||||
const MAX_PAGES_TO_ANALYZE = 3
|
const MAX_PAGES_TO_ANALYZE = 4
|
||||||
|
|
||||||
type AnalyzeAttachment = {
|
type AnalyzeAttachment = {
|
||||||
|
filename: string
|
||||||
contentType: string
|
contentType: string
|
||||||
base64: string
|
base64: string
|
||||||
}
|
}
|
||||||
@@ -32,7 +33,11 @@ export const retrieveFileContentForAI = async (file: File, page: number): Promis
|
|||||||
const buffer = await blob.arrayBuffer()
|
const buffer = await blob.arrayBuffer()
|
||||||
const base64 = Buffer.from(buffer).toString("base64")
|
const base64 = Buffer.from(buffer).toString("base64")
|
||||||
|
|
||||||
return { contentType: response.headers.get("Content-Type") || file.mimetype, base64: base64 }
|
return {
|
||||||
|
filename: file.filename,
|
||||||
|
contentType: response.headers.get("Content-Type") || file.mimetype,
|
||||||
|
base64: base64,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function analyzeTransaction(
|
export async function analyzeTransaction(
|
||||||
@@ -49,33 +54,41 @@ export async function analyzeTransaction(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const prompt = buildLLMPrompt(promptTemplate, fields, categories, projects)
|
const prompt = buildLLMPrompt(promptTemplate, fields, categories, projects)
|
||||||
|
const schema = fieldsToJsonSchema(fields)
|
||||||
|
|
||||||
console.log("PROMPT:", prompt)
|
console.log("PROMPT:", prompt)
|
||||||
|
console.log("SCHEMA:", schema)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await openai.chat.completions.create({
|
const response = await openai.responses.create({
|
||||||
model: "gpt-4o-mini",
|
model: "gpt-4o-mini-2024-07-18",
|
||||||
messages: [
|
input: [
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [
|
content: prompt,
|
||||||
{ type: "text", text: prompt || "" },
|
},
|
||||||
...attachments.slice(0, MAX_PAGES_TO_ANALYZE).map((attachment) => ({
|
{
|
||||||
type: "image_url" as const,
|
role: "user",
|
||||||
image_url: {
|
content: attachments.map((attachment) => ({
|
||||||
url: `data:${attachment.contentType};base64,${attachment.base64}`,
|
type: "input_image",
|
||||||
},
|
image_url: `data:${attachment.contentType};base64,${attachment.base64}`,
|
||||||
})),
|
})),
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
text: {
|
||||||
|
format: {
|
||||||
|
type: "json_schema",
|
||||||
|
name: "transaction",
|
||||||
|
schema: schema,
|
||||||
|
strict: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log("ChatGPT response:", response.choices[0].message)
|
console.log("ChatGPT response:", response.output_text)
|
||||||
|
|
||||||
const cleanedJson = extractAndParseJSON(response)
|
const result = JSON.parse(response.output_text)
|
||||||
|
return { success: true, data: result }
|
||||||
return { success: true, data: cleanedJson }
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("AI Analysis error:", error)
|
console.error("AI Analysis error:", error)
|
||||||
return {
|
return {
|
||||||
@@ -84,56 +97,3 @@ export async function analyzeTransaction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractAndParseJSON(response: ChatCompletion) {
|
|
||||||
try {
|
|
||||||
const content = response.choices?.[0]?.message?.content
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
throw new Error("No response content from AI")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for JSON in code blocks (handles ```json, ``` json, or just ```)
|
|
||||||
let jsonText = content.trim()
|
|
||||||
const codeBlockRegex = /```(?:json)?\s*([\s\S]*?)\s*```/
|
|
||||||
const jsonMatch = content.match(codeBlockRegex)
|
|
||||||
|
|
||||||
if (jsonMatch && jsonMatch[1]) {
|
|
||||||
jsonText = jsonMatch[1].trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to parse the JSON
|
|
||||||
try {
|
|
||||||
return JSON.parse(jsonText)
|
|
||||||
} catch (parseError) {
|
|
||||||
// JSON might have unescaped characters, try to fix them
|
|
||||||
const fixedJsonText = escapeJsonString(jsonText)
|
|
||||||
return JSON.parse(fixedJsonText)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error processing AI response:", error)
|
|
||||||
throw new Error(`Failed to extract valid JSON: ${error instanceof Error ? error.message : "Unknown error"}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeJsonString(jsonStr: string) {
|
|
||||||
// This is a black magic to fix some AI-generated JSONs
|
|
||||||
if (jsonStr.trim().startsWith("{") && jsonStr.trim().endsWith("}")) {
|
|
||||||
return jsonStr.replace(/"([^"]*?)":(\s*)"(.*?)"/g, (match, key, space, value) => {
|
|
||||||
const escapedValue = value
|
|
||||||
.replace(/\\/g, "\\\\") // backslash
|
|
||||||
.replace(/"/g, '\\"') // double quotes
|
|
||||||
.replace(/\n/g, "\\n") // newline
|
|
||||||
.replace(/\r/g, "\\r") // carriage return
|
|
||||||
.replace(/\t/g, "\\t") // tab
|
|
||||||
.replace(/\f/g, "\\f") // form feed
|
|
||||||
.replace(/[\x00-\x1F\x7F-\x9F]/g, (c: string) => {
|
|
||||||
return "\\u" + ("0000" + c.charCodeAt(0).toString(16)).slice(-4)
|
|
||||||
})
|
|
||||||
|
|
||||||
return `"${key}":${space}"${escapedValue}"`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonStr
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -35,15 +35,5 @@ export function buildLLMPrompt(
|
|||||||
prompt = prompt.replace("{categories.code}", categories.map((category) => `${category.code}`).join(", "))
|
prompt = prompt.replace("{categories.code}", categories.map((category) => `${category.code}`).join(", "))
|
||||||
prompt = prompt.replace("{projects.code}", projects.map((project) => `${project.code}`).join(", "))
|
prompt = prompt.replace("{projects.code}", projects.map((project) => `${project.code}`).join(", "))
|
||||||
|
|
||||||
prompt = prompt.replace(
|
|
||||||
"{json_structure}",
|
|
||||||
"{ " +
|
|
||||||
fields
|
|
||||||
.filter((field) => field.llm_prompt)
|
|
||||||
.map((field) => `${field.code}: ${field.type}`)
|
|
||||||
.join(", ") +
|
|
||||||
" }"
|
|
||||||
)
|
|
||||||
|
|
||||||
return prompt
|
return prompt
|
||||||
}
|
}
|
||||||
|
|||||||
16
app/ai/schema.ts
Normal file
16
app/ai/schema.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Field } from "@prisma/client"
|
||||||
|
|
||||||
|
export const fieldsToJsonSchema = (fields: Field[]) => {
|
||||||
|
const fieldsWithPrompt = fields.filter((field) => field.llm_prompt)
|
||||||
|
const schema = {
|
||||||
|
type: "object",
|
||||||
|
properties: fieldsWithPrompt.reduce((acc, field) => {
|
||||||
|
acc[field.code] = { type: field.type, description: field.llm_prompt || "" }
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, { type: string; description: string }>),
|
||||||
|
required: fieldsWithPrompt.map((field) => field.code),
|
||||||
|
additionalProperties: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ExportFields, ExportFilters } from "@/data/export"
|
import { ExportFields, ExportFilters, exportImportFieldsMapping } from "@/data/export_and_import"
|
||||||
import { getFields } from "@/data/fields"
|
import { getFields } from "@/data/fields"
|
||||||
import { getFilesByTransactionId } from "@/data/files"
|
import { getFilesByTransactionId } from "@/data/files"
|
||||||
import { getTransactions } from "@/data/transactions"
|
import { getTransactions } from "@/data/transactions"
|
||||||
@@ -18,11 +18,10 @@ export async function GET(request: Request) {
|
|||||||
const transactions = await getTransactions(filters)
|
const transactions = await getTransactions(filters)
|
||||||
const existingFields = await getFields()
|
const existingFields = await getFields()
|
||||||
|
|
||||||
|
// Generate CSV file with all transactions
|
||||||
try {
|
try {
|
||||||
const fieldKeys = fields.filter((field) => existingFields.some((f) => f.code === field))
|
const fieldKeys = fields.filter((field) => existingFields.some((f) => f.code === field))
|
||||||
const writeHeaders = fieldKeys.map((field) => existingFields.find((f) => f.code === field)?.name)
|
|
||||||
|
|
||||||
// Generate CSV file with all transactions
|
|
||||||
let csvContent = ""
|
let csvContent = ""
|
||||||
const csvStream = format({ headers: fieldKeys, writeBOM: true, writeHeaders: false })
|
const csvStream = format({ headers: fieldKeys, writeBOM: true, writeHeaders: false })
|
||||||
|
|
||||||
@@ -30,15 +29,25 @@ export async function GET(request: Request) {
|
|||||||
csvContent += chunk
|
csvContent += chunk
|
||||||
})
|
})
|
||||||
|
|
||||||
csvStream.write(writeHeaders)
|
// Custom CSV headers
|
||||||
transactions.forEach((transaction) => {
|
const headers = fieldKeys.map((field) => existingFields.find((f) => f.code === field)?.name ?? "UNKNOWN")
|
||||||
const row = fieldKeys.reduce((acc, key) => {
|
csvStream.write(headers)
|
||||||
acc[key] = transaction[key as keyof typeof transaction] ?? ""
|
|
||||||
return acc
|
// CSV rows
|
||||||
}, {} as Record<string, any>)
|
for (const transaction of transactions) {
|
||||||
|
const row: Record<string, any> = {}
|
||||||
|
for (const key of fieldKeys) {
|
||||||
|
const value = transaction[key as keyof typeof transaction] ?? ""
|
||||||
|
const exportFieldSettings = exportImportFieldsMapping[key]
|
||||||
|
if (exportFieldSettings && exportFieldSettings.export) {
|
||||||
|
row[key] = await exportFieldSettings.export(value)
|
||||||
|
} else {
|
||||||
|
row[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
csvStream.write(row)
|
csvStream.write(row)
|
||||||
})
|
}
|
||||||
csvStream.end()
|
csvStream.end()
|
||||||
|
|
||||||
// Wait for CSV generation to complete
|
// Wait for CSV generation to complete
|
||||||
|
|||||||
66
app/import/csv/actions.tsx
Normal file
66
app/import/csv/actions.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { exportImportFieldsMapping } from "@/data/export_and_import"
|
||||||
|
import { createTransaction } from "@/data/transactions"
|
||||||
|
import { parse } from "@fast-csv/parse"
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
|
||||||
|
export async function parseCSVAction(prevState: any, formData: FormData) {
|
||||||
|
const file = formData.get("file") as File
|
||||||
|
if (!file) {
|
||||||
|
return { success: false, error: "No file uploaded" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.name.toLowerCase().endsWith(".csv")) {
|
||||||
|
return { success: false, error: "Only CSV files are allowed" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer())
|
||||||
|
const rows: string[][] = []
|
||||||
|
|
||||||
|
const parser = parse()
|
||||||
|
.on("data", (row) => rows.push(row))
|
||||||
|
.on("error", (error) => {
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
parser.write(buffer)
|
||||||
|
parser.end()
|
||||||
|
|
||||||
|
// Wait for parsing to complete
|
||||||
|
await new Promise((resolve) => parser.on("end", resolve))
|
||||||
|
|
||||||
|
return { success: true, data: rows }
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error parsing CSV:", error)
|
||||||
|
return { success: false, error: "Failed to parse CSV file" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveTransactionsAction(prevState: any, formData: FormData) {
|
||||||
|
try {
|
||||||
|
const rows = JSON.parse(formData.get("rows") as string) as Record<string, unknown>[]
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const transactionData: Record<string, unknown> = {}
|
||||||
|
for (const [fieldCode, value] of Object.entries(row)) {
|
||||||
|
const fieldDef = exportImportFieldsMapping[fieldCode]
|
||||||
|
if (fieldDef?.import) {
|
||||||
|
transactionData[fieldCode] = await fieldDef.import(value as string)
|
||||||
|
} else {
|
||||||
|
transactionData[fieldCode] = value as string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await createTransaction(transactionData)
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/import/csv")
|
||||||
|
revalidatePath("/transactions")
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving transactions:", error)
|
||||||
|
return { success: false, error: "Failed to save transactions: " + error }
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/import/csv/page.tsx
Normal file
11
app/import/csv/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { ImportCSVTable } from "@/components/import/csv"
|
||||||
|
import { getFields } from "@/data/fields"
|
||||||
|
|
||||||
|
export default async function CSVImportPage() {
|
||||||
|
const fields = await getFields()
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 p-4">
|
||||||
|
<ImportCSVTable fields={fields} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<MobileMenu settings={settings} unsortedFilesCount={unsortedFilesCount} />
|
<MobileMenu settings={settings} unsortedFilesCount={unsortedFilesCount} />
|
||||||
<AppSidebar settings={settings} unsortedFilesCount={unsortedFilesCount} />
|
<AppSidebar settings={settings} unsortedFilesCount={unsortedFilesCount} />
|
||||||
<SidebarInset className="w-screen mt-[60px] md:mt-0">{children}</SidebarInset>
|
<SidebarInset className="w-full h-full mt-[60px] md:mt-0 overflow-auto">{children}</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</ScreenDropArea>
|
</ScreenDropArea>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Loader2 } from "lucide-react"
|
|||||||
|
|
||||||
export default function AppLoading() {
|
export default function AppLoading() {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full w-full">
|
<div className="absolute inset-0 flex items-center justify-center h-full w-full">
|
||||||
<Loader2 className="w-10 h-10 animate-spin text-muted-foreground" />
|
<Loader2 className="w-10 h-10 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { FormError } from "@/components/forms/error"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
import { Download, Loader2 } from "lucide-react"
|
import { Download, Loader2 } from "lucide-react"
|
||||||
@@ -51,7 +52,7 @@ export default function BackupSettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
{restoreState?.error && <p className="text-red-500">{restoreState.error}</p>}
|
{restoreState?.error && <FormError>{restoreState.error}</FormError>}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default async function FieldsSettingsPage() {
|
|||||||
items={fieldsWithActions}
|
items={fieldsWithActions}
|
||||||
columns={[
|
columns={[
|
||||||
{ key: "name", label: "Name", editable: true },
|
{ key: "name", label: "Name", editable: true },
|
||||||
{ key: "type", label: "Type", editable: true },
|
{ key: "type", label: "Type", defaultValue: "string", editable: true },
|
||||||
{ key: "llm_prompt", label: "LLM Prompt", editable: true },
|
{ key: "llm_prompt", label: "LLM Prompt", editable: true },
|
||||||
]}
|
]}
|
||||||
onDelete={async (code) => {
|
onDelete={async (code) => {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const settingsCategories = [
|
|||||||
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="hidden space-y-6 p-10 pb-16 md:block">
|
<div className="space-y-6 p-10 pb-16">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Settings</h2>
|
<h2 className="text-2xl font-bold tracking-tight">Settings</h2>
|
||||||
<p className="text-muted-foreground">Customize your settings here</p>
|
<p className="text-muted-foreground">Customize your settings here</p>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import LLMSettingsForm from "@/components/settings/llm-settings-form"
|
import LLMSettingsForm from "@/components/settings/llm-settings-form"
|
||||||
|
import { getFields } from "@/data/fields"
|
||||||
import { getSettings } from "@/data/settings"
|
import { getSettings } from "@/data/settings"
|
||||||
|
|
||||||
export default async function LlmSettingsPage() {
|
export default async function LlmSettingsPage() {
|
||||||
const settings = await getSettings()
|
const settings = await getSettings()
|
||||||
|
const fields = await getFields()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full max-w-2xl">
|
<div className="w-full max-w-2xl">
|
||||||
<LLMSettingsForm settings={settings} />
|
<LLMSettingsForm settings={settings} fields={fields} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { FILE_ACCEPTED_MIMETYPES } from "@/lib/files"
|
|||||||
import { Camera, Loader2 } from "lucide-react"
|
import { Camera, Loader2 } from "lucide-react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { startTransition, useState } from "react"
|
import { startTransition, useState } from "react"
|
||||||
|
import { FormError } from "../forms/error"
|
||||||
|
|
||||||
export default function DashboardDropZoneWidget() {
|
export default function DashboardDropZoneWidget() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -65,7 +66,7 @@ export default function DashboardDropZoneWidget() {
|
|||||||
upload receipts, invoices and any other documents for me to scan
|
upload receipts, invoices and any other documents for me to scan
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{uploadError && <p className="text-red-500">{uploadError}</p>}
|
{uploadError && <FormError>{uploadError}</FormError>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export async function StatsWidget({ filters }: { filters: TransactionFilters })
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{Object.entries(stats.totalIncomePerCurrency).map(([currency, total]) => (
|
{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)}
|
{formatCurrency(total, currency)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -46,12 +46,7 @@ export async function StatsWidget({ filters }: { filters: TransactionFilters })
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{Object.entries(stats.totalExpensesPerCurrency).map(([currency, total]) => (
|
{Object.entries(stats.totalExpensesPerCurrency).map(([currency, total]) => (
|
||||||
<div
|
<div key={currency} className="flex gap-2 items-center font-bold text-base first:text-2xl text-red-500">
|
||||||
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)}
|
{formatCurrency(total, currency)}
|
||||||
</div>
|
</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 { Category, Currency } from "@prisma/client"
|
||||||
import { CircleCheckBig } from "lucide-react"
|
import { CircleCheckBig } from "lucide-react"
|
||||||
import { useActionState } from "react"
|
import { useActionState } from "react"
|
||||||
|
import { FormError } from "../forms/error"
|
||||||
|
|
||||||
export default function GlobalSettingsForm({
|
export default function GlobalSettingsForm({
|
||||||
settings,
|
settings,
|
||||||
@@ -53,7 +54,7 @@ export default function GlobalSettingsForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{saveState?.error && <p className="text-red-500">{saveState.error}</p>}
|
{saveState?.error && <FormError>{saveState.error}</FormError>}
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,75 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { fieldsToJsonSchema } from "@/app/ai/schema"
|
||||||
import { saveSettingsAction } from "@/app/settings/actions"
|
import { saveSettingsAction } from "@/app/settings/actions"
|
||||||
import { FormInput, FormTextarea } from "@/components/forms/simple"
|
import { FormInput, FormTextarea } from "@/components/forms/simple"
|
||||||
import { Button } from "@/components/ui/button"
|
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 { 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)
|
const [saveState, saveAction, pending] = useActionState(saveSettingsAction, null)
|
||||||
|
|
||||||
return (
|
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">
|
<small className="text-muted-foreground">
|
||||||
Get your API key from{" "}
|
Get your API key from{" "}
|
||||||
<a href="https://platform.openai.com/settings/organization/api-keys" target="_blank" className="underline">
|
<a href="https://platform.openai.com/settings/organization/api-keys" target="_blank" className="underline">
|
||||||
OpenAI Platform Console
|
OpenAI Platform Console
|
||||||
</a>
|
</a>
|
||||||
</small>
|
</small>
|
||||||
|
|
||||||
<FormTextarea
|
<FormTextarea
|
||||||
title="Prompt for Analyze Transaction"
|
title="Prompt for Analyze Transaction"
|
||||||
name="prompt_analyse_new_file"
|
name="prompt_analyse_new_file"
|
||||||
defaultValue={settings.prompt_analyse_new_file}
|
defaultValue={settings.prompt_analyse_new_file}
|
||||||
className="h-96"
|
className="h-96"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-row items-center gap-4">
|
<div className="flex flex-row items-center gap-4">
|
||||||
<Button type="submit" disabled={pending}>
|
<Button type="submit" disabled={pending}>
|
||||||
{pending ? "Saving..." : "Save Settings"}
|
{pending ? "Saving..." : "Save Settings"}
|
||||||
</Button>
|
</Button>
|
||||||
{saveState?.success && (
|
{saveState?.success && (
|
||||||
<p className="text-green-500 flex flex-row items-center gap-2">
|
<p className="text-green-500 flex flex-row items-center gap-2">
|
||||||
<CircleCheckBig />
|
<CircleCheckBig />
|
||||||
Saved!
|
Saved!
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{saveState?.error && <p className="text-red-500">{saveState.error}</p>}
|
{saveState?.error && <FormError>{saveState.error}</FormError>}
|
||||||
</form>
|
</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()
|
const pathname = usePathname()
|
||||||
|
|
||||||
return (
|
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) => (
|
{items.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar"
|
} 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 Link from "next/link"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
@@ -31,7 +31,7 @@ export function AppSidebar({
|
|||||||
settings: Record<string, string>
|
settings: Record<string, string>
|
||||||
unsortedFilesCount: number
|
unsortedFilesCount: number
|
||||||
}) {
|
}) {
|
||||||
const { setOpenMobile } = useSidebar()
|
const { open, setOpenMobile } = useSidebar()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const { notification } = useNotification()
|
const { notification } = useNotification()
|
||||||
|
|
||||||
@@ -44,23 +44,31 @@ export function AppSidebar({
|
|||||||
<>
|
<>
|
||||||
<Sidebar variant="inset" collapsible="icon">
|
<Sidebar variant="inset" collapsible="icon">
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<Link href="/" className="flex items-center gap-2 p-2">
|
{open ? (
|
||||||
<Avatar className="h-12 w-12 rounded-lg">
|
<Link href="/" className="flex items-center gap-2 p-2">
|
||||||
<AvatarImage src="/logo/256.png" />
|
<Avatar className="h-12 w-12 rounded-lg">
|
||||||
<AvatarFallback className="rounded-lg">AI</AvatarFallback>
|
<AvatarImage src="/logo/256.png" />
|
||||||
</Avatar>
|
<AvatarFallback className="rounded-lg">AI</AvatarFallback>
|
||||||
<div className="grid flex-1 text-left leading-tight">
|
</Avatar>
|
||||||
<span className="truncate font-semibold">{settings.app_title}</span>
|
<div className="grid flex-1 text-left leading-tight">
|
||||||
<span className="truncate text-xs">Beta</span>
|
<span className="truncate font-semibold">{settings.app_title}</span>
|
||||||
</div>
|
<span className="truncate text-xs">Beta</span>
|
||||||
<SidebarTrigger className="md:hidden" />
|
</div>
|
||||||
</Link>
|
</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>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<UploadButton className="w-full mt-4 mb-2">
|
<UploadButton className="w-full mt-4 mb-2">
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
<Upload className="h-4 w-4" />
|
||||||
<span>Upload</span>
|
{open ? <span>Upload</span> : ""}
|
||||||
</UploadButton>
|
</UploadButton>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
@@ -121,6 +129,14 @@ export function AppSidebar({
|
|||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton asChild>
|
||||||
|
<Link href="/import/csv">
|
||||||
|
<Import />
|
||||||
|
Import from CSV
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton asChild>
|
<SidebarMenuButton asChild>
|
||||||
<Link href="https://vas3k.com/donate/" target="_blank">
|
<Link href="https://vas3k.com/donate/" target="_blank">
|
||||||
@@ -129,6 +145,11 @@ export function AppSidebar({
|
|||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
{!open && (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarTrigger />
|
||||||
|
</SidebarMenuItem>
|
||||||
|
)}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import { FormInput, FormTextarea } from "@/components/forms/simple"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Category, Currency, Project } from "@prisma/client"
|
import { Category, Currency, Project } from "@prisma/client"
|
||||||
import { format } from "date-fns"
|
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 { useRouter } from "next/navigation"
|
||||||
import { useActionState, useEffect, useState } from "react"
|
import { useActionState, useEffect, useState } from "react"
|
||||||
|
import { FormError } from "../forms/error"
|
||||||
|
|
||||||
export default function TransactionCreateForm({
|
export default function TransactionCreateForm({
|
||||||
categories,
|
categories,
|
||||||
@@ -110,7 +112,13 @@ export default function TransactionCreateForm({
|
|||||||
|
|
||||||
<FormTextarea title="Note" name="note" defaultValue={formData.note} />
|
<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}>
|
<Button type="submit" disabled={isCreating}>
|
||||||
{isCreating ? (
|
{isCreating ? (
|
||||||
<>
|
<>
|
||||||
@@ -122,7 +130,7 @@ export default function TransactionCreateForm({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{createState?.error && <span className="text-red-500">⚠️ {createState.error}</span>}
|
{createState?.error && <FormError>⚠️ {createState.error}</FormError>}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { format } from "date-fns"
|
|||||||
import { Loader2 } from "lucide-react"
|
import { Loader2 } from "lucide-react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { startTransition, useActionState, useEffect, useState } from "react"
|
import { startTransition, useActionState, useEffect, useState } from "react"
|
||||||
|
import { FormError } from "../forms/error"
|
||||||
|
|
||||||
export default function TransactionEditForm({
|
export default function TransactionEditForm({
|
||||||
transaction,
|
transaction,
|
||||||
@@ -164,8 +165,8 @@ export default function TransactionEditForm({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{deleteState?.error && <span className="text-red-500">⚠️ {deleteState.error}</span>}
|
{deleteState?.error && <FormError>⚠️ {deleteState.error}</FormError>}
|
||||||
{saveState?.error && <span className="text-red-500">⚠️ {saveState.error}</span>}
|
{saveState?.error && <FormError>⚠️ {saveState.error}</FormError>}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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 { Brain, Loader2 } from "lucide-react"
|
import { Brain, Loader2 } from "lucide-react"
|
||||||
import { startTransition, useActionState, useMemo, useState } from "react"
|
import { startTransition, useActionState, useMemo, useState } from "react"
|
||||||
|
import { FormError } from "../forms/error"
|
||||||
|
|
||||||
export default function AnalyzeForm({
|
export default function AnalyzeForm({
|
||||||
file,
|
file,
|
||||||
@@ -133,7 +134,7 @@ export default function AnalyzeForm({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</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}>
|
<form className="space-y-4" action={saveAsTransaction}>
|
||||||
<input type="hidden" name="fileId" value={file.id} />
|
<input type="hidden" name="fileId" value={file.id} />
|
||||||
@@ -288,8 +289,8 @@ export default function AnalyzeForm({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{deleteState?.error && <span className="text-red-500">⚠️ {deleteState.error}</span>}
|
{deleteState?.error && <FormError>⚠️ {deleteState.error}</FormError>}
|
||||||
{saveError && <span className="text-red-500">⚠️ {saveError}</span>}
|
{saveError && <FormError>⚠️ {saveError}</FormError>}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ export const getCategories = cache(async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const getCategoryByCode = cache(async (code: string) => {
|
||||||
|
return await prisma.category.findUnique({
|
||||||
|
where: { code },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
export const createCategory = async (category: Prisma.CategoryCreateInput) => {
|
export const createCategory = async (category: Prisma.CategoryCreateInput) => {
|
||||||
if (!category.code) {
|
if (!category.code) {
|
||||||
category.code = codeFromName(category.name as string)
|
category.code = codeFromName(category.name as string)
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { TransactionFilters } from "./transactions"
|
|
||||||
|
|
||||||
export type ExportFilters = TransactionFilters
|
|
||||||
|
|
||||||
export type ExportFields = string[]
|
|
||||||
155
data/export_and_import.ts
Normal file
155
data/export_and_import.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { prisma } from "@/lib/db"
|
||||||
|
import { codeFromName } from "@/lib/utils"
|
||||||
|
import { formatDate } from "date-fns"
|
||||||
|
import { createCategory, getCategoryByCode } from "./categories"
|
||||||
|
import { createProject, getProjectByCode } from "./projects"
|
||||||
|
import { TransactionFilters } from "./transactions"
|
||||||
|
|
||||||
|
export type ExportFilters = TransactionFilters
|
||||||
|
|
||||||
|
export type ExportFields = string[]
|
||||||
|
|
||||||
|
export const exportImportFields = [
|
||||||
|
{
|
||||||
|
code: "name",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "description",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "merchant",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "total",
|
||||||
|
type: "number",
|
||||||
|
export: async function (value: number) {
|
||||||
|
return value / 100
|
||||||
|
},
|
||||||
|
import: async function (value: string) {
|
||||||
|
const num = parseFloat(value)
|
||||||
|
return isNaN(num) ? 0.0 : num * 100
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "currencyCode",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "convertedTotal",
|
||||||
|
type: "number",
|
||||||
|
export: async function (value: number | null) {
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return value / 100
|
||||||
|
},
|
||||||
|
import: async function (value: string) {
|
||||||
|
const num = parseFloat(value)
|
||||||
|
return isNaN(num) ? 0.0 : num * 100
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "convertedCurrencyCode",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "type",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "note",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "categoryCode",
|
||||||
|
type: "string",
|
||||||
|
export: async function (value: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const category = await getCategoryByCode(value)
|
||||||
|
return category?.name
|
||||||
|
},
|
||||||
|
import: async function (value: string) {
|
||||||
|
const category = await importCategory(value)
|
||||||
|
return category?.code
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "projectCode",
|
||||||
|
type: "string",
|
||||||
|
export: async function (value: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const project = await getProjectByCode(value)
|
||||||
|
return project?.name
|
||||||
|
},
|
||||||
|
import: async function (value: string) {
|
||||||
|
const project = await importProject(value)
|
||||||
|
return project?.code
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "issuedAt",
|
||||||
|
type: "date",
|
||||||
|
export: async function (value: Date | null) {
|
||||||
|
if (!value || isNaN(value.getTime())) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return formatDate(value, "yyyy-MM-dd")
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
import: async function (value: string) {
|
||||||
|
try {
|
||||||
|
return new Date(value)
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const exportImportFieldsMapping = exportImportFields.reduce((acc, field) => {
|
||||||
|
acc[field.code] = field
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, any>)
|
||||||
|
|
||||||
|
export const importProject = async (name: string) => {
|
||||||
|
const code = codeFromName(name)
|
||||||
|
|
||||||
|
const existingProject = await prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [{ code }, { name }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingProject) {
|
||||||
|
return existingProject
|
||||||
|
}
|
||||||
|
|
||||||
|
return await createProject({ code, name })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const importCategory = async (name: string) => {
|
||||||
|
const code = codeFromName(name)
|
||||||
|
|
||||||
|
const existingCategory = await prisma.category.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [{ code }, { name }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingCategory) {
|
||||||
|
return existingCategory
|
||||||
|
}
|
||||||
|
|
||||||
|
return await createCategory({ code, name })
|
||||||
|
}
|
||||||
@@ -11,6 +11,12 @@ export const getProjects = cache(async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const getProjectByCode = cache(async (code: string) => {
|
||||||
|
return await prisma.project.findUnique({
|
||||||
|
where: { code },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
export const createProject = async (project: Prisma.ProjectCreateInput) => {
|
export const createProject = async (project: Prisma.ProjectCreateInput) => {
|
||||||
if (!project.code) {
|
if (!project.code) {
|
||||||
project.code = codeFromName(project.name as string)
|
project.code = codeFromName(project.name as string)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const FILE_ACCEPTED_MIMETYPES = "image/*,.pdf,.doc,.docx,.xls,.xlsx"
|
|||||||
export const FILE_UPLOAD_PATH = path.resolve(process.env.UPLOAD_PATH || "./uploads")
|
export const FILE_UPLOAD_PATH = path.resolve(process.env.UPLOAD_PATH || "./uploads")
|
||||||
export const FILE_UNSORTED_UPLOAD_PATH = path.join(FILE_UPLOAD_PATH, "unsorted")
|
export const FILE_UNSORTED_UPLOAD_PATH = path.join(FILE_UPLOAD_PATH, "unsorted")
|
||||||
export const FILE_PREVIEWS_PATH = path.join(FILE_UPLOAD_PATH, "previews")
|
export const FILE_PREVIEWS_PATH = path.join(FILE_UPLOAD_PATH, "previews")
|
||||||
|
export const FILE_IMPORT_CSV_UPLOAD_PATH = path.join(FILE_UPLOAD_PATH, "csv")
|
||||||
|
|
||||||
export async function getUnsortedFileUploadPath(filename: string) {
|
export async function getUnsortedFileUploadPath(filename: string) {
|
||||||
const fileUuid = randomUUID()
|
const fileUuid = randomUUID()
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { Transaction } from "@prisma/client"
|
|||||||
export function calcTotalPerCurrency(transactions: Transaction[]): Record<string, number> {
|
export function calcTotalPerCurrency(transactions: Transaction[]): Record<string, number> {
|
||||||
return transactions.reduce((acc, transaction) => {
|
return transactions.reduce((acc, transaction) => {
|
||||||
if (transaction.convertedCurrencyCode) {
|
if (transaction.convertedCurrencyCode) {
|
||||||
acc[transaction.convertedCurrencyCode] =
|
acc[transaction.convertedCurrencyCode.toUpperCase()] =
|
||||||
(acc[transaction.convertedCurrencyCode] || 0) + (transaction.convertedTotal || 0)
|
(acc[transaction.convertedCurrencyCode.toUpperCase()] || 0) + (transaction.convertedTotal || 0)
|
||||||
} else if (transaction.currencyCode) {
|
} else if (transaction.currencyCode) {
|
||||||
acc[transaction.currencyCode] = (acc[transaction.currencyCode] || 0) + (transaction.total || 0)
|
acc[transaction.currencyCode.toUpperCase()] =
|
||||||
|
(acc[transaction.currencyCode.toUpperCase()] || 0) + (transaction.total || 0)
|
||||||
}
|
}
|
||||||
return acc
|
return acc
|
||||||
}, {} as Record<string, number>)
|
}, {} as Record<string, number>)
|
||||||
|
|||||||
33
package-lock.json
generated
33
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fast-csv/format": "^5.0.2",
|
"@fast-csv/format": "^5.0.2",
|
||||||
|
"@fast-csv/parse": "^5.0.2",
|
||||||
"@hookform/resolvers": "^4.1.2",
|
"@hookform/resolvers": "^4.1.2",
|
||||||
"@prisma/client": "^6.4.1",
|
"@prisma/client": "^6.4.1",
|
||||||
"@radix-ui/react-avatar": "^1.1.3",
|
"@radix-ui/react-avatar": "^1.1.3",
|
||||||
@@ -679,6 +680,20 @@
|
|||||||
"lodash.isnil": "^4.0.0"
|
"lodash.isnil": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fast-csv/parse": {
|
||||||
|
"version": "5.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-5.0.2.tgz",
|
||||||
|
"integrity": "sha512-gMu1Btmm99TP+wc0tZnlH30E/F1Gw1Tah3oMDBHNPe9W8S68ixVHjt89Wg5lh7d9RuQMtwN+sGl5kxR891+fzw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash.escaperegexp": "^4.1.2",
|
||||||
|
"lodash.groupby": "^4.6.0",
|
||||||
|
"lodash.isfunction": "^3.0.9",
|
||||||
|
"lodash.isnil": "^4.0.0",
|
||||||
|
"lodash.isundefined": "^3.0.1",
|
||||||
|
"lodash.uniq": "^4.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@floating-ui/core": {
|
"node_modules/@floating-ui/core": {
|
||||||
"version": "1.6.9",
|
"version": "1.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
|
||||||
@@ -5597,6 +5612,12 @@
|
|||||||
"integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
|
"integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.groupby": {
|
||||||
|
"version": "4.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz",
|
||||||
|
"integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.isboolean": {
|
"node_modules/lodash.isboolean": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
@@ -5622,6 +5643,12 @@
|
|||||||
"integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==",
|
"integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.isundefined": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
@@ -5629,6 +5656,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.uniq": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
|
|||||||
@@ -8,10 +8,12 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"seed": "ts-node prisma/seed.ts"
|
"seed": "ts-node prisma/seed.ts",
|
||||||
|
"postinstall": "prisma generate && prisma migrate deploy && npm run seed"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fast-csv/format": "^5.0.2",
|
"@fast-csv/format": "^5.0.2",
|
||||||
|
"@fast-csv/parse": "^5.0.2",
|
||||||
"@hookform/resolvers": "^4.1.2",
|
"@hookform/resolvers": "^4.1.2",
|
||||||
"@prisma/client": "^6.4.1",
|
"@prisma/client": "^6.4.1",
|
||||||
"@radix-ui/react-avatar": "^1.1.3",
|
"@radix-ui/react-avatar": "^1.1.3",
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- The primary key for the `fields` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||||
|
- You are about to drop the column `id` on the `fields` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_fields" (
|
||||||
|
"code" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL DEFAULT 'string',
|
||||||
|
"llm_prompt" TEXT,
|
||||||
|
"options" JSONB,
|
||||||
|
"is_required" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"is_extra" BOOLEAN NOT NULL DEFAULT true
|
||||||
|
);
|
||||||
|
INSERT INTO "new_fields" ("code", "is_extra", "is_required", "llm_prompt", "name", "options", "type") SELECT "code", "is_extra", "is_required", "llm_prompt", "name", "options", "type" FROM "fields";
|
||||||
|
DROP TABLE "fields";
|
||||||
|
ALTER TABLE "new_fields" RENAME TO "fields";
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "transactions_project_id_idx" ON "transactions"("project_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "transactions_category_id_idx" ON "transactions"("category_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "transactions_issued_at_idx" ON "transactions"("issued_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "transactions_name_idx" ON "transactions"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "transactions_merchant_idx" ON "transactions"("merchant");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "transactions_total_idx" ON "transactions"("total");
|
||||||
@@ -42,10 +42,9 @@ model Project {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Field {
|
model Field {
|
||||||
id String @id @default(uuid())
|
code String @id
|
||||||
code String @unique
|
|
||||||
name String
|
name String
|
||||||
type String
|
type String @default("string")
|
||||||
llm_prompt String?
|
llm_prompt String?
|
||||||
options Json?
|
options Json?
|
||||||
isRequired Boolean @default(false) @map("is_required")
|
isRequired Boolean @default(false) @map("is_required")
|
||||||
|
|||||||
@@ -51,8 +51,7 @@ const settings = [
|
|||||||
{
|
{
|
||||||
code: "prompt_analyse_new_file",
|
code: "prompt_analyse_new_file",
|
||||||
name: "Prompt for Analyze Transaction",
|
name: "Prompt for Analyze Transaction",
|
||||||
description:
|
description: "Allowed variables: {fields}, {categories}, {categories.code}, {projects}, {projects.code}",
|
||||||
"Allowed variables: {fields}, {categories}, {categories.code}, {projects}, {projects.code}, {json_structure}",
|
|
||||||
value: `You are an accountant and invoice analysis assistant.
|
value: `You are an accountant and invoice analysis assistant.
|
||||||
Extract the following information from the given invoice:
|
Extract the following information from the given invoice:
|
||||||
|
|
||||||
@@ -66,11 +65,7 @@ And projects are:
|
|||||||
|
|
||||||
{projects}
|
{projects}
|
||||||
|
|
||||||
If you can't find something leave it blank. Return only valid JSON with these fields:
|
If you can't find something leave it blank. Return only one object. Do not include any other text in your response!`,
|
||||||
|
|
||||||
{json_structure}
|
|
||||||
|
|
||||||
Return only one object. Do not include any other text in your response!`,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: "is_welcome_message_hidden",
|
code: "is_welcome_message_hidden",
|
||||||
@@ -309,7 +304,7 @@ const fields = [
|
|||||||
{
|
{
|
||||||
code: "name",
|
code: "name",
|
||||||
name: "Name",
|
name: "Name",
|
||||||
type: "text",
|
type: "string",
|
||||||
llm_prompt: "human readable name, summarize what is the invoice about",
|
llm_prompt: "human readable name, summarize what is the invoice about",
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
isExtra: false,
|
isExtra: false,
|
||||||
@@ -317,7 +312,7 @@ const fields = [
|
|||||||
{
|
{
|
||||||
code: "description",
|
code: "description",
|
||||||
name: "Description",
|
name: "Description",
|
||||||
type: "text",
|
type: "string",
|
||||||
llm_prompt: "description of the transaction",
|
llm_prompt: "description of the transaction",
|
||||||
isRequired: false,
|
isRequired: false,
|
||||||
isExtra: false,
|
isExtra: false,
|
||||||
@@ -325,7 +320,7 @@ const fields = [
|
|||||||
{
|
{
|
||||||
code: "merchant",
|
code: "merchant",
|
||||||
name: "Merchant",
|
name: "Merchant",
|
||||||
type: "text",
|
type: "string",
|
||||||
llm_prompt: "merchant name",
|
llm_prompt: "merchant name",
|
||||||
isRequired: false,
|
isRequired: false,
|
||||||
isExtra: false,
|
isExtra: false,
|
||||||
@@ -333,7 +328,7 @@ const fields = [
|
|||||||
{
|
{
|
||||||
code: "type",
|
code: "type",
|
||||||
name: "Type",
|
name: "Type",
|
||||||
type: "text",
|
type: "string",
|
||||||
llm_prompt: "",
|
llm_prompt: "",
|
||||||
isRequired: false,
|
isRequired: false,
|
||||||
isExtra: false,
|
isExtra: false,
|
||||||
@@ -349,7 +344,7 @@ const fields = [
|
|||||||
{
|
{
|
||||||
code: "currencyCode",
|
code: "currencyCode",
|
||||||
name: "Currency",
|
name: "Currency",
|
||||||
type: "text",
|
type: "string",
|
||||||
llm_prompt: "currency code, ISO 4217 three letter code like USD, EUR, including crypto codes like BTC, ETH, etc",
|
llm_prompt: "currency code, ISO 4217 three letter code like USD, EUR, including crypto codes like BTC, ETH, etc",
|
||||||
isRequired: false,
|
isRequired: false,
|
||||||
isExtra: false,
|
isExtra: false,
|
||||||
@@ -365,7 +360,7 @@ const fields = [
|
|||||||
{
|
{
|
||||||
code: "convertedCurrencyCode",
|
code: "convertedCurrencyCode",
|
||||||
name: "Converted Currency Code",
|
name: "Converted Currency Code",
|
||||||
type: "text",
|
type: "string",
|
||||||
llm_prompt: "",
|
llm_prompt: "",
|
||||||
isRequired: false,
|
isRequired: false,
|
||||||
isExtra: false,
|
isExtra: false,
|
||||||
@@ -373,7 +368,7 @@ const fields = [
|
|||||||
{
|
{
|
||||||
code: "note",
|
code: "note",
|
||||||
name: "Note",
|
name: "Note",
|
||||||
type: "text",
|
type: "string",
|
||||||
llm_prompt: "",
|
llm_prompt: "",
|
||||||
isRequired: false,
|
isRequired: false,
|
||||||
isExtra: false,
|
isExtra: false,
|
||||||
@@ -381,7 +376,7 @@ const fields = [
|
|||||||
{
|
{
|
||||||
code: "categoryCode",
|
code: "categoryCode",
|
||||||
name: "Category",
|
name: "Category",
|
||||||
type: "text",
|
type: "string",
|
||||||
llm_prompt: "category code, one of: {categories.code}",
|
llm_prompt: "category code, one of: {categories.code}",
|
||||||
isRequired: false,
|
isRequired: false,
|
||||||
isExtra: false,
|
isExtra: false,
|
||||||
@@ -389,7 +384,7 @@ const fields = [
|
|||||||
{
|
{
|
||||||
code: "projectCode",
|
code: "projectCode",
|
||||||
name: "Project",
|
name: "Project",
|
||||||
type: "select",
|
type: "string",
|
||||||
llm_prompt: "project code, one of: {projects.code}",
|
llm_prompt: "project code, one of: {projects.code}",
|
||||||
isRequired: false,
|
isRequired: false,
|
||||||
isExtra: false,
|
isExtra: false,
|
||||||
@@ -397,7 +392,7 @@ const fields = [
|
|||||||
{
|
{
|
||||||
code: "issuedAt",
|
code: "issuedAt",
|
||||||
name: "Issued At",
|
name: "Issued At",
|
||||||
type: "date",
|
type: "string",
|
||||||
llm_prompt: "issued at date (YYYY-MM-DD format)",
|
llm_prompt: "issued at date (YYYY-MM-DD format)",
|
||||||
isRequired: false,
|
isRequired: false,
|
||||||
isExtra: false,
|
isExtra: false,
|
||||||
@@ -405,7 +400,7 @@ const fields = [
|
|||||||
{
|
{
|
||||||
code: "text",
|
code: "text",
|
||||||
name: "Extracted Text",
|
name: "Extracted Text",
|
||||||
type: "text",
|
type: "string",
|
||||||
llm_prompt: "extract all recognised text from the invoice",
|
llm_prompt: "extract all recognised text from the invoice",
|
||||||
isRequired: false,
|
isRequired: false,
|
||||||
isExtra: false,
|
isExtra: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user