refactor: migrate from ESLint to Biome and extract SQL queries to data.ts

- Replace ESLint with Biome for linting and formatting
- Configure Biome with tabs, double quotes, and organized imports
- Move all SQL/Drizzle queries from page.tsx files to data.ts files
- Create new data.ts files for: ajustes, dashboard, relatorios/categorias
- Update existing data.ts files: extrato, fatura (add lancamentos queries)
- Remove all drizzle-orm imports from page.tsx files
- Update README.md with new tooling info

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-01-27 13:15:37 +00:00
parent 8ffe61c59b
commit a7f63fb77a
442 changed files with 66141 additions and 69292 deletions

View File

@@ -1,167 +1,167 @@
"use client";
import { RiAddCircleLine } from "@remixicon/react";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { deleteCategoryAction } from "@/app/(dashboard)/categorias/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
CATEGORY_TYPE_LABEL,
CATEGORY_TYPES,
CATEGORY_TYPE_LABEL,
CATEGORY_TYPES,
} from "@/lib/categorias/constants";
import { RiAddCircleLine } from "@remixicon/react";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { CategoryCard } from "./category-card";
import { CategoryDialog } from "./category-dialog";
import type { Category, CategoryType } from "./types";
interface CategoriesPageProps {
categories: Category[];
categories: Category[];
}
export function CategoriesPage({ categories }: CategoriesPageProps) {
const [activeType, setActiveType] = useState<CategoryType>(CATEGORY_TYPES[0]);
const [editOpen, setEditOpen] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<Category | null>(
null
);
const [removeOpen, setRemoveOpen] = useState(false);
const [categoryToRemove, setCategoryToRemove] = useState<Category | null>(
null
);
const [activeType, setActiveType] = useState<CategoryType>(CATEGORY_TYPES[0]);
const [editOpen, setEditOpen] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<Category | null>(
null,
);
const [removeOpen, setRemoveOpen] = useState(false);
const [categoryToRemove, setCategoryToRemove] = useState<Category | null>(
null,
);
const categoriesByType = useMemo(() => {
const base = Object.fromEntries(
CATEGORY_TYPES.map((type) => [type, [] as Category[]])
) as Record<CategoryType, Category[]>;
const categoriesByType = useMemo(() => {
const base = Object.fromEntries(
CATEGORY_TYPES.map((type) => [type, [] as Category[]]),
) as Record<CategoryType, Category[]>;
categories.forEach((category) => {
base[category.type]?.push(category);
});
categories.forEach((category) => {
base[category.type]?.push(category);
});
CATEGORY_TYPES.forEach((type) => {
base[type].sort((a, b) =>
a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" })
);
});
CATEGORY_TYPES.forEach((type) => {
base[type].sort((a, b) =>
a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" }),
);
});
return base;
}, [categories]);
return base;
}, [categories]);
const handleEdit = useCallback((category: Category) => {
setSelectedCategory(category);
setEditOpen(true);
}, []);
const handleEdit = useCallback((category: Category) => {
setSelectedCategory(category);
setEditOpen(true);
}, []);
const handleEditOpenChange = useCallback((open: boolean) => {
setEditOpen(open);
if (!open) {
setSelectedCategory(null);
}
}, []);
const handleEditOpenChange = useCallback((open: boolean) => {
setEditOpen(open);
if (!open) {
setSelectedCategory(null);
}
}, []);
const handleRemoveRequest = useCallback((category: Category) => {
setCategoryToRemove(category);
setRemoveOpen(true);
}, []);
const handleRemoveRequest = useCallback((category: Category) => {
setCategoryToRemove(category);
setRemoveOpen(true);
}, []);
const handleRemoveOpenChange = useCallback((open: boolean) => {
setRemoveOpen(open);
if (!open) {
setCategoryToRemove(null);
}
}, []);
const handleRemoveOpenChange = useCallback((open: boolean) => {
setRemoveOpen(open);
if (!open) {
setCategoryToRemove(null);
}
}, []);
const handleRemoveConfirm = useCallback(async () => {
if (!categoryToRemove) {
return;
}
const handleRemoveConfirm = useCallback(async () => {
if (!categoryToRemove) {
return;
}
const result = await deleteCategoryAction({ id: categoryToRemove.id });
const result = await deleteCategoryAction({ id: categoryToRemove.id });
if (result.success) {
toast.success(result.message);
return;
}
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
}, [categoryToRemove]);
toast.error(result.error);
throw new Error(result.error);
}, [categoryToRemove]);
const removeTitle = categoryToRemove
? `Remover categoria "${categoryToRemove.name}"?`
: "Remover categoria?";
const removeTitle = categoryToRemove
? `Remover categoria "${categoryToRemove.name}"?`
: "Remover categoria?";
return (
<>
<div className="flex w-full flex-col gap-6">
<div className="flex justify-start">
<CategoryDialog
mode="create"
defaultType={activeType}
trigger={
<Button>
<RiAddCircleLine className="size-4" />
Nova categoria
</Button>
}
/>
</div>
return (
<>
<div className="flex w-full flex-col gap-6">
<div className="flex justify-start">
<CategoryDialog
mode="create"
defaultType={activeType}
trigger={
<Button>
<RiAddCircleLine className="size-4" />
Nova categoria
</Button>
}
/>
</div>
<Tabs
value={activeType}
onValueChange={(value) => setActiveType(value as CategoryType)}
className="w-full"
>
<TabsList>
{CATEGORY_TYPES.map((type) => (
<TabsTrigger key={type} value={type}>
{CATEGORY_TYPE_LABEL[type]}
</TabsTrigger>
))}
</TabsList>
<Tabs
value={activeType}
onValueChange={(value) => setActiveType(value as CategoryType)}
className="w-full"
>
<TabsList>
{CATEGORY_TYPES.map((type) => (
<TabsTrigger key={type} value={type}>
{CATEGORY_TYPE_LABEL[type]}
</TabsTrigger>
))}
</TabsList>
{CATEGORY_TYPES.map((type) => (
<TabsContent key={type} value={type} className="mt-4">
{categoriesByType[type].length === 0 ? (
<div className="flex min-h-[280px] items-center justify-center rounded-lg border border-dashed bg-muted/10 p-10 text-center text-sm text-muted-foreground">
Ainda não categorias de{" "}
{CATEGORY_TYPE_LABEL[type].toLowerCase()}.
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{categoriesByType[type].map((category) => (
<CategoryCard
key={category.id}
category={category}
onEdit={handleEdit}
onRemove={handleRemoveRequest}
/>
))}
</div>
)}
</TabsContent>
))}
</Tabs>
</div>
{CATEGORY_TYPES.map((type) => (
<TabsContent key={type} value={type} className="mt-4">
{categoriesByType[type].length === 0 ? (
<div className="flex min-h-[280px] items-center justify-center rounded-lg border border-dashed bg-muted/10 p-10 text-center text-sm text-muted-foreground">
Ainda não categorias de{" "}
{CATEGORY_TYPE_LABEL[type].toLowerCase()}.
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{categoriesByType[type].map((category) => (
<CategoryCard
key={category.id}
category={category}
onEdit={handleEdit}
onRemove={handleRemoveRequest}
/>
))}
</div>
)}
</TabsContent>
))}
</Tabs>
</div>
<CategoryDialog
mode="update"
category={selectedCategory ?? undefined}
open={editOpen && !!selectedCategory}
onOpenChange={handleEditOpenChange}
/>
<CategoryDialog
mode="update"
category={selectedCategory ?? undefined}
open={editOpen && !!selectedCategory}
onOpenChange={handleEditOpenChange}
/>
<ConfirmActionDialog
open={removeOpen && !!categoryToRemove}
onOpenChange={handleRemoveOpenChange}
title={removeTitle}
description="Ao remover esta categoria, os lançamentos associados serão desrelacionados."
confirmLabel="Remover categoria"
pendingLabel="Removendo..."
confirmVariant="destructive"
onConfirm={handleRemoveConfirm}
/>
</>
);
<ConfirmActionDialog
open={removeOpen && !!categoryToRemove}
onOpenChange={handleRemoveOpenChange}
title={removeTitle}
description="Ao remover esta categoria, os lançamentos associados serão desrelacionados."
confirmLabel="Remover categoria"
pendingLabel="Removendo..."
confirmVariant="destructive"
onConfirm={handleRemoveConfirm}
/>
</>
);
}

