mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
refactor(core): centraliza hooks, providers e base compartilhada
This commit is contained in:
122
components/shared/animated-theme-toggler.tsx
Normal file
122
components/shared/animated-theme-toggler.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
103
components/shared/confirm-action-dialog.tsx
Normal file
103
components/shared/confirm-action-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
components/shared/expandable-widget-card.tsx
Normal file
103
components/shared/expandable-widget-card.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
82
components/shared/logo.tsx
Normal file
82
components/shared/logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
components/shared/money-values.tsx
Normal file
40
components/shared/money-values.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
71
components/shared/period-picker.tsx
Normal file
71
components/shared/period-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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">
|
||||
|
||||
19
components/shared/status-dot.tsx
Normal file
19
components/shared/status-dot.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
63
components/shared/type-badge.tsx
Normal file
63
components/shared/type-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
components/shared/widget-card.tsx
Normal file
60
components/shared/widget-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
components/shared/widget-empty-state.tsx
Normal file
30
components/shared/widget-empty-state.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user