Merge branch 'main' into feat/fix-ui

This commit is contained in:
Alexsandro
2026-04-16 11:52:37 -03:00
committed by GitHub
187 changed files with 4380 additions and 2719 deletions

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiBankLine />}
title="Contas"

View File

@@ -0,0 +1,26 @@
import { RiAttachmentLine } from "@remixicon/react";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import PageDescription from "@/shared/components/page-description";
export const metadata = {
title: "Anexos",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6">
<PageDescription
icon={<RiAttachmentLine />}
title="Anexos"
subtitle="Gerencie os anexos das suas transações"
/>
<MonthNavigation />
{children}
</section>
);
}

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiBarChart2Line />}
title="Orçamentos"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiCalendarEventLine />}
title="Calendário"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiBankCard2Line />}
title="Cartões"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiPriceTag3Line />}
title="Categorias"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiHistoryLine />}
title="Changelog"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiAtLine />}
title="Pré-Lançamentos"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiSparklingLine />}
title="Insights"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiTodoLine />}
title="Anotações"

View File

@@ -80,6 +80,8 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
categoryFilter: null,
accountCardFilter: null,
searchFilter: null,
settledFilter: null,
attachmentFilter: null,
};
const createEmptySlugMaps = (): SlugMaps => ({

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiGroupLine />}
title="Pagadores"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiBankCard2Line />}
title="Uso de Cartões"

View File

@@ -71,7 +71,7 @@ export default async function RelatorioCartoesPage({
<div className="flex size-14 items-center justify-center rounded-full bg-muted mb-4">
<RiBankCard2Line className="size-7 text-muted-foreground" />
</div>
<p className="text-base font-medium">Nenhum cartão selecionado</p>
<p className="text-base font-semibold">Nenhum cartão selecionado</p>
<p className="text-sm text-muted-foreground mt-1">
Selecione um cartão para ver os detalhes de uso.
</p>

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiFileChartLine />}
title="Tendências"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiStore2Line />}
title="Top Estabelecimentos"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiSecurePaymentLine />}
title="Análise de Parcelas"

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiSettings2Line />}
title="Ajustes"

View File