View File

@@ -1,94 +1,94 @@
"use client";
import { RiDeleteBin5Line, RiMore2Fill, RiPencilLine } from "@remixicon/react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { RiDeleteBin5Line, RiMore2Fill, RiPencilLine } from "@remixicon/react";
import Link from "next/link";
import { TypeBadge } from "../type-badge";
import { CategoryIcon } from "./category-icon";
import type { Category } from "./types";
interface CategoryCardProps {
category: Category;
onEdit: (category: Category) => void;
onRemove: (category: Category) => void;
category: Category;
onEdit: (category: Category) => void;
onRemove: (category: Category) => void;
}
export function CategoryCard({
category,
onEdit,
onRemove,
category,
onEdit,
onRemove,
}: CategoryCardProps) {
// Categorias protegidas que não podem ser editadas ou removidas
const categoriasProtegidas = [
"Transferência interna",
"Saldo inicial",
"Pagamentos",
];
const isProtegida = categoriasProtegidas.includes(category.name);
const canEdit = !isProtegida;
const canRemove = !isProtegida;
// Categorias protegidas que não podem ser editadas ou removidas
const categoriasProtegidas = [
"Transferência interna",
"Saldo inicial",
"Pagamentos",
];
const isProtegida = categoriasProtegidas.includes(category.name);
const canEdit = !isProtegida;
const canRemove = !isProtegida;
return (
<Card className="group py-2">
<CardContent className="p-2">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2">
<span className="flex size-11 items-center justify-center text-primary">
<CategoryIcon name={category.icon} className="size-6" />
</span>
<div className="space-y-1">
<h3 className="text-base font-medium leading-tight">
<Link
href={`/categorias/${category.id}`}
className="underline-offset-4 hover:underline"
>
{category.name}
</Link>
</h3>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<TypeBadge type={category.type} />
</div>
</div>
</div>
return (
<Card className="group py-2">
<CardContent className="p-2">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2">
<span className="flex size-11 items-center justify-center text-primary">
<CategoryIcon name={category.icon} className="size-6" />
</span>
<div className="space-y-1">
<h3 className="text-base font-medium leading-tight">
<Link
href={`/categorias/${category.id}`}
className="underline-offset-4 hover:underline"
>
{category.name}
</Link>
</h3>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<TypeBadge type={category.type} />
</div>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="opacity-0 transition-opacity group-hover:opacity-100"
>
<RiMore2Fill className="size-4" />
<span className="sr-only">Abrir ações da categoria</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => onEdit(category)}
disabled={!canEdit}
>
<RiPencilLine className="mr-2 size-4" />
Editar
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => onRemove(category)}
disabled={!canRemove}
>
<RiDeleteBin5Line className="mr-2 size-4" />
Remover
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardContent>
</Card>
);
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="opacity-0 transition-opacity group-hover:opacity-100"
>
<RiMore2Fill className="size-4" />
<span className="sr-only">Abrir ações da categoria</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => onEdit(category)}
disabled={!canEdit}
>
<RiPencilLine className="mr-2 size-4" />
Editar
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => onRemove(category)}
disabled={!canRemove}
>
<RiDeleteBin5Line className="mr-2 size-4" />
Remover
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,149 +1,149 @@
import { type CategoryType } from "@/lib/categorias/constants";
import { RiArrowDownLine, RiArrowUpLine } from "@remixicon/react";
import type { CategoryType } from "@/lib/categorias/constants";
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
import { getIconComponent } from "@/lib/utils/icons";
import { cn } from "@/lib/utils/ui";
import { RiArrowDownLine, RiArrowUpLine } from "@remixicon/react";
import { TypeBadge } from "../type-badge";
import { Card } from "../ui/card";
const buildInitials = (value: string) => {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
return "CT";
}
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CT";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "CT";
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
return "CT";
}
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CT";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "CT";
};
type CategorySummary = {
id: string;
name: string;
icon: string | null;
type: CategoryType;
id: string;
name: string;
icon: string | null;
type: CategoryType;
};
type CategoryDetailHeaderProps = {
category: CategorySummary;
currentPeriodLabel: string;
previousPeriodLabel: string;
currentTotal: number;
previousTotal: number;
percentageChange: number | null;
transactionCount: number;
category: CategorySummary;
currentPeriodLabel: string;
previousPeriodLabel: string;
currentTotal: number;
previousTotal: number;
percentageChange: number | null;
transactionCount: number;
};
export function CategoryDetailHeader({
category,
currentPeriodLabel,
previousPeriodLabel,
currentTotal,
previousTotal,
percentageChange,
transactionCount,
category,
currentPeriodLabel,
previousPeriodLabel,
currentTotal,
previousTotal,
percentageChange,
transactionCount,
}: CategoryDetailHeaderProps) {
const IconComponent = category.icon ? getIconComponent(category.icon) : null;
const initials = buildInitials(category.name);
const IconComponent = category.icon ? getIconComponent(category.icon) : null;
const initials = buildInitials(category.name);
const isIncrease =
typeof percentageChange === "number" && percentageChange > 0;
const isDecrease =
typeof percentageChange === "number" && percentageChange < 0;
const isIncrease =
typeof percentageChange === "number" && percentageChange > 0;
const isDecrease =
typeof percentageChange === "number" && percentageChange < 0;
const variationColor =
category.type === "receita"
? isIncrease
? "text-emerald-600"
: isDecrease
? "text-rose-600"
: "text-muted-foreground"
: isIncrease
? "text-rose-600"
: isDecrease
? "text-emerald-600"
: "text-muted-foreground";
const variationColor =
category.type === "receita"
? isIncrease
? "text-emerald-600"
: isDecrease
? "text-rose-600"
: "text-muted-foreground"
: isIncrease
? "text-rose-600"
: isDecrease
? "text-emerald-600"
: "text-muted-foreground";
const variationIcon =
isIncrease || isDecrease ? (
isIncrease ? (
<RiArrowUpLine className="size-4" aria-hidden />
) : (
<RiArrowDownLine className="size-4" aria-hidden />
)
) : null;
const variationIcon =
isIncrease || isDecrease ? (
isIncrease ? (
<RiArrowUpLine className="size-4" aria-hidden />
) : (
<RiArrowDownLine className="size-4" aria-hidden />
)
) : null;
const variationLabel =
typeof percentageChange === "number"
? `${percentageChange > 0 ? "+" : ""}${Math.abs(percentageChange).toFixed(
1
)}%`
: "—";
const variationLabel =
typeof percentageChange === "number"
? `${percentageChange > 0 ? "+" : ""}${Math.abs(percentageChange).toFixed(
1,
)}%`
: "—";
return (
<Card className="px-4">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-start gap-3">
<span className="flex size-12 items-center justify-center rounded-xl bg-muted">
{IconComponent ? (
<IconComponent className="size-6" aria-hidden />
) : (
<span className="text-sm font-semibold uppercase text-muted-foreground">
{initials}
</span>
)}
</span>
<div className="space-y-2">
<h1 className="text-xl font-semibold leading-tight">
{category.name}
</h1>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<TypeBadge type={category.type} />
<span>
{transactionCount}{" "}
{transactionCount === 1 ? "lançamento" : "lançamentos"} no{" "}
período
</span>
</div>
</div>
</div>
return (
<Card className="px-4">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-start gap-3">
<span className="flex size-12 items-center justify-center rounded-xl bg-muted">
{IconComponent ? (
<IconComponent className="size-6" aria-hidden />
) : (
<span className="text-sm font-semibold uppercase text-muted-foreground">
{initials}
</span>
)}
</span>
<div className="space-y-2">
<h1 className="text-xl font-semibold leading-tight">
{category.name}
</h1>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<TypeBadge type={category.type} />
<span>
{transactionCount}{" "}
{transactionCount === 1 ? "lançamento" : "lançamentos"} no{" "}
período
</span>
</div>
</div>
</div>
<div className="grid w-full gap-4 sm:grid-cols-2 lg:w-auto lg:grid-cols-3">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total em {currentPeriodLabel}
</p>
<p className="mt-1 text-2xl font-semibold">
{currencyFormatter.format(currentTotal)}
</p>
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total em {previousPeriodLabel}
</p>
<p className="mt-1 text-lg font-medium text-muted-foreground">
{currencyFormatter.format(previousTotal)}
</p>
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Variação vs mês anterior
</p>
<div
className={cn(
"mt-1 flex items-center gap-1 text-xl font-semibold",
variationColor
)}
>
{variationIcon}
<span>{variationLabel}</span>
</div>
</div>
</div>
</div>
</Card>
);
<div className="grid w-full gap-4 sm:grid-cols-2 lg:w-auto lg:grid-cols-3">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total em {currentPeriodLabel}
</p>
<p className="mt-1 text-2xl font-semibold">
{currencyFormatter.format(currentTotal)}
</p>
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total em {previousPeriodLabel}
</p>
<p className="mt-1 text-lg font-medium text-muted-foreground">
{currencyFormatter.format(previousTotal)}
</p>
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Variação vs mês anterior
</p>
<div
className={cn(
"mt-1 flex items-center gap-1 text-xl font-semibold",
variationColor,
)}
>
{variationIcon}
<span>{variationLabel}</span>
</div>
</div>
</div>
</div>
</Card>
);
}

