(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,165 @@
"use client"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { CircleCheck, Edit, Trash2 } from "lucide-react"
import { useOptimistic, useState } from "react"
interface CrudProps<T> {
items: T[]
columns: {
key: keyof T
label: string
type?: "text" | "number" | "checkbox"
editable?: boolean
}[]
onDelete: (id: string) => Promise<void>
onAdd: (data: Partial<T>) => Promise<void>
onEdit?: (id: string, data: Partial<T>) => Promise<void>
}
export function CrudTable<T extends { [key: string]: any }>({ items, columns, onDelete, onAdd, onEdit }: CrudProps<T>) {
const [isAdding, setIsAdding] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [newItem, setNewItem] = useState<Partial<T>>({})
const [editingItem, setEditingItem] = useState<Partial<T>>({})
const [optimisticItems, addOptimisticItem] = useOptimistic(items, (state, newItem: T) => [...state, newItem])
const handleAdd = async () => {
try {
await onAdd(newItem)
setIsAdding(false)
setNewItem({})
} catch (error) {
console.error("Failed to add item:", error)
}
}
const handleEdit = async (id: string) => {
if (!onEdit) return
try {
await onEdit(id, editingItem)
setEditingId(null)
setEditingItem({})
} catch (error) {
console.error("Failed to edit item:", error)
}
}
const startEditing = (item: T) => {
setEditingId(item.code || item.id)
setEditingItem(item)
}
const handleDelete = async (id: string) => {
try {
await onDelete(id)
} catch (error) {
console.error("Failed to delete item:", error)
}
}
return (
<div className="space-y-4">
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead key={String(column.key)}>{column.label}</TableHead>
))}
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{optimisticItems.map((item, index) => (
<TableRow key={index}>
{columns.map((column) => (
<TableCell key={String(column.key)} className="first:font-semibold">
{editingId === (item.code || item.id) && column.editable ? (
<Input
type={column.type || "text"}
value={editingItem[column.key] || ""}
onChange={(e) =>
setEditingItem({
...editingItem,
[column.key]: column.type === "checkbox" ? e.target.checked : e.target.value,
})
}
/>
) : column.type === "checkbox" ? (
item[column.key] ? (
<CircleCheck />
) : (
""
)
) : (
item[column.key]
)}
</TableCell>
))}
<TableCell>
<div className="flex gap-2">
{editingId === (item.code || item.id) ? (
<>
<Button size="sm" onClick={() => handleEdit(item.code || item.id)}>
Save
</Button>
<Button size="sm" variant="outline" onClick={() => setEditingId(null)}>
Cancel
</Button>
</>
) : (
<>
{onEdit && (
<Button variant="ghost" size="icon" onClick={() => startEditing(item)}>
<Edit />
</Button>
)}
{item.isDeletable && (
<Button variant="ghost" size="icon" onClick={() => handleDelete(item.code || item.id)}>
<Trash2 />
</Button>
)}
</>
)}
</div>
</TableCell>
</TableRow>
))}
{isAdding && (
<TableRow>
{columns.map((column) => (
<TableCell key={String(column.key)} className="first:font-semibold">
{column.editable && (
<Input
type={column.type || "text"}
value={newItem[column.key] || ""}
onChange={(e) =>
setNewItem({
...newItem,
[column.key]: column.type === "checkbox" ? e.target.checked : e.target.value,
})
}
/>
)}
</TableCell>
))}
<TableCell>
<div className="flex gap-2">
<Button size="sm" onClick={handleAdd}>
Save
</Button>
<Button size="sm" variant="outline" onClick={() => setIsAdding(false)}>
Cancel
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{!isAdding && <Button onClick={() => setIsAdding(true)}>Add New</Button>}
</div>
)
}

View File

@@ -0,0 +1,59 @@
"use client"
import { saveSettingsAction } from "@/app/settings/actions"
import { FormSelectCategory } from "@/components/forms/select-category"
import { FormSelectCurrency } from "@/components/forms/select-currency"
import { FormSelectType } from "@/components/forms/select-type"
import { FormInput } from "@/components/forms/simple"
import { Button } from "@/components/ui/button"
import { Category, Currency } from "@prisma/client"
import { CircleCheckBig } from "lucide-react"
import { useActionState } from "react"
export default function GlobalSettingsForm({
settings,
currencies,
categories,
}: {
settings: Record<string, string>
currencies: Currency[]
categories: Category[]
}) {
const [saveState, saveAction, pending] = useActionState(saveSettingsAction, null)
return (
<form action={saveAction} className="space-y-4">
<FormInput title="App Title" name="app_title" defaultValue={settings.app_title} />
<FormSelectCurrency
title="Default Currency"
name="default_currency"
defaultValue={settings.default_currency}
currencies={currencies}
/>
<FormSelectType title="Default Transaction Type" name="default_type" defaultValue={settings.default_type} />
<FormSelectCategory
title="Default Transaction Category"
name="default_category"
defaultValue={settings.default_category}
categories={categories}
/>
<div className="flex flex-row items-center gap-4">
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save Settings"}
</Button>
{saveState?.success && (
<p className="text-green-500 flex flex-row items-center gap-2">
<CircleCheckBig />
Saved!
</p>
)}
</div>
{saveState?.error && <p className="text-red-500">{saveState.error}</p>}
</form>
)
}

View File

@@ -0,0 +1,38 @@
"use client"
import { saveSettingsAction } from "@/app/settings/actions"
import { FormInput, FormTextarea } from "@/components/forms/simple"
import { Button } from "@/components/ui/button"
import { CircleCheckBig } from "lucide-react"
import { useActionState } from "react"
export default function LLMSettingsForm({ settings }: { settings: Record<string, string> }) {
const [saveState, saveAction, pending] = useActionState(saveSettingsAction, null)
return (
<form action={saveAction} className="space-y-4">
<FormInput title="OpenAI API Key" name="openai_api_key" defaultValue={settings.openai_api_key} />
<FormTextarea
title="Prompt for Analyze Transaction"
name="prompt_analyse_new_file"
defaultValue={settings.prompt_analyse_new_file}
className="h-96"
/>
<div className="flex flex-row items-center gap-4">
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save Settings"}
</Button>
{saveState?.success && (
<p className="text-green-500 flex flex-row items-center gap-2">
<CircleCheckBig />
Saved!
</p>
)}
</div>
{saveState?.error && <p className="text-red-500">{saveState.error}</p>}
</form>
)
}

View File

@@ -0,0 +1,35 @@
"use client"
import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import Link from "next/link"
import { usePathname } from "next/navigation"
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
items: {
href: string
title: string
}[]
}
export function SideNav({ className, items, ...props }: SidebarNavProps) {
const pathname = usePathname()
return (
<nav className={cn("flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1", className)} {...props}>
{items.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
buttonVariants({ variant: "ghost" }),
pathname === item.href ? "bg-muted hover:bg-muted" : "hover:bg-transparent hover:underline",
"justify-start"
)}
>
{item.title}
</Link>
))}
</nav>
)
}