feat: implementar relatórios de categorias e substituir seleção de período por picker visual

BREAKING CHANGE: Remove feature de seleção de período das preferências do usuário

  Alterações principais:

  - Adiciona sistema completo de relatórios por categoria
    - Cria página /relatorios/categorias com filtros e visualizações
    - Implementa tabela e gráfico de evolução mensal
    - Adiciona funcionalidade de exportação de dados
    - Cria skeleton otimizado para melhor UX de loading

  - Remove feature de seleção de período das preferências
    - Deleta lib/user-preferences/period.ts
    - Remove colunas periodMonthsBefore e periodMonthsAfter do schema
    - Remove todas as referências em 16+ arquivos
    - Atualiza database schema via Drizzle

  - Substitui Select de período por MonthPicker visual
    - Implementa componente PeriodPicker reutilizável
    - Integra shadcn MonthPicker customizado (português, Remix icons)
    - Substitui createMonthOptions em todos os formulários
    - Mantém formato "YYYY-MM" no banco de dados

  - Melhora design da tabela de relatórios
    - Mescla colunas Categoria e Tipo em uma única coluna
    - Substitui badge de tipo por dot colorido discreto
    - Reduz largura da tabela em ~120px
    - Atualiza skeleton para refletir nova estrutura

  - Melhorias gerais de UI
    - Reduz espaçamento entre títulos da sidebar (p-2 → px-2 py-1)
    - Adiciona MonthNavigation para navegação entre períodos
    - Otimiza loading states com skeletons detalhados
This commit is contained in:
Felipe Coutinho
2026-01-04 03:03:09 +00:00
parent d192f47bc7
commit 4237062bde
54 changed files with 2987 additions and 472 deletions

View File

@@ -1,11 +1,11 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils/ui";
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:scale-105 transition-transform",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
@@ -34,27 +34,29 @@ const buttonVariants = cva(
size: "default",
},
}
);
)
function Button({
className,
variant,
size,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button";
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
)
}
export { Button, buttonVariants };
export { Button, buttonVariants }

View File

@@ -0,0 +1,219 @@
"use client";
import * as React from "react";
import { RiArrowLeftSFill, RiArrowRightSFill } from "@remixicon/react";
import { buttonVariants } from "./button";
import { cn } from "@/lib/utils/ui";
type Month = {
number: number;
name: string;
};
const MONTHS: Month[][] = [
[
{ number: 0, name: "Jan" },
{ number: 1, name: "Fev" },
{ number: 2, name: "Mar" },
{ number: 3, name: "Abr" },
],
[
{ number: 4, name: "Mai" },
{ number: 5, name: "Jun" },
{ number: 6, name: "Jul" },
{ number: 7, name: "Ago" },
],
[
{ number: 8, name: "Set" },
{ number: 9, name: "Out" },
{ number: 10, name: "Nov" },
{ number: 11, name: "Dez" },
],
];
type MonthCalProps = {
selectedMonth?: Date;
onMonthSelect?: (date: Date) => void;
onYearForward?: () => void;
onYearBackward?: () => void;
callbacks?: {
yearLabel?: (year: number) => string;
monthLabel?: (month: Month) => string;
};
variant?: {
calendar?: {
main?: ButtonVariant;
selected?: ButtonVariant;
};
chevrons?: ButtonVariant;
};
minDate?: Date;
maxDate?: Date;
disabledDates?: Date[];
};
type ButtonVariant =
| "default"
| "outline"
| "ghost"
| "link"
| "destructive"
| "secondary"
| null
| undefined;
function MonthPicker({
onMonthSelect,
selectedMonth,
minDate,
maxDate,
disabledDates,
callbacks,
onYearBackward,
onYearForward,
variant,
className,
...props
}: React.HTMLAttributes<HTMLDivElement> & MonthCalProps) {
return (
<div className={cn("min-w-[200px] w-[280px] p-3", className)} {...props}>
<div className="flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0">
<div className="space-y-4 w-full">
<MonthCal
onMonthSelect={onMonthSelect}
callbacks={callbacks}
selectedMonth={selectedMonth}
onYearBackward={onYearBackward}
onYearForward={onYearForward}
variant={variant}
minDate={minDate}
maxDate={maxDate}
disabledDates={disabledDates}
></MonthCal>
</div>
</div>
</div>
);
}
function MonthCal({
selectedMonth,
onMonthSelect,
callbacks,
variant,
minDate,
maxDate,
disabledDates,
onYearBackward,
onYearForward,
}: MonthCalProps) {
const [year, setYear] = React.useState<number>(
selectedMonth?.getFullYear() ?? new Date().getFullYear()
);
const [month, setMonth] = React.useState<number>(
selectedMonth?.getMonth() ?? new Date().getMonth()
);
const [menuYear, setMenuYear] = React.useState<number>(year);
if (minDate && maxDate && minDate > maxDate) minDate = maxDate;
const disabledDatesMapped = disabledDates?.map((d) => {
return { year: d.getFullYear(), month: d.getMonth() };
});
return (
<>
<div className="flex justify-center pt-1 relative items-center">
<div className="text-sm font-bold">
{callbacks?.yearLabel ? callbacks?.yearLabel(menuYear) : menuYear}
</div>
<div className="space-x-1 flex items-center">
<button
onClick={() => {
setMenuYear(menuYear - 1);
if (onYearBackward) onYearBackward();
}}
className={cn(
buttonVariants({ variant: variant?.chevrons ?? "outline" }),
"inline-flex items-center justify-center h-7 w-7 p-0 absolute left-1"
)}
>
<RiArrowLeftSFill className="opacity-50 size-4" />
</button>
<button
onClick={() => {
setMenuYear(menuYear + 1);
if (onYearForward) onYearForward();
}}
className={cn(
buttonVariants({ variant: variant?.chevrons ?? "outline" }),
"inline-flex items-center justify-center h-7 w-7 p-0 absolute right-1"
)}
>
<RiArrowRightSFill className="opacity-50 size-4" />
</button>
</div>
</div>
<table className="w-full border-collapse space-y-1">
<tbody>
{MONTHS.map((monthRow, a) => {
return (
<tr key={"row-" + a} className="flex w-full mt-2">
{monthRow.map((m) => {
return (
<td
key={m.number}
className="h-10 w-1/4 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20"
>
<button
onClick={() => {
setMonth(m.number);
setYear(menuYear);
if (onMonthSelect)
onMonthSelect(new Date(menuYear, m.number));
}}
disabled={
(maxDate
? menuYear > maxDate?.getFullYear() ||
(menuYear == maxDate?.getFullYear() &&
m.number > maxDate.getMonth())
: false) ||
(minDate
? menuYear < minDate?.getFullYear() ||
(menuYear == minDate?.getFullYear() &&
m.number < minDate.getMonth())
: false) ||
(disabledDatesMapped
? disabledDatesMapped?.some(
(d) => d.year == menuYear && d.month == m.number
)
: false)
}
className={cn(
buttonVariants({
variant:
month == m.number && menuYear == year
? variant?.calendar?.selected ?? "default"
: variant?.calendar?.main ?? "ghost",
}),
"h-full w-full p-0 font-normal aria-selected:opacity-100"
)}
>
{callbacks?.monthLabel
? callbacks.monthLabel(m)
: m.name}
</button>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</>
);
}
MonthPicker.displayName = "MonthPicker";
export { MonthPicker };

View File

@@ -386,7 +386,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
className={cn("relative flex w-full min-w-0 flex-col px-2 py-1", className)}
{...props}
/>
);