diff --git a/.env.example b/.env.example index 14dc456e..5ceb8c41 100644 --- a/.env.example +++ b/.env.example @@ -3,18 +3,35 @@ SELF_HOSTED_MODE=true DISABLE_SIGNUP=true UPLOAD_PATH="./data/uploads" -DATABASE_URL="postgresql://user@localhost:5432/taxhacker" + +# Database Configuration +# Option 1: PostgreSQL (recommended for production) +DATABASE_URL="postgresql://user:password@localhost:5432/taxhacker" + +# Option 2: SQLite (simpler, single-file database) +# DATABASE_URL="file:./data/taxhacker.db" + +# Optional: Explicitly set database provider (auto-detected from DATABASE_URL) +# DATABASE_PROVIDER="postgresql" # or "sqlite" + +# Set to true to skip database readiness check on startup +# Useful when using external PostgreSQL or if the check fails due to URL parsing issues +SKIP_DB_CHECK=false # You can put it here or the app will ask you to enter it OPENAI_MODEL_NAME="gpt-4o-mini" OPENAI_API_KEY="" # "sk-..." -GOOGLE_MODEL_NAME="gemini-2.5-flash" +GOOGLE_MODEL_NAME="gemini-2.0-flash" GOOGLE_API_KEY="" MISTRAL_MODEL_NAME="mistral-medium-latest" MISTRAL_API_KEY="" +# Ollama (Local LLM) Configuration - requires a vision-capable model +OLLAMA_MODEL_NAME="llava" +OLLAMA_BASE_URL="http://localhost:11434" # Ollama server URL + # Auth Config BETTER_AUTH_SECRET="random-secret-key" # please use any long random string here diff --git a/.github/workflows/docker-latest.yml b/.github/workflows/docker-latest.yml index 53ccfd36..3796539f 100644 --- a/.github/workflows/docker-latest.yml +++ b/.github/workflows/docker-latest.yml @@ -43,5 +43,5 @@ jobs: platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=node23 + cache-to: type=gha,mode=max,scope=node23 diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index b1e75943..78aa6a79 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -44,5 +44,5 @@ jobs: platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=node23 + cache-to: type=gha,mode=max,scope=node23 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/Dockerfile b/Dockerfile index 9b59aa41..78746a4a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,7 @@ FROM node:23-slim AS base # Default environment variables ENV PORT=7331 ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 # Build stage FROM base AS builder @@ -22,8 +23,8 @@ RUN npm ci # Copy source code COPY . . -# Build the application -RUN npm run build +# Disable Next.js telemetry and build +RUN npx next telemetry disable && npm run build # Production stage FROM base @@ -47,6 +48,7 @@ RUN mkdir -p /app/upload COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package*.json ./ COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/scripts ./scripts COPY --from=builder /app/.next ./.next COPY --from=builder /app/public ./public COPY --from=builder /app/app ./app diff --git a/ai/providers/llmProvider.ts b/ai/providers/llmProvider.ts index ec56100a..63aa57a4 100644 --- a/ai/providers/llmProvider.ts +++ b/ai/providers/llmProvider.ts @@ -3,7 +3,7 @@ import { ChatGoogleGenerativeAI } from "@langchain/google-genai" import { ChatMistralAI } from "@langchain/mistralai" import { BaseMessage, HumanMessage } from "@langchain/core/messages" -export type LLMProvider = "openai" | "google" | "mistral" +export type LLMProvider = "openai" | "google" | "mistral" | "ollama" export interface LLMConfig { provider: LLMProvider @@ -28,6 +28,44 @@ export interface LLMResponse { error?: string } +// Known valid model names for each provider (for error messages) +const VALID_MODELS: Record = { + openai: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-3.5-turbo"], + google: ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-flash", "gemini-1.5-pro", "gemini-2.5-flash"], + mistral: ["mistral-large-latest", "mistral-medium-latest", "mistral-small-latest", "open-mistral-nemo"], + ollama: ["llava", "llava:13b", "llama3.2-vision", "bakllava", "moondream"], +} + +function formatModelError(provider: LLMProvider, model: string, originalError: string): string { + const validModels = VALID_MODELS[provider] + + // Check for common error patterns and provide helpful messages + if ( + originalError.includes("does not support images") || + originalError.includes("not found") || + originalError.includes("model not found") || + originalError.includes("Invalid model") || + originalError.includes("404") + ) { + return `Model "${model}" for ${provider} is invalid or does not exist. Valid models include: ${validModels.join(", ")}. Please check your settings.` + } + + if (originalError.includes("API key") || originalError.includes("authentication") || originalError.includes("401")) { + return `Invalid API key for ${provider}. Please verify your API key in settings.` + } + + if (originalError.includes("rate limit") || originalError.includes("429")) { + return `Rate limit exceeded for ${provider}. Please try again later.` + } + + if (originalError.includes("quota") || originalError.includes("insufficient")) { + return `Quota exceeded for ${provider}. Please check your billing/quota settings.` + } + + // Return original error with model context + return `${provider} (model: ${model}) error: ${originalError}` +} + async function requestLLMUnified(config: LLMConfig, req: LLMRequest): Promise { try { const temperature = 0 @@ -50,6 +88,18 @@ async function requestLLMUnified(config: LLMConfig, req: LLMRequest): Promise { + const errors: string[] = [] + for (const config of settings.providers) { if (!config.apiKey || !config.model) { - console.info("Skipping provider:", config.provider) + console.info("Skipping provider:", config.provider, "(no API key or model configured)") continue } - console.info("Use provider:", config.provider) + console.info("Use provider:", config.provider, "with model:", config.model) const response = await requestLLMUnified(config, req) @@ -101,12 +156,26 @@ export async function requestLLM(settings: LLMSettings, req: LLMRequest): Promis return response } else { console.error(response.error) + errors.push(response.error) + } + } + + // Build a helpful error message + const configuredProviders = settings.providers.filter(p => p.apiKey && p.model) + + if (configuredProviders.length === 0) { + return { + output: {}, + provider: settings.providers[0]?.provider || "openai", + error: "No LLM providers configured. Please add an API key and model name in Settings > LLM Providers.", } } + // Include specific errors for each failed provider + const errorDetails = errors.length > 0 ? ` Errors: ${errors.join(" | ")}` : "" return { output: {}, provider: settings.providers[0]?.provider || "openai", - error: "All LLM providers failed or are not configured", + error: `All LLM providers failed.${errorDetails}`, } } diff --git a/app/(app)/apps/invoices/actions.ts b/app/(app)/apps/invoices/actions.ts index a65948fe..a5eee15e 100644 --- a/app/(app)/apps/invoices/actions.ts +++ b/app/(app)/apps/invoices/actions.ts @@ -29,14 +29,14 @@ export async function generateInvoicePDF(data: InvoiceFormData): Promise(user, "invoices") const updatedTemplates = [...(appData?.templates || []), template] const appDataResult = await setAppData(user, "invoices", { ...appData, templates: updatedTemplates }) return { success: true, data: appDataResult } } export async function deleteTemplateAction(user: User, templateId: string) { - const appData = (await getAppData(user, "invoices")) as InvoiceAppData | null + const appData = await getAppData(user, "invoices") if (!appData) return { success: false, error: "No app data found" } const updatedTemplates = appData.templates.filter((t) => t.id !== templateId) diff --git a/app/(app)/apps/invoices/page.tsx b/app/(app)/apps/invoices/page.tsx index 89eb5974..d4428546 100644 --- a/app/(app)/apps/invoices/page.tsx +++ b/app/(app)/apps/invoices/page.tsx @@ -14,7 +14,7 @@ export default async function InvoicesApp() { const user = await getCurrentUser() const settings = await getSettings(user.id) const currencies = await getCurrencies(user.id) - const appData = (await getAppData(user, "invoices")) as InvoiceAppData | null + const appData = await getAppData(user, "invoices") return (
diff --git a/app/(app)/files/actions.ts b/app/(app)/files/actions.ts index deeda578..2924a8f5 100644 --- a/app/(app)/files/actions.ts +++ b/app/(app)/files/actions.ts @@ -1,5 +1,14 @@ "use server" +// File-like interface for form data uploads (avoids runtime File check issues in Node.js) +interface UploadedFile { + name: string + size: number + type: string + lastModified: number + arrayBuffer(): Promise +} + import { ActionState } from "@/lib/actions" import { getCurrentUser, isSubscriptionExpired } from "@/lib/auth" import { @@ -18,13 +27,13 @@ import path from "path" export async function uploadFilesAction(formData: FormData): Promise> { const user = await getCurrentUser() - const files = formData.getAll("files") as File[] + const files = formData.getAll("files") as UploadedFile[] // Make sure upload dir exists const userUploadsDirectory = getUserUploadsDirectory(user) // Check limits - const totalFileSize = files.reduce((acc, file) => acc + file.size, 0) + const totalFileSize = files.reduce((acc, file) => acc + (file?.size || 0), 0) if (!isEnoughStorageToUploadFile(user, totalFileSize)) { return { success: false, error: `Insufficient storage to upload these files` } } @@ -39,7 +48,7 @@ export async function uploadFilesAction(formData: FormData): Promise { - if (!(file instanceof File)) { + if (!file || typeof file !== "object" || !("size" in file) || !("arrayBuffer" in file)) { return { success: false, error: "Invalid file" } } diff --git a/app/(app)/import/csv/actions.tsx b/app/(app)/import/csv/actions.tsx index e9fb4c09..923fcdaa 100644 --- a/app/(app)/import/csv/actions.tsx +++ b/app/(app)/import/csv/actions.tsx @@ -1,5 +1,13 @@ "use server" +// File-like interface for form data uploads (avoids runtime File check issues in Node.js) +interface UploadedFile { + name: string + size: number + type: string + arrayBuffer(): Promise +} + import { ActionState } from "@/lib/actions" import { getCurrentUser } from "@/lib/auth" import { EXPORT_AND_IMPORT_FIELD_MAP } from "@/models/export_and_import" @@ -12,7 +20,7 @@ export async function parseCSVAction( _prevState: ActionState | null, formData: FormData ): Promise> { - const file = formData.get("file") as File + const file = formData.get("file") as UploadedFile | null if (!file) { return { success: false, error: "No file uploaded" } } diff --git a/app/(app)/settings/actions.ts b/app/(app)/settings/actions.ts index 9c416566..e7d1615d 100644 --- a/app/(app)/settings/actions.ts +++ b/app/(app)/settings/actions.ts @@ -14,7 +14,7 @@ import { uploadStaticImage } from "@/lib/uploads" import { codeFromName, randomHexColor } from "@/lib/utils" import { createCategory, deleteCategory, updateCategory } from "@/models/categories" import { createCurrency, deleteCurrency, updateCurrency } from "@/models/currencies" -import { createField, deleteField, updateField } from "@/models/fields" +import { createField, deleteField, updateField, updateFieldOrders } from "@/models/fields" import { createProject, deleteProject, updateProject } from "@/models/projects" import { SettingsMap, updateSettings } from "@/models/settings" import { updateUser } from "@/models/users" @@ -57,8 +57,8 @@ export async function saveProfileAction( // Upload avatar let avatarUrl = user.avatar - const avatarFile = formData.get("avatar") as File | null - if (avatarFile instanceof File && avatarFile.size > 0) { + const avatarFile = formData.get("avatar") + if (avatarFile && typeof avatarFile === "object" && "size" in avatarFile && (avatarFile as Blob).size > 0) { try { const uploadedAvatarPath = await uploadStaticImage(user, avatarFile, "avatar.webp", 500, 500) avatarUrl = `/files/static/${path.basename(uploadedAvatarPath)}` @@ -69,8 +69,8 @@ export async function saveProfileAction( // Upload business logo let businessLogoUrl = user.businessLogo - const businessLogoFile = formData.get("businessLogo") as File | null - if (businessLogoFile instanceof File && businessLogoFile.size > 0) { + const businessLogoFile = formData.get("businessLogo") + if (businessLogoFile && typeof businessLogoFile === "object" && "size" in businessLogoFile && (businessLogoFile as Blob).size > 0) { try { const uploadedBusinessLogoPath = await uploadStaticImage(user, businessLogoFile, "businessLogo.png", 500, 500) businessLogoUrl = `/files/static/${path.basename(uploadedBusinessLogoPath)}` @@ -195,6 +195,7 @@ export async function addCategoryAction(userId: string, data: Prisma.CategoryCre name: validatedForm.data.name, llm_prompt: validatedForm.data.llm_prompt, color: validatedForm.data.color || "", + parentCode: validatedForm.data.parentCode || null, }) revalidatePath("/settings/categories") @@ -221,6 +222,7 @@ export async function editCategoryAction(userId: string, code: string, data: Pri name: validatedForm.data.name, llm_prompt: validatedForm.data.llm_prompt, color: validatedForm.data.color || "", + parentCode: validatedForm.data.parentCode || null, }) revalidatePath("/settings/categories") @@ -288,3 +290,17 @@ export async function deleteFieldAction(userId: string, code: string) { revalidatePath("/settings/fields") return { success: true } } + +export async function reorderFieldsAction( + fieldOrders: { code: string; order: number }[] +): Promise> { + try { + const user = await getCurrentUser() + await updateFieldOrders(user.id, fieldOrders) + revalidatePath("/settings/fields") + return { success: true } + } catch (error) { + console.error("Failed to reorder fields:", error) + return { success: false, error: "Failed to reorder fields" } + } +} diff --git a/app/(app)/settings/backups/actions.ts b/app/(app)/settings/backups/actions.ts index f7307d1f..61bc1f4e 100644 --- a/app/(app)/settings/backups/actions.ts +++ b/app/(app)/settings/backups/actions.ts @@ -1,5 +1,13 @@ "use server" +// File-like interface for form data uploads (avoids runtime File check issues in Node.js) +interface UploadedFile { + name: string + size: number + type: string + arrayBuffer(): Promise +} + import { ActionState } from "@/lib/actions" import { getCurrentUser } from "@/lib/auth" import { prisma } from "@/lib/db" @@ -23,7 +31,7 @@ export async function restoreBackupAction( ): Promise> { const user = await getCurrentUser() const userUploadsDirectory = getUserUploadsDirectory(user) - const file = formData.get("file") as File + const file = formData.get("file") as UploadedFile | null if (!file || file.size === 0) { return { success: false, error: "No file provided" } diff --git a/app/(app)/settings/categories/page.tsx b/app/(app)/settings/categories/page.tsx index c0e2a463..f7270e26 100644 --- a/app/(app)/settings/categories/page.tsx +++ b/app/(app)/settings/categories/page.tsx @@ -8,8 +8,22 @@ import { Prisma } from "@/prisma/client" export default async function CategoriesSettingsPage() { const user = await getCurrentUser() const categories = await getCategories(user.id) + + // Get only parent categories (no parentCode) for the parent selector + const parentCategoryOptions = ["", ...categories.filter((c) => !c.parentCode).map((c) => c.code)] + + // Format display name to show hierarchy + const getDisplayName = (category: (typeof categories)[0]) => { + if (category.parentCode) { + const parent = categories.find((c) => c.code === category.parentCode) + return parent ? ` └ ${category.name}` : category.name + } + return category.name + } + const categoriesWithActions = categories.map((category) => ({ ...category, + displayName: getDisplayName(category), isEditable: true, isDeletable: true, })) @@ -19,13 +33,21 @@ export default async function CategoriesSettingsPage() {

