From 0b98a2c3072ed8e3c1e6fb0d43765dce2e3aea04 Mon Sep 17 00:00:00 2001 From: Vasily Zubarev Date: Thu, 13 Mar 2025 00:30:47 +0100 Subject: [PATCH] (squash) init feat: filters, settings, backups fix: ts compile errors feat: new dashboard, webp previews and settings feat: use webp for pdfs feat: use webp fix: analyze resets old data fix: switch to corsproxy fix: switch to free cors fix: max upload limit fix: currency conversion feat: transaction export fix: currency conversion feat: refactor settings actions feat: new loader feat: README + LICENSE doc: update readme doc: update readme doc: update readme doc: update screenshots ci: bump prisma --- .env.example | 20 + .gitignore | 51 + .prettierrc | 13 + Dockerfile | 59 + LICENSE | 21 + README.md | 213 + app/ai/analyze.ts | 139 + app/ai/prompt.ts | 49 + app/context.tsx | 32 + app/export/transactions/route.ts | 104 + app/files/actions.ts | 52 + app/files/download/[fileId]/route.ts | 41 + app/files/page.tsx | 10 + app/files/preview/[fileId]/route.ts | 66 + app/globals.css | 85 + app/layout.tsx | 56 + app/loading.tsx | 9 + app/page.tsx | 30 + app/settings/actions.ts | 131 + app/settings/backups/actions.ts | 21 + app/settings/backups/database/route.ts | 18 + app/settings/backups/files/route.ts | 52 + app/settings/backups/page.tsx | 58 + app/settings/backups/restore/route.ts | 24 + app/settings/categories/page.tsx | 52 + app/settings/currencies/page.tsx | 37 + app/settings/fields/page.tsx | 51 + app/settings/layout.tsx | 59 + app/settings/llm/page.tsx | 14 + app/settings/page.tsx | 18 + app/settings/projects/page.tsx | 38 + app/transactions/[transactionId]/layout.tsx | 28 + app/transactions/[transactionId]/loading.tsx | 7 + app/transactions/[transactionId]/page.tsx | 62 + app/transactions/actions.ts | 143 + app/transactions/layout.tsx | 3 + app/transactions/loading.tsx | 30 + app/transactions/page.tsx | 71 + app/unsorted/actions.ts | 63 + app/unsorted/layout.tsx | 3 + app/unsorted/page.tsx | 103 + components.json | 21 + components/dashboard/drop-zone-widget.tsx | 74 + components/dashboard/filters-widget.tsx | 54 + components/dashboard/projects-widget.tsx | 86 + components/dashboard/stats-widget.tsx | 86 + components/dashboard/unsorted-widget.tsx | 45 + components/dashboard/welcome-widget.tsx | 105 + components/export/transactions.tsx | 162 + components/files/preview.tsx | 53 + components/files/screen-drop-area.tsx | 134 + components/files/upload-button.tsx | 75 + components/forms/convert-currency.tsx | 77 + components/forms/date-range-picker.tsx | 129 + components/forms/select-category.tsx | 23 + components/forms/select-currency.tsx | 21 + components/forms/select-project.tsx | 21 + components/forms/select-type.tsx | 18 + components/forms/simple.tsx | 77 + components/settings/crud.tsx | 165 + components/settings/global-settings-form.tsx | 59 + components/settings/llm-settings-form.tsx | 38 + components/settings/side-nav.tsx | 35 + components/sidebar/blinker.tsx | 8 + components/sidebar/mobile-menu.tsx | 33 + components/sidebar/sidebar-item.tsx | 32 + components/sidebar/sidebar.tsx | 139 + components/transactions/create.tsx | 129 + components/transactions/edit.tsx | 175 + components/transactions/filters.tsx | 135 + components/transactions/list.tsx | 241 + components/transactions/new.tsx | 39 + components/transactions/transaction-files.tsx | 72 + components/ui/alert.tsx | 59 + components/ui/avatar.tsx | 50 + components/ui/badge.tsx | 36 + components/ui/breadcrumb.tsx | 115 + components/ui/button.tsx | 57 + components/ui/calendar.tsx | 76 + components/ui/card.tsx | 76 + components/ui/checkbox.tsx | 30 + components/ui/collapsible.tsx | 11 + components/ui/dialog.tsx | 122 + components/ui/dropdown-menu.tsx | 201 + components/ui/form.tsx | 178 + components/ui/input.tsx | 22 + components/ui/label.tsx | 26 + components/ui/popover.tsx | 33 + components/ui/select.tsx | 159 + components/ui/separator.tsx | 31 + components/ui/sheet.tsx | 140 + components/ui/sidebar.tsx | 640 ++ components/ui/skeleton.tsx | 15 + components/ui/sonner.tsx | 31 + components/ui/table.tsx | 120 + components/ui/textarea.tsx | 22 + components/ui/tooltip.tsx | 32 + components/unsorted/analyze-form.tsx | 288 + data/categories.ts | 34 + data/currencies.ts | 30 + data/export.ts | 5 + data/fields.ts | 30 + data/files.ts | 70 + data/projects.ts | 34 + data/settings.ts | 24 + data/stats.ts | 83 + data/transactions.ts | 147 + docker-compose.selfhosting.yml | 59 + docker-compose.yml | 23 + docker-entrypoint.sh | 16 + docs/screenshots/analyze.png | Bin 0 -> 593973 bytes docs/screenshots/categories.png | Bin 0 -> 123801 bytes docs/screenshots/currency_conversion.png | Bin 0 -> 200810 bytes docs/screenshots/dashboard.png | Bin 0 -> 277662 bytes docs/screenshots/export.png | Bin 0 -> 392921 bytes docs/screenshots/exported_archive.png | Bin 0 -> 289803 bytes docs/screenshots/fields.png | Bin 0 -> 138205 bytes docs/screenshots/title.png | Bin 0 -> 634055 bytes docs/screenshots/transactions.png | Bin 0 -> 322098 bytes eslint.config.mjs | 16 + forms/settings.ts | 11 + forms/transactions.ts | 49 + hooks/use-mobile.tsx | 19 + hooks/use-persistent-form-state.tsx | 22 + lib/currency-scraper.ts | 57 + lib/db.ts | 11 + lib/files.ts | 36 + lib/images.ts | 50 + lib/pdf.ts | 57 + lib/stats.ts | 13 + lib/utils.ts | 24 + next.config.ts | 15 + package-lock.json | 8200 +++++++++++++++++ package.json | 65 + postcss.config.mjs | 8 + .../20250312231916_init/migration.sql | 84 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 105 + prisma/seed.ts | 490 + public/android-chrome-192x192.png | Bin 0 -> 80019 bytes public/android-chrome-512x512.png | Bin 0 -> 413973 bytes public/apple-touch-icon.png | Bin 0 -> 71533 bytes public/favicon-16x16.png | Bin 0 -> 1013 bytes public/favicon-32x32.png | Bin 0 -> 3427 bytes public/favicon.ico | Bin 0 -> 15406 bytes public/logo/1024.png | Bin 0 -> 1018899 bytes public/logo/256.png | Bin 0 -> 114528 bytes public/logo/512.png | Bin 0 -> 340942 bytes public/logo/logo.svg | 14 + public/macos-dock-icon.png | Bin 0 -> 344482 bytes public/site.webmanifest | 1 + tailwind.config.ts | 72 + tsconfig.json | 27 + 153 files changed, 17271 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/ai/analyze.ts create mode 100644 app/ai/prompt.ts create mode 100644 app/context.tsx create mode 100644 app/export/transactions/route.ts create mode 100644 app/files/actions.ts create mode 100644 app/files/download/[fileId]/route.ts create mode 100644 app/files/page.tsx create mode 100644 app/files/preview/[fileId]/route.ts create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/loading.tsx create mode 100644 app/page.tsx create mode 100644 app/settings/actions.ts create mode 100644 app/settings/backups/actions.ts create mode 100644 app/settings/backups/database/route.ts create mode 100644 app/settings/backups/files/route.ts create mode 100644 app/settings/backups/page.tsx create mode 100644 app/settings/backups/restore/route.ts create mode 100644 app/settings/categories/page.tsx create mode 100644 app/settings/currencies/page.tsx create mode 100644 app/settings/fields/page.tsx create mode 100644 app/settings/layout.tsx create mode 100644 app/settings/llm/page.tsx create mode 100644 app/settings/page.tsx create mode 100644 app/settings/projects/page.tsx create mode 100644 app/transactions/[transactionId]/layout.tsx create mode 100644 app/transactions/[transactionId]/loading.tsx create mode 100644 app/transactions/[transactionId]/page.tsx create mode 100644 app/transactions/actions.ts create mode 100644 app/transactions/layout.tsx create mode 100644 app/transactions/loading.tsx create mode 100644 app/transactions/page.tsx create mode 100644 app/unsorted/actions.ts create mode 100644 app/unsorted/layout.tsx create mode 100644 app/unsorted/page.tsx create mode 100644 components.json create mode 100644 components/dashboard/drop-zone-widget.tsx create mode 100644 components/dashboard/filters-widget.tsx create mode 100644 components/dashboard/projects-widget.tsx create mode 100644 components/dashboard/stats-widget.tsx create mode 100644 components/dashboard/unsorted-widget.tsx create mode 100644 components/dashboard/welcome-widget.tsx create mode 100644 components/export/transactions.tsx create mode 100644 components/files/preview.tsx create mode 100644 components/files/screen-drop-area.tsx create mode 100644 components/files/upload-button.tsx create mode 100644 components/forms/convert-currency.tsx create mode 100644 components/forms/date-range-picker.tsx create mode 100644 components/forms/select-category.tsx create mode 100644 components/forms/select-currency.tsx create mode 100644 components/forms/select-project.tsx create mode 100644 components/forms/select-type.tsx create mode 100644 components/forms/simple.tsx create mode 100644 components/settings/crud.tsx create mode 100644 components/settings/global-settings-form.tsx create mode 100644 components/settings/llm-settings-form.tsx create mode 100644 components/settings/side-nav.tsx create mode 100644 components/sidebar/blinker.tsx create mode 100644 components/sidebar/mobile-menu.tsx create mode 100644 components/sidebar/sidebar-item.tsx create mode 100644 components/sidebar/sidebar.tsx create mode 100644 components/transactions/create.tsx create mode 100644 components/transactions/edit.tsx create mode 100644 components/transactions/filters.tsx create mode 100644 components/transactions/list.tsx create mode 100644 components/transactions/new.tsx create mode 100644 components/transactions/transaction-files.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/breadcrumb.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/calendar.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/collapsible.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/form.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/popover.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/sidebar.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 components/unsorted/analyze-form.tsx create mode 100644 data/categories.ts create mode 100644 data/currencies.ts create mode 100644 data/export.ts create mode 100644 data/fields.ts create mode 100644 data/files.ts create mode 100644 data/projects.ts create mode 100644 data/settings.ts create mode 100644 data/stats.ts create mode 100644 data/transactions.ts create mode 100644 docker-compose.selfhosting.yml create mode 100644 docker-compose.yml create mode 100644 docker-entrypoint.sh create mode 100644 docs/screenshots/analyze.png create mode 100644 docs/screenshots/categories.png create mode 100644 docs/screenshots/currency_conversion.png create mode 100644 docs/screenshots/dashboard.png create mode 100644 docs/screenshots/export.png create mode 100644 docs/screenshots/exported_archive.png create mode 100644 docs/screenshots/fields.png create mode 100644 docs/screenshots/title.png create mode 100644 docs/screenshots/transactions.png create mode 100644 eslint.config.mjs create mode 100644 forms/settings.ts create mode 100644 forms/transactions.ts create mode 100644 hooks/use-mobile.tsx create mode 100644 hooks/use-persistent-form-state.tsx create mode 100644 lib/currency-scraper.ts create mode 100644 lib/db.ts create mode 100644 lib/files.ts create mode 100644 lib/images.ts create mode 100644 lib/pdf.ts create mode 100644 lib/stats.ts create mode 100644 lib/utils.ts create mode 100644 next.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 prisma/migrations/20250312231916_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.ts create mode 100644 public/android-chrome-192x192.png create mode 100644 public/android-chrome-512x512.png create mode 100644 public/apple-touch-icon.png create mode 100644 public/favicon-16x16.png create mode 100644 public/favicon-32x32.png create mode 100644 public/favicon.ico create mode 100644 public/logo/1024.png create mode 100644 public/logo/256.png create mode 100644 public/logo/512.png create mode 100644 public/logo/logo.svg create mode 100644 public/macos-dock-icon.png create mode 100644 public/site.webmanifest create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..eb663ee --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +UPLOAD_PATH="./uploads" +DATABASE_URL="file:./db.sqlite" +PROMPT_ANALYSE_NEW_FILE="You are an accountant and invoice analysis assistant. +Extract the following information from the given invoice: + +{fields} + +Where categories are: + +{categories} + +And projects are: + +{projects} + +If you can't find something leave it blank. Return only valid JSON with these fields: + +{json_structure} + +Do not include any other text in your response!" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9520006 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# uploads +/uploads/* + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build +/dist +/release + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# sqlite +*.db +*.sqlite +*.sqlite3 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..b021180 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,13 @@ +{ + "tabWidth": 2, + "useTabs": false, + "semi": false, + "singleQuote": false, + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "trailingComma": "es5", + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "always", + "printWidth": 120 +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ef43379 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,59 @@ +# Build stage +FROM node:23-slim AS builder + +# Install dependencies required for Prisma +RUN apt-get update && apt-get install -y openssl + +WORKDIR /app + +# Copy package files +COPY package*.json ./ +COPY prisma ./prisma/ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Generate Prisma client +RUN npx prisma generate + +# Build the application +RUN npm run build + +# Production stage +FROM node:23-slim + +# Install required system dependencies +RUN apt-get update && apt-get install -y \ + ghostscript \ + graphicsmagick \ + openssl \ + libwebp-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Create upload directory and set permissions +RUN mkdir -p /app/upload && chown -R node:node /app/upload + +# Copy built assets from builder +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package*.json ./ +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public +COPY --from=builder /app/next.config.ts ./ + +# Copy and set up entrypoint script +COPY docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Create directory for SQLite database and set permissions +RUN mkdir -p /app/data && chown -R node:node /app/data + +EXPOSE 3000 + +ENTRYPOINT ["docker-entrypoint.sh"] +CMD ["npm", "start"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0d192e4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Vasily Zubarev, me@vas3k.ru + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b75c30 --- /dev/null +++ b/README.md @@ -0,0 +1,213 @@ +
+ + + +# TaxHacker + +I'm a small self-hosted accountant app that can help you deal with invoices, receipts and taxes with power of GenAI.

