refactor(core): centraliza hooks, providers e base compartilhada

This commit is contained in:
Felipe Coutinho
2026-03-09 17:11:55 +00:00
parent 2de5101058
commit 3e06a1d056
76 changed files with 3271 additions and 709 deletions

View File

@@ -0,0 +1,122 @@
"use client";
import { RiMoonClearLine, RiSunLine } from "@remixicon/react";
import { useEffect, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { buttonVariants } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils/ui";
interface AnimatedThemeTogglerProps
extends React.ComponentPropsWithoutRef<"button"> {
duration?: number;
}
export const AnimatedThemeToggler = ({
className,
duration = 400,
...props
}: AnimatedThemeTogglerProps) => {
const [isDark, setIsDark] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const updateTheme = () => {
setIsDark(document.documentElement.classList.contains("dark"));
};
updateTheme();
const observer = new MutationObserver(updateTheme);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
const toggleTheme = async () => {
if (!buttonRef.current) return;
await document.startViewTransition(() => {
flushSync(() => {
const newTheme = !isDark;
setIsDark(newTheme);
document.documentElement.classList.toggle("dark");
localStorage.setItem("theme", newTheme ? "dark" : "light");
});
}).ready;
const { top, left, width, height } =
buttonRef.current.getBoundingClientRect();
const x = left + width / 2;
const y = top + height / 2;
const maxRadius = Math.hypot(
Math.max(left, window.innerWidth - left),
Math.max(top, window.innerHeight - top),
);
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRadius}px at ${x}px ${y}px)`,
],
},
{
duration,
easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)",
},
);
};
return (
<Tooltip>
<TooltipTrigger asChild>
<button
ref={buttonRef}
type="button"
onClick={toggleTheme}
data-state={isDark ? "dark" : "light"}
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative text-muted-foreground transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border",
className,
)}
{...props}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 opacity-0 transition-opacity duration-200 data-[state=dark]:opacity-100"
>
<span className="absolute inset-0 bg-linear-to-br from-amber-500/5 via-transparent to-amber-500/15 dark:from-amber-500/10 dark:to-amber-500/30" />
</span>
{isDark ? (
<RiSunLine
className="size-4 transition-transform duration-200"
aria-hidden
/>
) : (
<RiMoonClearLine
className="size-4 transition-transform duration-200"
aria-hidden
/>
)}
<span className="sr-only">
{isDark ? "Ativar tema claro" : "Ativar tema escuro"}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
{isDark ? "Tema claro" : "Tema escuro"}
</TooltipContent>
</Tooltip>
);
};

View File

@@ -0,0 +1,103 @@
"use client";
import type { VariantProps } from "class-variance-authority";
import { useState, useTransition } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { buttonVariants } from "@/components/ui/button";
interface ConfirmActionDialogProps {
trigger?: React.ReactNode;
title: string;
description?: string;
confirmLabel?: string;
cancelLabel?: string;
pendingLabel?: string;
confirmVariant?: VariantProps<typeof buttonVariants>["variant"];
open?: boolean;
onOpenChange?: (open: boolean) => void;
onConfirm?: () => Promise<void> | void;
disabled?: boolean;
className?: string;
}
export function ConfirmActionDialog({
trigger,
title,
description,
confirmLabel = "Confirmar",
cancelLabel = "Cancelar",
pendingLabel,
confirmVariant = "default",
open,
onOpenChange,
onConfirm,
disabled = false,
className,
}: ConfirmActionDialogProps) {
const [internalOpen, setInternalOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const dialogOpen = open ?? internalOpen;
const setDialogOpen = (value: boolean) => {
if (open === undefined) {
setInternalOpen(value);
}
onOpenChange?.(value);
};
const resolvedPendingLabel = pendingLabel ?? confirmLabel;
const handleConfirm = () => {
if (!onConfirm) {
setDialogOpen(false);
return;
}
startTransition(async () => {
try {
await onConfirm();
setDialogOpen(false);
} catch {
// Mantém o diálogo aberto para que o chamador trate o erro.
}
});
};
return (
<AlertDialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? (
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
) : null}
<AlertDialogContent className={className}>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
{description ? (
<AlertDialogDescription>{description}</AlertDialogDescription>
) : null}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending || disabled}>
{cancelLabel}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={isPending || disabled}
className={buttonVariants({ variant: confirmVariant })}
>
{isPending ? resolvedPendingLabel : confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import { RiExpandDiagonalLine } from "@remixicon/react";
import { useEffect, useRef, useState } from "react";
import type { WidgetCardProps } from "@/components/shared/widget-card";
import WidgetCard from "@/components/shared/widget-card";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
const OVERFLOW_THRESHOLD_PX = 16;
const EXPANDABLE_CONTENT_CLASSNAME =
"max-h-[calc(var(--spacing-custom-height-card)-5rem)] overflow-hidden md:max-h-[calc(100%-5rem)]";
export function ExpandableWidgetCard({
title,
subtitle,
icon,
children,
action,
}: WidgetCardProps) {
const contentRef = useRef<HTMLDivElement | null>(null);
const [hasOverflow, setHasOverflow] = useState(false);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const element = contentRef.current;
if (!element) return;
let frameId = 0;
const checkOverflow = () => {
cancelAnimationFrame(frameId);
frameId = window.requestAnimationFrame(() => {
const hasOverflowNow =
element.scrollHeight - element.clientHeight > OVERFLOW_THRESHOLD_PX;
setHasOverflow((currentValue) =>
currentValue === hasOverflowNow ? currentValue : hasOverflowNow,
);
});
};
checkOverflow();
const resizeObserver = new ResizeObserver(checkOverflow);
resizeObserver.observe(element);
return () => {
cancelAnimationFrame(frameId);
resizeObserver.disconnect();
};
}, []);
return (
<>
<WidgetCard
title={title}
subtitle={subtitle}
icon={icon}
action={action}
contentRef={contentRef}
contentClassName={EXPANDABLE_CONTENT_CLASSNAME}
overlay={
hasOverflow ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center bg-linear-to-t from-card to-transparent pt-12 pb-6">
<Button
variant="secondary"
className="pointer-events-auto rounded-full text-xs"
onClick={() => setIsOpen(true)}
aria-label="Expandir para ver todo o conteúdo"
>
Ver tudo <RiExpandDiagonalLine size={10} aria-hidden="true" />
</Button>
</div>
) : null
}
>
{children}
</WidgetCard>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="max-h-[85vh] w-full max-w-[calc(100%-2rem)] sm:max-w-3xl overflow-hidden p-6">
<DialogHeader className="text-left">
<DialogTitle className="flex items-center gap-2">
{icon}
<span>{title}</span>
</DialogTitle>
{subtitle ? (
<p className="text-muted-foreground text-sm">{subtitle}</p>
) : null}
</DialogHeader>
<div className="scrollbar-hide max-h-[calc(85vh-6rem)] overflow-y-auto pb-6">
{children}
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,82 @@
import Image from "next/image";
import { cn } from "@/lib/utils/ui";
import { version } from "@/package.json";
interface LogoProps {
variant?: "full" | "small" | "compact";
className?: string;
showVersion?: boolean;
invertTextOnDark?: boolean;
}
export function Logo({
variant = "full",
className,
showVersion = false,
invertTextOnDark = true,
}: LogoProps) {
if (variant === "compact") {
return (
<div className={cn("flex items-center gap-1", className)}>
<Image
src="/imagens/logo_small.png"
alt="OpenMonetis"
width={32}
height={32}
className="object-contain brightness-0 saturate-0"
priority
/>
<Image
src="/imagens/logo_text.png"
alt="OpenMonetis"
width={110}
height={32}
className={cn(
"hidden object-contain sm:block",
invertTextOnDark && "dark:invert",
)}
priority
/>
</div>
);
}
if (variant === "small") {
return (
<Image
src="/imagens/logo_small.png"
alt="OpenMonetis"
width={32}
height={32}
className={cn("object-contain", className)}
priority
/>
);
}
return (
<div className={cn("flex items-center gap-1.5 py-4", className)}>
<Image
src="/imagens/logo_small.png"
alt="OpenMonetis"
width={28}
height={28}
className="object-contain"
priority
/>
<Image
src="/imagens/logo_text.png"
alt="OpenMonetis"
width={100}
height={32}
className={cn("object-contain", invertTextOnDark && "dark:invert")}
priority
/>
{showVersion && (
<span className="text-[9px] font-medium text-muted-foreground">
{version}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { usePrivacyMode } from "@/components/providers/privacy-provider";
import { formatCurrency } from "@/lib/utils/currency";
import { cn } from "@/lib/utils/ui";
type Props = {
amount: number;
className?: string;
showPositiveSign?: boolean;
};
function MoneyValues({ amount, className, showPositiveSign = false }: Props) {
const { privacyMode } = usePrivacyMode();
const formattedValue = formatCurrency(amount);
const displayValue =
showPositiveSign && amount > 0 ? `+${formattedValue}` : formattedValue;
return (
<span
style={{ fontFamily: "var(--font-money)" }}
className={cn(
"inline-flex items-baseline transition-all duration-200 tracking-tighter",
privacyMode &&
"blur-[6px] select-none hover:blur-none focus-within:blur-none",
className,
)}
aria-label={privacyMode ? "Valor oculto" : displayValue}
data-privacy={privacyMode ? "hidden" : undefined}
title={
privacyMode ? "Valor oculto - passe o mouse para revelar" : undefined
}
>
{displayValue}
</span>
);
}
export default MoneyValues;

View File

@@ -9,7 +9,7 @@ export default function PageDescription({
}) {
return (
<div>
<h1 className="text-2xl font-semibold flex items-center gap-1">
<h1 className="text-2xl font-semibold flex items-center gap-1 font-[aeonik] tracking-tighter">
<span className="text-primary">{icon}</span>
{title}
</h1>

View File

@@ -0,0 +1,71 @@
"use client";
import { RiCalendarLine } from "@remixicon/react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { MonthPicker } from "@/components/ui/month-picker";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
dateToPeriod,
formatMonthYearLabel,
periodToDate,
} from "@/lib/utils/period";
import { cn } from "@/lib/utils/ui";
interface PeriodPickerProps {
value: string; // "YYYY-MM" format
onChange: (value: string) => void;
disabled?: boolean;
className?: string;
placeholder?: string;
variant?: "default" | "outline" | "ghost";
size?: "default" | "sm" | "lg";
}
export function PeriodPicker({
value,
onChange,
disabled = false,
className,
placeholder = "Selecione o período",
variant = "outline",
size = "default",
}: PeriodPickerProps) {
const [open, setOpen] = useState(false);
const handleSelect = (date: Date) => {
const period = dateToPeriod(date);
onChange(period);
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant={variant}
size={size}
disabled={disabled}
className={cn(
"justify-start text-left font-normal capitalize",
!value && "text-muted-foreground",
className,
)}
>
<RiCalendarLine className="h-4 w-4" />
{value ? formatMonthYearLabel(value) : placeholder}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<MonthPicker
selectedMonth={value ? periodToDate(value) : new Date()}
onMonthSelect={handleSelect}
/>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,4 +1,4 @@
import { SectionCardsSkeleton } from "./section-cards-skeleton";
import { DashboardMetricsCardsSkeleton } from "./dashboard-metrics-cards-skeleton";
import { WidgetSkeleton } from "./widget-skeleton";
/**
@@ -8,8 +8,8 @@ import { WidgetSkeleton } from "./widget-skeleton";
export function DashboardGridSkeleton() {
return (
<div className="@container/main space-y-4">
{/* Section Cards no topo */}
<SectionCardsSkeleton />
{/* Cards de métricas no topo */}
<DashboardMetricsCardsSkeleton />
{/* Grid de widgets - mesmos breakpoints do dashboard real */}
<div className="grid grid-cols-1 gap-3 @4xl/main:grid-cols-2 @6xl/main:grid-cols-3">

View File

@@ -0,0 +1,37 @@
import { Card, CardFooter, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
/**
* Skeleton fiel aos cards de métricas do dashboard (DashboardMetricsCards)
* Mantém o mesmo layout de 4 colunas responsivo
*/
export function DashboardMetricsCardsSkeleton() {
return (
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<Card key={index} className="@container/card gap-2">
<CardHeader>
<div className="space-y-3">
{/* Título com ícone */}
<div className="flex items-center gap-1">
<Skeleton className="size-4 rounded-2xl bg-foreground/10" />
<Skeleton className="h-5 w-20 rounded-2xl bg-foreground/10" />
</div>
{/* Valor principal */}
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
{/* Badge de tendência */}
<Skeleton className="h-6 w-16 rounded-2xl bg-foreground/10" />
</div>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<Skeleton className="h-4 w-24 rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
</CardFooter>
</Card>
))}
</div>
);
}

View File

@@ -5,8 +5,8 @@
export { AccountStatementCardSkeleton } from "./account-statement-card-skeleton";
export { CategoryReportSkeleton } from "./category-report-skeleton";
export { DashboardGridSkeleton } from "./dashboard-grid-skeleton";
export { DashboardMetricsCardsSkeleton } from "./dashboard-metrics-cards-skeleton";
export { FilterSkeleton } from "./filter-skeleton";
export { InvoiceSummaryCardSkeleton } from "./invoice-summary-card-skeleton";
export { SectionCardsSkeleton } from "./section-cards-skeleton";
export { TransactionsTableSkeleton } from "./transactions-table-skeleton";
export { WidgetSkeleton } from "./widget-skeleton";

View File

@@ -7,23 +7,23 @@ import { Skeleton } from "@/components/ui/skeleton";
*/
export function WidgetSkeleton() {
return (
<Card className="relative h-auto md:h-custom-height-1 md:overflow-hidden">
<CardHeader className="border-b [.border-b]:pb-2">
<Card className="relative h-auto gap-0 py-0 md:h-custom-height-card md:overflow-hidden">
<CardHeader className="border-b px-6 py-4">
<div className="flex w-full items-start justify-between">
<div className="space-y-2">
<div className="min-w-0 space-y-1.5">
{/* Title com ícone */}
<div className="flex items-center gap-1">
<div className="flex items-center gap-2">
<Skeleton className="size-4 rounded-2xl bg-foreground/10" />
<Skeleton className="h-5 w-32 rounded-2xl bg-foreground/10" />
</div>
{/* Subtitle */}
<Skeleton className="h-4 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-3 w-48 rounded-2xl bg-foreground/10" />
</div>
</div>
</CardHeader>
<CardContent className="max-h-[calc(var(--spacing-custom-height-1)-5rem)] overflow-hidden md:max-h-[calc(100%-5rem)]">
<div className="flex flex-col gap-3 py-4">
<CardContent className="min-h-0 flex-1 overflow-hidden px-6 py-4">
<div className="flex flex-col gap-3">
{/* Simula 5 linhas de conteúdo */}
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center justify-between gap-3">

View File

@@ -0,0 +1,19 @@
import { cn } from "@/lib/utils";
type StatusDotProps = {
color: string;
className?: string;
};
export default function StatusDot({ color, className }: StatusDotProps) {
return (
<span
className={cn(
"inline-block size-2 shrink-0 rounded-full",
color,
className,
)}
aria-hidden="true"
/>
);
}

View File

@@ -0,0 +1,63 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils/ui";
import StatusDot from "./status-dot";
type TypeBadgeType =
| "receita"
| "despesa"
| "Receita"
| "Despesa"
| "Transferência"
| "transferência"
| "Saldo inicial"
| "Saldo Inicial";
interface TypeBadgeProps {
type: TypeBadgeType | string;
className?: string;
}
const TYPE_LABELS: Record<string, string> = {
receita: "Receita",
despesa: "Despesa",
Receita: "Receita",
Despesa: "Despesa",
Transferência: "Transferência",
transferência: "Transferência",
"Saldo inicial": "Saldo Inicial",
"Saldo Inicial": "Saldo Inicial",
};
export function TypeBadge({ type, className }: TypeBadgeProps) {
const normalizedType = type.toLowerCase();
const isReceita = normalizedType === "receita";
const isTransferencia = normalizedType === "transferência";
const isSaldoInicial = normalizedType === "saldo inicial";
const label = TYPE_LABELS[type] || type;
const colorClass = isTransferencia
? "text-info"
: isReceita || isSaldoInicial
? "text-success"
: "text-destructive";
const dotColor = isTransferencia
? "bg-info"
: isReceita || isSaldoInicial
? "bg-success"
: "bg-destructive";
return (
<Badge
variant={"outline"}
className={cn(
"flex items-center gap-1 px-2 text-xs",
colorClass,
className,
)}
>
<StatusDot color={dotColor} />
{label}
</Badge>
);
}

View File

@@ -0,0 +1,60 @@
import type * as React from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
export type WidgetCardProps = {
title: string;
subtitle: string;
children: React.ReactNode;
icon: React.ReactNode;
action?: React.ReactNode;
};
type WidgetCardShellProps = WidgetCardProps & {
contentClassName?: string;
contentRef?: React.Ref<HTMLDivElement>;
overlay?: React.ReactNode;
};
export default function WidgetCard({
title,
subtitle,
icon,
children,
action,
contentClassName,
contentRef,
overlay,
}: WidgetCardShellProps) {
return (
<Card className="relative gap-2 overflow-hidden md:h-custom-height-card">
<CardHeader>
<div className="flex w-full items-start justify-between">
<div>
<CardTitle className="flex items-center gap-1 font-[aeonik] tracking-tighter lowercase">
<span className="size-4">{icon}</span>
{title}
</CardTitle>
<CardDescription className="text-muted-foreground text-sm lowercase mt-2">
{subtitle}
</CardDescription>
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
<Separator className="mt-1" />
</CardHeader>
<CardContent ref={contentRef} className={contentClassName}>
{children}
</CardContent>
{overlay}
</Card>
);
}

View File

@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
type WidgetEmptyStateProps = {
icon?: ReactNode;
title: string;
description?: string;
};
export function WidgetEmptyState({
icon,
title,
description,
}: WidgetEmptyStateProps) {
return (
<Empty>
<EmptyHeader>
<EmptyMedia>{icon}</EmptyMedia>
<EmptyTitle>{title}</EmptyTitle>
<EmptyDescription>{description}</EmptyDescription>
</EmptyHeader>
</Empty>
);
}