chore: organize ts types, fix eslint errors

This commit is contained in:
Vasily Zubarev
2025-04-09 12:45:56 +02:00
parent 707a030a0a
commit 416c45d08c
29 changed files with 277 additions and 84 deletions

View File

@@ -1,5 +1,6 @@
"use server" "use server"
import { ActionState } from "@/lib/actions"
import OpenAI from "openai" import OpenAI from "openai"
import { AnalyzeAttachment } from "./attachments" import { AnalyzeAttachment } from "./attachments"
@@ -8,7 +9,7 @@ export async function analyzeTransaction(
schema: Record<string, unknown>, schema: Record<string, unknown>,
attachments: AnalyzeAttachment[], attachments: AnalyzeAttachment[],
apiKey: string apiKey: string
): Promise<{ success: boolean; data?: Record<string, any>; error?: string }> { ): Promise<ActionState<Record<string, string>>> {
const openai = new OpenAI({ const openai = new OpenAI({
apiKey, apiKey,
}) })

View File

@@ -38,7 +38,7 @@ export async function GET(request: Request) {
// CSV rows // CSV rows
for (const transaction of transactions) { for (const transaction of transactions) {
const row: Record<string, any> = {} const row: Record<string, unknown> = {}
for (const field of existingFields) { for (const field of existingFields) {
let value let value
if (field.isExtra) { if (field.isExtra) {

View File

@@ -1,5 +1,6 @@
"use server" "use server"
import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import { getUserUploadsDirectory, unsortedFilePath } from "@/lib/files" import { getUserUploadsDirectory, unsortedFilePath } from "@/lib/files"
import { createFile } from "@/models/files" import { createFile } from "@/models/files"
@@ -8,7 +9,7 @@ import { mkdir, writeFile } from "fs/promises"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import path from "path" import path from "path"
export async function uploadFilesAction(prevState: any, formData: FormData) { export async function uploadFilesAction(formData: FormData): Promise<ActionState<null>> {
const user = await getCurrentUser() const user = await getCurrentUser()
const files = formData.getAll("files") const files = formData.getAll("files")

View File

@@ -1,12 +1,17 @@
"use server" "use server"
import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import { EXPORT_AND_IMPORT_FIELD_MAP } from "@/models/export_and_import" import { EXPORT_AND_IMPORT_FIELD_MAP } from "@/models/export_and_import"
import { createTransaction } from "@/models/transactions" import { createTransaction } from "@/models/transactions"
import { parse } from "@fast-csv/parse" import { parse } from "@fast-csv/parse"
import { Transaction } from "@prisma/client"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
export async function parseCSVAction(prevState: any, formData: FormData) { export async function parseCSVAction(
_prevState: ActionState<string[][]> | null,
formData: FormData
): Promise<ActionState<string[][]>> {
const file = formData.get("file") as File const file = formData.get("file") as File
if (!file) { if (!file) {
return { success: false, error: "No file uploaded" } return { success: false, error: "No file uploaded" }
@@ -38,7 +43,10 @@ export async function parseCSVAction(prevState: any, formData: FormData) {
} }
} }
export async function saveTransactionsAction(prevState: any, formData: FormData) { export async function saveTransactionsAction(
_prevState: ActionState<Transaction> | null,
formData: FormData
): Promise<ActionState<Transaction>> {
const user = await getCurrentUser() const user = await getCurrentUser()
try { try {
const rows = JSON.parse(formData.get("rows") as string) as Record<string, unknown>[] const rows = JSON.parse(formData.get("rows") as string) as Record<string, unknown>[]

View File

@@ -8,19 +8,23 @@ import {
settingsFormSchema, settingsFormSchema,
} from "@/forms/settings" } from "@/forms/settings"
import { userFormSchema } from "@/forms/users" import { userFormSchema } from "@/forms/users"
import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import { codeFromName, randomHexColor } from "@/lib/utils" import { codeFromName, randomHexColor } from "@/lib/utils"
import { createCategory, deleteCategory, updateCategory } from "@/models/categories" import { createCategory, deleteCategory, updateCategory } from "@/models/categories"
import { createCurrency, deleteCurrency, updateCurrency } from "@/models/currencies" import { createCurrency, deleteCurrency, updateCurrency } from "@/models/currencies"
import { createField, deleteField, updateField } from "@/models/fields" import { createField, deleteField, updateField } from "@/models/fields"
import { createProject, deleteProject, updateProject } from "@/models/projects" import { createProject, deleteProject, updateProject } from "@/models/projects"
import { updateSettings } from "@/models/settings" import { SettingsMap, updateSettings } from "@/models/settings"
import { updateUser } from "@/models/users" import { updateUser } from "@/models/users"
import { Prisma } from "@prisma/client" import { Prisma, User } from "@prisma/client"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
export async function saveSettingsAction(prevState: any, formData: FormData) { export async function saveSettingsAction(
_prevState: ActionState<SettingsMap> | null,
formData: FormData
): Promise<ActionState<SettingsMap>> {
const user = await getCurrentUser() const user = await getCurrentUser()
const validatedForm = settingsFormSchema.safeParse(Object.fromEntries(formData)) const validatedForm = settingsFormSchema.safeParse(Object.fromEntries(formData))
@@ -29,7 +33,10 @@ export async function saveSettingsAction(prevState: any, formData: FormData) {
} }
for (const key in validatedForm.data) { for (const key in validatedForm.data) {
await updateSettings(user.id, key, validatedForm.data[key as keyof typeof validatedForm.data]) const value = validatedForm.data[key as keyof typeof validatedForm.data]
if (value !== undefined) {
await updateSettings(user.id, key, value)
}
} }
revalidatePath("/settings") revalidatePath("/settings")
@@ -37,7 +44,10 @@ export async function saveSettingsAction(prevState: any, formData: FormData) {
// return { success: true } // return { success: true }
} }
export async function saveProfileAction(prevState: any, formData: FormData) { export async function saveProfileAction(
_prevState: ActionState<User> | null,
formData: FormData
): Promise<ActionState<User>> {
const user = await getCurrentUser() const user = await getCurrentUser()
const validatedForm = userFormSchema.safeParse(Object.fromEntries(formData)) const validatedForm = userFormSchema.safeParse(Object.fromEntries(formData))

View File

@@ -1,5 +1,6 @@
"use server" "use server"
import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import { prisma } from "@/lib/db" import { prisma } from "@/lib/db"
import { getUserUploadsDirectory } from "@/lib/files" import { getUserUploadsDirectory } from "@/lib/files"
@@ -12,7 +13,14 @@ const SUPPORTED_BACKUP_VERSIONS = ["1.0"]
const REMOVE_EXISTING_DATA = true const REMOVE_EXISTING_DATA = true
const MAX_BACKUP_SIZE = 256 * 1024 * 1024 // 256MB const MAX_BACKUP_SIZE = 256 * 1024 * 1024 // 256MB
export async function restoreBackupAction(prevState: any, formData: FormData) { type BackupRestoreResult = {
counters: Record<string, number>
}
export async function restoreBackupAction(
_prevState: ActionState<BackupRestoreResult> | null,
formData: FormData
): Promise<ActionState<BackupRestoreResult>> {
const user = await getCurrentUser() const user = await getCurrentUser()
const userUploadsDirectory = await getUserUploadsDirectory(user) const userUploadsDirectory = await getUserUploadsDirectory(user)
const file = formData.get("file") as File const file = formData.get("file") as File
@@ -32,7 +40,7 @@ export async function restoreBackupAction(prevState: any, formData: FormData) {
const fileData = Buffer.from(fileBuffer) const fileData = Buffer.from(fileBuffer)
zip = await JSZip.loadAsync(fileData) zip = await JSZip.loadAsync(fileData)
} catch (error) { } catch (error) {
return { success: false, error: "Bad zip archive" } return { success: false, error: "Bad zip archive: " + (error as Error).message }
} }
// Check metadata and start restoring // Check metadata and start restoring
@@ -133,7 +141,7 @@ export async function restoreBackupAction(prevState: any, formData: FormData) {
} }
} }
return { success: true, message: "Restore completed successfully", counters } return { success: true, data: { counters } }
} catch (error) { } catch (error) {
console.error("Error restoring from backup:", error) console.error("Error restoring from backup:", error)
return { return {

View File

@@ -9,7 +9,7 @@ import path from "path"
const MAX_FILE_SIZE = 64 * 1024 * 1024 // 64MB const MAX_FILE_SIZE = 64 * 1024 * 1024 // 64MB
const BACKUP_VERSION = "1.0" const BACKUP_VERSION = "1.0"
export async function GET(request: Request) { export async function GET() {
const user = await getCurrentUser() const user = await getCurrentUser()
const userUploadsDirectory = await getUserUploadsDirectory(user) const userUploadsDirectory = await getUserUploadsDirectory(user)
@@ -87,7 +87,7 @@ export async function GET(request: Request) {
} }
async function getAllFilePaths(dirPath: string): Promise<string[]> { async function getAllFilePaths(dirPath: string): Promise<string[]> {
let filePaths: string[] = [] const filePaths: string[] = []
async function readDirectoryRecursively(currentPath: string) { async function readDirectoryRecursively(currentPath: string) {
const isDirExists = await fileExists(currentPath) const isDirExists = await fileExists(currentPath)

View File

@@ -62,7 +62,7 @@ export default function BackupSettingsPage() {
<h2 className="text-xl font-semibold">Backup restored successfully</h2> <h2 className="text-xl font-semibold">Backup restored successfully</h2>
<p className="text-sm text-muted-foreground">You can now continue using the app. Import stats:</p> <p className="text-sm text-muted-foreground">You can now continue using the app. Import stats:</p>
<ul className="list-disc list-inside"> <ul className="list-disc list-inside">
{Object.entries(restoreState.counters || {}).map(([key, value]) => ( {Object.entries(restoreState.data?.counters || {}).map(([key, value]) => (
<li key={key}> <li key={key}>
<span className="font-bold">{key}</span>: {value} items <span className="font-bold">{key}</span>: {value} items
</li> </li>

View File

@@ -17,8 +17,9 @@ export default async function FieldsSettingsPage() {
<div className="container"> <div className="container">
<h1 className="text-2xl font-bold mb-2">Custom Fields</h1> <h1 className="text-2xl font-bold mb-2">Custom Fields</h1>
<p className="text-sm text-gray-500 mb-6 max-w-prose"> <p className="text-sm text-gray-500 mb-6 max-w-prose">
You can add new fields to your transactions. Standard fields can't be removed but you can tweak their prompts or You can add new fields to your transactions. Standard fields can&apos;t be removed but you can tweak their
hide them. If you don't want a field to be analyzed by AI but filled in by hand, leave the "LLM prompt" empty. prompts or hide them. If you don&apos;t want a field to be analyzed by AI but filled in by hand, leave the
&quot;LLM prompt&quot; empty.
</p> </p>
<CrudTable <CrudTable
items={fieldsWithActions} items={fieldsWithActions}

View File

@@ -1,6 +1,7 @@
"use server" "use server"
import { transactionFormSchema } from "@/forms/transactions" import { transactionFormSchema } from "@/forms/transactions"
import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import { getTransactionFileUploadPath, getUserUploadsDirectory } from "@/lib/files" import { getTransactionFileUploadPath, getUserUploadsDirectory } from "@/lib/files"
import { updateField } from "@/models/fields" import { updateField } from "@/models/fields"
@@ -13,12 +14,16 @@ import {
updateTransaction, updateTransaction,
updateTransactionFiles, updateTransactionFiles,
} from "@/models/transactions" } from "@/models/transactions"
import { Transaction } from "@prisma/client"
import { randomUUID } from "crypto" import { randomUUID } from "crypto"
import { mkdir, writeFile } from "fs/promises" import { mkdir, writeFile } from "fs/promises"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import path from "path" import path from "path"
export async function createTransactionAction(prevState: any, formData: FormData) { export async function createTransactionAction(
_prevState: ActionState<Transaction> | null,
formData: FormData
): Promise<ActionState<Transaction>> {
try { try {
const user = await getCurrentUser() const user = await getCurrentUser()
const validatedForm = transactionFormSchema.safeParse(Object.fromEntries(formData.entries())) const validatedForm = transactionFormSchema.safeParse(Object.fromEntries(formData.entries()))
@@ -30,14 +35,17 @@ export async function createTransactionAction(prevState: any, formData: FormData
const transaction = await createTransaction(user.id, validatedForm.data) const transaction = await createTransaction(user.id, validatedForm.data)
revalidatePath("/transactions") revalidatePath("/transactions")
return { success: true, transactionId: transaction.id } return { success: true, data: transaction }
} catch (error) { } catch (error) {
console.error("Failed to create transaction:", error) console.error("Failed to create transaction:", error)
return { success: false, error: "Failed to create transaction" } return { success: false, error: "Failed to create transaction" }
} }
} }
export async function saveTransactionAction(prevState: any, formData: FormData) { export async function saveTransactionAction(
_prevState: ActionState<Transaction> | null,
formData: FormData
): Promise<ActionState<Transaction>> {
try { try {
const user = await getCurrentUser() const user = await getCurrentUser()
const transactionId = formData.get("transactionId") as string const transactionId = formData.get("transactionId") as string
@@ -50,14 +58,17 @@ export async function saveTransactionAction(prevState: any, formData: FormData)
const transaction = await updateTransaction(transactionId, user.id, validatedForm.data) const transaction = await updateTransaction(transactionId, user.id, validatedForm.data)
revalidatePath("/transactions") revalidatePath("/transactions")
return { success: true, transactionId: transaction.id } return { success: true, data: transaction }
} catch (error) { } catch (error) {
console.error("Failed to update transaction:", error) console.error("Failed to update transaction:", error)
return { success: false, error: "Failed to save transaction" } return { success: false, error: "Failed to save transaction" }
} }
} }
export async function deleteTransactionAction(prevState: any, transactionId: string) { export async function deleteTransactionAction(
_prevState: ActionState<Transaction> | null,
transactionId: string
): Promise<ActionState<Transaction>> {
try { try {
const user = await getCurrentUser() const user = await getCurrentUser()
const transaction = await getTransactionById(transactionId, user.id) const transaction = await getTransactionById(transactionId, user.id)
@@ -67,7 +78,7 @@ export async function deleteTransactionAction(prevState: any, transactionId: str
revalidatePath("/transactions") revalidatePath("/transactions")
return { success: true, transactionId: transaction.id } return { success: true, data: transaction }
} catch (error) { } catch (error) {
console.error("Failed to delete transaction:", error) console.error("Failed to delete transaction:", error)
return { success: false, error: "Failed to delete transaction" } return { success: false, error: "Failed to delete transaction" }
@@ -77,7 +88,7 @@ export async function deleteTransactionAction(prevState: any, transactionId: str
export async function deleteTransactionFileAction( export async function deleteTransactionFileAction(
transactionId: string, transactionId: string,
fileId: string fileId: string
): Promise<{ success: boolean; error?: string }> { ): Promise<ActionState<Transaction>> {
if (!fileId || !transactionId) { if (!fileId || !transactionId) {
return { success: false, error: "File ID and transaction ID are required" } return { success: false, error: "File ID and transaction ID are required" }
} }
@@ -96,10 +107,10 @@ export async function deleteTransactionFileAction(
await deleteFile(fileId, user.id) await deleteFile(fileId, user.id)
revalidatePath(`/transactions/${transactionId}`) revalidatePath(`/transactions/${transactionId}`)
return { success: true } return { success: true, data: transaction }
} }
export async function uploadTransactionFilesAction(formData: FormData): Promise<{ success: boolean; error?: string }> { export async function uploadTransactionFilesAction(formData: FormData): Promise<ActionState<Transaction>> {
try { try {
const transactionId = formData.get("transactionId") as string const transactionId = formData.get("transactionId") as string
const files = formData.getAll("files") as File[] const files = formData.getAll("files") as File[]

View File

@@ -5,13 +5,14 @@ import { AnalyzeAttachment, loadAttachmentsForAI } from "@/ai/attachments"
import { buildLLMPrompt } from "@/ai/prompt" import { buildLLMPrompt } from "@/ai/prompt"
import { fieldsToJsonSchema } from "@/ai/schema" import { fieldsToJsonSchema } from "@/ai/schema"
import { transactionFormSchema } from "@/forms/transactions" import { transactionFormSchema } from "@/forms/transactions"
import { ActionState } from "@/lib/actions"
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import config from "@/lib/config" import config from "@/lib/config"
import { getTransactionFileUploadPath, getUserUploadsDirectory } from "@/lib/files" import { getTransactionFileUploadPath, getUserUploadsDirectory } from "@/lib/files"
import { DEFAULT_PROMPT_ANALYSE_NEW_FILE } from "@/models/defaults" import { DEFAULT_PROMPT_ANALYSE_NEW_FILE } from "@/models/defaults"
import { deleteFile, getFileById, updateFile } from "@/models/files" import { deleteFile, getFileById, updateFile } from "@/models/files"
import { createTransaction, updateTransactionFiles } from "@/models/transactions" import { createTransaction, updateTransactionFiles } from "@/models/transactions"
import { Category, Field, File, Project } from "@prisma/client" import { Category, Field, File, Project, Transaction } from "@prisma/client"
import { mkdir, rename } from "fs/promises" import { mkdir, rename } from "fs/promises"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import path from "path" import path from "path"
@@ -22,7 +23,7 @@ export async function analyzeFileAction(
fields: Field[], fields: Field[],
categories: Category[], categories: Category[],
projects: Project[] projects: Project[]
): Promise<{ success: boolean; data?: Record<string, any>; error?: string }> { ): Promise<ActionState<Record<string, string>>> {
const user = await getCurrentUser() const user = await getCurrentUser()
if (!file || file.userId !== user.id) { if (!file || file.userId !== user.id) {
@@ -58,7 +59,10 @@ export async function analyzeFileAction(
return results return results
} }
export async function saveFileAsTransactionAction(prevState: any, formData: FormData) { export async function saveFileAsTransactionAction(
_prevState: ActionState<Transaction> | null,
formData: FormData
): Promise<ActionState<Transaction>> {
try { try {
const user = await getCurrentUser() const user = await getCurrentUser()
const validatedForm = transactionFormSchema.safeParse(Object.fromEntries(formData.entries())) const validatedForm = transactionFormSchema.safeParse(Object.fromEntries(formData.entries()))
@@ -97,14 +101,17 @@ export async function saveFileAsTransactionAction(prevState: any, formData: Form
revalidatePath("/unsorted") revalidatePath("/unsorted")
revalidatePath("/transactions") revalidatePath("/transactions")
return { success: true, transactionId: transaction.id } return { success: true, data: transaction }
} catch (error) { } catch (error) {
console.error("Failed to save transaction:", error) console.error("Failed to save transaction:", error)
return { success: false, error: `Failed to save transaction: ${error}` } return { success: false, error: `Failed to save transaction: ${error}` }
} }
} }
export async function deleteUnsortedFileAction(prevState: any, fileId: string) { export async function deleteUnsortedFileAction(
_prevState: ActionState<Transaction> | null,
fileId: string
): Promise<ActionState<Transaction>> {
try { try {
const user = await getCurrentUser() const user = await getCurrentUser()
await deleteFile(fileId, user.id) await deleteFile(fileId, user.id)

View File

@@ -15,12 +15,12 @@ export async function selfHostedGetStartedAction(formData: FormData) {
const openaiApiKey = formData.get("openai_api_key") const openaiApiKey = formData.get("openai_api_key")
if (openaiApiKey) { if (openaiApiKey) {
await updateSettings(user.id, "openai_api_key", openaiApiKey) await updateSettings(user.id, "openai_api_key", openaiApiKey as string)
} }
const defaultCurrency = formData.get("default_currency") const defaultCurrency = formData.get("default_currency")
if (defaultCurrency) { if (defaultCurrency) {
await updateSettings(user.id, "default_currency", defaultCurrency) await updateSettings(user.id, "default_currency", defaultCurrency as string)
} }
revalidatePath("/dashboard") revalidatePath("/dashboard")

View File

@@ -2,6 +2,7 @@ import { LoginForm } from "@/components/auth/login-form"
import { Card, CardContent, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardTitle } from "@/components/ui/card"
import { ColoredText } from "@/components/ui/colored-text" import { ColoredText } from "@/components/ui/colored-text"
import config from "@/lib/config" import config from "@/lib/config"
import Image from "next/image"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
export default async function LoginPage() { export default async function LoginPage() {
@@ -11,7 +12,7 @@ export default async function LoginPage() {
return ( return (
<Card className="w-full max-w-xl mx-auto p-8 flex flex-col items-center justify-center gap-4"> <Card className="w-full max-w-xl mx-auto p-8 flex flex-col items-center justify-center gap-4">
<img src="/logo/512.png" alt="Logo" className="w-36 h-36" /> <Image src="/logo/512.png" alt="Logo" width={144} height={144} className="w-36 h-36" />
<CardTitle className="text-3xl font-bold "> <CardTitle className="text-3xl font-bold ">
<ColoredText>TaxHacker: Cloud Edition</ColoredText> <ColoredText>TaxHacker: Cloud Edition</ColoredText>
</CardTitle> </CardTitle>

View File

@@ -1,16 +1,17 @@
import { X } from "lucide-react" import { X } from "lucide-react"
import Link from "next/link"
export default function AuthLayout({ children }: { children: React.ReactNode }) { export default function AuthLayout({ children }: { children: React.ReactNode }) {
return ( return (
<div className="min-h-screen bg-gray-900 flex flex-col relative"> <div className="min-h-screen bg-gray-900 flex flex-col relative">
<a <Link
href="/" href="/"
className="absolute top-4 right-4 flex items-center justify-center w-10 h-10 rounded-full bg-gray-800 hover:bg-gray-700 transition-colors" className="absolute top-4 right-4 flex items-center justify-center w-10 h-10 rounded-full bg-gray-800 hover:bg-gray-700 transition-colors"
> >
<span className="text-gray-300 font-bold text-xl"> <span className="text-gray-300 font-bold text-xl">
<X /> <X />
</span> </span>
</a> </Link>
<div className="flex-grow flex flex-col justify-center items-center py-12 px-4 sm:px-6 lg:px-8">{children}</div> <div className="flex-grow flex flex-col justify-center items-center py-12 px-4 sm:px-6 lg:px-8">{children}</div>
</div> </div>
) )

View File

@@ -7,6 +7,7 @@ import config from "@/lib/config"
import { DEFAULT_CURRENCIES, DEFAULT_SETTINGS } from "@/models/defaults" import { DEFAULT_CURRENCIES, DEFAULT_SETTINGS } from "@/models/defaults"
import { getSelfHostedUser } from "@/models/users" import { getSelfHostedUser } from "@/models/users"
import { ShieldAlert } from "lucide-react" import { ShieldAlert } from "lucide-react"
import Image from "next/image"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
import { selfHostedGetStartedAction } from "../actions" import { selfHostedGetStartedAction } from "../actions"
@@ -36,12 +37,12 @@ export default async function SelfHostedWelcomePage() {
return ( return (
<Card className="w-full max-w-xl mx-auto p-8 flex flex-col items-center justify-center gap-4"> <Card className="w-full max-w-xl mx-auto p-8 flex flex-col items-center justify-center gap-4">
<img src="/logo/512.png" alt="Logo" className="w-36 h-36" /> <Image src="/logo/512.png" alt="Logo" width={144} height={144} className="w-36 h-36" />
<CardTitle className="text-3xl font-bold "> <CardTitle className="text-3xl font-bold ">
<ColoredText>TaxHacker: Self-Hosted Edition</ColoredText> <ColoredText>TaxHacker: Self-Hosted Edition</ColoredText>
</CardTitle> </CardTitle>
<CardDescription className="flex flex-col gap-4 text-center text-lg"> <CardDescription className="flex flex-col gap-4 text-center text-lg">
<p>Welcome to your own instance of TaxHacker. Let's set up a couple of settings to get started.</p> <p>Welcome to your own instance of TaxHacker. Let&apos;s set up a couple of settings to get started.</p>
<form action={selfHostedGetStartedAction} className="flex flex-col gap-8 pt-8"> <form action={selfHostedGetStartedAction} className="flex flex-col gap-8 pt-8">
<div> <div>

View File

@@ -4,7 +4,7 @@ import { getSelfHostedUser } from "@/models/users"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
export async function GET(request: Request) { export async function GET() {
if (!config.selfHosted.isEnabled) { if (!config.selfHosted.isEnabled) {
redirect(config.auth.loginUrl) redirect(config.auth.loginUrl)
} }

View File

@@ -8,10 +8,10 @@ export default function LandingPage() {
<div className="min-h-screen flex flex-col bg-[#FAFAFA]"> <div className="min-h-screen flex flex-col bg-[#FAFAFA]">
<header className="py-6 px-8 bg-white/80 backdrop-blur-md shadow-sm fixed w-full z-10"> <header className="py-6 px-8 bg-white/80 backdrop-blur-md shadow-sm fixed w-full z-10">
<div className="max-w-7xl mx-auto flex justify-between items-center"> <div className="max-w-7xl mx-auto flex justify-between items-center">
<a href="/" className="flex items-center gap-2"> <Link href="/" className="flex items-center gap-2">
<img src="/logo/256.png" alt="Logo" className="h-8" /> <Image src="/logo/256.png" alt="Logo" width={32} height={32} className="h-8" />
<ColoredText className="text-2xl font-bold">TaxHacker</ColoredText> <ColoredText className="text-2xl font-bold">TaxHacker</ColoredText>
</a> </Link>
<div className="flex gap-4"> <div className="flex gap-4">
<Link <Link
href="#start" href="#start"
@@ -43,12 +43,12 @@ export default function LandingPage() {
> >
Get Started Get Started
</Link> </Link>
<a <Link
href="mailto:me@vas3k.ru" href="mailto:me@vas3k.ru"
className="px-8 py-3 border border-gray-200 text-gray-700 font-medium rounded-full hover:bg-gray-50 transition-all" className="px-8 py-3 border border-gray-200 text-gray-700 font-medium rounded-full hover:bg-gray-50 transition-all"
> >
Contact Us Contact Us
</a> </Link>
</div> </div>
</div> </div>
<div className="relative aspect-auto rounded-2xl overflow-hidden shadow-2xl ring-8 ring-gray-100 hover:scale-105 transition-all duration-300"> <div className="relative aspect-auto rounded-2xl overflow-hidden shadow-2xl ring-8 ring-gray-100 hover:scale-105 transition-all duration-300">
@@ -323,7 +323,7 @@ export default function LandingPage() {
Upcoming Features Upcoming Features
</h2> </h2>
<p className="text-gray-600 max-w-2xl mx-auto"> <p className="text-gray-600 max-w-2xl mx-auto">
We're a small, indie project constantly improving. Here's what we're working on next. We&apos;re a small, indie project constantly improving. Here&apos;s what we&apos;re working on next.
</p> </p>
</div> </div>
@@ -429,9 +429,9 @@ export default function LandingPage() {
<footer className="py-8 px-8 bg-white"> <footer className="py-8 px-8 bg-white">
<div className="max-w-7xl mx-auto text-center text-sm text-gray-500"> <div className="max-w-7xl mx-auto text-center text-sm text-gray-500">
Made with in Berlin by{" "} Made with in Berlin by{" "}
<a href="https://github.com/vas3k" className="underline"> <Link href="https://github.com/vas3k" className="underline">
vas3k vas3k
</a> </Link>
</div> </div>
</footer> </footer>
</div> </div>

View File

@@ -27,7 +27,7 @@ export default function DashboardDropZoneWidget() {
// Submit the files using the server action // Submit the files using the server action
startTransition(async () => { startTransition(async () => {
const result = await uploadFilesAction(null, formData) const result = await uploadFilesAction(formData)
if (result.success) { if (result.success) {
showNotification({ code: "sidebar.unsorted", message: "new" }) showNotification({ code: "sidebar.unsorted", message: "new" })
setTimeout(() => showNotification({ code: "sidebar.unsorted", message: "" }), 3000) setTimeout(() => showNotification({ code: "sidebar.unsorted", message: "" }), 3000)

View File

@@ -65,7 +65,7 @@ export default function ScreenDropArea({ children }: { children: React.ReactNode
startTransition(async () => { startTransition(async () => {
const result = transactionId const result = transactionId
? await uploadTransactionFilesAction(formData) ? await uploadTransactionFilesAction(formData)
: await uploadFilesAction(null, formData) : await uploadFilesAction(formData)
if (result.success) { if (result.success) {
showNotification({ code: "sidebar.unsorted", message: "new" }) showNotification({ code: "sidebar.unsorted", message: "new" })
@@ -88,18 +88,18 @@ export default function ScreenDropArea({ children }: { children: React.ReactNode
// Add event listeners to document body // Add event listeners to document body
useEffect(() => { useEffect(() => {
document.body.addEventListener("dragenter", handleDragEnter as any) document.body.addEventListener("dragenter", handleDragEnter as unknown as EventListener)
document.body.addEventListener("dragover", handleDragOver as any) document.body.addEventListener("dragover", handleDragOver as unknown as EventListener)
document.body.addEventListener("dragleave", handleDragLeave as any) document.body.addEventListener("dragleave", handleDragLeave as unknown as EventListener)
document.body.addEventListener("drop", handleDrop as any) document.body.addEventListener("drop", handleDrop as unknown as EventListener)
return () => { return () => {
document.body.removeEventListener("dragenter", handleDragEnter as any) document.body.removeEventListener("dragenter", handleDragEnter as unknown as EventListener)
document.body.removeEventListener("dragover", handleDragOver as any) document.body.removeEventListener("dragover", handleDragOver as unknown as EventListener)
document.body.removeEventListener("dragleave", handleDragLeave as any) document.body.removeEventListener("dragleave", handleDragLeave as unknown as EventListener)
document.body.removeEventListener("drop", handleDrop as any) document.body.removeEventListener("drop", handleDrop as unknown as EventListener)
} }
}, [isDragging]) }, [isDragging, handleDrop])
return ( return (
<div className="relative min-h-screen w-full"> <div className="relative min-h-screen w-full">

View File

@@ -28,7 +28,7 @@ export function UploadButton({ children, ...props }: { children: React.ReactNode
// Submit the files using the server action // Submit the files using the server action
startTransition(async () => { startTransition(async () => {
const result = await uploadFilesAction(null, formData) const result = await uploadFilesAction(formData)
if (result.success) { if (result.success) {
showNotification({ code: "sidebar.unsorted", message: "new" }) showNotification({ code: "sidebar.unsorted", message: "new" })
setTimeout(() => showNotification({ code: "sidebar.unsorted", message: "" }), 3000) setTimeout(() => showNotification({ code: "sidebar.unsorted", message: "" }), 3000)

View File

@@ -16,16 +16,8 @@ export const FormConvertCurrency = ({
date?: Date | undefined date?: Date | undefined
onChange?: (value: number) => void onChange?: (value: number) => void
}) => { }) => {
if (
originalTotal === 0 ||
!originalCurrencyCode ||
!targetCurrencyCode ||
originalCurrencyCode === targetCurrencyCode
) {
return <></>
}
const normalizedDate = startOfDay(date || new Date(Date.now() - 24 * 60 * 60 * 1000)) const normalizedDate = startOfDay(date || new Date(Date.now() - 24 * 60 * 60 * 1000))
const normalizedDateString = format(normalizedDate, "yyyy-MM-dd")
const [exchangeRate, setExchangeRate] = useState(0) const [exchangeRate, setExchangeRate] = useState(0)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
@@ -46,7 +38,11 @@ export const FormConvertCurrency = ({
} }
fetchData() fetchData()
}, [originalCurrencyCode, targetCurrencyCode, format(normalizedDate, "LLLL-mm-dd")]) }, [originalCurrencyCode, targetCurrencyCode, normalizedDateString, originalTotal])
if (!originalTotal || !originalCurrencyCode || !targetCurrencyCode || originalCurrencyCode === targetCurrencyCode) {
return <></>
}
return ( return (
<div className="flex flex-row gap-2 items-center text-muted-foreground"> <div className="flex flex-row gap-2 items-center text-muted-foreground">

View File

@@ -117,7 +117,7 @@ export const FormDate = ({
if (!isNaN(newDate.getTime())) { if (!isNaN(newDate.getTime())) {
setDate(newDate) setDate(newDate)
} }
} catch (error) {} } catch (_) {}
} }
return ( return (

View File

@@ -23,10 +23,11 @@ export function ImportCSVTable({ fields }: { fields: Field[] }) {
useEffect(() => { useEffect(() => {
if (parseState?.success && parseState.data) { if (parseState?.success && parseState.data) {
setCSVData(parseState.data) const parsedData = parseState.data as string[][]
if (parseState.data.length > 0) { setCSVData(parsedData)
if (parsedData.length > 0) {
setColumnMappings( setColumnMappings(
parseState.data[0].map((value) => { parsedData[0].map((value) => {
const field = fields.find((field) => field.code === value || field.name === value) const field = fields.find((field) => field.code === value || field.name === value)
return field?.code || "" return field?.code || ""
}) })

View File

@@ -44,8 +44,8 @@ export default function TransactionCreateForm({
}) })
useEffect(() => { useEffect(() => {
if (createState?.success) { if (createState?.success && createState.data) {
router.push(`/transactions/${createState.transactionId}`) router.push(`/transactions/${createState.data.id}`)
} }
}, [createState, router]) }, [createState, router])

View File

@@ -8,7 +8,7 @@ export const settingsFormSchema = z.object({
default_project: z.string().optional(), default_project: z.string().optional(),
openai_api_key: z.string().optional(), openai_api_key: z.string().optional(),
prompt_analyse_new_file: z.string().optional(), prompt_analyse_new_file: z.string().optional(),
is_welcome_message_hidden: z.boolean().optional(), is_welcome_message_hidden: z.string().optional(),
}) })
export const currencyFormSchema = z.object({ export const currencyFormSchema = z.object({

5
lib/actions.ts Normal file
View File

@@ -0,0 +1,5 @@
export type ActionState<T> = {
success: boolean
error?: string | null
data?: T | null
}

View File

@@ -13,7 +13,7 @@ export const getSettings = cache(async (userId: string): Promise<SettingsMap> =>
}, {} as SettingsMap) }, {} as SettingsMap)
}) })
export const updateSettings = cache(async (userId: string, code: string, value: any) => { export const updateSettings = cache(async (userId: string, code: string, value: string | undefined) => {
return await prisma.setting.upsert({ return await prisma.setting.upsert({
where: { userId_code: { code, userId } }, where: { userId_code: { code, userId } },
update: { value }, update: { value },

151
package-lock.json generated
View File

@@ -20,7 +20,7 @@
"@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-select": "^2.1.6", "@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@sentry/nextjs": "^9.11.0", "@sentry/nextjs": "^9.11.0",
"@types/sharp": "^0.31.1", "@types/sharp": "^0.31.1",
@@ -2625,6 +2625,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": { "node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
@@ -2691,6 +2709,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": { "node_modules/@radix-ui/react-direction": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
@@ -2883,6 +2919,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover": { "node_modules/@radix-ui/react-popover": {
"version": "1.1.6", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz",
@@ -2920,6 +2974,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": { "node_modules/@radix-ui/react-popper": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz",
@@ -3023,6 +3095,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": { "node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz",
@@ -3097,6 +3187,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator": { "node_modules/@radix-ui/react-separator": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.2.tgz",
@@ -3121,12 +3229,12 @@
} }
}, },
"node_modules/@radix-ui/react-slot": { "node_modules/@radix-ui/react-slot": {
"version": "1.1.2", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-compose-refs": "1.1.1" "@radix-ui/react-compose-refs": "1.1.2"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -3138,6 +3246,21 @@
} }
} }
}, },
"node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": { "node_modules/@radix-ui/react-tooltip": {
"version": "1.1.8", "version": "1.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz",
@@ -3172,6 +3295,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": { "node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",

View File

@@ -22,7 +22,7 @@
"@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-select": "^2.1.6", "@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@sentry/nextjs": "^9.11.0", "@sentry/nextjs": "^9.11.0",
"@types/sharp": "^0.31.1", "@types/sharp": "^0.31.1",