(squash) init

feat: filters, settings, backups

fix: ts compile errors

feat: new dashboard, webp previews and settings

feat: use webp for pdfs

feat: use webp

fix: analyze resets old data

fix: switch to corsproxy

fix: switch to free cors

fix: max upload limit

fix: currency conversion

feat: transaction export

fix: currency conversion

feat: refactor settings actions

feat: new loader

feat: README + LICENSE

doc: update readme

doc: update readme

doc: update readme

doc: update screenshots

ci: bump prisma
This commit is contained in:
Vasily Zubarev
2025-03-13 00:30:47 +01:00
commit 0b98a2c307
153 changed files with 17271 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
"use client"
import { useNotification } from "@/app/context"
import { uploadFilesAction } from "@/app/files/actions"
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/files"
import { Camera, Loader2 } from "lucide-react"
import { useRouter } from "next/navigation"
import { startTransition, useState } from "react"
export default function DashboardDropZoneWidget() {
const router = useRouter()
const { showNotification } = useNotification()
const [isUploading, setIsUploading] = useState(false)
const [uploadError, setUploadError] = useState("")
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
setIsUploading(true)
setUploadError("")
if (e.target.files && e.target.files.length > 0) {
const formData = new FormData()
// Append all selected files to the FormData
for (let i = 0; i < e.target.files.length; i++) {
formData.append("files", e.target.files[i])
}
// Submit the files using the server action
startTransition(async () => {
const result = await uploadFilesAction(null, formData)
if (result.success) {
showNotification({ code: "sidebar.unsorted", message: "new" })
setTimeout(() => showNotification({ code: "sidebar.unsorted", message: "" }), 3000)
router.push("/unsorted")
} else {
setUploadError(result.error ? result.error : "Something went wrong...")
}
setIsUploading(false)
})
}
}
return (
<div className="flex w-full h-full">
<label className="relative w-full h-full border-2 border-dashed rounded-lg transition-colors hover:border-primary cursor-pointer">
<input
type="file"
id="fileInput"
className="hidden"
multiple
accept={FILE_ACCEPTED_MIMETYPES}
onChange={handleFileChange}
/>
<div className="flex flex-col items-center justify-center gap-4 p-8 text-center h-full">
{isUploading ? (
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
) : (
<Camera className="h-8 w-8 text-muted-foreground" />
)}
<div>
<p className="text-lg font-medium">
{isUploading ? "Uploading..." : "Take a photo or drop your files here"}
</p>
{!uploadError && (
<p className="text-sm text-muted-foreground">
upload receipts, invoices and any other documents for me to scan
</p>
)}
{uploadError && <p className="text-red-500">{uploadError}</p>}
</div>
</div>
</label>
</div>
)
}

View File

