Compare commits

...

20 Commits

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

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

View File

@@ -2,58 +2,151 @@ name: Release
on: on:
push: push:
branches: tags:
- main - "v*.*.*"
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
DOCKER_IMAGE_NAME: openmonetis
permissions:
contents: read
jobs: jobs:
release: quality:
runs-on: ubuntu-latest 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: permissions:
contents: write contents: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Read version from package.json
id: version
run: |
VERSION=$(jq -r '.version' package.json)
echo "value=$VERSION" >> $GITHUB_OUTPUT
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
- name: Check if tag already exists
id: tag_check
run: |
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Extract changelog for this version - name: Extract changelog for this version
if: steps.tag_check.outputs.exists == 'false'
id: changelog id: changelog
shell: bash
run: | run: |
VERSION="${{ steps.version.outputs.value }}" VERSION="${GITHUB_REF_NAME#v}"
# Extrai o bloco entre ## [X.Y.Z] e o próximo ## [ NOTES=$(awk -v version="$VERSION" '
NOTES=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md) index($0, "## [" version "]") == 1 { found=1; next }
# Remove linhas em branco do início e fim found && /^## \[/ { exit }
NOTES=$(echo "$NOTES" | sed '/./,$!d' | sed -e :a -e '/^\n*$/{$d;N;ba}') 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<<EOF"
echo "$NOTES" echo "$NOTES"
echo "EOF" echo "EOF"
} >> $GITHUB_OUTPUT } >> "$GITHUB_OUTPUT"
- name: Create tag and GitHub Release - name: Create GitHub Release
if: steps.tag_check.outputs.exists == 'false'
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ steps.version.outputs.tag }} tag_name: ${{ github.ref_name }}
name: ${{ steps.version.outputs.tag }} name: ${{ github.ref_name }}
body: ${{ steps.changelog.outputs.notes }} body: ${{ steps.changelog.outputs.notes }}
draft: false draft: false
prerelease: false prerelease: false

1
.gitignore vendored
View File

@@ -105,7 +105,6 @@ docker-compose.override.yml
.gemini/ .gemini/
.cursor/ .cursor/
QWEN.md QWEN.md
AGENTS.md
.codex .codex
# === Backups locais === # === Backups locais ===
/backup/ /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,85 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/), O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/). e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
## [2.7.12] - 2026-06-30
Esta versão corrige a seleção de faturas e períodos em popovers usados dentro de diálogos, alinhando esses componentes ao mesmo comportamento seguro aplicado recentemente aos seletores de data.
### Corrigido
- Lançamentos: o seletor inline de fatura volta a aceitar cliques no mês em diálogos de criação/edição e no modal de múltiplos lançamentos.
- Períodos: botões internos do seletor mensal agora são explicitamente `type="button"`, evitando submits acidentais quando o componente aparece dentro de formulários.
- Interface: popovers de período e escolha de logo de estabelecimento passam a usar modo modal quando podem aparecer sobre diálogos, preservando foco e clique.
## [2.7.11] - 2026-06-28
Esta atualização melhora a leitura diária dos lançamentos e deixa a nova visualização opcional, mantendo a possibilidade de voltar ao formato anterior quando a lista agrupada não for a melhor escolha para o usuário.
### Adicionado
- Preferências: nova opção `Agrupar por data` em Ajustes > Preferências > Lançamentos para alternar entre a lista agrupada por data e a visualização anterior.
- Lançamentos: a lista agora pode exibir uma barra de data por grupo no formato `TER, 26 JUN 2026`, reunindo os lançamentos daquele dia.
### Alterado
- Lançamentos: quando o agrupamento por data está ativo, os cards e linhas deixam de repetir a data em cada item, reduzindo ruído visual e mantendo vencimentos de boleto como informação do lançamento.
- Lançamentos: a preferência de agrupamento por data é aplicada nas listagens principais, extratos de conta, faturas de cartão, detalhes de pessoa e detalhes de categoria.
- Interface: checkboxes passam a usar um visual mais compacto.
- Documentação: o README agora cita o agrupamento por data entre as opções de personalização.
### Corrigido
- Lançamentos: o seletor de data em modais de criação e edição volta a aceitar a data selecionada no calendário.
## [2.7.10] - 2026-06-27
Esta versão ajusta a experiência de leitura dos lançamentos parcelados após antecipações, permitindo esconder parcelas já liquidadas por antecipação sem perder o histórico quando ele ainda for necessário.
### Adicionado
- Ajustes: nova preferência `Ocultar parcelas antecipadas` para remover da tabela lançamentos marcados como parcela antecipada.
### Alterado
- Lançamentos: a preferência passa a ser aplicada nas listagens principais, extratos de conta, faturas de cartão, detalhes de pessoa, detalhes de categoria e exportação de lançamentos, preservando paginação e contagens visíveis.
## [2.7.9] - 2026-06-21
Esta versão torna a publicação mais previsível ao separar a validação contínua da entrega de versões oficiais. Pull requests e a branch principal continuam sendo verificadas, enquanto imagens Docker e releases passam a ser produzidas somente a partir de uma tag SemVer validada.
### Alterado
- CI: pull requests e pushes na `main` agora executam geração de tipos, verificação TypeScript, lint e build sem publicar imagens.
- Releases: tags `vX.Y.Z` agora validam sua correspondência com o `package.json` e o `CHANGELOG.md` antes de publicar as imagens Docker e criar a GitHub Release.
- Docker: as tags versionadas e `latest` passam a ser publicadas exclusivamente por releases oficiais, depois das verificações de qualidade.
## [2.7.8] - 2026-06-21
Esta versão deixa documentos e comprovantes mais fáceis de guardar e encontrar sem tirar o foco da rotina financeira. Agora é possível manter arquivos junto às notas, consultar a galeria por pessoa com identificação visual e abrir uma categoria diretamente das tendências do dashboard, sempre preservando o contexto do período selecionado.
### Adicionado
- Anotações: itens do tipo `Nota` agora aceitam anexos PDF, JPEG, PNG e WebP na criação e na edição, com consulta e download nos detalhes e respeito ao limite configurado pelo usuário.
- Anexos: a galeria agora oferece filtro por pessoa, incluindo a pessoa principal, pessoas específicas e uma visão consolidada de todas as pessoas.
### Alterado
- Anexos: os cards da galeria agora identificam a pessoa vinculada ao lançamento e o filtro exibe os respectivos avatares, preservando o contexto quando vários responsáveis são exibidos.
- Dashboard: os nomes no widget `Tendências de categorias` agora levam aos detalhes da categoria mantendo o período selecionado.
## [2.7.7] - 2026-06-20
Esta versão faz ajustes pontuais de leitura nos resumos financeiros e no dashboard, reforçando a identidade visual de cartões e contas e deixando as listas dos widgets mais consistentes sem alterar a estrutura de navegação das páginas.
### 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 ## [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. 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.

View File

@@ -16,7 +16,7 @@
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`. 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`. 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. 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. 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. 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. 9. **README.md**: sempre que fizer alteracoes significativas, atualize o README.md.

View File

