-
Alterar nome
+
Alterar nome
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() {
-
Alterar senha
+
Alterar senha
Defina uma nova senha para sua conta. Guarde-a em local
seguro.
@@ -147,7 +149,7 @@ export default async function Page() {
-
Passkeys
+
Passkeys
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() {
-
Alterar e-mail
+
Alterar e-mail
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() {
-
- Ações perigosas
-
+
Ações perigosas
Você pode zerar os dados do OpenMonetis e manter seu acesso,
ou excluir sua conta inteira de forma irreversível.
diff --git a/src/app/(dashboard)/transactions/layout.tsx b/src/app/(dashboard)/transactions/layout.tsx
index 2cbc7c0..2f68a21 100644
--- a/src/app/(dashboard)/transactions/layout.tsx
+++ b/src/app/(dashboard)/transactions/layout.tsx
@@ -11,7 +11,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
-
+
}
title="Lançamentos"
diff --git a/src/app/(landing-page)/page.tsx b/src/app/(landing-page)/page.tsx
index 0e767d1..5e55bf2 100644
--- a/src/app/(landing-page)/page.tsx
+++ b/src/app/(landing-page)/page.tsx
@@ -120,13 +120,13 @@ export default async function Page() {
-
+
Projeto Open Source
-
+
Suas finanças,
do seu jeito
@@ -207,7 +207,7 @@ export default async function Page() {
className="flex flex-col items-center text-center gap-1.5"
>
-
+
{value}
@@ -229,7 +229,7 @@ export default async function Page() {
Conheça as telas
-
+
Veja o que você pode fazer
@@ -254,7 +254,7 @@ export default async function Page() {
O que tem aqui
-
+
Funcionalidades que importam
@@ -282,7 +282,7 @@ export default async function Page() {
/>
-
+
{feature.title}
@@ -298,7 +298,7 @@ export default async function Page() {
-
+
Também inclui
@@ -319,7 +319,7 @@ export default async function Page() {
/>
-
+
{feature.title}
@@ -346,7 +346,7 @@ export default async function Page() {
Mobile
-
+
Use o OpenMonetis no celular sem perder o fluxo
@@ -529,7 +529,7 @@ export default async function Page() {
Stack técnica
-
+
O que roda por baixo
@@ -556,7 +556,7 @@ export default async function Page() {
/>
-
+
{item.title}
@@ -582,7 +582,7 @@ export default async function Page() {
Como usar
-
+
Rode no seu computador
@@ -617,7 +617,7 @@ export default async function Page() {
Para quem é?
-
+
Feito para quem gosta de controle
@@ -644,7 +644,7 @@ export default async function Page() {
/>
-
{item.title}
+
{item.title}
{item.description}
@@ -664,7 +664,7 @@ export default async function Page() {
-
+
Pronto para testar?
@@ -715,7 +715,7 @@ export default async function Page() {
-
Projeto
+
Projeto
-
Companion
+
Companion
{
+ 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();
+ 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);
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 18d405e..4724d60 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -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);
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index ecc48c3..182fc2f 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -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({
-
+ {process.env.UMAMI_URL && process.env.UMAMI_WEBSITE_ID && (
+
+ )}
-
+
{children}
diff --git a/src/db/schema.ts b/src/db/schema.ts
index 7fc9e5c..118da17 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -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],
+ }),
+ }),
+);
diff --git a/src/features/accounts/actions.ts b/src/features/accounts/actions.ts
index 0191494..56db01b 100644
--- a/src/features/accounts/actions.ts
+++ b/src/features/accounts/actions.ts
@@ -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.",
);
}
diff --git a/src/features/accounts/components/account-card.tsx b/src/features/accounts/components/account-card.tsx
index 211dfef..d323159 100644
--- a/src/features/accounts/components/account-card.tsx
+++ b/src/features/accounts/components/account-card.tsx
@@ -88,7 +88,9 @@ export function AccountCard({
{icon}
) : null}
- {accountName}
+
+ {accountName}
+
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
diff --git a/src/features/accounts/components/account-statement-card.tsx b/src/features/accounts/components/account-statement-card.tsx
index 12b16d0..750a8db 100644
--- a/src/features/accounts/components/account-statement-card.tsx
+++ b/src/features/accounts/components/account-statement-card.tsx
@@ -68,7 +68,7 @@ export function AccountStatementCard({
) : null}
-
+
{accountName}
@@ -81,12 +81,12 @@ export function AccountStatementCard({
{/* Linha 2 — saldo final (hero) */}
-
+
Saldo ao final do período
-
- PDF Protegido
-
+ PDF Protegido
);
}
@@ -153,7 +151,7 @@ export function AttachmentGridItem({
-
+
{attachment.fileName}
@@ -180,25 +178,21 @@ export function AttachmentGridItem({
{attachment.transactionName}
-
+
{formatCurrency(amount)}
{/* Footer: Tamanho + Botão Detalhes */}
-
+
{formatBytes(attachment.fileSize)}
{isLoadingDetails ? "Carregando..." : "Detalhes"}
diff --git a/src/features/attachments/components/attachment-preview.tsx b/src/features/attachments/components/attachment-preview.tsx
index caed828..4466cdd 100644
--- a/src/features/attachments/components/attachment-preview.tsx
+++ b/src/features/attachments/components/attachment-preview.tsx
@@ -105,7 +105,7 @@ export function AttachmentPreview({
>
-
+
{currentIndex + 1} / {attachments.length}
- }
- title="Anexos"
- subtitle="Comprovantes e documentos dos seus lançamentos no mês."
- />
-
-
-
{attachments.length === 0 ? (
diff --git a/src/features/auth/components/auth-header.tsx b/src/features/auth/components/auth-header.tsx
index 8ba6f67..faab22a 100644
--- a/src/features/auth/components/auth-header.tsx
+++ b/src/features/auth/components/auth-header.tsx
@@ -8,7 +8,7 @@ interface AuthHeaderProps {
export function AuthHeader({ title, description }: AuthHeaderProps) {
return (
-
+
{title}
{description ? (
diff --git a/src/features/budgets/components/budget-card.tsx b/src/features/budgets/components/budget-card.tsx
index b04d4b2..be51b42 100644
--- a/src/features/budgets/components/budget-card.tsx
+++ b/src/features/budgets/components/budget-card.tsx
@@ -52,7 +52,7 @@ export function BudgetCard({
size="lg"
/>
-
+
{formatCategoryName(budget)}
diff --git a/src/features/calendar/components/calendar-grid.tsx b/src/features/calendar/components/calendar-grid.tsx
index 5a916fb..638510f 100644
--- a/src/features/calendar/components/calendar-grid.tsx
+++ b/src/features/calendar/components/calendar-grid.tsx
@@ -19,9 +19,9 @@ export function CalendarGrid({
}: CalendarGridProps) {
return (
-
+
{WEEK_DAYS_SHORT.map((dayName) => (
-
+
{dayName}
))}
diff --git a/src/features/calendar/components/event-modal.tsx b/src/features/calendar/components/event-modal.tsx
index 5a39a9a..e44657f 100644
--- a/src/features/calendar/components/event-modal.tsx
+++ b/src/features/calendar/components/event-modal.tsx
@@ -130,7 +130,7 @@ const renderCard = (event: Extract) => (
- Vencimento Invoice - {event.card.name}
+ Vencimento Fatura - {event.card.name}
diff --git a/src/features/cards/components/card-item.tsx b/src/features/cards/components/card-item.tsx
index 5cbf630..4b63804 100644
--- a/src/features/cards/components/card-item.tsx
+++ b/src/features/cards/components/card-item.tsx
@@ -136,7 +136,7 @@ export function CardItem({
-
+
{name}
{note ? (
@@ -206,29 +206,29 @@ export function CardItem({
<>
-
+
-
+
{metrics[0].label}
-
+
-
+
{metrics[1].label}
-
+
-
+
{metrics[2].label}
diff --git a/src/features/categories/components/categories-page.tsx b/src/features/categories/components/categories-page.tsx
index 238b80b..cb3726c 100644
--- a/src/features/categories/components/categories-page.tsx
+++ b/src/features/categories/components/categories-page.tsx
@@ -183,7 +183,7 @@ export function CategoriesPage({ categories }: CategoriesPageProps) {
{category.name}
-
+
{category.name}
@@ -99,7 +99,7 @@ export function CategoryDetailHeader({
Total em {currentPeriodLabel}
-
+
{currencyFormatter.format(currentTotal)}
@@ -107,7 +107,7 @@ export function CategoryDetailHeader({
Total em {previousPeriodLabel}
-
+
{currencyFormatter.format(previousTotal)}
@@ -117,7 +117,7 @@ export function CategoryDetailHeader({
diff --git a/src/features/categories/components/category-picker-dialog.tsx b/src/features/categories/components/category-picker-dialog.tsx
index 91f73da..97175cb 100644
--- a/src/features/categories/components/category-picker-dialog.tsx
+++ b/src/features/categories/components/category-picker-dialog.tsx
@@ -80,7 +80,7 @@ export function CategoryPickerDialog({
{filteredGroups.map((group) => (
-
+
{group.label}
diff --git a/src/features/dashboard/categories/category-details-queries.ts b/src/features/dashboard/categories/category-details-queries.ts
index aac5c75..54a4f15 100644
--- a/src/features/dashboard/categories/category-details-queries.ts
+++ b/src/features/dashboard/categories/category-details-queries.ts
@@ -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),
diff --git a/src/features/dashboard/components/attachments-widget.tsx b/src/features/dashboard/components/attachments-widget.tsx
new file mode 100644
index 0000000..e78731c
--- /dev/null
+++ b/src/features/dashboard/components/attachments-widget.tsx
@@ -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 (
+
}
+ title="Nenhum anexo no período"
+ description="Adicione comprovantes nos seus lançamentos para vê-los aqui."
+ />
+ );
+ }
+
+ return (
+ <>
+
+
+
+ {snapshot.totalCount} {snapshot.totalCount === 1 ? "anexo" : "anexos"}
+
+
+ {formatBytes(snapshot.totalBytes)}
+
+ {snapshot.imageCount > 0 && (
+
+
+ {snapshot.imageCount}
+
+ )}
+ {snapshot.pdfCount > 0 && (
+
+
+ {snapshot.pdfCount}
+
+ )}
+
+
+
+ {snapshot.recentAttachments.map((attachment, index) => {
+ const isPdf = attachment.mimeType === "application/pdf";
+ const isImage = attachment.mimeType.startsWith("image/");
+
+ return (
+
+ setSelectedIndex(index)}
+ className="flex w-full items-center gap-2 py-2 text-left"
+ >
+
+ {isPdf && }
+ {isImage && }
+ {!isPdf && !isImage && (
+
+ )}
+
+
+
+
+
+ {attachment.fileName}
+
+
+
+ {attachment.fileName}
+
+
+
+ {attachment.transactionName}
+
+
+
+
+ {formatDateOnly(attachment.purchaseDate, {
+ day: "2-digit",
+ month: "2-digit",
+ }) ?? "—"}
+
+
+ {formatBytes(attachment.fileSize)}
+
+
+
+
+ );
+ })}
+
+
+
setSelectedIndex(-1)}
+ />
+ >
+ );
+}
diff --git a/src/features/dashboard/components/bills/bill-list-item.tsx b/src/features/dashboard/components/bills/bill-list-item.tsx
index bda3a49..0dd6eff 100644
--- a/src/features/dashboard/components/bills/bill-list-item.tsx
+++ b/src/features/dashboard/components/bills/bill-list-item.tsx
@@ -46,7 +46,7 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
{statusLabel}
@@ -60,7 +60,7 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
{statusLabel}
@@ -72,7 +72,7 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
-
+
Boleto
-
+
{bill.name}
@@ -113,7 +113,7 @@ export function BillPaymentDialog({
diff --git a/src/features/dashboard/components/category-breakdown/category-breakdown-widget-view.tsx b/src/features/dashboard/components/category-breakdown/category-breakdown-widget-view.tsx
index 2b0228d..1c234f3 100644
--- a/src/features/dashboard/components/category-breakdown/category-breakdown-widget-view.tsx
+++ b/src/features/dashboard/components/category-breakdown/category-breakdown-widget-view.tsx
@@ -281,12 +281,12 @@ export function CategoryBreakdownWidgetView({
{category.percentageChange !== null ? (
{hasIncrease ? (
diff --git a/src/features/dashboard/components/category-history-widget.tsx b/src/features/dashboard/components/category-history-widget.tsx
index 7e7edc1..a14960d 100644
--- a/src/features/dashboard/components/category-history-widget.tsx
+++ b/src/features/dashboard/components/category-history-widget.tsx
@@ -197,7 +197,9 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
style={{ backgroundColor: color }}
/>
)}
- {category.name}
+
+ {category.name}
+
-
+
{formatCurrency(value)}
diff --git a/src/features/dashboard/components/category-trends-widget.tsx b/src/features/dashboard/components/category-trends-widget.tsx
new file mode 100644
index 0000000..28feb43
--- /dev/null
+++ b/src/features/dashboard/components/category-trends-widget.tsx
@@ -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 (
+ }
+ title="Dados insuficientes"
+ description="As variações aparecem após lançamentos em dois meses consecutivos."
+ />
+ );
+ }
+
+ return (
+
+ {trending.map((category) => {
+ const change = category.percentageChange ?? 0;
+ const isUp = change > 0;
+
+ return (
+
+
+
+
+
+ {category.categoryName}
+
+
+ vs{" "}
+
+
+
+
+ {isUp ? (
+
+ ) : (
+
+ )}
+ {Math.abs(change).toFixed(0)}%
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/features/dashboard/components/dashboard-grid-editable.tsx b/src/features/dashboard/components/dashboard-grid-editable.tsx
index 5b2abe4..a6b768d 100644
--- a/src/features/dashboard/components/dashboard-grid-editable.tsx
+++ b/src/features/dashboard/components/dashboard-grid-editable.tsx
@@ -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,
})}
diff --git a/src/features/dashboard/components/dashboard-metrics-cards.tsx b/src/features/dashboard/components/dashboard-metrics-cards.tsx
index 30b03d6..ac80de0 100644
--- a/src/features/dashboard/components/dashboard-metrics-cards.tsx
+++ b/src/features/dashboard/components/dashboard-metrics-cards.tsx
@@ -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) {
-
+
{label}
@@ -195,7 +196,7 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
no mês anterior
diff --git a/src/features/dashboard/components/dashboard-welcome.tsx b/src/features/dashboard/components/dashboard-welcome.tsx
index 32649b1..3bea253 100644
--- a/src/features/dashboard/components/dashboard-welcome.tsx
+++ b/src/features/dashboard/components/dashboard-welcome.tsx
@@ -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 (
-
-
+
+
{greeting}, {displayName}
- {formattedDate}
+ {formattedDate}
);
diff --git a/src/features/dashboard/components/goals-progress/goal-progress-item.tsx b/src/features/dashboard/components/goals-progress/goal-progress-item.tsx
index 4ea14bc..9a7c2e0 100644
--- a/src/features/dashboard/components/goals-progress/goal-progress-item.tsx
+++ b/src/features/dashboard/components/goals-progress/goal-progress-item.tsx
@@ -44,8 +44,9 @@ export function GoalProgressItem({
{item.categoryName}
- de{" "}
-
+ {" "}
+ de{" "}
+
{formatGoalProgressPercentage(percentageDelta, true)}
diff --git a/src/features/dashboard/components/inbox-widget.tsx b/src/features/dashboard/components/inbox-widget.tsx
new file mode 100644
index 0000000..d87a0a9
--- /dev/null
+++ b/src/features/dashboard/components/inbox-widget.tsx
@@ -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 (
+ }
+ title="Tudo em dia"
+ description="Nenhum pré-lançamento aguardando revisão."
+ />
+ );
+ }
+
+ return (
+
+ {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 (
+
+
+
+
+
+
+ {displayName}
+
+
+ {item.sourceAppName && {item.sourceAppName} }
+
+ {relativeTime(item.createdAt)}
+
+
+
+
+
+
+ {amount !== null && (
+
+ )}
+
+ handleProcessRequest(item)}
+ aria-label="Processar notificação"
+ title="Processar"
+ >
+
+
+ handleDiscardRequest(item)}
+ aria-label="Descartar notificação"
+ title="Descartar"
+ >
+
+
+
+
+
+ );
+ })}
+
+
+
+
+
+ );
+}
diff --git a/src/features/dashboard/components/installment-analysis/installment-analysis-page.tsx b/src/features/dashboard/components/installment-analysis/installment-analysis-page.tsx
index 093ce12..f60aebe 100644
--- a/src/features/dashboard/components/installment-analysis/installment-analysis-page.tsx
+++ b/src/features/dashboard/components/installment-analysis/installment-analysis-page.tsx
@@ -132,12 +132,12 @@ export function InstallmentAnalysisPage({
{/* Card de resumo principal */}
-
+
Se você pagar tudo que está selecionado:
{selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}{" "}
@@ -167,7 +167,7 @@ export function InstallmentAnalysisPage({
{/* Seção de Lançamentos Parcelados */}
{data.installmentGroups.length > 0 && (
-
+
{data.installmentGroups.map((group) => (
!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 (
-
-
- {/* Header do card */}
-
-
+ <>
+
+ {/* Header Section */}
+
+
+ {/* Checkbox de seleção do grupo */}
+
+
+
-
-
-
- {group.cartaoLogo && (
-
+
+ {group.cartaoLogo ? (
+
+ ) : (
+
+
+
)}
-
{group.name}
-
- | {group.cartaoName}
-
-
-
-
-
- Total:
-
-
-
-
- Pendente:
-
-
+
+
+ {group.name}
+
+
+ {group.cartaoName ?? "Compra parcelada"}
+
- {/* Progress bar */}
-
-
+ {/* Badge de status */}
+
+ {progress === 100 ? "Quitado" : `${Math.round(progress)}% pago`}
+
+
+
+
+
+ {/* Grid de valores */}
+
+
+
+
+ Pendente
+
+
0 ? "text-amber-600" : "text-success-600",
+ )}
+ />
+
+
+
+ {/* Barra de progresso */}
+
+
+
+
- {group.paidInstallments} de {group.totalInstallments} pagas
+ {group.paidInstallments} de {group.totalInstallments} parcelas
+ pagas
-
+
+ {unpaidCount > 0 && (
+
+
{unpaidCount} {unpaidCount === 1 ? "pendente" : "pendentes"}
- {selectedInstallments.size > 0 && (
-
- • Selecionado:{" "}
-
-
- )}
-
-
-
-
- {/* Botão de expandir */}
-
setIsExpanded(!isExpanded)}
- className="mt-2 flex items-center gap-1 text-xs font-medium text-primary hover:underline"
- >
- {isExpanded ? (
- <>
-
- Ocultar parcelas ({group.pendingInstallments.length})
- >
- ) : (
- <>
-
- Ver parcelas ({group.pendingInstallments.length})
- >
)}
-
+
+
-
- {/* Lista de parcelas expandida */}
- {isExpanded && (
-
- {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 && (
+
+
+ {selectedInstallments.size}{" "}
+ {selectedInstallments.size === 1
+ ? "parcela selecionada"
+ : "parcelas selecionadas"}
+
+
+
+ )}
- return (
-
-
- !isPaid && onToggleInstallment(installment.id)
- }
- aria-label={`Selecionar parcela ${installment.currentInstallment} de ${group.totalInstallments}`}
- />
+ {/* Botão para abrir detalhes */}
+ setIsDetailsOpen(true)}
+ >
+
+ Ver detalhes ({group.pendingInstallments.length} parcelas)
+
+
+
-
-
-
- Parcela {installment.currentInstallment}/
- {group.totalInstallments}
- {isPaid && (
-
- Pago
-
- )}
-
-
- Vencimento: {dueDate}
-
-
-
-
-
+ {/* Modal de detalhes */}
+
+
+
+
+ {group.cartaoLogo ? (
+
+ ) : (
+
+
- );
- })}
+ )}
+
{group.name}
+
+
+ Detalhes das parcelas do grupo {group.name}
+
+
+
+
+ {/* Parcelas pagas */}
+ {paidInstallments.length > 0 && (
+
+
+ Parcelas pagas
+
+ {paidInstallments.map((installment) => {
+ const dueDate = installment.dueDate
+ ? format(installment.dueDate, "dd MMM yyyy", {
+ locale: ptBR,
+ })
+ : format(installment.purchaseDate, "dd MMM yyyy", {
+ locale: ptBR,
+ });
+
+ return (
+
+
+
+
+
+
+
+ Parcela {installment.currentInstallment}/
+ {group.totalInstallments}
+
+
+ Vencimento: {dueDate}
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ {/* Parcelas pendentes */}
+ {unpaidInstallments.length > 0 && (
+
+
+ Parcelas pendentes
+
+ {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 (
+
+
+ onToggleInstallment(installment.id)
+ }
+ className="size-5"
+ aria-label={`Selecionar parcela ${installment.currentInstallment} de ${group.totalInstallments}`}
+ />
+
+
+
+ Parcela {installment.currentInstallment}/
+ {group.totalInstallments}
+
+
+
+ Vencimento: {dueDate}
+
+
+
+
+
+ );
+ })}
+
+ )}
- )}
-
-
+
+ {/* Footer com resumo da seleção */}
+ {hasSelection && (
+
+
+ {selectedInstallments.size}{" "}
+ {selectedInstallments.size === 1
+ ? "parcela selecionada"
+ : "parcelas selecionadas"}
+
+
+
+ )}
+
+
+ >
);
}
diff --git a/src/features/dashboard/components/installment-expenses/installment-expense-list-item.tsx b/src/features/dashboard/components/installment-expenses/installment-expense-list-item.tsx
index 9a7bcb4..22ddd1b 100644
--- a/src/features/dashboard/components/installment-expenses/installment-expense-list-item.tsx
+++ b/src/features/dashboard/components/installment-expenses/installment-expense-list-item.tsx
@@ -59,7 +59,10 @@ export function InstallmentExpenseListItem({
) : null}
-
+
@@ -67,7 +70,7 @@ export function InstallmentExpenseListItem({
{" · Restante "}
{" "}
({remainingInstallments})
diff --git a/src/features/dashboard/components/invoices/invoice-list-item.tsx b/src/features/dashboard/components/invoices/invoice-list-item.tsx
index 5f7d3da..fcb0672 100644
--- a/src/features/dashboard/components/invoices/invoice-list-item.tsx
+++ b/src/features/dashboard/components/invoices/invoice-list-item.tsx
@@ -116,7 +116,10 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
-
+
))}
@@ -144,7 +147,7 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
paymentTooltipLabel ? (
-
+
{paymentInfo.label}
@@ -153,7 +156,9 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
) : (
-
{paymentInfo.label}
+
+ {paymentInfo.label}
+
)
) : null}
@@ -161,7 +166,10 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
-
+
Cartão
-
+
{invoice.cardName}
@@ -130,7 +130,7 @@ export function InvoicePaymentDialog({
diff --git a/src/features/dashboard/components/my-accounts-widget.tsx b/src/features/dashboard/components/my-accounts-widget.tsx
index eedddbe..4eff4c5 100644
--- a/src/features/dashboard/components/my-accounts-widget.tsx
+++ b/src/features/dashboard/components/my-accounts-widget.tsx
@@ -78,7 +78,7 @@ export function MyAccountsWidget({
{excludedAccountsCount > 0 ? (
@@ -137,7 +137,7 @@ export function MyAccountsWidget({
) : (
- {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}
@@ -199,7 +200,10 @@ export function MyAccountsWidget({
-
+
);
diff --git a/src/features/dashboard/components/payers-widget.tsx b/src/features/dashboard/components/payers-widget.tsx
index d443918..fae2e57 100644
--- a/src/features/dashboard/components/payers-widget.tsx
+++ b/src/features/dashboard/components/payers-widget.tsx
@@ -83,10 +83,13 @@ export function PayersWidget({ payers }: PayersWidgetProps) {
-
+
{percentageChange !== null && (
0
? "text-destructive"
: percentageChange < 0
diff --git a/src/features/dashboard/components/payment-overview-widget.tsx b/src/features/dashboard/components/payment-overview-widget.tsx
index 2de4276..4a0bb7b 100644
--- a/src/features/dashboard/components/payment-overview-widget.tsx
+++ b/src/features/dashboard/components/payment-overview-widget.tsx
@@ -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}
/>
);
}
diff --git a/src/features/dashboard/components/payment-overview/payment-breakdown-list-item.tsx b/src/features/dashboard/components/payment-overview/payment-breakdown-list-item.tsx
index 2f37786..c645fe4 100644
--- a/src/features/dashboard/components/payment-overview/payment-breakdown-list-item.tsx
+++ b/src/features/dashboard/components/payment-overview/payment-breakdown-list-item.tsx
@@ -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({
-
{item.title}
-
+ {item.href ? (
+
+
{item.title}
+
+
+ ) : (
+
{item.title}
+ )}
+
diff --git a/src/features/dashboard/components/payment-overview/payment-conditions-widget.tsx b/src/features/dashboard/components/payment-overview/payment-conditions-widget.tsx
index 189d398..73bbe60 100644
--- a/src/features/dashboard/components/payment-overview/payment-conditions-widget.tsx
+++ b/src/features/dashboard/components/payment-overview/payment-conditions-widget.tsx
@@ -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 (
diff --git a/src/features/dashboard/components/payment-overview/payment-methods-widget.tsx b/src/features/dashboard/components/payment-overview/payment-methods-widget.tsx
index cf84b59..a056b47 100644
--- a/src/features/dashboard/components/payment-overview/payment-methods-widget.tsx
+++ b/src/features/dashboard/components/payment-overview/payment-methods-widget.tsx
@@ -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) =>
);
-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 (
void;
+ period: string;
+ adminPayerSlug: string | null;
};
export function PaymentOverviewWidgetView({
@@ -23,6 +25,8 @@ export function PaymentOverviewWidgetView({
paymentConditionsData,
paymentMethodsData,
onTabChange,
+ period,
+ adminPayerSlug,
}: PaymentOverviewWidgetViewProps) {
return (
@@ -38,11 +42,19 @@ export function PaymentOverviewWidgetView({
-
+
-
+
);
diff --git a/src/features/dashboard/components/payment-status/payment-status-category-section.tsx b/src/features/dashboard/components/payment-status/payment-status-category-section.tsx
index cb6942e..907f9c8 100644
--- a/src/features/dashboard/components/payment-status/payment-status-category-section.tsx
+++ b/src/features/dashboard/components/payment-status/payment-status-category-section.tsx
@@ -24,10 +24,7 @@ export function PaymentStatusCategorySection({
{title}
-
+
@@ -35,13 +32,13 @@ export function PaymentStatusCategorySection({
-
+
confirmados
-
+
pendentes
diff --git a/src/features/dashboard/components/purchases-by-category-widget.tsx b/src/features/dashboard/components/purchases-by-category-widget.tsx
index 687a425..c2f53d4 100644
--- a/src/features/dashboard/components/purchases-by-category-widget.tsx
+++ b/src/features/dashboard/components/purchases-by-category-widget.tsx
@@ -178,7 +178,10 @@ export function PurchasesByCategoryWidget({
-
+
);
diff --git a/src/features/dashboard/components/recurring-expenses-widget.tsx b/src/features/dashboard/components/recurring-expenses-widget.tsx
index 9b848f7..59b0868 100644
--- a/src/features/dashboard/components/recurring-expenses-widget.tsx
+++ b/src/features/dashboard/components/recurring-expenses-widget.tsx
@@ -45,7 +45,7 @@ export function RecurringExpensesWidget({
{expense.name}
-
+
diff --git a/src/features/dashboard/components/top-establishments-widget.tsx b/src/features/dashboard/components/top-establishments-widget.tsx
index bbddea3..e3742a7 100644
--- a/src/features/dashboard/components/top-establishments-widget.tsx
+++ b/src/features/dashboard/components/top-establishments-widget.tsx
@@ -48,7 +48,10 @@ export function TopEstablishmentsWidget({
-
+
);
diff --git a/src/features/dashboard/components/top-expenses-widget.tsx b/src/features/dashboard/components/top-expenses-widget.tsx
index c0cf92d..fddf358 100644
--- a/src/features/dashboard/components/top-expenses-widget.tsx
+++ b/src/features/dashboard/components/top-expenses-widget.tsx
@@ -113,7 +113,10 @@ export function TopExpensesWidget({
-
+
);
diff --git a/src/features/dashboard/fetch-dashboard-data.ts b/src/features/dashboard/fetch-dashboard-data.ts
index 48bb84c..f091634 100644
--- a/src/features/dashboard/fetch-dashboard-data.ts
+++ b/src/features/dashboard/fetch-dashboard-data.ts
@@ -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,
};
}
diff --git a/src/features/dashboard/inbox-snapshot-queries.ts b/src/features/dashboard/inbox-snapshot-queries.ts
new file mode 100644
index 0000000..6bf7a2f
--- /dev/null
+++ b/src/features/dashboard/inbox-snapshot-queries.ts
@@ -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
;
+};
+
+export async function fetchDashboardInboxSnapshot(
+ userId: string,
+): Promise {
+ "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 = {};
+ 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,
+ };
+}
diff --git a/src/features/dashboard/widgets/widgets-config.tsx b/src/features/dashboard/widgets/widgets-config.tsx
index 5922cdb..04d6c9f 100644
--- a/src/features/dashboard/widgets/widgets-config.tsx
+++ b/src/features/dashboard/widgets/widgets-config.tsx
@@ -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: ,
component: ({ data }) => (
),
},
+ {
+ id: "inbox",
+ title: "Pré-lançamentos",
+ subtitle: "Notificações pendentes de revisão",
+ icon: ,
+ component: ({ data, quickActionOptions }) => (
+
+ ),
+ action: (
+
+ Revisar
+
+
+ ),
+ },
{
id: "income-expense-balance",
title: "Receita, Despesa e Balanço",
- subtitle: "Últimos 6 Meses",
+ subtitle: "Últimos 6 meses",
icon: ,
component: ({ data }) => (
),
},
+ {
+ id: "goals-progress",
+ title: "Progresso de Orçamentos",
+ subtitle: "Orçamentos por categoria no período",
+ icon: ,
+ component: ({ data }) => (
+
+ ),
+ action: (
+
+ Ver todos
+
+
+ ),
+ },
+ {
+ id: "category-trends",
+ title: "Tendências de Categorias",
+ subtitle: "Top 10 maiores variações vs. mês anterior",
+ icon: ,
+ component: ({ data }) => (
+
+ ),
+ },
+ {
+ id: "spending-overview",
+ title: "Panorama de Gastos",
+ subtitle: "Principais despesas e frequência por local",
+ icon: ,
+ component: ({ data }) => (
+
+ ),
+ },
+ {
+ id: "payment-overview",
+ title: "Comportamento de Pagamento",
+ subtitle: "Despesas por condição e forma de pagamento",
+ icon: ,
+ component: ({ data, period, adminPayerSlug }) => (
+
+ ),
+ },
+ {
+ id: "expenses-by-category",
+ title: "Categorias por Despesas",
+ subtitle: "Distribuição de despesas por categoria",
+ icon: ,
+ component: ({ data, period }) => (
+
+ ),
+ },
+ {
+ id: "income-by-category",
+ title: "Categorias por Receitas",
+ subtitle: "Distribuição de receitas por categoria",
+ icon: ,
+ component: ({ data, period }) => (
+
+ ),
+ },
+ {
+ id: "purchases-by-category",
+ title: "Lançamentos por Categorias",
+ subtitle: "Distribuição de lançamentos por categoria",
+ icon: ,
+ component: ({ data }) => (
+
+ ),
+ },
+ {
+ id: "recurring-expenses",
+ title: "Lançamentos Recorrentes",
+ subtitle: "Despesas recorrentes do período",
+ icon: ,
+ component: ({ data }) => (
+
+ ),
+ },
+ {
+ id: "installment-expenses",
+ title: "Lançamentos Parcelados",
+ subtitle: "Acompanhe as parcelas abertas",
+ icon: ,
+ component: ({ data }) => (
+
+ ),
+ },
{
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: ,
+ id: "attachments",
+ title: "Anexos",
+ subtitle: "Comprovantes do período",
+ icon: ,
component: ({ data }) => (
-
+
),
action: (
Ver todos
@@ -155,80 +301,4 @@ export const widgetsConfig: WidgetConfig[] = [
),
},
- {
- id: "payment-overview",
- title: "Comportamento de Pagamento",
- subtitle: "Despesas por condição e forma de pagamento",
- icon: ,
- component: ({ data }) => (
-
- ),
- },
- {
- id: "recurring-expenses",
- title: "Lançamentos Recorrentes",
- subtitle: "Despesas recorrentes do período",
- icon: ,
- component: ({ data }) => (
-
- ),
- },
- {
- id: "installment-expenses",
- title: "Lançamentos Parcelados",
- subtitle: "Acompanhe as parcelas abertas",
- icon: ,
- component: ({ data }) => (
-
- ),
- },
- {
- id: "spending-overview",
- title: "Panorama de Gastos",
- subtitle: "Principais despesas e frequência por local",
- icon: ,
- component: ({ data }) => (
-
- ),
- },
- {
- id: "purchases-by-category",
- title: "Lançamentos por Categorias",
- subtitle: "Distribuição de lançamentos por categoria",
- icon: ,
- component: ({ data }) => (
-
- ),
- },
- {
- id: "income-by-category",
- title: "Categorias por Receitas",
- subtitle: "Distribuição de receitas por categoria",
- icon: ,
- component: ({ data, period }) => (
-
- ),
- },
- {
- id: "expenses-by-category",
- title: "Categorias por Despesas",
- subtitle: "Distribuição de despesas por categoria",
- icon: ,
- component: ({ data, period }) => (
-
- ),
- },
];
diff --git a/src/features/inbox/components/inbox-card.tsx b/src/features/inbox/components/inbox-card.tsx
index 8de995b..57d93bc 100644
--- a/src/features/inbox/components/inbox-card.tsx
+++ b/src/features/inbox/components/inbox-card.tsx
@@ -104,11 +104,11 @@ export const InboxCard = memo(function InboxCard({
return (
-
-
+
+
{onSelectToggle && (
)}
-
+
-
- {item.sourceAppName || item.sourceApp}
-
-
-
-
- {timeAgo}
-
-
- {fullDate}
-
+
+
+ {item.sourceAppName || item.sourceApp}
+
+
+
+
+ {timeAgo}
+
+
+ {fullDate}
+
+
{amount !== null && (
-
+
)}
diff --git a/src/features/inbox/components/inbox-details-dialog.tsx b/src/features/inbox/components/inbox-details-dialog.tsx
index b7ae91a..0e5d84f 100644
--- a/src/features/inbox/components/inbox-details-dialog.tsx
+++ b/src/features/inbox/components/inbox-details-dialog.tsx
@@ -67,7 +67,7 @@ export function InboxDetailsDialog({
-
+
Notificação Original
{item.originalTitle && (
diff --git a/src/features/insights/components/insights-grid.tsx b/src/features/insights/components/insights-grid.tsx
index 56dcd58..caa415e 100644
--- a/src/features/insights/components/insights-grid.tsx
+++ b/src/features/insights/components/insights-grid.tsx
@@ -82,7 +82,7 @@ export function InsightsGrid({ insights }: InsightsGridProps) {
-
+
{categoryConfig.title}
diff --git a/src/features/insights/components/insights-page.tsx b/src/features/insights/components/insights-page.tsx
index 099dcae..90ebd93 100644
--- a/src/features/insights/components/insights-page.tsx
+++ b/src/features/insights/components/insights-page.tsx
@@ -301,7 +301,7 @@ function ErrorState({
return (
-
{title}
+
{title}
{error}
diff --git a/src/features/insights/components/model-selector.tsx b/src/features/insights/components/model-selector.tsx
index ba448e7..dcff8df 100644
--- a/src/features/insights/components/model-selector.tsx
+++ b/src/features/insights/components/model-selector.tsx
@@ -133,7 +133,7 @@ export function ModelSelector({
{/* Descrição */}
-
Definir modelo de análise
+
Definir modelo de análise
Escolha o provedor de IA e o modelo específico que será utilizado para
gerar insights sobre seus dados financeiros.
diff --git a/src/features/invoices/components/invoice-summary-card.tsx b/src/features/invoices/components/invoice-summary-card.tsx
index e599b79..9c298b7 100644
--- a/src/features/invoices/components/invoice-summary-card.tsx
+++ b/src/features/invoices/components/invoice-summary-card.tsx
@@ -176,7 +176,7 @@ export function InvoiceSummaryCard({
) : null}
-
+
{cardName}
@@ -189,13 +189,11 @@ export function InvoiceSummaryCard({
{/* Linha 2 — valor da fatura (hero) */}
-
- Valor da fatura
-
+
Valor da fatura
diff --git a/src/features/landing/components/setup-tabs.tsx b/src/features/landing/components/setup-tabs.tsx
index 0075017..51f2273 100644
--- a/src/features/landing/components/setup-tabs.tsx
+++ b/src/features/landing/components/setup-tabs.tsx
@@ -103,7 +103,7 @@ function StepCard({
{step}
-
{title}
+ {title}
{children}
diff --git a/src/features/notes/components/note-card.tsx b/src/features/notes/components/note-card.tsx
index f458c88..a50003d 100644
--- a/src/features/notes/components/note-card.tsx
+++ b/src/features/notes/components/note-card.tsx
@@ -77,7 +77,7 @@ export function NoteCard({
-
+
{displayTitle}
{createdAtLabel && (
diff --git a/src/features/payers/actions.ts b/src/features/payers/actions.ts
index d2e6802..df836f6 100644
--- a/src/features/payers/actions.ts
+++ b/src/features/payers/actions.ts
@@ -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.",
};
}
diff --git a/src/features/payers/components/details/payer-header-card.tsx b/src/features/payers/components/details/payer-header-card.tsx
index f0ab14f..acb9ec5 100644
--- a/src/features/payers/components/details/payer-header-card.tsx
+++ b/src/features/payers/components/details/payer-header-card.tsx
@@ -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({
-
+
{payer.name}
{isAdmin ? (
@@ -215,10 +217,10 @@ export function PayerHeaderCard({
-
+
Total de Despesas
-
+
{formatCurrency(summary.totalExpenses)}
@@ -239,7 +241,7 @@ export function PayerHeaderCard({
Cartões
-
+
{formatCurrency(summary.paymentSplits.card)}
@@ -251,7 +253,7 @@ export function PayerHeaderCard({
Boletos
-
+
{formatCurrency(summary.paymentSplits.boleto)}
@@ -263,7 +265,7 @@ export function PayerHeaderCard({
Pix/Débito
-
+
{formatCurrency(summary.paymentSplits.instant)}
diff --git a/src/features/payers/components/details/payer-history-card.tsx b/src/features/payers/components/details/payer-history-card.tsx
index da83da0..82ff394 100644
--- a/src/features/payers/components/details/payer-history-card.tsx
+++ b/src/features/payers/components/details/payer-history-card.tsx
@@ -63,7 +63,7 @@ export function PayerHistoryCard({ data }: PagadorHistoryCardProps) {
return (
-
+
Evolução (últimos 6 meses)
diff --git a/src/features/payers/components/details/payer-info-card.tsx b/src/features/payers/components/details/payer-info-card.tsx
index 5a5a3ec..2213aa1 100644
--- a/src/features/payers/components/details/payer-info-card.tsx
+++ b/src/features/payers/components/details/payer-info-card.tsx
@@ -31,7 +31,7 @@ export function PagadorInfoCard({ payer }: PayerInfoCardProps) {
return (
-
+
Detalhes do pagador
@@ -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 = {
diff --git a/src/features/payers/components/details/payer-leave-share-card.tsx b/src/features/payers/components/details/payer-leave-share-card.tsx
index 6e5ecda..63c3f6c 100644
--- a/src/features/payers/components/details/payer-leave-share-card.tsx
+++ b/src/features/payers/components/details/payer-leave-share-card.tsx
@@ -53,7 +53,7 @@ export function PayerLeaveShareCard({
return (
-
+
Acesso Compartilhado
diff --git a/src/features/payers/components/details/payer-monthly-summary-card.tsx b/src/features/payers/components/details/payer-monthly-summary-card.tsx
index 8b745d0..4b64fbf 100644
--- a/src/features/payers/components/details/payer-monthly-summary-card.tsx
+++ b/src/features/payers/components/details/payer-monthly-summary-card.tsx
@@ -51,7 +51,7 @@ export function PayerMonthlySummaryCard({
return (
- Totais do mês
+ Totais do mês
{periodLabel} - Despesas por forma de pagamento
@@ -65,7 +65,7 @@ export function PayerMonthlySummaryCard({
@@ -100,7 +100,7 @@ export function PayerMonthlySummaryCard({
totalBase > 0 ? Math.round((entry.value / totalBase) * 100) : 0;
return (
-
+
{percent}% das despesas
diff --git a/src/features/payers/components/details/payer-sharing-card.tsx b/src/features/payers/components/details/payer-sharing-card.tsx
index f6b56ad..15908f8 100644
--- a/src/features/payers/components/details/payer-sharing-card.tsx
+++ b/src/features/payers/components/details/payer-sharing-card.tsx
@@ -84,7 +84,9 @@ export function PayerSharingCard({
return (
- Compartilhamentos
+
+ Compartilhamentos
+
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
diff --git a/src/features/payers/components/payer-card.tsx b/src/features/payers/components/payer-card.tsx
index 2298357..dc95573 100644
--- a/src/features/payers/components/payer-card.tsx
+++ b/src/features/payers/components/payer-card.tsx
@@ -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) {
-
- {payer.name}
-
+ {payer.name}
{isAdmin ? (
) : null}
diff --git a/src/features/payers/components/payer-dialog.tsx b/src/features/payers/components/payer-dialog.tsx
index 9fdd9e6..a53c792 100644
--- a/src/features/payers/components/payer-dialog.tsx
+++ b/src/features/payers/components/payer-dialog.tsx
@@ -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 {
+ 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[0];
interface PayerDialogProps {
@@ -77,8 +117,10 @@ export function PayerDialog({
}: PayerDialogProps) {
const [errorMessage, setErrorMessage] = useState(null);
const [isPending, startTransition] = useTransition();
+ const [uploadedAvatar, setUploadedAvatar] = useState(null);
+ const [isProcessingImage, setIsProcessingImage] = useState(false);
+ const fileInputRef = useRef(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(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) => {
+ 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 (
{trigger ? {trigger} : null}
@@ -255,6 +321,7 @@ export function PayerDialog({
diff --git a/src/features/payers/detail-actions.ts b/src/features/payers/detail-actions.ts
index 701bc33..633e1ac 100644
--- a/src/features/payers/detail-actions.ts
+++ b/src/features/payers/detail-actions.ts
@@ -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) {
diff --git a/src/features/reports/components/cards/card-usage-chart.tsx b/src/features/reports/components/cards/card-usage-chart.tsx
index 261d809..78e045d 100644
--- a/src/features/reports/components/cards/card-usage-chart.tsx
+++ b/src/features/reports/components/cards/card-usage-chart.tsx
@@ -142,7 +142,7 @@ export function CardUsageChart({ data, limit, card }: CardUsageChartProps) {
Uso
-
+
{formatCurrency(value, {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
@@ -154,7 +154,7 @@ export function CardUsageChart({ data, limit, card }: CardUsageChartProps) {
% do Limite
-
+
{formatPercentage(usagePercent, {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
diff --git a/src/features/reports/components/cards/cards-overview.tsx b/src/features/reports/components/cards/cards-overview.tsx
index f6a2b1f..b2d6f80 100644
--- a/src/features/reports/components/cards/cards-overview.tsx
+++ b/src/features/reports/components/cards/cards-overview.tsx
@@ -67,11 +67,11 @@ export function CardsOverview({ data }: CardsOverviewProps) {
{card.title}
{card.isMoney ? (
) : (
-
+
{formatPercentage(card.value, {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
@@ -83,7 +83,7 @@ export function CardsOverview({ data }: CardsOverviewProps) {
))}
- Meus cartões
+ Meus cartões
{/* Cards list */}
@@ -116,7 +116,7 @@ export function CardsOverview({ data }: CardsOverviewProps) {
-
+
{card.name}
{brandAsset && (
@@ -129,7 +129,7 @@ export function CardsOverview({ data }: CardsOverviewProps) {
/>
)}
-
+
{formatCurrency(card.currentUsage)} /{" "}
{formatCurrency(card.limit)}
@@ -141,7 +141,7 @@ export function CardsOverview({ data }: CardsOverviewProps) {
`[&>div]:${getUsageColor(card.usagePercent)}`,
)}
/>
-
+
{formatPercentage(card.usagePercent, {
maximumFractionDigits: 0,
minimumFractionDigits: 0,
diff --git a/src/features/reports/components/category-cell.tsx b/src/features/reports/components/category-cell.tsx
index 82d0fb7..dd792b2 100644
--- a/src/features/reports/components/category-cell.tsx
+++ b/src/features/reports/components/category-cell.tsx
@@ -53,7 +53,9 @@ export function CategoryCell({
>
{isIncrease && }
{isDecrease && }
- {formatPercentageChange(percentageChange)}
+
+ {formatPercentageChange(percentageChange)}
+
)}
diff --git a/src/features/reports/components/category-report-chart.tsx b/src/features/reports/components/category-report-chart.tsx
index 51447fa..fe4b4d6 100644
--- a/src/features/reports/components/category-report-chart.tsx
+++ b/src/features/reports/components/category-report-chart.tsx
@@ -73,7 +73,7 @@ function AreaTooltip({
{entry.name}
-
+
{currencyFormatter.format(Number(entry.value))}
diff --git a/src/features/reports/components/category-table.tsx b/src/features/reports/components/category-table.tsx
index ab76c22..9170a83 100644
--- a/src/features/reports/components/category-table.tsx
+++ b/src/features/reports/components/category-table.tsx
@@ -78,12 +78,12 @@ export function CategoryTable({
{periods.map((period) => (
{formatPeriodLabel(period)}
))}
-
+
Média
@@ -100,7 +100,7 @@ export function CategoryTable({
-
+
Total
@@ -128,7 +128,7 @@ export function CategoryTable({
/>
{category.name}
@@ -149,7 +149,7 @@ export function CategoryTable({
);
})}
-
+
{(() => {
const nonZeroCount = periods.filter(
(p) => (category.monthlyData.get(p)?.amount ?? 0) > 0,
@@ -178,10 +178,10 @@ export function CategoryTable({
);
})}
-
+
{formatCurrency(sectionTotals.averageMonthlyTotal)}
-
+
{formatCurrency(sectionTotals.grandTotal)}
diff --git a/src/features/reports/components/establishments/highlights-cards.tsx b/src/features/reports/components/establishments/highlights-cards.tsx
index 541c0d9..0cca1be 100644
--- a/src/features/reports/components/establishments/highlights-cards.tsx
+++ b/src/features/reports/components/establishments/highlights-cards.tsx
@@ -19,7 +19,7 @@ export function HighlightsCards({ summary }: HighlightsCardsProps) {
Mais Frequente
-
+
{summary.mostFrequent || "—"}
@@ -35,7 +35,7 @@ export function HighlightsCards({ summary }: HighlightsCardsProps) {
Maior Gasto Total
-
+
{summary.highestSpending || "—"}
diff --git a/src/features/reports/components/establishments/summary-cards.tsx b/src/features/reports/components/establishments/summary-cards.tsx
index e869bd8..7c4c466 100644
--- a/src/features/reports/components/establishments/summary-cards.tsx
+++ b/src/features/reports/components/establishments/summary-cards.tsx
@@ -53,16 +53,14 @@ export function SummaryCards({ summary }: SummaryCardsProps) {
-
- {card.title}
-
+
{card.title}
{card.isMoney ? (
) : (
-
{card.value}
+
{card.value}
)}
{card.description}
diff --git a/src/features/settings/components/api-tokens-form.tsx b/src/features/settings/components/api-tokens-form.tsx
index 34aa16c..9ed686e 100644
--- a/src/features/settings/components/api-tokens-form.tsx
+++ b/src/features/settings/components/api-tokens-form.tsx
@@ -139,7 +139,7 @@ export function ApiTokensForm({ tokens }: ApiTokensFormProps) {
-
Dispositivos conectados
+
Dispositivos conectados
Gerencie os dispositivos que podem enviar notificações para o
OpenMonetis.
diff --git a/src/features/settings/components/changelog-tab.tsx b/src/features/settings/components/changelog-tab.tsx
index b9151d3..84d4b22 100644
--- a/src/features/settings/components/changelog-tab.tsx
+++ b/src/features/settings/components/changelog-tab.tsx
@@ -32,7 +32,7 @@ export function ChangelogTab({ versions }: { versions: ChangelogVersion[] }) {
{versions.map((version) => (
-
v{version.version}
+
v{version.version}
{version.date}
diff --git a/src/features/settings/components/delete-account-form.tsx b/src/features/settings/components/delete-account-form.tsx
index 9904877..49ea397 100644
--- a/src/features/settings/components/delete-account-form.tsx
+++ b/src/features/settings/components/delete-account-form.tsx
@@ -84,7 +84,7 @@ export function DeleteAccountForm() {
-
Zerar conta
+
Zerar conta
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() {
-
+
-
Deletar conta
+
Deletar conta
Remove seu usuário e todos os dados associados de forma
permanente.
@@ -132,7 +132,7 @@ export function DeleteAccountForm() {
Contas, cartões e categorias
Pagadores, credenciais e configurações
- Resumindo tudo, sua conta será permanentemente removida
+ Resumindo, sua conta irá de arrasta pra cima!
diff --git a/src/features/settings/components/passkeys-form.tsx b/src/features/settings/components/passkeys-form.tsx
index ab181d6..23dec11 100644
--- a/src/features/settings/components/passkeys-form.tsx
+++ b/src/features/settings/components/passkeys-form.tsx
@@ -197,7 +197,7 @@ export function PasskeysForm() {
-
Suas passkeys
+
Suas passkeys
Gerencie suas passkeys para login sem senha.
diff --git a/src/features/settings/components/preferences-form.tsx b/src/features/settings/components/preferences-form.tsx
index b51d001..e46a8b4 100644
--- a/src/features/settings/components/preferences-form.tsx
+++ b/src/features/settings/components/preferences-form.tsx
@@ -145,7 +145,7 @@ export function PreferencesForm({
{/* Seção: Lançamentos */}
-
Lançamentos
+
Lançamentos
Configurações de exibição da tabela de movimentações.
diff --git a/src/features/settings/components/update-password-form.tsx b/src/features/settings/components/update-password-form.tsx
index e8a3321..ebf73bd 100644
--- a/src/features/settings/components/update-password-form.tsx
+++ b/src/features/settings/components/update-password-form.tsx
@@ -131,7 +131,7 @@ export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) {
-
+
Alteração de senha não disponível
diff --git a/src/features/transactions/actions/export-actions.ts b/src/features/transactions/actions/export-actions.ts
index e74fe52..4926f8c 100644
--- a/src/features/transactions/actions/export-actions.ts
+++ b/src/features/transactions/actions/export-actions.ts
@@ -31,6 +31,8 @@ const exportTransactionsSchema: z.ZodType = z.object(
categoryFilter: z.string().nullable(),
accountCardFilter: z.string().nullable(),
searchFilter: z.string().nullable(),
+ settledFilter: z.string().nullable(),
+ attachmentFilter: z.string().nullable(),
}),
accountId: z.string().min(1).nullable().optional(),
cardId: z.string().min(1).nullable().optional(),
diff --git a/src/features/transactions/components/attachments/attachment-item.tsx b/src/features/transactions/components/attachments/attachment-item.tsx
index 2f520e3..5b2cedc 100644
--- a/src/features/transactions/components/attachments/attachment-item.tsx
+++ b/src/features/transactions/components/attachments/attachment-item.tsx
@@ -54,6 +54,7 @@ function AttachmentPreview({
diff --git a/src/features/transactions/components/attachments/attachment-section.tsx b/src/features/transactions/components/attachments/attachment-section.tsx
index 3fa89b5..4481bca 100644
--- a/src/features/transactions/components/attachments/attachment-section.tsx
+++ b/src/features/transactions/components/attachments/attachment-section.tsx
@@ -44,8 +44,10 @@ export function AttachmentSection({
} = useTransactionAttachments(transactionId);
useEffect(() => {
- onLoaded?.(items.length);
- }, [items.length, onLoaded]);
+ if (!isLoading) {
+ onLoaded?.(items.length);
+ }
+ }, [items.length, isLoading, onLoaded]);
const invalidateAttachments = () => {
void queryClient.invalidateQueries({
diff --git a/src/features/transactions/components/dialogs/anticipate-installments-dialog/anticipate-installments-dialog.tsx b/src/features/transactions/components/dialogs/anticipate-installments-dialog/anticipate-installments-dialog.tsx
index ca464ae..e1c7d5d 100644
--- a/src/features/transactions/components/dialogs/anticipate-installments-dialog/anticipate-installments-dialog.tsx
+++ b/src/features/transactions/components/dialogs/anticipate-installments-dialog/anticipate-installments-dialog.tsx
@@ -342,21 +342,21 @@ export function AnticipateInstallmentsDialog({
{/* Seção 3: Resumo */}
{selectedIds.length > 0 && (
-
Resumo
+
Resumo
{selectedIds.length} parcela
{selectedIds.length > 1 ? "s" : ""}
-
+
{Number(formState.discount) > 0 && (
Desconto
-
+
-{" "}
Total
-
+
diff --git a/src/features/transactions/components/dialogs/anticipate-installments-dialog/installment-selection-table.tsx b/src/features/transactions/components/dialogs/anticipate-installments-dialog/installment-selection-table.tsx
index 322301d..eff3ad1 100644
--- a/src/features/transactions/components/dialogs/anticipate-installments-dialog/installment-selection-table.tsx
+++ b/src/features/transactions/components/dialogs/anticipate-installments-dialog/installment-selection-table.tsx
@@ -116,7 +116,7 @@ export function InstallmentSelectionTable({
{formatDate(inst.dueDate)}
-
+
diff --git a/src/features/transactions/components/dialogs/mass-add-dialog.tsx b/src/features/transactions/components/dialogs/mass-add-dialog.tsx
index 33d640b..533fd4e 100644
--- a/src/features/transactions/components/dialogs/mass-add-dialog.tsx
+++ b/src/features/transactions/components/dialogs/mass-add-dialog.tsx
@@ -279,7 +279,7 @@ export function MassAddDialog({
{/* Fixed Fields Section */}
-
Valores Padrão
+
Valores Padrão
{/* Transaction Type */}
@@ -452,7 +452,7 @@ export function MassAddDialog({
{/* Transactions Section */}
-
Lançamentos
+
Lançamentos
{transactions.map((transaction, index) => (
diff --git a/src/features/transactions/components/dialogs/transaction-details-dialog.tsx b/src/features/transactions/components/dialogs/transaction-details-dialog.tsx
index 6ff5ce7..e392aed 100644
--- a/src/features/transactions/components/dialogs/transaction-details-dialog.tsx
+++ b/src/features/transactions/components/dialogs/transaction-details-dialog.tsx
@@ -88,7 +88,7 @@ export function TransactionDetailsDialog({
Resumo
-
+
{currencyFormatter.format(valorTotal)}
@@ -116,7 +116,7 @@ export function TransactionDetailsDialog({
-
+
Detalhes
@@ -167,7 +167,7 @@ export function TransactionDetailsDialog({
-
+
Valores
@@ -207,7 +207,7 @@ export function TransactionDetailsDialog({
{transaction.note ? (
-
+
Notas
@@ -218,7 +218,7 @@ export function TransactionDetailsDialog({
{attachmentCount !== 0 && (
-
+
Anexos
diff --git a/src/features/transactions/components/import/review-table.tsx b/src/features/transactions/components/import/review-table.tsx
index f3d0bd5..dadcaed 100644
--- a/src/features/transactions/components/import/review-table.tsx
+++ b/src/features/transactions/components/import/review-table.tsx
@@ -131,7 +131,7 @@ export function ReviewTable({
aria-label={`Selecionar ${row.description}`}
/>
-
+
{formatDate(row.date)}
@@ -204,7 +204,7 @@ export function ReviewTable({
}
/>
-
+
Valor Original
-
+
@@ -92,7 +92,7 @@ export function AnticipationCard({
{Number(anticipation.discount) > 0 && (
Desconto
-
+
-
@@ -110,7 +110,7 @@ export function AnticipationCard({
? "Valor Final"
: "Valor Total"}
-
+
();
+ const containerRef = React.useRef(null);
+
+ React.useEffect(() => {
+ if (!open || !containerRef.current) return;
+ setWidth(containerRef.current.offsetWidth);
+ }, [open]);
const handleSelect = (selectedValue: string) => {
onChange(selectedValue);
@@ -50,7 +57,6 @@ export function EstabelecimentoInput({
onChange(newValue);
setSearchValue(newValue);
- // Open popover when user types and there are suggestions
if (newValue.length > 0 && estabelecimentos.length > 0) {
setOpen(true);
}
@@ -68,7 +74,7 @@ export function EstabelecimentoInput({
return (
-
+
{estabelecimentos.length > 0 && (
e.preventDefault()}
>
diff --git a/src/features/transactions/components/shared/establishment-logo.tsx b/src/features/transactions/components/shared/establishment-logo.tsx
deleted file mode 100644
index d7df2ce..0000000
--- a/src/features/transactions/components/shared/establishment-logo.tsx
+++ /dev/null
@@ -1,2 +0,0 @@
-// Re-export from shared — componente movido para src/shared/components/entity-avatar/
-export { EstablishmentLogo as EstabelecimentoLogo } from "@/shared/components/entity-avatar";
diff --git a/src/features/transactions/components/table/transactions-columns.tsx b/src/features/transactions/components/table/transactions-columns.tsx
index 4f498d1..59327f1 100644
--- a/src/features/transactions/components/table/transactions-columns.tsx
+++ b/src/features/transactions/components/table/transactions-columns.tsx
@@ -199,7 +199,7 @@ function buildColumns({
return (
-
+
{formatDate(purchaseDate)}
@@ -210,7 +210,7 @@ function buildColumns({
-
+
{name}
@@ -570,12 +570,44 @@ function buildColumns({
paymentMethod === "Transferência bancária" ||
paymentMethod === "Pré-Pago | VR/VA";
- if (!canToggleSettlement)
+ if (!canToggleSettlement) {
+ const invoicePaid = Boolean(row.original.isSettled);
return (
-
-
-
+
+
+
+
+ {invoicePaid ? (
+
+ ) : (
+
+ )}
+
+ {invoicePaid
+ ? "Fatura paga"
+ : "Lançamento de cartão de crédito"}
+
+
+
+
+
+ {invoicePaid
+ ? "Fatura paga"
+ : "Lançamentos de cartão de crédito são liquidados ao pagar a fatura"}
+
+
);
+ }
const readOnly = row.original.readonly;
const loading = isSettlementLoading(row.original.id);
diff --git a/src/features/transactions/components/table/transactions-filters.tsx b/src/features/transactions/components/table/transactions-filters.tsx
index 709b171..983e129 100644
--- a/src/features/transactions/components/table/transactions-filters.tsx
+++ b/src/features/transactions/components/table/transactions-filters.tsx
@@ -15,6 +15,7 @@ import {
} from "react";
import {
PAYMENT_METHODS,
+ SETTLED_FILTER_VALUES,
TRANSACTION_CONDITIONS,
TRANSACTION_TYPES,
} from "@/features/transactions/constants";
@@ -50,6 +51,8 @@ import {
SelectLabel,
SelectTrigger,
} from "@/shared/components/ui/select";
+import { Switch } from "@/shared/components/ui/switch";
+import { slugify } from "@/shared/utils/string";
import { cn } from "@/shared/utils/ui";
import {
AccountCardSelectContent,
@@ -66,9 +69,6 @@ import type {
const FILTER_EMPTY_VALUE = "__all";
-const buildStaticOptions = (values: readonly string[]) =>
- values.map((value) => ({ value, label: value }));
-
interface FilterSelectProps {
param: string;
placeholder: string;
@@ -263,7 +263,9 @@ export function TransactionsFilters({
searchParams.get("payment") ||
searchParams.get("payer") ||
searchParams.get("category") ||
- searchParams.get("accountCard");
+ searchParams.get("accountCard") ||
+ searchParams.get("settled") ||
+ searchParams.get("hasAttachment");
const handleResetFilters = () => {
handleReset();
@@ -327,7 +329,10 @@ export function TransactionsFilters({
({
+ value: slugify(v),
+ label: v,
+ }))}
widthClass="w-full border-dashed"
disabled={isPending}
getParamValue={getParamValue}
@@ -345,7 +350,10 @@ export function TransactionsFilters({
({
+ value: slugify(v),
+ label: v,
+ }))}
widthClass="w-full border-dashed"
disabled={isPending}
getParamValue={getParamValue}
@@ -363,7 +371,10 @@ export function TransactionsFilters({
({
+ value: slugify(v),
+ label: v,
+ }))}
widthClass="w-full border-dashed"
disabled={isPending}
getParamValue={getParamValue}
@@ -547,6 +558,76 @@ export function TransactionsFilters({
+
+
+
Status
+
+
+
+ Somente pagos
+
+ {
+ handleFilterChange(
+ "settled",
+ checked ? SETTLED_FILTER_VALUES.PAID : null,
+ );
+ }}
+ />
+
+
+
+ Somente não pagos
+
+ {
+ handleFilterChange(
+ "settled",
+ checked ? SETTLED_FILTER_VALUES.UNPAID : null,
+ );
+ }}
+ />
+
+
+
+
+
+
+ Com anexo
+
+ {
+ handleFilterChange(
+ "hasAttachment",
+ checked ? "true" : null,
+ );
+ }}
+ />
+
diff --git a/src/features/transactions/constants.ts b/src/features/transactions/constants.ts
index 9d6821c..156c5b4 100644
--- a/src/features/transactions/constants.ts
+++ b/src/features/transactions/constants.ts
@@ -19,3 +19,8 @@ export const PAYMENT_METHODS = [
"Pré-Pago | VR/VA",
"Transferência bancária",
] as const;
+
+export const SETTLED_FILTER_VALUES = {
+ PAID: "pago",
+ UNPAID: "nao-pago",
+} as const;
diff --git a/src/features/transactions/export-types.ts b/src/features/transactions/export-types.ts
index 19e0e95..e02f4da 100644
--- a/src/features/transactions/export-types.ts
+++ b/src/features/transactions/export-types.ts
@@ -6,6 +6,8 @@ export type TransactionExportFilters = {
categoryFilter: string | null;
accountCardFilter: string | null;
searchFilter: string | null;
+ settledFilter: string | null;
+ attachmentFilter: string | null;
};
export type TransactionsExportContext = {
diff --git a/src/features/transactions/hooks/use-transaction-attachments.ts b/src/features/transactions/hooks/use-transaction-attachments.ts
index 778970b..cf1b82b 100644
--- a/src/features/transactions/hooks/use-transaction-attachments.ts
+++ b/src/features/transactions/hooks/use-transaction-attachments.ts
@@ -15,6 +15,7 @@ export function useTransactionAttachments(transactionId: string) {
`/api/transactions/${transactionId}/attachments`,
),
enabled: Boolean(transactionId),
- staleTime: 30_000,
+ staleTime: 50 * 60 * 1000, // 50 min — presigned URLs duram 1h
+ gcTime: 60 * 60 * 1000, // 1h — mantém cache enquanto URL é válida
});
}
diff --git a/src/features/transactions/page-helpers.ts b/src/features/transactions/page-helpers.ts
index fc6ddba..3db0ebd 100644
--- a/src/features/transactions/page-helpers.ts
+++ b/src/features/transactions/page-helpers.ts
@@ -1,15 +1,17 @@
import type { SQL } from "drizzle-orm";
-import { and, eq, ilike, isNotNull, or } from "drizzle-orm";
+import { and, eq, ilike, isNotNull, or, sql } from "drizzle-orm";
import {
cards,
type categories,
financialAccounts,
type payers,
+ transactionAttachments,
transactions,
} from "@/db/schema";
import type { SelectOption } from "@/features/transactions/components/types";
import {
PAYMENT_METHODS,
+ SETTLED_FILTER_VALUES,
TRANSACTION_CONDITIONS,
TRANSACTION_TYPES,
} from "@/features/transactions/constants";
@@ -19,6 +21,7 @@ import {
PAYER_ROLE_THIRD_PARTY,
} from "@/shared/lib/payers/constants";
import { toDateOnlyString } from "@/shared/utils/date";
+import { slugify } from "@/shared/utils/string";
type PayerRow = typeof payers.$inferSelect;
type AccountRow = typeof financialAccounts.$inferSelect;
@@ -40,6 +43,8 @@ export type TransactionSearchFilters = {
categoryFilter: string | null;
accountCardFilter: string | null;
searchFilter: string | null;
+ settledFilter: string | null;
+ attachmentFilter: string | null;
};
type BaseSluggedOption = {
@@ -127,6 +132,8 @@ export const extractTransactionSearchFilters = (
categoryFilter: getSingleParam(params, "category"),
accountCardFilter: getSingleParam(params, "accountCard"),
searchFilter: getSingleParam(params, "q"),
+ settledFilter: getSingleParam(params, "settled"),
+ attachmentFilter: getSingleParam(params, "hasAttachment"),
});
export const resolveTransactionPagination = (
@@ -152,15 +159,17 @@ export const resolveTransactionPagination = (
const normalizeLabel = (value: string | null | undefined) =>
value?.trim().length ? value.trim() : "Sem descrição";
-const slugify = (value: string) => {
- const base = value
- .normalize("NFD")
- .replace(/[\u0300-\u036f]/g, "")
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, "-")
- .replace(/^-+|-+$/g, "");
- return base || "item";
-};
+const typeSlugToValue = Object.fromEntries(
+ TRANSACTION_TYPES.map((t) => [slugify(t), t]),
+) as Record;
+
+const conditionSlugToValue = Object.fromEntries(
+ TRANSACTION_CONDITIONS.map((c) => [slugify(c), c]),
+) as Record;
+
+const paymentSlugToValue = Object.fromEntries(
+ PAYMENT_METHODS.map((m) => [slugify(m), m]),
+) as Record;
const createSlugGenerator = () => {
const seen = new Map();
@@ -338,16 +347,20 @@ export const buildTransactionWhere = ({
where.push(eq(transactions.accountId, accountId));
}
- if (isValidTransaction(filters.transactionFilter)) {
- where.push(eq(transactions.transactionType, filters.transactionFilter));
+ const typeValue = typeSlugToValue[filters.transactionFilter ?? ""] ?? null;
+ if (isValidTransaction(typeValue)) {
+ where.push(eq(transactions.transactionType, typeValue));
}
- if (isValidCondition(filters.conditionFilter)) {
- where.push(eq(transactions.condition, filters.conditionFilter));
+ const conditionValue =
+ conditionSlugToValue[filters.conditionFilter ?? ""] ?? null;
+ if (isValidCondition(conditionValue)) {
+ where.push(eq(transactions.condition, conditionValue));
}
- if (isValidPaymentMethod(filters.paymentFilter)) {
- where.push(eq(transactions.paymentMethod, filters.paymentFilter));
+ const paymentValue = paymentSlugToValue[filters.paymentFilter ?? ""] ?? null;
+ if (isValidPaymentMethod(paymentValue)) {
+ where.push(eq(transactions.paymentMethod, paymentValue));
}
if (!payerId && filters.payerFilter) {
@@ -377,6 +390,18 @@ export const buildTransactionWhere = ({
}
}
+ if (filters.settledFilter === SETTLED_FILTER_VALUES.PAID) {
+ where.push(eq(transactions.isSettled, true));
+ } else if (filters.settledFilter === SETTLED_FILTER_VALUES.UNPAID) {
+ where.push(eq(transactions.isSettled, false));
+ }
+
+ if (filters.attachmentFilter === "true") {
+ where.push(
+ sql`EXISTS (SELECT 1 FROM ${transactionAttachments} WHERE ${transactionAttachments.transactionId} = ${transactions.id})`,
+ );
+ }
+
const searchPattern = buildSearchPattern(filters.searchFilter);
if (searchPattern) {
where.push(
diff --git a/src/proxy.ts b/src/proxy.ts
index 011a964..633821d 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -34,21 +34,26 @@ function buildCsp(): string {
}
})();
- const connectExtras = ["https://umami.felipecoutinho.com", s3Origin]
- .filter(Boolean)
- .join(" ");
+ const umamiOrigin = process.env.UMAMI_URL ?? "";
- const imgExtras = ["https://lh3.googleusercontent.com", s3Origin]
+ const connectExtras = [umamiOrigin, s3Origin].filter(Boolean).join(" ");
+
+ const imgExtras = [
+ "https://lh3.googleusercontent.com",
+ "https://img.logo.dev",
+ s3Origin,
+ ]
.filter(Boolean)
.join(" ");
return [
"default-src 'self'",
- `script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://umami.felipecoutinho.com`,
+ `script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""}${umamiOrigin ? ` ${umamiOrigin}` : ""}`,
"style-src 'self' 'unsafe-inline'",
`img-src 'self' ${imgExtras} data: blob:`,
"font-src 'self'",
`connect-src 'self' ${connectExtras}`,
+ `frame-src 'self'${s3Origin ? ` ${s3Origin}` : ""}`,
"frame-ancestors 'none'",
].join("; ");
}
diff --git a/src/shared/components/calculator/calculator-display.tsx b/src/shared/components/calculator/calculator-display.tsx
index 34db5a0..df31c52 100644
--- a/src/shared/components/calculator/calculator-display.tsx
+++ b/src/shared/components/calculator/calculator-display.tsx
@@ -34,7 +34,7 @@ export function CalculatorDisplay({
diff --git a/src/shared/components/entity-avatar/establishment-logo-picker.tsx b/src/shared/components/entity-avatar/establishment-logo-picker.tsx
new file mode 100644
index 0000000..c652ecb
--- /dev/null
+++ b/src/shared/components/entity-avatar/establishment-logo-picker.tsx
@@ -0,0 +1,187 @@
+"use client";
+
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect, useState, useTransition } from "react";
+import { Input } from "@/shared/components/ui/input";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/shared/components/ui/popover";
+import { Spinner } from "@/shared/components/ui/spinner";
+import { buildLogoDevUrl, logoQueryKeys, toNameKey } from "@/shared/lib/logo";
+import {
+ removeEstablishmentLogoAction,
+ saveEstablishmentLogoAction,
+} from "@/shared/lib/logo/establishment-logo-actions";
+import {
+ buildInitials,
+ getCategoryBgColorFromName,
+ getCategoryColorFromName,
+} from "@/shared/utils/category-colors";
+import { cn } from "@/shared/utils/ui";
+
+interface LogoResult {
+ name: string;
+ domain: string;
+}
+
+async function fetchLogoResults(query: string): Promise
{
+ if (!query.trim()) return [];
+ const res = await fetch(
+ `/api/logo/search?q=${encodeURIComponent(query.trim())}`,
+ );
+ if (!res.ok) return [];
+ const data = await res.json();
+ return Array.isArray(data) ? data : [];
+}
+
+interface EstablishmentLogoPickerProps {
+ name: string;
+ resolvedDomain: string | null;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSelect: (domain: string | null) => void;
+ children: React.ReactNode;
+}
+
+export function EstablishmentLogoPicker({
+ name,
+ resolvedDomain,
+ open,
+ onOpenChange,
+ onSelect,
+ children,
+}: EstablishmentLogoPickerProps) {
+ const [isPending, startTransition] = useTransition();
+ const [searchInput, setSearchInput] = useState(name);
+ const [debouncedSearch, setDebouncedSearch] = useState(name);
+ const queryClient = useQueryClient();
+
+ useEffect(() => {
+ if (open) {
+ setSearchInput(name);
+ setDebouncedSearch(name);
+ }
+ }, [open, name]);
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebouncedSearch(searchInput), 400);
+ return () => clearTimeout(timer);
+ }, [searchInput]);
+
+ const { data: results = [], isLoading } = useQuery({
+ queryKey: logoQueryKeys.search(debouncedSearch),
+ queryFn: () => fetchLogoResults(debouncedSearch),
+ enabled: open && debouncedSearch.trim().length > 0,
+ staleTime: 1000 * 60 * 60,
+ });
+
+ function handleSelect(domain: string) {
+ startTransition(async () => {
+ await saveEstablishmentLogoAction(name, domain);
+ queryClient.setQueryData(logoQueryKeys.mapping(toNameKey(name)), {
+ domain,
+ });
+ onSelect(domain);
+ });
+ }
+
+ function handleReset() {
+ startTransition(async () => {
+ await removeEstablishmentLogoAction(name);
+ queryClient.setQueryData(logoQueryKeys.mapping(toNameKey(name)), {
+ domain: null,
+ });
+ onSelect(null);
+ });
+ }
+
+ return (
+
+ {children}
+
+
+ Escolha um logo para {name}
+
+
+ setSearchInput(e.target.value)}
+ placeholder="Buscar marca..."
+ className="mb-3 h-8 text-sm"
+ autoFocus
+ />
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+
+
+
+ {buildInitials(name)}
+
+
+ Iniciais
+
+
+
+ {results.map((r) => (
+
handleSelect(r.domain)}
+ className={cn(
+ "flex flex-col items-center gap-1 rounded-md p-1.5 text-center transition-colors hover:bg-accent disabled:opacity-50",
+ resolvedDomain === r.domain &&
+ "ring-2 ring-primary ring-offset-1",
+ )}
+ title={r.name}
+ >
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+ {
+ (e.target as HTMLImageElement).style.display = "none";
+ }}
+ />
+
+ {r.name}
+
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/src/shared/components/entity-avatar/establishment-logo.tsx b/src/shared/components/entity-avatar/establishment-logo.tsx
index d821350..7d8f1dc 100644
--- a/src/shared/components/entity-avatar/establishment-logo.tsx
+++ b/src/shared/components/entity-avatar/establishment-logo.tsx
@@ -1,31 +1,75 @@
+"use client";
+
+import { RiPencilLine } from "@remixicon/react";
+import { useQuery } from "@tanstack/react-query";
+import { useEffect, useState } from "react";
+import {
+ buildLogoDevUrl,
+ LOGO_DEV_TOKEN,
+ logoQueryKeys,
+ toNameKey,
+} from "@/shared/lib/logo";
import {
buildInitials,
getCategoryBgColorFromName,
getCategoryColorFromName,
} from "@/shared/utils/category-colors";
import { cn } from "@/shared/utils/ui";
+import { EstablishmentLogoPicker } from "./establishment-logo-picker";
+
+async function fetchLogoMapping(
+ name: string,
+): Promise<{ domain: string | null }> {
+ const res = await fetch(`/api/logo/mapping?name=${encodeURIComponent(name)}`);
+ if (!res.ok) return { domain: null };
+ return res.json();
+}
interface EstablishmentLogoProps {
name: string;
+ /** Domínio Logo.dev pré-carregado pelo servidor (otimização opcional). */
+ domain?: string | null;
size?: number;
className?: string;
}
export function EstablishmentLogo({
name,
+ domain: initialDomain,
size = 32,
className,
}: EstablishmentLogoProps) {
+ const [pickerOpen, setPickerOpen] = useState(false);
+ const [imgError, setImgError] = useState(false);
+
+ const { data: mappingData } = useQuery({
+ queryKey: logoQueryKeys.mapping(toNameKey(name)),
+ queryFn: () => fetchLogoMapping(name),
+ placeholderData:
+ initialDomain !== undefined
+ ? { domain: initialDomain ?? null }
+ : undefined,
+ staleTime: 1000 * 60 * 5,
+ enabled: LOGO_DEV_TOKEN !== undefined && LOGO_DEV_TOKEN !== "",
+ });
+
+ const resolvedDomain = mappingData?.domain ?? null;
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: resetar imgError é o efeito de domain mudar
+ useEffect(() => {
+ setImgError(false);
+ }, [resolvedDomain]);
+
+ const logoUrl = buildLogoDevUrl(resolvedDomain);
+ const showLogo = Boolean(logoUrl) && !imgError;
+
const initials = buildInitials(name);
const color = getCategoryColorFromName(name);
const bgColor = getCategoryBgColorFromName(name);
- return (
+ const initialsAvatar = (
);
+
+ const logoImage =
+ showLogo && logoUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
setImgError(true)}
+ className="shrink-0 rounded-full object-cover"
+ style={{ width: size, height: size }}
+ />
+ ) : (
+ initialsAvatar
+ );
+
+ if (!LOGO_DEV_TOKEN) {
+ return (
+
+ {initialsAvatar}
+
+ );
+ }
+
+ return (
+
setPickerOpen(false)}
+ >
+ e.stopPropagation()}
+ title={`Alterar logo de ${name}`}
+ aria-label={`Alterar logo de ${name}`}
+ >
+ {logoImage}
+
+
+
+
+
+ );
}
diff --git a/src/shared/components/entity-avatar/index.ts b/src/shared/components/entity-avatar/index.ts
index b0832cd..d320004 100644
--- a/src/shared/components/entity-avatar/index.ts
+++ b/src/shared/components/entity-avatar/index.ts
@@ -4,3 +4,4 @@ export type {
} from "./category-icon-badge";
export { CategoryIconBadge } from "./category-icon-badge";
export { EstablishmentLogo } from "./establishment-logo";
+export { EstablishmentLogoPicker } from "./establishment-logo-picker";
diff --git a/src/shared/components/logo-picker/logo-picker.tsx b/src/shared/components/logo-picker/logo-picker.tsx
index 3e002d7..6d5680f 100644
--- a/src/shared/components/logo-picker/logo-picker.tsx
+++ b/src/shared/components/logo-picker/logo-picker.tsx
@@ -49,14 +49,14 @@ export function LogoPickerTrigger({
className,
)}
>
-
+
{selectedLogoPath ? (
) : (
Logo
@@ -161,14 +161,16 @@ export function LogoPickerDialog({
"border-primary bg-primary/5 ring-2 ring-primary/40",
)}
>
-
-
+
+
+
+
{logoLabel}
diff --git a/src/shared/components/money-values.tsx b/src/shared/components/money-values.tsx
index 100e183..ead35be 100644
--- a/src/shared/components/money-values.tsx
+++ b/src/shared/components/money-values.tsx
@@ -20,9 +20,9 @@ function MoneyValues({ amount, className, showPositiveSign = false }: Props) {
return (
+
void }) {
-
+
{item.title}
diff --git a/src/shared/components/navigation/navbar/nav-dropdown.tsx b/src/shared/components/navigation/navbar/nav-dropdown.tsx
index e288252..609eefa 100644
--- a/src/shared/components/navigation/navbar/nav-dropdown.tsx
+++ b/src/shared/components/navigation/navbar/nav-dropdown.tsx
@@ -42,7 +42,7 @@ export function NavDropdown({ items }: NavDropdownProps) {
{item.icon}
- {item.label}
+ {item.label}
{item.description && (
{item.description}
diff --git a/src/shared/components/navigation/navbar/navbar-user.tsx b/src/shared/components/navigation/navbar/navbar-user.tsx
index d6f81b3..d5523cd 100644
--- a/src/shared/components/navigation/navbar/navbar-user.tsx
+++ b/src/shared/components/navigation/navbar/navbar-user.tsx
@@ -1,6 +1,8 @@
"use client";
import {
+ RiCheckLine,
+ RiFileCopyLine,
RiHistoryLine,
RiLogoutCircleLine,
RiMegaphoneLine,
@@ -23,6 +25,11 @@ import {
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu";
import { Spinner } from "@/shared/components/ui/spinner";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/shared/components/ui/tooltip";
import { authClient } from "@/shared/lib/auth/client";
import { getAvatarSrc } from "@/shared/lib/payers/utils";
import type { UpdateCheckResult } from "@/shared/lib/version/check-update";
@@ -50,10 +57,18 @@ export function NavbarUser({
const router = useRouter();
const [logoutLoading, setLogoutLoading] = useState(false);
const [feedbackOpen, setFeedbackOpen] = useState(false);
+ const [copied, setCopied] = useState(false);
+
+ function handleCopyId() {
+ navigator.clipboard.writeText(user.id);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
const avatarSrc = pagadorAvatarUrl
? getAvatarSrc(pagadorAvatarUrl)
: user.image || getAvatarSrc(null);
+ const isDataUrl = avatarSrc.startsWith("data:");
async function handleLogout() {
await authClient.signOut({
@@ -77,6 +92,7 @@ export function NavbarUser({
-
{user.name}
+
+
+ {user.name}
+
+
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+
+ {copied ? "Copiado!" : "Copiar ID do usuário"}
+
+
+
{user.email}
@@ -126,7 +166,9 @@ export function NavbarUser({
>
Changelog
-
v{version}
+
+ v{version}
+
@@ -147,7 +189,7 @@ export function NavbarUser({
className={cn(itemClass, "text-success")}
>
-
+
Atualização {updateCheck.latestVersion} disponível
diff --git a/src/shared/components/navigation/sidebar/nav-link.tsx b/src/shared/components/navigation/sidebar/nav-link.tsx
index 33e6130..58e36ba 100644
--- a/src/shared/components/navigation/sidebar/nav-link.tsx
+++ b/src/shared/components/navigation/sidebar/nav-link.tsx
@@ -68,7 +68,7 @@ export function createSidebarNavData(
.map((pagador) => ({
title: pagador.name?.trim().length
? pagador.name.trim()
- : "Payer sem nome",
+ : "Pagador sem nome",
url: `/payers/${pagador.id}`,
key: pagador.canEdit ? pagador.id : `${pagador.id}-shared`,
isShared: !pagador.canEdit,
diff --git a/src/shared/components/navigation/sidebar/nav-user.tsx b/src/shared/components/navigation/sidebar/nav-user.tsx
index 8ce0066..a8afe09 100644
--- a/src/shared/components/navigation/sidebar/nav-user.tsx
+++ b/src/shared/components/navigation/sidebar/nav-user.tsx
@@ -22,6 +22,7 @@ export function NavUser({ user, pagadorAvatarUrl }: NavUserProps) {
const avatarSrc = pagadorAvatarUrl
? getAvatarSrc(pagadorAvatarUrl)
: user.image || getAvatarSrc(null);
+ const isDataUrl = avatarSrc.startsWith("data:");
return (
@@ -33,6 +34,7 @@ export function NavUser({ user, pagadorAvatarUrl }: NavUserProps) {
-
+
{icon}
{title}
diff --git a/src/shared/components/payment-success.tsx b/src/shared/components/payment-success.tsx
index 49e5c11..960c359 100644
--- a/src/shared/components/payment-success.tsx
+++ b/src/shared/components/payment-success.tsx
@@ -89,7 +89,7 @@ export function PaymentSuccess({
-
{title}
+
{title}
{description}
diff --git a/src/shared/components/ui/alert-dialog.tsx b/src/shared/components/ui/alert-dialog.tsx
index dae677c..b63b4ea 100644
--- a/src/shared/components/ui/alert-dialog.tsx
+++ b/src/shared/components/ui/alert-dialog.tsx
@@ -98,7 +98,7 @@ function AlertDialogTitle({
return (
);
diff --git a/src/shared/components/ui/avatar.tsx b/src/shared/components/ui/avatar.tsx
index 6f61914..96d5882 100644
--- a/src/shared/components/ui/avatar.tsx
+++ b/src/shared/components/ui/avatar.tsx
@@ -28,7 +28,7 @@ function AvatarImage({
return (
);
diff --git a/src/shared/components/ui/chart.tsx b/src/shared/components/ui/chart.tsx
index b64c972..62a51c7 100644
--- a/src/shared/components/ui/chart.tsx
+++ b/src/shared/components/ui/chart.tsx
@@ -266,7 +266,7 @@ function ChartTooltipContent({
{item.value !== undefined && item.value !== null && (
-
+
{item.value.toLocaleString()}
)}
diff --git a/src/shared/components/ui/checkbox.tsx b/src/shared/components/ui/checkbox.tsx
index 4c37dc4..c7eee60 100644
--- a/src/shared/components/ui/checkbox.tsx
+++ b/src/shared/components/ui/checkbox.tsx
@@ -1,7 +1,7 @@
"use client";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
-import { RiCheckLine } from "@remixicon/react";
+import { RiCheckLine, RiSubtractLine } from "@remixicon/react";
import type * as React from "react";
import { cn } from "@/shared/utils/ui";
@@ -13,7 +13,7 @@ function Checkbox({
-
+
+
);
diff --git a/src/shared/components/ui/currency-input.tsx b/src/shared/components/ui/currency-input.tsx
index 6ebc360..da1f43b 100644
--- a/src/shared/components/ui/currency-input.tsx
+++ b/src/shared/components/ui/currency-input.tsx
@@ -95,10 +95,7 @@ export const CurrencyInput = React.forwardRef<
value={displayValue}
onChange={handleChange}
onBlur={onBlur}
- className={cn(
- "text-left font-medium tabular-nums tracking-tight",
- className,
- )}
+ className={cn("text-left tracking-tight", className)}
/>
);
});
diff --git a/src/shared/components/ui/dialog.tsx b/src/shared/components/ui/dialog.tsx
index ee28cb3..962e96a 100644
--- a/src/shared/components/ui/dialog.tsx
+++ b/src/shared/components/ui/dialog.tsx
@@ -106,7 +106,7 @@ function DialogTitle({
return (
);
diff --git a/src/shared/components/ui/empty.tsx b/src/shared/components/ui/empty.tsx
index f49cc75..75dfbd1 100644
--- a/src/shared/components/ui/empty.tsx
+++ b/src/shared/components/ui/empty.tsx
@@ -62,7 +62,7 @@ function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
);
diff --git a/src/shared/components/ui/navigation-menu.tsx b/src/shared/components/ui/navigation-menu.tsx
index 930a46e..555cf61 100644
--- a/src/shared/components/ui/navigation-menu.tsx
+++ b/src/shared/components/ui/navigation-menu.tsx
@@ -1,6 +1,6 @@
+import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { RiArrowDropDownLine } from "@remixicon/react";
import { cva } from "class-variance-authority";
-import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/shared/utils";
diff --git a/src/shared/components/ui/sidebar.tsx b/src/shared/components/ui/sidebar.tsx
index aadc9c6..46d2a98 100644
--- a/src/shared/components/ui/sidebar.tsx
+++ b/src/shared/components/ui/sidebar.tsx
@@ -586,7 +586,7 @@ function SidebarMenuBadge({
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
- "text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
+ "text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
diff --git a/src/shared/components/ui/slider.tsx b/src/shared/components/ui/slider.tsx
index 060133a..4a5e53e 100644
--- a/src/shared/components/ui/slider.tsx
+++ b/src/shared/components/ui/slider.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Slider as SliderPrimitive } from "radix-ui";
+import * as SliderPrimitive from "@radix-ui/react-slider";
import type * as React from "react";
import { cn } from "@/shared/utils/ui";
diff --git a/src/shared/components/widget-card.tsx b/src/shared/components/widget-card.tsx
index 4e10251..70795a4 100644
--- a/src/shared/components/widget-card.tsx
+++ b/src/shared/components/widget-card.tsx
@@ -37,7 +37,7 @@ export default function WidgetCard({
-
+
{icon && {icon} }
{title}
diff --git a/src/shared/lib/categories/constants.ts b/src/shared/lib/categories/constants.ts
index ae34290..7347014 100644
--- a/src/shared/lib/categories/constants.ts
+++ b/src/shared/lib/categories/constants.ts
@@ -1,5 +1,7 @@
export const CATEGORY_TYPES = ["receita", "despesa"] as const;
+export const INVOICE_PAYMENT_CATEGORY_NAME = "Pagamentos";
+
export type CategoryType = (typeof CATEGORY_TYPES)[number];
export const CATEGORY_TYPE_LABEL: Record = {
diff --git a/src/shared/lib/logo/establishment-logo-actions.ts b/src/shared/lib/logo/establishment-logo-actions.ts
new file mode 100644
index 0000000..b4ae1a8
--- /dev/null
+++ b/src/shared/lib/logo/establishment-logo-actions.ts
@@ -0,0 +1,64 @@
+"use server";
+
+import { and, eq } from "drizzle-orm";
+import { establishmentLogos } from "@/db/schema";
+import type { ActionResult } from "@/shared/lib/actions/helpers";
+import {
+ handleActionError,
+ revalidateForEntity,
+} from "@/shared/lib/actions/helpers";
+import { getUserId } from "@/shared/lib/auth/server";
+import { db } from "@/shared/lib/db";
+import { toNameKey } from "@/shared/lib/logo";
+
+/**
+ * Salva ou atualiza o domínio Logo.dev preferido para um estabelecimento.
+ */
+export async function saveEstablishmentLogoAction(
+ name: string,
+ domain: string,
+): Promise {
+ try {
+ const userId = await getUserId();
+ const nameKey = toNameKey(name);
+
+ await db
+ .insert(establishmentLogos)
+ .values({ userId, nameKey, domain })
+ .onConflictDoUpdate({
+ target: [establishmentLogos.userId, establishmentLogos.nameKey],
+ set: { domain, updatedAt: new Date() },
+ });
+
+ revalidateForEntity("establishments", userId);
+ return { success: true, message: "Logo salvo." };
+ } catch (error) {
+ return handleActionError(error);
+ }
+}
+
+/**
+ * Remove o mapeamento salvo, voltando ao comportamento automático do Logo.dev.
+ */
+export async function removeEstablishmentLogoAction(
+ name: string,
+): Promise {
+ try {
+ const userId = await getUserId();
+ const nameKey = toNameKey(name);
+
+ await db
+ .delete(establishmentLogos)
+ .where(
+ and(
+ eq(establishmentLogos.userId, userId),
+ eq(establishmentLogos.nameKey, nameKey),
+ ),
+ );
+
+ revalidateForEntity("establishments", userId);
+ return { success: true, message: "Logo restaurado." };
+ } catch (error) {
+ return handleActionError(error);
+ }
+}
diff --git a/src/shared/lib/logo/establishment-logo-queries.ts b/src/shared/lib/logo/establishment-logo-queries.ts
new file mode 100644
index 0000000..c70d7d1
--- /dev/null
+++ b/src/shared/lib/logo/establishment-logo-queries.ts
@@ -0,0 +1,46 @@
+import { and, eq, inArray } from "drizzle-orm";
+import { establishmentLogos } from "@/db/schema";
+import { db } from "@/shared/lib/db";
+import { toNameKey } from "@/shared/lib/logo";
+
+export { toNameKey };
+
+/**
+ * Busca o domínio salvo para um único estabelecimento.
+ */
+export async function fetchEstablishmentLogoDomain(
+ userId: string,
+ name: string,
+): Promise {
+ const nameKey = toNameKey(name);
+ const row = await db.query.establishmentLogos.findFirst({
+ where: and(
+ eq(establishmentLogos.userId, userId),
+ eq(establishmentLogos.nameKey, nameKey),
+ ),
+ columns: { domain: true },
+ });
+ return row?.domain ?? null;
+}
+
+/**
+ * Busca domínios salvos para múltiplos nomes de uma vez (evita N+1).
+ * Retorna um Map de nameKey → domain.
+ */
+export async function fetchEstablishmentLogoMap(
+ userId: string,
+ names: string[],
+): Promise> {
+ const nameKeys = [...new Set(names.map(toNameKey))];
+ if (nameKeys.length === 0) return new Map();
+
+ const rows = await db.query.establishmentLogos.findMany({
+ where: and(
+ eq(establishmentLogos.userId, userId),
+ inArray(establishmentLogos.nameKey, nameKeys),
+ ),
+ columns: { nameKey: true, domain: true },
+ });
+
+ return new Map(rows.map((r) => [r.nameKey, r.domain]));
+}
diff --git a/src/shared/lib/logo/index.ts b/src/shared/lib/logo/index.ts
index 30698bf..1448b33 100644
--- a/src/shared/lib/logo/index.ts
+++ b/src/shared/lib/logo/index.ts
@@ -39,6 +39,27 @@ export const deriveNameFromLogo = (logo?: string | null) => {
.join(" ");
};
+/**
+ * Normaliza o nome do estabelecimento para usar como chave de lookup no banco.
+ */
+export const toNameKey = (name: string): string => name.trim().toLowerCase();
+
+// === Logo.dev ===
+
+export const LOGO_DEV_TOKEN = process.env.NEXT_PUBLIC_LOGO_DEV_TOKEN;
+
+export function buildLogoDevUrl(domain?: string | null): string | null {
+ if (!LOGO_DEV_TOKEN || !domain) return null;
+ return `https://img.logo.dev/${domain}?token=${LOGO_DEV_TOKEN}&size=64&format=png`;
+}
+
+export const logoQueryKeys = {
+ mapping: (nameKey: string) => ["logo-mapping", nameKey] as const,
+ search: (query: string) => ["logo-search", query] as const,
+};
+
+// === Local logo resolution ===
+
const LOGO_SRC_PATTERN = /^(https?:\/\/|data:)/;
type ResolveLogoSrcOptions = {
diff --git a/src/shared/lib/storage/s3-client.ts b/src/shared/lib/storage/s3-client.ts
index bcddcf9..6a3460f 100644
--- a/src/shared/lib/storage/s3-client.ts
+++ b/src/shared/lib/storage/s3-client.ts
@@ -1,13 +1,13 @@
import { S3Client } from "@aws-sdk/client-s3";
export const s3 = new S3Client({
- endpoint: process.env.S3_ENDPOINT ?? "",
- region: process.env.S3_REGION ?? "auto",
+ endpoint: process.env.S3_ENDPOINT || "",
+ region: process.env.S3_REGION || "auto",
credentials: {
- accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "",
- secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "",
+ accessKeyId: process.env.S3_ACCESS_KEY_ID || "",
+ secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || "",
},
forcePathStyle: true,
});
-export const S3_BUCKET = process.env.S3_BUCKET ?? "attachments";
+export const S3_BUCKET = process.env.S3_BUCKET || "attachments";
diff --git a/src/shared/utils/string.ts b/src/shared/utils/string.ts
index 30fa588..2a420ca 100644
--- a/src/shared/utils/string.ts
+++ b/src/shared/utils/string.ts
@@ -2,6 +2,16 @@
* Utility functions for string normalization and manipulation
*/
+export function slugify(value: string): string {
+ const base = value
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+ return base || "item";
+}
+
/**
* Capitalizes the first letter of a string
*/