feat(dashboard): refina layout e widgets do painel

This commit is contained in:
Felipe Coutinho
2026-03-17 17:09:40 +00:00
parent 272e90aef9
commit 50177621ff
9 changed files with 91 additions and 63 deletions

View File

@@ -2,6 +2,7 @@ import { fetchDashboardNotifications } from "@/features/dashboard/notifications-
import { fetchPendingInboxCount } from "@/features/inbox/queries"; import { fetchPendingInboxCount } from "@/features/inbox/queries";
import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar"; import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar";
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider"; import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
import { DotPattern } from "@/shared/components/ui/dot-pattern";
import { getUserSession } from "@/shared/lib/auth/server"; import { getUserSession } from "@/shared/lib/auth/server";
import { fetchPayersWithAccess } from "@/shared/lib/payers/access"; import { fetchPayersWithAccess } from "@/shared/lib/payers/access";
import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants"; import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
@@ -48,7 +49,17 @@ export default async function DashboardLayout({
notificationsSnapshot={notificationsSnapshot} notificationsSnapshot={notificationsSnapshot}
/> />
<div className="relative flex flex-1 flex-col pt-16"> <div className="relative flex flex-1 flex-col pt-16">
<div className="pointer-events-none absolute inset-0" /> <div className="pointer-events-none absolute inset-x-0 top-0 h-80 overflow-hidden">
<DotPattern
width={20}
height={20}
cx={1.25}
cy={1.25}
cr={1.25}
className="text-primary/10 mask-[linear-gradient(to_bottom,black_0%,transparent_100%)]"
/>
<div className="absolute inset-0 bg-linear-to-b from-primary/6 to-transparent" />
</div>
<div className="@container/main flex flex-1 flex-col gap-2"> <div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-5 md:gap-6 w-full max-w-8xl mx-auto px-4 "> <div className="flex flex-col gap-4 py-5 md:gap-6 w-full max-w-8xl mx-auto px-4 ">
{children} {children}

View File

@@ -174,12 +174,11 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
{selectedCategoryDetails.length > 0 && ( {selectedCategoryDetails.length > 0 && (
<div className="flex items-start justify-between gap-4 mb-4"> <div className="flex items-start justify-between gap-4 mb-4">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{selectedCategoryDetails.map((category) => { {selectedCategoryDetails.map((category, colorIndex) => {
if (!category) return null; if (!category) return null;
const IconComponent = category.icon const IconComponent = category.icon
? getIconComponent(category.icon) ? getIconComponent(category.icon)
: null; : null;
const colorIndex = selectedCategories.indexOf(category.id);
const color = CHART_COLORS[colorIndex % CHART_COLORS.length]; const color = CHART_COLORS[colorIndex % CHART_COLORS.length];
return ( return (

View File

@@ -11,11 +11,14 @@ import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metric
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { import {
Card, Card,
CardFooter, CardContent,
CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/shared/components/ui/card"; } from "@/shared/components/ui/card";
import { Separator } from "@/shared/components/ui/separator";
import { formatPercentage } from "@/shared/utils/percentage"; import { formatPercentage } from "@/shared/utils/percentage";
import { cn } from "@/shared/utils/ui";
type DashboardMetricsCardsProps = { type DashboardMetricsCardsProps = {
metrics: DashboardCardMetrics; metrics: DashboardCardMetrics;
@@ -28,35 +31,35 @@ const TREND_THRESHOLD = 0.005;
const CARDS = [ const CARDS = [
{ {
label: "Receitas", label: "Receitas",
subtitle: "Entradas do período",
key: "receitas", key: "receitas",
icon: RiArrowUpLine, icon: RiArrowUpLine,
invertTrend: false, invertTrend: false,
cardClass: "",
iconClass: "text-success", iconClass: "text-success",
}, },
{ {
label: "Despesas", label: "Despesas",
subtitle: "Saídas do período",
key: "despesas", key: "despesas",
icon: RiArrowDownLine, icon: RiArrowDownLine,
invertTrend: true, invertTrend: true,
cardClass: "",
iconClass: "text-destructive", iconClass: "text-destructive",
}, },
{ {
label: "Balanço", label: "Balanço",
subtitle: "Receitas menos despesas",
key: "balanco", key: "balanco",
icon: RiScalesLine, icon: RiScalesLine,
invertTrend: false, invertTrend: false,
cardClass: "", iconClass: "text-warning",
iconClass: "text-amber-500",
}, },
{ {
label: "Previsto", label: "Previsto",
subtitle: "Saldo acumulado projetado",
key: "previsto", key: "previsto",
icon: RiCalendarCheckLine, icon: RiCalendarCheckLine,
invertTrend: false, invertTrend: false,
cardClass: "border border-dashed", iconClass: "text-primary",
iconClass: "",
}, },
] as const; ] as const;
@@ -91,8 +94,8 @@ const getPercentChange = (current: number, previous: number): string => {
: "—"; : "—";
}; };
const getTrendColor = (trend: Trend, invertTrend: boolean): string => { const getTrendBadgeClass = (trend: Trend, invertTrend: boolean): string => {
if (trend === "flat") return "text-muted-foreground"; if (trend === "flat") return "bg-muted text-muted-foreground";
const isPositive = invertTrend ? trend === "down" : trend === "up"; const isPositive = invertTrend ? trend === "down" : trend === "up";
return isPositive ? "text-success" : "text-destructive"; return isPositive ? "text-success" : "text-destructive";
}; };
@@ -101,36 +104,61 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
return ( return (
<div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4"> <div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
{CARDS.map( {CARDS.map(
({ label, key, icon: Icon, invertTrend, cardClass, iconClass }) => { ({ label, subtitle, key, icon: Icon, invertTrend, iconClass }) => {
const metric = metrics[key]; const metric = metrics[key];
const trend = getTrend(metric.current, metric.previous); const trend = getTrend(metric.current, metric.previous);
const TrendIcon = TREND_ICONS[trend]; const TrendIcon = TREND_ICONS[trend];
const trendColor = getTrendColor(trend, invertTrend); const trendBadgeClass = getTrendBadgeClass(trend, invertTrend);
const percentChange = getPercentChange(
metric.current,
metric.previous,
);
return ( return (
<Card <Card key={label} className="gap-2 overflow-hidden">
key={label}
className={`@container/card flex flex-col justify-between min-h-34 ${cardClass}`}
>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-1 tracking-tight lowercase"> <div className="flex items-start justify-between">
<Icon className={`size-4 ${iconClass}`} /> <div>
{label} <CardTitle className="flex items-center gap-1 tracking-tight">
</CardTitle> <Icon
<div className="flex items-baseline gap-2 mt-auto pt-2"> className={cn("size-4", iconClass)}
<MoneyValues className="text-2xl" amount={metric.current} /> aria-hidden
<div className={`flex items-center text-xs ${trendColor}`}> />
<TrendIcon size={14} /> {label}
{getPercentChange(metric.current, metric.previous)} </CardTitle>
<CardDescription className="mt-1.5 tracking-tight">
{subtitle}
</CardDescription>
</div> </div>
</div> </div>
<Separator className="mt-1" />
</CardHeader> </CardHeader>
<CardFooter className="text-sm">
<div className="flex items-center gap-1 text-xs text-muted-foreground"> <CardContent className="flex flex-col gap-3">
<span>vs. mês anterior</span> <div className="flex flex-wrap items-center justify-between gap-2 mt-1">
<MoneyValues amount={metric.previous} /> <MoneyValues
className="text-[1.55rem] leading-none font-medium"
amount={metric.current}
/>
<div
className={cn(
"inline-flex items-center gap-1 text-xs font-medium",
trendBadgeClass,
)}
>
<TrendIcon className="size-3.5" aria-hidden />
<span>{percentChange}</span>
</div>
</div> </div>
</CardFooter>
<div className="text-xs text-muted-foreground">
<MoneyValues
className="inline text-xs font-medium text-muted-foreground"
amount={metric.previous}
/>
<span className="ml-1">no mês anterior</span>
</div>
</CardContent>
</Card> </Card>
); );
}, },

View File

@@ -6,12 +6,12 @@ export function DashboardWelcome({ name }: { name?: string | null }) {
const greeting = getGreeting(); const greeting = getGreeting();
return ( return (
<section className="p-2"> <section className="py-4">
<div className="tracking-tight"> <div className="tracking-tight">
<h1 className="text-xl"> <h1 className="text-xl">
{greeting}, <span className="text-primary">{displayName}</span> {greeting}, {displayName}
</h1> </h1>
<p className="text-sm mt-1 text-muted-foreground">{formattedDate}</p> <h2 className="text-sm mt-1 text-muted-foreground">{formattedDate}</h2>
</div> </div>
</section> </section>
); );

View File

@@ -40,20 +40,20 @@ export function NoteListItem({
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center">
<Button <Button
variant="outline" variant="link"
size="icon-sm" size="icon-sm"
className="opacity-30 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground" className="transition-opacity text-primary hover:opacity-80"
onClick={() => onOpenEdit(note)} onClick={() => onOpenEdit(note)}
aria-label={`Editar anotação ${displayTitle}`} aria-label={`Editar anotação ${displayTitle}`}
> >
<RiPencilLine className="size-4" /> <RiPencilLine className="size-4" />
</Button> </Button>
<Button <Button
variant="outline" variant="link"
size="icon-sm" size="icon-sm"
className="opacity-30 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground" className="transition-opacity text-primary hover:opacity-80"
onClick={() => onOpenDetails(note)} onClick={() => onOpenDetails(note)}
aria-label={`Ver detalhes da anotação ${displayTitle}`} aria-label={`Ver detalhes da anotação ${displayTitle}`}
> >

View File

@@ -42,7 +42,7 @@ export default function MonthNavigation() {
}; };
return ( return (
<Card className="sticky top-16 z-10 flex w-full flex-row p-4 backdrop-blur-sm bg-card/50"> <Card className="sticky top-16 z-10 flex w-full flex-row p-4">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<NavigationButton <NavigationButton
direction="left" direction="left"

View File

@@ -4,7 +4,6 @@ import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler
import { Logo } from "@/shared/components/logo"; import { Logo } from "@/shared/components/logo";
import { NotificationBell } from "@/shared/components/navigation/navbar/notification-bell"; import { NotificationBell } from "@/shared/components/navigation/navbar/notification-bell";
import { RefreshPageButton } from "@/shared/components/refresh-page-button"; import { RefreshPageButton } from "@/shared/components/refresh-page-button";
import { DotPattern } from "@/shared/components/ui/dot-pattern";
import { NavMenu } from "./nav-menu"; import { NavMenu } from "./nav-menu";
import { NavbarUser } from "./navbar-user"; import { NavbarUser } from "./navbar-user";
@@ -32,14 +31,6 @@ export function AppNavbar({
return ( return (
<header className="fixed top-0 left-0 right-0 z-50 flex h-16 shrink-0 items-center bg-primary"> <header className="fixed top-0 left-0 right-0 z-50 flex h-16 shrink-0 items-center bg-primary">
<div className="pointer-events-none absolute inset-0 overflow-hidden"> <div className="pointer-events-none absolute inset-0 overflow-hidden">
<DotPattern
width={20}
height={20}
cx={1.25}
cy={1.25}
cr={1.25}
className="text-black/5 mask-[linear-gradient(to_right,transparent,black_5%,black_55%,transparent)]"
/>
<div className="absolute inset-0 bg-linear-to-b from-white/8 via-transparent to-black/6" /> <div className="absolute inset-0 bg-linear-to-b from-white/8 via-transparent to-black/6" />
</div> </div>

View File

@@ -12,23 +12,23 @@ export function DashboardMetricsCardsSkeleton() {
{Array.from({ length: 4 }).map((_, index) => ( {Array.from({ length: 4 }).map((_, index) => (
<Card <Card
key={index} key={index}
className="@container/card flex flex-col justify-between min-h-32" className="@container/card min-h-36 justify-between gap-0"
> >
<CardHeader> <CardHeader className="gap-4 pb-3">
<CardTitle className="flex items-center gap-1"> <CardTitle className="flex items-center gap-2">
<Skeleton className="size-4 rounded-md bg-foreground/10" /> <Skeleton className="size-8 rounded-md bg-foreground/10" />
<Skeleton className="h-4 w-20 rounded-md bg-foreground/10" /> <Skeleton className="h-4 w-24 rounded-md bg-foreground/10" />
</CardTitle> </CardTitle>
<div className="flex items-baseline gap-2 mt-auto pt-4"> <div className="flex flex-wrap items-end justify-between gap-3">
<Skeleton className="h-9 w-32 rounded-md bg-foreground/10" /> <Skeleton className="h-10 w-36 rounded-md bg-foreground/10" />
<Skeleton className="h-4 w-12 rounded-md bg-foreground/10" /> <Skeleton className="h-7 w-20 rounded-full bg-foreground/10" />
</div> </div>
</CardHeader> </CardHeader>
<CardFooter className="text-sm"> <CardFooter className="items-start pt-0">
<div className="flex items-center gap-1.5"> <div className="flex flex-col items-start gap-1.5">
<Skeleton className="h-3 w-20 rounded-md bg-foreground/10" /> <Skeleton className="h-3 w-24 rounded-md bg-foreground/10" />
<Skeleton className="h-3 w-16 rounded-md bg-foreground/10" /> <Skeleton className="h-4 w-20 rounded-md bg-foreground/10" />
</div> </div>
</CardFooter> </CardFooter>
</Card> </Card>

View File

@@ -37,7 +37,6 @@ export function formatPercentageChange(value: number | null): string {
return formatPercentage(value, { return formatPercentage(value, {
...formatterOptions, ...formatterOptions,
absolute: true,
signDisplay: value === 0 ? "auto" : "always", signDisplay: value === 0 ? "auto" : "always",
}); });
} }