@@ -68,7 +68,7 @@ export default async function Page() {
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-xl font-medium mb-1">Preferências</h2>
<h2 className="text-xl font-semibold mb-1">Preferências</h2>
<p className="text-sm text-muted-foreground">
Personalize sua experiência no OpenMonetis ajustando as
configurações de acordo com suas necessidades.
@@ -93,7 +93,9 @@ export default async function Page() {
<div className="space-y-4">
<div>
<div className="flex items-center gap-2 mb-1">
<h2 className="text-xl font-medium">OpenMonetis Companion</h2>
<h2 className="text-xl font-semibold">
OpenMonetis Companion
</h2>
<span className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-0.5 text-xs font-medium text-success dark:bg-success/10">
<RiAndroidLine className="h-3 w-3" />
Android
@@ -115,7 +117,7 @@ export default async function Page() {
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-xl font-medium mb-1">Alterar nome</h2>
<h2 className="text-xl font-semibold mb-1">Alterar nome</h2>
<p className="text-sm text-muted-foreground">
Atualize como seu nome aparece no OpenMonetis. Esse nome pode
ser exibido em diferentes seções do app e em comunicações.
@@ -131,7 +133,7 @@ export default async function Page() {
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-xl font-medium mb-1">Alterar senha</h2>
<h2 className="text-xl font-semibold mb-1">Alterar senha</h2>
<p className="text-sm text-muted-foreground">
Defina uma nova senha para sua conta. Guarde-a em local
seguro.
@@ -147,7 +149,7 @@ export default async function Page() {
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-xl font-medium mb-1">Passkeys</h2>
<h2 className="text-xl font-semibold mb-1">Passkeys</h2>
<p className="text-sm text-muted-foreground">
Passkeys permitem login sem senha, usando biometria (Face ID,
Touch ID, Windows Hello) ou chaves de segurança.
@@ -163,7 +165,7 @@ export default async function Page() {
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-xl font-medium mb-1">Alterar e-mail</h2>
<h2 className="text-xl font-semibold mb-1">Alterar e-mail</h2>
<p className="text-sm text-muted-foreground">
Atualize o e-mail associado à sua conta. Você precisará
confirmar os links enviados para o novo e também para o e-mail
@@ -183,9 +185,7 @@ export default async function Page() {
<Card className="p-6">
<div className="space-y-4">
<div>
<h2 className="text-xl font-medium mb-1 text-destructive">
Ações perigosas
</h2>
<h2 className="text-xl font-semibold mb-1">Ações perigosas</h2>
<p className="text-sm text-muted-foreground">
Você pode zerar os dados do OpenMonetis e manter seu acesso,
ou excluir sua conta inteira de forma irreversível.

View File

@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<section className="space-y-6 pt-4">
<section className="space-y-6">
<PageDescription
icon={<RiArrowLeftRightLine />}
title="Lançamentos"

View File

@@ -120,13 +120,13 @@ export default async function Page() {
</div>
<div className="max-w-8xl mx-auto px-4 relative">
<div className="mx-auto flex max-w-3xl 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">
<RiGithubFill className="size-4 mr-1" />
Projeto Open Source
</Badge>
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-medium tracking-tight">
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-semibold">
Suas finanças,
<span className="text-primary"> do seu jeito</span>
</h1>
@@ -207,7 +207,7 @@ export default async function Page() {
className="flex flex-col items-center text-center gap-1.5"
>
<Icon className="size-5" style={{ color: colorVar }} />
<span className="text-2xl md:text-3xl font-medium">
<span className="text-2xl md:text-3xl font-semibold">
{value}
</span>
<span className="text-xs md:text-sm text-muted-foreground">
@@ -229,7 +229,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4">
Conheça as telas
</Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
<h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
Veja o que você pode fazer
</h2>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
@@ -254,7 +254,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4">
O que tem aqui
</Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
<h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
Funcionalidades que importam
</h2>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
@@ -282,7 +282,7 @@ export default async function Page() {
/>
</div>
<div>
<h3 className="font-medium text-base md:text-lg mb-1.5 md:mb-2">
<h3 className="font-semibold text-base md:text-lg mb-1.5 md:mb-2">
{feature.title}
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
@@ -298,7 +298,7 @@ export default async function Page() {
<AnimateOnScroll>
<div className="mt-8 md:mt-12">
<h3 className="text-sm font-medium text-center mb-4 md:mb-6 text-muted-foreground">
<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">
@@ -319,7 +319,7 @@ export default async function Page() {
/>
</div>
<div className="min-w-0">
<h4 className="font-medium text-sm mb-0.5">
<h4 className="font-semibold text-sm mb-0.5">
{feature.title}
</h4>
<p className="text-xs text-muted-foreground leading-relaxed">
@@ -346,7 +346,7 @@ export default async function Page() {
<RiSmartphoneLine className="size-3.5 mr-1" />
Mobile
</Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
<h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
Use o OpenMonetis no celular sem perder o fluxo
</h2>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
@@ -529,7 +529,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4">
Stack técnica
</Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
<h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
O que roda por baixo
</h2>
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
@@ -556,7 +556,7 @@ export default async function Page() {
/>
</div>
<div>
<h3 className="font-medium text-base md:text-lg mb-1.5 md:mb-2">
<h3 className="font-semibold text-base md:text-lg mb-1.5 md:mb-2">
{item.title}
</h3>
<p className="text-sm text-muted-foreground mb-2 md:mb-3">
@@ -582,7 +582,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4">
Como usar
</Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
<h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
Rode no seu computador
</h2>
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
@@ -617,7 +617,7 @@ export default async function Page() {
<Badge variant="outline" className="mb-4">
Para quem é?
</Badge>
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
<h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
Feito para quem gosta de controle
</h2>
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
@@ -644,7 +644,7 @@ export default async function Page() {
/>
</div>
<div>
<h3 className="font-medium mb-1">{item.title}</h3>
<h3 className="font-semibold mb-1">{item.title}</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
{item.description}
</p>
@@ -664,7 +664,7 @@ export default async function Page() {
<div className="max-w-8xl mx-auto px-4">
<AnimateOnScroll>
<div className="mx-auto max-w-4xl rounded-2xl border bg-card px-8 py-12 md:py-16 text-center">
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
<h2 className="text-2xl sm:text-3xl md:text-4xl mb-3 md:mb-4 font-semibold">
Pronto para testar?
</h2>
<p className="text-base md:text-lg text-muted-foreground mb-6 md:mb-8">
@@ -715,7 +715,7 @@ export default async function Page() {
</div>
<div>
<h3 className="font-medium mb-3 md:mb-4">Projeto</h3>
<h3 className="font-semibold mb-3 md:mb-4">Projeto</h3>
<ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground">
<li>
<Link
@@ -749,7 +749,7 @@ export default async function Page() {
</div>
<div>
<h3 className="font-medium mb-3 md:mb-4">Companion</h3>
<h3 className="font-semibold mb-3 md:mb-4">Companion</h3>
<ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground">
<li>
<Link

View File

@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
import { fetchEstablishmentLogoDomain } from "@/shared/lib/logo/establishment-logo-queries";
/**
* GET /api/logo/mapping?name={name}
*
* Retorna o domínio Logo.dev salvo pelo usuário para um estabelecimento.
* Usado pelo EstablishmentLogo para hidratar o domain salvo no banco.
*/
export async function GET(request: Request) {
const session = await getOptionalUserSession();
if (!session) {
return NextResponse.json({ domain: null }, { status: 200 });
}
const { searchParams } = new URL(request.url);
const name = searchParams.get("name")?.trim();
if (!name) {
return NextResponse.json({ domain: null }, { status: 200 });
}
const domain = await fetchEstablishmentLogoDomain(session.user.id, name);
return NextResponse.json({ domain });
}

View File

@@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
const LOGO_DEV_SEARCH_URL = "https://api.logo.dev/search";
interface LogoResult {
name: string;
domain: string;
}
async function searchByStrategy(
q: string,
strategy: "match" | "typeahead",
secretKey: string,
): Promise<LogoResult[]> {
try {
const url = `${LOGO_DEV_SEARCH_URL}?q=${encodeURIComponent(q)}&strategy=${strategy}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${secretKey}` },
next: { revalidate: 3600 },
});
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
/**
* GET /api/logo/search?q={name}
*
* Proxy seguro para a Logo.dev Brand Search API.
* Faz duas buscas paralelas (match + typeahead) e retorna até 20 resultados únicos.
* Usa LOGO_DEV_SECRET_KEY server-side — nunca exposta ao cliente.
*/
export async function GET(request: Request) {
const session = await getOptionalUserSession();
if (!session) {
return NextResponse.json({ error: "Não autorizado." }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const q = searchParams.get("q")?.trim();
if (!q) {
return NextResponse.json(
{ error: "Parâmetro q obrigatório." },
{ status: 400 },
);
}
const secretKey = process.env.LOGO_DEV_SECRET_KEY;
if (!secretKey) {
return NextResponse.json(
{ error: "Logo.dev não configurado." },
{ status: 503 },
);
}
// Duas buscas paralelas para maximizar resultados (cada uma retorna até 10)
const [matchResults, typeaheadResults] = await Promise.all([
searchByStrategy(q, "match", secretKey),
searchByStrategy(q, "typeahead", secretKey),
]);
// Mescla e deduplica por domain, mantendo ordem (match tem prioridade)
const seen = new Set<string>();
const merged: LogoResult[] = [];
for (const result of [...matchResults, ...typeaheadResults]) {
if (!seen.has(result.domain)) {
seen.add(result.domain);
merged.push(result);
if (merged.length >= 20) break;
}
}
return NextResponse.json(merged);
}

View File

@@ -177,7 +177,7 @@
}
@theme inline {
--default-font-family: var(--font-america);
--default-font-family: var(--font-inter);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);

View File

@@ -4,7 +4,7 @@ import { QueryProvider } from "@/shared/components/providers/query-provider";
import { ThemeProvider } from "@/shared/components/providers/theme-provider";
import { Toaster } from "@/shared/components/ui/sonner";
import "./globals.css";
import { america } from "@/public/fonts/font_index";
import { inter } from "@/public/fonts/font_index";
export const metadata: Metadata = {
title: {
@@ -24,19 +24,23 @@ export default function RootLayout({
<html
data-scroll-behavior="smooth"
lang="pt-BR"
className={`${america.variable} ${america.className} `}
className={`${inter.variable}`}
suppressHydrationWarning
>
<head>
<meta name="apple-mobile-web-app-title" content="OpenMonetis" />
<script
defer
src="https://umami.felipecoutinho.com/script.js"
data-website-id="ea438854-a014-42ea-b416-0a8321471f0f"
data-domains="openmonetis.com"
/>
{process.env.UMAMI_URL && process.env.UMAMI_WEBSITE_ID && (
<script
defer
src={`${process.env.UMAMI_URL}/script.js`}
data-website-id={process.env.UMAMI_WEBSITE_ID}
{...(process.env.UMAMI_DOMAINS
? { "data-domains": process.env.UMAMI_DOMAINS }
: {})}
/>
)}
</head>
<body className="antialiased" suppressHydrationWarning>
<body className="subpixel-antialiased" suppressHydrationWarning>
<ThemeProvider attribute="class" defaultTheme="light">
<QueryProvider>
<Suspense>{children}</Suspense>

View File

@@ -721,6 +721,7 @@ export const userRelations = relations(user, ({ many, one }) => ({
installmentAnticipations: many(installmentAnticipations),
apiTokens: many(apiTokens),
inboxItems: many(inboxItems),
establishmentLogos: many(establishmentLogos),
}));
export const accountRelations = relations(account, ({ one }) => ({
@@ -955,6 +956,25 @@ export const importCategoryMappings = pgTable(
}),
);
export const establishmentLogos = pgTable(
"establishment_logos",
{
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
nameKey: text("name_key").notNull(),
domain: text("domain").notNull(),
updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => ({
pk: primaryKey({ columns: [table.userId, table.nameKey] }),
}),
);
export type EstablishmentLogo = typeof establishmentLogos.$inferSelect;
export type User = typeof user.$inferSelect;
export type NewUser = typeof user.$inferInsert;
export type Account = typeof account.$inferSelect;
@@ -1004,3 +1024,13 @@ export const transactionAttachmentsRelations = relations(
export type Attachment = typeof attachments.$inferSelect;
export type TransactionAttachment = typeof transactionAttachments.$inferSelect;
export const establishmentLogosRelations = relations(
establishmentLogos,
({ one }) => ({
user: one(user, {
fields: [establishmentLogos.userId],
references: [user.id],
}),
}),
);

View File

@@ -99,7 +99,7 @@ export async function createAccountAction(
if (hasInitialBalance && !adminPayerId) {
throw new Error(
"Payer com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial.",
"Pagador com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial.",
);
}
@@ -299,7 +299,7 @@ export async function transferBetweenAccountsAction(
if (!adminPayerId) {
throw new Error(
"Payer administrador não encontrado. Por favor, crie um pagador admin.",
"Pagador administrador não encontrado. Por favor, crie um pagador admin.",
);
}

View File

@@ -88,7 +88,9 @@ export function AccountCard({
{icon}
</div>
) : null}
<h2 className="text-lg font-medium text-foreground">{accountName}</h2>
<h2 className="text-lg font-semibold text-foreground">
{accountName}
</h2>
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
<Tooltip>

View File

@@ -68,7 +68,7 @@ export function AccountStatementCard({
</div>
) : null}
<div className="min-w-0">
<h2 className="truncate text-sm font-medium text-foreground">
<h2 className="truncate text-sm font-semibold text-foreground">
{accountName}
</h2>
<p className="text-xs text-muted-foreground">
@@ -81,12 +81,12 @@ export function AccountStatementCard({
{/* Linha 2 — saldo final (hero) */}
<div className="space-y-4">
<p className="text-sm font-medium text-muted-foreground ">
<p className="text-sm text-muted-foreground ">
Saldo ao final do período
</p>
<MoneyValues
amount={currentBalance}
className="text-3xl leading-none font-medium tracking-tight sm:text-[2rem]"
className="text-3xl leading-none tracking-tighter sm:text-[2rem]"
/>
<div className="flex items-center gap-2">
<Badge

View File

@@ -69,9 +69,7 @@ function PdfCanvas({ url }: PdfCanvasProps) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-muted/50">
<RiFilePdf2Line className="size-12 text-muted-foreground/40" />
<span className="text-xs font-medium text-muted-foreground/60">
PDF Protegido
</span>
<span className="text-xs text-muted-foreground/60">PDF Protegido</span>
</div>
);
}
@@ -153,7 +151,7 @@ export function AttachmentGridItem({
<Tooltip>
<TooltipTrigger asChild>
<p className="truncate text-sm font-medium leading-tight text-foreground">
<p className="truncate text-sm font-semibold leading-tight text-foreground">
{attachment.fileName}
</p>
</TooltipTrigger>
@@ -180,25 +178,21 @@ export function AttachmentGridItem({
{attachment.transactionName}
</TooltipContent>
</Tooltip>
<span
className={cn(
"shrink-0 text-sm font-medium tracking-tighter tabular-nums",
)}
>
<span className={cn("shrink-0 text-sm font-medium tracking-tighter")}>
{formatCurrency(amount)}
</span>
</div>
{/* Footer: Tamanho + Botão Detalhes */}
<div className="mt-auto flex items-center justify-between border-t pt-3">
<span className="text-xs font-medium text-muted-foreground/70">
<span className="text-xs text-muted-foreground/70">
{formatBytes(attachment.fileSize)}
</span>
<button
type="button"
onClick={onDetails}
disabled={isLoadingDetails}
className="text-xs font-medium text-muted-foreground/70 underline-offset-2 hover:underline focus-visible:outline-none disabled:opacity-50"
className="text-xs text-muted-foreground/70 underline-offset-2 hover:underline focus-visible:outline-none disabled:opacity-50"
>
{isLoadingDetails ? "Carregando..." : "Detalhes"}
</button>

View File

@@ -105,7 +105,7 @@ export function AttachmentPreview({
>
<RiArrowLeftSLine className="size-4" />
</Button>
<span className="select-none text-xs text-muted-foreground tabular-nums">
<span className="select-none text-xs text-muted-foreground">
{currentIndex + 1} / {attachments.length}
</span>
<Button

View File

@@ -19,8 +19,6 @@ import { TransactionDetailsDialog } from "@/features/transactions/components/dia
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
import type { TransactionItem } from "@/features/transactions/components/types";
import { EmptyState } from "@/shared/components/empty-state";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import PageDescription from "@/shared/components/page-description";
import { Card, CardContent } from "@/shared/components/ui/card";
import { cn } from "@/shared/utils/ui";
@@ -143,14 +141,6 @@ export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
return (
<div className="w-full space-y-6">
<PageDescription
icon={<RiAttachmentLine className="size-5" />}
title="Anexos"
subtitle="Comprovantes e documentos dos seus lançamentos no mês."
/>
<MonthNavigation />
<Card>
<CardContent>
{attachments.length === 0 ? (

View File

@@ -8,7 +8,7 @@ interface AuthHeaderProps {
export function AuthHeader({ title, description }: AuthHeaderProps) {
return (
<div className={cn("flex flex-col gap-2.5")}>
<h1 className="text-2xl font-medium tracking-tight text-card-foreground">
<h1 className="text-2xl font-semibold tracking-tight text-card-foreground">
{title}
</h1>
{description ? (

View File

@@ -52,7 +52,7 @@ export function BudgetCard({
size="lg"
/>
<div className="space-y-1">
<h3 className="text-base font-medium leading-tight">
<h3 className="text-base font-semibold leading-tight">
{formatCategoryName(budget)}
</h3>
<p className="text-xs text-muted-foreground">

View File

@@ -19,9 +19,9 @@ export function CalendarGrid({
}: CalendarGridProps) {
return (
<div className="overflow-hidden rounded-lg bg-card drop-shadow-xs border-none">
<div className="grid grid-cols-7 text-sm font-medium 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) => (
<span key={dayName} className="px-3 py-2 text-center text-primary">
<span key={dayName} className="px-3 py-2 text-center">
{dayName}
</span>
))}

View File

@@ -130,7 +130,7 @@ const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => (
<div className="flex flex-col gap-1">
<div className="flex gap-1 items-center">
<span className="text-sm font-medium leading-tight">
Vencimento Invoice - {event.card.name}
Vencimento Fatura - {event.card.name}
</span>
</div>

View File

@@ -136,7 +136,7 @@ export function CardItem({
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<h3 className="truncate text-sm font-medium text-foreground sm:text-base">
<h3 className="truncate text-sm font-semibold text-foreground sm:text-base">
{name}
</h3>
{note ? (
@@ -206,29 +206,29 @@ export function CardItem({
<>
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col items-start gap-1">
<p className="text-sm font-medium text-foreground">
<p className="text-sm font-semibold text-foreground">
<MoneyValues amount={metrics[0].value} />
</p>
<span className="text-xs font-medium text-muted-foreground">
<span className="text-xs text-muted-foreground">
{metrics[0].label}
</span>
</div>
<div className="flex flex-col items-center gap-1">
<p className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<p className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
<span className="size-2 rounded-full bg-primary" />
<MoneyValues amount={metrics[1].value} />
</p>
<span className="text-xs font-medium text-muted-foreground">
<span className="text-xs text-muted-foreground">
{metrics[1].label}
</span>
</div>
<div className="flex flex-col items-end gap-1">
<p className="text-sm font-medium text-foreground">
<p className="text-sm font-semibold text-foreground">
<MoneyValues amount={metrics[2].value} />
</p>
<span className="text-xs font-medium text-muted-foreground">
<span className="text-xs text-muted-foreground">
{metrics[2].label}
</span>
</div>

View File

@@ -183,7 +183,7 @@ export function CategoriesPage({ categories }: CategoriesPageProps) {
<TableCell className="font-medium">
<Link
href={`/categories/${category.id}`}
className="inline-flex items-center gap-1 underline-offset-2 hover:text-primary hover:underline"
className="inline-flex items-center gap-1 underline-offset-2 hover:text-primary hover:underline font-semibold"
>
{category.name}
<RiExternalLinkLine

View File

@@ -80,7 +80,7 @@ export function CategoryDetailHeader({
size="lg"
/>
<div className="space-y-2">
<h1 className="text-xl font-medium leading-tight">
<h1 className="text-xl font-semibold leading-tight">
{category.name}
</h1>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
@@ -99,7 +99,7 @@ export function CategoryDetailHeader({
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total em {currentPeriodLabel}
</p>
<p className="mt-1 text-2xl font-medium">
<p className="mt-1 text-2xl font-semibold">
{currencyFormatter.format(currentTotal)}
</p>
</div>
@@ -107,7 +107,7 @@ export function CategoryDetailHeader({
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total em {previousPeriodLabel}
</p>
<p className="mt-1 text-lg font-medium text-muted-foreground">
<p className="mt-1 text-lg font-semibold text-muted-foreground">
{currencyFormatter.format(previousTotal)}
</p>
</div>
@@ -117,7 +117,7 @@ export function CategoryDetailHeader({
</p>
<div
className={cn(
"mt-1 flex items-center gap-1 text-xl font-medium",
"mt-1 flex items-center gap-1 text-lg font-semibold",
variationColor,
)}
>

View File

@@ -80,7 +80,7 @@ export function CategoryPickerDialog({
<div className="flex max-h-96 flex-col gap-4 overflow-y-auto pr-1">
{filteredGroups.map((group) => (
<div key={group.label}>
<p className="mb-2 text-xs font-medium text-muted-foreground">
<p className="mb-2 text-xs text-muted-foreground">
{group.label}
</p>
<div className="grid grid-cols-8 gap-1.5">

View File

@@ -5,7 +5,10 @@ import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/shared/lib/accounts/constants";
import type { CategoryType } from "@/shared/lib/categories/constants";
import {
type CategoryType,
INVOICE_PAYMENT_CATEGORY_NAME,
} from "@/shared/lib/categories/constants";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { calculatePercentageChange } from "@/shared/utils/math";
@@ -45,6 +48,7 @@ export async function fetchCategoryDetails(
const previousPeriod = getPreviousPeriod(period);
const transactionType = category.type === "receita" ? "Receita" : "Despesa";
const adminPayerId = await getAdminPayerId(userId);
const isInvoiceCategory = category.name === INVOICE_PAYMENT_CATEGORY_NAME;
const sanitizedNote = or(
isNull(transactions.note),
@@ -59,7 +63,7 @@ export async function fetchCategoryDetails(
eq(transactions.transactionType, transactionType),
eq(transactions.period, period),
eq(transactions.payerId, adminPayerId),
sanitizedNote,
...(isInvoiceCategory ? [] : [sanitizedNote]),
),
with: {
payer: true,
@@ -108,7 +112,7 @@ export async function fetchCategoryDetails(
eq(transactions.categoryId, categoryId),
eq(transactions.transactionType, transactionType),
eq(transactions.payerId, adminPayerId),
sanitizedNote,
...(isInvoiceCategory ? [] : [sanitizedNote]),
eq(transactions.period, previousPeriod),
or(
isNull(transactions.note),

View File

@@ -0,0 +1,128 @@
"use client";
import {
RiAttachmentLine,
RiFileLine,
RiFilePdf2Line,
RiImageLine,
} from "@remixicon/react";
import { useState } from "react";
import { AttachmentPreview } from "@/features/attachments/components/attachment-preview";
import type { AttachmentForPeriod } from "@/features/attachments/queries";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
import { formatDateOnly } from "@/shared/utils/date";
import { formatBytes } from "@/shared/utils/number";
type AttachmentsSnapshot = {
totalCount: number;
totalBytes: number;
imageCount: number;
pdfCount: number;
recentAttachments: AttachmentForPeriod[];
};
type AttachmentsWidgetProps = {
snapshot: AttachmentsSnapshot;
};
export function AttachmentsWidget({ snapshot }: AttachmentsWidgetProps) {
const [selectedIndex, setSelectedIndex] = useState(-1);
if (snapshot.totalCount === 0) {
return (
<WidgetEmptyState
icon={<RiAttachmentLine className="size-6 text-muted-foreground" />}
title="Nenhum anexo no período"
description="Adicione comprovantes nos seus lançamentos para vê-los aqui."
/>
);
}
return (
<>
<div className="mb-2 flex flex-wrap gap-2">
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs text-muted-foreground">
<RiAttachmentLine className="size-3.5" />
{snapshot.totalCount} {snapshot.totalCount === 1 ? "anexo" : "anexos"}
</span>
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs text-muted-foreground">
{formatBytes(snapshot.totalBytes)}
</span>
{snapshot.imageCount > 0 && (
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs text-muted-foreground">
<RiImageLine className="size-3.5 text-blue-500" />
{snapshot.imageCount}
</span>
)}
{snapshot.pdfCount > 0 && (
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs text-muted-foreground">
<RiFilePdf2Line className="size-3.5 text-red-500" />
{snapshot.pdfCount}
</span>
)}
</div>
<ul className="flex flex-col">
{snapshot.recentAttachments.map((attachment, index) => {
const isPdf = attachment.mimeType === "application/pdf";
const isImage = attachment.mimeType.startsWith("image/");
return (
<li key={`${attachment.attachmentId}-${attachment.transactionId}`}>
<button
type="button"
onClick={() => setSelectedIndex(index)}
className="flex w-full items-center gap-2 py-2 text-left"
>
<div className="shrink-0">
{isPdf && <RiFilePdf2Line className="size-6 text-red-500" />}
{isImage && <RiImageLine className="size-6 text-blue-500" />}
{!isPdf && !isImage && (
<RiFileLine className="size-6 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="block truncate text-sm font-medium text-foreground hover:underline">
{attachment.fileName}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs break-all">
{attachment.fileName}
</TooltipContent>
</Tooltip>
<span className="block truncate text-xs text-muted-foreground">
{attachment.transactionName}
</span>
</div>
<div className="shrink-0 text-right">
<span className="block text-xs text-muted-foreground">
{formatDateOnly(attachment.purchaseDate, {
day: "2-digit",
month: "2-digit",
}) ?? "—"}
</span>
<span className="block text-xs text-muted-foreground/60">
{formatBytes(attachment.fileSize)}
</span>
</div>
</button>
</li>
);
})}
</ul>
<AttachmentPreview
attachments={snapshot.recentAttachments}
selectedIndex={selectedIndex}
onClose={() => setSelectedIndex(-1)}
/>
</>
);
}

View File

@@ -46,7 +46,7 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
<span
className={cn(
"cursor-help rounded-full py-0.5",
bill.isSettled && "text-success",
bill.isSettled && "text-success font-semibold",
)}
>
{statusLabel}
@@ -60,7 +60,7 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
<span
className={cn(
"rounded-full py-0.5",
bill.isSettled && "text-success",
bill.isSettled && "text-success font-semibold",
)}
>
{statusLabel}
@@ -72,7 +72,7 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
</div>
<div className="flex shrink-0 flex-col items-end">
<MoneyValues amount={bill.amount} />
<MoneyValues className="font-medium" amount={bill.amount} />
<Button
type="button"
size="sm"

View File

@@ -97,7 +97,7 @@ export function BillPaymentDialog({
<p className="mb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide">
Boleto
</p>
<p className="text-base font-medium text-foreground">
<p className="text-base font-semibold text-foreground">
{bill.name}
</p>
</div>
@@ -113,7 +113,7 @@ export function BillPaymentDialog({
</div>
<MoneyValues
amount={bill.amount}
className="text-lg font-medium"
className="text-lg font-semibold"
/>
</div>

View File

@@ -281,12 +281,12 @@ export function CategoryBreakdownWidgetView({
<div className="flex shrink-0 flex-col items-end gap-0.5">
<MoneyValues
className="text-foreground"
className="text-foreground font-medium"
amount={category.currentAmount}
/>
{category.percentageChange !== null ? (
<span
className={`flex items-center gap-0.5 text-xs ${changeClassName}`}
className={`flex items-center gap-0.5 text-xs font-medium ${changeClassName}`}
>
{hasIncrease ? (
<RiArrowUpSFill className="size-3" />

View File

@@ -197,7 +197,9 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
style={{ backgroundColor: color }}
/>
)}
<span className="text-foreground">{category.name}</span>
<span className="text-sm font-medium text-foreground">
{category.name}
</span>
<Button
variant="ghost"
size="sm"
@@ -398,7 +400,7 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
{config?.label}
</span>
</div>
<span className="text-xs font-medium tabular-nums">
<span className="text-xs font-medium">
{formatCurrency(value)}
</span>
</div>

View File

@@ -0,0 +1,84 @@
"use client";
import {
RiArrowDownSFill,
RiArrowUpSFill,
RiLineChartLine,
} from "@remixicon/react";
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown";
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
import { cn } from "@/shared/utils/ui";
type CategoryTrendsWidgetProps = {
categories: DashboardCategoryBreakdownItem[];
};
export function CategoryTrendsWidget({
categories,
}: CategoryTrendsWidgetProps) {
const trending = categories
.filter((c) => c.percentageChange !== null && c.previousAmount > 0)
.sort(
(a, b) =>
Math.abs(b.percentageChange ?? 0) - Math.abs(a.percentageChange ?? 0),
)
.slice(0, 10);
if (trending.length === 0) {
return (
<WidgetEmptyState
icon={<RiLineChartLine className="size-6 text-muted-foreground" />}
title="Dados insuficientes"
description="As variações aparecem após lançamentos em dois meses consecutivos."
/>
);
}
return (
<ul className="flex flex-col space-y-1">
{trending.map((category) => {
const change = category.percentageChange ?? 0;
const isUp = change > 0;
return (
<li key={category.categoryId}>
<div className="-mx-2 flex items-center gap-3 rounded-md p-2">
<CategoryIconBadge
icon={category.categoryIcon}
name={category.categoryName}
size="md"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{category.categoryName}
</p>
<p className="text-xs text-muted-foreground">
<MoneyValues amount={category.previousAmount} /> vs{" "}
<MoneyValues
amount={category.currentAmount}
className="font-semibold"
/>
</p>
</div>
<span
className={cn(
"inline-flex shrink-0 items-center gap-0.5 font-semibold text-sm",
isUp ? " text-destructive" : " text-success",
)}
>
{isUp ? (
<RiArrowUpSFill className="size-3.5" />
) : (
<RiArrowDownSFill className="size-3.5" />
)}
{Math.abs(change).toFixed(0)}%
</span>
</div>
</li>
);
})}
</ul>
);
}

View File

@@ -34,12 +34,12 @@ import {
type WidgetPreferences,
} from "@/features/dashboard/widgets/actions";
import {
type DashboardWidgetQuickActionOptions,
type WidgetConfig,
widgetsConfig,
} from "@/features/dashboard/widgets/widgets-config";
import { NoteDialog } from "@/features/notes/components/note-dialog";
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
import type { SelectOption } from "@/features/transactions/components/types";
import { ExpandableWidgetCard } from "@/shared/components/expandable-widget-card";
import { Button } from "@/shared/components/ui/button";
@@ -47,15 +47,7 @@ type DashboardGridEditableProps = {
data: DashboardData;
period: string;
initialPreferences: WidgetPreferences | null;
quickActionOptions: {
payerOptions: SelectOption[];
splitPayerOptions: SelectOption[];
defaultPayerId: string | null;
accountOptions: SelectOption[];
cardOptions: SelectOption[];
categoryOptions: SelectOption[];
estabelecimentos: string[];
};
quickActionOptions: DashboardWidgetQuickActionOptions;
};
const DEFAULT_WIDGET_ORDER = widgetsConfig.map((widget) => widget.id);
@@ -368,11 +360,16 @@ export function DashboardGridEditable({
{widget.component({
data,
period,
adminPayerSlug:
quickActionOptions.payerOptions.find(
(p) => p.value === quickActionOptions.defaultPayerId,
)?.slug ?? null,
widgetPreferences: {
order: widgetOrder,
hidden: hiddenWidgets,
myAccountsShowExcluded,
},
quickActionOptions,
onMyAccountsShowExcludedChange: setMyAccountsShowExcluded,
})}
</ExpandableWidgetCard>

View File

@@ -116,13 +116,14 @@ const getPercentChange = (current: number, previous: number): string => {
}
const change = ((current - previous) / Math.abs(previous)) * 100;
return Number.isFinite(change) && Math.abs(change) < 1000000
? formatPercentage(change, {
maximumFractionDigits: 1,
minimumFractionDigits: 1,
signDisplay: "always",
})
: "—";
if (!Number.isFinite(change)) return "—";
if (change > 999) return "+999%";
if (change < -999) return "-999%";
return formatPercentage(change, {
maximumFractionDigits: 2,
minimumFractionDigits: 2,
signDisplay: "always",
});
};
const getTrendBadgeClass = (trend: Trend, invertTrend: boolean): string => {
@@ -159,7 +160,7 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="flex items-center gap-1.5 tracking-tight">
<CardTitle className="flex items-center gap-1.5 ">
<Icon className={cn("size-4", iconClass)} aria-hidden />
{label}
<MetricsCardInfoButton
@@ -179,12 +180,12 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
<CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2 mt-1">
<MoneyValues
className="text-2xl leading-none"
className="text-2xl leading-none font-medium"
amount={metric.current}
/>
<div
className={cn(
"inline-flex items-center gap-1 text-xs ",
"inline-flex items-center gap-1 text-xs font-medium",
trendBadgeClass,
)}
>
@@ -195,7 +196,7 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
<div className="text-xs text-muted-foreground">
<MoneyValues
className="inline text-xs text-muted-foreground"
className="inline text-xs font-medium text-muted-foreground"
amount={metric.previous}
/>
<span className="ml-1">no mês anterior</span>

View File

@@ -1,17 +1,21 @@
import { formatCurrentDate, getGreeting } from "./welcome-widget";
export function DashboardWelcome({ name }: { name?: string | null }) {
type DashboardWelcomeProps = {
name?: string | null;
};
export function DashboardWelcome({ name }: DashboardWelcomeProps) {
const displayName = name && name.trim().length > 0 ? name : "Administrador";
const formattedDate = formatCurrentDate();
const greeting = getGreeting();
return (
<section className="py-4">
<div className="tracking-tight">
<h1 className="text-xl font-medium">
<div>
<h1 className="text-xl tracking-tight">
{greeting}, {displayName}
</h1>
<h2 className="text-sm mt-1 text-muted-foreground">{formattedDate}</h2>
<h2 className="mt-1 text-sm text-muted-foreground">{formattedDate}</h2>
</div>
</section>
);

View File

@@ -44,8 +44,9 @@ export function GoalProgressItem({
{item.categoryName}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<MoneyValues amount={item.spentAmount} /> de{" "}
<MoneyValues amount={item.budgetAmount} />
<MoneyValues className="font-medium" amount={item.spentAmount} />{" "}
de{" "}
<MoneyValues className="font-medium" amount={item.budgetAmount} />
<span className={`ml-1.5 font-medium ${deltaColor}`}>
{formatGoalProgressPercentage(percentageDelta, true)}
</span>

View File

@@ -0,0 +1,268 @@
"use client";
import {
RiCheckboxCircleFill,
RiCheckLine,
RiDeleteBinLine,
} from "@remixicon/react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import type { DashboardInboxSnapshot } from "@/features/dashboard/inbox-snapshot-queries";
import type { DashboardWidgetQuickActionOptions } from "@/features/dashboard/widgets/widgets-config";
import {
discardInboxItemAction,
markInboxAsProcessedAction,
} from "@/features/inbox/actions";
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import MoneyValues from "@/shared/components/money-values";
import { Button } from "@/shared/components/ui/button";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
import { resolveLogoSrc } from "@/shared/lib/logo";
const DEFAULT_INBOX_APP_LOGO = "/avatars/default_icon.png";
function relativeTime(date: Date): string {
const diff = Date.now() - date.getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "agora";
if (minutes < 60) return `${minutes}min`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
return `${days}d`;
}
type InboxWidgetProps = {
snapshot: DashboardInboxSnapshot;
quickActionOptions: DashboardWidgetQuickActionOptions;
};
function getDateString(date: Date | string | null | undefined): string | null {
if (!date) return null;
if (typeof date === "string") return date.slice(0, 10);
return date.toISOString().slice(0, 10);
}
export function InboxWidget({
snapshot,
quickActionOptions,
}: InboxWidgetProps) {
const router = useRouter();
const [processOpen, setProcessOpen] = useState(false);
const [discardOpen, setDiscardOpen] = useState(false);
const [itemToProcess, setItemToProcess] = useState<
DashboardInboxSnapshot["recentItems"][number] | null
>(null);
const [itemToDiscard, setItemToDiscard] = useState<
DashboardInboxSnapshot["recentItems"][number] | null
>(null);
const handleProcessOpenChange = (open: boolean) => {
setProcessOpen(open);
if (!open) setItemToProcess(null);
};
const handleDiscardOpenChange = (open: boolean) => {
setDiscardOpen(open);
if (!open) setItemToDiscard(null);
};
const handleProcessRequest = (
item: DashboardInboxSnapshot["recentItems"][number],
) => {
setItemToProcess(item);
setProcessOpen(true);
};
const handleDiscardRequest = (
item: DashboardInboxSnapshot["recentItems"][number],
) => {
setItemToDiscard(item);
setDiscardOpen(true);
};
const refreshWidget = () => {
router.refresh();
};
const handleDiscardConfirm = async () => {
if (!itemToDiscard) return;
const result = await discardInboxItemAction({
inboxItemId: itemToDiscard.id,
});
if (result.success) {
toast.success(result.message);
refreshWidget();
return;
}
toast.error(result.error);
throw new Error(result.error);
};
const handleLancamentoSuccess = async () => {
if (!itemToProcess) return;
const result = await markInboxAsProcessedAction({
inboxItemId: itemToProcess.id,
});
if (result.success) {
toast.success("Notificação processada!");
refreshWidget();
return;
}
toast.error(result.error);
};
const defaultPurchaseDate =
getDateString(itemToProcess?.notificationTimestamp) ?? null;
const defaultName = itemToProcess?.parsedName
? itemToProcess.parsedName
.toLowerCase()
.replace(/\b\w/g, (char) => char.toUpperCase())
: null;
const defaultAmount = itemToProcess?.parsedAmount
? String(Math.abs(Number(itemToProcess.parsedAmount)))
: null;
const matchedCardId = useMemo(() => {
const appName = itemToProcess?.sourceAppName?.toLowerCase();
if (!appName) return null;
for (const option of quickActionOptions.cardOptions) {
const label = option.label.toLowerCase();
if (label.includes(appName) || appName.includes(label)) {
return option.value;
}
}
return null;
}, [itemToProcess?.sourceAppName, quickActionOptions.cardOptions]);
if (snapshot.pendingCount === 0) {
return (
<WidgetEmptyState
icon={<RiCheckboxCircleFill color="green" className="size-6" />}
title="Tudo em dia"
description="Nenhum pré-lançamento aguardando revisão."
/>
);
}
return (
<div className="flex flex-col">
{snapshot.recentItems.map((item) => {
const displayName = item.parsedName ?? item.originalText.slice(0, 40);
const parsedAmount =
item.parsedAmount !== null
? Number.parseFloat(item.parsedAmount)
: null;
const amount =
parsedAmount !== null && Number.isFinite(parsedAmount)
? parsedAmount
: null;
const logoKey = item.sourceAppName?.toLowerCase() ?? "";
const rawLogo = snapshot.logoMap[logoKey] ?? null;
const logoSrc = resolveLogoSrc(rawLogo);
const displayLogo = logoSrc ?? DEFAULT_INBOX_APP_LOGO;
return (
<div
key={item.id}
className="flex items-center justify-between py-1.5"
>
<div className="flex min-w-0 flex-1 items-center gap-2 py-1">
<Image
src={displayLogo}
alt={item.sourceAppName ?? ""}
width={38}
height={38}
className="size-9.5 shrink-0 rounded-full object-contain"
unoptimized
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{displayName}
</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{item.sourceAppName && <span>{item.sourceAppName}</span>}
<span className="text-muted-foreground/60">
{relativeTime(item.createdAt)}
</span>
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end">
{amount !== null && (
<MoneyValues className="font-medium" amount={amount} />
)}
<div className="flex items-center">
<Button
size="icon-sm"
variant="ghost"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => handleProcessRequest(item)}
aria-label="Processar notificação"
title="Processar"
>
<RiCheckLine className="size-3.5" />
</Button>
<Button
size="icon-sm"
variant="ghost"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={() => handleDiscardRequest(item)}
aria-label="Descartar notificação"
title="Descartar"
>
<RiDeleteBinLine className="size-3.5" />
</Button>
</div>
</div>
</div>
);
})}
<TransactionDialog
mode="create"
open={processOpen}
onOpenChange={handleProcessOpenChange}
payerOptions={quickActionOptions.payerOptions}
splitPayerOptions={quickActionOptions.splitPayerOptions}
defaultPayerId={quickActionOptions.defaultPayerId}
accountOptions={quickActionOptions.accountOptions}
cardOptions={quickActionOptions.cardOptions}
categoryOptions={quickActionOptions.categoryOptions}
estabelecimentos={quickActionOptions.estabelecimentos}
defaultPurchaseDate={defaultPurchaseDate}
defaultName={defaultName}
defaultAmount={defaultAmount}
defaultCardId={matchedCardId}
defaultPaymentMethod={matchedCardId ? "Cartão de crédito" : null}
defaultTransactionType="Despesa"
forceShowTransactionType
onSuccess={handleLancamentoSuccess}
/>
<ConfirmActionDialog
open={discardOpen}
onOpenChange={handleDiscardOpenChange}
title="Descartar notificação?"
description="A notificação será marcada como descartada e não aparecerá mais na lista de pendentes."
confirmLabel="Descartar"
confirmVariant="destructive"
pendingLabel="Descartando..."
onConfirm={handleDiscardConfirm}
/>
</div>
);
}

View File

@@ -132,12 +132,12 @@ export function InstallmentAnalysisPage({
{/* Card de resumo principal */}
<Card className="border-none bg-primary/15">
<CardContent className="flex flex-col items-start justify-center gap-2 py-2">
<p className="text-sm font-medium text-muted-foreground">
<p className="text-sm text-muted-foreground">
Se você pagar tudo que está selecionado:
</p>
<MoneyValues
amount={grandTotal}
className="text-3xl font-medium text-primary"
className="text-3xl font-semibold text-primary"
/>
<p className="text-sm text-muted-foreground">
{selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}{" "}
@@ -167,7 +167,7 @@ export function InstallmentAnalysisPage({
{/* Seção de Lançamentos Parcelados */}
{data.installmentGroups.length > 0 && (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{data.installmentGroups.map((group) => (
<InstallmentGroupCard
key={group.seriesId}

View File

@@ -1,19 +1,35 @@
"use client";
import {
RiArrowDownSLine,
RiArrowRightSLine,
RiBankCard2Line,
RiCheckboxCircleFill,
RiEyeLine,
RiTimeLine,
} from "@remixicon/react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import Image from "next/image";
import { useState } from "react";
import MoneyValues from "@/shared/components/money-values";
import { Badge } from "@/shared/components/ui/badge";
import { Card, CardContent } from "@/shared/components/ui/card";
import { Button } from "@/shared/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { Checkbox } from "@/shared/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Progress } from "@/shared/components/ui/progress";
import { cn } from "@/shared/utils/ui";
import { cn } from "@/shared/utils";
import type { InstallmentGroup } from "./types";
type InstallmentGroupCardProps = {
@@ -29,18 +45,22 @@ export function InstallmentGroupCard({
onToggleGroup,
onToggleInstallment,
}: InstallmentGroupCardProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
const unpaidInstallments = group.pendingInstallments.filter(
(i) => !i.isSettled,
);
const paidInstallments = group.pendingInstallments.filter((i) => i.isSettled);
const unpaidCount = unpaidInstallments.length;
const isFullySelected =
selectedInstallments.size === unpaidInstallments.length &&
unpaidInstallments.length > 0;
const isPartiallySelected = selectedInstallments.size > 0 && !isFullySelected;
const hasSelection = selectedInstallments.size > 0;
const progress =
group.totalInstallments > 0
? (group.paidInstallments / group.totalInstallments) * 100
@@ -50,186 +70,304 @@ export function InstallmentGroupCard({
.filter((i) => selectedInstallments.has(i.id) && !i.isSettled)
.reduce((sum, i) => sum + Number(i.amount), 0);
// Calcular valor total de todas as parcelas (pagas + pendentes)
const totalAmount = group.pendingInstallments.reduce(
(sum, i) => sum + i.amount,
0,
);
// Calcular valor pendente (apenas não pagas)
const pendingAmount = unpaidInstallments.reduce(
(sum, i) => sum + i.amount,
0,
);
return (
<Card className={cn(isFullySelected && "border-primary/50")}>
<CardContent className="flex flex-col gap-2">
{/* Header do card */}
<div className="flex items-start gap-3">
<Checkbox
checked={isFullySelected}
onCheckedChange={onToggleGroup}
className="mt-1"
aria-label={`Selecionar todas as parcelas de ${group.name}`}
/>
<>
<Card
className={cn(
"overflow-hidden transition-all duration-300",
isFullySelected && "ring-2 ring-primary/30 border-primary/50",
isPartiallySelected && "border-primary/30",
)}
>
{/* Header Section */}
<CardHeader className="pb-0">
<div className="flex items-start gap-2">
{/* Checkbox de seleção do grupo */}
<div className="pt-1">
<Checkbox
checked={
isFullySelected
? true
: isPartiallySelected
? "indeterminate"
: false
}
onCheckedChange={onToggleGroup}
className="size-4"
aria-label={`Selecionar todas as parcelas de ${group.name}`}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-col gap-1">
<div className="flex gap-1 items-center flex-wrap">
{group.cartaoLogo && (
<img
{/* Info principal */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap">
{group.cartaoLogo ? (
<Image
src={`/logos/${group.cartaoLogo}`}
alt={group.cartaoName ?? "Cartão"}
className="h-6 w-auto object-contain rounded"
width={40}
height={40}
className="size-10 rounded-full object-cover"
/>
) : (
<div className="size-10 flex items-center justify-center">
<RiBankCard2Line className="size-5 text-muted-foreground" />
</div>
)}
<span className="font-medium truncate">{group.name}</span>
<span className="text-xs text-muted-foreground">
| {group.cartaoName}
</span>
</div>
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">Total:</span>
<MoneyValues
amount={totalAmount}
className="text-base font-medium"
/>
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">
Pendente:
</span>
<MoneyValues
amount={pendingAmount}
className="text-sm font-medium text-primary"
/>
<div className="flex-1 min-w-0">
<CardTitle className="text-base truncate">
{group.name}
</CardTitle>
<CardDescription className="text-xs">
{group.cartaoName ?? "Compra parcelada"}
</CardDescription>
</div>
</div>
</div>
{/* Progress bar */}
<div className="mt-3">
<div className="mb-2 flex flex-wrap items-center px-1 justify-between gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
{/* Badge de status */}
<Badge
variant={progress === 100 ? "default" : "outline"}
className={cn("shrink-0", progress === 100 && "bg-success")}
>
{progress === 100 ? "Quitado" : `${Math.round(progress)}% pago`}
</Badge>
</div>
</CardHeader>
<CardContent>
{/* Grid de valores */}
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-muted/50 mb-4">
<div className="space-y-1">
<p className="text-xs text-muted-foreground font-medium">
Valor total
</p>
<MoneyValues
amount={totalAmount}
className="text-lg font-semibold text-foreground"
/>
</div>
<div className="space-y-1 text-right">
<p className="text-xs text-muted-foreground font-medium">
Pendente
</p>
<MoneyValues
amount={pendingAmount}
className={cn(
"text-lg font-semibold",
pendingAmount > 0 ? "text-amber-600" : "text-success-600",
)}
/>
</div>
</div>
{/* Barra de progresso */}
<div className="space-y-2 mb-4">
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1 text-muted-foreground">
<RiCheckboxCircleFill className="size-3.5 text-success" />
<span>
{group.paidInstallments} de {group.totalInstallments} pagas
{group.paidInstallments} de {group.totalInstallments} parcelas
pagas
</span>
<div className="flex items-center gap-2 flex-wrap">
</div>
{unpaidCount > 0 && (
<div className="flex items-center gap-1 text-muted-foreground">
<RiTimeLine className="size-3.5 text-amber-600" />
<span>
{unpaidCount} {unpaidCount === 1 ? "pendente" : "pendentes"}
</span>
{selectedInstallments.size > 0 && (
<span className="text-primary font-medium">
Selecionado:{" "}
<MoneyValues
amount={selectedAmount}
className="text-xs font-medium text-primary inline"
/>
</span>
)}
</div>
</div>
<Progress value={progress} className="h-2" />
</div>
{/* Botão de expandir */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="mt-2 flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{isExpanded ? (
<>
<RiArrowDownSLine className="size-4" />
Ocultar parcelas ({group.pendingInstallments.length})
</>
) : (
<>
<RiArrowRightSLine className="size-4" />
Ver parcelas ({group.pendingInstallments.length})
</>
)}
</button>
</div>
<Progress value={progress} className="h-2.5" />
</div>
</div>
{/* Lista de parcelas expandida */}
{isExpanded && (
<div className="px-2 sm:px-8 mt-2 flex flex-col gap-2">
{group.pendingInstallments.map((installment) => {
const isSelected = selectedInstallments.has(installment.id);
const isPaid = installment.isSettled;
const dueDate = installment.dueDate
? format(installment.dueDate, "dd/MM/yyyy", { locale: ptBR })
: format(installment.purchaseDate, "dd/MM/yyyy", {
locale: ptBR,
});
{/* Valor selecionado */}
{hasSelection && (
<div className="flex items-center justify-between p-3 rounded-lg bg-primary/5 border border-primary/20 mb-4">
<span className="text-sm font-medium text-foreground">
{selectedInstallments.size}{" "}
{selectedInstallments.size === 1
? "parcela selecionada"
: "parcelas selecionadas"}
</span>
<MoneyValues
amount={selectedAmount}
className="text-base font-semibold text-primary"
/>
</div>
)}
return (
<div
key={installment.id}
className={cn(
"flex items-center gap-3 rounded-md border p-2 transition-colors",
isSelected && !isPaid && "border-primary/50 bg-primary/5",
isPaid &&
"border-success/40 bg-success/5 dark:border-success/20 dark:bg-success/5",
)}
>
<Checkbox
checked={isPaid ? false : isSelected}
disabled={isPaid}
onCheckedChange={() =>
!isPaid && onToggleInstallment(installment.id)
}
aria-label={`Selecionar parcela ${installment.currentInstallment} de ${group.totalInstallments}`}
/>
{/* Botão para abrir detalhes */}
<Button
type="button"
variant="outline"
size="sm"
className="w-full gap-1.5"
onClick={() => setIsDetailsOpen(true)}
>
<RiEyeLine className="size-4" />
Ver detalhes ({group.pendingInstallments.length} parcelas)
</Button>
</CardContent>
</Card>
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
<div className="min-w-0">
<p
className={cn(
"text-xs font-medium",
isPaid &&
"text-success line-through decoration-success/50",
)}
>
Parcela {installment.currentInstallment}/
{group.totalInstallments}
{isPaid && (
<Badge
variant="outline"
className="ml-1 text-xs border-none text-success"
>
<RiCheckboxCircleFill /> Pago
</Badge>
)}
</p>
<p
className={cn(
"text-xs mt-1",
isPaid ? "text-success" : "text-muted-foreground",
)}
>
Vencimento: {dueDate}
</p>
</div>
<MoneyValues
amount={installment.amount}
className={cn(
"shrink-0 text-sm",
isPaid && "text-success",
)}
/>
</div>
{/* Modal de detalhes */}
<Dialog open={isDetailsOpen} onOpenChange={setIsDetailsOpen}>
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
<DialogHeader>
<div className="flex items-center gap-3">
{group.cartaoLogo ? (
<img
src={`/logos/${group.cartaoLogo}`}
alt={group.cartaoName ?? "Cartão"}
className="size-8 rounded-full object-cover"
/>
) : (
<div className="size-8 rounded-full bg-muted flex items-center justify-center">
<RiBankCard2Line className="size-4 text-muted-foreground" />
</div>
);
})}
)}
<DialogTitle className="text-base">{group.name}</DialogTitle>
</div>
<DialogDescription className="sr-only">
Detalhes das parcelas do grupo {group.name}
</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto flex-1 space-y-4 pr-1">
{/* Parcelas pagas */}
{paidInstallments.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Parcelas pagas
</p>
{paidInstallments.map((installment) => {
const dueDate = installment.dueDate
? format(installment.dueDate, "dd MMM yyyy", {
locale: ptBR,
})
: format(installment.purchaseDate, "dd MMM yyyy", {
locale: ptBR,
});
return (
<div
key={installment.id}
className="flex items-center gap-3 p-3 rounded-lg bg-success/5 dark:bg-success/10 border border-success/20 dark:border-success/10"
>
<div className="size-8 rounded-full flex items-center justify-center shrink-0">
<RiCheckboxCircleFill className="size-6 text-success" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-success">
Parcela {installment.currentInstallment}/
{group.totalInstallments}
</p>
<p className="text-xs text-success/80">
Vencimento: {dueDate}
</p>
</div>
<MoneyValues
amount={installment.amount}
className="text-sm font-semibold text-success shrink-0"
/>
</div>
);
})}
</div>
)}
{/* Parcelas pendentes */}
{unpaidInstallments.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Parcelas pendentes
</p>
{unpaidInstallments.map((installment) => {
const isSelected = selectedInstallments.has(installment.id);
const dueDate = installment.dueDate
? format(installment.dueDate, "dd MMM yyyy", {
locale: ptBR,
})
: format(installment.purchaseDate, "dd MMM yyyy", {
locale: ptBR,
});
return (
<label
key={installment.id}
className={cn(
"flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all duration-200",
isSelected
? "bg-primary/5 border-primary/30 shadow-sm"
: "bg-card hover:bg-muted/50 hover:border-border",
)}
>
<Checkbox
checked={isSelected}
onCheckedChange={() =>
onToggleInstallment(installment.id)
}
className="size-5"
aria-label={`Selecionar parcela ${installment.currentInstallment} de ${group.totalInstallments}`}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">
Parcela {installment.currentInstallment}/
{group.totalInstallments}
</p>
<p className="text-xs text-muted-foreground flex items-center gap-1">
<RiTimeLine className="size-3 text-amber-600" />
Vencimento: {dueDate}
</p>
</div>
<MoneyValues
amount={installment.amount}
className={cn(
"text-sm font-semibold shrink-0",
isSelected ? "text-primary" : "text-foreground",
)}
/>
</label>
);
})}
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* Footer com resumo da seleção */}
{hasSelection && (
<div className="border-t pt-3 mt-1 flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{selectedInstallments.size}{" "}
{selectedInstallments.size === 1
? "parcela selecionada"
: "parcelas selecionadas"}
</span>
<MoneyValues
amount={selectedAmount}
className="text-base font-bold text-primary"
/>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -59,7 +59,10 @@ export function InstallmentExpenseListItem({
</span>
) : null}
</div>
<MoneyValues amount={expense.amount} className="shrink-0" />
<MoneyValues
amount={expense.amount}
className="shrink-0 font-medium"
/>
</div>
<p className="text-xs text-muted-foreground">
@@ -67,7 +70,7 @@ export function InstallmentExpenseListItem({
{" · Restante "}
<MoneyValues
amount={remainingAmount}
className="inline-block font-medium"
className="inline-block font-semibold"
/>{" "}
({remainingInstallments})
</p>

View File

@@ -116,7 +116,10 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
</p>
</div>
<div className="text-sm font-medium text-foreground">
<MoneyValues amount={share.amount} />
<MoneyValues
className="font-medium"
amount={share.amount}
/>
</div>
</li>
))}
@@ -144,7 +147,7 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
paymentTooltipLabel ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-help text-success">
<span className="cursor-help text-success font-semibold">
{paymentInfo.label}
</span>
</TooltipTrigger>
@@ -153,7 +156,9 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
</TooltipContent>
</Tooltip>
) : (
<span className="text-success">{paymentInfo.label}</span>
<span className="text-success font-semibold">
{paymentInfo.label}
</span>
)
) : null}
</div>
@@ -161,7 +166,10 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
</div>
<div className="flex shrink-0 flex-col items-end">
<MoneyValues amount={Math.abs(invoice.totalAmount)} />
<MoneyValues
className="font-medium"
amount={Math.abs(invoice.totalAmount)}
/>
<Button
type="button"
size="sm"

View File

@@ -113,7 +113,7 @@ export function InvoicePaymentDialog({
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Cartão
</p>
<p className="truncate text-base font-medium text-foreground">
<p className="truncate text-base font-semibold text-foreground">
{invoice.cardName}
</p>
</div>
@@ -130,7 +130,7 @@ export function InvoicePaymentDialog({
</div>
<MoneyValues
amount={Math.abs(invoice.totalAmount)}
className="text-lg font-medium"
className="text-lg font-semibold"
/>
</div>

View File

@@ -78,7 +78,7 @@ export function MyAccountsWidget({
<div className="flex items-start justify-between gap-3 py-1">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Saldo Total</p>
<MoneyValues className="text-2xl" amount={totalBalance} />
<MoneyValues className="text-2xl font-medium" amount={totalBalance} />
</div>
{excludedAccountsCount > 0 ? (
@@ -137,7 +137,7 @@ export function MyAccountsWidget({
</div>
) : (
<ul className="flex flex-col">
{displayedAccounts.map((account) => {
{displayedAccounts.map((account, index) => {
const logoSrc = resolveLogoSrc(account.logo);
return (
@@ -154,6 +154,7 @@ export function MyAccountsWidget({
fill
sizes="38px"
className="object-contain rounded-full"
priority={index === 0}
/>
) : null}
</div>
@@ -199,7 +200,10 @@ export function MyAccountsWidget({
</div>
<div className="flex flex-col items-end gap-0.5 text-right">
<MoneyValues amount={account.balance} />
<MoneyValues
className="font-medium"
amount={account.balance}
/>
</div>
</div>
);

View File

@@ -83,10 +83,13 @@ export function PayersWidget({ payers }: PayersWidgetProps) {
</div>
<div className="flex shrink-0 flex-col items-end">
<MoneyValues amount={payer.totalExpenses} />
<MoneyValues
className="font-medium"
amount={payer.totalExpenses}
/>
{percentageChange !== null && (
<span
className={`flex items-center gap-0.5 text-xs ${
className={`flex items-center gap-0.5 text-xs font-medium ${
percentageChange > 0
? "text-destructive"
: percentageChange < 0

View File

@@ -8,11 +8,15 @@ import { PaymentOverviewWidgetView } from "./payment-overview/payment-overview-w
type PaymentOverviewWidgetProps = {
paymentConditionsData: PaymentConditionsData;
paymentMethodsData: PaymentMethodsData;
period: string;
adminPayerSlug: string | null;
};
export function PaymentOverviewWidget({
paymentConditionsData,
paymentMethodsData,
period,
adminPayerSlug,
}: PaymentOverviewWidgetProps) {
const { activeTab, handleTabChange } = usePaymentOverviewWidgetController();
@@ -22,6 +26,8 @@ export function PaymentOverviewWidget({
paymentConditionsData={paymentConditionsData}
paymentMethodsData={paymentMethodsData}
onTabChange={handleTabChange}
period={period}
adminPayerSlug={adminPayerSlug}
/>
);
}

View File

@@ -1,3 +1,5 @@
import { RiExternalLinkLine } from "@remixicon/react";
import Link from "next/link";
import type { ReactNode } from "react";
import {
formatPaymentBreakdownPercentage,
@@ -17,6 +19,7 @@ export type PaymentBreakdownListItemData = {
amount: number;
transactions: number;
percentage: number;
href?: string;
};
type PaymentBreakdownListItemProps = {
@@ -40,8 +43,21 @@ export function PaymentBreakdownListItem({
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-foreground">{item.title}</p>
<MoneyValues amount={item.amount} />
{item.href ? (
<Link
href={item.href}
className="inline-flex items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
>
<span className="truncate">{item.title}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
) : (
<p className="text-sm font-medium text-foreground">{item.title}</p>
)}
<MoneyValues className="font-medium" amount={item.amount} />
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">

View File

@@ -1,6 +1,8 @@
import { RiCheckLine, RiSlideshowLine } from "@remixicon/react";
import type { PaymentConditionsData } from "@/features/dashboard/payments/payment-conditions-queries";
import { getConditionIcon } from "@/shared/utils/icons";
import { formatPeriodForUrl } from "@/shared/utils/period";
import { slugify } from "@/shared/utils/string";
import {
PaymentBreakdownList,
type PaymentBreakdownListItemData,
@@ -8,6 +10,8 @@ import {
type PaymentConditionsWidgetProps = {
data: PaymentConditionsData;
period: string;
adminPayerSlug: string | null;
};
const resolveConditionIcon = (condition: string) =>
@@ -15,16 +19,27 @@ const resolveConditionIcon = (condition: string) =>
export function PaymentConditionsWidget({
data,
period,
adminPayerSlug,
}: PaymentConditionsWidgetProps) {
const items: PaymentBreakdownListItemData[] = data.conditions.map(
(condition) => ({
id: condition.condition,
title: condition.condition,
icon: resolveConditionIcon(condition.condition),
amount: condition.amount,
transactions: condition.transactions,
percentage: condition.percentage,
}),
(condition) => {
const params = new URLSearchParams({
type: slugify("Despesa"),
condition: slugify(condition.condition),
periodo: formatPeriodForUrl(period),
});
if (adminPayerSlug) params.set("payer", adminPayerSlug);
return {
id: condition.condition,
title: condition.condition,
icon: resolveConditionIcon(condition.condition),
amount: condition.amount,
transactions: condition.transactions,
percentage: condition.percentage,
href: `/transactions?${params.toString()}`,
};
},
);
return (

View File

@@ -1,6 +1,8 @@
import { RiBankCard2Line, RiMoneyDollarCircleLine } from "@remixicon/react";
import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-methods-queries";
import { getPaymentMethodIcon } from "@/shared/utils/icons";
import { formatPeriodForUrl } from "@/shared/utils/period";
import { slugify } from "@/shared/utils/string";
import {
PaymentBreakdownList,
type PaymentBreakdownListItemData,
@@ -8,6 +10,8 @@ import {
type PaymentMethodsWidgetProps = {
data: PaymentMethodsData;
period: string;
adminPayerSlug: string | null;
};
const resolvePaymentMethodIcon = (paymentMethod: string) =>
@@ -15,15 +19,28 @@ const resolvePaymentMethodIcon = (paymentMethod: string) =>
<RiBankCard2Line className="size-5" aria-hidden />
);
export function PaymentMethodsWidget({ data }: PaymentMethodsWidgetProps) {
const items: PaymentBreakdownListItemData[] = data.methods.map((method) => ({
id: method.paymentMethod,
title: method.paymentMethod,
icon: resolvePaymentMethodIcon(method.paymentMethod),
amount: method.amount,
transactions: method.transactions,
percentage: method.percentage,
}));
export function PaymentMethodsWidget({
data,
period,
adminPayerSlug,
}: PaymentMethodsWidgetProps) {
const items: PaymentBreakdownListItemData[] = data.methods.map((method) => {
const params = new URLSearchParams({
type: slugify("Despesa"),
payment: slugify(method.paymentMethod),
periodo: formatPeriodForUrl(period),
});
if (adminPayerSlug) params.set("payer", adminPayerSlug);
return {
id: method.paymentMethod,
title: method.paymentMethod,
icon: resolvePaymentMethodIcon(method.paymentMethod),
amount: method.amount,
transactions: method.transactions,
percentage: method.percentage,
href: `/transactions?${params.toString()}`,
};
});
return (
<PaymentBreakdownList

View File

@@ -16,6 +16,8 @@ type PaymentOverviewWidgetViewProps = {
paymentConditionsData: PaymentConditionsData;
paymentMethodsData: PaymentMethodsData;
onTabChange: (value: string) => void;
period: string;
adminPayerSlug: string | null;
};
export function PaymentOverviewWidgetView({
@@ -23,6 +25,8 @@ export function PaymentOverviewWidgetView({
paymentConditionsData,
paymentMethodsData,
onTabChange,
period,
adminPayerSlug,
}: PaymentOverviewWidgetViewProps) {
return (
<Tabs value={activeTab} onValueChange={onTabChange} className="w-full">
@@ -38,11 +42,19 @@ export function PaymentOverviewWidgetView({
</TabsList>
<TabsContent value="conditions" className="mt-2">
<PaymentConditionsWidget data={paymentConditionsData} />
<PaymentConditionsWidget
data={paymentConditionsData}
period={period}
adminPayerSlug={adminPayerSlug}
/>
</TabsContent>
<TabsContent value="methods" className="mt-2">
<PaymentMethodsWidget data={paymentMethodsData} />
<PaymentMethodsWidget
data={paymentMethodsData}
period={period}
adminPayerSlug={adminPayerSlug}
/>
</TabsContent>
</Tabs>
);

View File

@@ -24,10 +24,7 @@ export function PaymentStatusCategorySection({
<div className="mt-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">{title}</span>
<MoneyValues
amount={total}
className="text-sm font-medium tabular-nums"
/>
<MoneyValues amount={total} className="font-medium" />
</div>
<Progress value={confirmedPercentage} className="h-2" />
@@ -35,13 +32,13 @@ export function PaymentStatusCategorySection({
<div className="flex flex-col gap-1 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-1.5">
<StatusDot color="bg-primary" />
<MoneyValues amount={confirmed} className="tabular-nums" />
<MoneyValues amount={confirmed} className="font-medium" />
<span className="text-xs text-muted-foreground">confirmados</span>
</div>
<div className="flex items-center gap-1.5">
<StatusDot color="bg-warning/40" />
<MoneyValues amount={pending} className="tabular-nums" />
<MoneyValues amount={pending} className="font-medium" />
<span className="text-xs text-muted-foreground">pendentes</span>
</div>
</div>

View File

@@ -178,7 +178,10 @@ export function PurchasesByCategoryWidget({
</div>
<div className="shrink-0 text-foreground">
<MoneyValues amount={transaction.amount} />
<MoneyValues
className="font-medium"
amount={transaction.amount}
/>
</div>
</div>
);

View File

@@ -45,7 +45,7 @@ export function RecurringExpensesWidget({
{expense.name}
</p>
<MoneyValues amount={expense.amount} />
<MoneyValues className="font-medium" amount={expense.amount} />
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">

View File

@@ -48,7 +48,10 @@ export function TopEstablishmentsWidget({
</div>
<div className="shrink-0 text-foreground">
<MoneyValues amount={establishment.amount} />
<MoneyValues
className="font-medium"
amount={establishment.amount}
/>
</div>
</div>
);

View File

@@ -113,7 +113,10 @@ export function TopExpensesWidget({
</div>
<div className="shrink-0 text-foreground">
<MoneyValues amount={expense.amount} />
<MoneyValues
className="font-medium"
amount={expense.amount}
/>
</div>
</div>
);

View File

@@ -1,7 +1,9 @@
import { cacheLife, cacheTag } from "next/cache";
import { fetchAttachmentsForPeriod } from "@/features/attachments/queries";
import { fetchDashboardAccounts } from "./accounts-queries";
import { fetchDashboardCategoryOverview } from "./category-overview-queries";
import { fetchDashboardCurrentPeriodOverview } from "./current-period-overview-queries";
import { fetchDashboardInboxSnapshot } from "./inbox-snapshot-queries";
import { fetchDashboardInvoices } from "./invoices-queries";
import { fetchDashboardNotes } from "./notes-queries";
import { fetchDashboardPayers } from "./payers-queries";
@@ -16,6 +18,8 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
categoryOverview,
pagadoresSnapshot,
notesData,
allAttachments,
inboxSnapshot,
] = await Promise.all([
fetchDashboardPeriodOverview(userId, period),
fetchDashboardAccounts(userId),
@@ -24,8 +28,27 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
fetchDashboardCategoryOverview(userId, period),
fetchDashboardPayers(userId, period),
fetchDashboardNotes(userId),
fetchAttachmentsForPeriod(userId, period),
fetchDashboardInboxSnapshot(userId),
]);
const attachmentsSnapshot = allAttachments.reduce(
(acc, attachment, index) => {
acc.totalBytes += attachment.fileSize;
if (attachment.mimeType.startsWith("image/")) acc.imageCount++;
if (attachment.mimeType === "application/pdf") acc.pdfCount++;
if (index < 5) acc.recentAttachments.push(attachment);
return acc;
},
{
totalCount: allAttachments.length,
totalBytes: 0,
imageCount: 0,
pdfCount: 0,
recentAttachments: [] as typeof allAttachments,
},
);
return {
metrics: periodOverview.metrics,
accountsSnapshot,
@@ -46,6 +69,8 @@ async function fetchDashboardDataInternal(userId: string, period: string) {
purchasesByCategoryData: currentPeriodOverview.purchasesByCategoryData,
incomeByCategoryData: categoryOverview.incomeByCategoryData,
expensesByCategoryData: categoryOverview.expensesByCategoryData,
attachmentsSnapshot,
inboxSnapshot,
};
}

View File

@@ -0,0 +1,74 @@
import { and, count, desc, eq } from "drizzle-orm";
import { cacheLife, cacheTag } from "next/cache";
import { cards, financialAccounts, inboxItems } from "@/db/schema";
import { db } from "@/shared/lib/db";
export type DashboardInboxItem = {
id: string;
sourceAppName: string | null;
parsedName: string | null;
parsedAmount: string | null;
originalText: string;
notificationTimestamp: Date;
createdAt: Date;
};
export type DashboardInboxSnapshot = {
pendingCount: number;
recentItems: DashboardInboxItem[];
logoMap: Record<string, string>;
};
export async function fetchDashboardInboxSnapshot(
userId: string,
): Promise<DashboardInboxSnapshot> {
"use cache";
cacheTag(`dashboard-${userId}`);
cacheLife({ revalidate: 3 });
const [countRows, items, userCards, userAccounts] = await Promise.all([
db
.select({ total: count() })
.from(inboxItems)
.where(
and(eq(inboxItems.userId, userId), eq(inboxItems.status, "pending")),
),
db
.select({
id: inboxItems.id,
sourceAppName: inboxItems.sourceAppName,
parsedName: inboxItems.parsedName,
parsedAmount: inboxItems.parsedAmount,
originalText: inboxItems.originalText,
notificationTimestamp: inboxItems.notificationTimestamp,
createdAt: inboxItems.createdAt,
})
.from(inboxItems)
.where(
and(eq(inboxItems.userId, userId), eq(inboxItems.status, "pending")),
)
.orderBy(desc(inboxItems.notificationTimestamp))
.limit(10),
db
.select({ name: cards.name, logo: cards.logo })
.from(cards)
.where(eq(cards.userId, userId)),
db
.select({ name: financialAccounts.name, logo: financialAccounts.logo })
.from(financialAccounts)
.where(eq(financialAccounts.userId, userId)),
]);
const logoMap: Record<string, string> = {};
for (const item of [...userCards, ...userAccounts]) {
if (item.logo) {
logoMap[item.name.toLowerCase()] = item.logo;
}
}
return {
pendingCount: Number(countRows[0]?.total ?? 0),
recentItems: items,
logoMap,
};
}

View File

@@ -1,6 +1,8 @@
import {
RiArrowRightLine,
RiArrowUpDoubleLine,
RiAtLine,
RiAttachmentLine,
RiBarChartBoxLine,
RiBarcodeLine,
RiBillLine,
@@ -16,9 +18,12 @@ import {
} from "@remixicon/react";
import Link from "next/link";
import type { ReactNode } from "react";
import { AttachmentsWidget } from "@/features/dashboard/components/attachments-widget";
import { BillWidget } from "@/features/dashboard/components/bill-widget";
import { CategoryTrendsWidget } from "@/features/dashboard/components/category-trends-widget";
import { ExpensesByCategoryWidgetWithChart } from "@/features/dashboard/components/expenses-by-category-widget-with-chart";
import { GoalsProgressWidget } from "@/features/dashboard/components/goals-progress-widget";
import { InboxWidget } from "@/features/dashboard/components/inbox-widget";
import { IncomeByCategoryWidgetWithChart } from "@/features/dashboard/components/income-by-category-widget-with-chart";
import { IncomeExpenseBalanceWidget } from "@/features/dashboard/components/income-expense-balance-widget";
import { InstallmentExpensesWidget } from "@/features/dashboard/components/installment-expenses-widget";
@@ -32,8 +37,19 @@ import { PurchasesByCategoryWidget } from "@/features/dashboard/components/purch
import { RecurringExpensesWidget } from "@/features/dashboard/components/recurring-expenses-widget";
import { SpendingOverviewWidget } from "@/features/dashboard/components/spending-overview-widget";
import type { WidgetPreferences } from "@/features/dashboard/widgets/actions";
import type { SelectOption } from "@/features/transactions/components/types";
import type { DashboardData } from "../fetch-dashboard-data";
export type DashboardWidgetQuickActionOptions = {
payerOptions: SelectOption[];
splitPayerOptions: SelectOption[];
defaultPayerId: string | null;
accountOptions: SelectOption[];
cardOptions: SelectOption[];
categoryOptions: SelectOption[];
estabelecimentos: string[];
};
export type WidgetConfig = {
id: string;
title: string;
@@ -42,7 +58,9 @@ export type WidgetConfig = {
component: (props: {
data: DashboardData;
period: string;
adminPayerSlug: string | null;
widgetPreferences: WidgetPreferences;
quickActionOptions: DashboardWidgetQuickActionOptions;
onMyAccountsShowExcludedChange?: (value: boolean) => void;
}) => ReactNode;
action?: ReactNode;
@@ -88,21 +106,149 @@ export const widgetsConfig: WidgetConfig[] = [
{
id: "payment-status",
title: "Status de Pagamento",
subtitle: "Valores Confirmados E Pendentes",
subtitle: "Valores confirmados e pendentes",
icon: <RiWallet3Line className="size-4" />,
component: ({ data }) => (
<PaymentStatusWidget data={data.paymentStatusData} />
),
},
{
id: "inbox",
title: "Pré-lançamentos",
subtitle: "Notificações pendentes de revisão",
icon: <RiAtLine className="size-4" />,
component: ({ data, quickActionOptions }) => (
<InboxWidget
snapshot={data.inboxSnapshot}
quickActionOptions={quickActionOptions}
/>
),
action: (
<Link
href="/inbox"
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
>
Revisar
<RiArrowRightLine className="size-4" />
</Link>
),
},
{
id: "income-expense-balance",
title: "Receita, Despesa e Balanço",
subtitle: "Últimos 6 Meses",
subtitle: "Últimos 6 meses",
icon: <RiLineChartLine className="size-4" />,
component: ({ data }) => (
<IncomeExpenseBalanceWidget data={data.incomeExpenseBalanceData} />
),
},
{
id: "goals-progress",
title: "Progresso de Orçamentos",
subtitle: "Orçamentos por categoria no período",
icon: <RiExchangeLine className="size-4" />,
component: ({ data }) => (
<GoalsProgressWidget data={data.goalsProgressData} />
),
action: (
<Link
href="/budgets"
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
>
Ver todos
<RiArrowRightLine className="size-4" />
</Link>
),
},
{
id: "category-trends",
title: "Tendências de Categorias",
subtitle: "Top 10 maiores variações vs. mês anterior",
icon: <RiLineChartLine className="size-4" />,
component: ({ data }) => (
<CategoryTrendsWidget
categories={data.expensesByCategoryData.categories}
/>
),
},
{
id: "spending-overview",
title: "Panorama de Gastos",
subtitle: "Principais despesas e frequência por local",
icon: <RiArrowUpDoubleLine className="size-4" />,
component: ({ data }) => (
<SpendingOverviewWidget
topExpensesAll={data.topExpensesAll}
topExpensesCardOnly={data.topExpensesCardOnly}
topEstablishmentsData={data.topEstablishmentsData}
/>
),
},
{
id: "payment-overview",
title: "Comportamento de Pagamento",
subtitle: "Despesas por condição e forma de pagamento",
icon: <RiWallet3Line className="size-4" />,
component: ({ data, period, adminPayerSlug }) => (
<PaymentOverviewWidget
paymentConditionsData={data.paymentConditionsData}
paymentMethodsData={data.paymentMethodsData}
period={period}
adminPayerSlug={adminPayerSlug}
/>
),
},
{
id: "expenses-by-category",
title: "Categorias por Despesas",
subtitle: "Distribuição de despesas por categoria",
icon: <RiPieChartLine className="size-4" />,
component: ({ data, period }) => (
<ExpensesByCategoryWidgetWithChart
data={data.expensesByCategoryData}
period={period}
/>
),
},
{
id: "income-by-category",
title: "Categorias por Receitas",
subtitle: "Distribuição de receitas por categoria",
icon: <RiPieChartLine className="size-4" />,
component: ({ data, period }) => (
<IncomeByCategoryWidgetWithChart
data={data.incomeByCategoryData}
period={period}
/>
),
},
{
id: "purchases-by-category",
title: "Lançamentos por Categorias",
subtitle: "Distribuição de lançamentos por categoria",
icon: <RiStore3Line className="size-4" />,
component: ({ data }) => (
<PurchasesByCategoryWidget data={data.purchasesByCategoryData} />
),
},
{
id: "recurring-expenses",
title: "Lançamentos Recorrentes",
subtitle: "Despesas recorrentes do período",
icon: <RiRefreshLine className="size-4" />,
component: ({ data }) => (
<RecurringExpensesWidget data={data.recurringExpensesData} />
),
},
{
id: "installment-expenses",
title: "Lançamentos Parcelados",
subtitle: "Acompanhe as parcelas abertas",
icon: <RiNumbersLine className="size-4" />,
component: ({ data }) => (
<InstallmentExpensesWidget data={data.installmentExpensesData} />
),
},
{
id: "pagadores",
title: "Pagadores",
@@ -138,16 +284,16 @@ export const widgetsConfig: WidgetConfig[] = [
),
},
{
id: "goals-progress",
title: "Progresso de Orçamentos",
subtitle: "Orçamentos por categoria no período",
icon: <RiExchangeLine className="size-4" />,
id: "attachments",
title: "Anexos",
subtitle: "Comprovantes do período",
icon: <RiAttachmentLine className="size-4" />,
component: ({ data }) => (
<GoalsProgressWidget data={data.goalsProgressData} />
<AttachmentsWidget snapshot={data.attachmentsSnapshot} />
),
action: (
<Link
href="/budgets"
href="/attachments"
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
>
Ver todos
@@ -155,80 +301,4 @@ export const widgetsConfig: WidgetConfig[] = [
</Link>
),
},
{
id: "payment-overview",
title: "Comportamento de Pagamento",
subtitle: "Despesas por condição e forma de pagamento",
icon: <RiWallet3Line className="size-4" />,
component: ({ data }) => (
<PaymentOverviewWidget
paymentConditionsData={data.paymentConditionsData}
paymentMethodsData={data.paymentMethodsData}
/>
),
},
{
id: "recurring-expenses",
title: "Lançamentos Recorrentes",
subtitle: "Despesas recorrentes do período",
icon: <RiRefreshLine className="size-4" />,
component: ({ data }) => (
<RecurringExpensesWidget data={data.recurringExpensesData} />
),
},
{
id: "installment-expenses",
title: "Lançamentos Parcelados",
subtitle: "Acompanhe as parcelas abertas",
icon: <RiNumbersLine className="size-4" />,
component: ({ data }) => (
<InstallmentExpensesWidget data={data.installmentExpensesData} />
),
},
{
id: "spending-overview",
title: "Panorama de Gastos",
subtitle: "Principais despesas e frequência por local",
icon: <RiArrowUpDoubleLine className="size-4" />,
component: ({ data }) => (
<SpendingOverviewWidget
topExpensesAll={data.topExpensesAll}
topExpensesCardOnly={data.topExpensesCardOnly}
topEstablishmentsData={data.topEstablishmentsData}
/>
),
},
{
id: "purchases-by-category",
title: "Lançamentos por Categorias",
subtitle: "Distribuição de lançamentos por categoria",
icon: <RiStore3Line className="size-4" />,
component: ({ data }) => (
<PurchasesByCategoryWidget data={data.purchasesByCategoryData} />
),
},
{
id: "income-by-category",
title: "Categorias por Receitas",
subtitle: "Distribuição de receitas por categoria",
icon: <RiPieChartLine className="size-4" />,
component: ({ data, period }) => (
<IncomeByCategoryWidgetWithChart
data={data.incomeByCategoryData}
period={period}
/>
),
},
{
id: "expenses-by-category",
title: "Categorias por Despesas",
subtitle: "Distribuição de despesas por categoria",
icon: <RiPieChartLine className="size-4" />,
component: ({ data, period }) => (
<ExpensesByCategoryWidgetWithChart
data={data.expensesByCategoryData}
period={period}
/>
),
},
];

View File

@@ -104,11 +104,11 @@ export const InboxCard = memo(function InboxCard({
return (
<Card
className={`flex h-54 flex-col gap-0 py-0 transition-colors ${selected ? "ring-2 ring-primary" : ""}`}
className={`flex h-54 flex-col gap-0 py-0 transition-colors ${selected ? "ring-2 ring-primary/30" : ""}`}
>
<CardHeader className="pt-4">
<div className="flex items-center justify-between gap-2">
<CardTitle className="flex min-w-0 items-center gap-1.5 text-sm">
<div className="flex items-center justify-between">
<CardTitle className="flex min-w-0 items-center gap-3 text-sm">
{onSelectToggle && (
<Checkbox
checked={!!selected}
@@ -117,29 +117,34 @@ export const InboxCard = memo(function InboxCard({
className="shrink-0"
/>
)}
<div className="relative size-8 shrink-0 overflow-hidden rounded-full">
<div className="relative shrink-0 overflow-hidden ">
<Image
src={displayLogo}
alt=""
fill
sizes="32px"
className="object-cover"
alt={item.sourceAppName || item.sourceApp}
width={40}
height={40}
className="size-10 rounded-full object-cover"
/>
</div>
<span className="truncate">
{item.sourceAppName || item.sourceApp}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0 cursor-default text-xs font-normal text-muted-foreground underline decoration-dotted underline-offset-2">
{timeAgo}
</span>
</TooltipTrigger>
<TooltipContent>{fullDate}</TooltipContent>
</Tooltip>
<div className="flex min-w-0 flex-col">
<span className="truncate font-semibold text-base">
{item.sourceAppName || item.sourceApp}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default text-xs text-muted-foreground underline decoration-dotted underline-offset-2">
{timeAgo}
</span>
</TooltipTrigger>
<TooltipContent>{fullDate}</TooltipContent>
</Tooltip>
</div>
</CardTitle>
{amount !== null && (
<MoneyValues amount={amount} className="shrink-0 text-sm" />
<MoneyValues
amount={amount}
className="shrink-0 text-base font-semibold"
/>
)}
</div>
</CardHeader>

View File

@@ -67,7 +67,7 @@ export function InboxDetailsDialog({
<Separator />
<div>
<h4 className="mb-1 text-sm font-medium text-muted-foreground">
<h4 className="mb-1 text-sm font-semibold text-muted-foreground">
Notificação Original
</h4>
{item.originalTitle && (

View File

@@ -82,7 +82,7 @@ export function InsightsGrid({ insights }: InsightsGridProps) {
<CardHeader>
<div className="flex items-center gap-2">
<Icon className={cn("size-5", colors.chatAiIcon)} />
<CardTitle className={cn("font-medium", colors.titleText)}>
<CardTitle className={cn("font-semibold", colors.titleText)}>
{categoryConfig.title}
</CardTitle>
</div>

View File

@@ -301,7 +301,7 @@ function ErrorState({
return (
<div className="flex flex-col items-center justify-center gap-4 py-12 px-4 text-center">
<div className="flex flex-col gap-2">
<h3 className="text-lg font-medium text-destructive">{title}</h3>
<h3 className="text-lg font-semibold text-destructive">{title}</h3>
<p className="text-sm text-muted-foreground max-w-md">{error}</p>
</div>
<Button onClick={onRetry} variant="outline">

View File

@@ -133,7 +133,7 @@ export function ModelSelector({
<Card className="grid grid-cols-1 lg:grid-cols-[1fr,auto] gap-6 items-start p-6">
{/* Descrição */}
<div className="space-y-2">
<h3 className="text-lg font-medium">Definir modelo de análise</h3>
<h3 className="text-lg font-semibold">Definir modelo de análise</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Escolha o provedor de IA e o modelo específico que será utilizado para
gerar insights sobre seus dados financeiros. <br />

View File

@@ -176,7 +176,7 @@ export function InvoiceSummaryCard({
</span>
) : null}
<div className="min-w-0">
<h2 className="truncate text-sm font-medium text-foreground">
<h2 className="truncate text-sm font-semibold text-foreground">
{cardName}
</h2>
<p className="text-xs text-muted-foreground">
@@ -189,13 +189,11 @@ export function InvoiceSummaryCard({
{/* Linha 2 — valor da fatura (hero) */}
<div className="space-y-4">
<p className="text-sm font-medium text-muted-foreground">
Valor da fatura
</p>
<p className="text-sm text-muted-foreground">Valor da fatura</p>
<MoneyValues
amount={totalAmount}
className={cn(
"text-3xl leading-none font-medium tracking-tight sm:text-[2rem]",
"text-3xl leading-none tracking-tighter sm:text-[2rem]",
isPaid ? "text-success" : "text-foreground",
)}
/>

View File

@@ -103,7 +103,7 @@ function StepCard({
{step}
</div>
<div className="min-w-0">
<h3 className="font-medium mb-1.5 md:mb-2">{title}</h3>
<h3 className="font-semibold mb-1.5 md:mb-2">{title}</h3>
{children}
</div>
</div>

View File

@@ -77,7 +77,7 @@ export function NoteCard({
<CardContent className="flex min-h-0 flex-1 flex-col gap-4">
<div className="flex shrink-0 items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1">
<h3 className="text-lg font-medium leading-tight text-foreground wrap-break-word">
<h3 className="text-lg font-semibold text-foreground wrap-break-word">
{displayTitle}
</h3>
{createdAtLabel && (

View File

@@ -130,7 +130,7 @@ export async function updatePayerAction(
if (!existing) {
return {
success: false,
error: "Payer não encontrado.",
error: "Pagador não encontrado.",
};
}
@@ -180,7 +180,7 @@ export async function deletePayerAction(
if (!existing) {
return {
success: false,
error: "Payer não encontrado.",
error: "Pagador não encontrado.",
};
}

View File

@@ -52,6 +52,7 @@ export function PayerHeaderCard({
const [confirmOpen, setConfirmOpen] = useState(false);
const avatarSrc = getAvatarSrc(payer.avatarUrl);
const isDataUrl = avatarSrc.startsWith("data:");
const createdAtLabel = formatDate(payer.createdAt);
const isAdmin = payer.role === PAYER_ROLE_ADMIN;
@@ -109,6 +110,7 @@ export function PayerHeaderCard({
<div className="relative flex size-16 shrink-0 items-center justify-center overflow-hidden">
<Image
src={avatarSrc}
unoptimized={isDataUrl}
alt={`Avatar de ${payer.name}`}
width={64}
height={64}
@@ -118,7 +120,7 @@ export function PayerHeaderCard({
<div className="flex flex-1 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-xl font-medium text-foreground">
<CardTitle className="text-xl font-semibold text-foreground">
{payer.name}
</CardTitle>
{isAdmin ? (
@@ -215,10 +217,10 @@ export function PayerHeaderCard({
<RiExchangeDollarLine className="size-5 text-primary" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">
<p className="text-sm text-muted-foreground">
Total de Despesas
</p>
<p className="text-2xl font-medium text-foreground">
<p className="text-2xl font-semibold text-foreground">
{formatCurrency(summary.totalExpenses)}
</p>
</div>
@@ -239,7 +241,7 @@ export function PayerHeaderCard({
Cartões
</span>
</div>
<p className="text-lg font-medium text-foreground">
<p className="text-lg font-semibold text-foreground">
{formatCurrency(summary.paymentSplits.card)}
</p>
</div>
@@ -251,7 +253,7 @@ export function PayerHeaderCard({
Boletos
</span>
</div>
<p className="text-lg font-medium text-foreground">
<p className="text-lg font-semibold text-foreground">
{formatCurrency(summary.paymentSplits.boleto)}
</p>
</div>
@@ -263,7 +265,7 @@ export function PayerHeaderCard({
Pix/Débito
</span>
</div>
<p className="text-lg font-medium text-foreground">
<p className="text-lg font-semibold text-foreground">
{formatCurrency(summary.paymentSplits.instant)}
</p>
</div>

View File

@@ -63,7 +63,7 @@ export function PayerHistoryCard({ data }: PagadorHistoryCardProps) {
return (
<Card className="border">
<CardHeader className="gap-1.5 pb-3">
<CardTitle className="text-lg font-medium">
<CardTitle className="text-lg font-semibold">
Evolução (últimos 6 meses)
</CardTitle>
<p className="text-xs text-muted-foreground">

View File

@@ -31,7 +31,7 @@ export function PagadorInfoCard({ payer }: PayerInfoCardProps) {
return (
<Card className="border gap-4">
<CardHeader className="gap-1.5">
<CardTitle className="text-lg font-medium">
<CardTitle className="text-lg font-semibold">
Detalhes do pagador
</CardTitle>
<CardDescription>
@@ -106,7 +106,7 @@ export function PagadorInfoCard({ payer }: PayerInfoCardProps) {
const resolveRoleLabel = (role: string | null) => {
if (role === PAYER_ROLE_ADMIN) return "Administrador";
return "Payer";
return "Pagador";
};
type InfoItemProps = {

View File

@@ -53,7 +53,7 @@ export function PayerLeaveShareCard({
return (
<Card className="border">
<CardHeader>
<CardTitle className="text-base font-medium">
<CardTitle className="text-base font-semibold">
Acesso Compartilhado
</CardTitle>
<p className="text-sm text-muted-foreground">

View File

@@ -51,7 +51,7 @@ export function PayerMonthlySummaryCard({
return (
<Card>
<CardHeader className="flex flex-col gap-1.5">
<CardTitle className="text-lg font-medium">Totais do mês</CardTitle>
<CardTitle className="text-lg font-semibold">Totais do mês</CardTitle>
<p className="text-xs text-muted-foreground">
{periodLabel} - Despesas por forma de pagamento
</p>
@@ -65,7 +65,7 @@ export function PayerMonthlySummaryCard({
</span>
<MoneyValues
amount={breakdown.totalExpenses}
className="block text-2xl font-medium text-foreground"
className="block text-2xl font-semibold text-foreground"
/>
</div>
@@ -100,7 +100,7 @@ export function PayerMonthlySummaryCard({
totalBase > 0 ? Math.round((entry.value / totalBase) * 100) : 0;
return (
<div key={entry.key} className="space-y-1 rounded-lg border p-3">
<span className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground/70">
<span className="flex items-center gap-2 text-xs uppercase tracking-wide text-muted-foreground/70">
<span
className={cn("size-2 rounded-full", entry.color)}
aria-hidden
@@ -109,7 +109,7 @@ export function PayerMonthlySummaryCard({
</span>
<MoneyValues
amount={entry.value}
className="block text-lg font-medium text-foreground"
className="block text-lg font-semibold text-foreground"
/>
<span className="text-xs text-muted-foreground">
{percent}% das despesas

View File

@@ -84,7 +84,9 @@ export function PayerSharingCard({
return (
<Card className="border">
<CardHeader>
<CardTitle className="text-lg font-medium">Compartilhamentos</CardTitle>
<CardTitle className="text-lg font-semibold">
Compartilhamentos
</CardTitle>
<p className="text-sm text-muted-foreground">
Compartilhe o código abaixo com outra pessoa. Ela poderá adicioná-lo
na página de pagadores usando a opção Adicionar por código para ter

View File

@@ -24,6 +24,7 @@ interface PayerCardProps {
export function PayerCard({ payer, onEdit, onRemove }: PayerCardProps) {
const avatarSrc = getAvatarSrc(payer.avatarUrl);
const isAdmin = payer.role === PAYER_ROLE_ADMIN;
const isDataUrl = avatarSrc.startsWith("data:");
const isReadOnly = !payer.canEdit;
return (
@@ -33,6 +34,7 @@ export function PayerCard({ payer, onEdit, onRemove }: PayerCardProps) {
<div className="relative mb-3 flex size-16 items-center justify-center overflow-hidden rounded-full border-background bg-background shadow-lg">
<Image
src={avatarSrc}
unoptimized={isDataUrl}
alt={`Avatar de ${payer.name}`}
width={80}
height={80}
@@ -42,9 +44,7 @@ export function PayerCard({ payer, onEdit, onRemove }: PayerCardProps) {
{/* Nome e badges */}
<div className="flex items-center gap-1.5">
<h3 className="text-base font-medium text-foreground">
{payer.name}
</h3>
<h3 className="font-semibold text-foreground">{payer.name}</h3>
{isAdmin ? (
<RiVerifiedBadgeFill className="size-4 text-blue-500" aria-hidden />
) : null}

View File

@@ -1,6 +1,7 @@
"use client";
import { RiImageAddLine } from "@remixicon/react";
import Image from "next/image";
import { useEffect, useMemo, useState, useTransition } from "react";
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import { toast } from "sonner";
import {
createPayerAction,
@@ -37,6 +38,45 @@ import { getAvatarSrc } from "@/shared/lib/payers/utils";
import { StatusSelectContent } from "./payer-select-items";
import type { Payer, PayerFormValues } from "./types";
const AVATAR_MAX_SIZE = 200;
function resizeImageToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new window.Image();
img.onload = () => {
let { width, height } = img;
if (width > height) {
if (width > AVATAR_MAX_SIZE) {
height = Math.round((height * AVATAR_MAX_SIZE) / width);
width = AVATAR_MAX_SIZE;
}
} else {
if (height > AVATAR_MAX_SIZE) {
width = Math.round((width * AVATAR_MAX_SIZE) / height);
height = AVATAR_MAX_SIZE;
}
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("Canvas não disponível"));
return;
}
ctx.drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL("image/jpeg", 0.85));
};
img.onerror = () => reject(new Error("Falha ao carregar imagem"));
img.src = e.target?.result as string;
};
reader.onerror = () => reject(new Error("Falha ao ler arquivo"));
reader.readAsDataURL(file);
});
}
type PayerCreatePayload = Parameters<typeof createPayerAction>[0];
interface PayerDialogProps {
@@ -77,8 +117,10 @@ export function PayerDialog({
}: PayerDialogProps) {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [uploadedAvatar, setUploadedAvatar] = useState<string | null>(null);
const [isProcessingImage, setIsProcessingImage] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Use controlled state hook for dialog open state
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
@@ -90,26 +132,47 @@ export function PayerDialog({
[payer, avatarOptions],
);
// Use form state hook for form management
const { formState, resetForm, updateField } =
useFormState<PayerFormValues>(initialState);
// Avatares da biblioteca excluem data URLs (que ficam no círculo de upload)
const availableAvatars = useMemo(() => {
const set = new Set([
...avatarOptions,
initialState.avatarUrl,
DEFAULT_PAYER_AVATAR,
]);
const set = new Set([...avatarOptions, DEFAULT_PAYER_AVATAR]);
if (initialState.avatarUrl && !initialState.avatarUrl.startsWith("data:")) {
set.add(initialState.avatarUrl);
}
return Array.from(set).sort((a, b) =>
a.localeCompare(b, "pt-BR", { sensitivity: "base" }),
);
}, [avatarOptions, initialState.avatarUrl]);
// Reset form when dialog opens
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsProcessingImage(true);
try {
const base64 = await resizeImageToBase64(file);
setUploadedAvatar(base64);
updateField("avatarUrl", base64);
} catch {
toast.error("Não foi possível processar a imagem.");
} finally {
setIsProcessingImage(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
useEffect(() => {
if (dialogOpen) {
resetForm(initialState);
setErrorMessage(null);
setIsProcessingImage(false);
// Se o avatar atual for um upload anterior, restaura no círculo
setUploadedAvatar(
initialState.avatarUrl.startsWith("data:")
? initialState.avatarUrl
: null,
);
}
}, [dialogOpen, initialState, resetForm]);
@@ -119,7 +182,7 @@ export function PayerDialog({
const payerId = payer?.id;
if (mode === "update" && !payerId) {
const message = "Payer inválido.";
const message = "Pagador inválido.";
setErrorMessage(message);
toast.error(message);
return;
@@ -161,6 +224,9 @@ export function PayerDialog({
const submitLabel =
mode === "create" ? "Salvar pagador" : "Atualizar pagador";
const isUploadSelected =
uploadedAvatar !== null && formState.avatarUrl === uploadedAvatar;
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
@@ -255,6 +321,7 @@ export function PayerDialog({
<div className="flex flex-wrap gap-3">
{availableAvatars.map((avatar) => {
const isSelected = avatar === formState.avatarUrl;
const src = getAvatarSrc(avatar);
return (
<button
type="button"
@@ -265,7 +332,8 @@ export function PayerDialog({
aria-pressed={isSelected}
>
<Image
src={getAvatarSrc(avatar)}
src={src}
unoptimized={src.startsWith("data:")}
alt={`Avatar ${avatar}`}
width={40}
height={40}
@@ -274,6 +342,43 @@ export function PayerDialog({
</button>
);
})}
{/* Círculo de upload — sempre o último */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isProcessingImage}
className="group relative flex items-center justify-center rounded-full p-0.5 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 data-[selected=true]:ring-2 data-[selected=true]:ring-primary"
data-selected={isUploadSelected}
aria-pressed={isUploadSelected}
aria-label="Fazer upload de foto"
>
{uploadedAvatar ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={uploadedAvatar}
alt="Avatar personalizado"
className="size-12 rounded-full object-cover 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 ? (
<span className="text-[10px] text-muted-foreground animate-pulse">
...
</span>
) : (
<RiImageAddLine className="size-4 text-muted-foreground/50" />
)}
</div>
)}
</button>
</div>
</div>

View File

@@ -404,7 +404,7 @@ export async function sendPayerSummaryAction(
});
if (!pagadorRow) {
return { success: false, error: "Payer não encontrado." };
return { success: false, error: "Pagador não encontrado." };
}
if (!pagadorRow.email) {

View File

@@ -142,7 +142,7 @@ export function CardUsageChart({ data, limit, card }: CardUsageChartProps) {
<span className="text-xs text-muted-foreground">
Uso
</span>
<span className="text-xs font-medium tabular-nums">
<span className="text-xs font-medium">
{formatCurrency(value, {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
@@ -154,7 +154,7 @@ export function CardUsageChart({ data, limit, card }: CardUsageChartProps) {
<span className="text-xs text-muted-foreground">
% do Limite
</span>
<span className="text-xs font-medium tabular-nums">
<span className="text-xs font-medium">
{formatPercentage(usagePercent, {
maximumFractionDigits: 0,
minimumFractionDigits: 0,

View File

@@ -67,11 +67,11 @@ export function CardsOverview({ data }: CardsOverviewProps) {
<p className="text-xs text-muted-foreground">{card.title}</p>
{card.isMoney ? (
<MoneyValues
className="text-2xl font-medium"
className="text-2xl font-semibold"
amount={card.value}
/>
) : (
<p className="text-2xl font-medium">
<p className="text-2xl font-semibold">
{formatPercentage(card.value, {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
@@ -83,7 +83,7 @@ export function CardsOverview({ data }: CardsOverviewProps) {
))}
</div>
<p className="text-base font-medium ml-2 py-2">Meus cartões</p>
<p className="text-base font-semibold ml-2 py-2">Meus cartões</p>
{/* Cards list */}
<div className="grid gap-2 grid-cols-2 lg:grid-cols-4 xl:grid-cols-4">
@@ -116,7 +116,7 @@ export function CardsOverview({ data }: CardsOverviewProps) {
</div>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="text-base font-medium truncate">
<span className="text-base font-semibold truncate">
{card.name}
</span>
{brandAsset && (
@@ -129,7 +129,7 @@ export function CardsOverview({ data }: CardsOverviewProps) {
/>
)}
</div>
<p className="text-xs text-muted-foreground tabular-nums">
<p className="text-xs text-muted-foreground">
{formatCurrency(card.currentUsage)} /{" "}
{formatCurrency(card.limit)}
</p>
@@ -141,7 +141,7 @@ export function CardsOverview({ data }: CardsOverviewProps) {
`[&>div]:${getUsageColor(card.usagePercent)}`,
)}
/>
<span className="text-xs font-medium tabular-nums">
<span className="text-xs font-medium">
{formatPercentage(card.usagePercent, {
maximumFractionDigits: 0,
minimumFractionDigits: 0,

View File

@@ -53,7 +53,9 @@ export function CategoryCell({
>
{isIncrease && <RiArrowUpSFill className="h-3 w-3" />}
{isDecrease && <RiArrowDownSFill className="h-3 w-3" />}
<span>{formatPercentageChange(percentageChange)}</span>
<span className="font-medium">
{formatPercentageChange(percentageChange)}
</span>
</div>
)}
</div>

View File

@@ -73,7 +73,7 @@ function AreaTooltip({
{entry.name}
</span>
</div>
<span className="shrink-0 text-xs font-medium tabular-nums text-foreground">
<span className="shrink-0 text-xs font-medium text-foreground">
{currencyFormatter.format(Number(entry.value))}
</span>
</div>

View File

@@ -78,12 +78,12 @@ export function CategoryTable({
{periods.map((period) => (
<TableHead
key={period}
className="text-right min-w-[120px] font-medium"
className="text-right min-w-[120px] font-semibold"
>
{formatPeriodLabel(period)}
</TableHead>
))}
<TableHead className="text-right min-w-[140px] font-medium">
<TableHead className="text-right min-w-[140px] font-semibold">
<div className="flex items-center justify-end gap-1">
Média
<Tooltip>
@@ -100,7 +100,7 @@ export function CategoryTable({
</Tooltip>
</div>
</TableHead>
<TableHead className="text-right min-w-[120px] font-medium">
<TableHead className="text-right min-w-[120px] font-semibold">
Total
</TableHead>
</TableRow>
@@ -128,7 +128,7 @@ export function CategoryTable({
/>
<Link
href={`/categories/${category.categoryId}?periodo=${periodParam}`}
className="flex items-center gap-1.5 truncate hover:underline underline-offset-2"
className="flex items-center gap-1.5 truncate hover:underline underline-offset-2 font-semibold"
>
{category.name}
</Link>
@@ -149,7 +149,7 @@ export function CategoryTable({
</TableCell>
);
})}
<TableCell className="text-right font-medium text-info">
<TableCell className="text-right font-semibold text-info">
{(() => {
const nonZeroCount = periods.filter(
(p) => (category.monthlyData.get(p)?.amount ?? 0) > 0,
@@ -178,10 +178,10 @@ export function CategoryTable({
</TableCell>
);
})}
<TableCell className="text-right font-medium text-info">
<TableCell className="text-right font-semibold text-info">
{formatCurrency(sectionTotals.averageMonthlyTotal)}
</TableCell>
<TableCell className="text-right font-medium">
<TableCell className="text-right font-semibold">
{formatCurrency(sectionTotals.grandTotal)}
</TableCell>
</TableRow>

View File

@@ -19,7 +19,7 @@ export function HighlightsCards({ summary }: HighlightsCardsProps) {
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium">Mais Frequente</p>
<p className="font-medium text-2xl truncate">
<p className="font-semibold text-2xl truncate">
{summary.mostFrequent || "—"}
</p>
</div>
@@ -35,7 +35,7 @@ export function HighlightsCards({ summary }: HighlightsCardsProps) {
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium">Maior Gasto Total</p>
<p className="font-medium text-2xl truncate">
<p className="font-semibold text-2xl truncate">
{summary.highestSpending || "—"}
</p>
</div>

View File

@@ -53,16 +53,14 @@ export function SummaryCards({ summary }: SummaryCardsProps) {
<CardContent className="px-4 py-2">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">
{card.title}
</p>
<p className="text-xs text-muted-foreground">{card.title}</p>
{card.isMoney ? (
<MoneyValues
className="text-2xl font-medium"
className="text-2xl font-semibold"
amount={card.value}
/>
) : (
<p className="text-2xl font-medium">{card.value}</p>
<p className="text-2xl font-semibold">{card.value}</p>
)}
<p className="text-xs text-muted-foreground">
{card.description}

View File

@@ -139,7 +139,7 @@ export function ApiTokensForm({ tokens }: ApiTokensFormProps) {
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">Dispositivos conectados</h3>
<h3 className="font-semibold">Dispositivos conectados</h3>
<p className="text-sm text-muted-foreground">
Gerencie os dispositivos que podem enviar notificações para o
OpenMonetis.

View File

@@ -32,7 +32,7 @@ export function ChangelogTab({ versions }: { versions: ChangelogVersion[] }) {
{versions.map((version) => (
<Card key={version.version} className="p-6">
<div className="flex items-baseline gap-3">
<h3 className="text-lg font-medium">v{version.version}</h3>
<h3 className="text-lg font-semibold">v{version.version}</h3>
<span className="text-sm text-muted-foreground">
{version.date}
</span>

View File

@@ -84,7 +84,7 @@ export function DeleteAccountForm() {
<div className="rounded-lg border p-4">
<div className="space-y-4">
<div>
<h3 className="font-medium">Zerar conta</h3>
<h3 className="font-semibold">Zerar conta</h3>
<p className="text-sm text-muted-foreground">
Apaga todos os dados do OpenMonetis e deixa sua conta no estado
inicial, mantendo seu login e credenciais de acesso.
@@ -117,10 +117,10 @@ export function DeleteAccountForm() {
</div>
</div>
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<div className="rounded-lg border border-destructive/30 p-4">
<div className="space-y-4">
<div>
<h3 className="font-medium text-destructive">Deletar conta</h3>
<h3 className="font-semibold text-destructive">Deletar conta</h3>
<p className="text-sm text-muted-foreground">
Remove seu usuário e todos os dados associados de forma
permanente.
@@ -132,7 +132,7 @@ export function DeleteAccountForm() {
<li>Contas, cartões e categorias</li>
<li>Pagadores, credenciais e configurações</li>
<li className="font-medium">
Resumindo tudo, sua conta se permanentemente removida
Resumindo, sua conta i de arrasta pra cima!
</li>
</ul>

Some files were not shown because too many files have changed in this diff Show More