View File

@@ -1,189 +1,189 @@
"use client";
import {
createCategoryAction,
updateCategoryAction,
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { toast } from "sonner";
import {
createCategoryAction,
updateCategoryAction,
} from "@/app/(dashboard)/categorias/actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state";
import { CATEGORY_TYPES } from "@/lib/categorias/constants";
import { getDefaultIconForType } from "@/lib/categorias/icons";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { toast } from "sonner";
import { CategoryFormFields } from "./category-form-fields";
import type { Category, CategoryFormValues } from "./types";
interface CategoryDialogProps {
mode: "create" | "update";
trigger?: React.ReactNode;
category?: Category;
defaultType?: CategoryFormValues["type"];
open?: boolean;
onOpenChange?: (open: boolean) => void;
mode: "create" | "update";
trigger?: React.ReactNode;
category?: Category;
defaultType?: CategoryFormValues["type"];
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
const buildInitialValues = ({
category,
defaultType,
category,
defaultType,
}: {
category?: Category;
defaultType?: CategoryFormValues["type"];
category?: Category;
defaultType?: CategoryFormValues["type"];
}): CategoryFormValues => {
const initialType = category?.type ?? defaultType ?? CATEGORY_TYPES[0];
const fallbackIcon = getDefaultIconForType(initialType);
const existingIcon = category?.icon ?? "";
const icon = existingIcon || fallbackIcon;
const initialType = category?.type ?? defaultType ?? CATEGORY_TYPES[0];
const fallbackIcon = getDefaultIconForType(initialType);
const existingIcon = category?.icon ?? "";
const icon = existingIcon || fallbackIcon;
return {
name: category?.name ?? "",
type: initialType,
icon,
};
return {
name: category?.name ?? "",
type: initialType,
icon,
};
};
export function CategoryDialog({
mode,
trigger,
category,
defaultType,
open,
onOpenChange,
mode,
trigger,
category,
defaultType,
open,
onOpenChange,
}: CategoryDialogProps) {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
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
);
// Use controlled state hook for dialog open state
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange,
);
const initialState = useMemo(
() =>
buildInitialValues({
category,
defaultType,
}),
[category, defaultType]
);
const initialState = useMemo(
() =>
buildInitialValues({
category,
defaultType,
}),
[category, defaultType],
);
// Use form state hook for form management
const { formState, updateField, setFormState } =
useFormState<CategoryFormValues>(initialState);
// Use form state hook for form management
const { formState, updateField, setFormState } =
useFormState<CategoryFormValues>(initialState);
// Reset form when dialog opens
useEffect(() => {
if (dialogOpen) {
setFormState(initialState);
setErrorMessage(null);
}
}, [dialogOpen, initialState, setFormState]);
// 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]);
// Clear error when dialog closes
useEffect(() => {
if (!dialogOpen) {
setErrorMessage(null);
}
}, [dialogOpen]);
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
if (mode === "update" && !category?.id) {
const message = "Categoria inválida.";
setErrorMessage(message);
toast.error(message);
return;
}
if (mode === "update" && !category?.id) {
const message = "Categoria inválida.";
setErrorMessage(message);
toast.error(message);
return;
}
const payload = {
name: formState.name.trim(),
type: formState.type,
icon: formState.icon.trim(),
};
const payload = {
name: formState.name.trim(),
type: formState.type,
icon: formState.icon.trim(),
};
startTransition(async () => {
const result =
mode === "create"
? await createCategoryAction(payload)
: await updateCategoryAction({
id: category?.id ?? "",
...payload,
});
startTransition(async () => {
const result =
mode === "create"
? await createCategoryAction(payload)
: await updateCategoryAction({
id: category?.id ?? "",
...payload,
});
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
return;
}
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
});
},
[category?.id, formState, initialState, mode, setDialogOpen, setFormState]
);
setErrorMessage(result.error);
toast.error(result.error);
});
},
[category?.id, formState, initialState, mode, setDialogOpen, setFormState],
);
const title = mode === "create" ? "Nova categoria" : "Editar categoria";
const description =
mode === "create"
? "Crie uma categoria para organizar seus lançamentos."
: "Atualize os detalhes da categoria selecionada.";
const submitLabel =
mode === "create" ? "Salvar categoria" : "Atualizar categoria";
const title = mode === "create" ? "Nova categoria" : "Editar categoria";
const description =
mode === "create"
? "Crie uma categoria para organizar seus lançamentos."
: "Atualize os detalhes da categoria selecionada.";
const submitLabel =
mode === "create" ? "Salvar categoria" : "Atualizar categoria";
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<CategoryFormFields values={formState} onChange={updateField} />
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<CategoryFormFields values={formState} onChange={updateField} />
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
<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>
);
<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>
);
}

