feat: bugfixes, spedup, bulk actions,

This commit is contained in:
Vasily Zubarev
2025-03-17 18:36:25 +01:00
parent b27f07043e
commit 14967e1c85
34 changed files with 433 additions and 225 deletions

2
.gitignore vendored
View File

@@ -49,3 +49,5 @@ next-env.d.ts
*.db *.db
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
*.sqlite-journal
*.db-journal

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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