Compare commits

...

14 Commits

Author SHA1 Message Date
Felipe Coutinho
5dcd30010e fix(lancamentos): corrige tipo do filtro selecionado 2026-05-23 13:22:32 -03:00
Felipe Coutinho
d589df6993 chore(release): prepara versao 2.6.4 2026-05-23 13:18:15 -03:00
Felipe Coutinho
8a19f0f311 feat(lancamentos): aprimora antecipacao de parcelas 2026-05-23 13:17:55 -03:00
Felipe Coutinho
887885cd98 feat(relatorios): refina analise de parcelas 2026-05-23 13:17:49 -03:00
Felipe Coutinho
7a0e33efd8 feat(lancamentos): adiciona filtro por intervalo de datas 2026-05-23 13:17:42 -03:00
Felipe Coutinho
b9557961e5 style(logos): formata dicionario de nomes 2026-05-23 13:03:27 -03:00
Felipe Coutinho
53c8e47981 Merge branch 'pr-69' 2026-05-23 12:57:48 -03:00
Felipe Coutinho
adc9292cd8 Merge branch 'pr-72' 2026-05-23 12:57:47 -03:00
Felipe Coutinho
b95d6f6752 Merge branch 'pr-70' 2026-05-23 12:57:43 -03:00
lucas
c9f667a065 fix(transactions): restaura scroll no container de anexos do TransactionDialog
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:08:30 -03:00
lucas
01d9c6ea05 (HEAD) fix(transactions): remove overflow styles do container de rolagem do TransactionDialog 2026-05-23 11:58:53 -03:00
lucas
d383d2db91 fix(transactions): usa data da última transação ao adicionar nova linha no MassAddDialog
Quando o usuário adiciona uma nova linha de transação no dialog de múltiplos lançamentos,
a data agora é pré-preenchida com o valor da transação anterior em vez da data atual.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 11:27:43 -03:00
lucas
7a8d01debe feat(logos): adiciona nomes de exibicao via dicionario e busca sem acentos
- Adiciona arquivo display-names.ts com 433 nomes legiveis (ex: bb.png → "Banco do Brasil")
- Adiciona getLogoDisplayName() que consulta dicionario primeiro, com fallback para deriveNameFromLogo
- Adiciona normalizeForSearch() para busca accent-insensitive
- Atualiza account-dialog, card-dialog, logo-picker e use-logo-selection para usar a nova API

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 23:35:11 -03:00
lucas
3be15d3b15 feat(transactions): adiciona dialogo de confirmacao ao descartar lancamentos massivos
Quando o usuario tenta fechar ou cancelar o dialogo de multiplos lancamentos
com dados ainda nao salvos, agora exibe um ConfirmActionDialog pedindo
confirmacao. Evita perda acidental de dados preenchidos.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 21:30:48 -03:00
27 changed files with 1331 additions and 491 deletions

View File

