mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 13:25:18 +00:00
(squash) init
feat: filters, settings, backups fix: ts compile errors feat: new dashboard, webp previews and settings feat: use webp for pdfs feat: use webp fix: analyze resets old data fix: switch to corsproxy fix: switch to free cors fix: max upload limit fix: currency conversion feat: transaction export fix: currency conversion feat: refactor settings actions feat: new loader feat: README + LICENSE doc: update readme doc: update readme doc: update readme doc: update screenshots ci: bump prisma
This commit is contained in:
74
components/dashboard/drop-zone-widget.tsx
Normal file
74
components/dashboard/drop-zone-widget.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import { useNotification } from "@/app/context"
|
||||
import { uploadFilesAction } from "@/app/files/actions"
|
||||
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/files"
|
||||
import { Camera, Loader2 } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { startTransition, useState } from "react"
|
||||
|
||||
export default function DashboardDropZoneWidget() {
|
||||
const router = useRouter()
|
||||
const { showNotification } = useNotification()
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [uploadError, setUploadError] = useState("")
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setIsUploading(true)
|
||||
setUploadError("")
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const formData = new FormData()
|
||||
|
||||
// Append all selected files to the FormData
|
||||
for (let i = 0; i < e.target.files.length; i++) {
|
||||
formData.append("files", e.target.files[i])
|
||||
}
|
||||
|
||||
// Submit the files using the server action
|
||||
startTransition(async () => {
|
||||
const result = await uploadFilesAction(null, formData)
|
||||
if (result.success) {
|
||||
showNotification({ code: "sidebar.unsorted", message: "new" })
|
||||
setTimeout(() => showNotification({ code: "sidebar.unsorted", message: "" }), 3000)
|
||||
router.push("/unsorted")
|
||||
} else {
|
||||
setUploadError(result.error ? result.error : "Something went wrong...")
|
||||
}
|
||||
setIsUploading(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full h-full">
|
||||
<label className="relative w-full h-full border-2 border-dashed rounded-lg transition-colors hover:border-primary cursor-pointer">
|
||||
<input
|
||||
type="file"
|
||||
id="fileInput"
|
||||
className="hidden"
|
||||
multiple
|
||||
accept={FILE_ACCEPTED_MIMETYPES}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<div className="flex flex-col items-center justify-center gap-4 p-8 text-center h-full">
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
|
||||
) : (
|
||||
<Camera className="h-8 w-8 text-muted-foreground" />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-lg font-medium">
|
||||
{isUploading ? "Uploading..." : "Take a photo or drop your files here"}
|
||||
</p>
|
||||
{!uploadError && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
upload receipts, invoices and any other documents for me to scan
|
||||
</p>
|
||||
)}
|
||||
{uploadError && <p className="text-red-500">{uploadError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
components/dashboard/filters-widget.tsx
Normal file
54
components/dashboard/filters-widget.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client"
|
||||
|
||||
import { StatsFilters } from "@/data/stats"
|
||||
import { format } from "date-fns"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import { DateRangePicker } from "../forms/date-range-picker"
|
||||
|
||||
export function FiltersWidget({
|
||||
defaultFilters,
|
||||
defaultRange = "last-12-months",
|
||||
}: {
|
||||
defaultFilters: StatsFilters
|
||||
defaultRange?: string
|
||||
}) {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const [filters, setFilters] = useState<StatsFilters>(defaultFilters)
|
||||
|
||||
const applyFilters = () => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (filters?.dateFrom) {
|
||||
params.set("dateFrom", format(new Date(filters.dateFrom), "yyyy-MM-dd"))
|
||||
} else {
|
||||
params.delete("dateFrom")
|
||||
}
|
||||
if (filters?.dateTo) {
|
||||
params.set("dateTo", format(new Date(filters.dateTo), "yyyy-MM-dd"))
|
||||
} else {
|
||||
params.delete("dateTo")
|
||||
}
|
||||
router.push(`?${params.toString()}`)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
applyFilters()
|
||||
}, [filters])
|
||||
|
||||
return (
|
||||
<DateRangePicker
|
||||
defaultDate={{
|
||||
from: filters?.dateFrom ? new Date(filters.dateFrom) : undefined,
|
||||
to: filters?.dateTo ? new Date(filters.dateTo) : undefined,
|
||||
}}
|
||||
defaultRange={defaultRange}
|
||||
onChange={(date) => {
|
||||
setFilters({
|
||||
dateFrom: date && date.from ? format(date.from, "yyyy-MM-dd") : undefined,
|
||||
dateTo: date && date.to ? format(date.to, "yyyy-MM-dd") : undefined,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
86
components/dashboard/projects-widget.tsx
Normal file
86
components/dashboard/projects-widget.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { ProjectStats } from "@/data/stats"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
import { Project } from "@prisma/client"
|
||||
import { Plus } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
export function ProjectsWidget({
|
||||
projects,
|
||||
statsPerProject,
|
||||
}: {
|
||||
projects: Project[]
|
||||
statsPerProject: Record<string, ProjectStats>
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{projects.map((project) => (
|
||||
<Card key={project.code}>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Link href={`/transactions?projectCode=${project.code}`}>
|
||||
<Badge className="text-lg" style={{ backgroundColor: project.color }}>
|
||||
{project.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-4 justify-between items-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Income</div>
|
||||
<div className="text-2xl font-bold text-green-500">
|
||||
{Object.entries(statsPerProject[project.code]?.totalIncomePerCurrency).map(([currency, total]) => (
|
||||
<div
|
||||
key={currency}
|
||||
className="flex flex-col gap-2 font-bold text-green-500 text-base first:text-2xl"
|
||||
>
|
||||
{formatCurrency(total, currency)}
|
||||
</div>
|
||||
))}
|
||||
{!Object.entries(statsPerProject[project.code]?.totalIncomePerCurrency).length && (
|
||||
<div className="font-bold text-base first:text-2xl">0.00</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Expenses</div>
|
||||
<div className="text-2xl font-bold text-red-500">
|
||||
{Object.entries(statsPerProject[project.code]?.totalExpensesPerCurrency).map(([currency, total]) => (
|
||||
<div key={currency} className="flex flex-col gap-2 font-bold text-red-500 text-base first:text-2xl">
|
||||
{formatCurrency(total, currency)}
|
||||
</div>
|
||||
))}
|
||||
{!Object.entries(statsPerProject[project.code]?.totalExpensesPerCurrency).length && (
|
||||
<div className="font-bold text-base first:text-2xl">0.00</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Profit</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{Object.entries(statsPerProject[project.code]?.profitPerCurrency).map(([currency, total]) => (
|
||||
<div key={currency} className="flex flex-col gap-2 items-center text-2xl font-bold text-green-500">
|
||||
{formatCurrency(total, currency)}
|
||||
</div>
|
||||
))}
|
||||
{!Object.entries(statsPerProject[project.code]?.profitPerCurrency).length && (
|
||||
<div className="text-2xl font-bold">0.00</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<Link
|
||||
href="/settings/projects"
|
||||
className="flex items-center justify-center gap-2 border-dashed border-2 border-gray-300 rounded-md p-4 text-muted-foreground hover:text-primary hover:border-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create New Project
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
86
components/dashboard/stats-widget.tsx
Normal file
86
components/dashboard/stats-widget.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { getProjects } from "@/data/projects"
|
||||
import { getDashboardStats, getProjectStats, StatsFilters } from "@/data/stats"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
import { ArrowDown, ArrowUp, BicepsFlexed } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
|
||||
import { FiltersWidget } from "./filters-widget"
|
||||
import { ProjectsWidget } from "./projects-widget"
|
||||
|
||||
export async function StatsWidget({ filters }: { filters: StatsFilters }) {
|
||||
const projects = await getProjects()
|
||||
const stats = await getDashboardStats(filters)
|
||||
const statsPerProject = Object.fromEntries(
|
||||
await Promise.all(
|
||||
projects.map((project) => getProjectStats(project.code, filters).then((stats) => [project.code, stats]))
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold">Overview</h2>
|
||||
|
||||
<FiltersWidget defaultFilters={filters} defaultRange="last-12-months" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Income</CardTitle>
|
||||
<ArrowUp className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{Object.entries(stats.totalIncomePerCurrency).map(([currency, total]) => (
|
||||
<div key={currency} className="flex gap-2 items-center font-bold text-base first:text-2xl">
|
||||
{formatCurrency(total, currency)}
|
||||
</div>
|
||||
))}
|
||||
{!Object.entries(stats.totalIncomePerCurrency).length && <div className="text-2xl font-bold">0.00</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Expenses</CardTitle>
|
||||
<ArrowDown className="h-4 w-4 text-red-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{Object.entries(stats.totalExpensesPerCurrency).map(([currency, total]) => (
|
||||
<div key={currency} className="flex gap-2 items-center font-bold text-red-500 text-base first:text-2xl">
|
||||
{formatCurrency(total, currency)}
|
||||
</div>
|
||||
))}
|
||||
{!Object.entries(stats.totalExpensesPerCurrency).length && <div className="text-2xl font-bold">0.00</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Net Profit</CardTitle>
|
||||
<BicepsFlexed className="h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{Object.entries(stats.profitPerCurrency).map(([currency, total]) => (
|
||||
<div key={currency} className="flex gap-2 items-center font-bold text-green-500 text-base first:text-2xl">
|
||||
{formatCurrency(total, currency)}
|
||||
</div>
|
||||
))}
|
||||
{!Object.entries(stats.profitPerCurrency).length && <div className="text-2xl font-bold">0.00</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Processed Transactions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.invoicesProcessed}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Projects</h2>
|
||||
</div>
|
||||
|
||||
<ProjectsWidget projects={projects} statsPerProject={statsPerProject} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
components/dashboard/unsorted-widget.tsx
Normal file
45
components/dashboard/unsorted-widget.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { File } from "@prisma/client"
|
||||
import { FilePlus, PartyPopper } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function DashboardUnsortedWidget({ files }: { files: File[] }) {
|
||||
return (
|
||||
<Card className="w-full h-full sm:max-w-xs bg-accent">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Link href="/unsorted">
|
||||
{files.length > 0 ? `${files.length} unsorted files` : "No unsorted files"} →
|
||||
</Link>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
{files.slice(0, 3).map((file) => (
|
||||
<Link
|
||||
href={`/unsorted/#${file.id}`}
|
||||
key={file.id}
|
||||
className="rounded-md p-2 bg-background hover:bg-black hover:text-white"
|
||||
>
|
||||
<div className="flex flex-row gap-2">
|
||||
<FilePlus className="w-8 h-8" />
|
||||
<div className="grid flex-1 text-left leading-tight">
|
||||
<span className="truncate text-xs font-semibold">{file.filename}</span>
|
||||
<span className="truncate text-xs">{file.mimetype}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{files.length == 0 && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-sm text-muted-foreground h-full min-h-[100px]">
|
||||
<PartyPopper className="w-5 h-5" />
|
||||
<span>Everything is clear! Congrats!</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
105
components/dashboard/welcome-widget.tsx
Normal file
105
components/dashboard/welcome-widget.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardDescription, CardTitle } from "@/components/ui/card"
|
||||
import { getSettings } from "@/data/settings"
|
||||
import { Banknote, ChartBarStacked, FolderOpenDot, Key, TextCursorInput } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
export async function WelcomeWidget() {
|
||||
const settings = await getSettings()
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col md:flex-row items-start gap-10 p-10 w-full">
|
||||
<img src="/logo/1024.png" alt="Logo" className="w-64 h-64" />
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="text-2xl font-bold">Hey, I'm TaxHacker 👋</CardTitle>
|
||||
<CardDescription className="mt-5">
|
||||
<p className="mb-3">
|
||||
I'm a little accountant app that tries to help you deal with endless receipts, checks and invoices with (you
|
||||
guessed it) GenAI. Here's what I can do:
|
||||
</p>
|
||||
<ul className="mb-5 list-disc pl-5 space-y-1">
|
||||
<li>
|
||||
<strong>Upload me a photo or a PDF</strong> and I will recognize, categorize and save it as a transaction
|
||||
for your tax advisor.
|
||||
</li>
|
||||
<li>
|
||||
I can <strong>automatically convert currencies</strong> and look up exchange rates for a given date.
|
||||
</li>
|
||||
<li>
|
||||
I even <strong>support crypto!</strong> Historical exchange rates for staking too.
|
||||
</li>
|
||||
<li>
|
||||
All <strong>LLM prompts are configurable</strong>: for fields, categories and projects. You can go to
|
||||
settings and change them.
|
||||
</li>
|
||||
<li>
|
||||
I save data in a <strong>local SQLite database</strong> and can export it to CSV and ZIP archives.
|
||||
</li>
|
||||
<li>
|
||||
You can even <strong>create your own new fields</strong> to be analyzed and they will be included in the
|
||||
CSV export for your tax advisor.
|
||||
</li>
|
||||
<li>
|
||||
I'm still <strong>very young</strong> and can make mistakes. Use me at your own risk!
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mb-3">
|
||||
While I can save you a lot of time in categorizing transactions and generating reports, I still highly
|
||||
recommend giving the results to a professional tax advisor for review when filing your taxes!
|
||||
</p>
|
||||
</CardDescription>
|
||||
<div className="mt-2">
|
||||
<Link href="https://github.com/vas3k/TaxHacker" className="text-blue-500 hover:underline">
|
||||
Source Code
|
||||
</Link>
|
||||
<span className="mx-2">|</span>
|
||||
<Link href="https://github.com/vas3k/TaxHacker/issues" className="text-blue-500 hover:underline">
|
||||
Request New Feature
|
||||
</Link>
|
||||
<span className="mx-2">|</span>
|
||||
<Link href="https://github.com/vas3k/TaxHacker/issues" className="text-blue-500 hover:underline">
|
||||
Report a Bug
|
||||
</Link>
|
||||
<span className="mx-2">|</span>
|
||||
<Link href="mailto:me@vas3k.ru" className="text-blue-500 hover:underline">
|
||||
Contact the Author
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-8">
|
||||
{settings.openai_api_key === "" && (
|
||||
<Link href="/settings/llm">
|
||||
<Button>
|
||||
<Key className="h-4 w-4" />
|
||||
Please give your ChatGPT key here
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/settings">
|
||||
<Button variant="outline">
|
||||
<Banknote className="h-4 w-4" />
|
||||
Default Currency: {settings.default_currency}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/settings/categories">
|
||||
<Button variant="outline">
|
||||
<ChartBarStacked className="h-4 w-4" />
|
||||
Categories
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/settings/projects">
|
||||
<Button variant="outline">
|
||||
<FolderOpenDot className="h-4 w-4" />
|
||||
Projects
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/settings/fields">
|
||||
<Button variant="outline">
|
||||
<TextCursorInput className="h-4 w-4" />
|
||||
Custom Fields
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user