style(ui): polimento visual — tema, cards, dark mode e landing page

Raio de borda global 0.625rem → 0.7rem; ajustes finos em --card e --border.
DotPattern removido do layout, tela de auth e landing page.
Account-card redesenhado (cores de saldo, tooltip de flags de exclusão).
Budget-card, card-item, calendário (day-cell, event-modal) com layout revisado.
Auth-card-shell simplificado (sem glassmorphism/blob). Landing page com
mainFeatures + extraFeatures em grid único e dark mode nos botões de CTA.
Imagens de preview da landing atualizadas. CSS --data-7..10 removidas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-04-20 17:52:17 +00:00
parent 5d84ae928a
commit 6d81ff8b53
67 changed files with 612 additions and 737 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 176 KiB

View File

@@ -20,22 +20,13 @@ const getSingleParam = (
return Array.isArray(value) ? (value[0] ?? null) : value; return Array.isArray(value) ? (value[0] ?? null) : value;
}; };
const capitalize = (value: string) =>
value.length === 0 ? value : value[0]?.toUpperCase() + value.slice(1);
export default async function Page({ searchParams }: PageProps) { export default async function Page({ searchParams }: PageProps) {
await connection(); await connection();
const userId = await getUserId(); const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined; const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { const { period: selectedPeriod } = parsePeriodParam(periodoParam);
period: selectedPeriod,
monthName: rawMonthName,
year,
} = parsePeriodParam(periodoParam);
const periodLabel = `${capitalize(rawMonthName)} ${year}`;
const { budgets, categoriesOptions } = await fetchBudgetsForUser( const { budgets, categoriesOptions } = await fetchBudgetsForUser(
userId, userId,
@@ -49,7 +40,6 @@ export default async function Page({ searchParams }: PageProps) {
budgets={budgets} budgets={budgets}
categories={categoriesOptions} categories={categoriesOptions}
selectedPeriod={selectedPeriod} selectedPeriod={selectedPeriod}
periodLabel={periodLabel}
/> />
</main> </main>
); );

View File

@@ -1,6 +1,6 @@
import { connection } from "next/server"; import { connection } from "next/server";
import { fetchCategoryHistory } from "@/features/dashboard/categories/category-history-queries"; import { fetchCategoryHistory } from "@/features/dashboard/categories/category-history-queries";
import { CategoryHistoryWidget } from "@/features/dashboard/components/category-history-widget"; import { CategoryHistoryWidget } from "@/features/dashboard/components/widgets/category-history-widget";
import { getUser } from "@/shared/lib/auth/server"; import { getUser } from "@/shared/lib/auth/server";
import { getCurrentPeriod } from "@/shared/utils/period"; import { getCurrentPeriod } from "@/shared/utils/period";

View File

@@ -40,7 +40,9 @@ export default async function Page({ searchParams }: PageProps) {
// Extract query params // Extract query params
const inicioParam = getSingleParam(resolvedSearchParams, "inicio"); const inicioParam = getSingleParam(resolvedSearchParams, "inicio");
const fimParam = getSingleParam(resolvedSearchParams, "fim"); const fimParam = getSingleParam(resolvedSearchParams, "fim");
const categoriasParam = getSingleParam(resolvedSearchParams, "categories"); const categoriasParam =
getSingleParam(resolvedSearchParams, "categorias") ??
getSingleParam(resolvedSearchParams, "categories");
// Calculate default period (last 6 months) // Calculate default period (last 6 months)
const currentPeriod = getCurrentPeriod(); const currentPeriod = getCurrentPeriod();

View File

@@ -30,7 +30,6 @@ import { NavbarShell } from "@/shared/components/navigation/navbar/navbar-shell"
import { Badge } from "@/shared/components/ui/badge"; import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card"; import { Card, CardContent } from "@/shared/components/ui/card";
import { DotPattern } from "@/shared/components/ui/dot-pattern";
import { getOptionalUserSession } from "@/shared/lib/auth/server"; import { getOptionalUserSession } from "@/shared/lib/auth/server";
export default async function Page() { export default async function Page() {
@@ -57,7 +56,7 @@ export default async function Page() {
<a <a
key={href} key={href}
href={href} href={href}
className="rounded-md px-2 py-1.5 text-sm font-medium text-black/75 hover:text-black hover:bg-black/10 transition-colors" className="rounded-md px-2 py-1.5 text-sm font-medium text-black/75 hover:text-black hover:bg-black/10 transition-colors dark:text-white/75 dark:hover:text-white dark:hover:bg-white/10"
> >
{label} {label}
</a> </a>
@@ -70,9 +69,9 @@ export default async function Page() {
(session?.user ? ( (session?.user ? (
<Link prefetch href="/dashboard" className="hidden md:block"> <Link prefetch href="/dashboard" className="hidden md:block">
<Button <Button
variant="outline" variant="navbar"
size="sm" size="sm"
className="border-black/20 text-black/80 bg-transparent hover:bg-black/10 hover:text-black shadow-none" className="border border-black/20 dark:border-white/20"
> >
Dashboard Dashboard
</Button> </Button>
@@ -83,7 +82,7 @@ export default async function Page() {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-black/75 hover:bg-black/10 hover:text-black shadow-none" className="text-black/75 hover:bg-black/10 hover:text-black shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
> >
Entrar Entrar
</Button> </Button>
@@ -91,7 +90,7 @@ export default async function Page() {
<Link href="/signup"> <Link href="/signup">
<Button <Button
size="sm" size="sm"
className="bg-black/10 border border-black/20 text-black shadow-none hover:bg-black/20 gap-2" className="bg-black/10 border border-black/20 text-black shadow-none hover:bg-black/20 gap-2 dark:bg-white/10 dark:border-white/20 dark:text-white dark:hover:bg-white/20"
> >
Começar Começar
</Button> </Button>
@@ -107,18 +106,6 @@ export default async function Page() {
{/* Hero Section */} {/* Hero Section */}
<section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0"> <section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-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="max-w-8xl mx-auto px-4 relative"> <div className="max-w-8xl mx-auto px-4 relative">
<div className="mx-auto flex max-w-4xl flex-col items-center text-center gap-5 md:gap-6 pb-10 md:pb-14"> <div className="mx-auto flex max-w-4xl flex-col items-center text-center gap-5 md:gap-6 pb-10 md:pb-14">
<Badge variant="outline"> <Badge variant="outline">
@@ -265,72 +252,34 @@ export default async function Page() {
</AnimateOnScroll> </AnimateOnScroll>
<AnimateOnScroll> <AnimateOnScroll>
<div className="grid gap-4 md:gap-6 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:gap-4 sm:grid-cols-2 lg:grid-cols-3">
{mainFeatures.map((feature) => ( {[...mainFeatures, ...extraFeatures].map((feature) => (
<Card key={feature.title}> <Card key={feature.title}>
<CardContent className="pt-5 pb-5 md:pt-6"> <CardContent>
<div className="flex flex-col gap-3 md:gap-4"> <div className="flex items-center gap-3 mb-3">
<div <div
className="flex h-11 w-11 md:h-12 md:w-12 items-center justify-center rounded-lg" className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full"
style={{ style={{
backgroundColor: `color-mix(in oklch, ${feature.colorVar} 14%, transparent)`, backgroundColor: `color-mix(in oklch, ${feature.colorVar} 20%, transparent)`,
}} }}
> >
<feature.icon <feature.icon
className="size-[22px] md:size-6" className="size-5"
style={{ color: feature.colorVar }} style={{ color: "var(--foreground)" }}
/> />
</div> </div>
<div> <h3 className="font-semibold text-base leading-tight">
<h3 className="font-semibold text-base md:text-lg mb-1.5 md:mb-2">
{feature.title} {feature.title}
</h3> </h3>
</div>
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
{feature.description} {feature.description}
</p> </p>
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
</AnimateOnScroll> </AnimateOnScroll>
<AnimateOnScroll>
<div className="mt-8 md:mt-12">
<h3 className="text-sm font-semibold text-center mb-4 md:mb-6 text-muted-foreground">
Também inclui
</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{extraFeatures.map((feature) => (
<div
key={feature.title}
className="flex items-start gap-3 rounded-lg border bg-card p-3 md:p-4"
>
<div
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md"
style={{
backgroundColor: `color-mix(in oklch, ${feature.colorVar} 14%, transparent)`,
}}
>
<feature.icon
className="size-[18px]"
style={{ color: feature.colorVar }}
/>
</div>
<div className="min-w-0">
<h4 className="font-semibold text-sm mb-0.5">
{feature.title}
</h4>
<p className="text-xs text-muted-foreground leading-relaxed">
{feature.description}
</p>
</div>
</div>
))}
</div>
</div>
</AnimateOnScroll>
</div> </div>
</div> </div>
</section> </section>
@@ -396,14 +345,14 @@ export default async function Page() {
{pwaHighlights.map((item) => ( {pwaHighlights.map((item) => (
<li key={item.title} className="flex items-start gap-3"> <li key={item.title} className="flex items-start gap-3">
<div <div
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md" className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
style={{ style={{
backgroundColor: `color-mix(in oklch, ${item.colorVar} 14%, transparent)`, backgroundColor: `color-mix(in oklch, ${item.colorVar} 20%, transparent)`,
}} }}
> >
<item.icon <item.icon
className="size-[15px]" className="size-[15px]"
style={{ color: item.colorVar }} style={{ color: "var(--foreground)" }}
/> />
</div> </div>
<p className="text-sm"> <p className="text-sm">
@@ -438,17 +387,19 @@ export default async function Page() {
pré-lançamentos automaticamente para você revisar na inbox. pré-lançamentos automaticamente para você revisar na inbox.
</p> </p>
<ol className="space-y-3 mb-6"> <ol className="space-y-3 mb-6">
{companionSteps.map((step, index) => ( {companionSteps.map((step) => (
<li key={step.title} className="flex items-start gap-3"> <li key={step.title} className="flex items-start gap-3">
<span <div
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-medium" className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
style={{ style={{
backgroundColor: `color-mix(in oklch, ${step.colorVar} 14%, transparent)`, backgroundColor: `color-mix(in oklch, ${step.colorVar} 20%, transparent)`,
color: step.colorVar,
}} }}
> >
{index + 1} <step.icon
</span> className="size-3.5"
style={{ color: "var(--foreground)" }}
/>
</div>
<p className="text-sm"> <p className="text-sm">
<span className="font-medium">{step.title}</span> <span className="font-medium">{step.title}</span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
@@ -545,14 +496,14 @@ export default async function Page() {
<CardContent> <CardContent>
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div <div
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg" className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full"
style={{ style={{
backgroundColor: `color-mix(in oklch, ${item.colorVar} 14%, transparent)`, backgroundColor: `color-mix(in oklch, ${item.colorVar} 20%, transparent)`,
}} }}
> >
<item.icon <item.icon
className="size-6" className="size-6"
style={{ color: item.colorVar }} style={{ color: "var(--foreground)" }}
/> />
</div> </div>
<div> <div>
@@ -633,14 +584,14 @@ export default async function Page() {
<CardContent> <CardContent>
<div className="flex gap-3 md:gap-4"> <div className="flex gap-3 md:gap-4">
<div <div
className="flex h-9 w-9 md:h-10 md:w-10 shrink-0 items-center justify-center rounded-lg" className="flex h-9 w-9 md:h-10 md:w-10 shrink-0 items-center justify-center rounded-full"
style={{ style={{
backgroundColor: `color-mix(in oklch, ${item.colorVar} 14%, transparent)`, backgroundColor: `color-mix(in oklch, ${item.colorVar} 20%, transparent)`,
}} }}
> >
<item.icon <item.icon
className="size-[18px] md:size-5" className="size-[18px] md:size-5"
style={{ color: item.colorVar }} style={{ color: "var(--foreground)" }}
/> />
</div> </div>
<div> <div>

View File

@@ -10,7 +10,7 @@
:root { :root {
--background: oklch(97.412% 0.00332 67.032); --background: oklch(97.412% 0.00332 67.032);
--foreground: oklch(27% 0.008 45); --foreground: oklch(27% 0.008 45);
--card: oklch(99% 0.002 67); --card: oklch(100% 0 0);
--card-foreground: var(--foreground); --card-foreground: var(--foreground);
--popover: oklch(100% 0 0); --popover: oklch(100% 0 0);
--popover-foreground: var(--foreground); --popover-foreground: var(--foreground);
@@ -36,7 +36,7 @@
--destructive: oklch(55% 0.22 27); --destructive: oklch(55% 0.22 27);
--destructive-foreground: oklch(98% 0.005 30); --destructive-foreground: oklch(98% 0.005 30);
--border: oklch(90.274% 0.01362 60.342); --border: oklch(92.323% 0.01276 63.703);
--input: var(--border); --input: var(--border);
--ring: var(--primary); --ring: var(--primary);
@@ -57,10 +57,6 @@
--data-4: oklch(74% 0.18 55); /* âmbar */ --data-4: oklch(74% 0.18 55); /* âmbar */
--data-5: oklch(78% 0.16 68); /* âmbar-dourado */ --data-5: oklch(78% 0.16 68); /* âmbar-dourado */
--data-6: oklch(76% 0.15 82); /* amarelo-quente */ --data-6: oklch(76% 0.15 82); /* amarelo-quente */
--data-7: oklch(70% 0.17 95); /* amarelo-lima */
--data-8: oklch(65% 0.18 108); /* lima-verde */
--data-9: oklch(62% 0.17 120); /* verde-oliva claro */
--data-10: oklch(56% 0.15 10); /* terracota escuro */
--sidebar: oklch(99.3% 0.0015 75); --sidebar: oklch(99.3% 0.0015 75);
--sidebar-foreground: var(--foreground); --sidebar-foreground: var(--foreground);
@@ -71,7 +67,7 @@
--sidebar-border: oklch(91% 0.004 70); --sidebar-border: oklch(91% 0.004 70);
--sidebar-ring: var(--primary); --sidebar-ring: var(--primary);
--radius: 0.625rem; --radius: 0.7rem;
--shadow-2xs: 0 1px 2px 0px oklch(35% 0.02 45 / 0.04); --shadow-2xs: 0 1px 2px 0px oklch(35% 0.02 45 / 0.04);
--shadow-xs: 0 1px 3px 0px oklch(35% 0.02 45 / 0.06); --shadow-xs: 0 1px 3px 0px oklch(35% 0.02 45 / 0.06);
@@ -94,7 +90,7 @@
.dark { .dark {
--background: oklch(18% 0.004 55); --background: oklch(18% 0.004 55);
--foreground: oklch(93% 0.008 80); --foreground: oklch(93% 0.008 80);
--card: oklch(21.5% 0.004 55); --card: oklch(21.531% 0.00369 48.293);
--card-foreground: var(--foreground); --card-foreground: var(--foreground);
--popover: oklch(24% 0.004 55); --popover: oklch(24% 0.004 55);
--popover-foreground: var(--foreground); --popover-foreground: var(--foreground);
@@ -120,7 +116,7 @@
--destructive: oklch(62% 0.2 28); --destructive: oklch(62% 0.2 28);
--destructive-foreground: oklch(98% 0.005 30); --destructive-foreground: oklch(98% 0.005 30);
--border: oklch(31% 0.004 55); --border: oklch(28% 0.0035 55);
--input: var(--border); --input: var(--border);
--ring: var(--primary); --ring: var(--primary);
@@ -141,10 +137,6 @@
--data-4: oklch(81% 0.18 55); --data-4: oklch(81% 0.18 55);
--data-5: oklch(84% 0.16 68); --data-5: oklch(84% 0.16 68);
--data-6: oklch(82% 0.15 82); --data-6: oklch(82% 0.15 82);
--data-7: oklch(77% 0.17 95);
--data-8: oklch(72% 0.18 108);
--data-9: oklch(69% 0.17 120);
--data-10: oklch(63% 0.15 10);
--sidebar: oklch(15.5% 0.004 55); --sidebar: oklch(15.5% 0.004 55);
--sidebar-foreground: var(--foreground); --sidebar-foreground: var(--foreground);
@@ -155,7 +147,7 @@
--sidebar-border: oklch(30% 0.004 55); --sidebar-border: oklch(30% 0.004 55);
--sidebar-ring: var(--primary); --sidebar-ring: var(--primary);
--radius: 0.625rem; --radius: 0.7rem;
--shadow-2xs: 0 1px 2px 0px oklch(0% 0 0 / 0.3); --shadow-2xs: 0 1px 2px 0px oklch(0% 0 0 / 0.3);
--shadow-xs: 0 1px 3px 0px oklch(0% 0 0 / 0.4); --shadow-xs: 0 1px 3px 0px oklch(0% 0 0 / 0.4);

View File

@@ -1,4 +1,5 @@
"use client"; "use client";
import { import {
RiArrowLeftRightLine, RiArrowLeftRightLine,
RiDeleteBin5Line, RiDeleteBin5Line,
@@ -47,6 +48,13 @@ export function AccountCard({
}: AccountCardProps) { }: AccountCardProps) {
const isInactive = status?.toLowerCase() === "inativa"; const isInactive = status?.toLowerCase() === "inativa";
const balanceColor =
balance > 0
? "text-success"
: balance < 0
? "text-destructive"
: "text-foreground";
const actions = [ const actions = [
{ {
label: "editar", label: "editar",
@@ -75,36 +83,39 @@ export function AccountCard({
].filter((action) => typeof action.onClick === "function"); ].filter((action) => typeof action.onClick === "function");
return ( return (
<Card className={cn("h-full w-full gap-0", className)}> <Card className={cn("flex w-full flex-col p-6", className)}>
<CardContent className="flex flex-1 flex-col gap-4"> <div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
{icon ? (
<div <div
className={cn( className={cn(
"flex items-center justify-center", "flex shrink-0 items-center justify-center",
isInactive && "[&_img]:grayscale [&_img]:opacity-40", isInactive && "grayscale opacity-40",
)} )}
> >
{icon} {icon}
</div> </div>
) : null} <div className="min-w-0">
<h2 className="text-lg font-semibold text-foreground"> <div className="flex items-center gap-1">
<h3 className="truncate font-semibold text-foreground">
{accountName} {accountName}
</h2> </h3>
{excludeFromBalance || excludeInitialBalanceFromIncome ? (
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center"> <button
<RiInformationLine className="size-5 text-muted-foreground hover:text-foreground transition-colors cursor-help" /> type="button"
</div> className="shrink-0 text-muted-foreground/70 transition-colors hover:text-foreground"
aria-label="Informações da conta"
>
<RiInformationLine className="size-3.5" />
</button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" className="max-w-xs"> <TooltipContent side="top" align="start" className="max-w-xs">
<div className="space-y-1"> <div className="space-y-1">
{excludeFromBalance && ( {excludeFromBalance && (
<p className="text-xs"> <p className="text-xs">
<strong>Desconsiderado do saldo total:</strong> Esta conta <strong>Desconsiderado do saldo total:</strong> Esta
não é incluída no cálculo do saldo total geral. conta não é incluída no cálculo do saldo total geral.
</p> </p>
)} )}
{excludeInitialBalanceFromIncome && ( {excludeInitialBalanceFromIncome && (
@@ -119,17 +130,27 @@ export function AccountCard({
</div> </div>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} ) : null}
</div> </div>
<div className="space-y-2"> <p className="text-xs text-muted-foreground">{status}</p>
<MoneyValues amount={balance} className="text-3xl" /> </div>
<p className="text-sm text-muted-foreground">{accountType}</p> </div>
<p className="text-xs text-muted-foreground">{accountType}</p>
</div>
<CardContent className="flex flex-1 flex-col gap-2 px-0 pb-2">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Saldo</span>
<MoneyValues
amount={balance}
className={cn("text-2xl font-semibold", balanceColor)}
/>
</div> </div>
</CardContent> </CardContent>
{actions.length > 0 ? ( <CardFooter className="flex flex-wrap gap-4 p-0 text-sm">
<CardFooter className="flex flex-wrap gap-3 px-6 pt-6 text-sm">
{actions.map(({ label, icon, onClick, variant }) => ( {actions.map(({ label, icon, onClick, variant }) => (
<button <button
key={label} key={label}
@@ -146,7 +167,6 @@ export function AccountCard({
</button> </button>
))} ))}
</CardFooter> </CardFooter>
) : null}
</Card> </Card>
); );
} }

View File

@@ -86,7 +86,7 @@ export function AccountStatementCard({
</p> </p>
<MoneyValues <MoneyValues
amount={currentBalance} amount={currentBalance}
className="text-3xl leading-none tracking-tighter sm:text-[2rem]" className="text-3xl leading-none tracking-tighter sm:text-2xl"
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge <Badge

View File

@@ -8,7 +8,7 @@ import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id"; import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
export type AccountSummaryData = { type AccountSummaryData = {
openingBalance: number; openingBalance: number;
currentBalance: number; currentBalance: number;
totalIncomes: number; totalIncomes: number;

View File

@@ -1,28 +1,12 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { Card, CardContent } from "@/shared/components/ui/card"; import { Card, CardContent } from "@/shared/components/ui/card";
import { DotPattern } from "@/shared/components/ui/dot-pattern";
import AuthSidebar from "./auth-sidebar"; import AuthSidebar from "./auth-sidebar";
export function AuthCardShell({ children }: PropsWithChildren) { export function AuthCardShell({ children }: PropsWithChildren) {
return ( return (
<Card className="relative overflow-hidden rounded-2xl md:rounded-[2rem] p-0 shadow-lg border-primary/10"> <Card className="overflow-hidden border-primary/10 p-0 shadow-lg">
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]"> <CardContent className="grid p-0 md:min-h-[640px] md:grid-cols-[1.05fr_0.95fr]">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,var(--color-primary)_0%,transparent_70%)] opacity-10 blur-3xl animate-blob mix-blend-multiply" /> <div className="flex md:rounded-l-4xl">{children}</div>
<DotPattern
width={17}
height={17}
cx={1.3}
cy={1.3}
cr={1.3}
className="text-primary/8 mask-[linear-gradient(to_bottom,black,transparent_84%)]"
/>
<div className="absolute inset-0 bg-linear-to-br from-primary/6 via-transparent to-transparent opacity-80" />
</div>
<CardContent className="relative z-10 grid gap-0 p-0 md:min-h-[640px] md:grid-cols-[1.05fr_0.95fr] overflow-hidden rounded-[inherit]">
<div className="flex bg-card/60 backdrop-blur-xl md:rounded-l-[2rem]">
{children}
</div>
<AuthSidebar /> <AuthSidebar />
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -15,7 +15,6 @@ import type { Budget } from "./types";
interface BudgetCardProps { interface BudgetCardProps {
budget: Budget; budget: Budget;
periodLabel: string;
onEdit: (budget: Budget) => void; onEdit: (budget: Budget) => void;
onRemove: (budget: Budget) => void; onRemove: (budget: Budget) => void;
} }
@@ -29,81 +28,88 @@ const buildUsagePercent = (spent: number, limit: number) => {
}; };
const formatCategoryName = (budget: Budget) => const formatCategoryName = (budget: Budget) =>
budget.category?.name ?? "Category removida"; budget.category?.name ?? "Categoria removida";
export function BudgetCard({ export function BudgetCard({ budget, onEdit, onRemove }: BudgetCardProps) {
budget,
periodLabel,
onEdit,
onRemove,
}: BudgetCardProps) {
const { amount: limit, spent } = budget; const { amount: limit, spent } = budget;
const exceeded = spent > limit && limit >= 0; const exceeded = spent > limit && limit >= 0;
const difference = Math.abs(spent - limit); const difference = Math.abs(spent - limit);
const usagePercent = buildUsagePercent(spent, limit); const usagePercent = buildUsagePercent(spent, limit);
const remaining = Math.max(limit - spent, 0);
return ( return (
<Card className="flex h-full flex-col"> <Card className="flex w-full flex-col p-6">
<CardContent className="flex h-full flex-col gap-4"> <div className="flex items-center gap-2">
<div className="flex items-start gap-3">
<CategoryIconBadge <CategoryIconBadge
icon={budget.category?.icon ?? undefined} icon={budget.category?.icon ?? undefined}
name={formatCategoryName(budget)} name={formatCategoryName(budget)}
size="lg" size="lg"
/> />
<div className="space-y-1"> <div className="min-w-0">
<h3 className="text-base font-semibold leading-tight"> <h3 className="truncate font-semibold text-foreground">
{formatCategoryName(budget)} {formatCategoryName(budget)}
</h3> </h3>
<p className="text-xs text-muted-foreground">
Orçamento de {periodLabel}
</p>
</div> </div>
</div> </div>
<div className="flex flex-1 flex-col gap-2"> <CardContent className="flex flex-1 flex-col gap-4 p-0">
<div className="flex items-baseline justify-between text-sm"> <div className="flex flex-col gap-0.5">
<span className="text-muted-foreground">Gasto até agora</span> <span className="text-xs text-muted-foreground">
{exceeded ? "Excedido em" : "Disponível"}
</span>
<MoneyValues
amount={exceeded ? difference : remaining}
className={cn(
"text-xl font-semibold",
exceeded ? "text-destructive" : "text-success",
)}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Orçamento</span>
<MoneyValues
amount={limit}
className="text-sm font-semibold text-foreground"
/>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">Gasto</span>
<MoneyValues <MoneyValues
amount={spent} amount={spent}
className={cn(exceeded && "text-destructive")} className={cn(
"text-sm font-semibold",
exceeded ? "text-destructive" : "text-primary",
)}
/> />
</div> </div>
<Progress
value={usagePercent}
className={cn("h-2", exceeded && "bg-destructive/20!")}
/>
<div className="flex flex-wrap items-baseline justify-between gap-1 text-sm">
<span className="text-muted-foreground">Limite</span>
<MoneyValues amount={limit} className="text-foreground" />
</div> </div>
<div> <div className="flex flex-col gap-2">
{exceeded ? ( <Progress
<div className="text-xs text-destructive"> value={usagePercent}
Excedeu em <MoneyValues amount={difference} /> className={cn("h-2.5", exceeded && "bg-destructive/20!")}
</div> aria-label={`${usagePercent.toFixed(1)}% do orçamento utilizado`}
) : ( />
<div className="text-xs text-success"> <span className="text-xs text-muted-foreground">
Restam <MoneyValues amount={Math.max(limit - spent, 0)} />{" "} {usagePercent.toFixed(1)}% utilizado
disponíveis. </span>
</div>
)}
</div>
</div> </div>
</CardContent> </CardContent>
<CardFooter className="flex flex-wrap gap-3 px-5 text-sm">
<CardFooter className="mt-auto flex flex-wrap gap-4 px-0 pt-2 text-sm">
<button <button
type="button" type="button"
onClick={() => onEdit(budget)} onClick={() => onEdit(budget)}
className="flex items-center gap-1 text-primary font-medium transition-opacity hover:opacity-80" className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
> >
<RiPencilLine className="size-4" aria-hidden /> editar <RiPencilLine className="size-4" aria-hidden /> editar
</button> </button>
{budget.category && ( {budget.category && (
<Link <Link
href={`/categories/${budget.category.id}`} href={`/categories/${budget.category.id}`}
className="flex items-center gap-1 text-primary font-medium transition-opacity hover:opacity-80" className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
> >
<RiFileList2Line className="size-4" aria-hidden /> detalhes <RiFileList2Line className="size-4" aria-hidden /> detalhes
</Link> </Link>
@@ -111,7 +117,7 @@ export function BudgetCard({
<button <button
type="button" type="button"
onClick={() => onRemove(budget)} onClick={() => onRemove(budget)}
className="flex items-center gap-1 text-destructive font-medium transition-opacity hover:opacity-80" className="flex items-center gap-1 font-medium text-destructive transition-opacity hover:opacity-80"
> >
<RiDeleteBin5Line className="size-4" aria-hidden /> remover <RiDeleteBin5Line className="size-4" aria-hidden /> remover
</button> </button>

View File

@@ -19,14 +19,12 @@ interface BudgetsPageProps {
budgets: Budget[]; budgets: Budget[];
categories: BudgetCategory[]; categories: BudgetCategory[];
selectedPeriod: string; selectedPeriod: string;
periodLabel: string;
} }
export function BudgetsPage({ export function BudgetsPage({
budgets, budgets,
categories, categories,
selectedPeriod, selectedPeriod,
periodLabel,
}: BudgetsPageProps) { }: BudgetsPageProps) {
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [selectedBudget, setSelectedBudget] = useState<Budget | null>(null); const [selectedBudget, setSelectedBudget] = useState<Budget | null>(null);
@@ -137,7 +135,6 @@ export function BudgetsPage({
<BudgetCard <BudgetCard
key={budget.id} key={budget.id}
budget={budget} budget={budget}
periodLabel={periodLabel}
onEdit={handleEdit} onEdit={handleEdit}
onRemove={handleRemoveRequest} onRemove={handleRemoveRequest}
/> />

View File

@@ -13,7 +13,7 @@ const toNumber = (value: string | number | null | undefined) => {
return 0; return 0;
}; };
export type BudgetData = { type BudgetData = {
id: string; id: string;
amount: number; amount: number;
spent: number; spent: number;

View File

@@ -1,10 +1,8 @@
"use client"; "use client";
import { DayCell } from "@/features/calendar/components/day-cell"; import { DayCell } from "@/features/calendar/components/day-cell";
import type { CalendarDay } from "@/shared/lib/types/calendar"; import type { CalendarDay } from "@/shared/lib/types/calendar";
import { WEEK_DAYS_SHORT } from "@/shared/utils/calendar"; import { WEEK_DAYS_SHORT } from "@/shared/utils/calendar";
import { cn } from "@/shared/utils/ui";
type CalendarGridProps = { type CalendarGridProps = {
days: CalendarDay[]; days: CalendarDay[];
@@ -18,21 +16,18 @@ export function CalendarGrid({
onCreateDay, onCreateDay,
}: CalendarGridProps) { }: CalendarGridProps) {
return ( return (
<div className="overflow-hidden rounded-lg bg-card drop-shadow-xs border-none"> <div className="overflow-hidden rounded-lg border p-2">
<div className="grid grid-cols-7 text-sm font-semibold uppercase tracking-wide text-muted-foreground"> <div className="grid grid-cols-7 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{WEEK_DAYS_SHORT.map((dayName) => ( {WEEK_DAYS_SHORT.map((dayName) => (
<span key={dayName} className="px-3 py-2 text-center"> <span key={dayName} className="text-center">
{dayName} {dayName}
</span> </span>
))} ))}
</div> </div>
<div className="grid grid-cols-7 gap-px bg-border/60 px-px pb-px pt-px"> <div className="grid grid-cols-7 gap-px px-px pb-px pt-px">
{days.map((day) => ( {days.map((day) => (
<div <div key={day.date} className="h-[150px] p-0.5">
key={day.date}
className={cn("h-[150px] bg-card p-0.5", !day.isCurrentMonth && "")}
>
<DayCell day={day} onSelect={onSelectDay} onCreate={onCreateDay} /> <DayCell day={day} onSelect={onSelectDay} onCreate={onCreateDay} />
</div> </div>
))} ))}

View File

@@ -1,34 +1,32 @@
"use client"; "use client";
import { EVENT_TYPE_STYLES } from "@/features/calendar/components/day-cell"; import { EVENT_TYPE_STYLES } from "@/features/calendar/components/day-cell";
import StatusDot from "@/shared/components/status-dot"; import { cn } from "@/shared/utils/ui";
import { Card } from "@/shared/components/ui/card";
import type { CalendarEvent } from "@/shared/lib/types/calendar";
const LEGEND_ITEMS: Array<{ const LEGEND_ITEMS = [
type?: CalendarEvent["type"]; { label: "Lançamentos", ...EVENT_TYPE_STYLES.transaction },
label: string; { label: "Boletos", ...EVENT_TYPE_STYLES.boleto },
dotColor?: string; { label: "Fatura de Cartão", ...EVENT_TYPE_STYLES.card },
}> = [
{ type: "transaction", label: "Lançamentos" },
{ type: "boleto", label: "Boleto com vencimento" },
{ type: "card", label: "Vencimento de cartão" },
{ label: "Pagamento fatura", dotColor: "bg-success" },
]; ];
export function CalendarLegend() { export function CalendarLegend() {
return ( return (
<Card className="flex flex-row gap-2 p-2 text-sm"> <ul className="flex items-center justify-start gap-2 px-1">
{LEGEND_ITEMS.map((item, index) => { {LEGEND_ITEMS.map((item) => (
const dotColor = <li
item.dotColor || (item.type ? EVENT_TYPE_STYLES[item.type].dot : ""); key={item.label}
return ( className={cn(
<span key={item.type || index} className="flex items-center gap-2"> "flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium",
<StatusDot color={dotColor} /> item.wrapper,
)}
>
<span
className={cn("size-1.5 shrink-0 rounded-full", item.dot)}
aria-hidden
/>
{item.label} {item.label}
</span> </li>
); ))}
})} </ul>
</Card>
); );
} }

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { RiAddLine } from "@remixicon/react"; import { RiAddLine, RiCheckboxCircleFill } from "@remixicon/react";
import type { KeyboardEvent, MouseEvent } from "react"; import type { KeyboardEvent, MouseEvent } from "react";
import type { CalendarDay, CalendarEvent } from "@/shared/lib/types/calendar"; import type { CalendarDay, CalendarEvent } from "@/shared/lib/types/calendar";
import { currencyFormatter } from "@/shared/utils/currency"; import { currencyFormatter } from "@/shared/utils/currency";
@@ -14,44 +14,33 @@ type DayCellProps = {
export const EVENT_TYPE_STYLES: Record< export const EVENT_TYPE_STYLES: Record<
CalendarEvent["type"], CalendarEvent["type"],
{ wrapper: string; dot: string; accent?: string } { wrapper: string; dot: string }
> = { > = {
transaction: { transaction: {
wrapper: wrapper: "bg-primary/10 text-primary dark:bg-primary/5 dark:text-primary",
"bg-warning/10 text-warning dark:bg-warning/5 dark:text-warning border-l-4 border-warning", dot: "bg-primary",
dot: "bg-warning",
}, },
boleto: { boleto: {
wrapper: wrapper: "bg-info/10 text-info dark:bg-info/5 dark:text-info",
"bg-info/10 text-info dark:bg-info/5 dark:text-info border-l-4 border-info",
dot: "bg-info", dot: "bg-info",
}, },
card: { card: {
wrapper: wrapper:
"bg-violet-100 text-violet-600 dark:bg-violet-900/10 dark:text-violet-50 border-l-4 border-violet-500", "bg-violet-100 text-violet-600 dark:bg-violet-900/10 dark:text-violet-500",
dot: "bg-violet-600", dot: "bg-violet-600 dark:bg-violet-500",
}, },
}; };
const eventStyles = EVENT_TYPE_STYLES;
const formatCurrencyValue = (value: number | null | undefined) => const formatCurrencyValue = (value: number | null | undefined) =>
currencyFormatter.format(Math.abs(value ?? 0)); currencyFormatter.format(Math.abs(value ?? 0));
const formatAmount = (event: Extract<CalendarEvent, { type: "transaction" }>) =>
formatCurrencyValue(event.transaction.amount);
const buildEventLabel = (event: CalendarEvent) => { const buildEventLabel = (event: CalendarEvent) => {
switch (event.type) { switch (event.type) {
case "transaction": { case "transaction":
case "boleto":
return event.transaction.name; return event.transaction.name;
} case "card":
case "boleto": {
return event.transaction.name;
}
case "card": {
return event.card.name; return event.card.name;
}
default: default:
return ""; return "";
} }
@@ -59,60 +48,48 @@ const buildEventLabel = (event: CalendarEvent) => {
const buildEventComplement = (event: CalendarEvent) => { const buildEventComplement = (event: CalendarEvent) => {
switch (event.type) { switch (event.type) {
case "transaction": { case "transaction":
return formatAmount(event); case "boleto":
}
case "boleto": {
return formatCurrencyValue(event.transaction.amount); return formatCurrencyValue(event.transaction.amount);
} case "card":
case "card": { return event.card.totalDue !== null
if (event.card.totalDue !== null) { ? formatCurrencyValue(event.card.totalDue)
return formatCurrencyValue(event.card.totalDue); : null;
}
return null;
}
default: default:
return null; return null;
} }
}; };
const isPagamentoFatura = (event: CalendarEvent) => { const isPaid = (event: CalendarEvent) => {
return ( if (event.type === "boleto") return Boolean(event.transaction.isSettled);
event.type === "transaction" && if (event.type === "card") return event.card.isPaid;
event.transaction.name.startsWith("Pagamento fatura -") return false;
);
};
const getEventStyle = (event: CalendarEvent) => {
if (isPagamentoFatura(event)) {
return {
wrapper:
"bg-success/10 text-success dark:bg-success/5 dark:text-success border-l-4 border-success",
dot: "bg-success",
};
}
return eventStyles[event.type];
}; };
const DayEventPreview = ({ event }: { event: CalendarEvent }) => { const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
const complement = buildEventComplement(event); const complement = buildEventComplement(event);
const label = buildEventLabel(event); const label = buildEventLabel(event);
const style = getEventStyle(event); const style = EVENT_TYPE_STYLES[event.type];
return ( return (
<div <div
className={cn( className={cn(
"flex w-full items-center justify-between gap-2 rounded p-1 text-xs", "flex w-full items-center justify-between gap-2 rounded-md px-2 py-1 text-xs",
style.wrapper, style.wrapper,
)} )}
> >
<div className="flex min-w-0 items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
<span
className={cn("size-1.5 shrink-0 rounded-full", style.dot)}
aria-hidden
/>
<span className="truncate">{label}</span> <span className="truncate">{label}</span>
{isPaid(event) && (
<RiCheckboxCircleFill className="size-3.5 shrink-0 text-success" />
)}
</div> </div>
{complement ? ( {complement ? (
<span className={cn("shrink-0 font-medium", style.accent ?? "text-xs")}> <span className="shrink-0 font-medium">{complement}</span>
{complement}
</span>
) : null} ) : null}
</div> </div>
); );
@@ -143,8 +120,8 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
onClick={() => onSelect(day)} onClick={() => onSelect(day)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className={cn( className={cn(
"group flex h-full cursor-pointer flex-col gap-1.5 rounded-lg border border-transparent bg-card/80 p-2 text-left transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-accent", "group flex h-full cursor-pointer flex-col gap-1.5 rounded-lg border bg-card/80 p-2 text-left transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-accent",
!day.isCurrentMonth && "opacity-60", !day.isCurrentMonth && "bg-muted/20 opacity-60",
day.isToday && "border-primary/70 bg-primary/5 hover:border-primary", day.isToday && "border-primary/70 bg-primary/5 hover:border-primary",
)} )}
> >
@@ -159,6 +136,7 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
> >
{day.label} {day.label}
</span> </span>
{day.isCurrentMonth && (
<button <button
type="button" type="button"
onClick={handleCreateClick} onClick={handleCreateClick}
@@ -167,6 +145,7 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
> >
<RiAddLine className="size-3.5" /> <RiAddLine className="size-3.5" />
</button> </button>
)}
</div> </div>
<div className="flex flex-1 flex-col gap-1.5"> <div className="flex flex-1 flex-col gap-1.5">

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { RiCalendarEventLine } from "@remixicon/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { EVENT_TYPE_STYLES } from "@/features/calendar/components/day-cell"; import { EVENT_TYPE_STYLES } from "@/features/calendar/components/day-cell";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
@@ -29,17 +30,13 @@ type EventModalProps = {
const EventCard = ({ const EventCard = ({
children, children,
type, type,
isPagamentoFatura = false,
}: { }: {
children: ReactNode; children: ReactNode;
type: CalendarEvent["type"]; type: CalendarEvent["type"];
isPagamentoFatura?: boolean;
}) => { }) => {
const style = isPagamentoFatura const style = EVENT_TYPE_STYLES[type];
? { dot: "bg-success" }
: EVENT_TYPE_STYLES[type];
return ( return (
<Card className="flex flex-row gap-2 p-3 mb-1"> <Card className="flex flex-row gap-2 p-3">
<span <span
className={cn("mt-1 size-3 shrink-0 rounded-full", style.dot)} className={cn("mt-1 size-3 shrink-0 rounded-full", style.dot)}
aria-hidden aria-hidden
@@ -49,41 +46,34 @@ const EventCard = ({
); );
}; };
const DATE_FORMAT: Intl.DateTimeFormatOptions = {
day: "2-digit",
month: "2-digit",
year: "numeric",
};
const renderLancamento = ( const renderLancamento = (
event: Extract<CalendarEvent, { type: "transaction" }>, event: Extract<CalendarEvent, { type: "transaction" }>,
) => { ) => {
const isReceita = event.transaction.transactionType === "Receita"; const isReceita = event.transaction.transactionType === "Receita";
const isPagamentoFatura =
event.transaction.name.startsWith("Pagamento fatura -");
return ( return (
<EventCard type="transaction" isPagamentoFatura={isPagamentoFatura}> <EventCard type="transaction">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span <span className="text-sm font-medium leading-tight">
className={`text-sm font-medium leading-tight ${
isPagamentoFatura && "text-success"
}`}
>
{event.transaction.name} {event.transaction.name}
</span> </span>
<Badge variant="outline">{event.transaction.categoriaName}</Badge>
<div className="flex gap-1">
<Badge variant={"outline"}>{event.transaction.categoriaName}</Badge>
</div> </div>
</div>
<span
className={cn(
"text-sm font-medium whitespace-nowrap",
isReceita ? "text-success" : "text-foreground",
)}
>
<MoneyValues <MoneyValues
showPositiveSign showPositiveSign
className="text-base" className={cn(
"text-base whitespace-nowrap font-medium",
isReceita ? "text-success" : "text-foreground",
)}
amount={event.transaction.amount} amount={event.transaction.amount}
/> />
</span>
</div> </div>
</EventCard> </EventCard>
); );
@@ -91,59 +81,80 @@ const renderLancamento = (
const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => { const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
const isPaid = Boolean(event.transaction.isSettled); const isPaid = Boolean(event.transaction.isSettled);
const dueDate = event.transaction.dueDate; const dueDateLabel = formatFinancialDateLabel(
const dueDateLabel = formatFinancialDateLabel(dueDate, "Vence em", { event.transaction.dueDate,
day: "2-digit", "Vence em",
month: "2-digit", DATE_FORMAT,
year: "numeric", );
}); const paymentDateLabel = isPaid
? formatFinancialDateLabel(
event.transaction.boletoPaymentDate,
"Pago em",
DATE_FORMAT,
)
: null;
return ( return (
<EventCard type="boleto"> <EventCard type="boleto">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex gap-1 items-center">
<span className="text-sm font-medium leading-tight"> <span className="text-sm font-medium leading-tight">
{event.transaction.name} {event.transaction.name}
</span> </span>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs">
{dueDateLabel && ( {dueDateLabel && (
<span className="text-xs text-muted-foreground leading-tight"> <span className="text-muted-foreground">{dueDateLabel}</span>
{dueDateLabel} )}
</span> {paymentDateLabel && (
<span className="text-success">{paymentDateLabel}</span>
)} )}
</div> </div>
<Badge variant="outline">{isPaid ? "Pago" : "Pendente"}</Badge>
<Badge variant={"outline"}>{isPaid ? "Pago" : "Pendente"}</Badge>
</div> </div>
<span className="font-medium"> <MoneyValues
<MoneyValues amount={event.transaction.amount} /> className="font-medium whitespace-nowrap"
</span> amount={event.transaction.amount}
/>
</div> </div>
</EventCard> </EventCard>
); );
}; };
const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => ( const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => {
const paymentDateLabel = event.card.isPaid
? formatFinancialDateLabel(event.card.paymentDate, "Pago em", DATE_FORMAT)
: null;
return (
<EventCard type="card"> <EventCard type="card">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex gap-1 items-center">
<span className="text-sm font-medium leading-tight"> <span className="text-sm font-medium leading-tight">
Vencimento Fatura - {event.card.name} Vencimento Fatura {event.card.name}
</span> </span>
</div> {paymentDateLabel && (
<span className="text-xs text-success">{paymentDateLabel}</span>
<Badge variant={"outline"}>{event.card.status ?? "Invoice"}</Badge> )}
<Badge variant="outline">
{event.card.isPaid ? "Pago" : (event.card.status ?? "Fatura")}
</Badge>
</div> </div>
{event.card.totalDue !== null ? ( {event.card.totalDue !== null ? (
<span className="font-medium"> <MoneyValues
<MoneyValues amount={event.card.totalDue} /> className="font-medium whitespace-nowrap"
</span> amount={event.card.totalDue}
/>
) : null} ) : null}
</div> </div>
</EventCard> </EventCard>
); );
};
const SECTION_LABELS: Record<CalendarEvent["type"], string> = {
transaction: "Lançamentos",
boleto: "Boletos",
card: "Faturas",
};
const renderEvent = (event: CalendarEvent) => { const renderEvent = (event: CalendarEvent) => {
switch (event.type) { switch (event.type) {
@@ -169,28 +180,50 @@ export function EventModal({ open, day, onClose, onCreate }: EventModalProps) {
onCreate(day.date); onCreate(day.date);
}; };
const description = day?.events.length const hasEvents = Boolean(day?.events.length);
? "Confira os lançamentos e vencimentos cadastrados para este dia."
: "Nenhum lançamento encontrado para este dia. Você pode criar um novo lançamento agora."; const grouped = day
? {
transaction: day.events.filter((e) => e.type === "transaction"),
boleto: day.events.filter((e) => e.type === "boleto"),
card: day.events.filter((e) => e.type === "card"),
}
: null;
return ( return (
<Dialog open={open} onOpenChange={(value) => (!value ? onClose() : null)}> <Dialog open={open} onOpenChange={(value) => (!value ? onClose() : null)}>
<DialogContent className="max-w-xl"> <DialogContent className="max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{formattedDate}</DialogTitle> <DialogTitle>{formattedDate}</DialogTitle>
<DialogDescription>{description}</DialogDescription> <DialogDescription>
{hasEvents
? "Lançamentos e vencimentos cadastrados para este dia."
: "Nenhum lançamento encontrado para este dia."}
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="max-h-[380px] space-y-2 overflow-y-auto pr-2"> <div className="max-h-[380px] space-y-3 overflow-y-auto pr-2">
{day?.events.length ? ( {hasEvents && grouped ? (
day.events.map((event) => ( (["transaction", "boleto", "card"] as const)
.filter((type) => grouped[type].length > 0)
.map((type) => (
<div key={type} className="space-y-1.5">
<p className="px-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{SECTION_LABELS[type]}
</p>
<div className="space-y-1.5">
{grouped[type].map((event) => (
<div key={event.id}>{renderEvent(event)}</div> <div key={event.id}>{renderEvent(event)}</div>
))}
</div>
</div>
)) ))
) : ( ) : (
<div className="rounded-xl border border-dashed border-border/60 bg-muted/30 p-6 text-center text-sm text-muted-foreground"> <div className="flex flex-col items-center gap-3 rounded-xl border border-dashed border-border/60 bg-muted/30 p-8 text-center">
Nenhum lançamento ou vencimento registrado. Clique em{" "} <RiCalendarEventLine className="size-8 text-muted-foreground/50" />
<span className="font-medium text-primary">Novo lançamento</span>{" "} <p className="text-sm text-muted-foreground">
para começar. Nenhum lançamento registrado para este dia.
</p>
</div> </div>
)} )}
</div> </div>

View File

@@ -17,6 +17,7 @@ import { parsePeriod } from "@/shared/utils/period";
const PAYMENT_METHOD_BOLETO = "Boleto"; const PAYMENT_METHOD_BOLETO = "Boleto";
const TRANSACTION_TYPE_TRANSFERENCIA = "Transferência"; const TRANSACTION_TYPE_TRANSFERENCIA = "Transferência";
const PAYMENT_PREFIX = "Pagamento fatura - ";
const clampDayInMonth = (year: number, monthIndex: number, day: number) => { const clampDayInMonth = (year: number, monthIndex: number, day: number) => {
const lastDay = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate(); const lastDay = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
@@ -88,19 +89,28 @@ export const fetchCalendarData = async ({
const transactionData = mapTransactionsData(transactionRows); const transactionData = mapTransactionsData(transactionRows);
const events: CalendarEvent[] = []; const events: CalendarEvent[] = [];
// Totais por cartão para exibir no vencimento
const cardTotals = new Map<string, number>(); const cardTotals = new Map<string, number>();
for (const item of transactionData) { for (const item of transactionData) {
if (!item.cardId || item.period !== period) { if (!item.cardId || item.period !== period) continue;
continue;
}
const amount = Math.abs(item.amount ?? 0); const amount = Math.abs(item.amount ?? 0);
cardTotals.set(item.cardId, (cardTotals.get(item.cardId) ?? 0) + amount); cardTotals.set(item.cardId, (cardTotals.get(item.cardId) ?? 0) + amount);
} }
// Pagamentos de fatura por nome do cartão → data de pagamento
const paymentByCardName = new Map<string, string | null>();
for (const item of transactionData) { for (const item of transactionData) {
if (!item.name.startsWith(PAYMENT_PREFIX)) continue;
const cardName = item.name.slice(PAYMENT_PREFIX.length);
paymentByCardName.set(cardName, item.purchaseDate?.slice(0, 10) ?? null);
}
for (const item of transactionData) {
// Pagamentos de fatura são consumidos pelos eventos de cartão
if (item.name.startsWith(PAYMENT_PREFIX)) continue;
const isBoleto = item.paymentMethod === PAYMENT_METHOD_BOLETO; const isBoleto = item.paymentMethod === PAYMENT_METHOD_BOLETO;
// Para boletos, exibir apenas na data de vencimento
if (isBoleto) { if (isBoleto) {
if ( if (
item.dueDate && item.dueDate &&
@@ -114,7 +124,6 @@ export const fetchCalendarData = async ({
}); });
} }
} else { } else {
// Para outros tipos de lançamento, exibir na data de compra
const purchaseDateKey = item.purchaseDate.slice(0, 10); const purchaseDateKey = item.purchaseDate.slice(0, 10);
if (isWithinRange(purchaseDateKey, rangeStartKey, rangeEndKey)) { if (isWithinRange(purchaseDateKey, rangeStartKey, rangeEndKey)) {
events.push({ events.push({
@@ -127,22 +136,21 @@ export const fetchCalendarData = async ({
} }
} }
// Exibir vencimentos apenas de cartões com lançamentos do período // Vencimentos de cartões com lançamentos no período
for (const card of cardRows) { for (const card of cardRows) {
if (!cardTotals.has(card.id)) { if (!cardTotals.has(card.id)) continue;
continue;
}
const dueDayNumber = Number.parseInt(card.dueDay ?? "", 10); const dueDayNumber = Number.parseInt(card.dueDay ?? "", 10);
if (Number.isNaN(dueDayNumber)) { if (Number.isNaN(dueDayNumber)) continue;
continue;
}
const normalizedDay = clampDayInMonth(year, monthIndex, dueDayNumber); const normalizedDay = clampDayInMonth(year, monthIndex, dueDayNumber);
const dueDateKey = formatDateKey( const dueDateKey = formatDateKey(
new Date(Date.UTC(year, monthIndex, normalizedDay)), new Date(Date.UTC(year, monthIndex, normalizedDay)),
); );
const isPaid = paymentByCardName.has(card.name);
const paymentDate = paymentByCardName.get(card.name) ?? null;
events.push({ events.push({
id: `${card.id}:cartao`, id: `${card.id}:cartao`,
type: "card", type: "card",
@@ -156,6 +164,8 @@ export const fetchCalendarData = async ({
status: card.status, status: card.status,
logo: card.logo ?? null, logo: card.logo ?? null,
totalDue: cardTotals.get(card.id) ?? null, totalDue: cardTotals.get(card.id) ?? null,
isPaid,
paymentDate,
}, },
}); });
} }

View File

@@ -84,39 +84,11 @@ export function CardItem({
const logoPath = resolveLogoSrc(logo); const logoPath = resolveLogoSrc(logo);
const brandAsset = resolveCardBrandAsset(brand); const brandAsset = resolveCardBrandAsset(brand);
const isInactive = status?.toLowerCase() === "inativo"; const isInactive = status?.toLowerCase() === "inativo";
const metrics = const hasMetrics = limitTotal !== null && used !== null && available !== null;
limitTotal === null || used === null || available === null
? null
: [
{ label: "Limite Total", value: limitTotal },
{ label: "Em uso", value: used },
{ label: "Disponível", value: available },
];
const actions = [
{
label: "editar",
icon: <RiPencilLine className="size-4" aria-hidden />,
onClick: onEdit,
className: "text-primary",
},
{
label: "ver fatura",
icon: <RiFileList2Line className="size-4" aria-hidden />,
onClick: onInvoice,
className: "text-primary",
},
{
label: "remover",
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
onClick: onRemove,
className: "text-destructive",
},
];
return ( return (
<Card className="flex flex-col p-6 w-full"> <Card className="flex flex-col p-6 w-full">
<CardHeader className="space-y-2 px-0 pb-0"> <CardHeader className="space-y-2 p-0">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex flex-1 items-center gap-2"> <div className="flex flex-1 items-center gap-2">
{logoPath ? ( {logoPath ? (
@@ -135,8 +107,8 @@ export function CardItem({
) : null} ) : null}
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-2">
<h3 className="truncate text-sm font-semibold text-foreground sm:text-base"> <h3 className="truncate font-semibold text-foreground">
{name} {name}
</h3> </h3>
{note ? ( {note ? (
@@ -166,14 +138,14 @@ export function CardItem({
</div> </div>
{brandAsset ? ( {brandAsset ? (
<div className="flex items-center justify-center py-1"> <div className="flex items-center justify-center py-2">
<Image <Image
src={brandAsset} src={brandAsset}
alt={`Bandeira ${brand}`} alt={`Bandeira ${brand}`}
width={36} width={36}
height={36} height={36}
className={cn( className={cn(
"h-5 w-auto rounded", "h-4 w-auto rounded",
isInactive && "grayscale opacity-40", isInactive && "grayscale opacity-40",
)} )}
/> />
@@ -185,56 +157,65 @@ export function CardItem({
)} )}
</div> </div>
<div className="flex items-center justify-between border-y py-3 text-xs font-medium text-muted-foreground sm:text-sm"> <div className="flex items-center justify-between border-y py-3 text-sm text-muted-foreground">
<span> <span>
Fecha dia{" "} Fecha em{" "}
<span className="font-medium text-foreground"> <span className="font-semibold text-foreground">
{formatDay(closingDay)} dia {formatDay(closingDay)}
</span> </span>
</span> </span>
<span> <span>
Vence dia{" "} Vence em{" "}
<span className="font-medium text-foreground"> <span className="font-semibold text-foreground">
{formatDay(dueDay)} dia {formatDay(dueDay)}
</span> </span>
</span> </span>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-1 flex-col gap-5 px-0"> <CardContent className="flex flex-1 flex-col gap-4 px-0">
{metrics ? ( {hasMetrics &&
available !== null &&
used !== null &&
limitTotal !== null ? (
<> <>
<div className="grid grid-cols-3 gap-4"> <div className="flex flex-col gap-0.5">
<div className="flex flex-col items-start gap-1"> <span className="text-xs text-muted-foreground">Disponível</span>
<p className="text-sm font-semibold text-foreground"> <MoneyValues
<MoneyValues amount={metrics[0].value} /> amount={available}
</p> className="text-xl font-semibold text-success"
<span className="text-xs text-muted-foreground"> />
{metrics[0].label}
</span>
</div> </div>
<div className="flex flex-col items-center gap-1"> <div className="grid grid-cols-2 gap-2">
<p className="flex items-center gap-1.5 text-sm font-semibold text-foreground"> <div className="flex flex-col gap-0.5">
<span className="size-2 rounded-full bg-primary" />
<MoneyValues amount={metrics[1].value} />
</p>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{metrics[1].label} Limite total
</span> </span>
<MoneyValues
amount={limitTotal}
className="text-sm font-semibold text-foreground"
/>
</div> </div>
<div className="flex flex-col gap-0.5">
<div className="flex flex-col items-end gap-1"> <span className="text-xs text-muted-foreground">Em uso</span>
<p className="text-sm font-semibold text-foreground"> <MoneyValues
<MoneyValues amount={metrics[2].value} /> amount={used}
</p> className="text-sm font-semibold text-primary"
<span className="text-xs text-muted-foreground"> />
{metrics[2].label}
</span>
</div> </div>
</div> </div>
<Progress value={usagePercent} className="h-3" /> <div className="flex flex-col gap-2">
<Progress
value={usagePercent}
className="h-2.5"
aria-label={`${usagePercent.toFixed(0)}% do limite utilizado`}
/>
<span className="text-xs text-muted-foreground">
{usagePercent.toFixed(1)}% utilizado
</span>
</div>
</> </>
) : ( ) : (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -243,21 +224,31 @@ export function CardItem({
)} )}
</CardContent> </CardContent>
<CardFooter className="mt-auto flex flex-wrap gap-4 px-0 text-sm"> <CardFooter className="mt-auto flex flex-wrap gap-4 px-0 pt-2 text-sm">
{actions.map(({ label, icon, onClick, className }) => (
<button <button
key={label}
type="button" type="button"
onClick={onClick} onClick={onEdit}
className={cn( className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
"flex items-center gap-1 font-medium transition-opacity hover:opacity-80",
className,
)}
> >
{icon} <RiPencilLine className="size-4" aria-hidden />
{label} editar
</button>
<button
type="button"
onClick={onInvoice}
className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
>
<RiFileList2Line className="size-4" aria-hidden />
ver fatura
</button>
<button
type="button"
onClick={onRemove}
className="flex items-center gap-1 font-medium text-destructive transition-opacity hover:opacity-80"
>
<RiDeleteBin5Line className="size-4" aria-hidden />
remover
</button> </button>
))}
</CardFooter> </CardFooter>
</Card> </Card>
); );

View File

@@ -3,7 +3,7 @@ import { cards, financialAccounts, transactions } from "@/db/schema";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
import { loadLogoOptions } from "@/shared/lib/logo/options"; import { loadLogoOptions } from "@/shared/lib/logo/options";
export type CardData = { type CardData = {
id: string; id: string;
name: string; name: string;
brand: string; brand: string;

View File

@@ -30,10 +30,11 @@ import {
import { import {
CATEGORY_TYPE_LABEL, CATEGORY_TYPE_LABEL,
CATEGORY_TYPES, CATEGORY_TYPES,
type CategoryType,
} from "@/shared/lib/categories/constants"; } from "@/shared/lib/categories/constants";
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
import { CategoryDialog } from "./category-dialog"; import { CategoryDialog } from "./category-dialog";
import { CategoryIconBadge } from "./category-icon-badge"; import type { Category } from "./types";
import type { Category, CategoryType } from "./types";
const CATEGORIAS_PROTEGIDAS = [ const CATEGORIAS_PROTEGIDAS = [
"Transferência interna", "Transferência interna",

View File

@@ -1,11 +1,10 @@
import { RiArrowDownSFill, RiArrowUpSFill } from "@remixicon/react"; import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge"; import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge";
import { Card } from "@/shared/components/ui/card"; import { Card } from "@/shared/components/ui/card";
import type { CategoryType } from "@/shared/lib/categories/constants"; import type { CategoryType } from "@/shared/lib/categories/constants";
import { currencyFormatter } from "@/shared/utils/currency"; import { currencyFormatter } from "@/shared/utils/currency";
import { formatPercentage } from "@/shared/utils/percentage"; import { formatPercentage } from "@/shared/utils/percentage";
import { cn } from "@/shared/utils/ui";
import { CategoryIconBadge } from "./category-icon-badge";
type CategorySummary = { type CategorySummary = {
id: string; id: string;
@@ -33,33 +32,6 @@ export function CategoryDetailHeader({
percentageChange, percentageChange,
transactionCount, transactionCount,
}: CategoryDetailHeaderProps) { }: 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 = const variationLabel =
typeof percentageChange === "number" typeof percentageChange === "number"
? formatPercentage(percentageChange, { ? formatPercentage(percentageChange, {
@@ -115,15 +87,13 @@ export function CategoryDetailHeader({
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Variação vs mês anterior Variação vs mês anterior
</p> </p>
<div <PercentageChangeIndicator
className={cn( value={percentageChange}
"mt-1 flex items-center gap-1 text-lg font-semibold", label={variationLabel}
variationColor, positiveTrend={category.type === "receita" ? "up" : "down"}
)} className="mt-1 gap-1 text-lg font-semibold"
> iconClassName="size-4"
{variationIcon} />
<span>{variationLabel}</span>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +0,0 @@
// Re-export from shared — componente movido para src/shared/components/entity-avatar/
export {
CategoryIconBadge,
type CategoryIconBadgeProps,
type CategoryIconBadgeSize,
} from "@/shared/components/entity-avatar";

View File

@@ -1,11 +1,5 @@
import type { CategoryType } from "@/shared/lib/categories/constants"; 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 = { export type Category = {
id: string; id: string;
name: string; name: string;

View File

@@ -1,6 +1,6 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { type Category, categories } from "@/db/schema"; import { type Category, categories } from "@/db/schema";
import type { CategoryType } from "@/features/categories/components/types"; import type { CategoryType } from "@/shared/lib/categories/constants";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
export type CategoryData = { export type CategoryData = {

View File

@@ -149,9 +149,9 @@ export const InboxCard = memo(function InboxCard({
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex-1 py-2"> <CardContent className="min-h-0 flex-1 overflow-hidden py-2">
{item.originalTitle && ( {item.originalTitle && (
<p className="mb-1 text-sm font-medium">{item.originalTitle}</p> <p className="mb-1 line-clamp-2 text-sm font-medium">{item.originalTitle}</p>
)} )}
<p className="line-clamp-4 whitespace-pre-wrap text-sm text-muted-foreground"> <p className="line-clamp-4 whitespace-pre-wrap text-sm text-muted-foreground">
{item.originalText} {item.originalText}

View File

@@ -102,17 +102,15 @@ export function InboxItemsList({
const groups = groupItemsByDay(items); const groups = groupItemsByDay(items);
return ( return (
<div className="space-y-6">
{groups.map((group) => (
<div key={group.label}>
<div className="mb-3 flex items-center gap-1 text-muted-foreground">
<RiCalendarEventLine className="size-3.5 shrink-0" />
<p className="text-sm font-medium">{group.label}</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{group.items.map((item) => ( {groups.flatMap((group) =>
group.items.map((item) => (
<div key={item.id} className="flex flex-col gap-1.5">
<div className="flex items-center gap-1.5 text-muted-foreground">
<RiCalendarEventLine className="size-3 shrink-0" />
<span className="text-xs font-medium">{group.label}</span>
</div>
<InboxCard <InboxCard
key={item.id}
item={item} item={item}
readonly={readonly} readonly={readonly}
appLogoMap={appLogoMap} appLogoMap={appLogoMap}
@@ -124,10 +122,9 @@ export function InboxItemsList({
selected={selectedIds.includes(item.id)} selected={selectedIds.includes(item.id)}
onSelectToggle={onSelectToggle} onSelectToggle={onSelectToggle}
/> />
))}
</div> </div>
</div> )),
))} )}
</div> </div>
); );
} }

View File

@@ -191,9 +191,9 @@ export function InvoiceSummaryCard({
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-muted-foreground">Valor da fatura</p> <p className="text-sm text-muted-foreground">Valor da fatura</p>
<MoneyValues <MoneyValues
amount={totalAmount} amount={Math.abs(totalAmount)}
className={cn( className={cn(
"text-3xl leading-none tracking-tighter sm:text-[2rem]", "text-3xl tracking-tighter font-semibold",
isPaid ? "text-success" : "text-foreground", isPaid ? "text-success" : "text-foreground",
)} )}
/> />

View File

@@ -86,6 +86,13 @@ export function SetupTabs() {
); );
} }
const DATA_COLORS = [
"var(--data-1)",
"var(--data-3)",
"var(--data-5)",
"var(--data-4)",
];
function StepCard({ function StepCard({
step, step,
title, title,
@@ -95,11 +102,18 @@ function StepCard({
title: string; title: string;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const colorVar = DATA_COLORS[(step - 1) % DATA_COLORS.length];
return ( return (
<Card className="border"> <Card className="border">
<CardContent> <CardContent>
<div className="flex gap-3 md:gap-4"> <div className="flex gap-3 md:gap-4">
<div className="flex h-9 w-9 md:h-10 md:w-10 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground font-medium text-sm md:text-base"> <div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-semibold"
style={{
backgroundColor: `color-mix(in oklch, ${colorVar} 20%, transparent)`,
color: "var(--foreground)",
}}
>
{step} {step}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">

View File

@@ -49,42 +49,42 @@ export const mainFeatures: FeatureItem[] = [
icon: RiWalletLine, icon: RiWalletLine,
title: "Contas e transações", title: "Contas e transações",
description: description:
"Registre suas contas bancárias, cartões e dinheiro. Adicione receitas, despesas e transferências. Organize por categorias. Extratos detalhados por conta.", "Contas bancárias, cartões e dinheiro em um só lugar, se organize como preferir.",
colorVar: "var(--data-9)", colorVar: "var(--data-5)",
}, },
{ {
icon: RiPercentLine, icon: RiPercentLine,
title: "Parcelamentos avançados", title: "Parcelamentos avançados",
description: description:
"Controle completo de compras parceladas. Antecipe parcelas com cálculo automático de desconto. Veja análise consolidada de todas as parcelas em aberto.", "Controle compras parceladas e antecipe parcelas com cálculo automático de desconto.",
colorVar: "var(--data-4)", colorVar: "var(--data-4)",
}, },
{ {
icon: RiRobot2Line, icon: RiRobot2Line,
title: "Insights com IA", title: "Insights com IA",
description: description:
"Análises financeiras geradas por IA (Claude, GPT, Gemini). Insights personalizados sobre seus padrões de gastos e recomendações inteligentes.", "Análises por IA com insights sobre padrões de gastos e recomendações personalizadas.",
colorVar: "var(--data-8)", colorVar: "var(--data-6)",
}, },
{ {
icon: RiBarChartBoxLine, icon: RiBarChartBoxLine,
title: "Relatórios e gráficos", title: "Relatórios e gráficos",
description: description:
"Dashboard com 20+ widgets interativos. Relatórios detalhados por categoria. Gráficos de evolução e comparativos. Exportação em PDF e Excel.", "20+ widgets interativos, relatórios por categoria e exportação em PDF e Excel.",
colorVar: "var(--data-5)", colorVar: "var(--data-5)",
}, },
{ {
icon: RiBankCard2Line, icon: RiBankCard2Line,
title: "Faturas de cartão", title: "Faturas de cartão",
description: description:
"Cadastre seus cartões e acompanhe as faturas por período. Veja o que ainda não foi fechado. Controle limites, vencimentos e fechamentos.", "Acompanhe faturas por período, limites e vencimentos de cada cartão.",
colorVar: "var(--data-1)", colorVar: "var(--data-1)",
}, },
{ {
icon: RiTeamLine, icon: RiTeamLine,
title: "Gestão colaborativa", title: "Gestão colaborativa",
description: description:
"Compartilhe pagadores com permissões granulares (admin/viewer). Notificações automáticas por e-mail. Colabore em lançamentos compartilhados.", "Compartilhe acesso com permissões granulares (admin/viewer) e notificações por e-mail.",
colorVar: "var(--data-3)", colorVar: "var(--data-3)",
}, },
]; ];
@@ -94,40 +94,40 @@ export const extraFeatures: FeatureItem[] = [
icon: RiPieChartLine, icon: RiPieChartLine,
title: "Categorias e orçamentos", title: "Categorias e orçamentos",
description: description:
"Crie categorias personalizadas e defina orçamentos mensais com indicadores visuais.", "Categorias personalizadas com orçamentos mensais e indicadores visuais de progresso.",
colorVar: "var(--data-7)", colorVar: "var(--data-6)",
}, },
{ {
icon: RiFileTextLine, icon: RiFileTextLine,
title: "Anotações e tarefas", title: "Anotações e tarefas",
description: description:
"Notas de texto e listas de tarefas com checkboxes. Arquivamento para manter histórico.", "Notas de texto e listas de tarefas com checkboxes e arquivamento.",
colorVar: "var(--data-6)", colorVar: "var(--data-6)",
}, },
{ {
icon: RiCalendarLine, icon: RiCalendarLine,
title: "Calendário financeiro", title: "Calendário financeiro",
description: description:
"Visualize transações em calendário mensal. Nunca perca prazos de pagamentos.", "Visualize transações em calendário mensal para não perder prazos.",
colorVar: "var(--data-2)", colorVar: "var(--data-2)",
}, },
{ {
icon: RiDownloadCloudLine, icon: RiDownloadCloudLine,
title: "Importação em massa", title: "Importação em massa",
description: "Lance múltiplos lançamentos de uma vez", description: "Importe múltiplos lançamentos de uma vez.",
colorVar: "var(--data-9)", colorVar: "var(--data-5)",
}, },
{ {
icon: RiEyeOffLine, icon: RiEyeOffLine,
title: "Modo privacidade", title: "Modo privacidade",
description: description:
"Oculte valores sensíveis com um clique. Tema dark/light. Calculadora integrada.", "Oculte valores com um clique. Tema dark/light e calculadora integrada.",
colorVar: "var(--data-4)", colorVar: "var(--data-4)",
}, },
{ {
icon: RiFlashlightLine, icon: RiFlashlightLine,
title: "Performance otimizada", title: "Performance otimizada",
description: "Sistema rápido e com alta performance", description: "Interface rápida e otimizada para uso diário.",
colorVar: "var(--data-5)", colorVar: "var(--data-5)",
}, },
]; ];
@@ -150,7 +150,7 @@ export const pwaHighlights: FeatureItem[] = [
icon: RiLayoutGridLine, icon: RiLayoutGridLine,
title: "Acesso rápido ao que importa", title: "Acesso rápido ao que importa",
description: "Dashboard, inbox e lançamentos a um toque.", description: "Dashboard, inbox e lançamentos a um toque.",
colorVar: "var(--data-9)", colorVar: "var(--data-5)",
}, },
{ {
icon: RiFlashlightLine, icon: RiFlashlightLine,
@@ -193,7 +193,7 @@ export const companionSteps: FeatureItem[] = [
icon: RiCheckLine, icon: RiCheckLine,
title: "Revise e confirme no OpenMonetis", title: "Revise e confirme no OpenMonetis",
description: "Pré-lançamentos ficam na inbox para sua aprovação", description: "Pré-lançamentos ficam na inbox para sua aprovação",
colorVar: "var(--data-9)", colorVar: "var(--data-5)",
}, },
]; ];
@@ -210,7 +210,7 @@ export const stackItems = [
title: "Backend", title: "Backend",
subtitle: "PostgreSQL, Drizzle ORM, Better Auth", subtitle: "PostgreSQL, Drizzle ORM, Better Auth",
description: "Banco relacional robusto com type-safe ORM", description: "Banco relacional robusto com type-safe ORM",
colorVar: "var(--data-9)", colorVar: "var(--data-5)",
}, },
{ {
icon: RiShieldCheckLine, icon: RiShieldCheckLine,
@@ -242,7 +242,7 @@ export const whoIsItForItems: FeatureItem[] = [
title: "Quer controle total sobre seus dados", title: "Quer controle total sobre seus dados",
description: description:
"Prefere hospedar seus próprios dados ao invés de depender de serviços terceiros", "Prefere hospedar seus próprios dados ao invés de depender de serviços terceiros",
colorVar: "var(--data-9)", colorVar: "var(--data-5)",
}, },
{ {
icon: RiLineChartLine, icon: RiLineChartLine,
@@ -270,7 +270,7 @@ export const whoIsItForItems: FeatureItem[] = [
title: "Não sou responsável por nada", title: "Não sou responsável por nada",
description: description:
"Não sou responsável por nada que aconteça com você ou com seus dados.", "Não sou responsável por nada que aconteça com você ou com seus dados.",
colorVar: "var(--data-9)", colorVar: "var(--data-5)",
}, },
]; ];
@@ -280,7 +280,7 @@ export function getMetricsItems(stars: number, forks: number) {
icon: RiLayoutGridLine, icon: RiLayoutGridLine,
value: "20+", value: "20+",
label: "Widgets no dashboard", label: "Widgets no dashboard",
colorVar: "var(--data-9)", colorVar: "var(--data-5)",
}, },
{ {
icon: RiShieldCheckLine, icon: RiShieldCheckLine,

View File

@@ -40,7 +40,7 @@ export const formatNoteCreatedAt = (
value: string | Date | null | undefined, value: string | Date | null | undefined,
) => { ) => {
const parsed = parseNoteDate(value); const parsed = parseNoteDate(value);
return parsed ? NOTE_CREATED_AT_FORMATTER.format(parsed) : null; return parsed ? NOTE_CREATED_AT_FORMATTER.format(parsed) : "";
}; };
export const formatNoteCreatedAtLong = ( export const formatNoteCreatedAtLong = (

View File

@@ -4,7 +4,7 @@ import {
RiHourglass2Line, RiHourglass2Line,
RiWallet3Line, RiWallet3Line,
} from "@remixicon/react"; } from "@remixicon/react";
import { buildBillStatusLabel } from "@/features/dashboard/bills-helpers"; import { buildBillStatusLabel } from "@/features/dashboard/bills/bills-helpers";
import { EstablishmentLogo } from "@/shared/components/entity-avatar"; import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { CardContent } from "@/shared/components/ui/card"; import { CardContent } from "@/shared/components/ui/card";

View File

@@ -72,7 +72,7 @@ export function PayerCard({ payer, onEdit, onRemove }: PayerCardProps) {
</Badge> </Badge>
{isReadOnly ? ( {isReadOnly ? (
<Badge variant="outline" className="text-xs text-warning"> <Badge variant="outline" className="text-xs text-primary">
Somente leitura Somente leitura
</Badge> </Badge>
) : null} ) : null}

View File

@@ -370,7 +370,7 @@ export function PayerDialog({
) : ( ) : (
<div className="size-12 rounded-full bg-muted border-2 border-dashed border-muted-foreground/20 flex items-center justify-center hover:scale-110 transition-transform duration-200"> <div className="size-12 rounded-full bg-muted border-2 border-dashed border-muted-foreground/20 flex items-center justify-center hover:scale-110 transition-transform duration-200">
{isProcessingImage ? ( {isProcessingImage ? (
<span className="text-[10px] text-muted-foreground animate-pulse"> <span className="text-xs text-muted-foreground animate-pulse">
... ...
</span> </span>
) : ( ) : (

View File

@@ -9,7 +9,7 @@ import {
PAYER_STATUS_OPTIONS, PAYER_STATUS_OPTIONS,
} from "@/shared/lib/payers/constants"; } from "@/shared/lib/payers/constants";
export type PayerData = { type PayerData = {
id: string; id: string;
name: string; name: string;
email: string | null; email: string | null;

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { RiArrowDownSFill, RiArrowUpSFill } from "@remixicon/react"; import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import { formatPercentageChange } from "@/features/reports/utils"; import { formatPercentageChange } from "@/features/reports/utils";
import { import {
Tooltip, Tooltip,
@@ -30,13 +30,9 @@ export function CategoryCell({
const absoluteChange = !isFirstMonth ? value - previousValue : null; const absoluteChange = !isFirstMonth ? value - previousValue : null;
const isIncrease = percentageChange !== null && percentageChange > 0;
const isDecrease = percentageChange !== null && percentageChange < 0;
// Despesa: aumento é ruim (vermelho), diminuição é bom (verde) // Despesa: aumento é ruim (vermelho), diminuição é bom (verde)
// Receita: aumento é bom (verde), diminuição é ruim (vermelho) // Receita: aumento é bom (verde), diminuição é ruim (vermelho)
const isPositive = categoryType === "receita" ? isIncrease : isDecrease; const positiveTrend = categoryType === "receita" ? "up" : "down";
const isNegative = categoryType === "receita" ? isDecrease : isIncrease;
return ( return (
<Tooltip> <Tooltip>
@@ -44,19 +40,12 @@ export function CategoryCell({
<div className="flex flex-col items-end gap-0.5 min-h-9 justify-center cursor-default px-4 py-2"> <div className="flex flex-col items-end gap-0.5 min-h-9 justify-center cursor-default px-4 py-2">
<span className="font-medium">{formatCurrency(value)}</span> <span className="font-medium">{formatCurrency(value)}</span>
{!isFirstMonth && percentageChange !== null && ( {!isFirstMonth && percentageChange !== null && (
<div <PercentageChangeIndicator
className={cn( value={percentageChange}
"flex items-center gap-0.5 text-xs", label={formatPercentageChange(percentageChange)}
isNegative && "text-destructive", positiveTrend={positiveTrend}
isPositive && "text-success", iconClassName="h-3 w-3"
)} />
>
{isIncrease && <RiArrowUpSFill className="h-3 w-3" />}
{isDecrease && <RiArrowDownSFill className="h-3 w-3" />}
<span className="font-medium">
{formatPercentageChange(percentageChange)}
</span>
</div>
)} )}
</div> </div>
</TooltipTrigger> </TooltipTrigger>
@@ -71,8 +60,14 @@ export function CategoryCell({
<div <div
className={cn( className={cn(
"font-medium", "font-medium",
isNegative && "text-destructive", (positiveTrend === "up"
isPositive && "text-success", ? absoluteChange !== null && absoluteChange < 0
: absoluteChange !== null && absoluteChange > 0) &&
"text-destructive",
(positiveTrend === "up"
? absoluteChange !== null && absoluteChange > 0
: absoluteChange !== null && absoluteChange < 0) &&
"text-success",
)} )}
> >
Diferença:{" "} Diferença:{" "}

View File

@@ -341,7 +341,7 @@ export function AnticipateInstallmentsDialog({
{/* Seção 3: Resumo */} {/* Seção 3: Resumo */}
{selectedIds.length > 0 && ( {selectedIds.length > 0 && (
<div className="rounded-lg border bg-muted/20 p-3"> <div className="rounded-lg border p-3">
<h4 className="text-sm font-semibold mb-2">Resumo</h4> <h4 className="text-sm font-semibold mb-2">Resumo</h4>
<dl className="space-y-1.5 text-sm"> <dl className="space-y-1.5 text-sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@@ -82,7 +82,7 @@ export function TransactionDetailsDialog({
<div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm"> <div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm">
<div className="min-w-0 space-y-4"> <div className="min-w-0 space-y-4">
<section className="rounded-lg border bg-muted/20 p-3"> <section className="rounded-lg border p-3">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">

View File

@@ -31,7 +31,7 @@ export function PayerSelectContent({
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Avatar className="size-5 border border-border/60 bg-background"> <Avatar className="size-5 border border-border/60 bg-background">
<AvatarImage src={avatarSrc} alt={`Avatar de ${label}`} /> <AvatarImage src={avatarSrc} alt={`Avatar de ${label}`} />
<AvatarFallback className="text-[10px] font-medium uppercase"> <AvatarFallback className="text-xs font-medium uppercase">
{initial} {initial}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>

View File

@@ -148,7 +148,7 @@ export function AnticipationCard({
</dl> </dl>
{anticipation.note && ( {anticipation.note && (
<div className="rounded-lg border bg-muted/20 p-3"> <div className="rounded-lg border p-3">
<dt className="text-xs font-medium text-muted-foreground"> <dt className="text-xs font-medium text-muted-foreground">
Observação Observação
</dt> </dt>

View File

@@ -419,7 +419,7 @@ function buildColumns({
<> <>
<Avatar className="size-7"> <Avatar className="size-7">
<AvatarImage src={avatarSrc} alt={`Avatar de ${label}`} /> <AvatarImage src={avatarSrc} alt={`Avatar de ${label}`} />
<AvatarFallback className="text-[10px] font-medium uppercase"> <AvatarFallback className="text-xs font-medium uppercase">
{initial} {initial}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>

View File

@@ -59,7 +59,7 @@ export function LogoPickerTrigger({
className="object-contain p-0.5" className="object-contain p-0.5"
/> />
) : ( ) : (
<span className="text-[10px] text-muted-foreground">Logo</span> <span className="text-xs text-muted-foreground">Logo</span>
)} )}
</span> </span>
@@ -172,7 +172,7 @@ export function LogoPickerDialog({
/> />
</span> </span>
</span> </span>
<span className="line-clamp-1 text-[10px] leading-tight text-muted-foreground"> <span className="line-clamp-1 text-xs leading-tight text-muted-foreground">
{logoLabel} {logoLabel}
</span> </span>
</button> </button>

View File

@@ -8,6 +8,10 @@ interface LogoProps {
invertTextOnDark?: boolean; invertTextOnDark?: boolean;
/** Exibe o ícone na cor original, sem filtro preto. Apenas nos variants "full" e "compact" */ /** Exibe o ícone na cor original, sem filtro preto. Apenas nos variants "full" e "compact" */
colorIcon?: boolean; colorIcon?: boolean;
/** Classes extras aplicadas na imagem do ícone */
iconClassName?: string;
/** Classes extras aplicadas na imagem do texto */
textClassName?: string;
} }
const iconFilterClass = "brightness-0 saturate-0"; const iconFilterClass = "brightness-0 saturate-0";
@@ -17,6 +21,8 @@ export function Logo({
className, className,
invertTextOnDark = true, invertTextOnDark = true,
colorIcon = false, colorIcon = false,
iconClassName,
textClassName,
}: LogoProps) { }: LogoProps) {
if (variant === "compact") { if (variant === "compact") {
return ( return (
@@ -27,7 +33,11 @@ export function Logo({
alt="OpenMonetis" alt="OpenMonetis"
fill fill
sizes="32px" sizes="32px"
className={cn("object-contain", !colorIcon && iconFilterClass)} className={cn(
"object-contain",
!colorIcon && iconFilterClass,
iconClassName,
)}
priority priority
/> />
</div> </div>
@@ -37,7 +47,11 @@ export function Logo({
alt="OpenMonetis" alt="OpenMonetis"
fill fill
sizes="110px" sizes="110px"
className={cn("object-contain", invertTextOnDark && "dark:invert")} className={cn(
"object-contain",
invertTextOnDark && "dark:invert",
textClassName,
)}
priority priority
/> />
</div> </div>

View File

@@ -37,7 +37,7 @@ export default function MonthNavigation() {
}; };
return ( return (
<Card className="sticky top-16 z-10 flex w-full flex-row p-4 backdrop-blur-xs supports-backdrop-filter:bg-card/80 "> <Card className="sticky top-18 z-10 flex w-full flex-row p-4 backdrop-blur-md supports-backdrop-filter:bg-card/60">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<NavigationButton <NavigationButton
direction="left" direction="left"

View File

@@ -8,12 +8,12 @@ export default function PageDescription({
icon?: React.ReactNode; icon?: React.ReactNode;
}) { }) {
return ( return (
<div> <div className="space-y-2">
<h1 className="text-2xl font-semibold flex items-center gap-1"> <h1 className="text-2xl font-semibold flex items-center gap-1">
<span className="text-primary">{icon}</span> <span className="text-primary">{icon}</span>
{title} {title}
</h1> </h1>
<h2 className="text-sm max-w-2xl text-muted-foreground leading-relaxed mt-1.5"> <h2 className="text-sm max-w-2xl text-muted-foreground leading-relaxed">
{subtitle} {subtitle}
</h2> </h2>
</div> </div>

View File

@@ -72,7 +72,7 @@ export function TransactionTypeBadge({
variant="outline" variant="outline"
data-kind={normalizedKind ?? "custom"} data-kind={normalizedKind ?? "custom"}
className={cn( className={cn(
"h-6 gap-1.5 rounded-full border-transparent px-2 py-0 text-xs font-medium shadow-none", "h-6 gap-1 border-none rounded-md px-2 py-0 text-xs shadow-none",
config?.className ?? config?.className ??
"bg-muted/30 text-muted-foreground dark:bg-muted/20", "bg-muted/30 text-muted-foreground dark:bg-muted/20",
className, className,

View File

@@ -20,7 +20,7 @@ const buttonVariants = cva(
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
navbar: navbar:
"bg-transparent text-black/75 shadow-none hover:bg-black/10 hover:text-black focus-visible:ring-black/20", "bg-transparent text-black/75 shadow-none hover:bg-black/10 hover:text-black focus-visible:ring-black/20 dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white dark:focus-visible:ring-white/20",
}, },
size: { size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3", default: "h-9 px-4 py-2 has-[>svg]:px-3",

View File

@@ -90,7 +90,7 @@ function Calendar({
table: "w-full border-collapse", table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays), weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn( weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none", "text-muted-foreground rounded-md flex-1 font-normal text-xs select-none",
defaultClassNames.weekday, defaultClassNames.weekday,
), ),
week: cn("flex w-full mt-2", defaultClassNames.week), week: cn("flex w-full mt-2", defaultClassNames.week),
@@ -99,7 +99,7 @@ function Calendar({
defaultClassNames.week_number_header, defaultClassNames.week_number_header,
), ),
week_number: cn( week_number: cn(
"text-[0.8rem] select-none text-muted-foreground", "text-xs select-none text-muted-foreground",
defaultClassNames.week_number, defaultClassNames.week_number,
), ),
day: cn( day: cn(

View File

@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground flex flex-col gap-4 border py-6 rounded-lg hover:border-primary/40", "bg-card text-card-foreground flex flex-col gap-4 border border-border/70 dark:border-border/40 py-6 rounded-lg hover:border-primary/60 transition-colors duration-200",
className, className,
)} )}
{...props} {...props}

View File

@@ -29,6 +29,8 @@ export type CalendarEvent =
status: string; status: string;
logo: string | null; logo: string | null;
totalDue: number | null; totalDue: number | null;
isPaid: boolean;
paymentDate: string | null;
}; };
}; };

View File

@@ -3,7 +3,7 @@
* Os valores são CSS variables definidas em globals.css, * Os valores são CSS variables definidas em globals.css,
* com variantes light/dark — sem hardcode de hex fora do tema. * com variantes light/dark — sem hardcode de hex fora do tema.
*/ */
const DATA_PALETTE_SIZE = 10; const DATA_PALETTE_SIZE = 6;
/** Array de CSS variables da paleta de dados — usado em gráficos e charts. */ /** Array de CSS variables da paleta de dados — usado em gráficos e charts. */
export const CATEGORY_COLORS = Array.from( export const CATEGORY_COLORS = Array.from(
@@ -20,19 +20,18 @@ function hashNameToIndex(name: string): number {
} }
/** /**
* Retorna a CSS variable de cor para um nome (determinístico via hash). * Cor do ícone — sempre primary para consistência visual.
*/ */
export function getCategoryColorFromName(name: string): string { export function getCategoryColorFromName(_name: string): string {
const n = hashNameToIndex(name) + 1; return "var(--foreground)";
return `var(--data-${n})`;
} }
/** /**
* Retorna o background com transparência usando color-mix. * Background distinto por nome (hash), com 20% de opacidade.
*/ */
export function getCategoryBgColorFromName(name: string): string { export function getCategoryBgColorFromName(name: string): string {
const n = hashNameToIndex(name) + 1; const n = hashNameToIndex(name) + 1;
return `color-mix(in oklch, var(--data-${n}) 14%, transparent)`; return `color-mix(in oklch, var(--data-${n}) 20%, transparent)`;
} }
/** /**
@@ -49,20 +48,3 @@ export function buildInitials(name: string): string {
const b = parts[1]?.[0] ?? ""; const b = parts[1]?.[0] ?? "";
return `${a}${b}`.toUpperCase() || "?"; return `${a}${b}`.toUpperCase() || "?";
} }
// --- compatibilidade retroativa (para não quebrar callers durante migração) ---
/** @deprecated Use getCategoryColorFromName */
export function getCategoryColor(index: number): string {
return `var(--data-${(index % DATA_PALETTE_SIZE) + 1})`;
}
/** @deprecated Use getCategoryBgColorFromName */
export function getCategoryBgColor(index: number): string {
return `color-mix(in oklch, var(--data-${(index % DATA_PALETTE_SIZE) + 1}) 14%, transparent)`;
}
/** @deprecated Use buildInitials */
export function buildCategoryInitials(value: string): string {
return buildInitials(value);
}

View File

@@ -39,7 +39,7 @@ const MONTH_NAMES = [
"dezembro", "dezembro",
] as const; ] as const;
export const OPENMONETIS_TIME_ZONE = "America/Sao_Paulo"; const OPENMONETIS_TIME_ZONE = "America/Sao_Paulo";
type DateOnlyParts = { type DateOnlyParts = {
year: number; year: number;
@@ -200,7 +200,7 @@ export function getTodayDateString(date: Date = new Date()): string {
/** /**
* Gets a date string in YYYY-MM-DD format for a specific timezone * Gets a date string in YYYY-MM-DD format for a specific timezone
*/ */
export function getDateStringInTimeZone( function getDateStringInTimeZone(
timeZone: string, timeZone: string,
date: Date = new Date(), date: Date = new Date(),
): string { ): string {
@@ -215,14 +215,6 @@ export function getBusinessDateString(date: Date = new Date()): string {
return getDateStringInTimeZone(OPENMONETIS_TIME_ZONE, date); return getDateStringInTimeZone(OPENMONETIS_TIME_ZONE, date);
} }
/**
* Gets today's date as Date object
* @returns Date object for today
*/
export function getTodayDate(date: Date = new Date()): Date {
return parseLocalDateString(getTodayDateString(date));
}
/** /**
* Gets today's date as Date object using the app business timezone * Gets today's date as Date object using the app business timezone
*/ */
@@ -397,19 +389,6 @@ export function formatDateOnlyLabel(
return prefix ? `${prefix} ${formatted}` : formatted; return prefix ? `${prefix} ${formatted}` : formatted;
} }
export function formatDateTimeLabel(
value: string | Date | null | undefined,
prefix?: string,
options?: Intl.DateTimeFormatOptions,
): string | null {
const formatted = formatDateTime(value, options);
if (!formatted) {
return null;
}
return prefix ? `${prefix} ${formatted}` : formatted;
}
export function compareDateOnly( export function compareDateOnly(
left: string | Date | null | undefined, left: string | Date | null | undefined,
right: string | Date | null | undefined, right: string | Date | null | undefined,
@@ -505,19 +484,7 @@ export function friendlyDate(date: Date): string {
// TIME-BASED UTILITIES // TIME-BASED UTILITIES
// ============================================================================ // ============================================================================
/** function getGreetingInTimeZone(
* Gets appropriate greeting based on time of day
* @param date - Date to get greeting for (defaults to now)
* @returns "Bom dia", "Boa tarde", or "Boa noite"
*/
export function getGreeting(date: Date = new Date()): string {
const hour = date.getHours();
if (hour >= 5 && hour < 12) return "Bom dia";
if (hour >= 12 && hour < 18) return "Boa tarde";
return "Boa noite";
}
export function getGreetingInTimeZone(
timeZone: string, timeZone: string,
date: Date = new Date(), date: Date = new Date(),
): string { ): string {
@@ -531,7 +498,7 @@ export function getBusinessGreeting(date: Date = new Date()): string {
return getGreetingInTimeZone(OPENMONETIS_TIME_ZONE, date); return getGreetingInTimeZone(OPENMONETIS_TIME_ZONE, date);
} }
export function formatCurrentDateInTimeZone( function formatCurrentDateInTimeZone(
timeZone: string, timeZone: string,
date: Date = new Date(), date: Date = new Date(),
): string { ): string {
@@ -550,6 +517,3 @@ export function formatCurrentDateInTimeZone(
export function formatBusinessCurrentDate(date: Date = new Date()): string { export function formatBusinessCurrentDate(date: Date = new Date()): string {
return formatCurrentDateInTimeZone(OPENMONETIS_TIME_ZONE, date); return formatCurrentDateInTimeZone(OPENMONETIS_TIME_ZONE, date);
} }
// Re-export MONTH_NAMES for convenience
export { MONTH_NAMES };