Compare commits

..

65 Commits

Author SHA1 Message Date
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
Felipe Coutinho
129295d2e2 chore: prepara versao 2.7.7 2026-06-20 14:57:18 -03:00
Yuri Argolo
4b5cdf81b8 feat(lançamentos): ações de converter transações existentes em parceladas e recorrentes 2026-06-20 11:56:54 -03:00
Yuri Argolo
558197e870 feat: mapeamento automatico de categoria na importacao de planilhas 2026-06-20 11:56:54 -03:00
Felipe Coutinho
2fd6e3c323 Prepara versao 2.7.5 2026-06-13 11:05:00 -03:00
Felipe Coutinho
833845b5cf Ajusta widgets do dashboard 2026-06-13 11:04:54 -03:00
Felipe Coutinho
4cbdddb12e corrige importacao de lancamentos compartilhados 2026-06-09 20:41:41 -03:00
Felipe Coutinho
c81584095b docs: informa manutencao reduzida do projeto 2026-06-06 17:06:18 -03:00
Felipe Coutinho
8ccc4479be docs: registra release 2.7.3 2026-06-06 16:31:57 -03:00
Felipe Coutinho
2cead626ab chore: atualiza dependencias e build de PDF 2026-06-06 16:31:42 -03:00
Felipe Coutinho
811a035cb0 refactor: remove exports internos sem uso 2026-06-06 16:31:38 -03:00
Felipe Coutinho
356801324c feat: destaca fatura paga nos cartoes 2026-06-06 16:31:33 -03:00
Felipe Coutinho
b443fb010a config: adiciona origins confiaveis do Better Auth 2026-06-06 16:31:27 -03:00
Felipe Coutinho
026dff5399 Merge pull request #78 from felipegcoutinho/imgbot
[ImgBot] Optimize images
2026-05-31 18:16:42 -03:00
ImgBotApp
18b6a6a470 [ImgBot] Optimize images
*Total -- 1,696.54kb -> 1,060.14kb (37.51%)

