(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
This commit is contained in:
Vasily Zubarev
2025-03-13 00:30:47 +01:00
commit 0b98a2c307
153 changed files with 17271 additions and 0 deletions

20
.env.example Normal file
View File

@@ -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!"

51
.gitignore vendored Normal file
View File

@@ -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

13
.prettierrc Normal file
View File

@@ -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
}

59
Dockerfile Normal file
View File

@@ -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"]

21
LICENSE Normal file
View File

@@ -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.

213
README.md Normal file
View File

@@ -0,0 +1,213 @@
<div align="center"><a name="readme-top"></a>
<img src="public/logo/512.png" alt="" width="320">
# TaxHacker
I'm a small self-hosted accountant app that can help you deal with invoices, receipts and taxes with power of GenAI.<br/><br/>
[![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)
</div>
## 👋🏻 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
<br/>
[![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.

139
app/ai/analyze.ts Normal file
View File

@@ -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<AnalyzeAttachment[]> => {
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<AnalyzeAttachment> => {
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<string, string>,
fields: Field[],
categories: Category[] = [],
projects: Project[] = [],
attachments: AnalyzeAttachment[] = []
): Promise<{ success: boolean; data?: Record<string, any>; 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
}

49
app/ai/prompt.ts Normal file
View File

@@ -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
}

32
app/context.tsx Normal file
View File

@@ -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<NotificationContextType>({
notification: null,
showNotification: () => {},
})
export function NotificationProvider({ children }: { children: ReactNode }) {
const [notification, setNotification] = useState<Notification | null>(null)
const showNotification = (notification: Notification) => {
setNotification(notification)
}
return (
<NotificationContext.Provider value={{ notification, showNotification }}>{children}</NotificationContext.Provider>
)
}
export const useNotification = () => useContext(NotificationContext)

View File

@@ -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<string, any>)
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 })
}
}

52
app/files/actions.ts Normal file
View File

@@ -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 }
}

View File

@@ -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 })
}
}

10
app/files/page.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { Metadata } from "next"
import { notFound } from "next/navigation"
export const metadata: Metadata = {
title: "Uploading...",
}
export default function UploadStatusPage() {
notFound()
}

View File

@@ -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 })
}
}

85
app/globals.css Normal file
View File

@@ -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;
}
}

56
app/layout.tsx Normal file
View File

@@ -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 (
<html lang="en">
<head>
<meta name="theme-color" content="#ffffff" />
</head>
<body>
<NotificationProvider>
<ScreenDropArea>
<SidebarProvider>
<MobileMenu settings={settings} unsortedFilesCount={unsortedFilesCount} />
<AppSidebar settings={settings} unsortedFilesCount={unsortedFilesCount} />
<SidebarInset className="w-screen mt-[60px] md:mt-0">{children}</SidebarInset>
</SidebarProvider>
<Toaster />
</ScreenDropArea>
</NotificationProvider>
</body>
</html>
)
}
export const dynamic = "force-dynamic"

9
app/loading.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { Loader2 } from "lucide-react"
export default function AppLoading() {
return (
<div className="flex items-center justify-center h-full w-full">
<Loader2 className="w-10 h-10 animate-spin text-muted-foreground" />
</div>
)
}

30
app/page.tsx Normal file
View File

@@ -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<StatsFilters> }) {
const filters = await searchParams
const unsortedFiles = await getUnsortedFiles()
const settings = await getSettings()
return (
<div className="flex flex-col gap-5 p-5 w-full max-w-7xl self-center">
<div className="flex flex-col sm:flex-row gap-5 items-stretch">
<DashboardDropZoneWidget />
<DashboardUnsortedWidget files={unsortedFiles} />
</div>
{!settings.is_welcome_message_hidden && <WelcomeWidget />}
<Separator />
<StatsWidget filters={filters} />
</div>
)
}

131
app/settings/actions.ts Normal file
View File

@@ -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")
}

View File

@@ -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 }
}

View File

@@ -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 })
}
}

View File

@@ -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
}

View File

@@ -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 (
<div className="container flex flex-col gap-4">
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Download backup</h1>
<div className="flex flex-row gap-4">
<Link href="/settings/backups/database">
<Button>
<Download /> Download database.sqlite
</Button>
</Link>
<Link href="/settings/backups/files">
<Button>
<Download /> Download files archive
</Button>
</Link>
</div>
<div className="text-sm text-muted-foreground">
You can use any SQLite client to view the database.sqlite file contents
</div>
</div>
<Card className="flex flex-col gap-4 mt-24 p-5 bg-red-100 max-w-xl">
<h2 className="text-xl font-semibold">Restore database from backup</h2>
<div className="text-sm text-muted-foreground">
Warning: This will overwrite your current database and destroy all the data! Don't forget to download backup
first.
</div>
<form action={restoreBackup}>
<label>
<input type="file" name="file" />
</label>
<Button type="submit" variant="destructive" disabled={restorePending}>
{restorePending ? (
<>
<Loader2 className="animate-spin" /> Uploading new database...
</>
) : (
"Restore"
)}
</Button>
</form>
{restoreState?.error && <p className="text-red-500">{restoreState.error}</p>}
</Card>
</div>
)
}

View File

@@ -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 })
}
}

View File

@@ -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 (
<div className="container">
<h1 className="text-2xl font-bold mb-6">Categories</h1>
<CrudTable
items={categoriesWithActions}
columns={[
{ key: "name", label: "Name", editable: true },
{ key: "llm_prompt", label: "LLM Prompt", editable: true },
{ key: "color", label: "Color", editable: true },
]}
onDelete={async (code) => {
"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
}
)
}}
/>
</div>
)
}

View File

@@ -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 (
<div className="container">
<h1 className="text-2xl font-bold mb-6">Currencies</h1>
<CrudTable
items={currenciesWithActions}
columns={[
{ key: "code", label: "Code" },
{ key: "name", label: "Name", editable: true },
]}
onDelete={async (code) => {
"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 })
}}
/>
</div>
)
}

View File

@@ -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 (
<div className="container">
<h1 className="text-2xl font-bold mb-6">Custom Fields</h1>
<CrudTable
items={fieldsWithActions}
columns={[
{ key: "name", label: "Name", editable: true },
{ key: "type", label: "Type", editable: true },
{ key: "llm_prompt", label: "LLM Prompt", editable: true },
]}
onDelete={async (code) => {
"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
}
)
}}
/>
</div>
)
}

59
app/settings/layout.tsx Normal file
View File

@@ -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 (
<>
<div className="hidden space-y-6 p-10 pb-16 md:block">
<div className="space-y-0.5">
<h2 className="text-2xl font-bold tracking-tight">Settings</h2>
<p className="text-muted-foreground">Customize your settings here</p>
</div>
<Separator className="my-6" />
<div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
<aside className="-mx-4 lg:w-1/5">
<SideNav items={settingsCategories} />
</aside>
<div className="flex w-full">{children}</div>
</div>
</div>
</>
)
}

14
app/settings/llm/page.tsx Normal file
View File

@@ -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 (
<>
<div className="w-full max-w-2xl">
<LLMSettingsForm settings={settings} />
</div>
</>
)
}

18
app/settings/page.tsx Normal file
View File

@@ -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 (
<>
<div className="w-full max-w-2xl">
<GlobalSettingsForm settings={settings} currencies={currencies} categories={categories} />
</div>
</>
)
}

View File

@@ -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 (
<div className="container">
<h1 className="text-2xl font-bold mb-6">Projects</h1>
<CrudTable
items={projectsWithActions}
columns={[
{ key: "name", label: "Name", editable: true },
{ key: "llm_prompt", label: "LLM Prompt", editable: true },
{ key: "color", label: "Color", editable: true },
]}
onDelete={async (code) => {
"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 })
}}
/>
</div>
)
}

View File

@@ -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 (
<>
<header className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight">Transaction Details</h2>
</header>
<main>
<div className="flex flex-1 flex-col gap-4 pt-0">{children}</div>
</main>
</>
)
}

View File

@@ -0,0 +1,7 @@
import { Skeleton } from "@/components/ui/skeleton"
export default function Loading() {
return (
<Skeleton className="flex flex-row flex-wrap md:flex-nowrap justify-center items-start gap-10 p-5 bg-accent max-w-[1200px] min-h-[800px]" />
)
}

View File

@@ -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 (
<>
<Card className="flex flex-col md:flex-row flex-wrap justify-center items-start gap-10 p-5 bg-accent max-w-6xl">
<div className="flex-1">
<TransactionEditForm
transaction={transaction}
categories={categories}
currencies={currencies}
settings={settings}
fields={fields}
projects={projects}
/>
</div>
<div className="max-w-[320px] space-y-4">
<TransactionFiles transaction={transaction} files={files} />
</div>
</Card>
{transaction.text && (
<Card className="flex items-stretch p-5 mt-10 max-w-6xl">
<div className="flex-1">
<FormTextarea
title="Recognized Text"
name="text"
defaultValue={transaction.text || ""}
hideIfEmpty={true}
className="w-full h-[400px]"
/>
</div>
</Card>
)}
</>
)
}

143
app/transactions/actions.ts Normal file
View File

