feat(expenses): implement responsive dialog for expense management and create expense provider
- 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.
This commit is contained in:
87
apps/frontend/src/components/ui/calendar.tsx
Normal file
87
apps/frontend/src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("w-full", className)}
|
||||
classNames={{
|
||||
root: "w-full",
|
||||
months: "flex flex-col",
|
||||
month: "flex flex-col gap-3",
|
||||
month_caption: "flex items-center justify-center",
|
||||
caption_label: "hidden",
|
||||
nav: "flex items-center gap-1",
|
||||
button_previous:
|
||||
"inline-flex items-center justify-center rounded-md size-7 bg-transparent p-0 text-foreground opacity-50 hover:opacity-100 hover:bg-accent",
|
||||
button_next:
|
||||
"inline-flex items-center justify-center rounded-md size-7 bg-transparent p-0 text-foreground opacity-50 hover:opacity-100 hover:bg-accent",
|
||||
dropdowns: "flex items-center gap-2",
|
||||
dropdown_root: "relative inline-flex items-center",
|
||||
dropdown:
|
||||
"h-7 w-full appearance-none rounded-md border border-input bg-background pl-2 pr-6 text-xs font-medium text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 cursor-pointer",
|
||||
months_dropdown: "w-28",
|
||||
years_dropdown: "w-22",
|
||||
chevron: "size-3 text-muted-foreground",
|
||||
month_grid: "w-full border-collapse",
|
||||
weekdays: "flex",
|
||||
weekday: "w-8 text-xs font-normal text-muted-foreground pt-2 pb-1 text-center",
|
||||
week: "flex w-full",
|
||||
day: "p-0 size-8 text-center text-sm",
|
||||
day_button:
|
||||
"inline-flex items-center justify-center rounded-md size-8 text-sm font-normal text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 aria-selected:opacity-100",
|
||||
today: "bg-accent text-accent-foreground rounded-md",
|
||||
outside: "text-muted-foreground opacity-50",
|
||||
disabled: "text-muted-foreground opacity-50",
|
||||
range_middle:
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground rounded-none",
|
||||
range_start:
|
||||
"aria-selected:bg-primary aria-selected:text-primary-foreground rounded-l-md",
|
||||
range_end:
|
||||
"aria-selected:bg-primary aria-selected:text-primary-foreground rounded-r-md",
|
||||
selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground rounded-md",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Chevron: (props) => {
|
||||
const { orientation, ...rest } = props
|
||||
if (orientation === "up" || orientation === "down") {
|
||||
return <ChevronDownIcon className="size-3" {...rest} />
|
||||
}
|
||||
const Icon = orientation === "left" ? ChevronLeftIcon : ChevronRightIcon
|
||||
return <Icon className="size-4" {...rest} />
|
||||
},
|
||||
Select: (props) => {
|
||||
const { className, children, ...rest } = props
|
||||
return (
|
||||
<select
|
||||
className={cn("h-7 appearance-none rounded-md border border-input bg-background px-2 text-xs font-medium text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 cursor-pointer bg-[right_4px_center] bg-no-repeat", className)}
|
||||
style={{ backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23666' stroke-width='1.5'%3e%3cpath d='M4 6l4 4 4-4'/%3e%3c/svg%3e")` }}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
)
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Calendar.displayName = "Calendar"
|
||||
|
||||
export { Calendar }
|
||||
62
apps/frontend/src/components/ui/date-picker.tsx
Normal file
62
apps/frontend/src/components/ui/date-picker.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { format } from "date-fns"
|
||||
import { es } from "date-fns/locale"
|
||||
import { CalendarIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
|
||||
interface DatePickerProps {
|
||||
value: Date | undefined
|
||||
onChange: (date: Date | undefined) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Seleccionar fecha",
|
||||
}: DatePickerProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-8 w-full justify-start gap-2 px-2.5 font-normal",
|
||||
!value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="size-4 shrink-0" />
|
||||
{value ? (
|
||||
format(value, "PPP", { locale: es })
|
||||
) : (
|
||||
<span>{placeholder}</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={value}
|
||||
defaultMonth={value}
|
||||
captionLayout="dropdown"
|
||||
onSelect={(date) => {
|
||||
onChange(date)
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
48
apps/frontend/src/components/ui/popover.tsx
Normal file
48
apps/frontend/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-lg border bg-popover p-4 text-popover-foreground shadow-md outline-none",
|
||||
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
|
||||
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2",
|
||||
"data-[side=left]:slide-in-from-right-2",
|
||||
"data-[side=right]:slide-in-from-left-2",
|
||||
"data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
225
apps/frontend/src/components/ui/responsive-dialog.tsx
Normal file
225
apps/frontend/src/components/ui/responsive-dialog.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
"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,
|
||||
}
|
||||
Reference in New Issue
Block a user