From d590f8dfe3006f5d3c81eda0da5a1b89ff45497c Mon Sep 17 00:00:00 2001 From: adryserage <17680194+adryserage@users.noreply.github.com> Date: Sat, 17 Jan 2026 16:26:24 -0500 Subject: [PATCH 01/19] fix: improve LLM provider error messages and update default model names - Add helpful error messages for invalid model names, API key issues, rate limits, and quota errors - Update default Google model from non-existent "gemini-2.5-flash" to valid "gemini-2.0-flash" - Add suggested models list for each provider in settings UI - Improve error logging with model name context - Use safer key-based provider lookup instead of array indices Fixes #57 --- .env.example | 2 +- ai/providers/llmProvider.ts | 64 +++++++++++++++++++++-- components/settings/llm-settings-form.tsx | 31 ++++++----- forms/settings.ts | 2 +- lib/config.ts | 2 +- lib/llm-providers.ts | 5 +- models/settings.ts | 8 +-- 7 files changed, 91 insertions(+), 23 deletions(-) diff --git a/.env.example b/.env.example index 14dc456e..ceec3555 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,7 @@ DATABASE_URL="postgresql://user@localhost:5432/taxhacker" 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" diff --git a/ai/providers/llmProvider.ts b/ai/providers/llmProvider.ts index ec56100a..324c9597 100644 --- a/ai/providers/llmProvider.ts +++ b/ai/providers/llmProvider.ts @@ -28,6 +28,43 @@ 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"], +} + +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 @@ -79,21 +116,26 @@ 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 +143,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/components/settings/llm-settings-form.tsx b/components/settings/llm-settings-form.tsx index c4ab6b08..b4315777 100644 --- a/components/settings/llm-settings-form.tsx +++ b/components/settings/llm-settings-form.tsx @@ -225,18 +225,25 @@ function SortableProviderBlock({ id, idx, providerKey, value, handleValueChange placeholder="Model name" /> - {provider.apiDoc && ( - - Get your API key from{" "} - - {provider.apiDocLabel} - - - )} +
+ {provider.apiDoc && ( + + Get your API key from{" "} + + {provider.apiDocLabel} + + + )} + {provider.suggestedModels && ( + + Suggested models: {provider.suggestedModels.join(", ")} + + )} +
) } diff --git a/forms/settings.ts b/forms/settings.ts index 08ab5997..4be25c41 100644 --- a/forms/settings.ts +++ b/forms/settings.ts @@ -9,7 +9,7 @@ export const settingsFormSchema = z.object({ openai_api_key: z.string().optional(), openai_model_name: z.string().default('gpt-4o-mini'), google_api_key: z.string().optional(), - google_model_name: z.string().default("gemini-2.5-flash"), + google_model_name: z.string().default("gemini-2.0-flash"), mistral_api_key: z.string().optional(), mistral_model_name: z.string().default("mistral-medium-latest"), llm_providers: z.string().default('openai,google,mistral'), diff --git a/lib/config.ts b/lib/config.ts index e28579ea..4a9f14c5 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -7,7 +7,7 @@ const envSchema = z.object({ OPENAI_API_KEY: z.string().optional(), OPENAI_MODEL_NAME: z.string().default("gpt-4o-mini"), GOOGLE_API_KEY: z.string().optional(), - GOOGLE_MODEL_NAME: z.string().default("gemini-2.5-flash"), + GOOGLE_MODEL_NAME: z.string().default("gemini-2.0-flash"), MISTRAL_API_KEY: z.string().optional(), MISTRAL_MODEL_NAME: z.string().default("mistral-medium-latest"), BETTER_AUTH_SECRET: z diff --git a/lib/llm-providers.ts b/lib/llm-providers.ts index 8ffa670a..43c4ac33 100644 --- a/lib/llm-providers.ts +++ b/lib/llm-providers.ts @@ -5,6 +5,7 @@ export const PROVIDERS = [ apiKeyName: "openai_api_key", modelName: "openai_model_name", defaultModelName: "gpt-4o-mini", + suggestedModels: ["gpt-4o-mini", "gpt-4o", "gpt-4-turbo", "gpt-3.5-turbo"], apiDoc: "https://platform.openai.com/settings/organization/api-keys", apiDocLabel: "OpenAI Platform Console", placeholder: "sk-...", @@ -19,7 +20,8 @@ export const PROVIDERS = [ label: "Google", apiKeyName: "google_api_key", modelName: "google_model_name", - defaultModelName: "gemini-2.5-flash", + defaultModelName: "gemini-2.0-flash", + suggestedModels: ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-flash", "gemini-1.5-pro"], apiDoc: "https://aistudio.google.com/apikey", apiDocLabel: "Google AI Studio", placeholder: "...", @@ -35,6 +37,7 @@ export const PROVIDERS = [ apiKeyName: "mistral_api_key", modelName: "mistral_model_name", defaultModelName: "mistral-medium-latest", + suggestedModels: ["mistral-large-latest", "mistral-medium-latest", "mistral-small-latest"], apiDoc: "https://admin.mistral.ai/organization/api-keys", apiDocLabel: "Mistral Admin Console", placeholder: "...", diff --git a/models/settings.ts b/models/settings.ts index e4cd71f8..024a9548 100644 --- a/models/settings.ts +++ b/models/settings.ts @@ -12,25 +12,27 @@ export function getLLMSettings(settings: SettingsMap) { const priorities = (settings.llm_providers || "openai,google,mistral").split(",").map(p => p.trim()).filter(Boolean) const providers = priorities.map((provider) => { + const providerConfig = PROVIDERS.find(p => p.key === provider) + if (provider === "openai") { return { provider: provider as LLMProvider, apiKey: settings.openai_api_key || "", - model: settings.openai_model_name || PROVIDERS[0]['defaultModelName'], + model: settings.openai_model_name || providerConfig?.defaultModelName || "gpt-4o-mini", } } if (provider === "google") { return { provider: provider as LLMProvider, apiKey: settings.google_api_key || "", - model: settings.google_model_name || PROVIDERS[1]['defaultModelName'], + model: settings.google_model_name || providerConfig?.defaultModelName || "gemini-2.0-flash", } } if (provider === "mistral") { return { provider: provider as LLMProvider, apiKey: settings.mistral_api_key || "", - model: settings.mistral_model_name || PROVIDERS[2]['defaultModelName'], + model: settings.mistral_model_name || providerConfig?.defaultModelName || "mistral-medium-latest", } } return null From 0c9743c20b52b42c007e6f8fe7c6f12bf5b22fd4 Mon Sep 17 00:00:00 2001 From: adryserage <17680194+adryserage@users.noreply.github.com> Date: Sat, 17 Jan 2026 16:33:46 -0500 Subject: [PATCH 02/19] feat: add dynamic model fetching from provider APIs - Add model-fetcher.ts utility to fetch available models from OpenAI, Google Gemini, and Mistral APIs - Add /api/models endpoint to expose model fetching functionality - Update LLM settings form with dynamic model selection dropdown - Add refresh button to fetch models when API key is provided - Display model count and vision support indicators - Cache model lists for 1 hour to reduce API calls This allows users to see exactly which models they have access to with their API key, preventing invalid model configuration errors. --- app/api/models/route.ts | 35 ++++ components/settings/llm-settings-form.tsx | 178 +++++++++++++++-- lib/model-fetcher.ts | 223 ++++++++++++++++++++++ 3 files changed, 419 insertions(+), 17 deletions(-) create mode 100644 app/api/models/route.ts create mode 100644 lib/model-fetcher.ts 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/settings/llm-settings-form.tsx b/components/settings/llm-settings-form.tsx index b4315777..70234e5f 100644 --- a/components/settings/llm-settings-form.tsx +++ b/components/settings/llm-settings-form.tsx @@ -7,9 +7,9 @@ import { FormTextarea } from "@/components/forms/simple" import { Button } from "@/components/ui/button" import { Card, CardTitle } from "@/components/ui/card" import { Field } from "@/prisma/client" -import { CircleCheckBig, Edit, GripVertical } from "lucide-react" +import { CircleCheckBig, Edit, GripVertical, RefreshCw, ChevronDown } from "lucide-react" import Link from "next/link" -import { useState, useActionState } from "react" +import { useState, useActionState, useCallback } from "react" import { DndContext, closestCenter, @@ -26,6 +26,11 @@ import { } from "@dnd-kit/sortable" import { PROVIDERS } from "@/lib/llm-providers"; +interface ModelInfo { + id: string + name: string + supportsVision?: boolean +} function getInitialProviderOrder(settings: Record) { let order: string[] = [] @@ -34,7 +39,6 @@ function getInitialProviderOrder(settings: Record) { } else { order = settings.llm_providers.split(",").map(p => p.trim()) } - // Remove duplicates and keep only valid providers return order.filter((key, idx) => PROVIDERS.some(p => p.key === key) && order.indexOf(key) === idx) } @@ -49,7 +53,6 @@ export default function LLMSettingsForm({ const [saveState, saveAction, pending] = useActionState(saveSettingsAction, null) const [providerOrder, setProviderOrder] = useState(getInitialProviderOrder(settings)) - // Controlled values for each provider const [providerValues, setProviderValues] = useState(() => { const values: Record = {} PROVIDERS.forEach((provider) => { @@ -61,6 +64,10 @@ export default function LLMSettingsForm({ return values }) + const [availableModels, setAvailableModels] = useState>({}) + const [loadingModels, setLoadingModels] = useState>({}) + const [modelErrors, setModelErrors] = useState>({}) + function handleProviderValueChange(providerKey: string, field: "apiKey" | "model", value: string) { setProviderValues((prev) => ({ ...prev, @@ -71,10 +78,39 @@ export default function LLMSettingsForm({ })) } + const fetchModelsForProvider = useCallback(async (providerKey: string, apiKey: string) => { + if (!apiKey || apiKey.length < 10) return + + setLoadingModels((prev) => ({ ...prev, [providerKey]: true })) + setModelErrors((prev) => ({ ...prev, [providerKey]: "" })) + + try { + const response = await fetch("/api/models", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: providerKey, apiKey }), + }) + + const data = await response.json() + + if (data.error) { + setModelErrors((prev) => ({ ...prev, [providerKey]: data.error })) + } else if (data.models && data.models.length > 0) { + setAvailableModels((prev) => ({ ...prev, [providerKey]: data.models })) + } + } catch { + setModelErrors((prev) => ({ + ...prev, + [providerKey]: "Failed to fetch models", + })) + } finally { + setLoadingModels((prev) => ({ ...prev, [providerKey]: false })) + } + }, []) + return ( <>
-
Drag provider blocks to reorder. First is highest priority. @@ -143,9 +183,22 @@ type DndProviderBlocksProps = { setProviderOrder: React.Dispatch>; providerValues: Record; handleProviderValueChange: (providerKey: string, field: "apiKey" | "model", value: string) => void; + availableModels: Record; + loadingModels: Record; + modelErrors: Record; + fetchModelsForProvider: (providerKey: string, apiKey: string) => Promise; }; -function DndProviderBlocks({ providerOrder, setProviderOrder, providerValues, handleProviderValueChange }: DndProviderBlocksProps) { +function DndProviderBlocks({ + providerOrder, + setProviderOrder, + providerValues, + handleProviderValueChange, + availableModels, + loadingModels, + modelErrors, + fetchModelsForProvider, +}: DndProviderBlocksProps) { const sensors = useSensors(useSensor(PointerSensor)) function handleDragEnd(event: DragEndEvent) { const { active, over } = event @@ -165,6 +218,10 @@ function DndProviderBlocks({ providerOrder, setProviderOrder, providerValues, ha providerKey={providerKey} value={providerValues[providerKey]} handleValueChange={handleProviderValueChange} + availableModels={availableModels[providerKey] || []} + isLoadingModels={loadingModels[providerKey] || false} + modelError={modelErrors[providerKey] || ""} + fetchModels={fetchModelsForProvider} /> ))} @@ -178,13 +235,40 @@ type SortableProviderBlockProps = { providerKey: string; value: { apiKey: string; model: string }; handleValueChange: (providerKey: string, field: "apiKey" | "model", value: string) => void; + availableModels: ModelInfo[]; + isLoadingModels: boolean; + modelError: string; + fetchModels: (providerKey: string, apiKey: string) => Promise; }; -function SortableProviderBlock({ id, idx, providerKey, value, handleValueChange }: SortableProviderBlockProps) { +function SortableProviderBlock({ + id, + idx, + providerKey, + value, + handleValueChange, + availableModels, + isLoadingModels, + modelError, + fetchModels, +}: SortableProviderBlockProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }) + const [showDropdown, setShowDropdown] = useState(false) const provider = PROVIDERS.find(p => p.key === providerKey) if (!provider) return null + + const handleFetchModels = () => { + if (value.apiKey) { + fetchModels(providerKey, value.apiKey) + } + } + + const handleSelectModel = (modelId: string) => { + handleValueChange(providerKey, "model", modelId) + setShowDropdown(false) + } + return (
- {/* Drag handle */} - handleValueChange(provider.key, "model", e.target.value)} - className="flex-1 border rounded px-2 py-1" - placeholder="Model name" - /> +
+
+ handleValueChange(provider.key, "model", e.target.value)} + className="flex-1 border rounded px-2 py-1" + placeholder="Model name" + /> + {value.apiKey && ( + <> + + {availableModels.length > 0 && ( + + )} + + )} +
+ {showDropdown && availableModels.length > 0 && ( +
+ {availableModels.map((model) => ( + + ))} +
+ )} +
{provider.apiDoc && ( @@ -238,7 +369,20 @@ function SortableProviderBlock({ id, idx, providerKey, value, handleValueChange )} - {provider.suggestedModels && ( + {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(", ")} diff --git a/lib/model-fetcher.ts b/lib/model-fetcher.ts new file mode 100644 index 00000000..7ff17726 --- /dev/null +++ b/lib/model-fetcher.ts @@ -0,0 +1,223 @@ +"use server" + +import { LLMProvider } from "@/ai/providers/llmProvider" + +export interface ModelInfo { + id: string + name: string + supportsVision?: boolean +} + +export interface ModelListResult { + models: ModelInfo[] + error?: string +} + +// Cache for model lists (TTL: 1 hour) +const modelCache = new Map() +const CACHE_TTL = 60 * 60 * 1000 // 1 hour + +function getCachedModels(provider: LLMProvider): ModelInfo[] | null { + const cached = modelCache.get(provider) + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return cached.data + } + return null +} + +function setCachedModels(provider: LLMProvider, models: ModelInfo[]): void { + modelCache.set(provider, { data: models, timestamp: Date.now() }) +} + +/** + * Fetch available models from OpenAI API + * @see https://platform.openai.com/docs/api-reference/models/list + */ +async function fetchOpenAIModels(apiKey: string): Promise { + try { + const response = await fetch("https://api.openai.com/v1/models", { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }) + + if (!response.ok) { + const error = await response.text() + return { models: [], error: `OpenAI API error: ${response.status} - ${error}` } + } + + const data = await response.json() + + // Filter to only chat models (gpt-*) + const chatModels = data.data + .filter((model: any) => model.id.startsWith("gpt-") && !model.id.includes("instruct")) + .map((model: any) => ({ + id: model.id, + name: model.id, + supportsVision: model.id.includes("gpt-4") || model.id.includes("gpt-4o"), + })) + .sort((a: ModelInfo, b: ModelInfo) => b.id.localeCompare(a.id)) // Newest first + + return { models: chatModels } + } catch (error) { + return { + models: [], + error: error instanceof Error ? error.message : "Failed to fetch OpenAI models", + } + } +} + +/** + * Fetch available models from Google Gemini API + * @see https://ai.google.dev/api/models + */ +async function fetchGoogleModels(apiKey: string): Promise { + try { + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}&pageSize=100` + ) + + if (!response.ok) { + const error = await response.text() + return { models: [], error: `Google API error: ${response.status} - ${error}` } + } + + const data = await response.json() + + // Filter to models that support generateContent + const generativeModels = data.models + .filter((model: any) => + model.supportedGenerationMethods?.includes("generateContent") && + model.name.includes("gemini") + ) + .map((model: any) => ({ + id: model.name.replace("models/", ""), + name: model.displayName || model.name.replace("models/", ""), + supportsVision: model.supportedGenerationMethods?.includes("generateContent"), + })) + .sort((a: ModelInfo, b: ModelInfo) => { + // Sort by version (2.0 > 1.5 > 1.0) + const versionA = a.id.match(/(\d+\.\d+)/)?.[1] || "0" + const versionB = b.id.match(/(\d+\.\d+)/)?.[1] || "0" + return versionB.localeCompare(versionA) + }) + + return { models: generativeModels } + } catch (error) { + return { + models: [], + error: error instanceof Error ? error.message : "Failed to fetch Google models", + } + } +} + +/** + * Fetch available models from Mistral API + * @see https://docs.mistral.ai/api/endpoint/models + */ +async function fetchMistralModels(apiKey: string): Promise { + try { + const response = await fetch("https://api.mistral.ai/v1/models", { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }) + + if (!response.ok) { + const error = await response.text() + return { models: [], error: `Mistral API error: ${response.status} - ${error}` } + } + + const data = await response.json() + + // Filter to chat models + const chatModels = data.data + .filter((model: any) => + model.capabilities?.completion_chat || + model.id.includes("mistral") || + model.id.includes("codestral") || + model.id.includes("pixtral") + ) + .map((model: any) => ({ + id: model.id, + name: model.id, + supportsVision: model.capabilities?.vision || model.id.includes("pixtral"), + })) + .sort((a: ModelInfo, b: ModelInfo) => a.id.localeCompare(b.id)) + + return { models: chatModels } + } catch (error) { + return { + models: [], + error: error instanceof Error ? error.message : "Failed to fetch Mistral models", + } + } +} + +/** + * Fetch available models for a provider + */ +export async function fetchModelsForProvider( + provider: LLMProvider, + apiKey: string +): Promise { + if (!apiKey) { + return { models: [], error: "No API key provided" } + } + + // Check cache first + const cached = getCachedModels(provider) + if (cached) { + return { models: cached } + } + + let result: ModelListResult + + switch (provider) { + case "openai": + result = await fetchOpenAIModels(apiKey) + break + case "google": + result = await fetchGoogleModels(apiKey) + break + case "mistral": + result = await fetchMistralModels(apiKey) + break + default: + return { models: [], error: `Unknown provider: ${provider}` } + } + + // Cache successful results + if (result.models.length > 0) { + setCachedModels(provider, result.models) + } + + return result +} + +/** + * Validate if a model exists for a provider + */ +export async function validateModel( + provider: LLMProvider, + model: string, + apiKey: string +): Promise<{ valid: boolean; error?: string; suggestions?: string[] }> { + const result = await fetchModelsForProvider(provider, apiKey) + + if (result.error) { + return { valid: false, error: result.error } + } + + const modelExists = result.models.some((m) => m.id === model || m.id === `models/${model}`) + + if (!modelExists) { + return { + valid: false, + error: `Model "${model}" not found`, + suggestions: result.models.slice(0, 5).map((m) => m.id), + } + } + + return { valid: true } +} From cad5b6b7d43ef4a6871a2a417b1867add0ec7888 Mon Sep 17 00:00:00 2001 From: adryserage <17680194+adryserage@users.noreply.github.com> Date: Sat, 17 Jan 2026 17:24:14 -0500 Subject: [PATCH 03/19] fix: resolve timezone-related date shift issue Parse date-only strings (YYYY-MM-DD) as local midnight instead of UTC midnight to prevent dates from shifting by one day for users in timezones behind UTC. Fixes #22 #20 Changes: - forms/transactions.ts: Parse form date inputs as local time - models/export_and_import.ts: Parse CSV import dates as local time --- forms/transactions.ts | 9 ++++++++- models/export_and_import.ts | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/forms/transactions.ts b/forms/transactions.ts index c574797e..5941314c 100644 --- a/forms/transactions.ts +++ b/forms/transactions.ts @@ -42,7 +42,14 @@ export const transactionFormSchema = z .refine((val) => !isNaN(Date.parse(val)), { message: "Invalid date format", }) - .transform((val) => new Date(val)), + .transform((val) => { + // Parse date-only strings as local midnight to avoid timezone shift + // e.g., "2024-04-09" should be April 9th in user's timezone, not UTC + if (/^\d{4}-\d{2}-\d{2}$/.test(val)) { + return new Date(val + "T00:00:00") + } + return new Date(val) + }), ]) .optional(), text: z.string().optional(), diff --git a/models/export_and_import.ts b/models/export_and_import.ts index a032fc39..0bf6dd72 100644 --- a/models/export_and_import.ts +++ b/models/export_and_import.ts @@ -122,6 +122,10 @@ export const EXPORT_AND_IMPORT_FIELD_MAP: Record Date: Sat, 17 Jan 2026 17:32:20 -0500 Subject: [PATCH 04/19] chore: remove unused error variable in catch block --- models/export_and_import.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/export_and_import.ts b/models/export_and_import.ts index 0bf6dd72..95083f59 100644 --- a/models/export_and_import.ts +++ b/models/export_and_import.ts @@ -127,7 +127,7 @@ export const EXPORT_AND_IMPORT_FIELD_MAP: Record Date: Sat, 17 Jan 2026 17:36:09 -0500 Subject: [PATCH 05/19] feat: add duplicate/copy transaction feature - Add duplicateTransaction function in models/transactions.ts - Add duplicateTransactionAction in transactions/actions.ts - Add Duplicate button to transaction edit form - Duplicated transactions get "(Copy)" suffix and no file references Closes #38 --- app/(app)/transactions/actions.ts | 16 +++++++++++ components/transactions/edit.tsx | 46 +++++++++++++++++++++++++------ models/transactions.ts | 20 ++++++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/app/(app)/transactions/actions.ts b/app/(app)/transactions/actions.ts index e6ea61d6..b1093861 100644 --- a/app/(app)/transactions/actions.ts +++ b/app/(app)/transactions/actions.ts @@ -16,6 +16,7 @@ import { bulkDeleteTransactions, createTransaction, deleteTransaction, + duplicateTransaction, getTransactionById, updateTransaction, updateTransactionFiles, @@ -226,3 +227,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/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({ )}
- +
+ + + +
) } diff --git a/components/settings/fields-settings-form.tsx b/components/settings/fields-settings-form.tsx new file mode 100644 index 00000000..40070907 --- /dev/null +++ b/components/settings/fields-settings-form.tsx @@ -0,0 +1,372 @@ +"use client" + +import { addFieldAction, deleteFieldAction, editFieldAction, reorderFieldsAction } from "@/app/(app)/settings/actions" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Field, Prisma } from "@/prisma/client" +import { Check, Edit, GripVertical, Trash2 } from "lucide-react" +import { useState, useTransition } from "react" +import { + DndContext, + closestCenter, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core" +import type { DragEndEvent } from "@dnd-kit/core" +import { + arrayMove, + SortableContext, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable" + +interface FieldsSettingsFormProps { + initialFields: (Field & { isEditable: boolean; isDeletable: boolean })[] + userId: string +} + +interface FieldFormData { + name: string + type: string + llm_prompt: string + isVisibleInList: boolean + isVisibleInAnalysis: boolean + isRequired: boolean +} + +const defaultFormData: FieldFormData = { + name: "", + type: "string", + llm_prompt: "", + isVisibleInList: false, + isVisibleInAnalysis: false, + isRequired: false, +} + +export default function FieldsSettingsForm({ initialFields, userId }: FieldsSettingsFormProps) { + const [fields, setFields] = useState(initialFields) + const [isAdding, setIsAdding] = useState(false) + const [editingCode, setEditingCode] = useState(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/models/fields.ts b/models/fields.ts index 70a6a642..c712f133 100644 --- a/models/fields.ts +++ b/models/fields.ts @@ -10,9 +10,10 @@ export type FieldData = { export const getFields = cache(async (userId: string) => { return await prisma.field.findMany({ where: { userId }, - orderBy: { - createdAt: "asc", - }, + orderBy: [ + { order: "asc" }, + { createdAt: "asc" }, + ], }) }) @@ -44,3 +45,15 @@ export const deleteField = async (userId: string, code: string) => { where: { userId_code: { code, userId } }, }) } + +export const updateFieldOrders = async (userId: string, fieldOrders: { code: string; order: number }[]) => { + // Use a transaction to update all field orders atomically + return await prisma.$transaction( + fieldOrders.map(({ code, order }) => + prisma.field.update({ + where: { userId_code: { code, userId } }, + data: { order }, + }) + ) + ) +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 65aa0f5f..dc07c7eb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -146,6 +146,7 @@ model Field { isVisibleInAnalysis Boolean @default(false) @map("is_visible_in_analysis") isRequired Boolean @default(false) @map("is_required") isExtra Boolean @default(true) @map("is_extra") + order Int @default(0) @@unique([userId, code]) @@map("fields") From d5dc530870c6f549e403beea3898794d1d5d42a3 Mon Sep 17 00:00:00 2001 From: adryserage <17680194+adryserage@users.noreply.github.com> Date: Sat, 17 Jan 2026 17:48:28 -0500 Subject: [PATCH 08/19] feat: add SKIP_DB_CHECK env variable to bypass database readiness check (#32) - Add SKIP_DB_CHECK environment variable support in docker-entrypoint.sh - When set to 'true', skips the PostgreSQL connection check on startup - Useful for external PostgreSQL or when URL parsing fails with special characters - Document the option in .env.example --- .env.example | 4 ++++ docker-entrypoint.sh | 24 +++++++++++++++--------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index ceec3555..88e0bf76 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,10 @@ DISABLE_SIGNUP=true UPLOAD_PATH="./data/uploads" DATABASE_URL="postgresql://user@localhost:5432/taxhacker" +# 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-..." diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 50060832..92322ab0 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,16 +1,22 @@ #!/bin/sh set -e -# Extract server part from DATABASE_URL (remove database name) -SERVER_URL=$(echo "$DATABASE_URL" | sed 's/\/[^/]*$//') +# Skip database check if SKIP_DB_CHECK is set +if [ "$SKIP_DB_CHECK" = "true" ]; then + echo "Skipping database readiness check (SKIP_DB_CHECK=true)" +else + # Extract server part from DATABASE_URL (remove database name) + # This handles URLs like: postgresql://user:pass@host:port/dbname + SERVER_URL=$(echo "$DATABASE_URL" | sed 's/\/[^/]*$//') -# Wait for database to be ready using psql and SERVER_URL -echo "Waiting for PostgreSQL server to be ready at $SERVER_URL..." -until psql "$SERVER_URL" -c '\q' >/dev/null 2>&1; do - echo "PostgreSQL server is unavailable - sleeping" - sleep 1 -done -echo "PostgreSQL server is ready!" + # Wait for database to be ready using psql and SERVER_URL + echo "Waiting for PostgreSQL server to be ready at $SERVER_URL..." + until psql "$SERVER_URL" -c '\q' >/dev/null 2>&1; do + echo "PostgreSQL server is unavailable - sleeping" + sleep 1 + done + echo "PostgreSQL server is ready!" +fi # Run database migrations echo "Running database migrations..." From 7d236ecafaa3c345a3ce12275eb9d703e09544b5 Mon Sep 17 00:00:00 2001 From: adryserage <17680194+adryserage@users.noreply.github.com> Date: Sat, 17 Jan 2026 17:50:27 -0500 Subject: [PATCH 09/19] feat: add SHIFT+click range selection for transactions (#27) - Track last selected transaction index - When SHIFT is held during click, select all transactions in range - Range selection works in both directions (up and down) - Combines with existing selection (union behavior) --- components/transactions/list.tsx | 35 ++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/components/transactions/list.tsx b/components/transactions/list.tsx index b5f4098b..686d740d 100644 --- a/components/transactions/list.tsx +++ b/components/transactions/list.tsx @@ -181,6 +181,7 @@ const getFieldRenderer = (field: Field): FieldRenderer => { export function TransactionList({ transactions, fields = [] }: { transactions: Transaction[]; fields?: Field[] }) { const [selectedIds, setSelectedIds] = useState([]) + const [lastSelectedIndex, setLastSelectedIndex] = useState(null) const router = useRouter() const searchParams = useSearchParams() @@ -208,17 +209,33 @@ export function TransactionList({ transactions, fields = [] }: { transactions: T const toggleAllRows = () => { if (selectedIds.length === transactions.length) { setSelectedIds([]) + setLastSelectedIndex(null) } else { setSelectedIds(transactions.map((transaction) => transaction.id)) + setLastSelectedIndex(null) } } - const toggleOneRow = (e: React.MouseEvent, id: string) => { + const toggleOneRow = (e: React.MouseEvent, id: string, index: number) => { e.stopPropagation() - if (selectedIds.includes(id)) { - setSelectedIds(selectedIds.filter((item) => item !== id)) + + // SHIFT+click for range selection + if (e.shiftKey && lastSelectedIndex !== null) { + const start = Math.min(lastSelectedIndex, index) + const end = Math.max(lastSelectedIndex, index) + const rangeIds = transactions.slice(start, end + 1).map((t) => t.id) + + // Add all items in range to selection (union with existing selection) + const newSelection = [...new Set([...selectedIds, ...rangeIds])] + setSelectedIds(newSelection) } else { - setSelectedIds([...selectedIds, id]) + // Normal toggle behavior + if (selectedIds.includes(id)) { + setSelectedIds(selectedIds.filter((item) => item !== id)) + } else { + setSelectedIds([...selectedIds, id]) + } + setLastSelectedIndex(index) } } @@ -294,7 +311,7 @@ export function TransactionList({ transactions, fields = [] }: { transactions: T - {transactions.map((transaction) => ( + {transactions.map((transaction, index) => ( handleRowClick(transaction.id)} > - e.stopPropagation()}> + toggleOneRow(e, transaction.id, index)}> { - if (checked !== "indeterminate") { - toggleOneRow({ stopPropagation: () => {} } as React.MouseEvent, transaction.id) - } - }} + onCheckedChange={() => {}} /> {visibleFields.map((field) => ( From 7f3a518e04a9e9e73d8a62ff1ebc6789a6847789 Mon Sep 17 00:00:00 2001 From: adryserage <17680194+adryserage@users.noreply.github.com> Date: Sat, 17 Jan 2026 17:55:41 -0500 Subject: [PATCH 10/19] feat: add sub-categories support (#6) - Add parentCode field to Category model with self-referential relation - Update models/categories.ts to include children and add getCategoriesHierarchical - Update categoryFormSchema to include parentCode field - Update add/edit category actions to handle parentCode - Update FormSelectCategory to display hierarchical categories with indentation - Update categories settings page with parent category selector column Closes #6 --- app/(app)/settings/actions.ts | 2 ++ app/(app)/settings/categories/page.tsx | 24 +++++++++++++- components/forms/select-category.tsx | 46 +++++++++++++++++++++++--- forms/settings.ts | 1 + models/categories.ts | 31 +++++++++++++++++ prisma/schema.prisma | 3 ++ 6 files changed, 101 insertions(+), 6 deletions(-) diff --git a/app/(app)/settings/actions.ts b/app/(app)/settings/actions.ts index 9c416566..35dcac66 100644 --- a/app/(app)/settings/actions.ts +++ b/app/(app)/settings/actions.ts @@ -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") 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.

{ - 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 ( { orderBy: { name: "asc", }, + include: { + children: true, + }, + }) +}) + +// Get categories in hierarchical structure (parent categories first, then children) +export const getCategoriesHierarchical = cache(async (userId: string) => { + const categories = await prisma.category.findMany({ + where: { userId }, + orderBy: { + name: "asc", + }, }) + + // Build hierarchical structure + const parentCategories = categories.filter((c) => !c.parentCode) + const childCategories = categories.filter((c) => c.parentCode) + + // Group children by parent + const childrenByParent = childCategories.reduce( + (acc, child) => { + if (!acc[child.parentCode!]) { + acc[child.parentCode!] = [] + } + acc[child.parentCode!].push(child) + return acc + }, + {} as Record + ) + + return { parentCategories, childrenByParent, allCategories: categories } }) export const getCategoryByCode = cache(async (userId: string, code: string) => { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 65aa0f5f..6a0ddf7d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -110,6 +110,9 @@ model Category { name String color String @default("#000000") llm_prompt String? + parentCode String? @map("parent_code") + parent Category? @relation("CategoryToSubcategory", fields: [parentCode, userId], references: [code, userId], onDelete: SetNull, onUpdate: Cascade) + children Category[] @relation("CategoryToSubcategory") transactions Transaction[] createdAt DateTime @default(now()) @map("created_at") From ed1798e83522e986dddb01966cad2c19e8fdd6ba Mon Sep 17 00:00:00 2001 From: adryserage <17680194+adryserage@users.noreply.github.com> Date: Sat, 17 Jan 2026 17:59:37 -0500 Subject: [PATCH 11/19] feat: add tax year filter (#44) - Add tax_year_start setting to configure when tax year begins (MM-DD format) - Add TaxYearSelector component for quick tax year filtering - Integrate tax year selector into transaction filters - Support both calendar years (01-01) and fiscal years (e.g., 04-06 for UK) - Auto-detect selected tax year from date range Closes #44 --- app/(app)/transactions/page.tsx | 9 ++- components/settings/global-settings-form.tsx | 13 +++ components/transactions/filters.tsx | 47 +++++++++++ components/transactions/tax-year-selector.tsx | 80 +++++++++++++++++++ forms/settings.ts | 1 + 5 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 components/transactions/tax-year-selector.tsx 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/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) +

+
+ + +
+ 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" + /> +
+
+ + )}