@@ -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}` }
}
}

View File

@@ -0,0 +1,3 @@
export default async function TransactionsLayout({ children }: { children: React.ReactNode }) {
return <div className="flex flex-col gap-4 p-4">{children}</div>
}

View File

@@ -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 (
<>
<header className="flex items-center justify-between mb-12">
<h2 className="text-3xl font-bold tracking-tight">Transactions</h2>
<div className="flex gap-2">
<Button variant="outline">
<Download />
Export
</Button>
<Button>
<Plus /> Add Transaction
</Button>
</div>
</header>
<main>
<div className="flex flex-col gap-3 w-full">
{[...Array(20)].map((_, i) => (
<Skeleton key={i} className="h-8" />
))}
</div>
</main>
</>
)
}

71
app/transactions/page.tsx Normal file
View File

@@ -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<TransactionFilters> }) {
const filters = await searchParams
const transactions = await getTransactions(filters)
const categories = await getCategories()
const projects = await getProjects()
const fields = await getFields()
return (
<>
<header className="flex flex-wrap items-center justify-between gap-2 mb-8">
<h2 className="text-3xl font-bold tracking-tight">Transactions</h2>
<div className="flex gap-2">
<ExportTransactionsDialog filters={filters} fields={fields} categories={categories} projects={projects}>
<Button variant="outline">
<Download />
<span className="hidden md:block">Export</span>
</Button>
</ExportTransactionsDialog>
<NewTransactionDialog>
<Button>
<Plus /> <span className="hidden md:block">Add Transaction</span>
</Button>
</NewTransactionDialog>
</div>
</header>
<TransactionSearchAndFilters categories={categories} projects={projects} />
<main>
<TransactionList transactions={transactions} />
{transactions.length === 0 && (
<div className="flex flex-col items-center justify-center gap-2 h-full min-h-[400px]">
<p className="text-muted-foreground">
You don't seem to have any transactions yet. Let's start and create the first one!
</p>
<div className="flex flex-row gap-5 mt-8">
<UploadButton>
<Upload /> Analyze New Invoice
</UploadButton>
<NewTransactionDialog>
<Button variant="outline">
<Plus />
Add Manually
</Button>
</NewTransactionDialog>
</div>
</div>
)}
</main>
</>
)
}

63
app/unsorted/actions.ts Normal file
View File

@@ -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" }
}
}

3
app/unsorted/layout.tsx Normal file
View File

@@ -0,0 +1,3 @@
export default function UnsortedLayout({ children }: { children: React.ReactNode }) {
return <div className="flex flex-col gap-4 p-4 max-w-6xl">{children}</div>
}

103
app/unsorted/page.tsx Normal file
View File

@@ -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 (
<>
<header className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight">You have {files.length} unsorted files</h2>
</header>
{!settings.openai_api_key && (
<Alert>
<Settings className="h-4 w-4 mt-2" />
<div className="flex flex-row justify-between pt-2">
<div className="flex flex-col">
<AlertTitle>ChatGPT API Key is required for analyzing files</AlertTitle>
<AlertDescription>
Please set your OpenAI API key in the settings to use the analyze form.
</AlertDescription>
</div>
<Link href="/settings/llm">
<Button>Go to Settings</Button>
</Link>
</div>
</Alert>
)}
<main className="flex flex-col gap-5">
{files.map((file) => (
<Card
key={file.id}
id={file.id}
className="flex flex-row flex-wrap md:flex-nowrap justify-center items-start gap-5 p-5 bg-accent"
>
<div className="w-full max-w-[500px]">
<Card>
<FilePreview file={file} />
</Card>
</div>
<div className="w-full">
<AnalyzeForm
file={file}
categories={categories}
projects={projects}
currencies={currencies}
fields={fields}
settings={settings}
/>
</div>
</Card>
))}
{files.length == 0 && (
<div className="flex flex-col items-center justify-center gap-2 h-full min-h-[600px]">
<PartyPopper className="w-12 h-12 text-muted-foreground" />
<p className="pt-4 text-muted-foreground">Everything is clear! Congrats!</p>
<p className="flex flex-row gap-2 text-muted-foreground">
<span>Drag and drop new files here to analyze</span>
<Upload />
</p>
<div className="flex flex-row gap-5 mt-8">
<UploadButton>
<Upload /> Upload New File
</UploadButton>
<Button variant="outline" asChild>
<Link href="/transactions">
<FileText />
Go to Transactions
</Link>
</Button>
</div>
</div>
)}
</main>
</>
)
}

21
components.json Normal file
View File

@@ -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"
}

View File

@@ -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<HTMLInputElement>) => {
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 (
<div className="flex w-full h-full">
<label className="relative w-full h-full border-2 border-dashed rounded-lg transition-colors hover:border-primary cursor-pointer">
<input
type="file"
id="fileInput"
className="hidden"
multiple
accept={FILE_ACCEPTED_MIMETYPES}
onChange={handleFileChange}
/>
<div className="flex flex-col items-center justify-center gap-4 p-8 text-center h-full">
{isUploading ? (
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
) : (
<Camera className="h-8 w-8 text-muted-foreground" />
)}
<div>
<p className="text-lg font-medium">
{isUploading ? "Uploading..." : "Take a photo or drop your files here"}
</p>
{!uploadError && (
<p className="text-sm text-muted-foreground">
upload receipts, invoices and any other documents for me to scan
</p>
)}
{uploadError && <p className="text-red-500">{uploadError}</p>}
</div>
</div>
</label>
</div>
)
}

View File

@@ -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<StatsFilters>(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 (
<DateRangePicker
defaultDate={{
from: filters?.dateFrom ? new Date(filters.dateFrom) : undefined,
to: filters?.dateTo ? new Date(filters.dateTo) : undefined,
}}
defaultRange={defaultRange}
onChange={(date) => {
setFilters({
dateFrom: date && date.from ? format(date.from, "yyyy-MM-dd") : undefined,
dateTo: date && date.to ? format(date.to, "yyyy-MM-dd") : undefined,
})
}}
/>
)
}

View File

@@ -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<string, ProjectStats>
}) {
return (
<div className="grid gap-4 md:grid-cols-2">
{projects.map((project) => (
<Card key={project.code}>
<CardHeader>
<CardTitle>
<Link href={`/transactions?projectCode=${project.code}`}>
<Badge className="text-lg" style={{ backgroundColor: project.color }}>
{project.name}
</Badge>
</Link>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-4 justify-between items-center">
<div>
<div className="text-sm font-medium text-muted-foreground">Income</div>
<div className="text-2xl font-bold text-green-500">
{Object.entries(statsPerProject[project.code]?.totalIncomePerCurrency).map(([currency, total]) => (
<div
key={currency}
className="flex flex-col gap-2 font-bold text-green-500 text-base first:text-2xl"
>
{formatCurrency(total, currency)}
</div>
))}
{!Object.entries(statsPerProject[project.code]?.totalIncomePerCurrency).length && (
<div className="font-bold text-base first:text-2xl">0.00</div>
)}
</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Expenses</div>
<div className="text-2xl font-bold text-red-500">
{Object.entries(statsPerProject[project.code]?.totalExpensesPerCurrency).map(([currency, total]) => (
<div key={currency} className="flex flex-col gap-2 font-bold text-red-500 text-base first:text-2xl">
{formatCurrency(total, currency)}
</div>
))}
{!Object.entries(statsPerProject[project.code]?.totalExpensesPerCurrency).length && (
<div className="font-bold text-base first:text-2xl">0.00</div>
)}
</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Profit</div>
<div className="text-2xl font-bold">
{Object.entries(statsPerProject[project.code]?.profitPerCurrency).map(([currency, total]) => (
<div key={currency} className="flex flex-col gap-2 items-center text-2xl font-bold text-green-500">
{formatCurrency(total, currency)}
</div>
))}
{!Object.entries(statsPerProject[project.code]?.profitPerCurrency).length && (
<div className="text-2xl font-bold">0.00</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
))}
<Link
href="/settings/projects"
className="flex items-center justify-center gap-2 border-dashed border-2 border-gray-300 rounded-md p-4 text-muted-foreground hover:text-primary hover:border-primary"
>
<Plus className="h-4 w-4" />
Create New Project
</Link>
</div>
)
}

View File

@@ -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 (
<div className="flex flex-col gap-5">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Overview</h2>
<FiltersWidget defaultFilters={filters} defaultRange="last-12-months" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Income</CardTitle>
<ArrowUp className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
{Object.entries(stats.totalIncomePerCurrency).map(([currency, total]) => (
<div key={currency} className="flex gap-2 items-center font-bold text-base first:text-2xl">
{formatCurrency(total, currency)}
</div>
))}
{!Object.entries(stats.totalIncomePerCurrency).length && <div className="text-2xl font-bold">0.00</div>}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Expenses</CardTitle>
<ArrowDown className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
{Object.entries(stats.totalExpensesPerCurrency).map(([currency, total]) => (
<div key={currency} className="flex gap-2 items-center font-bold text-red-500 text-base first:text-2xl">
{formatCurrency(total, currency)}
</div>
))}
{!Object.entries(stats.totalExpensesPerCurrency).length && <div className="text-2xl font-bold">0.00</div>}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Net Profit</CardTitle>
<BicepsFlexed className="h-4 w-4" />
</CardHeader>
<CardContent>
{Object.entries(stats.profitPerCurrency).map(([currency, total]) => (
<div key={currency} className="flex gap-2 items-center font-bold text-green-500 text-base first:text-2xl">
{formatCurrency(total, currency)}
</div>
))}
{!Object.entries(stats.profitPerCurrency).length && <div className="text-2xl font-bold">0.00</div>}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Processed Transactions</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.invoicesProcessed}</div>
</CardContent>
</Card>
</div>
<div>
<h2 className="text-2xl font-bold">Projects</h2>
</div>
<ProjectsWidget projects={projects} statsPerProject={statsPerProject} />
</div>
)
}

View File

@@ -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 (
<Card className="w-full h-full sm:max-w-xs bg-accent">
<CardHeader>
<CardTitle>
<Link href="/unsorted">
{files.length > 0 ? `${files.length} unsorted files` : "No unsorted files"} &rarr;
</Link>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2">
{files.slice(0, 3).map((file) => (
<Link
href={`/unsorted/#${file.id}`}
key={file.id}
className="rounded-md p-2 bg-background hover:bg-black hover:text-white"
>
<div className="flex flex-row gap-2">
<FilePlus className="w-8 h-8" />
<div className="grid flex-1 text-left leading-tight">
<span className="truncate text-xs font-semibold">{file.filename}</span>
<span className="truncate text-xs">{file.mimetype}</span>
</div>
</div>
</Link>
))}
{files.length == 0 && (
<div className="flex flex-col items-center justify-center gap-2 text-sm text-muted-foreground h-full min-h-[100px]">
<PartyPopper className="w-5 h-5" />
<span>Everything is clear! Congrats!</span>
</div>
)}
</div>
</CardContent>
</Card>
)
}

