Compare commits

..

18 Commits

Author SHA1 Message Date
Felipe Coutinho
bf4138db82 chore: preparar versão 2.7.13 2026-08-09 19:32:12 -03:00
Felipe Coutinho
ed9196797b chore: preparar versão 2.7.12 2026-06-30 16:53:44 -03:00
Felipe Coutinho
532186fe39 fix: corrigir seleção de faturas em popovers 2026-06-30 16:50:23 -03:00
Felipe Coutinho
a2ce7f1283 chore: preparar versão 2.7.11 2026-06-28 19:48:56 -03:00
Felipe Coutinho
f3c3d98aeb feat: agrupar lançamentos por data 2026-06-28 19:48:39 -03:00
Felipe Coutinho
24709ec232 style: compactar checkboxes 2026-06-28 19:48:29 -03:00
Felipe Coutinho
2fd94118f2 fix: corrigir seleção de data nos lançamentos 2026-06-28 19:48:25 -03:00
Felipe Coutinho
01f161f011 docs: documenta fluxo completo de publicacao 2026-06-27 18:24:16 -03:00
Felipe Coutinho
4741087feb chore: prepara versao 2.7.10 2026-06-27 14:24:47 -03:00
Felipe Coutinho
32b190ab4e feat(lancamentos): oculta parcelas antecipadas por preferencia 2026-06-27 14:24:47 -03:00
Felipe Coutinho
d06bac5624 chore(deps): atualiza dependencias 2026-06-27 14:24:47 -03:00
Felipe Coutinho
be6fa6dcfc chore: prepara versao 2.7.9 2026-06-21 12:20:12 -03:00
Felipe Coutinho
954fdc148e docs: versiona instrucoes dos agentes 2026-06-21 12:20:01 -03:00
Felipe Coutinho
fb1759c2ee ci: publica releases somente por tags semver 2026-06-21 12:19:53 -03:00
Felipe Coutinho
b1b2f5fe0d chore: prepara versão 2.7.8 2026-06-21 11:54:27 -03:00
Felipe Coutinho
4d62abfc6b feat(dashboard): vincula tendências às categorias 2026-06-21 11:54:19 -03:00
Felipe Coutinho
1660f68a4b feat(anexos): adiciona filtro por pessoa 2026-06-21 11:54:15 -03:00
Felipe Coutinho
d363662548 feat(anotações): permite anexos em notas 2026-06-21 11:54:05 -03:00
84 changed files with 16648 additions and 1912 deletions

57
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
quality:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
- name: Cache Next.js build
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx', 'src/**/*.css', 'next.config.ts') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate Next.js types
run: pnpm exec next typegen
- name: Typecheck
run: pnpm exec tsc --noEmit
- name: Lint
run: pnpm run lint
- name: Build application
run: pnpm run build

View File