@@ -0,0 +1,54 @@
"use client"
import { StatsFilters } from "@/data/stats"
import { format } from "date-fns"
import { useRouter, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react"
import { DateRangePicker } from "../forms/date-range-picker"
export function FiltersWidget({
defaultFilters,
defaultRange = "last-12-months",
}: {
defaultFilters: StatsFilters
defaultRange?: string
}) {
const searchParams = useSearchParams()
const router = useRouter()
const [filters, setFilters] = useState<StatsFilters>(defaultFilters)
const applyFilters = () => {
const params = new URLSearchParams(searchParams.toString())
if (filters?.dateFrom) {
params.set("dateFrom", format(new Date(filters.dateFrom), "yyyy-MM-dd"))
} else {
params.delete("dateFrom")
}
if (filters?.dateTo) {
params.set("dateTo", format(new Date(filters.dateTo), "yyyy-MM-dd"))
} else {
params.delete("dateTo")
}
router.push(`?${params.toString()}`)
}
useEffect(() => {
applyFilters()
}, [filters])
return (
<DateRangePicker
defaultDate={{
from: filters?.dateFrom ? new Date(filters.dateFrom) : undefined,
to: filters?.dateTo ? new Date(filters.dateTo) : undefined,
}}
defaultRange={defaultRange}
onChange={(date) => {
setFilters({
dateFrom: date && date.from ? format(date.from, "yyyy-MM-dd") : undefined,
dateTo: date && date.to ? format(date.to, "yyyy-MM-dd") : undefined,
})
}}
/>
)
}

View File

@@ -0,0 +1,86 @@
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ProjectStats } from "@/data/stats"
import { formatCurrency } from "@/lib/utils"
import { Project } from "@prisma/client"
import { Plus } from "lucide-react"
import Link from "next/link"
export function ProjectsWidget({
projects,
statsPerProject,
}: {
projects: Project[]
statsPerProject: Record<string, ProjectStats>
}) {
return (
<div className="grid gap-4 md:grid-cols-2">
{projects.map((project) => (
<Card key={project.code}>
<CardHeader>
<CardTitle>
<Link href={`/transactions?projectCode=${project.code}`}>
<Badge className="text-lg" style={{ backgroundColor: project.color }}>
{project.name}
</Badge>
</Link>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-4 justify-between items-center">
<div>
<div className="text-sm font-medium text-muted-foreground">Income</div>
<div className="text-2xl font-bold text-green-500">
{Object.entries(statsPerProject[project.code]?.totalIncomePerCurrency).map(([currency, total]) => (
<div
key={currency}
className="flex flex-col gap-2 font-bold text-green-500 text-base first:text-2xl"
>
{formatCurrency(total, currency)}
</div>
))}
{!Object.entries(statsPerProject[project.code]?.totalIncomePerCurrency).length && (
<div className="font-bold text-base first:text-2xl">0.00</div>
)}
</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Expenses</div>
<div className="text-2xl font-bold text-red-500">
{Object.entries(statsPerProject[project.code]?.totalExpensesPerCurrency).map(([currency, total]) => (
<div key={currency} className="flex flex-col gap-2 font-bold text-red-500 text-base first:text-2xl">
{formatCurrency(total, currency)}
</div>
))}
{!Object.entries(statsPerProject[project.code]?.totalExpensesPerCurrency).length && (
<div className="font-bold text-base first:text-2xl">0.00</div>
)}
</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Profit</div>
<div className="text-2xl font-bold">
{Object.entries(statsPerProject[project.code]?.profitPerCurrency).map(([currency, total]) => (
<div key={currency} className="flex flex-col gap-2 items-center text-2xl font-bold text-green-500">
{formatCurrency(total, currency)}
</div>
))}
{!Object.entries(statsPerProject[project.code]?.profitPerCurrency).length && (
<div className="text-2xl font-bold">0.00</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
))}
<Link
href="/settings/projects"
className="flex items-center justify-center gap-2 border-dashed border-2 border-gray-300 rounded-md p-4 text-muted-foreground hover:text-primary hover:border-primary"
>
<Plus className="h-4 w-4" />
Create New Project
</Link>
</div>
)
}

View File

@@ -0,0 +1,86 @@
import { getProjects } from "@/data/projects"
import { getDashboardStats, getProjectStats, StatsFilters } from "@/data/stats"
import { formatCurrency } from "@/lib/utils"
import { ArrowDown, ArrowUp, BicepsFlexed } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
import { FiltersWidget } from "./filters-widget"
import { ProjectsWidget } from "./projects-widget"
export async function StatsWidget({ filters }: { filters: StatsFilters }) {
const projects = await getProjects()
const stats = await getDashboardStats(filters)
const statsPerProject = Object.fromEntries(
await Promise.all(
projects.map((project) => getProjectStats(project.code, filters).then((stats) => [project.code, stats]))
)
)
return (
<div className="flex flex-col gap-5">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Overview</h2>
<FiltersWidget defaultFilters={filters} defaultRange="last-12-months" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Income</CardTitle>
<ArrowUp className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
{Object.entries(stats.totalIncomePerCurrency).map(([currency, total]) => (
<div key={currency} className="flex gap-2 items-center font-bold text-base first:text-2xl">
{formatCurrency(total, currency)}
</div>
))}
{!Object.entries(stats.totalIncomePerCurrency).length && <div className="text-2xl font-bold">0.00</div>}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Expenses</CardTitle>
<ArrowDown className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
{Object.entries(stats.totalExpensesPerCurrency).map(([currency, total]) => (
<div key={currency} className="flex gap-2 items-center font-bold text-red-500 text-base first:text-2xl">
{formatCurrency(total, currency)}
</div>
))}
{!Object.entries(stats.totalExpensesPerCurrency).length && <div className="text-2xl font-bold">0.00</div>}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Net Profit</CardTitle>
<BicepsFlexed className="h-4 w-4" />
</CardHeader>
<CardContent>
{Object.entries(stats.profitPerCurrency).map(([currency, total]) => (
<div key={currency} className="flex gap-2 items-center font-bold text-green-500 text-base first:text-2xl">
{formatCurrency(total, currency)}
</div>
))}
{!Object.entries(stats.profitPerCurrency).length && <div className="text-2xl font-bold">0.00</div>}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Processed Transactions</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.invoicesProcessed}</div>
</CardContent>
</Card>
</div>
<div>
<h2 className="text-2xl font-bold">Projects</h2>
</div>
<ProjectsWidget projects={projects} statsPerProject={statsPerProject} />
</div>
)
}

View File

@@ -0,0 +1,45 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { File } from "@prisma/client"
import { FilePlus, PartyPopper } from "lucide-react"
import Link from "next/link"
export default function DashboardUnsortedWidget({ files }: { files: File[] }) {
return (
<Card className="w-full h-full sm:max-w-xs bg-accent">
<CardHeader>
<CardTitle>
<Link href="/unsorted">
{files.length > 0 ? `${files.length} unsorted files` : "No unsorted files"} &rarr;
</Link>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2">
{files.slice(0, 3).map((file) => (
<Link
href={`/unsorted/#${file.id}`}
key={file.id}
className="rounded-md p-2 bg-background hover:bg-black hover:text-white"
>
<div className="flex flex-row gap-2">
<FilePlus className="w-8 h-8" />
<div className="grid flex-1 text-left leading-tight">
<span className="truncate text-xs font-semibold">{file.filename}</span>
<span className="truncate text-xs">{file.mimetype}</span>
</div>
</div>
</Link>
))}
{files.length == 0 && (
<div className="flex flex-col items-center justify-center gap-2 text-sm text-muted-foreground h-full min-h-[100px]">
<PartyPopper className="w-5 h-5" />
<span>Everything is clear! Congrats!</span>
</div>
)}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,105 @@
import { Button } from "@/components/ui/button"
import { Card, CardDescription, CardTitle } from "@/components/ui/card"
import { getSettings } from "@/data/settings"
import { Banknote, ChartBarStacked, FolderOpenDot, Key, TextCursorInput } from "lucide-react"
import Link from "next/link"
export async function WelcomeWidget() {
const settings = await getSettings()
return (
<Card className="flex flex-col md:flex-row items-start gap-10 p-10 w-full">
<img src="/logo/1024.png" alt="Logo" className="w-64 h-64" />
<div className="flex flex-col">
<CardTitle className="text-2xl font-bold">Hey, I'm TaxHacker 👋</CardTitle>
<CardDescription className="mt-5">
<p className="mb-3">
I'm a little accountant app that tries to help you deal with endless receipts, checks and invoices with (you
guessed it) GenAI. Here's what I can do:
</p>
<ul className="mb-5 list-disc pl-5 space-y-1">
<li>
<strong>Upload me a photo or a PDF</strong> and I will recognize, categorize and save it as a transaction
for your tax advisor.
</li>
<li>
I can <strong>automatically convert currencies</strong> and look up exchange rates for a given date.
</li>
<li>
I even <strong>support crypto!</strong> Historical exchange rates for staking too.
</li>
<li>
All <strong>LLM prompts are configurable</strong>: for fields, categories and projects. You can go to
settings and change them.
</li>
<li>
I save data in a <strong>local SQLite database</strong> and can export it to CSV and ZIP archives.
</li>
<li>
You can even <strong>create your own new fields</strong> to be analyzed and they will be included in the
CSV export for your tax advisor.
</li>
<li>
I'm still <strong>very young</strong> and can make mistakes. Use me at your own risk!
</li>
</ul>
<p className="mb-3">
While I can save you a lot of time in categorizing transactions and generating reports, I still highly
recommend giving the results to a professional tax advisor for review when filing your taxes!
</p>
</CardDescription>
<div className="mt-2">
<Link href="https://github.com/vas3k/TaxHacker" className="text-blue-500 hover:underline">
Source Code
</Link>
<span className="mx-2">|</span>
<Link href="https://github.com/vas3k/TaxHacker/issues" className="text-blue-500 hover:underline">
Request New Feature
</Link>
<span className="mx-2">|</span>
<Link href="https://github.com/vas3k/TaxHacker/issues" className="text-blue-500 hover:underline">
Report a Bug
</Link>
<span className="mx-2">|</span>
<Link href="mailto:me@vas3k.ru" className="text-blue-500 hover:underline">
Contact the Author
</Link>
</div>
<div className="flex flex-wrap gap-2 mt-8">
{settings.openai_api_key === "" && (
<Link href="/settings/llm">
<Button>
<Key className="h-4 w-4" />
Please give your ChatGPT key here
</Button>
</Link>
)}
<Link href="/settings">
<Button variant="outline">
<Banknote className="h-4 w-4" />
Default Currency: {settings.default_currency}
</Button>
</Link>
<Link href="/settings/categories">
<Button variant="outline">
<ChartBarStacked className="h-4 w-4" />
Categories
</Button>
</Link>
<Link href="/settings/projects">
<Button variant="outline">
<FolderOpenDot className="h-4 w-4" />
Projects
</Button>
</Link>
<Link href="/settings/fields">
<Button variant="outline">
<TextCursorInput className="h-4 w-4" />
Custom Fields
</Button>
</Link>
</div>
</div>
</Card>
)
}