refactor(core): move app para src e padroniza estrutura

This commit is contained in:
Felipe Coutinho
2026-03-12 19:22:50 +00:00
parent d92e70f1b9
commit b0fbb1062a
567 changed files with 8981 additions and 5014 deletions

View File

@@ -0,0 +1,176 @@
"use server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { categorias } from "@/db/schema";
import {
type ActionResult,
handleActionError,
revalidateForEntity,
} from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server";
import { CATEGORY_TYPES } from "@/shared/lib/categories/constants";
import { db } from "@/shared/lib/db";
import { uuidSchema } from "@/shared/lib/schemas/common";
import { normalizeIconInput } from "@/shared/utils/string";
const categoryBaseSchema = z.object({
name: z
.string({ message: "Informe o nome da categoria." })
.trim()
.min(1, "Informe o nome da categoria."),
type: z.enum(CATEGORY_TYPES, {
message: "Tipo de categoria inválido.",
}),
icon: z
.string()
.trim()
.max(100, "O ícone deve ter no máximo 100 caracteres.")
.nullish()
.transform((value) => normalizeIconInput(value)),
});
const createCategorySchema = categoryBaseSchema;
const updateCategorySchema = categoryBaseSchema.extend({
id: uuidSchema("Categoria"),
});
const deleteCategorySchema = z.object({
id: uuidSchema("Categoria"),
});
type CategoryCreateInput = z.infer<typeof createCategorySchema>;
type CategoryUpdateInput = z.infer<typeof updateCategorySchema>;
type CategoryDeleteInput = z.infer<typeof deleteCategorySchema>;
export async function createCategoryAction(
input: CategoryCreateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = createCategorySchema.parse(input);
await db.insert(categorias).values({
name: data.name,
type: data.type,
icon: data.icon,
userId: user.id,
});
revalidateForEntity("categorias");
return { success: true, message: "Categoria criada com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function updateCategoryAction(
input: CategoryUpdateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateCategorySchema.parse(input);
// Buscar categoria antes de atualizar para verificar restrições
const categoria = await db.query.categorias.findFirst({
columns: { id: true, name: true },
where: and(eq(categorias.id, data.id), eq(categorias.userId, user.id)),
});
if (!categoria) {
return {
success: false,
error: "Categoria não encontrada.",
};
}
// Bloquear edição das categorias protegidas
const categoriasProtegidas = [
"Transferência interna",
"Saldo inicial",
"Pagamentos",
];
if (categoriasProtegidas.includes(categoria.name)) {
return {
success: false,
error: `A categoria '${categoria.name}' é protegida e não pode ser editada.`,
};
}
const [updated] = await db
.update(categorias)
.set({
name: data.name,
type: data.type,
icon: data.icon,
})
.where(and(eq(categorias.id, data.id), eq(categorias.userId, user.id)))
.returning();
if (!updated) {
return {
success: false,
error: "Categoria não encontrada.",
};
}
revalidateForEntity("categorias");
return { success: true, message: "Categoria atualizada com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function deleteCategoryAction(
input: CategoryDeleteInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteCategorySchema.parse(input);
// Buscar categoria antes de deletar para verificar restrições
const categoria = await db.query.categorias.findFirst({
columns: { id: true, name: true },
where: and(eq(categorias.id, data.id), eq(categorias.userId, user.id)),
});
if (!categoria) {
return {
success: false,
error: "Categoria não encontrada.",
};
}
// Bloquear remoção das categorias protegidas
const categoriasProtegidas = [
"Transferência interna",
"Saldo inicial",
"Pagamentos",
];
if (categoriasProtegidas.includes(categoria.name)) {
return {
success: false,
error: `A categoria '${categoria.name}' é protegida e não pode ser removida.`,
};
}
const [deleted] = await db
.delete(categorias)
.where(and(eq(categorias.id, data.id), eq(categorias.userId, user.id)))
.returning({ id: categorias.id });
if (!deleted) {
return {
success: false,
error: "Categoria não encontrada.",
};
}
revalidateForEntity("categorias");
return { success: true, message: "Categoria removida com sucesso." };
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -0,0 +1,260 @@
"use client";
import {
RiAddCircleLine,
RiDeleteBin5Line,
RiExternalLinkLine,
RiPencilLine,
} from "@remixicon/react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { deleteCategoryAction } from "@/features/categories/actions";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/shared/components/ui/tabs";
import {
CATEGORY_TYPE_LABEL,
CATEGORY_TYPES,
} from "@/shared/lib/categories/constants";
import { CategoryDialog } from "./category-dialog";
import { CategoryIconBadge } from "./category-icon-badge";
import type { Category, CategoryType } from "./types";
const CATEGORIAS_PROTEGIDAS = [
"Transferência interna",
"Saldo inicial",
"Pagamentos",
];
interface CategoriesPageProps {
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 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);
});
CATEGORY_TYPES.forEach((type) => {
base[type].sort((a, b) =>
a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" }),
);
});
return base;
}, [categories]);
const handleEdit = (category: Category) => {
setSelectedCategory(category);
setEditOpen(true);
};
const handleEditOpenChange = (open: boolean) => {
setEditOpen(open);
if (!open) {
setSelectedCategory(null);
}
};
const handleRemoveRequest = (category: Category) => {
setCategoryToRemove(category);
setRemoveOpen(true);
};
const handleRemoveOpenChange = (open: boolean) => {
setRemoveOpen(open);
if (!open) {
setCategoryToRemove(null);
}
};
const handleRemoveConfirm = async () => {
if (!categoryToRemove) {
return;
}
const result = await deleteCategoryAction({ id: categoryToRemove.id });
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
};
const removeTitle = categoryToRemove
? `Remover categoria "${categoryToRemove.name}"?`
: "Remover categoria?";
return (
<>
<div className="flex w-full flex-col gap-6">
<div className="flex">
<CategoryDialog
mode="create"
defaultType={activeType}
trigger={
<Button className="w-full sm:w-auto">
<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>
{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>
) : (
<Card className="py-2">
<CardContent className="px-2 py-4 sm:px-4">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10" />
<TableHead>Nome</TableHead>
<TableHead className="text-right">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categoriesByType[type].map((category, index) => {
const isProtegida = CATEGORIAS_PROTEGIDAS.includes(
category.name,
);
return (
<TableRow key={category.id}>
<TableCell>
<CategoryIconBadge
icon={category.icon}
name={category.name}
colorIndex={index}
size="md"
/>
</TableCell>
<TableCell className="font-medium">
<Link
href={`/categories/${category.id}`}
className="inline-flex items-center gap-1 underline-offset-2 hover:text-primary hover:underline"
>
{category.name}
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-3 text-sm">
{!isProtegida && (
<button
type="button"
onClick={() => handleEdit(category)}
className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
>
<RiPencilLine
className="size-4"
aria-hidden
/>
editar
</button>
)}
{!isProtegida && (
<button
type="button"
onClick={() =>
handleRemoveRequest(category)
}
className="flex items-center gap-1 font-medium text-destructive transition-opacity hover:opacity-80"
>
<RiDeleteBin5Line
className="size-4"
aria-hidden
/>
remover
</button>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</TabsContent>
))}
</Tabs>
</div>
<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}
/>
</>
);
}

View File

@@ -0,0 +1,133 @@
import { RiArrowDownSFill, RiArrowUpSFill } from "@remixicon/react";
import { TypeBadge } from "@/shared/components/type-badge";
import { Card } from "@/shared/components/ui/card";
import type { CategoryType } from "@/shared/lib/categories/constants";
import { currencyFormatter } from "@/shared/utils/currency";
import { formatPercentage } from "@/shared/utils/percentage";
import { cn } from "@/shared/utils/ui";
import { CategoryIconBadge } from "./category-icon-badge";
type CategorySummary = {
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;
};
export function CategoryDetailHeader({
category,
currentPeriodLabel,
previousPeriodLabel,
currentTotal,
previousTotal,
percentageChange,
transactionCount,
}: CategoryDetailHeaderProps) {
const isIncrease =
typeof percentageChange === "number" && percentageChange > 0;
const isDecrease =
typeof percentageChange === "number" && percentageChange < 0;
const variationColor =
category.type === "receita"
? isIncrease
? "text-success"
: isDecrease
? "text-destructive"
: "text-muted-foreground"
: isIncrease
? "text-destructive"
: isDecrease
? "text-success"
: "text-muted-foreground";
const variationIcon =
isIncrease || isDecrease ? (
isIncrease ? (
<RiArrowUpSFill className="size-4" aria-hidden />
) : (
<RiArrowDownSFill className="size-4" aria-hidden />
)
) : null;
const variationLabel =
typeof percentageChange === "number"
? formatPercentage(percentageChange, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
absolute: true,
signDisplay: percentageChange === 0 ? "auto" : "always",
})
: "—";
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">
<CategoryIconBadge
icon={category.icon}
name={category.name}
colorIndex={0}
size="lg"
/>
<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>
);
}

