14 Commits

Author SHA1 Message Date
Felipe Coutinho
10afef9fec fix(segurança): corrigir 10 vulnerabilidades do relatório de segurança
- tokens: remover aceite de expiresAt NULL e forçar TTL de 1 ano
- tokens: corrigir refresh que invalidava access token anterior
- xlsx: desabilitar parsing de fórmulas (CVE-2024-44294)
- csp: expandir Content-Security-Policy com origens explícitas
- headers: adicionar Referrer-Policy e X-Permitted-Cross-Domain-Policies
- api: retornar 401 JSON em vez de redirect 302 em rotas autenticadas
- health: remover version disclosure do /api/health
- robots.txt: simplificar para não expor rotas internas
- sitemap: corrigir URL com protocolo duplicado
- criar security.txt (RFC 9116)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 02:47:05 +00:00
Felipe Coutinho
fd4d90a53e ci: forçar Node.js 24 nas actions do workflow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:52:52 +00:00
Felipe Coutinho
a24406271c chore: corrigir formatacao do package.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:51:14 +00:00
Felipe Coutinho
a09942e3d8 chore(release): preparar changelog da versão 2.3.1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:46:47 +00:00
Felipe Coutinho
96febd5904 fix(docker): separar deps drizzle do node_modules standalone
O pnpm install no Stage 3 sobrescrevia o node_modules copiado do
.next/standalone, removendo o modulo next e quebrando o startup.

Agora as deps do drizzle-kit sao instaladas em /app/migrate/ antes
de copiar o standalone, mantendo os dois node_modules isolados.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:45:48 +00:00
Felipe Coutinho
c3cfbc878c fix(tipografia): ajustar display da fonte america 2026-04-03 18:11:56 +00:00
Felipe Coutinho
55bbfabe9f chore(release): preparar changelog da versão 2.3.0 2026-04-03 18:11:34 +00:00
Felipe Coutinho
f5cdae4853 fix(ui): remover avisos visuais e destacar atualizações 2026-04-03 18:11:30 +00:00
Felipe Coutinho
5c4995961c refactor(lista): componentizar inbox e tabela de lançamentos 2026-04-03 18:10:58 +00:00
Felipe Coutinho
1b4dfaaba7 fix(lançamentos): reforçar validações e revisar formulário 2026-04-03 18:10:50 +00:00
Felipe Coutinho
549a5bdba1 fix(financeiro): alinhar saldo, métricas e relatórios 2026-04-03 18:10:43 +00:00
Felipe Coutinho
acaf9d5c27 feat(dados-client): adotar react query em leituras do app 2026-04-03 18:10:34 +00:00
Felipe Coutinho
e4c6a91350 fix(segurança): endurecer autenticação e rotas privadas 2026-04-03 18:10:23 +00:00
Felipe Coutinho
ba369e8a83 chore(infra): atualizar build, docker e tooling 2026-04-03 18:10:16 +00:00
114 changed files with 5422 additions and 2722 deletions

View File

@@ -1,58 +0,0 @@
# AI Coding Assistant Instructions for OpenMonetis
## Project Overview
OpenMonetis is a self-hosted personal finance management application built with Next.js 16, TypeScript, PostgreSQL, and Drizzle ORM. It provides manual transaction tracking, account management, budgeting, and financial insights with a Portuguese interface.
## Architecture
- **Frontend**: Next.js App Router with React 19, shadcn/ui components, Tailwind CSS
- **Backend**: Server actions in Next.js, API routes for auth/health
- **Database**: PostgreSQL with Drizzle ORM, schema in `db/schema.ts`
- **Auth**: Better Auth (OAuth + email magic links)
- **Deployment**: Docker multi-stage build, health checks
## Key Patterns
- **Server Actions**: Use `"use server"` for mutations, validate with Zod schemas, handle errors with `handleActionError`
- **Database Queries**: Use Drizzle's query API with relations, e.g., `db.query.lancamentos.findMany({ with: { categoria: true } })`
- **Authentication**: Import from `lib/auth/server`, redirect on failure
- **Revalidation**: Call `revalidateForEntity("lancamentos")` after mutations
- **Portuguese Naming**: DB fields like `nome`, `tipo_conta`, `pagador` (payer), `lancamento` (transaction)
- **Component Structure**: Feature-based folders in `components/`, shared UI in `components/ui/`
## Development Workflow
- **Start Dev**: `pnpm dev` (Turbopack), `docker compose up db -d` for DB
- **Database**: `pnpm db:push` to sync schema, `pnpm db:studio` for visual editor
- **Build**: `pnpm build`, `pnpm start` for production
- **Docker**: `pnpm docker:up` for full stack, `pnpm docker:logs` for monitoring
## Common Tasks
- **Add Transaction**: Create server action in `app/(dashboard)/lancamentos/actions.ts`, validate with Zod, insert via Drizzle
- **New Entity**: Add to `db/schema.ts`, define relations, create CRUD actions in `lib/[entity]/actions.ts`
- **UI Component**: Use shadcn/ui, place in `components/[feature]/`, export from `components/ui/`
- **API Route**: Add to `app/api/`, use `getUserSession()` for auth
## Conventions
- **Imports**: Absolute paths with `@/`, group by external/internal
- **Error Handling**: Return `{ success: false, error: string }` from actions
- **Currency**: Store as decimal strings (e.g., "123.45"), convert to cents for calculations
- **Periods**: Format as "YYYY-MM", use `parsePeriodParam()` for URL params
- **Notifications**: Send emails via `sendPagadorAutoEmails()` for payer updates
## External Integrations
- **Better Auth**: Config in `lib/auth/config.ts`, session handling
- **Drizzle**: Migrations in `drizzle/`, studio at `pnpm db:studio`
- **AI Features**: Use `@ai-sdk/*` for insights, configured in environment
- **Email**: Resend for notifications, configured via `RESEND_API_KEY`
## File Examples
- Schema: `db/schema.ts` (relations, indexes)
- Actions: `app/(dashboard)/lancamentos/actions.ts` (CRUD with validation)
- Components: `components/lancamentos/page/lancamentos-page.tsx` (client component)
- Utils: `lib/lancamentos/page-helpers.ts` (data transformation)

View File

@@ -13,10 +13,35 @@ on:
env:
DOCKER_IMAGE_NAME: openmonetis
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
quality:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.33.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm run lint
build-and-push:
runs-on: ubuntu-latest
needs: quality
permissions:
contents: read
packages: write

View File

@@ -5,6 +5,9 @@ on:
branches:
- main
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
release:
runs-on: ubuntu-latest

View File

@@ -7,6 +7,62 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
## [Unreleased]
## [2.3.2] - 2026-04-04
### Segurança
- Tokens: removido aceite de tokens sem expiração (`expiresAt NULL`); tokens criados via settings agora expiram em 1 ano
- Tokens: corrigido refresh que sobrescrevia hash e invalidava access token anterior; verify agora valida JWT por assinatura
- xlsx: desabilitado parsing de fórmulas (`cellFormula: false`) para mitigar CVE-2024-44294
- CSP: expandida Content-Security-Policy com `default-src`, `script-src`, `style-src`, `img-src`, `font-src` e `connect-src`
- Headers: adicionados `Referrer-Policy` e `X-Permitted-Cross-Domain-Policies`
- API: rotas autenticadas agora retornam `401 JSON` em vez de redirect `302` para clientes não autenticados
- Health: removido campo `version` da resposta do `/api/health`
- robots.txt: simplificado para não expor mapa de rotas internas
- Sitemap: corrigida URL com protocolo duplicado (`https://https://`)
- Criado `security.txt` (RFC 9116)
## [2.3.1] - 2026-04-03
### Corrigido
- Infraestrutura: deps do drizzle-kit agora são instaladas em `/app/migrate/` separado do `node_modules` do standalone, corrigindo erro `Cannot find module 'next'` no startup do container
## [2.3.0] - 2026-04-03
### Adicionado
- Dependências: adiciona `@tanstack/react-query` e um provider global para padronizar cache, deduplicação e invalidação de leituras client-side
- Dashboard: widget "Minhas Contas" ganha preferência persistida para mostrar ou ocultar contas marcadas como não consideradas no saldo total
- Dashboard: cards de métricas ganham botão de ajuda com explicação do cálculo exibido no app
- Versionamento: menu do usuário na navbar passa a avisar quando existe release mais recente publicada no GitHub
- Qualidade: adiciona `knip` ao projeto com o script `pnpm run lint:deadcode` para auditar arquivos, exports e tipos sem uso
- Infraestrutura: imagem Docker passa a rodar migrations automaticamente via `docker-entrypoint.sh` antes de iniciar a aplicação
### Alterado
- Anexos: listagem no modal de edição/detalhes, URLs temporárias da galeria e preview deixam de depender de `useEffect` para data fetching direto no componente e passam a usar React Query sobre rotas GET dedicadas
- Insights: carregamento de análises salvas passa a usar React Query com cache por período, mantendo estado draft local apenas para análises recém-geradas ou removidas
- Parcelamentos: histórico de antecipações no diálogo passa a usar React Query com invalidação automática após cancelamento
- Dashboard, insights e relatórios passam a excluir movimentações de contas marcadas como não consideradas no saldo total; balanço e previsto também passam a considerar ajustes de transferências entre contas consideradas e não consideradas
- UX: boletos e faturas passam a exibir labels relativas como "vence hoje", "vence amanhã" e "pago ontem", com tooltip para a data completa
- Lançamentos: diálogo foi reorganizado em blocos mais claros; a criação passa a aceitar múltiplos anexos e a edição em lote preserva `purchaseDate` e `period` ao propagar alterações por série
- Inbox e tabela de lançamentos foram componentizados em partes menores, mantendo paginação e ações em lote mais simples de evoluir
- Infraestrutura: workflow de publish ganha etapa obrigatória de qualidade; `docker-compose` passa a suportar perfil local ou banco remoto; build fixa `pnpm@10.33.0`; projeto atualizado para `Next.js 16.2.2`, `Biome 2.4.10` e dependências correlatas
- Qualidade: `knip` ganha configuração inicial para reduzir falsos positivos, ignorando `src/shared/components/ui/**`, o worker público de PDF, `setup.mjs` e o falso positivo de `postcss`
### Corrigido
- Segurança: criação de antecipações agora valida se `payerId` e `categoryId` informados pertencem ao usuário autenticado antes de persistir referências cruzadas
- Segurança: histórico de antecipações endurece os joins de `transactions`, `payers` e `categories` com filtro por `userId`, evitando exposição de nomes relacionados caso exista referência inconsistente no banco
- Segurança: domínio público deixa de responder rotas `/api/*`, e o Better Auth passa a aplicar rate limits explícitos para login e cadastro por e-mail
- APIs privadas: rotas de anexos, insights salvos, histórico de antecipações e presign de download passam a responder com `Cache-Control: private, no-store`; a rota de antecipações também deixa de devolver mensagens internas de erro ao cliente
- Build: rotas web de tokens do Companion passam a ser explicitamente dinâmicas, removendo o warning de prerender no `next build`
- Lançamentos: edição em série de compras parceladas volta a persistir `purchaseDate` e `period`, permitindo mover parcelas para a fatura ou competência correta conforme o escopo escolhido
- Lançamentos: edições que tentam mover compras de cartão para faturas já pagas agora são bloqueadas com mensagem clara também no fluxo de atualização e propagação em lote
- Imagens: logos institucionais, avatares padrão e componentes com `next/image` em modo `fill` passam a usar containers fixos com `sizes`, removendo avisos de proporção e performance
- Gráficos: `ChartContainer` passa a definir `initialDimension` no `ResponsiveContainer` do Recharts, evitando avisos `width(-1)` e `height(-1)` durante a medição inicial em widgets e relatórios
## [2.2.1] - 2026-04-01
### Corrigido

View File

@@ -5,8 +5,7 @@
# ============================================
FROM node:22-alpine AS deps
# Instalar pnpm globalmente
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
WORKDIR /app
@@ -24,8 +23,7 @@ RUN pnpm install --frozen-lockfile
# ============================================
FROM node:22-alpine AS builder
# Instalar pnpm globalmente
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
WORKDIR /app
@@ -35,13 +33,14 @@ COPY --from=deps /app/node_modules ./node_modules
# Copiar todo o código fonte
COPY . .
# Garantir que o pdf.worker vem da versão instalada no stage 1, não do host
COPY --from=deps /app/public/pdf.worker.min.mjs ./public/pdf.worker.min.mjs
# Variáveis de ambiente necessárias para o build
# DATABASE_URL será fornecida em runtime, mas precisa estar definida para validação
ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production
# Build da aplicação Next.js
# Nota: Se houver erros de tipo, ajuste typescript.ignoreBuildErrors no next.config.ts
RUN pnpm build
# ============================================
@@ -49,8 +48,7 @@ RUN pnpm build
# ============================================
FROM node:22-alpine AS runner
# Instalar pnpm globalmente
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
WORKDIR /app
@@ -58,12 +56,27 @@ WORKDIR /app
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Instalar deps do drizzle-kit em diretório separado ANTES de copiar o standalone
# Isso evita que o pnpm install sobrescreva o node_modules do Next.js standalone
COPY --from=builder /app/package.json /tmp/pkg.json
RUN mkdir -p /app/migrate && \
node -e "\
const p=JSON.parse(require('fs').readFileSync('/tmp/pkg.json','utf8'));\
require('fs').writeFileSync('/app/migrate/package.json',JSON.stringify({\
name:'openmonetis-migrate',version:p.version,\
dependencies:{\
'drizzle-orm':p.dependencies['drizzle-orm'],\
'pg':p.dependencies['pg']\
},\
devDependencies:{'drizzle-kit':p.devDependencies['drizzle-kit']}\
}));" && \
cd /app/migrate && pnpm install --no-frozen-lockfile --ignore-scripts && \
chown -R nextjs:nodejs /app/migrate
# Copiar apenas arquivos necessários para produção
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/pnpm-lock.yaml ./pnpm-lock.yaml
# Copiar arquivos de build do Next.js
# Copiar arquivos de build do Next.js (inclui node_modules standalone com next)
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
@@ -72,8 +85,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
COPY --from=builder --chown=nextjs:nodejs /app/drizzle.config.ts ./drizzle.config.ts
COPY --from=builder --chown=nextjs:nodejs /app/src/db ./src/db
# Copiar node_modules para ter drizzle-kit disponível para migrations
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
# Copiar entrypoint de migrations
COPY docker-entrypoint.sh ./
RUN chmod +x /app/docker-entrypoint.sh && chown nextjs:nodejs /app/docker-entrypoint.sh
# Definir variáveis de ambiente de produção
ENV NODE_ENV=production \
@@ -89,8 +103,8 @@ USER nextjs
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" || exit 1
CMD wget --quiet --tries=1 --spider http://localhost:3000/api/health || exit 1
# Comando de inicialização
# Nota: Em produção com standalone build, o servidor é iniciado pelo arquivo server.js
# Entrypoint: roda migrations e depois executa o CMD
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["node", "server.js"]

View File

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

View File

@@ -4,23 +4,28 @@ name: openmonetis
# MODOS DE USO:
# 1. Banco LOCAL (PostgreSQL em container):
# - Configure DATABASE_URL com host "db" no .env
# - Execute: docker compose up
# - Execute: docker compose --profile local up
#
# 2. Banco REMOTO (ex: Supabase, Neon, etc):
# - Configure DATABASE_URL com a URL do banco remoto no .env
# - Execute: docker compose up app (apenas o serviço app)
# - Execute: docker compose up
#
# 3. Para parar todos os serviços:
# 3. Build local (desenvolvimento):
# - Execute: docker compose --profile local up --build
#
# 4. Para parar todos os serviços:
# - Execute: docker compose down
#
# 4. Para remover volumes (CUIDADO: apaga dados do banco local):
# 5. Para remover volumes (CUIDADO: apaga dados do banco local):
# - Execute: docker compose down -v
services:
# ============================================
# Serviço: PostgreSQL (Banco de dados local)
# Ativado apenas com: --profile local
# ============================================
db:
profiles: ["local"]
image: postgres:18-alpine
container_name: openmonetis_postgres
restart: unless-stopped
@@ -63,6 +68,7 @@ services:
# Serviço: Aplicação Next.js
# ============================================
app:
build: .
image: felipegcoutinho/openmonetis:latest
container_name: openmonetis_app
@@ -80,6 +86,13 @@ services:
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
# S3 (opcional)
S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_REGION: ${S3_REGION:-}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
S3_BUCKET: ${S3_BUCKET:-}
# Email (opcional)
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
@@ -96,24 +109,11 @@ services:
GOOGLE_GENERATIVE_AI_API_KEY: ${GOOGLE_GENERATIVE_AI_API_KEY:-}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
# Só depende do 'db' se estiver usando banco local
# Para banco remoto, comente as linhas abaixo
# required: false permite subir sem banco local (banco remoto via DATABASE_URL)
depends_on:
db:
condition: service_healthy
# Script de inicialização: roda migrations antes de iniciar o app
entrypoint: ["/bin/sh", "-c"]
command:
- |
echo "Aguardando banco de dados..."
sleep 5
echo "Rodando migrations..."
pnpm db:push || echo "Migrations falharam ou já estão atualizadas"
echo "Iniciando aplicação Next.js..."
node server.js
required: false
healthcheck:
test:

15
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
echo "Rodando migrations..."
RETRIES=5
until /app/migrate/node_modules/.bin/drizzle-kit push || [ "$RETRIES" -eq 0 ]; do
RETRIES=$((RETRIES - 1))
echo "Migration falhou, aguardando banco... ($RETRIES tentativas restantes)"
sleep 5
done
if [ "$RETRIES" -eq 0 ]; then
echo "Aviso: migrations nao foram aplicadas"
fi
exec "$@"

22
knip.jsonc Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://unpkg.com/knip@6/schema-jsonc.json",
// Exclude shared UI primitives from dead code reporting while we focus the
// cleanup on feature and domain code first.
"ignore": [
"src/shared/components/ui/**"
],
// Runtime asset referenced by string in the PDF viewer.
"ignoreFiles": [
"public/pdf.worker.min.mjs",
"setup.mjs"
],
// PostCSS is inferred from the config file, but the project only depends on
// the Tailwind PostCSS plugin directly.
"ignoreDependencies": [
"postcss"
],
"next": true,
"postcss": true,
"biome": true,
"drizzle": true
}

View File