Categories

Create your own categories that better reflect the type of income and expenses you have. Define an LLM Prompt so - that AI can determine this category automatically. + that AI can determine this category automatically. You can create sub-categories by selecting a parent category.

You can add new fields to your transactions. Standard fields can't be removed but you can tweak their prompts or hide them. If you don't want a field to be analyzed by AI but filled in by hand, leave the - "LLM prompt" empty. + "LLM prompt" empty. Drag fields to reorder them.

- { - "use server" - return await deleteFieldAction(user.id, code) - }} - onAdd={async (data) => { - "use server" - return await addFieldAction(user.id, data as Prisma.FieldCreateInput) - }} - onEdit={async (code, data) => { - "use server" - return await editFieldAction(user.id, code, data as Prisma.FieldUpdateInput) - }} - /> +
) } diff --git a/app/(app)/transactions/actions.ts b/app/(app)/transactions/actions.ts index e6ea61d6..716d2dfa 100644 --- a/app/(app)/transactions/actions.ts +++ b/app/(app)/transactions/actions.ts @@ -1,8 +1,18 @@ "use server" +// File-like interface for form data uploads (avoids runtime File check issues in Node.js) +interface UploadedFile { + name: string + size: number + type: string + lastModified: number + arrayBuffer(): Promise +} + import { transactionFormSchema } from "@/forms/transactions" import { ActionState } from "@/lib/actions" import { getCurrentUser, isSubscriptionExpired } from "@/lib/auth" +import { parseFilesArray } from "@/lib/db-compat" import { getDirectorySize, getTransactionFileUploadPath, @@ -16,6 +26,7 @@ import { bulkDeleteTransactions, createTransaction, deleteTransaction, + duplicateTransaction, getTransactionById, updateTransaction, updateTransactionFiles, @@ -106,10 +117,11 @@ export async function deleteTransactionFileAction( return { success: false, error: "Transaction not found" } } + const currentFiles = parseFilesArray(transaction.files) await updateTransactionFiles( transactionId, user.id, - transaction.files ? (transaction.files as string[]).filter((id) => id !== fileId) : [] + currentFiles.filter((id) => id !== fileId) ) await deleteFile(fileId, user.id) @@ -125,7 +137,7 @@ export async function deleteTransactionFileAction( export async function uploadTransactionFilesAction(formData: FormData): Promise> { try { const transactionId = formData.get("transactionId") as string - const files = formData.getAll("files") as File[] + const files = formData.getAll("files") as UploadedFile[] if (!files || !transactionId) { return { success: false, error: "No files or transaction ID provided" } @@ -182,12 +194,11 @@ export async function uploadTransactionFilesAction(formData: FormData): Promise< ) // Update invoice with the new file ID + const existingFiles = parseFilesArray(transaction.files) await updateTransactionFiles( transactionId, user.id, - transaction.files - ? [...(transaction.files as string[]), ...fileRecords.map((file) => file.id)] - : fileRecords.map((file) => file.id) + [...existingFiles, ...fileRecords.map((file) => file.id)] ) // Update user storage used @@ -226,3 +237,18 @@ export async function updateFieldVisibilityAction(fieldCode: string, isVisible: return { success: false, error: "Failed to update field visibility" } } } + +export async function duplicateTransactionAction( + transactionId: string +): Promise> { + try { + const user = await getCurrentUser() + const newTransaction = await duplicateTransaction(transactionId, user.id) + + revalidatePath("/transactions") + return { success: true, data: newTransaction } + } catch (error) { + console.error("Failed to duplicate transaction:", error) + return { success: false, error: "Failed to duplicate transaction" } + } +} diff --git a/app/(app)/transactions/page.tsx b/app/(app)/transactions/page.tsx index e2f324f9..5f653109 100644 --- a/app/(app)/transactions/page.tsx +++ b/app/(app)/transactions/page.tsx @@ -9,6 +9,7 @@ import { getCurrentUser } from "@/lib/auth" import { getCategories } from "@/models/categories" import { getFields } from "@/models/fields" import { getProjects } from "@/models/projects" +import { getSettings } from "@/models/settings" import { getTransactions, TransactionFilters } from "@/models/transactions" import { Download, Plus, Upload } from "lucide-react" import { Metadata } from "next" @@ -31,6 +32,7 @@ export default async function TransactionsPage({ searchParams }: { searchParams: const categories = await getCategories(user.id) const projects = await getProjects(user.id) const fields = await getFields(user.id) + const settings = await getSettings(user.id) // Reset page if user clicks a filter and no transactions are found if (page && page > 1 && transactions.length === 0) { @@ -60,7 +62,12 @@ export default async function TransactionsPage({ searchParams }: { searchParams: - +
diff --git a/app/api/models/route.ts b/app/api/models/route.ts new file mode 100644 index 00000000..1eb1fa7e --- /dev/null +++ b/app/api/models/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server" +import { fetchModelsForProvider } from "@/lib/model-fetcher" +import { LLMProvider } from "@/ai/providers/llmProvider" + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { provider, apiKey } = body + + if (!provider || !apiKey) { + return NextResponse.json( + { error: "Provider and API key are required" }, + { status: 400 } + ) + } + + const validProviders: LLMProvider[] = ["openai", "google", "mistral"] + if (!validProviders.includes(provider)) { + return NextResponse.json( + { error: `Invalid provider: ${provider}` }, + { status: 400 } + ) + } + + const result = await fetchModelsForProvider(provider as LLMProvider, apiKey) + + return NextResponse.json(result) + } catch (error) { + console.error("Error fetching models:", error) + return NextResponse.json( + { error: "Failed to fetch models", models: [] }, + { status: 500 } + ) + } +} diff --git a/components/files/preview.tsx b/components/files/preview.tsx index 1669e05e..a8a8d870 100644 --- a/components/files/preview.tsx +++ b/components/files/preview.tsx @@ -9,8 +9,13 @@ import { useState } from "react" export function FilePreview({ file }: { file: File }) { const [isEnlarged, setIsEnlarged] = useState(false) - const fileSize = - file.metadata && typeof file.metadata === "object" && "size" in file.metadata ? Number(file.metadata.size) : 0 + // Handle metadata that could be string (SQLite) or object (PostgreSQL) + const getFileSize = () => { + if (!file.metadata) return 0 + const metadata = typeof file.metadata === "string" ? JSON.parse(file.metadata) : file.metadata + return "size" in metadata ? Number(metadata.size) : 0 + } + const fileSize = getFileSize() return ( <> diff --git a/components/forms/select-category.tsx b/components/forms/select-category.tsx index 4cfa92fe..ed4bf6ed 100644 --- a/components/forms/select-category.tsx +++ b/components/forms/select-category.tsx @@ -5,6 +5,8 @@ import { SelectProps } from "@radix-ui/react-select" import { useMemo } from "react" import { FormSelect } from "./simple" +type CategoryWithChildren = Category & { children?: Category[] } + export const FormSelectCategory = ({ title, categories, @@ -15,16 +17,50 @@ export const FormSelectCategory = ({ ...props }: { title: string - categories: Category[] + categories: CategoryWithChildren[] emptyValue?: string placeholder?: string hideIfEmpty?: boolean isRequired?: boolean } & SelectProps) => { - const items = useMemo( - () => categories.map((category) => ({ code: category.code, name: category.name, color: category.color })), - [categories] - ) + // Build hierarchical items list with proper ordering and indentation + const items = useMemo(() => { + const result: { code: string; name: string; color: string }[] = [] + + // Get parent categories (no parentCode) + const parents = categories.filter((c) => !c.parentCode) + const childrenByParent = categories.reduce( + (acc, c) => { + if (c.parentCode) { + if (!acc[c.parentCode]) acc[c.parentCode] = [] + acc[c.parentCode].push(c) + } + return acc + }, + {} as Record + ) + + // Add parents with their children + for (const parent of parents) { + result.push({ + code: parent.code, + name: parent.name, + color: parent.color, + }) + + const children = childrenByParent[parent.code] || [] + for (const child of children) { + result.push({ + code: child.code, + name: ` └ ${child.name}`, + color: child.color, + }) + } + } + + return result + }, [categories]) + return ( (null) + const [newFieldData, setNewFieldData] = useState(defaultFormData) + const [editingData, setEditingData] = useState(defaultFormData) + const [isPending, startTransition] = useTransition() + + const sensors = useSensors(useSensor(PointerSensor)) + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + + const oldIndex = fields.findIndex((f) => f.code === active.id) + const newIndex = fields.findIndex((f) => f.code === over.id) + + const newFields = arrayMove(fields, oldIndex, newIndex) + setFields(newFields) + + // Save the new order + startTransition(async () => { + const fieldOrders = newFields.map((field, index) => ({ + code: field.code, + order: index, + })) + await reorderFieldsAction(fieldOrders) + }) + } + + const handleAdd = async () => { + const result = await addFieldAction(userId, newFieldData as unknown as Prisma.FieldCreateInput) + if (result.success) { + setIsAdding(false) + setNewFieldData(defaultFormData) + } else { + alert(result.error) + } + } + + const handleEdit = async (code: string) => { + const result = await editFieldAction(userId, code, editingData as unknown as Prisma.FieldUpdateInput) + if (result.success) { + setEditingCode(null) + setEditingData(defaultFormData) + } else { + alert(result.error) + } + } + + const handleDelete = async (code: string) => { + const result = await deleteFieldAction(userId, code) + if (!result.success) { + alert(result.error) + } + } + + const startEditing = (field: Field) => { + setEditingCode(field.code) + setEditingData({ + name: field.name, + type: field.type, + llm_prompt: field.llm_prompt || "", + isVisibleInList: field.isVisibleInList, + isVisibleInAnalysis: field.isVisibleInAnalysis, + isRequired: field.isRequired, + }) + } + + return ( +
+ + + + + + Name + Type + LLM Prompt + Show in list + Show in analysis + Required + Actions + + + f.code)} strategy={verticalListSortingStrategy}> + + {fields.map((field) => ( + startEditing(field)} + onCancelEdit={() => setEditingCode(null)} + onSaveEdit={() => handleEdit(field.code)} + onDelete={() => handleDelete(field.code)} + /> + ))} + {isAdding && ( + + + + setNewFieldData({ ...newFieldData, name: e.target.value })} + placeholder="Field name" + /> + + + + + + setNewFieldData({ ...newFieldData, llm_prompt: e.target.value })} + placeholder="LLM prompt" + /> + + + setNewFieldData({ ...newFieldData, isVisibleInList: e.target.checked })} + /> + + + setNewFieldData({ ...newFieldData, isVisibleInAnalysis: e.target.checked })} + /> + + + setNewFieldData({ ...newFieldData, isRequired: e.target.checked })} + /> + + +
+ + +
+
+
+ )} +
+
+
+
+ {!isAdding && ( + + )} + {isPending &&

Saving order...

} +
+ ) +} + +interface SortableFieldRowProps { + field: Field & { isEditable: boolean; isDeletable: boolean } + isEditing: boolean + editingData: FieldFormData + setEditingData: React.Dispatch> + onStartEdit: () => void + onCancelEdit: () => void + onSaveEdit: () => void + onDelete: () => void +} + +function SortableFieldRow({ + field, + isEditing, + editingData, + setEditingData, + onStartEdit, + onCancelEdit, + onSaveEdit, + onDelete, +}: SortableFieldRowProps) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: field.code, + }) + + const style = { + transform: transform ? `translateY(${transform.y}px)` : undefined, + transition, + opacity: isDragging ? 0.5 : 1, + } + + return ( + + + + + + + + {isEditing ? ( + setEditingData({ ...editingData, name: e.target.value })} + /> + ) : ( + field.name + )} + + + {isEditing ? ( + + ) : ( + field.type + )} + + + {isEditing ? ( + setEditingData({ ...editingData, llm_prompt: e.target.value })} + /> + ) : ( + field.llm_prompt + )} + + + {isEditing ? ( + setEditingData({ ...editingData, isVisibleInList: e.target.checked })} + /> + ) : field.isVisibleInList ? ( + + ) : null} + + + {isEditing ? ( + setEditingData({ ...editingData, isVisibleInAnalysis: e.target.checked })} + /> + ) : field.isVisibleInAnalysis ? ( + + ) : null} + + + {isEditing ? ( + setEditingData({ ...editingData, isRequired: e.target.checked })} + /> + ) : field.isRequired ? ( + + ) : null} + + +
+ {isEditing ? ( + <> + + + + ) : ( + <> + {field.isEditable && ( + + )} + {field.isDeletable && ( + + )} + + )} +
+
+
+ ) +} diff --git a/components/settings/global-settings-form.tsx b/components/settings/global-settings-form.tsx index 75ea4a64..8050b5f2 100644 --- a/components/settings/global-settings-form.tsx +++ b/components/settings/global-settings-form.tsx @@ -2,6 +2,7 @@ import { saveSettingsAction } from "@/app/(app)/settings/actions" import { FormError } from "@/components/forms/error" +import { FormInput } from "@/components/forms/simple" import { FormSelectCategory } from "@/components/forms/select-category" import { FormSelectCurrency } from "@/components/forms/select-currency" import { FormSelectType } from "@/components/forms/select-type" @@ -39,6 +40,18 @@ export default function GlobalSettingsForm({ categories={categories} /> + +

+ Set when your tax year begins. Examples: 01-01 (January 1), 04-06 (UK: April 6), 07-01 (Australia: July 1) +

+
+ {availableModels.length > 0 && ( + + )} + + )} +
+ {showDropdown && availableModels.length > 0 && ( +
+ {availableModels.map((model) => ( + + ))} +
+ )} + + +
+ {provider.apiDoc && ( + + Get your API key from{" "} + + {provider.apiDocLabel} + + + )} + {modelError && ( + {modelError} + )} + {availableModels.length > 0 && ( + + ✓ {availableModels.length} models available - click ↓ to select + + )} + {!availableModels.length && value.apiKey && !isLoadingModels && !modelError && ( + + Click refresh ↻ to load available models from the API + + )} + {!value.apiKey && provider.suggestedModels && ( + + Suggested models: {provider.suggestedModels.join(", ")} + + )}
- {provider.apiDoc && ( - - Get your API key from{" "} - - {provider.apiDocLabel} - - - )} ) } diff --git a/components/transactions/edit.tsx b/components/transactions/edit.tsx index 6787ca1c..47635c0e 100644 --- a/components/transactions/edit.tsx +++ b/components/transactions/edit.tsx @@ -1,6 +1,6 @@ "use client" -import { deleteTransactionAction, saveTransactionAction } from "@/app/(app)/transactions/actions" +import { deleteTransactionAction, duplicateTransactionAction, saveTransactionAction } from "@/app/(app)/transactions/actions" import { ItemsDetectTool } from "@/components/agents/items-detect" import ToolWindow from "@/components/agents/tool-window" import { FormError } from "@/components/forms/error" @@ -13,7 +13,7 @@ import { Button } from "@/components/ui/button" import { TransactionData } from "@/models/transactions" import { Category, Currency, Field, Project, Transaction } from "@/prisma/client" import { format } from "date-fns" -import { Loader2, Save, Trash2 } from "lucide-react" +import { Copy, Loader2, Save, Trash2 } from "lucide-react" import { useRouter } from "next/navigation" import { startTransition, useActionState, useEffect, useMemo, useState } from "react" @@ -70,6 +70,8 @@ export default function TransactionEditForm({ ) }, [fields]) + const [isDuplicating, setIsDuplicating] = useState(false) + const handleDelete = async () => { if (confirm("Are you sure? This will delete the transaction with all the files permanently")) { startTransition(async () => { @@ -79,6 +81,18 @@ export default function TransactionEditForm({ } } + const handleDuplicate = async () => { + setIsDuplicating(true) + try { + const result = await duplicateTransactionAction(transaction.id) + if (result.success && result.data) { + router.push(`/transactions/${result.data.id}`) + } + } finally { + setIsDuplicating(false) + } + } + useEffect(() => { if (saveState?.success) { router.back() @@ -223,12 +237,28 @@ export default function TransactionEditForm({ )}
- +
+ + + +
+ + +
+ setFormData((prev) => ({ ...prev, text: e.target.value }))} + className="font-mono text-xs bg-muted/50 min-h-[200px]" + placeholder="No text was recognized from this file" + /> +
+
+ + )}