View File

@@ -0,0 +1,180 @@
"use client";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import {
createCategoryAction,
updateCategoryAction,
} from "@/features/categories/actions";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog";
import { useControlledState } from "@/shared/hooks/use-controlled-state";
import { useFormState } from "@/shared/hooks/use-form-state";
import { CATEGORY_TYPES } from "@/shared/lib/categories/constants";
import { getDefaultIconForType } from "@/shared/lib/categories/icons";
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;
}
const buildInitialValues = ({
category,
defaultType,
}: {
category?: Category;
defaultType?: CategoryFormValues["type"];
}): CategoryFormValues => {
const initialType = category?.type ?? defaultType ?? CATEGORY_TYPES[0];
const fallbackIcon = getDefaultIconForType();
const existingIcon = category?.icon ?? "";
const icon = existingIcon || fallbackIcon;
return {
name: category?.name ?? "",
type: initialType,
icon,
};
};
export function CategoryDialog({
mode,
trigger,
category,
defaultType,
open,
onOpenChange,
}: CategoryDialogProps) {
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({
category,
defaultType,
}),
[category, defaultType],
);
// Use form state hook for form management
const { formState, resetForm, updateField } =
useFormState<CategoryFormValues>(initialState);
// Reset form when dialog opens
useEffect(() => {
if (dialogOpen) {
resetForm(initialState);
setErrorMessage(null);
}
}, [dialogOpen, initialState, resetForm]);
// Clear error when dialog closes
useEffect(() => {
if (!dialogOpen) {
setErrorMessage(null);
}
}, [dialogOpen]);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
if (mode === "update" && !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(),
};
startTransition(async () => {
const result =
mode === "create"
? await createCategoryAction(payload)
: await updateCategoryAction({
id: category?.id ?? "",
...payload,
});
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
resetForm(initialState);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
});
};
const title = mode === "create" ? "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>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<CategoryFormFields values={formState} onChange={updateField} />
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Salvando..." : submitLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,128 @@
"use client";
import { RiMoreLine } from "@remixicon/react";
import { useState } from "react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import {
CATEGORY_TYPE_LABEL,
CATEGORY_TYPES,
} from "@/shared/lib/categories/constants";
import { getCategoryIconOptions } from "@/shared/lib/categories/icons";
import { cn } from "@/shared/utils/ui";
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;
}
export function CategoryFormFields({
values,
onChange,
}: CategoryFormFieldsProps) {
const [popoverOpen, setPopoverOpen] = useState(false);
const iconOptions = getCategoryIconOptions();
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>
<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>
);
}