@@ -5,6 +5,38 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
## [2.6.4] - 2026-05-23
Esta versão reúne o polimento final antes da próxima publicação: melhora o fluxo de antecipação de parcelas, deixa os dialogs de lançamentos mais seguros e consistentes, e incorpora as contribuições vindas dos PRs abertos para nomes de logos e ajustes no cadastro de transações.
### Adicionado
- Logos: adicionado um dicionário de nomes de exibição para logos, com busca normalizada sem acentos e fallback para o comportamento anterior quando não houver mapeamento específico (PR #69).
- Lançamentos: o dialog de adicionar múltiplos lançamentos agora pede confirmação antes de descartar alterações não salvas ao fechar ou cancelar (PR #70).
### Alterado
- Lançamentos: o modal "Histórico de Antecipações" agora segue o padrão do modal de detalhes, com `Fechar` e `Desfazer Antecipação` no rodapé, contagem dentro do conteúdo e cards de antecipação reorganizados em blocos mais escaneáveis.
- Lançamentos: a antecipação de parcelas agora só permite selecionar parcelas futuras ao período escolhido, evitando antecipar a parcela do próprio mês sem bloquear parcelas seguintes da mesma compra.
- Lançamentos: ao criar uma antecipação, o cache do histórico da série agora é invalidado e o modal refaz a busca ao abrir.
- Lançamentos: ao adicionar uma nova linha no dialog de múltiplos lançamentos, a data passa a seguir a última transação informada em vez de voltar para a data atual (PR #72).
### Corrigido
- Lançamentos: ajustado o espaçamento horizontal da área rolável do dialog de adicionar transação para preservar o alinhamento dos campos e botões (PR #71).
## [2.6.3] - 2026-05-22
Esta versão concentra os ajustes feitos depois da `2.6.2` em um único ciclo público. O foco está em dar mais precisão aos filtros de lançamentos por período real de compra e em polir a análise de parcelas para priorizar parcelamentos mais próximos da quitação sem causar saltos visuais nos cards.
### Adicionado
- Lançamentos: o drawer de filtros agora permite informar data inicial e data final para filtrar a tabela por `data_compra`.
### Alterado
- Lançamentos: quando um intervalo de datas está ativo, a consulta server-side deixa de limitar os dados a um único mês e usa o intervalo real de compra, mantendo paginação e exportação alinhadas ao que aparece na tabela.
- Relatórios: os cards de `/reports/installment-analysis` agora são ordenados pelo percentual pago em ordem decrescente, mantendo a data da compra como critério de desempate.
- Relatórios: em `/reports/installment-analysis`, o contador de parcelas selecionadas agora aparece discretamente no botão "detalhes", sem criar uma área extra no corpo do card.
### Corrigido
- Relatórios: selecionar parcelas em um card de `/reports/installment-analysis` não força mais os outros cards da mesma linha a reservarem espaço vazio para o resumo de seleção.
## [2.6.2] - 2026-05-21
Esta versão corrige o build da imagem Docker depois da atualização para `pnpm@11.1.3`. A etapa de dependências dentro do Docker não recebia a configuração do workspace, então o install congelado falhava ao comparar os `overrides` e as políticas de build com o lockfile.

View File

@@ -8,7 +8,7 @@
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
[![Version](https://img.shields.io/badge/version-2.6.2-blue?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-2.6.4-blue?style=flat-square)](CHANGELOG.md)
[![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/)
@@ -39,6 +39,7 @@
- [Arquitetura](#-arquitetura)
- [Contribuindo](#-contribuindo)
- [Apoie o Projeto](#-apoie-o-projeto)
- [Star History](#-star-history)
- [Licença](#-licença)
---
@@ -61,7 +62,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
### Funcionalidades
💰 **Contas e transações** — Contas bancárias, cartões, dinheiro. Receitas, despesas e transferências. Categorização, filtros combináveis, extratos detalhados e importação de extratos OFX e XLS/XLSX com detecção automática de categoria.
💰 **Contas e transações** — Contas bancárias, cartões, dinheiro. Receitas, despesas e transferências. Categorização, filtros combináveis com intervalo de datas, extratos detalhados e importação de extratos OFX e XLS/XLSX com detecção automática de categoria.
📊 **Dashboard e relatórios** — Widgets interativos de métricas, gráficos de evolução, comparativos por categoria, tendências, uso de cartões, top estabelecimentos. Exportação em PDF e Excel.
@@ -579,6 +580,18 @@ Outras formas de contribuir: ⭐ estrela no repo, reportar bugs, melhorar docs,
---
## ⭐ Star History
<a href="https://www.star-history.com/?repos=felipegcoutinho%2Fopenmonetis&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=felipegcoutinho/openmonetis&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=felipegcoutinho/openmonetis&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=felipegcoutinho/openmonetis&type=date&legend=top-left" />
</picture>
</a>
---
## 📄 Licença
**Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International** (CC BY-NC-SA 4.0).

View File

@@ -1,6 +1,6 @@
{
"name": "openmonetis",
"version": "2.6.2",
"version": "2.6.4",
"private": true,
"packageManager": "pnpm@11.1.3",
"scripts": {

View File

@@ -87,6 +87,8 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
dividedFilter: null,
amountMinFilter: null,
amountMaxFilter: null,
dateStartFilter: null,
dateEndFilter: null,
};
const createEmptySlugMaps = (): SlugMaps => ({

View File

@@ -23,7 +23,7 @@ import {
} from "@/shared/components/ui/dialog";
import { useControlledState } from "@/shared/hooks/use-controlled-state";
import { useFormState } from "@/shared/hooks/use-form-state";
import { deriveNameFromLogo, normalizeLogo } from "@/shared/lib/logo";
import { getLogoDisplayName, normalizeLogo } from "@/shared/lib/logo";
import {
formatInitialBalanceInput,
normalizeDecimalInput,
@@ -66,7 +66,7 @@ const buildInitialValues = ({
}): AccountFormValues => {
const fallbackLogo = logoOptions[0] ?? "";
const selectedLogo = normalizeLogo(account?.logo) || fallbackLogo;
const derivedName = deriveNameFromLogo(selectedLogo);
const derivedName = getLogoDisplayName(selectedLogo);
return {
name: account?.name ?? derivedName,

View File

@@ -24,7 +24,7 @@ import {
DEFAULT_CARD_BRANDS,
DEFAULT_CARD_STATUS,
} from "@/shared/lib/cards/constants";
import { deriveNameFromLogo, normalizeLogo } from "@/shared/lib/logo";
import { getLogoDisplayName, normalizeLogo } from "@/shared/lib/logo";
import {
formatLimitInput,
normalizeDecimalInput,
@@ -59,7 +59,7 @@ const buildInitialValues = ({
}): CardFormValues => {
const fallbackLogo = logoOptions[0] ?? "";
const selectedLogo = normalizeLogo(card?.logo) || fallbackLogo;
const derivedName = deriveNameFromLogo(selectedLogo);
const derivedName = getLogoDisplayName(selectedLogo);
return {
name: card?.name ?? derivedName,

View File

@@ -207,32 +207,23 @@ export function InstallmentGroupCard({
)}
</div>
{/* Valor selecionado */}
{hasSelection && (
<div className="flex items-center justify-between p-3 rounded-lg bg-primary/5 border border-primary/20 mb-4">
<span className="text-sm font-medium text-foreground">
{selectedInstallments.size}{" "}
{selectedInstallments.size === 1
? "parcela selecionada"
: "parcelas selecionadas"}
</span>
<MoneyValues
amount={selectedAmount}
className="text-base font-semibold text-primary"
/>
</div>
)}
{/* Botão para abrir detalhes */}
<Button
type="button"
variant="secondary"
size="sm"
className="w-full gap-1.5"
className="relative w-full justify-center gap-1.5"
onClick={() => setIsDetailsOpen(true)}
>
<RiFileList2Line className="size-4" />
detalhes ({group.pendingInstallments.length} parcelas)
<span className="inline-flex items-center gap-1.5">
<RiFileList2Line className="size-4" />
detalhes
</span>
{hasSelection && (
<span className="absolute right-2 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{selectedInstallments.size} sel.
</span>
)}
</Button>
</CardContent>
</Card>

View File

@@ -192,6 +192,22 @@ export async function fetchInstallmentAnalysis(
(i) => !i.isSettled,
);
return hasUnpaidInstallments;
})
.sort((a, b) => {
const progressA =
a.trackedInstallments > 0
? a.paidInstallments / a.trackedInstallments
: 0;
const progressB =
b.trackedInstallments > 0
? b.paidInstallments / b.trackedInstallments
: 0;
if (progressA !== progressB) {
return progressB - progressA;
}
return a.firstPurchaseDate.getTime() - b.firstPurchaseDate.getTime();
});
// Calcular totais

View File

@@ -26,6 +26,7 @@ import type {
import { uuidSchema } from "@/shared/lib/schemas/common";
import type { ActionResult } from "@/shared/lib/types/actions";
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
import { comparePeriods } from "@/shared/utils/period";
/**
* Schema de validação para criar antecipação
@@ -63,14 +64,18 @@ const cancelAnticipationSchema = z.object({
*/
export async function getEligibleInstallmentsAction(
seriesId: string,
anticipationPeriod: string,
): Promise<ActionResult<EligibleInstallment[]>> {
try {
const user = await getUser();
// Validar seriesId
const validatedSeriesId = uuidSchema("Série").parse(seriesId);
const validatedAnticipationPeriod =
createAnticipationSchema.shape.anticipationPeriod.parse(
anticipationPeriod,
);
// Buscar todas as parcelas da série que estão elegíveis
const rows = await db.query.transactions.findMany({
where: and(
eq(transactions.seriesId, validatedSeriesId),
@@ -96,19 +101,23 @@ export async function getEligibleInstallmentsAction(
},
});
const eligibleInstallments: EligibleInstallment[] = rows.map((row) => ({
id: row.id,
name: row.name,
amount: row.amount,
period: row.period,
purchaseDate: row.purchaseDate,
dueDate: row.dueDate,
currentInstallment: row.currentInstallment,
installmentCount: row.installmentCount,
paymentMethod: row.paymentMethod,
categoryId: row.categoryId,
payerId: row.payerId,
}));
const eligibleInstallments: EligibleInstallment[] = rows
.filter(
(row) => comparePeriods(row.period, validatedAnticipationPeriod) > 0,
)
.map((row) => ({
id: row.id,
name: row.name,
amount: row.amount,
period: row.period,
purchaseDate: row.purchaseDate,
dueDate: row.dueDate,
currentInstallment: row.currentInstallment,
installmentCount: row.installmentCount,
paymentMethod: row.paymentMethod,
categoryId: row.categoryId,
payerId: row.payerId,
}));
return {
success: true,
@@ -195,6 +204,18 @@ export async function createInstallmentAnticipationAction(
};
}
const selectedIncludesCurrentOrPastPeriod = installments.some(
(installment) =>
comparePeriods(installment.period, data.anticipationPeriod) <= 0,
);
if (selectedIncludesCurrentOrPastPeriod) {
return {
success: false,
error: "Selecione apenas parcelas de períodos futuros para antecipar.",
};
}
// 2. Calcular valor total
const totalAmountCents = installments.reduce(
(sum, inst) => sum + Number(inst.amount) * 100,

View File

@@ -36,6 +36,14 @@ const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = z.object(
dividedFilter: z.string().nullable(),
amountMinFilter: z.number().nullable(),
amountMaxFilter: z.number().nullable(),
dateStartFilter: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.nullable(),
dateEndFilter: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.nullable(),
}),
accountId: z.string().min(1).nullable().optional(),
cardId: z.string().min(1).nullable().optional(),

View File

@@ -1,6 +1,7 @@
"use client";
import { RiLoader4Line } from "@remixicon/react";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import { CategoryIcon } from "@/features/categories/components/category-icon";
@@ -8,6 +9,7 @@ import {
createInstallmentAnticipationAction,
getEligibleInstallmentsAction,
} from "@/features/transactions/actions/anticipation";
import { installmentAnticipationsQueryKey } from "@/features/transactions/hooks/use-installment-anticipations";
import MoneyValues from "@/shared/components/money-values";
import { PeriodPicker } from "@/shared/components/period-picker";
import { Button } from "@/shared/components/ui/button";
@@ -70,6 +72,7 @@ export function AnticipateInstallmentsDialog({
open,
onOpenChange,
}: AnticipateInstallmentsDialogProps) {
const queryClient = useQueryClient();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [isLoadingInstallments, setIsLoadingInstallments] = useState(false);
@@ -86,7 +89,7 @@ export function AnticipateInstallmentsDialog({
);
// Use form state hook for form management
const { formState, replaceForm, updateField } =
const { formState, replaceForm, updateField, updateFields } =
useFormState<AnticipationFormValues>({
anticipationPeriod: defaultPeriod,
discount: "0",
@@ -95,15 +98,34 @@ export function AnticipateInstallmentsDialog({
note: "",
});
// Buscar parcelas elegíveis ao abrir o dialog
// Resetar formulário ao abrir o dialog
useEffect(() => {
if (dialogOpen) {
setSelectedIds([]);
setErrorMessage(null);
replaceForm({
anticipationPeriod: defaultPeriod,
discount: "0",
payerId: "",
categoryId: "",
note: "",
});
}
}, [defaultPeriod, dialogOpen, replaceForm]);
// Buscar parcelas elegíveis ao abrir o dialog e ao trocar o período
useEffect(() => {
if (dialogOpen) {
let shouldUpdate = true;
setIsLoadingInstallments(true);
setSelectedIds([]);
setErrorMessage(null);
getEligibleInstallmentsAction(seriesId)
getEligibleInstallmentsAction(seriesId, formState.anticipationPeriod)
.then((result) => {
if (!shouldUpdate) return;
if (!result.success) {
toast.error(result.error || "Erro ao carregar parcelas");
setEligibleInstallments([]);
@@ -116,25 +138,30 @@ export function AnticipateInstallmentsDialog({
// Pré-preencher pagador e categoria da primeira parcela
if (installments.length > 0) {
const first = installments[0];
replaceForm({
anticipationPeriod: defaultPeriod,
discount: "0",
updateFields({
payerId: first.payerId ?? "",
categoryId: first.categoryId ?? "",
note: "",
});
}
})
.catch((error) => {
if (!shouldUpdate) return;
console.error("Erro ao buscar parcelas:", error);
toast.error("Erro ao carregar parcelas elegíveis");
setEligibleInstallments([]);
})
.finally(() => {
if (!shouldUpdate) return;
setIsLoadingInstallments(false);
});
return () => {
shouldUpdate = false;
};
}
}, [defaultPeriod, dialogOpen, replaceForm, seriesId]);
}, [dialogOpen, formState.anticipationPeriod, seriesId, updateFields]);
const totalAmount = useMemo(() => {
return eligibleInstallments
@@ -189,6 +216,9 @@ export function AnticipateInstallmentsDialog({
if (result.success) {
toast.success(result.message);
void queryClient.invalidateQueries({
queryKey: installmentAnticipationsQueryKey(seriesId),
});
setDialogOpen(false);
} else {
const errorMsg = result.error || "Erro ao criar antecipação";

View File

@@ -1,16 +1,21 @@
"use client";
import { RiCalendarCheckLine, RiLoader4Line } from "@remixicon/react";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { toast } from "sonner";
import { cancelInstallmentAnticipationAction } from "@/features/transactions/actions/anticipation";
import {
installmentAnticipationsQueryKey,
useInstallmentAnticipations,
} from "@/features/transactions/hooks/use-installment-anticipations";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
@@ -31,7 +36,6 @@ interface AnticipationHistoryDialogProps {
lancamentoName: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
onViewLancamento?: (transactionId: string) => void;
}
export function AnticipationHistoryDialog({
@@ -40,7 +44,6 @@ export function AnticipationHistoryDialog({
lancamentoName,
open,
onOpenChange,
onViewLancamento,
}: AnticipationHistoryDialogProps) {
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useControlledState(
@@ -51,87 +54,152 @@ export function AnticipationHistoryDialog({
const {
data: anticipations = [],
isLoading,
isFetching,
isError,
refetch,
} = useInstallmentAnticipations(seriesId, dialogOpen);
const handleCanceled = () => {
useEffect(() => {
if (dialogOpen) {
void refetch();
}
}, [dialogOpen, refetch]);
const cancelableAnticipation = anticipations.find(
(anticipation) => anticipation.transaction?.isSettled !== true,
);
const anticipationCountLabel =
anticipations.length === 1
? "1 registro de antecipação encontrada"
: `${anticipations.length} registros de antecipações encontradas`;
const refreshHistory = () => {
void queryClient.invalidateQueries({
queryKey: installmentAnticipationsQueryKey(seriesId),
});
};
const handleCancelAnticipation = async () => {
if (!cancelableAnticipation) return;
const result = await cancelInstallmentAnticipationAction({
anticipationId: cancelableAnticipation.id,
});
if (result.success) {
toast.success(result.message);
refreshHistory();
return;
}
toast.error(result.error || "Erro ao cancelar antecipação");
};
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent className="max-w-3xl px-6 py-5 sm:px-8 sm:py-6">
<DialogHeader>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent className="min-w-0 overflow-x-hidden">
<DialogHeader className="text-left">
<DialogTitle>Histórico de Antecipações</DialogTitle>
<DialogDescription>{lancamentoName}</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] space-y-4 overflow-y-auto pr-2">
{isLoading ? (
<div className="flex items-center justify-center rounded-lg border border-dashed p-12">
<RiLoader4Line className="size-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
Carregando histórico...
</span>
</div>
<div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm">
{isLoading || isFetching ? (
<LoadingState />
) : isError ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<RiCalendarCheckLine className="size-6 text-muted-foreground" />
</EmptyMedia>
<EmptyTitle>Não foi possível carregar</EmptyTitle>
<EmptyDescription>
O histórico de antecipações não pôde ser carregado agora.
</EmptyDescription>
</EmptyHeader>
<Button
type="button"
variant="outline"
className="mx-auto"
onClick={() => void refetch()}
>
Tentar novamente
</Button>
</Empty>
<ErrorState onRetry={() => void refetch()} />
) : anticipations.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<RiCalendarCheckLine className="size-6 text-muted-foreground" />
</EmptyMedia>
<EmptyTitle>Nenhuma antecipação registrada</EmptyTitle>
<EmptyDescription>
As antecipações realizadas para esta compra parcelada
aparecerão aqui.
</EmptyDescription>
</EmptyHeader>
</Empty>
<EmptyState />
) : (
anticipations.map((anticipation) => (
<AnticipationCard
key={anticipation.id}
anticipation={anticipation}
onViewLancamento={onViewLancamento}
onCanceled={handleCanceled}
/>
))
<div className="min-w-0 space-y-3">
<p className="text-left text-muted-foreground text-primary">
{anticipationCountLabel}
</p>
{anticipations.map((anticipation) => (
<AnticipationCard
key={anticipation.id}
anticipation={anticipation}
/>
))}
</div>
)}
</div>
{!isLoading && anticipations.length > 0 && (
<div className="border-t pt-4 text-center text-sm text-muted-foreground">
{anticipations.length}{" "}
{anticipations.length === 1
? "antecipação encontrada"
: "antecipações encontradas"}
</div>
)}
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Fechar
</Button>
</DialogClose>
{cancelableAnticipation ? (
<ConfirmActionDialog
trigger={
<Button type="button" variant="destructive">
Desfazer Antecipação
</Button>
}
title="Cancelar antecipação?"
description="Esta ação irá reverter a antecipação e restaurar as parcelas originais. O lançamento de antecipação será removido."
confirmLabel="Cancelar Antecipação"
confirmVariant="destructive"
pendingLabel="Cancelando..."
onConfirm={handleCancelAnticipation}
/>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function LoadingState() {
return (
<div className="flex min-h-48 items-center justify-center rounded-lg border border-dashed">
<RiLoader4Line className="size-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
Carregando histórico...
</span>
</div>
);
}
function ErrorState({ onRetry }: { onRetry: () => void }) {
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<RiCalendarCheckLine className="size-6 text-muted-foreground" />
</EmptyMedia>
<EmptyTitle>Não foi possível carregar</EmptyTitle>
<EmptyDescription>
O histórico de antecipações não pôde ser carregado agora.
</EmptyDescription>
</EmptyHeader>
<Button
type="button"
variant="outline"
className="mx-auto"
onClick={onRetry}
>
Tentar novamente
</Button>
</Empty>
);
}
function EmptyState() {
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<RiCalendarCheckLine className="size-6 text-muted-foreground" />
</EmptyMedia>
<EmptyTitle>Nenhuma antecipação registrada</EmptyTitle>
<EmptyDescription>
As antecipações realizadas para esta compra parcelada aparecerão aqui.
</EmptyDescription>
</EmptyHeader>
</Empty>
);
}

View File

@@ -49,7 +49,8 @@ export function InstallmentSelectionTable({
Nenhuma parcela elegível para antecipação encontrada.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Todas as parcelas desta compra foram pagas ou antecipadas.
Apenas parcelas futuras, ainda não pagas ou antecipadas, aparecem
aqui.
</p>
</div>
);

View File

@@ -8,6 +8,7 @@ import {
PAYMENT_METHODS,
type TRANSACTION_TYPES,
} from "@/features/transactions/lib/constants";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import { Button } from "@/shared/components/ui/button";
import { CurrencyInput } from "@/shared/components/ui/currency-input";
import { DatePicker } from "@/shared/components/ui/date-picker";
@@ -123,10 +124,11 @@ interface TransactionRow {
function createEmptyTransactionRow(
defaultPayerId?: string | null,
lastPurchaseDate?: string,
): TransactionRow {
return {
id: createClientSafeId(),
purchaseDate: getTodayDateString(),
purchaseDate: lastPurchaseDate ?? getTodayDateString(),
name: "",
amount: "",
categoryId: undefined,
@@ -148,6 +150,9 @@ export function MassAddDialog({
defaultCardId,
}: MassAddDialogProps) {
const [loading, setLoading] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const [confirmCloseOpen, setConfirmCloseOpen] = useState(false);
const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false);
// Fixed fields state (sempre ativos, sem checkboxes)
const [transactionType, setTransactionType] =
@@ -179,11 +184,23 @@ export function MassAddDialog({
return groupAndSortCategories(filtered);
}, [categoryOptions, transactionType]);
const resetForm = () => {
setTransactionType("Despesa");
setPaymentMethod(PAYMENT_METHODS[0]);
setPeriod(selectedPeriod);
setContaId(undefined);
setCartaoId(defaultCardId ?? undefined);
setTransactions([createEmptyTransactionRow(defaultPayerId)]);
setIsDirty(false);
};
const addTransaction = () => {
const lastTransaction = transactions[transactions.length - 1];
setTransactions([
...transactions,
createEmptyTransactionRow(defaultPayerId),
createEmptyTransactionRow(defaultPayerId, lastTransaction?.purchaseDate),
]);
setIsDirty(true);
};
const removeTransaction = (id: string) => {
@@ -192,6 +209,7 @@ export function MassAddDialog({
return;
}
setTransactions(transactions.filter((t) => t.id !== id));
setIsDirty(true);
};
const updateTransaction = (
@@ -202,6 +220,7 @@ export function MassAddDialog({
setTransactions(
transactions.map((t) => (t.id === id ? { ...t, [field]: value } : t)),
);
setIsDirty(true);
};
const handleSubmit = async () => {
@@ -250,13 +269,7 @@ export function MassAddDialog({
try {
await onSubmit(formData);
onOpenChange(false);
// Reset form
setTransactionType("Despesa");
setPaymentMethod(PAYMENT_METHODS[0]);
setPeriod(selectedPeriod);
setContaId(undefined);
setCartaoId(defaultCardId ?? undefined);
setTransactions([createEmptyTransactionRow(defaultPayerId)]);
resetForm();
} catch (_error) {
// Error is handled by the onSubmit function
} finally {
@@ -265,7 +278,19 @@ export function MassAddDialog({
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog
open={open}
onOpenChange={(newOpen) => {
if (!newOpen && isDirty) {
setConfirmCloseOpen(true);
} else {
onOpenChange(newOpen);
if (newOpen === false) {
resetForm();
}
}
}}
>
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto p-6 sm:px-8">
<DialogHeader>
<DialogTitle>Adicionar múltiplos lançamentos</DialogTitle>
@@ -286,9 +311,10 @@ export function MassAddDialog({
<Label htmlFor="transaction-type">Tipo de Transação</Label>
<Select
value={transactionType}
onValueChange={(value) =>
setTransactionType(value as MassAddTransactionType)
}
onValueChange={(value) => {
setTransactionType(value as MassAddTransactionType);
setIsDirty(true);
}}
>
<SelectTrigger id="transaction-type" className="w-full">
<SelectValue>
@@ -315,6 +341,7 @@ export function MassAddDialog({
value={paymentMethod}
onValueChange={(value) => {
setPaymentMethod(value as MassAddPaymentMethod);
setIsDirty(true);
// Reset conta/cartao when changing payment method
if (value === "Cartão de crédito") {
setContaId(undefined);
@@ -346,7 +373,10 @@ export function MassAddDialog({
<Label htmlFor="cartao">Cartão</Label>
<Select
value={cardId}
onValueChange={setCartaoId}
onValueChange={(value) => {
setCartaoId(value);
setIsDirty(true);
}}
disabled={isLockedToCartao}
>
<SelectTrigger id="cartao" className="w-full">
@@ -395,7 +425,10 @@ export function MassAddDialog({
{cardId ? (
<InlinePeriodPicker
period={period}
onPeriodChange={setPeriod}
onPeriodChange={(value) => {
setPeriod(value);
setIsDirty(true);
}}
/>
) : null}
</div>
@@ -405,7 +438,13 @@ export function MassAddDialog({
{!isCartaoSelected ? (
<div className="space-y-2">
<Label htmlFor="conta">Conta</Label>
<Select value={accountId} onValueChange={setContaId}>
<Select
value={accountId}
onValueChange={(value) => {
setContaId(value);
setIsDirty(true);
}}
>
<SelectTrigger id="conta" className="w-full">
<SelectValue placeholder="Selecione">
{accountId &&
@@ -635,7 +674,13 @@ export function MassAddDialog({
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
onClick={() => {
if (isDirty) {
setCancelConfirmOpen(true);
} else {
onOpenChange(false);
}
}}
disabled={loading}
>
Cancelar
@@ -646,6 +691,36 @@ export function MassAddDialog({
{transactions.length === 1 ? "lançamento" : "lançamentos"}
</Button>
</DialogFooter>
<ConfirmActionDialog
open={confirmCloseOpen}
onOpenChange={setConfirmCloseOpen}
title="Descartar alterações?"
description="Há lançamentos não salvos. Se fechar agora, todos os dados serão perdidos."
confirmLabel="Descartar"
cancelLabel="Continuar editando"
confirmVariant="destructive"
onConfirm={() => {
setConfirmCloseOpen(false);
onOpenChange(false);
resetForm();
}}
/>
<ConfirmActionDialog
open={cancelConfirmOpen}
onOpenChange={setCancelConfirmOpen}
title="Cancelar adição de lançamentos?"
description="Há lançamentos não salvos. Se cancelar, todos os dados serão perdidos."
confirmLabel="Cancelar"
cancelLabel="Continuar editando"
confirmVariant="destructive"
onConfirm={() => {
setCancelConfirmOpen(false);
onOpenChange(false);
resetForm();
}}
/>
</DialogContent>
</Dialog>
);

View File

@@ -569,7 +569,7 @@ export function TransactionDialog({
>
<div
ref={scrollContainerRef}
className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain pr-1 pb-1"
className="-mx-1 min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain px-1 pb-1"
>
{/* Detalhes */}
<div className="space-y-3">

View File

@@ -851,16 +851,6 @@ export function TransactionsPage({
onOpenChange={setAnticipationHistoryOpen}
seriesId={selectedForAnticipation.seriesId as string}
lancamentoName={selectedForAnticipation.name}
onViewLancamento={(transactionId) => {
const transaction = transactionList.find(
(l) => l.id === transactionId,
);
if (transaction) {
setSelectedTransaction(transaction);
setDetailsOpen(true);
setAnticipationHistoryOpen(false);
}
}}
/>
)}
</>

View File

@@ -3,19 +3,13 @@
import { RiCalendarCheckLine } from "@remixicon/react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useTransition } from "react";
import { toast } from "sonner";
import { cancelInstallmentAnticipationAction } from "@/features/transactions/actions/anticipation";
import type { ReactNode } from "react";
import type { InstallmentAnticipationListItem } from "@/features/transactions/hooks/use-installment-anticipations";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import MoneyValues from "@/shared/components/money-values";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
@@ -23,172 +17,129 @@ import { displayPeriod } from "@/shared/utils/period";
interface AnticipationCardProps {
anticipation: InstallmentAnticipationListItem;
onViewLancamento?: (transactionId: string) => void;
onCanceled?: () => void;
}
export function AnticipationCard({
anticipation,
onViewLancamento,
onCanceled,
}: AnticipationCardProps) {
const [isPending, startTransition] = useTransition();
export function AnticipationCard({ anticipation }: AnticipationCardProps) {
const isSettled = anticipation.transaction?.isSettled === true;
const canCancel = !isSettled;
const totalAmount = Number(anticipation.totalAmount);
const discount = Number(anticipation.discount);
const finalAmount =
totalAmount < 0 ? totalAmount + discount : totalAmount - discount;
const hasDiscount = discount > 0;
const formatDate = (date: string) => {
return format(new Date(date), "dd 'de' MMMM 'de' yyyy", { locale: ptBR });
};
const handleCancel = async () => {
startTransition(async () => {
const result = await cancelInstallmentAnticipationAction({
anticipationId: anticipation.id,
});
if (result.success) {
toast.success(result.message);
onCanceled?.();
} else {
toast.error(result.error || "Erro ao cancelar antecipação");
}
return format(new Date(date), "dd 'de' MMMM 'de' yyyy", {
locale: ptBR,
});
};
const handleViewLancamento = () => {
onViewLancamento?.(anticipation.transactionId);
};
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
<div className="space-y-1">
<CardTitle className="text-base">
{anticipation.installmentCount}{" "}
{anticipation.installmentCount === 1
? "parcela antecipada"
: "parcelas antecipadas"}
</CardTitle>
<CardDescription>
<RiCalendarCheckLine className="mr-1 inline size-3.5" />
{formatDate(anticipation.anticipationDate)}
</CardDescription>
<Card className="shadow-none py-2">
<CardHeader className="space-y-3 p-4 pb-1">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<CardTitle className="text-base leading-none">
{anticipation.installmentCount}{" "}
{anticipation.installmentCount === 1
? "parcela antecipada"
: "parcelas antecipadas"}
</CardTitle>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<RiCalendarCheckLine className="size-3 shrink-0" />
<span>{formatDate(anticipation.anticipationDate)}</span>
</div>
</div>
<Badge variant="secondary" className="shrink-0 rounded-full px-3">
{displayPeriod(anticipation.anticipationPeriod)}
</Badge>
</div>
<div className="flex items-center justify-between gap-3 rounded-lg bg-primary/10 p-3">
<span className="text-xs font-medium text-foreground">
{hasDiscount ? "Valor Final" : "Valor Total"}
</span>
<span className="text-lg font-semibold leading-none text-primary">
<MoneyValues amount={finalAmount} />
</span>
</div>
<Badge variant="secondary">
{displayPeriod(anticipation.anticipationPeriod)}
</Badge>
</CardHeader>
<CardContent className="space-y-3">
<dl className="grid grid-cols-2 gap-3 text-sm">
<div>
<dt className="text-muted-foreground">Valor Original</dt>
<dd className="mt-1 font-medium">
<MoneyValues amount={Number(anticipation.totalAmount)} />
</dd>
</div>
<CardContent className="px-4 pb-4 pt-0">
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<DetailItem label="Valor Original">
<MoneyValues amount={totalAmount} />
</DetailItem>
{Number(anticipation.discount) > 0 && (
<div>
<dt className="text-muted-foreground">Desconto</dt>
<dd className="mt-1 font-medium text-success">
- <MoneyValues amount={Number(anticipation.discount)} />
</dd>
</div>
{hasDiscount ? (
<DetailItem label="Desconto" valueClassName="text-success">
- <MoneyValues amount={discount} />
</DetailItem>
) : (
<div />
)}
<div
className={
Number(anticipation.discount) > 0
? "col-span-2 border-t pt-3"
: ""
}
>
<dt className="text-muted-foreground">
{Number(anticipation.discount) > 0
? "Valor Final"
: "Valor Total"}
</dt>
<dd className="mt-1 text-lg font-semibold text-primary">
<MoneyValues
amount={
Number(anticipation.totalAmount) < 0
? Number(anticipation.totalAmount) +
Number(anticipation.discount)
: Number(anticipation.totalAmount) -
Number(anticipation.discount)
}
/>
</dd>
</div>
<DetailItem label="Status">
<Badge
variant={isSettled ? "success" : "outline"}
className="h-5 rounded-full px-2 text-xs"
>
{isSettled ? "Pago" : "Pendente"}
</Badge>
</DetailItem>
<div>
<dt className="text-muted-foreground">Status do Lançamento</dt>
<dd className="mt-1">
<Badge variant={isSettled ? "success" : "outline"}>
{isSettled ? "Pago" : "Pendente"}
</Badge>
</dd>
</div>
{anticipation.payer && (
<div>
<dt className="text-muted-foreground">Pessoa</dt>
<dd className="mt-1 font-medium">{anticipation.payer.name}</dd>
</div>
{anticipation.payer ? (
<DetailItem label="Pessoa">{anticipation.payer.name}</DetailItem>
) : (
<div />
)}
{anticipation.category && (
<div>
<dt className="text-muted-foreground">Categoria</dt>
<dd className="mt-1 font-medium">{anticipation.category.name}</dd>
</div>
)}
{anticipation.category ? (
<DetailItem label="Categoria">
{anticipation.category.name}
</DetailItem>
) : null}
</dl>
{anticipation.note && (
<div className="rounded-lg border p-3">
<dt className="text-xs font-medium text-muted-foreground">
{anticipation.note ? (
<div className="mt-3 border-t pt-3">
<p className="text-xs font-medium text-muted-foreground">
Observação
</dt>
<dd className="mt-1 text-sm">{anticipation.note}</dd>
</p>
<p className="mt-1 text-sm leading-snug">{anticipation.note}</p>
</div>
)}
) : null}
</CardContent>
<CardFooter className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
<Button
variant="outline"
size="sm"
onClick={handleViewLancamento}
disabled={isPending}
>
Cancelar
</Button>
{canCancel && (
<ConfirmActionDialog
trigger={
<Button variant="destructive" size="sm" disabled={isPending}>
Desfazer Antecipação
</Button>
}
title="Cancelar antecipação?"
description="Esta ação irá reverter a antecipação e restaurar as parcelas originais. O lançamento de antecipação será removido."
confirmLabel="Cancelar Antecipação"
confirmVariant="destructive"
pendingLabel="Cancelando..."
onConfirm={handleCancel}
/>
)}
{isSettled && (
<div className="text-xs text-muted-foreground">
Não é possível cancelar uma antecipação paga
</div>
)}
</CardFooter>
</Card>
);
}
function DetailItem({
label,
children,
valueClassName,
}: {
label: string;
children: ReactNode;
valueClassName?: string;
}) {
return (
<div className="min-w-0 space-y-1">
<dt className="text-xs font-medium leading-none text-muted-foreground">
{label}
</dt>
<dd
className={`truncate text-sm font-medium leading-tight ${
valueClassName ?? ""
}`}
>
{children}
</dd>
</div>
);
}

View File

@@ -23,12 +23,17 @@ import {
import {
AMOUNT_MAX_PARAM,
AMOUNT_MIN_PARAM,
DATE_END_PARAM,
DATE_START_PARAM,
PAYMENT_METHODS,
SETTLED_FILTER_VALUES,
TRANSACTION_CONDITIONS,
TRANSACTION_TYPES,
} from "@/features/transactions/lib/constants";
import { parsePositiveAmount } from "@/features/transactions/lib/page-helpers";
import {
parseDateFilterParam,
parsePositiveAmount,
} from "@/features/transactions/lib/page-helpers";
import { Button } from "@/shared/components/ui/button";
import { Checkbox } from "@/shared/components/ui/checkbox";
import {
@@ -39,6 +44,7 @@ import {
CommandItem,
CommandList,
} from "@/shared/components/ui/command";
import { DatePicker } from "@/shared/components/ui/date-picker";
import {
Drawer,
DrawerContent,
@@ -60,7 +66,12 @@ import {
SelectItem,
SelectTrigger,
} from "@/shared/components/ui/select";
import { Separator } from "@/shared/components/ui/separator";
import { Switch } from "@/shared/components/ui/switch";
import {
ToggleGroup,
ToggleGroupItem,
} from "@/shared/components/ui/toggle-group";
import { slugify } from "@/shared/utils/string";
import { cn } from "@/shared/utils/ui";
import {
@@ -83,6 +94,9 @@ const normalizeAmountParam = (raw: string): string | null => {
return parsed === null ? null : parsed.toString();
};
const normalizeDateParam = (raw: string): string | null =>
parseDateFilterParam(raw.trim());
function useDebouncedAmountFilter(
param: string,
searchParams: URLSearchParams | ReadonlyURLSearchParams,
@@ -135,6 +149,7 @@ function FilterSelect({
value === FILTER_EMPTY_VALUE
? placeholder
: (current?.label ?? placeholder);
const hasSelection = value !== FILTER_EMPTY_VALUE && Boolean(current);
return (
<Select
@@ -148,8 +163,13 @@ function FilterSelect({
className={cn("text-sm border-dashed", widthClass)}
disabled={disabled}
>
<span className="truncate">
{value !== FILTER_EMPTY_VALUE && current && renderContent
<span
className={cn(
"truncate",
hasSelection ? "text-foreground" : "text-muted-foreground",
)}
>
{current && renderContent
? renderContent(current.label)
: displayLabel}
</span>
@@ -255,12 +275,19 @@ function MultiSelectFilter({
role="combobox"
aria-expanded={open}
className={cn(
"justify-between text-sm border-dashed font-normal",
"justify-between text-sm border-dashed font-normal shadow-none",
widthClass,
)}
disabled={disabled}
>
<span className="truncate flex items-center gap-2">
<span
className={cn(
"truncate flex items-center gap-2",
selectedOptions.length > 0
? "text-foreground"
: "text-muted-foreground",
)}
>
{triggerLabel}
</span>
<RiExpandUpDownLine className="ml-2 size-4 shrink-0 opacity-50" />
@@ -392,6 +419,13 @@ export function TransactionsFilters({
[searchParams, pathname, router],
);
const handleDateFilterChange = useCallback(
(key: string, value: string) => {
handleFilterChange(key, normalizeDateParam(value));
},
[handleFilterChange],
);
const [searchValue, setSearchValue] = useState(searchParams.get("q") ?? "");
const currentSearchParam = searchParams.get("q") ?? "";
@@ -509,25 +543,46 @@ export function TransactionsFilters({
);
const [drawerOpen, setDrawerOpen] = useState(false);
const hasActiveFilters =
searchParams.get("type") ||
searchParams.getAll("condition").length > 0 ||
searchParams.getAll("payment").length > 0 ||
searchParams.getAll("payer").length > 0 ||
searchParams.getAll("category").length > 0 ||
searchParams.getAll("accountCard").length > 0 ||
searchParams.get("settled") ||
searchParams.get("hasAttachment") ||
searchParams.get("isDivided") ||
searchParams.get(AMOUNT_MIN_PARAM) ||
searchParams.get(AMOUNT_MAX_PARAM);
const hasDateRangeFilter =
Boolean(searchParams.get(DATE_START_PARAM)) ||
Boolean(searchParams.get(DATE_END_PARAM));
const hasAmountFilter =
Boolean(searchParams.get(AMOUNT_MIN_PARAM)) ||
Boolean(searchParams.get(AMOUNT_MAX_PARAM));
const activeFilterCount = [
Boolean(searchParams.get("type")),
searchParams.getAll("condition").length > 0,
searchParams.getAll("payment").length > 0,
searchParams.getAll("payer").length > 0,
searchParams.getAll("category").length > 0,
searchParams.getAll("accountCard").length > 0,
Boolean(searchParams.get("settled")),
Boolean(searchParams.get("hasAttachment")),
Boolean(searchParams.get("isDivided")),
hasAmountFilter,
hasDateRangeFilter,
].filter(Boolean).length;
const hasActiveFilters = activeFilterCount > 0;
const settledFilterValue = searchParams.get("settled") ?? FILTER_EMPTY_VALUE;
const handleResetFilters = () => {
handleReset();
setDrawerOpen(false);
};
const handleResetDateRange = () => {
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.delete(DATE_START_PARAM);
nextParams.delete(DATE_END_PARAM);
nextParams.delete("page");
startTransition(() => {
const target = nextParams.toString()
? `${pathname}?${nextParams.toString()}`
: pathname;
router.replace(target, { scroll: false });
});
};
return (
<div
className={cn(
@@ -607,182 +662,234 @@ export function TransactionsFilters({
</DrawerHeader>
<div className="flex-1 overflow-y-auto px-4 space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">
Tipo de Lançamento
</label>
<FilterSelect
param="type"
placeholder="Todos"
options={TRANSACTION_TYPES.map((v) => ({
value: slugify(v),
label: v,
}))}
widthClass="w-full border-dashed"
disabled={isPending}
getParamValue={getParamValue}
onChange={handleFilterChange}
renderContent={(label) => (
<TransactionTypeSelectContent label={label} />
)}
/>
</div>
<div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Tipo de lançamento
</label>
<FilterSelect
param="type"
placeholder="Todos"
options={TRANSACTION_TYPES.map((v) => ({
value: slugify(v),
label: v,
}))}
widthClass="w-full border-dashed"
disabled={isPending}
getParamValue={getParamValue}
onChange={handleFilterChange}
renderContent={(label) => (
<TransactionTypeSelectContent label={label} />
)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
Condição de Lançamento
</label>
<MultiSelectFilter
placeholder="Todas"
options={conditionOptions}
selected={getParamValues("condition")}
onChange={(values) =>
handleMultiFilterChange("condition", values)
}
disabled={isPending}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Condição de pagamento
</label>
<MultiSelectFilter
placeholder="Todas"
options={conditionOptions}
selected={getParamValues("condition")}
onChange={(values) =>
handleMultiFilterChange("condition", values)
}
disabled={isPending}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
Forma de Pagamento
</label>
<MultiSelectFilter
placeholder="Todas"
options={paymentOptions}
selected={getParamValues("payment")}
onChange={(values) =>
handleMultiFilterChange("payment", values)
}
disabled={isPending}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Forma de pagamento
</label>
<MultiSelectFilter
placeholder="Todas"
options={paymentOptions}
selected={getParamValues("payment")}
onChange={(values) =>
handleMultiFilterChange("payment", values)
}
disabled={isPending}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Pessoa</label>
<MultiSelectFilter
placeholder="Todas"
options={payerMultiOptions}
selected={getParamValues("payer")}
onChange={(values) =>
handleMultiFilterChange("payer", values)
}
disabled={isPending}
searchable
searchPlaceholder="Buscar pessoa..."
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Pessoa
</label>
<MultiSelectFilter
placeholder="Todas"
options={payerMultiOptions}
selected={getParamValues("payer")}
onChange={(values) =>
handleMultiFilterChange("payer", values)
}
disabled={isPending}
searchable
searchPlaceholder="Buscar pessoa..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Categoria</label>
<MultiSelectFilter
placeholder="Todas"
options={categoryMultiOptions}
selected={getParamValues("category")}
onChange={(values) =>
handleMultiFilterChange("category", values)
}
disabled={isPending}
searchable
searchPlaceholder="Buscar categoria..."
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Categoria
</label>
<MultiSelectFilter
placeholder="Todas"
options={categoryMultiOptions}
selected={getParamValues("category")}
onChange={(values) =>
handleMultiFilterChange("category", values)
}
disabled={isPending}
searchable
searchPlaceholder="Buscar categoria..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Conta/Cartão</label>
<MultiSelectFilter
placeholder="Todos"
options={accountCardMultiOptions}
selected={getParamValues("accountCard")}
onChange={(values) =>
handleMultiFilterChange("accountCard", values)
}
disabled={isPending}
searchable
searchPlaceholder="Buscar conta ou cartão..."
groupOrder={["Contas", "Cartões"]}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Faixa de valor</label>
<div className="flex items-center gap-2">
<Input
type="number"
inputMode="decimal"
min="0"
step="0.01"
placeholder="Mínimo"
aria-label="Valor mínimo"
value={valorMinValue}
onChange={(event) => setValorMinValue(event.target.value)}
disabled={isPending}
className="text-sm border-dashed"
/>
<span className="text-xs text-muted-foreground">até</span>
<Input
type="number"
inputMode="decimal"
min="0"
step="0.01"
placeholder="Máximo"
aria-label="Valor máximo"
value={valorMaxValue}
onChange={(event) => setValorMaxValue(event.target.value)}
disabled={isPending}
className="text-sm border-dashed"
/>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Conta/Cartão
</label>
<MultiSelectFilter
placeholder="Todos"
options={accountCardMultiOptions}
selected={getParamValues("accountCard")}
onChange={(values) =>
handleMultiFilterChange("accountCard", values)
}
disabled={isPending}
searchable
searchPlaceholder="Buscar conta ou cartão..."
groupOrder={["Contas", "Cartões"]}
/>
</div>
</div>
</div>
<Separator />
<div className="space-y-3">
<p className="text-sm font-medium">Status</p>
<div className="space-y-3">
<div className="flex items-center justify-between">
<label
htmlFor="filter-pago"
className="text-sm text-muted-foreground cursor-pointer"
>
Somente pagos
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-muted-foreground">
Período
</label>
<Switch
id="filter-pago"
checked={
searchParams.get("settled") ===
SETTLED_FILTER_VALUES.PAID
}
disabled={isPending}
onCheckedChange={(checked) => {
handleFilterChange(
"settled",
checked ? SETTLED_FILTER_VALUES.PAID : null,
);
}}
/>
{hasDateRangeFilter ? (
<button
type="button"
onClick={handleResetDateRange}
disabled={isPending}
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:pointer-events-none disabled:opacity-50"
>
Limpar período
</button>
) : null}
</div>
<div className="flex items-center justify-between">
<label
htmlFor="filter-nao-pago"
className="text-sm text-muted-foreground cursor-pointer"
>
Somente não pagos
</label>
<Switch
id="filter-nao-pago"
checked={
searchParams.get("settled") ===
SETTLED_FILTER_VALUES.UNPAID
<div className="grid gap-2 sm:grid-cols-[1fr_auto_1fr] sm:items-center">
<DatePicker
value={searchParams.get(DATE_START_PARAM) ?? ""}
onChange={(value) =>
handleDateFilterChange(DATE_START_PARAM, value)
}
placeholder="Data inicial"
disabled={isPending}
onCheckedChange={(checked) => {
handleFilterChange(
"settled",
checked ? SETTLED_FILTER_VALUES.UNPAID : null,
);
}}
inputClassName="border-dashed"
compact
/>
<span className="hidden text-xs text-muted-foreground sm:block">
até
</span>
<DatePicker
value={searchParams.get(DATE_END_PARAM) ?? ""}
onChange={(value) =>
handleDateFilterChange(DATE_END_PARAM, value)
}
placeholder="Data final"
disabled={isPending}
inputClassName="border-dashed"
compact
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
Faixa de valor
</label>
<div className="flex items-center gap-2">
<Input
type="number"
inputMode="decimal"
min="0"
step="0.01"
placeholder="Mínimo"
aria-label="Valor mínimo"
value={valorMinValue}
onChange={(event) =>
setValorMinValue(event.target.value)
}
disabled={isPending}
className="text-sm border-dashed"
/>
<span className="text-xs text-muted-foreground">até</span>
<Input
type="number"
inputMode="decimal"
min="0"
step="0.01"
placeholder="Máximo"
aria-label="Valor máximo"
value={valorMaxValue}
onChange={(event) =>
setValorMaxValue(event.target.value)
}
disabled={isPending}
className="text-sm border-dashed"
/>
</div>
</div>
</div>
<Separator />
<div className="space-y-3">
<ToggleGroup
type="single"
value={settledFilterValue}
onValueChange={(value) => {
if (!value) return;
handleFilterChange(
"settled",
value === FILTER_EMPTY_VALUE ? null : value,
);
}}
variant="outline"
size="sm"
className="grid w-full grid-cols-3 rounded-md bg-muted/30 p-0.5"
aria-label="Status de pagamento"
>
<ToggleGroupItem
value={FILTER_EMPTY_VALUE}
className="text-xs font-medium transition-all data-[state=on]:border-foreground data-[state=on]:bg-foreground data-[state=on]:text-background data-[state=on]:shadow-sm"
>
Todos
</ToggleGroupItem>
<ToggleGroupItem
value={SETTLED_FILTER_VALUES.PAID}
className="text-xs font-medium transition-all data-[state=on]:border-foreground data-[state=on]:bg-foreground data-[state=on]:text-background data-[state=on]:shadow-sm"
>
Pagos
</ToggleGroupItem>
<ToggleGroupItem
value={SETTLED_FILTER_VALUES.UNPAID}
className="text-xs font-medium transition-all data-[state=on]:border-foreground data-[state=on]:bg-foreground data-[state=on]:text-background data-[state=on]:shadow-sm"
>
Não pagos
</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="flex items-center justify-between">
@@ -824,14 +931,27 @@ export function TransactionsFilters({
</div>
<DrawerFooter>
<Button
type="button"
variant="outline"
onClick={handleResetFilters}
disabled={isPending || !hasActiveFilters}
>
Limpar filtros
</Button>
<div className="flex items-center justify-between gap-3 rounded-md border border-dashed px-3 py-2">
<span className="text-xs text-muted-foreground">
{hasActiveFilters
? `${activeFilterCount} ${
activeFilterCount === 1
? "filtro ativo"
: "filtros ativos"
}`
: "Nenhum filtro ativo"}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleResetFilters}
disabled={isPending || !hasActiveFilters}
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
>
Limpar
</Button>
</div>
</DrawerFooter>
</DrawerContent>
</Drawer>

View File

@@ -43,15 +43,34 @@ const loadPdfDeps = async () => {
return { jsPDF, autoTable };
};
const formatPeriodDate = (dateString: string) =>
formatDateOnly(dateString, {
day: "2-digit",
month: "2-digit",
year: "numeric",
}) ?? dateString;
export function TransactionsExport({
lancamentos,
period,
exportContext,
}: TransactionsExportProps) {
const [isExporting, setIsExporting] = useState(false);
const dateStartFilter = exportContext?.filters.dateStartFilter ?? null;
const dateEndFilter = exportContext?.filters.dateEndFilter ?? null;
const periodLabel =
dateStartFilter || dateEndFilter
? `${dateStartFilter ? formatPeriodDate(dateStartFilter) : "Início"} até ${
dateEndFilter ? formatPeriodDate(dateEndFilter) : "hoje"
}`
: displayPeriod(period);
const filePeriodSlug =
dateStartFilter || dateEndFilter
? `${dateStartFilter ?? "inicio"}-${dateEndFilter ?? "hoje"}`
: period;
const getFileName = (extension: string) => {
return `lancamentos-${period}.${extension}`;
return `lancamentos-${filePeriodSlug}.${extension}`;
};
const formatDate = (dateString: string) => {
@@ -251,7 +270,7 @@ export function TransactionsExport({
doc.text("Lançamentos", titleX, 15);
doc.setFontSize(10);
doc.text(`Período: ${displayPeriod(period)}`, titleX, 22);
doc.text(`Período: ${periodLabel}`, titleX, 22);
doc.text(
`Gerado em: ${
formatDateTime(new Date(), {

View File

@@ -33,3 +33,5 @@ export const SETTLED_FILTER_VALUES = {
export const AMOUNT_MIN_PARAM = "valorMin";
export const AMOUNT_MAX_PARAM = "valorMax";
export const DATE_START_PARAM = "dataInicio";
export const DATE_END_PARAM = "dataFim";

View File

@@ -11,6 +11,8 @@ type TransactionExportFilters = {
dividedFilter: string | null;
amountMinFilter: number | null;
amountMaxFilter: number | null;
dateStartFilter: string | null;
dateEndFilter: string | null;
};
export type TransactionsExportContext = {

View File

@@ -22,6 +22,8 @@ import type { SelectOption } from "@/features/transactions/components/types";
import {
AMOUNT_MAX_PARAM,
AMOUNT_MIN_PARAM,
DATE_END_PARAM,
DATE_START_PARAM,
PAYMENT_METHODS,
SETTLED_FILTER_VALUES,
TRANSACTION_CONDITIONS,
@@ -38,7 +40,7 @@ import {
PAYER_ROLE_ADMIN,
PAYER_ROLE_THIRD_PARTY,
} from "@/shared/lib/payers/constants";
import { toDateOnlyString } from "@/shared/utils/date";
import { parseLocalDateString, toDateOnlyString } from "@/shared/utils/date";
import { slugify } from "@/shared/utils/string";
type PayerRow = typeof payers.$inferSelect;
@@ -66,6 +68,8 @@ export type TransactionSearchFilters = {
dividedFilter: string | null;
amountMinFilter: number | null;
amountMaxFilter: number | null;
dateStartFilter: string | null;
dateEndFilter: string | null;
};
type BaseSluggedOption = {
@@ -162,6 +166,14 @@ export const parsePositiveAmount = (value: string | null): number | null => {
return Math.round(normalized * 100) / 100;
};
export const parseDateFilterParam = (value: string | null): string | null => {
if (!value) return null;
const normalized = value.trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) return null;
const parsed = parseLocalDateString(normalized);
return Number.isNaN(parsed.getTime()) ? null : normalized;
};
export const extractTransactionSearchFilters = (
params: ResolvedSearchParams,
): TransactionSearchFilters => ({
@@ -181,6 +193,10 @@ export const extractTransactionSearchFilters = (
amountMaxFilter: parsePositiveAmount(
getSingleParam(params, AMOUNT_MAX_PARAM),
),
dateStartFilter: parseDateFilterParam(
getSingleParam(params, DATE_START_PARAM),
),
dateEndFilter: parseDateFilterParam(getSingleParam(params, DATE_END_PARAM)),
});
export const resolveTransactionPagination = (
@@ -377,10 +393,29 @@ export const buildTransactionWhere = ({
accountId?: string;
payerId?: string;
}): SQL[] => {
const where: SQL[] = [
eq(transactions.userId, userId),
eq(transactions.period, period),
];
const where: SQL[] = [eq(transactions.userId, userId)];
if (filters.dateStartFilter || filters.dateEndFilter) {
if (filters.dateStartFilter) {
where.push(
gte(
transactions.purchaseDate,
parseLocalDateString(filters.dateStartFilter),
),
);
}
if (filters.dateEndFilter) {
where.push(
lte(
transactions.purchaseDate,
parseLocalDateString(filters.dateEndFilter),
),
);
}
} else {
where.push(eq(transactions.period, period));
}
if (payerId) {
where.push(eq(transactions.payerId, payerId));

View File

@@ -10,7 +10,11 @@ import {
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Input } from "@/shared/components/ui/input";
import { deriveNameFromLogo, resolveLogoSrc } from "@/shared/lib/logo";
import {
getLogoDisplayName,
normalizeForSearch,
resolveLogoSrc,
} from "@/shared/lib/logo";
import { cn } from "@/shared/utils/ui";
const DEFAULT_BASE_PATH = "/logos";
@@ -35,7 +39,7 @@ export function LogoPickerTrigger({
className,
}: LogoPickerTriggerProps) {
const hasLogo = Boolean(selectedLogo);
const selectedLogoLabel = deriveNameFromLogo(selectedLogo);
const selectedLogoLabel = getLogoDisplayName(selectedLogo);
const selectedLogoPath =
hasLogo && selectedLogo ? resolveLogoSrc(selectedLogo, { basePath }) : null;
@@ -102,8 +106,8 @@ export function LogoPickerDialog({
const filteredLogos = logos.filter((logo) => {
if (!search.trim()) return true;
const logoLabel = deriveNameFromLogo(logo).toLowerCase();
return logoLabel.includes(search.toLowerCase().trim());
const logoLabel = getLogoDisplayName(logo);
return normalizeForSearch(logoLabel).includes(normalizeForSearch(search));
});
const handleOpenChange = (isOpen: boolean) => {
@@ -145,7 +149,7 @@ export function LogoPickerDialog({
<div className="grid max-h-custom-height-card grid-cols-4 gap-2 overflow-y-auto p-1 md:grid-cols-5">
{filteredLogos.map((logo) => {
const isActive = value === logo;
const logoLabel = deriveNameFromLogo(logo);
const logoLabel = getLogoDisplayName(logo);
return (
<button

View File

@@ -1,5 +1,5 @@
import { useCallback } from "react";
import { deriveNameFromLogo } from "@/shared/lib/logo";
import { getLogoDisplayName } from "@/shared/lib/logo";
interface UseLogoSelectionProps {
mode: "create" | "update";
@@ -37,8 +37,8 @@ export function useLogoSelection({
}: UseLogoSelectionProps) {
const handleLogoSelection = useCallback(
(newLogo: string) => {
const derived = deriveNameFromLogo(newLogo);
const previousDerived = deriveNameFromLogo(currentLogo);
const derived = getLogoDisplayName(newLogo);
const previousDerived = getLogoDisplayName(currentLogo);
const shouldUpdateName =
mode === "create" ||

View File

@@ -75,6 +75,7 @@ export interface DatePickerProps {
required?: boolean;
disabled?: boolean;
className?: string;
inputClassName?: string;
/** Show compact format like "10 mar" instead of "10 de março de 2025" */
compact?: boolean;
}
@@ -87,6 +88,7 @@ export function DatePicker({
required = false,
disabled = false,
className,
inputClassName,
compact = false,
}: DatePickerProps) {
const [open, setOpen] = React.useState(false);
@@ -140,7 +142,7 @@ export function DatePicker({
id={id}
value={displayValue}
placeholder={placeholder}
className="bg-background pr-10"
className={cn("bg-background pr-10", inputClassName)}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
required={required}

View File

@@ -0,0 +1,433 @@
/**
* Dicionário de nomes de exibição para logos.
* Usa o nome do arquivo (lowercase) como chave.
* Fallback: deriveNameFromLogo
*/
export const logoDisplayNames: Record<string, string> = {
"99pay.png": "99Pay",
"abank.png": "Akbank",
"abcbrasil.png": "ABC Brasil",
"abnamro.png": "ABN AMRO",
"abrapetite.png": "Abrapetite",
"absolutbank.png": "Absolut Bank",
"acessobank.png": "Acesso Bank",
"activo.bank.png": "ActivoBank",
"activtrades.png": "ActivTrades",
"agbank.png": "Agricultural Bank of China",
"agibank.png": "AgiBank",
"agora.png": "Ágora Investimentos",
"agribank.png": "BIDV",
"aktia.png": "Aktia Bank",
"alelo.png": "Alelo",
"alfabank.png": "Alfa-Bank",
"alphabank.png": "Alpha Bank",
"alt.bank.png": "alt.bank",
"amazon.png": "Amazon",
"amazonia.png": "Banco da Amazônia",
"ame.png": "Amex",
"ameriprisefinancial.png": "Ameriprise Financial",
"amex.png": "Amex",
"amppersonalbanking.png": "AMP",
"anz.png": "ANZ",
"asaas.png": "Asaas",
"asn.png": "ASN Bank",
"astro-pay.png": "AstroPay",
"atlantico.png": "ATLANTICO",
"atticabank.png": "Attica Bank",
"aura.png": "Aura",
"avenida.png": "Lojas Avenida",
"avenuesecuritie.png": "Avenue",
"azul.png": "Azul",
"b3.png": "B3",
"bahamas-cred.png": "Bahamas Cred",
"bancamediolanum.png": "Banca Mediolanum",
"bancamps.png": "Banca Monte dei Paschi di Siena",
"bancaopopularedisondrio.png": "Banca Populare di Sondrio",
"banco-bai.png": "Banco Bai",
"banco-bic.png": "Banco BIC",
"bancobpm.png": "Banco BPM",
"banco-comercio-industria.png": "Banco de Comércio e Indústria",
"bancodavivienda.png": "Davivienda",
"bancodebogota.png": "Banco de Bogotá",
"bancodelbajio.png": "BanBajío",
"bancodeoccident.png": "Banco de Occident",
"bancohipotecario.png": "Banco Hipotecario",
"bancolombia.png": "Bancolombia",
"bancomacro.png": "Banco Macro",
"banconacion.png": "Banco Nación",
"banco-parana.png": "Paraná Banco",
"bancopatagonia.png": "Banco Patagonia",
"bancoposta.png": "BancoPosta",
"banco-poupanca-credito.png": "Banco de Poupança e Crédito",
"banese.png": "Banco Banese",
"banestes.png": "Banestes",
"bangkokbank.png": "Bangkok Bank",
"banif.png": "Banif",
"bankia.png": "Bankia",
"bankmandiri.png": "Bank Mandiri",
"banknorwegian.png": "Bank Norwegian",
"bankofaland.png": "Bank of Åland",
"bankofamerica.png": "Bank of America",
"bankofchina.png": "Bank of China",
"bankrakyat.png": "Bank Rakyat",
"bankwest.png": "Bankwest",
"banorte.png": "Banorte",
"banpara.png": "Banpará",
"banrisul.png": "Banrisul",
"bari.png": "Banco Bari",
"bb.png": "Banco do Brasil",
"bbva.png": "BBVA",
"bca.png": "BCA",
"bemol.png": "Bemol",
"bendigobank.png": "Bendigo Bank",
"benvisavale.png": "Ben Visa Vale",
"betfair.png": "Betfair",
"bidv.png": "BIDV",
"bilhete-unico.png": "Bilhete Único",
"binance.png": "Binance",
"binomo.png": "Binomo",
"bipa.png": "Bipa",
"bitybank.png": "BityBank",
"bitz.png": "Bitz",
"bmg.png": "BMG",
"bmg-corinthians.png": "BMG Corinthians",
"bmo.png": "BMO",
"bni.png": "BNI",
"bnl.png": "BNL",
"bnpparibas.png": "BNP Paribas",
"boq.png": "BOQ",
"bperbanca.png": "BPER Banca",
"bpi.png": "BPI",
"bradesco.png": "Bradesco",
"bradesco-empresas.png": "Bradesco Empresas",
"bradesco-prime.png": "Bradesco Prime",
"brasilcard.png": "BrasilCard",
"brb.png": "Banco BRB",
"brde.png": "BRDE",
"bs2.png": "Banco BS2",
"btg.empresas.png": "BTG Empresas",
"btgpactual.png": "BTG Pactual",
"btgplus.png": "BTG Plus",
"buddy.bank.png": "Buddy Bank",
"bunq.png": "Bunq",
"bv.png": "Banco BV",
"c6bank.png": "C6 Bank",
"c-a.png": "C&A",
"cacique.png": "Banco Cacique",
"caisse.png": "Caisse",
"caixa.png": "Caixa",
"caixabank.png": "CaixaBank",
"caixageral.png": "Caixa Geral de Depósitos",
"caju.png": "Caju",
"c-a-pay.png": "C&A Pay",
"capitalone.png": "Capital One",
"carrefour.png": "Carrefour",
"cassadepositieprestiti.png": "Cassa Depositi e Prestiti",
"cdb.png": "CDB",
"cdp.png": "CDP",
"celcoin.png": "Celcoin",
"cetelem.png": "Cetelem",
"charlesschwab.png": "Charles Schwab",
"chinaconstructionbank.png": "China Construction Bank",
"cibc.png": "CIBC",
"cimbbank.png": "CIMB Bank",
"cimbniaga.png": "CIMB Niaga",
"citibank.png": "Citibank",
"clara.png": "Clara",
"clear.png": "Clear",
"clear-corretora.png": "Clear Corretora",
"coinbase.png": "Coinbase",
"commbank.png": "CommBank",
"commerzbank.png": "Commerzbank",
"conectcar.png": "ConectCar",
"contabilizei.bank.png": "Contabilizei Bank",
"contasimples.png": "Conta Simples",
"cooperativa-ailos.png": "Cooperativa Ailos",
"cooperativa-cresol.png": "Cooperativa Cresol",
"cooperativa-unilos.png": "Cooperativa Unilos",
"coopercard.png": "Coopercard",
"cooperforte.png": "Cooperforte",
"cora.png": "Cora",
"credicard.png": "Credicard",
"credicard-on.png": "Credicard On",
"credicard-zero.png": "Credicard Zero",
"credisis.png": "Credisis",
"creditagricole.png": "Credit Agricole",
"creditagricoleitaly.png": "Credit Agricole Italy",
"creditas.png": "Creditas",
"creditbankofmoscow.png": "Credit Bank of Moscow",
"creditdunord.png": "Credit du Nord",
"credito.agricola.png": "Crédito Agrícola",
"creditoemiliano.png": "Credito Emiliano",
"crefisa.png": "Crefisa",
"cruzeirodosul.png": "Cruzeiro do Sul",
"crypto-com.png": "Crypto.com",
"ctt.png": "CTT",
"danskebank.png": "Danske Bank",
"daycoval.png": "Daycoval",
"desjardins.png": "Desjardins",
"digio.png": "Digio",
"digiplus.png": "Digiplus",
"diin.png": "Diin",
"diners.png": "Diners",
"dinheiro.png": "Dinheiro",
"dmcard.png": "DM Card",
"dnbbank.png": "DNB Bank",
"dotz.png": "Dotz",
"dzbank.png": "DZ Bank",
"easynvest.png": "EasyInvest",
"ebanxgo.png": "EBANX Go",
"edenred.png": "Edenred",
"efi.bank.png": "EFI Bank",
"elliot.png": "Elliot",
"elo.png": "Elo",
"eqi.png": "EQI",
"eurobank.png": "Eurobank",
"evlibank.png": "Evli Bank",
"exodus.png": "Exodus",
"fidelity.png": "Fidelity",
"flash.png": "Flash",
"forexbank.png": "Forex Bank",
"fortbrasil.png": "Fort Brasil",
"foxbit.png": "Foxbit",
"freedom.24.png": "Freedom 24",
"garantibank.png": "Garantibank",
"gazprombank.png": "Gazprombank",
"genial.png": "Genial",
"grao.png": "Grão",
"handelsbanken.png": "Handelsbanken",
"havan.png": "Havan",
"hipercard.png": "Hipercard",
"hongleong.png": "Hong Leong",
"hotmart.png": "Hotmart",
"hsbc.png": "HSBC",
"hypovereinsbank.png": "Hypo Vereinsbank",
"icatu.png": "Icatu",
"icbc.png": "ICBC",
"iccreabanca.png": "ICCREA Banca",
"ifood-beneficios.png": "iFood Benefícios",
"ifood-conta-digital.png": "iFood Conta Digital",
"inbursa.png": "Inbursa",
"infinitepay.png": "InfinitePay",
"ing.png": "ING",
"intermedium.png": "Inter",
"interpj.png": "Inter PJ",
"intesa-san-paolo.png": "Intesa Sanpaolo",
"ion.png": "Ion",
"iqoption.png": "IQ Option",
"isbank.png": "Isbank",
"itau.png": "Itaú",
"itau-ion.png": "Itaú Ion",
"itaupersonnalite.png": "Itaú Personnalité",
"itau-uniclass.png": "Itaú Uniclass",
"iti.png": "Iti",
"jpbank.png": "JP Bank",
"jpmorgan.png": "JPMorgan",
"juno.png": "Juno",
"jyskebank.png": "Jyske Bank",
"kbank.png": "KBank",
"kbkookminbank.png": "KB Kookmin Bank",
"kdbbank.png": "KDB Bank",
"kebhanabank.png": "KEB Hana Bank",
"kfw.png": "KFW",
"kiwify.png": "Kiwify",
"klarna.png": "Klarna",
"krungthaibank.png": "Krungthai Bank",
"latam.pass.png": "Latam Pass",
"lhv.png": "LHV",
"lojasamericanas.png": "Lojas Americanas",
"macquariebank.png": "Macquarie Bank",
"magalu-pay.png": "Magalu Pay",
"magnetis.png": "Magnetis",
"mais.png": "Mais",
"mandiri.png": "Mandiri",
"marisa.png": "Marisa",
"master-black.png": "Master Black",
"mastercard.png": "Mastercard",
"maybank.png": "Maybank",
"mediobanca.png": "Mediobanca",
"meliuz.png": "Méliuz",
"mercadobitcoin.png": "Mercado Bitcoin",
"mercadolivre.png": "Mercado Livre",
"mercadopago.png": "Mercado Pago",
"mercadopagocartao.png": "Mercado Pago Cartão",
"mercantilbrasil.png": "Banco Mercantil",
"meu-util.png": "Meu Útil",
"midway.png": "Midway",
"milleniumbcp.png": "Millenium BCP",
"mizuhobank.png": "Mizuho Bank",
"modalmais.png": "Modal Mais",
"moey.png": "Moey",
"moip.png": "Moip",
"monetizze.png": "Monetizze",
"monetus.png": "Monetus",
"monzo-bank.png": "Monzo Bank",
"morganstanley.png": "Morgan Stanley",
"mps.png": "MPS",
"mufgbank.png": "MUFG Bank",
"n26.png": "N26",
"nab.png": "NAB",
"national.australia.bank.png": "National Australia Bank",
"nationalbank.png": "National Bank",
"neon.png": "Neon",
"next.png": "Next",
"nhbank.png": "NH Bank",
"nochubank.png": "Nochu Bank",
"noh.png": "NOH",
"nomad.png": "Nomad",
"nordea.png": "Nordea",
"nordeste.png": "Nordeste",
"nordnet.png": "Nordnet",
"novadax.png": "Novadax",
"novafatura.png": "Nova Fatura",
"novobancopt.png": "Novo Banco PT",
"novucard.png": "Novu Card",
"nubank.pj.png": "Nubank PJ",
"nubank.png": "Nubank",
"nubank-ultravioleta.png": "Nubank Ultravioleta",
"nuconta.png": "Nu Conta",
"nu-invest.png": "Nu Invest",
"nykredit.png": "Nykredit",
"olymp.trade.png": "Olymp Trade",
"omni.png": "Omni",
"opbank.png": "OP Bank",
"orama.png": "Orama",
"original.png": "Banco Original",
"otkritie.png": "Otkritie",
"pag.png": "Pag",
"pagar-me.png": "Pagar.me",
"pagbank.png": "PagBank",
"pagseguro.png": "PagSeguro",
"pan.png": "Pan",
"pao-acucar.png": "Pão de Açúcar",
"passfolio.png": "Passfolio",
"payoneer.png": "Payoneer",
"paypal.png": "PayPal",
"payu.png": "PayU",
"petrobras.png": "Petrobras",
"picpay.png": "PicPay",
"piraeusbank.png": "Piraeus Bank",
"pix.png": "Pix",
"players-bank.png": "Players Bank",
"pluxxe.png": "Pluxxe",
"pncfinancialservices.png": "PNC Financial Services",
"pocket.option.png": "Pocket Option",
"portoseguro.png": "Porto Seguro",
"primacredi.png": "Primacredi",
"proencamercado.png": "Proença Supermercados",
"promsvyazbank.png": "Promsvyazbank",
"publicbank.png": "Public Bank",
"quotex.png": "Quotex",
"rabobank.png": "Rabobank",
"raiffeisenbank.png": "Raiffeisen Bank",
"rappi-bank.png": "Rappi Bank",
"rbcroyalbank.png": "RBC Royal Bank",
"rbs.png": "RBS",
"rci.png": "RCI",
"recargapay.png": "RecargaPay",
"renner.png": "Renner",
"revolut.png": "Revolut",
"rhbbank.png": "RHB Bank",
"riachuelo.png": "Riachuelo",
"rico.png": "Rico",
"sabadell.png": "Sabadell",
"safra.png": "Safra",
"samsung.png": "Samsung",
"santander.png": "Santander",
"santander-private.png": "Santander Private",
"saraiva.png": "Saraiva",
"sberbank.png": "Sberbank",
"scb.png": "SCB",
"scotiabank.png": "Scotiabank",
"sebbank.png": "SEB Bank",
"sem-parar.png": "Sem Parar",
"senff.png": "Senff",
"serasa-consumidor.png": "Serasa Consumidor",
"shinhanbank.png": "Shinhan Bank",
"shopee.png": "Shopee",
"shoptime.png": "Shoptime",
"sicoob.png": "Sicoob",
"sicredi.png": "Sicredi",
"sisprime.png": "Sisprime",
"smbc.png": "SMBC",
"smiles.png": "Smiles",
"smpbank.png": "SMP Bank",
"snsbank.png": "SNS Bank",
"socialbank.png": "Social Bank",
"societegenerale.png": "Banco Societe Generale",
"sodexo.png": "Sodexo",
"sofi.png": "Sofi",
"sofisadireto.png": "Sofisa Direto",
"sparebank1.png": "SpareBank 1",
"sportingbet.png": "Sportingbet",
"spuerkeess.png": "Spuerkeess",
"standardchartered.png": "Standard Chartered",
"stanford-federal-credit-union.png": "Stanford Federal Credit Union",
"stone.png": "Stone",
"storebrandbank.png": "Storebrand Bank",
"submarino.png": "Submarino",
"sumup.png": "Sumup",
"suncorpbank.png": "Suncorp Bank",
"superdigital.png": "Super Digital",
"swedbank.png": "Swedbank",
"swile.png": "Swile",
"sydbank.png": "SYD Bank",
"tangerine.png": "Tangerine",
"tdameritrade.png": "TD Ameritrade",
"tdbank.png": "TD Bank",
"techcombank.png": "Techcombank",
"telekom.png": "Telekom",
"tesourodireto.png": "Tesouro Direto",
"tesouronacional.png": "Tesouro Nacional",
"ticket.png": "Ticket",
"tinkoff.png": "Tinkoff",
"tmbbank.png": "TMB Bank",
"ton.png": "Ton",
"toroinvestimentos.png": "Toro Investimentos",
"trade.republic.png": "Trade Republic",
"trading.212.png": "Trading 212",
"transferwise.png": "TransferWise",
"tribanco.png": "Tribanco",
"trigg.png": "Trigg",
"tudoazul.png": "Tudo Azul",
"uber.drive.png": "Uber Drive",
"ubibanca.png": "Ubi Banca",
"unicred.png": "Unicred",
"unicreditbank.png": "UniCredit Bank",
"unimed.seguros.png": "Unimed Seguros",
"unipolbanca.png": "Unipol Banca",
"uniprime.png": "Uniprime",
"up.brasil.png": "Up Brasil",
"urbeme.png": "Urbeme",
"urpay.png": "UrPay",
"usbank.png": "U.S. Bank",
"veloe.png": "Veloe",
"venmo.png": "Venmo",
"verocard.png": "Verocard",
"viacredi.png": "Viacredi",
"vietcombank.png": "Vietcombank",
"vietinbank.png": "VietinBank",
"visa.png": "Visa",
"vitreo.png": "Vitreo",
"votorantim.png": "Votorantim",
"vr.png": "VR",
"vtbbank.png": "VTB Bank",
"vuon.png": "Vuon",
"warren.png": "Warren",
"wellsfargo.png": "Wells Fargo",
"westpac.png": "Westpac",
"wiipo.png": "Wiipo",
"will-bank.png": "Will Bank",
"wirecard.png": "Wirecard",
"wise.png": "Wise",
"woop.png": "Woop",
"xdex.png": "XDEX",
"xm.png": "XM",
"xp.png": "XP Investimentos",
"xtb.png": "XTB",
"yapikredi.png": "Yapi Kredi",
"z1.png": "Z1",
"zilch.png": "Zilch",
"ziraatbank.png": "Ziraat Bank",
"zro-bank.png": "ZRO Bank",
};

View File

@@ -1,9 +1,34 @@
import { logoDisplayNames } from "./display-names";
/**
* Normalizes logo path to get just the filename
*/
export const normalizeLogo = (logo?: string | null) =>
logo?.split("/").filter(Boolean).pop() ?? "";
/**
* Normalizes a string for accent-insensitive search.
* Removes diacritics and converts to lowercase.
*/
export const normalizeForSearch = (text: string): string =>
text
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
/**
* Gets the display name for a logo, using a manual dictionary first
* and falling back to deriveNameFromLogo for unknown logos.
*/
export const getLogoDisplayName = (logo?: string | null): string => {
if (!logo) return "";
const fileName = normalizeLogo(logo);
if (!fileName) return "";
return logoDisplayNames[fileName.toLowerCase()] ?? deriveNameFromLogo(logo);
};
/**
* Derives a display name from a logo filename
* @param logo - Logo path or filename