mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 21:35:19 +00:00
feat: invoice generator
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
61
components/settings/business-settings-form.tsx
Normal file
61
components/settings/business-settings-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user