- Added ResponsiveDialog component for handling responsive dialogs in the UI. - Created ExpensesProvider to manage state and API interactions for expenses. - Developed ExpensesTabContent to display expenses with filtering options and dialogs for new and pay expense actions. - Implemented ExpensesTable for rendering expense data in a tabular format with actions. - Added dialogs for creating new expenses and paying existing ones with form validation. - Introduced PeriodicExpenseForm for managing periodic expenses with month selection. - Created PeriodicExpensesTabContent to manage and display periodic expenses with create/edit functionality. - Added GenerateCurrentMonthDialog for confirming monthly expense generation. - Implemented PeriodicExpensesTable for displaying periodic expenses with edit and delete actions.
226 lines
6.3 KiB
TypeScript
226 lines
6.3 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
|
import { cn } from "@/lib/utils"
|
|
import { Button } from "@/components/ui/button"
|
|
import { XIcon } from "lucide-react"
|
|
|
|
const MEDIA_QUERY = "(min-width: 640px)"
|
|
|
|
function useMediaQuery(query: string) {
|
|
const [matches, setMatches] = React.useState(() => {
|
|
if (typeof window === "undefined") return true
|
|
return window.matchMedia(query).matches
|
|
})
|
|
|
|
React.useEffect(() => {
|
|
const mql = window.matchMedia(query)
|
|
const handler = (e: MediaQueryListEvent) => setMatches(e.matches)
|
|
mql.addEventListener("change", handler)
|
|
return () => mql.removeEventListener("change", handler)
|
|
}, [query])
|
|
|
|
return matches
|
|
}
|
|
|
|
interface ResponsiveDialogContextValue {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
isDesktop: boolean
|
|
}
|
|
|
|
const ResponsiveDialogContext = React.createContext<ResponsiveDialogContextValue | null>(null)
|
|
|
|
function useResponsiveDialog() {
|
|
const ctx = React.useContext(ResponsiveDialogContext)
|
|
if (!ctx) throw new Error("ResponsiveDialog components must be used within ResponsiveDialog")
|
|
return ctx
|
|
}
|
|
|
|
function ResponsiveDialog({
|
|
open,
|
|
onOpenChange,
|
|
children,
|
|
}: {
|
|
open?: boolean
|
|
onOpenChange?: (open: boolean) => void
|
|
children: React.ReactNode
|
|
}) {
|
|
const [internalOpen, setInternalOpen] = React.useState(false)
|
|
const isDesktop = useMediaQuery(MEDIA_QUERY)
|
|
|
|
const controlled = open !== undefined
|
|
const currentOpen = controlled ? open : internalOpen
|
|
const setCurrentOpen = controlled ? onOpenChange! : setInternalOpen
|
|
|
|
return (
|
|
<ResponsiveDialogContext value={{ open: currentOpen, onOpenChange: setCurrentOpen, isDesktop }}>
|
|
{isDesktop ? (
|
|
<DialogPrimitive.Root open={currentOpen} onOpenChange={setCurrentOpen}>
|
|
{children}
|
|
</DialogPrimitive.Root>
|
|
) : (
|
|
<DialogPrimitive.Root open={currentOpen} onOpenChange={setCurrentOpen}>
|
|
{children}
|
|
</DialogPrimitive.Root>
|
|
)}
|
|
</ResponsiveDialogContext>
|
|
)
|
|
}
|
|
|
|
function ResponsiveDialogTrigger({
|
|
children,
|
|
asChild,
|
|
...props
|
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
|
return (
|
|
<DialogPrimitive.Trigger data-slot="responsive-dialog-trigger" {...props}>
|
|
{children}
|
|
</DialogPrimitive.Trigger>
|
|
)
|
|
}
|
|
|
|
function ResponsiveDialogContent({
|
|
className,
|
|
children,
|
|
...props
|
|
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
|
const { isDesktop, open, onOpenChange } = useResponsiveDialog()
|
|
|
|
if (isDesktop) {
|
|
return (
|
|
<DialogPrimitive.Portal>
|
|
<DialogPrimitive.Overlay
|
|
data-slot="responsive-dialog-overlay"
|
|
className="fixed inset-0 z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0"
|
|
/>
|
|
<DialogPrimitive.Content
|
|
data-slot="responsive-dialog-content"
|
|
className={cn(
|
|
"fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2",
|
|
"flex flex-col gap-4 rounded-xl border bg-popover p-6 text-popover-foreground shadow-lg",
|
|
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-open:slide-in-from-left-1/2 data-open:slide-in-from-top-[48%]",
|
|
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-closed:slide-out-to-left-1/2 data-closed:slide-out-to-top-[48%]",
|
|
"duration-200",
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
{children}
|
|
<DialogPrimitive.Close asChild>
|
|
<Button
|
|
variant="ghost"
|
|
className="absolute top-3 right-3"
|
|
size="icon-sm"
|
|
>
|
|
<XIcon />
|
|
<span className="sr-only">Close</span>
|
|
</Button>
|
|
</DialogPrimitive.Close>
|
|
</DialogPrimitive.Content>
|
|
</DialogPrimitive.Portal>
|
|
)
|
|
}
|
|
|
|
if (!open) return null
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-end sm:hidden" data-slot="responsive-dialog-mobile">
|
|
<div
|
|
className="fixed inset-0 z-40 bg-black/10 supports-backdrop-filter:backdrop-blur-xs"
|
|
onClick={() => onOpenChange(false)}
|
|
/>
|
|
<div
|
|
className={cn(
|
|
"relative z-50 flex w-full flex-col gap-4 rounded-t-xl border bg-popover p-6 text-popover-foreground shadow-lg",
|
|
"animate-in slide-in-from-bottom-10 fade-in-0 duration-200",
|
|
className
|
|
)}
|
|
>
|
|
<div className="mx-auto -mt-2 mb-1 h-1.5 w-10 rounded-full bg-muted" />
|
|
<DialogPrimitive.Close asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="absolute top-3 right-3"
|
|
>
|
|
<XIcon />
|
|
<span className="sr-only">Close</span>
|
|
</Button>
|
|
</DialogPrimitive.Close>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ResponsiveDialogHeader({
|
|
className,
|
|
...props
|
|
}: React.ComponentProps<"div">) {
|
|
return (
|
|
<div
|
|
data-slot="responsive-dialog-header"
|
|
className={cn("flex flex-col gap-0.5", className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function ResponsiveDialogFooter({
|
|
className,
|
|
...props
|
|
}: React.ComponentProps<"div">) {
|
|
return (
|
|
<div
|
|
data-slot="responsive-dialog-footer"
|
|
className={cn("mt-2 flex flex-col-reverse sm:flex-row sm:justify-end gap-2", className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function ResponsiveDialogTitle({
|
|
className,
|
|
...props
|
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
|
return (
|
|
<DialogPrimitive.Title
|
|
data-slot="responsive-dialog-title"
|
|
className={cn("text-base font-medium text-foreground", className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function ResponsiveDialogDescription({
|
|
className,
|
|
...props
|
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
|
return (
|
|
<DialogPrimitive.Description
|
|
data-slot="responsive-dialog-description"
|
|
className={cn("text-sm text-muted-foreground", className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function ResponsiveDialogClose({
|
|
...props
|
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
|
return <DialogPrimitive.Close data-slot="responsive-dialog-close" {...props} />
|
|
}
|
|
|
|
export {
|
|
ResponsiveDialog,
|
|
ResponsiveDialogTrigger,
|
|
ResponsiveDialogContent,
|
|
ResponsiveDialogHeader,
|
|
ResponsiveDialogFooter,
|
|
ResponsiveDialogTitle,
|
|
ResponsiveDialogDescription,
|
|
ResponsiveDialogClose,
|
|
}
|