+ +[![GitHub Stars](https://img.shields.io/github/stars/vas3k/TaxHacker?color=ffcb47&labelColor=black&style=flat-square)](https://github.com/vas3k/TaxHacker/stargazers) +[![License](https://img.shields.io/badge/license-MIT-ffcb47?labelColor=black&style=flat-square)](https://github.com/vas3k/TaxHacker/blob/main/LICENSE) +[![GitHub Issues](https://img.shields.io/github/issues/vas3k/TaxHacker?color=ff80eb&labelColor=black&style=flat-square)](https://github.com/vas3k/TaxHacker/issues) +[![Donate](https://img.shields.io/badge/-Donate-f04f88?logo=githubsponsors&logoColor=white&style=flat-square)](https://vas3k.com/donate/) + +**Share TaxHacker** + +[![Share on X](https://img.shields.io/badge/-share%20on%20x-black?labelColor=black&logo=x&logoColor=white&style=flat-square)](https://x.com/intent/tweet?text=Check%20out%20TaxHacker%20-%20an%20AI-powered%20assistant%20that%20helps%20you%20manage%20receipts%2C%20checks%2C%20and%20invoices%20with%20ease.&url=https%3A%2F%2Fgithub.com%2Fvas3k%2FTaxHacker) +[![Share on LinkedIn](https://img.shields.io/badge/-share%20on%20linkedin-black?labelColor=black&logo=linkedin&logoColor=white&style=flat-square)](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fgithub.com%2Fvas3k%2FTaxHacker) +[![Share on Reddit](https://img.shields.io/badge/-share%20on%20reddit-black?labelColor=black&logo=reddit&logoColor=white&style=flat-square)](https://www.reddit.com/submit?title=Check%20out%20TaxHacker%20-%20an%20AI-powered%20assistant%20that%20helps%20you%20manage%20receipts%2C%20checks%2C%20and%20invoices%20with%20ease.&url=https%3A%2F%2Fgithub.com%2Fvas3k%2FTaxHacker) + +
+ +## 👋🏻 Getting Started + +TaxHacker is a self-hosted accounting app for freelancers and small businesses who want to save time and automate tracking expences and income with power of GenAI. It can recognise uploaded photos or PDF files and automatically extract important transaction data: name, total amount, date, category, VAT amount, etc, and save it in a table in a structured way. + +Automatic currency conversion on a day of transaction is also supported (even for crypto). TaxHacker can save you time filling endless Excel spreadsheets with expences and income. + +A built-in system of powerful filters allows you to then export transactions with their files in the specified period of time for tax filing or other reporting. + +![Dashboard](docs/screenshots/title.png) + +> \[!NOTE] +> +> TaxHacker is a single-user app. SaaS version will probably appear in the future if anyone is interested. Stay tuned for updates. + +> \[!IMPORTANT] +> +> **Star Us** to receive all release notifications from GitHub without any delay! ⭐️ + +## ✨ Features + +### `1` Upload photos or documents to analyze with LLM + +![Analyze with AI](docs/screenshots/analyze.png) + +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 + +TaxHacker recognizes a wide variety of documents including store receipts, restaurant bills, invoices, bank checks, letters, even handwritten receipts. + +### `2` Multi-currency support with automatic conversion (even for crypto) + +![Currency Conversion](docs/screenshots/currency_conversion.png) + +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 +- Historical exchange rate lookup for past transactions +- Support for over 170 world currencies and 14 popular cryptocurrencies (BTC, ETC, LTC, DOT, etc)! + +### `3` Customize any LLM prompt + +![Transactions Table](docs/screenshots/transactions.png) + +You can customize LLM Prompts for built-in fields, categories, and projects, as well as modify global templates in the application settings. This allows to customize the quality of recognizing specific things to your specific use-cases. + +- General prompt template is configurable is settings +- Create custom extraction rules for your specific needs +- Adjust field extraction priorities and naming conventions +- Fine-tune the AI for your industry-specific documents + +The whole extraction process is under your contoll all the time! + +### `4` Create custom fields, projects, categories + +![Custom Categories](docs/screenshots/fields.png) + +Adapt TaxHacker to your specific tracking needs. You can create new fields, projects or categories to extract additional information from documents. For example, if you need to save emails, addresses, and any custom information into separate fields, you can do it. Custom fields will be available when exporting too. + +- Create unlimited custom fields for transaction tracking +- Automatically extract custom field data using AI +- Include custom fields in exports and reports +- Create new categories or projects to organise your transactions and filter by them + +### `5` Flexible data filtering and export + +![Data Export](docs/screenshots/export.png) + +Once all documents have been uploaded and analyzed, you can view, filter and export your transaction history. + +- Filter transactions by time, category, and other features +- Use full-text search by recognized document content +- Export filtered transactions to CSV with attached documents +- Upload your entire income and expense history at the end of the year for your tax advisor to analyze + +### `6` Local data storage and self-hosting + +![Self-hosting](docs/screenshots/exported_archive.png) + +## 🛳 Deploying or Self-hosting + +TaxHacker can be self-hosted on your own infrastructure for complete control over your data and application environment. If you don't have your own server, you can use Vercel to quickly deploy the app just for yourself. + +### `A` Deploying with Vercel + +Deploy your own instance of TaxHacker with Vercel in just a few clicks: + +1. Prepare your OpenAI API Key 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 + +
+ +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvas3k%2FTaxHacker&project-name=TaxHacker&repository-name=TaxHacker) + +### `B` Deploying with Docker + +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 + +# Run docker compose +docker compose up +``` + +For more advanced setups, you can adapt Docker Compose configuration to your own needs. + +### Environment Variables + +Configure TaxHacker to suit your needs with these environment variables: + +| Variable | Required | Description | Example | +|----------|----------|-------------|---------| +| `UPLOAD_PATH` | Yes | Local directory for uploading files | `./upload` | +| `DATABASE_URL` | Yes | Database file for SQLite | `file:./db.sqlite` | +| `PROMPT_ANALYSE_NEW_FILE` | No | Default prompt for LLM | `Act as an accountant...` | + +## ⌨️ Local Development + +We use: + +- Next.js version 15+ or later +- [Prisma](https://www.prisma.io/) for database ORM and migrations +- SQLite as a database +- Ghostscript and graphicsmagick libs for PDF files (can be installed on macOS via `brew install gs graphicsmagick`) + +Set up a local development environment with these steps: + +```bash +# Clone the repository +git clone https://github.com/vas3k/TaxHacker.git +cd TaxHacker + +# Install dependencies +npm install + +# Set up environment variables +cp .env.example .env +# Edit .env with your configuration + +# Generate Prisma client +npx prisma generate + +# Seed the database (optional) +npm run seed + +# Start the development server with Turbopack +npm run dev +``` + +Visit `http://localhost:3000` to see your local instance of TaxHacker. + +For a production build: + +```bash +# Build the application +npm run build + +# Start the production server +npm run start +``` + +## 🤝 Contributing + +Contributions to TaxHacker are welcome and appreciated! Here's how you can help: + +- **Bug Reports**: File detailed issues when you encounter problems +- **Feature Requests**: Share your ideas for new features +- **Code Contributions**: Submit pull requests to improve the application +- **Documentation**: Help improve documentation + +All work is done on GitHub through issues and pull requests. + +[![PRs Welcome](https://img.shields.io/badge/🤯_PRs-welcome-ffcb47?labelColor=black&style=for-the-badge)](https://github.com/vas3k/TaxHacker/pulls) + +## ❤️ Donate + +If TaxHacker has helped you - help us in return! You donations will support maintainance and development. If you find this project valuable for your personal or business use, consider making a donation. + +[![Donate to TaxHacker developers](https://img.shields.io/badge/❤️-donate%20to%20Taxhacker%20devs-f08080?labelColor=black&style=for-the-badge)](https://vas3k.com/donate/) + +## 📄 License + +TaxHacker is licensed under the MIT License - see the [LICENSE](https://github.com/vas3k/TaxHacker/blob/main/LICENSE) file for details. diff --git a/app/ai/analyze.ts b/app/ai/analyze.ts new file mode 100644 index 0000000..84add0c --- /dev/null +++ b/app/ai/analyze.ts @@ -0,0 +1,139 @@ +import { Category, Field, File, Project } from "@prisma/client" +import OpenAI from "openai" +import { ChatCompletion } from "openai/resources/index.mjs" +import { buildLLMPrompt } from "./prompt" + +const MAX_PAGES_TO_ANALYZE = 3 + +type AnalyzeAttachment = { + contentType: string + base64: string +} + +export const retrieveAllAttachmentsForAI = async (file: File): Promise => { + const attachments: AnalyzeAttachment[] = [] + for (let i = 1; i < MAX_PAGES_TO_ANALYZE; i++) { + try { + const attachment = await retrieveFileContentForAI(file, i) + attachments.push(attachment) + } catch (error) { + break + } + } + + return attachments +} + +export const retrieveFileContentForAI = async (file: File, page: number): Promise => { + const response = await fetch(`/files/preview/${file.id}?page=${page}`) + if (!response.ok) throw new Error("Failed to retrieve file") + + const blob = await response.blob() + const buffer = await blob.arrayBuffer() + const base64 = Buffer.from(buffer).toString("base64") + + return { contentType: response.headers.get("Content-Type") || file.mimetype, base64: base64 } +} + +export async function analyzeTransaction( + promptTemplate: string, + settings: Record, + fields: Field[], + categories: Category[] = [], + projects: Project[] = [], + attachments: AnalyzeAttachment[] = [] +): Promise<{ success: boolean; data?: Record; error?: string }> { + const openai = new OpenAI({ + apiKey: settings.openai_api_key, + dangerouslyAllowBrowser: true, + }) + + const prompt = buildLLMPrompt(promptTemplate, fields, categories, projects) + + console.log("PROMPT:", prompt) + + try { + const response = await openai.chat.completions.create({ + model: "gpt-4o-mini", + messages: [ + { + role: "user", + content: [ + { type: "text", text: prompt || "" }, + ...attachments.slice(0, MAX_PAGES_TO_ANALYZE).map((attachment) => ({ + type: "image_url" as const, + image_url: { + url: `data:${attachment.contentType};base64,${attachment.base64}`, + }, + })), + ], + }, + ], + }) + + console.log("ChatGPT response:", response.choices[0].message) + + const cleanedJson = extractAndParseJSON(response) + + return { success: true, data: cleanedJson } + } catch (error) { + console.error("AI Analysis error:", error) + return { + success: false, + error: error instanceof Error ? error.message : "Failed to analyze invoice", + } + } +} + +function extractAndParseJSON(response: ChatCompletion) { + try { + const content = response.choices?.[0]?.message?.content + + if (!content) { + throw new Error("No response content from AI") + } + + // Check for JSON in code blocks (handles ```json, ``` json, or just ```) + let jsonText = content.trim() + const codeBlockRegex = /```(?:json)?\s*([\s\S]*?)\s*```/ + const jsonMatch = content.match(codeBlockRegex) + + if (jsonMatch && jsonMatch[1]) { + jsonText = jsonMatch[1].trim() + } + + // Try to parse the JSON + try { + return JSON.parse(jsonText) + } catch (parseError) { + // JSON might have unescaped characters, try to fix them + const fixedJsonText = escapeJsonString(jsonText) + return JSON.parse(fixedJsonText) + } + } catch (error) { + console.error("Error processing AI response:", error) + throw new Error(`Failed to extract valid JSON: ${error instanceof Error ? error.message : "Unknown error"}`) + } +} + +function escapeJsonString(jsonStr: string) { + // This is a black magic to fix some AI-generated JSONs + if (jsonStr.trim().startsWith("{") && jsonStr.trim().endsWith("}")) { + return jsonStr.replace(/"([^"]*?)":(\s*)"(.*?)"/g, (match, key, space, value) => { + const escapedValue = value + .replace(/\\/g, "\\\\") // backslash + .replace(/"/g, '\\"') // double quotes + .replace(/\n/g, "\\n") // newline + .replace(/\r/g, "\\r") // carriage return + .replace(/\t/g, "\\t") // tab + .replace(/\f/g, "\\f") // form feed + .replace(/[\x00-\x1F\x7F-\x9F]/g, (c: string) => { + return "\\u" + ("0000" + c.charCodeAt(0).toString(16)).slice(-4) + }) + + return `"${key}":${space}"${escapedValue}"` + }) + } + + return jsonStr +} diff --git a/app/ai/prompt.ts b/app/ai/prompt.ts new file mode 100644 index 0000000..a2df3cd --- /dev/null +++ b/app/ai/prompt.ts @@ -0,0 +1,49 @@ +import { Category, Field, Project } from "@prisma/client" + +export function buildLLMPrompt( + promptTemplate: string, + fields: Field[], + categories: Category[] = [], + projects: Project[] = [] +) { + let prompt = promptTemplate + + prompt = prompt.replace( + "{fields}", + fields + .filter((field) => field.llm_prompt) + .map((field) => `- ${field.code}: ${field.llm_prompt}`) + .join("\n") + ) + + prompt = prompt.replace( + "{categories}", + categories + .filter((category) => category.llm_prompt) + .map((category) => `- ${category.code}: for ${category.llm_prompt}`) + .join("\n") + ) + + prompt = prompt.replace( + "{projects}", + projects + .filter((project) => project.llm_prompt) + .map((project) => `- ${project.code}: for ${project.llm_prompt}`) + .join("\n") + ) + + prompt = prompt.replace("{categories.code}", categories.map((category) => `${category.code}`).join(", ")) + prompt = prompt.replace("{projects.code}", projects.map((project) => `${project.code}`).join(", ")) + + prompt = prompt.replace( + "{json_structure}", + "{ " + + fields + .filter((field) => field.llm_prompt) + .map((field) => `${field.code}: ${field.type}`) + .join(", ") + + " }" + ) + + return prompt +} diff --git a/app/context.tsx b/app/context.tsx new file mode 100644 index 0000000..ad47fed --- /dev/null +++ b/app/context.tsx @@ -0,0 +1,32 @@ +"use client" + +import { createContext, ReactNode, useContext, useState } from "react" + +type Notification = { + code: string + message: string +} + +type NotificationContextType = { + notification: Notification | null + showNotification: (notification: Notification) => void +} + +const NotificationContext = createContext({ + notification: null, + showNotification: () => {}, +}) + +export function NotificationProvider({ children }: { children: ReactNode }) { + const [notification, setNotification] = useState(null) + + const showNotification = (notification: Notification) => { + setNotification(notification) + } + + return ( + {children} + ) +} + +export const useNotification = () => useContext(NotificationContext) diff --git a/app/export/transactions/route.ts b/app/export/transactions/route.ts new file mode 100644 index 0000000..772fc19 --- /dev/null +++ b/app/export/transactions/route.ts @@ -0,0 +1,104 @@ +import { ExportFields, ExportFilters } from "@/data/export" +import { getFields } from "@/data/fields" +import { getFilesByTransactionId } from "@/data/files" +import { getTransactions } from "@/data/transactions" +import { format } from "@fast-csv/format" +import { formatDate } from "date-fns" +import fs from "fs" +import JSZip from "jszip" +import { NextResponse } from "next/server" +import path from "path" + +export async function GET(request: Request) { + const url = new URL(request.url) + const filters = Object.fromEntries(url.searchParams.entries()) as ExportFilters + const fields = (url.searchParams.get("fields")?.split(",") ?? []) as ExportFields + const includeAttachments = url.searchParams.get("includeAttachments") === "true" + + const transactions = await getTransactions(filters) + const existingFields = await getFields() + + try { + const fieldKeys = fields.filter((field) => existingFields.some((f) => f.code === field)) + const writeHeaders = fieldKeys.map((field) => existingFields.find((f) => f.code === field)?.name) + + // Generate CSV file with all transactions + let csvContent = "" + const csvStream = format({ headers: fieldKeys, writeBOM: true, writeHeaders: false }) + + csvStream.on("data", (chunk) => { + csvContent += chunk + }) + + csvStream.write(writeHeaders) + transactions.forEach((transaction) => { + const row = fieldKeys.reduce((acc, key) => { + acc[key] = transaction[key as keyof typeof transaction] ?? "" + return acc + }, {} as Record) + + csvStream.write(row) + }) + csvStream.end() + + // Wait for CSV generation to complete + await new Promise((resolve) => csvStream.on("end", resolve)) + + if (!includeAttachments) { + return new NextResponse(csvContent, { + headers: { + "Content-Type": "text/csv", + "Content-Disposition": `attachment; filename="transactions.csv"`, + }, + }) + } + + // If includeAttachments is true, create a ZIP file with the CSV and attachments + const zip = new JSZip() + zip.file("transactions.csv", csvContent) + + const filesFolder = zip.folder("files") + if (!filesFolder) { + console.error("Failed to create zip folder") + return new NextResponse("Internal Server Error", { status: 500 }) + } + + for (const transaction of transactions) { + const transactionFiles = await getFilesByTransactionId(transaction.id) + + const transactionFolder = filesFolder.folder( + path.join( + transaction.issuedAt ? formatDate(transaction.issuedAt, "yyyy/MM") : "", + transactionFiles.length > 1 ? transaction.name || transaction.id : "" + ) + ) + if (!transactionFolder) { + console.error(`Failed to create transaction folder for ${transaction.name}`) + continue + } + + for (const file of transactionFiles) { + const fileData = fs.readFileSync(file.path) + const fileExtension = path.extname(file.path) + transactionFolder.file( + `${formatDate(transaction.issuedAt || new Date(), "yyyy-MM-dd")} - ${ + transaction.name || transaction.id + }${fileExtension}`, + fileData + ) + } + } + + const zipContent = await zip.generateAsync({ type: "uint8array" }) + + return new NextResponse(zipContent, { + headers: { + "Content-Type": "application/zip", + "Content-Disposition": `attachment; filename="transactions.zip"`, + }, + }) + } catch (error) { + console.error("Error exporting transactions:", error) + return new NextResponse("Internal Server Error", { status: 500 }) + } +} diff --git a/app/files/actions.ts b/app/files/actions.ts new file mode 100644 index 0000000..679f7df --- /dev/null +++ b/app/files/actions.ts @@ -0,0 +1,52 @@ +"use server" + +import { createFile } from "@/data/files" +import { FILE_UNSORTED_UPLOAD_PATH, getUnsortedFileUploadPath } from "@/lib/files" +import { existsSync } from "fs" +import { mkdir, writeFile } from "fs/promises" +import { revalidatePath } from "next/cache" + +export async function uploadFilesAction(prevState: any, formData: FormData) { + const files = formData.getAll("files") + + // Make sure upload dir exists + if (!existsSync(FILE_UNSORTED_UPLOAD_PATH)) { + await mkdir(FILE_UNSORTED_UPLOAD_PATH, { recursive: true }) + } + + // Process each file + const uploadedFiles = await Promise.all( + files.map(async (file) => { + if (!(file instanceof File)) { + return { success: false, error: "Invalid file" } + } + + // Save file to filesystem + const { fileUuid, filePath } = await getUnsortedFileUploadPath(file.name) + 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, + metadata: { + size: file.size, + lastModified: file.lastModified, + }, + }) + + return fileRecord + }) + ) + + console.log("uploadedFiles", uploadedFiles) + + revalidatePath("/unsorted") + + return { success: true, error: null } +} diff --git a/app/files/download/[fileId]/route.ts b/app/files/download/[fileId]/route.ts new file mode 100644 index 0000000..88d3167 --- /dev/null +++ b/app/files/download/[fileId]/route.ts @@ -0,0 +1,41 @@ +import { getFileById } from "@/data/files" +import fs from "fs/promises" +import { NextResponse } from "next/server" + +export async function GET(request: Request, { params }: { params: Promise<{ fileId: string }> }) { + const { fileId } = await params + + if (!fileId) { + return new NextResponse("No fileId provided", { status: 400 }) + } + + try { + // Find file in database + const file = await getFileById(fileId) + + if (!file) { + return new NextResponse("File not found", { status: 404 }) + } + + // Check if file exists + try { + await fs.access(file.path) + } catch { + return new NextResponse("File not found on disk", { status: 404 }) + } + + // Read file + const fileBuffer = await fs.readFile(file.path) + + // Return file with proper content type + return new NextResponse(fileBuffer, { + headers: { + "Content-Type": file.mimetype, + "Content-Disposition": `attachment; filename="${file.filename}"`, + }, + }) + } catch (error) { + console.error("Error serving file:", error) + return new NextResponse("Internal Server Error", { status: 500 }) + } +} diff --git a/app/files/page.tsx b/app/files/page.tsx new file mode 100644 index 0000000..7c39827 --- /dev/null +++ b/app/files/page.tsx @@ -0,0 +1,10 @@ +import { Metadata } from "next" +import { notFound } from "next/navigation" + +export const metadata: Metadata = { + title: "Uploading...", +} + +export default function UploadStatusPage() { + notFound() +} diff --git a/app/files/preview/[fileId]/route.ts b/app/files/preview/[fileId]/route.ts new file mode 100644 index 0000000..3939159 --- /dev/null +++ b/app/files/preview/[fileId]/route.ts @@ -0,0 +1,66 @@ +import { getFileById } from "@/data/files" +import { resizeImage } from "@/lib/images" +import { pdfToImages } from "@/lib/pdf" +import fs from "fs/promises" +import { NextResponse } from "next/server" +import path from "path" + +export async function GET(request: Request, { params }: { params: Promise<{ fileId: string }> }) { + const { fileId } = await params + + if (!fileId) { + return new NextResponse("No fileId provided", { status: 400 }) + } + + const url = new URL(request.url) + const page = parseInt(url.searchParams.get("page") || "1", 10) + + try { + // Find file in database + const file = await getFileById(fileId) + + if (!file) { + return new NextResponse("File not found", { status: 404 }) + } + + // Check if file exists + try { + await fs.access(file.path) + } catch { + return new NextResponse("File not found on disk", { status: 404 }) + } + + let previewPath = file.path + let previewType = file.mimetype + + if (file.mimetype === "application/pdf") { + const { contentType, pages } = await pdfToImages(file.path) + if (page > pages.length) { + return new NextResponse("Page not found", { status: 404 }) + } + previewPath = pages[page - 1] || file.path + previewType = contentType + } else if (file.mimetype.startsWith("image/")) { + const { contentType, resizedPath } = await resizeImage(file.path) + previewPath = resizedPath + previewType = contentType + } else { + previewPath = file.path + previewType = file.mimetype + } + + // Read filex + const fileBuffer = await fs.readFile(previewPath) + + // Return file with proper content type + return new NextResponse(fileBuffer, { + headers: { + "Content-Type": previewType, + "Content-Disposition": `inline; filename="${path.basename(previewPath)}"`, + }, + }) + } catch (error) { + console.error("Error serving file:", error) + return new NextResponse("Internal Server Error", { status: 500 }) + } +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..c613386 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,85 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 10% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; + } + + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; + } +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..decdb2a --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,56 @@ +import ScreenDropArea from "@/components/files/screen-drop-area" +import MobileMenu from "@/components/sidebar/mobile-menu" +import { AppSidebar } from "@/components/sidebar/sidebar" +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" +import { Toaster } from "@/components/ui/sonner" +import { getUnsortedFilesCount } from "@/data/files" +import { getSettings } from "@/data/settings" +import type { Metadata, Viewport } from "next" +import { NotificationProvider } from "./context" +import "./globals.css" + +export const metadata: Metadata = { + title: { + template: "%s | TaxHacker", + default: "TaxHacker", + }, + description: "Your personal AI accountant", + icons: { + icon: "/favicon.ico", + shortcut: "/favicon.ico", + apple: "/apple-touch-icon.png", + }, + manifest: "/site.webmanifest", +} + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, +} + +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const unsortedFilesCount = await getUnsortedFilesCount() + const settings = await getSettings() + + return ( + + + + + + + + + + + {children} + + + + + + + ) +} + +export const dynamic = "force-dynamic" diff --git a/app/loading.tsx b/app/loading.tsx new file mode 100644 index 0000000..9a10c6b --- /dev/null +++ b/app/loading.tsx @@ -0,0 +1,9 @@ +import { Loader2 } from "lucide-react" + +export default function AppLoading() { + return ( +
+ +
+ ) +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..71453ad --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,30 @@ +import DashboardDropZoneWidget from "@/components/dashboard/drop-zone-widget" +import { StatsWidget } from "@/components/dashboard/stats-widget" +import DashboardUnsortedWidget from "@/components/dashboard/unsorted-widget" +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" + +export default async function Home({ searchParams }: { searchParams: Promise }) { + const filters = await searchParams + const unsortedFiles = await getUnsortedFiles() + const settings = await getSettings() + + return ( +
+
+ + + +
+ + {!settings.is_welcome_message_hidden && } + + + + +
+ ) +} diff --git a/app/settings/actions.ts b/app/settings/actions.ts new file mode 100644 index 0000000..341968a --- /dev/null +++ b/app/settings/actions.ts @@ -0,0 +1,131 @@ +"use server" + +import { createCategory, deleteCategory, updateCategory } from "@/data/categories" +import { createCurrency, deleteCurrency, updateCurrency } from "@/data/currencies" +import { createField, deleteField, updateField } from "@/data/fields" +import { createProject, deleteProject, updateProject } from "@/data/projects" +import { updateSettings } from "@/data/settings" +import { settingsFormSchema } from "@/forms/settings" +import { codeFromName } from "@/lib/utils" +import { revalidatePath } from "next/cache" +import { redirect } from "next/navigation" + +export async function saveSettingsAction(prevState: any, formData: FormData) { + const validatedForm = settingsFormSchema.safeParse(Object.fromEntries(formData)) + + if (!validatedForm.success) { + return { success: false, error: validatedForm.error.message } + } + + for (const key in validatedForm.data) { + await updateSettings(key, validatedForm.data[key as keyof typeof validatedForm.data] || "") + } + + revalidatePath("/settings") + redirect("/settings") + // return { success: true } +} + +export async function addProjectAction(data: { name: string; llm_prompt?: string; color?: string }) { + const project = await createProject({ + code: codeFromName(data.name), + name: data.name, + llm_prompt: data.llm_prompt || null, + color: data.color || "#000000", + }) + revalidatePath("/settings/projects") + return project +} + +export async function editProjectAction(code: string, data: { name: string; llm_prompt?: string; color?: string }) { + const project = await updateProject(code, { + name: data.name, + llm_prompt: data.llm_prompt, + color: data.color, + }) + revalidatePath("/settings/projects") + return project +} + +export async function deleteProjectAction(code: string) { + await deleteProject(code) + revalidatePath("/settings/projects") +} + +export async function addCurrencyAction(data: { code: string; name: string }) { + const currency = await createCurrency({ + code: data.code, + name: data.name, + }) + revalidatePath("/settings/currencies") + return currency +} + +export async function editCurrencyAction(code: string, data: { name: string }) { + const currency = await updateCurrency(code, { name: data.name }) + revalidatePath("/settings/currencies") + return currency +} + +export async function deleteCurrencyAction(code: string) { + await deleteCurrency(code) + revalidatePath("/settings/currencies") +} + +export async function addCategoryAction(data: { name: string; llm_prompt?: string; color?: string }) { + const category = await createCategory({ + code: codeFromName(data.name), + name: data.name, + llm_prompt: data.llm_prompt, + color: data.color, + }) + revalidatePath("/settings/categories") + return category +} + +export async function editCategoryAction(code: string, data: { name: string; llm_prompt?: string; color?: string }) { + const category = await updateCategory(code, { + name: data.name, + llm_prompt: data.llm_prompt, + color: data.color, + }) + revalidatePath("/settings/categories") + return category +} + +export async function deleteCategoryAction(code: string) { + await deleteCategory(code) + revalidatePath("/settings/categories") +} + +export async function addFieldAction(data: { name: string; type: string; llm_prompt?: string; isRequired?: boolean }) { + const field = await createField({ + code: codeFromName(data.name), + name: data.name, + type: data.type, + llm_prompt: data.llm_prompt, + isRequired: data.isRequired, + isExtra: true, + }) + revalidatePath("/settings/fields") + return field +} + +export async function editFieldAction( + code: string, + data: { name: string; type: string; llm_prompt?: string; isRequired?: boolean } +) { + const field = await updateField(code, { + name: data.name, + type: data.type, + llm_prompt: data.llm_prompt, + isRequired: data.isRequired, + }) + revalidatePath("/settings/fields") + return field +} + +export async function deleteFieldAction(code: string) { + await deleteField(code) + revalidatePath("/settings/fields") +} diff --git a/app/settings/backups/actions.ts b/app/settings/backups/actions.ts new file mode 100644 index 0000000..8f0f350 --- /dev/null +++ b/app/settings/backups/actions.ts @@ -0,0 +1,21 @@ +"use server" + +import { DATABASE_FILE } from "@/lib/db" +import fs from "fs" + +export async function restoreBackupAction(prevState: any, formData: FormData) { + const file = formData.get("file") as File + if (!file) { + return { success: false, error: "No file provided" } + } + + try { + const fileBuffer = await file.arrayBuffer() + const fileData = Buffer.from(fileBuffer) + fs.writeFileSync(DATABASE_FILE, fileData) + } catch (error) { + return { success: false, error: "Failed to restore backup" } + } + + return { success: true } +} diff --git a/app/settings/backups/database/route.ts b/app/settings/backups/database/route.ts new file mode 100644 index 0000000..65f5240 --- /dev/null +++ b/app/settings/backups/database/route.ts @@ -0,0 +1,18 @@ +import { DATABASE_FILE } from "@/lib/db" +import fs from "fs" +import { NextResponse } from "next/server" + +export async function GET(request: Request) { + try { + const file = fs.readFileSync(DATABASE_FILE) + return new NextResponse(file, { + headers: { + "Content-Type": "application/octet-stream", + "Content-Disposition": `attachment; filename="database.sqlite"`, + }, + }) + } catch (error) { + console.error("Error exporting database:", error) + return new NextResponse("Internal Server Error", { status: 500 }) + } +} diff --git a/app/settings/backups/files/route.ts b/app/settings/backups/files/route.ts new file mode 100644 index 0000000..6ec0211 --- /dev/null +++ b/app/settings/backups/files/route.ts @@ -0,0 +1,52 @@ +import { FILE_UPLOAD_PATH } from "@/lib/files" +import fs, { readdirSync } from "fs" +import JSZip from "jszip" +import { NextResponse } from "next/server" +import path from "path" + +export async function GET(request: Request) { + try { + const zip = new JSZip() + const folder = zip.folder("uploads") + if (!folder) { + console.error("Failed to create zip folder") + return new NextResponse("Internal Server Error", { status: 500 }) + } + + const files = getAllFilePaths(FILE_UPLOAD_PATH) + files.forEach((file) => { + folder.file(file.replace(FILE_UPLOAD_PATH, ""), fs.readFileSync(file)) + }) + const archive = await zip.generateAsync({ type: "blob" }) + + return new NextResponse(archive, { + headers: { + "Content-Type": "application/octet-stream", + "Content-Disposition": `attachment; filename="uploads.zip"`, + }, + }) + } catch (error) { + console.error("Error exporting database:", error) + return new NextResponse("Internal Server Error", { status: 500 }) + } +} + +function getAllFilePaths(dirPath: string): string[] { + let filePaths: string[] = [] + + function readDirectory(currentPath: string) { + const entries = readdirSync(currentPath, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = path.join(currentPath, entry.name) + if (entry.isDirectory()) { + readDirectory(fullPath) + } else { + filePaths.push(fullPath) + } + } + } + + readDirectory(dirPath) + return filePaths +} diff --git a/app/settings/backups/page.tsx b/app/settings/backups/page.tsx new file mode 100644 index 0000000..a636c0f --- /dev/null +++ b/app/settings/backups/page.tsx @@ -0,0 +1,58 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { Card } from "@/components/ui/card" +import { Download, Loader2 } from "lucide-react" +import Link from "next/link" +import { useActionState } from "react" +import { restoreBackupAction } from "./actions" + +export default function BackupSettingsPage() { + const [restoreState, restoreBackup, restorePending] = useActionState(restoreBackupAction, null) + + return ( +
+
+

Download backup

+
+ + + + + + +
+
+ You can use any SQLite client to view the database.sqlite file contents +
+
+ + +

Restore database from backup

+
+ Warning: This will overwrite your current database and destroy all the data! Don't forget to download backup + first. +
+
+ + +
+ {restoreState?.error &&

{restoreState.error}

} +
+
+ ) +} diff --git a/app/settings/backups/restore/route.ts b/app/settings/backups/restore/route.ts new file mode 100644 index 0000000..8d1f95a --- /dev/null +++ b/app/settings/backups/restore/route.ts @@ -0,0 +1,24 @@ +import { DATABASE_FILE } from "@/lib/db" +import fs from "fs" +import { NextResponse } from "next/server" + +export async function POST(request: Request) { + try { + const formData = await request.formData() + const file = formData.get("file") as File + + if (!file) { + return new NextResponse("No file provided", { status: 400 }) + } + + const fileBuffer = await file.arrayBuffer() + const fileData = Buffer.from(fileBuffer) + + fs.writeFileSync(DATABASE_FILE, fileData) + + return new NextResponse("File restored", { status: 200 }) + } catch (error) { + console.error("Error restoring from backup:", error) + return new NextResponse("Internal Server Error", { status: 500 }) + } +} diff --git a/app/settings/categories/page.tsx b/app/settings/categories/page.tsx new file mode 100644 index 0000000..3205906 --- /dev/null +++ b/app/settings/categories/page.tsx @@ -0,0 +1,52 @@ +import { addCategoryAction, deleteCategoryAction, editCategoryAction } from "@/app/settings/actions" +import { CrudTable } from "@/components/settings/crud" +import { getCategories } from "@/data/categories" + +export default async function CategoriesSettingsPage() { + const categories = await getCategories() + const categoriesWithActions = categories.map((category) => ({ + ...category, + isEditable: true, + isDeletable: true, + })) + + return ( +
+

Categories

+ { + "use server" + await deleteCategoryAction(code) + }} + onAdd={async (data) => { + "use server" + await addCategoryAction( + data as { + code: string + name: string + llm_prompt?: string + color: string + } + ) + }} + onEdit={async (code, data) => { + "use server" + await editCategoryAction( + code, + data as { + name: string + llm_prompt?: string + color?: string + } + ) + }} + /> +
+ ) +} diff --git a/app/settings/currencies/page.tsx b/app/settings/currencies/page.tsx new file mode 100644 index 0000000..7fd9692 --- /dev/null +++ b/app/settings/currencies/page.tsx @@ -0,0 +1,37 @@ +import { addCurrencyAction, deleteCurrencyAction, editCurrencyAction } from "@/app/settings/actions" +import { CrudTable } from "@/components/settings/crud" +import { getCurrencies } from "@/data/currencies" + +export default async function CurrenciesSettingsPage() { + const currencies = await getCurrencies() + const currenciesWithActions = currencies.map((currency) => ({ + ...currency, + isEditable: true, + isDeletable: true, + })) + + return ( +
+

Currencies

+ { + "use server" + await deleteCurrencyAction(code) + }} + onAdd={async (data) => { + "use server" + await addCurrencyAction(data as { code: string; name: string }) + }} + onEdit={async (code, data) => { + "use server" + await editCurrencyAction(code, data as { name: string }) + }} + /> +
+ ) +} diff --git a/app/settings/fields/page.tsx b/app/settings/fields/page.tsx new file mode 100644 index 0000000..5c16f99 --- /dev/null +++ b/app/settings/fields/page.tsx @@ -0,0 +1,51 @@ +import { addFieldAction, deleteFieldAction, editFieldAction } from "@/app/settings/actions" +import { CrudTable } from "@/components/settings/crud" +import { getFields } from "@/data/fields" + +export default async function FieldsSettingsPage() { + const fields = await getFields() + const fieldsWithActions = fields.map((field) => ({ + ...field, + isEditable: true, + isDeletable: field.isExtra, + })) + + return ( +
+

Custom Fields

+ { + "use server" + await deleteFieldAction(code) + }} + onAdd={async (data) => { + "use server" + await addFieldAction( + data as { + name: string + type: string + llm_prompt?: string + } + ) + }} + onEdit={async (code, data) => { + "use server" + await editFieldAction( + code, + data as { + name: string + type: string + llm_prompt?: string + } + ) + }} + /> +
+ ) +} diff --git a/app/settings/layout.tsx b/app/settings/layout.tsx new file mode 100644 index 0000000..bf0aac1 --- /dev/null +++ b/app/settings/layout.tsx @@ -0,0 +1,59 @@ +import { SideNav } from "@/components/settings/side-nav" +import { Separator } from "@/components/ui/separator" +import { Metadata } from "next" + +export const metadata: Metadata = { + title: "Settings", + description: "Customize your settings here", +} + +const settingsCategories = [ + { + title: "General", + href: "/settings", + }, + { + title: "LLM settings", + href: "/settings/llm", + }, + { + title: "Fields", + href: "/settings/fields", + }, + { + title: "Categories", + href: "/settings/categories", + }, + { + title: "Projects", + href: "/settings/projects", + }, + { + title: "Currencies", + href: "/settings/currencies", + }, + { + title: "Backups", + href: "/settings/backups", + }, +] + +export default function SettingsLayout({ children }: { children: React.ReactNode }) { + return ( + <> +
+
+

Settings

+

Customize your settings here

+
+ +
+ +
{children}
+
+
+ + ) +} diff --git a/app/settings/llm/page.tsx b/app/settings/llm/page.tsx new file mode 100644 index 0000000..2422779 --- /dev/null +++ b/app/settings/llm/page.tsx @@ -0,0 +1,14 @@ +import LLMSettingsForm from "@/components/settings/llm-settings-form" +import { getSettings } from "@/data/settings" + +export default async function LlmSettingsPage() { + const settings = await getSettings() + + return ( + <> +
+ +
+ + ) +} diff --git a/app/settings/page.tsx b/app/settings/page.tsx new file mode 100644 index 0000000..f9f2101 --- /dev/null +++ b/app/settings/page.tsx @@ -0,0 +1,18 @@ +import GlobalSettingsForm from "@/components/settings/global-settings-form" +import { getCategories } from "@/data/categories" +import { getCurrencies } from "@/data/currencies" +import { getSettings } from "@/data/settings" + +export default async function SettingsPage() { + const settings = await getSettings() + const currencies = await getCurrencies() + const categories = await getCategories() + + return ( + <> +
+ +
+ + ) +} diff --git a/app/settings/projects/page.tsx b/app/settings/projects/page.tsx new file mode 100644 index 0000000..10f0aaa --- /dev/null +++ b/app/settings/projects/page.tsx @@ -0,0 +1,38 @@ +import { addProjectAction, deleteProjectAction, editProjectAction } from "@/app/settings/actions" +import { CrudTable } from "@/components/settings/crud" +import { getProjects } from "@/data/projects" + +export default async function ProjectsSettingsPage() { + const projects = await getProjects() + const projectsWithActions = projects.map((project) => ({ + ...project, + isEditable: true, + isDeletable: true, + })) + + return ( +
+

Projects

+ { + "use server" + await deleteProjectAction(code) + }} + onAdd={async (data) => { + "use server" + await addProjectAction(data as { code: string; name: string; llm_prompt: string; color: string }) + }} + onEdit={async (code, data) => { + "use server" + await editProjectAction(code, data as { name: string; llm_prompt: string; color: string }) + }} + /> +
+ ) +} diff --git a/app/transactions/[transactionId]/layout.tsx b/app/transactions/[transactionId]/layout.tsx new file mode 100644 index 0000000..265a0bd --- /dev/null +++ b/app/transactions/[transactionId]/layout.tsx @@ -0,0 +1,28 @@ +import { getTransactionById } from "@/data/transactions" +import { notFound } from "next/navigation" + +export default async function TransactionLayout({ + children, + params, +}: { + children: React.ReactNode + params: Promise<{ transactionId: string }> +}) { + const { transactionId } = await params + const transaction = await getTransactionById(transactionId) + + if (!transaction) { + notFound() + } + + return ( + <> +
+

Transaction Details

+
+
+
{children}
+
+ + ) +} diff --git a/app/transactions/[transactionId]/loading.tsx b/app/transactions/[transactionId]/loading.tsx new file mode 100644 index 0000000..4011433 --- /dev/null +++ b/app/transactions/[transactionId]/loading.tsx @@ -0,0 +1,7 @@ +import { Skeleton } from "@/components/ui/skeleton" + +export default function Loading() { + return ( + + ) +} diff --git a/app/transactions/[transactionId]/page.tsx b/app/transactions/[transactionId]/page.tsx new file mode 100644 index 0000000..14cd445 --- /dev/null +++ b/app/transactions/[transactionId]/page.tsx @@ -0,0 +1,62 @@ +import { FormTextarea } from "@/components/forms/simple" +import TransactionEditForm from "@/components/transactions/edit" +import TransactionFiles from "@/components/transactions/transaction-files" +import { Card } from "@/components/ui/card" +import { getCategories } from "@/data/categories" +import { getCurrencies } from "@/data/currencies" +import { getFields } from "@/data/fields" +import { getFilesByTransactionId } from "@/data/files" +import { getProjects } from "@/data/projects" +import { getSettings } from "@/data/settings" +import { getTransactionById } from "@/data/transactions" +import { notFound } from "next/navigation" + +export default async function TransactionPage({ params }: { params: Promise<{ transactionId: string }> }) { + const { transactionId } = await params + const transaction = await getTransactionById(transactionId) + if (!transaction) { + notFound() + } + + const files = await getFilesByTransactionId(transactionId) + const categories = await getCategories() + const currencies = await getCurrencies() + const settings = await getSettings() + const fields = await getFields() + const projects = await getProjects() + + return ( + <> + +
+ +
+ +
+ +
+
+ + {transaction.text && ( + +
+ +
+
+ )} + + ) +} diff --git a/app/transactions/actions.ts b/app/transactions/actions.ts new file mode 100644 index 0000000..aa844c0 --- /dev/null +++ b/app/transactions/actions.ts @@ -0,0 +1,143 @@ +"use server" + +import { createFile, deleteFile } from "@/data/files" +import { + createTransaction, + deleteTransaction, + getTransactionById, + updateTransaction, + updateTransactionFiles, +} from "@/data/transactions" +import { transactionFormSchema } from "@/forms/transactions" +import { FILE_UPLOAD_PATH, getTransactionFileUploadPath } from "@/lib/files" +import { existsSync } from "fs" +import { mkdir, writeFile } from "fs/promises" +import { revalidatePath } from "next/cache" + +export async function createTransactionAction(prevState: any, formData: FormData) { + try { + const validatedForm = transactionFormSchema.safeParse(Object.fromEntries(formData.entries())) + + if (!validatedForm.success) { + return { success: false, error: validatedForm.error.message } + } + + const transaction = await createTransaction(validatedForm.data) + + revalidatePath("/transactions") + return { success: true, transactionId: transaction.id } + } catch (error) { + console.error("Failed to create transaction:", error) + return { success: false, error: "Failed to create transaction" } + } +} + +export async function saveTransactionAction(prevState: any, formData: FormData) { + try { + const transactionId = formData.get("transactionId") as string + const validatedForm = transactionFormSchema.safeParse(Object.fromEntries(formData.entries())) + + if (!validatedForm.success) { + return { success: false, error: validatedForm.error.message } + } + + const transaction = await updateTransaction(transactionId, validatedForm.data) + + revalidatePath("/transactions") + return { success: true, transactionId: transaction.id } + } catch (error) { + console.error("Failed to update transaction:", error) + return { success: false, error: "Failed to save transaction" } + } +} + +export async function deleteTransactionAction(prevState: any, transactionId: string) { + try { + const transaction = await getTransactionById(transactionId) + if (!transaction) throw new Error("Transaction not found") + + await deleteTransaction(transaction.id) + + revalidatePath("/transactions") + + return { success: true, transactionId: transaction.id } + } catch (error) { + console.error("Failed to delete transaction:", error) + return { success: false, error: "Failed to delete transaction" } + } +} + +export async function deleteTransactionFileAction( + transactionId: string, + fileId: string +): Promise<{ success: boolean; error?: string }> { + if (!fileId || !transactionId) { + return { success: false, error: "File ID and transaction ID are required" } + } + + const transaction = await getTransactionById(transactionId) + if (!transaction) { + return { success: false, error: "Transaction not found" } + } + + await updateTransactionFiles( + transactionId, + transaction.files ? (transaction.files as string[]).filter((id) => id !== fileId) : [] + ) + + await deleteFile(fileId) + revalidatePath(`/transactions/${transactionId}`) + return { success: true } +} + +export async function uploadTransactionFileAction(formData: FormData): Promise<{ success: boolean; error?: string }> { + try { + const transactionId = formData.get("transactionId") as string + const file = formData.get("file") as File + + if (!file || !transactionId) { + return { success: false, error: "No file or transaction ID provided" } + } + + const transaction = await getTransactionById(transactionId) + if (!transaction) { + return { success: false, error: "Transaction not found" } + } + + // Make sure upload dir exists + if (!existsSync(FILE_UPLOAD_PATH)) { + 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) + + // 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, + }, + }) + + // Update invoice with the new file ID + await updateTransactionFiles( + transactionId, + transaction.files ? [...(transaction.files as string[]), fileRecord.id] : [fileRecord.id] + ) + + revalidatePath(`/transactions/${transactionId}`) + return { success: true } + } catch (error) { + console.error("Upload error:", error) + return { success: false, error: `File upload failed: ${error}` } + } +} diff --git a/app/transactions/layout.tsx b/app/transactions/layout.tsx new file mode 100644 index 0000000..8c37995 --- /dev/null +++ b/app/transactions/layout.tsx @@ -0,0 +1,3 @@ +export default async function TransactionsLayout({ children }: { children: React.ReactNode }) { + return
{children}
+} diff --git a/app/transactions/loading.tsx b/app/transactions/loading.tsx new file mode 100644 index 0000000..04ec005 --- /dev/null +++ b/app/transactions/loading.tsx @@ -0,0 +1,30 @@ +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { Download, Plus } from "lucide-react" + +export default function Loading() { + return ( + <> +
+

Transactions

+
+ + +
+
+ +
+
+ {[...Array(20)].map((_, i) => ( + + ))} +
+
+ + ) +} diff --git a/app/transactions/page.tsx b/app/transactions/page.tsx new file mode 100644 index 0000000..e91a97a --- /dev/null +++ b/app/transactions/page.tsx @@ -0,0 +1,71 @@ +import { ExportTransactionsDialog } from "@/components/export/transactions" +import { UploadButton } from "@/components/files/upload-button" +import { TransactionSearchAndFilters } from "@/components/transactions/filters" +import { TransactionList } from "@/components/transactions/list" +import { NewTransactionDialog } from "@/components/transactions/new" +import { Button } from "@/components/ui/button" +import { getCategories } from "@/data/categories" +import { getFields } from "@/data/fields" +import { getProjects } from "@/data/projects" +import { getTransactions, TransactionFilters } from "@/data/transactions" +import { Download, Plus, Upload } from "lucide-react" +import { Metadata } from "next" + +export const metadata: Metadata = { + title: "Transactions", + description: "Manage your transactions", +} + +export default async function TransactionsPage({ searchParams }: { searchParams: Promise }) { + const filters = await searchParams + const transactions = await getTransactions(filters) + const categories = await getCategories() + const projects = await getProjects() + const fields = await getFields() + + return ( + <> +
+

Transactions

+
+ + + + + + +
+
+ + + +
+ + + {transactions.length === 0 && ( +
+

+ You don't seem to have any transactions yet. Let's start and create the first one! +

+
+ + Analyze New Invoice + + + + +
+
+ )} +
+ + ) +} diff --git a/app/unsorted/actions.ts b/app/unsorted/actions.ts new file mode 100644 index 0000000..3321333 --- /dev/null +++ b/app/unsorted/actions.ts @@ -0,0 +1,63 @@ +"use server" + +import { deleteFile, getFileById, updateFile } from "@/data/files" +import { createTransaction, updateTransactionFiles } from "@/data/transactions" +import { transactionFormSchema } from "@/forms/transactions" +import { getTransactionFileUploadPath } from "@/lib/files" +import { mkdir, rename } from "fs/promises" +import { revalidatePath } from "next/cache" +import path from "path" + +export async function saveFileAsTransactionAction(prevState: any, formData: FormData) { + try { + const validatedForm = transactionFormSchema.safeParse(Object.fromEntries(formData.entries())) + + if (!validatedForm.success) { + return { success: false, error: validatedForm.error.message } + } + + // Get the file record + const fileId = formData.get("fileId") as string + const file = await getFileById(fileId) + if (!file) throw new Error("File not found") + + // Create transaction + const transaction = await createTransaction(validatedForm.data) + + // Move file to processed location + const originalFileName = path.basename(file.path) + const { fileUuid, filePath: newFilePath } = await getTransactionFileUploadPath(originalFileName, transaction) + + // Move file to new location and name + await mkdir(path.dirname(newFilePath), { recursive: true }) + await rename(path.resolve(file.path), path.resolve(newFilePath)) + + // Update file record + await updateFile(file.id, { + id: fileUuid, + path: newFilePath, + isReviewed: true, + }) + + await updateTransactionFiles(transaction.id, [fileUuid]) + + revalidatePath("/unsorted") + revalidatePath("/transactions") + + return { success: true, transactionId: transaction.id } + } catch (error) { + console.error("Failed to save transaction:", error) + return { success: false, error: `Failed to save transaction: ${error}` } + } +} + +export async function deleteUnsortedFileAction(prevState: any, fileId: string) { + try { + await deleteFile(fileId) + revalidatePath("/unsorted") + return { success: true } + } catch (error) { + console.error("Failed to delete file:", error) + return { success: false, error: "Failed to delete file" } + } +} diff --git a/app/unsorted/layout.tsx b/app/unsorted/layout.tsx new file mode 100644 index 0000000..24a7502 --- /dev/null +++ b/app/unsorted/layout.tsx @@ -0,0 +1,3 @@ +export default function UnsortedLayout({ children }: { children: React.ReactNode }) { + return
{children}
+} diff --git a/app/unsorted/page.tsx b/app/unsorted/page.tsx new file mode 100644 index 0000000..04141b6 --- /dev/null +++ b/app/unsorted/page.tsx @@ -0,0 +1,103 @@ +import AnalyzeForm from "@/components/unsorted/analyze-form" +import { FilePreview } from "@/components/files/preview" +import { UploadButton } from "@/components/files/upload-button" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { Card } from "@/components/ui/card" +import { getCategories } from "@/data/categories" +import { getCurrencies } from "@/data/currencies" +import { getFields } from "@/data/fields" +import { getUnsortedFiles } from "@/data/files" +import { getProjects } from "@/data/projects" +import { getSettings } from "@/data/settings" +import { FileText, PartyPopper, Settings, Upload } from "lucide-react" +import { Metadata } from "next" +import Link from "next/link" + +export const metadata: Metadata = { + title: "Unsorted", + description: "Analyze unsorted files", +} + +export default async function UnsortedPage() { + const files = await getUnsortedFiles() + const categories = await getCategories() + const projects = await getProjects() + const currencies = await getCurrencies() + const fields = await getFields() + const settings = await getSettings() + + return ( + <> +
+

You have {files.length} unsorted files

+
+ + {!settings.openai_api_key && ( + + +
+
+ ChatGPT API Key is required for analyzing files + + Please set your OpenAI API key in the settings to use the analyze form. + +
+ + + +
+
+ )} + +
+ {files.map((file) => ( + +
+ + + +
+ +
+ +
+
+ ))} + {files.length == 0 && ( +
+ +

Everything is clear! Congrats!

+

+ Drag and drop new files here to analyze + +

+ +
+ + Upload New File + + +
+
+ )} +
+ + ) +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..a312865 --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/components/dashboard/drop-zone-widget.tsx b/components/dashboard/drop-zone-widget.tsx new file mode 100644 index 0000000..2bbf5b7 --- /dev/null +++ b/components/dashboard/drop-zone-widget.tsx @@ -0,0 +1,74 @@ +"use client" + +import { useNotification } from "@/app/context" +import { uploadFilesAction } from "@/app/files/actions" +import { FILE_ACCEPTED_MIMETYPES } from "@/lib/files" +import { Camera, Loader2 } from "lucide-react" +import { useRouter } from "next/navigation" +import { startTransition, useState } from "react" + +export default function DashboardDropZoneWidget() { + const router = useRouter() + const { showNotification } = useNotification() + const [isUploading, setIsUploading] = useState(false) + const [uploadError, setUploadError] = useState("") + + const handleFileChange = async (e: React.ChangeEvent) => { + setIsUploading(true) + setUploadError("") + if (e.target.files && e.target.files.length > 0) { + const formData = new FormData() + + // Append all selected files to the FormData + for (let i = 0; i < e.target.files.length; i++) { + formData.append("files", e.target.files[i]) + } + + // Submit the files using the server action + startTransition(async () => { + const result = await uploadFilesAction(null, formData) + if (result.success) { + showNotification({ code: "sidebar.unsorted", message: "new" }) + setTimeout(() => showNotification({ code: "sidebar.unsorted", message: "" }), 3000) + router.push("/unsorted") + } else { + setUploadError(result.error ? result.error : "Something went wrong...") + } + setIsUploading(false) + }) + } + } + + return ( +
+ +
+ ) +} diff --git a/components/dashboard/filters-widget.tsx b/components/dashboard/filters-widget.tsx new file mode 100644 index 0000000..76a4e36 --- /dev/null +++ b/components/dashboard/filters-widget.tsx @@ -0,0 +1,54 @@ +"use client" + +import { StatsFilters } from "@/data/stats" +import { format } from "date-fns" +import { useRouter, useSearchParams } from "next/navigation" +import { useEffect, useState } from "react" +import { DateRangePicker } from "../forms/date-range-picker" + +export function FiltersWidget({ + defaultFilters, + defaultRange = "last-12-months", +}: { + defaultFilters: StatsFilters + defaultRange?: string +}) { + const searchParams = useSearchParams() + const router = useRouter() + const [filters, setFilters] = useState(defaultFilters) + + const applyFilters = () => { + const params = new URLSearchParams(searchParams.toString()) + 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") + } + router.push(`?${params.toString()}`) + } + + useEffect(() => { + applyFilters() + }, [filters]) + + return ( + { + setFilters({ + dateFrom: date && date.from ? format(date.from, "yyyy-MM-dd") : undefined, + dateTo: date && date.to ? format(date.to, "yyyy-MM-dd") : undefined, + }) + }} + /> + ) +} diff --git a/components/dashboard/projects-widget.tsx b/components/dashboard/projects-widget.tsx new file mode 100644 index 0000000..fca4309 --- /dev/null +++ b/components/dashboard/projects-widget.tsx @@ -0,0 +1,86 @@ +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { ProjectStats } from "@/data/stats" +import { formatCurrency } from "@/lib/utils" +import { Project } from "@prisma/client" +import { Plus } from "lucide-react" +import Link from "next/link" + +export function ProjectsWidget({ + projects, + statsPerProject, +}: { + projects: Project[] + statsPerProject: Record +}) { + return ( +
+ {projects.map((project) => ( + + + + + + {project.name} + + + + + +
+
+
Income
+
+ {Object.entries(statsPerProject[project.code]?.totalIncomePerCurrency).map(([currency, total]) => ( +
+ {formatCurrency(total, currency)} +
+ ))} + {!Object.entries(statsPerProject[project.code]?.totalIncomePerCurrency).length && ( +
0.00
+ )} +
+
+
+
Expenses
+
+ {Object.entries(statsPerProject[project.code]?.totalExpensesPerCurrency).map(([currency, total]) => ( +
+ {formatCurrency(total, currency)} +
+ ))} + {!Object.entries(statsPerProject[project.code]?.totalExpensesPerCurrency).length && ( +
0.00
+ )} +
+
+
+
Profit
+
+ {Object.entries(statsPerProject[project.code]?.profitPerCurrency).map(([currency, total]) => ( +
+ {formatCurrency(total, currency)} +
+ ))} + {!Object.entries(statsPerProject[project.code]?.profitPerCurrency).length && ( +
0.00
+ )} +
+
+
+
+
+ ))} + + + Create New Project + +
+ ) +} diff --git a/components/dashboard/stats-widget.tsx b/components/dashboard/stats-widget.tsx new file mode 100644 index 0000000..9f28f79 --- /dev/null +++ b/components/dashboard/stats-widget.tsx @@ -0,0 +1,86 @@ +import { getProjects } from "@/data/projects" +import { getDashboardStats, getProjectStats, StatsFilters } from "@/data/stats" +import { formatCurrency } from "@/lib/utils" +import { ArrowDown, ArrowUp, BicepsFlexed } from "lucide-react" +import { Card, CardContent, CardHeader, CardTitle } from "../ui/card" +import { FiltersWidget } from "./filters-widget" +import { ProjectsWidget } from "./projects-widget" + +export async function StatsWidget({ filters }: { filters: StatsFilters }) { + const projects = await getProjects() + const stats = await getDashboardStats(filters) + const statsPerProject = Object.fromEntries( + await Promise.all( + projects.map((project) => getProjectStats(project.code, filters).then((stats) => [project.code, stats])) + ) + ) + + return ( +
+
+

Overview

+ + +
+ +
+ + + Total Income + + + + {Object.entries(stats.totalIncomePerCurrency).map(([currency, total]) => ( +
+ {formatCurrency(total, currency)} +
+ ))} + {!Object.entries(stats.totalIncomePerCurrency).length &&
0.00
} +
+
+ + + Total Expenses + + + + {Object.entries(stats.totalExpensesPerCurrency).map(([currency, total]) => ( +
+ {formatCurrency(total, currency)} +
+ ))} + {!Object.entries(stats.totalExpensesPerCurrency).length &&
0.00
} +
+
+ + + Net Profit + + + + {Object.entries(stats.profitPerCurrency).map(([currency, total]) => ( +
+ {formatCurrency(total, currency)} +
+ ))} + {!Object.entries(stats.profitPerCurrency).length &&
0.00
} +
+
+ + + Processed Transactions + + +
{stats.invoicesProcessed}
+
+
+
+ +
+

Projects

+
+ + +
+ ) +} diff --git a/components/dashboard/unsorted-widget.tsx b/components/dashboard/unsorted-widget.tsx new file mode 100644 index 0000000..3cab5c1 --- /dev/null +++ b/components/dashboard/unsorted-widget.tsx @@ -0,0 +1,45 @@ +"use client" + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { File } from "@prisma/client" +import { FilePlus, PartyPopper } from "lucide-react" +import Link from "next/link" + +export default function DashboardUnsortedWidget({ files }: { files: File[] }) { + return ( + + + + + {files.length > 0 ? `${files.length} unsorted files` : "No unsorted files"} → + + + + +
+ {files.slice(0, 3).map((file) => ( + +
+ +
+ {file.filename} + {file.mimetype} +
+
+ + ))} + {files.length == 0 && ( +
+ + Everything is clear! Congrats! +
+ )} +
+
+
+ ) +} diff --git a/components/dashboard/welcome-widget.tsx b/components/dashboard/welcome-widget.tsx new file mode 100644 index 0000000..8dea39f --- /dev/null +++ b/components/dashboard/welcome-widget.tsx @@ -0,0 +1,105 @@ +import { Button } from "@/components/ui/button" +import { Card, CardDescription, CardTitle } from "@/components/ui/card" +import { getSettings } from "@/data/settings" +import { Banknote, ChartBarStacked, FolderOpenDot, Key, TextCursorInput } from "lucide-react" +import Link from "next/link" + +export async function WelcomeWidget() { + const settings = await getSettings() + + return ( + + Logo +
+ Hey, I'm TaxHacker 👋 + +

+ I'm a little accountant app that tries to help you deal with endless receipts, checks and invoices with (you + guessed it) GenAI. Here's what I can do: +

+
    +
  • + Upload me a photo or a PDF and I will recognize, categorize and save it as a transaction + for your tax advisor. +
  • +
  • + I can automatically convert currencies and look up exchange rates for a given date. +
  • +
  • + I even support crypto! Historical exchange rates for staking too. +
  • +
  • + All LLM prompts are configurable: for fields, categories and projects. You can go to + settings and change them. +
  • +
  • + I save data in a local SQLite database and can export it to CSV and ZIP archives. +
  • +
  • + You can even create your own new fields to be analyzed and they will be included in the + CSV export for your tax advisor. +
  • +
  • + I'm still very young and can make mistakes. Use me at your own risk! +
  • +
+

+ While I can save you a lot of time in categorizing transactions and generating reports, I still highly + recommend giving the results to a professional tax advisor for review when filing your taxes! +

+
+
+ + Source Code + + | + + Request New Feature + + | + + Report a Bug + + | + + Contact the Author + +
+
+ {settings.openai_api_key === "" && ( + + + + )} + + + + + + + + + + + + +
+
+
+ ) +} diff --git a/components/export/transactions.tsx b/components/export/transactions.tsx new file mode 100644 index 0000000..2b9880f --- /dev/null +++ b/components/export/transactions.tsx @@ -0,0 +1,162 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Separator } from "@/components/ui/separator" +import { TransactionFilters } from "@/data/transactions" +import { Category, Field, Project } from "@prisma/client" +import { formatDate } from "date-fns" +import { useRouter } from "next/navigation" +import { useState } from "react" +import { DateRangePicker } from "../forms/date-range-picker" +import { FormSelectCategory } from "../forms/select-category" +import { FormSelectProject } from "../forms/select-project" + +const deselectedFields = ["files", "text"] + +export function ExportTransactionsDialog({ + filters, + fields, + categories, + projects, + children, +}: { + filters: TransactionFilters + fields: Field[] + categories: Category[] + projects: Project[] + children: React.ReactNode +}) { + const router = useRouter() + const [exportFilters, setExportFilters] = useState(filters) + const [exportFields, setExportFields] = useState( + fields.map((field) => (deselectedFields.includes(field.code) ? "" : field.code)) + ) + const [includeAttachments, setIncludeAttachments] = useState(true) + + const handleSubmit = () => { + router.push( + `/export/transactions?${new URLSearchParams({ + ...exportFilters, + fields: exportFields.join(","), + includeAttachments: includeAttachments.toString(), + }).toString()}` + ) + } + + return ( + + {children} + + + Export Transactions + Export selected transactions and files as a CSV file or a ZIP archive + +
+
+ {filters.search && ( +
+ Search query: + {filters.search} +
+ )} + +
+ Time range: + + { + setExportFilters({ + ...exportFilters, + dateFrom: date?.from ? formatDate(date.from, "yyyy-MM-dd") : undefined, + dateTo: date?.to ? formatDate(date.to, "yyyy-MM-dd") : undefined, + }) + }} + /> +
+ +
+ setExportFilters({ ...exportFilters, categoryCode: value })} + placeholder="All Categories" + emptyValue="All Categories" + /> + + setExportFilters({ ...exportFilters, projectCode: value })} + placeholder="All Projects" + emptyValue="All Projects" + /> +
+
+ + + +
Fields to be included in CSV
+ +
+ {fields.map((field) => ( +
+ +
+ ))} +
+ +
+ +
+
+ + + +
+
+ ) +} diff --git a/components/files/preview.tsx b/components/files/preview.tsx new file mode 100644 index 0000000..c79a0d7 --- /dev/null +++ b/components/files/preview.tsx @@ -0,0 +1,53 @@ +"use client" + +import { File } from "@prisma/client" +import Image from "next/image" +import Link from "next/link" +import { useState } from "react" + +export function FilePreview({ file }: { file: File }) { + const [isEnlarged, setIsEnlarged] = useState(false) + + const fileSize = + file.metadata && typeof file.metadata === "object" && "size" in file.metadata + ? Number(file.metadata.size) / 1024 / 1024 + : 0 + + return ( + <> +
+
+ {file.filename} setIsEnlarged(!isEnlarged)} + /> + {isEnlarged && ( +
setIsEnlarged(false)} /> + )} +
+
+

