mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 19:01:47 +00:00
refactor(core): move app para src e padroniza estrutura
This commit is contained in:
295
src/features/budgets/components/budget-dialog.tsx
Normal file
295
src/features/budgets/components/budget-dialog.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createBudgetAction,
|
||||
updateBudgetAction,
|
||||
} from "@/features/budgets/actions";
|
||||
import { CategoryIcon } from "@/features/categories/components/category-icon";
|
||||
import { PeriodPicker } from "@/shared/components/period-picker";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select";
|
||||
import { Slider } from "@/shared/components/ui/slider";
|
||||
import { useControlledState } from "@/shared/hooks/use-controlled-state";
|
||||
import { useFormState } from "@/shared/hooks/use-form-state";
|
||||
import { formatCurrency } from "@/shared/utils/currency";
|
||||
|
||||
import type { Budget, BudgetCategory, BudgetFormValues } from "./types";
|
||||
|
||||
interface BudgetDialogProps {
|
||||
mode: "create" | "update";
|
||||
trigger?: React.ReactNode;
|
||||
budget?: Budget;
|
||||
categories: BudgetCategory[];
|
||||
defaultPeriod: string;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const buildInitialValues = ({
|
||||
budget,
|
||||
defaultPeriod,
|
||||
}: {
|
||||
budget?: Budget;
|
||||
defaultPeriod: string;
|
||||
}): BudgetFormValues => ({
|
||||
categoriaId: budget?.category?.id ?? "",
|
||||
period: budget?.period ?? defaultPeriod,
|
||||
amount: budget ? (Math.round(budget.amount * 100) / 100).toFixed(2) : "",
|
||||
});
|
||||
|
||||
export function BudgetDialog({
|
||||
mode,
|
||||
trigger,
|
||||
budget,
|
||||
categories,
|
||||
defaultPeriod,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: BudgetDialogProps) {
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// Use controlled state hook for dialog open state
|
||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||
open,
|
||||
false,
|
||||
onOpenChange,
|
||||
);
|
||||
|
||||
const initialState = useMemo(
|
||||
() =>
|
||||
buildInitialValues({
|
||||
budget,
|
||||
defaultPeriod,
|
||||
}),
|
||||
[budget, defaultPeriod],
|
||||
);
|
||||
|
||||
// Use form state hook for form management
|
||||
const { formState, resetForm, updateField } =
|
||||
useFormState<BudgetFormValues>(initialState);
|
||||
|
||||
// Reset form when dialog opens
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
resetForm(initialState);
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, [dialogOpen, initialState, resetForm]);
|
||||
|
||||
// Clear error when dialog closes
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) {
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, [dialogOpen]);
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
|
||||
if (mode === "update" && !budget?.id) {
|
||||
const message = "Orçamento inválido.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formState.categoriaId.length === 0) {
|
||||
const message = "Selecione uma categoria.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formState.period.length === 0) {
|
||||
const message = "Informe o período.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formState.amount.length === 0) {
|
||||
const message = "Informe o valor limite.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
categoriaId: formState.categoriaId,
|
||||
period: formState.period,
|
||||
amount: formState.amount,
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
const result =
|
||||
mode === "create"
|
||||
? await createBudgetAction(payload)
|
||||
: await updateBudgetAction({
|
||||
id: budget?.id ?? "",
|
||||
...payload,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setDialogOpen(false);
|
||||
resetForm(initialState);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(result.error);
|
||||
toast.error(result.error);
|
||||
});
|
||||
};
|
||||
|
||||
const title = mode === "create" ? "Novo orçamento" : "Editar orçamento";
|
||||
const description =
|
||||
mode === "create"
|
||||
? "Defina um limite de gastos para acompanhar suas despesas."
|
||||
: "Atualize os detalhes do orçamento selecionado.";
|
||||
const submitLabel =
|
||||
mode === "create" ? "Salvar orçamento" : "Atualizar orçamento";
|
||||
const disabled = categories.length === 0;
|
||||
const parsedAmount = Number.parseFloat(formState.amount);
|
||||
const sliderValue = Number.isFinite(parsedAmount)
|
||||
? Math.max(0, parsedAmount)
|
||||
: 0;
|
||||
const baseForSlider = Math.max(budget?.spent ?? 0, sliderValue, 1000);
|
||||
const sliderMax = Math.max(
|
||||
1000,
|
||||
Math.ceil((baseForSlider * 1.5) / 100) * 100,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{disabled ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-dashed bg-muted/10 p-4 text-sm text-muted-foreground">
|
||||
Cadastre pelo menos uma categoria de despesa para criar um
|
||||
orçamento.
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
>
|
||||
Fechar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="budget-category">Categoria</Label>
|
||||
<Select
|
||||
value={formState.categoriaId}
|
||||
onValueChange={(value) => updateField("categoriaId", value)}
|
||||
>
|
||||
<SelectTrigger id="budget-category" className="w-full">
|
||||
<SelectValue placeholder="Selecione uma categoria" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
<CategoryIcon
|
||||
name={category.icon ?? undefined}
|
||||
className="size-4"
|
||||
/>
|
||||
<span>{category.name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="budget-period">Período</Label>
|
||||
<PeriodPicker
|
||||
value={formState.period}
|
||||
onChange={(value) => updateField("period", value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="budget-amount">Valor limite</Label>
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Limite atual</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatCurrency(sliderValue)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Slider
|
||||
id="budget-amount"
|
||||
value={[sliderValue]}
|
||||
min={0}
|
||||
max={sliderMax}
|
||||
step={10}
|
||||
onValueChange={(value) =>
|
||||
updateField("amount", value[0]?.toFixed(2) ?? "0.00")
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{formatCurrency(0)}</span>
|
||||
<span>{formatCurrency(sliderMax)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Salvando..." : submitLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user