View File

@@ -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 (
<Card className="flex flex-col md:flex-row items-start gap-10 p-10 w-full">
<img src="/logo/1024.png" alt="Logo" className="w-64 h-64" />
<div className="flex flex-col">
<CardTitle className="text-2xl font-bold">Hey, I'm TaxHacker 👋</CardTitle>
<CardDescription className="mt-5">
<p className="mb-3">
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:
</p>
<ul className="mb-5 list-disc pl-5 space-y-1">
<li>
<strong>Upload me a photo or a PDF</strong> and I will recognize, categorize and save it as a transaction
for your tax advisor.
</li>
<li>
I can <strong>automatically convert currencies</strong> and look up exchange rates for a given date.
</li>
<li>
I even <strong>support crypto!</strong> Historical exchange rates for staking too.
</li>
<li>
All <strong>LLM prompts are configurable</strong>: for fields, categories and projects. You can go to
settings and change them.
</li>
<li>
I save data in a <strong>local SQLite database</strong> and can export it to CSV and ZIP archives.
</li>
<li>
You can even <strong>create your own new fields</strong> to be analyzed and they will be included in the
CSV export for your tax advisor.
</li>
<li>
I'm still <strong>very young</strong> and can make mistakes. Use me at your own risk!
</li>
</ul>
<p className="mb-3">
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!
</p>
</CardDescription>
<div className="mt-2">
<Link href="https://github.com/vas3k/TaxHacker" className="text-blue-500 hover:underline">
Source Code
</Link>
<span className="mx-2">|</span>
<Link href="https://github.com/vas3k/TaxHacker/issues" className="text-blue-500 hover:underline">
Request New Feature
</Link>
<span className="mx-2">|</span>
<Link href="https://github.com/vas3k/TaxHacker/issues" className="text-blue-500 hover:underline">
Report a Bug
</Link>
<span className="mx-2">|</span>
<Link href="mailto:me@vas3k.ru" className="text-blue-500 hover:underline">
Contact the Author
</Link>
</div>
<div className="flex flex-wrap gap-2 mt-8">
{settings.openai_api_key === "" && (
<Link href="/settings/llm">
<Button>
<Key className="h-4 w-4" />
Please give your ChatGPT key here
</Button>
</Link>
)}
<Link href="/settings">
<Button variant="outline">
<Banknote className="h-4 w-4" />
Default Currency: {settings.default_currency}
</Button>
</Link>
<Link href="/settings/categories">
<Button variant="outline">
<ChartBarStacked className="h-4 w-4" />
Categories
</Button>
</Link>
<Link href="/settings/projects">
<Button variant="outline">
<FolderOpenDot className="h-4 w-4" />
Projects
</Button>
</Link>
<Link href="/settings/fields">
<Button variant="outline">
<TextCursorInput className="h-4 w-4" />
Custom Fields
</Button>
</Link>
</div>
</div>
</Card>
)
}

View File

@@ -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<TransactionFilters>(filters)
const [exportFields, setExportFields] = useState<string[]>(
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 (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">Export Transactions</DialogTitle>
<DialogDescription>Export selected transactions and files as a CSV file or a ZIP archive</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4">
{filters.search && (
<div className="flex flex-row items-center gap-2">
<span className="text-sm font-medium">Search query:</span>
<span className="text-sm">{filters.search}</span>
</div>
)}
<div className="flex flex-row items-center gap-2">
<span className="text-sm font-medium">Time range:</span>
<DateRangePicker
defaultDate={{
from: filters?.dateFrom ? new Date(filters.dateFrom) : undefined,
to: filters?.dateTo ? new Date(filters.dateTo) : undefined,
}}
defaultRange="all-time"
onChange={(date) => {
setExportFilters({
...exportFilters,
dateFrom: date?.from ? formatDate(date.from, "yyyy-MM-dd") : undefined,
dateTo: date?.to ? formatDate(date.to, "yyyy-MM-dd") : undefined,
})
}}
/>
</div>
<div className="flex flex-row items-center gap-2">
<FormSelectCategory
title="Category"
name="category"
categories={categories}
value={exportFilters.categoryCode}
onValueChange={(value) => setExportFilters({ ...exportFilters, categoryCode: value })}
placeholder="All Categories"
emptyValue="All Categories"
/>
<FormSelectProject
title="Project"
name="project"
projects={projects}
value={exportFilters.projectCode}
onValueChange={(value) => setExportFilters({ ...exportFilters, projectCode: value })}
placeholder="All Projects"
emptyValue="All Projects"
/>
</div>
</div>
<Separator />
<div className="text-lg font-bold">Fields to be included in CSV</div>
<div className="grid grid-cols-2 gap-2">
{fields.map((field) => (
<div key={field.code} className="inline-flex gap-2">
<label className="flex gap-1">
<input
type="checkbox"
name={field.code}
checked={exportFields.includes(field.code)}
onChange={(e) =>
setExportFields(
e.target.checked ? [...exportFields, field.code] : exportFields.filter((f) => f !== field.code)
)
}
/>
<span>{field.name}</span>
</label>
</div>
))}
</div>
<Separator />
<div>
<label className="flex items-center gap-3 text-lg">
<input
type="checkbox"
name="attachments"
className="h-[20px] w-[20px]"
checked={includeAttachments}
onChange={(e) => setIncludeAttachments(e.target.checked)}
/>
<span className="flex flex-col">
<span className="font-medium">Include attached files</span>
<span className="text-sm">(create a zip archive)</span>
</span>
</label>
</div>
</div>
<DialogFooter className="sm:justify-end">
<Button type="button" onClick={handleSubmit}>
Export Transactions
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -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 (
<>
<div className="flex flex-col gap-2 p-4 overflow-hidden">
<div className="aspect-[3/4]">
<Image
src={`/files/preview/${file.id}`}
alt={file.filename}
width={300}
height={400}
className={`${
isEnlarged
? "fixed inset-0 z-50 m-auto w-screen h-screen object-contain cursor-zoom-out"
: "w-full h-full object-contain cursor-zoom-in"
}`}
onClick={() => setIsEnlarged(!isEnlarged)}
/>
{isEnlarged && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40" onClick={() => setIsEnlarged(false)} />
)}
</div>
<div className="flex flex-col gap-2 mt-2 overflow-hidden">
<h2 className="text-md underline font-semibold overflow-ellipsis">
<Link href={`/files/download/${file.id}`}>{file.filename}</Link>
</h2>
<p className="text-sm overflow-ellipsis">
<strong>Type:</strong> {file.mimetype}
</p>
<p className="text-sm">
<strong>Size:</strong> {fileSize < 1 ? (fileSize * 1024).toFixed(2) + " KB" : fileSize.toFixed(2) + " MB"}
</p>
<p className="text-xs overflow-ellipsis">
<strong>Path:</strong> {file.path}
</p>
</div>
</div>
</>
)
}

View File

@@ -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<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current++
if (dragCounter.current === 1) {
setIsDragging(true)
}
}
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
}
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current--
if (dragCounter.current === 0) {
setIsDragging(false)
}
}
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
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 (
<div className="relative min-h-screen w-full">
{children}
{isDragging && (
<div
className="fixed inset-0 bg-opacity-20 backdrop-blur-sm z-50 flex items-center justify-center"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-xl text-center">
<CloudUpload className="h-16 w-16 mx-auto mb-4 text-primary" />
<h3 className="text-xl font-semibold mb-2">Drop Files to Upload</h3>
<p className="text-gray-600 dark:text-gray-400">Drop anywhere on the screen</p>
</div>
</div>
)}
{isUploading && (
<div className="fixed inset-0 bg-opacity-20 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-xl text-center">
<Loader2 className="h-16 w-16 mx-auto mb-4 text-primary animate-spin" />
<h3 className="text-xl font-semibold mb-2">Uploading...</h3>
</div>
</div>
)}
{uploadError && (
<div className="fixed inset-0 bg-opacity-20 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-xl text-center">
<AlertCircle className="h-16 w-16 mx-auto mb-4 text-red-500" />
<h3 className="text-xl font-semibold mb-2">Upload Error</h3>
<p className="text-gray-600 dark:text-gray-400">{uploadError}</p>
</div>
</div>
)}
</div>
)
}

View File

@@ -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<typeof Button>) {
const router = useRouter()
const { showNotification } = useNotification()
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploadError, setUploadError] = useState("")
const [isUploading, setIsUploading] = useState(false)
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div>
<input
ref={fileInputRef}
type="file"
id="fileInput"
className="hidden"
multiple
accept={FILE_ACCEPTED_MIMETYPES}
onChange={handleFileChange}
/>
<Button onClick={handleButtonClick} disabled={isUploading} type="button" {...props}>
{isUploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Uploading...
</>
) : (
<>{children}</>
)}
</Button>
{uploadError && <span className="text-red-500"> {uploadError}</span>}
</div>
)
}

View File

@@ -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 (
<div className="flex flex-row gap-2 items-center text-muted-foreground">
{isLoading ? (
<div className="flex flex-row gap-2">
<Loader2 className="animate-spin" />
<div>Loading exchange rates...</div>
</div>
) : (
<div className="flex items-center gap-2">
<div>{formatCurrency(originalTotal * 100, originalCurrencyCode)}</div>
<div>=</div>
<div>{formatCurrency(originalTotal * 100 * exchangeRate, targetCurrencyCode).slice(0, 1)}</div>
<input
type="number"
step="0.01"
name="convertedTotal"
value={(originalTotal * exchangeRate).toFixed(2)}
onChange={(e) => onChange?.(parseFloat(e.target.value))}
className="w-32 rounded-md border border-input bg-transparent px-1"
/>
<div className="text-xs">(exchange rate on {format(normalizedDate, "LLLL dd, yyyy")})</div>
</div>
)}
</div>
)
}