/public/logos/dinheiro.png -- 72.04kb -> 17.17kb (76.17%)
/public/images/dashboard-preview-dark.png -- 597.68kb -> 350.27kb (41.4%)
/public/images/dashboard-preview-light.png -- 589.20kb -> 355.11kb (39.73%)
/public/avatars/default_icon.png -- 5.41kb -> 3.37kb (37.69%)
/public/logos/bipa.png -- 47.72kb -> 33.57kb (29.64%)
/public/images/pwa-preview-dark.png -- 181.92kb -> 138.71kb (23.75%)
/public/images/pwa-preview-light.png -- 177.18kb -> 136.59kb (22.91%)
/public/images/logo_small.svg -- 0.39kb -> 0.38kb (1.99%)
/public/images/logo_text.svg -- 6.74kb -> 6.73kb (0.12%)
/public/providers/minimax.svg -- 1.52kb -> 1.52kb (0.06%)
/public/providers/ollama_dark.svg -- 8.36kb -> 8.36kb (0.06%)
/public/providers/ollama_light.svg -- 8.36kb -> 8.36kb (0.06%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2026-05-31 21:11:31 +00:00
Felipe Coutinho
78e778311d chore(release): prepara versao 2.7.2 2026-05-31 18:06:04 -03:00
Felipe Coutinho
78c3ed5995 chore(release): prepara versao 2.7.1 2026-05-31 15:19:15 -03:00
Felipe Coutinho
c34adba587 style(inbox): padroniza acao de lancar notificacoes 2026-05-31 15:19:07 -03:00
Felipe Coutinho
99bc049cf4 fix(boletos): diferencia pagamentos e recebimentos 2026-05-31 15:18:55 -03:00
Felipe Coutinho
35abe1b0bf feat(dashboard): refina experiencia dos widgets 2026-05-31 15:18:43 -03:00
Felipe Coutinho
402f0072af feat(lancamentos): melhora painel de filtros ativos 2026-05-31 15:18:23 -03:00
Felipe Coutinho
02ee5bb758 feat(navegacao): adiciona atalhos financeiros e seletor mensal 2026-05-31 15:18:19 -03:00
Felipe Coutinho
41eecc2538 feat(preferencias): permite ocultar resumo do lancamento 2026-05-31 15:18:07 -03:00
Felipe Coutinho
cdcc677787 style(ui): atualiza identidade visual 2026-05-31 15:17:57 -03:00
Felipe Coutinho
e50eeba36e feat(insights): adiciona suporte ao Ollama 2026-05-28 20:01:44 -03:00
Felipe Coutinho
26cb18a9ad chore(release): prepara versao 2.7.0 2026-05-28 11:00:27 -03:00
Felipe Coutinho
382727a96d chore(deps): atualiza dependencias do workspace 2026-05-28 11:00:20 -03:00
Felipe Coutinho
0df648c7f3 chore(config): atualiza ambiente e setup 2026-05-28 11:00:14 -03:00
Felipe Coutinho
27f361923c style(ui): ajusta tipografia e descricoes 2026-05-28 10:59:46 -03:00
Felipe Coutinho
60b2612e8a feat(relatorios): refina indicadores e filtros 2026-05-28 10:59:36 -03:00
Felipe Coutinho
0171b0ce2f feat(lancamentos): refina filtros e tabela responsiva 2026-05-28 10:59:24 -03:00
Felipe Coutinho
311369f81b feat(lancamentos): amplia divisao e resumo do modal 2026-05-28 10:59:13 -03:00
Felipe Coutinho
ef2c8c50e8 feat(contas): adiciona rendimento pelo extrato 2026-05-28 10:58:59 -03:00
Felipe Coutinho
5319d8a5a6 feat(insights): adiciona suporte ao MiniMax 2026-05-28 10:58:52 -03:00
Felipe Coutinho
37247e319c feat(auth): adiciona sessao persistente no login 2026-05-28 10:58:43 -03:00
Felipe Coutinho
766af2b347 fix(ci): atualiza actions para checkout em Node 24 2026-05-23 13:27:22 -03:00
Felipe Coutinho
5dcd30010e fix(lancamentos): corrige tipo do filtro selecionado 2026-05-23 13:22:32 -03:00
Felipe Coutinho
d589df6993 chore(release): prepara versao 2.6.4 2026-05-23 13:18:15 -03:00
Felipe Coutinho
8a19f0f311 feat(lancamentos): aprimora antecipacao de parcelas 2026-05-23 13:17:55 -03:00
Felipe Coutinho
887885cd98 feat(relatorios): refina analise de parcelas 2026-05-23 13:17:49 -03:00
Felipe Coutinho
7a0e33efd8 feat(lancamentos): adiciona filtro por intervalo de datas 2026-05-23 13:17:42 -03:00
Felipe Coutinho
b9557961e5 style(logos): formata dicionario de nomes 2026-05-23 13:03:27 -03:00
Felipe Coutinho
53c8e47981 Merge branch 'pr-69' 2026-05-23 12:57:48 -03:00
Felipe Coutinho
adc9292cd8 Merge branch 'pr-72' 2026-05-23 12:57:47 -03:00
Felipe Coutinho
b95d6f6752 Merge branch 'pr-70' 2026-05-23 12:57:43 -03:00
lucas
c9f667a065 fix(transactions): restaura scroll no container de anexos do TransactionDialog
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:08:30 -03:00
lucas
01d9c6ea05 (HEAD) fix(transactions): remove overflow styles do container de rolagem do TransactionDialog 2026-05-23 11:58:53 -03:00
lucas
d383d2db91 fix(transactions): usa data da última transação ao adicionar nova linha no MassAddDialog
Quando o usuário adiciona uma nova linha de transação no dialog de múltiplos lançamentos,
a data agora é pré-preenchida com o valor da transação anterior em vez da data atual.

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 21:30:48 -03:00
Felipe Coutinho
fea9cf81d8 fix(docker): aplica configuracao do pnpm no build 2026-05-21 14:15:24 +00:00
225 changed files with 24465 additions and 5049 deletions

View File

@@ -17,9 +17,19 @@ POSTGRES_DB=openmonetis_db
# Gere com: openssl rand -base64 32
BETTER_AUTH_SECRET=your-secret-key-here-change-this
BETTER_AUTH_URL=http://localhost:3000
# Origins adicionais confiáveis para o Better Auth.
# Útil para Cloudflare Tunnel, reverse proxy e URLs diferentes de BETTER_AUTH_URL.
# Separe múltiplas origins por vírgula.
# Exemplo: https://*.trycloudflare.com,https://openmonetis.seudominio.com
BETTER_AUTH_TRUSTED_ORIGINS=
# Defina como true para bloquear novos cadastros
DISABLE_SIGNUP=false
# Duração de sessões persistentes quando "Manter conectado" estiver marcado
AUTH_SESSION_EXPIRES_IN_DAYS=30
AUTH_SESSION_UPDATE_AGE_HOURS=24
# === Portas ===
APP_PORT=3000
DB_PORT=5432
@@ -56,7 +66,10 @@ UMAMI_DOMAINS=
ANTHROPIC_API_KEY=
OPENAI_API_KEY=
GOOGLE_GENERATIVE_AI_API_KEY=
MINIMAX_API_KEY=
OPENROUTER_API_KEY=
OLLAMA_BASE_URL=http://127.0.0.1:11434/v1
OLLAMA_API_KEY=
# === Logo.dev (Opcional) ===
# Logos automáticos de estabelecimentos. Cadastre em https://www.logo.dev

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

@@ -0,0 +1,49 @@
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: 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,88 +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
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
quality:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
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@v4
- 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,61 +2,151 @@ name: Release
on:
push:
branches:
- main
tags:
- "v*.*.*"
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
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@v4
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
uses: actions/checkout@v5
- 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/

366
AGENTS.md Normal file
View File

@@ -0,0 +1,366 @@
# 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.
## Related Projects
- **OpenMonetis Companion** (`~/github/openmonetis-companion`): Android app que captura notificacoes de apps bancarios e envia para o OpenMonetis via API. Os itens chegam na feature `inbox` para revisao.
---
## Critical Rules
1. **Sempre filtrar por `userId`** em queries.
2. **Usar `getAdminPayerId(userId)`** de `src/shared/lib/payers/get-admin-id.ts` ao inves de JOIN com `payers` para descobrir o admin.
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 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.
---
## Architecture
### Feature-First
- `src/app/`: roteamento, layouts, loading states e paginas finas
- `src/features/`: codigo de dominio por feature
- `src/shared/`: tudo que e genuinamente reutilizado entre features
- `src/db/`: schema do banco
### Regra Feature vs Shared
Use esta pergunta:
> Se eu deletar esta feature, este arquivo deveria sumir junto?
- Sim: vai para `src/features/<feature>/`
- Nao: vai para `src/shared/`
### Features nao importam outras features
Se um contrato cruza dominios, ele deve morar em `src/shared/`.
**Excecao intencional: `attachments` depende de `transactions`**
`src/features/attachments` importa `TransactionDialog`, `TransactionDetailsDialog` e `TransactionItem` diretamente de `src/features/transactions`. Isso e uma dependencia explicita e aceita: anexos sao semanticamente uma extensao de lancamentos — existem por causa deles e nao fazem sentido sem esse contexto. Mover esses componentes para `shared/` seria errado (eles pertencem a transactions). Nao tratar isso como bug a corrigir.
Exemplos comuns:
- auth: `src/shared/lib/auth/*`
- db: `src/shared/lib/db.ts`
- revalidation helpers: `src/shared/lib/actions/*`
- payers cross-domain helpers: `src/shared/lib/payers/*`
- period/currency/date: `src/shared/utils/*`
- shadcn/ui: `src/shared/components/ui/*`
---
## Directory Structure
```text
src/
├── app/
│ ├── (auth)/
│ │ ├── login/page.tsx
│ │ └── signup/page.tsx
│ ├── (dashboard)/
│ │ ├── dashboard/
│ │ ├── transactions/
│ │ ├── cards/
│ │ │ └── [cardId]/invoice/
│ │ ├── accounts/
│ │ │ └── [accountId]/statement/
│ │ ├── categories/
│ │ │ ├── [categoryId]/
│ │ │ └── history/
│ │ ├── budgets/
│ │ ├── payers/
│ │ │ └── [payerId]/
│ │ ├── notes/
│ │ ├── insights/
│ │ ├── calendar/
│ │ ├── inbox/
│ │ ├── attachments/
│ │ ├── changelog/
│ │ ├── reports/
│ │ │ ├── category-trends/
│ │ │ ├── card-usage/
│ │ │ ├── installment-analysis/
│ │ │ └── establishments/
│ │ └── settings/
│ ├── (landing-page)/
│ ├── api/
│ ├── globals.css
│ └── layout.tsx
├── features/ # cada feature segue: actions.ts, queries.ts, actions/, components/, hooks/, lib/
│ ├── auth/
│ ├── landing/
│ ├── dashboard/
│ ├── transactions/
│ ├── cards/
│ ├── invoices/
│ ├── accounts/
│ ├── categories/
│ ├── budgets/
│ ├── payers/
│ ├── notes/
│ ├── insights/
│ ├── calendar/
│ ├── inbox/
│ ├── attachments/
│ ├── reports/
│ └── settings/
├── shared/
│ ├── components/
│ │ ├── ui/ # shadcn/ui primitives
│ │ ├── navigation/ # navbar, sidebar, breadcrumbs
│ │ ├── providers/ # React context providers
│ │ ├── brand/ # logos do app (logo, logo-icon, logo-text)
│ │ ├── widgets/ # widget-card, widget-empty-state, expandable-widget-card
│ │ ├── feedback/ # empty-state, status-dot, payment-success
│ │ ├── month-picker/
│ │ ├── logo-picker/
│ │ ├── calculator/
│ │ ├── entity-avatar/
│ │ └── skeletons/
│ ├── hooks/
│ ├── lib/
│ │ ├── actions/
│ │ ├── auth/
│ │ ├── accounts/
│ │ ├── cards/
│ │ ├── calculator/
│ │ ├── categories/
│ │ ├── email/
│ │ ├── import/
│ │ ├── installments/
│ │ ├── invoices/
│ │ ├── logo/
│ │ ├── notifications/
│ │ ├── payers/
│ │ ├── schemas/
│ │ ├── storage/
│ │ ├── transfers/
│ │ ├── types/
│ │ ├── version/
│ │ └── db.ts
│ └── utils/
│ ├── period/
│ ├── calculator.ts
│ ├── calendar.ts
│ ├── category-colors.ts
│ ├── currency.ts
│ ├── date.ts
│ ├── export-branding.ts
│ ├── fetch-json.ts
│ ├── financial-dates.ts
│ ├── icons.tsx
│ ├── id.ts
│ ├── initials.ts
│ ├── math.ts
│ ├── number.ts
│ ├── percentage.ts
│ ├── string.ts
│ └── ui.ts
└── db/
└── schema.ts
```
### Estrutura interna padrão de uma feature
Toda feature em `src/features/<nome>/` segue:
```text
<feature>/
├── actions.ts # entry point de Server Actions (barrel quando há actions/)
├── queries.ts # entry point de leitura do banco
├── actions/ # (opcional) Server Actions divididas por domínio quando o volume cresce
├── components/ # componentes de UI da feature
├── hooks/ # React hooks específicos da feature
└── lib/ # helpers, types, sub-queries e constantes internas
```
`actions.ts` e `queries.ts` são as portas de entrada da feature. Tudo que é helper interno fica em `lib/`. Componentes e hooks ficam nas pastas com nome óbvio.
---
## Import Patterns
### Preferidos
```ts
import { getUser } from "@/shared/lib/auth/server";
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
import { parsePeriodParam } from "@/shared/utils/period";
import { TransactionsPage } from "@/features/transactions/components/page/transactions-page";
import { fetchLancamentos } from "@/features/transactions/queries";
```
### Evitar
```ts
import { Something } from "@/components/...";
import { Something } from "@/lib/...";
import { something } from "@/app/(dashboard)/...";
```
---
## App Router Pattern
Paginas em `src/app/` devem ser finas:
```ts
import { getUser } from "@/shared/lib/auth/server";
import { TransactionsPage } from "@/features/transactions/components/page/transactions-page";
import { fetchLancamentos } from "@/features/transactions/queries";
export default async function Page() {
const user = await getUser();
const data = await fetchLancamentos([/* filters */]);
return <TransactionsPage {...data} />;
}
```
Layouts, `loading.tsx` e metadata continuam em `src/app/`.
---
## Naming
### Routes / folders
| Portugues | English |
|---|---|
| `lancamentos` | `transactions` |
| `cartoes` | `cards` |
| `contas` | `accounts` |
| `categorias` | `categories` |
| `orcamentos` | `budgets` |
| `pessoas` | `payers` |
> **Nota:** o conceito de "pagador" foi renomeado para **"pessoa"** na UI (labels, toasts, textos visíveis ao usuário). O código, rotas e schema continuam usando o termo original em inglês (`payer`, `payerId`, `adminPayerId`) e em português interno (`pagador` como variável). Não renomear esses identificadores — a divergência entre UI e código é intencional e documentada.
| `anotacoes` | `notes` |
| `calendario` | `calendar` |
| `ajustes` | `settings` |
| `pre-lancamentos` | `inbox` |
| `relatorios/tendencias` | `reports/category-trends` |
| `relatorios/uso-cartoes` | `reports/card-usage` |
| `relatorios/analise-parcelas` | `reports/installment-analysis` |
| `relatorios/estabelecimentos` | `reports/establishments` |
| `contas/[contaId]/extrato` | `accounts/[accountId]/statement` |
| `cartoes/[cartaoId]/fatura` | `cards/[cardId]/invoice` |
| `categorias/historico` | `categories/history` |
| `changelog` | `settings/changelog` |
### Files
- preferir `kebab-case`
- preferir nomes em ingles
- manter nomes internos de tipos/funcoes somente quando a troca aumentar risco sem ganho real
---
## Commands
```bash
pnpm run dev
pnpm run build
pnpm run lint
pnpm run lint:fix
pnpm exec next typegen
pnpm exec tsc --noEmit
pnpm run db:generate
pnpm run db:push
pnpm run db:studio
pnpm run docker:up:db
```
---
## Revalidation
Arquivo: `src/shared/lib/actions/helpers.ts`
- atualizar sempre os paths em ingles
- lembrar de manter a tag `"dashboard"` para invalidacoes financeiras
---
## Auth
- `getUser()` / `getUserId()` em `src/shared/lib/auth/server.ts`
- sessao deduplicada por request com `React.cache()`
---
## Dashboard Fetcher
Padrao recomendado:
```ts
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
export async function fetchData(userId: string, period: string) {
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) return [];
return db.query.transactions.findMany({
where: /* sempre com userId + adminPayerId + period */,
});
}
```
---
## New Feature Checklist
1. Criar a rota fina em `src/app/(dashboard)/<feature>/page.tsx`
2. Criar a feature em `src/features/<feature>/`
3. Separar:
- `components/`
- `queries.ts` (entry point de leitura)
- `actions.ts` (entry point de Server Actions; vira barrel quando crescer e migrar para `actions/`)
- `lib/` para helpers internos, sub-queries por tópico, types e constantes da feature
- `types.ts` ou `schemas.ts` quando fizer sentido (alternativa a `lib/`)
- `hooks/` quando houver hooks específicos da feature
4. Extrair para `src/shared/` tudo que for reutilizavel
5. Atualizar navegacao e `revalidateForEntity()` se a feature tiver CRUD
6. Rodar:
- `pnpm exec next typegen`
- `pnpm exec tsc --noEmit`
- `pnpm run lint`
---
## Security Rules
Regras aplicadas automaticamente ao gerar codigo.
### Secrets
Nunca colocar API keys, credenciais de banco ou tokens em codigo frontend. Evitar variaveis prefixadas com `NEXT_PUBLIC_` para dados sensiveis — estas sao bundladas no cliente. Usar variaveis server-side apenas. `.env` deve estar no `.gitignore` antes do primeiro commit. `.env.example` deve ter apenas placeholders.
### Autenticacao & Autorizacao
Toda rota protegida em `src/app/api/` requer `getUser()` ou `getOptionalUserSession()` antes de qualquer logica, retornando 401 para nao autenticados. Rotas com IDs de recursos devem verificar ownership: `eq(table.userId, userId)`. Rotas admin devem checar role e retornar 403 para nao-admins. Session cookies em Better Auth ja tem `httpOnly`, `secure` e `sameSite` configurados — nao alterar.
### Input & Output
Usar Drizzle ORM (parametrizado por padrao) — nunca concatenar input de usuario em SQL. Validar todo input com Zod antes de usar. Upload de arquivos: usar whitelist de MIME types (`ALLOWED_MIME_TYPES`), presigned URLs para S3, token de upload assinado com verificacao pos-upload. Nunca usar `dangerouslySetInnerHTML` com conteudo de usuario.
### Headers & CSP
CSP definida em `src/proxy.ts` via middleware — alterar la, nao em `next.config.ts`. Headers de seguranca (HSTS, X-Frame-Options, etc.) definidos em `next.config.ts`. Nao remover nem enfraquecer essas configuracoes.
### Rate Limiting
Login: 5 tentativas/min. Signup: 3 tentativas/min. API tokens: 100 req/min (inbox), 20 req/min (batch). Configurado em `src/shared/lib/auth/config.ts` e nas rotas de inbox. Nao remover.
### Tratamento de Erros
Erros nao devem expor stack traces, paths ou nomes de bibliotecas ao cliente. Usar mensagens genericas: `"Algo deu errado"`. Logar detalhes apenas no servidor com `console.error()`.
### Dependencias
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.
---

View File

@@ -5,6 +5,219 @@ 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.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.
### Alterado
- Cartões e contas: os cabeçalhos dos resumos agora destacam melhor o logo, o nome da entidade e o período exibido.
- Dashboard: as listas dos widgets agora compartilham padrões de altura, espaçamento, alinhamento e truncamento para melhorar a leitura de valores, status e metadados.
## [2.7.6] - 2026-06-20
Esta versão melhora dois fluxos importantes: a importação de planilhas fica mais esperta ao reconhecer categorias já informadas no arquivo, e lançamentos avulsos podem ser reorganizados como parcelamentos ou recorrências sem precisar recriá-los manualmente.
As funcionalidades desta versão foram desenvolvidas originalmente por Yuri Argolo (`yurnasg`) e adaptadas para integração ao projeto principal.
### Adicionado
- Importação: planilhas XLS/XLSX agora aceitam a coluna `Categoria` no template e tentam mapear automaticamente o valor para uma categoria existente compatível com o tipo do lançamento.
- Lançamentos: lançamentos à vista agora podem ser convertidos em recorrentes diretamente pelo menu de ações.
- Lançamentos: lançamentos à vista de cartão de crédito agora podem ser convertidos em uma série parcelada informando o total de parcelas.
### Alterado
- Lançamentos: conversões para séries respeitam faturas pagas e limite disponível do cartão antes de criar novos movimentos.
## [2.7.5] - 2026-06-13
Esta versão faz um polimento pontual no dashboard, deixando os widgets mais explicativos, consistentes e confiáveis quando há listas maiores ou informações complementares para revisar.
### Alterado
- Dashboard: os indicadores percentuais de faturas por pessoa, despesas por categoria, receitas por categoria e tendências de categorias agora deixam explícito que a comparação é contra o mês anterior.
- Dashboard: pequenos ajustes visuais em widgets melhoram espaçamento, bordas e leitura de itens financeiros.
### Corrigido
- Dashboard: o widget `Lançamentos por categoria` agora recalcula corretamente o overflow quando a lista muda e volta a exibir o botão `Expandir` em listagens grandes.
- Relatórios: em `/reports/installment-analysis`, os cards de parcelamentos agora exibem o ícone de observação ao lado do nome do lançamento quando há anotação cadastrada.
## [2.7.4] - 2026-06-09
Esta versão corrige o fluxo de revisão de lançamentos compartilhados para que o acesso somente leitura proteja os dados originais sem impedir que a pessoa copie movimentos para a própria conta.
### Corrigido
- Pessoas: lançamentos de uma pessoa compartilhada em modo somente leitura agora podem ser selecionados e importados para a conta do usuário logado, tanto individualmente quanto em lote, mantendo edição e remoção bloqueadas no lançamento original.
## [2.7.3] - 2026-06-05
Esta versão melhora pequenos pontos de leitura e configuração para o uso diário e self-hosted. As faturas pagas ficam mais fáceis de identificar na lista de cartões, a configuração de origins confiáveis do Better Auth passa a ficar documentada para Docker e túneis, o dashboard corrige a leitura de tempo dos pré-lançamentos e as dependências seguem atualizadas sem quebrar o build da imagem.
### Adicionado
- Cartões: a lista de cartões agora exibe a etiqueta `Paga` ao lado do valor da fatura atual quando ela já foi quitada.
- Self-hosting: adicionada a variável `BETTER_AUTH_TRUSTED_ORIGINS` ao `.env.example`, ao `docker-compose.yml` e ao README para permitir origins adicionais confiáveis em cenários com Cloudflare Tunnel, reverse proxy ou URLs diferentes de `BETTER_AUTH_URL`.
### Alterado
- Dependências: atualizados Next.js, React, Better Auth, AI SDK, AWS SDK, pdf.js e ferramentas de desenvolvimento usadas no build.
### Corrigido
- Dashboard: o widget `Pré-lançamentos` agora calcula o rótulo `há X` a partir da chegada do item ao OpenMonetis, evitando deslocamentos causados por timestamps de notificação enviados com timezone incorreto.
- Anexos: o preview de PDFs foi ajustado para a API atual do `pdfjs-dist`, evitando falha de TypeScript durante o build da imagem Docker.
## [2.7.2] - 2026-05-31
Esta versão atualiza as imagens de apresentação do OpenMonetis na landing page e no compartilhamento em redes sociais.
### Alterado
- Landing page: atualizadas as imagens de preview do dashboard e da versão PWA.
- Compartilhamento: ajustada a imagem usada nos metadados sociais da landing page.
## [2.7.1] - 2026-05-30
Esta versão melhora a clareza dos fluxos de lançamento e a experiência do dashboard. Boletos de receita agora diferenciam pagamentos de recebimentos, a navegação mensal ficou mais direta e o painel ganhou atalhos mais úteis com personalização simplificada.
### Adicionado
- Preferências: nova opção para exibir ou ocultar o card `Resumo da operação` no modal de lançamento.
- Navegação mensal: ao passar o mouse, focar ou clicar no período selecionado, agora é possível abrir um seletor e ir diretamente para outro mês.
### Alterado
- Documentação: o guia visual foi reescrito com os tokens, temas, componentes e práticas de acessibilidade atuais; o README agora apresenta a identidade visual e as preferências disponíveis.
- Dashboard: os cards de receitas e despesas agora oferecem um atalho discreto para abrir os lançamentos da pessoa principal filtrados pelo tipo e período.
- Dashboard: a configuração e a reordenação de widgets agora partem de uma única ação `Personalizar`, com controle de visibilidade durante a edição.
- Dashboard: em telas pequenas, os atalhos para receita, despesa e anotação foram agrupados no menu `Adicionar`.
- Dashboard: os títulos dos widgets agora usam sentence case para reduzir ruído visual.
- Dashboard: os widgets receberam uma revisão ampla de UX, com hierarquia visual mais clara, listas compactas, textos mais diretos, estados acessíveis e navegação interna consistente.
- Dashboard: o widget `Comportamento de pagamento` foi renomeado para `Distribuição de despesas`.
- Dashboard: limites de orçamento agora aparecem apenas no widget de despesas por categoria.
- Dashboard: o widget `Panorama de gastos` agora exibe todos os lançamentos sem filtro adicional por cartão.
- Navegação: o menu de finanças agora oferece submenus para abrir diretamente as faturas dos cartões e os extratos das contas ativas.
- Lançamentos: ao passar o mouse sobre `Filtros`, os filtros ativos agora aparecem em um painel compacto com remoção individual e ação para limpar todos de uma vez.
### Corrigido
- Dashboard: o saldo consolidado do widget `Minhas contas` não inclui mais contas inativas.
- Boletos: lançamentos de receita agora exibem ações e status como `Receber`, `Recebido` e `Recebido em`, enquanto despesas continuam usando `Pagar`, `Pago` e `Pago em`.
- Dashboard: o modal de baixa de boleto agora usa textos de recebimento e conta de destino para receitas.
- Calendário e pessoas: os detalhes de boletos de receita agora preservam a nomenclatura de recebimento.
## [2.7.0] - 2026-05-28
Esta versão amplia o OpenMonetis para quem usa o app todos os dias e para quem prefere mais controle sobre os próprios dados. Os Insights ganham novas opções de IA, incluindo modelos locais via Ollama, enquanto a autenticação fica mais confortável em dispositivos pessoais. Também entram melhorias práticas em contas, lançamentos compartilhados, filtros, relatórios e dashboard, deixando os fluxos financeiros mais completos e fáceis de revisar.
### Adicionado
- Autenticação: a tela de login agora tem a opção "Manter conectado neste dispositivo", usando a persistência nativa do Better Auth para evitar novo login ao reabrir o navegador ou PWA.
- Autenticação: novas variáveis `AUTH_SESSION_EXPIRES_IN_DAYS` e `AUTH_SESSION_UPDATE_AGE_HOURS` para configurar, em ambientes self-hosted, a duração e a renovação de sessões persistentes.
- Contas: o extrato de uma conta agora tem um atalho "Adicionar rendimento" ao lado de "Ajustar saldo", abrindo um modal simples com valor e data para criar uma receita paga na conta atual, com categoria `Rendimentos`, forma de pagamento `Transferência bancária` e pessoa admin.
- Insights: adicionado suporte ao provider MiniMax via `vercel-minimax-ai-provider`, incluindo os modelos M2.7, M2.7 Highspeed, M2.5, M2.5 Highspeed, M2.1, M2.1 Highspeed e M2.
- Insights: adicionado suporte ao provider Ollama via endpoint OpenAI-compatible, com modelos sugeridos `llama3.2`, `llama3.1`, `qwen2.5` e `mistral`, além de input para qualquer modelo instalado localmente.
- Configuração: adicionadas as variáveis `MINIMAX_API_KEY`, `OLLAMA_BASE_URL` e `OLLAMA_API_KEY` aos exemplos de ambiente, ao assistente de setup e à documentação.
- Dependências: adicionada `@ai-sdk/openai-compatible` para integrar provedores compatíveis com a API da OpenAI, incluindo Ollama.
- Lançamentos: o campo "Dividir com" agora permite selecionar múltiplas pessoas e exibe um campo de valor para cada participante escolhido.
- Lançamentos: o modal de criação e edição agora exibe um card compacto de resumo da operação abaixo dos anexos, incluindo forma de pagamento, destino, categoria, pessoas, valores divididos e quantidade de lançamentos que serão criados.
### Alterado
- Contas: o modal "Adicionar rendimento" usa o mesmo seletor de data do modal de lançamentos e os botões de rendimento e ajuste de saldo agora exibem tooltip.
- Categorias: o header de `/categories/[categoryId]` agora usa três blocos de métrica alinhados para total do mês selecionado, total do mês anterior e variação.
- Dashboard: o botão expansível dos widgets passou de "Ver tudo" para "Expandir", com visual secundário e gradiente inferior mais compacto para diferenciar melhor a ação de abrir o modal dos links que navegam para páginas completas.
- Insights: a resolução de modelos foi centralizada em `model-provider.ts`, reduzindo ramificações na action de geração e preservando OpenAI, Anthropic, Google, MiniMax e OpenRouter.
- Insights: o aviso de privacidade agora diferencia providers externos de providers locais como Ollama.
- Lançamentos: o filtro de categorias agora separa as opções em grupos de `Despesas` e `Receitas`, preservando ícones e busca dentro do seletor.
- Lançamentos: a configuração de divisão foi movida para um modal dedicado e minimalista, com seleção direta de participantes, divisão igual e conferência do total distribuído.
- Lançamentos: a validação de divisão agora aceita uma lista de participações, exige pessoas distintas e confere se a soma dos valores bate com o total do lançamento.
- Lançamentos: os textos de edição de lançamentos divididos foram ajustados para tratar divisões com mais de duas pessoas.
- Lançamentos: o card "Dividir lançamento" agora mostra avatares discretos antes dos nomes das pessoas selecionadas e remove as vírgulas entre os nomes no resumo.
- Relatórios: em `/reports/category-trends`, o seletor de categorias não exibe mais a ação `Todas`; quando há seleção ativa, mostra apenas `Limpar seleção` e resume múltiplas escolhas pela contagem.
- Relatórios: em `/reports/category-trends`, as tabelas agora usam os cabeçalhos `Categoria Despesa` e `Categoria Receita` e não exibem mais o ponto colorido antes do nome da categoria.
### Corrigido
- Categorias: em `/categories/[categoryId]`, o percentual de variação do header agora aparece sem `+` quando já há uma etiqueta indicando aumento, queda ou estabilidade.
- Dashboard: os modais "Ver tudo" dos widgets agora reservam espaço para a barra de rolagem, evitando que ela fique sobreposta aos valores alinhados à direita.
- Insights: o seletor de modelo do OpenRouter mantém o provider selecionado enquanto o usuário digita um modelo customizado sem `/`, evitando voltar automaticamente para o provider padrão.
- Relatórios: em `/reports/category-trends`, a busca do seletor de categorias agora pesquisa pelo nome da categoria, e não apenas pelo ID interno, incluindo correspondência sem acentos.
## [2.6.4] - 2026-05-23
Esta versão reúne o polimento final antes da próxima publicação: melhora o fluxo de antecipação de parcelas, deixa os dialogs de lançamentos mais seguros e consistentes, e incorpora as contribuições vindas dos PRs abertos para nomes de logos e ajustes no cadastro de transações.
### Adicionado
- Logos: adicionado um dicionário de nomes de exibição para logos, com busca normalizada sem acentos e fallback para o comportamento anterior quando não houver mapeamento específico (PR #69).
- Lançamentos: o dialog de adicionar múltiplos lançamentos agora pede confirmação antes de descartar alterações não salvas ao fechar ou cancelar (PR #70).
### Alterado
- Lançamentos: o modal "Histórico de Antecipações" agora segue o padrão do modal de detalhes, com `Fechar` e `Desfazer Antecipação` no rodapé, contagem dentro do conteúdo e cards de antecipação reorganizados em blocos mais escaneáveis.
- Lançamentos: a antecipação de parcelas agora só permite selecionar parcelas futuras ao período escolhido, evitando antecipar a parcela do próprio mês sem bloquear parcelas seguintes da mesma compra.
- Lançamentos: ao criar uma antecipação, o cache do histórico da série agora é invalidado e o modal refaz a busca ao abrir.
- Lançamentos: ao adicionar uma nova linha no dialog de múltiplos lançamentos, a data passa a seguir a última transação informada em vez de voltar para a data atual (PR #72).
### Corrigido
- Lançamentos: ajustado o espaçamento horizontal da área rolável do dialog de adicionar transação para preservar o alinhamento dos campos e botões (PR #71).
## [2.6.3] - 2026-05-22
Esta versão concentra os ajustes feitos depois da `2.6.2` em um único ciclo público. O foco está em dar mais precisão aos filtros de lançamentos por período real de compra e em polir a análise de parcelas para priorizar parcelamentos mais próximos da quitação sem causar saltos visuais nos cards.
### Adicionado
- Lançamentos: o drawer de filtros agora permite informar data inicial e data final para filtrar a tabela por `data_compra`.
### Alterado
- Lançamentos: quando um intervalo de datas está ativo, a consulta server-side deixa de limitar os dados a um único mês e usa o intervalo real de compra, mantendo paginação e exportação alinhadas ao que aparece na tabela.
- Relatórios: os cards de `/reports/installment-analysis` agora são ordenados pelo percentual pago em ordem decrescente, mantendo a data da compra como critério de desempate.
- Relatórios: em `/reports/installment-analysis`, o contador de parcelas selecionadas agora aparece discretamente no botão "detalhes", sem criar uma área extra no corpo do card.
### Corrigido
- Relatórios: selecionar parcelas em um card de `/reports/installment-analysis` não força mais os outros cards da mesma linha a reservarem espaço vazio para o resumo de seleção.
## [2.6.2] - 2026-05-21
Esta versão corrige o build da imagem Docker depois da atualização para `pnpm@11.1.3`. A etapa de dependências dentro do Docker não recebia a configuração do workspace, então o install congelado falhava ao comparar os `overrides` e as políticas de build com o lockfile.
### Corrigido
- Docker: o `Dockerfile` agora usa `pnpm@11.1.3` em todos os estágios e copia `pnpm-workspace.yaml` antes do `pnpm install --frozen-lockfile`, garantindo que `overrides` e `allowBuilds` sejam aplicados também no build da imagem.
## [2.6.1] - 2026-05-21
Esta versão corrige o pipeline de publicação após o salto para a `2.6.0`. O build do GitHub Actions falhava antes mesmo de instalar as dependências porque o workflow ainda fixava uma versão antiga do `pnpm`, enquanto o projeto já declarava `pnpm@11.1.3` no `packageManager`.

View File

@@ -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.

479
DESIGN.md
View File

@@ -1,389 +1,178 @@
# Design System Inspired by OpenMonetis
# Design System do OpenMonetis
## 1. Visual Theme & Atmosphere
Este documento descreve a identidade visual implementada no OpenMonetis. Ele deve
ser usado como referência ao criar telas, revisar componentes e manter a
experiência consistente entre dashboard, relatórios, formulários e landing page.
OpenMonetis embodies a warm, approachable financial management aesthetic grounded in trust and transparency. The design system combines a rich warm-orange accent palette with a sophisticated warm-neutral foundation, creating an interface that feels both professional and inviting. The typography and spacing work together to emphasize clarity and hierarchy, supporting the open-source ethos of personal financial control. The visual atmosphere prioritizes legibility and calm navigation, with generous whitespace and deliberate color restraint—the bold orange is reserved for critical calls-to-action and highlights, while the warm grays and blacks anchor the interface with stability and focus.
## 1. Direção visual
**Key Characteristics**
- Warm, approachable color story with a dominant orange accent (`#FF7733`)
- Generous whitespace and breathing room between sections
- High contrast between backgrounds and text for accessibility
- Clear typographic hierarchy using Inter for all text and UI
- Minimal elevation and shadow treatment—mostly flat design
- Subtle border accents in warm grays to define surfaces
- Open-source transparency reflected in straightforward, honest design language
O OpenMonetis busca tornar a gestão financeira clara e acolhedora. A interface
usa superfícies quentes, poucos elementos decorativos e uma cor laranja de
destaque para orientar o olhar sem transformar toda ação em urgência.
## 2. Color Palette & Roles
Princípios:
### Primary
- **Primary Accent** (`#FF7733`): Used for primary call-to-action buttons, highlights, and key interactive elements throughout the interface; draws user attention to the most important actions
- **Primary Dark** (`#443732`): Warm-brown anchor color used extensively for text, headings, and interactive elements; provides the primary text color across the system
- priorizar legibilidade e hierarquia em telas com muitos dados;
- usar laranja para ações principais, seleção e foco;
- manter superfícies leves no tema claro e contraste confortável no tema escuro;
- aplicar cores semânticas para comunicar estado, não como decoração;
- preservar espaço suficiente entre blocos para evitar ruído visual;
- favorecer componentes responsivos e navegação acessível por teclado.
### Interactive
- **Interactive Neutral** (`#0F0D0C`): Near-black used for primary text and strong emphasis elements; highest contrast state
- **Interactive Overlay** (`#0006`): Transparent black overlay at 24% opacity; used for hover states, modals, and depth layering
## 2. Fonte de verdade
### Neutral Scale
- **Neutral 900** (`#2A2827`): Very dark warm gray; used for secondary text and disabled states
- **Neutral 800** (`#322C2A`): Dark warm gray; alternative text color for lower-emphasis content
- **Neutral 700** (`#676260`): Medium warm gray; used for tertiary text, captions, and metadata
- **Neutral 50** (`#FCF7F6`): Almost-white warm cream; primary background color for light surfaces
- **Neutral 100** (`#F8F6F4`): Very light warm gray; secondary background and subtle surface distinction
- **Neutral 200** (`#F5F2EF`): Light warm gray; tertiary background and card interiors
- **Neutral 300** (`#F0EEEC`): Pale warm gray; border and divider lines
Os tokens globais estão definidos em
[`src/app/globals.css`](./src/app/globals.css). Componentes reutilizáveis ficam
em [`src/shared/components/ui/`](./src/shared/components/ui/) e seguem o padrão
do shadcn/ui com Radix UI e Tailwind CSS 4.
### Surface & Borders
- **Surface** (`#FFFFFF`): Pure white; primary card and container background; high-contrast surface
- **Border Light** (`#F0EEEC`): Pale warm gray used for subtle borders between elements
- **Border Dark** (`#2A2827`): Dark warm gray; stronger borders for defined card boundaries
Ao implementar uma tela:
### Semantic / Status
- **Success** (`#0E9D6E`): Green used for positive status indicators, confirmation states, and success messages
- **Warning** (`#F7A439`): Amber used for cautionary states, warnings, and attention-drawing alerts
- **Error** (`#F53F2D`): Red-orange used for error states, destructive actions, and validation failures
- **Error Alt** (`#D40C1A`): Deep red alternative for critical errors and danger states
1. use classes semânticas como `bg-background`, `bg-card`, `text-foreground`,
`text-muted-foreground`, `border-border` e `ring-ring`;
2. reutilize os componentes em `src/shared/components/ui/`;
3. evite cores hexadecimais e valores arbitrários quando já existir um token;
4. valide os dois temas antes de concluir a alteração.
## 3. Typography Rules
## 3. Cores
### Font Family
**Primary:** Inter (sans-serif)
Fallback: `Inter, system-ui, -apple-system, sans-serif`
A paleta é definida em OKLCH para manter uma percepção de contraste mais
consistente. Não copie os valores para componentes: use os tokens semânticos.
**Monospace:** ui-monospace
Fallback: `ui-monospace, 'Courier New', monospace`
| Token | Papel |
|---|---|
| `background` | Fundo geral da aplicação |
| `foreground` | Texto principal |
| `card` / `card-foreground` | Cards e conteúdo em destaque |
| `popover` / `popover-foreground` | Menus, popovers e overlays |
| `primary` / `primary-foreground` | Ações principais, foco e seleção |
| `secondary` / `secondary-foreground` | Ações secundárias e superfícies discretas |
| `muted` / `muted-foreground` | Apoio visual, descrições e metadados |
| `accent` / `accent-foreground` | Hover e seleção leve |
| `success` | Confirmações, recebimentos e estados positivos |
| `warning` | Atenção, vencimentos e estados intermediários |
| `info` | Informações auxiliares |
| `destructive` | Erros e ações destrutivas |
| `border`, `input`, `ring` | Bordas, campos e foco |
### Hierarchy
### Gráficos
| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes |
|------|------|------|--------|-------------|----------------|-------|
| Display / H1 | Inter | 60px | 600 | 60px | 0px | Page titles and hero headlines; maximum visual impact |
| Heading H2 | Inter | 36px | 600 | 40px | 0px | Section headers and major subsections |
| Heading H3 | Inter | 16px | 600 | 20px | 0px | Card titles and smaller section headers |
| Body | Inter | 20px | 400 | 28px | 0px | Descriptive text, paragraphs, and long-form content |
| Body Secondary | Inter | 16px | 400 | 24px | 0px | Standard body text, list items, and descriptions |
| Emphasis / Span | Inter | 14px | 500 | 20px | 0px | Emphasized text, labels, and badge content |
| Button | Inter | 14px | 500 | 20px | 0px | All button text; medium weight for clarity |
| Caption | Inter | 14px | 400 | 20px | 0px | Metadata, timestamps, and small supporting text |
| Code | ui-monospace | 14px | 400 | 20px | 0px | Code blocks and technical content |
Gráficos usam `chart-1` a `chart-10`. Visualizações que precisam de uma escala
sequencial quente podem usar `data-1` a `data-6`. A cor nunca deve ser o único
meio de distinguir uma série: inclua legenda, rótulo ou tooltip.
### Principles
- **Contrast & Clarity:** Text color `#0F0D0C` on light backgrounds; `#FFFFFF` on dark backgrounds
- **Weight Hierarchy:** Use 600 weight for all headings; 500 for interactive/emphasized text; 400 for body
- **Scale Progression:** Sizes increase in meaningful increments (14 → 16 → 20 → 36 → 60); maintain consistent rhythm
- **Line Height:** Body text uses 1.4× line height multiplier for comfortable reading; headings use tighter ratio (1:1) for impact
- **Readability First:** Avoid line lengths over 80 characters for long-form content; increase line height on smaller screens
### Tema escuro
## 4. Component Stylings
O tema escuro redefine a mesma camada semântica dentro de `.dark`. Não crie uma
segunda árvore de componentes para suportá-lo. Prefira tokens e, somente quando
necessário, variantes Tailwind `dark:`.
### Buttons
## 4. Tipografia
#### Primary Button
- **Background:** `#FF7733`
- **Text Color:** `#FFFFFF`
- **Font Size:** `14px`
- **Font Weight:** `500`
- **Font Family:** `Inter`
- **Padding:** `8px 16px`
- **Border Radius:** `9.2px`
- **Border:** `0px solid transparent`
- **Height:** `40px`
- **Box Shadow:** `none`
- **Hover State:** Darken background to `#E55F1F`; add subtle shadow `0px 2px 8px rgba(0, 0, 0, 0.12)`
- **Active State:** Darken further to `#CC5118`; increase shadow
- **Disabled State:** Background `#E8E3E0`; text color `#999890`; cursor not-allowed
A família principal é **Bricolage Grotesque**, carregada com `next/font` em
[`public/fonts/font_index.ts`](./public/fonts/font_index.ts). Os pesos
disponíveis são `500`, `600` e `700`, com fallback para Arial e fontes sans-serif
do sistema.
#### Secondary Button
- **Background:** `#FFFFFF`
- **Text Color:** `#2A2827`
- **Font Size:** `14px`
- **Font Weight:** `500`
- **Font Family:** `Inter`
- **Padding:** `8px 24px`
- **Border Radius:** `9.2px`
- **Border:** `1px solid #F0EEEC`
- **Height:** `40px`
- **Box Shadow:** `0px 1px 3px rgba(0, 0, 0, 0.06)`
- **Hover State:** Background `#F8F6F4`; border color `#E8E3E0`
- **Active State:** Background `#F0EEEC`; border color `#D5CCCA`
- **Disabled State:** Background `#FAFAF8`; text color `#BFBAB7`; border color `#F0EEEC`
Diretrizes:
#### Ghost Button
- **Background:** `transparent`
- **Text Color:** `#443732`
- **Font Size:** `14px`
- **Font Weight:** `500`
- **Font Family:** `Inter`
- **Padding:** `6px 8px`
- **Border Radius:** `9.2px`
- **Border:** `0px solid transparent`
- **Height:** `32px`
- **Box Shadow:** `none`
- **Hover State:** Background `rgba(68, 55, 50, 0.05)`
- **Active State:** Background `rgba(68, 55, 50, 0.1)`
- **Disabled State:** Text color `#BFBAB7`; cursor not-allowed
- corpo e controles: `text-sm` ou `text-base`;
- descrições e metadados: `text-sm text-muted-foreground`;
- títulos de card: `text-base font-medium`;
- títulos de modal: `text-lg font-semibold`;
- títulos de página: hierarquia responsiva conforme a densidade da tela;
- números financeiros: destaque por peso e alinhamento, sem depender apenas da
cor.
#### Icon Button
- **Background:** `transparent`
- **Icon Color:** `#443732`
- **Size:** `32px` × `32px`
- **Border Radius:** `9.2px`
- **Border:** `0px solid transparent`
- **Padding:** `0px`
- **Box Shadow:** `none`
- **Hover State:** Background `rgba(68, 55, 50, 0.08)`
- **Active State:** Background `rgba(68, 55, 50, 0.12)`
## 5. Espaçamento, raio e elevação
### Cards & Containers
A escala base é de `0.25rem` (`4px`). Prefira a escala padrão do Tailwind para
padding, gap e margens. O raio base é `0.7rem`, exposto pelas classes
`rounded-sm`, `rounded-md`, `rounded-lg` e `rounded-xl`.
#### Standard Card
- **Background:** `#FFFFFF`
- **Border:** `1px solid #F0EEEC`
- **Border Radius:** `11.2px`
- **Padding:** `24px`
- **Box Shadow:** `none`
- **Text Color:** `#2A2827`
- **Hover State:** Border color `#E8E3E0`; box-shadow `0px 4px 12px rgba(0, 0, 0, 0.08)`
Sombras também são tokens. Cards comuns usam `shadow-xs`; menus, tooltips e
modais podem subir de nível conforme a necessidade. Evite adicionar sombra forte
a cada bloco: bordas e diferença de superfície devem resolver a maior parte da
hierarquia.
#### Card with Top Border
- **Background:** `#FFFFFF`
- **Border:** `1px solid #F0EEEC`
- **Border Radius:** `15.2px 15.2px 0px 0px` (top corners only)
- **Padding:** `24px`
- **Box Shadow:** `none`
- **Top Border Color:** `#FF7733` (3px height implied)
## 6. Componentes
#### Surface Container (Header/Nav)
- **Background:** `#FF7733`
- **Height:** `64px`
- **Padding:** `0px 24px`
- **Box Shadow:** `none`
- **Text Color:** `#FFFFFF`
- **Border:** `0px solid transparent`
### Botões
#### Light Surface
- **Background:** `#F8F6F4`
- **Border:** `0px solid transparent`
- **Border Radius:** `11.2px`
- **Padding:** `16px`
- **Box Shadow:** `none`
Use [`Button`](./src/shared/components/ui/button.tsx) e suas variantes:
### Inputs & Forms
| Variante | Uso |
|---|---|
| `default` | Ação principal da tela ou do fluxo |
| `secondary` | Ação complementar |
| `outline` | Ação neutra com contorno |
| `ghost` | Ação discreta em barras e grupos |
| `link` | Ação textual |
| `destructive` | Exclusão ou operação irreversível |
| `navbar` | Ferramentas da navegação superior |
#### Text Input
- **Background:** `#FFFFFF`
- **Border:** `1px solid #F0EEEC`
- **Border Radius:** `9.2px`
- **Padding:** `12px 16px`
- **Font Size:** `16px`
- **Text Color:** `#2A2827`
- **Line Height:** `24px`
- **Placeholder Color:** `#999890`
- **Focus State:** Border color `#FF7733`; box-shadow `0px 0px 0px 3px rgba(255, 119, 51, 0.1)`
- **Error State:** Border color `#F53F2D`; background `#FEF5F3`
- **Disabled State:** Background `#F8F6F4`; text color `#BFBAB7`; border color `#F0EEEC`
Não coloque duas ações `default` competindo na mesma região. Para ícones sem
rótulo visível, inclua `aria-label` ou texto apenas para leitores de tela.
#### Select / Dropdown
- **Background:** `#FFFFFF`
- **Border:** `1px solid #F0EEEC`
- **Border Radius:** `9.2px`
- **Padding:** `12px 16px`
- **Font Size:** `16px`
- **Text Color:** `#2A2827`
- **Focus State:** Border color `#FF7733`; outline `0px`
- **Hover State:** Background `#FAFAF8`
### Cards
#### Checkbox & Radio
- **Size:** `20px` × `20px`
- **Border Radius:** `4px` (checkbox), `50%` (radio)
- **Border:** `2px solid #F0EEEC`
- **Background:** `#FFFFFF`
- **Checked Background:** `#FF7733`
- **Checked Border:** `2px solid #FF7733`
- **Checked Icon Color:** `#FFFFFF`
- **Focus:** Border `2px solid #FF7733`; box-shadow `0px 0px 0px 3px rgba(255, 119, 51, 0.1)`
Use [`Card`](./src/shared/components/ui/card.tsx) para agrupar informações
relacionadas. O componente já define fundo, borda, sombra leve, raio e destaque
de hover. Não transforme todo conteúdo em card: listas densas e tabelas podem
usar uma única superfície.
### Navigation
### Formulários
#### Primary Navigation
- **Background:** `#FF7733`
- **Height:** `64px`
- **Padding:** `0px 48px`
- **Display:** flex; align-items: center; gap `32px`
- **Link Color:** `#FFFFFF`
- **Link Font Size:** `16px`
- **Link Font Weight:** `400`
- **Link Hover:** Opacity `0.8`
- **Link Active:** Text decoration underline; opacity `1.0`
Campos devem usar os componentes compartilhados, como `Input`, `Select`,
`Checkbox`, `Switch` e `DatePicker`. Eles já aplicam foco com `ring`, estados
desabilitados e integração visual com os temas. Sempre associe controles a
`Label` e apresente erros próximos ao campo correspondente.
#### Secondary Navigation / Tabs
- **Background:** `transparent`
- **Border Bottom:** `2px solid #F0EEEC`
- **Tab Padding:** `16px 24px`
- **Tab Color:** `#676260`
- **Tab Font Size:** `16px`
- **Tab Hover:** Color `#443732`
- **Tab Active:** Color `#FF7733`; border-bottom color `#FF7733`
### Diálogos
#### Breadcrumb Navigation
- **Font Size:** `14px`
- **Color:** `#676260`
- **Separator:** `/` with `0px 8px` margin
- **Link Color:** `#443732`
- **Link Hover:** Color `#FF7733`
- **Current (Active):** Color `#2A2827`; font-weight `500`
Use [`Dialog`](./src/shared/components/ui/dialog.tsx) para tarefas focadas. Em
mobile, o conteúdo respeita a largura disponível; em telas maiores, o modal pode
ganhar mais espaço. Botões do rodapé devem preservar a ordem e a hierarquia da
ação principal.
### Badges & Status Indicators
### Feedback
#### Badge Default
- **Background:** `#F8F6F4`
- **Text Color:** `#443732`
- **Padding:** `4px 12px`
- **Border Radius:** `20px`
- **Font Size:** `12px`
- **Font Weight:** `500`
- **Border:** `0px solid transparent`
Use toast para retorno breve, `Alert` para contexto persistente e componentes em
[`src/shared/components/feedback/`](./src/shared/components/feedback/) para
estados vazios, status e confirmações. Textos visíveis ao usuário devem estar em
português claro.
#### Badge Success
- **Background:** `#E8F5F0`
- **Text Color:** `#0E9D6E`
- **Padding:** `4px 12px`
- **Border Radius:** `20px`
- **Font Size:** `12px`
- **Font Weight:** `500`
## 7. Layout e navegação
#### Badge Warning
- **Background:** `#FEF5E8`
- **Text Color:** `#F7A439`
- **Padding:** `4px 12px`
- **Border Radius:** `20px`
- **Font Size:** `12px`
- **Font Weight:** `500`
As páginas protegidas usam uma navbar fixa e um contêiner central com largura
máxima `max-w-8xl`, padding lateral responsivo e espaçamento vertical enxuto. A
navegação principal fica em
[`src/shared/components/navigation/navbar/`](./src/shared/components/navigation/navbar/).
#### Badge Error
- **Background:** `#FEF5F3`
- **Text Color:** `#F53F2D`
- **Padding:** `4px 12px`
- **Border Radius:** `20px`
- **Font Size:** `12px`
- **Font Weight:** `500`
Padrões:
## 5. Layout Principles
- telas do App Router devem continuar finas;
- conteúdo principal começa abaixo da navbar fixa (`pt-16`);
- use uma coluna em telas pequenas e expanda grids progressivamente;
- tabelas e gráficos devem preservar leitura em viewport estreita;
- ações essenciais precisam continuar alcançáveis por toque e teclado.
### Spacing System
- **Base Unit:** `4px`
- **Scale:** `4px`, `8px`, `12px`, `16px`, `24px`, `32px`, `48px`, `56px`, `64px`, `80px`, `96px`, `128px`
## 8. Acessibilidade
**Usage Contexts:**
- **48px:** Tight spacing within compact components (icon-text pairs, inline elements)
- **1216px:** Standard padding inside cards, inputs, and buttons
- **2432px:** Section gaps, spacing between components on a page
- **4864px:** Large section separations, hero spacing
- **80128px:** Hero margins, page-level vertical rhythm
- mantenha foco visível com os tokens `ring`;
- use HTML semântico antes de adicionar ARIA;
- não comunique estado apenas por cor;
- associe labels a inputs;
- forneça nome acessível para botões de ícone;
- confira contraste e navegação por teclado nos temas claro e escuro;
- mantenha áreas de toque confortáveis em mobile.
### Grid & Container
- **Max Width:** `1440px` for full-width containers
- **Content Width:** `1152px` for typical page layouts
- **Column Strategy:** 12-column grid system; gutter `24px`
- **Container Padding:** `48px` on desktop (left + right)
- **Section Pattern:** Full-width containers with internal max-width constraint
## 9. Checklist de revisão visual
### Whitespace Philosophy
OpenMonetis prioritizes breathing room and visual clarity. Whitespace is intentional and strategic—surrounding headings, separating card groups, and framing key messages. The warm neutral backgrounds (`#F8F6F4`, `#F5F2EF`) create natural visual separation without hard borders. Minimum margin between major sections is `64px` vertically; minimum padding inside containers is `16px`.
### Border Radius Scale
- **Sharp Corners:** `0px` (utility container tops, category selectors)
- **Subtle Radius:** `9.2px` (buttons, small inputs, icon buttons)
- **Standard Radius:** `11.2px` (cards, standard containers, modals)
- **Rounded Top:** `15.2px 15.2px 0px 0px` (card headers, sheet-style containers)
- **Pill Shape:** `24px` (badges, full-rounded tags, avatar images)
- **Circle:** `50%` (avatar images, radial elements)
## 6. Depth & Elevation
| Level | Treatment | Use |
|-------|-----------|-----|
| Flat (None) | No shadow, `border: 1px solid #F0EEEC` | Default cards, inputs, containers; baseline surfaces |
| Subtle (sm) | `0px 1px 3px rgba(0, 0, 0, 0.06)` | Secondary buttons, hover states on light surfaces |
| Medium (md) | `0px 4px 12px rgba(0, 0, 0, 0.08)` | Elevated cards on hover, floating actions |
| Deep (lg) | `0px 10px 24px rgba(0, 0, 0, 0.12)` | Modals, dropdowns, popover menus |
**Shadow Philosophy:**
The design system uses restrained shadow treatment aligned with a flat-modern aesthetic. Shadows emerge subtly on interaction (hover, focus) rather than as default styling. The primary depth cue is border color (`#F0EEEC`), which maintains visual hierarchy without excessive z-depth. When shadows are used, they employ warm-tinted blacks (`rgba(0, 0, 0, 0.060.12)`) to harmonize with the warm neutral palette.
## 7. Do's and Don'ts
### Do
- Use the primary orange (`#FF7733`) exclusively for the most important call-to-action buttons
- Apply generous padding (`24px48px`) around sections and inside cards for breathing room
- Stack elements vertically with `2432px` gaps for clear visual rhythm
- Use warm grays (`#443732`, `#2A2827`) for all body text to maintain the warm aesthetic
- Apply `9.2px` border radius consistently to all interactive elements (buttons, inputs)
- Keep line heights at `1.4×` or greater for comfortable reading on body text
- Use semantic colors (`#0E9D6E` success, `#F7A439` warning, `#F53F2D` error) intentionally
- Test contrast ratios; maintain WCAG AA minimum (4.5:1 for body text)
- Use the `Inter` typeface exclusively for consistency
- Implement focus states with a `3px` colored outline or border
### Don't
- Don't use orange anywhere except primary CTAs and critical highlights
- Don't reduce padding below `12px` inside cards or `8px` inside compact buttons
- Don't use dark backgrounds (`#0F0D0C`) for body text on light surfaces; stick to `#2A2827` or `#443732`
- Don't apply shadows as default styling; reserve them for elevated states (hover, focus, modal)
- Don't mix border radius values on the same component type; stick to defined scale
- Don't increase line height above `1.6×` for headings; tighten for impact
- Don't use the error color (`#F53F2D`) for general emphasis; reserve for genuine errors
- Don't create new colors outside the palette; use opacity if gradation is needed
- Don't apply multiple shadows to a single element; layer a maximum of two shadow values
- Don't forget to include focus/keyboard navigation states on all interactive elements
## 8. Responsive Behavior
### Breakpoints
| Breakpoint | Width | Key Changes |
|-----------|-------|-------------|
| Mobile | `375px599px` | Single column; container padding `16px`; font sizes reduce 12 sizes; gap scale halved |
| Tablet | `600px1023px` | Two-column grid; container padding `32px`; button height `36px`; heading sizes reduce slightly |
| Desktop | `1024px+` | Full 12-column grid; container padding `48px`; full-scale typography; max-width `1440px` |
### Touch Targets
- **Minimum Interactive Size:** `44px` × `44px` for mobile; `40px` × `40px` for desktop
- **Button Padding:** `12px` vertical, `16px` horizontal (minimum)
- **Link Padding:** `6px` vertical, `8px` horizontal minimum
- **Icon Button:** `32px` × `32px` minimum (32px on mobile preferred)
- **Spacing Between Targets:** `8px` minimum to avoid accidental activation
### Collapsing Strategy
- **Navigation:** Horizontal nav on desktop collapses to hamburger menu on tablet; menu items stack vertically with `12px` gap
- **Grid:** 12-column layout on desktop → 6-column on tablet → 2-column (stacked) on mobile
- **Cards:** Three-column card layouts collapse to single column on mobile; padding reduces from `24px` to `16px`
- **Typography:** Display (60px) → 36px on tablet → 28px on mobile; body (20px) → 18px on tablet → 16px on mobile
- **Spacing:** All spacing scale values reduce by 2533% on mobile (e.g., `24px` gap → `16px` on tablet, `12px` on mobile)
- **Buttons:** Full-width on mobile (padding `0px`); inline (auto-width) on desktop
- **Inputs:** Full-width on mobile; constrained width on desktop
## 9. Agent Prompt Guide
### Quick Color Reference
- **Primary CTA:** Warm Orange (`#FF7733`) — Buttons, highlights, key interactions
- **Primary Text:** Warm Brown (`#443732`) — Headings, strong emphasis
- **Secondary Text:** Dark Neutral (`#2A2827`) — Body text, card content
- **Background:** White (`#FFFFFF`) — Cards, primary surfaces
- **Background Alt:** Cream (`#FCF7F6`) — Alternative surfaces, light containers
- **Border:** Pale Gray (`#F0EEEC`) — Card borders, divider lines
- **Success:** Green (`#0E9D6E`) — Confirmation, positive states
- **Warning:** Amber (`#F7A439`) — Cautions, attention states
- **Error:** Red-Orange (`#F53F2D`) — Errors, destructive actions
- **Disabled:** Light Gray (`#E8E3E0`) — Inactive elements, inaccessible states
### Iteration Guide
1. **Always use `#FF7733` for primary buttons** and all main call-to-action elements; secondary buttons use `#FFFFFF` with `#F0EEEC` border
2. **Typography is always `Inter`** with weights 400 (body), 500 (emphasis), and 600 (headings); size hierarchy: 14 → 16 → 20 → 36 → 60px
3. **Spacing base is `4px`**; use multiples from the scale (8, 12, 16, 24, 32, 48, 64, 80, 96, 128px); never arbitrary values
4. **Border radius:** Apply `9.2px` to all buttons and inputs, `11.2px` to cards, `15.2px 15.2px 0px 0px` to top-bordered containers
5. **Cards default to `#FFFFFF` background with `1px solid #F0EEEC` border**; add shadow only on hover (0px 4px 12px rgba(0, 0, 0, 0.08))
6. **Form inputs:** Padding `12px 16px`, border `1px solid #F0EEEC`, focus state `border: 1px solid #FF7733` + `box-shadow: 0px 0px 0px 3px rgba(255, 119, 51, 0.1)`
7. **Navigation background is always `#FF7733`** with white text; links in content use `#443732` with underline on active
8. **Maintain 1.4× line height minimum for body text**; tighten headings to 1:1 or 1.2:1 ratio
9. **Contrast validation:** Text `#0F0D0C` or `#2A2827` on light backgrounds (WCAG AA); text `#FFFFFF` on `#FF7733` (WCAG AAA)
10. **Responsive collapse:** Reduce padding and font by 25% on mobile; stack multi-column layouts to single column; full-width buttons on mobile only
- O componente compartilhado existente foi reutilizado?
- As cores usam tokens semânticos?
- A tela funciona em tema claro e escuro?
- O layout continua legível em mobile?
- Foco, labels e nomes acessíveis estão presentes?
- Estados vazio, carregando, erro e sucesso foram considerados?
- Valores financeiros continuam fáceis de comparar?

View File

@@ -5,12 +5,13 @@
# ============================================
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
ARG PNPM_VERSION=11.1.3
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
WORKDIR /app
# Copiar apenas arquivos de dependências para aproveitar cache
COPY package.json pnpm-lock.yaml* ./
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
# Criar pasta public para o postinstall do pdfjs-dist
RUN mkdir -p public
@@ -23,7 +24,8 @@ RUN pnpm install --frozen-lockfile
# ============================================
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
ARG PNPM_VERSION=11.1.3
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
WORKDIR /app
@@ -52,7 +54,8 @@ RUN pnpm build
# ============================================
FROM node:22-alpine AS runner
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
ARG PNPM_VERSION=11.1.3
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
WORKDIR /app

View File

@@ -6,9 +6,11 @@
Projeto pessoal de gestão financeira. Self-hosted, manual e open source.
</p>
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
> **⚠️ Nota:** o OpenMonetis não está sendo encerrado, mas o desenvolvimento deve reduzir para quase zero daqui em diante. O app já cobre minhas demandas atuais de gerenciamento financeiro, então novas mudanças tendem a ser pontuais: correções, ajustes necessários e pequenas melhorias quando fizerem bastante sentido para meu uso.
[![Version](https://img.shields.io/badge/version-2.6.1-blue?style=flat-square)](CHANGELOG.md)
> **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.11-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/)
@@ -36,9 +38,11 @@
- [Backup](#-backup)
- [Storage S3 Compatível](#-storage-s3-compatível)
- [Variáveis de Ambiente](#-variáveis-de-ambiente)
- [Design System](#-design-system)
- [Arquitetura](#-arquitetura)
- [Contribuindo](#-contribuindo)
- [Apoie o Projeto](#-apoie-o-projeto)
- [Star History](#-star-history)
- [Licença](#-licença)
---
@@ -61,21 +65,21 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
### Funcionalidades
💰 **Contas e transações** — Contas bancárias, cartões, dinheiro. Receitas, despesas e transferências. Categorização, filtros combináveis, extratos detalhados e importação de extratos OFX e XLS/XLSX com detecção automática de categoria.
💰 **Contas e transações** — Contas bancárias, cartões, dinheiro. Receitas, despesas, 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.
📊 **Dashboard e relatórios** — Widgets interativos de métricas, gráficos de evolução, comparativos por categoria, tendências, uso de cartões, top estabelecimentos. Exportação em PDF e Excel.
📊 **Dashboard e relatórios** — Widgets 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.
💳 **Faturas de cartão** — Acompanhe faturas por período, controle limites e vencimentos.
💳 **Faturas de cartão** — Acompanhe faturas por período, controle limites e vencimentos com identificação visual mais clara do cartão.
🎯 **Orçamentos** — Defina limites por categoria e acompanhe o progresso.
💸 **Parcelamentos avançados** — Séries de parcelas, antecipação com cálculo de desconto, análise consolidada.
🤖 **Insights com IA** — Análises geradas por Claude, GPT, Gemini ou OpenRouter. Insights personalizados e histórico salvo.
🤖 **Insights com IA** — Análises geradas por Claude, GPT, Gemini, MiniMax, OpenRouter ou modelos locais via Ollama. Insights personalizados e histórico salvo.
👥 **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.
@@ -85,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 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
@@ -93,9 +97,10 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
- **PostgreSQL** + **Drizzle ORM**
- **Better Auth** (email/senha, OAuth, Passkeys/WebAuthn)
- **shadcn/ui** (Radix UI) + **Tailwind CSS**
- **Bricolage Grotesque** via `next/font`
- **Docker** (multi-stage build)
- **Biome** (linting + formatting)
- **Vercel AI SDK** (Claude, GPT, Gemini, OpenRouter)
- **Vercel AI SDK** (Claude, GPT, Gemini, MiniMax, OpenRouter, Ollama)
---
@@ -446,6 +451,9 @@ POSTGRES_DB=openmonetis_db
# Autenticação
DISABLE_SIGNUP=false # true bloqueia novos cadastros
AUTH_SESSION_EXPIRES_IN_DAYS=30 # duração de sessões persistentes
AUTH_SESSION_UPDATE_AGE_HOURS=24 # frequência de renovação da sessão
BETTER_AUTH_TRUSTED_ORIGINS= # origins adicionais confiáveis, separadas por vírgula
# S3 Server (opcional, necessario para anexos)
S3_ENDPOINT=
@@ -469,7 +477,10 @@ RESEND_FROM_EMAIL=
ANTHROPIC_API_KEY=
OPENAI_API_KEY=
GOOGLE_GENERATIVE_AI_API_KEY=
MINIMAX_API_KEY=
OPENROUTER_API_KEY=
OLLAMA_BASE_URL=http://localhost:11434/v1
OLLAMA_API_KEY=
# Logo.dev (opcional, necessário para logos automáticos de estabelecimentos)
# Ambas as variáveis são runtime — basta definir no host; nenhum build arg necessário.
@@ -477,6 +488,51 @@ LOGO_DEV_TOKEN=
LOGO_DEV_SECRET_KEY=
```
### BETTER_AUTH_TRUSTED_ORIGINS
Use `BETTER_AUTH_TRUSTED_ORIGINS` quando o OpenMonetis for acessado por uma URL diferente de `BETTER_AUTH_URL`, como Cloudflare Tunnel, reverse proxy, domínio local ou subdomínios temporários. Isso evita falhas de login como `Invalid origin` sem precisar alterar a imagem Docker.
Informe apenas origins confiáveis, separadas por vírgula:
```env
BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_TRUSTED_ORIGINS=https://*.trycloudflare.com,https://openmonetis.seudominio.com
```
Para Google OAuth e outros callbacks externos, mantenha `BETTER_AUTH_URL` apontando para a URL pública/canônica configurada no provedor.
### IA local com Ollama
O provider Ollama permite gerar insights usando modelos locais. Instale e suba o Ollama no host onde o modelo ficará disponível:
```bash
ollama pull llama3.2
ollama serve
```
Configure a URL OpenAI-compatible no `.env`:
```env
OLLAMA_BASE_URL=http://localhost:11434/v1
# Opcional; normalmente o Ollama local não exige chave.
OLLAMA_API_KEY=
```
Se o OpenMonetis estiver rodando dentro de um container Docker e o Ollama estiver no host, `localhost` aponta para o próprio container. Nesse caso, use uma URL acessível a partir do container, como `http://host.docker.internal:11434/v1` quando disponível, ou o endereço da rede Docker/host configurado no seu ambiente.
---
## 🎨 Design System
O OpenMonetis usa uma identidade visual própria com superfícies quentes, laranja
como cor de destaque, temas claro e escuro e tipografia Bricolage Grotesque. A
interface é construída com tokens semânticos em OKLCH, Tailwind CSS 4 e
componentes compartilhados baseados em shadcn/ui e Radix UI.
As regras de cores, tipografia, componentes, responsividade e acessibilidade
estão documentadas no [`DESIGN.md`](DESIGN.md). Use esse guia como referência ao
criar telas ou alterar componentes visuais.
---
## 🏗️ Arquitetura
@@ -567,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.11 -m "v2.7.11"
git push origin v2.7.11
```
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
@@ -579,6 +646,18 @@ Outras formas de contribuir: ⭐ estrela no repo, reportar bugs, melhorar docs,
---
## ⭐ Star History
<a href="https://www.star-history.com/?repos=felipegcoutinho%2Fopenmonetis&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=felipegcoutinho/openmonetis&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=felipegcoutinho/openmonetis&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=felipegcoutinho/openmonetis&type=date&legend=top-left" />
</picture>
</a>
---
## 📄 Licença
**Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International** (CC BY-NC-SA 4.0).

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",

View File

@@ -36,6 +36,7 @@ services:
DATABASE_URL: ${DATABASE_URL:-postgresql://openmonetis:openmonetis_dev_password@db:5432/openmonetis_db}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-}
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
BETTER_AUTH_TRUSTED_ORIGINS: ${BETTER_AUTH_TRUSTED_ORIGINS:-}
depends_on:
db:
condition: service_healthy

View File

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

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;

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

@@ -204,6 +204,34 @@
"when": 1777648189399,
"tag": "0029_friendly_spitfire",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"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
}
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "openmonetis",
"version": "2.6.1",
"version": "2.7.11",
"private": true,
"packageManager": "pnpm@11.1.3",
"scripts": {
@@ -31,31 +31,32 @@
"mockup": "tsx scripts/mock-data.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.78",
"@ai-sdk/google": "^3.0.75",
"@ai-sdk/openai": "^3.0.64",
"@aws-sdk/client-s3": "^3.1050.0",
"@aws-sdk/s3-request-presigner": "^3.1050.0",
"@better-auth/passkey": "^1.6.11",
"@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",
@@ -63,49 +64,50 @@
"@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8",
"@remixicon/react": "4.9.0",
"@tanstack/react-query": "^5.100.11",
"@tanstack/react-query": "^5.101.1",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"ai": "^6.0.185",
"better-auth": "1.6.11",
"@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",
"cmdk": "^1.1.1",
"date-fns": "^4.2.1",
"date-fns": "^4.4.0",
"drizzle-orm": "0.45.2",
"exceljs": "^4.4.0",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"next": "16.2.6",
"next": "16.2.7",
"next-themes": "0.4.6",
"pdfjs-dist": "^5.7.284",
"pdfjs-dist": "^6.0.227",
"pg": "8.21.0",
"react": "19.2.6",
"react": "19.2.7",
"react-day-picker": "^10.0.1",
"react-dom": "19.2.6",
"react-dom": "19.2.7",
"recharts": "3.8.1",
"resend": "^6.12.3",
"resend": "^6.16.0",
"sonner": "2.0.7",
"tailwind-merge": "3.6.0",
"tw-animate-css": "^1.4.0",
"vaul": "1.1.2",
"vercel-minimax-ai-provider": "^0.0.2",
"zod": "4.4.3"
},
"devDependencies": {
"@biomejs/biome": "2.4.15",
"@biomejs/biome": "2.4.16",
"@tailwindcss/postcss": "4.3.0",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "25.9.1",
"@types/pg": "^8.20.0",
"@types/react": "19.2.15",
"@types/react": "19.2.16",
"@types/react-dom": "19.2.3",
"babel-plugin-react-compiler": "^1.0.0",
"dotenv": "^17.4.2",
"drizzle-kit": "0.31.10",
"knip": "^6.14.1",
"knip": "^6.22.0",
"tailwindcss": "4.3.0",
"tsx": "4.22.3",
"tsx": "4.22.4",
"typescript": "6.0.3"
}
}

3971
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,11 +7,7 @@ allowBuilds:
sharp: true
unrs-resolver: true
minimumReleaseAgeExclude:
- '@aws-sdk/client-s3@3.1050.0'
- '@aws-sdk/s3-request-presigner@3.1050.0'
- '@types/node@25.9.1'
- '@types/react@19.2.15'
minimumReleaseAge: 0
overrides:
defu: 6.1.7

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -1,10 +1,10 @@
import { Inter } from "next/font/google";
import { Bricolage_Grotesque } from "next/font/google";
export const inter = Inter({
export const bricolage = Bricolage_Grotesque({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
fallback: ["ui-sans-serif", "system-ui"],
variable: "--font-bricolage",
fallback: ["arial", "ui-sans-serif", "system-ui"],
weight: ["500", "600", "700"],
preload: true,
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 571 KiB

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 562 KiB

After

Width:  |  Height:  |  Size: 355 KiB

View File

@@ -1,3 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200" role="img" aria-label="OpenMonetis">
<path fill="#ff7733" d="M 77.66,165.64 L 37.77,141.54 L 63.30,108.72 L 27.13,97.44 L 46.81,50.77 L 77.66,63.08 L 81.91,30.26 L 126.40,29.23 L 126.06,33.85 L 122.87,67.69 L 158.51,50.77 L 178.19,90.26 L 140.96,104.62 L 162.23,127.18 L 132.98,162.56 L 103.19,131.79 Z"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" aria-label="OpenMonetis" role="img" viewBox="0 0 200 200"><path fill="#f73" d="M 77.66,165.64 L 37.77,141.54 L 63.30,108.72 L 27.13,97.44 L 46.81,50.77 L 77.66,63.08 L 81.91,30.26 L 126.40,29.23 L 126.06,33.85 L 122.87,67.69 L 158.51,50.77 L 178.19,90.26 L 140.96,104.62 L 162.23,127.18 L 132.98,162.56 L 103.19,131.79 Z"/></svg>

Before

Width:  |  Height:  |  Size: 402 B

After

Width:  |  Height:  |  Size: 394 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="1em" style="flex:none;line-height:1" width="1em" viewBox="0 0 24 24"><title>MiniMax</title><defs><linearGradient id="lobe-icons-minimax-gradient" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"/><stop offset="100%" stop-color="#FE603C"/></linearGradient></defs><path fill="url(#lobe-icons-minimax-gradient)" fill-rule="nonzero" d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@@ -229,14 +229,22 @@ if (await askYesNo(" E-mail via Resend (notificações e convites)?")) {
let anthropicKey = "";
let openaiKey = "";
let googleAiKey = "";
let minimaxKey = "";
let openrouterKey = "";
if (await askYesNo(" Insights com IA (Claude, GPT, Gemini, OpenRouter)?")) {
let ollamaBaseUrl = "";
let ollamaApiKey = "";
if (await askYesNo(" Insights com IA (Claude, GPT, Gemini, MiniMax, OpenRouter)?")) {
console.log(` ${c.dim}Deixe em branco o que não for usar${c.reset}`);
anthropicKey = await ask(" ANTHROPIC_API_KEY: ");
openaiKey = await ask(" OPENAI_API_KEY: ");
googleAiKey = await ask(" GOOGLE_GENERATIVE_AI_API_KEY: ");
minimaxKey = await ask(" MINIMAX_API_KEY: ");
openrouterKey = await ask(" OPENROUTER_API_KEY: ");
}
if (await askYesNo(" Insights locais com Ollama?")) {
ollamaBaseUrl = await askDefault(" OLLAMA_BASE_URL", "http://localhost:11434/v1");
ollamaApiKey = await ask(" OLLAMA_API_KEY (opcional): ");
}
// Domínio público
let publicDomain = "";
@@ -285,6 +293,9 @@ const envContent = [
"# === Better Auth ===",
`BETTER_AUTH_SECRET=${authSecret}`,
`BETTER_AUTH_URL=${betterAuthUrl}`,
"DISABLE_SIGNUP=false",
"AUTH_SESSION_EXPIRES_IN_DAYS=30",
"AUTH_SESSION_UPDATE_AGE_HOURS=24",
"",
"# === Portas ===",
"APP_PORT=3000",
@@ -310,7 +321,10 @@ const envContent = [
opt("ANTHROPIC_API_KEY", anthropicKey),
opt("OPENAI_API_KEY", openaiKey),
opt("GOOGLE_GENERATIVE_AI_API_KEY", googleAiKey),
opt("MINIMAX_API_KEY", minimaxKey),
opt("OPENROUTER_API_KEY", openrouterKey),
opt("OLLAMA_BASE_URL", ollamaBaseUrl),
opt("OLLAMA_API_KEY", ollamaApiKey),
].join("\n");
writeFileSync(join(targetDir, ".env"), envContent);

View File

@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
import { connection } from "next/server";
import { AccountDialog } from "@/features/accounts/components/account-dialog";
import { AccountStatementCard } from "@/features/accounts/components/account-statement-card";
import { AddYieldDialog } from "@/features/accounts/components/add-yield-dialog";
import { AdjustBalanceDialog } from "@/features/accounts/components/adjust-balance-dialog";
import type { Account } from "@/features/accounts/components/types";
import {
@@ -31,6 +32,7 @@ import MonthNavigation from "@/shared/components/month-picker/month-navigation";
import { Button } from "@/shared/components/ui/button";
import { getUserId } from "@/shared/lib/auth/server";
import { loadLogoOptions } from "@/shared/lib/logo/options";
import { getBusinessDateString } from "@/shared/utils/date";
import { parsePeriodParam } from "@/shared/utils/period";
type PageSearchParams = Promise<ResolvedSearchParams>;
@@ -52,6 +54,17 @@ const resolveDefaultPaymentMethod = (
return "Pix";
};
const resolveDefaultYieldDate = (period: string) => {
const today = getBusinessDateString();
if (today.startsWith(period)) return today;
const [year, month] = period.split("-").map((part) => Number(part));
if (!year || !month) return today;
const lastDay = new Date(year, month, 0).getDate();
return `${period}-${String(lastDay).padStart(2, "0")}`;
};
export default async function Page({ params, searchParams }: PageProps) {
await connection();
const { accountId } = await params;
@@ -96,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(
@@ -109,6 +124,7 @@ export default async function Page({ params, searchParams }: PageProps) {
accountSummary;
const periodLabel = `${capitalize(monthName)} de ${year}`;
const defaultYieldDate = resolveDefaultYieldDate(selectedPeriod);
const accountDialogData: Account = {
id: account.id,
@@ -152,11 +168,17 @@ export default async function Page({ params, searchParams }: PageProps) {
totalExpenses={totalExpenses}
logo={account.logo}
balanceAdjustment={
<>
<AddYieldDialog
accountId={account.id}
defaultDate={defaultYieldDate}
/>
<AdjustBalanceDialog
accountId={account.id}
period={selectedPeriod}
currentBalance={currentBalance}
/>
</>
}
actions={
<AccountDialog
@@ -213,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

@@ -16,8 +16,7 @@ export default function RootLayout({
icon={<RiBankLine />}
title="Contas"
subtitle="Acompanhe todas as contas do mês selecionado incluindo receitas,
despesas e transações previstas. Use o seletor abaixo para navegar pelos
meses e visualizar as movimentações correspondentes."
despesas e transações previstas."
/>
{children}
</section>

View File

@@ -1,6 +1,6 @@
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 { getUserId } from "@/shared/lib/auth/server";
import { parsePeriodParam } from "@/shared/utils/period";
@@ -26,11 +26,14 @@ export default async function Page({ searchParams }: PageProps) {
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);
@@ -136,6 +138,7 @@ export default async function Page({ params, searchParams }: PageProps) {
limitAvailable: limitAmount,
currentInvoiceAmount: 0,
currentInvoiceLabel: "",
currentInvoiceStatus: null,
};
const { totalAmount, invoiceStatus, paymentDate } = invoiceData;
@@ -209,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

@@ -16,8 +16,7 @@ export default function RootLayout({
icon={<RiBankCard2Line />}
title="Cartões"
subtitle="Acompanhe todas os cartões do mês selecionado incluindo faturas, limites
e transações previstas. Use o seletor abaixo para navegar pelos meses e
visualizar as movimentações correspondentes."
e transações previstas."
/>
{children}
</section>

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),
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

@@ -27,6 +27,10 @@ export default async function Page({ searchParams }: PageProps) {
const { dashboardData, preferences, quickActionOptions } =
await fetchDashboardPageData(user.id, selectedPeriod);
const { dashboardWidgets } = preferences;
const adminPayerSlug =
quickActionOptions.payerOptions.find(
(option) => option.value === quickActionOptions.defaultPayerId,
)?.slug ?? null;
const logoMappings = await prefetchLogoMappings(
user.id,
@@ -37,7 +41,11 @@ export default async function Page({ searchParams }: PageProps) {
<main className="flex flex-col gap-4">
<DashboardWelcome name={user.name} />
<MonthNavigation />
<DashboardMetricsCards metrics={dashboardData.metrics} />
<DashboardMetricsCards
metrics={dashboardData.metrics}
period={selectedPeriod}
adminPayerSlug={adminPayerSlug}
/>
<LogoPrefetchProvider mappings={logoMappings}>
<DashboardGridEditable
data={dashboardData}

View File

@@ -1,39 +1,125 @@
import { Card, CardContent } from "@/shared/components/ui/card";
import { Skeleton } from "@/shared/components/ui/skeleton";
/**
* Loading state para a página de insights com IA
*/
const providers = [
"openai",
"anthropic",
"google",
"minimax",
"openrouter",
"ollama",
];
const summaryRows = ["period", "data-source"];
export default function InsightsLoading() {
return (
<main className="flex flex-col gap-6">
<div className="space-y-6 pt-4">
{/* Header */}
<Card className="flex w-full flex-row items-center justify-between gap-2 px-3 py-3 sm:px-4">
<div className="flex items-center gap-2">
<Skeleton className="size-8 bg-foreground/10" />
<Skeleton className="h-8 w-40 bg-foreground/10" />
<Skeleton className="size-8 bg-foreground/10" />
</div>
</Card>
<section className="space-y-4">
<div className="grid items-stretch gap-4 xl:grid-cols-[minmax(0,1fr)_340px]">
<div className="space-y-4">
<Card className="border-border/70 bg-card/95 shadow-sm">
<CardContent className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-10 w-64 rounded-md bg-foreground/10" />
<Skeleton className="h-6 w-96 rounded-md bg-foreground/10" />
<Skeleton className="h-8 w-64 bg-foreground/10" />
<Skeleton className="h-4 w-full max-w-2xl bg-foreground/10" />
<Skeleton className="h-4 w-3/4 max-w-xl bg-foreground/10" />
</div>
{/* Grid de insights */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-md border p-6 space-y-4">
<div className="flex items-start justify-between">
<div className="space-y-2 flex-1">
<Skeleton className="h-6 w-48 rounded-md bg-foreground/10" />
<Skeleton className="h-4 w-full rounded-md bg-foreground/10" />
<Skeleton className="h-4 w-3/4 rounded-md bg-foreground/10" />
</div>
<Skeleton className="size-8 rounded-full bg-foreground/10" />
</div>
<div className="space-y-3">
<div className="space-y-2">
<Skeleton className="h-3 w-full rounded-md bg-foreground/10" />
<Skeleton className="h-3 w-full rounded-md bg-foreground/10" />
<Skeleton className="h-3 w-2/3 rounded-md bg-foreground/10" />
<Skeleton className="h-4 w-28 bg-foreground/10" />
<Skeleton className="h-3 w-80 max-w-full bg-foreground/10" />
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{providers.map((provider) => (
<div
className="flex min-h-24 items-start gap-3 rounded-2xl border p-4"
key={provider}
>
<Skeleton className="mt-1 size-4 shrink-0 rounded-full bg-foreground/10" />
<Skeleton className="size-8 shrink-0 rounded-full bg-foreground/10" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-20 bg-foreground/10" />
<Skeleton className="h-3 w-full bg-foreground/10" />
<Skeleton className="h-3 w-3/4 bg-foreground/10" />
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
<Card className="border-border/70 bg-card/95 shadow-sm">
<CardContent className="space-y-6">
<div className="space-y-3">
<div className="space-y-2">
<Skeleton className="h-4 w-32 bg-foreground/10" />
<Skeleton className="h-3 w-72 max-w-full bg-foreground/10" />
</div>
<Skeleton className="h-9 w-full max-w-72 bg-foreground/10" />
</div>
<div className="flex items-center justify-between gap-3">
<Skeleton className="h-9 w-24 bg-foreground/10" />
<Skeleton className="h-9 w-32 bg-foreground/10" />
</div>
</CardContent>
</Card>
</div>
<Card className="border-border/70 bg-card/95 shadow-sm">
<CardContent className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<Skeleton className="size-9 rounded-xl bg-foreground/10" />
<div className="space-y-2">
<Skeleton className="h-4 w-32 bg-foreground/10" />
<Skeleton className="h-3 w-24 bg-foreground/10" />
</div>
</div>
<Skeleton className="h-9 w-full bg-foreground/10" />
<Skeleton className="h-8 w-full bg-foreground/10" />
<div className="space-y-4">
{summaryRows.map((row) => (
<div className="flex gap-3" key={row}>
<Skeleton className="size-4 shrink-0 bg-foreground/10" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3 w-24 bg-foreground/10" />
<Skeleton className="h-3 w-full bg-foreground/10" />
</div>
</div>
))}
</div>
<div className="space-y-3">
<Skeleton className="h-3 w-32 bg-foreground/10" />
<div className="flex items-center gap-3">
<Skeleton className="size-8 rounded-full bg-foreground/10" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-20 bg-foreground/10" />
<Skeleton className="h-3 w-32 bg-foreground/10" />
</div>
</div>
</div>
<Skeleton className="h-20 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-20 w-full rounded-2xl bg-foreground/10" />
</CardContent>
</Card>
</div>
</section>
</main>
);
}

View File

@@ -1,10 +1,12 @@
import { connection } from "next/server";
import { fetchDashboardNavbarData } from "@/features/dashboard/lib/navbar-queries";
import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar";
import { AppPreferencesProvider } from "@/shared/components/providers/app-preferences-provider";
import { LogoDevProvider } from "@/shared/components/providers/logo-dev-provider";
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
import { getUserSession } from "@/shared/lib/auth/server";
import { isLogoDevEnabled } from "@/shared/lib/logo/server";
import { fetchAppPreferences } from "@/shared/lib/preferences/queries";
export default async function DashboardLayout({
children,
@@ -13,17 +15,22 @@ export default async function DashboardLayout({
}>) {
await connection();
const session = await getUserSession();
const navbarData = await fetchDashboardNavbarData(session.user.id);
const [navbarData, appPreferences] = await Promise.all([
fetchDashboardNavbarData(session.user.id),
fetchAppPreferences(session.user.id),
]);
const logoDevEnabled = isLogoDevEnabled();
return (
<LogoDevProvider enabled={logoDevEnabled}>
<AppPreferencesProvider {...appPreferences}>
<PrivacyProvider>
<AppNavbar
user={{ ...session.user, image: session.user.image ?? null }}
payerAvatarUrl={navbarData.payerAvatarUrl}
inboxPendingCount={navbarData.inboxPendingCount}
notificationsSnapshot={navbarData.notificationsSnapshot}
financeLinks={navbarData.financeLinks}
/>
<div className="relative flex flex-1 flex-col pt-16">
<div className="@container/main flex flex-1 flex-col gap-2">
@@ -33,6 +40,7 @@ export default async function DashboardLayout({
</div>
</div>
</PrivacyProvider>
</AppPreferencesProvider>
</LogoDevProvider>
);
}

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

@@ -87,6 +87,8 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
dividedFilter: null,
amountMinFilter: null,
amountMaxFilter: null,
dateStartFilter: null,
dateEndFilter: null,
};
const createEmptySlugMaps = (): SlugMaps => ({
@@ -129,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>
@@ -161,6 +164,8 @@ export default async function Page({ params, searchParams }: PageProps) {
filters: searchFilters,
slugMaps,
payerId: pagador.id,
hideAnticipatedInstallments:
userPreferences?.hideAnticipatedInstallments ?? false,
});
const sharesPromise = canEdit
@@ -182,7 +187,6 @@ export default async function Page({ params, searchParams }: PageProps) {
shareRows,
currentUserShare,
estabelecimentos,
userPreferences,
] = await Promise.all([
fetchPayerTransactions(filters),
fetchPayerMonthlyBreakdown({
@@ -218,7 +222,6 @@ export default async function Page({ params, searchParams }: PageProps) {
sharesPromise,
currentUserSharePromise,
fetchRecentEstablishments(userId),
fetchUserPreferences(userId),
]);
const mappedTransactions = mapTransactionsData(transactionRows);
@@ -405,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

@@ -0,0 +1,61 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card";
import { Skeleton } from "@/shared/components/ui/skeleton";
const installmentCards = ["first", "second", "third"];
export default function Loading() {
return (
<main className="flex flex-col gap-4 pb-8">
<Card className="border-none bg-primary/10 shadow-none">
<CardContent className="flex flex-col items-start justify-center gap-2 py-2">
<Skeleton className="h-4 w-64 bg-foreground/10" />
<Skeleton className="h-9 w-36 bg-foreground/10" />
<Skeleton className="h-4 w-32 bg-foreground/10" />
</CardContent>
</Card>
<Skeleton className="h-8 w-36 bg-foreground/10" />
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{installmentCards.map((card) => (
<Card key={card} className="overflow-hidden">
<CardHeader className="pb-0">
<div className="flex items-start gap-2">
<Skeleton className="mt-1 size-4 shrink-0 bg-foreground/10" />
<Skeleton className="size-10 shrink-0 rounded-full bg-foreground/10" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-32 bg-foreground/10" />
<Skeleton className="h-4 w-24 bg-foreground/10" />
</div>
<Skeleton className="h-5 w-16 rounded-full bg-foreground/10" />
</div>
</CardHeader>
<CardContent>
<div className="mb-4 grid grid-cols-2 gap-4 rounded-lg bg-primary/5 p-4">
<div className="space-y-2">
<Skeleton className="h-3 w-24 bg-foreground/10" />
<Skeleton className="h-6 w-20 bg-foreground/10" />
</div>
<div className="flex flex-col items-end gap-2">
<Skeleton className="h-3 w-16 bg-foreground/10" />
<Skeleton className="h-6 w-20 bg-foreground/10" />
</div>
</div>
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between">
<Skeleton className="h-3 w-40 bg-foreground/10" />
<Skeleton className="h-3 w-16 bg-foreground/10" />
</div>
<Skeleton className="h-2.5 w-full bg-foreground/10" />
</div>
<Skeleton className="h-8 w-full bg-foreground/10" />
</CardContent>
</Card>
))}
</div>
</main>
);
}

View File

@@ -82,6 +82,15 @@ export default async function Page() {
userPreferences?.transactionsColumnOrder ?? null
}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
showTransactionSummary={
userPreferences?.showTransactionSummary ?? true
}
groupTransactionsByDate={
userPreferences?.groupTransactionsByDate ?? true
}
hideAnticipatedInstallments={
userPreferences?.hideAnticipatedInstallments ?? false
}
/>
</div>
</Card>

View File

@@ -16,8 +16,7 @@ export default function RootLayout({
icon={<RiArrowLeftRightLine />}
title="Lançamentos"
subtitle="Acompanhe todos os lançamentos financeiros do mês selecionado incluindo
receitas, despesas e transações previstas. Use o seletor abaixo para
navegar pelos meses e visualizar as movimentações correspondentes."
receitas, despesas e transações previstas."
/>
{children}
</section>

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

@@ -38,7 +38,7 @@ export const metadata: Metadata = {
description: DESCRIPTION,
images: [
{
url: "/images/dashboard-preview-light.webp",
url: "/images/dashboard-preview-light.png",
width: 1920,
height: 1080,
alt: "OpenMonetis — Dashboard de finanças pessoais",
@@ -49,7 +49,7 @@ export const metadata: Metadata = {
card: "summary_large_image",
title: TITLE,
description: DESCRIPTION,
images: ["/images/dashboard-preview-light.webp"],
images: ["/images/dashboard-preview-light.png"],
},
robots: {
index: true,

View File

@@ -86,7 +86,7 @@ export async function POST(request: Request) {
const body = await request.json();
const { items } = inboxBatchSchema.parse(body);
// Processar todos os itens em paralelo
// lançar todos os itens em paralelo
const settled = await Promise.allSettled(
items.map((item) =>
db
@@ -119,7 +119,7 @@ export async function POST(request: Request) {
return {
clientId: item?.clientId,
success: false,
error: "Erro ao processar notificação",
error: "Erro ao lançar notificação",
};
});
@@ -160,7 +160,7 @@ export async function POST(request: Request) {
console.error("[API] Error creating batch inbox items:", error);
return NextResponse.json(
{ error: "Erro ao processar notificações" },
{ error: "Erro ao lançar notificações" },
{ status: 500 },
);
}

View File

@@ -127,7 +127,7 @@ export async function POST(request: Request) {
console.error("[API] Error creating inbox item:", error);
return NextResponse.json(
{ error: "Erro ao processar notificação" },
{ error: "Erro ao lançar notificação" },
{ status: 500 },
);
}

View File

@@ -4,7 +4,7 @@
@custom-variant dark (&:is(.dark *));
@theme {
--spacing-custom-height-card: 29rem;
--spacing-custom-height-card: 30rem;
--spacing-8xl: 90rem;
}
@@ -28,7 +28,7 @@
--accent: oklch(94.8% 0.009 65);
--accent-foreground: var(--foreground);
--success: oklch(61.685% 0.13077 162.978);
--success: oklch(63.924% 0.1657 151.561);
--success-foreground: oklch(98% 0.01 150);
--warning: oklch(78.357% 0.15147 68.301);
--warning-foreground: oklch(20% 0.04 85);
@@ -90,7 +90,7 @@
.dark {
--background: oklch(18% 0.004 55);
--foreground: oklch(93% 0.008 80);
--foreground: #feefe1;
--card: oklch(21.531% 0.00369 48.293);
--card-foreground: var(--foreground);
--popover: oklch(24% 0.004 55);
@@ -117,7 +117,7 @@
--destructive: oklch(62% 0.2 28);
--destructive-foreground: oklch(98% 0.005 30);
--border: oklch(31.987% 0.00462 39.069);
--border: oklch(29.675% 0.01144 67.3);
--input: var(--border);
--ring: var(--primary);
@@ -170,7 +170,7 @@
}
@theme inline {
--default-font-family: var(--font-inter);
--default-font-family: var(--font-bricolage);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);

View File

@@ -4,7 +4,7 @@ import { QueryProvider } from "@/shared/components/providers/query-provider";
import { ThemeProvider } from "@/shared/components/providers/theme-provider";
import { Toaster } from "@/shared/components/ui/sonner";
import "./globals.css";
import { inter } from "@/public/fonts/font_index";
import { bricolage } from "@/public/fonts/font_index";
export const metadata: Metadata = {
title: {
@@ -24,7 +24,7 @@ export default function RootLayout({
<html
data-scroll-behavior="smooth"
lang="pt-BR"
className={`${inter.variable}`}
className={`${bricolage.className}`}
suppressHydrationWarning
>
<head>

View File

@@ -154,6 +154,15 @@ export const userPreferences = pgTable("preferencias_usuario", {
string[] | null
>(),
attachmentMaxSizeMb: integer("attachment_max_size_mb").notNull().default(50),
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[];
@@ -495,7 +504,7 @@ export const inboxItems = pgTable(
withTimezone: true,
}).notNull(),
// Dados parseados (editáveis pelo usuário antes de processar)
// Dados parseados (editáveis pelo usuário antes de lançar)
parsedName: text("parsed_name"), // Nome do estabelecimento
parsedAmount: numeric("parsed_amount", { precision: 12, scale: 2 }),
@@ -844,11 +853,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 }) => ({
@@ -969,6 +979,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",
{
@@ -1041,6 +1069,7 @@ export const attachmentsRelations = relations(attachments, ({ one, many }) => ({
references: [user.id],
}),
transactionAttachments: many(transactionAttachments),
noteAttachments: many(noteAttachments),
}));
export const transactionAttachmentsRelations = relations(
@@ -1057,8 +1086,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

@@ -32,9 +32,20 @@ import {
formatCurrency,
formatDecimalForDbRequired,
} from "@/shared/utils/currency";
import { getBusinessTodayDate, getTodayInfo } from "@/shared/utils/date";
import {
getBusinessTodayDate,
getTodayInfo,
parseLocalDateString,
} from "@/shared/utils/date";
import { derivePeriodFromDate } from "@/shared/utils/period";
import { normalizeFilePath } from "@/shared/utils/string";
const ACCOUNT_YIELD_CATEGORY_NAME = "Rendimentos";
const ACCOUNT_YIELD_CATEGORY_ICON = "RiFundsLine";
const ACCOUNT_YIELD_TRANSACTION_NAME = "Rendimento";
const ACCOUNT_YIELD_CONDITION = INITIAL_BALANCE_CONDITION;
const ACCOUNT_YIELD_PAYMENT_METHOD = "Transferência bancária" as const;
const accountBaseSchema = z.object({
name: z
.string({ message: "Informe o nome da conta." })
@@ -408,6 +419,107 @@ const adjustAccountBalanceSchema = z.object({
type AdjustAccountBalanceInput = z.infer<typeof adjustAccountBalanceSchema>;
const addAccountYieldSchema = z.object({
accountId: uuidSchema("FinancialAccount"),
amount: z
.number({ message: "Valor inválido." })
.positive("Informe um valor maior que zero."),
date: z
.string({ message: "Data inválida." })
.trim()
.regex(/^\d{4}-\d{2}-\d{2}$/u, "Data inválida."),
});
type AddAccountYieldInput = z.infer<typeof addAccountYieldSchema>;
export async function addAccountYieldAction(
input: AddAccountYieldInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = addAccountYieldSchema.parse(input);
const adminPayerId = await getAdminPayerId(user.id);
if (!adminPayerId) {
throw new Error(
"Pessoa com papel administrador não encontrada. Crie uma pessoa admin antes de adicionar rendimentos.",
);
}
const purchaseDate = parseLocalDateString(data.date);
if (Number.isNaN(purchaseDate.getTime())) {
throw new Error("Data inválida.");
}
await db.transaction(async (tx: typeof db) => {
const account = await tx.query.financialAccounts.findFirst({
columns: { id: true },
where: and(
eq(financialAccounts.id, data.accountId),
eq(financialAccounts.userId, user.id),
),
});
if (!account) {
throw new Error("Conta não encontrada.");
}
const existingCategory = await tx.query.categories.findFirst({
columns: { id: true },
where: and(
eq(categories.userId, user.id),
eq(categories.type, "receita"),
eq(categories.name, ACCOUNT_YIELD_CATEGORY_NAME),
),
});
const category =
existingCategory ??
(
await tx
.insert(categories)
.values({
name: ACCOUNT_YIELD_CATEGORY_NAME,
type: "receita",
icon: ACCOUNT_YIELD_CATEGORY_ICON,
userId: user.id,
})
.returning({ id: categories.id })
)[0];
if (!category) {
throw new Error(
"Não foi possível preparar a categoria de rendimentos.",
);
}
await tx.insert(transactions).values({
condition: ACCOUNT_YIELD_CONDITION,
name: ACCOUNT_YIELD_TRANSACTION_NAME,
paymentMethod: ACCOUNT_YIELD_PAYMENT_METHOD,
note: null,
amount: formatDecimalForDbRequired(data.amount),
purchaseDate,
transactionType: "Receita" as const,
period: derivePeriodFromDate(data.date),
isSettled: true,
userId: user.id,
accountId: data.accountId,
cardId: null,
categoryId: category.id,
payerId: adminPayerId,
});
});
revalidateForEntity("accounts", user.id);
revalidateForEntity("transactions", user.id);
return { success: true, message: "Rendimento adicionado com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function adjustAccountBalanceAction(
input: AdjustAccountBalanceInput,
): Promise<ActionResult> {

View File

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

View File

@@ -1,6 +1,6 @@
"use client";
import { RiInformationLine } from "@remixicon/react";
import { RiBankLine, RiInformationLine } from "@remixicon/react";
import Image from "next/image";
import type { ReactNode } from "react";
import MoneyValues from "@/shared/components/money-values";
@@ -52,28 +52,32 @@ export function AccountStatementCard({
const resultado = totalIncomes - totalExpenses;
return (
<Card className="gap-0 py-0">
<Card className="gap-0 py-0 space-y-2">
<CardContent className="px-4 py-4 sm:px-5 sm:py-5">
<div className="flex flex-col gap-4">
{/* Linha 1 — identidade */}
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
{logoPath ? (
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full">
<Image
src={logoPath}
alt={`Logo ${accountName}`}
width={42}
height={42}
width={48}
height={48}
className="h-full w-full object-contain"
/>
</div>
) : null}
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold text-foreground">
) : (
<span className="flex size-12 shrink-0 items-center justify-center rounded-full border bg-card text-primary">
<RiBankLine className="size-5" aria-hidden />
</span>
)}
<div className="min-w-0 space-y-1">
<h2 className="truncate text-xl font-semibold text-foreground sm:text-2xl">
{accountName}
</h2>
<p className="text-xs text-muted-foreground">
<p className="text-sm leading-relaxed text-muted-foreground">
Extrato de {periodLabel}
</p>
</div>
@@ -82,7 +86,7 @@ export function AccountStatementCard({
</div>
{/* Linha 2 — saldo final (hero) */}
<div className="space-y-4">
<div className="space-y-3">
<p className="text-sm text-muted-foreground ">
Saldo ao final do período
</p>

View File

@@ -0,0 +1,155 @@
"use client";
import { RiCalculatorLine, RiFundsLine } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner";
import { addAccountYieldAction } from "@/features/accounts/actions";
import { CalculatorDialogButton } from "@/shared/components/calculator/calculator-dialog";
import { Button } from "@/shared/components/ui/button";
import { CurrencyInput } from "@/shared/components/ui/currency-input";
import { DatePicker } from "@/shared/components/ui/date-picker";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog";
import { Label } from "@/shared/components/ui/label";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
type AddYieldDialogProps = {
accountId: string;
defaultDate: string;
};
export function AddYieldDialog({
accountId,
defaultDate,
}: AddYieldDialogProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const [amount, setAmount] = useState("");
const [date, setDate] = useState(defaultDate);
useEffect(() => {
if (open) {
setAmount("");
setDate(defaultDate);
}
}, [open, defaultDate]);
const handleSave = () => {
const numericAmount = Number(amount);
if (!Number.isFinite(numericAmount) || numericAmount <= 0) {
toast.error("Informe um valor maior que zero.");
return;
}
if (!date) {
toast.error("Informe a data do rendimento.");
return;
}
startTransition(async () => {
const result = await addAccountYieldAction({
accountId,
amount: numericAmount,
date,
});
if (result.success) {
toast.success(result.message);
setOpen(false);
router.refresh();
return;
}
toast.error(result.error);
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-primary hover:text-primary"
aria-label="Adicionar rendimento"
>
<RiFundsLine className="size-4" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>Adicionar rendimento</TooltipContent>
</Tooltip>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Adicionar rendimento</DialogTitle>
<DialogDescription>
Registre um rendimento como receita paga nesta conta.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="yield-amount">Valor</Label>
<div className="relative">
<CurrencyInput
id="yield-amount"
value={amount}
onValueChange={setAmount}
autoFocus
className="pr-10"
placeholder="R$ 0,00"
/>
<CalculatorDialogButton
variant="ghost"
size="icon-sm"
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2"
onSelectValue={setAmount}
>
<RiCalculatorLine className="h-4 w-4 text-muted-foreground" />
</CalculatorDialogButton>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="yield-date">Data</Label>
<DatePicker
id="yield-date"
value={date}
onChange={setDate}
placeholder="Data"
required
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="button" onClick={handleSave} disabled={isPending}>
{isPending ? "Salvando..." : "Adicionar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -17,6 +17,11 @@ import {
DialogTrigger,
} from "@/shared/components/ui/dialog";
import { Label } from "@/shared/components/ui/label";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { formatCurrency } from "@/shared/utils/currency";
type AdjustBalanceDialogProps = {
@@ -79,17 +84,22 @@ export function AdjustBalanceDialog({
return (
<Dialog open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
className="text-primary hover:text-primary"
aria-label="Ajustar saldo"
>
<RiEqualizerLine className="size-4" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>Ajustar saldo</TooltipContent>
</Tooltip>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Ajustar saldo</DialogTitle>

View File

@@ -32,7 +32,7 @@ function PdfCanvas({ url }: PdfCanvasProps) {
let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;
try {
pdf = await pdfjsLib.getDocument(url).promise;
pdf = await pdfjsLib.getDocument({ url }).promise;
} catch (err) {
if ((err as { name?: string }).name === "PasswordException") {
if (!cancelled) setLocked(true);
@@ -162,9 +162,15 @@ export function AttachmentGridItem({
</div>
{/* Data */}
<span className="text-xs text-muted-foreground">
<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,6 +201,67 @@ export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
{filter !== "all" &&
` · ${FILTERS.find((f) => f.value === filter)?.label.toLowerCase()}`}
</p>
<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"
>
<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
@@ -193,13 +294,18 @@ export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
))}
</div>
</div>
</div>
{filteredAttachments.length === 0 ? (
<div className="flex w-full items-center justify-center py-12">
<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

@@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
import { type FormEvent, useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/shared/components/ui/button";
import { Checkbox } from "@/shared/components/ui/checkbox";
import {
Field,
FieldDescription,
@@ -38,6 +39,7 @@ export function LoginForm({
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const [error, setError] = useState("");
const [loadingEmail, setLoadingEmail] = useState(false);
@@ -60,7 +62,7 @@ export function LoginForm({
email,
password,
callbackURL: "/dashboard",
rememberMe: false,
rememberMe,
},
{
onRequest: () => {
@@ -186,6 +188,24 @@ export function LoginForm({
/>
</Field>
<div className="flex items-start gap-3">
<Checkbox
id="remember-me"
checked={rememberMe}
onCheckedChange={(checked) => setRememberMe(checked === true)}
disabled={loadingEmail || loadingGoogle || loadingPasskey}
className="mt-0.5"
/>
<div className="grid gap-1">
<FieldLabel
htmlFor="remember-me"
className="cursor-pointer font-medium"
>
Manter conectado neste dispositivo
</FieldLabel>
</div>
</div>
<Field>
<Button
type="submit"

View File

@@ -81,6 +81,8 @@ const renderLancamento = (
const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
const isPaid = Boolean(event.transaction.isSettled);
const isIncome = event.transaction.transactionType === "Receita";
const settlementLabel = isIncome ? "Recebido" : "Pago";
const dueDateLabel = formatFinancialDateLabel(
event.transaction.dueDate,
"Vence em",
@@ -89,7 +91,7 @@ const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
const paymentDateLabel = isPaid
? formatFinancialDateLabel(
event.transaction.boletoPaymentDate,
"Pago em",
`${settlementLabel} em`,
DATE_FORMAT,
)
: null;
@@ -109,7 +111,9 @@ const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
<span className="text-success">{paymentDateLabel}</span>
)}
</div>
<Badge variant="outline">{isPaid ? "Pago" : "Pendente"}</Badge>
<Badge variant="outline">
{isPaid ? settlementLabel : "Pendente"}
</Badge>
</div>
<MoneyValues
className="font-medium whitespace-nowrap"

View File

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

View File

@@ -10,6 +10,7 @@ import {
} from "@remixicon/react";
import Image from "next/image";
import MoneyValues from "@/shared/components/money-values";
import { Badge } from "@/shared/components/ui/badge";
import {
Card,
CardContent,
@@ -23,6 +24,10 @@ import {
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { resolveCardBrandAsset } from "@/shared/lib/cards/brand-assets";
import {
INVOICE_PAYMENT_STATUS,
type InvoicePaymentStatus,
} from "@/shared/lib/invoices";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { cn } from "@/shared/utils/ui";
@@ -37,6 +42,7 @@ interface CardItemProps {
limitAvailable?: number;
currentInvoiceAmount: number;
currentInvoiceLabel: string;
currentInvoiceStatus: InvoicePaymentStatus | null;
accountName: string;
logo?: string | null;
note?: string | null;
@@ -58,6 +64,7 @@ export function CardItem({
limitAvailable,
currentInvoiceAmount,
currentInvoiceLabel,
currentInvoiceStatus,
accountName: _accountName,
logo,
note,
@@ -80,6 +87,8 @@ export function CardItem({
const logoPath = resolveLogoSrc(logo);
const brandAsset = resolveCardBrandAsset(brand);
const isInactive = status?.toLowerCase() === "inativo";
const isCurrentInvoicePaid =
currentInvoiceStatus === INVOICE_PAYMENT_STATUS.PAID;
return (
<Card className="flex flex-col p-6 w-full">
@@ -175,10 +184,17 @@ export function CardItem({
<span className="text-xs text-muted-foreground">
{currentInvoiceLabel}
</span>
<div className="flex flex-wrap items-center gap-2">
<MoneyValues
amount={currentInvoiceAmount}
className="text-xl font-semibold text-info"
/>
{isCurrentInvoicePaid ? (
<Badge variant="success" className="text-xs">
Paga
</Badge>
) : null}
</div>
</div>
<div className="flex gap-2 justify-between w-full">

View File

@@ -144,6 +144,7 @@ export function CardsPage({
limitAvailable={card.limitAvailable ?? card.limit ?? null}
currentInvoiceAmount={card.currentInvoiceAmount}
currentInvoiceLabel={card.currentInvoiceLabel}
currentInvoiceStatus={card.currentInvoiceStatus}
accountName={card.accountName}
logo={card.logo}
note={card.note}

View File

@@ -1,3 +1,5 @@
import type { InvoicePaymentStatus } from "@/shared/lib/invoices";
export type Card = {
id: string;
name: string;
@@ -14,6 +16,7 @@ export type Card = {
limitAvailable: number;
currentInvoiceAmount: number;
currentInvoiceLabel: string;
currentInvoiceStatus: InvoicePaymentStatus | null;
};
export type CardFormValues = {

View File

@@ -11,7 +11,11 @@ import {
} from "drizzle-orm";
import { cards, financialAccounts, invoices, transactions } from "@/db/schema";
import { db } from "@/shared/lib/db";
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
import {
INVOICE_PAYMENT_STATUS,
INVOICE_STATUS_VALUES,
type InvoicePaymentStatus,
} from "@/shared/lib/invoices";
import { loadLogoOptions } from "@/shared/lib/logo/options";
import {
formatPeriodMonthShort,
@@ -33,6 +37,7 @@ type CardData = {
limitAvailable: number;
currentInvoiceAmount: number;
currentInvoiceLabel: string;
currentInvoiceStatus: InvoicePaymentStatus | null;
accountId: string;
accountName: string;
};
@@ -48,6 +53,12 @@ function formatCurrentInvoiceLabel(period: string) {
return `Fatura ${formatPeriodMonthShort(period)}. ${year}`;
}
function parseInvoiceStatus(value: unknown): InvoicePaymentStatus | null {
return INVOICE_STATUS_VALUES.includes(value as InvoicePaymentStatus)
? (value as InvoicePaymentStatus)
: null;
}
async function fetchCardsByStatus(
userId: string,
archived: boolean,
@@ -58,8 +69,14 @@ async function fetchCardsByStatus(
}> {
const currentPeriod = getCurrentPeriod();
const currentInvoiceLabel = formatCurrentInvoiceLabel(currentPeriod);
const [cardRows, accountRows, logoOptions, usageRows, invoiceRows] =
await Promise.all([
const [
cardRows,
accountRows,
logoOptions,
usageRows,
invoiceRows,
invoiceStatusRows,
] = await Promise.all([
db.query.cards.findMany({
orderBy: (table, { desc }) => [desc(table.name)],
where: and(
@@ -130,6 +147,15 @@ async function fetchCardsByStatus(
),
)
.groupBy(transactions.cardId),
db
.select({
cardId: invoices.cardId,
paymentStatus: invoices.paymentStatus,
})
.from(invoices)
.where(
and(eq(invoices.userId, userId), eq(invoices.period, currentPeriod)),
),
]);
const usageMap = new Map<string, number>();
@@ -144,6 +170,13 @@ async function fetchCardsByStatus(
invoiceMap.set(row.cardId, Math.abs(Number(row.total ?? 0)));
},
);
const invoiceStatusMap = new Map<string, InvoicePaymentStatus>();
invoiceStatusRows.forEach((row) => {
if (!row.cardId) return;
const status = parseInvoiceStatus(row.paymentStatus);
if (!status) return;
invoiceStatusMap.set(row.cardId, status);
});
const cardList = cardRows.map((card) => ({
id: card.id,
@@ -166,6 +199,7 @@ async function fetchCardsByStatus(
})(),
currentInvoiceAmount: invoiceMap.get(card.id) ?? 0,
currentInvoiceLabel,
currentInvoiceStatus: invoiceStatusMap.get(card.id) ?? null,
accountId: card.accountId,
accountName:
(card.financialAccount as { name?: string } | null)?.name ??

View File

@@ -5,6 +5,7 @@ import { Card } from "@/shared/components/ui/card";
import type { CategoryType } from "@/shared/lib/categories/constants";
import { currencyFormatter } from "@/shared/utils/currency";
import { formatPercentage } from "@/shared/utils/percentage";
import { cn } from "@/shared/utils/ui";
type CategorySummary = {
id: string;
@@ -32,19 +33,40 @@ export function CategoryDetailHeader({
percentageChange,
transactionCount,
}: CategoryDetailHeaderProps) {
const absoluteChange = currentTotal - previousTotal;
const variationLabel =
typeof percentageChange === "number"
? formatPercentage(percentageChange, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
absolute: true,
signDisplay: percentageChange === 0 ? "auto" : "always",
})
: "—";
const hasComparison = typeof percentageChange === "number";
const isFlat = absoluteChange === 0;
const changeDirection =
absoluteChange > 0 ? "increase" : absoluteChange < 0 ? "decrease" : "flat";
const comparisonTone =
isFlat || !hasComparison
? "neutral"
: category.type === "receita"
? changeDirection === "increase"
? "positive"
: "negative"
: changeDirection === "decrease"
? "positive"
: "negative";
const statusLabel = !hasComparison
? "Sem comparação"
: isFlat
? "Estável"
: changeDirection === "increase"
? "Aumento"
: "Queda";
return (
<Card className="px-4">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<Card className="px-5 py-5">
<div className="flex flex-col gap-5">
<div className="flex items-start gap-3">
<CategoryIconBadge
icon={category.icon}
@@ -59,44 +81,62 @@ export function CategoryDetailHeader({
<TransactionTypeBadge kind={category.type} />
<span>
{transactionCount}{" "}
{transactionCount === 1 ? "lançamento" : "lançamentos"} no{" "}
período
{transactionCount === 1 ? "lançamento" : "lançamentos"} em{" "}
{currentPeriodLabel}
</span>
</div>
</div>
</div>
<div className="grid w-full gap-4 sm:grid-cols-2 lg:w-auto lg:grid-cols-3">
<div>
<div className="grid gap-3 md:grid-cols-3">
<div className="rounded-lg border p-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total em {currentPeriodLabel}
</p>
<p className="mt-1 text-2xl font-semibold">
<p className="mt-1 text-3xl font-semibold tracking-tight">
{currencyFormatter.format(currentTotal)}
</p>
</div>
<div>
<div className="rounded-lg border p-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Total em {previousPeriodLabel}
</p>
<p className="mt-1 text-lg font-semibold text-muted-foreground">
<p className="mt-1 text-2xl font-semibold tracking-tight text-muted-foreground">
{currencyFormatter.format(previousTotal)}
</p>
</div>
<div>
<div className="rounded-lg border p-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Variação vs mês anterior
Variação
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span
className={cn(
"inline-flex h-6 items-center rounded-sm border px-2 text-xs font-medium",
comparisonTone === "positive" &&
"border-success/30 bg-success/5 text-success",
comparisonTone === "negative" &&
"border-destructive/30 bg-destructive/5 text-destructive",
comparisonTone === "neutral" &&
"border-muted-foreground/30 bg-muted/30 text-muted-foreground",
)}
>
{statusLabel}
</span>
<PercentageChangeIndicator
value={percentageChange}
label={variationLabel}
positiveTrend={category.type === "receita" ? "up" : "down"}
className="mt-1 gap-1 text-lg font-semibold"
className="gap-1 text-lg font-semibold"
iconClassName="size-4"
showFlatIcon
/>
</div>
</div>
</div>
</div>
</Card>
);
}

View File

@@ -1,18 +1,28 @@
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
import type { PaymentDialogState } from "@/features/dashboard/payments/use-payment-dialog-controller";
import { getBusinessDateString, isDateOnlyPast } from "@/shared/utils/date";
import {
getBusinessDateString,
isDateOnlyPast,
parseUtcDateString,
toDateOnlyString,
} from "@/shared/utils/date";
import {
buildFinancialStatusLabel,
buildRelativeFinancialStatusLabel,
formatFinancialDateLabel,
formatRelativeFinancialDateLabel,
} from "@/shared/utils/financial-dates";
export type BillDialogState = PaymentDialogState;
type BillStatusDateItem = Pick<
DashboardBill,
"dueDate" | "boletoPaymentDate" | "isSettled"
"dueDate" | "boletoPaymentDate" | "isSettled" | "transactionType"
>;
export const isIncomeBill = (bill: Pick<DashboardBill, "transactionType">) => {
return bill.transactionType === "Receita";
};
export const formatBillDateLabel = (value: string | null, prefix?: string) => {
return formatFinancialDateLabel(value, prefix);
};
@@ -22,10 +32,15 @@ export const buildBillStatusLabel = (bill: BillStatusDateItem) => {
isSettled: bill.isSettled,
dueDate: bill.dueDate,
paidAt: bill.boletoPaymentDate,
paidPrefix: isIncomeBill(bill) ? "Recebido em" : "Pago em",
});
};
export const buildBillWidgetStatusLabel = (bill: BillStatusDateItem) => {
if (bill.isSettled && isIncomeBill(bill)) {
return formatRelativeFinancialDateLabel(bill.boletoPaymentDate, "received");
}
return buildRelativeFinancialStatusLabel({
isSettled: bill.isSettled,
dueDate: bill.dueDate,
@@ -43,6 +58,34 @@ export const isBillOverdue = (bill: DashboardBill) => {
return isDateOnlyPast(bill.dueDate);
};
export const formatBillWidgetOverdueLabel = (
bill: Pick<DashboardBill, "dueDate" | "isSettled" | "transactionType">,
): string | null => {
if (bill.isSettled) {
return null;
}
const dueDateValue = toDateOnlyString(bill.dueDate);
const todayValue = getBusinessDateString();
if (!dueDateValue || dueDateValue >= todayValue) {
return null;
}
const dueDate = parseUtcDateString(dueDateValue);
const today = parseUtcDateString(todayValue);
if (!dueDate || !today) {
return null;
}
const overdueDays = Math.round(
(today.getTime() - dueDate.getTime()) / (24 * 60 * 60 * 1000),
);
const overdueLabel = isIncomeBill(bill) ? "Atrasada" : "Atrasado";
return overdueDays === 1
? `${overdueLabel} · venceu ontem`
: `${overdueLabel} · venceu há ${overdueDays} dias`;
};
export const getBillStatusBadgeVariant = (
statusLabel: string,
): "success" | "info" => {

View File

@@ -6,6 +6,7 @@ export type DashboardBill = {
boletoPaymentDate: string | null;
isSettled: boolean;
accountId: string | null;
transactionType: string;
};
export type BillPaymentAccountOption = {

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

@@ -1,11 +1,14 @@
import { RiCheckboxCircleFill, RiExternalLinkLine } from "@remixicon/react";
import { RiCheckboxCircleFill } from "@remixicon/react";
import Link from "next/link";
import {
buildBillStatusLabel,
buildBillWidgetStatusLabel,
formatBillWidgetOverdueLabel,
isBillOverdue,
isIncomeBill,
} from "@/features/dashboard/bills/bills-helpers";
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries";
import { dashboardWidgetListStyles as styles } from "@/features/dashboard/components/dashboard-widget-list-styles";
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values";
import { Button } from "@/shared/components/ui/button";
@@ -36,40 +39,36 @@ export function BillListItem({ bill, period, onPay }: BillListItemProps) {
const statusLabel = buildBillWidgetStatusLabel(bill);
const absoluteStatusLabel = buildBillStatusLabel(bill);
const overdue = isBillOverdue(bill);
const income = isIncomeBill(bill);
const overdueLabel = formatBillWidgetOverdueLabel(bill);
const statusTooltipLabel =
statusLabel && statusLabel !== absoluteStatusLabel
overdueLabel || (statusLabel && statusLabel !== absoluteStatusLabel)
? absoluteStatusLabel
: null;
const href = buildTransactionsHref(bill.name, period);
return (
<li className="flex items-center justify-between transition-all duration-300 py-1.5">
<div className="flex min-w-0 flex-1 items-center gap-2 py-0.5">
<li className={styles.row}>
<div className={styles.main}>
<EstablishmentLogo name={bill.name} size={37} />
<div className="min-w-0">
<Link
href={href}
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
>
<div className={styles.textStack}>
<Link href={href} className={styles.titleLink}>
<span className="truncate">{bill.name}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<div className={styles.meta}>
{statusLabel ? (
statusTooltipLabel ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"cursor-help rounded-full py-0.5",
"cursor-help",
bill.isSettled && "text-success font-semibold",
overdue && "text-destructive font-semibold",
)}
>
{statusLabel}
{overdueLabel ?? statusLabel}
</span>
</TooltipTrigger>
<TooltipContent side="top">
@@ -79,11 +78,11 @@ export function BillListItem({ bill, period, onPay }: BillListItemProps) {
) : (
<span
className={cn(
"rounded-full py-0.5",
bill.isSettled && "text-success font-semibold",
overdue && "text-destructive font-semibold",
)}
>
{statusLabel}
{overdueLabel ?? statusLabel}
</span>
)
) : null}
@@ -91,31 +90,37 @@ export function BillListItem({ bill, period, onPay }: BillListItemProps) {
</div>
</div>
<div className="flex shrink-0 flex-col items-end">
<MoneyValues className="font-medium" amount={bill.amount} />
<div className={styles.trailing}>
<MoneyValues className={styles.trailingValue} amount={bill.amount} />
{bill.isSettled ? (
<span className={`${styles.trailingMeta} text-success`}>
<RiCheckboxCircleFill className="size-3.5" />{" "}
{income ? "Recebido" : "Pago"}
</span>
) : (
<Button
type="button"
size="sm"
variant="link"
className="h-auto p-0 disabled:opacity-100"
disabled={bill.isSettled}
className={styles.actionButton}
onClick={() => onPay(bill.id)}
>
{bill.isSettled ? (
<span className="flex items-center gap-0.5 text-success">
<RiCheckboxCircleFill className="size-3.5" /> Pago
</span>
) : overdue ? (
{overdue ? (
<span className="overdue-blink">
<span className="overdue-blink-primary text-destructive">
Atrasado
{income ? "Atrasada" : "Atrasado"}
</span>
<span className="overdue-blink-secondary">Pagar</span>
<span className="overdue-blink-secondary">
{income ? "Receber" : "Pagar"}
</span>
</span>
) : income ? (
"Receber"
) : (
"Pagar"
)}
</Button>
)}
</div>
</li>
);

View File

@@ -7,6 +7,7 @@ import {
type BillDialogState,
formatBillDateLabel,
getBillStatusBadgeVariant,
isIncomeBill,
} from "@/features/dashboard/bills/bills-helpers";
import type {
BillPaymentAccountOption,
@@ -66,11 +67,13 @@ export function BillPaymentDialog({
onConfirm,
}: BillPaymentDialogProps) {
const isProcessing = modalState === "processing" || isPending;
const income = bill ? isIncomeBill(bill) : false;
const settlementLabel = income ? "Recebido" : "Pago";
const dueLabel = bill
? formatBillDateLabel(bill.dueDate, "Vencimento:")
: null;
const paidLabel = bill
? formatBillDateLabel(bill.boletoPaymentDate, "Pago em:")
? formatBillDateLabel(bill.boletoPaymentDate, `${settlementLabel} em:`)
: null;
const isBillPending = bill ? !bill.isSettled : false;
const paymentDateValue = paymentDate.toISOString().split("T")[0] ?? "";
@@ -103,8 +106,8 @@ export function BillPaymentDialog({
>
{modalState === "success" ? (
<PaymentSuccess
title="Pagamento registrado!"
description="Atualizamos o status do boleto para pago. Em instantes ele aparecerá como baixado no histórico."
title={income ? "Recebimento registrado!" : "Pagamento registrado!"}
description={`Atualizamos o status do boleto para ${income ? "recebido" : "pago"}. Em instantes ele aparecerá como baixado no histórico.`}
onClose={onClose}
/>
) : (
@@ -112,10 +115,12 @@ export function BillPaymentDialog({
<DialogHeader>
<div className="mb-1 flex items-center gap-3">
<div>
<DialogTitle>Confirmar pagamento</DialogTitle>
<DialogTitle>
{income ? "Confirmar recebimento" : "Confirmar pagamento"}
</DialogTitle>
<DialogDescription className="mt-1 text-xs">
{isBillPending
? "Escolha a conta de origem e a data em que o boleto foi pago."
? `Escolha a conta de ${income ? "destino" : "origem"} e a data em que o boleto foi ${income ? "recebido" : "pago"}.`
: "Boleto"}
</DialogDescription>
</div>
@@ -158,12 +163,15 @@ export function BillPaymentDialog({
<div className="flex items-center gap-1.5 text-muted-foreground">
<RiCalendarLine className="size-3.5" />
<span className="text-xs font-medium uppercase">
{bill.isSettled ? "Pago em" : "Vencimento"}
{bill.isSettled
? `${settlementLabel} em`
: "Vencimento"}
</span>
</div>
<p className="font-semibold">
{bill.isSettled
? (paidLabel?.replace("Pago em: ", "") ?? "—")
? (paidLabel?.replace(`${settlementLabel} em: `, "") ??
"—")
: (dueLabel?.replace("Vencimento: ", "") ?? "—")}
</p>
</Card>
@@ -175,7 +183,7 @@ export function BillPaymentDialog({
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="bill-widget-payment-account">
Conta de pagamento
Conta de {income ? "recebimento" : "pagamento"}
</Label>
<Select
value={paymentAccountId}
@@ -212,7 +220,7 @@ export function BillPaymentDialog({
<div className="space-y-2">
<Label htmlFor="bill-widget-payment-date">
Data do pagamento
Data do {income ? "recebimento" : "pagamento"}
</Label>
<DatePicker
id="bill-widget-payment-date"
@@ -231,8 +239,8 @@ export function BillPaymentDialog({
<span className="text-sm text-muted-foreground">
Status atual
</span>
<Badge variant={getBillStatusBadgeVariant("Pago")}>
Pago
<Badge variant={getBillStatusBadgeVariant(settlementLabel)}>
{settlementLabel}
</Badge>
</div>
)}

View File

@@ -15,7 +15,7 @@ export function BillsList({ bills, period, onPay }: BillsListProps) {
<WidgetEmptyState
icon={<RiBarcodeFill className="size-6 text-muted-foreground" />}
title="Nenhum boleto cadastrado para o período selecionado"
description="Cadastre boletos para monitorar os pagamentos aqui."
description="Cadastre boletos para monitorar os vencimentos aqui."
/>
);
}

View File

@@ -41,9 +41,7 @@ export function BillsWidgetView({
}: BillsWidgetViewProps) {
return (
<>
<div className="flex flex-col gap-4">
<BillsList bills={bills} period={period} onPay={onOpenPaymentDialog} />
</div>
<BillPaymentDialog
bill={selectedBill}

View File

@@ -96,7 +96,7 @@ export function CategoryBreakdownChart({
}, [categories, chartConfig]);
return (
<div className="flex items-center gap-4">
<div className="flex flex-col items-center gap-4 sm:flex-row">
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
<PieChart>
<Pie
@@ -143,7 +143,7 @@ export function CategoryBreakdownChart({
</PieChart>
</ChartContainer>
<div className="min-w-[140px] flex flex-col gap-2">
<div className="grid w-full grid-cols-2 gap-2 sm:min-w-[140px] sm:w-auto sm:grid-cols-1">
{chartData.map((entry, index) => (
<div key={`legend-${index}`} className="flex items-center gap-2">
<div

View File

@@ -1,6 +1,6 @@
import { RiExternalLinkLine, RiWallet3Line } 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";
import { CategoryIconBadge } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values";
@@ -11,13 +11,14 @@ type CategoryBreakdownListItemConfig = {
shareLabel: string;
percentageDigits: number;
positiveTrend: "up" | "down";
includeBudgetAmount: boolean;
showBudget: boolean;
};
type CategoryBreakdownListItemProps = {
category: DashboardCategoryBreakdownItem;
periodParam: string;
config: CategoryBreakdownListItemConfig;
position: number;
};
const formatPercentage = (value: number, digits: number) =>
@@ -31,8 +32,9 @@ export function CategoryBreakdownListItem({
category,
periodParam,
config,
position,
}: CategoryBreakdownListItemProps) {
const hasBudget = category.budgetAmount !== null;
const hasBudget = config.showBudget && category.budgetAmount !== null;
const budgetExceeded =
hasBudget &&
category.budgetUsedPercentage !== null &&
@@ -44,26 +46,23 @@ export function CategoryBreakdownListItem({
return (
<div>
<div className="flex items-center justify-between gap-3 transition-all duration-300 py-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className={styles.row}>
<span className={styles.rank}>{position}</span>
<div className={styles.main}>
<CategoryIconBadge
icon={category.categoryIcon}
name={category.categoryName}
/>
<div className="min-w-0 flex-1">
<div className={styles.textStack}>
<div className="flex items-center gap-2">
<Link
href={`/categories/${category.categoryId}?periodo=${periodParam}`}
className="flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:underline"
className={styles.titleLink}
>
<span className="truncate">{category.categoryName}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
<div className={styles.meta}>
<span>
{formatPercentage(
category.percentageOfTotal,
@@ -71,16 +70,14 @@ export function CategoryBreakdownListItem({
)}{" "}
da {config.shareLabel}
</span>
</div>
{hasBudget && category.budgetUsedPercentage !== null ? (
<>
<span aria-hidden>·</span>
<span
className={`flex items-center gap-1 ${budgetExceeded ? "text-destructive" : "text-info"}`}
<div
className={`mt-0.5 text-xs ${budgetExceeded ? "text-destructive" : "text-info"}`}
>
<RiWallet3Line className="size-3 shrink-0" />
{budgetExceeded ? (
<>
excedeu{" "}
Limite excedido em{" "}
<span className="font-medium">
{formatCurrency(exceededAmount)}
</span>
@@ -91,37 +88,32 @@ export function CategoryBreakdownListItem({
category.budgetUsedPercentage,
config.percentageDigits,
)}{" "}
do limite
{config.includeBudgetAmount &&
category.budgetAmount !== null
? ` ${formatCurrency(category.budgetAmount)}`
: ""}
do limite utilizado
</>
)}
</span>
</>
</div>
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5">
<div className={styles.trailing}>
<MoneyValues
className="text-foreground font-medium"
className={styles.trailingValue}
amount={category.currentAmount}
/>
{category.percentageChange !== null ? (
<span className={`${styles.trailingMeta} text-muted-foreground`}>
<PercentageChangeIndicator
value={category.percentageChange}
label={
category.percentageChange !== null
? formatPercentage(
label={formatPercentage(
category.percentageChange,
config.percentageDigits,
)
: undefined
}
)}
positiveTrend={config.positiveTrend}
/>
<span>vs. mês ant.</span>
</span>
) : null}
</div>
</div>
</div>

View File

@@ -5,7 +5,7 @@ type CategoryBreakdownListConfig = {
shareLabel: string;
percentageDigits: number;
positiveTrend: "up" | "down";
includeBudgetAmount: boolean;
showBudget: boolean;
};
type CategoryBreakdownListProps = {
@@ -20,13 +20,14 @@ export function CategoryBreakdownList({
config,
}: CategoryBreakdownListProps) {
return (
<div>
{categories.map((category) => (
<div className="flex flex-col">
{categories.map((category, index) => (
<CategoryBreakdownListItem
key={category.categoryId}
category={category}
periodParam={periodParam}
config={config}
position={index + 1}
/>
))}
</div>

View File

@@ -34,7 +34,7 @@ const VARIANT_CONFIG = {
shareLabel: "receita total",
percentageDigits: 1,
positiveTrend: "up",
includeBudgetAmount: true,
showBudget: false,
},
expense: {
emptyTitle: "Nenhuma despesa encontrada",
@@ -43,7 +43,7 @@ const VARIANT_CONFIG = {
shareLabel: "despesa total",
percentageDigits: 0,
positiveTrend: "down",
includeBudgetAmount: false,
showBudget: true,
},
} as const;

View File

@@ -21,6 +21,7 @@ import {
RiCloseLine,
RiDragMove2Line,
RiEyeOffLine,
RiSettings4Line,
RiTodoLine,
} from "@remixicon/react";
import { useMemo, useState, useTransition } from "react";
@@ -41,6 +42,12 @@ import {
import { NoteDialog } from "@/features/notes/components/note-dialog";
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
import { Button } from "@/shared/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu";
import { ExpandableWidgetCard } from "@/shared/components/widgets/expandable-widget-card";
type DashboardGridEditableProps = {
@@ -60,6 +67,9 @@ export function DashboardGridEditable({
}: DashboardGridEditableProps) {
const [isEditing, setIsEditing] = useState(false);
const [isPending, startTransition] = useTransition();
const [isMobileIncomeOpen, setIsMobileIncomeOpen] = useState(false);
const [isMobileExpenseOpen, setIsMobileExpenseOpen] = useState(false);
const [isMobileNoteOpen, setIsMobileNoteOpen] = useState(false);
// Initialize widget order and hidden state
const [widgetOrder, setWidgetOrder] = useState<string[]>(
@@ -132,14 +142,6 @@ export function DashboardGridEditable({
: [...hiddenWidgets, widgetId];
setHiddenWidgets(newHidden);
// Salvar automaticamente ao toggle
startTransition(async () => {
await updateWidgetPreferences({
order: widgetOrder,
hidden: newHidden,
});
});
};
const handleHideWidget = (widgetId: string) => {
@@ -182,6 +184,8 @@ export function DashboardGridEditable({
setWidgetOrder(DEFAULT_WIDGET_ORDER);
setHiddenWidgets([]);
setMyAccountsShowExcluded(true);
setOriginalOrder(DEFAULT_WIDGET_ORDER);
setOriginalHidden([]);
toast.success("Preferências restauradas!");
} else {
toast.error(result.error ?? "Erro ao restaurar");
@@ -195,7 +199,68 @@ export function DashboardGridEditable({
<div className="flex flex-wrap items-center justify-between gap-2">
{!isEditing ? (
<div className="flex w-full min-w-0 flex-col gap-1 sm:w-auto sm:flex-row sm:items-center sm:gap-2">
<div className="-mb-1 grid w-full grid-cols-3 gap-1 pb-1 sm:mb-0 sm:flex sm:w-auto sm:items-center sm:gap-2 sm:overflow-visible sm:pb-0">
<div className="sm:hidden">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="w-full gap-2">
<RiAddFill className="size-4 text-primary" />
Adicionar
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuItem
onSelect={() => setIsMobileIncomeOpen(true)}
>
<RiAddFill className="text-success/80" />
Nova receita
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setIsMobileExpenseOpen(true)}
>
<RiAddFill className="text-destructive/80" />
Nova despesa
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setIsMobileNoteOpen(true)}>
<RiTodoLine className="text-info/80" />
Nova anotação
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TransactionDialog
mode="create"
open={isMobileIncomeOpen}
onOpenChange={setIsMobileIncomeOpen}
payerOptions={quickActionOptions.payerOptions}
splitPayerOptions={quickActionOptions.splitPayerOptions}
defaultPayerId={quickActionOptions.defaultPayerId}
accountOptions={quickActionOptions.accountOptions}
cardOptions={quickActionOptions.cardOptions}
categoryOptions={quickActionOptions.categoryOptions}
estabelecimentos={quickActionOptions.estabelecimentos}
defaultPeriod={period}
defaultTransactionType="Receita"
/>
<TransactionDialog
mode="create"
open={isMobileExpenseOpen}
onOpenChange={setIsMobileExpenseOpen}
payerOptions={quickActionOptions.payerOptions}
splitPayerOptions={quickActionOptions.splitPayerOptions}
defaultPayerId={quickActionOptions.defaultPayerId}
accountOptions={quickActionOptions.accountOptions}
cardOptions={quickActionOptions.cardOptions}
categoryOptions={quickActionOptions.categoryOptions}
estabelecimentos={quickActionOptions.estabelecimentos}
defaultPeriod={period}
defaultTransactionType="Despesa"
/>
<NoteDialog
mode="create"
open={isMobileNoteOpen}
onOpenChange={setIsMobileNoteOpen}
/>
</div>
<div className="hidden items-center gap-2 sm:flex">
<TransactionDialog
mode="create"
payerOptions={quickActionOptions.payerOptions}
@@ -269,6 +334,12 @@ export function DashboardGridEditable({
<div className="flex w-full items-center justify-end gap-2 sm:w-auto">
{isEditing ? (
<>
<WidgetSettingsDialog
hiddenWidgets={hiddenWidgets}
onToggleWidget={handleToggleWidget}
onReset={handleReset}
triggerLabel="Visibilidade"
/>
<Button
variant="outline"
size="sm"
@@ -290,21 +361,15 @@ export function DashboardGridEditable({
</Button>
</>
) : (
<div className="grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto">
<WidgetSettingsDialog
hiddenWidgets={hiddenWidgets}
onToggleWidget={handleToggleWidget}
onReset={handleReset}
triggerClassName="w-full sm:w-auto"
/>
<div className="w-full sm:w-auto">
<Button
variant="outline"
size="sm"
onClick={handleStartEditing}
className="w-full gap-2 sm:w-auto"
>
<RiDragMove2Line className="size-4" />
Reordenar
<RiSettings4Line className="size-4" />
Personalizar
</Button>
</div>
)}

View File

@@ -1,9 +1,11 @@
import {
RiArrowLeftRightLine,
RiArrowRightDownLine,
RiArrowRightLine,
RiArrowRightUpLine,
RiCalendar2Line,
} from "@remixicon/react";
import Link from "next/link";
import { MetricsCardInfoButton } from "@/features/dashboard/components/metrics-card-info-button";
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import type { DashboardCardMetrics } from "@/features/dashboard/overview/dashboard-metrics-queries";
@@ -18,10 +20,13 @@ import {
} from "@/shared/components/ui/card";
import { Separator } from "@/shared/components/ui/separator";
import { formatPercentage } from "@/shared/utils/percentage";
import { formatPeriodForUrl } from "@/shared/utils/period";
import { cn } from "@/shared/utils/ui";
type DashboardMetricsCardsProps = {
metrics: DashboardCardMetrics;
period: string;
adminPayerSlug: string | null;
};
type Trend = "up" | "down" | "flat";
@@ -36,6 +41,7 @@ const CARDS = [
icon: RiArrowRightDownLine,
invertTrend: false,
iconClass: "text-success",
transactionType: "receita",
helpTitle: "Como calculamos receitas",
helpLines: [
"Somamos os lançamentos do tipo Receita no período selecionado.",
@@ -53,6 +59,7 @@ const CARDS = [
icon: RiArrowRightUpLine,
invertTrend: true,
iconClass: "text-destructive",
transactionType: "despesa",
helpTitle: "Como calculamos despesas",
helpLines: [
"Somamos os lançamentos do tipo Despesa no período selecionado.",
@@ -70,6 +77,7 @@ const CARDS = [
icon: RiArrowLeftRightLine,
invertTrend: false,
iconClass: "text-warning",
transactionType: null,
helpTitle: "Como calculamos o balanço",
helpLines: [
"Partimos de receitas menos despesas do período.",
@@ -86,6 +94,7 @@ const CARDS = [
icon: RiCalendar2Line,
invertTrend: false,
iconClass: "text-cyan-600",
transactionType: null,
helpTitle: "Como calculamos o previsto",
helpLines: [
"Acumulamos o balanço mês a mês até o período atual.",
@@ -123,7 +132,11 @@ const getPercentChange = (current: number, previous: number): string | null => {
});
};
export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
export function DashboardMetricsCards({
metrics,
period,
adminPayerSlug,
}: DashboardMetricsCardsProps) {
return (
<div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
{CARDS.map(
@@ -134,6 +147,7 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
icon: Icon,
invertTrend,
iconClass,
transactionType,
helpTitle,
helpLines,
}) => {
@@ -143,10 +157,14 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
metric.current,
metric.previous,
);
const transactionsHref = transactionType
? `/transactions?periodo=${formatPeriodForUrl(period)}&type=${transactionType}${adminPayerSlug ? `&payer=${adminPayerSlug}` : ""}`
: null;
return (
<Card key={label} className="gap-2 overflow-hidden">
<Card key={label} className="gap-2 overflow-hidden py-6">
<CardHeader className="gap-1">
<div className="flex items-center justify-between gap-2">
<CardTitle className="flex items-center gap-1">
<Icon className={cn("size-4", iconClass)} aria-hidden />
{label}
@@ -156,6 +174,16 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
helpLines={helpLines}
/>
</CardTitle>
{transactionsHref ? (
<Link
href={transactionsHref}
className="rounded-sm px-1 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-primary focus-visible:outline-none focus-visible:ring-ring/50 focus-visible:ring-[3px]"
aria-label={`Ver lançamentos de ${label.toLowerCase()}`}
>
<RiArrowRightLine className="size-4" aria-hidden />
</Link>
) : null}
</div>
<CardDescription className="mt-1 tracking-tight">
{subtitle}
</CardDescription>

View File

@@ -0,0 +1,15 @@
export const dashboardWidgetListStyles = {
row: "flex min-h-[3.25rem] items-center justify-between gap-2 py-1.5 transition-all duration-300",
main: "flex min-w-0 flex-1 items-center gap-2",
textStack: "min-w-0 flex-1 space-y-0.5",
title: "truncate text-sm font-medium leading-5 text-foreground",
titleLink:
"inline-flex max-w-full items-center gap-1 text-sm font-medium leading-5 text-foreground underline-offset-2 hover:text-primary hover:underline",
meta: "flex min-h-4 flex-wrap items-center gap-x-2 gap-y-0.5 text-xs leading-4 text-muted-foreground",
rank: "w-3 shrink-0 text-left text-xs font-medium leading-4 text-muted-foreground",
trailing:
"flex min-w-[5.75rem] shrink-0 flex-col items-end gap-0.5 text-right",
trailingValue: "font-medium leading-5",
trailingMeta: "flex h-5 items-center gap-0.5 text-xs font-medium leading-4",
actionButton: "-mr-1 h-5 px-1 py-0 text-xs leading-4",
};

View File

@@ -1,5 +1,6 @@
import { RiPencilLine } from "@remixicon/react";
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import Link from "next/link";
import { dashboardWidgetListStyles as styles } from "@/features/dashboard/components/dashboard-widget-list-styles";
import {
clampGoalProgress,
formatGoalProgressPercentage,
@@ -9,24 +10,28 @@ import { CategoryIconBadge } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values";
import { Button } from "@/shared/components/ui/button";
import { Progress } from "@/shared/components/ui/progress";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { cn } from "@/shared/utils";
import { formatPeriodForUrl } from "@/shared/utils/period";
type GoalProgressItemProps = {
item: GoalProgressItemData;
index: number;
onEdit: (item: GoalProgressItemData) => void;
};
export function GoalProgressItem({
item,
index,
onEdit,
}: GoalProgressItemProps) {
export function GoalProgressItem({ item, onEdit }: GoalProgressItemProps) {
const progressValue = clampGoalProgress(item.usedPercentage, 0, 100);
const percentageDelta = item.usedPercentage - 100;
const isExceeded = item.status === "exceeded";
const isCritical = item.status === "critical";
const exceededAmount = Math.max(item.spentAmount - item.budgetAmount, 0);
const usedPercentageLabel = formatGoalProgressPercentage(item.usedPercentage);
return (
<div className="group transition-all duration-300 py-2">
<li className="group py-1.5 transition-all duration-300">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 items-start gap-2">
<CategoryIconBadge
@@ -35,46 +40,70 @@ export function GoalProgressItem({
size="md"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{item.categoryId ? (
<Link
href={`/categories/${item.categoryId}?periodo=${formatPeriodForUrl(item.period)}`}
className={`${styles.title} block underline-offset-2 hover:text-primary hover:underline`}
>
{item.categoryName}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
</Link>
) : (
<p className={styles.title}>{item.categoryName}</p>
)}
<p className="mt-0.5 text-xs leading-4 text-muted-foreground">
<MoneyValues className="font-medium" amount={item.spentAmount} />{" "}
de{" "}
<MoneyValues className="font-medium" amount={item.budgetAmount} />
<PercentageChangeIndicator
value={percentageDelta}
label={formatGoalProgressPercentage(percentageDelta, true)}
positiveTrend="down"
className="ml-1.5 align-middle"
/>
<span aria-hidden> · </span>
<span
className={cn(
"font-medium",
isExceeded && "text-destructive",
isCritical && "text-warning",
)}
>
{isExceeded ? (
<>
<MoneyValues amount={exceededAmount} /> acima do limite
</>
) : (
`${usedPercentageLabel} utilizado`
)}
</span>
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="link"
variant="ghost"
size="icon-sm"
className="transition-opacity text-primary hover:opacity-80"
className="shrink-0 text-primary/70 opacity-70 transition-all hover:text-primary hover:opacity-100 focus-visible:text-primary focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100"
onClick={() => onEdit(item)}
aria-label={`Atualizar orçamento de ${item.categoryName}`}
>
<RiPencilLine className="size-3.5" />
</Button>
</div>
</TooltipTrigger>
<TooltipContent side="top">Atualizar orçamento</TooltipContent>
</Tooltip>
</div>
<div className="ml-11 mt-1.5">
<Progress
value={progressValue}
className={
isExceeded
? "**:data-[slot=progress-indicator]:bg-destructive bg-destructive/20"
: undefined
}
className={cn(
isExceeded && "bg-destructive/20",
isCritical && "bg-warning/20",
)}
indicatorClassName={cn(
isExceeded && "bg-destructive",
isCritical && "bg-warning",
)}
aria-label={`${usedPercentageLabel} do orçamento utilizado em ${item.categoryName}`}
/>
</div>
</div>
</li>
);
}

View File

@@ -21,13 +21,8 @@ export function GoalsProgressList({ items, onEdit }: GoalsProgressListProps) {
return (
<ul className="flex flex-col">
{items.map((item, index) => (
<GoalProgressListItem
key={item.id}
item={item}
index={index}
onEdit={onEdit}
/>
{items.map((item) => (
<GoalProgressListItem key={item.id} item={item} onEdit={onEdit} />
))}
</ul>
);

View File

@@ -2,6 +2,7 @@
import {
RiBankCard2Line,
RiChat1Line,
RiCheckboxCircleFill,
RiFileList2Line,
RiTimeLine,
@@ -30,6 +31,11 @@ import {
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Progress } from "@/shared/components/ui/progress";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { cn } from "@/shared/utils";
import type { InstallmentGroup } from "./types";
@@ -83,6 +89,7 @@ export function InstallmentGroupCard({
);
const cardLogoSrc = resolveLogoSrc(group.cartaoLogo);
const cardName = group.cartaoName ?? "Compra parcelada";
const hasNote = Boolean(group.note?.trim().length);
const untrackedLabel =
group.untrackedInstallments === 1
? "1 parcela anterior fora do acompanhamento"
@@ -121,8 +128,28 @@ export function InstallmentGroupCard({
<div className="flex items-center gap-3 flex-wrap">
<EstablishmentLogo name={group.name} size={40} />
<div className="flex-1 min-w-0">
<CardTitle className="text-base truncate">
{group.name}
<CardTitle className="flex items-center gap-1 text-base">
<span className="truncate">{group.name}</span>
{hasNote ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0 rounded-full p-1 hover:bg-accent transition-colors duration-300">
<RiChat1Line
className="h-4 w-4 text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Ver anotação</span>
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-xs whitespace-pre-line"
>
{group.note}
</TooltipContent>
</Tooltip>
) : null}
</CardTitle>
<CardDescription className="flex min-w-0 items-center gap-1 text-xs">
{cardLogoSrc ? (
@@ -207,32 +234,23 @@ export function InstallmentGroupCard({
)}
</div>
{/* Valor selecionado */}
{hasSelection && (
<div className="flex items-center justify-between p-3 rounded-lg bg-primary/5 border border-primary/20 mb-4">
<span className="text-sm font-medium text-foreground">
{selectedInstallments.size}{" "}
{selectedInstallments.size === 1
? "parcela selecionada"
: "parcelas selecionadas"}
</span>
<MoneyValues
amount={selectedAmount}
className="text-base font-semibold text-primary"
/>
</div>
)}
{/* Botão para abrir detalhes */}
<Button
type="button"
variant="secondary"
size="sm"
className="w-full gap-1.5"
className="relative w-full justify-center gap-1.5"
onClick={() => setIsDetailsOpen(true)}
>
<span className="inline-flex items-center gap-1.5">
<RiFileList2Line className="size-4" />
detalhes ({group.pendingInstallments.length} parcelas)
detalhes
</span>
{hasSelection && (
<span className="absolute right-2 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{selectedInstallments.size} sel.
</span>
)}
</Button>
</CardContent>
</Card>
@@ -244,8 +262,28 @@ export function InstallmentGroupCard({
<div className="flex items-center gap-3">
<EstablishmentLogo name={group.name} size={32} />
<div className="min-w-0">
<DialogTitle className="truncate text-base">
{group.name}
<DialogTitle className="flex items-center gap-1 text-base">
<span className="truncate">{group.name}</span>
{hasNote ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0 rounded-full p-1 hover:bg-accent transition-colors duration-300">
<RiChat1Line
className="h-4 w-4 text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Ver anotação</span>
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-xs whitespace-pre-line"
>
{group.note}
</TooltipContent>
</Tooltip>
) : null}
</DialogTitle>
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
{cardLogoSrc ? (

View File

@@ -9,6 +9,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { getPaymentMethodIcon } from "@/shared/utils/icons";
type InstallmentExpenseListItemProps = {
expense: InstallmentExpense;
@@ -20,6 +21,7 @@ export function InstallmentExpenseListItem({
const {
compactLabel,
isLast,
remainingLabel,
remainingInstallments,
remainingAmount,
endDate,
@@ -27,7 +29,7 @@ export function InstallmentExpenseListItem({
} = buildInstallmentExpenseDisplay(expense);
return (
<div className="flex items-center gap-3 transition-all duration-300 py-2">
<div className="flex items-center gap-2 transition-all duration-300 py-1.5">
<EstablishmentLogo name={expense.name} size={37} />
<div className="min-w-0 flex-1">
@@ -65,15 +67,32 @@ export function InstallmentExpenseListItem({
/>
</div>
<p className="text-xs text-muted-foreground">
{endDate ? `Termina em ${endDate}` : null}
{" · Restante "}
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span className="inline-flex min-w-0 items-center gap-1">
<span
className="inline-flex shrink-0 [&_svg]:size-3.5"
title={expense.paymentMethod}
>
{getPaymentMethodIcon(expense.paymentMethod)}
<span className="sr-only">{expense.paymentMethod}</span>
</span>
{endDate ? <span className="shrink-0">Até {endDate}</span> : null}
</span>
<span className="shrink-0">
{remainingInstallments === 0 ? (
"Quitado"
) : (
<>
{remainingLabel}:{" "}
<MoneyValues
amount={remainingAmount}
className="inline-block font-semibold"
/>{" "}
({remainingInstallments})
</p>
({remainingInstallments}x)
</>
)}
</span>
</div>
<Progress value={progress} className="mt-1 h-2" />
</div>

View File

@@ -14,17 +14,17 @@ export function InstallmentExpensesList({
return (
<WidgetEmptyState
icon={<RiNumbersLine className="size-6 text-muted-foreground" />}
title="Nenhuma despesa parcelada"
title="Nenhuma despesa parcelada encontrada"
description="Lançamentos parcelados aparecerão aqui conforme forem registrados."
/>
);
}
return (
<ul className="flex flex-col">
<div className="flex flex-col">
{expenses.map((expense) => (
<InstallmentExpenseListItem key={expense.id} expense={expense} />
))}
</ul>
</div>
);
}

View File

@@ -1,10 +1,12 @@
import { RiCheckboxCircleFill, RiExternalLinkLine } from "@remixicon/react";
import { RiCheckboxCircleFill, RiGroupLine } from "@remixicon/react";
import Link from "next/link";
import { dashboardWidgetListStyles as styles } from "@/features/dashboard/components/dashboard-widget-list-styles";
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import {
buildInvoiceDetailsHref,
buildInvoiceInitials,
formatInvoicePaymentDate,
formatInvoiceWidgetOverdueLabel,
formatInvoiceWidgetPaymentDate,
getInvoiceShareLabel,
parseInvoiceDueDate,
@@ -48,31 +50,27 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
const absolutePaymentInfo = formatInvoicePaymentDate(invoice.paidAt);
const breakdown = invoice.pagadorBreakdown ?? [];
const hasBreakdown = breakdown.length > 0;
const hasMultiplePayers = breakdown.length > 1;
const detailHref = buildInvoiceDetailsHref(invoice.cardId, invoice.period);
const overdueLabel = formatInvoiceWidgetOverdueLabel(dueInfo.date);
const dueTooltipLabel =
dueInfo.label !== absoluteDueInfo.label ? absoluteDueInfo.label : null;
overdueLabel || dueInfo.label !== absoluteDueInfo.label
? absoluteDueInfo.label
: null;
const paymentTooltipLabel =
paymentInfo?.label && paymentInfo.label !== absolutePaymentInfo?.label
? absolutePaymentInfo?.label
: null;
const linkNode = (
<Link
prefetch
href={detailHref}
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
>
<Link prefetch href={detailHref} className={styles.titleLink}>
<span className="truncate">{invoice.cardName}</span>
<RiExternalLinkLine
className="size-3 shrink-0 text-muted-foreground"
aria-hidden
/>
</Link>
);
return (
<div className="flex items-center justify-between transition-all duration-300 py-1.5">
<div className="flex min-w-0 flex-1 items-center gap-2 py-1">
<li className={styles.row}>
<div className={styles.main}>
<InvoiceLogo
cardName={invoice.cardName}
logo={invoice.logo}
@@ -80,7 +78,8 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
containerClassName="size-9.5"
/>
<div className="min-w-0">
<div className={styles.textStack}>
<div className="flex max-w-full items-center gap-1">
{hasBreakdown ? (
<HoverCard openDelay={150}>
<HoverCardTrigger asChild>{linkNode}</HoverCardTrigger>
@@ -121,9 +120,14 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
className="font-medium"
amount={share.amount}
/>
{share.percentageChange !== null ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<PercentageChangeIndicator
value={share.percentageChange}
/>
<span>vs. mês ant.</span>
</span>
) : null}
</div>
</li>
))}
@@ -133,18 +137,46 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
) : (
linkNode
)}
{hasMultiplePayers ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0 cursor-help text-muted-foreground">
<RiGroupLine className="size-3.5" aria-hidden />
<span className="sr-only">Ver distribuição por pessoa</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Ver distribuição por pessoa
</TooltipContent>
</Tooltip>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<div className={styles.meta}>
{!isPaid ? (
dueTooltipLabel ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-help">{dueInfo.label}</span>
<span
className={
isOverdue
? "cursor-help font-semibold text-destructive"
: "cursor-help"
}
>
{overdueLabel ?? dueInfo.label}
</span>
</TooltipTrigger>
<TooltipContent side="top">{dueTooltipLabel}</TooltipContent>
</Tooltip>
) : (
<span>{dueInfo.label}</span>
<span
className={
isOverdue ? "font-semibold text-destructive" : undefined
}
>
{overdueLabel ?? dueInfo.label}
</span>
)
) : null}
{isPaid && paymentInfo ? (
@@ -169,24 +201,24 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
</div>
</div>
<div className="flex shrink-0 flex-col items-end">
<div className={styles.trailing}>
<MoneyValues
className="font-medium"
className={styles.trailingValue}
amount={Math.abs(invoice.totalAmount)}
/>
{isPaid ? (
<span className={`${styles.trailingMeta} text-success`}>
<RiCheckboxCircleFill className="size-3.5" /> Pago
</span>
) : (
<Button
type="button"
size="sm"
variant="link"
className="h-auto p-0 disabled:opacity-100"
disabled={isPaid}
className={styles.actionButton}
onClick={() => onPay(invoice.id)}
>
{isPaid ? (
<span className="flex items-center gap-0.5 text-success">
<RiCheckboxCircleFill className="size-3.5" /> Pago
</span>
) : isOverdue ? (
{isOverdue ? (
<span className="overdue-blink">
<span className="overdue-blink-primary text-destructive">
Atrasado
@@ -197,7 +229,8 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
<span>Pagar</span>
)}
</Button>
)}
</div>
</div>
</li>
);
}

View File

@@ -39,9 +39,7 @@ export function InvoicesWidgetView({
}: InvoicesWidgetViewProps) {
return (
<>
<div className="flex flex-col gap-4">
<InvoicesList invoices={invoices} onPay={onOpenPaymentDialog} />
</div>
<InvoicePaymentDialog
invoice={selectedInvoice}

Some files were not shown because too many files have changed in this diff Show More