View File

@@ -0,0 +1,75 @@
"use client";
import {
buildCategoryInitials,
getCategoryBgColor,
getCategoryColor,
} from "@/shared/utils/category-colors";
import { getIconComponent } from "@/shared/utils/icons";
import { cn } from "@/shared/utils/ui";
const sizeVariants = {
sm: {
container: "size-8",
icon: "size-4",
text: "text-[10px]",
},
md: {
container: "size-9",
icon: "size-5",
text: "text-xs",
},
lg: {
container: "size-12",
icon: "size-6",
text: "text-sm",
},
} as const;
export type CategoryIconBadgeSize = keyof typeof sizeVariants;
export interface CategoryIconBadgeProps {
/** Nome do ícone Remix (ex: "RiShoppingBag3Line") */
icon?: string | null;
/** Nome da categoria (usado para gerar iniciais como fallback) */
name: string;
/** Índice para determinar a cor (cicla entre as cores disponíveis) */
colorIndex: number;
/** Tamanho do badge: sm (32px), md (36px), lg (48px) */
size?: CategoryIconBadgeSize;
/** Classes adicionais para o container */
className?: string;
}
export function CategoryIconBadge({
icon,
name,
colorIndex,
size = "md",
className,
}: CategoryIconBadgeProps) {
const IconComponent = icon ? getIconComponent(icon) : null;
const initials = buildCategoryInitials(name);
const color = getCategoryColor(colorIndex);
const bgColor = getCategoryBgColor(colorIndex);
const variant = sizeVariants[size];
return (
<div
className={cn(
"flex shrink-0 items-center justify-center overflow-hidden rounded-full",
variant.container,
className,
)}
style={{ backgroundColor: bgColor }}
>
{IconComponent ? (
<IconComponent className={variant.icon} style={{ color }} />
) : (
<span className={cn("uppercase", variant.text)} style={{ color }}>
{initials}
</span>
)}
</div>
);
}

View File

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

View File

@@ -0,0 +1,14 @@
"use client";
import StatusDot from "@/shared/components/status-dot";
export function TypeSelectContent({ label }: { label: string }) {
const isReceita = label === "Receita";
return (
<span className="flex items-center gap-2">
<StatusDot color={isReceita ? "bg-success" : "bg-destructive"} />
<span>{label}</span>
</span>
);
}

View File

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

View File

@@ -0,0 +1,26 @@
import { eq } from "drizzle-orm";
import { type Categoria, categorias } from "@/db/schema";
import type { CategoryType } from "@/features/categories/components/types";
import { db } from "@/shared/lib/db";
export type CategoryData = {
id: string;
name: string;
type: CategoryType;
icon: string | null;
};
export async function fetchCategoriesForUser(
userId: string,
): Promise<CategoryData[]> {
const categoryRows = await db.query.categorias.findMany({
where: eq(categorias.userId, userId),
});
return categoryRows.map((category: Categoria) => ({
id: category.id,
name: category.name,
type: category.type as CategoryType,
icon: category.icon,
}));
}