mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
Merge branch 'main' into feat/fix-ui
This commit is contained in:
@@ -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"
|
||||
|
||||
26
src/app/(dashboard)/attachments/layout.tsx
Normal file
26
src/app/(dashboard)/attachments/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -80,6 +80,8 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
|
||||
categoryFilter: null,
|
||||
accountCardFilter: null,
|
||||
searchFilter: null,
|
||||
settledFilter: null,
|
||||
attachmentFilter: null,
|
||||
};
|
||||
|
||||
const createEmptySlugMaps = (): SlugMaps => ({
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
26
src/app/api/logo/mapping/route.ts
Normal file
26
src/app/api/logo/mapping/route.ts
Normal 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 });
|
||||
}
|
||||
80
src/app/api/logo/search/route.ts
Normal file
80
src/app/api/logo/search/route.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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),
|
||||
|
||||
128
src/features/dashboard/components/attachments-widget.tsx
Normal file
128
src/features/dashboard/components/attachments-widget.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
84
src/features/dashboard/components/category-trends-widget.tsx
Normal file
84
src/features/dashboard/components/category-trends-widget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
268
src/features/dashboard/components/inbox-widget.tsx
Normal file
268
src/features/dashboard/components/inbox-widget.tsx
Normal 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 `há ${minutes}min`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `há ${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `há ${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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
74
src/features/dashboard/inbox-snapshot-queries.ts
Normal file
74
src/features/dashboard/inbox-snapshot-queries.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 será permanentemente removida
|
||||
Resumindo, sua conta irá de arrasta pra cima!
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user