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:
@@ -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 há 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 há 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user