View File

@@ -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<string>(defaultDate?.from ? "custom" : defaultRange)
const [dateRange, setDateRange] = useState<DateRange | undefined>(defaultDate)
return (
<Popover>
<PopoverTrigger asChild>
<Button
id="date"
variant={"outline"}
className={cn(
"w-auto min-w-[130px] justify-start text-left font-normal",
rangeName === "all-time" && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{rangeName === "custom" ? (
dateRange?.from ? (
dateRange.to ? (
`${format(dateRange.from, "LLL dd, y")} - ${format(dateRange.to, "LLL dd, y")}`
) : (
format(dateRange.from, "LLL dd, y")
)
) : (
<span>???</span>
)
) : (
predefinedRanges.find((range) => range.code === rangeName)?.label
)}
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-row gap-3 w-auto p-0" align="end">
<div className="flex flex-col gap-3 p-3 border-r">
{predefinedRanges.map(({ code, label }) => (
<Button
key={code}
variant="ghost"
className="justify-start pr-5"
onClick={() => {
setRangeName(code)
const newDateRange = predefinedRanges.find((range) => range.code === code)?.range
setDateRange(newDateRange)
onChange?.(newDateRange)
}}
>
{label}
</Button>
))}
</div>
<Calendar
initialFocus
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={(newDateRange) => {
setRangeName("custom")
setDateRange(newDateRange)
onChange?.(newDateRange)
}}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
)
}

View File

@@ -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 (
<FormSelect
title={title}
items={categories.map((category) => ({ code: category.code, name: category.name, color: category.color }))}
emptyValue={emptyValue}
placeholder={placeholder}
{...props}
/>
)
}

View File

@@ -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 (
<FormSelect
title={title}
items={currencies.map((currency) => ({ code: currency.code, name: `${currency.code} - ${currency.name}` }))}
emptyValue={emptyValue}
placeholder={placeholder}
{...props}
/>
)
}

View File

@@ -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 (
<FormSelect
title={title}
items={projects.map((project) => ({ code: project.code, name: project.name, color: project.color }))}
emptyValue={emptyValue}
placeholder={placeholder}
{...props}
/>
)
}

View File

@@ -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 <FormSelect title={title} items={items} emptyValue={emptyValue} placeholder={placeholder} {...props} />
}

View File

@@ -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<HTMLInputElement> & {
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 (
<label className="flex flex-col gap-1">
<span className="text-sm font-medium">{title}</span>
<Input {...props} className={cn("bg-background", props.className)} />
</label>
)
}
type FormTextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
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 (
<label className="flex flex-col gap-1">
<span className="text-sm font-medium">{title}</span>
<Textarea {...props} className={cn("bg-background", props.className)} />
</label>
)
}
export const FormSelect = ({
title,
items,
emptyValue,
placeholder,
...props
}: {
title: string
items: Array<{ code: string; name: string; color?: string }>
emptyValue?: string
placeholder?: string
} & SelectProps) => {
return (
<span className="flex flex-col gap-1">
<span className="text-sm font-medium">{title}</span>
<Select {...props}>
<SelectTrigger className="w-full min-w-[150px] bg-background">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{emptyValue && <SelectItem value="-">{emptyValue}</SelectItem>}
{items.map((item) => (
<SelectItem key={item.code} value={item.code}>
<div className="flex items-center gap-2 text-base pr-2">
{item.color && <div className="w-2 h-2 rounded-full" style={{ backgroundColor: item.color }} />}
{item.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</span>
)
}

View File

@@ -0,0 +1,165 @@
"use client"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { CircleCheck, Edit, Trash2 } from "lucide-react"
import { useOptimistic, useState } from "react"
interface CrudProps<T> {
items: T[]
columns: {
key: keyof T
label: string
type?: "text" | "number" | "checkbox"
editable?: boolean
}[]
onDelete: (id: string) => Promise<void>
onAdd: (data: Partial<T>) => Promise<void>
onEdit?: (id: string, data: Partial<T>) => Promise<void>
}
export function CrudTable<T extends { [key: string]: any }>({ items, columns, onDelete, onAdd, onEdit }: CrudProps<T>) {
const [isAdding, setIsAdding] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [newItem, setNewItem] = useState<Partial<T>>({})
const [editingItem, setEditingItem] = useState<Partial<T>>({})
const [optimisticItems, addOptimisticItem] = useOptimistic(items, (state, newItem: T) => [...state, newItem])
const handleAdd = async () => {
try {
await onAdd(newItem)
setIsAdding(false)
setNewItem({})
} catch (error) {
console.error("Failed to add item:", error)
}
}
const handleEdit = async (id: string) => {
if (!onEdit) return
try {
await onEdit(id, editingItem)
setEditingId(null)
setEditingItem({})
} catch (error) {
console.error("Failed to edit item:", error)
}
}
const startEditing = (item: T) => {
setEditingId(item.code || item.id)
setEditingItem(item)
}
const handleDelete = async (id: string) => {
try {
await onDelete(id)
} catch (error) {
console.error("Failed to delete item:", error)
}
}
return (
<div className="space-y-4">
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead key={String(column.key)}>{column.label}</TableHead>
))}
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{optimisticItems.map((item, index) => (
<TableRow key={index}>
{columns.map((column) => (
<TableCell key={String(column.key)} className="first:font-semibold">
{editingId === (item.code || item.id) && column.editable ? (
<Input
type={column.type || "text"}
value={editingItem[column.key] || ""}
onChange={(e) =>
setEditingItem({
...editingItem,
[column.key]: column.type === "checkbox" ? e.target.checked : e.target.value,
})
}
/>
) : column.type === "checkbox" ? (
item[column.key] ? (
<CircleCheck />
) : (
""
)
) : (
item[column.key]
)}
</TableCell>
))}
<TableCell>
<div className="flex gap-2">
{editingId === (item.code || item.id) ? (
<>
<Button size="sm" onClick={() => handleEdit(item.code || item.id)}>
Save
</Button>
<Button size="sm" variant="outline" onClick={() => setEditingId(null)}>
Cancel
</Button>
</>
) : (
<>
{onEdit && (
<Button variant="ghost" size="icon" onClick={() => startEditing(item)}>
<Edit />
</Button>
)}
{item.isDeletable && (
<Button variant="ghost" size="icon" onClick={() => handleDelete(item.code || item.id)}>
<Trash2 />
</Button>
)}
</>
)}
</div>
</TableCell>
</TableRow>
))}
{isAdding && (
<TableRow>
{columns.map((column) => (
<TableCell key={String(column.key)} className="first:font-semibold">
{column.editable && (
<Input
type={column.type || "text"}
value={newItem[column.key] || ""}
onChange={(e) =>
setNewItem({
...newItem,
[column.key]: column.type === "checkbox" ? e.target.checked : e.target.value,
})
}
/>
)}
</TableCell>
))}
<TableCell>
<div className="flex gap-2">
<Button size="sm" onClick={handleAdd}>
Save
</Button>
<Button size="sm" variant="outline" onClick={() => setIsAdding(false)}>
Cancel
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{!isAdding && <Button onClick={() => setIsAdding(true)}>Add New</Button>}
</div>
)
}

View File

@@ -0,0 +1,59 @@
"use client"
import { saveSettingsAction } from "@/app/settings/actions"
import { FormSelectCategory } from "@/components/forms/select-category"
import { FormSelectCurrency } from "@/components/forms/select-currency"
import { FormSelectType } from "@/components/forms/select-type"
import { FormInput } from "@/components/forms/simple"
import { Button } from "@/components/ui/button"
import { Category, Currency } from "@prisma/client"
import { CircleCheckBig } from "lucide-react"
import { useActionState } from "react"
export default function GlobalSettingsForm({
settings,
currencies,
categories,
}: {
settings: Record<string, string>
currencies: Currency[]
categories: Category[]
}) {
const [saveState, saveAction, pending] = useActionState(saveSettingsAction, null)
return (
<form action={saveAction} className="space-y-4">
<FormInput title="App Title" name="app_title" defaultValue={settings.app_title} />
<FormSelectCurrency
title="Default Currency"
name="default_currency"
defaultValue={settings.default_currency}
currencies={currencies}
/>
<FormSelectType title="Default Transaction Type" name="default_type" defaultValue={settings.default_type} />
<FormSelectCategory
title="Default Transaction Category"
name="default_category"
defaultValue={settings.default_category}
categories={categories}
/>
<div className="flex flex-row items-center gap-4">
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save Settings"}
</Button>
{saveState?.success && (
<p className="text-green-500 flex flex-row items-center gap-2">
<CircleCheckBig />
Saved!
</p>
)}
</div>
{saveState?.error && <p className="text-red-500">{saveState.error}</p>}
</form>
)
}

View File

@@ -0,0 +1,38 @@
"use client"
import { saveSettingsAction } from "@/app/settings/actions"
import { FormInput, FormTextarea } from "@/components/forms/simple"
import { Button } from "@/components/ui/button"
import { CircleCheckBig } from "lucide-react"
import { useActionState } from "react"
export default function LLMSettingsForm({ settings }: { settings: Record<string, string> }) {
const [saveState, saveAction, pending] = useActionState(saveSettingsAction, null)
return (
<form action={saveAction} className="space-y-4">
<FormInput title="OpenAI API Key" name="openai_api_key" defaultValue={settings.openai_api_key} />
<FormTextarea
title="Prompt for Analyze Transaction"
name="prompt_analyse_new_file"
defaultValue={settings.prompt_analyse_new_file}
className="h-96"
/>
<div className="flex flex-row items-center gap-4">
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save Settings"}
</Button>
{saveState?.success && (
<p className="text-green-500 flex flex-row items-center gap-2">
<CircleCheckBig />
Saved!
</p>
)}
</div>
{saveState?.error && <p className="text-red-500">{saveState.error}</p>}
</form>
)
}

View File

@@ -0,0 +1,35 @@
"use client"
import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import Link from "next/link"
import { usePathname } from "next/navigation"
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
items: {
href: string
title: string
}[]
}
export function SideNav({ className, items, ...props }: SidebarNavProps) {
const pathname = usePathname()
return (
<nav className={cn("flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1", className)} {...props}>
{items.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
buttonVariants({ variant: "ghost" }),
pathname === item.href ? "bg-muted hover:bg-muted" : "hover:bg-transparent hover:underline",
"justify-start"
)}
>
{item.title}
</Link>
))}
</nav>
)
}