@@ -10,7 +10,7 @@
> **Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor. > **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.5-blue?style=flat-square)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-2.7.12-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/) [![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/) [![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/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/)
@@ -65,11 +65,11 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
### Funcionalidades ### Funcionalidades
💰 **Contas e transações** — Contas bancárias, cartões, dinheiro. Receitas, despesas, rendimentos e transferências. Categorização, divisão de lançamentos entre várias pessoas, filtros combináveis com intervalo de datas, extratos detalhados e importação de extratos OFX e XLS/XLSX com detecção automática de categoria. 💰 **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 personalizáveis, 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. 📊 **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. 🎯 **Orçamentos** — Defina limites por categoria e acompanhe o progresso.
@@ -79,7 +79,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
👥 **Gestão colaborativa** — Pagadores com permissões (admin/viewer), notificações automáticas por e-mail, códigos de compartilhamento. 👥 **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. 📅 **Calendário financeiro** — Visualize todos os lançamentos em um calendário mensal.
@@ -89,7 +89,7 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
<img src="./public/images/companion-preview-light.webp" alt="OpenMonetis Companion" width="300" height="600" /> <img src="./public/images/companion-preview-light.webp" alt="OpenMonetis Companion" width="300" height="600" />
</p> </p>
⚙️ **Personalização** — Tema dark/light, modo privacidade, ordem das colunas, exibição de anotações, tamanho máximo de anexos, resumo opcional no modal de lançamento e changelog visual para acompanhar as novidades do app. ⚙️ **Personalização** — Tema dark/light, modo privacidade, ordem das colunas, agrupamento por data em lançamentos, exibição de anotações, tamanho máximo de anexos, resumo opcional no modal de lançamento e changelog visual para acompanhar as novidades do app.
### Stack técnica ### Stack técnica
@@ -623,6 +623,17 @@ A regra é: `actions.ts` e `queries.ts` são as portas de entrada da feature. Tu
Antes de começar, leia o [`CLAUDE.md`](CLAUDE.md) — ele documenta a arquitetura, convenções de nomenclatura, regras de queries e o checklist para novas features. Use TypeScript, commits semânticos e mantenha o `CHANGELOG.md` atualizado. 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.12 -m "v2.7.12"
git push origin v2.7.12
```
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 ## 💖 Apoie o Projeto

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -211,6 +211,27 @@
"when": 1780150535055, "when": 1780150535055,
"tag": "0030_complete_umar", "tag": "0030_complete_umar",
"breakpoints": true "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", "name": "openmonetis",
"version": "2.7.5", "version": "2.7.12",
"private": true, "private": true,
"packageManager": "pnpm@11.1.3", "packageManager": "pnpm@11.1.3",
"scripts": { "scripts": {
@@ -31,32 +31,32 @@
"mockup": "tsx scripts/mock-data.ts" "mockup": "tsx scripts/mock-data.ts"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^3.0.81", "@ai-sdk/anthropic": "^3.0.88",
"@ai-sdk/google": "^3.0.80", "@ai-sdk/google": "^3.0.85",
"@ai-sdk/openai": "^3.0.67", "@ai-sdk/openai": "^3.0.76",
"@ai-sdk/openai-compatible": "^2.0.48", "@ai-sdk/openai-compatible": "^2.0.53",
"@aws-sdk/client-s3": "^3.1059.0", "@aws-sdk/client-s3": "^3.1075.0",
"@aws-sdk/s3-request-presigner": "^3.1059.0", "@aws-sdk/s3-request-presigner": "^3.1075.0",
"@better-auth/passkey": "^1.6.14", "@better-auth/passkey": "^1.6.22",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@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-alert-dialog": "1.1.15",
"@radix-ui/react-avatar": "1.1.11", "@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-checkbox": "1.3.3",
"@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-dropdown-menu": "2.1.16", "@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-label": "2.1.8",
"@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-navigation-menu": "^1.2.16",
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-popover": "^1.1.17",
"@radix-ui/react-progress": "1.1.8", "@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-select": "2.2.6",
"@radix-ui/react-separator": "1.1.8", "@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-slot": "1.2.4",
"@radix-ui/react-switch": "1.2.6", "@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-tabs": "1.1.13",
@@ -64,11 +64,11 @@
"@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-tooltip": "1.2.8",
"@remixicon/react": "4.9.0", "@remixicon/react": "4.9.0",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.1",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "^3.14.2", "@tanstack/react-virtual": "^3.14.4",
"ai": "^6.0.195", "ai": "^6.0.213",
"better-auth": "1.6.14", "better-auth": "1.6.22",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
@@ -86,7 +86,7 @@
"react-day-picker": "^10.0.1", "react-day-picker": "^10.0.1",
"react-dom": "19.2.7", "react-dom": "19.2.7",
"recharts": "3.8.1", "recharts": "3.8.1",
"resend": "^6.12.4", "resend": "^6.16.0",
"sonner": "2.0.7", "sonner": "2.0.7",
"tailwind-merge": "3.6.0", "tailwind-merge": "3.6.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
@@ -105,7 +105,7 @@
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"drizzle-kit": "0.31.10", "drizzle-kit": "0.31.10",
"knip": "^6.15.0", "knip": "^6.22.0",
"tailwindcss": "4.3.0", "tailwindcss": "4.3.0",
"tsx": "4.22.4", "tsx": "4.22.4",
"typescript": "6.0.3" "typescript": "6.0.3"

2673
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,6 +1,6 @@
import { connection } from "next/server"; import { connection } from "next/server";
import { AttachmentsPage } from "@/features/attachments/components/attachments-page"; 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 { getUserId } from "@/shared/lib/auth/server";
import { parsePeriodParam } from "@/shared/utils/period"; import { parsePeriodParam } from "@/shared/utils/period";
@@ -26,11 +26,14 @@ export default async function Page({ searchParams }: PageProps) {
const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period } = parsePeriodParam(periodoParam); const { period } = parsePeriodParam(periodoParam);
const attachments = await fetchAttachmentsForPeriod(userId, period); const data = await fetchAttachmentsPageData(userId, period);
return ( return (
<main className="flex flex-col gap-6"> <main className="flex flex-col gap-6">
<AttachmentsPage attachments={attachments} /> <AttachmentsPage
attachments={data?.attachments ?? []}
adminPayerId={data?.adminPayerId ?? ""}
/>
</main> </main>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -157,6 +157,12 @@ export const userPreferences = pgTable("preferencias_usuario", {
showTransactionSummary: boolean("mostrar_resumo_lancamento") showTransactionSummary: boolean("mostrar_resumo_lancamento")
.notNull() .notNull()
.default(true), .default(true),
groupTransactionsByDate: boolean("agrupar_lancamentos_por_data")
.notNull()
.default(true),
hideAnticipatedInstallments: boolean("ocultar_parcelas_antecipadas")
.notNull()
.default(false),
dashboardWidgets: jsonb("dashboard_widgets").$type<{ dashboardWidgets: jsonb("dashboard_widgets").$type<{
order: string[]; order: string[];
hidden: string[]; hidden: string[];
@@ -847,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, { user: one(user, {
fields: [notes.userId], fields: [notes.userId],
references: [user.id], references: [user.id],
}), }),
noteAttachments: many(noteAttachments),
})); }));
export const savedInsightsRelations = relations(savedInsights, ({ one }) => ({ export const savedInsightsRelations = relations(savedInsights, ({ one }) => ({
@@ -972,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( export const importCategoryMappings = pgTable(
"import_category_mappings", "import_category_mappings",
{ {
@@ -1044,6 +1069,7 @@ export const attachmentsRelations = relations(attachments, ({ one, many }) => ({
references: [user.id], references: [user.id],
}), }),
transactionAttachments: many(transactionAttachments), transactionAttachments: many(transactionAttachments),
noteAttachments: many(noteAttachments),
})); }));
export const transactionAttachmentsRelations = relations( export const transactionAttachmentsRelations = relations(
@@ -1060,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 Attachment = typeof attachments.$inferSelect;
export type TransactionAttachment = typeof transactionAttachments.$inferSelect; export type TransactionAttachment = typeof transactionAttachments.$inferSelect;
export type NoteAttachment = typeof noteAttachments.$inferSelect;
export const establishmentLogosRelations = relations( export const establishmentLogosRelations = relations(
establishmentLogos, establishmentLogos,

View File

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

View File

@@ -162,9 +162,15 @@ export function AttachmentGridItem({
</div> </div>
{/* Data */} {/* 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)} {formatDate(attachment.purchaseDate)}
</span> </span>
<span aria-hidden>·</span>
<span className="truncate" title={attachment.payerName}>
{attachment.payerName}
</span>
</div>
{/* Transação e Valor */} {/* Transação e Valor */}
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">

View File

@@ -4,6 +4,8 @@ import {
RiAttachmentLine, RiAttachmentLine,
RiFilePdf2Line, RiFilePdf2Line,
RiImageLine, RiImageLine,
RiUserLine,
RiVerifiedBadgeFill,
} from "@remixicon/react"; } from "@remixicon/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type React from "react"; 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 { fetchTransactionDialogOptionsAction } from "@/features/transactions/actions/fetch-dialog-options";
import { TransactionDetailsDialog } from "@/features/transactions/components/dialogs/transaction-details-dialog"; import { TransactionDetailsDialog } from "@/features/transactions/components/dialogs/transaction-details-dialog";
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-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 type { TransactionItem } from "@/features/transactions/components/types";
import { EmptyState } from "@/shared/components/feedback/empty-state"; import { EmptyState } from "@/shared/components/feedback/empty-state";
import { Card, CardContent } from "@/shared/components/ui/card"; 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"; import { cn } from "@/shared/utils/ui";
type FilterType = "all" | "images" | "pdfs"; type FilterType = "all" | "images" | "pdfs";
@@ -73,11 +83,18 @@ const FILTERS: {
interface AttachmentsPageProps { interface AttachmentsPageProps {
attachments: AttachmentForPeriod[]; attachments: AttachmentForPeriod[];
adminPayerId: string;
} }
export function AttachmentsPage({ attachments }: AttachmentsPageProps) { const ALL_PAYERS = "all";
export function AttachmentsPage({
attachments,
adminPayerId,
}: AttachmentsPageProps) {
const router = useRouter(); const router = useRouter();
const [filter, setFilter] = useState<FilterType>("all"); const [filter, setFilter] = useState<FilterType>("all");
const [payerFilter, setPayerFilter] = useState(adminPayerId);
const [selectedIndex, setSelectedIndex] = useState(-1); const [selectedIndex, setSelectedIndex] = useState(-1);
const [transactionDetails, setTransactionDetails] = const [transactionDetails, setTransactionDetails] =
useState<TransactionItem | null>(null); useState<TransactionItem | null>(null);
@@ -93,21 +110,44 @@ export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
const [dialogOptions, setDialogOptions] = const [dialogOptions, setDialogOptions] =
useState<TransactionDialogOptions | null>(null); 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 === "images") return a.mimeType.startsWith("image/");
if (filter === "pdfs") return a.mimeType === "application/pdf"; if (filter === "pdfs") return a.mimeType === "application/pdf";
return true; return true;
}); });
const imageCount = attachments.filter((a) => const imageCount = payerAttachments.filter((a) =>
a.mimeType.startsWith("image/"), a.mimeType.startsWith("image/"),
).length; ).length;
const pdfCount = attachments.filter( const pdfCount = payerAttachments.filter(
(a) => a.mimeType === "application/pdf", (a) => a.mimeType === "application/pdf",
).length; ).length;
const counts: Record<FilterType, number> = { const counts: Record<FilterType, number> = {
all: attachments.length, all: payerAttachments.length,
images: imageCount, images: imageCount,
pdfs: pdfCount, pdfs: pdfCount,
}; };
@@ -161,6 +201,67 @@ export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
{filter !== "all" && {filter !== "all" &&
` · ${FILTERS.find((f) => f.value === filter)?.label.toLowerCase()}`} ` · ${FILTERS.find((f) => f.value === filter)?.label.toLowerCase()}`}
</p> </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"> <div className="flex items-center gap-1 rounded-lg border p-1">
{FILTERS.map(({ value, label, icon }) => ( {FILTERS.map(({ value, label, icon }) => (
<button <button
@@ -193,13 +294,18 @@ export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
))} ))}
</div> </div>
</div> </div>
</div>
{filteredAttachments.length === 0 ? ( {filteredAttachments.length === 0 ? (
<div className="flex w-full items-center justify-center py-12"> <div className="flex w-full items-center justify-center py-12">
<EmptyState <EmptyState
media={<RiAttachmentLine className="size-6 text-primary" />} media={<RiAttachmentLine className="size-6 text-primary" />}
title="Nenhum anexo encontrado" 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> </div>
) : ( ) : (

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import {
isIncomeBill, isIncomeBill,
} from "@/features/dashboard/bills/bills-helpers"; } from "@/features/dashboard/bills/bills-helpers";
import type { DashboardBill } from "@/features/dashboard/bills/bills-queries"; 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 { EstablishmentLogo } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
@@ -47,18 +48,15 @@ export function BillListItem({ bill, period, onPay }: BillListItemProps) {
const href = buildTransactionsHref(bill.name, period); const href = buildTransactionsHref(bill.name, period);
return ( return (
<li className="flex items-center justify-between transition-all duration-300 py-1.5"> <li className={styles.row}>
<div className="flex min-w-0 flex-1 items-center gap-2 py-0.5"> <div className={styles.main}>
<EstablishmentLogo name={bill.name} size={37} /> <EstablishmentLogo name={bill.name} size={37} />
<div className="min-w-0"> <div className={styles.textStack}>
<Link <Link href={href} className={styles.titleLink}>
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"
>
<span className="truncate">{bill.name}</span> <span className="truncate">{bill.name}</span>
</Link> </Link>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <div className={styles.meta}>
{statusLabel ? ( {statusLabel ? (
statusTooltipLabel ? ( statusTooltipLabel ? (
<Tooltip> <Tooltip>
@@ -92,10 +90,10 @@ export function BillListItem({ bill, period, onPay }: BillListItemProps) {
</div> </div>
</div> </div>
<div className="flex shrink-0 flex-col items-end"> <div className={styles.trailing}>
<MoneyValues className="font-medium" amount={bill.amount} /> <MoneyValues className={styles.trailingValue} amount={bill.amount} />
{bill.isSettled ? ( {bill.isSettled ? (
<span className="flex h-7 items-center gap-0.5 text-xs font-medium text-success"> <span className={`${styles.trailingMeta} text-success`}>
<RiCheckboxCircleFill className="size-3.5" />{" "} <RiCheckboxCircleFill className="size-3.5" />{" "}
{income ? "Recebido" : "Pago"} {income ? "Recebido" : "Pago"}
</span> </span>
@@ -104,7 +102,7 @@ export function BillListItem({ bill, period, onPay }: BillListItemProps) {
type="button" type="button"
size="sm" size="sm"
variant="link" variant="link"
className="-mr-1.5 h-7 px-1.5 py-0" className={styles.actionButton}
onClick={() => onPay(bill.id)} onClick={() => onPay(bill.id)}
> >
{overdue ? ( {overdue ? (

View File

@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers"; 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 { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import { CategoryIconBadge } from "@/shared/components/entity-avatar"; import { CategoryIconBadge } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
@@ -45,25 +46,23 @@ export function CategoryBreakdownListItem({
return ( return (
<div> <div>
<div className="flex items-center justify-between gap-2 transition-all duration-300 py-1.5"> <div className={styles.row}>
<span className="w-3 shrink-0 text-left text-xs font-medium text-muted-foreground"> <span className={styles.rank}>{position}</span>
{position} <div className={styles.main}>
</span>
<div className="flex min-w-0 flex-1 items-center gap-2">
<CategoryIconBadge <CategoryIconBadge
icon={category.categoryIcon} icon={category.categoryIcon}
name={category.categoryName} name={category.categoryName}
/> />
<div className="min-w-0 flex-1"> <div className={styles.textStack}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
href={`/categories/${category.categoryId}?periodo=${periodParam}`} 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:text-primary hover:underline" className={styles.titleLink}
> >
<span className="truncate">{category.categoryName}</span> <span className="truncate">{category.categoryName}</span>
</Link> </Link>
</div> </div>
<div className="flex flex-wrap items-center gap-x-1 text-xs text-muted-foreground"> <div className={styles.meta}>
<span> <span>
{formatPercentage( {formatPercentage(
category.percentageOfTotal, category.percentageOfTotal,
@@ -97,13 +96,13 @@ export function CategoryBreakdownListItem({
</div> </div>
</div> </div>
<div className="flex shrink-0 flex-col items-end gap-0.5"> <div className={styles.trailing}>
<MoneyValues <MoneyValues
className="text-foreground font-medium" className={styles.trailingValue}
amount={category.currentAmount} amount={category.currentAmount}
/> />
{category.percentageChange !== null ? ( {category.percentageChange !== null ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground"> <span className={`${styles.trailingMeta} text-muted-foreground`}>
<PercentageChangeIndicator <PercentageChangeIndicator
value={category.percentageChange} value={category.percentageChange}
label={formatPercentage( label={formatPercentage(

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 { RiPencilLine } from "@remixicon/react";
import Link from "next/link"; import Link from "next/link";
import { dashboardWidgetListStyles as styles } from "@/features/dashboard/components/dashboard-widget-list-styles";
import { import {
clampGoalProgress, clampGoalProgress,
formatGoalProgressPercentage, formatGoalProgressPercentage,
@@ -30,7 +31,7 @@ export function GoalProgressItem({ item, onEdit }: GoalProgressItemProps) {
const usedPercentageLabel = formatGoalProgressPercentage(item.usedPercentage); const usedPercentageLabel = formatGoalProgressPercentage(item.usedPercentage);
return ( return (
<li className="group py-2 transition-all duration-300"> <li className="group py-1.5 transition-all duration-300">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 items-start gap-2"> <div className="flex min-w-0 flex-1 items-start gap-2">
<CategoryIconBadge <CategoryIconBadge
@@ -42,16 +43,14 @@ export function GoalProgressItem({ item, onEdit }: GoalProgressItemProps) {
{item.categoryId ? ( {item.categoryId ? (
<Link <Link
href={`/categories/${item.categoryId}?periodo=${formatPeriodForUrl(item.period)}`} href={`/categories/${item.categoryId}?periodo=${formatPeriodForUrl(item.period)}`}
className="block truncate text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline" className={`${styles.title} block underline-offset-2 hover:text-primary hover:underline`}
> >
{item.categoryName} {item.categoryName}
</Link> </Link>
) : ( ) : (
<p className="truncate text-sm font-medium text-foreground"> <p className={styles.title}>{item.categoryName}</p>
{item.categoryName}
</p>
)} )}
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs leading-4 text-muted-foreground">
<MoneyValues className="font-medium" amount={item.spentAmount} />{" "} <MoneyValues className="font-medium" amount={item.spentAmount} />{" "}
de{" "} de{" "}
<MoneyValues className="font-medium" amount={item.budgetAmount} /> <MoneyValues className="font-medium" amount={item.budgetAmount} />

View File

@@ -1,5 +1,6 @@
import { RiCheckboxCircleFill, RiGroupLine } from "@remixicon/react"; import { RiCheckboxCircleFill, RiGroupLine } from "@remixicon/react";
import Link from "next/link"; 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 { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import { import {
buildInvoiceDetailsHref, buildInvoiceDetailsHref,
@@ -62,18 +63,14 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
: null; : null;
const linkNode = ( const linkNode = (
<Link <Link prefetch href={detailHref} className={styles.titleLink}>
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"
>
<span className="truncate">{invoice.cardName}</span> <span className="truncate">{invoice.cardName}</span>
</Link> </Link>
); );
return ( return (
<li className="flex items-center justify-between transition-all duration-300 py-1.5"> <li className={styles.row}>
<div className="flex min-w-0 flex-1 items-center gap-2 py-1"> <div className={styles.main}>
<InvoiceLogo <InvoiceLogo
cardName={invoice.cardName} cardName={invoice.cardName}
logo={invoice.logo} logo={invoice.logo}
@@ -81,7 +78,7 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
containerClassName="size-9.5" containerClassName="size-9.5"
/> />
<div className="min-w-0"> <div className={styles.textStack}>
<div className="flex max-w-full items-center gap-1"> <div className="flex max-w-full items-center gap-1">
{hasBreakdown ? ( {hasBreakdown ? (
<HoverCard openDelay={150}> <HoverCard openDelay={150}>
@@ -155,7 +152,7 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
) : null} ) : null}
</div> </div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <div className={styles.meta}>
{!isPaid ? ( {!isPaid ? (
dueTooltipLabel ? ( dueTooltipLabel ? (
<Tooltip> <Tooltip>
@@ -204,13 +201,13 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
</div> </div>
</div> </div>
<div className="flex shrink-0 flex-col items-end"> <div className={styles.trailing}>
<MoneyValues <MoneyValues
className="font-medium" className={styles.trailingValue}
amount={Math.abs(invoice.totalAmount)} amount={Math.abs(invoice.totalAmount)}
/> />
{isPaid ? ( {isPaid ? (
<span className="flex h-7 items-center gap-0.5 text-xs font-medium text-success"> <span className={`${styles.trailingMeta} text-success`}>
<RiCheckboxCircleFill className="size-3.5" /> Pago <RiCheckboxCircleFill className="size-3.5" /> Pago
</span> </span>
) : ( ) : (
@@ -218,7 +215,7 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
type="button" type="button"
size="sm" size="sm"
variant="link" variant="link"
className="-mr-1.5 h-7 px-1.5 py-0" className={styles.actionButton}
onClick={() => onPay(invoice.id)} onClick={() => onPay(invoice.id)}
> >
{isOverdue ? ( {isOverdue ? (

View File

@@ -3,6 +3,7 @@ import {
RiFileList2Line, RiFileList2Line,
RiPencilLine, RiPencilLine,
} from "@remixicon/react"; } from "@remixicon/react";
import { dashboardWidgetListStyles as styles } from "@/features/dashboard/components/dashboard-widget-list-styles";
import type { Note } from "@/features/notes/components/types"; import type { Note } from "@/features/notes/components/types";
import { import {
buildNoteDisplayTitle, buildNoteDisplayTitle,
@@ -33,18 +34,16 @@ export function NoteListItem({
const isTask = note.type === "tarefa"; const isTask = note.type === "tarefa";
return ( return (
<li className="group flex items-center justify-between gap-2 py-1.5 transition-all duration-300"> <li className={`group ${styles.row}`}>
<div className="min-w-0 flex-1"> <div className={styles.textStack}>
<p className="truncate text-sm font-medium text-foreground"> <p className={styles.title}>{displayTitle}</p>
{displayTitle} <div className={styles.meta}>
</p>
<div className="mt-1 flex min-w-0 items-center gap-2">
{isTask ? ( {isTask ? (
<Badge variant="outline" className="h-5 px-1.5 text-xs"> <Badge variant="outline" className="h-5 px-1.5 text-xs">
{getNoteTasksSummary(note)} {getNoteTasksSummary(note)}
</Badge> </Badge>
) : null} ) : null}
<p className="truncate text-xs text-muted-foreground"> <p className="truncate">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<RiCalendarLine className="size-3.5 shrink-0" /> <RiCalendarLine className="size-3.5 shrink-0" />
{createdAtLabel} {createdAtLabel}
@@ -53,7 +52,7 @@ export function NoteListItem({
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-0.5"> <div className="flex min-w-[4.5rem] shrink-0 items-center justify-end gap-0.5">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button

View File

@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { dashboardWidgetListStyles as styles } from "@/features/dashboard/components/dashboard-widget-list-styles";
import { import {
formatPaymentBreakdownPercentage, formatPaymentBreakdownPercentage,
formatPaymentBreakdownTransactionsLabel, formatPaymentBreakdownTransactionsLabel,
@@ -31,10 +32,8 @@ export function PaymentBreakdownListItem({
position, position,
}: PaymentBreakdownListItemProps) { }: PaymentBreakdownListItemProps) {
return ( return (
<div className="flex items-center gap-2 transition-all duration-300 py-1"> <div className={styles.row}>
<span className="w-3 shrink-0 text-left text-xs font-medium text-muted-foreground"> <span className={styles.rank}>{position}</span>
{position}
</span>
<div <div
className="flex size-9.5 shrink-0 items-center justify-center rounded-full" className="flex size-9.5 shrink-0 items-center justify-center rounded-full"
style={{ style={{
@@ -45,26 +44,26 @@ export function PaymentBreakdownListItem({
{item.icon} {item.icon}
</div> </div>
<div className="min-w-0 flex-1"> <div className={styles.textStack}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-2">
{item.href ? ( {item.href ? (
<Link <Link href={item.href} className={styles.titleLink}>
href={item.href}
className="inline-flex items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline"
>
<span className="truncate">{item.title}</span> <span className="truncate">{item.title}</span>
</Link> </Link>
) : ( ) : (
<p className="text-sm font-medium text-foreground">{item.title}</p> <p className={styles.title}>{item.title}</p>
)} )}
<MoneyValues className="shrink-0 font-medium" amount={item.amount} /> <MoneyValues
className={`shrink-0 ${styles.trailingValue}`}
amount={item.amount}
/>
</div> </div>
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className={styles.meta}>
<span> <span>
{formatPaymentBreakdownTransactionsLabel(item.transactions)} {formatPaymentBreakdownTransactionsLabel(item.transactions)}
</span> </span>
<span> <span className="ml-auto">
{formatPaymentBreakdownPercentage(item.percentage)} do total {formatPaymentBreakdownPercentage(item.percentage)} do total
</span> </span>
</div> </div>

View File

@@ -9,6 +9,7 @@ import {
import { useState } from "react"; import { useState } from "react";
import { AttachmentPreview } from "@/features/attachments/components/attachment-preview"; import { AttachmentPreview } from "@/features/attachments/components/attachment-preview";
import type { AttachmentForPeriod } from "@/features/attachments/queries"; import type { AttachmentForPeriod } from "@/features/attachments/queries";
import { dashboardWidgetListStyles as styles } from "@/features/dashboard/components/dashboard-widget-list-styles";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -77,7 +78,7 @@ export function AttachmentsWidget({ snapshot }: AttachmentsWidgetProps) {
<button <button
type="button" type="button"
onClick={() => setSelectedIndex(index)} onClick={() => setSelectedIndex(index)}
className="flex w-full items-center gap-2 py-2 text-left" className={`${styles.row} w-full text-left`}
> >
<div className="shrink-0"> <div className="shrink-0">
{isPdf && <RiFilePdf2Line className="size-6 text-red-500" />} {isPdf && <RiFilePdf2Line className="size-6 text-red-500" />}
@@ -86,10 +87,10 @@ export function AttachmentsWidget({ snapshot }: AttachmentsWidgetProps) {
<RiFileLine className="size-6 text-muted-foreground" /> <RiFileLine className="size-6 text-muted-foreground" />
)} )}
</div> </div>
<div className="min-w-0 flex-1"> <div className={styles.textStack}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="block truncate text-sm font-medium text-foreground hover:underline"> <span className={`${styles.title} block hover:underline`}>
{attachment.fileName} {attachment.fileName}
</span> </span>
</TooltipTrigger> </TooltipTrigger>
@@ -97,18 +98,18 @@ export function AttachmentsWidget({ snapshot }: AttachmentsWidgetProps) {
{attachment.fileName} {attachment.fileName}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<span className="block truncate text-xs text-muted-foreground"> <span className={`${styles.meta} block truncate`}>
{attachment.transactionName} {attachment.transactionName}
</span> </span>
</div> </div>
<div className="shrink-0 text-right"> <div className={styles.trailing}>
<span className="block text-xs text-muted-foreground"> <span className="block text-xs leading-4 text-muted-foreground">
{formatDateOnly(attachment.purchaseDate, { {formatDateOnly(attachment.purchaseDate, {
day: "2-digit", day: "2-digit",
month: "2-digit", month: "2-digit",
}) ?? "—"} }) ?? "—"}
</span> </span>
<span className="block text-xs text-muted-foreground/60"> <span className="block text-xs leading-4 text-muted-foreground/60">
{formatBytes(attachment.fileSize)} {formatBytes(attachment.fileSize)}
</span> </span>
</div> </div>

View File

@@ -6,20 +6,26 @@ import {
RiHistoryLine, RiHistoryLine,
RiLineChartLine, RiLineChartLine,
} from "@remixicon/react"; } from "@remixicon/react";
import Link from "next/link";
import type { DashboardCategoryBreakdownItem } from "@/features/dashboard/categories/category-breakdown-helpers"; 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 { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import { CategoryIconBadge } from "@/shared/components/entity-avatar"; import { CategoryIconBadge } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state"; import { WidgetEmptyState } from "@/shared/components/widgets/widget-empty-state";
import { formatPercentage } from "@/shared/utils/percentage"; import { formatPercentage } from "@/shared/utils/percentage";
import { formatPeriodForUrl } from "@/shared/utils/period";
type CategoryTrendsWidgetProps = { type CategoryTrendsWidgetProps = {
categories: DashboardCategoryBreakdownItem[]; categories: DashboardCategoryBreakdownItem[];
period: string;
}; };
export function CategoryTrendsWidget({ export function CategoryTrendsWidget({
categories, categories,
period,
}: CategoryTrendsWidgetProps) { }: CategoryTrendsWidgetProps) {
const periodParam = formatPeriodForUrl(period);
const trending = categories const trending = categories
.filter((c) => c.percentageChange !== null && c.previousAmount > 0) .filter((c) => c.percentageChange !== null && c.previousAmount > 0)
.sort( .sort(
@@ -45,17 +51,20 @@ export function CategoryTrendsWidget({
return ( return (
<li key={category.categoryId}> <li key={category.categoryId}>
<div className="-mx-2 flex items-center gap-3 rounded-md p-2"> <div className={styles.row}>
<CategoryIconBadge <CategoryIconBadge
icon={category.categoryIcon} icon={category.categoryIcon}
name={category.categoryName} name={category.categoryName}
size="md" size="md"
/> />
<div className="min-w-0 flex-1"> <div className={styles.textStack}>
<p className="truncate text-sm font-medium text-foreground"> <Link
{category.categoryName} href={`/categories/${category.categoryId}?periodo=${periodParam}`}
</p> className={styles.titleLink}
<p className="flex items-center gap-1.5 text-xs text-muted-foreground"> >
<span className="truncate">{category.categoryName}</span>
</Link>
<p className={styles.meta}>
<span <span
className="inline-flex items-center gap-1" className="inline-flex items-center gap-1"
title="Mês anterior" title="Mês anterior"
@@ -81,7 +90,9 @@ export function CategoryTrendsWidget({
</span> </span>
</p> </p>
</div> </div>
<span className="flex shrink-0 items-center gap-1 text-xs text-muted-foreground"> <span
className={`${styles.trailingMeta} min-w-[5.75rem] justify-end text-muted-foreground`}
>
<PercentageChangeIndicator <PercentageChangeIndicator
value={change} value={change}
label={formatPercentage(change, { label={formatPercentage(change, {

View File

@@ -10,6 +10,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { dashboardWidgetListStyles as styles } from "@/features/dashboard/components/dashboard-widget-list-styles";
import type { DashboardInboxSnapshot } from "@/features/dashboard/lib/inbox-snapshot-queries"; import type { DashboardInboxSnapshot } from "@/features/dashboard/lib/inbox-snapshot-queries";
import type { DashboardWidgetQuickActionOptions } from "@/features/dashboard/widget-registry/widget-config"; import type { DashboardWidgetQuickActionOptions } from "@/features/dashboard/widget-registry/widget-config";
import { import {
@@ -201,8 +202,8 @@ export function InboxWidget({
const displayLogo = logoSrc ?? DEFAULT_INBOX_APP_LOGO; const displayLogo = logoSrc ?? DEFAULT_INBOX_APP_LOGO;
return ( return (
<div key={item.id} className="flex items-center justify-between py-2"> <div key={item.id} className={styles.row}>
<div className="flex min-w-0 flex-1 items-center gap-2"> <div className={styles.main}>
<Image <Image
src={displayLogo} src={displayLogo}
alt={item.sourceAppName ?? ""} alt={item.sourceAppName ?? ""}
@@ -212,11 +213,9 @@ export function InboxWidget({
unoptimized unoptimized
/> />
<div className="min-w-0"> <div className={styles.textStack}>
<p className="truncate text-sm font-medium text-foreground"> <p className={styles.title}>{displayName}</p>
{displayName} <div className={styles.meta}>
</p>
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
{item.sourceAppName && ( {item.sourceAppName && (
<span className="truncate">{item.sourceAppName}</span> <span className="truncate">{item.sourceAppName}</span>
)} )}
@@ -227,9 +226,9 @@ export function InboxWidget({
</div> </div>
</div> </div>
<div className="ml-2 flex shrink-0 items-center gap-1"> <div className="ml-2 flex min-w-[7.5rem] shrink-0 items-center justify-end gap-1">
{amount !== null && ( {amount !== null && (
<MoneyValues className="font-medium" amount={amount} /> <MoneyValues className={styles.trailingValue} amount={amount} />
)} )}
{amount === null && ( {amount === null && (
<span className="max-w-20 text-right text-xs leading-tight text-muted-foreground"> <span className="max-w-20 text-right text-xs leading-tight text-muted-foreground">

View File

@@ -10,6 +10,7 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useTransition } from "react"; import { useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { dashboardWidgetListStyles as styles } from "@/features/dashboard/components/dashboard-widget-list-styles";
import type { DashboardAccount } from "@/features/dashboard/lib/accounts-queries"; import type { DashboardAccount } from "@/features/dashboard/lib/accounts-queries";
import { updateMyAccountsWidgetPreference } from "@/features/dashboard/widget-registry/widget-actions"; import { updateMyAccountsWidgetPreference } from "@/features/dashboard/widget-registry/widget-actions";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
@@ -139,11 +140,8 @@ export function MyAccountsWidget({
const logoSrc = resolveLogoSrc(account.logo); const logoSrc = resolveLogoSrc(account.logo);
return ( return (
<li <li key={account.id} className={styles.row}>
key={account.id} <div className={styles.main}>
className="flex items-center justify-between py-1.5 transition-all duration-300"
>
<div className="flex min-w-0 flex-1 items-center gap-2 py-1">
<div className="relative flex size-9.5 shrink-0 items-center justify-center overflow-hidden rounded-full"> <div className="relative flex size-9.5 shrink-0 items-center justify-center overflow-hidden rounded-full">
{logoSrc ? ( {logoSrc ? (
<Image <Image
@@ -161,18 +159,18 @@ export function MyAccountsWidget({
)} )}
</div> </div>
<div className="min-w-0"> <div className={styles.textStack}>
<Link <Link
prefetch prefetch
href={`/accounts/${ href={`/accounts/${
account.id account.id
}/statement?periodo=${formatPeriodForUrl(period)}`} }/statement?periodo=${formatPeriodForUrl(period)}`}
className="inline-flex max-w-full items-center gap-1 text-sm font-medium text-foreground underline-offset-2 hover:text-primary hover:underline" className={styles.titleLink}
> >
<span className="truncate">{account.name}</span> <span className="truncate">{account.name}</span>
</Link> </Link>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <div className={styles.meta}>
<span className="truncate">{account.accountType}</span> <span className="truncate">{account.accountType}</span>
{account.excludeFromBalance ? ( {account.excludeFromBalance ? (
<Tooltip> <Tooltip>
@@ -195,10 +193,10 @@ export function MyAccountsWidget({
</div> </div>
</div> </div>
<div className="flex flex-col items-end gap-0.5 text-right"> <div className={styles.trailing}>
<MoneyValues <MoneyValues
className={cn( className={cn(
"font-medium", styles.trailingValue,
account.balance < 0 && "text-destructive", account.balance < 0 && "text-destructive",
)} )}
amount={account.balance} amount={account.balance}

View File

@@ -2,6 +2,7 @@
import { RiGroupLine, RiVerifiedBadgeFill } from "@remixicon/react"; import { RiGroupLine, RiVerifiedBadgeFill } from "@remixicon/react";
import Link from "next/link"; 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 { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
import type { DashboardPagador } from "@/features/dashboard/lib/payers-queries"; import type { DashboardPagador } from "@/features/dashboard/lib/payers-queries";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
@@ -44,14 +45,9 @@ export function PayersWidget({ payers }: PayersWidgetProps) {
: null; : null;
return ( return (
<div <div key={payer.id} className={styles.row}>
key={payer.id} <span className={styles.rank}>{index + 1}</span>
className="flex items-center justify-between gap-2 transition-all duration-300 py-1.5" <div className={styles.main}>
>
<span className="w-3 shrink-0 text-left text-xs font-medium text-muted-foreground">
{index + 1}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 py-1">
<Avatar className="size-9.5 shrink-0"> <Avatar className="size-9.5 shrink-0">
<AvatarImage <AvatarImage
src={getAvatarSrc(payer.avatarUrl)} src={getAvatarSrc(payer.avatarUrl)}
@@ -60,11 +56,11 @@ export function PayersWidget({ payers }: PayersWidgetProps) {
<AvatarFallback>{initials}</AvatarFallback> <AvatarFallback>{initials}</AvatarFallback>
</Avatar> </Avatar>
<div className="min-w-0"> <div className={styles.textStack}>
<Link <Link
prefetch prefetch
href={`/payers/${payer.id}`} href={`/payers/${payer.id}`}
className="inline-flex max-w-full items-center gap-1 text-sm text-foreground underline-offset-2 hover:text-primary hover:underline" className={styles.titleLink}
> >
<span className="truncate font-medium">{payer.name}</span> <span className="truncate font-medium">{payer.name}</span>
{payer.isAdmin && ( {payer.isAdmin && (
@@ -84,18 +80,18 @@ export function PayersWidget({ payers }: PayersWidgetProps) {
</Tooltip> </Tooltip>
)} )}
</Link> </Link>
<p className="truncate text-xs text-muted-foreground"> <p className={styles.meta}>Despesas no período</p>
Despesas no período
</p>
</div> </div>
</div> </div>
<div className="flex shrink-0 flex-col items-end"> <div className={styles.trailing}>
<MoneyValues <MoneyValues
className="font-medium" className={styles.trailingValue}
amount={payer.totalExpenses} amount={payer.totalExpenses}
/> />
<div className="flex items-center gap-1 text-xs text-muted-foreground"> <div
className={`${styles.trailingMeta} text-muted-foreground`}
>
<PercentageChangeIndicator value={percentageChange} /> <PercentageChangeIndicator value={percentageChange} />
{percentageChange !== null ? ( {percentageChange !== null ? (
<span>vs. mês ant.</span> <span>vs. mês ant.</span>

View File

@@ -3,6 +3,7 @@
import { RiFileList2Line, RiStore3Line } from "@remixicon/react"; import { RiFileList2Line, RiStore3Line } from "@remixicon/react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { PurchasesByCategoryData } from "@/features/dashboard/categories/purchases-by-category-queries"; import type { PurchasesByCategoryData } from "@/features/dashboard/categories/purchases-by-category-queries";
import { dashboardWidgetListStyles as styles } from "@/features/dashboard/components/dashboard-widget-list-styles";
import { EstablishmentLogo } from "@/shared/components/entity-avatar"; import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
import { import {
@@ -162,26 +163,21 @@ export function PurchasesByCategoryWidget({
<div className="flex flex-col"> <div className="flex flex-col">
{currentTransactions.map((transaction) => { {currentTransactions.map((transaction) => {
return ( return (
<div <div key={transaction.id} className={styles.row}>
key={transaction.id} <div className={styles.main}>
className="flex items-center justify-between gap-2 transition-all duration-300 py-1.5"
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<EstablishmentLogo name={transaction.name} size={37} /> <EstablishmentLogo name={transaction.name} size={37} />
<div className="min-w-0"> <div className={styles.textStack}>
<p className="truncate text-sm font-medium text-foreground"> <p className={styles.title}>{transaction.name}</p>
{transaction.name} <p className={styles.meta}>
</p>
<p className="text-xs text-muted-foreground">
{formatTransactionDate(transaction.purchaseDate)} {formatTransactionDate(transaction.purchaseDate)}
</p> </p>
</div> </div>
</div> </div>
<div className="shrink-0 text-foreground"> <div className={styles.trailing}>
<MoneyValues <MoneyValues
className="font-medium" className={styles.trailingValue}
amount={transaction.amount} amount={transaction.amount}
/> />
</div> </div>

View File

@@ -1,4 +1,5 @@
import { RiRefreshLine } from "@remixicon/react"; import { RiRefreshLine } from "@remixicon/react";
import { dashboardWidgetListStyles as styles } from "@/features/dashboard/components/dashboard-widget-list-styles";
import type { RecurringExpensesData } from "@/features/dashboard/expenses/recurring-expenses-queries"; import type { RecurringExpensesData } from "@/features/dashboard/expenses/recurring-expenses-queries";
import { EstablishmentLogo } from "@/shared/components/entity-avatar"; import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
@@ -36,26 +37,14 @@ export function RecurringExpensesWidget({
.sort((a, b) => b.amount - a.amount) .sort((a, b) => b.amount - a.amount)
.map((expense) => { .map((expense) => {
return ( return (
<div <div key={expense.id} className={styles.row}>
key={expense.id} <div className={styles.main}>
className="flex items-center gap-2 transition-all duration-300 py-1.5"
>
<EstablishmentLogo name={expense.name} size={37} /> <EstablishmentLogo name={expense.name} size={37} />
<div className="min-w-0 flex-1"> <div className={styles.textStack}>
<div className="flex items-center justify-between"> <p className={styles.title}>{expense.name}</p>
<p className="truncate text-foreground text-sm font-medium"> <div className={styles.meta}>
{expense.name} <span className="inline-flex min-w-0 items-center gap-1 [&_svg]:size-3.5">
</p>
<MoneyValues
className="font-medium"
amount={expense.amount}
/>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1 [&_svg]:size-3.5">
{getPaymentMethodIcon(expense.paymentMethod)} {getPaymentMethodIcon(expense.paymentMethod)}
{expense.paymentMethod} {expense.paymentMethod}
</span> </span>
@@ -63,6 +52,14 @@ export function RecurringExpensesWidget({
</div> </div>
</div> </div>
</div> </div>
<div className={styles.trailing}>
<MoneyValues
className={styles.trailingValue}
amount={expense.amount}
/>
</div>
</div>
); );
})} })}
</div> </div>

View File

@@ -1,4 +1,5 @@
import { RiStore2Line } from "@remixicon/react"; import { RiStore2Line } from "@remixicon/react";
import { dashboardWidgetListStyles as styles } from "@/features/dashboard/components/dashboard-widget-list-styles";
import type { TopEstablishmentsData } from "@/features/dashboard/lib/top-establishments-queries"; import type { TopEstablishmentsData } from "@/features/dashboard/lib/top-establishments-queries";
import { EstablishmentLogo } from "@/shared/components/entity-avatar"; import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
@@ -30,30 +31,23 @@ export function TopEstablishmentsWidget({
<div className="flex flex-col"> <div className="flex flex-col">
{data.establishments.map((establishment, index) => { {data.establishments.map((establishment, index) => {
return ( return (
<div <div key={establishment.id} className={styles.row}>
key={establishment.id} <span className={styles.rank}>{index + 1}</span>
className="flex items-center justify-between gap-2 transition-all duration-300 py-1.5" <div className={styles.main}>
>
<span className="w-3 shrink-0 text-left text-xs font-medium text-muted-foreground">
{index + 1}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2">
<EstablishmentLogo name={establishment.name} size={37} /> <EstablishmentLogo name={establishment.name} size={37} />
<div className="min-w-0"> <div className={styles.textStack}>
<p className="truncate text-sm font-medium text-foreground"> <p className={styles.title}>{establishment.name}</p>
{establishment.name} <p className={styles.meta}>
</p>
<p className="text-xs text-muted-foreground">
{formatOccurrencesLabel(establishment.occurrences)} · {formatOccurrencesLabel(establishment.occurrences)} ·
total acumulado total acumulado
</p> </p>
</div> </div>
</div> </div>
<div className="shrink-0 text-foreground"> <div className={styles.trailing}>
<MoneyValues <MoneyValues
className="font-medium" className={styles.trailingValue}
amount={establishment.amount} amount={establishment.amount}
/> />
</div> </div>

View File

@@ -2,6 +2,7 @@
import { RiArrowUpDoubleLine } from "@remixicon/react"; import { RiArrowUpDoubleLine } from "@remixicon/react";
import { useMemo } from "react"; import { useMemo } from "react";
import { dashboardWidgetListStyles as styles } from "@/features/dashboard/components/dashboard-widget-list-styles";
import type { import type {
TopExpense, TopExpense,
TopExpensesData, TopExpensesData,
@@ -49,29 +50,22 @@ export function TopExpensesWidget({ data }: TopExpensesWidgetProps) {
<div className="flex flex-col"> <div className="flex flex-col">
{expenses.map((expense, index) => { {expenses.map((expense, index) => {
return ( return (
<div <div key={expense.id} className={styles.row}>
key={expense.id} <span className={styles.rank}>{index + 1}</span>
className="flex items-center justify-between gap-2 transition-all duration-300 py-1.5" <div className={styles.main}>
>
<span className="w-3 shrink-0 text-left text-xs font-medium text-muted-foreground">
{index + 1}
</span>
<div className="flex min-w-0 flex-1 items-center gap-2">
<EstablishmentLogo name={expense.name} size={37} /> <EstablishmentLogo name={expense.name} size={37} />
<div className="min-w-0"> <div className={styles.textStack}>
<p className="truncate text-sm font-medium text-foreground"> <p className={styles.title}>{expense.name}</p>
{expense.name} <p className={styles.meta}>
</p>
<p className="text-xs text-muted-foreground">
{formatTransactionDate(expense.purchaseDate)} {formatTransactionDate(expense.purchaseDate)}
</p> </p>
</div> </div>
</div> </div>
<div className="shrink-0 text-foreground"> <div className={styles.trailing}>
<MoneyValues <MoneyValues
className="font-medium" className={styles.trailingValue}
amount={expense.amount} amount={expense.amount}
/> />
</div> </div>

View File

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

View File

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

View File

@@ -206,32 +206,32 @@ export function InvoiceSummaryCard({
}; };
return ( 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"> <CardContent className="px-4 py-4 sm:px-5 sm:py-5">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Linha 1 — identidade */} {/* Linha 1 — identidade */}
<div className="flex items-center justify-between 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-center gap-3"> <div className="flex min-w-0 items-start gap-3">
{logoPath ? ( {logoPath ? (
<div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full"> <div className="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full">
<Image <Image
src={logoPath} src={logoPath}
alt={`Logo ${cardName}`} alt={`Logo ${cardName}`}
width={42} width={48}
height={42} height={48}
className="h-full w-full object-contain" className="h-full w-full object-contain"
/> />
</div> </div>
) : cardBrand ? ( ) : cardBrand ? (
<span className="flex size-10 shrink-0 items-center justify-center rounded-full border bg-background text-xs font-medium text-muted-foreground"> <span className="flex size-12 shrink-0 items-center justify-center rounded-full border bg-card text-sm font-semibold text-primary">
{cardBrand.slice(0, 2).toUpperCase()} {cardBrand.slice(0, 2).toUpperCase()}
</span> </span>
) : null} ) : null}
<div className="min-w-0"> <div className="min-w-0 space-y-1">
<h2 className="truncate text-sm font-semibold text-foreground"> <h2 className="truncate text-xl font-semibold text-foreground sm:text-2xl">
{cardName} {cardName}
</h2> </h2>
<p className="text-xs text-muted-foreground"> <p className="text-sm leading-relaxed text-muted-foreground">
Fatura de {periodLabel} Fatura de {periodLabel}
</p> </p>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,8 @@ export {
} from "./actions/bulk-actions"; } from "./actions/bulk-actions";
export { exportTransactionsDataAction } from "./actions/export-actions"; export { exportTransactionsDataAction } from "./actions/export-actions";
export { export {
convertTransactionToInstallmentAction,
convertTransactionToRecurringAction,
createTransactionAction, createTransactionAction,
deleteTransactionAction, deleteTransactionAction,
toggleTransactionSettlementAction, toggleTransactionSettlementAction,

View File

@@ -513,11 +513,33 @@ export const toggleSettlementSchema = z.object({
.optional(), .optional(),
}); });
export const convertToInstallmentSchema = z.object({
id: uuidSchema("Lançamento"),
installmentCount: z.coerce
.number({ message: "Informe em quantas parcelas dividir." })
.int()
.min(2, "O parcelamento deve ter ao menos duas parcelas.")
.max(60, "Selecione até 60 parcelas."),
});
export const convertToRecurringSchema = z.object({
id: uuidSchema("Lançamento"),
recurrenceCount: z.coerce
.number({ message: "Informe por quantos meses repetir." })
.int()
.min(2, "A recorrência deve ter ao menos dois meses.")
.max(60, "Selecione até 60 meses."),
});
type BaseInput = z.infer<typeof baseFields>; type BaseInput = z.infer<typeof baseFields>;
export type CreateInput = z.infer<typeof createSchema>; export type CreateInput = z.infer<typeof createSchema>;
export type UpdateInput = z.infer<typeof updateSchema>; export type UpdateInput = z.infer<typeof updateSchema>;
export type DeleteInput = z.infer<typeof deleteSchema>; export type DeleteInput = z.infer<typeof deleteSchema>;
export type ToggleSettlementInput = z.infer<typeof toggleSettlementSchema>; export type ToggleSettlementInput = z.infer<typeof toggleSettlementSchema>;
export type ConvertToInstallmentInput = z.infer<
typeof convertToInstallmentSchema
>;
export type ConvertToRecurringInput = z.infer<typeof convertToRecurringSchema>;
export const revalidate = (userId: string) => export const revalidate = (userId: string) =>
revalidateForEntity("transactions", userId); revalidateForEntity("transactions", userId);

View File

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

View File

@@ -23,12 +23,17 @@ import {
parseLocalDateString, parseLocalDateString,
} from "@/shared/utils/date"; } from "@/shared/utils/date";
import { copyAttachmentsForImport } from "../lib/attachment-copy"; import { copyAttachmentsForImport } from "../lib/attachment-copy";
import { detectInstallmentFromName } from "../lib/installment-detection";
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments"; import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
import { import {
buildShares, buildShares,
buildTransactionRecords, buildTransactionRecords,
type ConvertToInstallmentInput,
type ConvertToRecurringInput,
type CreateInput, type CreateInput,
centsToDecimalString, centsToDecimalString,
convertToInstallmentSchema,
convertToRecurringSchema,
createSchema, createSchema,
type DeleteInput, type DeleteInput,
deleteSchema, deleteSchema,
@@ -471,6 +476,357 @@ export async function deleteTransactionAction(
} }
} }
export async function convertTransactionToInstallmentAction(
input: ConvertToInstallmentInput,
): Promise<ActionResult<{ createdCount: number }>> {
try {
const user = await getUser();
const data = convertToInstallmentSchema.parse(input);
const existing = await db.query.transactions.findFirst({
where: and(
eq(transactions.id, data.id),
eq(transactions.userId, user.id),
),
});
if (!existing) {
return { success: false, error: "Lançamento não encontrado." };
}
if (existing.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
return {
success: false,
error: "Pagamentos automáticos de fatura não podem ser convertidos.",
};
}
if (isInitialBalanceTransaction(existing)) {
return {
success: false,
error: "Lançamentos de saldo inicial não podem ser convertidos.",
};
}
if (
existing.paymentMethod !== "Cartão de crédito" ||
!existing.cardId ||
existing.condition !== "À vista"
) {
return {
success: false,
error:
"Apenas lançamentos à vista de cartão de crédito podem ser convertidos.",
};
}
if (existing.splitGroupId || existing.isDivided) {
return {
success: false,
error:
"Lançamentos divididos ainda não podem ser convertidos em parcelamento.",
};
}
const detected = detectInstallmentFromName(existing.name);
const transactionName =
detected?.installmentCount === data.installmentCount
? detected.name
: existing.name;
const amountSign: 1 | -1 = existing.transactionType === "Despesa" ? -1 : 1;
const totalCents = Math.round(Math.abs(Number(existing.amount)) * 100);
const seriesId = randomUUID();
const records = buildTransactionRecords({
data: {
purchaseDate: existing.purchaseDate.toISOString().slice(0, 10),
period: existing.period,
name: transactionName,
transactionType: existing.transactionType as "Receita" | "Despesa",
amount: totalCents / 100,
condition: "Parcelado",
paymentMethod: "Cartão de crédito",
payerId: existing.payerId,
isSplit: false,
accountId: null,
cardId: existing.cardId,
categoryId: existing.categoryId,
note: existing.note,
installmentCount: data.installmentCount,
startInstallment: 1,
dueDate: existing.dueDate?.toISOString().slice(0, 10),
isSettled: null,
},
userId: user.id,
period: existing.period,
purchaseDate: existing.purchaseDate,
dueDate: existing.dueDate,
boletoPaymentDate: null,
shares: [{ payerId: existing.payerId, amountCents: totalCents }],
amountSign,
shouldNullifySettled: true,
seriesId,
}).map((record) => ({
...record,
importBatchId: existing.importBatchId,
}));
const currentRow = records[0];
const rowsToInsert = records.slice(1);
if (!currentRow) {
throw new Error("Não foi possível montar o parcelamento.");
}
const periodsToUpdate = records
.map((row) => row.period)
.filter((period): period is string => Boolean(period));
const paidPeriods = await getPaidInvoicePeriods(
user.id,
existing.cardId,
periodsToUpdate,
);
if (paidPeriods.length > 0) {
return {
success: false,
error: `As faturas dos meses ${formatPaidInvoicePeriods(
paidPeriods,
)} já estão pagas. Desfaça o pagamento antes de converter este lançamento.`,
};
}
if (existing.transactionType === "Despesa") {
const limitCheck = await validateCardLimit({
userId: user.id,
cardId: existing.cardId,
addAmount: records.reduce(
(acc, row) => acc + Math.abs(Number(row.amount)),
0,
),
excludeTransactionIds: [existing.id],
});
if (!limitCheck.ok) {
return { success: false, error: limitCheck.error };
}
}
await db.transaction(async (tx: typeof db) => {
await tx
.update(transactions)
.set({
condition: currentRow.condition,
name: currentRow.name,
amount: currentRow.amount,
installmentCount: currentRow.installmentCount,
currentInstallment: currentRow.currentInstallment,
recurrenceCount: null,
period: currentRow.period,
dueDate: currentRow.dueDate,
isSettled: null,
seriesId,
})
.where(
and(
eq(transactions.id, existing.id),
eq(transactions.userId, user.id),
),
);
if (rowsToInsert.length > 0) {
await tx.insert(transactions).values(rowsToInsert);
}
});
revalidate(user.id);
return {
success: true,
message: `Lançamento convertido em ${data.installmentCount} parcelas.`,
data: { createdCount: rowsToInsert.length },
};
} catch (error) {
return handleActionError(error) as ActionResult<{ createdCount: number }>;
}
}
export async function convertTransactionToRecurringAction(
input: ConvertToRecurringInput,
): Promise<ActionResult<{ createdCount: number }>> {
try {
const user = await getUser();
const data = convertToRecurringSchema.parse(input);
const existing = await db.query.transactions.findFirst({
where: and(
eq(transactions.id, data.id),
eq(transactions.userId, user.id),
),
});
if (!existing) {
return { success: false, error: "Lançamento não encontrado." };
}
if (existing.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
return {
success: false,
error: "Pagamentos automáticos de fatura não podem ser convertidos.",
};
}
if (isInitialBalanceTransaction(existing)) {
return {
success: false,
error: "Lançamentos de saldo inicial não podem ser convertidos.",
};
}
if (existing.condition !== "À vista") {
return {
success: false,
error:
"Apenas lançamentos à vista podem ser convertidos em recorrência.",
};
}
if (existing.splitGroupId || existing.isDivided) {
return {
success: false,
error:
"Lançamentos divididos ainda não podem ser convertidos em recorrência.",
};
}
const amountSign: 1 | -1 = existing.transactionType === "Despesa" ? -1 : 1;
const totalCents = Math.round(Math.abs(Number(existing.amount)) * 100);
const seriesId = randomUUID();
const isCreditCard = existing.paymentMethod === "Cartão de crédito";
const records = buildTransactionRecords({
data: {
purchaseDate: existing.purchaseDate.toISOString().slice(0, 10),
period: existing.period,
name: existing.name,
transactionType: existing.transactionType as "Receita" | "Despesa",
amount: totalCents / 100,
condition: "Recorrente",
paymentMethod: existing.paymentMethod as
| "Pix"
| "Boleto"
| "Dinheiro"
| "Cartão de débito"
| "Cartão de crédito"
| "Pré-Pago | VR/VA"
| "Transferência bancária",
payerId: existing.payerId,
isSplit: false,
accountId: isCreditCard ? null : existing.accountId,
cardId: isCreditCard ? existing.cardId : null,
categoryId: existing.categoryId,
note: existing.note,
recurrenceCount: data.recurrenceCount,
dueDate: existing.dueDate?.toISOString().slice(0, 10),
boletoPaymentDate: existing.boletoPaymentDate
?.toISOString()
.slice(0, 10),
isSettled: existing.isSettled,
},
userId: user.id,
period: existing.period,
purchaseDate: existing.purchaseDate,
dueDate: existing.dueDate,
boletoPaymentDate: existing.boletoPaymentDate,
shares: [{ payerId: existing.payerId, amountCents: totalCents }],
amountSign,
shouldNullifySettled: isCreditCard,
seriesId,
}).map((record) => ({
...record,
importBatchId: existing.importBatchId,
}));
const currentRow = records[0];
const rowsToInsert = records.slice(1);
if (!currentRow) {
throw new Error("Não foi possível montar a recorrência.");
}
if (isCreditCard && existing.cardId) {
const periodsToUpdate = records
.map((row) => row.period)
.filter((period): period is string => Boolean(period));
const paidPeriods = await getPaidInvoicePeriods(
user.id,
existing.cardId,
periodsToUpdate,
);
if (paidPeriods.length > 0) {
return {
success: false,
error: `As faturas dos meses ${formatPaidInvoicePeriods(
paidPeriods,
)} já estão pagas. Desfaça o pagamento antes de converter este lançamento.`,
};
}
if (existing.transactionType === "Despesa") {
const limitCheck = await validateCardLimit({
userId: user.id,
cardId: existing.cardId,
addAmount: records.reduce(
(acc, row) => acc + Math.abs(Number(row.amount)),
0,
),
excludeTransactionIds: [existing.id],
});
if (!limitCheck.ok) {
return { success: false, error: limitCheck.error };
}
}
}
await db.transaction(async (tx: typeof db) => {
await tx
.update(transactions)
.set({
condition: currentRow.condition,
name: currentRow.name,
amount: currentRow.amount,
recurrenceCount: currentRow.recurrenceCount,
installmentCount: null,
currentInstallment: null,
period: currentRow.period,
purchaseDate: currentRow.purchaseDate,
dueDate: currentRow.dueDate,
isSettled: currentRow.isSettled,
boletoPaymentDate: currentRow.boletoPaymentDate,
seriesId,
})
.where(
and(
eq(transactions.id, existing.id),
eq(transactions.userId, user.id),
),
);
if (rowsToInsert.length > 0) {
await tx.insert(transactions).values(rowsToInsert);
}
});
revalidate(user.id);
return {
success: true,
message: `Lançamento convertido em recorrência de ${data.recurrenceCount} meses.`,
data: { createdCount: rowsToInsert.length },
};
} catch (error) {
return handleActionError(error) as ActionResult<{ createdCount: number }>;
}
}
export async function updateTransactionSplitPairAction( export async function updateTransactionSplitPairAction(
input: UpdateInput, input: UpdateInput,
): Promise<ActionResult> { ): Promise<ActionResult> {

View File

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

View File

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

View File

@@ -49,6 +49,8 @@ const categoryGroupByTransactionType = {
income: "receita", income: "receita",
} as const; } as const;
const normalizeCategoryName = (value: string) => value.trim().toLowerCase();
interface ImportPageProps { interface ImportPageProps {
payerOptions: SelectOption[]; payerOptions: SelectOption[];
accountOptions: SelectOption[]; accountOptions: SelectOption[];
@@ -108,9 +110,19 @@ export function ImportPage({
setRows( setRows(
stmt.transactions.map((t) => { stmt.transactions.map((t) => {
const mappedCategoryId = let mappedCategoryId =
categoryMappings[normalizeDescriptionKey(t.description)] ?? null; categoryMappings[normalizeDescriptionKey(t.description)] ?? null;
if (t.categoryRaw) {
const categoryRaw = normalizeCategoryName(t.categoryRaw);
const matchedOption = categoryOptions.find(
(opt) => normalizeCategoryName(opt.label) === categoryRaw,
);
if (matchedOption) {
mappedCategoryId = matchedOption.value;
}
}
return { return {
...t, ...t,
isDuplicate: t.externalId ? duplicates.has(t.externalId) : false, isDuplicate: t.externalId ? duplicates.has(t.externalId) : false,
@@ -129,7 +141,7 @@ export function ImportPage({
setIsChecking(false); setIsChecking(false);
} }
}, },
[isCategoryCompatible, payerId], [isCategoryCompatible, payerId, categoryOptions],
); );
// Pré-seleciona cartão ou conta com base no tipo detectado no OFX // Pré-seleciona cartão ou conta com base no tipo detectado no OFX

View File

@@ -4,6 +4,8 @@ import { RiAddFill } from "@remixicon/react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
convertTransactionToInstallmentAction,
convertTransactionToRecurringAction,
createMassTransactionsAction, createMassTransactionsAction,
deleteMultipleTransactionsAction, deleteMultipleTransactionsAction,
deleteTransactionAction, deleteTransactionAction,
@@ -18,8 +20,20 @@ import {
detachAttachmentBulkAction, detachAttachmentBulkAction,
getPresignedUploadUrlAction, getPresignedUploadUrlAction,
} from "@/features/transactions/actions/attachments"; } from "@/features/transactions/actions/attachments";
import { detectInstallmentFromName } from "@/features/transactions/lib/installment-detection";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog"; import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { formatCurrency } from "@/shared/utils/currency";
import type { import type {
TransactionsExportContext, TransactionsExportContext,
TransactionsPaginationState, TransactionsPaginationState,
@@ -68,6 +82,7 @@ interface TransactionsPageProps {
allowCreate?: boolean; allowCreate?: boolean;
noteAsColumn?: boolean; noteAsColumn?: boolean;
columnOrder?: string[] | null; columnOrder?: string[] | null;
groupTransactionsByDate?: boolean;
defaultCardId?: string | null; defaultCardId?: string | null;
defaultPaymentMethod?: string | null; defaultPaymentMethod?: string | null;
lockCardSelection?: boolean; lockCardSelection?: boolean;
@@ -84,6 +99,9 @@ interface TransactionsPageProps {
importCategoryOptions?: SelectOption[]; importCategoryOptions?: SelectOption[];
} }
const pluralize = (count: number, singular: string, plural: string) =>
count === 1 ? singular : plural;
export function TransactionsPage({ export function TransactionsPage({
currentUserId, currentUserId,
transactions: transactionList, transactions: transactionList,
@@ -102,6 +120,7 @@ export function TransactionsPage({
allowCreate = true, allowCreate = true,
noteAsColumn = false, noteAsColumn = false,
columnOrder = null, columnOrder = null,
groupTransactionsByDate = true,
defaultCardId, defaultCardId,
defaultPaymentMethod, defaultPaymentMethod,
lockCardSelection, lockCardSelection,
@@ -190,6 +209,16 @@ export function TransactionsPage({
const [refundOpen, setRefundOpen] = useState(false); const [refundOpen, setRefundOpen] = useState(false);
const [transactionToRefund, setTransactionToRefund] = const [transactionToRefund, setTransactionToRefund] =
useState<TransactionItem | null>(null); useState<TransactionItem | null>(null);
const [convertInstallmentOpen, setConvertInstallmentOpen] = useState(false);
const [transactionToConvert, setTransactionToConvert] =
useState<TransactionItem | null>(null);
const [installmentCount, setInstallmentCount] = useState("2");
const [installmentPending, setInstallmentPending] = useState(false);
const [convertRecurringOpen, setConvertRecurringOpen] = useState(false);
const [transactionToConvertRecurring, setTransactionToConvertRecurring] =
useState<TransactionItem | null>(null);
const [recurrenceCount, setRecurrenceCount] = useState("12");
const [recurrencePending, setRecurrencePending] = useState(false);
const handleToggleSettlement = async (item: TransactionItem) => { const handleToggleSettlement = async (item: TransactionItem) => {
if (item.paymentMethod === "Cartão de crédito") { if (item.paymentMethod === "Cartão de crédito") {
@@ -542,6 +571,112 @@ export function TransactionsPage({
setRefundOpen(true); setRefundOpen(true);
}; };
const handleConvertToInstallment = (item: TransactionItem) => {
const detectedInstallment = detectInstallmentFromName(item.name);
setTransactionToConvert(item);
setInstallmentCount(String(detectedInstallment?.installmentCount ?? 2));
setConvertInstallmentOpen(true);
};
const confirmConvertToInstallment = async () => {
if (!transactionToConvert) {
return;
}
const count = Number(installmentCount);
if (!Number.isInteger(count) || count < 2 || count > 60) {
toast.error("Informe um parcelamento entre 2 e 60 parcelas.");
return;
}
try {
setInstallmentPending(true);
const result = await convertTransactionToInstallmentAction({
id: transactionToConvert.id,
installmentCount: count,
});
if (!result.success) {
toast.error(result.error);
return;
}
toast.success(result.message);
setConvertInstallmentOpen(false);
setTransactionToConvert(null);
} finally {
setInstallmentPending(false);
}
};
const handleConvertToRecurring = (item: TransactionItem) => {
setTransactionToConvertRecurring(item);
setRecurrenceCount("12");
setConvertRecurringOpen(true);
};
const confirmConvertToRecurring = async () => {
if (!transactionToConvertRecurring) {
return;
}
const count = Number(recurrenceCount);
if (!Number.isInteger(count) || count < 2 || count > 60) {
toast.error("Informe uma recorrência entre 2 e 60 meses.");
return;
}
try {
setRecurrencePending(true);
const result = await convertTransactionToRecurringAction({
id: transactionToConvertRecurring.id,
recurrenceCount: count,
});
if (!result.success) {
toast.error(result.error);
return;
}
toast.success(result.message);
setConvertRecurringOpen(false);
setTransactionToConvertRecurring(null);
} finally {
setRecurrencePending(false);
}
};
const parsedInstallmentCount = Number(installmentCount);
const installmentSummary =
transactionToConvert &&
Number.isInteger(parsedInstallmentCount) &&
parsedInstallmentCount >= 2 &&
parsedInstallmentCount <= 60
? {
total: formatCurrency(Math.abs(transactionToConvert.amount)),
installmentValue: formatCurrency(
Math.abs(transactionToConvert.amount) / parsedInstallmentCount,
),
count: parsedInstallmentCount,
createdCount: parsedInstallmentCount - 1,
}
: null;
const parsedRecurrenceCount = Number(recurrenceCount);
const recurringSummary =
transactionToConvertRecurring &&
Number.isInteger(parsedRecurrenceCount) &&
parsedRecurrenceCount >= 2 &&
parsedRecurrenceCount <= 60
? {
amount: formatCurrency(
Math.abs(transactionToConvertRecurring.amount),
),
count: parsedRecurrenceCount,
createdCount: parsedRecurrenceCount - 1,
}
: null;
const handleAnticipate = (item: TransactionItem) => { const handleAnticipate = (item: TransactionItem) => {
setSelectedForAnticipation(item); setSelectedForAnticipation(item);
setAnticipateOpen(true); setAnticipateOpen(true);
@@ -612,6 +747,7 @@ export function TransactionsPage({
currentUserId={currentUserId} currentUserId={currentUserId}
noteAsColumn={noteAsColumn} noteAsColumn={noteAsColumn}
columnOrder={columnOrder} columnOrder={columnOrder}
groupTransactionsByDate={groupTransactionsByDate}
payerFilterOptions={payerFilterOptions} payerFilterOptions={payerFilterOptions}
categoryFilterOptions={categoryFilterOptions} categoryFilterOptions={categoryFilterOptions}
accountCardFilterOptions={accountCardFilterOptions} accountCardFilterOptions={accountCardFilterOptions}
@@ -628,6 +764,8 @@ export function TransactionsPage({
onBulkImport={handleBulkImport} onBulkImport={handleBulkImport}
onViewDetails={handleViewDetails} onViewDetails={handleViewDetails}
onRefund={handleRefund} onRefund={handleRefund}
onConvertToInstallment={handleConvertToInstallment}
onConvertToRecurring={handleConvertToRecurring}
onToggleSettlement={handleToggleSettlement} onToggleSettlement={handleToggleSettlement}
onAnticipate={handleAnticipate} onAnticipate={handleAnticipate}
onViewAnticipationHistory={handleViewAnticipationHistory} onViewAnticipationHistory={handleViewAnticipationHistory}
@@ -749,6 +887,138 @@ export function TransactionsPage({
disabled={!transactionToDelete} disabled={!transactionToDelete}
/> />
<Dialog
open={convertInstallmentOpen && !!transactionToConvert}
onOpenChange={(open) => {
setConvertInstallmentOpen(open);
if (!open) {
setTransactionToConvert(null);
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Converter em parcelamento?</DialogTitle>
<DialogDescription>
O lançamento atual será mantido como a primeira parcela e os
próximos meses serão criados automaticamente.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="installmentCount">Parcelar em</Label>
<Input
id="installmentCount"
type="number"
min={2}
max={60}
value={installmentCount}
onChange={(event) => setInstallmentCount(event.target.value)}
/>
<p className="text-muted-foreground text-sm">
Use o total de parcelas da série, incluindo este lançamento.
</p>
{installmentSummary ? (
<p className="rounded-md border bg-muted/40 px-3 py-2 text-muted-foreground text-sm">
Resumo: {installmentSummary.total} será dividido em{" "}
{installmentSummary.count} parcelas de aproximadamente{" "}
{installmentSummary.installmentValue}. Este lançamento vira a
primeira parcela e {installmentSummary.createdCount}{" "}
{pluralize(
installmentSummary.createdCount,
"nova parcela será criada",
"novas parcelas serão criadas",
)}
.
</p>
) : null}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setConvertInstallmentOpen(false)}
disabled={installmentPending}
>
Cancelar
</Button>
<Button
type="button"
onClick={confirmConvertToInstallment}
disabled={installmentPending}
>
{installmentPending ? "Convertendo..." : "Converter"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={convertRecurringOpen && !!transactionToConvertRecurring}
onOpenChange={(open) => {
setConvertRecurringOpen(open);
if (!open) {
setTransactionToConvertRecurring(null);
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Converter em recorrente?</DialogTitle>
<DialogDescription>
O lançamento atual será mantido como a primeira recorrência e os
próximos meses serão criados automaticamente.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="recurrenceCount">Repetir por</Label>
<Input
id="recurrenceCount"
type="number"
min={2}
max={60}
value={recurrenceCount}
onChange={(event) => setRecurrenceCount(event.target.value)}
/>
<p className="text-muted-foreground text-sm">
Use o total de meses da série, incluindo este lançamento.
</p>
{recurringSummary ? (
<p className="rounded-md border bg-muted/40 px-3 py-2 text-muted-foreground text-sm">
Resumo: este lançamento vira a primeira recorrência e{" "}
{recurringSummary.createdCount}{" "}
{pluralize(
recurringSummary.createdCount,
"novo lançamento mensal será criado",
"novos lançamentos mensais serão criados",
)}{" "}
com valor de {recurringSummary.amount}.
</p>
) : null}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setConvertRecurringOpen(false)}
disabled={recurrencePending}
>
Cancelar
</Button>
<Button
type="button"
onClick={confirmConvertToRecurring}
disabled={recurrencePending}
>
{recurrencePending ? "Convertendo..." : "Converter"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<BulkActionDialog <BulkActionDialog
open={bulkDeleteOpen && !!pendingDeleteData} open={bulkDeleteOpen && !!pendingDeleteData}
onOpenChange={setBulkDeleteOpen} onOpenChange={setBulkDeleteOpen}

View File

@@ -11,6 +11,7 @@ import {
RiRefundLine, RiRefundLine,
RiTimeLine, RiTimeLine,
} from "@remixicon/react"; } from "@remixicon/react";
import { CREDIT_CARD_PAYMENT_METHOD } from "@/features/transactions/lib/constants";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@@ -20,6 +21,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"; } from "@/shared/components/ui/dropdown-menu";
import { REFUND_NOTE_PREFIX } from "@/shared/lib/accounts/constants"; import { REFUND_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { getConditionIcon } from "@/shared/utils/icons";
import type { TransactionItem } from "../types"; import type { TransactionItem } from "../types";
type TransactionActionsMenuProps = { type TransactionActionsMenuProps = {
@@ -33,6 +35,8 @@ type TransactionActionsMenuProps = {
onRefund?: (item: TransactionItem) => void; onRefund?: (item: TransactionItem) => void;
onAnticipate?: (item: TransactionItem) => void; onAnticipate?: (item: TransactionItem) => void;
onViewAnticipationHistory?: (item: TransactionItem) => void; onViewAnticipationHistory?: (item: TransactionItem) => void;
onConvertToInstallment?: (item: TransactionItem) => void;
onConvertToRecurring?: (item: TransactionItem) => void;
}; };
export function TransactionActionsMenu({ export function TransactionActionsMenu({
@@ -46,6 +50,8 @@ export function TransactionActionsMenu({
onRefund, onRefund,
onAnticipate, onAnticipate,
onViewAnticipationHistory, onViewAnticipationHistory,
onConvertToInstallment,
onConvertToRecurring,
}: TransactionActionsMenuProps) { }: TransactionActionsMenuProps) {
const isOwnData = item.userId === currentUserId; const isOwnData = item.userId === currentUserId;
const canRefund = const canRefund =
@@ -55,9 +61,27 @@ export function TransactionActionsMenu({
!item.splitGroupId && !item.splitGroupId &&
!item.readonly && !item.readonly &&
!item.note?.startsWith(REFUND_NOTE_PREFIX); !item.note?.startsWith(REFUND_NOTE_PREFIX);
const showInstallmentActions = const showInstallmentActions =
isOwnData && item.condition === "Parcelado" && item.seriesId; isOwnData && item.condition === "Parcelado" && item.seriesId;
const canConvertToInstallment =
isOwnData &&
item.paymentMethod === CREDIT_CARD_PAYMENT_METHOD &&
item.condition === "À vista" &&
!item.splitGroupId &&
!item.isDivided &&
!item.readonly &&
Boolean(onConvertToInstallment);
const canConvertToRecurring =
isOwnData &&
item.condition === "À vista" &&
!item.splitGroupId &&
!item.isDivided &&
!item.readonly &&
Boolean(onConvertToRecurring);
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -112,6 +136,20 @@ export function TransactionActionsMenu({
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
{canConvertToInstallment ? (
<DropdownMenuItem onSelect={() => onConvertToInstallment?.(item)}>
{getConditionIcon("Parcelado")}
Converter em Parcelamento
</DropdownMenuItem>
) : null}
{canConvertToRecurring ? (
<DropdownMenuItem onSelect={() => onConvertToRecurring?.(item)}>
{getConditionIcon("Recorrente")}
Converter em Recorrente
</DropdownMenuItem>
) : null}
{isOwnData ? ( {isOwnData ? (
<DropdownMenuItem <DropdownMenuItem
variant="destructive" variant="destructive"

View File

@@ -47,8 +47,11 @@ type BuildColumnsArgs = {
onToggleSettlement?: (item: TransactionItem) => void; onToggleSettlement?: (item: TransactionItem) => void;
onAnticipate?: (item: TransactionItem) => void; onAnticipate?: (item: TransactionItem) => void;
onViewAnticipationHistory?: (item: TransactionItem) => void; onViewAnticipationHistory?: (item: TransactionItem) => void;
onConvertToInstallment?: (item: TransactionItem) => void;
onConvertToRecurring?: (item: TransactionItem) => void;
isSettlementLoading: (id: string) => boolean; isSettlementLoading: (id: string) => boolean;
showActions: boolean; showActions: boolean;
showDateGroups: boolean;
columnOrder?: string[] | null; columnOrder?: string[] | null;
}; };
@@ -109,8 +112,11 @@ function buildColumns({
onToggleSettlement, onToggleSettlement,
onAnticipate, onAnticipate,
onViewAnticipationHistory, onViewAnticipationHistory,
onConvertToInstallment,
onConvertToRecurring,
isSettlementLoading, isSettlementLoading,
showActions, showActions,
showDateGroups,
}: BuildColumnsArgs): ColumnDef<TransactionItem>[] { }: BuildColumnsArgs): ColumnDef<TransactionItem>[] {
const noop = () => undefined; const noop = () => undefined;
const handleEdit = onEdit ?? noop; const handleEdit = onEdit ?? noop;
@@ -122,6 +128,8 @@ function buildColumns({
const handleToggleSettlement = onToggleSettlement ?? noop; const handleToggleSettlement = onToggleSettlement ?? noop;
const handleAnticipate = onAnticipate ?? noop; const handleAnticipate = onAnticipate ?? noop;
const handleViewAnticipationHistory = onViewAnticipationHistory ?? noop; const handleViewAnticipationHistory = onViewAnticipationHistory ?? noop;
const handleConvertToInstallment = onConvertToInstallment ?? noop;
const handleConvertToRecurring = onConvertToRecurring ?? noop;
const columns: ColumnDef<TransactionItem>[] = [ const columns: ColumnDef<TransactionItem>[] = [
{ {
@@ -188,12 +196,14 @@ function buildColumns({
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<EstablishmentLogo name={name} size={32} /> <EstablishmentLogo name={name} size={32} />
<span className="flex flex-col py-0.5"> <span className="flex flex-col py-0.5">
{showDateGroups ? null : (
<span className="text-xs text-muted-foreground flex items-center gap-2"> <span className="text-xs text-muted-foreground flex items-center gap-2">
{formatDate(purchaseDate)} {formatDate(purchaseDate)}
{dueDateLabel ? ( {dueDateLabel ? (
<span className="text-primary">{dueDateLabel}</span> <span className="text-primary">{dueDateLabel}</span>
) : null} ) : null}
</span> </span>
)}
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -248,6 +258,15 @@ function buildColumns({
</Badge> </Badge>
) : null} ) : null}
{showDateGroups && dueDateLabel ? (
<Badge
variant="outline"
className="px-2 text-xs text-primary"
>
{dueDateLabel}
</Badge>
) : null}
{isAnticipated && ( {isAnticipated && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -545,6 +564,12 @@ function buildColumns({
onRefund={handleRefund} onRefund={handleRefund}
onAnticipate={handleAnticipate} onAnticipate={handleAnticipate}
onViewAnticipationHistory={handleViewAnticipationHistory} onViewAnticipationHistory={handleViewAnticipationHistory}
onConvertToInstallment={
onConvertToInstallment ? handleConvertToInstallment : undefined
}
onConvertToRecurring={
onConvertToRecurring ? handleConvertToRecurring : undefined
}
/> />
</div> </div>
), ),

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
export type InstallmentDetection = {
name: string;
currentInstallment: number;
installmentCount: number;
};
const INSTALLMENT_SUFFIX_PATTERNS = [
/^(?<name>.+?)\s*[-]?\s*parcela\s+(?<current>\d{1,2})\s+de\s+(?<total>\d{1,2})\s*$/iu,
/^(?<name>.+?)\s*[-]?\s*parcela\s+(?<current>\d{1,2})\s*\/\s*(?<total>\d{1,2})\s*$/iu,
/^(?<name>.+?)\s*\((?<current>\d{1,2})\s*[/?]\s*(?<total>\d{1,2})\)\s*$/u,
/^(?<name>.+?)\s+(?<current>\d{1,2})\s*[/?]\s*(?<total>\d{1,2})\s*$/u,
];
const normalizeDetectedName = (value: string) =>
value
.trim()
.replace(/\s+[-:]\s*$/u, "")
.trim();
export function detectInstallmentFromName(
value: string | null | undefined,
): InstallmentDetection | null {
const text = value?.trim();
if (!text) return null;
for (const pattern of INSTALLMENT_SUFFIX_PATTERNS) {
const match = pattern.exec(text);
const groups = match?.groups;
if (!groups) continue;
const currentInstallment = Number(groups.current);
const installmentCount = Number(groups.total);
const name = normalizeDetectedName(groups.name ?? "");
if (
name.length > 0 &&
Number.isInteger(currentInstallment) &&
Number.isInteger(installmentCount) &&
currentInstallment >= 1 &&
installmentCount >= 2 &&
currentInstallment <= installmentCount &&
installmentCount <= 60
) {
return { name, currentInstallment, installmentCount };
}
}
return null;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ export type ImportedTransaction = {
amount: number; // positivo = receita, negativo = despesa amount: number; // positivo = receita, negativo = despesa
description: string; // MEMO ou NAME description: string; // MEMO ou NAME
transactionType: "income" | "expense"; transactionType: "income" | "expense";
categoryRaw?: string | null;
}; };
export type ImportStatement = { export type ImportStatement = {

View File

@@ -99,6 +99,7 @@ export async function parseXls(buffer: ArrayBuffer): Promise<ImportStatement> {
const typeRaw = const typeRaw =
values[4] != null ? String(values[4]).toLowerCase().trim() : ""; values[4] != null ? String(values[4]).toLowerCase().trim() : "";
const transactionType = typeRaw === "receita" ? "income" : "expense"; const transactionType = typeRaw === "receita" ? "income" : "expense";
const categoryRaw = values[5] != null ? String(values[5]).trim() : null;
if (!date || !description || amount === null || amount <= 0) return; if (!date || !description || amount === null || amount <= 0) return;
@@ -108,6 +109,7 @@ export async function parseXls(buffer: ArrayBuffer): Promise<ImportStatement> {
amount, amount,
description, description,
transactionType, transactionType,
categoryRaw,
}); });
}); });
@@ -132,16 +134,17 @@ export async function generateXlsTemplate(): Promise<ArrayBuffer> {
const ws = workbook.addWorksheet("Lançamentos"); const ws = workbook.addWorksheet("Lançamentos");
ws.addRows([ ws.addRows([
["Data", "Descrição", "Valor", "Tipo"], ["Data", "Descrição", "Valor", "Tipo", "Categoria"],
["01/03/2026", "Ingressos São Januário", 160, "despesa"], ["01/03/2026", "Ingressos São Januário", 160, "despesa", "Lazer"],
["01/03/2026", "Salário", 3000.0, "receita"], ["01/03/2026", "Salário", 3000.0, "receita", "Salário"],
["01/03/2026", "Posto do Vasco da Gama", 89.9, "despesa"], ["01/03/2026", "Posto do Vasco da Gama", 89.9, "despesa", "Transporte"],
]); ]);
ws.getColumn(1).width = 14; ws.getColumn(1).width = 14;
ws.getColumn(2).width = 32; ws.getColumn(2).width = 32;
ws.getColumn(3).width = 12; ws.getColumn(3).width = 12;
ws.getColumn(4).width = 10; ws.getColumn(4).width = 10;
ws.getColumn(5).width = 24;
// Dropdown para coluna Tipo (D2:D100) // Dropdown para coluna Tipo (D2:D100)
for (let i = 2; i <= 100; i++) { for (let i = 2; i <= 100; i++) {

View File

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