feat: adição de novos ícones SVG e configuração do ambiente
- Adicionados ícones SVG para ChatGPT, Claude, Gemini e OpenRouter - Implementados ícones para modos claro e escuro do ChatGPT - Criado script de inicialização para PostgreSQL com extensão pgcrypto - Adicionado script de configuração de ambiente que faz backup do .env - Configurado tsconfig.json para TypeScript com opções de compilação
This commit is contained in:
110
components/orcamentos/budget-card.tsx
Normal file
110
components/orcamentos/budget-card.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { CategoryIcon } from "@/components/categorias/category-icon";
|
||||
import MoneyValues from "@/components/money-values";
|
||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
|
||||
import { RiDeleteBin5Line, RiPencilLine } from "@remixicon/react";
|
||||
import type { Budget } from "./types";
|
||||
|
||||
interface BudgetCardProps {
|
||||
budget: Budget;
|
||||
periodLabel: string;
|
||||
onEdit: (budget: Budget) => void;
|
||||
onRemove: (budget: Budget) => void;
|
||||
}
|
||||
|
||||
const buildUsagePercent = (spent: number, limit: number) => {
|
||||
if (limit <= 0) {
|
||||
return spent > 0 ? 100 : 0;
|
||||
}
|
||||
const percent = (spent / limit) * 100;
|
||||
return Math.min(Math.max(percent, 0), 100);
|
||||
};
|
||||
|
||||
const formatCategoryName = (budget: Budget) =>
|
||||
budget.category?.name ?? "Categoria removida";
|
||||
|
||||
export function BudgetCard({
|
||||
budget,
|
||||
periodLabel,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: BudgetCardProps) {
|
||||
const { amount: limit, spent } = budget;
|
||||
const exceeded = spent > limit && limit >= 0;
|
||||
const difference = Math.abs(spent - limit);
|
||||
const usagePercent = buildUsagePercent(spent, limit);
|
||||
|
||||
return (
|
||||
<Card className="flex h-full flex-col">
|
||||
<CardContent className="flex h-full flex-col gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center text-primary">
|
||||
<CategoryIcon
|
||||
name={budget.category?.icon ?? undefined}
|
||||
className="size-6"
|
||||
/>
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-base font-semibold leading-tight">
|
||||
{formatCategoryName(budget)}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Orçamento de {periodLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<div className="flex items-baseline justify-between text-sm">
|
||||
<span className="text-muted-foreground">Gasto até agora</span>
|
||||
<MoneyValues
|
||||
amount={spent}
|
||||
className={cn(exceeded && "text-destructive")}
|
||||
/>
|
||||
</div>
|
||||
<Progress
|
||||
value={usagePercent}
|
||||
className={cn("h-2", exceeded && "bg-destructive/20!")}
|
||||
/>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-1 text-sm">
|
||||
<span className="text-muted-foreground">Limite</span>
|
||||
<MoneyValues amount={limit} className="text-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
{exceeded ? (
|
||||
<div className="text-xs text-red-500">
|
||||
Excedeu em <MoneyValues amount={difference} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-green-600">
|
||||
Restam <MoneyValues amount={Math.max(limit - spent, 0)} />{" "}
|
||||
disponíveis.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-wrap gap-3 px-5 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(budget)}
|
||||
className="flex items-center gap-1 text-primary font-medium transition-opacity hover:opacity-80"
|
||||
>
|
||||
<RiPencilLine className="size-4" aria-hidden /> editar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(budget)}
|
||||
className="flex items-center gap-1 text-destructive font-medium transition-opacity hover:opacity-80"
|
||||
>
|
||||
<RiDeleteBin5Line className="size-4" aria-hidden /> remover
|
||||
</button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
344
components/orcamentos/budget-dialog.tsx
Normal file
344
components/orcamentos/budget-dialog.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createBudgetAction,
|
||||
updateBudgetAction,
|
||||
} from "@/app/(dashboard)/orcamentos/actions";
|
||||
import { CategoryIcon } from "@/components/categorias/category-icon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { CurrencyInput } from "@/components/ui/currency-input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
import { useFormState } from "@/hooks/use-form-state";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
type SelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const monthFormatter = new Intl.DateTimeFormat("pt-BR", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const formatPeriodLabel = (period: string) => {
|
||||
const [year, month] = period.split("-").map(Number);
|
||||
if (!year || !month) {
|
||||
return period;
|
||||
}
|
||||
const date = new Date(year, month - 1, 1);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return period;
|
||||
}
|
||||
const label = monthFormatter.format(date);
|
||||
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||
};
|
||||
|
||||
const buildPeriodOptions = (currentValue?: string): SelectOption[] => {
|
||||
const now = new Date();
|
||||
const options: SelectOption[] = [];
|
||||
|
||||
for (let offset = -3; offset <= 3; offset += 1) {
|
||||
const date = new Date(now.getFullYear(), now.getMonth() + offset, 1);
|
||||
const value = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(
|
||||
2,
|
||||
"0"
|
||||
)}`;
|
||||
options.push({ value, label: formatPeriodLabel(value) });
|
||||
}
|
||||
|
||||
if (
|
||||
currentValue &&
|
||||
!options.some((option) => option.value === currentValue)
|
||||
) {
|
||||
options.push({
|
||||
value: currentValue,
|
||||
label: formatPeriodLabel(currentValue),
|
||||
});
|
||||
}
|
||||
|
||||
return options
|
||||
.sort((a, b) => a.value.localeCompare(b.value))
|
||||
.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}));
|
||||
};
|
||||
|
||||
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, updateField, setFormState } =
|
||||
useFormState<BudgetFormValues>(initialState);
|
||||
|
||||
// Reset form when dialog opens
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
setFormState(initialState);
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, [dialogOpen, initialState, setFormState]);
|
||||
|
||||
// Clear error when dialog closes
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) {
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, [dialogOpen]);
|
||||
|
||||
const periodOptions = useMemo(
|
||||
() => buildPeriodOptions(formState.period),
|
||||
[formState.period]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(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);
|
||||
setFormState(initialState);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(result.error);
|
||||
toast.error(result.error);
|
||||
});
|
||||
},
|
||||
[budget?.id, formState, initialState, mode, setDialogOpen, setFormState]
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
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 className="gap-3">
|
||||
<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="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="budget-period">Período</Label>
|
||||
<Select
|
||||
value={formState.period}
|
||||
onValueChange={(value) => updateField("period", value)}
|
||||
>
|
||||
<SelectTrigger id="budget-period" className="w-full">
|
||||
<SelectValue placeholder="Selecione o período" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
{periodOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="budget-amount">Valor limite</Label>
|
||||
<CurrencyInput
|
||||
id="budget-amount"
|
||||
placeholder="R$ 0,00"
|
||||
value={formState.amount}
|
||||
onValueChange={(value) => updateField("amount", value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="gap-3">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
147
components/orcamentos/budgets-page.tsx
Normal file
147
components/orcamentos/budgets-page.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { deleteBudgetAction } from "@/app/(dashboard)/orcamentos/actions";
|
||||
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RiAddCircleLine, RiFundsLine } from "@remixicon/react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Card } from "../ui/card";
|
||||
import { BudgetCard } from "./budget-card";
|
||||
import { BudgetDialog } from "./budget-dialog";
|
||||
import type { Budget, BudgetCategory } from "./types";
|
||||
|
||||
interface BudgetsPageProps {
|
||||
budgets: Budget[];
|
||||
categories: BudgetCategory[];
|
||||
selectedPeriod: string;
|
||||
periodLabel: string;
|
||||
}
|
||||
|
||||
export function BudgetsPage({
|
||||
budgets,
|
||||
categories,
|
||||
selectedPeriod,
|
||||
periodLabel,
|
||||
}: BudgetsPageProps) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [selectedBudget, setSelectedBudget] = useState<Budget | null>(null);
|
||||
const [removeOpen, setRemoveOpen] = useState(false);
|
||||
const [budgetToRemove, setBudgetToRemove] = useState<Budget | null>(null);
|
||||
|
||||
const hasBudgets = budgets.length > 0;
|
||||
|
||||
const handleEdit = useCallback((budget: Budget) => {
|
||||
setSelectedBudget(budget);
|
||||
setEditOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleEditOpenChange = useCallback((open: boolean) => {
|
||||
setEditOpen(open);
|
||||
if (!open) {
|
||||
setSelectedBudget(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRemoveRequest = useCallback((budget: Budget) => {
|
||||
setBudgetToRemove(budget);
|
||||
setRemoveOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleRemoveOpenChange = useCallback((open: boolean) => {
|
||||
setRemoveOpen(open);
|
||||
if (!open) {
|
||||
setBudgetToRemove(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRemoveConfirm = useCallback(async () => {
|
||||
if (!budgetToRemove) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await deleteBudgetAction({ id: budgetToRemove.id });
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error);
|
||||
throw new Error(result.error);
|
||||
}, [budgetToRemove]);
|
||||
|
||||
const removeTitle = budgetToRemove
|
||||
? `Remover orçamento de "${
|
||||
budgetToRemove.category?.name ?? "categoria removida"
|
||||
}"?`
|
||||
: "Remover orçamento?";
|
||||
|
||||
const emptyDescription =
|
||||
categories.length === 0
|
||||
? "Cadastre uma categoria de despesa para começar a planejar seus gastos."
|
||||
: "Crie seu primeiro orçamento para controlar os gastos por categoria.";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex justify-start">
|
||||
<BudgetDialog
|
||||
mode="create"
|
||||
categories={categories}
|
||||
defaultPeriod={selectedPeriod}
|
||||
trigger={
|
||||
<Button disabled={categories.length === 0}>
|
||||
<RiAddCircleLine className="size-4" />
|
||||
Novo orçamento
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasBudgets ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{budgets.map((budget) => (
|
||||
<BudgetCard
|
||||
key={budget.id}
|
||||
budget={budget}
|
||||
periodLabel={periodLabel}
|
||||
onEdit={handleEdit}
|
||||
onRemove={handleRemoveRequest}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
|
||||
<EmptyState
|
||||
media={<RiFundsLine className="size-6 text-primary" />}
|
||||
title="Nenhum orçamento cadastrado"
|
||||
description={emptyDescription}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BudgetDialog
|
||||
mode="update"
|
||||
budget={selectedBudget ?? undefined}
|
||||
categories={categories}
|
||||
defaultPeriod={selectedPeriod}
|
||||
open={editOpen && !!selectedBudget}
|
||||
onOpenChange={handleEditOpenChange}
|
||||
/>
|
||||
|
||||
<ConfirmActionDialog
|
||||
open={removeOpen && !!budgetToRemove}
|
||||
onOpenChange={handleRemoveOpenChange}
|
||||
title={removeTitle}
|
||||
description="Esta ação remove o limite configurado para a categoria selecionada."
|
||||
confirmLabel="Remover orçamento"
|
||||
pendingLabel="Removendo..."
|
||||
confirmVariant="destructive"
|
||||
onConfirm={handleRemoveConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
21
components/orcamentos/types.ts
Normal file
21
components/orcamentos/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type BudgetCategory = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
};
|
||||
|
||||
export type Budget = {
|
||||
id: string;
|
||||
amount: number;
|
||||
spent: number;
|
||||
period: string;
|
||||
createdAt: string;
|
||||
category: BudgetCategory | null;
|
||||
};
|
||||
|
||||
export type BudgetFormValues = {
|
||||
categoriaId: string;
|
||||
period: string;
|
||||
amount: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user