View File

@@ -0,0 +1,8 @@
export function Blinker() {
return (
<span className="relative flex size-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75"></span>
<span className="relative inline-flex size-3 rounded-full bg-sky-500"></span>
</span>
)
}

View File

@@ -0,0 +1,33 @@
"use client"
import Link from "next/link"
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"
import { useSidebar } from "../ui/sidebar"
export default function MobileMenu({
settings,
unsortedFilesCount,
}: {
settings: Record<string, string>
unsortedFilesCount: number
}) {
const { toggleSidebar } = useSidebar()
return (
<menu className="flex flex-row gap-2 p-2 items-center justify-between fixed top-0 left-0 w-full z-50 border-b-2 border-solid bg-background md:hidden">
<Avatar className="h-10 w-10 rounded-lg cursor-pointer" onClick={toggleSidebar}>
<AvatarImage src="/logo/256.png" />
<AvatarFallback className="rounded-lg">AI</AvatarFallback>
</Avatar>
<Link href="/" className="text-lg font-bold">
{settings.app_title}
</Link>
<Link
href="/unsorted"
className="flex h-6 w-6 items-center justify-center rounded-full bg-primary text-sm font-medium text-primary-foreground"
>
{unsortedFilesCount}
</Link>
</menu>
)
}

View File

@@ -0,0 +1,32 @@
"use client"
import { cn } from "@/lib/utils"
import { usePathname } from "next/navigation"
import { ComponentProps } from "react"
import { SidebarMenuItem } from "../ui/sidebar"
export function SidebarMenuItemWithHighlight({
href,
children,
className,
...props
}: { href: string } & ComponentProps<typeof SidebarMenuItem>) {
const pathname = usePathname()
let isActive = false
if (href === "/") {
isActive = pathname === href
} else {
isActive = pathname.startsWith(href)
}
return (
<SidebarMenuItem
className={cn(isActive && "bg-sidebar-accent font-medium text-sidebar-accent-foreground", className)}
{...props}
>
{children}
</SidebarMenuItem>
)
}
// bg-primary text-primary-foreground

View File

@@ -0,0 +1,139 @@
"use client"
import { useNotification } from "@/app/context"
import { UploadButton } from "@/components/files/upload-button"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar"
import { ClockArrowUp, FileText, LayoutDashboard, Settings, Sparkles, Upload } from "lucide-react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { useEffect } from "react"
import { Blinker } from "./blinker"
import { SidebarMenuItemWithHighlight } from "./sidebar-item"
export function AppSidebar({
settings,
unsortedFilesCount,
}: {
settings: Record<string, string>
unsortedFilesCount: number
}) {
const { setOpenMobile } = useSidebar()
const pathname = usePathname()
const { notification } = useNotification()
// Hide sidebar on mobile when clicking an item
useEffect(() => {
setOpenMobile(false)
}, [pathname, setOpenMobile])
return (
<>
<Sidebar collapsible="icon">
<SidebarHeader>
<Link href="/" className="flex items-center gap-2 p-2">
<Avatar className="h-12 w-12 rounded-lg">
<AvatarImage src="/logo/256.png" />
<AvatarFallback className="rounded-lg">AI</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left leading-tight">
<span className="truncate font-semibold">{settings.app_title}</span>
<span className="truncate text-xs">Beta</span>
</div>
<SidebarTrigger className="md:hidden" />
</Link>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<UploadButton className="w-full mt-4 mb-2">
<Upload className="mr-2 h-4 w-4" />
<span>Upload</span>
</UploadButton>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItemWithHighlight href="/">
<SidebarMenuButton asChild>
<Link href="/">
<LayoutDashboard />
<span>Home</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItemWithHighlight>
<SidebarMenuItemWithHighlight href="/transactions">
<SidebarMenuButton asChild>
<Link href="/transactions">
<FileText />
<span>Transactions</span>
{notification && notification.code === "sidebar.transactions" && notification.message && (
<Blinker />
)}
<span></span>
</Link>
</SidebarMenuButton>
</SidebarMenuItemWithHighlight>
<SidebarMenuItemWithHighlight href="/unsorted">
<SidebarMenuButton asChild>
<Link href="/unsorted">
<ClockArrowUp />
<span>Unsorted</span>
{unsortedFilesCount > 0 && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs font-medium text-primary-foreground">
{unsortedFilesCount}
</span>
)}
{notification && notification.code === "sidebar.unsorted" && notification.message && <Blinker />}
<span></span>
</Link>
</SidebarMenuButton>
</SidebarMenuItemWithHighlight>
<SidebarMenuItemWithHighlight href="/settings">
<SidebarMenuButton asChild>
<Link href="/settings">
<Settings />
<span>Settings</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItemWithHighlight>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarRail />
<SidebarFooter>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link href="https://vas3k.com/donate/" target="_blank">
<Sparkles />
Thank the author
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarFooter>
</Sidebar>
</>
)
}

View File

@@ -0,0 +1,129 @@
"use client"
import { createTransactionAction } from "@/app/transactions/actions"
import { FormSelectCategory } from "@/components/forms/select-category"
import { FormSelectCurrency } from "@/components/forms/select-currency"
import { FormSelectProject } from "@/components/forms/select-project"
import { FormSelectType } from "@/components/forms/select-type"
import { FormInput, FormTextarea } from "@/components/forms/simple"
import { Button } from "@/components/ui/button"
import { Category, Currency, Project } from "@prisma/client"
import { format } from "date-fns"
import { Loader2 } from "lucide-react"
import { useRouter } from "next/navigation"
import { useActionState, useEffect, useState } from "react"
export default function TransactionCreateForm({
categories,
projects,
currencies,
settings,
}: {
categories: Category[]
projects: Project[]
currencies: Currency[]
settings: Record<string, string>
}) {
const router = useRouter()
const [createState, createAction, isCreating] = useActionState(createTransactionAction, null)
const [formData, setFormData] = useState({
name: "",
merchant: "",
description: "",
total: 0.0,
convertedTotal: 0.0,
currencyCode: settings.default_currency,
convertedCurrencyCode: settings.default_currency,
type: settings.default_type,
categoryCode: settings.default_category,
projectCode: settings.default_project,
issuedAt: format(new Date(), "yyyy-MM-dd"),
note: "",
})
useEffect(() => {
if (createState?.success) {
router.push(`/transactions/${createState.transactionId}`)
}
}, [createState, router])
return (
<form action={createAction} className="space-y-4">
<FormInput title="Name" name="name" defaultValue={formData.name} />
<FormInput title="Merchant" name="merchant" defaultValue={formData.merchant} />
<FormInput title="Description" name="description" defaultValue={formData.description} />
<div className="flex flex-row gap-4">
<FormInput title="Total" type="number" step="0.01" name="total" defaultValue={formData.total.toFixed(2)} />
<FormSelectCurrency
title="Currency"
name="currencyCode"
currencies={currencies}
placeholder="Select Currency"
value={formData.currencyCode}
onValueChange={(value) => {
setFormData({ ...formData, currencyCode: value })
}}
/>
<FormSelectType title="Type" name="type" defaultValue={formData.type} />
</div>
{formData.currencyCode !== settings.default_currency ? (
<div className="flex flex-row gap-4">
<FormInput
title={`Converted to ${settings.default_currency}`}
type="number"
step="0.01"
name="convertedTotal"
defaultValue={formData.convertedTotal.toFixed(2)}
/>
</div>
) : (
<></>
)}
<div className="flex flex-row flex-grow gap-4">
<FormInput title="Issued At" type="date" name="issuedAt" defaultValue={formData.issuedAt} />
</div>
<div className="flex flex-row gap-4">
<FormSelectCategory
title="Category"
categories={categories}
name="categoryCode"
defaultValue={formData.categoryCode}
placeholder="Select Category"
/>
<FormSelectProject
title="Project"
projects={projects}
name="projectCode"
defaultValue={formData.projectCode}
placeholder="Select Project"
/>
</div>
<FormTextarea title="Note" name="note" defaultValue={formData.note} />
<div className="flex justify-end space-x-4 pt-6">
<Button type="submit" disabled={isCreating}>
{isCreating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
"Create and Add Files"
)}
</Button>
{createState?.error && <span className="text-red-500"> {createState.error}</span>}
</div>
</form>
)
}

View File

