mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-14 13:01:19 +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:
77
components/forms/convert-currency.tsx
Normal file
77
components/forms/convert-currency.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
129
components/forms/date-range-picker.tsx
Normal file
129
components/forms/date-range-picker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
components/forms/select-category.tsx
Normal file
23
components/forms/select-category.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
21
components/forms/select-currency.tsx
Normal file
21
components/forms/select-currency.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
21
components/forms/select-project.tsx
Normal file
21
components/forms/select-project.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
18
components/forms/select-type.tsx
Normal file
18
components/forms/select-type.tsx
Normal 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} />
|
||||
}
|
||||
77
components/forms/simple.tsx
Normal file
77
components/forms/simple.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user