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:
Jose Selesan
2026-05-29 10:29:20 -03:00
parent bd5e29236b
commit e1786f7384
39 changed files with 4338 additions and 6435 deletions

View 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 }

View 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>
)
}

View 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 }

View 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,
}

View File

@@ -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>
);
}

View 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;
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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}>
, generar
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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);
}

View File

@@ -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 });
},
});
}