mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 21:35:19 +00:00
feat: bugfixes, spedup, bulk actions,
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -49,3 +49,5 @@ next-env.d.ts
|
|||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
*.sqlite-journal
|
||||||
|
*.db-journal
|
||||||
26
README.md
26
README.md
@@ -31,11 +31,11 @@ A built-in system of powerful filters allows you to then export transactions wit
|
|||||||
|
|
||||||
> \[!NOTE]
|
> \[!NOTE]
|
||||||
>
|
>
|
||||||
> TaxHacker is a single-user app. SaaS version will probably appear in the future if anyone is interested. Stay tuned for updates.
|
> TaxHacker is a single-user app. SaaS or Electron version will probably be developed in the future if anyone is interested.
|
||||||
|
|
||||||
> \[!IMPORTANT]
|
> \[!IMPORTANT]
|
||||||
>
|
>
|
||||||
> This project is still at a very early stage. **Star Us** to receive new release notifications from GitHub ⭐️
|
> This project is still at a very early stage. Use it at your own risk! **Star Us** to receive notifications about new bugfixes and features from GitHub ⭐️
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
@@ -45,10 +45,11 @@ A built-in system of powerful filters allows you to then export transactions wit
|
|||||||
|
|
||||||
Take a photo on upload or a PDF and TaxHacker will automatically recognise, categorise and store transaction information.
|
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
|
- Upload multiple documents and store in “unsorted” until you get the time to sort them out with AI
|
||||||
- Categorizes transactions based on content
|
- Use LLM to extract key information like date, amount, and vendor
|
||||||
- Stores everything in a structured format for easy filtering and retrieval
|
- Categorize transactions based on content
|
||||||
- Organizes documents for tax season
|
- Store everything in a structured format for easy filtering and retrieval
|
||||||
|
- Organize your documents by a tax season
|
||||||
|
|
||||||
TaxHacker recognizes a wide variety of documents including store receipts, restaurant bills, invoices, bank checks, letters, even handwritten receipts.
|
TaxHacker recognizes a wide variety of documents including store receipts, restaurant bills, invoices, bank checks, letters, even handwritten receipts.
|
||||||
|
|
||||||
@@ -58,8 +59,8 @@ TaxHacker recognizes a wide variety of documents including store receipts, resta
|
|||||||
|
|
||||||
TaxHacker automatically converts foreign currencies and even knows the historical exchange rates on the invoice date.
|
TaxHacker automatically converts foreign currencies and even knows the historical exchange rates on the invoice date.
|
||||||
|
|
||||||
- Automatic detection of different currencies
|
- Automatically detect currency in your documents
|
||||||
- Real-time currency conversion to your base currency
|
- Convert it to your base currency
|
||||||
- Historical exchange rate lookup for past transactions
|
- Historical exchange rate lookup for past transactions
|
||||||
- Support for over 170 world currencies and 14 popular cryptocurrencies (BTC, ETC, LTC, DOT, etc)!
|
- Support for over 170 world currencies and 14 popular cryptocurrencies (BTC, ETC, LTC, DOT, etc)!
|
||||||
|
|
||||||
@@ -110,7 +111,7 @@ TaxHacker can be self-hosted on your own infrastructure for complete control ove
|
|||||||
|
|
||||||
Deploy your own instance of TaxHacker with Vercel in just a few clicks:
|
Deploy your own instance of TaxHacker with Vercel in just a few clicks:
|
||||||
|
|
||||||
1. Prepare your OpenAI API Key for the AI features
|
1. Prepare your [OpenAI API Key](https://platform.openai.com/settings/organization/api-keys) for the AI features
|
||||||
2. Click the deploy button below
|
2. Click the deploy button below
|
||||||
3. Configure your environment variables in the Vercel dashboard
|
3. Configure your environment variables in the Vercel dashboard
|
||||||
4. (Optional) Connect your custom domain
|
4. (Optional) Connect your custom domain
|
||||||
@@ -124,11 +125,10 @@ Deploy your own instance of TaxHacker with Vercel in just a few clicks:
|
|||||||
For server deployment, we provide a [Docker image](./Dockerfile) and [Docker Compose](./docker-compose.yml) files that makes setting up TaxHacker simple:
|
For server deployment, we provide a [Docker image](./Dockerfile) and [Docker Compose](./docker-compose.yml) files that makes setting up TaxHacker simple:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
# Download docker-compose.yml file
|
||||||
git clone https://github.com/vas3k/TaxHacker.git
|
curl -O https://raw.githubusercontent.com/vas3k/TaxHacker/main/docker-compose.yml
|
||||||
cd TaxHacker
|
|
||||||
|
|
||||||
# Or use docker-compose (recommended)
|
# Run it
|
||||||
docker compose up
|
docker compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { WelcomeWidget } from "@/components/dashboard/welcome-widget"
|
|||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { getUnsortedFiles } from "@/data/files"
|
import { getUnsortedFiles } from "@/data/files"
|
||||||
import { getSettings } from "@/data/settings"
|
import { getSettings } from "@/data/settings"
|
||||||
import { StatsFilters } from "@/data/stats"
|
import { TransactionFilters } from "@/data/transactions"
|
||||||
|
|
||||||
export default async function Home({ searchParams }: { searchParams: Promise<StatsFilters> }) {
|
export default async function Home({ searchParams }: { searchParams: Promise<TransactionFilters> }) {
|
||||||
const filters = await searchParams
|
const filters = await searchParams
|
||||||
const unsortedFiles = await getUnsortedFiles()
|
const unsortedFiles = await getUnsortedFiles()
|
||||||
const settings = await getSettings()
|
const settings = await getSettings()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { addCategoryAction, deleteCategoryAction, editCategoryAction } from "@/app/settings/actions"
|
import { addCategoryAction, deleteCategoryAction, editCategoryAction } from "@/app/settings/actions"
|
||||||
import { CrudTable } from "@/components/settings/crud"
|
import { CrudTable } from "@/components/settings/crud"
|
||||||
import { getCategories } from "@/data/categories"
|
import { getCategories } from "@/data/categories"
|
||||||
|
import { randomHexColor } from "@/lib/utils"
|
||||||
|
|
||||||
export default async function CategoriesSettingsPage() {
|
export default async function CategoriesSettingsPage() {
|
||||||
const categories = await getCategories()
|
const categories = await getCategories()
|
||||||
@@ -18,7 +19,7 @@ export default async function CategoriesSettingsPage() {
|
|||||||
columns={[
|
columns={[
|
||||||
{ key: "name", label: "Name", editable: true },
|
{ key: "name", label: "Name", editable: true },
|
||||||
{ key: "llm_prompt", label: "LLM Prompt", editable: true },
|
{ key: "llm_prompt", label: "LLM Prompt", editable: true },
|
||||||
{ key: "color", label: "Color", editable: true },
|
{ key: "color", label: "Color", defaultValue: randomHexColor(), editable: true },
|
||||||
]}
|
]}
|
||||||
onDelete={async (code) => {
|
onDelete={async (code) => {
|
||||||
"use server"
|
"use server"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default async function CurrenciesSettingsPage() {
|
|||||||
<CrudTable
|
<CrudTable
|
||||||
items={currenciesWithActions}
|
items={currenciesWithActions}
|
||||||
columns={[
|
columns={[
|
||||||
{ key: "code", label: "Code" },
|
{ key: "code", label: "Code", editable: true },
|
||||||
{ key: "name", label: "Name", editable: true },
|
{ key: "name", label: "Name", editable: true },
|
||||||
]}
|
]}
|
||||||
onDelete={async (code) => {
|
onDelete={async (code) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { addProjectAction, deleteProjectAction, editProjectAction } from "@/app/settings/actions"
|
import { addProjectAction, deleteProjectAction, editProjectAction } from "@/app/settings/actions"
|
||||||
import { CrudTable } from "@/components/settings/crud"
|
import { CrudTable } from "@/components/settings/crud"
|
||||||
import { getProjects } from "@/data/projects"
|
import { getProjects } from "@/data/projects"
|
||||||
|
import { randomHexColor } from "@/lib/utils"
|
||||||
|
|
||||||
export default async function ProjectsSettingsPage() {
|
export default async function ProjectsSettingsPage() {
|
||||||
const projects = await getProjects()
|
const projects = await getProjects()
|
||||||
@@ -18,7 +19,7 @@ export default async function ProjectsSettingsPage() {
|
|||||||
columns={[
|
columns={[
|
||||||
{ key: "name", label: "Name", editable: true },
|
{ key: "name", label: "Name", editable: true },
|
||||||
{ key: "llm_prompt", label: "LLM Prompt", editable: true },
|
{ key: "llm_prompt", label: "LLM Prompt", editable: true },
|
||||||
{ key: "color", label: "Color", editable: true },
|
{ key: "color", label: "Color", defaultValue: randomHexColor(), editable: true },
|
||||||
]}
|
]}
|
||||||
onDelete={async (code) => {
|
onDelete={async (code) => {
|
||||||
"use server"
|
"use server"
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ export default async function TransactionPage({ params }: { params: Promise<{ tr
|
|||||||
const projects = await getProjects()
|
const projects = await getProjects()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-wrap flex-row items-start justify-center gap-4 max-w-6xl">
|
||||||
<Card className="flex flex-col md:flex-row flex-wrap justify-center items-start gap-10 p-5 bg-accent max-w-6xl">
|
<Card className="w-full flex-1 flex flex-col flex-wrap justify-center items-start gap-10 p-5 bg-accent">
|
||||||
<div className="flex-1">
|
<div className="w-full">
|
||||||
<TransactionEditForm
|
<TransactionEditForm
|
||||||
transaction={transaction}
|
transaction={transaction}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
@@ -37,26 +37,29 @@ export default async function TransactionPage({ params }: { params: Promise<{ tr
|
|||||||
fields={fields}
|
fields={fields}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-[320px] space-y-4">
|
{transaction.text && (
|
||||||
<TransactionFiles transaction={transaction} files={files} />
|
<details className="mt-10">
|
||||||
|
<summary className="cursor-pointer text-sm font-medium">Recognized Text</summary>
|
||||||
|
<Card className="flex items-stretch p-2 max-w-6xl">
|
||||||
|
<div className="flex-1">
|
||||||
|
<FormTextarea
|
||||||
|
title=""
|
||||||
|
name="text"
|
||||||
|
defaultValue={transaction.text || ""}
|
||||||
|
hideIfEmpty={true}
|
||||||
|
className="w-full h-[400px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{transaction.text && (
|
<div className="w-1/3 max-w-[380px] space-y-4">
|
||||||
<Card className="flex items-stretch p-5 mt-10 max-w-6xl">
|
<TransactionFiles transaction={transaction} files={files} />
|
||||||
<div className="flex-1">
|
</div>
|
||||||
<FormTextarea
|
</div>
|
||||||
title="Recognized Text"
|
|
||||||
name="text"
|
|
||||||
defaultValue={transaction.text || ""}
|
|
||||||
hideIfEmpty={true}
|
|
||||||
className="w-full h-[400px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { createFile, deleteFile } from "@/data/files"
|
import { createFile, deleteFile } from "@/data/files"
|
||||||
import {
|
import {
|
||||||
|
bulkDeleteTransactions,
|
||||||
createTransaction,
|
createTransaction,
|
||||||
deleteTransaction,
|
deleteTransaction,
|
||||||
getTransactionById,
|
getTransactionById,
|
||||||
@@ -90,13 +91,13 @@ export async function deleteTransactionFileAction(
|
|||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadTransactionFileAction(formData: FormData): Promise<{ success: boolean; error?: string }> {
|
export async function uploadTransactionFilesAction(formData: FormData): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const transactionId = formData.get("transactionId") as string
|
const transactionId = formData.get("transactionId") as string
|
||||||
const file = formData.get("file") as File
|
const files = formData.getAll("files") as File[]
|
||||||
|
|
||||||
if (!file || !transactionId) {
|
if (!files || !transactionId) {
|
||||||
return { success: false, error: "No file or transaction ID provided" }
|
return { success: false, error: "No files or transaction ID provided" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const transaction = await getTransactionById(transactionId)
|
const transaction = await getTransactionById(transactionId)
|
||||||
@@ -109,29 +110,36 @@ export async function uploadTransactionFileAction(formData: FormData): Promise<{
|
|||||||
await mkdir(FILE_UPLOAD_PATH, { recursive: true })
|
await mkdir(FILE_UPLOAD_PATH, { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save file to filesystem
|
const fileRecords = await Promise.all(
|
||||||
const { fileUuid, filePath } = await getTransactionFileUploadPath(file.name, transaction)
|
files.map(async (file) => {
|
||||||
const arrayBuffer = await file.arrayBuffer()
|
const { fileUuid, filePath } = await getTransactionFileUploadPath(file.name, transaction)
|
||||||
const buffer = Buffer.from(arrayBuffer)
|
const arrayBuffer = await file.arrayBuffer()
|
||||||
await writeFile(filePath, buffer)
|
const buffer = Buffer.from(arrayBuffer)
|
||||||
|
await writeFile(filePath, buffer)
|
||||||
|
|
||||||
// Create file record in database
|
// Create file record in database
|
||||||
const fileRecord = await createFile({
|
const fileRecord = await createFile({
|
||||||
id: fileUuid,
|
id: fileUuid,
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
path: filePath,
|
path: filePath,
|
||||||
mimetype: file.type,
|
mimetype: file.type,
|
||||||
isReviewed: true,
|
isReviewed: true,
|
||||||
metadata: {
|
metadata: {
|
||||||
size: file.size,
|
size: file.size,
|
||||||
lastModified: file.lastModified,
|
lastModified: file.lastModified,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return fileRecord
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
// Update invoice with the new file ID
|
// Update invoice with the new file ID
|
||||||
await updateTransactionFiles(
|
await updateTransactionFiles(
|
||||||
transactionId,
|
transactionId,
|
||||||
transaction.files ? [...(transaction.files as string[]), fileRecord.id] : [fileRecord.id]
|
transaction.files
|
||||||
|
? [...(transaction.files as string[]), ...fileRecords.map((file) => file.id)]
|
||||||
|
: fileRecords.map((file) => file.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
revalidatePath(`/transactions/${transactionId}`)
|
revalidatePath(`/transactions/${transactionId}`)
|
||||||
@@ -141,3 +149,14 @@ export async function uploadTransactionFileAction(formData: FormData): Promise<{
|
|||||||
return { success: false, error: `File upload failed: ${error}` }
|
return { success: false, error: `File upload failed: ${error}` }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function bulkDeleteTransactionsAction(transactionIds: string[]) {
|
||||||
|
try {
|
||||||
|
await bulkDeleteTransactions(transactionIds)
|
||||||
|
revalidatePath("/transactions")
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete transactions:", error)
|
||||||
|
return { success: false, error: "Failed to delete transactions" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
|
|||||||
<header className="flex flex-wrap items-center justify-between gap-2 mb-8">
|
<header className="flex flex-wrap items-center justify-between gap-2 mb-8">
|
||||||
<h2 className="text-3xl font-bold tracking-tight">Transactions</h2>
|
<h2 className="text-3xl font-bold tracking-tight">Transactions</h2>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<ExportTransactionsDialog filters={filters} fields={fields} categories={categories} projects={projects}>
|
<ExportTransactionsDialog fields={fields} categories={categories} projects={projects}>
|
||||||
<Button variant="outline">
|
<Button variant="outline">
|
||||||
<Download />
|
<Download />
|
||||||
<span className="hidden md:block">Export</span>
|
<span className="hidden md:block">Export</span>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import AnalyzeForm from "@/components/unsorted/analyze-form"
|
|
||||||
import { FilePreview } from "@/components/files/preview"
|
import { FilePreview } from "@/components/files/preview"
|
||||||
import { UploadButton } from "@/components/files/upload-button"
|
import { UploadButton } from "@/components/files/upload-button"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
|
import AnalyzeForm from "@/components/unsorted/analyze-form"
|
||||||
import { getCategories } from "@/data/categories"
|
import { getCategories } from "@/data/categories"
|
||||||
import { getCurrencies } from "@/data/currencies"
|
import { getCurrencies } from "@/data/currencies"
|
||||||
import { getFields } from "@/data/fields"
|
import { getFields } from "@/data/fields"
|
||||||
|
|||||||
@@ -1,40 +1,18 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { StatsFilters } from "@/data/stats"
|
import { TransactionFilters } from "@/data/transactions"
|
||||||
|
import { useTransactionFilters } from "@/hooks/use-transaction-filters"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { DateRangePicker } from "../forms/date-range-picker"
|
import { DateRangePicker } from "../forms/date-range-picker"
|
||||||
|
|
||||||
export function FiltersWidget({
|
export function FiltersWidget({
|
||||||
defaultFilters,
|
defaultFilters,
|
||||||
defaultRange = "last-12-months",
|
defaultRange = "last-12-months",
|
||||||
}: {
|
}: {
|
||||||
defaultFilters: StatsFilters
|
defaultFilters: TransactionFilters
|
||||||
defaultRange?: string
|
defaultRange?: string
|
||||||
}) {
|
}) {
|
||||||
const searchParams = useSearchParams()
|
const [filters, setFilters] = useTransactionFilters(defaultFilters)
|
||||||
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 (
|
return (
|
||||||
<DateRangePicker
|
<DateRangePicker
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { getProjects } from "@/data/projects"
|
import { getProjects } from "@/data/projects"
|
||||||
import { getDashboardStats, getProjectStats, StatsFilters } from "@/data/stats"
|
import { getDashboardStats, getProjectStats } from "@/data/stats"
|
||||||
|
import { TransactionFilters } from "@/data/transactions"
|
||||||
import { formatCurrency } from "@/lib/utils"
|
import { formatCurrency } from "@/lib/utils"
|
||||||
import { ArrowDown, ArrowUp, BicepsFlexed } from "lucide-react"
|
import { ArrowDown, ArrowUp, BicepsFlexed } from "lucide-react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
|
||||||
import { FiltersWidget } from "./filters-widget"
|
import { FiltersWidget } from "./filters-widget"
|
||||||
import { ProjectsWidget } from "./projects-widget"
|
import { ProjectsWidget } from "./projects-widget"
|
||||||
|
|
||||||
export async function StatsWidget({ filters }: { filters: StatsFilters }) {
|
export async function StatsWidget({ filters }: { filters: TransactionFilters }) {
|
||||||
const projects = await getProjects()
|
const projects = await getProjects()
|
||||||
const stats = await getDashboardStats(filters)
|
const stats = await getDashboardStats(filters)
|
||||||
const statsPerProject = Object.fromEntries(
|
const statsPerProject = Object.fromEntries(
|
||||||
@@ -45,7 +46,12 @@ export async function StatsWidget({ filters }: { filters: StatsFilters }) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{Object.entries(stats.totalExpensesPerCurrency).map(([currency, total]) => (
|
{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">
|
<div
|
||||||
|
key={currency}
|
||||||
|
className={`flex gap-2 items-center font-bold text-base first:text-2xl ${
|
||||||
|
total >= 0 ? "text-green-500" : "text-red-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{formatCurrency(total, currency)}
|
{formatCurrency(total, currency)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -59,7 +65,12 @@ export async function StatsWidget({ filters }: { filters: StatsFilters }) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{Object.entries(stats.profitPerCurrency).map(([currency, total]) => (
|
{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">
|
<div
|
||||||
|
key={currency}
|
||||||
|
className={`flex gap-2 items-center font-bold text-base first:text-2xl ${
|
||||||
|
total >= 0 ? "text-green-500" : "text-red-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{formatCurrency(total, currency)}
|
{formatCurrency(total, currency)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { TransactionFilters } from "@/data/transactions"
|
import { useTransactionFilters } from "@/hooks/use-transaction-filters"
|
||||||
import { Category, Field, Project } from "@prisma/client"
|
import { Category, Field, Project } from "@prisma/client"
|
||||||
import { formatDate } from "date-fns"
|
import { formatDate } from "date-fns"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
@@ -23,20 +23,18 @@ import { FormSelectProject } from "../forms/select-project"
|
|||||||
const deselectedFields = ["files", "text"]
|
const deselectedFields = ["files", "text"]
|
||||||
|
|
||||||
export function ExportTransactionsDialog({
|
export function ExportTransactionsDialog({
|
||||||
filters,
|
|
||||||
fields,
|
fields,
|
||||||
categories,
|
categories,
|
||||||
projects,
|
projects,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
filters: TransactionFilters
|
|
||||||
fields: Field[]
|
fields: Field[]
|
||||||
categories: Category[]
|
categories: Category[]
|
||||||
projects: Project[]
|
projects: Project[]
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [exportFilters, setExportFilters] = useState<TransactionFilters>(filters)
|
const [exportFilters, setExportFilters] = useTransactionFilters()
|
||||||
const [exportFields, setExportFields] = useState<string[]>(
|
const [exportFields, setExportFields] = useState<string[]>(
|
||||||
fields.map((field) => (deselectedFields.includes(field.code) ? "" : field.code))
|
fields.map((field) => (deselectedFields.includes(field.code) ? "" : field.code))
|
||||||
)
|
)
|
||||||
@@ -62,10 +60,10 @@ export function ExportTransactionsDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{filters.search && (
|
{exportFilters.search && (
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
<span className="text-sm font-medium">Search query:</span>
|
<span className="text-sm font-medium">Search query:</span>
|
||||||
<span className="text-sm">{filters.search}</span>
|
<span className="text-sm">{exportFilters.search}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -74,8 +72,8 @@ export function ExportTransactionsDialog({
|
|||||||
|
|
||||||
<DateRangePicker
|
<DateRangePicker
|
||||||
defaultDate={{
|
defaultDate={{
|
||||||
from: filters?.dateFrom ? new Date(filters.dateFrom) : undefined,
|
from: exportFilters?.dateFrom ? new Date(exportFilters.dateFrom) : undefined,
|
||||||
to: filters?.dateTo ? new Date(filters.dateTo) : undefined,
|
to: exportFilters?.dateTo ? new Date(exportFilters.dateTo) : undefined,
|
||||||
}}
|
}}
|
||||||
defaultRange="all-time"
|
defaultRange="all-time"
|
||||||
onChange={(date) => {
|
onChange={(date) => {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export function FilePreview({ file }: { file: File }) {
|
|||||||
alt={file.filename}
|
alt={file.filename}
|
||||||
width={300}
|
width={300}
|
||||||
height={400}
|
height={400}
|
||||||
|
loading="lazy"
|
||||||
className={`${
|
className={`${
|
||||||
isEnlarged
|
isEnlarged
|
||||||
? "fixed inset-0 z-50 m-auto w-screen h-screen object-contain cursor-zoom-out"
|
? "fixed inset-0 z-50 m-auto w-screen h-screen object-contain cursor-zoom-out"
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import { useNotification } from "@/app/context"
|
import { useNotification } from "@/app/context"
|
||||||
import { uploadFilesAction } from "@/app/files/actions"
|
import { uploadFilesAction } from "@/app/files/actions"
|
||||||
|
import { uploadTransactionFilesAction } from "@/app/transactions/actions"
|
||||||
import { AlertCircle, CloudUpload, Loader2 } from "lucide-react"
|
import { AlertCircle, CloudUpload, Loader2 } from "lucide-react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useParams, useRouter } from "next/navigation"
|
||||||
import { startTransition, useEffect, useRef, useState } from "react"
|
import { startTransition, useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
export default function ScreenDropArea({ children }: { children: React.ReactNode }) {
|
export default function ScreenDropArea({ children }: { children: React.ReactNode }) {
|
||||||
@@ -13,6 +14,7 @@ export default function ScreenDropArea({ children }: { children: React.ReactNode
|
|||||||
const [isUploading, setIsUploading] = useState(false)
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
const [uploadError, setUploadError] = useState("")
|
const [uploadError, setUploadError] = useState("")
|
||||||
const dragCounter = useRef(0)
|
const dragCounter = useRef(0)
|
||||||
|
const { transactionId } = useParams()
|
||||||
|
|
||||||
const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
|
const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -53,16 +55,24 @@ export default function ScreenDropArea({ children }: { children: React.ReactNode
|
|||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
try {
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
|
if (transactionId) {
|
||||||
|
formData.append("transactionId", transactionId as string)
|
||||||
|
}
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
formData.append("files", files[i])
|
formData.append("files", files[i])
|
||||||
}
|
}
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await uploadFilesAction(null, formData)
|
const result = transactionId
|
||||||
|
? await uploadTransactionFilesAction(formData)
|
||||||
|
: await uploadFilesAction(null, formData)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showNotification({ code: "sidebar.unsorted", message: "new" })
|
showNotification({ code: "sidebar.unsorted", message: "new" })
|
||||||
setTimeout(() => showNotification({ code: "sidebar.unsorted", message: "" }), 3000)
|
setTimeout(() => showNotification({ code: "sidebar.unsorted", message: "" }), 3000)
|
||||||
router.push("/unsorted")
|
if (!transactionId) {
|
||||||
|
router.push("/unsorted")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setUploadError(result.error ? result.error : "Something went wrong...")
|
setUploadError(result.error ? result.error : "Something went wrong...")
|
||||||
}
|
}
|
||||||
@@ -105,7 +115,9 @@ export default function ScreenDropArea({ children }: { children: React.ReactNode
|
|||||||
>
|
>
|
||||||
<div className="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-xl text-center">
|
<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" />
|
<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>
|
<h3 className="text-xl font-semibold mb-2">
|
||||||
|
{transactionId ? "Drop Files to Add to Transaction" : "Drop Files to Upload"}
|
||||||
|
</h3>
|
||||||
<p className="text-gray-600 dark:text-gray-400">Drop anywhere on the screen</p>
|
<p className="text-gray-600 dark:text-gray-400">Drop anywhere on the screen</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,7 +127,9 @@ export default function ScreenDropArea({ children }: { children: React.ReactNode
|
|||||||
<div className="fixed inset-0 bg-opacity-20 backdrop-blur-sm z-50 flex items-center justify-center">
|
<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">
|
<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" />
|
<Loader2 className="h-16 w-16 mx-auto mb-4 text-primary animate-spin" />
|
||||||
<h3 className="text-xl font-semibold mb-2">Uploading...</h3>
|
<h3 className="text-xl font-semibold mb-2">
|
||||||
|
{transactionId ? "Adding files to transaction..." : "Uploading..."}
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Category } from "@prisma/client"
|
import { Category } from "@prisma/client"
|
||||||
import { SelectProps } from "@radix-ui/react-select"
|
import { SelectProps } from "@radix-ui/react-select"
|
||||||
|
import { useMemo } from "react"
|
||||||
import { FormSelect } from "./simple"
|
import { FormSelect } from "./simple"
|
||||||
|
|
||||||
export const FormSelectCategory = ({
|
export const FormSelectCategory = ({
|
||||||
@@ -11,13 +12,9 @@ export const FormSelectCategory = ({
|
|||||||
placeholder,
|
placeholder,
|
||||||
...props
|
...props
|
||||||
}: { title: string; categories: Category[]; emptyValue?: string; placeholder?: string } & SelectProps) => {
|
}: { title: string; categories: Category[]; emptyValue?: string; placeholder?: string } & SelectProps) => {
|
||||||
return (
|
const items = useMemo(
|
||||||
<FormSelect
|
() => categories.map((category) => ({ code: category.code, name: category.name, color: category.color })),
|
||||||
title={title}
|
[categories]
|
||||||
items={categories.map((category) => ({ code: category.code, name: category.name, color: category.color }))}
|
|
||||||
emptyValue={emptyValue}
|
|
||||||
placeholder={placeholder}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
|
return <FormSelect title={title} items={items} emptyValue={emptyValue} placeholder={placeholder} {...props} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Currency } from "@prisma/client"
|
import { Currency } from "@prisma/client"
|
||||||
import { SelectProps } from "@radix-ui/react-select"
|
import { SelectProps } from "@radix-ui/react-select"
|
||||||
|
import { useMemo } from "react"
|
||||||
import { FormSelect } from "./simple"
|
import { FormSelect } from "./simple"
|
||||||
|
|
||||||
export const FormSelectCurrency = ({
|
export const FormSelectCurrency = ({
|
||||||
@@ -9,13 +10,9 @@ export const FormSelectCurrency = ({
|
|||||||
placeholder,
|
placeholder,
|
||||||
...props
|
...props
|
||||||
}: { title: string; currencies: Currency[]; emptyValue?: string; placeholder?: string } & SelectProps) => {
|
}: { title: string; currencies: Currency[]; emptyValue?: string; placeholder?: string } & SelectProps) => {
|
||||||
return (
|
const items = useMemo(
|
||||||
<FormSelect
|
() => currencies.map((currency) => ({ code: currency.code, name: `${currency.code} - ${currency.name}` })),
|
||||||
title={title}
|
[currencies]
|
||||||
items={currencies.map((currency) => ({ code: currency.code, name: `${currency.code} - ${currency.name}` }))}
|
|
||||||
emptyValue={emptyValue}
|
|
||||||
placeholder={placeholder}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
|
return <FormSelect title={title} items={items} emptyValue={emptyValue} placeholder={placeholder} {...props} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface CrudProps<T> {
|
|||||||
key: keyof T
|
key: keyof T
|
||||||
label: string
|
label: string
|
||||||
type?: "text" | "number" | "checkbox"
|
type?: "text" | "number" | "checkbox"
|
||||||
|
defaultValue?: string
|
||||||
editable?: boolean
|
editable?: boolean
|
||||||
}[]
|
}[]
|
||||||
onDelete: (id: string) => Promise<void>
|
onDelete: (id: string) => Promise<void>
|
||||||
@@ -134,7 +135,7 @@ export function CrudTable<T extends { [key: string]: any }>({ items, columns, on
|
|||||||
{column.editable && (
|
{column.editable && (
|
||||||
<Input
|
<Input
|
||||||
type={column.type || "text"}
|
type={column.type || "text"}
|
||||||
value={newItem[column.key] || ""}
|
value={newItem[column.key] || column.defaultValue || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setNewItem({
|
setNewItem({
|
||||||
...newItem,
|
...newItem,
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ export default function LLMSettingsForm({ settings }: { settings: Record<string,
|
|||||||
<form action={saveAction} className="space-y-4">
|
<form action={saveAction} className="space-y-4">
|
||||||
<FormInput title="OpenAI API Key" name="openai_api_key" defaultValue={settings.openai_api_key} />
|
<FormInput title="OpenAI API Key" name="openai_api_key" defaultValue={settings.openai_api_key} />
|
||||||
|
|
||||||
|
<small className="text-muted-foreground">
|
||||||
|
Get your API key from{" "}
|
||||||
|
<a href="https://platform.openai.com/settings/organization/api-keys" target="_blank" className="underline">
|
||||||
|
OpenAI Platform Console
|
||||||
|
</a>
|
||||||
|
</small>
|
||||||
|
|
||||||
<FormTextarea
|
<FormTextarea
|
||||||
title="Prompt for Analyze Transaction"
|
title="Prompt for Analyze Transaction"
|
||||||
name="prompt_analyse_new_file"
|
name="prompt_analyse_new_file"
|
||||||
|
|||||||
77
components/transactions/bulk-actions.tsx
Normal file
77
components/transactions/bulk-actions.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { bulkDeleteTransactionsAction } from "@/app/transactions/actions"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||||
|
import { ChevronUp, Trash2 } from "lucide-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
const bulkActions = [
|
||||||
|
{
|
||||||
|
id: "delete",
|
||||||
|
label: "Bulk Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive" as const,
|
||||||
|
action: bulkDeleteTransactionsAction,
|
||||||
|
confirmMessage:
|
||||||
|
"Are you sure you want to delete these transactions and all their files? This action cannot be undone.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
interface BulkActionsMenuProps {
|
||||||
|
selectedIds: string[]
|
||||||
|
onActionComplete?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BulkActionsMenu({ selectedIds, onActionComplete }: BulkActionsMenuProps) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleAction = async (actionId: string) => {
|
||||||
|
const action = bulkActions.find((a) => a.id === actionId)
|
||||||
|
if (!action) return
|
||||||
|
|
||||||
|
if (action.confirmMessage) {
|
||||||
|
if (!confirm(action.confirmMessage)) return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
const result = await action.action(selectedIds)
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error)
|
||||||
|
}
|
||||||
|
onActionComplete?.()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to execute bulk action ${actionId}:`, error)
|
||||||
|
alert(`Failed to execute action: ${error}`)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button className="min-w-48" disabled={isLoading}>
|
||||||
|
{selectedIds.length} transactions
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{bulkActions.map((action) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={action.id}
|
||||||
|
onClick={() => handleAction(action.id)}
|
||||||
|
className="gap-2"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<action.icon className="h-4 w-4" />
|
||||||
|
{action.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -55,18 +55,13 @@ export default function TransactionEditForm({
|
|||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
await deleteAction(transaction.id)
|
await deleteAction(transaction.id)
|
||||||
|
router.back()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (deleteState?.success) {
|
|
||||||
router.push("/transactions")
|
|
||||||
}
|
|
||||||
}, [deleteState, router])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (saveState?.success) {
|
if (saveState?.success) {
|
||||||
router.push("/transactions")
|
router.back()
|
||||||
}
|
}
|
||||||
}, [saveState, router])
|
}, [saveState, router])
|
||||||
|
|
||||||
@@ -152,7 +147,7 @@ export default function TransactionEditForm({
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="flex justify-end space-x-4 pt-6">
|
<div className="flex justify-end space-x-4 pt-6">
|
||||||
<Button type="button" onClick={handleDelete} variant="outline" disabled={isDeleting}>
|
<Button type="button" onClick={handleDelete} variant="destructive" disabled={isDeleting}>
|
||||||
{isDeleting ? "⏳ Deleting..." : "Delete Transaction"}
|
{isDeleting ? "⏳ Deleting..." : "Delete Transaction"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -4,22 +4,11 @@ import { DateRangePicker } from "@/components/forms/date-range-picker"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { TransactionFilters } from "@/data/transactions"
|
import { TransactionFilters } from "@/data/transactions"
|
||||||
|
import { useTransactionFilters } from "@/hooks/use-transaction-filters"
|
||||||
import { Category, Project } from "@prisma/client"
|
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[] }) {
|
export function TransactionSearchAndFilters({ categories, projects }: { categories: Category[]; projects: Project[] }) {
|
||||||
const searchParams = useSearchParams()
|
const [filters, setFilters] = useTransactionFilters()
|
||||||
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) => {
|
const handleFilterChange = (name: keyof TransactionFilters, value: any) => {
|
||||||
setFilters((prev) => ({
|
setFilters((prev) => ({
|
||||||
@@ -28,45 +17,6 @@ export function TransactionSearchAndFilters({ categories, projects }: { categori
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyFilters = () => {
|
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
|
||||||
if (filters.search) {
|
|
||||||
params.set("search", filters.search)
|
|
||||||
} else {
|
|
||||||
params.delete("search")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.dateFrom) {
|
|
||||||
params.set("dateFrom", format(new Date(filters.dateFrom), "yyyy-MM-dd"))
|
|
||||||
} else {
|
|
||||||
params.delete("dateFrom")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.dateTo) {
|
|
||||||
params.set("dateTo", format(new Date(filters.dateTo), "yyyy-MM-dd"))
|
|
||||||
} else {
|
|
||||||
params.delete("dateTo")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.categoryCode && filters.categoryCode !== "-") {
|
|
||||||
params.set("categoryCode", filters.categoryCode)
|
|
||||||
} else {
|
|
||||||
params.delete("categoryCode")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.projectCode && filters.projectCode !== "-") {
|
|
||||||
params.set("projectCode", filters.projectCode)
|
|
||||||
} else {
|
|
||||||
params.delete("projectCode")
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push(`/transactions?${params.toString()}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
applyFilters()
|
|
||||||
}, [filters])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { BulkActionsMenu } from "@/components/transactions/bulk-actions"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
@@ -236,6 +237,9 @@ export function TransactionList({ transactions }: { transactions: Transaction[]
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableFooter>
|
</TableFooter>
|
||||||
</Table>
|
</Table>
|
||||||
|
{selectedIds.length > 0 && (
|
||||||
|
<BulkActionsMenu selectedIds={selectedIds} onActionComplete={() => setSelectedIds([])} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { deleteTransactionFileAction, uploadTransactionFileAction } from "@/app/transactions/actions"
|
import { deleteTransactionFileAction, uploadTransactionFilesAction } from "@/app/transactions/actions"
|
||||||
import { FilePreview } from "@/components/files/preview"
|
import { FilePreview } from "@/components/files/preview"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/files"
|
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/files"
|
||||||
import { File, Transaction } from "@prisma/client"
|
import { File, Transaction } from "@prisma/client"
|
||||||
import { Loader2, Upload } from "lucide-react"
|
import { Loader2, Upload, X } from "lucide-react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
|
||||||
export default function TransactionFiles({ transaction, files }: { transaction: Transaction; files: File[] }) {
|
export default function TransactionFiles({ transaction, files }: { transaction: Transaction; files: File[] }) {
|
||||||
@@ -21,8 +21,10 @@ export default function TransactionFiles({ transaction, files }: { transaction:
|
|||||||
if (e.target.files && e.target.files.length > 0) {
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append("transactionId", transaction.id)
|
formData.append("transactionId", transaction.id)
|
||||||
formData.append("file", e.target.files[0])
|
for (let i = 0; i < e.target.files.length; i++) {
|
||||||
await uploadTransactionFileAction(formData)
|
formData.append("files", e.target.files[i])
|
||||||
|
}
|
||||||
|
await uploadTransactionFilesAction(formData)
|
||||||
setIsUploading(false)
|
setIsUploading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,19 +32,24 @@ export default function TransactionFiles({ transaction, files }: { transaction:
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{files.map((file) => (
|
{files.map((file) => (
|
||||||
<Card key={file.id} className="p-4">
|
<Card key={file.id} className="p-4 relative">
|
||||||
<FilePreview file={file} />
|
<Button
|
||||||
|
type="button"
|
||||||
<Button type="button" onClick={() => handleDeleteFile(file.id)} variant="destructive" className="w-full">
|
onClick={() => handleDeleteFile(file.id)}
|
||||||
Delete File
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
className="absolute -right-2 -top-2 rounded-full w-6 h-6 z-10"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<FilePreview file={file} />
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Card className="relative h-32 p-4">
|
<Card className="relative min-h-32 p-4">
|
||||||
<input type="hidden" name="transactionId" value={transaction.id} />
|
<input type="hidden" name="transactionId" value={transaction.id} />
|
||||||
<label
|
<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"
|
className="h-full w-full flex flex-col gap-2 items-center justify-center p-4 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:border-primary transition-colors"
|
||||||
onDragEnter={(e) => {
|
onDragEnter={(e) => {
|
||||||
e.currentTarget.classList.add("border-primary")
|
e.currentTarget.classList.add("border-primary")
|
||||||
}}
|
}}
|
||||||
@@ -56,9 +63,11 @@ export default function TransactionFiles({ transaction, files }: { transaction:
|
|||||||
<>
|
<>
|
||||||
<Upload className="w-8 h-8 text-gray-400" />
|
<Upload className="w-8 h-8 text-gray-400" />
|
||||||
<p className="text-sm text-gray-500">Add more files to this invoice</p>
|
<p className="text-sm text-gray-500">Add more files to this invoice</p>
|
||||||
|
<p className="text-xs text-gray-500">(or just drop them on this page)</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
|
multiple
|
||||||
type="file"
|
type="file"
|
||||||
name="file"
|
name="file"
|
||||||
className="absolute inset-0 top-0 left-0 w-full h-full opacity-0"
|
className="absolute inset-0 top-0 left-0 w-full h-full opacity-0"
|
||||||
|
|||||||
45
components/ui/resizable.tsx
Normal file
45
components/ui/resizable.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { GripVertical } from "lucide-react"
|
||||||
|
import * as ResizablePrimitive from "react-resizable-panels"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ResizablePanelGroup = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||||
|
<ResizablePrimitive.PanelGroup
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ResizablePanel = ResizablePrimitive.Panel
|
||||||
|
|
||||||
|
const ResizableHandle = ({
|
||||||
|
withHandle,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||||
|
withHandle?: boolean
|
||||||
|
}) => (
|
||||||
|
<ResizablePrimitive.PanelResizeHandle
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{withHandle && (
|
||||||
|
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||||
|
<GripVertical className="h-2.5 w-2.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePrimitive.PanelResizeHandle>
|
||||||
|
)
|
||||||
|
|
||||||
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { analyzeTransaction, retrieveAllAttachmentsForAI } from "@/app/ai/analyze"
|
import { analyzeTransaction, retrieveAllAttachmentsForAI } from "@/app/ai/analyze"
|
||||||
import { useNotification } from "@/app/context"
|
import { useNotification } from "@/app/context"
|
||||||
import { deleteUnsortedFileAction, saveFileAsTransactionAction } from "@/app/unsorted/actions"
|
import { deleteUnsortedFileAction, saveFileAsTransactionAction } from "@/app/unsorted/actions"
|
||||||
|
import { FormConvertCurrency } from "@/components/forms/convert-currency"
|
||||||
import { FormSelectCategory } from "@/components/forms/select-category"
|
import { FormSelectCategory } from "@/components/forms/select-category"
|
||||||
import { FormSelectCurrency } from "@/components/forms/select-currency"
|
import { FormSelectCurrency } from "@/components/forms/select-currency"
|
||||||
import { FormSelectProject } from "@/components/forms/select-project"
|
import { FormSelectProject } from "@/components/forms/select-project"
|
||||||
@@ -11,8 +12,7 @@ import { FormInput, FormTextarea } from "@/components/forms/simple"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Category, Currency, Field, File, Project } from "@prisma/client"
|
import { Category, Currency, Field, File, Project } from "@prisma/client"
|
||||||
import { Brain, Loader2 } from "lucide-react"
|
import { Brain, Loader2 } from "lucide-react"
|
||||||
import { startTransition, useActionState, useState } from "react"
|
import { startTransition, useActionState, useMemo, useState } from "react"
|
||||||
import { FormConvertCurrency } from "../forms/convert-currency"
|
|
||||||
|
|
||||||
export default function AnalyzeForm({
|
export default function AnalyzeForm({
|
||||||
file,
|
file,
|
||||||
@@ -37,26 +37,30 @@ export default function AnalyzeForm({
|
|||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [saveError, setSaveError] = useState("")
|
const [saveError, setSaveError] = useState("")
|
||||||
|
|
||||||
const extraFields = fields.filter((field) => field.isExtra)
|
const extraFields = useMemo(() => fields.filter((field) => field.isExtra), [fields])
|
||||||
const [formData, setFormData] = useState({
|
const initialFormState = useMemo(
|
||||||
name: file.filename,
|
() => ({
|
||||||
merchant: "",
|
name: file.filename,
|
||||||
description: "",
|
merchant: "",
|
||||||
type: settings.default_type,
|
description: "",
|
||||||
total: 0.0,
|
type: settings.default_type,
|
||||||
currencyCode: settings.default_currency,
|
total: 0.0,
|
||||||
convertedTotal: 0.0,
|
currencyCode: settings.default_currency,
|
||||||
convertedCurrencyCode: settings.default_currency,
|
convertedTotal: 0.0,
|
||||||
categoryCode: settings.default_category,
|
convertedCurrencyCode: settings.default_currency,
|
||||||
projectCode: settings.default_project,
|
categoryCode: settings.default_category,
|
||||||
issuedAt: "",
|
projectCode: settings.default_project,
|
||||||
note: "",
|
issuedAt: "",
|
||||||
text: "",
|
note: "",
|
||||||
...extraFields.reduce((acc, field) => {
|
text: "",
|
||||||
acc[field.code] = ""
|
...extraFields.reduce((acc, field) => {
|
||||||
return acc
|
acc[field.code] = ""
|
||||||
}, {} as Record<string, string>),
|
return acc
|
||||||
})
|
}, {} as Record<string, string>),
|
||||||
|
}),
|
||||||
|
[file.filename, settings, extraFields]
|
||||||
|
)
|
||||||
|
const [formData, setFormData] = useState(initialFormState)
|
||||||
|
|
||||||
async function saveAsTransaction(formData: FormData) {
|
async function saveAsTransaction(formData: FormData) {
|
||||||
setSaveError("")
|
setSaveError("")
|
||||||
@@ -138,6 +142,7 @@ export default function AnalyzeForm({
|
|||||||
name="name"
|
name="name"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
|
required={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormInput
|
<FormInput
|
||||||
@@ -161,9 +166,13 @@ export default function AnalyzeForm({
|
|||||||
name="total"
|
name="total"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={formData.total.toFixed(2)}
|
value={formData.total || ""}
|
||||||
onChange={(e) => setFormData((prev) => ({ ...prev, total: parseFloat(e.target.value) }))}
|
onChange={(e) => {
|
||||||
|
const newValue = parseFloat(e.target.value || "0")
|
||||||
|
!isNaN(newValue) && setFormData((prev) => ({ ...prev, total: newValue }))
|
||||||
|
}}
|
||||||
className="w-32"
|
className="w-32"
|
||||||
|
required={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormSelectCurrency
|
<FormSelectCurrency
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ export const getFilesByTransactionId = cache(async (id: string) => {
|
|||||||
in: transaction.files as string[],
|
in: transaction.files as string[],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "asc",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -2,11 +2,7 @@ import { prisma } from "@/lib/db"
|
|||||||
import { calcTotalPerCurrency } from "@/lib/stats"
|
import { calcTotalPerCurrency } from "@/lib/stats"
|
||||||
import { Prisma } from "@prisma/client"
|
import { Prisma } from "@prisma/client"
|
||||||
import { cache } from "react"
|
import { cache } from "react"
|
||||||
|
import { TransactionFilters } from "./transactions"
|
||||||
export type StatsFilters = {
|
|
||||||
dateFrom?: string
|
|
||||||
dateTo?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DashboardStats = {
|
export type DashboardStats = {
|
||||||
totalIncomePerCurrency: Record<string, number>
|
totalIncomePerCurrency: Record<string, number>
|
||||||
@@ -15,7 +11,7 @@ export type DashboardStats = {
|
|||||||
invoicesProcessed: number
|
invoicesProcessed: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getDashboardStats = cache(async (filters: StatsFilters = {}): Promise<DashboardStats> => {
|
export const getDashboardStats = cache(async (filters: TransactionFilters = {}): Promise<DashboardStats> => {
|
||||||
const where: Prisma.TransactionWhereInput = {}
|
const where: Prisma.TransactionWhereInput = {}
|
||||||
|
|
||||||
if (filters.dateFrom || filters.dateTo) {
|
if (filters.dateFrom || filters.dateTo) {
|
||||||
@@ -51,7 +47,7 @@ export type ProjectStats = {
|
|||||||
invoicesProcessed: number
|
invoicesProcessed: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getProjectStats = cache(async (projectId: string, filters: StatsFilters = {}) => {
|
export const getProjectStats = cache(async (projectId: string, filters: TransactionFilters = {}) => {
|
||||||
const where: Prisma.TransactionWhereInput = {
|
const where: Prisma.TransactionWhereInput = {
|
||||||
projectCode: projectId,
|
projectCode: projectId,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,12 @@ export const deleteTransaction = async (id: string): Promise<Transaction | undef
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const bulkDeleteTransactions = async (ids: string[]) => {
|
||||||
|
return await prisma.transaction.deleteMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const splitTransactionDataExtraFields = async (
|
const splitTransactionDataExtraFields = async (
|
||||||
data: TransactionData
|
data: TransactionData
|
||||||
): Promise<{ standard: TransactionData; extra: Prisma.InputJsonValue }> => {
|
): Promise<{ standard: TransactionData; extra: Prisma.InputJsonValue }> => {
|
||||||
|
|||||||
69
hooks/use-transaction-filters.tsx
Normal file
69
hooks/use-transaction-filters.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { TransactionFilters } from "@/data/transactions"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
export function searchParamsToFilters(searchParams: URLSearchParams) {
|
||||||
|
return {
|
||||||
|
search: searchParams.get("search") || "",
|
||||||
|
dateFrom: searchParams.get("dateFrom") || "",
|
||||||
|
dateTo: searchParams.get("dateTo") || "",
|
||||||
|
categoryCode: searchParams.get("categoryCode") || "",
|
||||||
|
projectCode: searchParams.get("projectCode") || "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filtersToSearchParams(filters: TransactionFilters): URLSearchParams {
|
||||||
|
const searchParams = new URLSearchParams()
|
||||||
|
if (filters.search) {
|
||||||
|
searchParams.set("search", filters.search)
|
||||||
|
} else {
|
||||||
|
searchParams.delete("search")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.dateFrom) {
|
||||||
|
searchParams.set("dateFrom", format(new Date(filters.dateFrom), "yyyy-MM-dd"))
|
||||||
|
} else {
|
||||||
|
searchParams.delete("dateFrom")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.dateTo) {
|
||||||
|
searchParams.set("dateTo", format(new Date(filters.dateTo), "yyyy-MM-dd"))
|
||||||
|
} else {
|
||||||
|
searchParams.delete("dateTo")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.categoryCode && filters.categoryCode !== "-") {
|
||||||
|
searchParams.set("categoryCode", filters.categoryCode)
|
||||||
|
} else {
|
||||||
|
searchParams.delete("categoryCode")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.projectCode && filters.projectCode !== "-") {
|
||||||
|
searchParams.set("projectCode", filters.projectCode)
|
||||||
|
} else {
|
||||||
|
searchParams.delete("projectCode")
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchParams
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTransactionFilters(defaultFilters?: TransactionFilters) {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const [filters, setFilters] = useState<TransactionFilters>({
|
||||||
|
...defaultFilters,
|
||||||
|
...searchParamsToFilters(searchParams),
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newSearchParams = filtersToSearchParams(filters)
|
||||||
|
router.push(`?${newSearchParams.toString()}`)
|
||||||
|
}, [filters])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFilters(searchParamsToFilters(searchParams))
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
return [filters, setFilters] as const
|
||||||
|
}
|
||||||
@@ -22,3 +22,7 @@ export function codeFromName(name: string, maxLength: number = 16) {
|
|||||||
})
|
})
|
||||||
return code.slice(0, maxLength)
|
return code.slice(0, maxLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function randomHexColor() {
|
||||||
|
return "#" + Math.floor(Math.random() * 16777215).toString(16)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { NextConfig } from "next"
|
import type { NextConfig } from "next"
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
|
||||||
eslint: {
|
eslint: {
|
||||||
ignoreDuringBuilds: true, // TODO: fixme
|
ignoreDuringBuilds: true, // TODO: fixme
|
||||||
},
|
},
|
||||||
|
|||||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -35,6 +35,7 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-resizable-panels": "^2.1.7",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"sonner": "^2.0.1",
|
"sonner": "^2.0.1",
|
||||||
@@ -6636,6 +6637,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-resizable-panels": {
|
||||||
|
"version": "2.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.7.tgz",
|
||||||
|
"integrity": "sha512-JtT6gI+nURzhMYQYsx8DKkx6bSoOGFp7A3CwMrOb8y5jFHFyqwo9m68UhmXRw57fRVJksFn1TSlm3ywEQ9vMgA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-style-singleton": {
|
"node_modules/react-style-singleton": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-resizable-panels": "^2.1.7",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"sonner": "^2.0.1",
|
"sonner": "^2.0.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user