diff --git a/.gitignore b/.gitignore index 9520006..e7972ce 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,5 @@ next-env.d.ts *.db *.sqlite *.sqlite3 +*.sqlite-journal +*.db-journal \ No newline at end of file diff --git a/README.md b/README.md index 776e01f..330280c 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,11 @@ A built-in system of powerful filters allows you to then export transactions wit > \[!NOTE] > -> TaxHacker is a single-user app. SaaS version will probably appear in the future if anyone is interested. Stay tuned for updates. +> TaxHacker is a single-user app. SaaS or Electron version will probably be developed in the future if anyone is interested. > \[!IMPORTANT] > -> This project is still at a very early stage. **Star Us** to receive new release notifications from GitHub ⭐️ +> This project is still at a very early stage. Use it at your own risk! **Star Us** to receive notifications about new bugfixes and features from GitHub ⭐️ ## ✨ Features @@ -45,10 +45,11 @@ A built-in system of powerful filters allows you to then export transactions wit Take a photo on upload or a PDF and TaxHacker will automatically recognise, categorise and store transaction information. -- Extracts key information like date, amount, and vendor -- Categorizes transactions based on content -- Stores everything in a structured format for easy filtering and retrieval -- Organizes documents for tax season +- Upload multiple documents and store in “unsorted” until you get the time to sort them out with AI +- Use LLM to extract key information like date, amount, and vendor +- Categorize transactions based on content +- Store everything in a structured format for easy filtering and retrieval +- Organize your documents by a tax season TaxHacker recognizes a wide variety of documents including store receipts, restaurant bills, invoices, bank checks, letters, even handwritten receipts. @@ -58,8 +59,8 @@ TaxHacker recognizes a wide variety of documents including store receipts, resta TaxHacker automatically converts foreign currencies and even knows the historical exchange rates on the invoice date. -- Automatic detection of different currencies -- Real-time currency conversion to your base currency +- Automatically detect currency in your documents +- Convert it to your base currency - Historical exchange rate lookup for past transactions - Support for over 170 world currencies and 14 popular cryptocurrencies (BTC, ETC, LTC, DOT, etc)! @@ -110,7 +111,7 @@ TaxHacker can be self-hosted on your own infrastructure for complete control ove Deploy your own instance of TaxHacker with Vercel in just a few clicks: -1. Prepare your OpenAI API Key for the AI features +1. Prepare your [OpenAI API Key](https://platform.openai.com/settings/organization/api-keys) for the AI features 2. Click the deploy button below 3. Configure your environment variables in the Vercel dashboard 4. (Optional) Connect your custom domain @@ -124,11 +125,10 @@ Deploy your own instance of TaxHacker with Vercel in just a few clicks: For server deployment, we provide a [Docker image](./Dockerfile) and [Docker Compose](./docker-compose.yml) files that makes setting up TaxHacker simple: ```bash -# Clone the repository -git clone https://github.com/vas3k/TaxHacker.git -cd TaxHacker +# Download docker-compose.yml file +curl -O https://raw.githubusercontent.com/vas3k/TaxHacker/main/docker-compose.yml -# Or use docker-compose (recommended) +# Run it docker compose up ``` diff --git a/app/page.tsx b/app/page.tsx index 71453ad..53625c7 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,9 +5,9 @@ import { WelcomeWidget } from "@/components/dashboard/welcome-widget" import { Separator } from "@/components/ui/separator" import { getUnsortedFiles } from "@/data/files" import { getSettings } from "@/data/settings" -import { StatsFilters } from "@/data/stats" +import { TransactionFilters } from "@/data/transactions" -export default async function Home({ searchParams }: { searchParams: Promise }) { +export default async function Home({ searchParams }: { searchParams: Promise }) { const filters = await searchParams const unsortedFiles = await getUnsortedFiles() const settings = await getSettings() diff --git a/app/settings/categories/page.tsx b/app/settings/categories/page.tsx index 3205906..592abfe 100644 --- a/app/settings/categories/page.tsx +++ b/app/settings/categories/page.tsx @@ -1,6 +1,7 @@ import { addCategoryAction, deleteCategoryAction, editCategoryAction } from "@/app/settings/actions" import { CrudTable } from "@/components/settings/crud" import { getCategories } from "@/data/categories" +import { randomHexColor } from "@/lib/utils" export default async function CategoriesSettingsPage() { const categories = await getCategories() @@ -18,7 +19,7 @@ export default async function CategoriesSettingsPage() { columns={[ { key: "name", label: "Name", editable: true }, { key: "llm_prompt", label: "LLM Prompt", editable: true }, - { key: "color", label: "Color", editable: true }, + { key: "color", label: "Color", defaultValue: randomHexColor(), editable: true }, ]} onDelete={async (code) => { "use server" diff --git a/app/settings/currencies/page.tsx b/app/settings/currencies/page.tsx index 7fd9692..47a1fdb 100644 --- a/app/settings/currencies/page.tsx +++ b/app/settings/currencies/page.tsx @@ -16,7 +16,7 @@ export default async function CurrenciesSettingsPage() { { diff --git a/app/settings/projects/page.tsx b/app/settings/projects/page.tsx index 10f0aaa..bf068ea 100644 --- a/app/settings/projects/page.tsx +++ b/app/settings/projects/page.tsx @@ -1,6 +1,7 @@ import { addProjectAction, deleteProjectAction, editProjectAction } from "@/app/settings/actions" import { CrudTable } from "@/components/settings/crud" import { getProjects } from "@/data/projects" +import { randomHexColor } from "@/lib/utils" export default async function ProjectsSettingsPage() { const projects = await getProjects() @@ -18,7 +19,7 @@ export default async function ProjectsSettingsPage() { columns={[ { key: "name", label: "Name", editable: true }, { key: "llm_prompt", label: "LLM Prompt", editable: true }, - { key: "color", label: "Color", editable: true }, + { key: "color", label: "Color", defaultValue: randomHexColor(), editable: true }, ]} onDelete={async (code) => { "use server" diff --git a/app/transactions/[transactionId]/page.tsx b/app/transactions/[transactionId]/page.tsx index 14cd445..56bf31d 100644 --- a/app/transactions/[transactionId]/page.tsx +++ b/app/transactions/[transactionId]/page.tsx @@ -26,9 +26,9 @@ export default async function TransactionPage({ params }: { params: Promise<{ tr const projects = await getProjects() return ( - <> - -
+
+ +
-
-
- + {transaction.text && ( +
+ Recognized Text + +
+ +
+
+
+ )}
- {transaction.text && ( - -
- -
-
- )} - +
+ +
+
) } diff --git a/app/transactions/actions.ts b/app/transactions/actions.ts index aa844c0..de0bc67 100644 --- a/app/transactions/actions.ts +++ b/app/transactions/actions.ts @@ -2,6 +2,7 @@ import { createFile, deleteFile } from "@/data/files" import { + bulkDeleteTransactions, createTransaction, deleteTransaction, getTransactionById, @@ -90,13 +91,13 @@ export async function deleteTransactionFileAction( return { success: true } } -export async function uploadTransactionFileAction(formData: FormData): Promise<{ success: boolean; error?: string }> { +export async function uploadTransactionFilesAction(formData: FormData): Promise<{ success: boolean; error?: string }> { try { const transactionId = formData.get("transactionId") as string - const file = formData.get("file") as File + const files = formData.getAll("files") as File[] - if (!file || !transactionId) { - return { success: false, error: "No file or transaction ID provided" } + if (!files || !transactionId) { + return { success: false, error: "No files or transaction ID provided" } } const transaction = await getTransactionById(transactionId) @@ -109,29 +110,36 @@ export async function uploadTransactionFileAction(formData: FormData): Promise<{ await mkdir(FILE_UPLOAD_PATH, { recursive: true }) } - // Save file to filesystem - const { fileUuid, filePath } = await getTransactionFileUploadPath(file.name, transaction) - const arrayBuffer = await file.arrayBuffer() - const buffer = Buffer.from(arrayBuffer) - await writeFile(filePath, buffer) + const fileRecords = await Promise.all( + files.map(async (file) => { + const { fileUuid, filePath } = await getTransactionFileUploadPath(file.name, transaction) + const arrayBuffer = await file.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + await writeFile(filePath, buffer) - // Create file record in database - const fileRecord = await createFile({ - id: fileUuid, - filename: file.name, - path: filePath, - mimetype: file.type, - isReviewed: true, - metadata: { - size: file.size, - lastModified: file.lastModified, - }, - }) + // Create file record in database + const fileRecord = await createFile({ + id: fileUuid, + filename: file.name, + path: filePath, + mimetype: file.type, + isReviewed: true, + metadata: { + size: file.size, + lastModified: file.lastModified, + }, + }) + + return fileRecord + }) + ) // Update invoice with the new file ID await updateTransactionFiles( transactionId, - transaction.files ? [...(transaction.files as string[]), fileRecord.id] : [fileRecord.id] + transaction.files + ? [...(transaction.files as string[]), ...fileRecords.map((file) => file.id)] + : fileRecords.map((file) => file.id) ) revalidatePath(`/transactions/${transactionId}`) @@ -141,3 +149,14 @@ export async function uploadTransactionFileAction(formData: FormData): Promise<{ return { success: false, error: `File upload failed: ${error}` } } } + +export async function bulkDeleteTransactionsAction(transactionIds: string[]) { + try { + await bulkDeleteTransactions(transactionIds) + revalidatePath("/transactions") + return { success: true } + } catch (error) { + console.error("Failed to delete transactions:", error) + return { success: false, error: "Failed to delete transactions" } + } +} diff --git a/app/transactions/page.tsx b/app/transactions/page.tsx index e91a97a..24922ce 100644 --- a/app/transactions/page.tsx +++ b/app/transactions/page.tsx @@ -28,7 +28,7 @@ export default async function TransactionsPage({ searchParams }: { searchParams:

Transactions

- + + + + {bulkActions.map((action) => ( + handleAction(action.id)} + className="gap-2" + disabled={isLoading} + > + + {action.label} + + ))} + + +
+ ) +} diff --git a/components/transactions/edit.tsx b/components/transactions/edit.tsx index 7271eb6..361a657 100644 --- a/components/transactions/edit.tsx +++ b/components/transactions/edit.tsx @@ -55,18 +55,13 @@ export default function TransactionEditForm({ const handleDelete = async () => { startTransition(async () => { await deleteAction(transaction.id) + router.back() }) } - useEffect(() => { - if (deleteState?.success) { - router.push("/transactions") - } - }, [deleteState, router]) - useEffect(() => { if (saveState?.success) { - router.push("/transactions") + router.back() } }, [saveState, router]) @@ -152,7 +147,7 @@ export default function TransactionEditForm({ ))}
- diff --git a/components/transactions/filters.tsx b/components/transactions/filters.tsx index c7d4fff..a21cb9e 100644 --- a/components/transactions/filters.tsx +++ b/components/transactions/filters.tsx @@ -4,22 +4,11 @@ import { DateRangePicker } from "@/components/forms/date-range-picker" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { TransactionFilters } from "@/data/transactions" +import { useTransactionFilters } from "@/hooks/use-transaction-filters" import { Category, Project } from "@prisma/client" -import { format } from "date-fns" -import { useRouter, useSearchParams } from "next/navigation" -import { useEffect, useState } from "react" export function TransactionSearchAndFilters({ categories, projects }: { categories: Category[]; projects: Project[] }) { - const searchParams = useSearchParams() - const router = useRouter() - - const [filters, setFilters] = useState({ - search: searchParams.get("search") || "", - dateFrom: searchParams.get("dateFrom") || "", - dateTo: searchParams.get("dateTo") || "", - categoryCode: searchParams.get("categoryCode") || "", - projectCode: searchParams.get("projectCode") || "", - }) + const [filters, setFilters] = useTransactionFilters() const handleFilterChange = (name: keyof TransactionFilters, value: any) => { setFilters((prev) => ({ @@ -28,45 +17,6 @@ export function TransactionSearchAndFilters({ categories, projects }: { categori })) } - const applyFilters = () => { - const params = new URLSearchParams(searchParams.toString()) - if (filters.search) { - params.set("search", filters.search) - } else { - params.delete("search") - } - - if (filters.dateFrom) { - params.set("dateFrom", format(new Date(filters.dateFrom), "yyyy-MM-dd")) - } else { - params.delete("dateFrom") - } - - if (filters.dateTo) { - params.set("dateTo", format(new Date(filters.dateTo), "yyyy-MM-dd")) - } else { - params.delete("dateTo") - } - - if (filters.categoryCode && filters.categoryCode !== "-") { - params.set("categoryCode", filters.categoryCode) - } else { - params.delete("categoryCode") - } - - if (filters.projectCode && filters.projectCode !== "-") { - params.set("projectCode", filters.projectCode) - } else { - params.delete("projectCode") - } - - router.push(`/transactions?${params.toString()}`) - } - - useEffect(() => { - applyFilters() - }, [filters]) - return (
diff --git a/components/transactions/list.tsx b/components/transactions/list.tsx index 71757bb..0b9ed9f 100644 --- a/components/transactions/list.tsx +++ b/components/transactions/list.tsx @@ -1,5 +1,6 @@ "use client" +import { BulkActionsMenu } from "@/components/transactions/bulk-actions" import { Badge } from "@/components/ui/badge" import { Checkbox } from "@/components/ui/checkbox" import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table" @@ -236,6 +237,9 @@ export function TransactionList({ transactions }: { transactions: Transaction[] + {selectedIds.length > 0 && ( + setSelectedIds([])} /> + )}
) } diff --git a/components/transactions/transaction-files.tsx b/components/transactions/transaction-files.tsx index cb54c87..7767493 100644 --- a/components/transactions/transaction-files.tsx +++ b/components/transactions/transaction-files.tsx @@ -1,12 +1,12 @@ "use client" -import { deleteTransactionFileAction, uploadTransactionFileAction } from "@/app/transactions/actions" +import { deleteTransactionFileAction, uploadTransactionFilesAction } from "@/app/transactions/actions" import { FilePreview } from "@/components/files/preview" import { Button } from "@/components/ui/button" import { Card } from "@/components/ui/card" import { FILE_ACCEPTED_MIMETYPES } from "@/lib/files" import { File, Transaction } from "@prisma/client" -import { Loader2, Upload } from "lucide-react" +import { Loader2, Upload, X } from "lucide-react" import { useState } from "react" export default function TransactionFiles({ transaction, files }: { transaction: Transaction; files: File[] }) { @@ -21,8 +21,10 @@ export default function TransactionFiles({ transaction, files }: { transaction: if (e.target.files && e.target.files.length > 0) { const formData = new FormData() formData.append("transactionId", transaction.id) - formData.append("file", e.target.files[0]) - await uploadTransactionFileAction(formData) + for (let i = 0; i < e.target.files.length; i++) { + formData.append("files", e.target.files[i]) + } + await uploadTransactionFilesAction(formData) setIsUploading(false) } } @@ -30,19 +32,24 @@ export default function TransactionFiles({ transaction, files }: { transaction: return ( <> {files.map((file) => ( - - - - + ))} - +