View File

@@ -1,128 +1,128 @@
"use client";
import { RiMoreLine } from "@remixicon/react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
CATEGORY_TYPE_LABEL,
CATEGORY_TYPES,
CATEGORY_TYPE_LABEL,
CATEGORY_TYPES,
} from "@/lib/categorias/constants";
import { getCategoryIconOptions } from "@/lib/categorias/icons";
import { cn } from "@/lib/utils/ui";
import { RiMoreLine } from "@remixicon/react";
import { useState } from "react";
import { CategoryIcon } from "./category-icon";
import { TypeSelectContent } from "./category-select-items";
import type { CategoryFormValues } from "./types";
interface CategoryFormFieldsProps {
values: CategoryFormValues;
onChange: (field: keyof CategoryFormValues, value: string) => void;
values: CategoryFormValues;
onChange: (field: keyof CategoryFormValues, value: string) => void;
}
export function CategoryFormFields({
values,
onChange,
values,
onChange,
}: CategoryFormFieldsProps) {
const [popoverOpen, setPopoverOpen] = useState(false);
const iconOptions = getCategoryIconOptions();
const [popoverOpen, setPopoverOpen] = useState(false);
const iconOptions = getCategoryIconOptions();
const handleIconSelect = (icon: string) => {
onChange("icon", icon);
setPopoverOpen(false);
};
const handleIconSelect = (icon: string) => {
onChange("icon", icon);
setPopoverOpen(false);
};
return (
<div className="grid grid-cols-1 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="category-name">Nome</Label>
<Input
id="category-name"
value={values.name}
onChange={(event) => onChange("name", event.target.value)}
placeholder="Ex.: Alimentação"
required
/>
</div>
return (
<div className="grid grid-cols-1 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="category-name">Nome</Label>
<Input
id="category-name"
value={values.name}
onChange={(event) => onChange("name", event.target.value)}
placeholder="Ex.: Alimentação"
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="category-type">Tipo da categoria</Label>
<Select
value={values.type}
onValueChange={(value) => onChange("type", value)}
>
<SelectTrigger id="category-type" className="w-full">
<SelectValue placeholder="Selecione o tipo">
{values.type && (
<TypeSelectContent label={CATEGORY_TYPE_LABEL[values.type]} />
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{CATEGORY_TYPES.map((type) => (
<SelectItem key={type} value={type}>
<TypeSelectContent label={CATEGORY_TYPE_LABEL[type]} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="category-type">Tipo da categoria</Label>
<Select
value={values.type}
onValueChange={(value) => onChange("type", value)}
>
<SelectTrigger id="category-type" className="w-full">
<SelectValue placeholder="Selecione o tipo">
{values.type && (
<TypeSelectContent label={CATEGORY_TYPE_LABEL[values.type]} />
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{CATEGORY_TYPES.map((type) => (
<SelectItem key={type} value={type}>
<TypeSelectContent label={CATEGORY_TYPE_LABEL[type]} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Ícone</Label>
<div className="flex items-center gap-3">
<div className="flex size-12 items-center justify-center rounded-lg border bg-muted/30 text-primary">
{values.icon ? (
<CategoryIcon name={values.icon} className="size-7" />
) : (
<RiMoreLine className="size-6 text-muted-foreground" />
)}
</div>
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" className="flex-1">
Selecionar ícone
</Button>
</PopoverTrigger>
<PopoverContent className="w-[480px] p-3" align="start">
<div className="grid max-h-96 grid-cols-8 gap-2 overflow-y-auto">
{iconOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => handleIconSelect(option.value)}
className={cn(
"flex size-12 items-center justify-center rounded-lg border transition-all hover:border-primary hover:bg-primary/5",
values.icon === option.value
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-primary"
)}
title={option.label}
>
<CategoryIcon name={option.value} className="size-6" />
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
<p className="text-xs text-muted-foreground">
Escolha um ícone que represente melhor esta categoria.
</p>
</div>
</div>
);
<div className="flex flex-col gap-2">
<Label>Ícone</Label>
<div className="flex items-center gap-3">
<div className="flex size-12 items-center justify-center rounded-lg border bg-muted/30 text-primary">
{values.icon ? (
<CategoryIcon name={values.icon} className="size-7" />
) : (
<RiMoreLine className="size-6 text-muted-foreground" />
)}
</div>
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" className="flex-1">
Selecionar ícone
</Button>
</PopoverTrigger>
<PopoverContent className="w-[480px] p-3" align="start">
<div className="grid max-h-96 grid-cols-8 gap-2 overflow-y-auto">
{iconOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => handleIconSelect(option.value)}
className={cn(
"flex size-12 items-center justify-center rounded-lg border transition-all hover:border-primary hover:bg-primary/5",
values.icon === option.value
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-primary",
)}
title={option.label}
>
<CategoryIcon name={option.value} className="size-6" />
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
<p className="text-xs text-muted-foreground">
Escolha um ícone que represente melhor esta categoria.
</p>
</div>
</div>
);
}

View File

@@ -5,24 +5,24 @@ import * as RemixIcons from "@remixicon/react";
import { cn } from "@/lib/utils/ui";
const ICONS = RemixIcons as Record<string, RemixiconComponentType | undefined>;
const FALLBACK_ICON = ICONS["RiPriceTag3Line"];
const FALLBACK_ICON = ICONS.RiPriceTag3Line;
interface CategoryIconProps {
name?: string | null;
className?: string;
name?: string | null;
className?: string;
}
export function CategoryIcon({ name, className }: CategoryIconProps) {
const IconComponent =
(name ? ICONS[name] : undefined) ?? FALLBACK_ICON ?? null;
const IconComponent =
(name ? ICONS[name] : undefined) ?? FALLBACK_ICON ?? null;
if (!IconComponent) {
return (
<span className={cn("text-xs text-muted-foreground", className)}>
{name ?? "Categoria"}
</span>
);
}
if (!IconComponent) {
return (
<span className={cn("text-xs text-muted-foreground", className)}>
{name ?? "Categoria"}
</span>
);
}
return <IconComponent className={cn("size-5", className)} aria-hidden />;
return <IconComponent className={cn("size-5", className)} aria-hidden />;
}

View File

@@ -3,18 +3,18 @@
import DotIcon from "@/components/dot-icon";
export function TypeSelectContent({ label }: { label: string }) {
const isReceita = label === "Receita";
const isReceita = label === "Receita";
return (
<span className="flex items-center gap-2">
<DotIcon
bg_dot={
isReceita
? "bg-emerald-600 dark:bg-emerald-300"
: "bg-rose-600 dark:bg-rose-300"
}
/>
<span>{label}</span>
</span>
);
return (
<span className="flex items-center gap-2">
<DotIcon
bg_dot={
isReceita
? "bg-emerald-600 dark:bg-emerald-300"
: "bg-rose-600 dark:bg-rose-300"
}
/>
<span>{label}</span>
</span>
);
}

View File

@@ -1,18 +1,18 @@
export {
CATEGORY_TYPES,
CATEGORY_TYPE_LABEL,
} from "@/lib/categorias/constants";
export type { CategoryType } from "@/lib/categorias/constants";
export {
CATEGORY_TYPE_LABEL,
CATEGORY_TYPES,
} from "@/lib/categorias/constants";
export type Category = {
id: string;
name: string;
type: CategoryType;
icon: string | null;
id: string;
name: string;
type: CategoryType;
icon: string | null;
};
export type CategoryFormValues = {
name: string;
type: CategoryType;
icon: string;
name: string;
type: CategoryType;
icon: string;
};