@@ -1,87 +0,0 @@
name: Build and Push to Docker Hub
on:
push:
branches:
- main
tags:
- 'v*.*.*'
pull_request:
branches:
- main
workflow_dispatch:
env:
DOCKER_IMAGE_NAME: openmonetis
jobs:
quality:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm run lint
build-and-push:
runs-on: ubuntu-latest
needs: quality
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USERNAME }}/${{ env.DOCKER_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix={{branch}}-
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
- name: Image digest
run: echo ${{ steps.meta.outputs.digest }}

View File

@@ -2,58 +2,151 @@ name: Release
on:
push:
branches:
- main
tags:
- "v*.*.*"
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
DOCKER_IMAGE_NAME: openmonetis
permissions:
contents: read
jobs:
release:
quality:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Validate release version
shell: bash
run: |
TAG_VERSION="${GITHUB_REF_NAME#v}"
PACKAGE_VERSION="$(jq -r '.version' package.json)"
if [[ ! "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "A tag $GITHUB_REF_NAME não segue o formato vX.Y.Z."
exit 1
fi
if [[ "$TAG_VERSION" != "$PACKAGE_VERSION" ]]; then
echo "A tag $GITHUB_REF_NAME não corresponde à versão $PACKAGE_VERSION do package.json."
exit 1
fi
if ! grep -Fq "## [$TAG_VERSION]" CHANGELOG.md; then
echo "A versão $TAG_VERSION não foi encontrada no CHANGELOG.md."
exit 1
fi
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate Next.js types
run: pnpm exec next typegen
- name: Typecheck
run: pnpm exec tsc --noEmit
- name: Lint
run: pnpm run lint
docker:
runs-on: ubuntu-latest
needs: quality
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USERNAME }}/${{ env.DOCKER_IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest
- name: Build and push Docker image
id: build
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
- name: Image digest
run: echo "${{ steps.build.outputs.digest }}"
github-release:
runs-on: ubuntu-latest
needs: docker
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Read version from package.json
id: version
run: |
VERSION=$(jq -r '.version' package.json)
echo "value=$VERSION" >> $GITHUB_OUTPUT
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
- name: Check if tag already exists
id: tag_check
run: |
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Extract changelog for this version
if: steps.tag_check.outputs.exists == 'false'
id: changelog
shell: bash
run: |
VERSION="${{ steps.version.outputs.value }}"
# Extrai o bloco entre ## [X.Y.Z] e o próximo ## [
NOTES=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
# Remove linhas em branco do início e fim
NOTES=$(echo "$NOTES" | sed '/./,$!d' | sed -e :a -e '/^\n*$/{$d;N;ba}')
VERSION="${GITHUB_REF_NAME#v}"
NOTES=$(awk -v version="$VERSION" '
index($0, "## [" version "]") == 1 { found=1; next }
found && /^## \[/ { exit }
found && !started && /^[[:space:]]*$/ { next }
found { started=1; print }
' CHANGELOG.md)
if [[ -z "$NOTES" ]]; then
echo "Não foi possível extrair as notas da versão $VERSION."
exit 1
fi
{
echo "notes<<EOF"
echo "$NOTES"
echo "EOF"
} >> $GITHUB_OUTPUT
} >> "$GITHUB_OUTPUT"
- name: Create tag and GitHub Release
if: steps.tag_check.outputs.exists == 'false'
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: ${{ steps.version.outputs.tag }}
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
body: ${{ steps.changelog.outputs.notes }}
draft: false
prerelease: false

1
.gitignore vendored
View File

@@ -105,7 +105,6 @@ docker-compose.override.yml
.gemini/
.cursor/
QWEN.md
AGENTS.md
.codex
# === Backups locais ===
/backup/

View File

@@ -1,4 +1,4 @@
# CLAUDE.md - OpenMonetis
# AGENTS.md - OpenMonetis
> Self-hosted personal finance app (Next.js 16, React 19, PostgreSQL, Drizzle ORM, Better Auth, Tailwind 4, shadcn/ui).
> Portuguese UI, English folders/imports. Linter: Biome 2.x. Package manager: pnpm.
@@ -16,7 +16,7 @@
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`.
4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`.
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também altere o `package.json` e `readme.md` (Badges do README.md). Cada versão deve ter um parágrafo introdutório em linguagem humana logo abaixo do cabeçalho `## [x.y.z]`, antes das seções `### Adicionado/Alterado/Removido` — descrevendo em prosa o que a versão representa (ex: "Esta versão foca em polimento visual e reorganização interna...").
6. **Versionamento e publicação**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também alterar o `package.json` e o badge de versão do `README.md`. Cada versão deve ter um parágrafo introdutório em linguagem humana logo abaixo do cabeçalho `## [x.y.z]`, antes das seções `### Adicionado/Alterado/Removido` — descrevendo em prosa o que a versão representa (ex: "Esta versão foca em polimento visual e reorganização interna..."). A `main` executa somente a CI; imagens Docker e GitHub Releases são publicadas exclusivamente por tags SemVer no formato `vX.Y.Z`. Antes de criar a tag, confirmar que a CI da `main` passou e que tag, `package.json`, `CHANGELOG.md` e badge do `README.md` usam a mesma versão. A tag deve apontar para o commit validado. Criar ou enviar uma tag dispara publicação externa (`X.Y.Z`, `X.Y`, `X` e `latest` no Docker Hub, seguida da GitHub Release), portanto agentes nunca devem criar ou fazer push de tags sem autorização explícita do usuário. Quando o usuário pedir **"commit e push"** em uma mudança que prepara uma nova versão, isso conta como autorização explícita para executar o fluxo completo: commitar, enviar a `main`, aguardar a CI da `main` passar, criar a tag SemVer correspondente à versão preparada e enviar essa tag. Se não houver versão preparada ou se houver divergência entre `package.json`, `CHANGELOG.md`, badge do `README.md` e tag pretendida, parar e pedir confirmação. Não voltar a publicar `latest` diretamente de pushes na `main`.
7. **Comunicacao**: responder em portugues clara e direta com o time.
8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema.
9. **README.md**: sempre que fizer alteracoes significativas, atualize o README.md.
@@ -364,3 +364,13 @@ Erros nao devem expor stack traces, paths ou nomes de bibliotecas ao cliente. Us
Verificar pacotes novos sugeridos pela IA em npmjs.com antes de instalar. Red flags: menos de 1.000 downloads/semana, publicado nos ultimos 30 dias, nome muito parecido com pacote popular. Rodar `pnpm audit` periodicamente.
---
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->

View File

@@ -5,6 +5,84 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
## [2.7.13] - 2026-08-09
Esta versão atualiza a base técnica do OpenMonetis com as correções de segurança e desempenho do Next.js 16.3, acelera as verificações de tipos, melhora a recuperação de falhas nas áreas mais complexas do aplicativo e corrige a importação de extratos OFX que reutilizam identificadores bancários.
### Adicionado
- Interface: dashboard, Insights, anexos e relatórios agora possuem limites de erro próprios, preservando a navegação e oferecendo uma nova tentativa sem derrubar toda a área autenticada.
- Desenvolvimento: agentes de código passam a consultar a documentação do Next.js correspondente à versão instalada no projeto.
### Alterado
- ATENÇÃO — mudança no banco de dados: esta versão adiciona a coluna ofx_import_fingerprint e substitui o índice único baseado apenas no FITID por um índice de fingerprint. Aplique a migração 0034_superb_blonde_phantom.sql antes de iniciar a aplicação atualizada (pnpm run db:migrate em instalações manuais). A imagem Docker tenta aplicar as migrações automaticamente durante a inicialização.
- Banco de dados: a deduplicação de importações OFX agora considera os dados completos e a ocorrência de cada transação, em vez de depender somente do FITID fornecido pela instituição financeira.
- Dependências: Next.js atualizado para 16.3.0 e TypeScript para 7.0.2.
- Desenvolvimento: Turbopack e seus caches passam a usar os padrões nativos do Next.js 16.3, removendo flags redundantes da configuração.
- CI: o cache de build em `.next/cache` agora é preservado entre execuções para acelerar compilações sucessivas.
### Corrigido
- Importação: transações OFX legítimas que compartilham o mesmo FITID agora permanecem independentes na revisão e podem ser importadas sem sobrescrever linhas, travar seletores ou serem descartadas como duplicatas (issue #88 - @cunhanai).
- Interface: a identidade estável de cada linha evita duplicações visuais e mantém funcionando a seleção, a escolha de categoria e a rolagem da tabela de revisão.
- Segurança: incorporadas as correções do Next.js 16.3 para Server Actions, Proxy, cache de respostas e otimização de imagens.
- Segurança: o otimizador de imagens agora aceita somente avatares do Google e logos do Logo.dev, removendo os curingas globais de HTTP e HTTPS; anexos do S3 continuam servidos diretamente, sem passar pelo otimizador.
## [2.7.12] - 2026-06-30
Esta versão corrige a seleção de faturas e períodos em popovers usados dentro de diálogos, alinhando esses componentes ao mesmo comportamento seguro aplicado recentemente aos seletores de data.
### Corrigido
- Lançamentos: o seletor inline de fatura volta a aceitar cliques no mês em diálogos de criação/edição e no modal de múltiplos lançamentos.
- Períodos: botões internos do seletor mensal agora são explicitamente `type="button"`, evitando submits acidentais quando o componente aparece dentro de formulários.
- Interface: popovers de período e escolha de logo de estabelecimento passam a usar modo modal quando podem aparecer sobre diálogos, preservando foco e clique.
## [2.7.11] - 2026-06-28
Esta atualização melhora a leitura diária dos lançamentos e deixa a nova visualização opcional, mantendo a possibilidade de voltar ao formato anterior quando a lista agrupada não for a melhor escolha para o usuário.
### Adicionado
- Preferências: nova opção `Agrupar por data` em Ajustes > Preferências > Lançamentos para alternar entre a lista agrupada por data e a visualização anterior.
- Lançamentos: a lista agora pode exibir uma barra de data por grupo no formato `TER, 26 JUN 2026`, reunindo os lançamentos daquele dia.
### Alterado
- Lançamentos: quando o agrupamento por data está ativo, os cards e linhas deixam de repetir a data em cada item, reduzindo ruído visual e mantendo vencimentos de boleto como informação do lançamento.
- Lançamentos: a preferência de agrupamento por data é aplicada nas listagens principais, extratos de conta, faturas de cartão, detalhes de pessoa e detalhes de categoria.
- Interface: checkboxes passam a usar um visual mais compacto.
- Documentação: o README agora cita o agrupamento por data entre as opções de personalização.
### Corrigido
- Lançamentos: o seletor de data em modais de criação e edição volta a aceitar a data selecionada no calendário.
## [2.7.10] - 2026-06-27
Esta versão ajusta a experiência de leitura dos lançamentos parcelados após antecipações, permitindo esconder parcelas já liquidadas por antecipação sem perder o histórico quando ele ainda for necessário.
### Adicionado
- Ajustes: nova preferência `Ocultar parcelas antecipadas` para remover da tabela lançamentos marcados como parcela antecipada.
### Alterado
- Lançamentos: a preferência passa a ser aplicada nas listagens principais, extratos de conta, faturas de cartão, detalhes de pessoa, detalhes de categoria e exportação de lançamentos, preservando paginação e contagens visíveis.
## [2.7.9] - 2026-06-21
Esta versão torna a publicação mais previsível ao separar a validação contínua da entrega de versões oficiais. Pull requests e a branch principal continuam sendo verificadas, enquanto imagens Docker e releases passam a ser produzidas somente a partir de uma tag SemVer validada.
### Alterado
- CI: pull requests e pushes na `main` agora executam geração de tipos, verificação TypeScript, lint e build sem publicar imagens.
- Releases: tags `vX.Y.Z` agora validam sua correspondência com o `package.json` e o `CHANGELOG.md` antes de publicar as imagens Docker e criar a GitHub Release.
- Docker: as tags versionadas e `latest` passam a ser publicadas exclusivamente por releases oficiais, depois das verificações de qualidade.
## [2.7.8] - 2026-06-21
Esta versão deixa documentos e comprovantes mais fáceis de guardar e encontrar sem tirar o foco da rotina financeira. Agora é possível manter arquivos junto às notas, consultar a galeria por pessoa com identificação visual e abrir uma categoria diretamente das tendências do dashboard, sempre preservando o contexto do período selecionado.
### Adicionado
- Anotações: itens do tipo `Nota` agora aceitam anexos PDF, JPEG, PNG e WebP na criação e na edição, com consulta e download nos detalhes e respeito ao limite configurado pelo usuário.
- Anexos: a galeria agora oferece filtro por pessoa, incluindo a pessoa principal, pessoas específicas e uma visão consolidada de todas as pessoas.
### Alterado
- Anexos: os cards da galeria agora identificam a pessoa vinculada ao lançamento e o filtro exibe os respectivos avatares, preservando o contexto quando vários responsáveis são exibidos.
- Dashboard: os nomes no widget `Tendências de categorias` agora levam aos detalhes da categoria mantendo o período selecionado.
## [2.7.7] - 2026-06-20
Esta versão faz ajustes pontuais de leitura nos resumos financeiros e no dashboard, reforçando a identidade visual de cartões e contas e deixando as listas dos widgets mais consistentes sem alterar a estrutura de navegação das páginas.

View File

@@ -10,7 +10,7 @@
> **Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
[![Version](https://img.shields.io/badge/version-2.7.7-blue?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-2.7.13-blue?style=flat-square)](CHANGELOG.md)
[![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/)
@@ -65,7 +65,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
### Funcionalidades
💰 **Contas e transações** — Contas bancárias, cartões, dinheiro. Receitas, despesas, rendimentos e transferências. Categorização, divisão de lançamentos entre várias pessoas, filtros combináveis com intervalo de datas, extratos detalhados com identificação visual clara da conta 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, rendimentos e transferências. Categorização, divisão de lançamentos entre várias pessoas, filtros combináveis com intervalo de datas, extratos detalhados com identificação visual clara da conta e importação de extratos OFX e XLS/XLSX com detecção automática de categoria e deduplicação resiliente a identificadores bancários repetidos.
📊 **Dashboard e relatórios** — Widgets personalizáveis com listas consistentes, métricas com atalhos para lançamentos, gráficos de evolução, comparativos por categoria, tendências, uso de cartões, top estabelecimentos e navegação direta entre meses pelo seletor de período. Exportação em PDF e Excel.
@@ -79,7 +79,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
👥 **Gestão colaborativa** — Pagadores com permissões (admin/viewer), notificações automáticas por e-mail, códigos de compartilhamento.
📝 **Anotações e tarefas** — Notas de texto, listas com checkboxes, sistema de arquivamento.
📝 **Anotações e tarefas** — Notas de texto com anexos, listas com checkboxes e sistema de arquivamento.
📅 **Calendário financeiro** — Visualize todos os lançamentos em um calendário mensal.
@@ -89,7 +89,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
<img src="./public/images/companion-preview-light.webp" alt="OpenMonetis Companion" width="300" height="600" />
</p>
⚙️ **Personalização** — Tema dark/light, modo privacidade, ordem das colunas, exibição de anotações, tamanho máximo de anexos, resumo opcional no modal de lançamento e changelog visual para acompanhar as novidades do app.
⚙️ **Personalização** — Tema dark/light, modo privacidade, ordem das colunas, agrupamento por data em lançamentos, exibição de anotações, tamanho máximo de anexos, resumo opcional no modal de lançamento e changelog visual para acompanhar as novidades do app.
### Stack técnica
@@ -623,6 +623,17 @@ A regra é: `actions.ts` e `queries.ts` são as portas de entrada da feature. Tu
Antes de começar, leia o [`CLAUDE.md`](CLAUDE.md) — ele documenta a arquitetura, convenções de nomenclatura, regras de queries e o checklist para novas features. Use TypeScript, commits semânticos e mantenha o `CHANGELOG.md` atualizado.
### Publicando uma versão
As validações rodam em pull requests e em cada push na `main`. A publicação só começa quando uma tag SemVer aponta para um commit validado e a versão da tag corresponde ao `package.json` e ao `CHANGELOG.md`.
```bash
git tag -a v2.7.13 -m "v2.7.13"
git push origin v2.7.13
```
O workflow da tag valida o código, publica as imagens Docker versionadas e `latest` e, somente depois, cria a GitHub Release com as notas do changelog.
---
## 💖 Apoie o Projeto

View File

@@ -0,0 +1,9 @@
CREATE TABLE "anotacao_anexos" (
"anotacao_id" uuid NOT NULL,
"anexo_id" uuid NOT NULL,
CONSTRAINT "anotacao_anexos_anotacao_id_anexo_id_pk" PRIMARY KEY("anotacao_id","anexo_id")
);
--> statement-breakpoint
ALTER TABLE "anotacao_anexos" ADD CONSTRAINT "anotacao_anexos_anotacao_id_anotacoes_id_fk" FOREIGN KEY ("anotacao_id") REFERENCES "public"."anotacoes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "anotacao_anexos" ADD CONSTRAINT "anotacao_anexos_anexo_id_anexos_id_fk" FOREIGN KEY ("anexo_id") REFERENCES "public"."anexos"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "anotacao_anexos_anexo_id_idx" ON "anotacao_anexos" USING btree ("anexo_id");

View File

@@ -0,0 +1 @@
ALTER TABLE "preferencias_usuario" ADD COLUMN "ocultar_parcelas_antecipadas" boolean DEFAULT false NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "preferencias_usuario" ADD COLUMN "agrupar_lancamentos_por_data" boolean DEFAULT true NOT NULL;

View File

@@ -0,0 +1,3 @@
DROP INDEX "lancamentos_ofx_fit_id_user_id_idx";--> statement-breakpoint
ALTER TABLE "lancamentos" ADD COLUMN "ofx_import_fingerprint" text;--> statement-breakpoint
CREATE UNIQUE INDEX "lancamentos_ofx_import_fingerprint_user_id_idx" ON "lancamentos" USING btree ("user_id","ofx_import_fingerprint") WHERE ofx_import_fingerprint IS NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -211,6 +211,34 @@
"when": 1780150535055,
"tag": "0030_complete_umar",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1782051007412,
"tag": "0031_lame_cerise",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1782569103402,
"tag": "0032_bumpy_spencer_smythe",
"breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1782685465530,
"tag": "0033_demonic_supreme_intelligence",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1786299746933,
"tag": "0034_superb_blonde_phantom",
"breakpoints": true
}
]
}

View File

@@ -4,23 +4,34 @@ import type { NextConfig } from "next";
// Carregar variáveis de ambiente explicitamente
dotenv.config();
type RemotePattern = NonNullable<
NonNullable<NextConfig["images"]>["remotePatterns"]
>[number];
const imageRemotePatterns: RemotePattern[] = [
{
protocol: "https",
hostname: "lh3.googleusercontent.com",
pathname: "/**",
},
{
protocol: "https",
hostname: "img.logo.dev",
pathname: "/**",
},
];
const nextConfig: NextConfig = {
output: "standalone",
cacheComponents: true,
reactCompiler: true,
images: {
remotePatterns: [
new URL("https://lh3.googleusercontent.com/**"),
{ protocol: "https", hostname: "**" },
{ protocol: "http", hostname: "**" },
],
remotePatterns: imageRemotePatterns,
},
devIndicators: {
position: "bottom-right",
},
experimental: {
prefetchInlining: true,
turbopackFileSystemCacheForDev: true,
optimizePackageImports: ["@remixicon/react"],
},

View File

@@ -1,10 +1,10 @@
{
"name": "openmonetis",
"version": "2.7.7",
"version": "2.7.13",
"private": true,
"packageManager": "pnpm@11.1.3",
"scripts": {
"dev": "next dev --turbopack",
"dev": "next dev",
"db:seed": "tsx scripts/mock-data.ts",
"build": "next build",
"start": "next start",
@@ -31,32 +31,32 @@
"mockup": "tsx scripts/mock-data.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.81",
"@ai-sdk/google": "^3.0.80",
"@ai-sdk/openai": "^3.0.67",
"@ai-sdk/openai-compatible": "^2.0.48",
"@aws-sdk/client-s3": "^3.1059.0",
"@aws-sdk/s3-request-presigner": "^3.1059.0",
"@better-auth/passkey": "^1.6.14",
"@ai-sdk/anthropic": "^3.0.88",
"@ai-sdk/google": "^3.0.85",
"@ai-sdk/openai": "^3.0.76",
"@ai-sdk/openai-compatible": "^2.0.53",
"@aws-sdk/client-s3": "^3.1075.0",
"@aws-sdk/s3-request-presigner": "^3.1075.0",
"@better-auth/passkey": "^1.6.22",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@openrouter/ai-sdk-provider": "^2.9.0",
"@openrouter/ai-sdk-provider": "^2.10.0",
"@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-hover-card": "^1.1.17",
"@radix-ui/react-label": "2.1.8",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-navigation-menu": "^1.2.16",
"@radix-ui/react-popover": "^1.1.17",
"@radix-ui/react-progress": "1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-radio-group": "^1.4.1",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-separator": "1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slider": "^1.4.1",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13",
@@ -64,11 +64,11 @@
"@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8",
"@remixicon/react": "4.9.0",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query": "^5.101.1",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "^3.14.2",
"ai": "^6.0.195",
"better-auth": "1.6.14",
"@tanstack/react-virtual": "^3.14.4",
"ai": "^6.0.213",
"better-auth": "1.6.22",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
@@ -78,7 +78,7 @@
"exceljs": "^4.4.0",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"next": "16.2.7",
"next": "16.3.0",
"next-themes": "0.4.6",
"pdfjs-dist": "^6.0.227",
"pg": "8.21.0",
@@ -86,7 +86,7 @@
"react-day-picker": "^10.0.1",
"react-dom": "19.2.7",
"recharts": "3.8.1",
"resend": "^6.12.4",
"resend": "^6.16.0",
"sonner": "2.0.7",
"tailwind-merge": "3.6.0",
"tw-animate-css": "^1.4.0",
@@ -105,9 +105,9 @@
"babel-plugin-react-compiler": "^1.0.0",
"dotenv": "^17.4.2",
"drizzle-kit": "0.31.10",
"knip": "^6.15.0",
"knip": "^6.22.0",
"tailwindcss": "4.3.0",
"tsx": "4.22.4",
"typescript": "6.0.3"
"typescript": "7.0.2"
}
}

3358
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -109,6 +109,8 @@ export default async function Page({ params, searchParams }: PageProps) {
filters: searchFilters,
slugMaps,
accountId: account.id,
hideAnticipatedInstallments:
userPreferences?.hideAnticipatedInstallments ?? false,
});
const transactionsPage = await fetchAccountTransactionsPage(
@@ -233,6 +235,9 @@ export default async function Page({ params, searchParams }: PageProps) {
)}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
groupTransactionsByDate={
userPreferences?.groupTransactionsByDate ?? true
}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/>
</section>

View File

@@ -1,6 +1,7 @@
import { connection } from "next/server";
import { AttachmentsPage } from "@/features/attachments/components/attachments-page";
import { fetchAttachmentsForPeriod } from "@/features/attachments/queries";
import { fetchAttachmentsPageData } from "@/features/attachments/queries";
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
import { getUserId } from "@/shared/lib/auth/server";
import { parsePeriodParam } from "@/shared/utils/period";
@@ -19,18 +20,32 @@ const getSingleParam = (
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function Page({ searchParams }: PageProps) {
export default function Page({ searchParams }: PageProps) {
return (
<ContentErrorBoundary
title="Não foi possível carregar os anexos"
description="Os documentos e comprovantes não puderam ser carregados agora."
>
<AttachmentsContent searchParams={searchParams} />
</ContentErrorBoundary>
);
}
async function AttachmentsContent({ searchParams }: PageProps) {
await connection();
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period } = parsePeriodParam(periodoParam);
const attachments = await fetchAttachmentsForPeriod(userId, period);
const data = await fetchAttachmentsPageData(userId, period);
return (
<main className="flex flex-col gap-6">
<AttachmentsPage attachments={attachments} />
<AttachmentsPage
attachments={data?.attachments ?? []}
adminPayerId={data?.adminPayerId ?? ""}
/>
</main>
);
}

View File

@@ -82,6 +82,8 @@ export default async function Page({ params, searchParams }: PageProps) {
filters: searchFilters,
slugMaps,
cardId: card.id,
hideAnticipatedInstallments:
userPreferences?.hideAnticipatedInstallments ?? false,
});
const transactionRows = await fetchCardTransactions(filters);
@@ -210,6 +212,9 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
groupTransactionsByDate={
userPreferences?.groupTransactionsByDate ?? true
}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
defaultCardId={card.id}
defaultPaymentMethod="Cartão de crédito"

View File

@@ -41,13 +41,17 @@ export default async function Page({ params, searchParams }: PageProps) {
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const [detail, filterSources, estabelecimentos, userPreferences] =
await Promise.all([
fetchCategoryDetails(userId, categoryId, selectedPeriod),
fetchTransactionFilterSources(userId),
fetchRecentEstablishments(userId),
fetchUserPreferences(userId),
]);
const [filterSources, estabelecimentos, userPreferences] = await Promise.all([
fetchTransactionFilterSources(userId),
fetchRecentEstablishments(userId),
fetchUserPreferences(userId),
]);
const detail = await fetchCategoryDetails(
userId,
categoryId,
selectedPeriod,
userPreferences?.hideAnticipatedInstallments ?? false,
);
if (!detail) {
notFound();
@@ -101,6 +105,9 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate={true}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
groupTransactionsByDate={
userPreferences?.groupTransactionsByDate ?? true
}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/>
</main>

View File

@@ -6,6 +6,7 @@ import { extractDashboardLogoNames } from "@/features/dashboard/lib/extract-logo
import { fetchDashboardPageData } from "@/features/dashboard/page-data-queries";
import { getSingleParam } from "@/features/transactions/lib/page-helpers";
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { getUser } from "@/shared/lib/auth/server";
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
@@ -17,7 +18,18 @@ type PageProps = {
searchParams?: PageSearchParams;
};
export default async function Page({ searchParams }: PageProps) {
export default function Page({ searchParams }: PageProps) {
return (
<ContentErrorBoundary
title="Não foi possível carregar o dashboard"
description="Seus dados financeiros não puderam ser carregados agora."
>
<DashboardContent searchParams={searchParams} />
</ContentErrorBoundary>
);
}
async function DashboardContent({ searchParams }: PageProps) {
await connection();
const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
@@ -41,19 +53,29 @@ export default async function Page({ searchParams }: PageProps) {
<main className="flex flex-col gap-4">
<DashboardWelcome name={user.name} />
<MonthNavigation />
<DashboardMetricsCards
metrics={dashboardData.metrics}
period={selectedPeriod}
adminPayerSlug={adminPayerSlug}
/>
<LogoPrefetchProvider mappings={logoMappings}>
<DashboardGridEditable
data={dashboardData}
<ContentErrorBoundary
title="Não foi possível exibir o resumo"
description="Os indicadores do período não puderam ser exibidos agora."
>
<DashboardMetricsCards
metrics={dashboardData.metrics}
period={selectedPeriod}
initialPreferences={dashboardWidgets}
quickActionOptions={quickActionOptions}
adminPayerSlug={adminPayerSlug}
/>
</LogoPrefetchProvider>
</ContentErrorBoundary>
<ContentErrorBoundary
title="Não foi possível exibir os widgets"
description="Os detalhes do dashboard não puderam ser exibidos agora."
>
<LogoPrefetchProvider mappings={logoMappings}>
<DashboardGridEditable
data={dashboardData}
period={selectedPeriod}
initialPreferences={dashboardWidgets}
quickActionOptions={quickActionOptions}
/>
</LogoPrefetchProvider>
</ContentErrorBoundary>
</main>
);
}

View File

@@ -1,5 +1,6 @@
import { connection } from "next/server";
import { InsightsPage } from "@/features/insights/components/insights-page";
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { parsePeriodParam } from "@/shared/utils/period";
@@ -18,7 +19,18 @@ const getSingleParam = (
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function Page({ searchParams }: PageProps) {
export default function Page({ searchParams }: PageProps) {
return (
<ContentErrorBoundary
title="Não foi possível carregar os Insights"
description="As análises financeiras não puderam ser carregadas agora."
>
<InsightsContent searchParams={searchParams} />
</ContentErrorBoundary>
);
}
async function InsightsContent({ searchParams }: PageProps) {
await connection();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");

View File

@@ -1,16 +1,24 @@
import { connection } from "next/server";
import { NotesPage } from "@/features/notes/components/notes-page";
import { fetchAllNotesForUser } from "@/features/notes/queries";
import { fetchUserPreferences } from "@/features/settings/queries";
import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() {
await connection();
const userId = await getUserId();
const { activeNotes, archivedNotes } = await fetchAllNotesForUser(userId);
const [{ activeNotes, archivedNotes }, preferences] = await Promise.all([
fetchAllNotesForUser(userId),
fetchUserPreferences(userId),
]);
return (
<main className="flex flex-col gap-6">
<NotesPage notes={activeNotes} archivedNotes={archivedNotes} />
<NotesPage
notes={activeNotes}
archivedNotes={archivedNotes}
attachmentMaxSizeMb={preferences?.attachmentMaxSizeMb ?? 50}
/>
</main>
);
}

View File

@@ -131,6 +131,7 @@ export default async function Page({ params, searchParams }: PageProps) {
...EMPTY_FILTERS,
searchFilter: allSearchFilters.searchFilter, // Permitir busca mesmo em modo read-only
};
const userPreferences = await fetchUserPreferences(userId);
let filterSources: Awaited<
ReturnType<typeof fetchTransactionFilterSources>
@@ -163,6 +164,8 @@ export default async function Page({ params, searchParams }: PageProps) {
filters: searchFilters,
slugMaps,
payerId: pagador.id,
hideAnticipatedInstallments:
userPreferences?.hideAnticipatedInstallments ?? false,
});
const sharesPromise = canEdit
@@ -184,7 +187,6 @@ export default async function Page({ params, searchParams }: PageProps) {
shareRows,
currentUserShare,
estabelecimentos,
userPreferences,
] = await Promise.all([
fetchPayerTransactions(filters),
fetchPayerMonthlyBreakdown({
@@ -220,7 +222,6 @@ export default async function Page({ params, searchParams }: PageProps) {
sharesPromise,
currentUserSharePromise,
fetchRecentEstablishments(userId),
fetchUserPreferences(userId),
]);
const mappedTransactions = mapTransactionsData(transactionRows);
@@ -407,6 +408,9 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate={canEdit}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
groupTransactionsByDate={
userPreferences?.groupTransactionsByDate ?? true
}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
importPayerOptions={loggedUserOptionSets?.payerOptions}
importSplitPayerOptions={

View File

@@ -6,6 +6,7 @@ import { CardTopExpenses } from "@/features/reports/components/cards/card-top-ex
import { CardUsageChart } from "@/features/reports/components/cards/card-usage-chart";
import { CardsOverview } from "@/features/reports/components/cards/cards-overview";
import { fetchCartoesReportData } from "@/features/reports/lib/cards-report-queries";
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { Card } from "@/shared/components/ui/card";
import { getUser } from "@/shared/lib/auth/server";
@@ -26,9 +27,18 @@ const getSingleParam = (
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function RelatorioCartoesPage({
searchParams,
}: PageProps) {
export default function CardUsagePage({ searchParams }: PageProps) {
return (
<ContentErrorBoundary
title="Não foi possível carregar o uso dos cartões"
description="Os dados deste relatório não puderam ser carregados agora."
>
<CardUsageContent searchParams={searchParams} />
</ContentErrorBoundary>
);
}
async function CardUsageContent({ searchParams }: PageProps) {
await connection();
const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined;

View File

@@ -10,6 +10,7 @@ import { fetchCategoryChartData } from "@/features/reports/lib/category-chart-qu
import { fetchCategoryReport } from "@/features/reports/lib/category-report-queries";
import { fetchUserCategories } from "@/features/reports/lib/category-trends-queries";
import { validateDateRange } from "@/features/reports/lib/utils";
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
import { getUserId } from "@/shared/lib/auth/server";
import type { CategoryReportFilters } from "@/shared/lib/types/reports";
import { addMonthsToPeriod, getCurrentPeriod } from "@/shared/utils/period";
@@ -29,7 +30,18 @@ const getSingleParam = (
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function Page({ searchParams }: PageProps) {
export default function Page({ searchParams }: PageProps) {
return (
<ContentErrorBoundary
title="Não foi possível carregar as tendências"
description="A evolução das categorias não pôde ser carregada agora."
>
<CategoryTrendsContent searchParams={searchParams} />
</ContentErrorBoundary>
);
}
async function CategoryTrendsContent({ searchParams }: PageProps) {
await connection();
// Get authenticated user
const userId = await getUserId();

View File

@@ -8,6 +8,7 @@ import {
fetchTopEstablishmentsData,
type PeriodFilter,
} from "@/features/reports/establishments/queries";
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
import { Card } from "@/shared/components/ui/card";
import { getUser } from "@/shared/lib/auth/server";
import { parsePeriodParam } from "@/shared/utils/period";
@@ -34,9 +35,18 @@ const validatePeriodFilter = (value: string | null): PeriodFilter => {
return "6";
};
export default async function TopEstablishmentsPage({
searchParams,
}: PageProps) {
export default function EstablishmentsPage({ searchParams }: PageProps) {
return (
<ContentErrorBoundary
title="Não foi possível carregar os estabelecimentos"
description="Os dados deste relatório não puderam ser carregados agora."
>
<EstablishmentsContent searchParams={searchParams} />
</ContentErrorBoundary>
);
}
async function EstablishmentsContent({ searchParams }: PageProps) {
await connection();
const user = await getUser();
const resolvedSearchParams = searchParams ? await searchParams : undefined;

View File

@@ -2,10 +2,22 @@ import { connection } from "next/server";
import { InstallmentAnalysisPage } from "@/features/dashboard/components/installment-analysis/installment-analysis-page";
import { fetchInstallmentAnalysis } from "@/features/dashboard/expenses/installment-analysis-queries";
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
import { ContentErrorBoundary } from "@/shared/components/feedback/content-error-boundary";
import { getUser } from "@/shared/lib/auth/server";
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
export default async function Page() {
export default function Page() {
return (
<ContentErrorBoundary
title="Não foi possível carregar a análise de parcelas"
description="Os parcelamentos não puderam ser analisados agora."
>
<InstallmentAnalysisContent />
</ContentErrorBoundary>
);
}
async function InstallmentAnalysisContent() {
await connection();
const user = await getUser();
const data = await fetchInstallmentAnalysis(user.id);

View File

@@ -85,6 +85,12 @@ export default async function Page() {
showTransactionSummary={
userPreferences?.showTransactionSummary ?? true
}
groupTransactionsByDate={
userPreferences?.groupTransactionsByDate ?? true
}
hideAnticipatedInstallments={
userPreferences?.hideAnticipatedInstallments ?? false
}
/>
</div>
</Card>

View File

@@ -53,6 +53,8 @@ export default async function Page({ searchParams }: PageProps) {
period: selectedPeriod,
filters: searchFilters,
slugMaps,
hideAnticipatedInstallments:
userPreferences?.hideAnticipatedInstallments ?? false,
});
const [transactionsPage, estabelecimentos] = await Promise.all([
@@ -112,6 +114,9 @@ export default async function Page({ searchParams }: PageProps) {
}}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
groupTransactionsByDate={
userPreferences?.groupTransactionsByDate ?? true
}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/>
</LogoPrefetchProvider>

View File

@@ -4,11 +4,10 @@ import {
RiShieldCheckLine,
RiSmartphoneLine,
} from "@remixicon/react";
import { headers } from "next/headers";
import Image from "next/image";
import Link from "next/link";
import { AnimateOnScroll } from "@/features/landing/components/animate-on-scroll";
import { MobileNav } from "@/features/landing/components/mobile-nav";
import { LandingNavbar } from "@/features/landing/components/landing-navbar";
import { SetupTabs } from "@/features/landing/components/setup-tabs";
import {
companionBanks,
@@ -16,98 +15,31 @@ import {
extraFeatures,
getMetricsItems,
mainFeatures,
navLinks,
pwaHighlights,
stackItems,
whoIsItForItems,
} from "@/features/landing/constants";
import { landingImages } from "@/features/landing/images";
import { fetchGitHubStats } from "@/features/landing/queries";
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
import {
fetchGitHubStats,
getLandingCopyrightYear,
} from "@/features/landing/queries";
import { Logo } from "@/shared/components/brand/logo";
import { NavbarShell } from "@/shared/components/navigation/navbar/navbar-shell";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Card, CardContent } from "@/shared/components/ui/card";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
import { isSignupDisabled } from "@/shared/lib/auth/signup";
export default async function Page() {
const [session, headersList, githubStats] = await Promise.all([
getOptionalUserSession(),
headers(),
const [githubStats, copyrightYear] = await Promise.all([
fetchGitHubStats(),
getLandingCopyrightYear(),
]);
const hostname = headersList.get("host")?.replace(/:\d+$/, "");
const publicDomain = process.env.PUBLIC_DOMAIN?.replace(
/^https?:\/\//,
"",
).replace(/:\d+$/, "");
const isPublicDomain = !!(publicDomain && hostname === publicDomain);
const signupDisabled = isSignupDisabled();
const metricsItems = getMetricsItems(githubStats.stars, githubStats.forks);
return (
<div className="flex min-h-screen flex-col">
{/* Navigation */}
<NavbarShell>
{/* Center Navigation Links */}
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
{navLinks.map(({ href, label }) => (
<Link
key={href}
href={href}
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-sm font-medium leading-none text-primary-foreground/75 transition-colors hover:bg-primary-foreground/10 hover:text-primary-foreground dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
{label}
</Link>
))}
</nav>
<nav className="ml-auto flex items-center gap-1">
<AnimatedThemeToggler variant="navbar" />
{!isPublicDomain &&
(session?.user ? (
<Link prefetch href="/dashboard" className="hidden md:block">
<Button
variant="navbar"
size="sm"
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Dashboard
</Button>
</Link>
) : (
<div className="hidden md:flex items-center gap-1">
<Link href="/login">
<Button
variant="ghost"
size="sm"
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Entrar
</Button>
</Link>
{!signupDisabled && (
<Link href="/signup">
<Button
variant="ghost"
size="sm"
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Começar
</Button>
</Link>
)}
</div>
))}
<MobileNav
isPublicDomain={isPublicDomain}
isLoggedIn={!!session?.user}
signupDisabled={signupDisabled}
/>
</nav>
</NavbarShell>
<LandingNavbar />
{/* Hero Section */}
<section className="relative overflow-hidden pt-14 md:pt-20 lg:pt-24 pb-0">
@@ -698,8 +630,7 @@ export default async function Page() {
<div className="border-t mt-8 md:mt-12 pt-6 md:pt-8 flex flex-col md:flex-row justify-between items-center gap-3 md:gap-4 text-sm text-muted-foreground">
<p>
© {new Date().getFullYear()} openmonetis. Projeto open source
sob licença.
© {copyrightYear} openmonetis. Projeto open source sob licença.
</p>
<div className="flex items-center gap-2">
<RiShieldCheckLine className="size-4 text-primary" />

View File

@@ -157,6 +157,12 @@ export const userPreferences = pgTable("preferencias_usuario", {
showTransactionSummary: boolean("mostrar_resumo_lancamento")
.notNull()
.default(true),
groupTransactionsByDate: boolean("agrupar_lancamentos_por_data")
.notNull()
.default(true),
hideAnticipatedInstallments: boolean("ocultar_parcelas_antecipadas")
.notNull()
.default(false),
dashboardWidgets: jsonb("dashboard_widgets").$type<{
order: string[];
hidden: string[];
@@ -676,6 +682,7 @@ export const transactions = pgTable(
splitGroupId: uuid("split_group_id"),
transferId: uuid("transfer_id"),
ofxFitId: text("ofx_fit_id"),
ofxImportFingerprint: text("ofx_import_fingerprint"),
importBatchId: text("import_batch_id"),
},
(table) => ({
@@ -729,10 +736,12 @@ export const transactions = pgTable(
anticipationIdIdx: index("lancamentos_antecipacao_id_idx").on(
table.anticipationId,
),
// Dedup OFX: garante FITID único por usuário
ofxFitIdUserIdIdx: uniqueIndex("lancamentos_ofx_fit_id_user_id_idx")
.on(table.userId, table.ofxFitId)
.where(sql`ofx_fit_id IS NOT NULL`),
// Dedup OFX: identifica a transação completa sem assumir FITID único
ofxImportFingerprintUserIdIdx: uniqueIndex(
"lancamentos_ofx_import_fingerprint_user_id_idx",
)
.on(table.userId, table.ofxImportFingerprint)
.where(sql`ofx_import_fingerprint IS NOT NULL`),
}),
);
@@ -847,11 +856,12 @@ export const budgetsRelations = relations(budgets, ({ one }) => ({
}),
}));
export const notesRelations = relations(notes, ({ one }) => ({
export const notesRelations = relations(notes, ({ one, many }) => ({
user: one(user, {
fields: [notes.userId],
references: [user.id],
}),
noteAttachments: many(noteAttachments),
}));
export const savedInsightsRelations = relations(savedInsights, ({ one }) => ({
@@ -972,6 +982,24 @@ export const transactionAttachments = pgTable(
}),
);
export const noteAttachments = pgTable(
"anotacao_anexos",
{
noteId: uuid("anotacao_id")
.notNull()
.references(() => notes.id, { onDelete: "cascade" }),
attachmentId: uuid("anexo_id")
.notNull()
.references(() => attachments.id, { onDelete: "cascade" }),
},
(table) => ({
pk: primaryKey({ columns: [table.noteId, table.attachmentId] }),
attachmentIdIdx: index("anotacao_anexos_anexo_id_idx").on(
table.attachmentId,
),
}),
);
export const importCategoryMappings = pgTable(
"import_category_mappings",
{
@@ -1044,6 +1072,7 @@ export const attachmentsRelations = relations(attachments, ({ one, many }) => ({
references: [user.id],
}),
transactionAttachments: many(transactionAttachments),
noteAttachments: many(noteAttachments),
}));
export const transactionAttachmentsRelations = relations(
@@ -1060,8 +1089,23 @@ export const transactionAttachmentsRelations = relations(
}),
);
export const noteAttachmentsRelations = relations(
noteAttachments,
({ one }) => ({
note: one(notes, {
fields: [noteAttachments.noteId],
references: [notes.id],
}),
attachment: one(attachments, {
fields: [noteAttachments.attachmentId],
references: [attachments.id],
}),
}),
);
export type Attachment = typeof attachments.$inferSelect;
export type TransactionAttachment = typeof transactionAttachments.$inferSelect;
export type NoteAttachment = typeof noteAttachments.$inferSelect;
export const establishmentLogosRelations = relations(
establishmentLogos,

View File

@@ -162,9 +162,15 @@ export function AttachmentGridItem({
</div>
{/* Data */}
<span className="text-xs text-muted-foreground">
{formatDate(attachment.purchaseDate)}
</span>
<div className="flex min-w-0 items-center gap-1 text-xs text-muted-foreground">
<span className="shrink-0">
{formatDate(attachment.purchaseDate)}
</span>
<span aria-hidden>·</span>
<span className="truncate" title={attachment.payerName}>
{attachment.payerName}
</span>
</div>
{/* Transação e Valor */}
<div className="flex items-start justify-between gap-2">

View File

@@ -4,6 +4,8 @@ import {
RiAttachmentLine,
RiFilePdf2Line,
RiImageLine,
RiUserLine,
RiVerifiedBadgeFill,
} from "@remixicon/react";
import { useRouter } from "next/navigation";
import type React from "react";
@@ -17,9 +19,17 @@ import type { TransactionDialogOptions } from "@/features/transactions/actions/f
import { fetchTransactionDialogOptionsAction } from "@/features/transactions/actions/fetch-dialog-options";
import { TransactionDetailsDialog } from "@/features/transactions/components/dialogs/transaction-details-dialog";
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
import { PayerSelectContent } from "@/features/transactions/components/select-items";
import type { TransactionItem } from "@/features/transactions/components/types";
import { EmptyState } from "@/shared/components/feedback/empty-state";
import { Card, CardContent } from "@/shared/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import { cn } from "@/shared/utils/ui";
type FilterType = "all" | "images" | "pdfs";
@@ -73,11 +83,18 @@ const FILTERS: {
interface AttachmentsPageProps {
attachments: AttachmentForPeriod[];
adminPayerId: string;
}
export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
const ALL_PAYERS = "all";
export function AttachmentsPage({
attachments,
adminPayerId,
}: AttachmentsPageProps) {
const router = useRouter();
const [filter, setFilter] = useState<FilterType>("all");
const [payerFilter, setPayerFilter] = useState(adminPayerId);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [transactionDetails, setTransactionDetails] =
useState<TransactionItem | null>(null);
@@ -93,21 +110,44 @@ export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
const [dialogOptions, setDialogOptions] =
useState<TransactionDialogOptions | null>(null);
const filteredAttachments = attachments.filter((a) => {
const payerOptions = Array.from(
new Map(
attachments.map((attachment) => [
attachment.payerId,
{
value: attachment.payerId,
label: attachment.payerName,
avatarUrl: attachment.payerAvatarUrl,
},
]),
).values(),
).sort((a, b) =>
a.label.localeCompare(b.label, "pt-BR", { sensitivity: "base" }),
);
const payerAttachments = attachments.filter(
(attachment) =>
payerFilter === ALL_PAYERS || attachment.payerId === payerFilter,
);
const selectedPayer = payerOptions.find(
(option) => option.value === payerFilter,
);
const filteredAttachments = payerAttachments.filter((a) => {
if (filter === "images") return a.mimeType.startsWith("image/");
if (filter === "pdfs") return a.mimeType === "application/pdf";
return true;
});
const imageCount = attachments.filter((a) =>
const imageCount = payerAttachments.filter((a) =>
a.mimeType.startsWith("image/"),
).length;
const pdfCount = attachments.filter(
const pdfCount = payerAttachments.filter(
(a) => a.mimeType === "application/pdf",
).length;
const counts: Record<FilterType, number> = {
all: attachments.length,
all: payerAttachments.length,
images: imageCount,
pdfs: pdfCount,
};
@@ -161,36 +201,98 @@ export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
{filter !== "all" &&
` · ${FILTERS.find((f) => f.value === filter)?.label.toLowerCase()}`}
</p>
<div className="flex items-center gap-1 rounded-lg border p-1">
{FILTERS.map(({ value, label, icon }) => (
<button
key={value}
type="button"
onClick={() => {
setFilter(value);
setSelectedIndex(-1);
}}
className={cn(
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
filter === value
? "bg-primary text-primary-foreground [&_svg]:opacity-100"
: "text-muted-foreground hover:text-foreground",
)}
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
<Select
value={payerFilter}
onValueChange={(value) => {
setPayerFilter(value);
setSelectedIndex(-1);
}}
>
<SelectTrigger
size="sm"
className="min-w-44 flex-1 sm:flex-none"
>
<span className={cn(filter !== value && "opacity-60")}>
{icon}
</span>
{label}{" "}
<span
<SelectValue placeholder="Pessoa">
{payerFilter === ALL_PAYERS ? (
<span className="flex items-center gap-2">
<RiUserLine className="size-4" />
Todas as pessoas
</span>
) : selectedPayer ? (
<span className="flex items-center gap-1.5">
<PayerSelectContent
label={selectedPayer.label}
avatarUrl={selectedPayer.avatarUrl}
/>
{selectedPayer.value === adminPayerId && (
<RiVerifiedBadgeFill
className="size-4 text-blue-500"
aria-label="Pessoa principal"
/>
)}
</span>
) : null}
</SelectValue>
</SelectTrigger>
<SelectContent align="end">
<SelectItem value={ALL_PAYERS}>
<span className="flex items-center gap-2">
<span className="flex size-6 items-center justify-center rounded-full bg-muted">
<RiUserLine className="size-3.5" />
</span>
Todas as pessoas
</span>
</SelectItem>
{payerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<span className="flex items-center gap-1.5">
<PayerSelectContent
label={option.label}
avatarUrl={option.avatarUrl}
/>
{option.value === adminPayerId && (
<RiVerifiedBadgeFill
className="size-4 text-blue-500"
aria-label="Pessoa principal"
/>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1 rounded-lg border p-1">
{FILTERS.map(({ value, label, icon }) => (
<button
key={value}
type="button"
onClick={() => {
setFilter(value);
setSelectedIndex(-1);
}}
className={cn(
"tabular-nums",
filter === value ? "opacity-80" : "opacity-60",
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
filter === value
? "bg-primary text-primary-foreground [&_svg]:opacity-100"
: "text-muted-foreground hover:text-foreground",
)}
>
({counts[value]})
</span>
</button>
))}
<span className={cn(filter !== value && "opacity-60")}>
{icon}
</span>
{label}{" "}
<span
className={cn(
"tabular-nums",
filter === value ? "opacity-80" : "opacity-60",
)}
>
({counts[value]})
</span>
</button>
))}
</div>
</div>
</div>
@@ -199,7 +301,11 @@ export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
<EmptyState
media={<RiAttachmentLine className="size-6 text-primary" />}
title="Nenhum anexo encontrado"
description="Não há anexos do tipo selecionado neste mês."
description={
payerAttachments.length === 0
? "Não há anexos desta pessoa neste mês."
: "Não há anexos do tipo selecionado neste mês."
}
/>
</div>
) : (

View File

@@ -3,6 +3,7 @@ import { cacheLife, cacheTag } from "next/cache";
import {
attachments,
categories,
payers,
transactionAttachments,
transactions,
} from "@/db/schema";
@@ -21,11 +22,20 @@ export type AttachmentForPeriod = {
purchaseDate: Date;
categoryName: string | null;
categoryIcon: string | null;
payerId: string;
payerName: string;
payerAvatarUrl: string | null;
};
export type AttachmentsPageData = {
attachments: AttachmentForPeriod[];
adminPayerId: string;
};
export async function fetchAttachmentsForPeriod(
userId: string,
period: string,
payerScope?: string | "all",
): Promise<AttachmentForPeriod[]> {
"use cache";
cacheTag(`dashboard-${userId}`);
@@ -33,8 +43,9 @@ export async function fetchAttachmentsForPeriod(
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) return [];
const payerId = payerScope ?? adminPayerId;
return db
const rows = await db
.select({
attachmentId: attachments.id,
fileName: attachments.fileName,
@@ -47,6 +58,9 @@ export async function fetchAttachmentsForPeriod(
purchaseDate: transactions.purchaseDate,
categoryName: categories.name,
categoryIcon: categories.icon,
payerId: payers.id,
payerName: payers.name,
payerAvatarUrl: payers.avatarUrl,
})
.from(transactionAttachments)
.innerJoin(
@@ -61,10 +75,32 @@ export async function fetchAttachmentsForPeriod(
and(
eq(transactionAttachments.transactionId, transactions.id),
eq(transactions.userId, userId),
eq(transactions.payerId, adminPayerId),
eq(transactions.period, period),
payerId === "all" ? undefined : eq(transactions.payerId, payerId),
),
)
.innerJoin(
payers,
and(eq(transactions.payerId, payers.id), eq(payers.userId, userId)),
)
.leftJoin(
categories,
and(
eq(transactions.categoryId, categories.id),
eq(categories.userId, userId),
),
)
.leftJoin(categories, eq(transactions.categoryId, categories.id))
.orderBy(desc(transactions.purchaseDate), desc(attachments.id));
return rows;
}
export async function fetchAttachmentsPageData(
userId: string,
period: string,
): Promise<AttachmentsPageData | null> {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) return null;
const rows = await fetchAttachmentsForPeriod(userId, period, "all");
return { attachments: rows, adminPayerId };
}

View File

@@ -36,6 +36,7 @@ export async function fetchCategoryDetails(
userId: string,
categoryId: string,
period: string,
hideAnticipatedInstallments = false,
): Promise<CategoryDetailData | null> {
const category = await db.query.categories.findFirst({
where: and(eq(categories.userId, userId), eq(categories.id, categoryId)),
@@ -63,6 +64,14 @@ export async function fetchCategoryDetails(
eq(transactions.transactionType, transactionType),
eq(transactions.period, period),
eq(transactions.payerId, adminPayerId),
...(hideAnticipatedInstallments
? [
or(
isNull(transactions.isAnticipated),
eq(transactions.isAnticipated, false),
),
]
: []),
...(isInvoiceCategory ? [] : [sanitizedNote]),
),
with: {

View File

@@ -6,6 +6,7 @@ import {
RiHistoryLine,
RiLineChartLine,
} from "@remixicon/react";
import Link from "next/link";
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers";
import { dashboardWidgetListStyles as styles } from "@/features/dashboard/components/dashboard-widget-list-styles";
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
@@ -13,14 +14,18 @@ import { CategoryIconBadge } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values";
import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state";
import { formatPercentage } from "@/shared/utils/percentage";
import { formatPeriodForUrl } from "@/shared/utils/period";
type CategoryTrendsWidgetProps = {
categories: DashboardCategoryBreakdownItem[];
period: string;
};
export function CategoryTrendsWidget({
categories,
period,
}: CategoryTrendsWidgetProps) {
const periodParam = formatPeriodForUrl(period);
const trending = categories
.filter((c) => c.percentageChange !== null && c.previousAmount > 0)
.sort(
@@ -53,7 +58,12 @@ export function CategoryTrendsWidget({
size="md"
/>
<div className={styles.textStack}>
<p className={styles.title}>{category.categoryName}</p>
<Link
href={`/categories/${category.categoryId}?periodo=${periodParam}`}
className={styles.titleLink}
>
<span className="truncate">{category.categoryName}</span>
</Link>
<p className={styles.meta}>
<span
className="inline-flex items-center gap-1"

View File

@@ -9,6 +9,7 @@ const mapDashboardNoteToNote = (note: DashboardNote): Note => ({
tasks: note.tasks,
archived: note.archived,
createdAt: note.createdAt,
attachments: [],
});
export const mapDashboardNotesToNotes = (notes: DashboardNote[]) =>

View File

@@ -174,9 +174,10 @@ export const widgetsConfig: WidgetConfig[] = [
title: "Tendências de categorias",
subtitle: "Top 10 maiores variações vs. mês anterior",
icon: <RiLineChartLine className="size-4" />,
component: ({ data }) => (
component: ({ data, period }) => (
<CategoryTrendsWidget
categories={data.expensesByCategoryData.categories}
period={period}
/>
),
},

View File

@@ -0,0 +1,103 @@
import { headers } from "next/headers";
import Link from "next/link";
import { Suspense } from "react";
import { AnimatedThemeToggler } from "@/shared/components/animated-theme-toggler";
import { NavbarShell } from "@/shared/components/navigation/navbar/navbar-shell";
import { Button } from "@/shared/components/ui/button";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
import { isSignupDisabled } from "@/shared/lib/auth/signup";
import { navLinks } from "../constants";
import { MobileNav } from "./mobile-nav";
async function LandingNavbarControls() {
const [session, headersList] = await Promise.all([
getOptionalUserSession(),
headers(),
]);
const hostname = headersList.get("host")?.replace(/:\d+$/, "");
const publicDomain = process.env.PUBLIC_DOMAIN?.replace(
/^https?:\/\//,
"",
).replace(/:\d+$/, "");
const isPublicDomain = !!(publicDomain && hostname === publicDomain);
const signupDisabled = isSignupDisabled();
return (
<>
{!isPublicDomain &&
(session?.user ? (
<Link prefetch href="/dashboard" className="hidden md:block">
<Button
variant="navbar"
size="sm"
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Dashboard
</Button>
</Link>
) : (
<div className="hidden md:flex items-center gap-1">
<Link href="/login">
<Button
variant="ghost"
size="sm"
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Entrar
</Button>
</Link>
{!signupDisabled && (
<Link href="/signup">
<Button
variant="ghost"
size="sm"
className="h-9 text-primary-foreground/75 hover:bg-primary-foreground/10 hover:text-primary-foreground shadow-none dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
Começar
</Button>
</Link>
)}
</div>
))}
<MobileNav
isPublicDomain={isPublicDomain}
isLoggedIn={!!session?.user}
signupDisabled={signupDisabled}
/>
</>
);
}
function LandingNavbarControlsFallback() {
return (
<>
<div className="hidden h-9 w-36 md:block" aria-hidden="true" />
<MobileNav isPublicDomain isLoggedIn={false} signupDisabled />
</>
);
}
export function LandingNavbar() {
return (
<NavbarShell>
<nav className="hidden md:flex items-center gap-1 absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
{navLinks.map(({ href, label }) => (
<Link
key={href}
href={href}
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-sm font-medium leading-none text-primary-foreground/75 transition-colors hover:bg-primary-foreground/10 hover:text-primary-foreground dark:text-white/75 dark:hover:bg-white/10 dark:hover:text-white"
>
{label}
</Link>
))}
</nav>
<nav className="ml-auto flex items-center gap-1">
<AnimatedThemeToggler variant="navbar" />
<Suspense fallback={<LandingNavbarControlsFallback />}>
<LandingNavbarControls />
</Suspense>
</nav>
</NavbarShell>
);
}

View File

@@ -1,11 +1,22 @@
import { cacheLife } from "next/cache";
export async function getLandingCopyrightYear(): Promise<number> {
"use cache";
cacheLife({ revalidate: 86_400 });
return new Date().getFullYear();
}
export async function fetchGitHubStats(): Promise<{
stars: number;
forks: number;
}> {
"use cache";
cacheLife({ revalidate: 3600 });
try {
const res = await fetch(
"https://api.github.com/repos/felipegcoutinho/openmonetis",
{ next: { revalidate: 3600 } },
);
if (!res.ok) return { stars: 200, forks: 60 };
const data = await res.json();

View File

@@ -1,8 +1,8 @@
"use server";
import { and, eq } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import { z } from "zod";
import { notes } from "@/db/schema";
import { attachments, noteAttachments, notes } from "@/db/schema";
import {
handleActionError,
revalidateForEntity,
@@ -10,6 +10,7 @@ import {
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import { uuidSchema } from "@/shared/lib/schemas/common";
import { deleteS3Object } from "@/shared/lib/storage/presign";
import type { ActionResult } from "@/shared/lib/types/actions";
const taskSchema = z.object({
@@ -70,25 +71,42 @@ type NoteDeleteInput = z.infer<typeof deleteNoteSchema>;
export async function createNoteAction(
input: NoteCreateInput,
): Promise<ActionResult> {
): Promise<ActionResult<{ noteId: string }>> {
try {
const user = await getUser();
const data = createNoteSchema.parse(input);
await db.insert(notes).values({
title: data.title,
description: data.description,
type: data.type,
tasks:
data.tasks && data.tasks.length > 0 ? JSON.stringify(data.tasks) : null,
userId: user.id,
});
const [created] = await db
.insert(notes)
.values({
title: data.title,
description: data.description,
type: data.type,
tasks:
data.tasks && data.tasks.length > 0
? JSON.stringify(data.tasks)
: null,
userId: user.id,
})
.returning({ id: notes.id });
if (!created) {
return { success: false, error: "Não foi possível criar a anotação." };
}
revalidateForEntity("notes", user.id);
return { success: true, message: "Anotação criada com sucesso." };
return {
success: true,
message: "Anotação criada com sucesso.",
data: { noteId: created.id },
};
} catch (error) {
return handleActionError(error);
const result = handleActionError(error);
return {
success: false,
error: result.success ? "Ocorreu um erro inesperado." : result.error,
};
}
}
@@ -135,6 +153,25 @@ export async function deleteNoteAction(
const user = await getUser();
const data = deleteNoteSchema.parse(input);
const linkedAttachments = await db
.select({ id: attachments.id, fileKey: attachments.fileKey })
.from(noteAttachments)
.innerJoin(
attachments,
and(
eq(noteAttachments.attachmentId, attachments.id),
eq(attachments.userId, user.id),
),
)
.innerJoin(
notes,
and(
eq(noteAttachments.noteId, notes.id),
eq(notes.id, data.id),
eq(notes.userId, user.id),
),
);
const [deleted] = await db
.delete(notes)
.where(and(eq(notes.id, data.id), eq(notes.userId, user.id)))
@@ -147,6 +184,23 @@ export async function deleteNoteAction(
};
}
if (linkedAttachments.length > 0) {
await Promise.all(
linkedAttachments.map((attachment) =>
deleteS3Object(attachment.fileKey),
),
);
await db.delete(attachments).where(
and(
eq(attachments.userId, user.id),
inArray(
attachments.id,
linkedAttachments.map((attachment) => attachment.id),
),
),
);
}
revalidateForEntity("notes", user.id);
return { success: true, message: "Anotação removida com sucesso." };

View File

@@ -0,0 +1,279 @@
"use server";
import crypto, { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { z } from "zod/v4";
import {
attachments,
noteAttachments,
notes,
userPreferences,
} from "@/db/schema";
import {
handleActionError,
revalidateForEntity,
} from "@/shared/lib/actions/helpers";
import {
ALLOWED_MIME_TYPES,
ATTACHMENT_SIZE_OPTIONS,
} from "@/shared/lib/attachments/config";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import {
createPresignedPutUrl,
deleteS3Object,
headS3Object,
} from "@/shared/lib/storage/presign";
import type { ActionResult } from "@/shared/lib/types/actions";
const UPLOAD_TOKEN_EXPIRY_SECONDS = 10 * 60;
const MAX_NOTE_FILE_SIZE = Math.max(...ATTACHMENT_SIZE_OPTIONS) * 1024 * 1024;
const presignSchema = z.object({
noteId: z.string().uuid(),
fileName: z.string().min(1).max(255),
mimeType: z.enum(ALLOWED_MIME_TYPES),
fileSize: z.number().positive().max(MAX_NOTE_FILE_SIZE),
});
const tokenPayloadSchema = presignSchema.extend({
userId: z.string().min(1),
fileKey: z.string().min(1),
exp: z.number().int(),
});
type UploadTokenPayload = z.infer<typeof tokenPayloadSchema>;
type PresignResult =
| { success: true; presignedUrl: string; uploadToken: string }
| { success: false; error: string };
export type NoteAttachmentData = {
attachmentId: string;
fileName: string;
fileSize: number;
mimeType: string;
};
function getUploadTokenSecret(): string {
const secret = process.env.BETTER_AUTH_SECRET;
if (!secret) throw new Error("BETTER_AUTH_SECRET is required.");
return secret;
}
function encode(value: string): string {
return Buffer.from(value).toString("base64url");
}
function signUploadToken(payload: UploadTokenPayload): string {
const encoded = encode(JSON.stringify(payload));
const signature = crypto
.createHmac("sha256", getUploadTokenSecret())
.update(encoded)
.digest("base64url");
return `${encoded}.${signature}`;
}
function verifyUploadToken(token: string): UploadTokenPayload | null {
try {
const [encoded, signature] = token.split(".");
if (!encoded || !signature) return null;
const expected = crypto
.createHmac("sha256", getUploadTokenSecret())
.update(encoded)
.digest("base64url");
if (
signature.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
) {
return null;
}
const parsed = tokenPayloadSchema.safeParse(
JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")),
);
if (!parsed.success || parsed.data.exp < Math.floor(Date.now() / 1000)) {
return null;
}
if (!parsed.data.fileKey.startsWith(`${parsed.data.userId}/`)) return null;
return parsed.data;
} catch {
return null;
}
}
async function findOwnedNote(noteId: string, userId: string) {
const [note] = await db
.select({ id: notes.id, type: notes.type })
.from(notes)
.where(and(eq(notes.id, noteId), eq(notes.userId, userId)));
return note?.type === "nota" ? note : null;
}
async function getAttachmentLimitBytes(userId: string): Promise<number> {
const [preferences] = await db
.select({ maxSizeMb: userPreferences.attachmentMaxSizeMb })
.from(userPreferences)
.where(eq(userPreferences.userId, userId));
return (preferences?.maxSizeMb ?? 50) * 1024 * 1024;
}
export async function getPresignedNoteAttachmentUploadUrlAction(input: {
noteId: string;
fileName: string;
mimeType: string;
fileSize: number;
}): Promise<PresignResult> {
try {
const user = await getUser();
const data = presignSchema.parse(input);
if (data.fileSize > (await getAttachmentLimitBytes(user.id))) {
return {
success: false,
error: "O arquivo excede o limite configurado para anexos.",
};
}
if (!(await findOwnedNote(data.noteId, user.id))) {
return { success: false, error: "Nota não encontrada." };
}
const extensions: Record<(typeof ALLOWED_MIME_TYPES)[number], string> = {
"application/pdf": "pdf",
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
};
const extension = extensions[data.mimeType];
const fileKey = `${user.id}/${randomUUID()}.${extension}`;
const presignedUrl = await createPresignedPutUrl(fileKey, data.mimeType);
const uploadToken = signUploadToken({
...data,
userId: user.id,
fileKey,
exp: Math.floor(Date.now() / 1000) + UPLOAD_TOKEN_EXPIRY_SECONDS,
});
return { success: true, presignedUrl, uploadToken };
} catch (error) {
const result = handleActionError(error);
return {
success: false,
error: result.success ? "Algo deu errado." : result.error,
};
}
}
export async function confirmNoteAttachmentUploadAction(input: {
uploadToken: string;
}): Promise<ActionResult<NoteAttachmentData>> {
try {
const user = await getUser();
const payload = verifyUploadToken(input.uploadToken);
if (!payload || payload.userId !== user.id) {
return { success: false, error: "Upload de anexo inválido ou expirado." };
}
if (!(await findOwnedNote(payload.noteId, user.id))) {
return { success: false, error: "Nota não encontrada." };
}
const metadata = await headS3Object(payload.fileKey);
if (
!metadata.contentLength ||
metadata.contentLength !== payload.fileSize ||
metadata.contentLength > MAX_NOTE_FILE_SIZE ||
metadata.contentType !== payload.mimeType
) {
return {
success: false,
error: "O arquivo enviado não confere com o upload autorizado.",
};
}
const [attachment] = await db
.insert(attachments)
.values({
userId: user.id,
fileKey: payload.fileKey,
fileName: payload.fileName,
fileSize: payload.fileSize,
mimeType: payload.mimeType,
})
.returning({ id: attachments.id });
if (!attachment)
return { success: false, error: "Não foi possível salvar o anexo." };
await db.insert(noteAttachments).values({
noteId: payload.noteId,
attachmentId: attachment.id,
});
revalidateForEntity("notes", user.id);
return {
success: true,
message: "Anexo enviado.",
data: {
attachmentId: attachment.id,
fileName: payload.fileName,
fileSize: payload.fileSize,
mimeType: payload.mimeType,
},
};
} catch (error) {
const result = handleActionError(error);
return {
success: false,
error: result.success ? "Ocorreu um erro inesperado." : result.error,
};
}
}
export async function removeNoteAttachmentAction(input: {
noteId: string;
attachmentId: string;
}): Promise<ActionResult> {
try {
const user = await getUser();
const data = z
.object({ noteId: z.string().uuid(), attachmentId: z.string().uuid() })
.parse(input);
if (!(await findOwnedNote(data.noteId, user.id))) {
return { success: false, error: "Nota não encontrada." };
}
const [attachment] = await db
.select({ fileKey: attachments.fileKey })
.from(noteAttachments)
.innerJoin(
attachments,
and(
eq(noteAttachments.attachmentId, attachments.id),
eq(attachments.userId, user.id),
),
)
.where(
and(
eq(noteAttachments.noteId, data.noteId),
eq(noteAttachments.attachmentId, data.attachmentId),
),
);
if (!attachment) return { success: false, error: "Anexo não encontrado." };
await db
.delete(noteAttachments)
.where(
and(
eq(noteAttachments.noteId, data.noteId),
eq(noteAttachments.attachmentId, data.attachmentId),
),
);
await deleteS3Object(attachment.fileKey);
await db
.delete(attachments)
.where(
and(
eq(attachments.id, data.attachmentId),
eq(attachments.userId, user.id),
),
);
revalidateForEntity("notes", user.id);
return { success: true, message: "Anexo removido." };
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -0,0 +1,345 @@
"use client";
import {
RiAttachment2,
RiCloseLine,
RiDeleteBinLine,
RiDownloadLine,
RiFileImageLine,
RiFilePdf2Line,
} from "@remixicon/react";
import { useRef, useState } from "react";
import { toast } from "sonner";
import {
confirmNoteAttachmentUploadAction,
getPresignedNoteAttachmentUploadUrlAction,
removeNoteAttachmentAction,
} from "@/features/notes/actions/attachments";
import type { NoteAttachment } from "@/features/notes/components/types";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import {
ALLOWED_MIME_TYPES,
DEFAULT_MAX_FILE_SIZE_MB,
} from "@/shared/lib/attachments/config";
type UploadResult =
| { success: true; attachment: NoteAttachment }
| { success: false; error: string };
export async function uploadNoteAttachment(
noteId: string,
file: File,
): Promise<UploadResult> {
try {
const presign = await getPresignedNoteAttachmentUploadUrlAction({
noteId,
fileName: file.name,
fileSize: file.size,
mimeType: file.type,
});
if (!presign.success) return presign;
const uploaded = await fetch(presign.presignedUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
if (!uploaded.ok) {
return { success: false, error: "Não foi possível enviar o arquivo." };
}
const confirmed = await confirmNoteAttachmentUploadAction({
uploadToken: presign.uploadToken,
});
if (!confirmed.success || !confirmed.data) {
return {
success: false,
error: confirmed.success
? "Não foi possível salvar o anexo."
: confirmed.error,
};
}
return { success: true, attachment: confirmed.data };
} catch {
return {
success: false,
error: "Não foi possível enviar o arquivo agora.",
};
}
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function validateFile(file: File, maxSizeMb: number): string | null {
if (
!ALLOWED_MIME_TYPES.includes(
file.type as (typeof ALLOWED_MIME_TYPES)[number],
)
) {
return "Tipo não suportado. Use PDF, JPEG, PNG ou WebP.";
}
if (file.size > maxSizeMb * 1024 * 1024) {
return `O arquivo deve ter no máximo ${maxSizeMb}MB.`;
}
return null;
}
interface NoteAttachmentsFieldProps {
noteId?: string;
attachments: NoteAttachment[];
pendingFiles: File[];
onAttachmentsChange: (attachments: NoteAttachment[]) => void;
onPendingFilesChange: (files: File[]) => void;
onBusyChange?: (busy: boolean) => void;
maxSizeMb?: number;
disabled?: boolean;
readonly?: boolean;
}
export function NoteAttachmentsField({
noteId,
attachments,
pendingFiles,
onAttachmentsChange,
onPendingFilesChange,
onBusyChange,
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
disabled = false,
readonly = false,
}: NoteAttachmentsFieldProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [removing, setRemoving] = useState<NoteAttachment | null>(null);
const [isRemoving, setIsRemoving] = useState(false);
const [openingId, setOpeningId] = useState<string | null>(null);
async function addFiles(files: File[]) {
const valid: File[] = [];
for (const file of files) {
const error = validateFile(file, maxSizeMb);
if (error) toast.error(`${file.name}: ${error}`);
else valid.push(file);
}
if (valid.length === 0) return;
if (!noteId) {
onPendingFilesChange([...pendingFiles, ...valid]);
return;
}
setUploading(true);
onBusyChange?.(true);
const added: NoteAttachment[] = [];
for (const file of valid) {
const result = await uploadNoteAttachment(noteId, file);
if (result.success) added.push(result.attachment);
else toast.error(`${file.name}: ${result.error}`);
}
setUploading(false);
onBusyChange?.(false);
if (added.length > 0) {
onAttachmentsChange([...attachments, ...added]);
toast.success(
added.length === 1
? "Anexo enviado."
: `${added.length} anexos enviados.`,
);
}
}
async function downloadAttachment(attachment: NoteAttachment) {
setOpeningId(attachment.attachmentId);
try {
const response = await fetch(
`/api/attachments/${attachment.attachmentId}/presign`,
);
if (!response.ok) throw new Error();
const { url } = (await response.json()) as { url: string };
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = attachment.fileName;
anchor.target = "_blank";
anchor.rel = "noreferrer";
anchor.click();
} catch {
toast.error("Não foi possível baixar o anexo agora.");
} finally {
setOpeningId(null);
}
}
async function confirmRemove() {
if (!noteId || !removing) return;
setIsRemoving(true);
onBusyChange?.(true);
const result = await removeNoteAttachmentAction({
noteId,
attachmentId: removing.attachmentId,
});
setIsRemoving(false);
onBusyChange?.(false);
if (result.success) {
onAttachmentsChange(
attachments.filter(
(item) => item.attachmentId !== removing.attachmentId,
),
);
setRemoving(null);
toast.success(result.message);
} else {
toast.error(result.error);
}
}
return (
<div className="space-y-1.5">
<p className="text-xs font-medium">Anexos</p>
<input
ref={inputRef}
type="file"
multiple
className="hidden"
accept={ALLOWED_MIME_TYPES.join(",")}
onChange={(event) => {
void addFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
{attachments.length > 0 && (
<div className="space-y-1.5">
{attachments.map((attachment) => (
<div
key={attachment.attachmentId}
className="flex min-w-0 items-center gap-2 rounded-md border px-3 py-2 text-sm"
>
{attachment.mimeType === "application/pdf" ? (
<RiFilePdf2Line className="size-4 shrink-0 text-red-500" />
) : (
<RiFileImageLine className="size-4 shrink-0 text-blue-500" />
)}
<div className="min-w-0 flex-1">
<p className="truncate font-medium" title={attachment.fileName}>
{attachment.fileName}
</p>
<p className="text-xs text-muted-foreground">
{formatBytes(attachment.fileSize)}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0"
disabled={openingId === attachment.attachmentId}
onClick={() => void downloadAttachment(attachment)}
aria-label={`Baixar ${attachment.fileName}`}
>
<RiDownloadLine className="size-4" />
</Button>
{!readonly && (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0 text-destructive hover:text-destructive"
disabled={disabled}
onClick={() => setRemoving(attachment)}
aria-label={`Remover ${attachment.fileName}`}
>
<RiDeleteBinLine className="size-4" />
</Button>
)}
</div>
))}
</div>
)}
{pendingFiles.map((file, index) => (
<div
key={`${file.name}-${file.size}-${file.lastModified}-${index}`}
className="flex min-w-0 items-center gap-2 rounded-md border border-dashed px-3 py-2 text-sm"
>
<RiAttachment2 className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{file.name}</p>
<p className="text-xs text-muted-foreground">
Será enviado ao salvar
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0"
onClick={() =>
onPendingFilesChange(
pendingFiles.filter((_, fileIndex) => fileIndex !== index),
)
}
aria-label={`Cancelar ${file.name}`}
>
<RiCloseLine className="size-4" />
</Button>
</div>
))}
{!readonly && (
<button
type="button"
className="flex min-h-16 w-full items-center justify-center gap-2 rounded-md border border-dashed px-3 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
onClick={() => inputRef.current?.click()}
disabled={disabled || uploading}
>
<RiAttachment2 className="size-4" />
<span>{uploading ? "Enviando..." : "Adicionar anexos"}</span>
<span className="hidden text-xs sm:inline">
PDF ou imagem · máx. {maxSizeMb} MB
</span>
</button>
)}
<Dialog
open={Boolean(removing)}
onOpenChange={(open) => !open && setRemoving(null)}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Remover anexo?</DialogTitle>
<DialogDescription>
O arquivo {removing?.fileName} será removido desta nota.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline" disabled={isRemoving}>
Cancelar
</Button>
</DialogClose>
<Button
type="button"
variant="destructive"
disabled={isRemoving}
onClick={() => void confirmRemove()}
>
{isRemoving ? "Removendo..." : "Remover"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -2,6 +2,7 @@
import {
RiArchiveLine,
RiAttachment2,
RiCheckLine,
RiDeleteBin5Line,
RiFileList2Line,
@@ -87,11 +88,19 @@ export function NoteCard({
</span>
)}
</div>
{isTask && (
<Badge variant="outline" className="shrink-0 text-xs">
{completedCount}/{totalCount} concluídas
</Badge>
)}
<div className="flex shrink-0 flex-col items-end gap-1.5">
{isTask && (
<Badge variant="outline" className="shrink-0 text-xs">
{completedCount}/{totalCount} concluídas
</Badge>
)}
{!isTask && note.attachments.length > 0 && (
<Badge variant="outline" className="gap-1 text-xs">
<RiAttachment2 className="size-3.5" />
{note.attachments.length}
</Badge>
)}
</div>
</div>
{isTask ? (

View File

@@ -1,6 +1,7 @@
"use client";
import { RiCheckLine, RiSubtractLine } from "@remixicon/react";
import { NoteAttachmentsField } from "@/features/notes/components/note-attachments-field";
import {
buildNoteDisplayTitle,
formatNoteCreatedAtLong,
@@ -85,8 +86,20 @@ export function NoteDetailsDialog({
))}
</div>
) : (
<div className="max-h-[320px] overflow-auto whitespace-pre-line wrap-break-word text-sm text-foreground">
{note.description}
<div className="max-h-[55vh] space-y-4 overflow-auto">
<div className="whitespace-pre-line wrap-break-word text-sm text-foreground">
{note.description}
</div>
{note.attachments.length > 0 && (
<NoteAttachmentsField
noteId={note.id}
attachments={note.attachments}
pendingFiles={[]}
onAttachmentsChange={() => undefined}
onPendingFilesChange={() => undefined}
readonly
/>
)}
</div>
)}

View File

@@ -15,6 +15,10 @@ import {
} from "react";
import { toast } from "sonner";
import { createNoteAction, updateNoteAction } from "@/features/notes/actions";
import {
NoteAttachmentsField,
uploadNoteAttachment,
} from "@/features/notes/components/note-attachments-field";
import { Button } from "@/shared/components/ui/button";
import { Checkbox } from "@/shared/components/ui/checkbox";
import {
@@ -34,6 +38,7 @@ import { useFormState } from "@/shared/hooks/use-form-state";
import { cn } from "@/shared/utils/ui";
import {
type Note,
type NoteAttachment,
type NoteFormValues,
sortTasksByStatus,
type Task,
@@ -46,6 +51,7 @@ interface NoteDialogProps {
note?: Note;
open?: boolean;
onOpenChange?: (open: boolean) => void;
attachmentMaxSizeMb?: number;
}
const MAX_TITLE = 30;
@@ -69,12 +75,16 @@ export function NoteDialog({
note,
open,
onOpenChange,
attachmentMaxSizeMb,
}: NoteDialogProps) {
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [newTaskText, setNewTaskText] = useState("");
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
const [editingTaskText, setEditingTaskText] = useState("");
const [noteAttachments, setNoteAttachments] = useState<NoteAttachment[]>([]);
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [isAttachmentPending, setIsAttachmentPending] = useState(false);
const titleRef = useRef<HTMLInputElement>(null);
const descRef = useRef<HTMLTextAreaElement>(null);
@@ -99,6 +109,9 @@ export function NoteDialog({
setNewTaskText("");
setEditingTaskId(null);
setEditingTaskText("");
setNoteAttachments(note?.attachments ?? []);
setPendingFiles([]);
setIsAttachmentPending(false);
requestAnimationFrame(() => titleRef.current?.focus());
}
}, [dialogOpen, note, resetForm]);
@@ -137,12 +150,14 @@ export function NoteDialog({
const disableSubmit =
isPending ||
isAttachmentPending ||
onlySpaces ||
unchanged ||
invalidLen ||
Boolean(editingTaskId);
const handleOpenChange = (v: boolean) => {
if (!v && (isPending || isAttachmentPending)) return;
setDialogOpen(v);
if (!v) setErrorMessage(null);
};
@@ -252,7 +267,9 @@ export function NoteDialog({
}
startTransition(async () => {
let result: { success: boolean; message?: string; error?: string };
let result:
| Awaited<ReturnType<typeof createNoteAction>>
| Awaited<ReturnType<typeof updateNoteAction>>;
if (mode === "create") {
result = await createNoteAction(payload);
} else {
@@ -266,7 +283,31 @@ export function NoteDialog({
}
if (result.success) {
toast.success(result.message);
if (mode === "create" && pendingFiles.length > 0) {
const noteId = "data" in result ? result.data?.noteId : undefined;
if (noteId) {
let failedUploads = 0;
for (const file of pendingFiles) {
const upload = await uploadNoteAttachment(noteId, file);
if (!upload.success) failedUploads += 1;
}
if (failedUploads > 0) {
toast.warning(
failedUploads === 1
? "A nota foi salva, mas um anexo não pôde ser enviado."
: `A nota foi salva, mas ${failedUploads} anexos não puderam ser enviados.`,
);
} else {
toast.success(
pendingFiles.length === 1
? "Anotação e anexo salvos."
: "Anotação e anexos salvos.",
);
}
}
} else {
toast.success(result.message);
}
setDialogOpen(false);
return;
}
@@ -355,35 +396,48 @@ export function NoteDialog({
</div>
{isNote && (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label htmlFor="note-description">Conteúdo</Label>
<span
className={cn(
"text-xs",
descCount > MAX_DESC
? "text-destructive"
: "text-muted-foreground",
)}
>
{descCount}/{MAX_DESC}
</span>
<div className="space-y-3">
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label htmlFor="note-description">Conteúdo</Label>
<span
className={cn(
"text-xs",
descCount > MAX_DESC
? "text-destructive"
: "text-muted-foreground",
)}
>
{descCount}/{MAX_DESC}
</span>
</div>
<Textarea
id="note-description"
className="field-sizing-fixed"
ref={descRef}
value={formState.description}
onChange={(e) => updateField("description", e.target.value)}
placeholder="Detalhe sua anotação..."
rows={5}
maxLength={MAX_DESC + 10}
disabled={isPending}
required
/>
<p className="text-xs text-muted-foreground">
Ctrl+Enter para salvar
</p>
</div>
<Textarea
id="note-description"
className="field-sizing-fixed"
ref={descRef}
value={formState.description}
onChange={(e) => updateField("description", e.target.value)}
placeholder="Detalhe sua anotação..."
rows={5}
maxLength={MAX_DESC + 10}
<NoteAttachmentsField
noteId={mode === "update" ? note?.id : undefined}
attachments={noteAttachments}
pendingFiles={pendingFiles}
onAttachmentsChange={setNoteAttachments}
onPendingFilesChange={setPendingFiles}
onBusyChange={setIsAttachmentPending}
maxSizeMb={attachmentMaxSizeMb}
disabled={isPending}
required
/>
<p className="text-xs text-muted-foreground">
Ctrl+Enter para salvar
</p>
</div>
)}
@@ -517,7 +571,7 @@ export function NoteDialog({
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isPending}
disabled={isPending || isAttachmentPending}
>
Cancelar
</Button>

View File

@@ -22,9 +22,14 @@ import type { Note } from "./types";
interface NotesPageProps {
notes: Note[];
archivedNotes: Note[];
attachmentMaxSizeMb?: number;
}
export function NotesPage({ notes, archivedNotes }: NotesPageProps) {
export function NotesPage({
notes,
archivedNotes,
attachmentMaxSizeMb,
}: NotesPageProps) {
const [activeTab, setActiveTab] = useState("ativas");
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
@@ -192,6 +197,7 @@ export function NotesPage({ notes, archivedNotes }: NotesPageProps) {
mode="create"
open={createOpen}
onOpenChange={handleCreateOpenChange}
attachmentMaxSizeMb={attachmentMaxSizeMb}
trigger={
<Button className="w-full sm:w-auto">
<RiAddFill className="size-4" />
@@ -222,6 +228,7 @@ export function NotesPage({ notes, archivedNotes }: NotesPageProps) {
note={noteToEdit ?? undefined}
open={editOpen}
onOpenChange={handleEditOpenChange}
attachmentMaxSizeMb={attachmentMaxSizeMb}
/>
<NoteDetailsDialog

View File

@@ -14,6 +14,14 @@ export interface Note {
tasks?: Task[];
archived: boolean;
createdAt: string;
attachments: NoteAttachment[];
}
export interface NoteAttachment {
attachmentId: string;
fileName: string;
fileSize: number;
mimeType: string;
}
export interface NoteFormValues {

View File

@@ -1,7 +1,14 @@
import { and, eq } from "drizzle-orm";
import { type Note, notes } from "@/db/schema";
import { and, desc, eq } from "drizzle-orm";
import { attachments, type Note, noteAttachments, notes } from "@/db/schema";
import { db } from "@/shared/lib/db";
export type NoteAttachmentData = {
attachmentId: string;
fileName: string;
fileSize: number;
mimeType: string;
};
type Task = {
id: string;
text: string;
@@ -16,6 +23,7 @@ type NoteData = {
tasks?: Task[];
archived: boolean;
createdAt: string;
attachments: NoteAttachmentData[];
};
function parseTasks(value: string | null): Task[] | undefined {
@@ -31,7 +39,10 @@ function parseTasks(value: string | null): Task[] | undefined {
}
}
function toNoteData(note: Note): NoteData {
function toNoteData(
note: Note,
linkedAttachments: NoteAttachmentData[],
): NoteData {
return {
id: note.id,
title: (note.title ?? "").trim(),
@@ -40,34 +51,53 @@ function toNoteData(note: Note): NoteData {
tasks: parseTasks(note.tasks),
archived: note.archived,
createdAt: note.createdAt.toISOString(),
attachments: linkedAttachments,
};
}
async function fetchNotesForUser(userId: string): Promise<NoteData[]> {
const noteRows = await db.query.notes.findMany({
where: and(eq(notes.userId, userId), eq(notes.archived, false)),
orderBy: (table, { desc }) => [desc(table.createdAt)],
});
return noteRows.map(toNoteData);
}
export async function fetchAllNotesForUser(
userId: string,
): Promise<{ activeNotes: NoteData[]; archivedNotes: NoteData[] }> {
const [activeNotes, archivedNotes] = await Promise.all([
fetchNotesForUser(userId),
fetchArchivedForUser(userId),
const [noteRows, attachmentRows] = await Promise.all([
db.query.notes.findMany({
where: eq(notes.userId, userId),
orderBy: (table, { desc }) => [desc(table.createdAt)],
}),
db
.select({
noteId: noteAttachments.noteId,
attachmentId: attachments.id,
fileName: attachments.fileName,
fileSize: attachments.fileSize,
mimeType: attachments.mimeType,
})
.from(noteAttachments)
.innerJoin(
notes,
and(eq(noteAttachments.noteId, notes.id), eq(notes.userId, userId)),
)
.innerJoin(
attachments,
and(
eq(noteAttachments.attachmentId, attachments.id),
eq(attachments.userId, userId),
),
)
.orderBy(desc(attachments.createdAt)),
]);
return { activeNotes, archivedNotes };
}
const attachmentsByNote = new Map<string, NoteAttachmentData[]>();
for (const { noteId, ...attachment } of attachmentRows) {
const current = attachmentsByNote.get(noteId) ?? [];
current.push(attachment);
attachmentsByNote.set(noteId, current);
}
const mapped = noteRows.map((note) =>
toNoteData(note, attachmentsByNote.get(note.id) ?? []),
);
async function fetchArchivedForUser(userId: string): Promise<NoteData[]> {
const noteRows = await db.query.notes.findMany({
where: and(eq(notes.userId, userId), eq(notes.archived, true)),
orderBy: (table, { desc }) => [desc(table.createdAt)],
});
return noteRows.map(toNoteData);
return {
activeNotes: mapped.filter((note) => !note.archived),
archivedNotes: mapped.filter((note) => note.archived),
};
}

View File

@@ -228,6 +228,7 @@ export function CategoryReportFilters({
<Popover open={startMonthOpen} onOpenChange={setStartMonthOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className="w-[calc(50%-0.25rem)] md:w-[150px] justify-start text-sm border-dashed"
disabled={isLoading}
@@ -248,6 +249,7 @@ export function CategoryReportFilters({
<Popover open={endMonthOpen} onOpenChange={setEndMonthOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className="w-[calc(50%-0.25rem)] md:w-[150px] justify-start text-sm border-dashed"
disabled={isLoading}

View File

@@ -69,6 +69,8 @@ const updatePreferencesSchema = z.object({
transactionsColumnOrder: z.array(z.string()).nullable(),
attachmentMaxSizeMb: z.number().int().min(1).max(100),
showTransactionSummary: z.boolean(),
groupTransactionsByDate: z.boolean(),
hideAnticipatedInstallments: z.boolean(),
});
type ResettableUser = {
@@ -584,6 +586,8 @@ export async function updatePreferencesAction(
transactionsColumnOrder: validated.transactionsColumnOrder,
attachmentMaxSizeMb: validated.attachmentMaxSizeMb,
showTransactionSummary: validated.showTransactionSummary,
groupTransactionsByDate: validated.groupTransactionsByDate,
hideAnticipatedInstallments: validated.hideAnticipatedInstallments,
updatedAt: new Date(),
})
.where(eq(schema.userPreferences.userId, session.user.id));
@@ -595,6 +599,8 @@ export async function updatePreferencesAction(
transactionsColumnOrder: validated.transactionsColumnOrder,
attachmentMaxSizeMb: validated.attachmentMaxSizeMb,
showTransactionSummary: validated.showTransactionSummary,
groupTransactionsByDate: validated.groupTransactionsByDate,
hideAnticipatedInstallments: validated.hideAnticipatedInstallments,
});
}

View File

@@ -43,6 +43,8 @@ interface PreferencesFormProps {
transactionsColumnOrder: string[] | null;
attachmentMaxSizeMb: number;
showTransactionSummary: boolean;
groupTransactionsByDate: boolean;
hideAnticipatedInstallments: boolean;
}
function SortableColumnItem({ id }: { id: string }) {
@@ -87,6 +89,8 @@ export function PreferencesForm({
transactionsColumnOrder: initialColumnOrder,
attachmentMaxSizeMb: initialAttachmentMaxSizeMb,
showTransactionSummary: initialShowTransactionSummary,
groupTransactionsByDate: initialGroupTransactionsByDate,
hideAnticipatedInstallments: initialHideAnticipatedInstallments,
}: PreferencesFormProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
@@ -109,6 +113,11 @@ export function PreferencesForm({
const [showTransactionSummary, setShowTransactionSummary] = useState(
initialShowTransactionSummary,
);
const [groupTransactionsByDate, setGroupTransactionsByDate] = useState(
initialGroupTransactionsByDate,
);
const [hideAnticipatedInstallments, setHideAnticipatedInstallments] =
useState(initialHideAnticipatedInstallments);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
@@ -135,6 +144,8 @@ export function PreferencesForm({
transactionsColumnOrder: columnOrder,
attachmentMaxSizeMb,
showTransactionSummary,
groupTransactionsByDate,
hideAnticipatedInstallments,
});
if (result.success) {
@@ -198,6 +209,46 @@ export function PreferencesForm({
<Separator />
<section className="flex items-center justify-between max-w-md gap-4">
<div className="space-y-2">
<Label htmlFor="group-transactions-by-date" className="text-sm">
Agrupar por data
</Label>
<p className="text-sm text-muted-foreground">
Mostra uma barra de data acima dos lançamentos daquele dia. Quando
desativado, a data volta a aparecer em cada lançamento.
</p>
</div>
<Switch
id="group-transactions-by-date"
checked={groupTransactionsByDate}
onCheckedChange={setGroupTransactionsByDate}
disabled={isPending}
/>
</section>
<Separator />
<section className="flex items-center justify-between max-w-md gap-4">
<div className="space-y-2">
<Label htmlFor="hide-anticipated-installments" className="text-sm">
Ocultar parcelas antecipadas
</Label>
<p className="text-sm text-muted-foreground">
Quando ativo, parcelas antecipadas não aparecem na tabela de
lançamentos.
</p>
</div>
<Switch
id="hide-anticipated-installments"
checked={hideAnticipatedInstallments}
onCheckedChange={setHideAnticipatedInstallments}
disabled={isPending}
/>
</section>
<Separator />
<section className="space-y-2 max-w-md">
<Label className="text-sm">Ordem das colunas</Label>
<p className="text-sm text-muted-foreground">

View File

@@ -7,6 +7,8 @@ interface UserPreferences {
transactionsColumnOrder: string[] | null;
attachmentMaxSizeMb: number;
showTransactionSummary: boolean;
groupTransactionsByDate: boolean;
hideAnticipatedInstallments: boolean;
}
interface ApiToken {
@@ -36,6 +38,9 @@ export async function fetchUserPreferences(
transactionsColumnOrder: schema.userPreferences.transactionsColumnOrder,
attachmentMaxSizeMb: schema.userPreferences.attachmentMaxSizeMb,
showTransactionSummary: schema.userPreferences.showTransactionSummary,
groupTransactionsByDate: schema.userPreferences.groupTransactionsByDate,
hideAnticipatedInstallments:
schema.userPreferences.hideAnticipatedInstallments,
})
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, userId))

View File

@@ -2,6 +2,7 @@
import { z } from "zod";
import { fetchAccountTransactions } from "@/features/accounts/statement-queries";
import { fetchUserPreferences } from "@/features/settings/queries";
import type { TransactionsExportContext } from "@/features/transactions/lib/export-types";
import {
buildSluggedFilters,
@@ -60,7 +61,10 @@ export async function exportTransactionsDataAction(
try {
const userId = await getUserId();
const validated = exportTransactionsSchema.parse(input);
const filterSources = await fetchTransactionFilterSources(userId);
const [filterSources, userPreferences] = await Promise.all([
fetchTransactionFilterSources(userId),
fetchUserPreferences(userId),
]);
const sluggedFilters = buildSluggedFilters(filterSources);
const slugMaps = buildSlugMaps(sluggedFilters);
@@ -72,6 +76,8 @@ export async function exportTransactionsDataAction(
accountId: validated.accountId ?? undefined,
cardId: validated.cardId ?? undefined,
payerId: validated.payerId ?? undefined,
hideAnticipatedInstallments:
userPreferences?.hideAnticipatedInstallments ?? false,
});
const rows =

View File

@@ -1,6 +1,6 @@
"use server";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
import { z } from "zod";
import { transactions } from "@/db/schema";
import {
@@ -9,23 +9,49 @@ import {
validateCartaoOwnership,
validateContaOwnership,
} from "@/features/transactions/actions/core";
import { createOfxImportFingerprint } from "@/features/transactions/lib/ofx-import-fingerprint";
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
import { getUserId } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import {
normalizeOfxIdentityText,
type OfxIdentityRow,
type OfxImportDestination,
} from "@/shared/lib/import/ofx-identity";
import { uuidSchema } from "@/shared/lib/schemas/common";
import { parseLocalDateString } from "@/shared/utils/date";
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
import { parseLocalDateString, toDateOnlyString } from "@/shared/utils/date";
const importRowSchema = z.object({
const ofxIdentityRowSchema = z.object({
externalId: z.string().nullable(),
externalIdOccurrence: z.number().int().nonnegative(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Data inválida."),
amount: z.number().positive(),
description: z.string().min(1, "Descrição obrigatória."),
transactionType: z.enum(["income", "expense"]),
sourceDescription: z.string(),
});
const ofxImportDestinationSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("account"), id: uuidSchema("Conta") }),
z.object({ type: z.literal("card"), id: uuidSchema("Cartão") }),
]);
const duplicateCheckSchema = z.object({
source: z.string().min(1),
accountNumber: z.string().nullable(),
destination: ofxImportDestinationSchema,
rows: z.array(ofxIdentityRowSchema),
});
const importRowSchema = ofxIdentityRowSchema.extend({
description: z.string().min(1, "Descrição obrigatória."),
categoryId: uuidSchema("Category").nullable().optional(),
payerId: uuidSchema("Payer").nullable().optional(),
});
const importSchema = z.object({
source: z.string().min(1),
accountNumber: z.string().nullable(),
rows: z.array(importRowSchema).min(1, "Selecione ao menos uma transação."),
payerId: uuidSchema("Payer").nullable().optional(),
accountId: uuidSchema("FinancialAccount").nullable().optional(),
@@ -44,22 +70,197 @@ type ImportResult =
| { success: true; imported: number; skipped: number; importBatchId: string }
| { success: false; error: string };
// Retorna os externalIds que já existem para o usuário (para marcar duplicatas)
export async function checkDuplicateFitIds(
fitIds: string[],
): Promise<string[]> {
const userId = await getUserId();
const ids = fitIds.filter(Boolean);
if (ids.length === 0) return [];
type ImportMatch = {
fingerprint: string | null;
existingTransactionId: string | null;
};
const rows = await db
.select({ ofxFitId: transactions.ofxFitId })
.from(transactions)
.where(
and(eq(transactions.userId, userId), inArray(transactions.ofxFitId, ids)),
type LegacyImportCandidate = {
id: string;
name: string;
amount: string;
purchaseDate: Date;
transactionType: string;
ofxFitId: string | null;
accountId: string | null;
cardId: string | null;
};
function isDestinationMatch(
candidate: LegacyImportCandidate,
destination: OfxImportDestination,
): boolean {
return destination.type === "card"
? candidate.cardId === destination.id
: candidate.accountId === destination.id;
}
function isLegacyImportMatch(
candidate: LegacyImportCandidate,
row: OfxIdentityRow,
destination: OfxImportDestination,
): boolean {
if (
!row.externalId ||
row.externalIdOccurrence !== 0 ||
candidate.ofxFitId !== row.externalId ||
!isDestinationMatch(candidate, destination)
) {
return false;
}
const expectedType = row.transactionType === "income" ? "Receita" : "Despesa";
const signedAmount =
row.transactionType === "expense" ? -row.amount : row.amount;
return (
toDateOnlyString(candidate.purchaseDate) === row.date &&
formatDecimalForDbRequired(Number(candidate.amount)) ===
formatDecimalForDbRequired(signedAmount) &&
candidate.transactionType === expectedType &&
normalizeOfxIdentityText(candidate.name) ===
normalizeOfxIdentityText(row.sourceDescription)
);
}
async function findExistingImports(
userId: string,
source: string,
accountNumber: string | null,
destination: OfxImportDestination,
rows: OfxIdentityRow[],
): Promise<ImportMatch[]> {
const fingerprints = rows.map((row) =>
createOfxImportFingerprint({
source,
accountNumber,
destination,
row,
}),
);
const fingerprintValues = [
...new Set(
fingerprints.filter(
(fingerprint): fingerprint is string => fingerprint !== null,
),
),
];
const fitIds = [
...new Set(
rows
.map((row) => row.externalId)
.filter((fitId): fitId is string => fitId !== null),
),
];
const [fingerprintMatches, legacyCandidates] = await Promise.all([
fingerprintValues.length > 0
? db
.select({
id: transactions.id,
fingerprint: transactions.ofxImportFingerprint,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
inArray(transactions.ofxImportFingerprint, fingerprintValues),
),
)
: Promise.resolve([]),
fitIds.length > 0
? db
.select({
id: transactions.id,
name: transactions.name,
amount: transactions.amount,
purchaseDate: transactions.purchaseDate,
transactionType: transactions.transactionType,
ofxFitId: transactions.ofxFitId,
accountId: transactions.accountId,
cardId: transactions.cardId,
})
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
isNull(transactions.ofxImportFingerprint),
inArray(transactions.ofxFitId, fitIds),
),
)
: Promise.resolve([]),
]);
const transactionIdByFingerprint = new Map(
fingerprintMatches.flatMap((match) =>
match.fingerprint ? [[match.fingerprint, match.id] as const] : [],
),
);
const consumedLegacyIds = new Set<string>();
return rows.map((row, index) => {
const fingerprint = fingerprints[index] ?? null;
const currentTransactionId = fingerprint
? (transactionIdByFingerprint.get(fingerprint) ?? null)
: null;
if (currentTransactionId) {
return { fingerprint, existingTransactionId: currentTransactionId };
}
const legacyMatch = legacyCandidates.find(
(candidate) =>
!consumedLegacyIds.has(candidate.id) &&
isLegacyImportMatch(candidate, row, destination),
);
return rows.map((r) => r.ofxFitId).filter((id): id is string => id !== null);
if (legacyMatch) {
consumedLegacyIds.add(legacyMatch.id);
}
return {
fingerprint,
existingTransactionId: legacyMatch?.id ?? null,
};
});
}
async function validateDestinationOwnership(
userId: string,
destination: OfxImportDestination,
): Promise<boolean> {
return destination.type === "card"
? validateCartaoOwnership(userId, destination.id)
: validateContaOwnership(userId, destination.id);
}
export async function checkDuplicateOfxTransactions(
input: unknown,
): Promise<
{ success: true; rows: ImportMatch[] } | { success: false; error: string }
> {
const userId = await getUserId();
const parsed = duplicateCheckSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: "Dados do arquivo inválidos." };
}
const { source, accountNumber, destination, rows } = parsed.data;
if (!(await validateDestinationOwnership(userId, destination))) {
return { success: false, error: "Conta ou cartão não encontrado." };
}
return {
success: true,
rows: await findExistingImports(
userId,
source,
accountNumber,
destination,
rows,
),
};
}
export async function importTransactionsAction(
@@ -75,8 +276,25 @@ export async function importTransactionsAction(
};
}
const { rows, payerId, accountId, cardId, paymentMethod, invoicePeriod } =
parsed.data;
const {
source,
accountNumber,
rows,
payerId,
accountId,
cardId,
paymentMethod,
invoicePeriod,
} = parsed.data;
const destination: OfxImportDestination | null = cardId
? { type: "card", id: cardId }
: accountId
? { type: "account", id: accountId }
: null;
if (!destination || (accountId && cardId)) {
return { success: false, error: "Selecione uma conta ou cartão." };
}
const payerIdsByRow = rows.map((row) => row.payerId ?? payerId ?? null);
@@ -109,8 +327,33 @@ export async function importTransactionsAction(
if (!accountOk) return { success: false, error: "Conta não encontrada." };
if (!cardOk) return { success: false, error: "Cartão não encontrado." };
if (rows.length === 0) {
return { success: true, imported: 0, skipped: 0, importBatchId: "" };
const importMatches = await findExistingImports(
userId,
source,
accountNumber,
destination,
rows,
);
const rowsToImport = rows.flatMap((row, index) => {
const match = importMatches[index];
return match?.existingTransactionId
? []
: [
{
row,
payerId: payerIdsByRow[index],
fingerprint: match?.fingerprint ?? null,
},
];
});
if (rowsToImport.length === 0) {
return {
success: true,
imported: 0,
skipped: rows.length,
importBatchId: "",
};
}
const importBatchId = crypto.randomUUID();
@@ -118,7 +361,7 @@ export async function importTransactionsAction(
// Cartão de crédito: fatura pode ainda não ter sido paga
const isSettled = paymentMethod !== "Cartão de crédito";
const records = rows.map((row, index) => {
const records = rowsToImport.map(({ row, payerId, fingerprint }) => {
const purchaseDate = parseLocalDateString(row.date);
const period =
invoicePeriod ??
@@ -137,21 +380,24 @@ export async function importTransactionsAction(
period,
isSettled,
userId,
payerId: payerIdsByRow[index],
payerId,
accountId: accountId ?? null,
cardId: cardId ?? null,
categoryId: row.categoryId ?? null,
ofxFitId: row.externalId,
ofxImportFingerprint: fingerprint,
importBatchId,
};
});
// onConflictDoNothing usa o uniqueIndex (userId, ofxFitId) WHERE ofxFitId IS NOT NULL
// eliminando o SELECT prévio de checkDuplicateFitIds
// O índice de fingerprint protege contra importações concorrentes do mesmo OFX.
const inserted = await db
.insert(transactions)
.values(records)
.onConflictDoNothing()
.onConflictDoNothing({
target: [transactions.userId, transactions.ofxImportFingerprint],
where: sql`ofx_import_fingerprint IS NOT NULL`,
})
.returning({ id: transactions.id });
await revalidateForEntity("transactions", userId);
@@ -159,23 +405,31 @@ export async function importTransactionsAction(
return {
success: true,
imported: inserted.length,
skipped: records.length - inserted.length,
skipped: rows.length - inserted.length,
importBatchId,
};
}
export async function deleteTransactionByFitId(
fitId: string,
export async function deleteImportedTransaction(
transactionId: string,
): Promise<{ success: boolean; error?: string }> {
if (!fitId) return { success: false, error: "FITID inválido." };
const parsedId = uuidSchema("Lançamento").safeParse(transactionId);
if (!parsedId.success) {
return { success: false, error: "Lançamento inválido." };
}
const userId = await getUserId();
await db
const deleted = await db
.delete(transactions)
.where(
and(eq(transactions.userId, userId), eq(transactions.ofxFitId, fitId)),
);
and(eq(transactions.userId, userId), eq(transactions.id, parsedId.data)),
)
.returning({ id: transactions.id });
if (deleted.length === 0) {
return { success: false, error: "Lançamento não encontrado." };
}
await revalidateForEntity("transactions", userId);

View File

@@ -72,7 +72,7 @@ function InlinePeriodPicker({
return (
<div className="-mt-1">
<span className="text-xs text-muted-foreground">Fatura de </span>
<Popover open={open} onOpenChange={setOpen}>
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"

View File

@@ -45,7 +45,7 @@ function InlinePeriodPicker({
return (
<div className="ml-1">
<span className="text-xs text-muted-foreground">Fatura de </span>
<Popover open={open} onOpenChange={setOpen}>
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"

View File

@@ -1,21 +1,15 @@
"use client";
import { useRouter } from "next/navigation";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { useCallback, useMemo, useRef, useState, useTransition } from "react";
import { toast } from "sonner";
import {
fetchCategoryMappings,
saveCategoryMappings,
} from "@/features/transactions/actions/category-memory-action";
import {
checkDuplicateFitIds,
deleteTransactionByFitId,
checkDuplicateOfxTransactions,
deleteImportedTransaction,
importTransactionsAction,
undoImportAction,
} from "@/features/transactions/actions/import-action";
@@ -43,6 +37,7 @@ import {
} from "@/shared/components/ui/card";
import { Skeleton } from "@/shared/components/ui/skeleton";
import type { ImportStatement } from "@/shared/lib/import/types";
import { createClientSafeId } from "@/shared/utils/id";
const categoryGroupByTransactionType = {
expense: "despesa",
@@ -69,6 +64,7 @@ export function ImportPage({
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isChecking, setIsChecking] = useState(false);
const duplicateCheckRequestId = useRef(0);
const [statement, setStatement] = useState<ImportStatement | null>(null);
const [rows, setRows] = useState<ReviewRow[]>([]);
@@ -95,23 +91,45 @@ export function ImportPage({
const handleParsed = useCallback(
async (stmt: ImportStatement) => {
const requestId = ++duplicateCheckRequestId.current;
setStatement(stmt);
setIsChecking(true);
const defaultAccountCardValue = stmt.isCreditCard
? cardOptions[0]
? encodeAccountCard("card", cardOptions[0].value)
: null
: accountOptions[0]
? encodeAccountCard("account", accountOptions[0].value)
: null;
const destination = defaultAccountCardValue
? decodeAccountCard(defaultAccountCardValue)
: null;
setAccountCardValue(defaultAccountCardValue);
try {
const fitIds = stmt.transactions
.map((t) => t.externalId)
.filter((id): id is string => id !== null);
const [duplicates, categoryMappings] = await Promise.all([
checkDuplicateFitIds(fitIds).then((ids) => new Set(ids)),
const [duplicateResult, categoryMappings] = await Promise.all([
destination
? checkDuplicateOfxTransactions({
source: stmt.source,
accountNumber: stmt.accountNumber,
destination,
rows: stmt.transactions,
})
: Promise.resolve({ success: true as const, rows: [] }),
fetchCategoryMappings(stmt.transactions.map((t) => t.description)),
]);
if (requestId !== duplicateCheckRequestId.current) return;
if (!duplicateResult.success) {
toast.error(duplicateResult.error);
}
setRows(
stmt.transactions.map((t) => {
stmt.transactions.map((t, index) => {
let mappedCategoryId =
categoryMappings[normalizeDescriptionKey(t.description)] ?? null;
const existingTransactionId = duplicateResult.success
? (duplicateResult.rows[index]?.existingTransactionId ?? null)
: null;
if (t.categoryRaw) {
const categoryRaw = normalizeCategoryName(t.categoryRaw);
@@ -125,8 +143,10 @@ export function ImportPage({
return {
...t,
isDuplicate: t.externalId ? duplicates.has(t.externalId) : false,
selected: t.externalId ? !duplicates.has(t.externalId) : true,
reviewId: createClientSafeId(),
existingTransactionId,
isDuplicate: existingTransactionId !== null,
selected: existingTransactionId === null,
payerId,
categoryId: isCategoryCompatible(
mappedCategoryId,
@@ -138,23 +158,64 @@ export function ImportPage({
}),
);
} finally {
setIsChecking(false);
if (requestId === duplicateCheckRequestId.current) {
setIsChecking(false);
}
}
},
[isCategoryCompatible, payerId, categoryOptions],
[
accountOptions,
cardOptions,
categoryOptions,
isCategoryCompatible,
payerId,
],
);
// Pré-seleciona cartão ou conta com base no tipo detectado no OFX
useEffect(() => {
if (!statement || accountCardValue) return;
if (statement.isCreditCard && cardOptions[0]) {
setAccountCardValue(encodeAccountCard("card", cardOptions[0].value));
} else if (!statement.isCreditCard && accountOptions[0]) {
setAccountCardValue(
encodeAccountCard("account", accountOptions[0].value),
);
const handleAccountCardChange = async (value: string | null) => {
const requestId = ++duplicateCheckRequestId.current;
setAccountCardValue(value);
const destination = value ? decodeAccountCard(value) : null;
if (!statement || !destination) {
setIsChecking(false);
return;
}
}, [statement, cardOptions, accountOptions, accountCardValue]);
setIsChecking(true);
try {
const result = await checkDuplicateOfxTransactions({
source: statement.source,
accountNumber: statement.accountNumber,
destination,
rows,
});
if (requestId !== duplicateCheckRequestId.current) return;
if (!result.success) {
toast.error(result.error);
return;
}
setRows((previousRows) =>
previousRows.map((row, index) => {
const existingTransactionId =
result.rows[index]?.existingTransactionId ?? null;
const isDuplicate = existingTransactionId !== null;
return {
...row,
existingTransactionId,
selected:
row.isDuplicate === isDuplicate ? row.selected : !isDuplicate,
isDuplicate,
};
}),
);
} finally {
if (requestId === duplicateCheckRequestId.current) {
setIsChecking(false);
}
}
};
const toggleRow = (index: number) => {
setRows((prev) =>
@@ -184,9 +245,9 @@ export function ImportPage({
const handleUndoDuplicate = async (index: number) => {
const row = rows[index];
if (!row?.externalId) return;
if (!row?.existingTransactionId) return;
const result = await deleteTransactionByFitId(row.externalId);
const result = await deleteImportedTransaction(row.existingTransactionId);
if (!result.success) {
toast.error("Não foi possível desfazer a importação anterior.");
return;
@@ -194,7 +255,14 @@ export function ImportPage({
setRows((prev) =>
prev.map((r, i) =>
i === index ? { ...r, isDuplicate: false, selected: true } : r,
i === index
? {
...r,
existingTransactionId: null,
isDuplicate: false,
selected: true,
}
: r,
),
);
toast.success("Importação anterior removida.");
@@ -261,11 +329,15 @@ export function ImportPage({
startTransition(async () => {
const result = await importTransactionsAction({
source: statement.source,
accountNumber: statement.accountNumber,
rows: selectedRows.map((r) => ({
externalId: r.externalId,
externalIdOccurrence: r.externalIdOccurrence,
date: r.date,
amount: r.amount,
description: r.description,
sourceDescription: r.sourceDescription,
transactionType: r.transactionType,
categoryId: r.categoryId,
payerId: r.payerId,
@@ -369,7 +441,7 @@ export function ImportPage({
accountCardValue={accountCardValue}
payerId={payerId}
invoicePeriod={invoicePeriod}
onAccountCardChange={setAccountCardValue}
onAccountCardChange={handleAccountCardChange}
onPayerChange={handleBulkPayerChange}
onInvoicePeriodChange={setInvoicePeriod}
onBulkCategoryChange={handleBulkCategoryChange}

View File

@@ -43,8 +43,10 @@ const categoryGroupByTransactionType: Record<
};
export type ReviewRow = ImportedTransaction & {
reviewId: string;
selected: boolean;
isDuplicate: boolean;
existingTransactionId: string | null;
categoryId: string | null;
payerId: string | null;
};
@@ -80,6 +82,7 @@ export function ReviewTable({
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
getItemKey: (index) => rows[index]?.reviewId ?? index,
estimateSize: () => 44,
overscan: 8,
});
@@ -141,7 +144,7 @@ export function ReviewTable({
);
return (
<TableRow
key={row.externalId ?? `${row.date}-${index}`}
key={row.reviewId}
className={
row.isDuplicate && !row.selected ? "opacity-50" : ""
}

View File

@@ -82,6 +82,7 @@ interface TransactionsPageProps {
allowCreate?: boolean;
noteAsColumn?: boolean;
columnOrder?: string[] | null;
groupTransactionsByDate?: boolean;
defaultCardId?: string | null;
defaultPaymentMethod?: string | null;
lockCardSelection?: boolean;
@@ -119,6 +120,7 @@ export function TransactionsPage({
allowCreate = true,
noteAsColumn = false,
columnOrder = null,
groupTransactionsByDate = true,
defaultCardId,
defaultPaymentMethod,
lockCardSelection,
@@ -745,6 +747,7 @@ export function TransactionsPage({
currentUserId={currentUserId}
noteAsColumn={noteAsColumn}
columnOrder={columnOrder}
groupTransactionsByDate={groupTransactionsByDate}
payerFilterOptions={payerFilterOptions}
categoryFilterOptions={categoryFilterOptions}
accountCardFilterOptions={accountCardFilterOptions}

View File

@@ -51,6 +51,7 @@ type BuildColumnsArgs = {
onConvertToRecurring?: (item: TransactionItem) => void;
isSettlementLoading: (id: string) => boolean;
showActions: boolean;
showDateGroups: boolean;
columnOrder?: string[] | null;
};
@@ -115,6 +116,7 @@ function buildColumns({
onConvertToRecurring,
isSettlementLoading,
showActions,
showDateGroups,
}: BuildColumnsArgs): ColumnDef<TransactionItem>[] {
const noop = () => undefined;
const handleEdit = onEdit ?? noop;
@@ -194,12 +196,14 @@ function buildColumns({
<span className="flex items-center gap-2">
<EstablishmentLogo name={name} size={32} />
<span className="flex flex-col py-0.5">
<span className="text-xs text-muted-foreground flex items-center gap-2">
{formatDate(purchaseDate)}
{dueDateLabel ? (
<span className="text-primary">{dueDateLabel}</span>
) : null}
</span>
{showDateGroups ? null : (
<span className="text-xs text-muted-foreground flex items-center gap-2">
{formatDate(purchaseDate)}
{dueDateLabel ? (
<span className="text-primary">{dueDateLabel}</span>
) : null}
</span>
)}
<span className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
@@ -254,6 +258,15 @@ function buildColumns({
</Badge>
) : null}
{showDateGroups && dueDateLabel ? (
<Badge
variant="outline"
className="px-2 text-xs text-primary"
>
{dueDateLabel}
</Badge>
) : null}
{isAnticipated && (
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -20,7 +20,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { formatDate } from "@/shared/utils/date";
import { formatDate, formatDateGroupLabel } from "@/shared/utils/date";
import { getConditionIcon, getPaymentMethodIcon } from "@/shared/utils/icons";
import { cn } from "@/shared/utils/ui";
import type { TransactionItem } from "../types";
@@ -43,6 +43,7 @@ type TransactionsMobileListProps = {
onConvertToRecurring?: (item: TransactionItem) => void;
isSettlementLoading: (id: string) => boolean;
showActions?: boolean;
showDateGroups?: boolean;
};
export function TransactionsMobileList({
@@ -61,28 +62,87 @@ export function TransactionsMobileList({
onConvertToRecurring,
isSettlementLoading,
showActions = true,
showDateGroups = true,
}: TransactionsMobileListProps) {
const groups = data.reduce<
Array<{ date: string; label: string; items: TransactionItem[] }>
>((acc, item) => {
const date = item.purchaseDate?.slice(0, 10) ?? "";
const existingGroup = acc.find((group) => group.date === date);
if (existingGroup) {
existingGroup.items.push(item);
return acc;
}
acc.push({
date,
label: formatDateGroupLabel(item.purchaseDate),
items: [item],
});
return acc;
}, []);
if (!showDateGroups) {
return (
<div className="space-y-3 md:hidden">
{data.map((item) => (
<TransactionMobileCard
key={item.id}
item={item}
currentUserId={currentUserId}
onEdit={onEdit}
onCopy={onCopy}
onImport={onImport}
onConfirmDelete={onConfirmDelete}
onViewDetails={onViewDetails}
onRefund={onRefund}
onToggleSettlement={onToggleSettlement}
onAnticipate={onAnticipate}
onViewAnticipationHistory={onViewAnticipationHistory}
onConvertToInstallment={onConvertToInstallment}
onConvertToRecurring={onConvertToRecurring}
isSettlementLoading={isSettlementLoading}
showActions={showActions}
showDate
/>
))}
</div>
);
}
return (
<div className="space-y-3 md:hidden">
{data.map((item) => (
<TransactionMobileCard
key={item.id}
item={item}
currentUserId={currentUserId}
onEdit={onEdit}
onCopy={onCopy}
onImport={onImport}
onConfirmDelete={onConfirmDelete}
onViewDetails={onViewDetails}
onRefund={onRefund}
onToggleSettlement={onToggleSettlement}
onAnticipate={onAnticipate}
onViewAnticipationHistory={onViewAnticipationHistory}
onConvertToInstallment={onConvertToInstallment}
onConvertToRecurring={onConvertToRecurring}
isSettlementLoading={isSettlementLoading}
showActions={showActions}
/>
<div className="space-y-4 md:hidden">
{groups.map((group, groupIndex) => (
<section
key={`${group.date || group.label}-${groupIndex}`}
className="space-y-2"
>
<div className="rounded-md border bg-muted/60 px-3 py-1.5 text-xs font-semibold tracking-wide text-muted-foreground">
{group.label}
</div>
<div className="space-y-3">
{group.items.map((item) => (
<TransactionMobileCard
key={item.id}
item={item}
currentUserId={currentUserId}
onEdit={onEdit}
onCopy={onCopy}
onImport={onImport}
onConfirmDelete={onConfirmDelete}
onViewDetails={onViewDetails}
onRefund={onRefund}
onToggleSettlement={onToggleSettlement}
onAnticipate={onAnticipate}
onViewAnticipationHistory={onViewAnticipationHistory}
onConvertToInstallment={onConvertToInstallment}
onConvertToRecurring={onConvertToRecurring}
isSettlementLoading={isSettlementLoading}
showActions={showActions}
/>
))}
</div>
</section>
))}
</div>
);
@@ -90,6 +150,7 @@ export function TransactionsMobileList({
type TransactionMobileCardProps = Omit<TransactionsMobileListProps, "data"> & {
item: TransactionItem;
showDate?: boolean;
};
function TransactionMobileCard({
@@ -108,6 +169,7 @@ function TransactionMobileCard({
onConvertToRecurring,
isSettlementLoading,
showActions = true,
showDate = false,
}: TransactionMobileCardProps) {
const installmentBadge =
item.currentInstallment && item.installmentCount
@@ -156,10 +218,12 @@ function TransactionMobileCard({
{item.name}
</h3>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<RiCalendarEventLine className="size-3.5" aria-hidden />
{formatDate(item.purchaseDate)}
</span>
{showDate ? (
<span className="inline-flex items-center gap-1">
<RiCalendarEventLine className="size-3.5" aria-hidden />
{formatDate(item.purchaseDate)}
</span>
) : null}
{dueDateLabel ? (
<span className="font-medium text-primary">
{dueDateLabel}

View File

@@ -9,13 +9,14 @@ import {
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type Row,
type RowSelectionState,
type SortingState,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { type ReactNode, useMemo, useState } from "react";
import { Fragment, type ReactNode, useMemo, useState } from "react";
import type {
TransactionsExportContext,
TransactionsPaginationState,
@@ -37,6 +38,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { formatDateGroupLabel } from "@/shared/utils/date";
import { cn } from "@/shared/utils/ui";
import { TransactionsExport } from "../transactions-export";
import type {
@@ -79,6 +81,7 @@ type TransactionsTableProps = {
isSettlementLoading?: (id: string) => boolean;
showActions?: boolean;
showFilters?: boolean;
groupTransactionsByDate?: boolean;
};
export function TransactionsTable({
@@ -110,6 +113,7 @@ export function TransactionsTable({
isSettlementLoading,
showActions = true,
showFilters = true,
groupTransactionsByDate = true,
}: TransactionsTableProps) {
const router = useRouter();
const pathname = usePathname();
@@ -145,12 +149,14 @@ export function TransactionsTable({
onViewAnticipationHistory,
isSettlementLoading: isSettlementLoading ?? (() => false),
showActions,
showDateGroups: groupTransactionsByDate,
columnOrder: columnOrderPreference,
}),
[
currentUserId,
noteAsColumn,
columnOrderPreference,
groupTransactionsByDate,
onEdit,
onCopy,
onImport,
@@ -191,6 +197,24 @@ export function TransactionsTable({
const rowModel = table.getRowModel();
const hasRows = rowModel.rows.length > 0;
const groupedRows = rowModel.rows.reduce<
Array<{ date: string; label: string; rows: Row<TransactionItem>[] }>
>((acc, row) => {
const date = row.original.purchaseDate?.slice(0, 10) ?? "";
const existingGroup = acc.find((group) => group.date === date);
if (existingGroup) {
existingGroup.rows.push(row);
return acc;
}
acc.push({
date,
label: formatDateGroupLabel(row.original.purchaseDate),
rows: [row],
});
return acc;
}, []);
const visibleColumnCount = table.getVisibleLeafColumns().length;
const totalRows = isServerPaginated
? (serverPagination?.totalItems ?? 0)
: table.getCoreRowModel().rows.length;
@@ -275,6 +299,25 @@ export function TransactionsTable({
const showTopControls =
Boolean(createSlot) || Boolean(onMassAdd) || showFilters;
const renderTransactionRow = (row: Row<TransactionItem>) => (
<TableRow
key={row.id}
className={cn(
row.original.paymentMethod === "Boleto" &&
row.original.dueDate &&
!row.original.isSettled &&
new Date(row.original.dueDate) < new Date()
? "bg-destructive/3 hover:bg-destructive/5"
: undefined,
)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
);
return (
<TooltipProvider>
@@ -366,7 +409,7 @@ export function TransactionsTable({
) : null}
<Card className="py-2">
<CardContent className="px-2 py-4 sm:px-4">
<CardContent className="px-2 sm:px-4">
{hasRows ? (
<>
<TransactionsMobileList
@@ -383,6 +426,7 @@ export function TransactionsTable({
onViewAnticipationHistory={onViewAnticipationHistory}
isSettlementLoading={isSettlementLoading ?? (() => false)}
showActions={showActions}
showDateGroups={groupTransactionsByDate}
/>
<div className="hidden overflow-x-auto md:block">
@@ -407,28 +451,23 @@ export function TransactionsTable({
))}
</TableHeader>
<TableBody>
{rowModel.rows.map((row) => (
<TableRow
key={row.id}
className={cn(
row.original.paymentMethod === "Boleto" &&
row.original.dueDate &&
!row.original.isSettled &&
new Date(row.original.dueDate) < new Date()
? "bg-destructive/3 hover:bg-destructive/5"
: undefined,
)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))}
{groupTransactionsByDate
? groupedRows.map((group, groupIndex) => (
<Fragment
key={`${group.date || group.label}-${groupIndex}`}
>
<TableRow className="border-y bg-muted/40 hover:bg-muted/60">
<TableCell
colSpan={visibleColumnCount}
className="h-9 px-3 py-2 text-xs font-semibold text-muted-foreground"
>
{group.label}
</TableCell>
</TableRow>
{group.rows.map(renderTransactionRow)}
</Fragment>
))
: rowModel.rows.map(renderTransactionRow)}
</TableBody>
</Table>
</div>

View File

@@ -1,13 +1,7 @@
export const ALLOWED_MIME_TYPES = [
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
] as const;
export const DEFAULT_MAX_FILE_SIZE_MB = 50;
export const MAX_FILE_SIZE = DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024; // 50MB (fallback)
export const ATTACHMENT_SIZE_OPTIONS = [5, 10, 25, 50, 100] as const;
export type AttachmentSizeOption = (typeof ATTACHMENT_SIZE_OPTIONS)[number];
export {
ALLOWED_MIME_TYPES,
ATTACHMENT_SIZE_OPTIONS,
type AttachmentSizeOption,
DEFAULT_MAX_FILE_SIZE_MB,
MAX_FILE_SIZE,
} from "@/shared/lib/attachments/config";

View File

@@ -0,0 +1,24 @@
import "server-only";
import { createHash } from "node:crypto";
import {
buildOfxFingerprintPayload,
type OfxIdentityRow,
type OfxImportDestination,
} from "@/shared/lib/import/ofx-identity";
type CreateOfxImportFingerprintInput = {
source: string;
accountNumber: string | null;
destination: OfxImportDestination;
row: OfxIdentityRow;
};
export function createOfxImportFingerprint(
input: CreateOfxImportFingerprintInput,
): string | null {
const payload = buildOfxFingerprintPayload(input);
if (!payload) return null;
return createHash("sha256").update(payload).digest("hex");
}

View File

@@ -6,6 +6,7 @@ import {
ilike,
inArray,
isNotNull,
isNull,
lte,
or,
sql,
@@ -384,6 +385,7 @@ export const buildTransactionWhere = ({
cardId,
accountId,
payerId,
hideAnticipatedInstallments = false,
}: {
userId: string;
period: string;
@@ -392,6 +394,7 @@ export const buildTransactionWhere = ({
cardId?: string;
accountId?: string;
payerId?: string;
hideAnticipatedInstallments?: boolean;
}): SQL[] => {
const where: SQL[] = [eq(transactions.userId, userId)];
@@ -421,6 +424,15 @@ export const buildTransactionWhere = ({
where.push(eq(transactions.payerId, payerId));
}
if (hideAnticipatedInstallments) {
where.push(
or(
isNull(transactions.isAnticipated),
eq(transactions.isAnticipated, false),
) as SQL,
);
}
if (cardId) {
where.push(eq(transactions.cardId, cardId));
}

View File

@@ -102,7 +102,7 @@ export function EstablishmentLogoPicker({
}
return (
<Popover open={open} onOpenChange={onOpenChange}>
<Popover modal open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent className="w-80 p-3" align="start" side="bottom">
<p className="mb-2 text-muted-foreground text-xs">

View File

@@ -0,0 +1,38 @@
"use client";
import { RiErrorWarningLine } from "@remixicon/react";
import { catchError, type ErrorInfo } from "next/error";
import { EmptyState } from "@/shared/components/feedback/empty-state";
import { Button } from "@/shared/components/ui/button";
type ContentErrorBoundaryProps = {
title: string;
description: string;
};
function ContentErrorFallback(
{ title, description }: ContentErrorBoundaryProps,
{ retry }: ErrorInfo,
) {
return (
<div
role="alert"
className="flex min-h-64 w-full items-center justify-center"
>
<EmptyState
title={title}
description={description}
media={<RiErrorWarningLine className="size-5 text-destructive" />}
mediaVariant="icon"
className="min-h-64 border border-dashed"
>
<Button type="button" variant="outline" onClick={retry}>
Tentar novamente
</Button>
</EmptyState>
</div>
);
}
export const ContentErrorBoundary =
catchError<ContentErrorBoundaryProps>(ContentErrorFallback);

View File

@@ -93,6 +93,7 @@ export default function MonthNavigation() {
<Popover open={isPickerOpen} onOpenChange={setIsPickerOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
disabled={isPending}

View File

@@ -44,9 +44,10 @@ export function PeriodPicker({
};
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant={variant}
size={size}
disabled={disabled}

View File

@@ -197,6 +197,7 @@ function CalendarDayButton({
return (
<Button
ref={ref}
type="button"
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}

View File

@@ -13,7 +13,7 @@ function Checkbox({
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground data-[state=indeterminate]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-lg border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"peer border-input dark:bg-input/40 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground data-[state=indeterminate]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-3.5 shrink-0 rounded border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}

View File

@@ -148,7 +148,7 @@ export function DatePicker({
required={required}
disabled={disabled}
/>
<Popover open={open} onOpenChange={setOpen}>
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"

View File

@@ -128,6 +128,7 @@ function MonthCal({
</div>
<div className="space-x-1 flex items-center">
<button
type="button"
onClick={() => {
setMenuYear(menuYear - 1);
if (onYearBackward) onYearBackward();
@@ -140,6 +141,7 @@ function MonthCal({
<RiArrowLeftSFill className="opacity-50 size-4" />
</button>
<button
type="button"
onClick={() => {
setMenuYear(menuYear + 1);
if (onYearForward) onYearForward();
@@ -165,6 +167,7 @@ function MonthCal({
className="h-10 w-1/4 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20"
>
<button
type="button"
onClick={() => {
setMonth(m.number);
setYear(menuYear);

View File

@@ -0,0 +1,11 @@
export const ALLOWED_MIME_TYPES = [
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
] as const;
export const DEFAULT_MAX_FILE_SIZE_MB = 50;
export const ATTACHMENT_SIZE_OPTIONS = [5, 10, 25, 50, 100] as const;
export type AttachmentSizeOption = (typeof ATTACHMENT_SIZE_OPTIONS)[number];
export const MAX_FILE_SIZE = DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024;

View File

@@ -0,0 +1,69 @@
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
export type OfxImportDestination = {
type: "account" | "card";
id: string;
};
export type OfxIdentityRow = {
externalId: string | null;
externalIdOccurrence: number;
date: string;
amount: number;
transactionType: "income" | "expense";
sourceDescription: string;
};
type OfxIdentityInput = {
source: string;
accountNumber: string | null;
destination: OfxImportDestination;
row: OfxIdentityRow;
};
export function normalizeOfxIdentityText(value: string): string {
return value.normalize("NFKC").trim().replace(/\s+/g, " ").toLowerCase();
}
export function buildOfxOccurrenceKey(
row: Omit<OfxIdentityRow, "externalIdOccurrence">,
): string | null {
if (!row.externalId) return null;
const signedAmount =
row.transactionType === "expense" ? -row.amount : row.amount;
return JSON.stringify([
row.externalId.trim(),
row.date,
formatDecimalForDbRequired(signedAmount),
row.transactionType,
normalizeOfxIdentityText(row.sourceDescription),
]);
}
export function buildOfxFingerprintPayload({
source,
accountNumber,
destination,
row,
}: OfxIdentityInput): string | null {
if (!row.externalId) return null;
const signedAmount =
row.transactionType === "expense" ? -row.amount : row.amount;
return JSON.stringify([
"openmonetis-ofx-v1",
normalizeOfxIdentityText(source),
normalizeOfxIdentityText(accountNumber ?? ""),
destination.type,
destination.id,
row.externalId.trim(),
row.date,
formatDecimalForDbRequired(signedAmount),
row.transactionType,
normalizeOfxIdentityText(row.sourceDescription),
row.externalIdOccurrence,
]);
}

View File

@@ -1,3 +1,4 @@
import { buildOfxOccurrenceKey } from "./ofx-identity";
import type { ImportedTransaction, ImportStatement } from "./types";
// Extrai o valor de uma tag leaf do OFX SGML: <TAG>valor
@@ -32,6 +33,7 @@ export function parseOfx(content: string): ImportStatement {
// Transações
const blocks = xml.match(/<STMTTRN>[\s\S]*?<\/STMTTRN>/g) ?? [];
const occurrenceCounts = new Map<string, number>();
const transactions: ImportedTransaction[] = blocks.map((block) => {
const trnType = getField(block, "TRNTYPE") ?? "DEBIT";
const dtPosted = getField(block, "DTPOSTED") ?? "";
@@ -41,16 +43,30 @@ export function parseOfx(content: string): ImportStatement {
const name = getField(block, "NAME");
const amount = Number.parseFloat(trnAmt.replace(",", "."));
const transactionType =
const transactionType: ImportedTransaction["transactionType"] =
amount > 0 || trnType === "CREDIT" ? "income" : "expense";
return {
const description = memo ?? name ?? "";
const transaction = {
externalId: fitId,
date: parseOfxDate(dtPosted),
amount: Math.abs(amount),
description: memo ?? name ?? "",
description,
sourceDescription: description,
transactionType,
};
const occurrenceKey = buildOfxOccurrenceKey(transaction);
const externalIdOccurrence = occurrenceKey
? (occurrenceCounts.get(occurrenceKey) ?? 0)
: 0;
if (occurrenceKey) {
occurrenceCounts.set(occurrenceKey, externalIdOccurrence + 1);
}
return {
...transaction,
externalIdOccurrence,
};
});
const isCreditCard = xml.includes("<CREDITCARDMSGSRSV1>");

View File

@@ -1,8 +1,10 @@
export type ImportedTransaction = {
externalId: string | null; // FITID do OFX
externalIdOccurrence: number; // posição entre registros OFX idênticos
date: string; // YYYY-MM-DD
amount: number; // positivo = receita, negativo = despesa
description: string; // MEMO ou NAME
sourceDescription: string; // descrição original, preservada para deduplicação
transactionType: "income" | "expense";
categoryRaw?: string | null;
};

View File

@@ -105,9 +105,11 @@ export async function parseXls(buffer: ArrayBuffer): Promise<ImportStatement> {
transactions.push({
externalId: null,
externalIdOccurrence: 0,
date,
amount,
description,
sourceDescription: description,
transactionType,
categoryRaw,
});

View File

@@ -331,6 +331,45 @@ export function formatDate(value: string | Date | null | undefined): string {
.replace(" de", "");
}
/**
* Formats a date-only value as a compact group label.
* @example
* formatDateGroupLabel("2026-06-26") // "SEX, 26 JUN 2026"
*/
export function formatDateGroupLabel(
value: string | Date | null | undefined,
): string {
const dateString = toDateOnlyString(value);
if (!dateString) {
return "—";
}
const parsed = parseUtcDateString(dateString);
if (!parsed) {
return "—";
}
const parts = new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
month: "short",
year: "numeric",
timeZone: "UTC",
}).formatToParts(parsed);
const weekday = parts.find((part) => part.type === "weekday")?.value;
const day = parts.find((part) => part.type === "day")?.value;
const month = parts.find((part) => part.type === "month")?.value;
const year = parts.find((part) => part.type === "year")?.value;
if (!weekday || !day || !month || !year) {
return "—";
}
return `${weekday.replace(".", "").toUpperCase()}, ${day} ${month
.replace(".", "")
.toUpperCase()} ${year}`;
}
/**
* Formats a date-only value (YYYY-MM-DD) using UTC to preserve the civil day
*/