(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,77 @@
import { getCurrencyRate } from "@/lib/currency-scraper"
import { formatCurrency } from "@/lib/utils"
import { format, startOfDay } from "date-fns"
import { Loader2 } from "lucide-react"
import { useEffect, useState } from "react"
export const FormConvertCurrency = ({
originalTotal,
originalCurrencyCode,
targetCurrencyCode,
date,
onChange,
}: {
originalTotal: number
originalCurrencyCode: string
targetCurrencyCode: string
date?: Date | undefined
onChange?: (value: number) => void
}) => {
if (
originalTotal === 0 ||
!originalCurrencyCode ||
!targetCurrencyCode ||
originalCurrencyCode === targetCurrencyCode
) {
return <></>
}
const normalizedDate = startOfDay(date || new Date(Date.now() - 24 * 60 * 60 * 1000))
const [exchangeRate, setExchangeRate] = useState(0)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
const fetchData = async () => {
try {
setIsLoading(true)
const exchangeRate = await getCurrencyRate(originalCurrencyCode, targetCurrencyCode, normalizedDate)
setExchangeRate(exchangeRate)
onChange?.(originalTotal * exchangeRate)
} catch (error) {
console.error("Error fetching currency rates:", error)
setExchangeRate(0)
onChange?.(0)
} finally {
setIsLoading(false)
}
}
fetchData()
}, [originalCurrencyCode, targetCurrencyCode, format(normalizedDate, "LLLL-mm-dd")])
return (
<div className="flex flex-row gap-2 items-center text-muted-foreground">
{isLoading ? (
<div className="flex flex-row gap-2">
<Loader2 className="animate-spin" />
<div>Loading exchange rates...</div>
</div>
) : (
<div className="flex items-center gap-2">
<div>{formatCurrency(originalTotal * 100, originalCurrencyCode)}</div>
<div>=</div>
<div>{formatCurrency(originalTotal * 100 * exchangeRate, targetCurrencyCode).slice(0, 1)}</div>
<input
type="number"
step="0.01"
name="convertedTotal"
value={(originalTotal * exchangeRate).toFixed(2)}
onChange={(e) => onChange?.(parseFloat(e.target.value))}
className="w-32 rounded-md border border-input bg-transparent px-1"
/>
<div className="text-xs">(exchange rate on {format(normalizedDate, "LLLL dd, yyyy")})</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,129 @@
"use client"
import { format, startOfMonth, startOfQuarter, subMonths, subWeeks } from "date-fns"
import { CalendarIcon } from "lucide-react"
import { useState } from "react"
import { DateRange } from "react-day-picker"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { cn } from "@/lib/utils"
export function DateRangePicker({
defaultDate,
defaultRange = "all-time",
onChange,
}: {
defaultDate?: DateRange
defaultRange?: string
onChange?: (date: DateRange | undefined) => void
}) {
const predefinedRanges = [
{
code: "last-4-weeks",
label: "Last 4 weeks",
range: { from: subWeeks(new Date(), 4), to: new Date() },
},
{
code: "last-12-months",
label: "Last 12 months",
range: { from: subMonths(new Date(), 12), to: new Date() },
},
{
code: "month-to-date",
label: "Month to date",
range: { from: startOfMonth(new Date()), to: new Date() },
},
{
code: "quarter-to-date",
label: "Quarter to date",
range: { from: startOfQuarter(new Date()), to: new Date() },
},
{
code: `${new Date().getFullYear()}`,
label: `${new Date().getFullYear()}`,
range: {
from: new Date(new Date().getFullYear(), 0, 1),
to: new Date(),
},
},
{
code: `${new Date().getFullYear() - 1}`,
label: `${new Date().getFullYear() - 1}`,
range: {
from: new Date(new Date().getFullYear() - 1, 0, 1),
to: new Date(new Date().getFullYear(), 0, 1),
},
},
{
code: "all-time",
label: "All time",
range: { from: undefined, to: undefined },
},
]
const [rangeName, setRangeName] = useState<string>(defaultDate?.from ? "custom" : defaultRange)
const [dateRange, setDateRange] = useState<DateRange | undefined>(defaultDate)
return (
<Popover>
<PopoverTrigger asChild>
<Button
id="date"
variant={"outline"}
className={cn(
"w-auto min-w-[130px] justify-start text-left font-normal",
rangeName === "all-time" && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{rangeName === "custom" ? (
dateRange?.from ? (
dateRange.to ? (
`${format(dateRange.from, "LLL dd, y")} - ${format(dateRange.to, "LLL dd, y")}`
) : (
format(dateRange.from, "LLL dd, y")
)
) : (
<span>???</span>
)
) : (
predefinedRanges.find((range) => range.code === rangeName)?.label
)}
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-row gap-3 w-auto p-0" align="end">
<div className="flex flex-col gap-3 p-3 border-r">
{predefinedRanges.map(({ code, label }) => (
<Button
key={code}
variant="ghost"
className="justify-start pr-5"
onClick={() => {
setRangeName(code)
const newDateRange = predefinedRanges.find((range) => range.code === code)?.range
setDateRange(newDateRange)
onChange?.(newDateRange)
}}
>
{label}
</Button>
))}
</div>
<Calendar
initialFocus
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={(newDateRange) => {
setRangeName("custom")
setDateRange(newDateRange)
onChange?.(newDateRange)
}}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,23 @@
"use client"
import { Category } from "@prisma/client"
import { SelectProps } from "@radix-ui/react-select"
import { FormSelect } from "./simple"
export const FormSelectCategory = ({
title,
categories,
emptyValue,
placeholder,
...props
}: { title: string; categories: Category[]; emptyValue?: string; placeholder?: string } & SelectProps) => {
return (
<FormSelect
title={title}
items={categories.map((category) => ({ code: category.code, name: category.name, color: category.color }))}
emptyValue={emptyValue}
placeholder={placeholder}
{...props}
/>
)
}

View File

@@ -0,0 +1,21 @@
import { Currency } from "@prisma/client"
import { SelectProps } from "@radix-ui/react-select"
import { FormSelect } from "./simple"
export const FormSelectCurrency = ({
title,
currencies,
emptyValue,
placeholder,
...props
}: { title: string; currencies: Currency[]; emptyValue?: string; placeholder?: string } & SelectProps) => {
return (
<FormSelect
title={title}
items={currencies.map((currency) => ({ code: currency.code, name: `${currency.code} - ${currency.name}` }))}
emptyValue={emptyValue}
placeholder={placeholder}
{...props}
/>
)
}

View File

@@ -0,0 +1,21 @@
import { Project } from "@prisma/client"
import { SelectProps } from "@radix-ui/react-select"
import { FormSelect } from "./simple"
export const FormSelectProject = ({
title,
projects,
emptyValue,
placeholder,
...props
}: { title: string; projects: Project[]; emptyValue?: string; placeholder?: string } & SelectProps) => {
return (
<FormSelect
title={title}
items={projects.map((project) => ({ code: project.code, name: project.name, color: project.color }))}
emptyValue={emptyValue}
placeholder={placeholder}
{...props}
/>
)
}

View File

@@ -0,0 +1,18 @@
import { SelectProps } from "@radix-ui/react-select"
import { FormSelect } from "./simple"
export const FormSelectType = ({
title,
emptyValue,
placeholder,
...props
}: { title: string; emptyValue?: string; placeholder?: string } & SelectProps) => {
const items = [
{ code: "expense", name: "Expense" },
{ code: "income", name: "Income" },
{ code: "pending", name: "Pending" },
{ code: "other", name: "Other" },
]
return <FormSelect title={title} items={items} emptyValue={emptyValue} placeholder={placeholder} {...props} />
}

View File

@@ -0,0 +1,77 @@
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
import { SelectProps } from "@radix-ui/react-select"
import { InputHTMLAttributes, TextareaHTMLAttributes } from "react"
type FormInputProps = InputHTMLAttributes<HTMLInputElement> & {
title: string
hideIfEmpty?: boolean
}
export function FormInput({ title, hideIfEmpty = false, ...props }: FormInputProps) {
if (hideIfEmpty && (!props.defaultValue || props.defaultValue.toString().trim() === "") && !props.value) {
return null
}
return (
<label className="flex flex-col gap-1">
<span className="text-sm font-medium">{title}</span>
<Input {...props} className={cn("bg-background", props.className)} />
</label>
)
}
type FormTextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
title: string
hideIfEmpty?: boolean
}
export function FormTextarea({ title, hideIfEmpty = false, ...props }: FormTextareaProps) {
if (hideIfEmpty && (!props.defaultValue || props.defaultValue.toString().trim() === "") && !props.value) {
return null
}
return (
<label className="flex flex-col gap-1">
<span className="text-sm font-medium">{title}</span>
<Textarea {...props} className={cn("bg-background", props.className)} />
</label>
)
}
export const FormSelect = ({
title,
items,
emptyValue,
placeholder,
...props
}: {
title: string
items: Array<{ code: string; name: string; color?: string }>
emptyValue?: string
placeholder?: string
} & SelectProps) => {
return (
<span className="flex flex-col gap-1">
<span className="text-sm font-medium">{title}</span>
<Select {...props}>
<SelectTrigger className="w-full min-w-[150px] bg-background">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{emptyValue && <SelectItem value="-">{emptyValue}</SelectItem>}
{items.map((item) => (
<SelectItem key={item.code} value={item.code}>
<div className="flex items-center gap-2 text-base pr-2">
{item.color && <div className="w-2 h-2 rounded-full" style={{ backgroundColor: item.color }} />}
{item.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</span>
)
}