feat: invoice generator

This commit is contained in:
Vasily Zubarev
2025-05-07 14:53:13 +02:00
parent 287abbb219
commit 8b5a2e8056
59 changed files with 2606 additions and 124 deletions

View File

@@ -34,6 +34,7 @@ export function ExportTransactionsDialog({
children: React.ReactNode
}) {
const router = useRouter()
const [isLoading, setIsLoading] = useState(false)
const [exportFilters, setExportFilters] = useTransactionFilters()
const [exportFields, setExportFields] = useState<string[]>(
fields.map((field) => (deselectedFields.includes(field.code) ? "" : field.code))
@@ -41,6 +42,7 @@ export function ExportTransactionsDialog({
const [includeAttachments, setIncludeAttachments] = useState(true)
const handleSubmit = () => {
setIsLoading(true)
router.push(
`/export/transactions?${new URLSearchParams({
search: exportFilters?.search || "",
@@ -53,6 +55,9 @@ export function ExportTransactionsDialog({
includeAttachments: includeAttachments.toString(),
}).toString()}`
)
setTimeout(() => {
setIsLoading(false)
}, 3000)
}
return (
@@ -155,8 +160,8 @@ export function ExportTransactionsDialog({
</div>
</div>
<DialogFooter className="sm:justify-end">
<Button type="button" onClick={handleSubmit}>
Export Transactions
<Button type="button" onClick={handleSubmit} disabled={isLoading}>
{isLoading ? "Exporting..." : "Export Transactions"}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -3,15 +3,15 @@ import { useMemo } from "react"
import { FormSelect } from "./simple"
export const FormSelectCurrency = ({
title,
currencies,
title,
emptyValue,
placeholder,
hideIfEmpty = false,
...props
}: {
title: string
currencies: { code: string; name: string }[]
title?: string
emptyValue?: string
placeholder?: string
hideIfEmpty?: boolean

View File

@@ -10,11 +10,11 @@ import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
import { SelectProps } from "@radix-ui/react-select"
import { format } from "date-fns"
import { CalendarIcon } from "lucide-react"
import { InputHTMLAttributes, TextareaHTMLAttributes, useState } from "react"
import { CalendarIcon, Upload } from "lucide-react"
import { InputHTMLAttributes, TextareaHTMLAttributes, useEffect, useRef, useState } from "react"
type FormInputProps = InputHTMLAttributes<HTMLInputElement> & {
title: string
title?: string
hideIfEmpty?: boolean
}
@@ -25,40 +25,57 @@ export function FormInput({ title, hideIfEmpty = false, ...props }: FormInputPro
return (
<label className="flex flex-col gap-1">
<span className="text-sm font-medium">{title}</span>
{title && <span className="text-sm font-medium">{title}</span>}
<Input {...props} className={cn("bg-background", props.className)} />
</label>
)
}
type FormTextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
title: string
title?: string
hideIfEmpty?: boolean
}
export function FormTextarea({ title, hideIfEmpty = false, ...props }: FormTextareaProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
const textarea = textareaRef.current
if (!textarea) return
const resize = () => {
textarea.style.height = "auto"
textarea.style.height = `${textarea.scrollHeight + 5}px`
}
resize() // initial resize
textarea.addEventListener("input", resize)
return () => textarea.removeEventListener("input", resize)
}, [props.value, props.defaultValue])
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)} />
{title && <span className="text-sm font-medium">{title}</span>}
<Textarea ref={textareaRef} {...props} className={cn("bg-background", props.className)} />
</label>
)
}
export const FormSelect = ({
title,
items,
title,
emptyValue,
placeholder,
hideIfEmpty = false,
...props
}: {
title: string
items: Array<{ code: string; name: string; color?: string; badge?: string }>
title?: string
emptyValue?: string
placeholder?: string
hideIfEmpty?: boolean
@@ -69,7 +86,7 @@ export const FormSelect = ({
return (
<span className="flex flex-col gap-1">
<span className="text-sm font-medium">{title}</span>
{title && <span className="text-sm font-medium">{title}</span>}
<Select {...props}>
<SelectTrigger className="w-full min-w-[150px] bg-background">
<SelectValue placeholder={placeholder} />
@@ -94,14 +111,14 @@ export const FormSelect = ({
}
export const FormDate = ({
title,
name,
title,
placeholder = "Select date",
defaultValue,
...props
}: {
title: string
name: string
title?: string
placeholder?: string
defaultValue?: Date
}) => {
@@ -126,7 +143,7 @@ export const FormDate = ({
return (
<label className="flex flex-col gap-1">
<span className="text-sm font-medium">{title}</span>
{title && <span className="text-sm font-medium">{title}</span>}
<div className="relative">
<Popover>
<PopoverTrigger asChild>
@@ -157,3 +174,61 @@ export const FormDate = ({
</label>
)
}
export const FormAvatar = ({
title,
defaultValue,
className,
onChange,
...props
}: {
title?: string
defaultValue?: string
className?: string
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
} & InputHTMLAttributes<HTMLInputElement>) => {
const [preview, setPreview] = useState<string | null>(defaultValue || null)
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
const reader = new FileReader()
reader.onloadend = () => {
setPreview(reader.result as string)
}
reader.readAsDataURL(file)
}
// Call the original onChange if provided
if (onChange) {
onChange(e)
}
}
return (
<label className="inline-block">
{title && <span className="text-sm font-medium">{title}</span>}
<div className={cn("relative group", className)}>
<div className="absolute inset-0 flex items-center justify-center bg-background rounded-lg overflow-hidden">
{preview ? (
<img src={preview} alt="Avatar preview" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-muted flex items-center justify-center">
<span className="text-muted-foreground">No image</span>
</div>
)}
</div>
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg">
<input
type="file"
accept="image/*"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onChange={handleFileChange}
{...props}
/>
<Upload className="z-10 bg-white/30 text-white p-1 rounded-sm h-7 w-8 cursor-pointer" />
</div>
</div>
</label>
)
}

View File

@@ -0,0 +1,61 @@
"use client"
import { saveProfileAction } from "@/app/(app)/settings/actions"
import { FormError } from "@/components/forms/error"
import { FormAvatar, FormInput, FormTextarea } from "@/components/forms/simple"
import { Button } from "@/components/ui/button"
import { User } from "@/prisma/client"
import { CircleCheckBig } from "lucide-react"
import { useActionState } from "react"
export default function BusinessSettingsForm({ user }: { user: User }) {
const [saveState, saveAction, pending] = useActionState(saveProfileAction, null)
return (
<div>
<form action={saveAction} className="space-y-4">
<FormInput
title="Business Name"
name="businessName"
placeholder="Acme Inc."
defaultValue={user.businessName ?? ""}
/>
<FormTextarea
title="Business Address"
name="businessAddress"
placeholder="Street, City, State, Zip Code, Country, Tax ID"
defaultValue={user.businessAddress ?? ""}
/>
<FormTextarea
title="Bank Details"
name="businessBankDetails"
placeholder="Bank Name, Account Number, BIC, IBAN, details of payment, etc."
defaultValue={user.businessBankDetails ?? ""}
/>
<FormAvatar
title="Business Logo"
name="businessLogo"
className="w-52 h-52"
defaultValue={user.businessLogo ?? ""}
/>
<div className="flex flex-row items-center gap-4">
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save"}
</Button>
{saveState?.success && (
<p className="text-green-500 flex flex-row items-center gap-2">
<CircleCheckBig />
Saved!
</p>
)}
</div>
{saveState?.error && <FormError>{saveState.error}</FormError>}
</form>
</div>
)
}

View File

@@ -2,7 +2,7 @@
import { saveProfileAction } from "@/app/(app)/settings/actions"
import { FormError } from "@/components/forms/error"
import { FormInput } from "@/components/forms/simple"
import { FormAvatar, FormInput } from "@/components/forms/simple"
import { Button } from "@/components/ui/button"
import { User } from "@/prisma/client"
import { CircleCheckBig } from "lucide-react"
@@ -15,7 +15,9 @@ export default function ProfileSettingsForm({ user }: { user: User }) {
return (
<div>
<form action={saveAction} className="space-y-4">
<FormInput title="Your Name" name="name" defaultValue={user.name || ""} />
<FormAvatar title="Avatar" name="avatar" className="w-24 h-24" defaultValue={user.avatar || ""} />
<FormInput title="Account Name" name="name" defaultValue={user.name || ""} />
<div className="flex flex-row items-center gap-4">
<Button type="submit" disabled={pending}>

View File

@@ -4,14 +4,16 @@ import {
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { SidebarMenuButton } from "@/components/ui/sidebar"
import { UserProfile } from "@/lib/auth"
import { authClient } from "@/lib/auth-client"
import { PLANS } from "@/lib/stripe"
import { formatBytes } from "@/lib/utils"
import { HardDrive, LogOut, MoreVertical, User } from "lucide-react"
import { CreditCard, LogOut, MoreVertical, Settings, Sparkles, User } from "lucide-react"
import Link from "next/link"
import { redirect } from "next/navigation"
@@ -40,26 +42,50 @@ export default function SidebarUser({ profile, isSelfHosted }: { profile: UserPr
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
side={"top"}
side="top"
align="center"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={profile.avatar} alt={profile.name || ""} />
<AvatarFallback className="rounded-lg">
<User className="h-4 w-4" />
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{profile.name || profile.email}</span>
<span className="truncate text-xs">{profile.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{/* <DropdownMenuItem>
<ThemeToggle />
</DropdownMenuItem> */}
<DropdownMenuItem asChild>
<Link href="/settings/profile" className="flex items-center gap-2">
<User className="h-4 w-4" />
Profile & Plan
<Sparkles />
<span className="truncate">{PLANS[profile.membershipPlan as keyof typeof PLANS].name}</span>
<span className="ml-auto text-xs text-muted-foreground">{formatBytes(profile.storageUsed)} used</span>
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link href="/settings/profile" className="flex items-center gap-2">
<HardDrive className="h-4 w-4" />
Storage: {formatBytes(profile.storageUsed)}
<Link href="/settings" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
Settings
</Link>
</DropdownMenuItem>
{!isSelfHosted && (
<DropdownMenuItem asChild>
<Link href="/api/stripe/portal" className="flex items-center gap-2">
<CreditCard className="h-4 w-4" />
Billing
</Link>
</DropdownMenuItem>
)}
</DropdownMenuGroup>
{!isSelfHosted && (
<>

View File

@@ -18,7 +18,7 @@ import {
} from "@/components/ui/sidebar"
import { UserProfile } from "@/lib/auth"
import config from "@/lib/config"
import { ClockArrowUp, FileText, Import, LayoutDashboard, Settings, Sparkles, Upload } from "lucide-react"
import { ClockArrowUp, FileText, Gift, House, Import, LayoutDashboard, Settings, Upload } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { usePathname } from "next/navigation"
@@ -72,7 +72,7 @@ export function AppSidebar({
<SidebarMenuItemWithHighlight href="/dashboard">
<SidebarMenuButton asChild>
<Link href="/dashboard">
<LayoutDashboard />
<House />
<span>Home</span>
</Link>
</SidebarMenuButton>
@@ -106,7 +106,14 @@ export function AppSidebar({
</Link>
</SidebarMenuButton>
</SidebarMenuItemWithHighlight>
<SidebarMenuItemWithHighlight href="/apps">
<SidebarMenuButton asChild>
<Link href="/apps">
<LayoutDashboard />
<span>Apps</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItemWithHighlight>
<SidebarMenuItemWithHighlight href="/settings">
<SidebarMenuButton asChild>
<Link href="/settings">
@@ -136,7 +143,7 @@ export function AppSidebar({
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link href="https://vas3k.com/donate/" target="_blank">
<Sparkles />
<Gift />
Thank the author
</Link>
</SidebarMenuButton>