@@ -0,0 +1,175 @@
"use client"
import { deleteTransactionAction, saveTransactionAction } from "@/app/transactions/actions"
import { FormSelectCategory } from "@/components/forms/select-category"
import { FormSelectCurrency } from "@/components/forms/select-currency"
import { FormSelectProject } from "@/components/forms/select-project"
import { FormSelectType } from "@/components/forms/select-type"
import { FormInput, FormTextarea } from "@/components/forms/simple"
import { Button } from "@/components/ui/button"
import { Category, Currency, Field, Project, Transaction } from "@prisma/client"
import { format } from "date-fns"
import { Loader2 } from "lucide-react"
import { useRouter } from "next/navigation"
import { startTransition, useActionState, useEffect, useState } from "react"
export default function TransactionEditForm({
transaction,
categories,
projects,
currencies,
fields,
settings,
}: {
transaction: Transaction
categories: Category[]
projects: Project[]
currencies: Currency[]
fields: Field[]
settings: Record<string, string>
}) {
const router = useRouter()
const [deleteState, deleteAction, isDeleting] = useActionState(deleteTransactionAction, null)
const [saveState, saveAction, isSaving] = useActionState(saveTransactionAction, null)
const extraFields = fields.filter((field) => field.isExtra)
const [formData, setFormData] = useState({
name: transaction.name || "",
merchant: transaction.merchant || "",
description: transaction.description || "",
total: transaction.total ? transaction.total / 100 : 0.0,
currencyCode: transaction.currencyCode || settings.default_currency,
convertedTotal: transaction.convertedTotal ? transaction.convertedTotal / 100 : 0.0,
convertedCurrencyCode: transaction.convertedCurrencyCode,
type: transaction.type || "expense",
categoryCode: transaction.categoryCode || settings.default_category,
projectCode: transaction.projectCode || settings.default_project,
issuedAt: transaction.issuedAt ? format(transaction.issuedAt, "yyyy-MM-dd") : "",
note: transaction.note || "",
...extraFields.reduce((acc, field) => {
acc[field.code] = transaction.extra?.[field.code as keyof typeof transaction.extra] || ""
return acc
}, {} as Record<string, any>),
})
const handleDelete = async () => {
startTransition(async () => {
await deleteAction(transaction.id)
})
}
useEffect(() => {
if (deleteState?.success) {
router.push("/transactions")
}
}, [deleteState, router])
useEffect(() => {
if (saveState?.success) {
router.push("/transactions")
}
}, [saveState, router])
return (
<form action={saveAction} className="space-y-4">
<input type="hidden" name="transactionId" value={transaction.id} />
<FormInput title="Name" name="name" defaultValue={formData.name} />
<FormInput title="Merchant" name="merchant" defaultValue={formData.merchant} />
<FormInput title="Description" name="description" defaultValue={formData.description} />
<div className="flex flex-row gap-4">
<FormInput
title="Total"
type="number"
step="0.01"
name="total"
defaultValue={formData.total.toFixed(2)}
className="w-32"
/>
<FormSelectCurrency
title="Currency"
name="currencyCode"
value={formData.currencyCode}
onValueChange={(value) => {
setFormData({ ...formData, currencyCode: value })
}}
currencies={currencies}
/>
<FormSelectType title="Type" name="type" defaultValue={formData.type} />
</div>
{formData.currencyCode !== settings.default_currency || formData.convertedTotal !== 0 ? (
<div className="flex flex-row gap-4">
<FormInput
title={`Total converted to ${formData.convertedCurrencyCode || "UNKNOWN CURRENCY"}`}
type="number"
step="0.01"
name="convertedTotal"
defaultValue={formData.convertedTotal.toFixed(2)}
/>
{(!formData.convertedCurrencyCode || formData.convertedCurrencyCode !== settings.default_currency) && (
<FormSelectCurrency
title="Convert to"
name="convertedCurrencyCode"
defaultValue={formData.convertedCurrencyCode || settings.default_currency}
currencies={currencies}
/>
)}
</div>
) : (
<></>
)}
<div className="flex flex-row flex-grow gap-4">
<FormInput title="Issued At" type="date" name="issuedAt" defaultValue={formData.issuedAt} />
</div>
<div className="flex flex-row gap-4">
<FormSelectCategory
title="Category"
categories={categories}
name="categoryCode"
defaultValue={formData.categoryCode}
/>
<FormSelectProject title="Project" projects={projects} name="projectCode" defaultValue={formData.projectCode} />
</div>
<FormTextarea title="Note" name="note" defaultValue={formData.note} className="h-24" />
{extraFields.map((field) => (
<FormInput
key={field.code}
type={field.type}
title={field.name}
name={field.code}
defaultValue={formData[field.code as keyof typeof formData] || ""}
/>
))}
<div className="flex justify-end space-x-4 pt-6">
<Button type="button" onClick={handleDelete} variant="outline" disabled={isDeleting}>
{isDeleting ? "⏳ Deleting..." : "Delete Transaction"}
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
"Save"
)}
</Button>
{deleteState?.error && <span className="text-red-500"> {deleteState.error}</span>}
{saveState?.error && <span className="text-red-500"> {saveState.error}</span>}
</div>
</form>
)
}

View File

