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:
6
apps/frontend/node_modules/.vite/deps/@tanstack_react-router.js
generated
vendored
6
apps/frontend/node_modules/.vite/deps/@tanstack_react-router.js
generated
vendored
@@ -3,12 +3,12 @@ import {
|
||||
require_with_selector
|
||||
} from "./chunk-NMJBVCD2.js";
|
||||
import "./chunk-X37QSMNJ.js";
|
||||
import {
|
||||
require_react_dom
|
||||
} from "./chunk-JCMTJHXH.js";
|
||||
import {
|
||||
require_jsx_runtime
|
||||
} from "./chunk-SOQCU2R6.js";
|
||||
import {
|
||||
require_react_dom
|
||||
} from "./chunk-JCMTJHXH.js";
|
||||
import {
|
||||
require_react
|
||||
} from "./chunk-6VO5C3OI.js";
|
||||
|
||||
142
apps/frontend/node_modules/.vite/deps/_metadata.json
generated
vendored
142
apps/frontend/node_modules/.vite/deps/_metadata.json
generated
vendored
@@ -1,128 +1,206 @@
|
||||
{
|
||||
"hash": "5f00aae5",
|
||||
"hash": "3d534d7c",
|
||||
"configHash": "c0c89d92",
|
||||
"lockfileHash": "9500aa13",
|
||||
"browserHash": "efa9b066",
|
||||
"lockfileHash": "e6e58bf9",
|
||||
"browserHash": "5ec6480f",
|
||||
"optimized": {
|
||||
"react": {
|
||||
"src": "../../../../../node_modules/react/index.js",
|
||||
"file": "react.js",
|
||||
"fileHash": "98e991e0",
|
||||
"fileHash": "fcd31548",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react-dom": {
|
||||
"src": "../../../../../node_modules/react-dom/index.js",
|
||||
"file": "react-dom.js",
|
||||
"fileHash": "3b7ba340",
|
||||
"fileHash": "ab67f690",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react/jsx-dev-runtime": {
|
||||
"src": "../../../../../node_modules/react/jsx-dev-runtime.js",
|
||||
"file": "react_jsx-dev-runtime.js",
|
||||
"fileHash": "8b662de2",
|
||||
"fileHash": "138cab66",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react/jsx-runtime": {
|
||||
"src": "../../../../../node_modules/react/jsx-runtime.js",
|
||||
"file": "react_jsx-runtime.js",
|
||||
"fileHash": "edbd65cc",
|
||||
"fileHash": "9805fa83",
|
||||
"needsInterop": true
|
||||
},
|
||||
"@hookform/resolvers/zod": {
|
||||
"src": "../../../../../node_modules/@hookform/resolvers/zod/dist/zod.mjs",
|
||||
"file": "@hookform_resolvers_zod.js",
|
||||
"fileHash": "194b2aaa",
|
||||
"needsInterop": false
|
||||
},
|
||||
"@radix-ui/react-dialog": {
|
||||
"src": "../../../../../node_modules/@radix-ui/react-dialog/dist/index.mjs",
|
||||
"file": "@radix-ui_react-dialog.js",
|
||||
"fileHash": "a148c7fe",
|
||||
"needsInterop": false
|
||||
},
|
||||
"@tanstack/react-query": {
|
||||
"src": "../../../../../node_modules/@tanstack/react-query/build/modern/index.js",
|
||||
"file": "@tanstack_react-query.js",
|
||||
"fileHash": "705b896d",
|
||||
"fileHash": "ffafe25c",
|
||||
"needsInterop": false
|
||||
},
|
||||
"@tanstack/react-query-devtools": {
|
||||
"src": "../../../../../node_modules/@tanstack/react-query-devtools/build/modern/index.js",
|
||||
"file": "@tanstack_react-query-devtools.js",
|
||||
"fileHash": "70485a02",
|
||||
"fileHash": "f836306e",
|
||||
"needsInterop": false
|
||||
},
|
||||
"@tanstack/react-router": {
|
||||
"src": "../../../../../node_modules/@tanstack/react-router/dist/esm/index.dev.js",
|
||||
"file": "@tanstack_react-router.js",
|
||||
"fileHash": "141b1c82",
|
||||
"fileHash": "5e18d809",
|
||||
"needsInterop": false
|
||||
},
|
||||
"@tanstack/react-table": {
|
||||
"src": "../../../../../node_modules/@tanstack/react-table/build/lib/index.mjs",
|
||||
"file": "@tanstack_react-table.js",
|
||||
"fileHash": "de1dde72",
|
||||
"needsInterop": false
|
||||
},
|
||||
"class-variance-authority": {
|
||||
"src": "../../../../../node_modules/class-variance-authority/dist/index.mjs",
|
||||
"file": "class-variance-authority.js",
|
||||
"fileHash": "6c7abe67",
|
||||
"fileHash": "79ca80ed",
|
||||
"needsInterop": false
|
||||
},
|
||||
"clsx": {
|
||||
"src": "../../../../../node_modules/clsx/dist/clsx.mjs",
|
||||
"file": "clsx.js",
|
||||
"fileHash": "97c8d984",
|
||||
"fileHash": "560d7dff",
|
||||
"needsInterop": false
|
||||
},
|
||||
"lucide-react": {
|
||||
"src": "../../../../../node_modules/lucide-react/dist/esm/lucide-react.mjs",
|
||||
"file": "lucide-react.js",
|
||||
"fileHash": "ad567fd3",
|
||||
"fileHash": "27bd6cb0",
|
||||
"needsInterop": false
|
||||
},
|
||||
"radix-ui": {
|
||||
"src": "../../../../../node_modules/radix-ui/dist/index.mjs",
|
||||
"file": "radix-ui.js",
|
||||
"fileHash": "10864bf0",
|
||||
"fileHash": "f216ffc2",
|
||||
"needsInterop": false
|
||||
},
|
||||
"react-dom/client": {
|
||||
"src": "../../../../../node_modules/react-dom/client.js",
|
||||
"file": "react-dom_client.js",
|
||||
"fileHash": "ab77e82f",
|
||||
"fileHash": "a87aab08",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react-hook-form": {
|
||||
"src": "../../../../../node_modules/react-hook-form/dist/index.esm.mjs",
|
||||
"file": "react-hook-form.js",
|
||||
"fileHash": "72222317",
|
||||
"needsInterop": false
|
||||
},
|
||||
"recharts": {
|
||||
"src": "../../../../../node_modules/recharts/es6/index.js",
|
||||
"file": "recharts.js",
|
||||
"fileHash": "d0cc5258",
|
||||
"fileHash": "de40db9e",
|
||||
"needsInterop": false
|
||||
},
|
||||
"tailwind-merge": {
|
||||
"src": "../../../../../node_modules/tailwind-merge/dist/bundle-mjs.mjs",
|
||||
"file": "tailwind-merge.js",
|
||||
"fileHash": "f48a862a",
|
||||
"fileHash": "d68cea17",
|
||||
"needsInterop": false
|
||||
},
|
||||
"zod": {
|
||||
"src": "../../../../../node_modules/zod/index.js",
|
||||
"file": "zod.js",
|
||||
"fileHash": "c1ae98b2",
|
||||
"needsInterop": false
|
||||
},
|
||||
"date-fns": {
|
||||
"src": "../../../../../node_modules/date-fns/index.js",
|
||||
"file": "date-fns.js",
|
||||
"fileHash": "5586195a",
|
||||
"needsInterop": false
|
||||
},
|
||||
"date-fns/locale": {
|
||||
"src": "../../../../../node_modules/date-fns/locale.js",
|
||||
"file": "date-fns_locale.js",
|
||||
"fileHash": "78139fca",
|
||||
"needsInterop": false
|
||||
},
|
||||
"@radix-ui/react-popover": {
|
||||
"src": "../../../../../node_modules/@radix-ui/react-popover/dist/index.mjs",
|
||||
"file": "@radix-ui_react-popover.js",
|
||||
"fileHash": "1a4bc9a8",
|
||||
"needsInterop": false
|
||||
},
|
||||
"react-day-picker": {
|
||||
"src": "../../../../../node_modules/react-day-picker/dist/esm/index.js",
|
||||
"file": "react-day-picker.js",
|
||||
"fileHash": "a6f8a45a",
|
||||
"needsInterop": false
|
||||
}
|
||||
},
|
||||
"chunks": {
|
||||
"ZUJJ2RGI-GFZTQM7R": {
|
||||
"file": "ZUJJ2RGI-GFZTQM7R.js"
|
||||
},
|
||||
"VKXKX7EQ-FAVAKZBN": {
|
||||
"file": "VKXKX7EQ-FAVAKZBN.js"
|
||||
},
|
||||
"chunk-LSVH5W4R": {
|
||||
"file": "chunk-LSVH5W4R.js"
|
||||
},
|
||||
"chunk-G4FIB5Z7": {
|
||||
"file": "chunk-G4FIB5Z7.js"
|
||||
},
|
||||
"chunk-6US6M7KI": {
|
||||
"file": "chunk-6US6M7KI.js"
|
||||
},
|
||||
"chunk-TLZOGT6B": {
|
||||
"file": "chunk-TLZOGT6B.js"
|
||||
},
|
||||
"chunk-O6J3DKHL": {
|
||||
"file": "chunk-O6J3DKHL.js"
|
||||
},
|
||||
"chunk-A2TD7EYA": {
|
||||
"file": "chunk-A2TD7EYA.js"
|
||||
},
|
||||
"chunk-NMJBVCD2": {
|
||||
"file": "chunk-NMJBVCD2.js"
|
||||
},
|
||||
"chunk-X37QSMNJ": {
|
||||
"file": "chunk-X37QSMNJ.js"
|
||||
},
|
||||
"VKXKX7EQ-FAVAKZBN": {
|
||||
"file": "VKXKX7EQ-FAVAKZBN.js"
|
||||
"chunk-WFNHCR67": {
|
||||
"file": "chunk-WFNHCR67.js"
|
||||
},
|
||||
"ZUJJ2RGI-GFZTQM7R": {
|
||||
"file": "ZUJJ2RGI-GFZTQM7R.js"
|
||||
"chunk-R6CVIFUM": {
|
||||
"file": "chunk-R6CVIFUM.js"
|
||||
},
|
||||
"chunk-LSVH5W4R": {
|
||||
"file": "chunk-LSVH5W4R.js"
|
||||
"chunk-2EY3U74L": {
|
||||
"file": "chunk-2EY3U74L.js"
|
||||
},
|
||||
"chunk-JCMTJHXH": {
|
||||
"file": "chunk-JCMTJHXH.js"
|
||||
"chunk-TYPHK3K7": {
|
||||
"file": "chunk-TYPHK3K7.js"
|
||||
},
|
||||
"chunk-TLZOGT6B": {
|
||||
"file": "chunk-TLZOGT6B.js"
|
||||
"chunk-ITOVD2G7": {
|
||||
"file": "chunk-ITOVD2G7.js"
|
||||
},
|
||||
"chunk-A2TD7EYA": {
|
||||
"file": "chunk-A2TD7EYA.js"
|
||||
"chunk-OAGFOHTQ": {
|
||||
"file": "chunk-OAGFOHTQ.js"
|
||||
},
|
||||
"chunk-SOQCU2R6": {
|
||||
"file": "chunk-SOQCU2R6.js"
|
||||
},
|
||||
"chunk-JCMTJHXH": {
|
||||
"file": "chunk-JCMTJHXH.js"
|
||||
},
|
||||
"chunk-6VO5C3OI": {
|
||||
"file": "chunk-6VO5C3OI.js"
|
||||
},
|
||||
"chunk-WFNHCR67": {
|
||||
"file": "chunk-WFNHCR67.js"
|
||||
},
|
||||
"chunk-WOOG5QLI": {
|
||||
"file": "chunk-WOOG5QLI.js"
|
||||
}
|
||||
|
||||
8051
apps/frontend/node_modules/.vite/deps/radix-ui.js
generated
vendored
8051
apps/frontend/node_modules/.vite/deps/radix-ui.js
generated
vendored
File diff suppressed because it is too large
Load Diff
8
apps/frontend/node_modules/.vite/deps/radix-ui.js.map
generated
vendored
8
apps/frontend/node_modules/.vite/deps/radix-ui.js.map
generated
vendored
File diff suppressed because one or more lines are too long
6
apps/frontend/node_modules/.vite/deps/recharts.js
generated
vendored
6
apps/frontend/node_modules/.vite/deps/recharts.js
generated
vendored
@@ -2,15 +2,15 @@ import {
|
||||
require_with_selector
|
||||
} from "./chunk-NMJBVCD2.js";
|
||||
import "./chunk-X37QSMNJ.js";
|
||||
import {
|
||||
clsx
|
||||
} from "./chunk-WFNHCR67.js";
|
||||
import {
|
||||
require_react_dom
|
||||
} from "./chunk-JCMTJHXH.js";
|
||||
import {
|
||||
require_react
|
||||
} from "./chunk-6VO5C3OI.js";
|
||||
import {
|
||||
clsx
|
||||
} from "./chunk-WFNHCR67.js";
|
||||
import {
|
||||
__commonJS,
|
||||
__export,
|
||||
|
||||
@@ -9,21 +9,27 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@personal-admin/common": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"@tanstack/react-query-devtools": "^5.100.11",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.3.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.1.0",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.76.1",
|
||||
"recharts": "^3.8.1",
|
||||
"shadcn": "^4.7.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/react-router": "^1.170.4",
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -1,8 +1,49 @@
|
||||
export function ExpensesPage() {
|
||||
import { useState } from "react";
|
||||
import { ExpensesProvider } from "./ExpensesProvider";
|
||||
import { ExpensesTabContent } from "./components/ExpensesTabContent";
|
||||
import { PeriodicExpensesTabContent } from "./components/PeriodicExpensesTabContent";
|
||||
|
||||
const tabs = [
|
||||
{ id: "expenses", label: "Gastos" },
|
||||
{ id: "periodic", label: "Gastos Periódicos" },
|
||||
] as const;
|
||||
|
||||
type TabId = (typeof tabs)[number]["id"];
|
||||
|
||||
function ExpensesPageInner() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>("expenses");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-semibold">Gastos</h1>
|
||||
<p className="text-muted-foreground">Administrá tus gastos acá.</p>
|
||||
|
||||
<div className="flex gap-1 border-b">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={
|
||||
activeTab === tab.id
|
||||
? "-mb-px border-b-2 border-primary px-3 pb-2 text-sm font-medium text-foreground"
|
||||
: "px-3 pb-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === "expenses" && <ExpensesTabContent />}
|
||||
{activeTab === "periodic" && <PeriodicExpensesTabContent />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExpensesPage() {
|
||||
return (
|
||||
<ExpensesProvider>
|
||||
<ExpensesPageInner />
|
||||
</ExpensesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
120
apps/frontend/src/features/expenses/ExpensesProvider.tsx
Normal file
120
apps/frontend/src/features/expenses/ExpensesProvider.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
|
||||
import {
|
||||
usePeriodicExpenses,
|
||||
useCreatePeriodicExpense,
|
||||
useUpdatePeriodicExpense,
|
||||
useDeletePeriodicExpense,
|
||||
useGenerateMonthlyExpense,
|
||||
useExpenses,
|
||||
useCreateNonPeriodicExpense,
|
||||
usePayExpense,
|
||||
} from "@/lib/queries";
|
||||
import type {
|
||||
PeriodicExpense,
|
||||
Expense,
|
||||
CreatePeriodicExpenseInput,
|
||||
CreateNonPeriodicExpenseInput,
|
||||
PayExpenseInput,
|
||||
} from "@/lib/api";
|
||||
|
||||
interface ExpensesContextValue {
|
||||
periodicExpenses: PeriodicExpense[];
|
||||
isLoadingPeriodic: boolean;
|
||||
|
||||
expenses: Expense[];
|
||||
isLoadingExpenses: boolean;
|
||||
statusFilter: string;
|
||||
setStatusFilter: (status: string) => void;
|
||||
|
||||
createPeriodicExpense: (data: CreatePeriodicExpenseInput) => Promise<PeriodicExpense>;
|
||||
updatePeriodicExpense: (id: number, data: CreatePeriodicExpenseInput) => Promise<void>;
|
||||
deletePeriodicExpense: (id: number) => Promise<void>;
|
||||
generateMonthlyExpense: (id: number) => Promise<Expense>;
|
||||
createNonPeriodicExpense: (data: CreateNonPeriodicExpenseInput) => Promise<void>;
|
||||
payExpense: (id: number, data: PayExpenseInput) => Promise<void>;
|
||||
}
|
||||
|
||||
const ExpensesContext = createContext<ExpensesContextValue | null>(null);
|
||||
|
||||
export function ExpensesProvider({ children }: { children: ReactNode }) {
|
||||
const [statusFilter, setStatusFilter] = useState("PENDING");
|
||||
|
||||
const { data: periodicExpenses = [], isLoading: isLoadingPeriodic } = usePeriodicExpenses();
|
||||
const { data: expenses = [], isLoading: isLoadingExpenses } = useExpenses(statusFilter);
|
||||
|
||||
const createPeriodicMutation = useCreatePeriodicExpense();
|
||||
const updatePeriodicMutation = useUpdatePeriodicExpense();
|
||||
const deletePeriodicMutation = useDeletePeriodicExpense();
|
||||
const generateMonthlyMutation = useGenerateMonthlyExpense();
|
||||
const createExpenseMutation = useCreateNonPeriodicExpense();
|
||||
const payExpenseMutation = usePayExpense();
|
||||
|
||||
const createPeriodicExpense = useCallback(
|
||||
async (data: CreatePeriodicExpenseInput): Promise<PeriodicExpense> => {
|
||||
return createPeriodicMutation.mutateAsync(data);
|
||||
},
|
||||
[createPeriodicMutation],
|
||||
);
|
||||
|
||||
const updatePeriodicExpenseFn = useCallback(
|
||||
async (id: number, data: CreatePeriodicExpenseInput) => {
|
||||
await updatePeriodicMutation.mutateAsync({ id, data });
|
||||
},
|
||||
[updatePeriodicMutation],
|
||||
);
|
||||
|
||||
const deletePeriodicExpenseFn = useCallback(
|
||||
async (id: number) => {
|
||||
await deletePeriodicMutation.mutateAsync(id);
|
||||
},
|
||||
[deletePeriodicMutation],
|
||||
);
|
||||
|
||||
const generateMonthlyExpenseFn = useCallback(
|
||||
async (id: number): Promise<Expense> => {
|
||||
return generateMonthlyMutation.mutateAsync(id);
|
||||
},
|
||||
[generateMonthlyMutation],
|
||||
);
|
||||
|
||||
const createNonPeriodicExpenseFn = useCallback(
|
||||
async (data: CreateNonPeriodicExpenseInput) => {
|
||||
await createExpenseMutation.mutateAsync(data);
|
||||
},
|
||||
[createExpenseMutation],
|
||||
);
|
||||
|
||||
const payExpenseFn = useCallback(
|
||||
async (id: number, data: PayExpenseInput) => {
|
||||
await payExpenseMutation.mutateAsync({ id, data });
|
||||
},
|
||||
[payExpenseMutation],
|
||||
);
|
||||
|
||||
return (
|
||||
<ExpensesContext
|
||||
value={{
|
||||
periodicExpenses,
|
||||
isLoadingPeriodic,
|
||||
expenses,
|
||||
isLoadingExpenses,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
createPeriodicExpense,
|
||||
updatePeriodicExpense: updatePeriodicExpenseFn,
|
||||
deletePeriodicExpense: deletePeriodicExpenseFn,
|
||||
generateMonthlyExpense: generateMonthlyExpenseFn,
|
||||
createNonPeriodicExpense: createNonPeriodicExpenseFn,
|
||||
payExpense: payExpenseFn,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ExpensesContext>
|
||||
);
|
||||
}
|
||||
|
||||
export function useExpensesContext() {
|
||||
const ctx = useContext(ExpensesContext);
|
||||
if (!ctx) throw new Error("useExpensesContext must be used within ExpensesProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState } from "react";
|
||||
import { useExpensesContext } from "../ExpensesProvider";
|
||||
import { ExpensesTable } from "./ExpensesTable";
|
||||
import { PayExpenseDialog } from "./PayExpenseDialog";
|
||||
import { NewExpenseDialog } from "./NewExpenseDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { Expense } from "@/lib/api";
|
||||
|
||||
export function ExpensesTabContent() {
|
||||
const { expenses, isLoadingExpenses, statusFilter, setStatusFilter } = useExpensesContext();
|
||||
|
||||
const [payDialogExpense, setPayDialogExpense] = useState<Expense | null>(null);
|
||||
const [newExpenseOpen, setNewExpenseOpen] = useState(false);
|
||||
|
||||
const filters = [
|
||||
{ value: "PENDING", label: "Pendientes" },
|
||||
{ value: "PAYED", label: "Pagados" },
|
||||
{ value: "all", label: "Todos" },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex gap-1">
|
||||
{filters.map((f) => (
|
||||
<Button
|
||||
key={f.value}
|
||||
variant={statusFilter === f.value ? "default" : "outline"}
|
||||
size="xs"
|
||||
onClick={() => setStatusFilter(f.value)}
|
||||
>
|
||||
{f.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={() => setNewExpenseOpen(true)} size="sm">
|
||||
<Plus className="size-4" />
|
||||
Nuevo gasto
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoadingExpenses ? (
|
||||
<p className="text-sm text-muted-foreground">Cargando...</p>
|
||||
) : (
|
||||
<ExpensesTable data={expenses} onPay={setPayDialogExpense} />
|
||||
)}
|
||||
|
||||
<PayExpenseDialog
|
||||
expense={payDialogExpense}
|
||||
open={payDialogExpense !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPayDialogExpense(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<NewExpenseDialog open={newExpenseOpen} onOpenChange={setNewExpenseOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
apps/frontend/src/features/expenses/components/ExpensesTable.tsx
Normal file
142
apps/frontend/src/features/expenses/components/ExpensesTable.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import type { Expense } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CircleDollarSign } from "lucide-react";
|
||||
|
||||
interface ExpensesTableProps {
|
||||
data: Expense[];
|
||||
onPay: (expense: Expense) => void;
|
||||
}
|
||||
|
||||
export function ExpensesTable({ data, onPay }: ExpensesTableProps) {
|
||||
const columns = useMemo<ColumnDef<Expense>[]>(
|
||||
() => [
|
||||
{
|
||||
header: "Descripción",
|
||||
accessorKey: "description",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.getValue("description")}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Monto",
|
||||
accessorKey: "amount",
|
||||
cell: ({ row }) => {
|
||||
const amount = Number(row.getValue("amount"));
|
||||
return (
|
||||
<span className="tabular-nums">
|
||||
${amount.toLocaleString("es-AR", { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Vencimiento",
|
||||
accessorKey: "dueDate",
|
||||
cell: ({ row }) => {
|
||||
const dueDate = new Date(row.getValue("dueDate"));
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
{dueDate.toLocaleDateString("es-AR", {
|
||||
timeZone: "UTC",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Estado",
|
||||
accessorKey: "status",
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status");
|
||||
const isPayed = status === "PAYED";
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
isPayed
|
||||
? "inline-flex h-5 items-center rounded-md bg-green-100 px-1.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||
: "inline-flex h-5 items-center rounded-md bg-yellow-100 px-1.5 text-xs font-medium text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
|
||||
}
|
||||
>
|
||||
{isPayed ? "Pagado" : "Pendiente"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const expense = row.original;
|
||||
if (expense.status === "PAYED") return null;
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => onPay(expense)}
|
||||
>
|
||||
<CircleDollarSign className="size-3.5" />
|
||||
Pagar
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[onPay],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id} className="border-b bg-muted/50">
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id} className="border-b last:border-0 hover:bg-muted/30">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-3 py-2.5">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{table.getRowModel().rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
No hay gastos para mostrar.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
ResponsiveDialog,
|
||||
ResponsiveDialogContent,
|
||||
ResponsiveDialogHeader,
|
||||
ResponsiveDialogTitle,
|
||||
ResponsiveDialogDescription,
|
||||
ResponsiveDialogFooter,
|
||||
} from "@/components/ui/responsive-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface GenerateCurrentMonthDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
description: string;
|
||||
onConfirm: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
export function GenerateCurrentMonthDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
description,
|
||||
onConfirm,
|
||||
onSkip,
|
||||
}: GenerateCurrentMonthDialogProps) {
|
||||
return (
|
||||
<ResponsiveDialog open={open} onOpenChange={onOpenChange}>
|
||||
<ResponsiveDialogContent>
|
||||
<ResponsiveDialogHeader>
|
||||
<ResponsiveDialogTitle>¿Generar gasto para este mes?</ResponsiveDialogTitle>
|
||||
<ResponsiveDialogDescription>
|
||||
<strong>{description}</strong> aplica al mes actual. ¿Querés generar el gasto ahora?
|
||||
</ResponsiveDialogDescription>
|
||||
</ResponsiveDialogHeader>
|
||||
<ResponsiveDialogFooter>
|
||||
<Button variant="outline" onClick={onSkip}>
|
||||
No, gracias
|
||||
</Button>
|
||||
<Button onClick={onConfirm}>
|
||||
Sí, generar
|
||||
</Button>
|
||||
</ResponsiveDialogFooter>
|
||||
</ResponsiveDialogContent>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState } from "react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useExpensesContext } from "../ExpensesProvider";
|
||||
import { DatePicker } from "@/components/ui/date-picker";
|
||||
import {
|
||||
ResponsiveDialog,
|
||||
ResponsiveDialogContent,
|
||||
ResponsiveDialogHeader,
|
||||
ResponsiveDialogTitle,
|
||||
ResponsiveDialogFooter,
|
||||
} from "@/components/ui/responsive-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const newExpenseSchema = z.object({
|
||||
description: z.string().min(1, "La descripción es obligatoria").max(50, "Máximo 50 caracteres"),
|
||||
amount: z.number().positive("El monto debe ser mayor a 0"),
|
||||
dueDate: z.date({ message: "La fecha es obligatoria" }),
|
||||
});
|
||||
|
||||
type NewExpenseFormValues = z.infer<typeof newExpenseSchema>;
|
||||
|
||||
interface NewExpenseDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps) {
|
||||
const { createNonPeriodicExpense } = useExpensesContext();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
} = useForm<NewExpenseFormValues>({
|
||||
resolver: zodResolver(newExpenseSchema),
|
||||
defaultValues: {
|
||||
description: "",
|
||||
amount: 0,
|
||||
dueDate: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
function handleClose(open: boolean) {
|
||||
onOpenChange(open);
|
||||
if (!open) reset();
|
||||
}
|
||||
|
||||
async function onSubmit(data: NewExpenseFormValues) {
|
||||
await createNonPeriodicExpense({
|
||||
description: data.description,
|
||||
amount: data.amount,
|
||||
dueDate: data.dueDate.toISOString(),
|
||||
});
|
||||
handleClose(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveDialog open={open} onOpenChange={handleClose}>
|
||||
<ResponsiveDialogContent>
|
||||
<ResponsiveDialogHeader>
|
||||
<ResponsiveDialogTitle>Nuevo gasto</ResponsiveDialogTitle>
|
||||
</ResponsiveDialogHeader>
|
||||
|
||||
<form id="new-expense-form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="description" className="text-sm font-medium">
|
||||
Descripción
|
||||
</label>
|
||||
<input
|
||||
id="description"
|
||||
{...register("description")}
|
||||
placeholder="Ej: Ropa"
|
||||
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
/>
|
||||
{errors.description && (
|
||||
<span className="text-xs text-destructive">{errors.description.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="amount" className="text-sm font-medium">
|
||||
Monto
|
||||
</label>
|
||||
<input
|
||||
id="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
{...register("amount", { valueAsNumber: true })}
|
||||
placeholder="2500"
|
||||
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
/>
|
||||
{errors.amount && (
|
||||
<span className="text-xs text-destructive">{errors.amount.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
Fecha del gasto
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="dueDate"
|
||||
render={({ field }) => (
|
||||
<DatePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="Seleccionar fecha"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.dueDate && (
|
||||
<span className="text-xs text-destructive">{errors.dueDate.message}</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ResponsiveDialogFooter>
|
||||
<Button variant="outline" onClick={() => handleClose(false)} disabled={isSubmitting}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" form="new-expense-form" disabled={isSubmitting}>
|
||||
Crear gasto
|
||||
</Button>
|
||||
</ResponsiveDialogFooter>
|
||||
</ResponsiveDialogContent>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect } from "react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type { Expense } from "@/lib/api";
|
||||
import { useExpensesContext } from "../ExpensesProvider";
|
||||
import { DatePicker } from "@/components/ui/date-picker";
|
||||
import {
|
||||
ResponsiveDialog,
|
||||
ResponsiveDialogContent,
|
||||
ResponsiveDialogHeader,
|
||||
ResponsiveDialogTitle,
|
||||
ResponsiveDialogFooter,
|
||||
} from "@/components/ui/responsive-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const paySchema = z.object({
|
||||
amountPayed: z.number().positive("El monto debe ser mayor a 0"),
|
||||
paymentDate: z.date({ message: "La fecha es obligatoria" }),
|
||||
});
|
||||
|
||||
type PayFormValues = z.infer<typeof paySchema>;
|
||||
|
||||
interface PayExpenseDialogProps {
|
||||
expense: Expense | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDialogProps) {
|
||||
const { payExpense } = useExpensesContext();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
} = useForm<PayFormValues>({
|
||||
resolver: zodResolver(paySchema),
|
||||
defaultValues: {
|
||||
amountPayed: 0,
|
||||
paymentDate: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (expense) {
|
||||
reset({
|
||||
amountPayed: Number(expense.amount),
|
||||
paymentDate: new Date(),
|
||||
});
|
||||
}
|
||||
}, [expense, reset]);
|
||||
|
||||
function handleClose(open: boolean) {
|
||||
onOpenChange(open);
|
||||
if (!open) reset();
|
||||
}
|
||||
|
||||
async function onSubmit(data: PayFormValues) {
|
||||
if (!expense) return;
|
||||
await payExpense(expense.id, {
|
||||
amountPayed: data.amountPayed,
|
||||
paymentDate: data.paymentDate.toISOString(),
|
||||
});
|
||||
handleClose(false);
|
||||
}
|
||||
|
||||
if (!expense) return null;
|
||||
|
||||
return (
|
||||
<ResponsiveDialog open={open} onOpenChange={handleClose}>
|
||||
<ResponsiveDialogContent>
|
||||
<ResponsiveDialogHeader>
|
||||
<ResponsiveDialogTitle>Registrar pago</ResponsiveDialogTitle>
|
||||
</ResponsiveDialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-1 rounded-lg bg-muted px-3 py-2 text-sm">
|
||||
<span className="font-medium">{expense.description}</span>
|
||||
<span className="text-muted-foreground">
|
||||
Monto original: ${Number(expense.amount).toLocaleString("es-AR", { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form id="pay-form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="amountPayed" className="text-sm font-medium">
|
||||
Monto pagado
|
||||
</label>
|
||||
<input
|
||||
id="amountPayed"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
{...register("amountPayed", { valueAsNumber: true })}
|
||||
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
/>
|
||||
{errors.amountPayed && (
|
||||
<span className="text-xs text-destructive">{errors.amountPayed.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
Fecha de pago
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="paymentDate"
|
||||
render={({ field }) => (
|
||||
<DatePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="Seleccionar fecha"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.paymentDate && (
|
||||
<span className="text-xs text-destructive">{errors.paymentDate.message}</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ResponsiveDialogFooter>
|
||||
<Button variant="outline" onClick={() => handleClose(false)} disabled={isSubmitting}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" form="pay-form" disabled={isSubmitting}>
|
||||
Confirmar pago
|
||||
</Button>
|
||||
</ResponsiveDialogFooter>
|
||||
</ResponsiveDialogContent>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type { PeriodicExpense } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const months = [
|
||||
{ value: 1, label: "Ene" },
|
||||
{ value: 2, label: "Feb" },
|
||||
{ value: 3, label: "Mar" },
|
||||
{ value: 4, label: "Abr" },
|
||||
{ value: 5, label: "May" },
|
||||
{ value: 6, label: "Jun" },
|
||||
{ value: 7, label: "Jul" },
|
||||
{ value: 8, label: "Ago" },
|
||||
{ value: 9, label: "Sep" },
|
||||
{ value: 10, label: "Oct" },
|
||||
{ value: 11, label: "Nov" },
|
||||
{ value: 12, label: "Dic" },
|
||||
];
|
||||
|
||||
const periodicExpenseFormSchema = z.object({
|
||||
description: z.string().min(1, "La descripción es obligatoria").max(50, "Máximo 50 caracteres"),
|
||||
defaultDueDay: z.number().int().min(1, "Día entre 1 y 31").max(31, "Día entre 1 y 31"),
|
||||
defaultAmount: z.number().positive("El monto debe ser mayor a 0"),
|
||||
periods: z.array(z.number()).min(1, "Seleccioná al menos un mes"),
|
||||
});
|
||||
|
||||
type PeriodicExpenseFormValues = z.infer<typeof periodicExpenseFormSchema>;
|
||||
|
||||
interface PeriodicExpenseFormProps {
|
||||
initialData?: PeriodicExpense;
|
||||
onSubmit: (data: PeriodicExpenseFormValues) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function PeriodicExpenseForm({ initialData, onSubmit, onCancel }: PeriodicExpenseFormProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<PeriodicExpenseFormValues>({
|
||||
resolver: zodResolver(periodicExpenseFormSchema),
|
||||
defaultValues: initialData
|
||||
? {
|
||||
description: initialData.description,
|
||||
defaultDueDay: initialData.defaultDueDay,
|
||||
defaultAmount: Number(initialData.defaultAmount),
|
||||
periods: initialData.periods,
|
||||
}
|
||||
: {
|
||||
description: "",
|
||||
defaultDueDay: 15,
|
||||
defaultAmount: 0,
|
||||
periods: [],
|
||||
},
|
||||
});
|
||||
|
||||
const selectedPeriods = watch("periods");
|
||||
|
||||
function toggleMonth(month: number) {
|
||||
if (selectedPeriods.includes(month)) {
|
||||
setValue("periods", selectedPeriods.filter((m) => m !== month), { shouldValidate: true });
|
||||
} else {
|
||||
setValue("periods", [...selectedPeriods, month], { shouldValidate: true });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="description" className="text-sm font-medium">
|
||||
Descripción
|
||||
</label>
|
||||
<input
|
||||
id="description"
|
||||
{...register("description")}
|
||||
placeholder="Ej: Gimnasio"
|
||||
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
/>
|
||||
{errors.description && (
|
||||
<span className="text-xs text-destructive">{errors.description.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<label htmlFor="defaultAmount" className="text-sm font-medium">
|
||||
Monto
|
||||
</label>
|
||||
<input
|
||||
id="defaultAmount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
{...register("defaultAmount", { valueAsNumber: true })}
|
||||
placeholder="1500"
|
||||
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
/>
|
||||
{errors.defaultAmount && (
|
||||
<span className="text-xs text-destructive">{errors.defaultAmount.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<label htmlFor="defaultDueDay" className="text-sm font-medium">
|
||||
Día de vencimiento
|
||||
</label>
|
||||
<input
|
||||
id="defaultDueDay"
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
{...register("defaultDueDay", { valueAsNumber: true })}
|
||||
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
/>
|
||||
{errors.defaultDueDay && (
|
||||
<span className="text-xs text-destructive">{errors.defaultDueDay.message}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Meses de aplicación</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const all = months.map((m) => m.value);
|
||||
setValue("periods", all, { shouldValidate: true });
|
||||
}}
|
||||
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
Seleccionar todos
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue("periods", [], { shouldValidate: true })}
|
||||
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
Deseleccionar todos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{months.map((m) => (
|
||||
<button
|
||||
key={m.value}
|
||||
type="button"
|
||||
onClick={() => toggleMonth(m.value)}
|
||||
className={
|
||||
selectedPeriods.includes(m.value)
|
||||
? "h-7 rounded-md bg-primary px-2.5 text-xs font-medium text-primary-foreground transition-colors"
|
||||
: "h-7 rounded-md border border-input bg-background px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted"
|
||||
}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{errors.periods && (
|
||||
<span className="text-xs text-destructive">{errors.periods.message}</span>
|
||||
)}
|
||||
<input type="hidden" {...register("periods")} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 mt-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{initialData ? "Guardar cambios" : "Crear gasto periódico"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from "react";
|
||||
import { useExpensesContext } from "../ExpensesProvider";
|
||||
import { PeriodicExpensesTable } from "./PeriodicExpensesTable";
|
||||
import { PeriodicExpenseForm } from "./PeriodicExpenseForm";
|
||||
import { GenerateCurrentMonthDialog } from "./GenerateCurrentMonthDialog";
|
||||
import {
|
||||
ResponsiveDialog,
|
||||
ResponsiveDialogContent,
|
||||
ResponsiveDialogHeader,
|
||||
ResponsiveDialogTitle,
|
||||
} from "@/components/ui/responsive-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { PeriodicExpense } from "@/lib/api";
|
||||
|
||||
export function PeriodicExpensesTabContent() {
|
||||
const {
|
||||
periodicExpenses,
|
||||
isLoadingPeriodic,
|
||||
createPeriodicExpense,
|
||||
updatePeriodicExpense,
|
||||
deletePeriodicExpense,
|
||||
generateMonthlyExpense,
|
||||
} = useExpensesContext();
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<PeriodicExpense | null>(null);
|
||||
const [generateDialog, setGenerateDialog] = useState<{
|
||||
open: boolean;
|
||||
description: string;
|
||||
periodicExpenseId: number;
|
||||
}>({ open: false, description: "", periodicExpenseId: 0 });
|
||||
|
||||
function openCreate() {
|
||||
setEditingItem(null);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(item: PeriodicExpense) {
|
||||
setEditingItem(item);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
setDialogOpen(false);
|
||||
setEditingItem(null);
|
||||
}
|
||||
|
||||
async function handleSubmit(data: { description: string; defaultDueDay: number; defaultAmount: number; periods: number[] }) {
|
||||
if (editingItem) {
|
||||
await updatePeriodicExpense(editingItem.id, data);
|
||||
} else {
|
||||
const numericData = {
|
||||
description: data.description,
|
||||
defaultDueDay: data.defaultDueDay,
|
||||
defaultAmount: data.defaultAmount,
|
||||
periods: data.periods,
|
||||
};
|
||||
const result = await createPeriodicExpense(numericData);
|
||||
if (result.currentMonthApplicable) {
|
||||
setGenerateDialog({
|
||||
open: true,
|
||||
description: result.description,
|
||||
periodicExpenseId: result.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
closeDialog();
|
||||
}
|
||||
|
||||
async function handleConfirmGenerate() {
|
||||
await generateMonthlyExpense(generateDialog.periodicExpenseId);
|
||||
setGenerateDialog({ open: false, description: "", periodicExpenseId: 0 });
|
||||
}
|
||||
|
||||
function handleSkipGenerate() {
|
||||
setGenerateDialog({ open: false, description: "", periodicExpenseId: 0 });
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
if (confirm("¿Eliminar este gasto periódico?")) {
|
||||
await deletePeriodicExpense(id);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Administrá tus gastos recurrentes.
|
||||
</p>
|
||||
<Button onClick={openCreate} size="sm">
|
||||
<Plus className="size-4" />
|
||||
Nuevo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoadingPeriodic ? (
|
||||
<p className="text-sm text-muted-foreground">Cargando...</p>
|
||||
) : (
|
||||
<PeriodicExpensesTable
|
||||
data={periodicExpenses}
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ResponsiveDialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<ResponsiveDialogContent>
|
||||
<ResponsiveDialogHeader>
|
||||
<ResponsiveDialogTitle>
|
||||
{editingItem ? "Editar gasto periódico" : "Nuevo gasto periódico"}
|
||||
</ResponsiveDialogTitle>
|
||||
</ResponsiveDialogHeader>
|
||||
<PeriodicExpenseForm
|
||||
initialData={editingItem ?? undefined}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={closeDialog}
|
||||
/>
|
||||
</ResponsiveDialogContent>
|
||||
</ResponsiveDialog>
|
||||
|
||||
<GenerateCurrentMonthDialog
|
||||
open={generateDialog.open}
|
||||
onOpenChange={(open) => setGenerateDialog((prev) => ({ ...prev, open }))}
|
||||
description={generateDialog.description}
|
||||
onConfirm={handleConfirmGenerate}
|
||||
onSkip={handleSkipGenerate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import type { PeriodicExpense } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
const monthLabels = [
|
||||
"", "Ene", "Feb", "Mar", "Abr", "May", "Jun",
|
||||
"Jul", "Ago", "Sep", "Oct", "Nov", "Dic",
|
||||
];
|
||||
|
||||
function formatPeriods(periods: number[]): string {
|
||||
if (periods.length === 0) return "";
|
||||
if (periods.length === 12) return "Ene-Dic";
|
||||
|
||||
const sorted = [...periods].sort((a, b) => a - b);
|
||||
const ranges: string[] = [];
|
||||
let start = sorted[0];
|
||||
let prev = sorted[0];
|
||||
|
||||
for (let i = 1; i <= sorted.length; i++) {
|
||||
const curr = sorted[i];
|
||||
if (curr === prev + 1) {
|
||||
prev = curr;
|
||||
} else {
|
||||
ranges.push(start === prev ? monthLabels[start] : `${monthLabels[start]}-${monthLabels[prev]}`);
|
||||
start = curr;
|
||||
prev = curr;
|
||||
}
|
||||
}
|
||||
|
||||
return ranges.join(", ");
|
||||
}
|
||||
|
||||
interface PeriodicExpensesTableProps {
|
||||
data: PeriodicExpense[];
|
||||
onEdit: (item: PeriodicExpense) => void;
|
||||
onDelete: (id: number) => void;
|
||||
}
|
||||
|
||||
export function PeriodicExpensesTable({ data, onEdit, onDelete }: PeriodicExpensesTableProps) {
|
||||
const columns = useMemo<ColumnDef<PeriodicExpense>[]>(
|
||||
() => [
|
||||
{
|
||||
header: "Descripción",
|
||||
accessorKey: "description",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.getValue("description")}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Monto",
|
||||
accessorKey: "defaultAmount",
|
||||
cell: ({ row }) => {
|
||||
const amount = Number(row.getValue("defaultAmount"));
|
||||
return (
|
||||
<span className="tabular-nums">
|
||||
${amount.toLocaleString("es-AR", { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Vence",
|
||||
accessorKey: "defaultDueDay",
|
||||
cell: ({ row }) => <span>Día {row.getValue("defaultDueDay")}</span>,
|
||||
},
|
||||
{
|
||||
header: "Meses",
|
||||
accessorKey: "periods",
|
||||
cell: ({ row }) => {
|
||||
const periods: number[] = row.getValue("periods");
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatPeriods(periods)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => onEdit(row.original)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => onDelete(row.original.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[onEdit, onDelete],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id} className="border-b bg-muted/50">
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id} className="border-b last:border-0 hover:bg-muted/30">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-3 py-2.5">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{table.getRowModel().rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
No hay gastos periódicos todavía.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,18 @@ async function fetcher<T>(url: string): Promise<T> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function mutator<T>(url: string, method: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function getQuotes(): Promise<Quote[]> {
|
||||
return fetcher<Quote[]>("/api/quotes");
|
||||
}
|
||||
@@ -80,3 +92,81 @@ export function getDailyQuotes(
|
||||
const params = new URLSearchParams({ startDate, endDate });
|
||||
return fetcher<DailyQuote[]>(`/api/quotes/${type}/daily?${params}`);
|
||||
}
|
||||
|
||||
// Expenses
|
||||
|
||||
export type PeriodicExpense = {
|
||||
id: number;
|
||||
description: string;
|
||||
defaultDueDay: number;
|
||||
defaultAmount: string;
|
||||
periods: number[];
|
||||
isDeleted: boolean;
|
||||
createdAt: string;
|
||||
currentMonthApplicable?: boolean;
|
||||
};
|
||||
|
||||
export type Expense = {
|
||||
id: number;
|
||||
description: string;
|
||||
periodicExpenseId: number | null;
|
||||
year: number;
|
||||
month: number;
|
||||
amount: string;
|
||||
amountPayed: string | null;
|
||||
status: "PENDING" | "PAYED";
|
||||
dueDate: string;
|
||||
paymentDate: string | null;
|
||||
periodicExpense?: PeriodicExpense | null;
|
||||
};
|
||||
|
||||
export type CreatePeriodicExpenseInput = {
|
||||
description: string;
|
||||
defaultDueDay: number;
|
||||
defaultAmount: number;
|
||||
periods: number[];
|
||||
};
|
||||
|
||||
export type CreateNonPeriodicExpenseInput = {
|
||||
description: string;
|
||||
amount: number;
|
||||
dueDate: string;
|
||||
};
|
||||
|
||||
export type PayExpenseInput = {
|
||||
amountPayed: number;
|
||||
paymentDate?: string;
|
||||
};
|
||||
|
||||
export function getPeriodicExpenses(): Promise<PeriodicExpense[]> {
|
||||
return fetcher<PeriodicExpense[]>("/api/periodic-expenses");
|
||||
}
|
||||
|
||||
export function createPeriodicExpense(data: CreatePeriodicExpenseInput): Promise<PeriodicExpense> {
|
||||
return mutator<PeriodicExpense>("/api/periodic-expenses", "POST", data);
|
||||
}
|
||||
|
||||
export function updatePeriodicExpense(id: number, data: CreatePeriodicExpenseInput): Promise<PeriodicExpense> {
|
||||
return mutator<PeriodicExpense>(`/api/periodic-expenses/${id}`, "PUT", data);
|
||||
}
|
||||
|
||||
export function deletePeriodicExpense(id: number): Promise<void> {
|
||||
return mutator<void>(`/api/periodic-expenses/${id}`, "DELETE");
|
||||
}
|
||||
|
||||
export function generateMonthlyExpense(id: number): Promise<Expense> {
|
||||
return mutator<Expense>(`/api/periodic-expenses/${id}/generate-month`, "POST");
|
||||
}
|
||||
|
||||
export function getExpenses(status?: string): Promise<Expense[]> {
|
||||
const params = status ? `?status=${status}` : "";
|
||||
return fetcher<Expense[]>(`/api/expenses${params}`);
|
||||
}
|
||||
|
||||
export function createNonPeriodicExpense(data: CreateNonPeriodicExpenseInput): Promise<Expense> {
|
||||
return mutator<Expense>("/api/expenses", "POST", data);
|
||||
}
|
||||
|
||||
export function payExpense(id: number, data: PayExpenseInput): Promise<Expense> {
|
||||
return mutator<Expense>(`/api/expenses/${id}/pay`, "PUT", data);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getQuotes, fetchQuotes, getQuoteHistory, getDailyMinMax, getDailyQuotes } from "./api";
|
||||
import {
|
||||
getQuotes, fetchQuotes, getQuoteHistory, getDailyMinMax, getDailyQuotes,
|
||||
getPeriodicExpenses, createPeriodicExpense as createPeriodicExpenseApi,
|
||||
updatePeriodicExpense as updatePeriodicExpenseApi,
|
||||
deletePeriodicExpense as deletePeriodicExpenseApi,
|
||||
generateMonthlyExpense as generateMonthlyExpenseApi,
|
||||
getExpenses, createNonPeriodicExpense as createNonPeriodicExpenseApi,
|
||||
payExpense as payExpenseApi,
|
||||
type CreatePeriodicExpenseInput, type CreateNonPeriodicExpenseInput, type PayExpenseInput,
|
||||
} from "./api";
|
||||
|
||||
export const quoteKeys = {
|
||||
all: ["quotes"] as const,
|
||||
@@ -57,3 +66,87 @@ export function useDailyQuotes(type: string, startDate: string, endDate: string)
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
// Expenses
|
||||
|
||||
export const expenseKeys = {
|
||||
periodic: ["periodic-expenses"] as const,
|
||||
all: ["expenses"] as const,
|
||||
byStatus: (status: string) => ["expenses", status] as const,
|
||||
};
|
||||
|
||||
export function usePeriodicExpenses() {
|
||||
return useQuery({
|
||||
queryKey: expenseKeys.periodic,
|
||||
queryFn: getPeriodicExpenses,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePeriodicExpense() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreatePeriodicExpenseInput) => createPeriodicExpenseApi(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: expenseKeys.periodic });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePeriodicExpense() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: CreatePeriodicExpenseInput }) =>
|
||||
updatePeriodicExpenseApi(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: expenseKeys.periodic });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePeriodicExpense() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => deletePeriodicExpenseApi(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: expenseKeys.periodic });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGenerateMonthlyExpense() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => generateMonthlyExpenseApi(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: expenseKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useExpenses(status: string) {
|
||||
return useQuery({
|
||||
queryKey: expenseKeys.byStatus(status),
|
||||
queryFn: () => getExpenses(status === "all" ? undefined : status),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateNonPeriodicExpense() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateNonPeriodicExpenseInput) => createNonPeriodicExpenseApi(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: expenseKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePayExpense() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: PayExpenseInput }) =>
|
||||
payExpenseApi(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: expenseKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user