mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 13:25:18 +00:00
(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:
20
.env.example
Normal file
20
.env.example
Normal 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
51
.gitignore
vendored
Normal 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
13
.prettierrc
Normal 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
59
Dockerfile
Normal 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
21
LICENSE
Normal 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
213
README.md
Normal 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/>
|
||||
|
||||
[](https://github.com/vas3k/TaxHacker/stargazers)
|
||||
[](https://github.com/vas3k/TaxHacker/blob/main/LICENSE)
|
||||
[](https://github.com/vas3k/TaxHacker/issues)
|
||||
[](https://vas3k.com/donate/)
|
||||
|
||||
**Share TaxHacker**
|
||||
|
||||
[](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)
|
||||
[](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fgithub.com%2Fvas3k%2FTaxHacker)
|
||||
[](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.
|
||||
|
||||

|
||||
|
||||
> \[!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
|
||||
|
||||

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

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

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

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

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

|
||||
|
||||
## 🛳 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/>
|
||||
|
||||
[](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.
|
||||
|
||||
[](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.
|
||||
|
||||
[](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
139
app/ai/analyze.ts
Normal 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
49
app/ai/prompt.ts
Normal 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
32
app/context.tsx
Normal 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)
|
||||
104
app/export/transactions/route.ts
Normal file
104
app/export/transactions/route.ts
Normal 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
52
app/files/actions.ts
Normal 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 }
|
||||
}
|
||||
41
app/files/download/[fileId]/route.ts
Normal file
41
app/files/download/[fileId]/route.ts
Normal 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
10
app/files/page.tsx
Normal 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()
|
||||
}
|
||||
66
app/files/preview/[fileId]/route.ts
Normal file
66
app/files/preview/[fileId]/route.ts
Normal 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
85
app/globals.css
Normal 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
56
app/layout.tsx
Normal 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
9
app/loading.tsx
Normal 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
30
app/page.tsx
Normal 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
131
app/settings/actions.ts
Normal 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")
|
||||
}
|
||||
21
app/settings/backups/actions.ts
Normal file
21
app/settings/backups/actions.ts
Normal 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 }
|
||||
}
|
||||
18
app/settings/backups/database/route.ts
Normal file
18
app/settings/backups/database/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
52
app/settings/backups/files/route.ts
Normal file
52
app/settings/backups/files/route.ts
Normal 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
|
||||
}
|
||||
58
app/settings/backups/page.tsx
Normal file
58
app/settings/backups/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
app/settings/backups/restore/route.ts
Normal file
24
app/settings/backups/restore/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
52
app/settings/categories/page.tsx
Normal file
52
app/settings/categories/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
app/settings/currencies/page.tsx
Normal file
37
app/settings/currencies/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
app/settings/fields/page.tsx
Normal file
51
app/settings/fields/page.tsx
Normal 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
59
app/settings/layout.tsx
Normal 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
14
app/settings/llm/page.tsx
Normal 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
18
app/settings/page.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
38
app/settings/projects/page.tsx
Normal file
38
app/settings/projects/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
app/transactions/[transactionId]/layout.tsx
Normal file
28
app/transactions/[transactionId]/layout.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
7
app/transactions/[transactionId]/loading.tsx
Normal file
7
app/transactions/[transactionId]/loading.tsx
Normal 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]" />
|
||||
)
|
||||
}
|
||||
62
app/transactions/[transactionId]/page.tsx
Normal file
62
app/transactions/[transactionId]/page.tsx
Normal 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
143
app/transactions/actions.ts
Normal 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}` }
|
||||
}
|
||||
}
|
||||
3
app/transactions/layout.tsx
Normal file
3
app/transactions/layout.tsx
Normal 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>
|
||||
}
|
||||
30
app/transactions/loading.tsx
Normal file
30
app/transactions/loading.tsx
Normal 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
71
app/transactions/page.tsx
Normal 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
63
app/unsorted/actions.ts
Normal 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
3
app/unsorted/layout.tsx
Normal 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
103
app/unsorted/page.tsx
Normal 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
21
components.json
Normal 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"
|
||||
}
|
||||
74
components/dashboard/drop-zone-widget.tsx
Normal file
74
components/dashboard/drop-zone-widget.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
components/dashboard/filters-widget.tsx
Normal file
54
components/dashboard/filters-widget.tsx
Normal 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,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
86
components/dashboard/projects-widget.tsx
Normal file
86
components/dashboard/projects-widget.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
components/dashboard/stats-widget.tsx
Normal file
86
components/dashboard/stats-widget.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
components/dashboard/unsorted-widget.tsx
Normal file
45
components/dashboard/unsorted-widget.tsx
Normal 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"} →
|
||||
</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>
|
||||
)
|
||||
}
|
||||
105
components/dashboard/welcome-widget.tsx
Normal file
105
components/dashboard/welcome-widget.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
162
components/export/transactions.tsx
Normal file
162
components/export/transactions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
components/files/preview.tsx
Normal file
53
components/files/preview.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
134
components/files/screen-drop-area.tsx
Normal file
134
components/files/screen-drop-area.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
75
components/files/upload-button.tsx
Normal file
75
components/files/upload-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
components/forms/convert-currency.tsx
Normal file
77
components/forms/convert-currency.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
129
components/forms/date-range-picker.tsx
Normal file
129
components/forms/date-range-picker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
components/forms/select-category.tsx
Normal file
23
components/forms/select-category.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
21
components/forms/select-currency.tsx
Normal file
21
components/forms/select-currency.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
21
components/forms/select-project.tsx
Normal file
21
components/forms/select-project.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
18
components/forms/select-type.tsx
Normal file
18
components/forms/select-type.tsx
Normal 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} />
|
||||
}
|
||||
77
components/forms/simple.tsx
Normal file
77
components/forms/simple.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
165
components/settings/crud.tsx
Normal file
165
components/settings/crud.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
components/settings/global-settings-form.tsx
Normal file
59
components/settings/global-settings-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
components/settings/llm-settings-form.tsx
Normal file
38
components/settings/llm-settings-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
components/settings/side-nav.tsx
Normal file
35
components/settings/side-nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
8
components/sidebar/blinker.tsx
Normal file
8
components/sidebar/blinker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
components/sidebar/mobile-menu.tsx
Normal file
33
components/sidebar/mobile-menu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
components/sidebar/sidebar-item.tsx
Normal file
32
components/sidebar/sidebar-item.tsx
Normal 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
|
||||
139
components/sidebar/sidebar.tsx
Normal file
139
components/sidebar/sidebar.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
129
components/transactions/create.tsx
Normal file
129
components/transactions/create.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
175
components/transactions/edit.tsx
Normal file
175
components/transactions/edit.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
135
components/transactions/filters.tsx
Normal file
135
components/transactions/filters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
241
components/transactions/list.tsx
Normal file
241
components/transactions/list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
components/transactions/new.tsx
Normal file
39
components/transactions/new.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
72
components/transactions/transaction-files.tsx
Normal file
72
components/transactions/transaction-files.tsx
Normal 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
59
components/ui/alert.tsx
Normal 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
50
components/ui/avatar.tsx
Normal 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
36
components/ui/badge.tsx
Normal 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 }
|
||||
115
components/ui/breadcrumb.tsx
Normal file
115
components/ui/breadcrumb.tsx
Normal 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
57
components/ui/button.tsx
Normal 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 }
|
||||
76
components/ui/calendar.tsx
Normal file
76
components/ui/calendar.tsx
Normal 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
76
components/ui/card.tsx
Normal 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 }
|
||||
30
components/ui/checkbox.tsx
Normal file
30
components/ui/checkbox.tsx
Normal 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 }
|
||||
11
components/ui/collapsible.tsx
Normal file
11
components/ui/collapsible.tsx
Normal 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
122
components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
201
components/ui/dropdown-menu.tsx
Normal file
201
components/ui/dropdown-menu.tsx
Normal 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
178
components/ui/form.tsx
Normal 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
22
components/ui/input.tsx
Normal 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
26
components/ui/label.tsx
Normal 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
33
components/ui/popover.tsx
Normal 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
159
components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
31
components/ui/separator.tsx
Normal file
31
components/ui/separator.tsx
Normal 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
140
components/ui/sheet.tsx
Normal 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
640
components/ui/sidebar.tsx
Normal 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,
|
||||
}
|
||||
15
components/ui/skeleton.tsx
Normal file
15
components/ui/skeleton.tsx
Normal 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
31
components/ui/sonner.tsx
Normal 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
120
components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
22
components/ui/textarea.tsx
Normal file
22
components/ui/textarea.tsx
Normal 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
32
components/ui/tooltip.tsx
Normal 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 }
|
||||
288
components/unsorted/analyze-form.tsx
Normal file
288
components/unsorted/analyze-form.tsx
Normal 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
34
data/categories.ts
Normal 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
30
data/currencies.ts
Normal 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
Reference in New Issue
Block a user