@@ -0,0 +1,135 @@
"use client"
import { DateRangePicker } from "@/components/forms/date-range-picker"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { TransactionFilters } from "@/data/transactions"
import { Category, Project } from "@prisma/client"
import { format } from "date-fns"
import { useRouter, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react"
export function TransactionSearchAndFilters({ categories, projects }: { categories: Category[]; projects: Project[] }) {
const searchParams = useSearchParams()
const router = useRouter()
const [filters, setFilters] = useState<TransactionFilters>({
search: searchParams.get("search") || "",
dateFrom: searchParams.get("dateFrom") || "",
dateTo: searchParams.get("dateTo") || "",
categoryCode: searchParams.get("categoryCode") || "",
projectCode: searchParams.get("projectCode") || "",
})
const handleFilterChange = (name: keyof TransactionFilters, value: any) => {
setFilters((prev) => ({
...prev,
[name]: value,
}))
}
const applyFilters = () => {
const params = new URLSearchParams(searchParams.toString())
if (filters.search) {
params.set("search", filters.search)
} else {
params.delete("search")
}
if (filters.dateFrom) {
params.set("dateFrom", format(new Date(filters.dateFrom), "yyyy-MM-dd"))
} else {
params.delete("dateFrom")
}
if (filters.dateTo) {
params.set("dateTo", format(new Date(filters.dateTo), "yyyy-MM-dd"))
} else {
params.delete("dateTo")
}
if (filters.categoryCode && filters.categoryCode !== "-") {
params.set("categoryCode", filters.categoryCode)
} else {
params.delete("categoryCode")
}
if (filters.projectCode && filters.projectCode !== "-") {
params.set("projectCode", filters.projectCode)
} else {
params.delete("projectCode")
}
router.push(`/transactions?${params.toString()}`)
}
useEffect(() => {
applyFilters()
}, [filters])
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<Input
placeholder="Search transactions..."
defaultValue={filters.search}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleFilterChange("search", (e.target as HTMLInputElement).value)
}
}}
className="w-full"
/>
</div>
<Select value={filters.categoryCode} onValueChange={(value) => handleFilterChange("categoryCode", value)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="All categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="-">All categories</SelectItem>
{categories.map((category) => (
<SelectItem key={category.code} value={category.code}>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: category.color }} />
{category.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{projects.length > 1 && (
<Select value={filters.projectCode} onValueChange={(value) => handleFilterChange("projectCode", value)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="All projects" />
</SelectTrigger>
<SelectContent>
<SelectItem value="-">All projects</SelectItem>
{projects.map((project) => (
<SelectItem key={project.code} value={project.code}>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: project.color }} />
{project.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
<DateRangePicker
defaultDate={{
from: filters.dateFrom ? new Date(filters.dateFrom) : undefined,
to: filters.dateTo ? new Date(filters.dateTo) : undefined,
}}
onChange={(date) => {
handleFilterChange("dateFrom", date ? date.from : undefined)
handleFilterChange("dateTo", date ? date.to : undefined)
}}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,241 @@
"use client"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { calcTotalPerCurrency } from "@/lib/stats"
import { cn, formatCurrency } from "@/lib/utils"
import { Category, Project, Transaction } from "@prisma/client"
import { formatDate } from "date-fns"
import { ArrowDownIcon, ArrowUpIcon, File } from "lucide-react"
import { useRouter, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react"
export const transactionsTable = [
{
name: "Name",
db: "name",
classes: "font-medium max-w-[300px] min-w-[120px] overflow-hidden",
sortable: true,
},
{
name: "Merchant",
db: "merchant",
classes: "max-w-[200px] max-h-[20px] min-w-[120px] overflow-hidden",
sortable: true,
},
{
name: "Date",
db: "issuedAt",
classes: "min-w-[100px]",
format: (transaction: Transaction) => (transaction.issuedAt ? formatDate(transaction.issuedAt, "yyyy-MM-dd") : ""),
sortable: true,
},
{
name: "Project",
db: "projectCode",
format: (transaction: Transaction & { project: Project }) =>
transaction.projectCode ? (
<Badge className="whitespace-nowrap" style={{ backgroundColor: transaction.project?.color }}>
{transaction.project?.name || ""}
</Badge>
) : (
"-"
),
sortable: true,
},
{
name: "Category",
db: "categoryCode",
format: (transaction: Transaction & { category: Category }) =>
transaction.categoryCode ? (
<Badge className="whitespace-nowrap" style={{ backgroundColor: transaction.category?.color }}>
{transaction.category?.name || ""}
</Badge>
) : (
"-"
),
sortable: true,
},
{
name: "Files",
db: "files",
format: (transaction: Transaction) => (
<div className="flex items-center gap-2 text-sm">
<File className="w-4 h-4" />
{(transaction.files as string[]).length}
</div>
),
sortable: false,
},
{
name: "Total",
db: "total",
classes: "text-right",
format: (transaction: Transaction) => (
<div className="text-right text-lg">
<div
className={cn(
{ income: "text-green-500", expense: "text-red-500", other: "text-black" }[transaction.type || "other"],
"flex flex-col justify-end"
)}
>
<span>
{transaction.total && transaction.currencyCode
? formatCurrency(transaction.total, transaction.currencyCode)
: transaction.total}
</span>
{transaction.convertedTotal &&
transaction.convertedCurrencyCode &&
transaction.convertedCurrencyCode !== transaction.currencyCode && (
<span className="text-sm -mt-1">
({formatCurrency(transaction.convertedTotal, transaction.convertedCurrencyCode)})
</span>
)}
</div>
</div>
),
sortable: true,
footer: (transactions: Transaction[]) => {
const totalPerCurrency = calcTotalPerCurrency(transactions)
return (
<div className="flex flex-col">
{Object.entries(totalPerCurrency).map(([currency, total]) => (
<div key={currency} className="text-sm first:text-base">
{formatCurrency(total, currency)}
</div>
))}
</div>
)
},
},
]
export function TransactionList({ transactions }: { transactions: Transaction[] }) {
const [selectedIds, setSelectedIds] = useState<string[]>([])
const router = useRouter()
const searchParams = useSearchParams()
const [sorting, setSorting] = useState<{ field: string | null; direction: "asc" | "desc" | null }>(() => {
const ordering = searchParams.get("ordering")
if (!ordering) return { field: null, direction: null }
const isDesc = ordering.startsWith("-")
return {
field: isDesc ? ordering.slice(1) : ordering,
direction: isDesc ? "desc" : "asc",
}
})
const toggleAll = () => {
if (selectedIds.length === transactions.length) {
setSelectedIds([])
} else {
setSelectedIds(transactions.map((transaction) => transaction.id))
}
}
const toggleOne = (e: React.MouseEvent, id: string) => {
e.stopPropagation()
if (selectedIds.includes(id)) {
setSelectedIds(selectedIds.filter((item) => item !== id))
} else {
setSelectedIds([...selectedIds, id])
}
}
const handleRowClick = (id: string) => {
router.push(`/transactions/${id}`)
}
const handleSort = (field: string) => {
let newDirection: "asc" | "desc" | null = "asc"
if (sorting.field === field) {
if (sorting.direction === "asc") newDirection = "desc"
else if (sorting.direction === "desc") newDirection = null
}
setSorting({
field: newDirection ? field : null,
direction: newDirection,
})
}
useEffect(() => {
const params = new URLSearchParams(searchParams.toString())
if (sorting.field && sorting.direction) {
const ordering = sorting.direction === "desc" ? `-${sorting.field}` : sorting.field
params.set("ordering", ordering)
} else {
params.delete("ordering")
}
router.push(`/transactions?${params.toString()}`)
}, [sorting])
const getSortIcon = (field: string) => {
if (sorting.field !== field) return null
return sorting.direction === "asc" ? (
<ArrowUpIcon className="w-4 h-4 ml-1 inline" />
) : sorting.direction === "desc" ? (
<ArrowDownIcon className="w-4 h-4 ml-1 inline" />
) : null
}
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px] select-none">
<Checkbox checked={selectedIds.length === transactions.length} onCheckedChange={toggleAll} />
</TableHead>
{transactionsTable.map((field) => (
<TableHead
key={field.db}
className={cn(field.classes, field.sortable && "hover:cursor-pointer hover:bg-accent select-none")}
onClick={() => field.sortable && handleSort(field.db)}
>
{field.name}
{field.sortable && getSortIcon(field.db)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{transactions.map((transaction: any) => (
<TableRow
key={transaction.id}
className={cn(selectedIds.includes(transaction.id) && "bg-muted", "cursor-pointer hover:bg-muted/50")}
onClick={() => handleRowClick(transaction.id)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedIds.includes(transaction.id)}
onCheckedChange={(checked) => {
if (checked !== "indeterminate") {
toggleOne({ stopPropagation: () => {} } as React.MouseEvent, transaction.id)
}
}}
/>
</TableCell>
{transactionsTable.map((field) => (
<TableCell key={field.db} className={field.classes}>
{field.format ? field.format(transaction) : transaction[field.db]}
</TableCell>
))}
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell></TableCell>
{transactionsTable.map((field) => (
<TableCell key={field.db} className={field.classes}>
{field.footer ? field.footer(transactions) : ""}
</TableCell>
))}
</TableRow>
</TableFooter>
</Table>
</div>
)
}

View File

@@ -0,0 +1,39 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { getCategories } from "@/data/categories"
import { getCurrencies } from "@/data/currencies"
import { getProjects } from "@/data/projects"
import { getSettings } from "@/data/settings"
import TransactionCreateForm from "./create"
export async function NewTransactionDialog({ children }: { children: React.ReactNode }) {
const categories = await getCategories()
const currencies = await getCurrencies()
const settings = await getSettings()
const projects = await getProjects()
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">New Transaction</DialogTitle>
<DialogDescription>Create a new transaction</DialogDescription>
</DialogHeader>
<TransactionCreateForm
categories={categories}
currencies={currencies}
settings={settings}
projects={projects}
/>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,72 @@
"use client"
import { deleteTransactionFileAction, uploadTransactionFileAction } from "@/app/transactions/actions"
import { FilePreview } from "@/components/files/preview"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/files"
import { File, Transaction } from "@prisma/client"
import { Loader2, Upload } from "lucide-react"
import { useState } from "react"
export default function TransactionFiles({ transaction, files }: { transaction: Transaction; files: File[] }) {
const [isUploading, setIsUploading] = useState(false)
const handleDeleteFile = async (fileId: string) => {
await deleteTransactionFileAction(transaction.id, fileId)
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
setIsUploading(true)
if (e.target.files && e.target.files.length > 0) {
const formData = new FormData()
formData.append("transactionId", transaction.id)
formData.append("file", e.target.files[0])
await uploadTransactionFileAction(formData)
setIsUploading(false)
}
}
return (
<>
{files.map((file) => (
<Card key={file.id} className="p-4">
<FilePreview file={file} />
<Button type="button" onClick={() => handleDeleteFile(file.id)} variant="destructive" className="w-full">
Delete File
</Button>
</Card>
))}
<Card className="relative h-32 p-4">
<input type="hidden" name="transactionId" value={transaction.id} />
<label
className="h-full w-full flex flex-col items-center justify-center p-4 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:border-primary transition-colors"
onDragEnter={(e) => {
e.currentTarget.classList.add("border-primary")
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove("border-primary")
}}
>
{isUploading ? (
<Loader2 className="w-8 h-8 text-gray-400 animate-spin" />
) : (
<>
<Upload className="w-8 h-8 text-gray-400" />
<p className="text-sm text-gray-500">Add more files to this invoice</p>
</>
)}
<input
type="file"
name="file"
className="absolute inset-0 top-0 left-0 w-full h-full opacity-0"
onChange={handleFileChange}
accept={FILE_ACCEPTED_MIMETYPES}
/>
</label>
</Card>
</>
)
}

59
components/ui/alert.tsx Normal file
View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

50
components/ui/avatar.tsx Normal file
View File

@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

36
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,115 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

57
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,76 @@
"use client"
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md"
),
day: cn(
buttonVariants({ variant: "ghost" }),
"h-8 w-8 p-0 font-normal aria-selected:opacity-100"
),
day_range_start: "day-range-start",
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("h-4 w-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("h-4 w-4", className)} {...props} />
),
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

76
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

122
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

178
components/ui/form.tsx Normal file
View File

@@ -0,0 +1,178 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

22
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

26
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

33
components/ui/popover.tsx Normal file
View File

@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

159
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,159 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

140
components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

640
components/ui/sidebar.tsx Normal file
View File

@@ -0,0 +1,640 @@
"use client"
import { Slot } from "@radix-ui/react-slot"
import { VariantProps, cva } from "class-variance-authority"
import { PanelLeft } from "lucide-react"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContext = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContext | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}
>(({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }, ref) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContext>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn("group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar", className)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
})
SidebarProvider.displayName = "SidebarProvider"
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}
>(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }, ref) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
className={cn("flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", className)}
ref={ref}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetTitle></SheetTitle>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
)
})
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
)
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button">>(
({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
)
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef<HTMLDivElement, React.ComponentProps<"main">>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props}
/>
)
})
SidebarInset.displayName = "SidebarInset"
const SidebarInput = React.forwardRef<React.ElementRef<typeof Input>, React.ComponentProps<typeof Input>>(
({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props}
/>
)
}
)
SidebarInput.displayName = "SidebarInput"
const SidebarHeader = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return <div ref={ref} data-sidebar="header" className={cn("flex flex-col gap-2 p-2", className)} {...props} />
})
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return <div ref={ref} data-sidebar="footer" className={cn("flex flex-col gap-2 p-2", className)} {...props} />
})
SidebarFooter.displayName = "SidebarFooter"
const SidebarSeparator = React.forwardRef<React.ElementRef<typeof Separator>, React.ComponentProps<typeof Separator>>(
({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
)
SidebarSeparator.displayName = "SidebarSeparator"
const SidebarContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
})
SidebarContent.displayName = "SidebarContent"
const SidebarGroup = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
})
SidebarGroup.displayName = "SidebarGroup"
const SidebarGroupLabel = React.forwardRef<HTMLDivElement, React.ComponentProps<"div"> & { asChild?: boolean }>(
({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
)
SidebarGroupLabel.displayName = "SidebarGroupLabel"
const SidebarGroupAction = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button"> & { asChild?: boolean }>(
({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
)
SidebarGroupAction.displayName = "SidebarGroupAction"
const SidebarGroupContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div ref={ref} data-sidebar="group-content" className={cn("w-full text-sm", className)} {...props} />
)
)
SidebarGroupContent.displayName = "SidebarGroupContent"
const SidebarMenu = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(({ className, ...props }, ref) => (
<ul ref={ref} data-sidebar="menu" className={cn("flex w-full min-w-0 flex-col gap-1", className)} {...props} />
))
SidebarMenu.displayName = "SidebarMenu"
const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ className, ...props }, ref) => (
<li ref={ref} data-sidebar="menu-item" className={cn("group/menu-item relative", className)} {...props} />
))
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>
>(({ asChild = false, isActive = false, variant = "default", size = "default", tooltip, className, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right" align="center" hidden={state !== "collapsed" || isMobile} {...tooltip} />
</Tooltip>
)
})
SidebarMenuButton.displayName = "SidebarMenuButton"
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props}
/>
)
})
SidebarMenuAction.displayName = "SidebarMenuAction"
const SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
)
SidebarMenuBadge.displayName = "SidebarMenuBadge"
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
)
SidebarMenuSub.displayName = "SidebarMenuSub"
const SidebarMenuSubItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ ...props }, ref) => (
<li ref={ref} {...props} />
))
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }

31
components/ui/sonner.tsx Normal file
View File

@@ -0,0 +1,31 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

120
components/ui/table.tsx Normal file
View File

@@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

32
components/ui/tooltip.tsx Normal file
View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,288 @@
"use client"
import { analyzeTransaction, retrieveAllAttachmentsForAI } from "@/app/ai/analyze"
import { useNotification } from "@/app/context"
import { deleteUnsortedFileAction, saveFileAsTransactionAction } from "@/app/unsorted/actions"
import { FormSelectCategory } from "@/components/forms/select-category"
import { FormSelectCurrency } from "@/components/forms/select-currency"
import { FormSelectProject } from "@/components/forms/select-project"
import { FormSelectType } from "@/components/forms/select-type"
import { FormInput, FormTextarea } from "@/components/forms/simple"
import { Button } from "@/components/ui/button"
import { Category, Currency, Field, File, Project } from "@prisma/client"
import { Brain, Loader2 } from "lucide-react"
import { startTransition, useActionState, useState } from "react"
import { FormConvertCurrency } from "../forms/convert-currency"
export default function AnalyzeForm({
file,
categories,
projects,
currencies,
fields,
settings,
}: {
file: File
categories: Category[]
projects: Project[]
currencies: Currency[]
fields: Field[]
settings: Record<string, string>
}) {
const { showNotification } = useNotification()
const [isAnalyzing, setIsAnalyzing] = useState(false)
const [analyzeStep, setAnalyzeStep] = useState<string>("")
const [analyzeError, setAnalyzeError] = useState<string>("")
const [deleteState, deleteAction, isDeleting] = useActionState(deleteUnsortedFileAction, null)
const [isSaving, setIsSaving] = useState(false)
const [saveError, setSaveError] = useState("")
const extraFields = fields.filter((field) => field.isExtra)
const [formData, setFormData] = useState({
name: file.filename,
merchant: "",
description: "",
type: settings.default_type,
total: 0.0,
currencyCode: settings.default_currency,
convertedTotal: 0.0,
convertedCurrencyCode: settings.default_currency,
categoryCode: settings.default_category,
projectCode: settings.default_project,
issuedAt: "",
note: "",
text: "",
...extraFields.reduce((acc, field) => {
acc[field.code] = ""
return acc
}, {} as Record<string, string>),
})
async function saveAsTransaction(formData: FormData) {
setSaveError("")
setIsSaving(true)
startTransition(async () => {
const result = await saveFileAsTransactionAction(null, formData)
setIsSaving(false)
if (result.success) {
showNotification({ code: "sidebar.transactions", message: "new" })
setTimeout(() => showNotification({ code: "sidebar.transactions", message: "" }), 3000)
} else {
setSaveError(result.error ? result.error : "Something went wrong...")
}
})
}
const startAnalyze = async () => {
setIsAnalyzing(true)
setAnalyzeStep("Retrieving files...")
setAnalyzeError("")
try {
const attachments = await retrieveAllAttachmentsForAI(file)
setAnalyzeStep("Analyzing...")
const results = await analyzeTransaction(
settings.prompt_analyse_new_file || process.env.PROMPT_ANALYSE_NEW_FILE || "",
settings,
fields,
categories,
projects,
attachments
)
console.log("Analysis results:", results)
if (!results.success) {
setAnalyzeError(results.error ? results.error : "Something went wrong...")
} else {
const nonEmptyFields = Object.fromEntries(
Object.entries(results.data || {}).filter(
([_, value]) => value !== null && value !== undefined && value !== ""
)
)
console.log("Setting form data:", nonEmptyFields)
setFormData({ ...formData, ...nonEmptyFields })
}
} catch (error) {
console.error("Analysis failed:", error)
setAnalyzeError(error instanceof Error ? error.message : "Analysis failed")
} finally {
setIsAnalyzing(false)
setAnalyzeStep("")
}
}
return (
<>
<Button className="w-full mb-6 py-6 text-lg" onClick={startAnalyze} disabled={isAnalyzing}>
{isAnalyzing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span>{analyzeStep}</span>
</>
) : (
<>
<Brain className="mr-2 h-4 w-4" />
<span>Analyze with AI</span>
</>
)}
</Button>
{analyzeError && <div className="mb-6 p-4 text-red-500 bg-red-50 rounded-md"> {analyzeError}</div>}
<form className="space-y-4" action={saveAsTransaction}>
<input type="hidden" name="fileId" value={file.id} />
<FormInput
title="Name"
name="name"
value={formData.name}
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
/>
<FormInput
title="Merchant"
name="merchant"
value={formData.merchant}
onChange={(e) => setFormData((prev) => ({ ...prev, merchant: e.target.value }))}
/>
<FormInput
title="Description"
name="description"
value={formData.description}
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
hideIfEmpty={true}
/>
<div className="flex flex-wrap gap-4">
<FormInput
title="Total"
name="total"
type="number"
step="0.01"
value={formData.total.toFixed(2)}
onChange={(e) => setFormData((prev) => ({ ...prev, total: parseFloat(e.target.value) }))}
className="w-32"
/>
<FormSelectCurrency
title="Currency"
currencies={currencies}
name="currencyCode"
value={formData.currencyCode}
onValueChange={(value) => setFormData((prev) => ({ ...prev, currencyCode: value }))}
/>
<FormSelectType
title="Type"
name="type"
value={formData.type}
onValueChange={(value) => setFormData((prev) => ({ ...prev, type: value }))}
/>
</div>
{formData.total != 0 && formData.currencyCode && formData.currencyCode !== settings.default_currency && (
<>
<FormConvertCurrency
originalTotal={formData.total}
originalCurrencyCode={formData.currencyCode}
targetCurrencyCode={settings.default_currency}
date={formData.issuedAt ? new Date(formData.issuedAt) : undefined}
onChange={(value) => setFormData((prev) => ({ ...prev, convertedTotal: value }))}
/>
<input type="hidden" name="convertedCurrencyCode" value={settings.default_currency} />
</>
)}
<div className="flex flex-row gap-4">
<FormInput
title="Issued At"
type="date"
name="issuedAt"
value={formData.issuedAt}
onChange={(e) => setFormData((prev) => ({ ...prev, issuedAt: e.target.value }))}
hideIfEmpty={true}
/>
</div>
<div className="flex flex-row gap-4">
<FormSelectCategory
title="Category"
categories={categories}
name="categoryCode"
value={formData.categoryCode}
onValueChange={(value) => setFormData((prev) => ({ ...prev, categoryCode: value }))}
placeholder="Select Category"
/>
{projects.length >= 0 && (
<FormSelectProject
title="Project"
projects={projects}
name="projectCode"
value={formData.projectCode}
onValueChange={(value) => setFormData((prev) => ({ ...prev, projectCode: value }))}
placeholder="Select Project"
/>
)}
</div>
<FormInput
title="Note"
name="note"
value={formData.note}
onChange={(e) => setFormData((prev) => ({ ...prev, note: e.target.value }))}
hideIfEmpty={true}
/>
{extraFields.map((field) => (
<FormInput
key={field.code}
type={field.type}
title={field.name}
name={field.code}
value={formData[field.code as keyof typeof formData]}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.code]: e.target.value }))}
hideIfEmpty={true}
/>
))}
<div className="hidden">
<FormTextarea
title="Recognized Text"
name="text"
value={formData.text}
onChange={(e) => setFormData((prev) => ({ ...prev, text: e.target.value }))}
hideIfEmpty={true}
/>
</div>
<div className="flex justify-end space-x-4 pt-6">
<Button
type="button"
onClick={() => startTransition(() => deleteAction(file.id))}
variant="outline"
disabled={isDeleting}
>
{isDeleting ? "⏳ Deleting..." : "Delete"}
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
"Save as Transaction"
)}
</Button>
{deleteState?.error && <span className="text-red-500"> {deleteState.error}</span>}
{saveError && <span className="text-red-500"> {saveError}</span>}
</div>
</form>
</>
)
}

34
data/categories.ts Normal file
View File

@@ -0,0 +1,34 @@
import { prisma } from "@/lib/db"
import { codeFromName } from "@/lib/utils"
import { Prisma } from "@prisma/client"
import { cache } from "react"
export const getCategories = cache(async () => {
return await prisma.category.findMany({
orderBy: {
name: "asc",
},
})
})
export const createCategory = async (category: Prisma.CategoryCreateInput) => {
if (!category.code) {
category.code = codeFromName(category.name as string)
}
return await prisma.category.create({
data: category,
})
}
export const updateCategory = async (code: string, category: Prisma.CategoryUpdateInput) => {
return await prisma.category.update({
where: { code },
data: category,
})
}
export const deleteCategory = async (code: string) => {
return await prisma.category.delete({
where: { code },
})
}

30
data/currencies.ts Normal file
View File

@@ -0,0 +1,30 @@
import { prisma } from "@/lib/db"
import { Prisma } from "@prisma/client"
import { cache } from "react"
export const getCurrencies = cache(async () => {
return await prisma.currency.findMany({
orderBy: {
code: "asc",
},
})
})
export const createCurrency = async (currency: Prisma.CurrencyCreateInput) => {
return await prisma.currency.create({
data: currency,
})
}
export const updateCurrency = async (code: string, currency: Prisma.CurrencyUpdateInput) => {
return await prisma.currency.update({
where: { code },
data: currency,
})
}
export const deleteCurrency = async (code: string) => {
return await prisma.currency.delete({
where: { code },
})
}

Some files were not shown because too many files have changed in this diff Show More