@@ -8,12 +8,18 @@ const nextConfig: NextConfig = {
output: "standalone",
cacheComponents: true,
reactCompiler: true,
images: {
remotePatterns: [new URL("https://lh3.googleusercontent.com/**")],
},
devIndicators: {
position: "bottom-right",
},
experimental: {
prefetchInlining: true,
turbopackFileSystemCacheForDev: true,
},
// Headers for Safari compatibility
async headers() {
return [
@@ -38,7 +44,23 @@ const nextConfig: NextConfig = {
},
{
key: "Content-Security-Policy",
value: "frame-ancestors 'none';",
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://umami.felipecoutinho.com",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' https://lh3.googleusercontent.com data: blob:",
"font-src 'self'",
"connect-src 'self' https://umami.felipecoutinho.com",
"frame-ancestors 'none'",
].join("; "),
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
{
key: "X-Permitted-Cross-Domain-Policies",
value: "none",
},
{
key: "Permissions-Policy",

View File

@@ -1,13 +1,15 @@
{
"name": "openmonetis",
"version": "2.2.1",
"version": "2.3.2",
"private": true,
"packageManager": "pnpm@10.33.0",
"scripts": {
"dev": "next dev --turbopack",
"db:seed": "tsx scripts/mock-data.ts",
"build": "next build",
"start": "next start",
"lint": "biome check .",
"lint:deadcode": "knip --reporter compact",
"lint:fix": "biome check --write .",
"env:setup": "bash scripts/setup-env.sh",
"db:generate": "drizzle-kit generate",
@@ -29,11 +31,11 @@
"backup": "bash scripts/backup.sh"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.64",
"@ai-sdk/google": "^3.0.53",
"@ai-sdk/openai": "^3.0.48",
"@aws-sdk/client-s3": "^3.1019.0",
"@aws-sdk/s3-request-presigner": "^3.1019.0",
"@ai-sdk/anthropic": "^3.0.65",
"@ai-sdk/google": "^3.0.55",
"@ai-sdk/openai": "^3.0.49",
"@aws-sdk/client-s3": "^3.1022.0",
"@aws-sdk/s3-request-presigner": "^3.1022.0",
"@better-auth/passkey": "^1.5.6",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -59,9 +61,10 @@
"@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8",
"@remixicon/react": "4.9.0",
"@tanstack/react-query": "^5.96.2",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "^3.13.23",
"ai": "^6.0.141",
"ai": "^6.0.143",
"better-auth": "1.5.6",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "0.7.1",
@@ -71,7 +74,7 @@
"drizzle-orm": "0.45.2",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"next": "16.1.7",
"next": "16.2.2",
"next-themes": "0.4.6",
"pdfjs-dist": "^5.6.205",
"pg": "8.20.0",
@@ -80,7 +83,7 @@
"react-day-picker": "^9.14.0",
"react-dom": "19.2.4",
"recharts": "3.8.1",
"resend": "^6.9.4",
"resend": "^6.10.0",
"sonner": "2.0.7",
"tailwind-merge": "3.5.0",
"vaul": "1.1.2",
@@ -88,15 +91,16 @@
"zod": "4.3.6"
},
"devDependencies": {
"@biomejs/biome": "2.4.9",
"@biomejs/biome": "2.4.10",
"@tailwindcss/postcss": "4.2.2",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "25.5.0",
"@types/pg": "^8.20.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"dotenv": "^17.3.1",
"dotenv": "^17.4.0",
"drizzle-kit": "0.31.10",
"knip": "^6.3.0",
"tailwindcss": "4.2.2",
"tsx": "4.21.0",
"typescript": "6.0.2"

1342
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
Contact: https://github.com/felipegcoutinho/openmonetis/security/advisories
Expires: 2027-04-04T00:00:00.000Z
Preferred-Languages: pt-BR, en
Canonical: https://openmonetis.com/.well-known/security.txt

Binary file not shown.

View File

@@ -13,8 +13,6 @@ export const america = localFont({
style: "normal",
},
],
display: "swap",
display: "fallback",
variable: "--font-america",
});
export const americaFontVariable = america.variable;

View File

@@ -1,15 +1,31 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { attachments } from "@/db/schema";
import { getUserId } from "@/shared/lib/auth/server";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store",
};
export async function GET(
_request: Request,
{ params }: { params: Promise<{ attachmentId: string }> },
) {
const [userId, { attachmentId }] = await Promise.all([getUserId(), params]);
const [session, { attachmentId }] = await Promise.all([
getOptionalUserSession(),
params,
]);
if (!session?.user) {
return NextResponse.json(
{ error: "Não autenticado" },
{ status: 401, headers: PRIVATE_RESPONSE_HEADERS },
);
}
const userId = session.user.id;
const [row] = await db
.select({ fileKey: attachments.fileKey })
@@ -19,9 +35,20 @@ export async function GET(
);
if (!row) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(
{ error: "Not found" },
{
status: 404,
headers: PRIVATE_RESPONSE_HEADERS,
},
);
}
const url = await createPresignedGetUrl(row.fileKey);
return NextResponse.json({ url });
return NextResponse.json(
{ url },
{
headers: PRIVATE_RESPONSE_HEADERS,
},
);
}

View File

@@ -3,7 +3,6 @@ import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import {
extractBearerToken,
hashToken,
refreshAccessToken,
verifyJwt,
} from "@/shared/lib/auth/api-token";
@@ -59,11 +58,11 @@ export async function POST(request: Request) {
);
}
// Atualizar hash do token e último uso
// Atualizar último uso e expiração (sem sobrescrever tokenHash,
// pois o JWT é auto-verificável por assinatura)
await db
.update(apiTokens)
.set({
tokenHash: hashToken(result.accessToken),
lastUsedAt: new Date(),
lastUsedIp:
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||

View File

@@ -1,5 +1,5 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { connection, NextResponse } from "next/server";
import { z } from "zod";
import { apiTokens } from "@/db/schema";
import {
@@ -16,14 +16,17 @@ const createTokenSchema = z.object({
});
export async function POST(request: Request) {
await connection();
// Verificar autenticação via sessão web
const requestHeaders = new Headers(await headers());
const session = await auth.api.getSession({ headers: requestHeaders });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
try {
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
// Validar body
const body = await request.json();
const { name, deviceId } = createTokenSchema.parse(body);

View File

@@ -1,6 +1,6 @@
import { and, eq } from "drizzle-orm";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { connection, NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import { auth } from "@/shared/lib/auth/config";
import { db } from "@/shared/lib/db";
@@ -10,16 +10,19 @@ interface RouteParams {
}
export async function DELETE(_request: Request, { params }: RouteParams) {
await connection();
const { tokenId } = await params;
// Verificar autenticação via sessão web
const requestHeaders = new Headers(await headers());
const session = await auth.api.getSession({ headers: requestHeaders });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
try {
const { tokenId } = await params;
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
// Verificar se token pertence ao usuário
const token = await db.query.apiTokens.findFirst({
where: and(

View File

@@ -1,19 +1,22 @@
import { and, desc, eq, isNull } from "drizzle-orm";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { connection, NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import { auth } from "@/shared/lib/auth/config";
import { db } from "@/shared/lib/db";
export async function GET() {
await connection();
// Verificar autenticação via sessão web
const requestHeaders = new Headers(await headers());
const session = await auth.api.getSession({ headers: requestHeaders });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
try {
// Verificar autenticação via sessão web
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
// Buscar tokens ativos do usuário
const activeTokens = await db
.select({

View File

@@ -1,7 +1,7 @@
import { and, eq, gt, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import { extractBearerToken, hashToken } from "@/shared/lib/auth/api-token";
import { extractBearerToken, verifyJwt } from "@/shared/lib/auth/api-token";
import { db } from "@/shared/lib/db";
export async function POST(request: Request) {
@@ -17,21 +17,21 @@ export async function POST(request: Request) {
);
}
// Validar token os_xxx via hash lookup
if (!token.startsWith("os_")) {
// Verificar JWT (assinatura + expiração)
const payload = verifyJwt(token);
if (!payload || payload.type !== "api_access") {
return NextResponse.json(
{ valid: false, error: "Formato de token inválido" },
{ valid: false, error: "Token inválido ou expirado" },
{ status: 401 },
);
}
// Hash do token para buscar no DB
const tokenHash = hashToken(token);
// Buscar token no banco
// Buscar token no banco por tokenId para checar revogação
const tokenRecord = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.tokenHash, tokenHash),
eq(apiTokens.id, payload.tokenId),
eq(apiTokens.userId, payload.sub),
isNull(apiTokens.revokedAt),
gt(apiTokens.expiresAt, new Date()),
),
@@ -39,7 +39,7 @@ export async function POST(request: Request) {
if (!tokenRecord) {
return NextResponse.json(
{ valid: false, error: "Token inválido ou revogado" },
{ valid: false, error: "Token revogado ou não encontrado" },
{ status: 401 },
);
}

View File

@@ -1,5 +1,4 @@
import { NextResponse } from "next/server";
import { version as APP_VERSION } from "@/package.json";
import { db } from "@/shared/lib/db";
/**
@@ -20,7 +19,6 @@ export async function GET() {
{
status: "ok",
name: "OpenMonetis",
version: APP_VERSION,
timestamp: new Date().toISOString(),
},
{ status: 200 },
@@ -33,7 +31,6 @@ export async function GET() {
{
status: "error",
name: "OpenMonetis",
version: APP_VERSION,
timestamp: new Date().toISOString(),
message: "Database connection failed",
},

View File

@@ -1,4 +1,4 @@
import { and, eq, gt, isNull, or } from "drizzle-orm";
import { and, eq, gt, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { apiTokens, inboxItems } from "@/db/schema";
@@ -63,7 +63,7 @@ export async function POST(request: Request) {
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt),
or(isNull(apiTokens.expiresAt), gt(apiTokens.expiresAt, new Date())),
gt(apiTokens.expiresAt, new Date()),
),
});

View File

@@ -1,4 +1,4 @@
import { and, eq, gt, isNull, or } from "drizzle-orm";
import { and, eq, gt, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { z } from "zod";
import { apiTokens, inboxItems } from "@/db/schema";
@@ -56,7 +56,7 @@ export async function POST(request: Request) {
where: and(
eq(apiTokens.tokenHash, tokenHash),
isNull(apiTokens.revokedAt),
or(isNull(apiTokens.expiresAt), gt(apiTokens.expiresAt, new Date())),
gt(apiTokens.expiresAt, new Date()),
),
});

View File

@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import {
fetchSavedInsights,
savedInsightsPeriodSchema,
} from "@/features/insights/queries";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store",
};
export async function GET(request: Request) {
const period = new URL(request.url).searchParams.get("period") ?? "";
const validatedPeriod = savedInsightsPeriodSchema.safeParse(period);
if (!validatedPeriod.success) {
return NextResponse.json(
{
error: validatedPeriod.error.issues[0]?.message ?? "Período inválido.",
},
{
status: 400,
headers: PRIVATE_RESPONSE_HEADERS,
},
);
}
const session = await getOptionalUserSession();
if (!session?.user) {
return NextResponse.json(
{ error: "Não autenticado" },
{ status: 401, headers: PRIVATE_RESPONSE_HEADERS },
);
}
const insights = await fetchSavedInsights(
session.user.id,
validatedPeriod.data,
);
return NextResponse.json(insights, {
headers: PRIVATE_RESPONSE_HEADERS,
});
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { fetchTransactionAttachments } from "@/features/transactions/attachment-queries";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store",
};
export async function GET(
_request: Request,
{ params }: { params: Promise<{ transactionId: string }> },
) {
const [session, { transactionId }] = await Promise.all([
getOptionalUserSession(),
params,
]);
if (!session?.user) {
return NextResponse.json(
{ error: "Não autenticado" },
{ status: 401, headers: PRIVATE_RESPONSE_HEADERS },
);
}
const userId = session.user.id;
const attachments = await fetchTransactionAttachments(userId, transactionId);
return NextResponse.json(attachments, {
headers: PRIVATE_RESPONSE_HEADERS,
});
}

View File

@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { fetchInstallmentAnticipations } from "@/features/transactions/anticipation-queries";
import { getOptionalUserSession } from "@/shared/lib/auth/server";
const PRIVATE_RESPONSE_HEADERS = {
"Cache-Control": "private, no-store",
};
export async function GET(
_request: Request,
{ params }: { params: Promise<{ seriesId: string }> },
) {
try {
const [session, { seriesId }] = await Promise.all([
getOptionalUserSession(),
params,
]);
if (!session?.user) {
return NextResponse.json(
{ error: "Não autenticado" },
{ status: 401, headers: PRIVATE_RESPONSE_HEADERS },
);
}
const userId = session.user.id;
const anticipations = await fetchInstallmentAnticipations(userId, seriesId);
return NextResponse.json(anticipations, {
headers: PRIVATE_RESPONSE_HEADERS,
});
} catch (error) {
console.error("Erro ao carregar histórico de antecipações:", error);
return NextResponse.json(
{
error: "Erro ao carregar histórico de antecipações.",
},
{
status: 400,
headers: PRIVATE_RESPONSE_HEADERS,
},
);
}
}

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { QueryProvider } from "@/shared/components/providers/query-provider";
import { ThemeProvider } from "@/shared/components/providers/theme-provider";
import { Toaster } from "@/shared/components/ui/sonner";
import "./globals.css";
@@ -21,6 +22,7 @@ export default function RootLayout({
}>) {
return (
<html
data-scroll-behavior="smooth"
lang="pt-BR"
className={`${america.variable} ${america.className} `}
suppressHydrationWarning
@@ -36,8 +38,10 @@ export default function RootLayout({
</head>
<body className="antialiased" suppressHydrationWarning>
<ThemeProvider attribute="class" defaultTheme="light">
<Suspense>{children}</Suspense>
<Toaster position="top-right" />
<QueryProvider>
<Suspense>{children}</Suspense>
<Toaster position="top-right" />
</QueryProvider>
</ThemeProvider>
</body>
</html>

View File

@@ -6,25 +6,7 @@ export default function robots(): MetadataRoute.Robots {
{
userAgent: "*",
allow: "/",
disallow: [
"/dashboard",
"/transactions",
"/accounts",
"/cards",
"/categories",
"/budgets",
"/payers",
"/notes",
"/insights",
"/calendar",
"/attachments",
"/settings",
"/reports",
"/inbox",
"/login",
"/signup",
"/api/",
],
disallow: "/api/",
},
],
};

View File

@@ -1,7 +1,7 @@
import type { MetadataRoute } from "next";
const BASE_URL = process.env.PUBLIC_DOMAIN
? `https://${process.env.PUBLIC_DOMAIN}`
? `https://${process.env.PUBLIC_DOMAIN.replace(/^https?:\/\//, "")}`
: "https://openmonetis.com";
export default function sitemap(): MetadataRoute.Sitemap {

View File

@@ -139,6 +139,7 @@ export const userPreferences = pgTable("preferencias_usuario", {
dashboardWidgets: jsonb("dashboard_widgets").$type<{
order: string[];
hidden: string[];
myAccountsShowExcluded?: boolean;
}>(),
createdAt: timestamp("created_at", {
mode: "date",

View File

@@ -117,6 +117,7 @@ export function AttachmentGridItem({
src={url}
alt={attachment.fileName}
fill
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, (max-width: 1280px) 25vw, 20vw"
unoptimized
className="object-cover transition-transform duration-300 group-hover:scale-105"
/>

View File

@@ -8,6 +8,7 @@ import {
RiExternalLinkLine,
} from "@remixicon/react";
import { useEffect, useState } from "react";
import { useAttachmentUrlQuery } from "@/features/attachments/hooks/use-attachment-url";
import type { AttachmentForPeriod } from "@/features/attachments/queries";
import { Button } from "@/shared/components/ui/button";
import {
@@ -30,7 +31,6 @@ export function AttachmentPreview({
onClose,
}: AttachmentPreviewProps) {
const [currentIndex, setCurrentIndex] = useState(selectedIndex);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const open = selectedIndex >= 0;
useEffect(() => {
@@ -52,17 +52,11 @@ export function AttachmentPreview({
const attachment = attachments[currentIndex];
const attachmentId = attachment?.attachmentId;
// Busca URL fresca a cada troca de anexo
useEffect(() => {
if (!attachmentId) return;
setPreviewUrl(null);
fetch(`/api/attachments/${attachmentId}/presign`)
.then((r) => r.json())
.then((data: { url: string }) => setPreviewUrl(data.url))
.catch(() => {});
}, [attachmentId]);
const {
data: previewUrl,
isLoading: isPreviewLoading,
isError: isPreviewError,
} = useAttachmentUrlQuery(attachmentId ?? "", open && Boolean(attachmentId));
if (!attachment) return null;
@@ -170,11 +164,16 @@ export function AttachmentPreview({
</DialogHeader>
<div className="min-h-0 min-w-0 flex-1">
{!previewUrl && (
{isPreviewLoading && (
<div className="flex h-full w-full items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-foreground" />
</div>
)}
{isPreviewError && (
<div className="flex h-full w-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
Não foi possível carregar a visualização deste anexo.
</div>
)}
{isPdf && previewUrl && (
<iframe
key={attachment.attachmentId}

View File

@@ -1,13 +1,37 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { fetchJson } from "@/shared/lib/fetch-json";
const ATTACHMENT_URL_STALE_TIME = 4 * 60 * 1000;
export const attachmentUrlQueryKey = (attachmentId: string) =>
["attachments", "url", attachmentId] as const;
export function useAttachmentUrlQuery(attachmentId: string, enabled: boolean) {
return useQuery({
queryKey: attachmentUrlQueryKey(attachmentId),
queryFn: async () => {
const payload = await fetchJson<{ url: string }>(
`/api/attachments/${attachmentId}/presign`,
);
return payload.url;
},
enabled: enabled && Boolean(attachmentId),
staleTime: ATTACHMENT_URL_STALE_TIME,
gcTime: ATTACHMENT_URL_STALE_TIME * 2,
});
}
export function useAttachmentUrl(attachmentId: string) {
const [url, setUrl] = useState<string | null>(null);
const [isVisible, setIsVisible] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setUrl(null);
void attachmentId;
setIsVisible(false);
const el = containerRef.current;
if (!el) return;
@@ -15,10 +39,7 @@ export function useAttachmentUrl(attachmentId: string) {
(entries) => {
if (!entries[0].isIntersecting) return;
observer.disconnect();
fetch(`/api/attachments/${attachmentId}/presign`)
.then((r) => r.json())
.then((data: { url: string }) => setUrl(data.url))
.catch(() => {});
setIsVisible(true);
},
{ rootMargin: "150px" },
);
@@ -27,5 +48,7 @@ export function useAttachmentUrl(attachmentId: string) {
return () => observer.disconnect();
}, [attachmentId]);
return { url, containerRef };
const { data: url } = useAttachmentUrlQuery(attachmentId, isVisible);
return { url: url ?? null, containerRef };
}

View File

@@ -14,13 +14,13 @@ const MOCK_INVOICES: MockInvoice[] = [
cardName: "Nubank",
logo: "nubank.png",
amount: 1898,
dueLabel: "Vence em 3 dias",
dueLabel: "Vence hoje",
},
{
cardName: "Itaú",
logo: "itau.png",
amount: 1923,
dueLabel: "Vence em 8 dias",
dueLabel: "Vence amanhã",
},
];

View File

@@ -3,6 +3,7 @@ import type { PaymentDialogState } from "@/features/dashboard/use-payment-dialog
import { getBusinessDateString, isDateOnlyPast } from "@/shared/utils/date";
import {
buildFinancialStatusLabel,
buildRelativeFinancialStatusLabel,
formatFinancialDateLabel,
} from "@/shared/utils/financial-dates";
@@ -24,6 +25,14 @@ export const buildBillStatusLabel = (bill: BillStatusDateItem) => {
});
};
export const buildBillWidgetStatusLabel = (bill: BillStatusDateItem) => {
return buildRelativeFinancialStatusLabel({
isSettled: bill.isSettled,
dueDate: bill.dueDate,
paidAt: bill.boletoPaymentDate,
});
};
export const getCurrentBillDateString = () => getBusinessDateString();
export const isBillOverdue = (bill: DashboardBill) => {

View File

@@ -1,10 +1,15 @@
"use server";
import { and, asc, eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { transactions } from "@/db/schema";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { toDateOnlyString } from "@/shared/utils/date";
import {
compareDateOnly,
getBusinessDateString,
isDateOnlyPast,
toDateOnlyString,
} from "@/shared/utils/date";
import { safeToNumber as toNumber } from "@/shared/utils/number";
const PAYMENT_METHOD_BOLETO = "Boleto";
@@ -33,10 +38,31 @@ export type DashboardBillsSnapshot = {
pendingCount: number;
};
const compareDateOnlyAscWithNullsLast = (
left: string | null,
right: string | null,
) => {
if (!left && !right) return 0;
if (!left) return 1;
if (!right) return -1;
return compareDateOnly(left, right);
};
const compareDateOnlyDescWithNullsLast = (
left: string | null,
right: string | null,
) => {
if (!left && !right) return 0;
if (!left) return 1;
if (!right) return -1;
return compareDateOnly(right, left);
};
export async function fetchDashboardBills(
userId: string,
period: string,
): Promise<DashboardBillsSnapshot> {
const today = getBusinessDateString();
const adminPayerId = await getAdminPayerId(userId);
if (!adminPayerId) {
return { bills: [], totalPendingAmount: 0, pendingCount: 0 };
@@ -59,11 +85,6 @@ export async function fetchDashboardBills(
eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
eq(transactions.payerId, adminPayerId),
),
)
.orderBy(
asc(transactions.isSettled),
asc(transactions.dueDate),
asc(transactions.name),
);
const bills = rows.map((row: RawDashboardBill): DashboardBill => {
@@ -78,6 +99,55 @@ export async function fetchDashboardBills(
};
});
bills.sort((a, b) => {
if (a.isSettled !== b.isSettled) {
return a.isSettled ? 1 : -1;
}
if (!a.isSettled && !b.isSettled) {
const aIsOverdue = a.dueDate ? isDateOnlyPast(a.dueDate, today) : false;
const bIsOverdue = b.dueDate ? isDateOnlyPast(b.dueDate, today) : false;
if (aIsOverdue !== bIsOverdue) {
return aIsOverdue ? -1 : 1;
}
const dueDateDiff = compareDateOnlyAscWithNullsLast(a.dueDate, b.dueDate);
if (dueDateDiff !== 0) {
return dueDateDiff;
}
const amountDiff = b.amount - a.amount;
if (amountDiff !== 0) {
return amountDiff;
}
}
if (a.isSettled && b.isSettled) {
const paidAtDiff = compareDateOnlyDescWithNullsLast(
a.boletoPaymentDate,
b.boletoPaymentDate,
);
if (paidAtDiff !== 0) {
return paidAtDiff;
}
const amountDiff = b.amount - a.amount;
if (amountDiff !== 0) {
return amountDiff;
}
}
const nameDiff = a.name.localeCompare(b.name, "pt-BR", {
sensitivity: "base",
});
if (nameDiff !== 0) {
return nameDiff;
}
return a.id.localeCompare(b.id);
});
let totalPendingAmount = 0;
let pendingCount = 0;

View File

@@ -1,5 +1,10 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { budgets, categories, transactions } from "@/db/schema";
import {
budgets,
categories,
financialAccounts,
transactions,
} from "@/db/schema";
import {
buildCategoryBreakdownData,
type DashboardCategoryBreakdownData,
@@ -8,6 +13,7 @@ import {
import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
@@ -39,6 +45,10 @@ export async function fetchExpensesByCategory(
})
.from(transactions)
.innerJoin(categories, eq(transactions.categoryId, categories.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
...buildDashboardAdminFilters({ userId, adminPayerId }),
@@ -46,6 +56,7 @@ export async function fetchExpensesByCategory(
eq(transactions.transactionType, "Despesa"),
eq(categories.type, "despesa"),
excludeAutoInvoiceEntries(),
excludeTransactionsFromExcludedAccounts(),
),
)
.groupBy(

View File

@@ -14,6 +14,7 @@ import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
@@ -57,6 +58,7 @@ export async function fetchIncomeByCategory(
eq(categories.type, "receita"),
excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(),
excludeTransactionsFromExcludedAccounts(),
),
)
.groupBy(

View File

@@ -20,6 +20,7 @@ import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
@@ -156,6 +157,7 @@ export async function fetchDashboardCategoryOverview(
and(
...buildDashboardAdminFilters({ userId, adminPayerId }),
inArray(transactions.period, [period, previousPeriod]),
excludeTransactionsFromExcludedAccounts(),
or(
and(
eq(transactions.transactionType, "Despesa"),

View File

@@ -1,12 +1,18 @@
import { RiCheckboxCircleFill } from "@remixicon/react";
import {
buildBillStatusLabel,
buildBillWidgetStatusLabel,
isBillOverdue,
} from "@/features/dashboard/bills-helpers";
import type { DashboardBill } from "@/features/dashboard/bills-queries";
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values";
import { Button } from "@/shared/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { cn } from "@/shared/utils/ui";
type BillListItemProps = {
@@ -15,8 +21,13 @@ type BillListItemProps = {
};
export function BillListItem({ bill, onPay }: BillListItemProps) {
const statusLabel = buildBillStatusLabel(bill);
const statusLabel = buildBillWidgetStatusLabel(bill);
const absoluteStatusLabel = buildBillStatusLabel(bill);
const overdue = isBillOverdue(bill);
const statusTooltipLabel =
statusLabel && statusLabel !== absoluteStatusLabel
? absoluteStatusLabel
: null;
return (
<li className="flex items-center justify-between transition-all duration-300 py-1.5">
@@ -29,14 +40,32 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
</span>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{statusLabel ? (
<span
className={cn(
"rounded-full py-0.5",
bill.isSettled && "text-success",
)}
>
{statusLabel}
</span>
statusTooltipLabel ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"cursor-help rounded-full py-0.5",
bill.isSettled && "text-success",
)}
>
{statusLabel}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{statusTooltipLabel}
</TooltipContent>
</Tooltip>
) : (
<span
className={cn(
"rounded-full py-0.5",
bill.isSettled && "text-success",
)}
>
{statusLabel}
</span>
)
) : null}
</div>
</div>

View File

@@ -56,7 +56,7 @@ export function BillPaymentDialog({
}}
>
<DialogContent
className="max-w-[calc(100%-2rem)] sm:max-w-md"
className="max-w-[calc(100%-2rem)] sm:max-w-md sm:p-8"
onEscapeKeyDown={(event) => {
if (isProcessing) {
event.preventDefault();
@@ -93,7 +93,7 @@ export function BillPaymentDialog({
{bill ? (
<div className="space-y-3">
{/* Card principal */}
<div className="rounded-xl border bg-muted/30 p-4">
<div className="rounded-xl border p-3">
<p className="mb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide">
Boleto
</p>

View File

@@ -76,6 +76,9 @@ export function DashboardGridEditable({
const [hiddenWidgets, setHiddenWidgets] = useState<string[]>(
initialPreferences?.hidden ?? [],
);
const [myAccountsShowExcluded, setMyAccountsShowExcluded] = useState(
initialPreferences?.myAccountsShowExcluded ?? true,
);
// Keep track of original state for cancel
const [originalOrder, setOriginalOrder] = useState(widgetOrder);
@@ -186,6 +189,7 @@ export function DashboardGridEditable({
if (result.success) {
setWidgetOrder(DEFAULT_WIDGET_ORDER);
setHiddenWidgets([]);
setMyAccountsShowExcluded(true);
toast.success("Preferências restauradas!");
} else {
toast.error(result.error ?? "Erro ao restaurar");
@@ -361,7 +365,16 @@ export function DashboardGridEditable({
icon={widget.icon}
action={widget.action}
>
{widget.component({ data, period })}
{widget.component({
data,
period,
widgetPreferences: {
order: widgetOrder,
hidden: hiddenWidgets,
myAccountsShowExcluded,
},
onMyAccountsShowExcludedChange: setMyAccountsShowExcluded,
})}
</ExpandableWidgetCard>
</div>
</SortableWidget>

View File

@@ -7,6 +7,7 @@ import {
RiScalesLine,
RiSubtractLine,
} from "@remixicon/react";
import { MetricsCardInfoButton } from "@/features/dashboard/components/metrics-card-info-button";
import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries";
import MoneyValues from "@/shared/components/money-values";
import {
@@ -36,6 +37,14 @@ const CARDS = [
icon: RiArrowDownLine,
invertTrend: false,
iconClass: "text-success",
helpTitle: "Como calculamos receitas",
helpLines: [
"Somamos os lançamentos do tipo Receita no período selecionado.",
"Consideramos lançamentos efetivados e não efetivados do pagador principal (admin).",
"Movimentações de contas marcadas como não consideradas no saldo total ficam fora deste card.",
"Não entram transferências internas nem lançamentos automáticos de fatura.",
"Saldo inicial também fica fora quando a conta está marcada para desconsiderá-lo das receitas.",
],
},
{
label: "Despesas",
@@ -44,14 +53,29 @@ const CARDS = [
icon: RiArrowUpLine,
invertTrend: true,
iconClass: "text-destructive",
helpTitle: "Como calculamos despesas",
helpLines: [
"Somamos os lançamentos do tipo Despesa no período selecionado.",
"Consideramos lançamentos efetivados e não efetivados do pagador principal (admin).",
"Movimentações de contas marcadas como não consideradas no saldo total ficam fora deste card.",
"Não entram transferências internas nem lançamentos automáticos de fatura.",
"O valor mostrado é a saída efetiva do período, sempre em número positivo no card.",
],
},
{
label: "Balanço",
subtitle: "Receitas menos despesas",
subtitle: "Receitas, despesas e ajustes entre contas",
key: "balanco",
icon: RiScalesLine,
invertTrend: false,
iconClass: "text-warning",
helpTitle: "Como calculamos o balanço",
helpLines: [
"Partimos de receitas menos despesas do período.",
"Receitas e despesas de contas marcadas como não consideradas no saldo total ficam fora do cálculo base.",
"Depois aplicamos ajustes de transferências entre contas consideradas e não consideradas no saldo total.",
"Se a transferência entra em conta considerada, soma. Se sai de conta considerada para conta não considerada, subtrai.",
],
},
{
label: "Previsto",
@@ -60,6 +84,13 @@ const CARDS = [
icon: RiCalendarCheckLine,
invertTrend: false,
iconClass: "text-cyan-600",
helpTitle: "Como calculamos o previsto",
helpLines: [
"Acumulamos o balanço mês a mês até o período atual.",
"Ele usa a mesma regra do card de balanço em cada mês do histórico.",
"Receitas e despesas de contas marcadas como não consideradas no saldo total ficam fora desse acumulado.",
"Por isso também reflete ajustes de transferências entre contas consideradas e não consideradas.",
],
},
] as const;
@@ -104,7 +135,16 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
return (
<div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
{CARDS.map(
({ label, subtitle, key, icon: Icon, invertTrend, iconClass }) => {
({
label,
subtitle,
key,
icon: Icon,
invertTrend,
iconClass,
helpTitle,
helpLines,
}) => {
const metric = metrics[key];
const trend = getTrend(metric.current, metric.previous);
const TrendIcon = TREND_ICONS[trend];
@@ -119,9 +159,14 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="flex items-center gap-1 tracking-tight">
<CardTitle className="flex items-center gap-1.5 tracking-tight">
<Icon className={cn("size-4", iconClass)} aria-hidden />
{label}
<MetricsCardInfoButton
label={label}
helpTitle={helpTitle}
helpLines={helpLines}
/>
</CardTitle>
<CardDescription className="mt-1.5 tracking-tight">
{subtitle}

View File

@@ -4,8 +4,10 @@ import {
buildInvoiceDetailsHref,
buildInvoiceInitials,
formatInvoicePaymentDate,
formatInvoiceWidgetPaymentDate,
getInvoiceShareLabel,
parseInvoiceDueDate,
parseInvoiceWidgetDueDate,
} from "@/features/dashboard/invoices-helpers";
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
import MoneyValues from "@/shared/components/money-values";
@@ -20,6 +22,11 @@ import {
HoverCardContent,
HoverCardTrigger,
} from "@/shared/components/ui/hover-card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
import { getAvatarSrc } from "@/shared/lib/payers/utils";
import { isDateOnlyPast } from "@/shared/utils/date";
@@ -31,14 +38,22 @@ type InvoiceListItemProps = {
};
export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
const dueInfo = parseInvoiceDueDate(invoice.period, invoice.dueDay);
const dueInfo = parseInvoiceWidgetDueDate(invoice.period, invoice.dueDay);
const absoluteDueInfo = parseInvoiceDueDate(invoice.period, invoice.dueDay);
const isPaid = invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID;
const isOverdue =
!isPaid && dueInfo.date !== null && isDateOnlyPast(dueInfo.date);
const paymentInfo = formatInvoicePaymentDate(invoice.paidAt);
const paymentInfo = formatInvoiceWidgetPaymentDate(invoice.paidAt);
const absolutePaymentInfo = formatInvoicePaymentDate(invoice.paidAt);
const breakdown = invoice.pagadorBreakdown ?? [];
const hasBreakdown = breakdown.length > 0;
const detailHref = buildInvoiceDetailsHref(invoice.cardId, invoice.period);
const dueTooltipLabel =
dueInfo.label !== absoluteDueInfo.label ? absoluteDueInfo.label : null;
const paymentTooltipLabel =
paymentInfo?.label && paymentInfo.label !== absolutePaymentInfo?.label
? absolutePaymentInfo?.label
: null;
const linkNode = (
<Link
@@ -113,9 +128,33 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
)}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{!isPaid ? <span>{dueInfo.label}</span> : null}
{!isPaid ? (
dueTooltipLabel ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-help">{dueInfo.label}</span>
</TooltipTrigger>
<TooltipContent side="top">{dueTooltipLabel}</TooltipContent>
</Tooltip>
) : (
<span>{dueInfo.label}</span>
)
) : null}
{isPaid && paymentInfo ? (
<span className="text-success">{paymentInfo.label}</span>
paymentTooltipLabel ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-help text-success">
{paymentInfo.label}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{paymentTooltipLabel}
</TooltipContent>
</Tooltip>
) : (
<span className="text-success">{paymentInfo.label}</span>
)
) : null}
</div>
</div>

View File

@@ -63,7 +63,7 @@ export function InvoicePaymentDialog({
}}
>
<DialogContent
className="max-w-[calc(100%-2rem)] sm:max-w-md"
className="max-w-[calc(100%-2rem)] sm:max-w-md sm:p-8"
onEscapeKeyDown={(event) => {
if (isProcessing) {
event.preventDefault();
@@ -100,7 +100,7 @@ export function InvoicePaymentDialog({
{invoice ? (
<div className="space-y-3">
{/* Card principal */}
<div className="flex items-center gap-3 rounded-xl border bg-muted/30 p-4">
<div className="flex items-center gap-3 rounded-xl border p-4">
<InvoiceLogo
cardName={invoice.cardName}
logo={invoice.logo}

View File

@@ -0,0 +1,44 @@
"use client";
import { RiInformationLine } from "@remixicon/react";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/shared/components/ui/hover-card";
type MetricsCardInfoButtonProps = {
label: string;
helpTitle: string;
helpLines: readonly string[];
};
export function MetricsCardInfoButton({
label,
helpTitle,
helpLines,
}: MetricsCardInfoButtonProps) {
return (
<HoverCard openDelay={150}>
<HoverCardTrigger asChild>
<button
type="button"
className="inline-flex items-center rounded-full text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
aria-label={`Entenda como ${label.toLowerCase()} é calculado`}
>
<RiInformationLine className="size-4" aria-hidden />
</button>
</HoverCardTrigger>
<HoverCardContent align="start" className="w-80 space-y-3">
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">{helpTitle}</p>
</div>
<ul className="space-y-2 text-xs text-muted-foreground">
{helpLines.map((line) => (
<li key={`${label}-${line}`}>{line}</li>
))}
</ul>
</HoverCardContent>
</HoverCard>
);
}

View File

@@ -1,39 +1,123 @@
import { RiBarChartBoxLine, RiExternalLinkLine } from "@remixicon/react";
"use client";
import {
RiBarChartBoxLine,
RiExternalLinkLine,
RiEyeLine,
RiEyeOffLine,
} from "@remixicon/react";
import Image from "next/image";
import Link from "next/link";
import { useTransition } from "react";
import { toast } from "sonner";
import type { DashboardAccount } from "@/features/dashboard/accounts-queries";
import { updateMyAccountsWidgetPreference } from "@/features/dashboard/widgets/actions";
import MoneyValues from "@/shared/components/money-values";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { CardFooter } from "@/shared/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { formatPeriodForUrl } from "@/shared/utils/period";
type MyAccountsWidgetProps = {
accounts: DashboardAccount[];
showExcludedAccounts: boolean;
onShowExcludedAccountsChange?: (value: boolean) => void;
totalBalance: number;
period: string;
};
export function MyAccountsWidget({
accounts,
showExcludedAccounts,
onShowExcludedAccountsChange,
totalBalance,
period,
}: MyAccountsWidgetProps) {
const visibleAccounts = accounts.filter(
(account) => !account.excludeFromBalance,
);
const [isPending, startTransition] = useTransition();
const excludedAccountsCount = accounts.filter(
(account) => account.excludeFromBalance,
).length;
const visibleAccounts = showExcludedAccounts
? accounts
: accounts.filter((account) => !account.excludeFromBalance);
const displayedAccounts = visibleAccounts.slice(0, 5);
const remainingCount = visibleAccounts.length - displayedAccounts.length;
const hiddenExcludedAccountsCount = showExcludedAccounts
? 0
: excludedAccountsCount;
const toggleButtonLabel = showExcludedAccounts
? "Ocultar contas não consideradas"
: "Mostrar contas não consideradas";
const handleToggleExcludedAccounts = () => {
const nextShowExcludedAccounts = !showExcludedAccounts;
onShowExcludedAccountsChange?.(nextShowExcludedAccounts);
startTransition(async () => {
const result = await updateMyAccountsWidgetPreference({
showExcludedAccounts: nextShowExcludedAccounts,
});
if (!result.success) {
onShowExcludedAccountsChange?.(!nextShowExcludedAccounts);
toast.error(result.error ?? "Erro ao salvar preferência");
}
});
};
return (
<>
<div className="flex justify-between py-1">
Saldo Total
<MoneyValues className="text-2xl" amount={totalBalance} />
<div className="flex items-start justify-between gap-3 py-1">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Saldo Total</p>
<MoneyValues className="text-2xl" amount={totalBalance} />
</div>
{excludedAccountsCount > 0 ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={isPending}
className="mt-0.5 text-muted-foreground"
aria-label={toggleButtonLabel}
onClick={handleToggleExcludedAccounts}
>
{showExcludedAccounts ? (
<RiEyeOffLine className="size-4" aria-hidden />
) : (
<RiEyeLine className="size-4" aria-hidden />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-xs">
<p className="text-xs">{toggleButtonLabel}</p>
</TooltipContent>
</Tooltip>
) : null}
</div>
{hiddenExcludedAccountsCount > 0 ? (
<p className="pb-2 text-xs text-muted-foreground">
{hiddenExcludedAccountsCount}{" "}
{hiddenExcludedAccountsCount === 1
? "conta não considerada oculta"
: "contas não consideradas ocultas"}
</p>
) : null}
<div>
{displayedAccounts.length === 0 ? (
{accounts.length === 0 ? (
<div className="-mt-10">
<WidgetEmptyState
icon={
@@ -43,6 +127,14 @@ export function MyAccountsWidget({
description="Cadastre suas contas bancárias para acompanhar os saldos e movimentações."
/>
</div>
) : displayedAccounts.length === 0 ? (
<div className="-mt-10">
<WidgetEmptyState
icon={<RiEyeOffLine className="size-6 text-muted-foreground" />}
title="As contas não consideradas estão ocultas"
description="Use o botão no topo do widget para mostrá-las novamente."
/>
</div>
) : (
<ul className="flex flex-col">
{displayedAccounts.map((account) => {
@@ -60,6 +152,7 @@ export function MyAccountsWidget({
src={logoSrc}
alt={`Logo da conta ${account.name}`}
fill
sizes="38px"
className="object-contain rounded-full"
/>
) : null}
@@ -79,6 +172,26 @@ export function MyAccountsWidget({
aria-hidden
/>
</Link>
{account.excludeFromBalance ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex cursor-help ml-2">
<Badge className="font-normal" variant="info">
Não considerada
</Badge>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs">
Esta conta aparece na lista, mas não entra no
cálculo do saldo total porque está marcada para
desconsiderar do saldo total.
</p>
</TooltipContent>
</Tooltip>
) : null}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">{account.accountType}</span>
</div>
@@ -95,7 +208,7 @@ export function MyAccountsWidget({
)}
</div>
{visibleAccounts.length > displayedAccounts.length ? (
{remainingCount > 0 ? (
<CardFooter className="border-border/60 border-t pt-4 text-sm text-muted-foreground">
+{remainingCount} contas não exibidas
</CardFooter>

View File

@@ -17,12 +17,18 @@ import type { PaymentMethodsData } from "@/features/dashboard/payments/payment-m
import type { PaymentStatusData } from "@/features/dashboard/payments/payment-status-queries";
import type { PurchasesByCategoryData } from "@/features/dashboard/purchases-by-category-queries";
import type { TopEstablishmentsData } from "@/features/dashboard/top-establishments-queries";
import { excludeTransactionsFromExcludedAccounts } from "@/features/dashboard/transaction-filters";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import {
compareDateOnly,
getBusinessDateString,
isDateOnlyPast,
} from "@/shared/utils/date";
import { safeToNumber as toNumber } from "@/shared/utils/number";
const PAYMENT_METHOD_BOLETO = "Boleto";
@@ -54,6 +60,7 @@ type CurrentPeriodTransactionRow = {
categoryType: string | null;
cardLogo: string | null;
accountLogo: string | null;
accountExcludeInitialBalanceFromIncome: boolean | null;
};
type CategoryOption = PurchasesByCategoryData["categories"][number];
@@ -112,6 +119,21 @@ const shouldIncludeWithoutAutoInvoice = (note: string | null | undefined) =>
const shouldIncludeWithoutAutoGenerated = (note: string | null | undefined) =>
!isInitialBalanceNote(note) && !isAutoInvoiceNote(note);
const shouldIncludeInPaymentStatus = (row: CurrentPeriodTransactionRow) => {
if (!shouldIncludeWithoutAutoInvoice(row.note)) {
return false;
}
if (
isInitialBalanceNote(row.note) &&
row.accountExcludeInitialBalanceFromIncome === true
) {
return false;
}
return true;
};
const shouldIncludeNamedItem = (name: string) => {
const normalized = name.trim().toLowerCase();
@@ -126,9 +148,30 @@ const shouldIncludeNamedItem = (name: string) => {
return true;
};
const compareDateOnlyAscWithNullsLast = (
left: string | null,
right: string | null,
) => {
if (!left && !right) return 0;
if (!left) return 1;
if (!right) return -1;
return compareDateOnly(left, right);
};
const compareDateOnlyDescWithNullsLast = (
left: string | null,
right: string | null,
) => {
if (!left && !right) return 0;
if (!left) return 1;
if (!right) return -1;
return compareDateOnly(right, left);
};
const buildBillsSnapshot = (
rows: CurrentPeriodTransactionRow[],
): DashboardBillsSnapshot => {
const today = getBusinessDateString();
const bills = rows
.filter((row) => row.paymentMethod === PAYMENT_METHOD_BOLETO)
.map((row) => ({
@@ -143,17 +186,44 @@ const buildBillsSnapshot = (
}))
.sort((a, b) => {
if (a.isSettled !== b.isSettled) {
return Number(a.isSettled) - Number(b.isSettled);
return a.isSettled ? 1 : -1;
}
const dueA = a.dueDate
? new Date(a.dueDate).getTime()
: Number.POSITIVE_INFINITY;
const dueB = b.dueDate
? new Date(b.dueDate).getTime()
: Number.POSITIVE_INFINITY;
if (dueA !== dueB) {
return dueA - dueB;
if (!a.isSettled && !b.isSettled) {
const aIsOverdue = a.dueDate ? isDateOnlyPast(a.dueDate, today) : false;
const bIsOverdue = b.dueDate ? isDateOnlyPast(b.dueDate, today) : false;
if (aIsOverdue !== bIsOverdue) {
return aIsOverdue ? -1 : 1;
}
const dueDateDiff = compareDateOnlyAscWithNullsLast(
a.dueDate,
b.dueDate,
);
if (dueDateDiff !== 0) {
return dueDateDiff;
}
const amountDiff = b.amount - a.amount;
if (amountDiff !== 0) {
return amountDiff;
}
}
if (a.isSettled && b.isSettled) {
const paidAtDiff = compareDateOnlyDescWithNullsLast(
a.boletoPaymentDate,
b.boletoPaymentDate,
);
if (paidAtDiff !== 0) {
return paidAtDiff;
}
const amountDiff = b.amount - a.amount;
if (amountDiff !== 0) {
return amountDiff;
}
}
return a.name.localeCompare(b.name, "pt-BR");
@@ -181,7 +251,7 @@ const buildPaymentStatusData = (
for (const row of rows) {
if (
!shouldIncludeWithoutAutoInvoice(row.note) ||
!shouldIncludeInPaymentStatus(row) ||
(row.transactionType !== TRANSACTION_TYPE_INCOME &&
row.transactionType !== TRANSACTION_TYPE_EXPENSE)
) {
@@ -496,6 +566,8 @@ export async function fetchDashboardCurrentPeriodOverview(
categoryType: categories.type,
cardLogo: cards.logo,
accountLogo: financialAccounts.logo,
accountExcludeInitialBalanceFromIncome:
financialAccounts.excludeInitialBalanceFromIncome,
})
.from(transactions)
.leftJoin(cards, eq(transactions.cardId, cards.id))
@@ -509,6 +581,7 @@ export async function fetchDashboardCurrentPeriodOverview(
eq(transactions.userId, userId),
eq(transactions.period, period),
eq(transactions.payerId, adminPayerId),
excludeTransactionsFromExcludedAccounts(),
),
)
.orderBy(

View File

@@ -1,9 +1,10 @@
import { and, asc, eq, gte, lte, ne, sum } from "drizzle-orm";
import { and, asc, eq, gte, inArray, lte, sum } from "drizzle-orm";
import { financialAccounts, transactions } from "@/db/schema";
import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
@@ -36,12 +37,14 @@ export type DashboardCardMetrics = {
type PeriodTotals = {
receitas: number;
despesas: number;
transferAdjustment: number;
balanco: number;
};
const createEmptyTotals = (): PeriodTotals => ({
receitas: 0,
despesas: 0,
transferAdjustment: 0,
balanco: 0,
});
@@ -90,6 +93,7 @@ export async function fetchDashboardCardMetrics(
period: transactions.period,
transactionType: transactions.transactionType,
totalAmount: sum(transactions.amount).as("total"),
accountExcludeFromBalance: financialAccounts.excludeFromBalance,
})
.from(transactions)
.leftJoin(
@@ -101,12 +105,21 @@ export async function fetchDashboardCardMetrics(
...buildDashboardAdminFilters({ userId, adminPayerId }),
gte(transactions.period, startPeriod),
lte(transactions.period, period),
ne(transactions.transactionType, TRANSFERENCIA),
inArray(transactions.transactionType, [
RECEITA,
DESPESA,
TRANSFERENCIA,
]),
excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(),
excludeTransactionsFromExcludedAccounts(),
),
)
.groupBy(transactions.period, transactions.transactionType)
.groupBy(
transactions.period,
transactions.transactionType,
financialAccounts.excludeFromBalance,
)
.orderBy(asc(transactions.period), asc(transactions.transactionType));
const periodTotals = new Map<string, PeriodTotals>();
@@ -119,6 +132,11 @@ export async function fetchDashboardCardMetrics(
totals.receitas += total;
} else if (row.transactionType === DESPESA) {
totals.despesas += Math.abs(total);
} else if (
row.transactionType === TRANSFERENCIA &&
row.accountExcludeFromBalance === false
) {
totals.transferAdjustment += total;
}
}
@@ -139,7 +157,8 @@ export async function fetchDashboardCardMetrics(
for (const key of periodRange) {
const totals = ensurePeriodTotals(periodTotals, key);
totals.balanco = totals.receitas - totals.despesas;
totals.balanco =
totals.receitas - totals.despesas + totals.transferAdjustment;
runningForecast += totals.balanco;
forecastByPeriod.set(key, runningForecast);
}

View File

@@ -4,6 +4,7 @@ import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
@@ -51,6 +52,7 @@ export async function fetchIncomeExpenseBalance(
period: transactions.period,
transactionType: transactions.transactionType,
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
accountExcludeFromBalance: financialAccounts.excludeFromBalance,
})
.from(transactions)
.leftJoin(
@@ -61,37 +63,62 @@ export async function fetchIncomeExpenseBalance(
and(
...buildDashboardAdminFilters({ userId, adminPayerId }),
inArray(transactions.period, periods),
inArray(transactions.transactionType, ["Receita", "Despesa"]),
inArray(transactions.transactionType, [
"Receita",
"Despesa",
"Transferência",
]),
excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(),
excludeTransactionsFromExcludedAccounts(),
),
)
.groupBy(transactions.period, transactions.transactionType);
.groupBy(
transactions.period,
transactions.transactionType,
financialAccounts.excludeFromBalance,
);
// Build lookup from query results
const dataMap = new Map<string, { income: number; expense: number }>();
const dataMap = new Map<
string,
{ income: number; expense: number; transferAdjustment: number }
>();
for (const row of rows) {
if (!row.period) continue;
const entry = dataMap.get(row.period) ?? { income: 0, expense: 0 };
const total = Math.abs(toNumber(row.total));
const entry = dataMap.get(row.period) ?? {
income: 0,
expense: 0,
transferAdjustment: 0,
};
const total = toNumber(row.total);
if (row.transactionType === "Receita") {
entry.income = total;
entry.income += Math.abs(total);
} else if (row.transactionType === "Despesa") {
entry.expense = total;
entry.expense += Math.abs(total);
} else if (
row.transactionType === "Transferência" &&
row.accountExcludeFromBalance === false
) {
entry.transferAdjustment += total;
}
dataMap.set(row.period, entry);
}
// Build result array preserving period order
const months = periods.map((period) => {
const entry = dataMap.get(period) ?? { income: 0, expense: 0 };
const entry = dataMap.get(period) ?? {
income: 0,
expense: 0,
transferAdjustment: 0,
};
return {
month: period,
monthLabel: formatPeriodMonthShort(period).toLowerCase(),
income: entry.income,
expense: entry.expense,
balance: entry.income - entry.expense,
balance: entry.income - entry.expense + entry.transferAdjustment,
};
});

View File

@@ -7,7 +7,9 @@ import {
import { getBusinessDateString } from "@/shared/utils/date";
import {
buildDueDateInfoFromPeriodDay,
buildRelativeDueDateInfoFromPeriodDay,
formatFinancialDateLabel,
formatRelativeFinancialDateLabel,
} from "@/shared/utils/financial-dates";
import { formatPercentage } from "@/shared/utils/percentage";
import { formatPeriodForUrl } from "@/shared/utils/period";
@@ -45,6 +47,13 @@ export const parseInvoiceDueDate = (
return buildDueDateInfoFromPeriodDay(period, dueDay);
};
export const parseInvoiceWidgetDueDate = (
period: string,
dueDay: string,
): InvoiceDueDateInfo => {
return buildRelativeDueDateInfoFromPeriodDay(period, dueDay);
};
export const formatInvoicePaymentDate = (
value: string | null,
): InvoicePaymentDateInfo | null => {
@@ -58,6 +67,19 @@ export const formatInvoicePaymentDate = (
};
};
export const formatInvoiceWidgetPaymentDate = (
value: string | null,
): InvoicePaymentDateInfo | null => {
const label = formatRelativeFinancialDateLabel(value, "paid");
if (!label) {
return null;
}
return {
label,
};
};
export const getCurrentDateString = () => getBusinessDateString();
const formatInvoiceSharePercentage = (value: number) => {

View File

@@ -7,7 +7,13 @@ import {
INVOICE_STATUS_VALUES,
type InvoicePaymentStatus,
} from "@/shared/lib/invoices";
import { toDateOnlyString } from "@/shared/utils/date";
import {
buildDateOnlyStringFromPeriodDay,
compareDateOnly,
getBusinessDateString,
isDateOnlyPast,
toDateOnlyString,
} from "@/shared/utils/date";
import { safeToNumber as toNumber } from "@/shared/utils/number";
type RawDashboardInvoice = {
@@ -68,10 +74,31 @@ const isInvoiceStatus = (value: unknown): value is InvoicePaymentStatus =>
const buildFallbackId = (cardId: string, period: string) =>
`${cardId}:${period}`;
const compareDateOnlyAscWithNullsLast = (
left: string | null,
right: string | null,
) => {
if (!left && !right) return 0;
if (!left) return 1;
if (!right) return -1;
return compareDateOnly(left, right);
};
const compareDateOnlyDescWithNullsLast = (
left: string | null,
right: string | null,
) => {
if (!left && !right) return 0;
if (!left) return 1;
if (!right) return -1;
return compareDateOnly(right, left);
};
export async function fetchDashboardInvoices(
userId: string,
period: string,
): Promise<DashboardInvoicesSnapshot> {
const today = getBusinessDateString();
const paymentRows = await db
.select({
note: transactions.note,
@@ -258,8 +285,53 @@ export async function fetchDashboardInvoices(
}
invoiceList.sort((a, b) => {
// Ordena do maior valor para o menor
return Math.abs(b.totalAmount) - Math.abs(a.totalAmount);
const aIsPending = a.paymentStatus === INVOICE_PAYMENT_STATUS.PENDING;
const bIsPending = b.paymentStatus === INVOICE_PAYMENT_STATUS.PENDING;
if (aIsPending !== bIsPending) {
return aIsPending ? -1 : 1;
}
if (aIsPending && bIsPending) {
const aDueDate = buildDateOnlyStringFromPeriodDay(a.period, a.dueDay);
const bDueDate = buildDateOnlyStringFromPeriodDay(b.period, b.dueDay);
const aIsOverdue = aDueDate ? isDateOnlyPast(aDueDate, today) : false;
const bIsOverdue = bDueDate ? isDateOnlyPast(bDueDate, today) : false;
if (aIsOverdue !== bIsOverdue) {
return aIsOverdue ? -1 : 1;
}
const dueDateDiff = compareDateOnlyAscWithNullsLast(aDueDate, bDueDate);
if (dueDateDiff !== 0) {
return dueDateDiff;
}
const amountDiff = Math.abs(b.totalAmount) - Math.abs(a.totalAmount);
if (amountDiff !== 0) {
return amountDiff;
}
}
if (!aIsPending && !bIsPending) {
const paidAtDiff = compareDateOnlyDescWithNullsLast(a.paidAt, b.paidAt);
if (paidAtDiff !== 0) {
return paidAtDiff;
}
const amountDiff = Math.abs(b.totalAmount) - Math.abs(a.totalAmount);
if (amountDiff !== 0) {
return amountDiff;
}
}
const nameDiff = a.cardName.localeCompare(b.cardName, "pt-BR", {
sensitivity: "base",
});
if (nameDiff !== 0) {
return nameDiff;
}
return a.id.localeCompare(b.id);
});
const totalPending = invoiceList.reduce((total, invoice) => {

View File

@@ -1,5 +1,6 @@
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { payers, transactions } from "@/db/schema";
import { financialAccounts, payers, transactions } from "@/db/schema";
import { excludeTransactionsFromExcludedAccounts } from "@/features/dashboard/transaction-filters";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { db } from "@/shared/lib/db";
import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
@@ -41,11 +42,16 @@ export async function fetchDashboardPayers(
})
.from(transactions)
.innerJoin(payers, eq(transactions.payerId, payers.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
eq(transactions.userId, userId),
inArray(transactions.period, [period, previousPeriod]),
eq(transactions.transactionType, "Despesa"),
excludeTransactionsFromExcludedAccounts(),
or(
isNull(transactions.note),
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,

View File

@@ -1,8 +1,10 @@
import { and, inArray, sql } from "drizzle-orm";
import { transactions } from "@/db/schema";
import { and, eq, inArray, sql } from "drizzle-orm";
import { financialAccounts, transactions } from "@/db/schema";
import {
buildDashboardAdminPeriodFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
@@ -52,6 +54,10 @@ export async function fetchPaymentStatus(
`,
})
.from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
...buildDashboardAdminPeriodFilters({
@@ -61,6 +67,8 @@ export async function fetchPaymentStatus(
}),
inArray(transactions.transactionType, ["Receita", "Despesa"]),
excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(),
excludeTransactionsFromExcludedAccounts(),
),
)
.groupBy(transactions.transactionType);

View File

@@ -1,4 +1,4 @@
import { and, asc, eq, gte, inArray, lte, ne, sum } from "drizzle-orm";
import { and, asc, eq, gte, inArray, lte, sum } from "drizzle-orm";
import { financialAccounts, transactions } from "@/db/schema";
import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries";
import type {
@@ -9,6 +9,7 @@ import {
buildDashboardAdminFilters,
excludeAutoInvoiceEntries,
excludeInitialBalanceWhenConfigured,
excludeTransactionsFromExcludedAccounts,
} from "@/features/dashboard/transaction-filters";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
@@ -30,6 +31,7 @@ const TRANSACTION_TYPE_TRANSFER = "Transferência";
type PeriodTotals = {
receitas: number;
despesas: number;
transferAdjustment: number;
balanco: number;
};
@@ -37,6 +39,7 @@ type PeriodSummaryRow = {
period: string | null;
transactionType: string;
totalAmount: string | number | null;
accountExcludeFromBalance: boolean | null;
};
export type DashboardPeriodOverview = {
@@ -47,6 +50,7 @@ export type DashboardPeriodOverview = {
const createEmptyTotals = (): PeriodTotals => ({
receitas: 0,
despesas: 0,
transferAdjustment: 0,
balanco: 0,
});
@@ -106,6 +110,7 @@ export async function fetchDashboardPeriodOverview(
period: transactions.period,
transactionType: transactions.transactionType,
totalAmount: sum(transactions.amount).as("total"),
accountExcludeFromBalance: financialAccounts.excludeFromBalance,
})
.from(transactions)
.leftJoin(
@@ -120,13 +125,18 @@ export async function fetchDashboardPeriodOverview(
inArray(transactions.transactionType, [
TRANSACTION_TYPE_INCOME,
TRANSACTION_TYPE_EXPENSE,
TRANSACTION_TYPE_TRANSFER,
]),
ne(transactions.transactionType, TRANSACTION_TYPE_TRANSFER),
excludeAutoInvoiceEntries(),
excludeInitialBalanceWhenConfigured(),
excludeTransactionsFromExcludedAccounts(),
),
)
.groupBy(transactions.period, transactions.transactionType)
.groupBy(
transactions.period,
transactions.transactionType,
financialAccounts.excludeFromBalance,
)
.orderBy(
asc(transactions.period),
asc(transactions.transactionType),
@@ -146,6 +156,11 @@ export async function fetchDashboardPeriodOverview(
totals.receitas += total;
} else if (row.transactionType === TRANSACTION_TYPE_EXPENSE) {
totals.despesas += Math.abs(total);
} else if (
row.transactionType === TRANSACTION_TYPE_TRANSFER &&
row.accountExcludeFromBalance === false
) {
totals.transferAdjustment += total;
}
}
@@ -164,7 +179,8 @@ export async function fetchDashboardPeriodOverview(
for (const key of periodRange) {
const totals = ensurePeriodTotals(periodTotals, key);
totals.balanco = totals.receitas - totals.despesas;
totals.balanco =
totals.receitas - totals.despesas + totals.transferAdjustment;
runningForecast += totals.balanco;
forecastByPeriod.set(key, runningForecast);
}
@@ -179,7 +195,7 @@ export async function fetchDashboardPeriodOverview(
monthLabel: formatPeriodMonthShort(chartPeriod).toLowerCase(),
income: entry.receitas,
expense: entry.despesas,
balance: entry.receitas - entry.despesas,
balance: entry.balanco,
};
});

View File

@@ -5,6 +5,8 @@ import {
INITIAL_BALANCE_NOTE,
} from "@/shared/lib/accounts/constants";
export { excludeTransactionsFromExcludedAccounts } from "@/shared/lib/accounts/query-filters";
type DashboardAdminFiltersParams = {
userId: string;
adminPayerId: string;

View File

@@ -8,36 +8,44 @@ import { db, schema } from "@/shared/lib/db";
export type WidgetPreferences = {
order: string[];
hidden: string[];
myAccountsShowExcluded?: boolean;
};
type WidgetLayoutPreferences = Pick<WidgetPreferences, "order" | "hidden">;
async function upsertUserWidgetPreferences(
userId: string,
updates: Partial<WidgetPreferences>,
): Promise<void> {
const existing = await db
.select({ dashboardWidgets: schema.userPreferences.dashboardWidgets })
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, userId))
.limit(1);
const current = existing[0]?.dashboardWidgets;
const next: WidgetPreferences = {
order: current?.order ?? [],
hidden: current?.hidden ?? [],
myAccountsShowExcluded: current?.myAccountsShowExcluded,
...updates,
};
await db
.insert(schema.userPreferences)
.values({ userId, dashboardWidgets: next })
.onConflictDoUpdate({
target: schema.userPreferences.userId,
set: { dashboardWidgets: next, updatedAt: new Date() },
});
}
export async function updateWidgetPreferences(
preferences: WidgetPreferences,
preferences: WidgetLayoutPreferences,
): Promise<{ success: boolean; error?: string }> {
try {
const user = await getUser();
// Check if preferences exist
const existing = await db
.select({ id: schema.userPreferences.id })
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, user.id))
.limit(1);
if (existing.length > 0) {
await db
.update(schema.userPreferences)
.set({
dashboardWidgets: preferences,
updatedAt: new Date(),
})
.where(eq(schema.userPreferences.userId, user.id));
} else {
await db.insert(schema.userPreferences).values({
userId: user.id,
dashboardWidgets: preferences,
});
}
await upsertUserWidgetPreferences(user.id, preferences);
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
@@ -46,6 +54,24 @@ export async function updateWidgetPreferences(
}
}
export async function updateMyAccountsWidgetPreference({
showExcludedAccounts,
}: {
showExcludedAccounts: boolean;
}): Promise<{ success: boolean; error?: string }> {
try {
const user = await getUser();
await upsertUserWidgetPreferences(user.id, {
myAccountsShowExcluded: showExcludedAccounts,
});
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Error updating my accounts widget preference:", error);
return { success: false, error: "Erro ao salvar preferência do widget" };
}
}
export async function resetWidgetPreferences(): Promise<{
success: boolean;
error?: string;

View File

@@ -31,6 +31,7 @@ import { PaymentStatusWidget } from "@/features/dashboard/components/payment-sta
import { PurchasesByCategoryWidget } from "@/features/dashboard/components/purchases-by-category-widget";
import { RecurringExpensesWidget } from "@/features/dashboard/components/recurring-expenses-widget";
import { SpendingOverviewWidget } from "@/features/dashboard/components/spending-overview-widget";
import type { WidgetPreferences } from "@/features/dashboard/widgets/actions";
import type { DashboardData } from "../fetch-dashboard-data";
export type WidgetConfig = {
@@ -38,7 +39,12 @@ export type WidgetConfig = {
title: string;
subtitle: string;
icon: ReactNode;
component: (props: { data: DashboardData; period: string }) => ReactNode;
component: (props: {
data: DashboardData;
period: string;
widgetPreferences: WidgetPreferences;
onMyAccountsShowExcludedChange?: (value: boolean) => void;
}) => ReactNode;
action?: ReactNode;
};
@@ -48,9 +54,16 @@ export const widgetsConfig: WidgetConfig[] = [
title: "Minhas Contas",
subtitle: "Saldo consolidado disponível",
icon: <RiBarChartBoxLine className="size-4" />,
component: ({ data, period }) => (
component: ({
data,
period,
widgetPreferences,
onMyAccountsShowExcludedChange,
}) => (
<MyAccountsWidget
accounts={data.accountsSnapshot.accounts}
showExcludedAccounts={widgetPreferences.myAccountsShowExcluded ?? true}
onShowExcludedAccountsChange={onMyAccountsShowExcludedChange}
totalBalance={data.accountsSnapshot.totalBalance}
period={period}
/>

View File

@@ -0,0 +1,153 @@
import { RiDeleteBinLine } from "@remixicon/react";
import Image from "next/image";
import { Button } from "@/shared/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import { resolveLogoSrc } from "@/shared/lib/logo";
import type { InboxItem, InboxStatus } from "./types";
const DEFAULT_INBOX_APP_LOGO = "/avatars/default_icon.png";
function findMatchingLogo(
sourceAppName: string | null,
appLogoMap: Record<string, string>,
): string | null {
if (!sourceAppName) return null;
const appName = sourceAppName.toLowerCase();
if (appLogoMap[appName]) return resolveLogoSrc(appLogoMap[appName]);
for (const [name, logo] of Object.entries(appLogoMap)) {
if (name.includes(appName) || appName.includes(name)) {
return resolveLogoSrc(logo);
}
}
return null;
}
type InboxBulkActionsProps = {
status: InboxStatus;
items: InboxItem[];
activeApp: string | null;
appFilterOptions: string[];
selectedIds: string[];
allSelected: boolean;
appLogoMap: Record<string, string>;
onAppChange: (app: string) => void;
onToggleSelectAll: () => void;
onSelectionBulkRequest: (status: InboxStatus) => void;
onBulkDeleteRequest: (status: "processed" | "discarded") => void;
};
export function InboxBulkActions({
status,
items,
activeApp,
appFilterOptions,
selectedIds,
allSelected,
appLogoMap,
onAppChange,
onToggleSelectAll,
onSelectionBulkRequest,
onBulkDeleteRequest,
}: InboxBulkActionsProps) {
const getAppLogo = (appName: string | null) =>
findMatchingLogo(appName, appLogoMap) ?? DEFAULT_INBOX_APP_LOGO;
const appFilter =
appFilterOptions.length > 0 ? (
<Select value={activeApp ?? "all"} onValueChange={onAppChange}>
<SelectTrigger className="w-[190px]">
<SelectValue>
<span className="flex min-w-0 items-center gap-2">
<div className="relative size-5 shrink-0 overflow-hidden rounded-full">
<Image
src={
activeApp ? getAppLogo(activeApp) : DEFAULT_INBOX_APP_LOGO
}
alt=""
fill
sizes="20px"
className="object-cover"
/>
</div>
<span className="truncate">{activeApp ?? "Todos"}</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<span className="flex items-center gap-2">
<div className="relative size-5 shrink-0 overflow-hidden rounded-full">
<Image
src={DEFAULT_INBOX_APP_LOGO}
alt=""
fill
sizes="20px"
className="object-cover"
/>
</div>
<span>Todos</span>
</span>
</SelectItem>
{appFilterOptions.map((app) => (
<SelectItem key={app} value={app}>
<span className="flex min-w-0 items-center gap-2">
<div className="relative size-5 shrink-0 overflow-hidden rounded-full">
<Image
src={getAppLogo(app)}
alt=""
fill
sizes="20px"
className="object-cover"
/>
</div>
<span className="truncate">{app}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
) : null;
return (
<div className="mb-4 flex flex-wrap items-center gap-2">
{appFilter}
{items.length > 0 ? (
<div className="ml-auto flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onToggleSelectAll}>
{allSelected ? "Cancelar seleção" : "Selecionar página"}
</Button>
{selectedIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => onSelectionBulkRequest(status)}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
{status === "pending"
? `Descartar selecionados (${selectedIds.length})`
: `Excluir selecionados (${selectedIds.length})`}
</Button>
)}
{(status === "processed" || status === "discarded") && (
<Button
variant="outline"
size="sm"
onClick={() => onBulkDeleteRequest(status)}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
{status === "processed"
? "Limpar processados"
: "Limpar descartados"}
</Button>
)}
</div>
) : null}
</div>
);
}

View File

@@ -117,13 +117,15 @@ export const InboxCard = memo(function InboxCard({
className="shrink-0"
/>
)}
<Image
src={displayLogo}
alt=""
width={32}
height={32}
className="shrink-0 rounded-full"
/>
<div className="relative size-8 shrink-0 overflow-hidden rounded-full">
<Image
src={displayLogo}
alt=""
fill
sizes="32px"
className="object-cover"
/>
</div>
<span className="truncate">
{item.sourceAppName || item.sourceApp}
</span>

View File

@@ -0,0 +1,133 @@
import { RiAtLine, RiCalendarEventLine } from "@remixicon/react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { EmptyState } from "@/shared/components/empty-state";
import { Card } from "@/shared/components/ui/card";
import { InboxCard } from "./inbox-card";
import type { InboxItem } from "./types";
// O Companion envia hora local de Brasília com 'Z' literal (não converte para UTC).
// Por isso, o timestamp armazenado no DB já tem a data correta de Brasília como componente UTC.
// Basta fatiar o ISO string sem nenhum ajuste para obter a data de Brasília do item.
function getItemDateKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
// Para "hoje" e "ontem", precisamos da data real de Brasília (UTC-3).
function getBrasiliaDateKey(date: Date): string {
const BRASILIA_OFFSET_MS = 3 * 60 * 60 * 1000;
return new Date(date.getTime() - BRASILIA_OFFSET_MS)
.toISOString()
.slice(0, 10);
}
function getGroupLabel(dateKey: string): string {
const now = new Date();
const todayKey = getBrasiliaDateKey(now);
const yesterdayKey = getBrasiliaDateKey(
new Date(now.getTime() - 24 * 60 * 60 * 1000),
);
if (dateKey === todayKey) return "Hoje";
if (dateKey === yesterdayKey) return "Ontem";
const [year, month, day] = dateKey.split("-").map(Number);
return format(new Date(year, (month ?? 1) - 1, day), "d 'de' MMMM", {
locale: ptBR,
});
}
function groupItemsByDay(
items: InboxItem[],
): { label: string; items: InboxItem[] }[] {
const groups = new Map<string, InboxItem[]>();
for (const item of items) {
const key = getItemDateKey(new Date(item.notificationTimestamp));
const group = groups.get(key);
if (group) {
group.push(item);
} else {
groups.set(key, [item]);
}
}
const sortedKeys = [...groups.keys()].sort((a, b) => b.localeCompare(a));
return sortedKeys.map((key) => ({
label: getGroupLabel(key),
items: groups.get(key) ?? [],
}));
}
type InboxItemsListProps = {
items: InboxItem[];
readonly?: boolean;
activeApp: string | null;
appLogoMap: Record<string, string>;
selectedIds: string[];
onProcess?: (item: InboxItem) => void;
onDiscard?: (item: InboxItem) => void;
onViewDetails?: (item: InboxItem) => void;
onDelete?: (item: InboxItem) => void;
onRestoreToPending?: (item: InboxItem) => void;
onSelectToggle: (id: string) => void;
};
export function InboxItemsList({
items,
readonly,
activeApp,
appLogoMap,
selectedIds,
onProcess,
onDiscard,
onViewDetails,
onDelete,
onRestoreToPending,
onSelectToggle,
}: InboxItemsListProps) {
if (items.length === 0) {
const message = activeApp
? "Nenhuma notificação deste app"
: readonly
? "Nenhuma notificação nesta aba"
: "Nenhum pré-lançamento pendente";
return (
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
<EmptyState
media={<RiAtLine className="size-6 text-primary" />}
title={message}
description="As notificações capturadas pelo app OpenMonetis Companion aparecerão aqui. Saiba mais em Ajustes > Companion."
/>
</Card>
);
}
const groups = groupItemsByDay(items);
return (
<div className="space-y-6">
{groups.map((group) => (
<div key={group.label}>
<div className="mb-3 flex items-center gap-1 text-muted-foreground">
<RiCalendarEventLine className="size-3.5 shrink-0" />
<p className="text-sm font-medium">{group.label}</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{group.items.map((item) => (
<InboxCard
key={item.id}
item={item}
readonly={readonly}
appLogoMap={appLogoMap}
onProcess={readonly ? undefined : onProcess}
onDiscard={readonly ? undefined : onDiscard}
onViewDetails={readonly ? undefined : onViewDetails}
onDelete={readonly ? onDelete : undefined}
onRestoreToPending={readonly ? onRestoreToPending : undefined}
selected={selectedIds.includes(item.id)}
onSelectToggle={onSelectToggle}
/>
))}
</div>
</div>
))}
</div>
);
}

View File

@@ -1,17 +1,5 @@
"use client";
import {
RiArrowLeftDoubleLine,
RiArrowLeftSLine,
RiArrowRightDoubleLine,
RiArrowRightSLine,
RiAtLine,
RiCalendarEventLine,
RiDeleteBinLine,
} from "@remixicon/react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import Image from "next/image";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
useCallback,
@@ -30,31 +18,15 @@ import {
markInboxAsProcessedAction,
restoreDiscardedInboxItemAction,
} from "@/features/inbox/actions";
import {
INBOX_DEFAULT_PAGE_SIZE,
INBOX_PAGE_SIZE_OPTIONS,
} from "@/features/inbox/page-helpers";
import { INBOX_DEFAULT_PAGE_SIZE } from "@/features/inbox/page-helpers";
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import { EmptyState } from "@/shared/components/empty-state";
import { Button } from "@/shared/components/ui/button";
import { Card } from "@/shared/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/shared/components/ui/tabs";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { InboxCard } from "./inbox-card";
import { Tabs, TabsContent } from "@/shared/components/ui/tabs";
import { InboxBulkActions } from "./inbox-bulk-actions";
import { InboxDetailsDialog } from "./inbox-details-dialog";
import { InboxItemsList } from "./inbox-items-list";
import { InboxPagination } from "./inbox-pagination";
import { InboxTabs } from "./inbox-tabs";
import type {
InboxItem,
InboxPaginationState,
@@ -63,76 +35,6 @@ import type {
SelectOption,
} from "./types";
const DEFAULT_INBOX_APP_LOGO = "/avatars/default_icon.png";
// O Companion envia hora local de Brasília com 'Z' literal (não converte para UTC).
// Por isso, o timestamp armazenado no DB já tem a data correta de Brasília como componente UTC.
// Basta fatiar o ISO string sem nenhum ajuste para obter a data de Brasília do item.
function getItemDateKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
// Para "hoje" e "ontem", precisamos da data real de Brasília (UTC-3).
function getBrasiliaDateKey(date: Date): string {
const BRASILIA_OFFSET_MS = 3 * 60 * 60 * 1000;
return new Date(date.getTime() - BRASILIA_OFFSET_MS)
.toISOString()
.slice(0, 10);
}
function getGroupLabel(dateKey: string): string {
const now = new Date();
const todayKey = getBrasiliaDateKey(now);
const yesterdayKey = getBrasiliaDateKey(
new Date(now.getTime() - 24 * 60 * 60 * 1000),
);
if (dateKey === todayKey) return "Hoje";
if (dateKey === yesterdayKey) return "Ontem";
const [year, month, day] = dateKey.split("-").map(Number);
return format(new Date(year, month - 1, day), "d 'de' MMMM", {
locale: ptBR,
});
}
function groupItemsByDay(
items: InboxItem[],
): { label: string; items: InboxItem[] }[] {
const groups = new Map<string, InboxItem[]>();
for (const item of items) {
const key = getItemDateKey(new Date(item.notificationTimestamp));
const group = groups.get(key);
if (group) {
group.push(item);
} else {
groups.set(key, [item]);
}
}
const sortedKeys = [...groups.keys()].sort((a, b) => b.localeCompare(a));
return sortedKeys.map((key) => ({
label: getGroupLabel(key),
items: groups.get(key) ?? [],
}));
}
function findMatchingLogo(
sourceAppName: string | null,
appLogoMap: Record<string, string>,
): string | null {
if (!sourceAppName) return null;
const appName = sourceAppName.toLowerCase();
if (appLogoMap[appName]) return resolveLogoSrc(appLogoMap[appName]);
for (const [name, logo] of Object.entries(appLogoMap)) {
if (name.includes(appName) || appName.includes(name)) {
return resolveLogoSrc(logo);
}
}
return null;
}
interface InboxPageProps {
activeStatus: InboxStatus;
activeApp: string | null;
@@ -197,24 +99,14 @@ export function InboxPage({
useState<InboxStatus>("pending");
const normalizedSourceApps = useMemo(() => {
if (!Array.isArray(sourceApps)) {
return [];
}
if (!Array.isArray(sourceApps)) return [];
const uniqueApps = new Set<string>();
for (const app of sourceApps) {
if (typeof app !== "string") {
continue;
}
if (typeof app !== "string") continue;
const trimmedApp = app.trim();
if (!trimmedApp) {
continue;
}
if (!trimmedApp) continue;
uniqueApps.add(trimmedApp);
}
return [...uniqueApps].sort((left, right) =>
left.localeCompare(right, "pt-BR"),
);
@@ -225,28 +117,19 @@ export function InboxPage({
? [activeApp, ...normalizedSourceApps]
: normalizedSourceApps;
const getAppLogo = (appName: string | null) =>
findMatchingLogo(appName, appLogoMap) ?? DEFAULT_INBOX_APP_LOGO;
const handleProcessOpenChange = (open: boolean) => {
setProcessOpen(open);
if (!open) {
setItemToProcess(null);
}
if (!open) setItemToProcess(null);
};
const handleDetailsOpenChange = (open: boolean) => {
setDetailsOpen(open);
if (!open) {
setItemDetails(null);
}
if (!open) setItemDetails(null);
};
const handleDiscardOpenChange = (open: boolean) => {
setDiscardOpen(open);
if (!open) {
setItemToDiscard(null);
}
if (!open) setItemToDiscard(null);
};
const handleProcessRequest = useCallback((item: InboxItem) => {
@@ -266,25 +149,20 @@ export function InboxPage({
const handleDiscardConfirm = async () => {
if (!itemToDiscard) return;
const result = await discardInboxItemAction({
inboxItemId: itemToDiscard.id,
});
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
};
const handleDeleteOpenChange = (open: boolean) => {
setDeleteOpen(open);
if (!open) {
setItemToDelete(null);
}
if (!open) setItemToDelete(null);
};
const handleDeleteRequest = useCallback((item: InboxItem) => {
@@ -294,25 +172,20 @@ export function InboxPage({
const handleDeleteConfirm = async () => {
if (!itemToDelete) return;
const result = await deleteInboxItemAction({
inboxItemId: itemToDelete.id,
});
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
};
const handleRestoreOpenChange = (open: boolean) => {
setRestoreOpen(open);
if (!open) {
setItemToRestore(null);
}
if (!open) setItemToRestore(null);
};
const handleRestoreRequest = useCallback((item: InboxItem) => {
@@ -322,16 +195,13 @@ export function InboxPage({
const handleRestoreToPendingConfirm = async () => {
if (!itemToRestore) return;
const result = await restoreDiscardedInboxItemAction({
inboxItemId: itemToRestore.id,
});
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
};
@@ -365,25 +235,21 @@ export function InboxPage({
nextPageSize: number,
) => {
const nextParams = new URLSearchParams(searchParams.toString());
if (nextStatus === "pending") {
nextParams.delete("status");
} else {
nextParams.set("status", nextStatus);
}
if (nextPage <= 1) {
nextParams.delete("page");
} else {
nextParams.set("page", nextPage.toString());
}
if (nextPageSize === INBOX_DEFAULT_PAGE_SIZE) {
nextParams.delete("pageSize");
} else {
nextParams.set("pageSize", nextPageSize.toString());
}
startTransition(() => {
const target = nextParams.toString()
? `${pathname}?${nextParams.toString()}`
@@ -431,10 +297,7 @@ export function InboxPage({
};
const handleSelectionBulkRequest = (status: InboxStatus) => {
if (selectedIds.length === 0) {
return;
}
if (selectedIds.length === 0) return;
setSelectionBulkStatus(status);
setSelectionBulkOpen(true);
};
@@ -465,10 +328,6 @@ export function InboxPage({
}
};
const handleBulkDeleteOpenChange = (open: boolean) => {
setBulkDeleteOpen(open);
};
const handleBulkDeleteRequest = (status: "processed" | "discarded") => {
setBulkDeleteStatus(status);
setBulkDeleteOpen(true);
@@ -478,23 +337,19 @@ export function InboxPage({
const result = await bulkDeleteInboxItemsAction({
status: bulkDeleteStatus,
});
if (result.success) {
toast.success(result.message);
return;
}
toast.error(result.error);
throw new Error(result.error);
};
const handleLancamentoSuccess = async () => {
if (!itemToProcess) return;
const result = await markInboxAsProcessedAction({
inboxItemId: itemToProcess.id,
});
if (result.success) {
toast.success("Notificação processada!");
} else {
@@ -502,10 +357,6 @@ export function InboxPage({
}
};
const canPreviousPage = pagination.page > 1;
const canNextPage = pagination.page < pagination.totalPages;
// Prepare default values from inbox item
const getDateString = (
date: Date | string | null | undefined,
): string | null => {
@@ -516,140 +367,29 @@ export function InboxPage({
const defaultPurchaseDate =
getDateString(itemToProcess?.notificationTimestamp) ?? null;
const defaultName = itemToProcess?.parsedName
? itemToProcess.parsedName
.toLowerCase()
.replace(/\b\w/g, (char) => char.toUpperCase())
: null;
const defaultAmount = itemToProcess?.parsedAmount
? String(Math.abs(Number(itemToProcess.parsedAmount)))
: null;
// Match sourceAppName with a cartão to pre-fill card select
const matchedCartaoId = useMemo(() => {
const appName = itemToProcess?.sourceAppName?.toLowerCase();
if (!appName) return null;
for (const option of cardOptions) {
const label = option.label.toLowerCase();
if (label.includes(appName) || appName.includes(label)) {
if (label.includes(appName) || appName.includes(label))
return option.value;
}
}
return null;
}, [itemToProcess?.sourceAppName, cardOptions]);
const renderEmptyState = (message: string) => (
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
<EmptyState
media={<RiAtLine className="size-6 text-primary" />}
title={message}
description="As notificações capturadas pelo app OpenMonetis Companion aparecerão aqui. Saiba mais em Ajustes > Companion."
/>
</Card>
);
const renderGroupedGrid = (list: InboxItem[], readonly?: boolean) => {
if (list.length === 0) {
if (activeApp) {
return renderEmptyState("Nenhuma notificação deste app");
}
return renderEmptyState(
readonly
? "Nenhuma notificação nesta aba"
: "Nenhum pré-lançamento pendente",
);
}
const groups = groupItemsByDay(list);
return (
<div className="space-y-6">
{groups.map((group) => (
<div key={group.label}>
<div className="mb-3 flex items-center gap-1 text-muted-foreground">
<RiCalendarEventLine className="size-3.5 shrink-0" />
<p className="text-sm font-medium">{group.label}</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{group.items.map((item) => (
<InboxCard
key={item.id}
item={item}
readonly={readonly}
appLogoMap={appLogoMap}
onProcess={readonly ? undefined : handleProcessRequest}
onDiscard={readonly ? undefined : handleDiscardRequest}
onViewDetails={readonly ? undefined : handleDetailsRequest}
onDelete={readonly ? handleDeleteRequest : undefined}
onRestoreToPending={
readonly ? handleRestoreRequest : undefined
}
selected={selectedIds.includes(item.id)}
onSelectToggle={toggleSelection}
/>
))}
</div>
</div>
))}
</div>
);
};
const renderAppFilter = () => {
if (appFilterOptions.length === 0) {
return null;
}
return (
<Select value={activeApp ?? "all"} onValueChange={handleAppChange}>
<SelectTrigger className="w-[190px]">
<SelectValue>
<span className="flex min-w-0 items-center gap-2">
<Image
src={activeApp ? getAppLogo(activeApp) : DEFAULT_INBOX_APP_LOGO}
alt=""
width={20}
height={20}
className="shrink-0 rounded-full"
/>
<span className="truncate">{activeApp ?? "Todos"}</span>
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<span className="flex items-center gap-2">
<Image
src={DEFAULT_INBOX_APP_LOGO}
alt=""
width={20}
height={20}
className="shrink-0 rounded-full"
/>
<span>Todos</span>
</span>
</SelectItem>
{appFilterOptions.map((app) => (
<SelectItem key={app} value={app}>
<span className="flex min-w-0 items-center gap-2">
<Image
src={getAppLogo(app)}
alt=""
width={20}
height={20}
className="shrink-0 rounded-full"
/>
<span className="truncate">{app}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
};
const showTabActions = (status: InboxStatus) =>
activeStatus === status &&
(appFilterOptions.length > 0 || items.length > 0);
return (
<>
@@ -658,229 +398,106 @@ export function InboxPage({
onValueChange={handleTabChange}
className="w-full"
>
<TabsList className="grid h-auto w-full grid-cols-3 sm:inline-flex sm:h-9 sm:grid-cols-none">
<TabsTrigger
value="pending"
disabled={isPending}
className="h-11 min-w-0 flex-col gap-0 px-1 text-sm leading-tight sm:h-9 sm:flex-row sm:gap-1 sm:px-4"
>
<span>Pendentes</span>
<span>({counts.pending})</span>
</TabsTrigger>
<TabsTrigger
value="processed"
disabled={isPending}
className="h-11 min-w-0 flex-col gap-0 px-1 text-sm leading-tight sm:h-9 sm:flex-row sm:gap-1 sm:px-4"
>
<span>Processados</span>
<span>({counts.processed})</span>
</TabsTrigger>
<TabsTrigger
value="discarded"
disabled={isPending}
className="h-11 min-w-0 flex-col gap-0 px-1 text-sm leading-tight sm:h-9 sm:flex-row sm:gap-1 sm:px-4"
>
<span>Descartados</span>
<span>({counts.discarded})</span>
</TabsTrigger>
</TabsList>
<InboxTabs counts={counts} isPending={isPending} />
<TabsContent value="pending" className="mt-4">
{activeStatus === "pending" &&
(appFilterOptions.length > 0 || items.length > 0) && (
<div className="mb-4 flex flex-wrap items-center gap-2">
{renderAppFilter()}
{items.length > 0 ? (
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectAll}
>
{allSelected ? "Cancelar seleção" : "Selecionar página"}
</Button>
{selectedIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => handleSelectionBulkRequest("pending")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Descartar selecionados ({selectedIds.length})
</Button>
)}
</div>
) : null}
</div>
)}
{activeStatus === "pending" ? renderGroupedGrid(items, false) : null}
{showTabActions("pending") && (
<InboxBulkActions
status="pending"
items={items}
activeApp={activeApp}
appFilterOptions={appFilterOptions}
selectedIds={selectedIds}
allSelected={allSelected}
appLogoMap={appLogoMap}
onAppChange={handleAppChange}
onToggleSelectAll={toggleSelectAll}
onSelectionBulkRequest={handleSelectionBulkRequest}
onBulkDeleteRequest={handleBulkDeleteRequest}
/>
)}
{activeStatus === "pending" && (
<InboxItemsList
items={items}
readonly={false}
activeApp={activeApp}
appLogoMap={appLogoMap}
selectedIds={selectedIds}
onProcess={handleProcessRequest}
onDiscard={handleDiscardRequest}
onViewDetails={handleDetailsRequest}
onSelectToggle={toggleSelection}
/>
)}
</TabsContent>
<TabsContent value="processed" className="mt-4">
{activeStatus === "processed" &&
(appFilterOptions.length > 0 || items.length > 0) && (
<div className="mb-4 flex flex-wrap items-center gap-2">
{renderAppFilter()}
{items.length > 0 ? (
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectAll}
>
{allSelected ? "Cancelar seleção" : "Selecionar página"}
</Button>
{selectedIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => handleSelectionBulkRequest("processed")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Excluir selecionados ({selectedIds.length})
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleBulkDeleteRequest("processed")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Limpar processados
</Button>
</div>
) : null}
</div>
)}
{activeStatus === "processed" ? renderGroupedGrid(items, true) : null}
{showTabActions("processed") && (
<InboxBulkActions
status="processed"
items={items}
activeApp={activeApp}
appFilterOptions={appFilterOptions}
selectedIds={selectedIds}
allSelected={allSelected}
appLogoMap={appLogoMap}
onAppChange={handleAppChange}
onToggleSelectAll={toggleSelectAll}
onSelectionBulkRequest={handleSelectionBulkRequest}
onBulkDeleteRequest={handleBulkDeleteRequest}
/>
)}
{activeStatus === "processed" && (
<InboxItemsList
items={items}
readonly
activeApp={activeApp}
appLogoMap={appLogoMap}
selectedIds={selectedIds}
onDelete={handleDeleteRequest}
onRestoreToPending={handleRestoreRequest}
onSelectToggle={toggleSelection}
/>
)}
</TabsContent>
<TabsContent value="discarded" className="mt-4">
{activeStatus === "discarded" &&
(appFilterOptions.length > 0 || items.length > 0) && (
<div className="mb-4 flex flex-wrap items-center gap-2">
{renderAppFilter()}
{items.length > 0 ? (
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectAll}
>
{allSelected ? "Cancelar seleção" : "Selecionar página"}
</Button>
{selectedIds.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => handleSelectionBulkRequest("discarded")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Excluir selecionados ({selectedIds.length})
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleBulkDeleteRequest("discarded")}
>
<RiDeleteBinLine className="mr-1.5 size-4" />
Limpar descartados
</Button>
</div>
) : null}
</div>
)}
{activeStatus === "discarded" ? renderGroupedGrid(items, true) : null}
{showTabActions("discarded") && (
<InboxBulkActions
status="discarded"
items={items}
activeApp={activeApp}
appFilterOptions={appFilterOptions}
selectedIds={selectedIds}
allSelected={allSelected}
appLogoMap={appLogoMap}
onAppChange={handleAppChange}
onToggleSelectAll={toggleSelectAll}
onSelectionBulkRequest={handleSelectionBulkRequest}
onBulkDeleteRequest={handleBulkDeleteRequest}
/>
)}
{activeStatus === "discarded" && (
<InboxItemsList
items={items}
readonly
activeApp={activeApp}
appLogoMap={appLogoMap}
selectedIds={selectedIds}
onDelete={handleDeleteRequest}
onRestoreToPending={handleRestoreRequest}
onSelectToggle={toggleSelection}
/>
)}
</TabsContent>
</Tabs>
{pagination.totalItems > 0 ? (
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{pagination.totalItems} notificações
</span>
<Select
disabled={isPending}
value={pagination.pageSize.toString()}
onValueChange={(value) => {
updateUrl(activeStatus, 1, Number(value));
}}
>
<SelectTrigger className="w-max">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INBOX_PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option} value={option.toString()}>
{option} itens
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Página {pagination.page} de {pagination.totalPages}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
onClick={() => updateUrl(activeStatus, 1, pagination.pageSize)}
disabled={!canPreviousPage || isPending}
aria-label="Primeira página"
>
<RiArrowLeftDoubleLine className="size-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() =>
updateUrl(
activeStatus,
pagination.page - 1,
pagination.pageSize,
)
}
disabled={!canPreviousPage || isPending}
aria-label="Página anterior"
>
<RiArrowLeftSLine className="size-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() =>
updateUrl(
activeStatus,
pagination.page + 1,
pagination.pageSize,
)
}
disabled={!canNextPage || isPending}
aria-label="Próxima página"
>
<RiArrowRightSLine className="size-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() =>
updateUrl(
activeStatus,
pagination.totalPages,
pagination.pageSize,
)
}
disabled={!canNextPage || isPending}
aria-label="Última página"
>
<RiArrowRightDoubleLine className="size-4" />
</Button>
</div>
</div>
</div>
) : null}
<InboxPagination
pagination={pagination}
activeStatus={activeStatus}
isPending={isPending}
onNavigate={updateUrl}
/>
<TransactionDialog
mode="create"
@@ -944,7 +561,7 @@ export function InboxPage({
<ConfirmActionDialog
open={bulkDeleteOpen}
onOpenChange={handleBulkDeleteOpenChange}
onOpenChange={setBulkDeleteOpen}
title={`Limpar ${bulkDeleteStatus === "processed" ? "processados" : "descartados"}?`}
description={`Todos os itens ${bulkDeleteStatus === "processed" ? "processados" : "descartados"} serão excluídos permanentemente.`}
confirmLabel="Limpar tudo"

View File

@@ -0,0 +1,122 @@
import {
RiArrowLeftDoubleLine,
RiArrowLeftSLine,
RiArrowRightDoubleLine,
RiArrowRightSLine,
} from "@remixicon/react";
import {
INBOX_DEFAULT_PAGE_SIZE,
INBOX_PAGE_SIZE_OPTIONS,
} from "@/features/inbox/page-helpers";
import { Button } from "@/shared/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import type { InboxPaginationState, InboxStatus } from "./types";
type InboxPaginationProps = {
pagination: InboxPaginationState;
activeStatus: InboxStatus;
isPending: boolean;
onNavigate: (status: InboxStatus, page: number, pageSize: number) => void;
};
export function InboxPagination({
pagination,
activeStatus,
isPending,
onNavigate,
}: InboxPaginationProps) {
if (pagination.totalItems === 0) return null;
const canPreviousPage = pagination.page > 1;
const canNextPage = pagination.page < pagination.totalPages;
return (
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{pagination.totalItems} notificações
</span>
<Select
disabled={isPending}
value={pagination.pageSize.toString()}
onValueChange={(value) => {
onNavigate(activeStatus, 1, Number(value));
}}
>
<SelectTrigger className="w-max">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INBOX_PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option} value={option.toString()}>
{option} itens
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Página {pagination.page} de {pagination.totalPages}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
onClick={() => onNavigate(activeStatus, 1, pagination.pageSize)}
disabled={!canPreviousPage || isPending}
aria-label="Primeira página"
>
<RiArrowLeftDoubleLine className="size-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() =>
onNavigate(activeStatus, pagination.page - 1, pagination.pageSize)
}
disabled={!canPreviousPage || isPending}
aria-label="Página anterior"
>
<RiArrowLeftSLine className="size-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() =>
onNavigate(activeStatus, pagination.page + 1, pagination.pageSize)
}
disabled={!canNextPage || isPending}
aria-label="Próxima página"
>
<RiArrowRightSLine className="size-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() =>
onNavigate(
activeStatus,
pagination.totalPages,
pagination.pageSize,
)
}
disabled={!canNextPage || isPending}
aria-label="Última página"
>
<RiArrowRightDoubleLine className="size-4" />
</Button>
</div>
</div>
</div>
);
}
// Re-export para facilitar uso externo
export { INBOX_DEFAULT_PAGE_SIZE };

View File

@@ -0,0 +1,40 @@
import { TabsList, TabsTrigger } from "@/shared/components/ui/tabs";
import type { InboxStatus, InboxStatusCounts } from "./types";
type InboxTabsProps = {
counts: InboxStatusCounts;
isPending: boolean;
};
export function InboxTabs({ counts, isPending }: InboxTabsProps) {
return (
<TabsList className="grid h-auto w-full grid-cols-3 sm:inline-flex sm:h-9 sm:grid-cols-none">
<TabsTrigger
value="pending"
disabled={isPending}
className="h-11 min-w-0 flex-col gap-0 px-1 text-sm leading-tight sm:h-9 sm:flex-row sm:gap-1 sm:px-4"
>
<span>Pendentes</span>
<span>({counts.pending})</span>
</TabsTrigger>
<TabsTrigger
value="processed"
disabled={isPending}
className="h-11 min-w-0 flex-col gap-0 px-1 text-sm leading-tight sm:h-9 sm:flex-row sm:gap-1 sm:px-4"
>
<span>Processados</span>
<span>({counts.processed})</span>
</TabsTrigger>
<TabsTrigger
value="discarded"
disabled={isPending}
className="h-11 min-w-0 flex-col gap-0 px-1 text-sm leading-tight sm:h-9 sm:flex-row sm:gap-1 sm:px-4"
>
<span>Descartados</span>
<span>({counts.discarded})</span>
</TabsTrigger>
</TabsList>
);
}
export type { InboxStatus, InboxStatusCounts };

View File

@@ -9,6 +9,7 @@ import {
transactions,
} from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { excludeTransactionsFromExcludedAccounts } from "@/shared/lib/accounts/query-filters";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber } from "@/shared/utils/number";
@@ -36,12 +37,14 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
transactionType,
excludeTransfers = true,
excludeAutoInvoice = true,
excludeExcludedAccounts = true,
}: {
period?: string;
periods?: string[];
transactionType?: string;
excludeTransfers?: boolean;
excludeAutoInvoice?: boolean;
excludeExcludedAccounts?: boolean;
}) => {
const conditions = [eq(transactions.userId, userId), adminPayerCondition];
@@ -60,6 +63,9 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
if (excludeAutoInvoice) {
conditions.push(autoInvoiceExclusion);
}
if (excludeExcludedAccounts) {
conditions.push(excludeTransactionsFromExcludedAccounts());
}
return conditions;
};
@@ -84,6 +90,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(and(...buildAdminTransactionConditions({ period })))
.groupBy(transactions.transactionType),
db
@@ -92,6 +102,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(...buildAdminTransactionConditions({ period: previousPeriod })),
)
@@ -102,6 +116,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(and(...buildAdminTransactionConditions({ period: twoMonthsAgo })))
.groupBy(transactions.transactionType),
db
@@ -110,6 +128,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
})
.from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(...buildAdminTransactionConditions({ period: threeMonthsAgo })),
)
@@ -121,6 +143,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
})
.from(transactions)
.innerJoin(categories, eq(transactions.categoryId, categories.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
...buildAdminTransactionConditions({
@@ -137,7 +163,7 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
.select({
categoryName: categories.name,
budgetAmount: budgets.amount,
spent: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
spent: sql<number>`coalesce(sum(case when ${excludeTransactionsFromExcludedAccounts()} then ${transactions.amount} else 0 end), 0)`,
})
.from(budgets)
.innerJoin(categories, eq(budgets.categoryId, categories.id))
@@ -152,6 +178,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
autoInvoiceExclusion,
),
)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(and(eq(budgets.userId, userId), eq(budgets.period, period)))
.groupBy(categories.name, budgets.amount),
db
@@ -180,6 +210,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
transactionCount: sql<number>`count(*)`,
})
.from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(and(...buildAdminTransactionConditions({ period }))),
db
.select({
@@ -187,6 +221,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
amount: transactions.amount,
})
.from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
...buildAdminTransactionConditions({
@@ -201,6 +239,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
total: sql<number>`coalesce(sum(abs(${transactions.amount})), 0)`,
})
.from(transactions)
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
...buildAdminTransactionConditions({
@@ -222,6 +264,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
})
.from(transactions)
.leftJoin(categories, eq(transactions.categoryId, categories.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(
and(
...buildAdminTransactionConditions({

View File

@@ -6,6 +6,7 @@ import {
RiSaveLine,
RiSparklingLine,
} from "@remixicon/react";
import { useQueryClient } from "@tanstack/react-query";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useEffect, useState, useTransition } from "react";
@@ -13,10 +14,13 @@ import { toast } from "sonner";
import {
deleteSavedInsightsAction,
generateInsightsAction,
loadSavedInsightsAction,
saveInsightsAction,
} from "@/features/insights/actions";
import { DEFAULT_MODEL } from "@/features/insights/constants";
import {
savedInsightsQueryKey,
useSavedInsights,
} from "@/features/insights/hooks/use-saved-insights";
import { EmptyState } from "@/shared/components/empty-state";
import { Alert, AlertDescription } from "@/shared/components/ui/alert";
import { Button } from "@/shared/components/ui/button";
@@ -32,47 +36,47 @@ interface InsightsPageProps {
}
export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL);
const [insights, setInsights] = useState<InsightsResponse | null>(null);
const queryClient = useQueryClient();
const savedInsightsQuery = useSavedInsights(period);
const [isPending, startTransition] = useTransition();
const [isSaving, startSaveTransition] = useTransition();
const [draftInsights, setDraftInsights] = useState<InsightsResponse | null>(
null,
);
const [selectedModelOverride, setSelectedModelOverride] = useState<
string | null
>(null);
const [error, setError] = useState<string | null>(null);
const [isSaved, setIsSaved] = useState(false);
const [savedDate, setSavedDate] = useState<Date | null>(null);
const [isLoading, setIsLoading] = useState(true);
const savedInsights = savedInsightsQuery.data ?? null;
const insights = draftInsights ?? savedInsights?.insights ?? null;
const selectedModel =
selectedModelOverride ?? savedInsights?.modelId ?? DEFAULT_MODEL;
const isSaved = draftInsights === null && savedInsights !== null;
const savedDate = isSaved ? (savedInsights?.createdAt ?? null) : null;
const isLoadingSavedInsights =
savedInsightsQuery.isLoading && draftInsights === null;
const savedInsightsError =
draftInsights === null && savedInsightsQuery.error instanceof Error
? savedInsightsQuery.error.message
: null;
// Carregar insights salvos ao montar o componente
useEffect(() => {
const loadSaved = async () => {
try {
const result = await loadSavedInsightsAction(period);
if (result.success && result.data) {
setInsights(result.data.insights);
setSelectedModel(result.data.modelId);
setIsSaved(true);
setSavedDate(result.data.createdAt);
}
} catch (err) {
console.error("Error loading saved insights:", err);
} finally {
setIsLoading(false);
}
};
loadSaved();
void period;
setDraftInsights(null);
setSelectedModelOverride(null);
setError(null);
}, [period]);
const handleAnalyze = () => {
setError(null);
setIsSaved(false);
setSavedDate(null);
onAnalyze?.();
startTransition(async () => {
try {
const result = await generateInsightsAction(period, selectedModel);
if (result.success) {
setInsights(result.data);
setDraftInsights(result.data);
setSelectedModelOverride(selectedModel);
toast.success("Insights gerados com sucesso!");
} else {
setError(result.error);
@@ -99,8 +103,13 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
);
if (result.success) {
setIsSaved(true);
setSavedDate(result.data.createdAt);
queryClient.setQueryData(savedInsightsQueryKey(period), {
insights,
modelId: selectedModel,
createdAt: result.data.createdAt.toISOString(),
});
setDraftInsights(null);
setSelectedModelOverride(null);
toast.success("Análise salva com sucesso!");
} else {
toast.error(result.error);
@@ -113,13 +122,16 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
};
const handleDelete = () => {
if (!insights) return;
startSaveTransition(async () => {
try {
const result = await deleteSavedInsightsAction(period);
if (result.success) {
setIsSaved(false);
setSavedDate(null);
queryClient.setQueryData(savedInsightsQueryKey(period), null);
setDraftInsights(insights);
setSelectedModelOverride(selectedModel);
toast.success("Análise removida com sucesso!");
} else {
toast.error(result.error);
@@ -148,7 +160,7 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
{/* Model Selector */}
<ModelSelector
value={selectedModel}
onValueChange={setSelectedModel}
onValueChange={setSelectedModelOverride}
disabled={isPending}
/>
@@ -156,7 +168,7 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
<div className="flex items-center gap-3 flex-wrap">
<Button
onClick={handleAnalyze}
disabled={isPending || isLoading}
disabled={isPending || isLoadingSavedInsights}
className="bg-linear-to-r from-primary via-violet-400 to-cyan-400 dark:from-primary-dark dark:to-cyan-600"
>
<RiSparklingLine className="mr-2 size-5" aria-hidden="true" />
@@ -166,7 +178,7 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
{insights && !error && (
<Button
onClick={isSaved ? handleDelete : handleSave}
disabled={isSaving || isPending || isLoading}
disabled={isSaving || isPending || isLoadingSavedInsights}
variant={isSaved ? "destructive" : "outline"}
>
{isSaved ? (
@@ -195,23 +207,43 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
{/* Content Area */}
<div className="min-h-[400px]">
{(isPending || isLoading) && <LoadingState />}
{!isPending && !isLoading && !insights && !error && (
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
<EmptyState
media={<RiSparklingLine className="size-6 text-primary" />}
title="Nenhuma análise realizada"
description="Clique no botão acima para gerar insights inteligentes sobre seus
{(isPending || isLoadingSavedInsights) && <LoadingState />}
{!isPending &&
!isLoadingSavedInsights &&
!insights &&
!error &&
!savedInsightsError && (
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
<EmptyState
media={<RiSparklingLine className="size-6 text-primary" />}
title="Nenhuma análise realizada"
description="Clique no botão acima para gerar insights inteligentes sobre seus
dados financeiros do mês selecionado."
/>
</Card>
)}
{!isPending && !isLoadingSavedInsights && error && (
<ErrorState
title="Erro ao gerar insights"
error={error}
onRetry={handleAnalyze}
/>
)}
{!isPending &&
!isLoadingSavedInsights &&
!error &&
savedInsightsError && (
<ErrorState
title="Erro ao carregar insights salvos"
error={savedInsightsError}
onRetry={() => void savedInsightsQuery.refetch()}
/>
</Card>
)}
{!isPending && !isLoading && error && (
<ErrorState error={error} onRetry={handleAnalyze} />
)}
{!isPending && !isLoading && insights && !error && (
<InsightsGrid insights={insights} />
)}
)}
{!isPending &&
!isLoadingSavedInsights &&
insights &&
!error &&
!savedInsightsError && <InsightsGrid insights={insights} />}
</div>
</div>
);
@@ -258,18 +290,18 @@ function LoadingState() {
}
function ErrorState({
title,
error,
onRetry,
}: {
title: string;
error: string;
onRetry: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center gap-4 py-12 px-4 text-center">
<div className="flex flex-col gap-2">
<h3 className="text-lg font-medium text-destructive">
Erro ao gerar insights
</h3>
<h3 className="text-lg font-medium text-destructive">{title}</h3>
<p className="text-sm text-muted-foreground max-w-md">{error}</p>
</div>
<Button onClick={onRetry} variant="outline">

View File

@@ -0,0 +1,32 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { z } from "zod";
import type { SavedInsightsRecord } from "@/features/insights/queries";
import { fetchJson } from "@/shared/lib/fetch-json";
import { InsightsResponseSchema } from "@/shared/lib/schemas/insights";
const savedInsightsRecordSchema = z.object({
insights: InsightsResponseSchema,
modelId: z.string().min(1),
createdAt: z.string().min(1),
});
export const savedInsightsQueryKey = (period: string) =>
["insights", "saved", period] as const;
export function useSavedInsights(period: string) {
return useQuery({
queryKey: savedInsightsQueryKey(period),
queryFn: async () => {
const params = new URLSearchParams({ period });
const payload = await fetchJson<SavedInsightsRecord | null>(
`/api/insights/saved?${params.toString()}`,
);
return payload === null ? null : savedInsightsRecordSchema.parse(payload);
},
enabled: Boolean(period),
staleTime: 60_000,
});
}

View File

@@ -0,0 +1,49 @@
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { savedInsights } from "@/db/schema";
import { db } from "@/shared/lib/db";
import {
type InsightsResponse,
InsightsResponseSchema,
} from "@/shared/lib/schemas/insights";
export const savedInsightsPeriodSchema = z
.string()
.regex(/^\d{4}-\d{2}$/, "Período inválido (formato esperado: YYYY-MM)");
export type SavedInsightsRecord = {
insights: InsightsResponse;
modelId: string;
createdAt: string;
};
export async function fetchSavedInsights(
userId: string,
period: string,
): Promise<SavedInsightsRecord | null> {
const validatedPeriod = savedInsightsPeriodSchema.parse(period);
const result = await db
.select()
.from(savedInsights)
.where(
and(
eq(savedInsights.userId, userId),
eq(savedInsights.period, validatedPeriod),
),
)
.limit(1);
if (result.length === 0) {
return null;
}
const saved = result[0];
const insights = InsightsResponseSchema.parse(JSON.parse(saved.data));
return {
insights,
modelId: saved.modelId,
createdAt: saved.createdAt.toISOString(),
};
}

View File

@@ -1,6 +1,7 @@
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categories, transactions } from "@/db/schema";
import { categories, financialAccounts, transactions } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { excludeTransactionsFromExcludedAccounts } from "@/shared/lib/accounts/query-filters";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber as toNumber } from "@/shared/utils/number";
@@ -49,6 +50,7 @@ export async function fetchCategoryChartData(
isNull(transactions.note),
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
excludeTransactionsFromExcludedAccounts(),
];
if (categoryIds && categoryIds.length > 0) {
@@ -67,6 +69,10 @@ export async function fetchCategoryChartData(
})
.from(transactions)
.innerJoin(categories, eq(transactions.categoryId, categories.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(and(...whereConditions))
.groupBy(
categories.id,

View File

@@ -1,6 +1,7 @@
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categories, transactions } from "@/db/schema";
import { categories, financialAccounts, transactions } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { excludeTransactionsFromExcludedAccounts } from "@/shared/lib/accounts/query-filters";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber as toNumber } from "@/shared/utils/number";
@@ -43,6 +44,7 @@ export async function fetchCategoryReport(
isNull(transactions.note),
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
excludeTransactionsFromExcludedAccounts(),
];
// Add optional category filter
@@ -62,6 +64,10 @@ export async function fetchCategoryReport(
})
.from(transactions)
.innerJoin(categories, eq(transactions.categoryId, categories.id))
.leftJoin(
financialAccounts,
eq(transactions.accountId, financialAccounts.id),
)
.where(and(...whereConditions))
.groupBy(
categories.id,

View File

@@ -19,6 +19,7 @@ import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/shared/lib/accounts/constants";
import { excludeTransactionsFromExcludedAccounts } from "@/shared/lib/accounts/query-filters";
import { db } from "@/shared/lib/db";
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
import { safeToNumber } from "@/shared/utils/number";
@@ -118,6 +119,7 @@ export async function fetchTopEstablishmentsData(
isNull(financialAccounts.excludeInitialBalanceFromIncome),
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
),
excludeTransactionsFromExcludedAccounts(),
] as const;
// Fetch establishments with transaction count and total amount

View File

@@ -649,7 +649,7 @@ export async function createApiTokenAction(
name: validated.name,
tokenHash,
tokenPrefix,
expiresAt: null, // No expiration for now
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 ano
})
.returning({ id: apiTokens.id });

View File

@@ -16,6 +16,7 @@ import {
} from "@/shared/lib/payers/notifications";
import type { ActionResult } from "@/shared/lib/types/actions";
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
import { addMonthsToPeriod, parsePeriod } from "@/shared/utils/period";
import {
centsToDecimalString,
type DeleteBulkInput,
@@ -26,6 +27,8 @@ import {
fetchOwnedCardIds,
fetchOwnedCategoryIds,
fetchOwnedPayerIds,
formatPaidInvoicePeriods,
getPaidInvoicePeriods,
type MassAddInput,
massAddSchema,
resolvePeriod,
@@ -37,6 +40,12 @@ import {
validateAllOwnership,
} from "./core";
const getPeriodOffset = (basePeriod: string, targetPeriod: string) => {
const base = parsePeriod(basePeriod);
const target = parsePeriod(targetPeriod);
return (target.year - base.year) * 12 + (target.month - base.month);
};
export async function deleteTransactionBulkAction(
input: DeleteBulkInput,
): Promise<ActionResult> {
@@ -164,8 +173,10 @@ export async function updateTransactionBulkAction(
period: true,
condition: true,
transactionType: true,
paymentMethod: true,
purchaseDate: true,
payerId: true,
cardId: true,
},
where: and(
eq(transactions.id, data.id),
@@ -204,6 +215,8 @@ export async function updateTransactionBulkAction(
const hasDueDateUpdate = data.dueDate !== undefined;
const hasBoletoPaymentDateUpdate = data.boletoPaymentDate !== undefined;
const hasPurchaseDateUpdate = data.purchaseDate !== undefined;
const hasPeriodUpdate = data.period !== undefined;
const baseDueDate =
hasDueDateUpdate && data.dueDate
@@ -218,8 +231,13 @@ export async function updateTransactionBulkAction(
: hasBoletoPaymentDateUpdate
? null
: undefined;
const basePurchaseDate = existing.purchaseDate ?? null;
const referencePurchaseDate = existing.purchaseDate ?? null;
const basePurchaseDate =
hasPurchaseDateUpdate && data.purchaseDate
? parseLocalDateString(data.purchaseDate)
: undefined;
const basePeriod = hasPeriodUpdate ? data.period : undefined;
const targetCardId = data.cardId ?? existing.cardId ?? null;
const buildDueDateForRecord = (recordPurchaseDate: Date | null) => {
if (!hasDueDateUpdate) {
@@ -230,18 +248,48 @@ export async function updateTransactionBulkAction(
return null;
}
if (!basePurchaseDate || !recordPurchaseDate) {
if (!referencePurchaseDate || !recordPurchaseDate) {
return baseDueDate;
}
const monthDiff =
(recordPurchaseDate.getFullYear() - basePurchaseDate.getFullYear()) *
(recordPurchaseDate.getFullYear() -
referencePurchaseDate.getFullYear()) *
12 +
(recordPurchaseDate.getMonth() - basePurchaseDate.getMonth());
(recordPurchaseDate.getMonth() - referencePurchaseDate.getMonth());
return addMonthsToDate(baseDueDate, monthDiff);
};
const buildPurchaseDateForRecord = (record: {
purchaseDate: Date | null;
period: string;
}) => {
if (!basePurchaseDate) {
return undefined;
}
if (existing.condition === "Recorrente" && existing.period) {
const offset = getPeriodOffset(existing.period, record.period);
return addMonthsToDate(basePurchaseDate, offset);
}
return basePurchaseDate;
};
const buildPeriodForRecord = (record: { period: string }) => {
if (!basePeriod) {
return undefined;
}
if (existing.period) {
const offset = getPeriodOffset(existing.period, record.period);
return addMonthsToPeriod(basePeriod, offset);
}
return basePeriod;
};
const serializeDateKey = (value: Date | null | undefined) => {
if (value === undefined) {
return "undefined";
@@ -252,8 +300,51 @@ export async function updateTransactionBulkAction(
return String(value.getTime());
};
const ensureTargetInvoicesAreOpen = async (
records: Array<{ period: string }>,
) => {
if (
existing.paymentMethod !== "Cartão de crédito" ||
!targetCardId ||
(!hasPurchaseDateUpdate &&
!hasPeriodUpdate &&
data.cardId === undefined)
) {
return null;
}
const movedPeriods = new Set<string>();
for (const record of records) {
const targetPeriodForRecord =
buildPeriodForRecord(record) ?? record.period;
const cardChanged = targetCardId !== existing.cardId;
const periodChanged = targetPeriodForRecord !== record.period;
if (cardChanged || periodChanged) {
movedPeriods.add(targetPeriodForRecord);
}
}
if (movedPeriods.size === 0) {
return null;
}
const paidPeriods = await getPaidInvoicePeriods(user.id, targetCardId, [
...movedPeriods,
]);
if (paidPeriods.length === 0) {
return null;
}
return `As faturas dos meses ${formatPaidInvoicePeriods(
paidPeriods,
)} já estão pagas. Desfaça o pagamento antes de mover este lançamento.`;
};
const applyUpdates = async (
records: Array<{ id: string; purchaseDate: Date | null }>,
records: Array<{ id: string; purchaseDate: Date | null; period: string }>,
) => {
if (records.length === 0) {
return;
@@ -269,10 +360,20 @@ export async function updateTransactionBulkAction(
for (const record of records) {
const dueDateForRecord = buildDueDateForRecord(record.purchaseDate);
const purchaseDateForRecord = buildPurchaseDateForRecord(record);
const periodForRecord = buildPeriodForRecord(record);
const perRecordPayload: Record<string, unknown> = {
...baseUpdatePayload,
};
if (purchaseDateForRecord !== undefined) {
perRecordPayload.purchaseDate = purchaseDateForRecord;
}
if (periodForRecord !== undefined) {
perRecordPayload.period = periodForRecord;
}
if (dueDateForRecord !== undefined) {
perRecordPayload.dueDate = dueDateForRecord;
}
@@ -282,6 +383,8 @@ export async function updateTransactionBulkAction(
}
const groupKey = [
serializeDateKey(purchaseDateForRecord),
periodForRecord ?? "undefined",
serializeDateKey(dueDateForRecord),
serializeDateKey(
hasBoletoPaymentDateUpdate
@@ -318,12 +421,19 @@ export async function updateTransactionBulkAction(
};
if (data.scope === "current") {
await applyUpdates([
const currentRecords = [
{
id: data.id,
purchaseDate: existing.purchaseDate ?? null,
period: existing.period,
},
]);
];
const invoiceError = await ensureTargetInvoicesAreOpen(currentRecords);
if (invoiceError) {
return { success: false, error: invoiceError };
}
await applyUpdates(currentRecords);
revalidate(user.id);
return { success: true, message: "Lançamento atualizado com sucesso." };
@@ -338,7 +448,7 @@ export async function updateTransactionBulkAction(
}
const periodLancamentos = await db.query.transactions.findMany({
columns: { id: true, purchaseDate: true },
columns: { id: true, purchaseDate: true, period: true },
where: and(
eq(transactions.seriesId, existing.seriesId),
eq(transactions.userId, user.id),
@@ -347,10 +457,16 @@ export async function updateTransactionBulkAction(
orderBy: asc(transactions.purchaseDate),
});
const invoiceError = await ensureTargetInvoicesAreOpen(periodLancamentos);
if (invoiceError) {
return { success: false, error: invoiceError };
}
await applyUpdates(
periodLancamentos.map((item: (typeof periodLancamentos)[number]) => ({
id: item.id,
purchaseDate: item.purchaseDate ?? null,
period: item.period,
})),
);
@@ -370,6 +486,7 @@ export async function updateTransactionBulkAction(
columns: {
id: true,
purchaseDate: true,
period: true,
},
where: and(
eq(transactions.seriesId, existing.seriesId),
@@ -380,10 +497,16 @@ export async function updateTransactionBulkAction(
orderBy: asc(transactions.purchaseDate),
});
const invoiceError = await ensureTargetInvoicesAreOpen(futureLancamentos);
if (invoiceError) {
return { success: false, error: invoiceError };
}
await applyUpdates(
futureLancamentos.map((item: (typeof futureLancamentos)[number]) => ({
id: item.id,
purchaseDate: item.purchaseDate ?? null,
period: item.period,
})),
);
@@ -399,6 +522,7 @@ export async function updateTransactionBulkAction(
columns: {
id: true,
purchaseDate: true,
period: true,
},
where: and(
eq(transactions.seriesId, existing.seriesId),
@@ -408,10 +532,16 @@ export async function updateTransactionBulkAction(
orderBy: asc(transactions.purchaseDate),
});
const invoiceError = await ensureTargetInvoicesAreOpen(allLancamentos);
if (invoiceError) {
return { success: false, error: invoiceError };
}
await applyUpdates(
allLancamentos.map((item: (typeof allLancamentos)[number]) => ({
id: item.id,
purchaseDate: item.purchaseDate ?? null,
period: item.period,
})),
);

View File

@@ -4,6 +4,7 @@ import {
cards,
categories,
financialAccounts,
invoices,
payers,
type transactions,
} from "@/db/schema";
@@ -20,9 +21,10 @@ import {
} from "@/shared/lib/accounts/constants";
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
import { db } from "@/shared/lib/db";
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
import { addMonthsToPeriod } from "@/shared/utils/period";
import { addMonthsToPeriod, MONTH_NAMES } from "@/shared/utils/period";
// ============================================================================
// Authorization Validation Functions
@@ -662,6 +664,43 @@ export const buildLancamentoRecords = ({
return records;
};
export const formatPaidInvoicePeriods = (periods: string[]) =>
periods
.map((period) => {
const [year, month] = period.split("-");
const monthName = MONTH_NAMES[Number(month) - 1] ?? month;
return `${monthName}/${year}`;
})
.join(", ");
export async function getPaidInvoicePeriods(
userId: string,
cardId: string,
periods: string[],
) {
if (periods.length === 0) {
return [];
}
const rows = await db.query.invoices.findMany({
columns: { period: true },
where: and(
eq(invoices.userId, userId),
eq(invoices.cardId, cardId),
eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PAID),
inArray(invoices.period, periods),
),
});
return [
...new Set(
rows
.map((row) => row.period)
.filter((period): period is string => Boolean(period)),
),
];
}
export const deleteBulkSchema = z.object({
id: uuidSchema("Lançamento"),
scope: z.enum(["current", "period", "future", "all"], {
@@ -676,6 +715,20 @@ export const updateBulkSchema = z.object({
scope: z.enum(["current", "period", "future", "all"], {
message: "Escopo de ação inválido.",
}),
purchaseDate: z
.string()
.trim()
.refine((value) => !value || isValidDateInput(value), {
message: "Data da transação inválida.",
})
.optional(),
period: z
.string()
.trim()
.regex(/^(\d{4})-(\d{2})$/, {
message: "Selecione um período válido.",
})
.optional(),
name: z
.string({ message: "Informe o estabelecimento." })
.trim()

View File

@@ -1,18 +1,16 @@
"use server";
import { randomUUID } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import {
attachments,
financialAccounts,
invoices,
transactionAttachments,
transactions,
} from "@/db/schema";
import { handleActionError } from "@/shared/lib/actions/helpers";
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
import {
buildEntriesByPayer,
sendPayerAutoEmails,
@@ -23,7 +21,6 @@ import {
getBusinessTodayDate,
parseLocalDateString,
} from "@/shared/utils/date";
import { MONTH_NAMES } from "@/shared/utils/period";
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
import {
buildLancamentoRecords,
@@ -33,6 +30,8 @@ import {
createSchema,
type DeleteInput,
deleteSchema,
formatPaidInvoicePeriods,
getPaidInvoicePeriods,
isInitialBalanceLancamento,
resolvePeriod,
resolveUserLabel,
@@ -118,27 +117,18 @@ export async function createTransactionAction(
),
];
const paidInvoices = await db.query.invoices.findMany({
columns: { period: true },
where: and(
eq(invoices.userId, user.id),
eq(invoices.cardId, data.cardId),
eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PAID),
inArray(invoices.period, uniquePeriods),
),
});
const paidPeriods = await getPaidInvoicePeriods(
user.id,
data.cardId,
uniquePeriods,
);
if (paidInvoices.length > 0) {
const labels = paidInvoices
.map((inv) => {
const [year, month] = (inv.period ?? "").split("-");
const monthName = MONTH_NAMES[Number(month) - 1] ?? month;
return `${monthName}/${year}`;
})
.join(", ");
if (paidPeriods.length > 0) {
return {
success: false,
error: `As faturas dos meses ${labels} já estão pagas. Desfaça o pagamento antes de adicionar este lançamento.`,
error: `As faturas dos meses ${formatPaidInvoicePeriods(
paidPeriods,
)} já estão pagas. Desfaça o pagamento antes de adicionar este lançamento.`,
} as ActionResult<{ ids: string[] }>;
}
}
@@ -204,10 +194,12 @@ export async function updateTransactionAction(
columns: {
id: true,
note: true,
period: true,
transactionType: true,
condition: true,
paymentMethod: true,
accountId: true,
cardId: true,
categoryId: true,
},
where: and(
@@ -225,10 +217,12 @@ export async function updateTransactionAction(
| {
id: string;
note: string | null;
period: string;
transactionType: string;
condition: string;
paymentMethod: string;
accountId: string | null;
cardId: string | null;
categoryId: string | null;
category: { name: string } | null;
}
@@ -264,6 +258,25 @@ export async function updateTransactionAction(
? parseLocalDateString(data.boletoPaymentDate)
: getBusinessTodayDate()
: null;
const targetCardId = data.cardId ?? existing.cardId;
const movedInvoice =
data.paymentMethod === "Cartão de crédito" &&
targetCardId &&
(targetCardId !== existing.cardId || period !== existing.period);
if (movedInvoice) {
const paidPeriods = await getPaidInvoicePeriods(user.id, targetCardId, [
period,
]);
if (paidPeriods.length > 0) {
return {
success: false,
error: `As faturas dos meses ${formatPaidInvoicePeriods(
paidPeriods,
)} já estão pagas. Desfaça o pagamento antes de mover este lançamento.`,
};
}
}
await db
.update(transactions)

View File

@@ -131,6 +131,46 @@ export async function createInstallmentAnticipationAction(
const user = await getUser();
const data = createAnticipationSchema.parse(input);
if (data.payerId || data.categoryId) {
const [payer, category] = await Promise.all([
data.payerId
? db
.select({ id: payers.id })
.from(payers)
.where(
and(eq(payers.id, data.payerId), eq(payers.userId, user.id)),
)
.limit(1)
: Promise.resolve([]),
data.categoryId
? db
.select({ id: categories.id })
.from(categories)
.where(
and(
eq(categories.id, data.categoryId),
eq(categories.userId, user.id),
),
)
.limit(1)
: Promise.resolve([]),
]);
if (data.payerId && payer.length === 0) {
return {
success: false,
error: "Pagador inválido para esta conta.",
};
}
if (data.categoryId && category.length === 0) {
return {
success: false,
error: "Categoria inválida para esta conta.",
};
}
}
// 1. Validar parcelas selecionadas
const installments = await db.query.transactions.findMany({
where: and(

View File

@@ -0,0 +1,99 @@
import { and, desc, eq } from "drizzle-orm";
import {
categories,
installmentAnticipations,
payers,
transactions,
} from "@/db/schema";
import { db } from "@/shared/lib/db";
import { uuidSchema } from "@/shared/lib/schemas/common";
export type InstallmentAnticipationListItem = {
id: string;
anticipationPeriod: string;
anticipationDate: string;
installmentCount: number;
totalAmount: string;
discount: string;
transactionId: string;
note: string | null;
transaction: {
isSettled: boolean | null;
} | null;
payer: {
name: string;
} | null;
category: {
name: string;
} | null;
};
export async function fetchInstallmentAnticipations(
userId: string,
seriesId: string,
): Promise<InstallmentAnticipationListItem[]> {
const validatedSeriesId = uuidSchema("Série").parse(seriesId);
const anticipations = await db
.select({
id: installmentAnticipations.id,
anticipationPeriod: installmentAnticipations.anticipationPeriod,
anticipationDate: installmentAnticipations.anticipationDate,
installmentCount: installmentAnticipations.installmentCount,
totalAmount: installmentAnticipations.totalAmount,
discount: installmentAnticipations.discount,
transactionId: installmentAnticipations.transactionId,
note: installmentAnticipations.note,
transactionRecordId: transactions.id,
transactionIsSettled: transactions.isSettled,
payerName: payers.name,
categoryName: categories.name,
})
.from(installmentAnticipations)
.leftJoin(
transactions,
and(
eq(installmentAnticipations.transactionId, transactions.id),
eq(transactions.userId, userId),
),
)
.leftJoin(
payers,
and(
eq(installmentAnticipations.payerId, payers.id),
eq(payers.userId, userId),
),
)
.leftJoin(
categories,
and(
eq(installmentAnticipations.categoryId, categories.id),
eq(categories.userId, userId),
),
)
.where(
and(
eq(installmentAnticipations.seriesId, validatedSeriesId),
eq(installmentAnticipations.userId, userId),
),
)
.orderBy(desc(installmentAnticipations.createdAt));
return anticipations.map((anticipation) => ({
id: anticipation.id,
anticipationPeriod: anticipation.anticipationPeriod,
anticipationDate: anticipation.anticipationDate.toISOString(),
installmentCount: anticipation.installmentCount,
totalAmount: anticipation.totalAmount,
discount: anticipation.discount,
transactionId: anticipation.transactionId,
note: anticipation.note,
transaction: anticipation.transactionRecordId
? { isSettled: anticipation.transactionIsSettled }
: null,
payer: anticipation.payerName ? { name: anticipation.payerName } : null,
category: anticipation.categoryName
? { name: anticipation.categoryName }
: null,
}));
}

View File

@@ -0,0 +1,66 @@
import { and, eq } from "drizzle-orm";
import { attachments, transactionAttachments, transactions } from "@/db/schema";
import { db } from "@/shared/lib/db";
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
export type TransactionAttachmentListItem = {
attachmentId: string;
fileName: string;
fileSize: number;
mimeType: string;
createdAt: string;
url: string;
};
export async function fetchTransactionAttachments(
userId: string,
transactionId: string,
): Promise<TransactionAttachmentListItem[]> {
const [transaction] = await db
.select({ id: transactions.id })
.from(transactions)
.where(
and(eq(transactions.id, transactionId), eq(transactions.userId, userId)),
);
if (!transaction) {
return [];
}
const rows = await db
.select({
attachmentId: transactionAttachments.attachmentId,
fileName: attachments.fileName,
fileSize: attachments.fileSize,
mimeType: attachments.mimeType,
fileKey: attachments.fileKey,
createdAt: attachments.createdAt,
})
.from(transactionAttachments)
.innerJoin(
transactions,
and(
eq(transactionAttachments.transactionId, transactions.id),
eq(transactions.userId, userId),
),
)
.innerJoin(
attachments,
and(
eq(transactionAttachments.attachmentId, attachments.id),
eq(attachments.userId, userId),
),
)
.where(eq(transactionAttachments.transactionId, transactionId));
return Promise.all(
rows.map(async (row) => ({
attachmentId: row.attachmentId,
fileName: row.fileName,
fileSize: row.fileSize,
mimeType: row.mimeType,
createdAt: row.createdAt.toISOString(),
url: await createPresignedGetUrl(row.fileKey),
})),
);
}

View File

@@ -10,14 +10,16 @@ import {
import { Button } from "@/shared/components/ui/button";
interface AttachmentFilePickerProps {
file: File | null;
onChange: (file: File | null) => void;
files: File[];
onAdd: (file: File) => void;
onRemove: (file: File) => void;
maxSizeMb?: number;
}
export function AttachmentFilePicker({
file,
onChange,
files,
onAdd,
onRemove,
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
}: AttachmentFilePickerProps) {
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
@@ -45,12 +47,12 @@ export function AttachmentFilePicker({
return;
}
onChange(selected);
onAdd(selected);
}
return (
<div className="space-y-1.5">
<p className="text-xs">Anexo</p>
<p className="text-xs font-medium">Anexos</p>
<input
ref={inputRef}
type="file"
@@ -59,37 +61,44 @@ export function AttachmentFilePicker({
onChange={handleFileChange}
/>
{file ? (
<div className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm">
<RiAttachment2 className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate" title={file.name}>
{file.name}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-6 shrink-0"
onClick={() => onChange(null)}
>
<RiCloseLine className="size-4" />
</Button>
{files.length > 0 && (
<div className="space-y-1.5">
{files.map((file) => (
<div
key={`${file.name}-${file.size}-${file.lastModified}`}
className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm"
>
<RiAttachment2 className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate" title={file.name}>
{file.name}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-6 shrink-0"
onClick={() => onRemove(file)}
>
<RiCloseLine className="size-4" />
</Button>
</div>
))}
</div>
) : (
<button
type="button"
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
onClick={() => inputRef.current?.click()}
>
<span className="flex items-center gap-2">
<RiAttachment2 className="size-4" />
Adicionar anexo
</span>
<span className="text-xs">
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
</span>
</button>
)}
<button
type="button"
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
onClick={() => inputRef.current?.click()}
>
<span className="flex items-center gap-2">
<RiAttachment2 className="size-4" />
Adicionar anexo
</span>
<span className="text-xs">
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
</span>
</button>
</div>
);
}

View File

@@ -1,21 +1,16 @@
"use client";
import { RiFileAddLine } from "@remixicon/react";
import { useCallback, useEffect, useState } from "react";
import { fetchTransactionAttachmentsAction } from "@/features/transactions/actions/attachments";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import {
transactionAttachmentsQueryKey,
useTransactionAttachments,
} from "@/features/transactions/hooks/use-transaction-attachments";
import { Button } from "@/shared/components/ui/button";
import { AttachmentItem } from "./attachment-item";
import { AttachmentUpload } from "./attachment-upload";
type AttachmentRow = {
attachmentId: string;
fileName: string;
fileSize: number;
mimeType: string;
createdAt: Date;
url: string;
};
interface AttachmentSectionProps {
transactionId: string;
readonly?: boolean;
@@ -41,28 +36,35 @@ export function AttachmentSection({
onCancelPendingUpload,
maxSizeMb,
}: AttachmentSectionProps) {
const [items, setItems] = useState<AttachmentRow[]>([]);
const [isLoading, setIsLoading] = useState(true);
const load = useCallback(async () => {
setIsLoading(true);
try {
const data = await fetchTransactionAttachmentsAction(transactionId);
setItems(data);
onLoaded?.(data.length);
} finally {
setIsLoading(false);
}
}, [transactionId, onLoaded]);
const queryClient = useQueryClient();
const {
data: items = [],
isLoading,
isError,
} = useTransactionAttachments(transactionId);
useEffect(() => {
load();
}, [load]);
onLoaded?.(items.length);
}, [items.length, onLoaded]);
const invalidateAttachments = () => {
void queryClient.invalidateQueries({
queryKey: transactionAttachmentsQueryKey(transactionId),
});
};
if (isLoading) {
return <p className="text-xs text-muted-foreground">Carregando...</p>;
}
if (isError) {
return (
<p className="text-xs text-muted-foreground">
Não foi possível carregar os anexos.
</p>
);
}
const hasPendingUploads = (pendingUploadFiles?.length ?? 0) > 0;
return (
@@ -82,7 +84,7 @@ export function AttachmentSection({
fileSize={item.fileSize}
mimeType={item.mimeType}
url={item.url}
onDeleted={load}
onDeleted={invalidateAttachments}
readonly={readonly}
isPendingDelete={pendingDetachIds?.includes(item.attachmentId)}
onPendingDelete={onPendingDetach}
@@ -119,7 +121,7 @@ export function AttachmentSection({
{!readonly && (
<AttachmentUpload
transactionId={transactionId}
onUploaded={load}
onUploaded={invalidateAttachments}
onPendingUpload={onPendingUpload}
maxSizeMb={maxSizeMb}
/>

View File

@@ -1,9 +1,12 @@
"use client";
import { RiCalendarCheckLine, RiLoader4Line } from "@remixicon/react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { getInstallmentAnticipationsAction } from "@/features/transactions/anticipation-actions";
import { useQueryClient } from "@tanstack/react-query";
import {
installmentAnticipationsQueryKey,
useInstallmentAnticipations,
} from "@/features/transactions/hooks/use-installment-anticipations";
import { Button } from "@/shared/components/ui/button";
import {
Dialog,
DialogContent,
@@ -20,7 +23,6 @@ import {
EmptyTitle,
} from "@/shared/components/ui/empty";
import { useControlledState } from "@/shared/hooks/use-controlled-state";
import type { InstallmentAnticipationWithRelations } from "@/shared/lib/installments/anticipation-types";
import { AnticipationCard } from "../../shared/anticipation-card";
interface AnticipationHistoryDialogProps {
@@ -40,53 +42,23 @@ export function AnticipationHistoryDialog({
onOpenChange,
onViewLancamento,
}: AnticipationHistoryDialogProps) {
const [isLoading, setIsLoading] = useState(false);
const [anticipations, setAnticipations] = useState<
InstallmentAnticipationWithRelations[]
>([]);
// Use controlled state hook for dialog open state
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
onOpenChange,
);
// Define loadAnticipations before it's used in useEffect
const loadAnticipations = useCallback(async () => {
setIsLoading(true);
try {
const result = await getInstallmentAnticipationsAction(seriesId);
if (!result.success) {
toast.error(
result.error || "Erro ao carregar histórico de antecipações",
);
setAnticipations([]);
return;
}
setAnticipations(result.data ?? []);
} catch (error) {
console.error("Erro ao buscar antecipações:", error);
toast.error("Erro ao carregar histórico de antecipações");
setAnticipations([]);
} finally {
setIsLoading(false);
}
}, [seriesId]);
// Buscar antecipações ao abrir o dialog
useEffect(() => {
if (dialogOpen) {
loadAnticipations();
}
}, [dialogOpen, loadAnticipations]);
const {
data: anticipations = [],
isLoading,
isError,
refetch,
} = useInstallmentAnticipations(seriesId, dialogOpen);
const handleCanceled = () => {
// Recarregar lista após cancelamento
loadAnticipations();
void queryClient.invalidateQueries({
queryKey: installmentAnticipationsQueryKey(seriesId),
});
};
return (
@@ -106,6 +78,26 @@ export function AnticipationHistoryDialog({
Carregando histórico...
</span>
</div>
) : isError ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<RiCalendarCheckLine className="size-6 text-muted-foreground" />
</EmptyMedia>
<EmptyTitle>Não foi possível carregar</EmptyTitle>
<EmptyDescription>
O histórico de antecipações não pôde ser carregado agora.
</EmptyDescription>
</EmptyHeader>
<Button
type="button"
variant="outline"
className="mx-auto"
onClick={() => void refetch()}
>
Tentar novamente
</Button>
</Empty>
) : anticipations.length === 0 ? (
<Empty>
<EmptyHeader>

View File

@@ -67,7 +67,7 @@ export function CategorySection({
>
<Label htmlFor="categoria">Categoria</Label>
<Select
value={formState.categoryId}
value={formState.categoryId ?? ""}
onValueChange={(value) => onFieldChange("categoryId", value)}
>
<SelectTrigger id="categoria" className="w-full">

View File

@@ -1,5 +1,7 @@
"use client";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { RiSliceFill } from "@remixicon/react";
import { CurrencyInput } from "@/shared/components/ui/currency-input";
import { Label } from "@/shared/components/ui/label";
import {
@@ -9,6 +11,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import { cn } from "@/shared/utils/ui";
import { PayerSelectContent } from "../../select-items";
import type { PayerSectionProps } from "./transaction-dialog-types";
@@ -34,75 +37,59 @@ export function PayerSection({
};
return (
<div className="flex w-full flex-col gap-2 md:flex-row">
<div className="w-full space-y-1">
<Label htmlFor="payer">Pagador</Label>
<div className="flex gap-2">
<Select
value={formState.payerId}
onValueChange={(value) => onFieldChange("payerId", value)}
>
<SelectTrigger
id="payer"
className={formState.isSplit ? "min-w-0 flex-1" : "w-full"}
>
<SelectValue placeholder="Selecione">
{formState.payerId &&
(() => {
const selectedOption = payerOptions.find(
(opt) => opt.value === formState.payerId,
);
return selectedOption ? (
<PayerSelectContent
label={selectedOption.label}
avatarUrl={selectedOption.avatarUrl}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{payerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<PayerSelectContent
label={option.label}
avatarUrl={option.avatarUrl}
/>
</SelectItem>
))}
</SelectContent>
</Select>
{formState.isSplit && (
<CurrencyInput
value={formState.primarySplitAmount}
onValueChange={handlePrimaryAmountChange}
placeholder="R$ 0,00"
className="h-9 w-[45%] text-sm"
/>
)}
<div className="space-y-3">
<div
className={cn(
"flex items-center justify-between rounded-lg border px-3 py-2.5 transition-colors",
formState.isSplit
? "border-primary/20 bg-primary/5"
: "border-border bg-transparent",
)}
>
<div className="flex items-center gap-2">
<div>
<p className="text-sm text-foreground">Dividir lançamento</p>
<p className="text-xs text-muted-foreground">
Atribuir parte do valor a outro pagador.
</p>
</div>
</div>
<CheckboxPrimitive.Root
checked={formState.isSplit}
onCheckedChange={(checked) =>
onFieldChange("isSplit", Boolean(checked))
}
aria-label="Dividir lançamento"
className={cn(
"peer size-4 shrink-0 rounded-lg border shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
formState.isSplit
? "border-primary bg-primary text-primary-foreground"
: "border-input dark:bg-input/30",
)}
>
<CheckboxPrimitive.Indicator className="grid place-content-center text-current transition-none">
<RiSliceFill className="size-3" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
</div>
{formState.isSplit ? (
<div className="w-full space-y-1 mb-1">
<Label htmlFor="secondaryPayer">Dividir com</Label>
<div className="flex w-full flex-col gap-2 md:flex-row">
<div className="w-full space-y-1">
<Label htmlFor="payer">Pagador</Label>
<div className="flex gap-2">
<Select
value={formState.secondaryPayerId}
onValueChange={(value) =>
onFieldChange("secondaryPayerId", value)
}
value={formState.payerId ?? ""}
onValueChange={(value) => onFieldChange("payerId", value)}
>
<SelectTrigger
id="secondaryPayer"
disabled={secondaryPayerOptions.length === 0}
className="w-[55%]"
id="payer"
className={formState.isSplit ? "min-w-0 flex-1" : "w-full"}
>
<SelectValue placeholder="Selecione">
{formState.secondaryPayerId &&
{formState.payerId &&
(() => {
const selectedOption = secondaryPayerOptions.find(
(opt) => opt.value === formState.secondaryPayerId,
const selectedOption = payerOptions.find(
(opt) => opt.value === formState.payerId,
);
return selectedOption ? (
<PayerSelectContent
@@ -114,7 +101,7 @@ export function PayerSection({
</SelectValue>
</SelectTrigger>
<SelectContent>
{secondaryPayerOptions.map((option) => (
{payerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<PayerSelectContent
label={option.label}
@@ -124,15 +111,68 @@ export function PayerSection({
))}
</SelectContent>
</Select>
<CurrencyInput
value={formState.secondarySplitAmount}
onValueChange={handleSecondaryAmountChange}
placeholder="R$ 0,00"
className="h-9 w-[45%] text-sm"
/>
{formState.isSplit && (
<CurrencyInput
value={formState.primarySplitAmount}
onValueChange={handlePrimaryAmountChange}
placeholder="R$ 0,00"
className="h-9 w-[45%] text-sm"
/>
)}
</div>
</div>
) : null}
{formState.isSplit ? (
<div className="w-full space-y-1 mb-1">
<Label htmlFor="secondaryPayer">Dividir com</Label>
<div className="flex gap-2">
<Select
value={formState.secondaryPayerId ?? ""}
onValueChange={(value) =>
onFieldChange("secondaryPayerId", value)
}
>
<SelectTrigger
id="secondaryPayer"
disabled={secondaryPayerOptions.length === 0}
className="w-[55%]"
>
<SelectValue placeholder="Selecione">
{formState.secondaryPayerId &&
(() => {
const selectedOption = secondaryPayerOptions.find(
(opt) => opt.value === formState.secondaryPayerId,
);
return selectedOption ? (
<PayerSelectContent
label={selectedOption.label}
avatarUrl={selectedOption.avatarUrl}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{secondaryPayerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<PayerSelectContent
label={option.label}
avatarUrl={option.avatarUrl}
/>
</SelectItem>
))}
</SelectContent>
</Select>
<CurrencyInput
value={formState.secondarySplitAmount}
onValueChange={handleSecondaryAmountChange}
placeholder="R$ 0,00"
className="h-9 w-[45%] text-sm"
/>
</div>
</div>
) : null}
</div>
</div>
);
}

View File

@@ -1,7 +1,12 @@
"use client";
import {
RiCheckboxBlankCircleLine,
RiCheckboxCircleFill,
} from "@remixicon/react";
import { useState } from "react";
import { PAYMENT_METHODS } from "@/features/transactions/constants";
import { Button } from "@/shared/components/ui/button";
import { Label } from "@/shared/components/ui/label";
import { MonthPicker } from "@/shared/components/ui/month-picker";
import {
@@ -71,6 +76,7 @@ export function PaymentMethodSection({
isUpdateMode,
disablePaymentMethod,
disableCardSelect,
showSettledToggle,
}: PaymentMethodSectionProps) {
const isCartaoSelected = formState.paymentMethod === "Cartão de crédito";
const showContaSelect = [
@@ -92,154 +98,200 @@ export function PaymentMethodSection({
const hasSecondaryColumn = isCartaoSelected || showContaSelect;
return (
<div className="flex w-full flex-col gap-2 md:flex-row">
{!isUpdateMode ? (
<div
className={cn(
"w-full space-y-1",
hasSecondaryColumn ? "md:w-1/2" : "md:w-full",
)}
>
<Label htmlFor="paymentMethod">Forma de pagamento</Label>
<Select
value={formState.paymentMethod}
onValueChange={(value) => onFieldChange("paymentMethod", value)}
disabled={disablePaymentMethod}
<div className="space-y-3">
<div className="flex w-full flex-col gap-2 md:flex-row">
{!isUpdateMode ? (
<div
className={cn(
"w-full space-y-1",
hasSecondaryColumn ? "md:w-1/2" : "md:w-full",
)}
>
<SelectTrigger
id="paymentMethod"
className="w-full"
<Label htmlFor="paymentMethod">Forma de pagamento</Label>
<Select
value={formState.paymentMethod}
onValueChange={(value) => onFieldChange("paymentMethod", value)}
disabled={disablePaymentMethod}
>
<SelectValue placeholder="Selecione" className="w-full">
{formState.paymentMethod && (
<PaymentMethodSelectContent label={formState.paymentMethod} />
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{PAYMENT_METHODS.map((method) => (
<SelectItem key={method} value={method}>
<PaymentMethodSelectContent label={method} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<SelectTrigger
id="paymentMethod"
className="w-full"
disabled={disablePaymentMethod}
>
<SelectValue placeholder="Selecione" className="w-full">
{formState.paymentMethod && (
<PaymentMethodSelectContent
label={formState.paymentMethod}
/>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{PAYMENT_METHODS.map((method) => (
<SelectItem key={method} value={method}>
<PaymentMethodSelectContent label={method} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
{isCartaoSelected ? (
<div
className={cn(
"w-full space-y-1",
!isUpdateMode ? "md:w-1/2" : "md:w-full",
)}
>
<Label htmlFor="cartao">Cartão</Label>
<Select
value={formState.cardId}
onValueChange={(value) => onFieldChange("cardId", value)}
disabled={disableCardSelect}
{isCartaoSelected ? (
<div
className={cn(
"w-full space-y-1",
!isUpdateMode ? "md:w-1/2" : "md:w-full",
)}
>
<SelectTrigger
id="cartao"
className="w-full"
<Label htmlFor="cartao">Cartão</Label>
<Select
value={formState.cardId ?? ""}
onValueChange={(value) => onFieldChange("cardId", value)}
disabled={disableCardSelect}
>
<SelectValue placeholder="Selecione">
{formState.cardId &&
(() => {
const selectedOption = cardOptions.find(
(opt) => opt.value === formState.cardId,
);
return selectedOption ? (
<SelectTrigger
id="cartao"
className="w-full"
disabled={disableCardSelect}
>
<SelectValue placeholder="Selecione">
{formState.cardId &&
(() => {
const selectedOption = cardOptions.find(
(opt) => opt.value === formState.cardId,
);
return selectedOption ? (
<AccountCardSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={true}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{cardOptions.length === 0 ? (
<div className="px-2 py-6 text-center">
<p className="text-sm text-muted-foreground">
Nenhum cartão cadastrado
</p>
</div>
) : (
cardOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<AccountCardSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
label={option.label}
logo={option.logo}
isCartao={true}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{cardOptions.length === 0 ? (
<div className="px-2 py-6 text-center">
<p className="text-sm text-muted-foreground">
Nenhum cartão cadastrado
</p>
</div>
) : (
cardOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<AccountCardSelectContent
label={option.label}
logo={option.logo}
isCartao={true}
/>
</SelectItem>
))
)}
</SelectContent>
</Select>
{formState.cardId ? (
<InlinePeriodPicker
period={formState.period}
onPeriodChange={(value) => onFieldChange("period", value)}
/>
) : null}
</div>
) : null}
</SelectItem>
))
)}
</SelectContent>
</Select>
{formState.cardId ? (
<InlinePeriodPicker
period={formState.period}
onPeriodChange={(value) => onFieldChange("period", value)}
/>
) : null}
</div>
) : null}
{!isCartaoSelected && showContaSelect ? (
<div
className={cn(
"w-full space-y-1",
!isUpdateMode ? "md:w-1/2" : "md:w-full",
)}
>
<Label htmlFor="conta">Conta</Label>
<Select
value={formState.accountId}
onValueChange={(value) => onFieldChange("accountId", value)}
{!isCartaoSelected && showContaSelect ? (
<div
className={cn(
"w-full space-y-1",
!isUpdateMode ? "md:w-1/2" : "md:w-full",
)}
>
<SelectTrigger id="conta" className="w-full">
<SelectValue placeholder="Selecione">
{formState.accountId &&
(() => {
const selectedOption = filteredContaOptions.find(
(opt) => opt.value === formState.accountId,
);
return selectedOption ? (
<Label htmlFor="conta">Conta</Label>
<Select
value={formState.accountId ?? ""}
onValueChange={(value) => onFieldChange("accountId", value)}
>
<SelectTrigger id="conta" className="w-full">
<SelectValue placeholder="Selecione">
{formState.accountId &&
(() => {
const selectedOption = filteredContaOptions.find(
(opt) => opt.value === formState.accountId,
);
return selectedOption ? (
<AccountCardSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
isCartao={false}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{filteredContaOptions.length === 0 ? (
<div className="px-2 py-6 text-center">
<p className="text-sm text-muted-foreground">
Nenhuma conta cadastrada
</p>
</div>
) : (
filteredContaOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<AccountCardSelectContent
label={selectedOption.label}
logo={selectedOption.logo}
label={option.label}
logo={option.logo}
isCartao={false}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{filteredContaOptions.length === 0 ? (
<div className="px-2 py-6 text-center">
<p className="text-sm text-muted-foreground">
Nenhuma conta cadastrada
</p>
</div>
) : (
filteredContaOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<AccountCardSelectContent
label={option.label}
logo={option.logo}
isCartao={false}
/>
</SelectItem>
))
)}
</SelectContent>
</Select>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
) : null}
</div>
{showSettledToggle ? (
<div
className={cn(
"flex items-center justify-between rounded-lg border px-3 py-2.5 transition-colors",
formState.isSettled
? "border-success/20 bg-success/5"
: "border-border bg-transparent",
)}
>
<div>
<p className="text-sm text-foreground text-left">
Marcar como pago
</p>
<p className="text-xs text-muted-foreground text-left">
Indica que o valor foi pago.
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => onFieldChange("isSettled", !formState.isSettled)}
aria-label={
formState.isSettled ? "Desfazer pagamento" : "Marcar como pago"
}
aria-pressed={Boolean(formState.isSettled)}
className={cn(
"transition-colors",
formState.isSettled
? "bg-success/10 text-success hover:bg-success/20 hover:text-success"
: "text-muted-foreground hover:text-foreground",
)}
>
{formState.isSettled ? (
<RiCheckboxCircleFill className="size-4" />
) : (
<RiCheckboxBlankCircleLine className="size-4" />
)}
</Button>
</div>
) : null}
</div>

View File

@@ -1,58 +0,0 @@
"use client";
import { Checkbox } from "@/shared/components/ui/checkbox";
import { cn } from "@/shared/utils/ui";
import type { SplitAndSettlementSectionProps } from "./transaction-dialog-types";
export function SplitAndSettlementSection({
formState,
onFieldChange,
showSettledToggle,
}: SplitAndSettlementSectionProps) {
return (
<div className="flex w-full flex-col gap-2 md:flex-row">
<div
className={cn(
"space-y-1",
showSettledToggle ? "md:w-1/2 md:pr-2" : "md:w-full",
)}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-foreground">Dividir lançamento</p>
<p className="text-xs text-muted-foreground">
Atribuir parte do valor a outro pagador.
</p>
</div>
<Checkbox
checked={formState.isSplit}
onCheckedChange={(checked) =>
onFieldChange("isSplit", Boolean(checked))
}
aria-label="Dividir lançamento"
/>
</div>
</div>
{showSettledToggle ? (
<div className="space-y-1 md:w-1/2 md:pr-2">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-foreground">Marcar como pago</p>
<p className="text-xs text-muted-foreground">
Indica que o valor foi pago.
</p>
</div>
<Checkbox
checked={Boolean(formState.isSettled)}
onCheckedChange={(checked) =>
onFieldChange("isSettled", Boolean(checked))
}
aria-label="Marcar como concluído"
/>
</div>
</div>
) : null}
</div>
);
}

View File

@@ -34,6 +34,8 @@ export interface TransactionDialogProps {
maxSizeMb?: number;
onBulkEditRequest?: (data: {
id: string;
purchaseDate: string;
period: string;
name: string;
categoryId: string | undefined;
note: string;
@@ -71,10 +73,6 @@ export interface CategorySectionProps extends BaseFieldSectionProps {
hideTransactionType?: boolean;
}
export interface SplitAndSettlementSectionProps extends BaseFieldSectionProps {
showSettledToggle: boolean;
}
export interface PayerSectionProps extends BaseFieldSectionProps {
payerOptions: SelectOption[];
secondaryPayerOptions: SelectOption[];
@@ -87,6 +85,7 @@ export interface PaymentMethodSectionProps extends BaseFieldSectionProps {
isUpdateMode: boolean;
disablePaymentMethod: boolean;
disableCardSelect: boolean;
showSettledToggle: boolean;
}
export interface BoletoFieldsSectionProps extends BaseFieldSectionProps {

View File

@@ -46,7 +46,6 @@ import { ConditionSection } from "./condition-section";
import { NoteSection } from "./note-section";
import { PayerSection } from "./payer-section";
import { PaymentMethodSection } from "./payment-method-section";
import { SplitAndSettlementSection } from "./split-settlement-section";
import type {
FormState,
TransactionDialogProps,
@@ -99,7 +98,7 @@ export function TransactionDialog({
);
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]);
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]);
@@ -139,7 +138,7 @@ export function TransactionDialog({
setFormState(initial);
setErrorMessage(null);
setPendingFile(null);
setPendingFiles([]);
setPendingDetachIds([]);
setPendingUploadFiles([]);
}
@@ -330,27 +329,29 @@ export function TransactionDialog({
const result = await createTransactionAction(payload);
if (result.success) {
if (pendingFile && result.data?.ids?.length) {
if (pendingFiles.length > 0 && result.data?.ids?.length) {
const firstId = result.data.ids[0];
const isNewSeries =
formState.condition === "Parcelado" ||
formState.condition === "Recorrente";
const presign = await getPresignedUploadUrlAction({
fileName: pendingFile.name,
mimeType: pendingFile.type,
fileSize: pendingFile.size,
transactionId: firstId,
});
if (presign.success) {
await fetch(presign.presignedUrl, {
method: "PUT",
body: pendingFile,
headers: { "Content-Type": pendingFile.type },
});
await confirmAttachmentUploadAction({
uploadToken: presign.uploadToken,
scope: isNewSeries ? "all" : "current",
for (const file of pendingFiles) {
const presign = await getPresignedUploadUrlAction({
fileName: file.name,
mimeType: file.type,
fileSize: file.size,
transactionId: firstId,
});
if (presign.success) {
await fetch(presign.presignedUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
await confirmAttachmentUploadAction({
uploadToken: presign.uploadToken,
scope: isNewSeries ? "all" : "current",
});
}
}
}
toast.success(result.message);
@@ -371,6 +372,8 @@ export function TransactionDialog({
// o upload após o escopo ser escolhido (sem upload antecipado ao S3)
onBulkEditRequest({
id: transaction?.id ?? "",
purchaseDate: formState.purchaseDate,
period: formState.period,
name: formState.name.trim(),
categoryId: formState.categoryId,
note: formState.note.trim() || "",
@@ -493,30 +496,30 @@ export function TransactionDialog({
onSubmit={handleSubmit}
noValidate
>
<div className="min-w-0 space-y-3 -mx-6 max-h-[90vh] overflow-x-hidden overflow-y-auto px-6 pb-1">
<BasicFieldsSection
formState={formState}
onFieldChange={handleFieldChange}
estabelecimentos={estabelecimentos}
/>
<div className="min-w-0 -mx-6 max-h-[90vh] overflow-x-hidden overflow-y-auto px-6 pb-1">
{/* Detalhes */}
<div className="space-y-3">
<BasicFieldsSection
formState={formState}
onFieldChange={handleFieldChange}
estabelecimentos={estabelecimentos}
/>
<CategorySection
formState={formState}
onFieldChange={handleFieldChange}
categoryOptions={categoryOptions}
categoryGroups={categoryGroups}
isUpdateMode={isUpdateMode}
hideTransactionType={
Boolean(isNewWithType) && !forceShowTransactionType
}
/>
<CategorySection
formState={formState}
onFieldChange={handleFieldChange}
categoryOptions={categoryOptions}
categoryGroups={categoryGroups}
isUpdateMode={isUpdateMode}
hideTransactionType={
Boolean(isNewWithType) && !forceShowTransactionType
}
/>
</div>
<SplitAndSettlementSection
formState={formState}
onFieldChange={handleFieldChange}
showSettledToggle={showSettledToggle}
/>
<div className="border-t border-border/40 my-3" />
{/* Pagador */}
<PayerSection
formState={formState}
onFieldChange={handleFieldChange}
@@ -525,56 +528,66 @@ export function TransactionDialog({
totalAmount={totalAmount}
/>
<PaymentMethodSection
formState={formState}
onFieldChange={handleFieldChange}
accountOptions={accountOptions}
cardOptions={cardOptions}
isUpdateMode={isUpdateMode}
disablePaymentMethod={disablePaymentMethod}
disableCardSelect={disableCardSelect}
/>
<div className="border-t border-border/40 my-3" />
{showDueDate ? (
<BoletoFieldsSection
{/* Pagamento */}
<div className="space-y-3">
<PaymentMethodSection
formState={formState}
onFieldChange={handleFieldChange}
showPaymentDate={showPaymentDate}
accountOptions={accountOptions}
cardOptions={cardOptions}
isUpdateMode={isUpdateMode}
disablePaymentMethod={disablePaymentMethod}
disableCardSelect={disableCardSelect}
showSettledToggle={showSettledToggle}
/>
) : null}
{isUpdateMode ? (
<>
<NoteSection
{showDueDate ? (
<BoletoFieldsSection
formState={formState}
onFieldChange={handleFieldChange}
showPaymentDate={showPaymentDate}
/>
<div className="space-y-2">
<Label className="text-xs font-medium leading-none">
Anexos
</Label>
<AttachmentSection
transactionId={transaction?.id ?? ""}
maxSizeMb={maxSizeMb}
pendingDetachIds={pendingDetachIds}
onPendingDetach={(id) =>
setPendingDetachIds((prev) => [...prev, id])
}
onUndoPendingDetach={(id) =>
setPendingDetachIds((prev) =>
prev.filter((x) => x !== id),
)
}
pendingUploadFiles={pendingUploadFiles}
onPendingUpload={(file) =>
setPendingUploadFiles((prev) => [...prev, file])
}
onCancelPendingUpload={(file) =>
setPendingUploadFiles((prev) =>
prev.filter((f) => f !== file),
)
}
) : null}
</div>
{/* Extras */}
{isUpdateMode ? (
<>
<div className="border-t border-border/40 my-3" />
<div className="space-y-3">
<NoteSection
formState={formState}
onFieldChange={handleFieldChange}
/>
<div className="space-y-2">
<Label className="text-xs font-medium leading-none">
Anexos
</Label>
<AttachmentSection
transactionId={transaction?.id ?? ""}
maxSizeMb={maxSizeMb}
pendingDetachIds={pendingDetachIds}
onPendingDetach={(id) =>
setPendingDetachIds((prev) => [...prev, id])
}
onUndoPendingDetach={(id) =>
setPendingDetachIds((prev) =>
prev.filter((x) => x !== id),
)
}
pendingUploadFiles={pendingUploadFiles}
onPendingUpload={(file) =>
setPendingUploadFiles((prev) => [...prev, file])
}
onCancelPendingUpload={(file) =>
setPendingUploadFiles((prev) =>
prev.filter((f) => f !== file),
)
}
/>
</div>
</div>
</>
) : (
@@ -598,8 +611,11 @@ export function TransactionDialog({
onFieldChange={handleFieldChange}
/>
<AttachmentFilePicker
file={pendingFile}
onChange={setPendingFile}
files={pendingFiles}
onAdd={(file) => setPendingFiles((prev) => [...prev, file])}
onRemove={(file) =>
setPendingFiles((prev) => prev.filter((f) => f !== file))
}
maxSizeMb={maxSizeMb}
/>
</CollapsibleContent>

View File

@@ -127,6 +127,8 @@ export function TransactionsPage({
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [pendingEditData, setPendingEditData] = useState<{
id: string;
purchaseDate: string;
period: string;
name: string;
categoryId: string | undefined;
note: string;
@@ -245,6 +247,8 @@ export function TransactionsPage({
const handleBulkEditRequest = (data: {
id: string;
purchaseDate: string;
period: string;
name: string;
categoryId: string | undefined;
note: string;
@@ -278,6 +282,8 @@ export function TransactionsPage({
const result = await updateTransactionBulkAction({
id: pendingEditData.id,
scope,
purchaseDate: pendingEditData.purchaseDate,
period: pendingEditData.period,
name: pendingEditData.name,
categoryId: pendingEditData.categoryId,
note: pendingEditData.note,

View File

@@ -6,6 +6,7 @@ import { ptBR } from "date-fns/locale";
import { useTransition } from "react";
import { toast } from "sonner";
import { cancelInstallmentAnticipationAction } from "@/features/transactions/anticipation-actions";
import type { InstallmentAnticipationListItem } from "@/features/transactions/hooks/use-installment-anticipations";
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
import MoneyValues from "@/shared/components/money-values";
import { Badge } from "@/shared/components/ui/badge";
@@ -18,11 +19,10 @@ import {
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import type { InstallmentAnticipationWithRelations } from "@/shared/lib/installments/anticipation-types";
import { displayPeriod } from "@/shared/utils/period";
interface AnticipationCardProps {
anticipation: InstallmentAnticipationWithRelations;
anticipation: InstallmentAnticipationListItem;
onViewLancamento?: (transactionId: string) => void;
onCanceled?: () => void;
}
@@ -37,8 +37,8 @@ export function AnticipationCard({
const isSettled = anticipation.transaction?.isSettled === true;
const canCancel = !isSettled;
const formatDate = (date: Date) => {
return format(date, "dd 'de' MMMM 'de' yyyy", { locale: ptBR });
const formatDate = (date: string) => {
return format(new Date(date), "dd 'de' MMMM 'de' yyyy", { locale: ptBR });
};
const handleCancel = async () => {

View File

@@ -0,0 +1,59 @@
import { RiDeleteBin5Line, RiFileCopyLine } from "@remixicon/react";
import MoneyValues from "@/shared/components/money-values";
import { Button } from "@/shared/components/ui/button";
type TransactionsBulkBarProps = {
selectedCount: number;
selectedTotal: number;
mode: "delete" | "import";
onAction: () => void;
};
export function TransactionsBulkBar({
selectedCount,
selectedTotal,
mode,
onAction,
}: TransactionsBulkBarProps) {
return (
<div className="flex flex-wrap items-center gap-3 rounded-lg border bg-muted/50 px-4 py-2">
<div className="flex flex-col text-sm text-muted-foreground sm:flex-row sm:items-center sm:gap-2">
<span>
{selectedCount}{" "}
{selectedCount === 1 ? "item selecionado" : "itens selecionados"}
</span>
<span className="hidden sm:inline" aria-hidden>
-
</span>
<span>
Total:{" "}
<MoneyValues
amount={selectedTotal}
className="inline font-medium text-foreground"
/>
</span>
</div>
{mode === "delete" ? (
<Button
onClick={onAction}
variant="destructive"
size="sm"
className="ml-auto"
>
<RiDeleteBin5Line className="size-4" />
Remover selecionados
</Button>
) : (
<Button
onClick={onAction}
variant="default"
size="sm"
className="ml-auto"
>
<RiFileCopyLine className="size-4" />
Importar selecionados
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,719 @@
import {
RiAttachment2,
RiBankCard2Line,
RiChat1Line,
RiCheckboxBlankCircleLine,
RiCheckboxCircleFill,
RiCheckLine,
RiDeleteBin5Line,
RiFileCopyLine,
RiFileList2Line,
RiGroupLine,
RiHistoryLine,
RiMoreFill,
RiPencilLine,
RiTimeLine,
} from "@remixicon/react";
import type { ColumnDef } from "@tanstack/react-table";
import Image from "next/image";
import Link from "next/link";
import { DEFAULT_LANCAMENTOS_COLUMN_ORDER } from "@/features/transactions/column-order";
import {
CategoryIconBadge,
EstablishmentLogo,
} from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values";
import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/shared/components/ui/avatar";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Checkbox } from "@/shared/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu";
import { Spinner } from "@/shared/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { getAvatarSrc } from "@/shared/lib/payers/utils";
import { formatDate } from "@/shared/utils/date";
import { getConditionIcon, getPaymentMethodIcon } from "@/shared/utils/icons";
import { cn } from "@/shared/utils/ui";
import type { TransactionItem } from "../types";
export type BuildColumnsArgs = {
currentUserId: string;
noteAsColumn: boolean;
onEdit?: (item: TransactionItem) => void;
onCopy?: (item: TransactionItem) => void;
onImport?: (item: TransactionItem) => void;
onConfirmDelete?: (item: TransactionItem) => void;
onViewDetails?: (item: TransactionItem) => void;
onToggleSettlement?: (item: TransactionItem) => void;
onAnticipate?: (item: TransactionItem) => void;
onViewAnticipationHistory?: (item: TransactionItem) => void;
isSettlementLoading: (id: string) => boolean;
showActions: boolean;
columnOrder?: string[] | null;
};
function getPaymentMethodTableLabel(method: string) {
if (method === "Transferência bancária") return "Transf. bancária";
return method;
}
const FIXED_START_IDS = ["select", "purchaseDate"];
const FIXED_END_IDS = ["actions"];
function getColumnId(col: ColumnDef<TransactionItem>): string {
const c = col as { id?: string; accessorKey?: string };
return c.id ?? c.accessorKey ?? "";
}
function reorderColumnsByPreference<T>(
columns: ColumnDef<T>[],
orderPreference: string[] | null | undefined,
): ColumnDef<T>[] {
if (!orderPreference || orderPreference.length === 0) return columns;
const order = orderPreference;
const fixedStart: ColumnDef<T>[] = [];
const reorderable: ColumnDef<T>[] = [];
const fixedEnd: ColumnDef<T>[] = [];
for (const col of columns) {
const id = getColumnId(col as ColumnDef<TransactionItem>);
if (FIXED_START_IDS.includes(id)) fixedStart.push(col);
else if (FIXED_END_IDS.includes(id)) fixedEnd.push(col);
else reorderable.push(col);
}
const sorted = [...reorderable].sort((a, b) => {
const idA = getColumnId(a as ColumnDef<TransactionItem>);
const idB = getColumnId(b as ColumnDef<TransactionItem>);
const indexA = order.indexOf(idA);
const indexB = order.indexOf(idB);
if (indexA === -1 && indexB === -1) return 0;
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
});
return [...fixedStart, ...sorted, ...fixedEnd];
}
function buildColumns({
currentUserId,
noteAsColumn,
onEdit,
onCopy,
onImport,
onConfirmDelete,
onViewDetails,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,
isSettlementLoading,
showActions,
}: BuildColumnsArgs): ColumnDef<TransactionItem>[] {
const noop = () => undefined;
const handleEdit = onEdit ?? noop;
const handleCopy = onCopy ?? noop;
const handleImport = onImport ?? noop;
const handleConfirmDelete = onConfirmDelete ?? noop;
const handleViewDetails = onViewDetails ?? noop;
const handleToggleSettlement = onToggleSettlement ?? noop;
const handleAnticipate = onAnticipate ?? noop;
const handleViewAnticipationHistory = onViewAnticipationHistory ?? noop;
const columns: ColumnDef<TransactionItem>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Selecionar todos"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Selecionar linha"
/>
),
enableSorting: false,
enableHiding: false,
},
{
id: "purchaseDate",
accessorKey: "purchaseDate",
header: () => null,
cell: () => null,
},
{
accessorKey: "name",
header: "Estabelecimento",
cell: ({ row }) => {
const {
name,
purchaseDate,
installmentCount,
currentInstallment,
paymentMethod,
dueDate,
note,
isDivided,
isAnticipated,
hasAttachments,
} = row.original;
const installmentBadge =
currentInstallment && installmentCount
? `${currentInstallment} de ${installmentCount}`
: null;
const isBoleto = paymentMethod === "Boleto" && dueDate;
const dueDateLabel =
isBoleto && dueDate ? `venc. ${formatDate(dueDate)}` : null;
const hasNote = Boolean(note?.trim().length);
const isLastInstallment =
currentInstallment === installmentCount &&
installmentCount &&
installmentCount > 1;
return (
<span className="flex items-center gap-2">
<EstablishmentLogo name={name} size={28} />
<span className="flex flex-col py-0.5">
<span className="text-xs text-muted-foreground flex items-center gap-2">
{formatDate(purchaseDate)}
{dueDateLabel ? (
<span className="text-primary">{dueDateLabel}</span>
) : null}
</span>
<span className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="line-clamp-2 max-w-[180px] font-medium truncate">
{name}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{name}
</TooltipContent>
</Tooltip>
{isDivided && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiGroupLine
size={14}
className="text-muted-foreground"
aria-hidden
/>
<span className="sr-only">
Dividido entre pagadores
</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Dividido entre pagadores
</TooltipContent>
</Tooltip>
)}
{isLastInstallment ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Image
src="/icons/party.svg"
alt="Última parcela"
width={16}
height={16}
className="h-4 w-4"
/>
<span className="sr-only">Última parcela</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">Última parcela!</TooltipContent>
</Tooltip>
) : null}
{installmentBadge ? (
<Badge variant="outline" className="px-2 text-xs">
{installmentBadge}
</Badge>
) : null}
{isAnticipated && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiTimeLine
size={14}
className="text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Parcela antecipada</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Parcela antecipada
</TooltipContent>
</Tooltip>
)}
{!noteAsColumn && hasNote ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1 hover:bg-accent transition-colors duration-300">
<RiChat1Line
className="h-4 w-4 text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Ver anotação</span>
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-xs whitespace-pre-line"
>
{note}
</TooltipContent>
</Tooltip>
) : null}
{hasAttachments ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiAttachment2
className="h-4 w-4 text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Possui anexos</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">Possui anexos</TooltipContent>
</Tooltip>
) : null}
</span>
</span>
</span>
);
},
},
{
accessorKey: "transactionType",
header: "Transação",
cell: ({ row }) => {
const type =
row.original.categoriaName === "Saldo inicial"
? "Saldo inicial"
: row.original.transactionType;
return (
<TransactionTypeBadge
kind={
type as "Despesa" | "Receita" | "Transferência" | "Saldo inicial"
}
/>
);
},
},
{
accessorKey: "amount",
header: "Valor",
cell: ({ row }) => {
const isReceita = row.original.transactionType === "Receita";
const isTransfer = row.original.transactionType === "Transferência";
return (
<MoneyValues
amount={row.original.amount}
showPositiveSign={isReceita}
className={cn(
"whitespace-nowrap",
isReceita ? "text-success" : "text-foreground",
isTransfer && "text-info",
)}
/>
);
},
},
{
accessorKey: "condition",
header: "Condição",
cell: ({ row }) => {
const condition = row.original.condition;
const icon = getConditionIcon(condition);
return (
<span className="flex items-center gap-2">
{icon}
<span>{condition}</span>
</span>
);
},
},
{
accessorKey: "paymentMethod",
header: "Forma de Pagamento",
cell: ({ row }) => {
const method = row.original.paymentMethod;
const icon = getPaymentMethodIcon(method);
return (
<span className="flex items-center gap-2">
{icon}
<span>{getPaymentMethodTableLabel(method)}</span>
</span>
);
},
},
{
accessorKey: "categoriaName",
header: "Categoria",
cell: ({ row }) => {
const { categoriaName, categoriaIcon } = row.original;
if (!categoriaName) {
return <span className="text-muted-foreground"></span>;
}
return (
<span className="flex items-center gap-2">
<CategoryIconBadge
icon={categoriaIcon}
name={categoriaName}
size="sm"
/>
<span>{categoriaName}</span>
</span>
);
},
},
{
accessorKey: "pagadorName",
header: "Pagador",
cell: ({ row }) => {
const { payerId, pagadorName, pagadorAvatar } = row.original;
const label = pagadorName?.trim() || "Sem pagador";
const displayName = label.split(/\s+/)[0] ?? label;
const avatarSrc = getAvatarSrc(pagadorAvatar);
const initial = displayName.charAt(0).toUpperCase() || "?";
const content = (
<>
<Avatar className="size-7">
<AvatarImage src={avatarSrc} alt={`Avatar de ${label}`} />
<AvatarFallback className="text-[10px] font-medium uppercase">
{initial}
</AvatarFallback>
</Avatar>
<span className="truncate">{displayName}</span>
</>
);
if (!payerId) {
return (
<span className="inline-flex items-center gap-2">{content}</span>
);
}
return (
<Link
href={`/payers/${payerId}`}
className="inline-flex items-center gap-2 hover:underline"
title={label}
>
{content}
</Link>
);
},
},
{
id: "contaCartao",
header: "Conta/Cartão",
cell: ({ row }) => {
const {
cartaoName,
contaName,
cartaoLogo,
contaLogo,
cardId,
accountId,
userId,
} = row.original;
const isCartao = Boolean(cartaoName);
const label = cartaoName ?? contaName;
const logoSrc = resolveLogoSrc(cartaoLogo ?? contaLogo);
const href = cardId
? `/cards/${cardId}/invoice`
: accountId
? `/accounts/${accountId}/statement`
: null;
const isOwnData = userId === currentUserId;
const content = (
<span className="inline-flex items-center gap-2">
{logoSrc && (
<Image
src={logoSrc}
alt={`Logo de ${label}`}
width={30}
height={30}
className="rounded-full"
/>
)}
<span className="truncate">{label}</span>
</span>
);
if (!isOwnData || !href) {
return (
<Tooltip>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side="top">
{isCartao ? "Cartão" : "Conta"}: {label}
</TooltipContent>
</Tooltip>
);
}
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
href={href}
className="inline-flex items-center gap-2 hover:underline"
>
{logoSrc && (
<Image
src={logoSrc}
alt={`Logo de ${label}`}
width={30}
height={30}
className="rounded-full"
/>
)}
<span className="truncate">{label}</span>
</Link>
</TooltipTrigger>
<TooltipContent side="top">
{isCartao ? "Cartão" : "Conta"}: {label}
</TooltipContent>
</Tooltip>
);
},
},
];
if (noteAsColumn) {
const accountCardIndex = columns.findIndex((c) => c.id === "contaCartao");
const noteColumn: ColumnDef<TransactionItem> = {
accessorKey: "note",
header: "Anotação",
cell: ({ row }) => {
const note = row.original.note;
if (!note?.trim())
return <span className="text-muted-foreground"></span>;
return (
<span
className="max-w-[200px] truncate whitespace-pre-line text-sm"
title={note}
>
{note}
</span>
);
},
};
columns.splice(accountCardIndex, 0, noteColumn);
}
if (showActions) {
columns.push({
id: "actions",
header: "Ações",
enableSorting: false,
cell: ({ row }) => (
<div className="flex items-center gap-2">
{(() => {
const paymentMethod = row.original.paymentMethod;
const showSettlementButton = [
"Pix",
"Boleto",
"Cartão de crédito",
"Dinheiro",
"Cartão de débito",
"Transferência bancária",
"Pré-Pago | VR/VA",
].includes(paymentMethod);
if (!showSettlementButton) return null;
const canToggleSettlement =
paymentMethod === "Pix" ||
paymentMethod === "Boleto" ||
paymentMethod === "Dinheiro" ||
paymentMethod === "Cartão de débito" ||
paymentMethod === "Transferência bancária" ||
paymentMethod === "Pré-Pago | VR/VA";
if (!canToggleSettlement)
return (
<span className="flex size-7 shrink-0 items-center justify-center">
<RiBankCard2Line className="size-4 text-muted-foreground/30" />
</span>
);
const readOnly = row.original.readonly;
const loading = isSettlementLoading(row.original.id);
const settled = Boolean(row.original.isSettled);
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleToggleSettlement(row.original)}
disabled={loading || readOnly}
className={cn(
"transition-colors",
settled
? "bg-success/10 text-success hover:bg-success/20 hover:text-success"
: "text-muted-foreground hover:text-foreground",
)}
>
{loading ? (
<Spinner className="size-4" />
) : settled ? (
<RiCheckboxCircleFill className="size-4" />
) : (
<RiCheckboxBlankCircleLine className="size-4" />
)}
<span className="sr-only">
{settled ? "Desfazer pagamento" : "Marcar como pago"}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{settled ? "Desfazer pagamento" : "Marcar como pago"}
</TooltipContent>
</Tooltip>
);
})()}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm">
<RiMoreFill className="size-4" />
<span className="sr-only">Abrir ações do lançamento</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem
onSelect={() => handleViewDetails(row.original)}
>
<RiFileList2Line className="size-4" />
Detalhes
</DropdownMenuItem>
{row.original.userId === currentUserId && (
<DropdownMenuItem
onSelect={() => handleEdit(row.original)}
disabled={row.original.readonly}
>
<RiPencilLine className="size-4" />
Editar
</DropdownMenuItem>
)}
{row.original.categoriaName !== "Pagamentos" &&
row.original.userId === currentUserId && (
<DropdownMenuItem onSelect={() => handleCopy(row.original)}>
<RiFileCopyLine className="size-4" />
Copiar
</DropdownMenuItem>
)}
{row.original.categoriaName !== "Pagamentos" &&
row.original.userId !== currentUserId && (
<DropdownMenuItem onSelect={() => handleImport(row.original)}>
<RiFileCopyLine className="size-4" />
Importar para Minha Conta
</DropdownMenuItem>
)}
{row.original.userId === currentUserId && (
<DropdownMenuItem
variant="destructive"
onSelect={() => handleConfirmDelete(row.original)}
disabled={row.original.readonly}
>
<RiDeleteBin5Line className="size-4" />
Remover
</DropdownMenuItem>
)}
{/* Opções de Antecipação */}
{row.original.userId === currentUserId &&
row.original.condition === "Parcelado" &&
row.original.seriesId && (
<>
<DropdownMenuSeparator />
{!row.original.isAnticipated && onAnticipate && (
<DropdownMenuItem
onSelect={() => handleAnticipate(row.original)}
>
<RiTimeLine className="size-4" />
Antecipar Parcelas
</DropdownMenuItem>
)}
{onViewAnticipationHistory && (
<DropdownMenuItem
onSelect={() =>
handleViewAnticipationHistory(row.original)
}
>
<RiHistoryLine className="size-4" />
Histórico de Antecipações
</DropdownMenuItem>
)}
{row.original.isAnticipated && (
<DropdownMenuItem disabled>
<RiCheckLine className="size-4 text-success" />
Parcela Antecipada
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
),
});
}
return columns;
}
export function getTransactionColumns(
args: BuildColumnsArgs,
): ColumnDef<TransactionItem>[] {
const built = buildColumns(args);
const order = args.columnOrder?.length
? args.columnOrder
: DEFAULT_LANCAMENTOS_COLUMN_ORDER;
return reorderColumnsByPreference(built, order);
}

View File

@@ -0,0 +1,106 @@
import {
RiArrowLeftDoubleLine,
RiArrowLeftSLine,
RiArrowRightDoubleLine,
RiArrowRightSLine,
} from "@remixicon/react";
import { Button } from "@/shared/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
type TransactionsPaginationProps = {
totalRows: number;
currentPage: number;
currentPageSize: number;
totalPages: number;
canPreviousPage: boolean;
canNextPage: boolean;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
};
export function TransactionsPagination({
totalRows,
currentPage,
currentPageSize,
totalPages,
canPreviousPage,
canNextPage,
onPageChange,
onPageSizeChange,
}: TransactionsPaginationProps) {
return (
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{totalRows} lançamentos
</span>
<Select
value={currentPageSize.toString()}
onValueChange={(value) => onPageSizeChange(Number(value))}
>
<SelectTrigger className="w-max">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5 linhas</SelectItem>
<SelectItem value="10">10 linhas</SelectItem>
<SelectItem value="20">20 linhas</SelectItem>
<SelectItem value="30">30 linhas</SelectItem>
<SelectItem value="40">40 linhas</SelectItem>
<SelectItem value="50">50 linhas</SelectItem>
<SelectItem value="100">100 linhas</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Página {currentPage} de {totalPages}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
onClick={() => onPageChange(1)}
disabled={!canPreviousPage}
aria-label="Primeira página"
>
<RiArrowLeftDoubleLine className="size-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={!canPreviousPage}
aria-label="Página anterior"
>
<RiArrowLeftSLine className="size-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={!canNextPage}
aria-label="Próxima página"
>
<RiArrowRightSLine className="size-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() => onPageChange(totalPages)}
disabled={!canNextPage}
aria-label="Última página"
>
<RiArrowRightDoubleLine className="size-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -60,11 +60,6 @@ export function deriveCreditCardPeriod(
return period;
}
/**
* Split type for dividing transactions between payers
*/
export type SplitType = "equal" | "60-40" | "70-30" | "80-20" | "custom";
/**
* Form state type for lancamento dialog
*/
@@ -79,7 +74,6 @@ export type TransactionFormState = {
payerId: string | undefined;
secondaryPayerId: string | undefined;
isSplit: boolean;
splitType: SplitType;
primarySplitAmount: string;
secondarySplitAmount: string;
accountId: string | undefined;
@@ -117,7 +111,7 @@ export function buildTransactionInitialState(
): TransactionFormState {
const purchaseDate = transaction?.purchaseDate
? transaction.purchaseDate.slice(0, 10)
: (overrides?.defaultPurchaseDate ?? "");
: (overrides?.defaultPurchaseDate ?? getTodayDateString());
const paymentMethod =
transaction?.paymentMethod ??
@@ -176,7 +170,7 @@ export function buildTransactionInitialState(
payerId: fallbackPayerId ?? undefined,
secondaryPayerId: undefined,
isSplit: false,
splitType: "equal",
primarySplitAmount: "",
secondarySplitAmount: "",
accountId:
@@ -210,39 +204,6 @@ export function buildTransactionInitialState(
};
}
/**
* Split presets with their percentages
*/
const SPLIT_PRESETS: Record<SplitType, { primary: number; secondary: number }> =
{
equal: { primary: 50, secondary: 50 },
"60-40": { primary: 60, secondary: 40 },
"70-30": { primary: 70, secondary: 30 },
"80-20": { primary: 80, secondary: 20 },
custom: { primary: 50, secondary: 50 },
};
/**
* Calculates split amounts based on total and split type
*/
export function calculateSplitAmounts(
totalAmount: number,
splitType: SplitType,
): { primary: string; secondary: string } {
if (totalAmount <= 0) {
return { primary: "", secondary: "" };
}
const preset = SPLIT_PRESETS[splitType];
const primaryAmount = (totalAmount * preset.primary) / 100;
const secondaryAmount = totalAmount - primaryAmount;
return {
primary: primaryAmount.toFixed(2),
secondary: secondaryAmount.toFixed(2),
};
}
/**
* Applies field dependencies when form state changes
* This function encapsulates the business logic for field interdependencies
@@ -348,7 +309,6 @@ export function applyFieldDependencies(
// When split is disabled, clear secondary pagador and split fields
if (key === "isSplit" && value === false) {
updates.secondaryPayerId = undefined;
updates.splitType = "equal";
updates.primarySplitAmount = "";
updates.secondarySplitAmount = "";
}
@@ -367,12 +327,9 @@ export function applyFieldDependencies(
if (key === "amount" && typeof value === "string" && currentState.isSplit) {
const totalAmount = Number.parseFloat(value) || 0;
if (totalAmount > 0) {
const splitAmounts = calculateSplitAmounts(
totalAmount,
currentState.splitType,
);
updates.primarySplitAmount = splitAmounts.primary;
updates.secondarySplitAmount = splitAmounts.secondary;
const half = (totalAmount / 2).toFixed(2);
updates.primarySplitAmount = half;
updates.secondarySplitAmount = half;
} else {
updates.primarySplitAmount = "";
updates.secondarySplitAmount = "";

View File

@@ -0,0 +1,56 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { z } from "zod";
import { fetchJson } from "@/shared/lib/fetch-json";
const anticipationItemSchema = z.object({
id: z.string().uuid(),
anticipationPeriod: z.string().regex(/^\d{4}-\d{2}$/),
anticipationDate: z.string().min(1),
installmentCount: z.number().int(),
totalAmount: z.string(),
discount: z.string(),
transactionId: z.string().uuid(),
note: z.string().nullable(),
transaction: z
.object({
isSettled: z.boolean().nullable(),
})
.nullable(),
payer: z
.object({
name: z.string().min(1),
})
.nullable(),
category: z
.object({
name: z.string().min(1),
})
.nullable(),
});
export type InstallmentAnticipationListItem = z.infer<
typeof anticipationItemSchema
>;
export const installmentAnticipationsQueryKey = (seriesId: string) =>
["transactions", "installment-anticipations", seriesId] as const;
export function useInstallmentAnticipations(
seriesId: string,
enabled: boolean,
) {
return useQuery({
queryKey: installmentAnticipationsQueryKey(seriesId),
queryFn: async () => {
const payload = await fetchJson<unknown>(
`/api/transactions/installments/${seriesId}/anticipations`,
);
return z.array(anticipationItemSchema).parse(payload);
},
enabled: enabled && Boolean(seriesId),
staleTime: 30_000,
});
}

View File

@@ -0,0 +1,20 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import type { TransactionAttachmentListItem } from "@/features/transactions/attachment-queries";
import { fetchJson } from "@/shared/lib/fetch-json";
export const transactionAttachmentsQueryKey = (transactionId: string) =>
["transactions", "attachments", transactionId] as const;
export function useTransactionAttachments(transactionId: string) {
return useQuery({
queryKey: transactionAttachmentsQueryKey(transactionId),
queryFn: () =>
fetchJson<TransactionAttachmentListItem[]>(
`/api/transactions/${transactionId}/attachments`,
),
enabled: Boolean(transactionId),
staleTime: 30_000,
});
}

View File

@@ -33,6 +33,9 @@ export default async function proxy(request: NextRequest) {
const hostname = request.headers.get("host")?.replace(/:\d+$/, "");
if (publicDomain && hostname === publicDomain) {
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (pathname !== "/") {
return NextResponse.redirect(new URL("/", request.url));
}
@@ -67,6 +70,7 @@ export const config = {
// Apply middleware to protected and auth routes
matcher: [
"/",
"/api/:path*",
"/settings/:path*",
"/notes/:path*",
"/calendar/:path*",

View File

@@ -2,7 +2,7 @@ import { RiCheckLine, RiFileCopyLine } from "@remixicon/react";
import { Button } from "@/shared/components/ui/button";
import { cn } from "@/shared/utils/ui";
export type CalculatorDisplayProps = {
type CalculatorDisplayProps = {
history: string | null;
expression: string;
resultText: string | null;

View File

@@ -21,60 +21,67 @@ export function Logo({
if (variant === "compact") {
return (
<div className={cn("flex items-center gap-1", className)}>
<Image
src="/images/logo_small.png"
alt="OpenMonetis"
width={32}
height={32}
className={cn("object-contain", !colorIcon && iconFilterClass)}
priority
/>
<Image
src="/images/logo_text.png"
alt="OpenMonetis"
width={110}
height={32}
className={cn(
"hidden object-contain sm:block",
invertTextOnDark && "dark:invert",
)}
priority
/>
<div className="relative size-8 shrink-0">
<Image
src="/images/logo_small.png"
alt="OpenMonetis"
fill
sizes="32px"
className={cn("object-contain", !colorIcon && iconFilterClass)}
priority
/>
</div>
<div className="relative hidden h-8 w-[110px] shrink-0 sm:block">
<Image
src="/images/logo_text.png"
alt="OpenMonetis"
fill
sizes="110px"
className={cn("object-contain", invertTextOnDark && "dark:invert")}
priority
/>
</div>
</div>
);
}
if (variant === "small") {
return (
<Image
src="/images/logo_small.png"
alt="OpenMonetis"
width={32}
height={32}
className={cn("object-contain", className)}
priority
/>
<div className={cn("relative size-8 shrink-0", className)}>
<Image
src="/images/logo_small.png"
alt="OpenMonetis"
fill
sizes="32px"
className="object-contain"
priority
/>
</div>
);
}
return (
<div className={cn("flex items-center gap-1.5 py-4", className)}>
<Image
src="/images/logo_small.png"
alt="OpenMonetis"
width={28}
height={28}
className={cn("object-contain", !colorIcon && iconFilterClass)}
priority
/>
<Image
src="/images/logo_text.png"
alt="OpenMonetis"
width={100}
height={32}
className={cn("object-contain", invertTextOnDark && "dark:invert")}
priority
/>
<div className="relative size-7 shrink-0">
<Image
src="/images/logo_small.png"
alt="OpenMonetis"
fill
sizes="28px"
className={cn("object-contain", !colorIcon && iconFilterClass)}
priority
/>
</div>
<div className="relative h-8 w-[100px] shrink-0">
<Image
src="/images/logo_text.png"
alt="OpenMonetis"
fill
sizes="100px"
className={cn("object-contain", invertTextOnDark && "dark:invert")}
priority
/>
</div>
</div>
);
}

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