mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-06-10 07:16:01 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dcd30010e | ||
|
|
d589df6993 | ||
|
|
8a19f0f311 | ||
|
|
887885cd98 | ||
|
|
7a0e33efd8 | ||
|
|
b9557961e5 | ||
|
|
53c8e47981 | ||
|
|
adc9292cd8 | ||
|
|
b95d6f6752 | ||
|
|
c9f667a065 | ||
|
|
01d9c6ea05 | ||
|
|
d383d2db91 | ||
|
|
7a8d01debe | ||
|
|
3be15d3b15 | ||
|
|
fea9cf81d8 | ||
|
|
7a10d431ab | ||
|
|
b7343eb235 |
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -24,8 +24,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: 10.33.0
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
47
CHANGELOG.md
47
CHANGELOG.md
@@ -5,6 +5,53 @@ 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/),
|
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/).
|
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.
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
- Docker: o `Dockerfile` agora usa `pnpm@11.1.3` em todos os estágios e copia `pnpm-workspace.yaml` antes do `pnpm install --frozen-lockfile`, garantindo que `overrides` e `allowBuilds` sejam aplicados também no build da imagem.
|
||||||
|
|
||||||
|
## [2.6.1] - 2026-05-21
|
||||||
|
|
||||||
|
Esta versão corrige o pipeline de publicação após o salto para a `2.6.0`. O build do GitHub Actions falhava antes mesmo de instalar as dependências porque o workflow ainda fixava uma versão antiga do `pnpm`, enquanto o projeto já declarava `pnpm@11.1.3` no `packageManager`.
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
- CI: o workflow de build deixou de fixar uma versão diferente do `pnpm`, usando a versão declarada em `packageManager` para evitar conflito no `pnpm/action-setup`.
|
||||||
|
- CI: a política de builds do `pnpm` foi migrada para `allowBuilds`, permitindo explicitamente os scripts necessários de `core-js`, `esbuild`, `sharp` e `unrs-resolver` durante o install no GitHub Actions.
|
||||||
|
|
||||||
## [2.6.0] - 2026-05-21
|
## [2.6.0] - 2026-05-21
|
||||||
|
|
||||||
Esta versão implementa melhorias pensadas para o uso real do dia a dia. O OpenMonetis passa a lidar melhor com parcelamentos que já começaram, recupera atalhos importantes no extrato, deixa a revisão de importações mais precisa e dá mais controle para quem mantém uma instância self-hosted. Também entram correções de consistência no dashboard, em cartões, no tratamento da categoria `Pagamentos` e nas datas vindas de planilhas.
|
Esta versão implementa melhorias pensadas para o uso real do dia a dia. O OpenMonetis passa a lidar melhor com parcelamentos que já começaram, recupera atalhos importantes no extrato, deixa a revisão de importações mais precisa e dá mais controle para quem mantém uma instância self-hosted. Também entram correções de consistência no dashboard, em cartões, no tratamento da categoria `Pagamentos` e nas datas vindas de planilhas.
|
||||||
|
|||||||
11
Dockerfile
11
Dockerfile
@@ -5,12 +5,13 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
FROM node:22-alpine AS deps
|
FROM node:22-alpine AS deps
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
|
ARG PNPM_VERSION=11.1.3
|
||||||
|
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copiar apenas arquivos de dependências para aproveitar cache
|
# Copiar apenas arquivos de dependências para aproveitar cache
|
||||||
COPY package.json pnpm-lock.yaml* ./
|
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
|
||||||
|
|
||||||
# Criar pasta public para o postinstall do pdfjs-dist
|
# Criar pasta public para o postinstall do pdfjs-dist
|
||||||
RUN mkdir -p public
|
RUN mkdir -p public
|
||||||
@@ -23,7 +24,8 @@ RUN pnpm install --frozen-lockfile
|
|||||||
# ============================================
|
# ============================================
|
||||||
FROM node:22-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
|
ARG PNPM_VERSION=11.1.3
|
||||||
|
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -52,7 +54,8 @@ RUN pnpm build
|
|||||||
# ============================================
|
# ============================================
|
||||||
FROM node:22-alpine AS runner
|
FROM node:22-alpine AS runner
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
|
ARG PNPM_VERSION=11.1.3
|
||||||
|
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -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.
|
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
|
||||||
|
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://nextjs.org/)
|
[](https://nextjs.org/)
|
||||||
[](https://www.typescriptlang.org/)
|
[](https://www.typescriptlang.org/)
|
||||||
[](https://www.postgresql.org/)
|
[](https://www.postgresql.org/)
|
||||||
@@ -39,6 +39,7 @@
|
|||||||
- [Arquitetura](#-arquitetura)
|
- [Arquitetura](#-arquitetura)
|
||||||
- [Contribuindo](#-contribuindo)
|
- [Contribuindo](#-contribuindo)
|
||||||
- [Apoie o Projeto](#-apoie-o-projeto)
|
- [Apoie o Projeto](#-apoie-o-projeto)
|
||||||
|
- [Star History](#-star-history)
|
||||||
- [Licença](#-licença)
|
- [Licença](#-licença)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -61,7 +62,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
|
|||||||
|
|
||||||
### Funcionalidades
|
### 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.
|
📊 **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
|
## 📄 Licença
|
||||||
|
|
||||||
**Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International** (CC BY-NC-SA 4.0).
|
**Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International** (CC BY-NC-SA 4.0).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "openmonetis",
|
"name": "openmonetis",
|
||||||
"version": "2.6.0",
|
"version": "2.6.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@11.1.3",
|
"packageManager": "pnpm@11.1.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
packages:
|
packages:
|
||||||
- '.'
|
- '.'
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
allowBuilds:
|
||||||
- core-js
|
core-js: true
|
||||||
- esbuild
|
esbuild: true
|
||||||
- sharp
|
sharp: true
|
||||||
- unrs-resolver
|
unrs-resolver: true
|
||||||
|
|
||||||
neverBuiltDependencies: []
|
|
||||||
|
|
||||||
minimumReleaseAgeExclude:
|
minimumReleaseAgeExclude:
|
||||||
- '@aws-sdk/client-s3@3.1050.0'
|
- '@aws-sdk/client-s3@3.1050.0'
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
|
|||||||
dividedFilter: null,
|
dividedFilter: null,
|
||||||
amountMinFilter: null,
|
amountMinFilter: null,
|
||||||
amountMaxFilter: null,
|
amountMaxFilter: null,
|
||||||
|
dateStartFilter: null,
|
||||||
|
dateEndFilter: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const createEmptySlugMaps = (): SlugMaps => ({
|
const createEmptySlugMaps = (): SlugMaps => ({
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
} from "@/shared/components/ui/dialog";
|
} from "@/shared/components/ui/dialog";
|
||||||
import { useControlledState } from "@/shared/hooks/use-controlled-state";
|
import { useControlledState } from "@/shared/hooks/use-controlled-state";
|
||||||
import { useFormState } from "@/shared/hooks/use-form-state";
|
import { useFormState } from "@/shared/hooks/use-form-state";
|
||||||
import { deriveNameFromLogo, normalizeLogo } from "@/shared/lib/logo";
|
import { getLogoDisplayName, normalizeLogo } from "@/shared/lib/logo";
|
||||||
import {
|
import {
|
||||||
formatInitialBalanceInput,
|
formatInitialBalanceInput,
|
||||||
normalizeDecimalInput,
|
normalizeDecimalInput,
|
||||||
@@ -66,7 +66,7 @@ const buildInitialValues = ({
|
|||||||
}): AccountFormValues => {
|
}): AccountFormValues => {
|
||||||
const fallbackLogo = logoOptions[0] ?? "";
|
const fallbackLogo = logoOptions[0] ?? "";
|
||||||
const selectedLogo = normalizeLogo(account?.logo) || fallbackLogo;
|
const selectedLogo = normalizeLogo(account?.logo) || fallbackLogo;
|
||||||
const derivedName = deriveNameFromLogo(selectedLogo);
|
const derivedName = getLogoDisplayName(selectedLogo);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: account?.name ?? derivedName,
|
name: account?.name ?? derivedName,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
DEFAULT_CARD_BRANDS,
|
DEFAULT_CARD_BRANDS,
|
||||||
DEFAULT_CARD_STATUS,
|
DEFAULT_CARD_STATUS,
|
||||||
} from "@/shared/lib/cards/constants";
|
} from "@/shared/lib/cards/constants";
|
||||||
import { deriveNameFromLogo, normalizeLogo } from "@/shared/lib/logo";
|
import { getLogoDisplayName, normalizeLogo } from "@/shared/lib/logo";
|
||||||
import {
|
import {
|
||||||
formatLimitInput,
|
formatLimitInput,
|
||||||
normalizeDecimalInput,
|
normalizeDecimalInput,
|
||||||
@@ -59,7 +59,7 @@ const buildInitialValues = ({
|
|||||||
}): CardFormValues => {
|
}): CardFormValues => {
|
||||||
const fallbackLogo = logoOptions[0] ?? "";
|
const fallbackLogo = logoOptions[0] ?? "";
|
||||||
const selectedLogo = normalizeLogo(card?.logo) || fallbackLogo;
|
const selectedLogo = normalizeLogo(card?.logo) || fallbackLogo;
|
||||||
const derivedName = deriveNameFromLogo(selectedLogo);
|
const derivedName = getLogoDisplayName(selectedLogo);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: card?.name ?? derivedName,
|
name: card?.name ?? derivedName,
|
||||||
|
|||||||
@@ -207,32 +207,23 @@ export function InstallmentGroupCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Botão para abrir detalhes */}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full gap-1.5"
|
className="relative w-full justify-center gap-1.5"
|
||||||
onClick={() => setIsDetailsOpen(true)}
|
onClick={() => setIsDetailsOpen(true)}
|
||||||
>
|
>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<RiFileList2Line className="size-4" />
|
<RiFileList2Line className="size-4" />
|
||||||
detalhes ({group.pendingInstallments.length} parcelas)
|
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>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -192,6 +192,22 @@ export async function fetchInstallmentAnalysis(
|
|||||||
(i) => !i.isSettled,
|
(i) => !i.isSettled,
|
||||||
);
|
);
|
||||||
return hasUnpaidInstallments;
|
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
|
// Calcular totais
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
import { uuidSchema } from "@/shared/lib/schemas/common";
|
import { uuidSchema } from "@/shared/lib/schemas/common";
|
||||||
import type { ActionResult } from "@/shared/lib/types/actions";
|
import type { ActionResult } from "@/shared/lib/types/actions";
|
||||||
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
|
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
|
||||||
|
import { comparePeriods } from "@/shared/utils/period";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema de validação para criar antecipação
|
* Schema de validação para criar antecipação
|
||||||
@@ -63,14 +64,18 @@ const cancelAnticipationSchema = z.object({
|
|||||||
*/
|
*/
|
||||||
export async function getEligibleInstallmentsAction(
|
export async function getEligibleInstallmentsAction(
|
||||||
seriesId: string,
|
seriesId: string,
|
||||||
|
anticipationPeriod: string,
|
||||||
): Promise<ActionResult<EligibleInstallment[]>> {
|
): Promise<ActionResult<EligibleInstallment[]>> {
|
||||||
try {
|
try {
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
|
|
||||||
// Validar seriesId
|
// Validar seriesId
|
||||||
const validatedSeriesId = uuidSchema("Série").parse(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({
|
const rows = await db.query.transactions.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(transactions.seriesId, validatedSeriesId),
|
eq(transactions.seriesId, validatedSeriesId),
|
||||||
@@ -96,7 +101,11 @@ export async function getEligibleInstallmentsAction(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const eligibleInstallments: EligibleInstallment[] = rows.map((row) => ({
|
const eligibleInstallments: EligibleInstallment[] = rows
|
||||||
|
.filter(
|
||||||
|
(row) => comparePeriods(row.period, validatedAnticipationPeriod) > 0,
|
||||||
|
)
|
||||||
|
.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
amount: row.amount,
|
amount: row.amount,
|
||||||
@@ -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
|
// 2. Calcular valor total
|
||||||
const totalAmountCents = installments.reduce(
|
const totalAmountCents = installments.reduce(
|
||||||
(sum, inst) => sum + Number(inst.amount) * 100,
|
(sum, inst) => sum + Number(inst.amount) * 100,
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = z.object(
|
|||||||
dividedFilter: z.string().nullable(),
|
dividedFilter: z.string().nullable(),
|
||||||
amountMinFilter: z.number().nullable(),
|
amountMinFilter: z.number().nullable(),
|
||||||
amountMaxFilter: 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(),
|
accountId: z.string().min(1).nullable().optional(),
|
||||||
cardId: z.string().min(1).nullable().optional(),
|
cardId: z.string().min(1).nullable().optional(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiLoader4Line } from "@remixicon/react";
|
import { RiLoader4Line } from "@remixicon/react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { CategoryIcon } from "@/features/categories/components/category-icon";
|
import { CategoryIcon } from "@/features/categories/components/category-icon";
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
createInstallmentAnticipationAction,
|
createInstallmentAnticipationAction,
|
||||||
getEligibleInstallmentsAction,
|
getEligibleInstallmentsAction,
|
||||||
} from "@/features/transactions/actions/anticipation";
|
} from "@/features/transactions/actions/anticipation";
|
||||||
|
import { installmentAnticipationsQueryKey } from "@/features/transactions/hooks/use-installment-anticipations";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { PeriodPicker } from "@/shared/components/period-picker";
|
import { PeriodPicker } from "@/shared/components/period-picker";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
@@ -70,6 +72,7 @@ export function AnticipateInstallmentsDialog({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: AnticipateInstallmentsDialogProps) {
|
}: AnticipateInstallmentsDialogProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [isLoadingInstallments, setIsLoadingInstallments] = useState(false);
|
const [isLoadingInstallments, setIsLoadingInstallments] = useState(false);
|
||||||
@@ -86,7 +89,7 @@ export function AnticipateInstallmentsDialog({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Use form state hook for form management
|
// Use form state hook for form management
|
||||||
const { formState, replaceForm, updateField } =
|
const { formState, replaceForm, updateField, updateFields } =
|
||||||
useFormState<AnticipationFormValues>({
|
useFormState<AnticipationFormValues>({
|
||||||
anticipationPeriod: defaultPeriod,
|
anticipationPeriod: defaultPeriod,
|
||||||
discount: "0",
|
discount: "0",
|
||||||
@@ -95,15 +98,34 @@ export function AnticipateInstallmentsDialog({
|
|||||||
note: "",
|
note: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Buscar parcelas elegíveis ao abrir o dialog
|
// Resetar formulário ao abrir o dialog
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dialogOpen) {
|
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);
|
setIsLoadingInstallments(true);
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
getEligibleInstallmentsAction(seriesId)
|
getEligibleInstallmentsAction(seriesId, formState.anticipationPeriod)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
|
if (!shouldUpdate) return;
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
toast.error(result.error || "Erro ao carregar parcelas");
|
toast.error(result.error || "Erro ao carregar parcelas");
|
||||||
setEligibleInstallments([]);
|
setEligibleInstallments([]);
|
||||||
@@ -116,25 +138,30 @@ export function AnticipateInstallmentsDialog({
|
|||||||
// Pré-preencher pagador e categoria da primeira parcela
|
// Pré-preencher pagador e categoria da primeira parcela
|
||||||
if (installments.length > 0) {
|
if (installments.length > 0) {
|
||||||
const first = installments[0];
|
const first = installments[0];
|
||||||
replaceForm({
|
updateFields({
|
||||||
anticipationPeriod: defaultPeriod,
|
|
||||||
discount: "0",
|
|
||||||
payerId: first.payerId ?? "",
|
payerId: first.payerId ?? "",
|
||||||
categoryId: first.categoryId ?? "",
|
categoryId: first.categoryId ?? "",
|
||||||
note: "",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
if (!shouldUpdate) return;
|
||||||
|
|
||||||
console.error("Erro ao buscar parcelas:", error);
|
console.error("Erro ao buscar parcelas:", error);
|
||||||
toast.error("Erro ao carregar parcelas elegíveis");
|
toast.error("Erro ao carregar parcelas elegíveis");
|
||||||
setEligibleInstallments([]);
|
setEligibleInstallments([]);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
if (!shouldUpdate) return;
|
||||||
|
|
||||||
setIsLoadingInstallments(false);
|
setIsLoadingInstallments(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
shouldUpdate = false;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}, [defaultPeriod, dialogOpen, replaceForm, seriesId]);
|
}, [dialogOpen, formState.anticipationPeriod, seriesId, updateFields]);
|
||||||
|
|
||||||
const totalAmount = useMemo(() => {
|
const totalAmount = useMemo(() => {
|
||||||
return eligibleInstallments
|
return eligibleInstallments
|
||||||
@@ -189,6 +216,9 @@ export function AnticipateInstallmentsDialog({
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: installmentAnticipationsQueryKey(seriesId),
|
||||||
|
});
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
} else {
|
} else {
|
||||||
const errorMsg = result.error || "Erro ao criar antecipação";
|
const errorMsg = result.error || "Erro ao criar antecipação";
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiCalendarCheckLine, RiLoader4Line } from "@remixicon/react";
|
import { RiCalendarCheckLine, RiLoader4Line } from "@remixicon/react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { cancelInstallmentAnticipationAction } from "@/features/transactions/actions/anticipation";
|
||||||
import {
|
import {
|
||||||
installmentAnticipationsQueryKey,
|
installmentAnticipationsQueryKey,
|
||||||
useInstallmentAnticipations,
|
useInstallmentAnticipations,
|
||||||
} from "@/features/transactions/hooks/use-installment-anticipations";
|
} from "@/features/transactions/hooks/use-installment-anticipations";
|
||||||
|
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
@@ -31,7 +36,6 @@ interface AnticipationHistoryDialogProps {
|
|||||||
lancamentoName: string;
|
lancamentoName: string;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
onViewLancamento?: (transactionId: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnticipationHistoryDialog({
|
export function AnticipationHistoryDialog({
|
||||||
@@ -40,7 +44,6 @@ export function AnticipationHistoryDialog({
|
|||||||
lancamentoName,
|
lancamentoName,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onViewLancamento,
|
|
||||||
}: AnticipationHistoryDialogProps) {
|
}: AnticipationHistoryDialogProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||||
@@ -51,34 +54,118 @@ export function AnticipationHistoryDialog({
|
|||||||
const {
|
const {
|
||||||
data: anticipations = [],
|
data: anticipations = [],
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isFetching,
|
||||||
isError,
|
isError,
|
||||||
refetch,
|
refetch,
|
||||||
} = useInstallmentAnticipations(seriesId, dialogOpen);
|
} = 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({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: installmentAnticipationsQueryKey(seriesId),
|
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 (
|
return (
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||||
<DialogContent className="max-w-3xl px-6 py-5 sm:px-8 sm:py-6">
|
<DialogContent className="min-w-0 overflow-x-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader className="text-left">
|
||||||
<DialogTitle>Histórico de Antecipações</DialogTitle>
|
<DialogTitle>Histórico de Antecipações</DialogTitle>
|
||||||
<DialogDescription>{lancamentoName}</DialogDescription>
|
<DialogDescription>{lancamentoName}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="max-h-[60vh] space-y-4 overflow-y-auto pr-2">
|
<div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm">
|
||||||
{isLoading ? (
|
{isLoading || isFetching ? (
|
||||||
<div className="flex items-center justify-center rounded-lg border border-dashed p-12">
|
<LoadingState />
|
||||||
|
) : isError ? (
|
||||||
|
<ErrorState onRetry={() => void refetch()} />
|
||||||
|
) : anticipations.length === 0 ? (
|
||||||
|
<EmptyState />
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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" />
|
<RiLoader4Line className="size-6 animate-spin text-muted-foreground" />
|
||||||
<span className="ml-2 text-sm text-muted-foreground">
|
<span className="ml-2 text-sm text-muted-foreground">
|
||||||
Carregando histórico...
|
Carregando histórico...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : isError ? (
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorState({ onRetry }: { onRetry: () => void }) {
|
||||||
|
return (
|
||||||
<Empty>
|
<Empty>
|
||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
<EmptyMedia variant="icon">
|
<EmptyMedia variant="icon">
|
||||||
@@ -93,12 +180,16 @@ export function AnticipationHistoryDialog({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="mx-auto"
|
className="mx-auto"
|
||||||
onClick={() => void refetch()}
|
onClick={onRetry}
|
||||||
>
|
>
|
||||||
Tentar novamente
|
Tentar novamente
|
||||||
</Button>
|
</Button>
|
||||||
</Empty>
|
</Empty>
|
||||||
) : anticipations.length === 0 ? (
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
return (
|
||||||
<Empty>
|
<Empty>
|
||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
<EmptyMedia variant="icon">
|
<EmptyMedia variant="icon">
|
||||||
@@ -106,32 +197,9 @@ export function AnticipationHistoryDialog({
|
|||||||
</EmptyMedia>
|
</EmptyMedia>
|
||||||
<EmptyTitle>Nenhuma antecipação registrada</EmptyTitle>
|
<EmptyTitle>Nenhuma antecipação registrada</EmptyTitle>
|
||||||
<EmptyDescription>
|
<EmptyDescription>
|
||||||
As antecipações realizadas para esta compra parcelada
|
As antecipações realizadas para esta compra parcelada aparecerão aqui.
|
||||||
aparecerão aqui.
|
|
||||||
</EmptyDescription>
|
</EmptyDescription>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
</Empty>
|
</Empty>
|
||||||
) : (
|
|
||||||
anticipations.map((anticipation) => (
|
|
||||||
<AnticipationCard
|
|
||||||
key={anticipation.id}
|
|
||||||
anticipation={anticipation}
|
|
||||||
onViewLancamento={onViewLancamento}
|
|
||||||
onCanceled={handleCanceled}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ export function InstallmentSelectionTable({
|
|||||||
Nenhuma parcela elegível para antecipação encontrada.
|
Nenhuma parcela elegível para antecipação encontrada.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Todas as parcelas desta compra já foram pagas ou antecipadas.
|
Apenas parcelas futuras, ainda não pagas ou antecipadas, aparecem
|
||||||
|
aqui.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
PAYMENT_METHODS,
|
PAYMENT_METHODS,
|
||||||
type TRANSACTION_TYPES,
|
type TRANSACTION_TYPES,
|
||||||
} from "@/features/transactions/lib/constants";
|
} from "@/features/transactions/lib/constants";
|
||||||
|
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
||||||
import { DatePicker } from "@/shared/components/ui/date-picker";
|
import { DatePicker } from "@/shared/components/ui/date-picker";
|
||||||
@@ -123,10 +124,11 @@ interface TransactionRow {
|
|||||||
|
|
||||||
function createEmptyTransactionRow(
|
function createEmptyTransactionRow(
|
||||||
defaultPayerId?: string | null,
|
defaultPayerId?: string | null,
|
||||||
|
lastPurchaseDate?: string,
|
||||||
): TransactionRow {
|
): TransactionRow {
|
||||||
return {
|
return {
|
||||||
id: createClientSafeId(),
|
id: createClientSafeId(),
|
||||||
purchaseDate: getTodayDateString(),
|
purchaseDate: lastPurchaseDate ?? getTodayDateString(),
|
||||||
name: "",
|
name: "",
|
||||||
amount: "",
|
amount: "",
|
||||||
categoryId: undefined,
|
categoryId: undefined,
|
||||||
@@ -148,6 +150,9 @@ export function MassAddDialog({
|
|||||||
defaultCardId,
|
defaultCardId,
|
||||||
}: MassAddDialogProps) {
|
}: MassAddDialogProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
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)
|
// Fixed fields state (sempre ativos, sem checkboxes)
|
||||||
const [transactionType, setTransactionType] =
|
const [transactionType, setTransactionType] =
|
||||||
@@ -179,11 +184,23 @@ export function MassAddDialog({
|
|||||||
return groupAndSortCategories(filtered);
|
return groupAndSortCategories(filtered);
|
||||||
}, [categoryOptions, transactionType]);
|
}, [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 addTransaction = () => {
|
||||||
|
const lastTransaction = transactions[transactions.length - 1];
|
||||||
setTransactions([
|
setTransactions([
|
||||||
...transactions,
|
...transactions,
|
||||||
createEmptyTransactionRow(defaultPayerId),
|
createEmptyTransactionRow(defaultPayerId, lastTransaction?.purchaseDate),
|
||||||
]);
|
]);
|
||||||
|
setIsDirty(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeTransaction = (id: string) => {
|
const removeTransaction = (id: string) => {
|
||||||
@@ -192,6 +209,7 @@ export function MassAddDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTransactions(transactions.filter((t) => t.id !== id));
|
setTransactions(transactions.filter((t) => t.id !== id));
|
||||||
|
setIsDirty(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateTransaction = (
|
const updateTransaction = (
|
||||||
@@ -202,6 +220,7 @@ export function MassAddDialog({
|
|||||||
setTransactions(
|
setTransactions(
|
||||||
transactions.map((t) => (t.id === id ? { ...t, [field]: value } : t)),
|
transactions.map((t) => (t.id === id ? { ...t, [field]: value } : t)),
|
||||||
);
|
);
|
||||||
|
setIsDirty(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
@@ -250,13 +269,7 @@ export function MassAddDialog({
|
|||||||
try {
|
try {
|
||||||
await onSubmit(formData);
|
await onSubmit(formData);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
// Reset form
|
resetForm();
|
||||||
setTransactionType("Despesa");
|
|
||||||
setPaymentMethod(PAYMENT_METHODS[0]);
|
|
||||||
setPeriod(selectedPeriod);
|
|
||||||
setContaId(undefined);
|
|
||||||
setCartaoId(defaultCardId ?? undefined);
|
|
||||||
setTransactions([createEmptyTransactionRow(defaultPayerId)]);
|
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// Error is handled by the onSubmit function
|
// Error is handled by the onSubmit function
|
||||||
} finally {
|
} finally {
|
||||||
@@ -265,7 +278,19 @@ export function MassAddDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto p-6 sm:px-8">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Adicionar múltiplos lançamentos</DialogTitle>
|
<DialogTitle>Adicionar múltiplos lançamentos</DialogTitle>
|
||||||
@@ -286,9 +311,10 @@ export function MassAddDialog({
|
|||||||
<Label htmlFor="transaction-type">Tipo de Transação</Label>
|
<Label htmlFor="transaction-type">Tipo de Transação</Label>
|
||||||
<Select
|
<Select
|
||||||
value={transactionType}
|
value={transactionType}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => {
|
||||||
setTransactionType(value as MassAddTransactionType)
|
setTransactionType(value as MassAddTransactionType);
|
||||||
}
|
setIsDirty(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="transaction-type" className="w-full">
|
<SelectTrigger id="transaction-type" className="w-full">
|
||||||
<SelectValue>
|
<SelectValue>
|
||||||
@@ -315,6 +341,7 @@ export function MassAddDialog({
|
|||||||
value={paymentMethod}
|
value={paymentMethod}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
setPaymentMethod(value as MassAddPaymentMethod);
|
setPaymentMethod(value as MassAddPaymentMethod);
|
||||||
|
setIsDirty(true);
|
||||||
// Reset conta/cartao when changing payment method
|
// Reset conta/cartao when changing payment method
|
||||||
if (value === "Cartão de crédito") {
|
if (value === "Cartão de crédito") {
|
||||||
setContaId(undefined);
|
setContaId(undefined);
|
||||||
@@ -346,7 +373,10 @@ export function MassAddDialog({
|
|||||||
<Label htmlFor="cartao">Cartão</Label>
|
<Label htmlFor="cartao">Cartão</Label>
|
||||||
<Select
|
<Select
|
||||||
value={cardId}
|
value={cardId}
|
||||||
onValueChange={setCartaoId}
|
onValueChange={(value) => {
|
||||||
|
setCartaoId(value);
|
||||||
|
setIsDirty(true);
|
||||||
|
}}
|
||||||
disabled={isLockedToCartao}
|
disabled={isLockedToCartao}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="cartao" className="w-full">
|
<SelectTrigger id="cartao" className="w-full">
|
||||||
@@ -395,7 +425,10 @@ export function MassAddDialog({
|
|||||||
{cardId ? (
|
{cardId ? (
|
||||||
<InlinePeriodPicker
|
<InlinePeriodPicker
|
||||||
period={period}
|
period={period}
|
||||||
onPeriodChange={setPeriod}
|
onPeriodChange={(value) => {
|
||||||
|
setPeriod(value);
|
||||||
|
setIsDirty(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -405,7 +438,13 @@ export function MassAddDialog({
|
|||||||
{!isCartaoSelected ? (
|
{!isCartaoSelected ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="conta">Conta</Label>
|
<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">
|
<SelectTrigger id="conta" className="w-full">
|
||||||
<SelectValue placeholder="Selecione">
|
<SelectValue placeholder="Selecione">
|
||||||
{accountId &&
|
{accountId &&
|
||||||
@@ -635,7 +674,13 @@ export function MassAddDialog({
|
|||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => {
|
||||||
|
if (isDirty) {
|
||||||
|
setCancelConfirmOpen(true);
|
||||||
|
} else {
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
@@ -646,6 +691,36 @@ export function MassAddDialog({
|
|||||||
{transactions.length === 1 ? "lançamento" : "lançamentos"}
|
{transactions.length === 1 ? "lançamento" : "lançamentos"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</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>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -569,7 +569,7 @@ export function TransactionDialog({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
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 */}
|
{/* Detalhes */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -851,16 +851,6 @@ export function TransactionsPage({
|
|||||||
onOpenChange={setAnticipationHistoryOpen}
|
onOpenChange={setAnticipationHistoryOpen}
|
||||||
seriesId={selectedForAnticipation.seriesId as string}
|
seriesId={selectedForAnticipation.seriesId as string}
|
||||||
lancamentoName={selectedForAnticipation.name}
|
lancamentoName={selectedForAnticipation.name}
|
||||||
onViewLancamento={(transactionId) => {
|
|
||||||
const transaction = transactionList.find(
|
|
||||||
(l) => l.id === transactionId,
|
|
||||||
);
|
|
||||||
if (transaction) {
|
|
||||||
setSelectedTransaction(transaction);
|
|
||||||
setDetailsOpen(true);
|
|
||||||
setAnticipationHistoryOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -3,19 +3,13 @@
|
|||||||
import { RiCalendarCheckLine } from "@remixicon/react";
|
import { RiCalendarCheckLine } from "@remixicon/react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
import { useTransition } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { cancelInstallmentAnticipationAction } from "@/features/transactions/actions/anticipation";
|
|
||||||
import type { InstallmentAnticipationListItem } from "@/features/transactions/hooks/use-installment-anticipations";
|
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 MoneyValues from "@/shared/components/money-values";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/shared/components/ui/card";
|
} from "@/shared/components/ui/card";
|
||||||
@@ -23,172 +17,129 @@ import { displayPeriod } from "@/shared/utils/period";
|
|||||||
|
|
||||||
interface AnticipationCardProps {
|
interface AnticipationCardProps {
|
||||||
anticipation: InstallmentAnticipationListItem;
|
anticipation: InstallmentAnticipationListItem;
|
||||||
onViewLancamento?: (transactionId: string) => void;
|
|
||||||
onCanceled?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnticipationCard({
|
export function AnticipationCard({ anticipation }: AnticipationCardProps) {
|
||||||
anticipation,
|
|
||||||
onViewLancamento,
|
|
||||||
onCanceled,
|
|
||||||
}: AnticipationCardProps) {
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
const isSettled = anticipation.transaction?.isSettled === true;
|
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) => {
|
const formatDate = (date: string) => {
|
||||||
return format(new Date(date), "dd 'de' MMMM 'de' yyyy", { locale: ptBR });
|
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");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewLancamento = () => {
|
|
||||||
onViewLancamento?.(anticipation.transactionId);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="shadow-none py-2">
|
||||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
|
<CardHeader className="space-y-3 p-4 pb-1">
|
||||||
<div className="space-y-1">
|
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||||
<CardTitle className="text-base">
|
<div className="min-w-0 space-y-1">
|
||||||
|
<CardTitle className="text-base leading-none">
|
||||||
{anticipation.installmentCount}{" "}
|
{anticipation.installmentCount}{" "}
|
||||||
{anticipation.installmentCount === 1
|
{anticipation.installmentCount === 1
|
||||||
? "parcela antecipada"
|
? "parcela antecipada"
|
||||||
: "parcelas antecipadas"}
|
: "parcelas antecipadas"}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
|
||||||
<RiCalendarCheckLine className="mr-1 inline size-3.5" />
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
{formatDate(anticipation.anticipationDate)}
|
<RiCalendarCheckLine className="size-3 shrink-0" />
|
||||||
</CardDescription>
|
<span>{formatDate(anticipation.anticipationDate)}</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary">
|
</div>
|
||||||
|
|
||||||
|
<Badge variant="secondary" className="shrink-0 rounded-full px-3">
|
||||||
{displayPeriod(anticipation.anticipationPeriod)}
|
{displayPeriod(anticipation.anticipationPeriod)}
|
||||||
</Badge>
|
</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>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="px-4 pb-4 pt-0">
|
||||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
||||||
<div>
|
<DetailItem label="Valor Original">
|
||||||
<dt className="text-muted-foreground">Valor Original</dt>
|
<MoneyValues amount={totalAmount} />
|
||||||
<dd className="mt-1 font-medium">
|
</DetailItem>
|
||||||
<MoneyValues amount={Number(anticipation.totalAmount)} />
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{Number(anticipation.discount) > 0 && (
|
{hasDiscount ? (
|
||||||
<div>
|
<DetailItem label="Desconto" valueClassName="text-success">
|
||||||
<dt className="text-muted-foreground">Desconto</dt>
|
- <MoneyValues amount={discount} />
|
||||||
<dd className="mt-1 font-medium text-success">
|
</DetailItem>
|
||||||
- <MoneyValues amount={Number(anticipation.discount)} />
|
) : (
|
||||||
</dd>
|
<div />
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<DetailItem label="Status">
|
||||||
className={
|
<Badge
|
||||||
Number(anticipation.discount) > 0
|
variant={isSettled ? "success" : "outline"}
|
||||||
? "col-span-2 border-t pt-3"
|
className="h-5 rounded-full px-2 text-xs"
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<dt className="text-muted-foreground">Status do Lançamento</dt>
|
|
||||||
<dd className="mt-1">
|
|
||||||
<Badge variant={isSettled ? "success" : "outline"}>
|
|
||||||
{isSettled ? "Pago" : "Pendente"}
|
{isSettled ? "Pago" : "Pendente"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</dd>
|
</DetailItem>
|
||||||
</div>
|
|
||||||
|
|
||||||
{anticipation.payer && (
|
{anticipation.payer ? (
|
||||||
<div>
|
<DetailItem label="Pessoa">{anticipation.payer.name}</DetailItem>
|
||||||
<dt className="text-muted-foreground">Pessoa</dt>
|
) : (
|
||||||
<dd className="mt-1 font-medium">{anticipation.payer.name}</dd>
|
<div />
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{anticipation.category && (
|
{anticipation.category ? (
|
||||||
<div>
|
<DetailItem label="Categoria">
|
||||||
<dt className="text-muted-foreground">Categoria</dt>
|
{anticipation.category.name}
|
||||||
<dd className="mt-1 font-medium">{anticipation.category.name}</dd>
|
</DetailItem>
|
||||||
</div>
|
) : null}
|
||||||
)}
|
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{anticipation.note && (
|
{anticipation.note ? (
|
||||||
<div className="rounded-lg border p-3">
|
<div className="mt-3 border-t pt-3">
|
||||||
<dt className="text-xs font-medium text-muted-foreground">
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
Observação
|
Observação
|
||||||
</dt>
|
</p>
|
||||||
<dd className="mt-1 text-sm">{anticipation.note}</dd>
|
<p className="mt-1 text-sm leading-snug">{anticipation.note}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</CardContent>
|
</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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,12 +23,17 @@ import {
|
|||||||
import {
|
import {
|
||||||
AMOUNT_MAX_PARAM,
|
AMOUNT_MAX_PARAM,
|
||||||
AMOUNT_MIN_PARAM,
|
AMOUNT_MIN_PARAM,
|
||||||
|
DATE_END_PARAM,
|
||||||
|
DATE_START_PARAM,
|
||||||
PAYMENT_METHODS,
|
PAYMENT_METHODS,
|
||||||
SETTLED_FILTER_VALUES,
|
SETTLED_FILTER_VALUES,
|
||||||
TRANSACTION_CONDITIONS,
|
TRANSACTION_CONDITIONS,
|
||||||
TRANSACTION_TYPES,
|
TRANSACTION_TYPES,
|
||||||
} from "@/features/transactions/lib/constants";
|
} 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 { Button } from "@/shared/components/ui/button";
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +44,7 @@ import {
|
|||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList,
|
CommandList,
|
||||||
} from "@/shared/components/ui/command";
|
} from "@/shared/components/ui/command";
|
||||||
|
import { DatePicker } from "@/shared/components/ui/date-picker";
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerContent,
|
DrawerContent,
|
||||||
@@ -60,7 +66,12 @@ import {
|
|||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
} from "@/shared/components/ui/select";
|
} from "@/shared/components/ui/select";
|
||||||
|
import { Separator } from "@/shared/components/ui/separator";
|
||||||
import { Switch } from "@/shared/components/ui/switch";
|
import { Switch } from "@/shared/components/ui/switch";
|
||||||
|
import {
|
||||||
|
ToggleGroup,
|
||||||
|
ToggleGroupItem,
|
||||||
|
} from "@/shared/components/ui/toggle-group";
|
||||||
import { slugify } from "@/shared/utils/string";
|
import { slugify } from "@/shared/utils/string";
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils/ui";
|
||||||
import {
|
import {
|
||||||
@@ -83,6 +94,9 @@ const normalizeAmountParam = (raw: string): string | null => {
|
|||||||
return parsed === null ? null : parsed.toString();
|
return parsed === null ? null : parsed.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeDateParam = (raw: string): string | null =>
|
||||||
|
parseDateFilterParam(raw.trim());
|
||||||
|
|
||||||
function useDebouncedAmountFilter(
|
function useDebouncedAmountFilter(
|
||||||
param: string,
|
param: string,
|
||||||
searchParams: URLSearchParams | ReadonlyURLSearchParams,
|
searchParams: URLSearchParams | ReadonlyURLSearchParams,
|
||||||
@@ -135,6 +149,7 @@ function FilterSelect({
|
|||||||
value === FILTER_EMPTY_VALUE
|
value === FILTER_EMPTY_VALUE
|
||||||
? placeholder
|
? placeholder
|
||||||
: (current?.label ?? placeholder);
|
: (current?.label ?? placeholder);
|
||||||
|
const hasSelection = value !== FILTER_EMPTY_VALUE && Boolean(current);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
@@ -148,8 +163,13 @@ function FilterSelect({
|
|||||||
className={cn("text-sm border-dashed", widthClass)}
|
className={cn("text-sm border-dashed", widthClass)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<span className="truncate">
|
<span
|
||||||
{value !== FILTER_EMPTY_VALUE && current && renderContent
|
className={cn(
|
||||||
|
"truncate",
|
||||||
|
hasSelection ? "text-foreground" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{current && renderContent
|
||||||
? renderContent(current.label)
|
? renderContent(current.label)
|
||||||
: displayLabel}
|
: displayLabel}
|
||||||
</span>
|
</span>
|
||||||
@@ -255,12 +275,19 @@ function MultiSelectFilter({
|
|||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className={cn(
|
className={cn(
|
||||||
"justify-between text-sm border-dashed font-normal",
|
"justify-between text-sm border-dashed font-normal shadow-none",
|
||||||
widthClass,
|
widthClass,
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
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}
|
{triggerLabel}
|
||||||
</span>
|
</span>
|
||||||
<RiExpandUpDownLine className="ml-2 size-4 shrink-0 opacity-50" />
|
<RiExpandUpDownLine className="ml-2 size-4 shrink-0 opacity-50" />
|
||||||
@@ -392,6 +419,13 @@ export function TransactionsFilters({
|
|||||||
[searchParams, pathname, router],
|
[searchParams, pathname, router],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDateFilterChange = useCallback(
|
||||||
|
(key: string, value: string) => {
|
||||||
|
handleFilterChange(key, normalizeDateParam(value));
|
||||||
|
},
|
||||||
|
[handleFilterChange],
|
||||||
|
);
|
||||||
|
|
||||||
const [searchValue, setSearchValue] = useState(searchParams.get("q") ?? "");
|
const [searchValue, setSearchValue] = useState(searchParams.get("q") ?? "");
|
||||||
const currentSearchParam = searchParams.get("q") ?? "";
|
const currentSearchParam = searchParams.get("q") ?? "";
|
||||||
|
|
||||||
@@ -509,25 +543,46 @@ export function TransactionsFilters({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const hasDateRangeFilter =
|
||||||
const hasActiveFilters =
|
Boolean(searchParams.get(DATE_START_PARAM)) ||
|
||||||
searchParams.get("type") ||
|
Boolean(searchParams.get(DATE_END_PARAM));
|
||||||
searchParams.getAll("condition").length > 0 ||
|
const hasAmountFilter =
|
||||||
searchParams.getAll("payment").length > 0 ||
|
Boolean(searchParams.get(AMOUNT_MIN_PARAM)) ||
|
||||||
searchParams.getAll("payer").length > 0 ||
|
Boolean(searchParams.get(AMOUNT_MAX_PARAM));
|
||||||
searchParams.getAll("category").length > 0 ||
|
const activeFilterCount = [
|
||||||
searchParams.getAll("accountCard").length > 0 ||
|
Boolean(searchParams.get("type")),
|
||||||
searchParams.get("settled") ||
|
searchParams.getAll("condition").length > 0,
|
||||||
searchParams.get("hasAttachment") ||
|
searchParams.getAll("payment").length > 0,
|
||||||
searchParams.get("isDivided") ||
|
searchParams.getAll("payer").length > 0,
|
||||||
searchParams.get(AMOUNT_MIN_PARAM) ||
|
searchParams.getAll("category").length > 0,
|
||||||
searchParams.get(AMOUNT_MAX_PARAM);
|
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 = () => {
|
const handleResetFilters = () => {
|
||||||
handleReset();
|
handleReset();
|
||||||
setDrawerOpen(false);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -607,9 +662,11 @@ export function TransactionsFilters({
|
|||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 space-y-4">
|
<div className="flex-1 overflow-y-auto px-4 space-y-4">
|
||||||
<div className="space-y-2">
|
<div>
|
||||||
<label className="text-sm font-medium">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
Tipo de Lançamento
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Tipo de lançamento
|
||||||
</label>
|
</label>
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
param="type"
|
param="type"
|
||||||
@@ -628,9 +685,9 @@ export function TransactionsFilters({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium">
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
Condição de Lançamento
|
Condição de pagamento
|
||||||
</label>
|
</label>
|
||||||
<MultiSelectFilter
|
<MultiSelectFilter
|
||||||
placeholder="Todas"
|
placeholder="Todas"
|
||||||
@@ -643,9 +700,9 @@ export function TransactionsFilters({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium">
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
Forma de Pagamento
|
Forma de pagamento
|
||||||
</label>
|
</label>
|
||||||
<MultiSelectFilter
|
<MultiSelectFilter
|
||||||
placeholder="Todas"
|
placeholder="Todas"
|
||||||
@@ -658,8 +715,10 @@ export function TransactionsFilters({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium">Pessoa</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Pessoa
|
||||||
|
</label>
|
||||||
<MultiSelectFilter
|
<MultiSelectFilter
|
||||||
placeholder="Todas"
|
placeholder="Todas"
|
||||||
options={payerMultiOptions}
|
options={payerMultiOptions}
|
||||||
@@ -673,8 +732,10 @@ export function TransactionsFilters({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium">Categoria</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Categoria
|
||||||
|
</label>
|
||||||
<MultiSelectFilter
|
<MultiSelectFilter
|
||||||
placeholder="Todas"
|
placeholder="Todas"
|
||||||
options={categoryMultiOptions}
|
options={categoryMultiOptions}
|
||||||
@@ -688,8 +749,10 @@ export function TransactionsFilters({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium">Conta/Cartão</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Conta/Cartão
|
||||||
|
</label>
|
||||||
<MultiSelectFilter
|
<MultiSelectFilter
|
||||||
placeholder="Todos"
|
placeholder="Todos"
|
||||||
options={accountCardMultiOptions}
|
options={accountCardMultiOptions}
|
||||||
@@ -703,9 +766,59 @@ export function TransactionsFilters({
|
|||||||
groupOrder={["Contas", "Cartões"]}
|
groupOrder={["Contas", "Cartões"]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<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>
|
||||||
|
{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="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}
|
||||||
|
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">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Faixa de valor</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Faixa de valor
|
||||||
|
</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -715,7 +828,9 @@ export function TransactionsFilters({
|
|||||||
placeholder="Mínimo"
|
placeholder="Mínimo"
|
||||||
aria-label="Valor mínimo"
|
aria-label="Valor mínimo"
|
||||||
value={valorMinValue}
|
value={valorMinValue}
|
||||||
onChange={(event) => setValorMinValue(event.target.value)}
|
onChange={(event) =>
|
||||||
|
setValorMinValue(event.target.value)
|
||||||
|
}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="text-sm border-dashed"
|
className="text-sm border-dashed"
|
||||||
/>
|
/>
|
||||||
@@ -728,61 +843,53 @@ export function TransactionsFilters({
|
|||||||
placeholder="Máximo"
|
placeholder="Máximo"
|
||||||
aria-label="Valor máximo"
|
aria-label="Valor máximo"
|
||||||
value={valorMaxValue}
|
value={valorMaxValue}
|
||||||
onChange={(event) => setValorMaxValue(event.target.value)}
|
onChange={(event) =>
|
||||||
|
setValorMaxValue(event.target.value)
|
||||||
|
}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="text-sm border-dashed"
|
className="text-sm border-dashed"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm font-medium">Status</p>
|
<ToggleGroup
|
||||||
<div className="space-y-3">
|
type="single"
|
||||||
<div className="flex items-center justify-between">
|
value={settledFilterValue}
|
||||||
<label
|
onValueChange={(value) => {
|
||||||
htmlFor="filter-pago"
|
if (!value) return;
|
||||||
className="text-sm text-muted-foreground cursor-pointer"
|
|
||||||
>
|
|
||||||
Somente pagos
|
|
||||||
</label>
|
|
||||||
<Switch
|
|
||||||
id="filter-pago"
|
|
||||||
checked={
|
|
||||||
searchParams.get("settled") ===
|
|
||||||
SETTLED_FILTER_VALUES.PAID
|
|
||||||
}
|
|
||||||
disabled={isPending}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
handleFilterChange(
|
handleFilterChange(
|
||||||
"settled",
|
"settled",
|
||||||
checked ? SETTLED_FILTER_VALUES.PAID : null,
|
value === FILTER_EMPTY_VALUE ? null : value,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
variant="outline"
|
||||||
</div>
|
size="sm"
|
||||||
<div className="flex items-center justify-between">
|
className="grid w-full grid-cols-3 rounded-md bg-muted/30 p-0.5"
|
||||||
<label
|
aria-label="Status de pagamento"
|
||||||
htmlFor="filter-nao-pago"
|
|
||||||
className="text-sm text-muted-foreground cursor-pointer"
|
|
||||||
>
|
>
|
||||||
Somente não pagos
|
<ToggleGroupItem
|
||||||
</label>
|
value={FILTER_EMPTY_VALUE}
|
||||||
<Switch
|
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"
|
||||||
id="filter-nao-pago"
|
>
|
||||||
checked={
|
Todos
|
||||||
searchParams.get("settled") ===
|
</ToggleGroupItem>
|
||||||
SETTLED_FILTER_VALUES.UNPAID
|
<ToggleGroupItem
|
||||||
}
|
value={SETTLED_FILTER_VALUES.PAID}
|
||||||
disabled={isPending}
|
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"
|
||||||
onCheckedChange={(checked) => {
|
>
|
||||||
handleFilterChange(
|
Pagos
|
||||||
"settled",
|
</ToggleGroupItem>
|
||||||
checked ? SETTLED_FILTER_VALUES.UNPAID : null,
|
<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"
|
||||||
/>
|
>
|
||||||
</div>
|
Não pagos
|
||||||
</div>
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -824,14 +931,27 @@ export function TransactionsFilters({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DrawerFooter>
|
<DrawerFooter>
|
||||||
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
onClick={handleResetFilters}
|
onClick={handleResetFilters}
|
||||||
disabled={isPending || !hasActiveFilters}
|
disabled={isPending || !hasActiveFilters}
|
||||||
|
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
Limpar filtros
|
Limpar
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</DrawerFooter>
|
</DrawerFooter>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -43,15 +43,34 @@ const loadPdfDeps = async () => {
|
|||||||
return { jsPDF, autoTable };
|
return { jsPDF, autoTable };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatPeriodDate = (dateString: string) =>
|
||||||
|
formatDateOnly(dateString, {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
}) ?? dateString;
|
||||||
|
|
||||||
export function TransactionsExport({
|
export function TransactionsExport({
|
||||||
lancamentos,
|
lancamentos,
|
||||||
period,
|
period,
|
||||||
exportContext,
|
exportContext,
|
||||||
}: TransactionsExportProps) {
|
}: TransactionsExportProps) {
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
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) => {
|
const getFileName = (extension: string) => {
|
||||||
return `lancamentos-${period}.${extension}`;
|
return `lancamentos-${filePeriodSlug}.${extension}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
@@ -251,7 +270,7 @@ export function TransactionsExport({
|
|||||||
doc.text("Lançamentos", titleX, 15);
|
doc.text("Lançamentos", titleX, 15);
|
||||||
|
|
||||||
doc.setFontSize(10);
|
doc.setFontSize(10);
|
||||||
doc.text(`Período: ${displayPeriod(period)}`, titleX, 22);
|
doc.text(`Período: ${periodLabel}`, titleX, 22);
|
||||||
doc.text(
|
doc.text(
|
||||||
`Gerado em: ${
|
`Gerado em: ${
|
||||||
formatDateTime(new Date(), {
|
formatDateTime(new Date(), {
|
||||||
|
|||||||
@@ -33,3 +33,5 @@ export const SETTLED_FILTER_VALUES = {
|
|||||||
|
|
||||||
export const AMOUNT_MIN_PARAM = "valorMin";
|
export const AMOUNT_MIN_PARAM = "valorMin";
|
||||||
export const AMOUNT_MAX_PARAM = "valorMax";
|
export const AMOUNT_MAX_PARAM = "valorMax";
|
||||||
|
export const DATE_START_PARAM = "dataInicio";
|
||||||
|
export const DATE_END_PARAM = "dataFim";
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ type TransactionExportFilters = {
|
|||||||
dividedFilter: string | null;
|
dividedFilter: string | null;
|
||||||
amountMinFilter: number | null;
|
amountMinFilter: number | null;
|
||||||
amountMaxFilter: number | null;
|
amountMaxFilter: number | null;
|
||||||
|
dateStartFilter: string | null;
|
||||||
|
dateEndFilter: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TransactionsExportContext = {
|
export type TransactionsExportContext = {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import type { SelectOption } from "@/features/transactions/components/types";
|
|||||||
import {
|
import {
|
||||||
AMOUNT_MAX_PARAM,
|
AMOUNT_MAX_PARAM,
|
||||||
AMOUNT_MIN_PARAM,
|
AMOUNT_MIN_PARAM,
|
||||||
|
DATE_END_PARAM,
|
||||||
|
DATE_START_PARAM,
|
||||||
PAYMENT_METHODS,
|
PAYMENT_METHODS,
|
||||||
SETTLED_FILTER_VALUES,
|
SETTLED_FILTER_VALUES,
|
||||||
TRANSACTION_CONDITIONS,
|
TRANSACTION_CONDITIONS,
|
||||||
@@ -38,7 +40,7 @@ import {
|
|||||||
PAYER_ROLE_ADMIN,
|
PAYER_ROLE_ADMIN,
|
||||||
PAYER_ROLE_THIRD_PARTY,
|
PAYER_ROLE_THIRD_PARTY,
|
||||||
} from "@/shared/lib/payers/constants";
|
} from "@/shared/lib/payers/constants";
|
||||||
import { toDateOnlyString } from "@/shared/utils/date";
|
import { parseLocalDateString, toDateOnlyString } from "@/shared/utils/date";
|
||||||
import { slugify } from "@/shared/utils/string";
|
import { slugify } from "@/shared/utils/string";
|
||||||
|
|
||||||
type PayerRow = typeof payers.$inferSelect;
|
type PayerRow = typeof payers.$inferSelect;
|
||||||
@@ -66,6 +68,8 @@ export type TransactionSearchFilters = {
|
|||||||
dividedFilter: string | null;
|
dividedFilter: string | null;
|
||||||
amountMinFilter: number | null;
|
amountMinFilter: number | null;
|
||||||
amountMaxFilter: number | null;
|
amountMaxFilter: number | null;
|
||||||
|
dateStartFilter: string | null;
|
||||||
|
dateEndFilter: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BaseSluggedOption = {
|
type BaseSluggedOption = {
|
||||||
@@ -162,6 +166,14 @@ export const parsePositiveAmount = (value: string | null): number | null => {
|
|||||||
return Math.round(normalized * 100) / 100;
|
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 = (
|
export const extractTransactionSearchFilters = (
|
||||||
params: ResolvedSearchParams,
|
params: ResolvedSearchParams,
|
||||||
): TransactionSearchFilters => ({
|
): TransactionSearchFilters => ({
|
||||||
@@ -181,6 +193,10 @@ export const extractTransactionSearchFilters = (
|
|||||||
amountMaxFilter: parsePositiveAmount(
|
amountMaxFilter: parsePositiveAmount(
|
||||||
getSingleParam(params, AMOUNT_MAX_PARAM),
|
getSingleParam(params, AMOUNT_MAX_PARAM),
|
||||||
),
|
),
|
||||||
|
dateStartFilter: parseDateFilterParam(
|
||||||
|
getSingleParam(params, DATE_START_PARAM),
|
||||||
|
),
|
||||||
|
dateEndFilter: parseDateFilterParam(getSingleParam(params, DATE_END_PARAM)),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resolveTransactionPagination = (
|
export const resolveTransactionPagination = (
|
||||||
@@ -377,10 +393,29 @@ export const buildTransactionWhere = ({
|
|||||||
accountId?: string;
|
accountId?: string;
|
||||||
payerId?: string;
|
payerId?: string;
|
||||||
}): SQL[] => {
|
}): SQL[] => {
|
||||||
const where: SQL[] = [
|
const where: SQL[] = [eq(transactions.userId, userId)];
|
||||||
eq(transactions.userId, userId),
|
|
||||||
eq(transactions.period, period),
|
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) {
|
if (payerId) {
|
||||||
where.push(eq(transactions.payerId, payerId));
|
where.push(eq(transactions.payerId, payerId));
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/shared/components/ui/dialog";
|
} from "@/shared/components/ui/dialog";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
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";
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
|
||||||
const DEFAULT_BASE_PATH = "/logos";
|
const DEFAULT_BASE_PATH = "/logos";
|
||||||
@@ -35,7 +39,7 @@ export function LogoPickerTrigger({
|
|||||||
className,
|
className,
|
||||||
}: LogoPickerTriggerProps) {
|
}: LogoPickerTriggerProps) {
|
||||||
const hasLogo = Boolean(selectedLogo);
|
const hasLogo = Boolean(selectedLogo);
|
||||||
const selectedLogoLabel = deriveNameFromLogo(selectedLogo);
|
const selectedLogoLabel = getLogoDisplayName(selectedLogo);
|
||||||
const selectedLogoPath =
|
const selectedLogoPath =
|
||||||
hasLogo && selectedLogo ? resolveLogoSrc(selectedLogo, { basePath }) : null;
|
hasLogo && selectedLogo ? resolveLogoSrc(selectedLogo, { basePath }) : null;
|
||||||
|
|
||||||
@@ -102,8 +106,8 @@ export function LogoPickerDialog({
|
|||||||
|
|
||||||
const filteredLogos = logos.filter((logo) => {
|
const filteredLogos = logos.filter((logo) => {
|
||||||
if (!search.trim()) return true;
|
if (!search.trim()) return true;
|
||||||
const logoLabel = deriveNameFromLogo(logo).toLowerCase();
|
const logoLabel = getLogoDisplayName(logo);
|
||||||
return logoLabel.includes(search.toLowerCase().trim());
|
return normalizeForSearch(logoLabel).includes(normalizeForSearch(search));
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleOpenChange = (isOpen: boolean) => {
|
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">
|
<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) => {
|
{filteredLogos.map((logo) => {
|
||||||
const isActive = value === logo;
|
const isActive = value === logo;
|
||||||
const logoLabel = deriveNameFromLogo(logo);
|
const logoLabel = getLogoDisplayName(logo);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { deriveNameFromLogo } from "@/shared/lib/logo";
|
import { getLogoDisplayName } from "@/shared/lib/logo";
|
||||||
|
|
||||||
interface UseLogoSelectionProps {
|
interface UseLogoSelectionProps {
|
||||||
mode: "create" | "update";
|
mode: "create" | "update";
|
||||||
@@ -37,8 +37,8 @@ export function useLogoSelection({
|
|||||||
}: UseLogoSelectionProps) {
|
}: UseLogoSelectionProps) {
|
||||||
const handleLogoSelection = useCallback(
|
const handleLogoSelection = useCallback(
|
||||||
(newLogo: string) => {
|
(newLogo: string) => {
|
||||||
const derived = deriveNameFromLogo(newLogo);
|
const derived = getLogoDisplayName(newLogo);
|
||||||
const previousDerived = deriveNameFromLogo(currentLogo);
|
const previousDerived = getLogoDisplayName(currentLogo);
|
||||||
|
|
||||||
const shouldUpdateName =
|
const shouldUpdateName =
|
||||||
mode === "create" ||
|
mode === "create" ||
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export interface DatePickerProps {
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
inputClassName?: string;
|
||||||
/** Show compact format like "10 mar" instead of "10 de março de 2025" */
|
/** Show compact format like "10 mar" instead of "10 de março de 2025" */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
@@ -87,6 +88,7 @@ export function DatePicker({
|
|||||||
required = false,
|
required = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
className,
|
className,
|
||||||
|
inputClassName,
|
||||||
compact = false,
|
compact = false,
|
||||||
}: DatePickerProps) {
|
}: DatePickerProps) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
@@ -140,7 +142,7 @@ export function DatePicker({
|
|||||||
id={id}
|
id={id}
|
||||||
value={displayValue}
|
value={displayValue}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="bg-background pr-10"
|
className={cn("bg-background pr-10", inputClassName)}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleInputKeyDown}
|
onKeyDown={handleInputKeyDown}
|
||||||
required={required}
|
required={required}
|
||||||
|
|||||||
433
src/shared/lib/logo/display-names.ts
Normal file
433
src/shared/lib/logo/display-names.ts
Normal 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",
|
||||||
|
};
|
||||||
@@ -1,9 +1,34 @@
|
|||||||
|
import { logoDisplayNames } from "./display-names";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes logo path to get just the filename
|
* Normalizes logo path to get just the filename
|
||||||
*/
|
*/
|
||||||
export const normalizeLogo = (logo?: string | null) =>
|
export const normalizeLogo = (logo?: string | null) =>
|
||||||
logo?.split("/").filter(Boolean).pop() ?? "";
|
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
|
* Derives a display name from a logo filename
|
||||||
* @param logo - Logo path or filename
|
* @param logo - Logo path or filename
|
||||||
|
|||||||
Reference in New Issue
Block a user