+ {file.filename} +

+

+ Type: {file.mimetype} +

+

+ Size: {fileSize < 1 ? (fileSize * 1024).toFixed(2) + " KB" : fileSize.toFixed(2) + " MB"} +

+

+ Path: {file.path} +

+
+
+ + ) +} diff --git a/components/files/screen-drop-area.tsx b/components/files/screen-drop-area.tsx new file mode 100644 index 0000000..64a5f0e --- /dev/null +++ b/components/files/screen-drop-area.tsx @@ -0,0 +1,134 @@ +"use client" + +import { useNotification } from "@/app/context" +import { uploadFilesAction } from "@/app/files/actions" +import { AlertCircle, CloudUpload, Loader2 } from "lucide-react" +import { useRouter } from "next/navigation" +import { startTransition, useEffect, useRef, useState } from "react" + +export default function ScreenDropArea({ children }: { children: React.ReactNode }) { + const router = useRouter() + const { showNotification } = useNotification() + const [isDragging, setIsDragging] = useState(false) + const [isUploading, setIsUploading] = useState(false) + const [uploadError, setUploadError] = useState("") + const dragCounter = useRef(0) + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + dragCounter.current++ + + if (dragCounter.current === 1) { + setIsDragging(true) + } + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + dragCounter.current-- + + if (dragCounter.current === 0) { + setIsDragging(false) + } + } + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + + // Reset counter and dragging state + dragCounter.current = 0 + setIsDragging(false) + setIsUploading(true) + setUploadError("") + + const files = e.dataTransfer.files + if (files && files.length > 0) { + try { + const formData = new FormData() + for (let i = 0; i < files.length; i++) { + formData.append("files", files[i]) + } + + startTransition(async () => { + const result = await uploadFilesAction(null, formData) + if (result.success) { + showNotification({ code: "sidebar.unsorted", message: "new" }) + setTimeout(() => showNotification({ code: "sidebar.unsorted", message: "" }), 3000) + router.push("/unsorted") + } else { + setUploadError(result.error ? result.error : "Something went wrong...") + } + setIsUploading(false) + }) + } catch (error) { + console.error("Upload error:", error) + setIsUploading(false) + setUploadError(error instanceof Error ? error.message : "Something went wrong...") + } + } + } + + // Add event listeners to document body + useEffect(() => { + document.body.addEventListener("dragenter", handleDragEnter as any) + document.body.addEventListener("dragover", handleDragOver as any) + document.body.addEventListener("dragleave", handleDragLeave as any) + document.body.addEventListener("drop", handleDrop as any) + + return () => { + document.body.removeEventListener("dragenter", handleDragEnter as any) + document.body.removeEventListener("dragover", handleDragOver as any) + document.body.removeEventListener("dragleave", handleDragLeave as any) + document.body.removeEventListener("drop", handleDrop as any) + } + }, [isDragging]) + + return ( +
+ {children} + + {isDragging && ( +
+
+ +

Drop Files to Upload

+

Drop anywhere on the screen

+
+
+ )} + + {isUploading && ( +
+
+ +

Uploading...

+
+
+ )} + + {uploadError && ( +
+
+ +

Upload Error

+

{uploadError}

+
+
+ )} +
+ ) +} diff --git a/components/files/upload-button.tsx b/components/files/upload-button.tsx new file mode 100644 index 0000000..73959e0 --- /dev/null +++ b/components/files/upload-button.tsx @@ -0,0 +1,75 @@ +"use client" + +import { useNotification } from "@/app/context" +import { uploadFilesAction } from "@/app/files/actions" +import { Button } from "@/components/ui/button" +import { FILE_ACCEPTED_MIMETYPES } from "@/lib/files" +import { Loader2 } from "lucide-react" +import { useRouter } from "next/navigation" +import { ComponentProps, startTransition, useRef, useState } from "react" + +export function UploadButton({ children, ...props }: { children: React.ReactNode } & ComponentProps) { + const router = useRouter() + const { showNotification } = useNotification() + const fileInputRef = useRef(null) + const [uploadError, setUploadError] = useState("") + const [isUploading, setIsUploading] = useState(false) + + const handleFileChange = async (e: React.ChangeEvent) => { + setUploadError("") + setIsUploading(true) + if (e.target.files && e.target.files.length > 0) { + const formData = new FormData() + + // Append all selected files to the FormData + for (let i = 0; i < e.target.files.length; i++) { + formData.append("files", e.target.files[i]) + } + + // Submit the files using the server action + startTransition(async () => { + const result = await uploadFilesAction(null, formData) + if (result.success) { + showNotification({ code: "sidebar.unsorted", message: "new" }) + setTimeout(() => showNotification({ code: "sidebar.unsorted", message: "" }), 3000) + router.push("/unsorted") + } else { + setUploadError(result.error ? result.error : "Something went wrong...") + } + setIsUploading(false) + }) + } + } + + const handleButtonClick = (e: React.MouseEvent) => { + e.preventDefault() // Prevent any form submission + fileInputRef.current?.click() + } + + return ( +
+ + + + + {uploadError && ⚠️ {uploadError}} +
+ ) +} diff --git a/components/forms/convert-currency.tsx b/components/forms/convert-currency.tsx new file mode 100644 index 0000000..60668a8 --- /dev/null +++ b/components/forms/convert-currency.tsx @@ -0,0 +1,77 @@ +import { getCurrencyRate } from "@/lib/currency-scraper" +import { formatCurrency } from "@/lib/utils" +import { format, startOfDay } from "date-fns" +import { Loader2 } from "lucide-react" +import { useEffect, useState } from "react" + +export const FormConvertCurrency = ({ + originalTotal, + originalCurrencyCode, + targetCurrencyCode, + date, + onChange, +}: { + originalTotal: number + originalCurrencyCode: string + targetCurrencyCode: string + date?: Date | undefined + 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 [exchangeRate, setExchangeRate] = useState(0) + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + const fetchData = async () => { + try { + setIsLoading(true) + const exchangeRate = await getCurrencyRate(originalCurrencyCode, targetCurrencyCode, normalizedDate) + setExchangeRate(exchangeRate) + onChange?.(originalTotal * exchangeRate) + } catch (error) { + console.error("Error fetching currency rates:", error) + setExchangeRate(0) + onChange?.(0) + } finally { + setIsLoading(false) + } + } + + fetchData() + }, [originalCurrencyCode, targetCurrencyCode, format(normalizedDate, "LLLL-mm-dd")]) + + return ( +
+ {isLoading ? ( +
+ +
Loading exchange rates...
+
+ ) : ( +
+
{formatCurrency(originalTotal * 100, originalCurrencyCode)}
+
=
+
{formatCurrency(originalTotal * 100 * exchangeRate, targetCurrencyCode).slice(0, 1)}
+ onChange?.(parseFloat(e.target.value))} + className="w-32 rounded-md border border-input bg-transparent px-1" + /> +
(exchange rate on {format(normalizedDate, "LLLL dd, yyyy")})
+
+ )} +
+ ) +} diff --git a/components/forms/date-range-picker.tsx b/components/forms/date-range-picker.tsx new file mode 100644 index 0000000..5d7725b --- /dev/null +++ b/components/forms/date-range-picker.tsx @@ -0,0 +1,129 @@ +"use client" + +import { format, startOfMonth, startOfQuarter, subMonths, subWeeks } from "date-fns" +import { CalendarIcon } from "lucide-react" +import { useState } from "react" +import { DateRange } from "react-day-picker" + +import { Button } from "@/components/ui/button" +import { Calendar } from "@/components/ui/calendar" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { cn } from "@/lib/utils" + +export function DateRangePicker({ + defaultDate, + defaultRange = "all-time", + onChange, +}: { + defaultDate?: DateRange + defaultRange?: string + onChange?: (date: DateRange | undefined) => void +}) { + const predefinedRanges = [ + { + code: "last-4-weeks", + label: "Last 4 weeks", + range: { from: subWeeks(new Date(), 4), to: new Date() }, + }, + { + code: "last-12-months", + label: "Last 12 months", + range: { from: subMonths(new Date(), 12), to: new Date() }, + }, + { + code: "month-to-date", + label: "Month to date", + range: { from: startOfMonth(new Date()), to: new Date() }, + }, + { + code: "quarter-to-date", + label: "Quarter to date", + range: { from: startOfQuarter(new Date()), to: new Date() }, + }, + { + code: `${new Date().getFullYear()}`, + label: `${new Date().getFullYear()}`, + range: { + from: new Date(new Date().getFullYear(), 0, 1), + to: new Date(), + }, + }, + { + code: `${new Date().getFullYear() - 1}`, + label: `${new Date().getFullYear() - 1}`, + range: { + from: new Date(new Date().getFullYear() - 1, 0, 1), + to: new Date(new Date().getFullYear(), 0, 1), + }, + }, + { + code: "all-time", + label: "All time", + range: { from: undefined, to: undefined }, + }, + ] + + const [rangeName, setRangeName] = useState(defaultDate?.from ? "custom" : defaultRange) + const [dateRange, setDateRange] = useState(defaultDate) + + return ( + + + + + +
+ {predefinedRanges.map(({ code, label }) => ( + + ))} +
+ { + setRangeName("custom") + setDateRange(newDateRange) + onChange?.(newDateRange) + }} + numberOfMonths={2} + /> +
+
+ ) +} diff --git a/components/forms/select-category.tsx b/components/forms/select-category.tsx new file mode 100644 index 0000000..ce5ef89 --- /dev/null +++ b/components/forms/select-category.tsx @@ -0,0 +1,23 @@ +"use client" + +import { Category } from "@prisma/client" +import { SelectProps } from "@radix-ui/react-select" +import { FormSelect } from "./simple" + +export const FormSelectCategory = ({ + title, + categories, + emptyValue, + placeholder, + ...props +}: { title: string; categories: Category[]; emptyValue?: string; placeholder?: string } & SelectProps) => { + return ( + ({ code: category.code, name: category.name, color: category.color }))} + emptyValue={emptyValue} + placeholder={placeholder} + {...props} + /> + ) +} diff --git a/components/forms/select-currency.tsx b/components/forms/select-currency.tsx new file mode 100644 index 0000000..31ebe46 --- /dev/null +++ b/components/forms/select-currency.tsx @@ -0,0 +1,21 @@ +import { Currency } from "@prisma/client" +import { SelectProps } from "@radix-ui/react-select" +import { FormSelect } from "./simple" + +export const FormSelectCurrency = ({ + title, + currencies, + emptyValue, + placeholder, + ...props +}: { title: string; currencies: Currency[]; emptyValue?: string; placeholder?: string } & SelectProps) => { + return ( + ({ code: currency.code, name: `${currency.code} - ${currency.name}` }))} + emptyValue={emptyValue} + placeholder={placeholder} + {...props} + /> + ) +} diff --git a/components/forms/select-project.tsx b/components/forms/select-project.tsx new file mode 100644 index 0000000..56c4ffd --- /dev/null +++ b/components/forms/select-project.tsx @@ -0,0 +1,21 @@ +import { Project } from "@prisma/client" +import { SelectProps } from "@radix-ui/react-select" +import { FormSelect } from "./simple" + +export const FormSelectProject = ({ + title, + projects, + emptyValue, + placeholder, + ...props +}: { title: string; projects: Project[]; emptyValue?: string; placeholder?: string } & SelectProps) => { + return ( + ({ code: project.code, name: project.name, color: project.color }))} + emptyValue={emptyValue} + placeholder={placeholder} + {...props} + /> + ) +} diff --git a/components/forms/select-type.tsx b/components/forms/select-type.tsx new file mode 100644 index 0000000..e63f67c --- /dev/null +++ b/components/forms/select-type.tsx @@ -0,0 +1,18 @@ +import { SelectProps } from "@radix-ui/react-select" +import { FormSelect } from "./simple" + +export const FormSelectType = ({ + title, + emptyValue, + placeholder, + ...props +}: { title: string; emptyValue?: string; placeholder?: string } & SelectProps) => { + const items = [ + { code: "expense", name: "Expense" }, + { code: "income", name: "Income" }, + { code: "pending", name: "Pending" }, + { code: "other", name: "Other" }, + ] + + return +} diff --git a/components/forms/simple.tsx b/components/forms/simple.tsx new file mode 100644 index 0000000..8a582cf --- /dev/null +++ b/components/forms/simple.tsx @@ -0,0 +1,77 @@ +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Textarea } from "@/components/ui/textarea" +import { cn } from "@/lib/utils" +import { SelectProps } from "@radix-ui/react-select" +import { InputHTMLAttributes, TextareaHTMLAttributes } from "react" + +type FormInputProps = InputHTMLAttributes & { + title: string + hideIfEmpty?: boolean +} + +export function FormInput({ title, hideIfEmpty = false, ...props }: FormInputProps) { + if (hideIfEmpty && (!props.defaultValue || props.defaultValue.toString().trim() === "") && !props.value) { + return null + } + + return ( + + ) +} + +type FormTextareaProps = TextareaHTMLAttributes & { + title: string + hideIfEmpty?: boolean +} + +export function FormTextarea({ title, hideIfEmpty = false, ...props }: FormTextareaProps) { + if (hideIfEmpty && (!props.defaultValue || props.defaultValue.toString().trim() === "") && !props.value) { + return null + } + + return ( +