mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3be64aa8d0 | ||
|
|
85f6dcfc22 | ||
|
|
df996df93d | ||
|
|
10afef9fec | ||
|
|
fd4d90a53e | ||
|
|
a24406271c | ||
|
|
a09942e3d8 | ||
|
|
96febd5904 | ||
|
|
c3cfbc878c | ||
|
|
55bbfabe9f | ||
|
|
f5cdae4853 | ||
|
|
5c4995961c | ||
|
|
1b4dfaaba7 | ||
|
|
549a5bdba1 | ||
|
|
acaf9d5c27 | ||
|
|
e4c6a91350 | ||
|
|
ba369e8a83 | ||
|
|
d01bc8a669 | ||
|
|
e024e0d54e |
58
.github/copilot-instructions.md
vendored
58
.github/copilot-instructions.md
vendored
@@ -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)
|
||||
25
.github/workflows/docker-publish.yml
vendored
25
.github/workflows/docker-publish.yml
vendored
@@ -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
|
||||
|
||||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -5,6 +5,9 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
74
CHANGELOG.md
74
CHANGELOG.md
@@ -7,6 +7,80 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.3.3] - 2026-04-05
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Tokens: corrigido `/api/auth/device/verify` que rejeitava tokens criados via Settings (revertido de JWT para hash lookup)
|
||||
|
||||
### Alterado
|
||||
|
||||
- Tokens: prefixo renomeado de `os_` para `opm_` (OpenMonetis); tokens existentes precisam ser recriados
|
||||
- Tokens: removidas rotas JWT não utilizadas (`/api/auth/device/token` e `/api/auth/device/refresh`)
|
||||
- Tokens: `api-token.ts` simplificado para conter apenas `hashToken` e `extractBearerToken`
|
||||
|
||||
## [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
|
||||
|
||||
- Docker: imagem de produção deixa de executar `chown -R /app` no stage final; as permissões passam a ser definidas nos `COPY --chown`, reduzindo o risco de travamento e lentidão excessiva no build/push da GitHub Action
|
||||
|
||||
## [2.2.0] - 2026-04-01
|
||||
|
||||
### Adicionado
|
||||
|
||||
56
Dockerfile
56
Dockerfile
@@ -5,14 +5,16 @@
|
||||
# ============================================
|
||||
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
|
||||
|
||||
# Copiar apenas arquivos de dependências para aproveitar cache
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
|
||||
# Criar pasta public para o postinstall do pdfjs-dist
|
||||
RUN mkdir -p public
|
||||
|
||||
# Instalar dependências (production + dev para o build)
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
@@ -21,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
|
||||
|
||||
@@ -32,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
|
||||
|
||||
# ============================================
|
||||
@@ -46,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
|
||||
|
||||
@@ -55,12 +56,27 @@ WORKDIR /app
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copiar apenas arquivos necessários para produção
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
# 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 arquivos de build do Next.js
|
||||
# Copiar apenas arquivos necessários para produção
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
|
||||
# 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
|
||||
|
||||
@@ -69,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 \
|
||||
@@ -81,16 +98,13 @@ ENV NODE_ENV=production \
|
||||
# Expor porta
|
||||
EXPOSE 3000
|
||||
|
||||
# Ajustar permissões para o usuário nextjs
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
|
||||
# Mudar para usuário não-root
|
||||
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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
15
docker-entrypoint.sh
Normal 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
22
knip.jsonc
Normal 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
|
||||
}
|
||||
@@ -4,16 +4,24 @@ import type { NextConfig } from "next";
|
||||
// Carregar variáveis de ambiente explicitamente
|
||||
dotenv.config();
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
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 +46,23 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: "frame-ancestors 'none';",
|
||||
value: [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} 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",
|
||||
|
||||
28
package.json
28
package.json
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "openmonetis",
|
||||
"version": "2.2.0",
|
||||
"version": "2.3.3",
|
||||
"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",
|
||||
@@ -69,9 +72,10 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"exceljs": "^4.4.0",
|
||||
"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,23 +84,23 @@
|
||||
"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",
|
||||
"xlsx": "^0.18.5",
|
||||
"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"
|
||||
|
||||
1988
pnpm-lock.yaml
generated
1988
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
4
public/.well-known/security.txt
Normal file
4
public/.well-known/security.txt
Normal 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.
@@ -13,8 +13,6 @@ export const america = localFont({
|
||||
style: "normal",
|
||||
},
|
||||
],
|
||||
display: "swap",
|
||||
display: "fallback",
|
||||
variable: "--font-america",
|
||||
});
|
||||
|
||||
export const americaFontVariable = america.variable;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import {
|
||||
extractBearerToken,
|
||||
hashToken,
|
||||
refreshAccessToken,
|
||||
verifyJwt,
|
||||
} from "@/shared/lib/auth/api-token";
|
||||
import { db } from "@/shared/lib/db";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Extrair refresh token do header
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
const token = extractBearerToken(authHeader);
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: "Refresh token não fornecido" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validar refresh token
|
||||
const payload = verifyJwt(token);
|
||||
|
||||
if (!payload || payload.type !== "api_refresh") {
|
||||
return NextResponse.json(
|
||||
{ error: "Refresh token inválido ou expirado" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Verificar se token não foi revogado
|
||||
const tokenRecord = await db.query.apiTokens.findFirst({
|
||||
where: and(
|
||||
eq(apiTokens.id, payload.tokenId),
|
||||
eq(apiTokens.userId, payload.sub),
|
||||
isNull(apiTokens.revokedAt),
|
||||
gt(apiTokens.expiresAt, new Date()),
|
||||
),
|
||||
});
|
||||
|
||||
if (!tokenRecord) {
|
||||
return NextResponse.json(
|
||||
{ error: "Token revogado ou não encontrado" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Gerar novo access token
|
||||
const result = refreshAccessToken(token);
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json(
|
||||
{ error: "Não foi possível renovar o token" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Atualizar hash do token e último uso
|
||||
await db
|
||||
.update(apiTokens)
|
||||
.set({
|
||||
tokenHash: hashToken(result.accessToken),
|
||||
lastUsedAt: new Date(),
|
||||
lastUsedIp:
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
null,
|
||||
expiresAt: result.expiresAt,
|
||||
})
|
||||
.where(eq(apiTokens.id, payload.tokenId));
|
||||
|
||||
return NextResponse.json({
|
||||
accessToken: result.accessToken,
|
||||
expiresAt: result.expiresAt.toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[API] Error refreshing device token:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erro ao renovar token" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import {
|
||||
generateTokenPair,
|
||||
getTokenPrefix,
|
||||
hashToken,
|
||||
} from "@/shared/lib/auth/api-token";
|
||||
import { auth } from "@/shared/lib/auth/config";
|
||||
import { db } from "@/shared/lib/db";
|
||||
|
||||
const createTokenSchema = z.object({
|
||||
name: z.string().min(1, "Nome é obrigatório").max(100, "Nome muito longo"),
|
||||
deviceId: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
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);
|
||||
|
||||
// Gerar par de tokens
|
||||
const { accessToken, refreshToken, tokenId, expiresAt } = generateTokenPair(
|
||||
session.user.id,
|
||||
deviceId,
|
||||
);
|
||||
|
||||
// Salvar hash do token no banco
|
||||
await db.insert(apiTokens).values({
|
||||
id: tokenId,
|
||||
userId: session.user.id,
|
||||
name,
|
||||
tokenHash: hashToken(accessToken),
|
||||
tokenPrefix: getTokenPrefix(accessToken),
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// Retornar tokens (mostrados apenas uma vez)
|
||||
return NextResponse.json(
|
||||
{
|
||||
accessToken,
|
||||
refreshToken,
|
||||
tokenId,
|
||||
name,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
message:
|
||||
"Token criado com sucesso. Guarde-o em local seguro, ele não será mostrado novamente.",
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.issues[0]?.message ?? "Dados inválidos" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
console.error("[API] Error creating device token:", error);
|
||||
return NextResponse.json({ error: "Erro ao criar token" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -17,15 +17,14 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Validar token os_xxx via hash lookup
|
||||
if (!token.startsWith("os_")) {
|
||||
// Validar token opm_xxx via hash
|
||||
if (!token.startsWith("opm_")) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Formato de token inválido" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Hash do token para buscar no DB
|
||||
const tokenHash = hashToken(token);
|
||||
|
||||
// Buscar token no banco
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
@@ -48,8 +48,8 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Validar token os_xxx via hash
|
||||
if (!token.startsWith("os_")) {
|
||||
// Validar token opm_xxx via hash
|
||||
if (!token.startsWith("opm_")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Formato de token inválido" },
|
||||
{ status: 401 },
|
||||
@@ -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()),
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -41,8 +41,8 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Validar token os_xxx via hash
|
||||
if (!token.startsWith("os_")) {
|
||||
// Validar token opm_xxx via hash
|
||||
if (!token.startsWith("opm_")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Formato de token inválido" },
|
||||
{ status: 401 },
|
||||
@@ -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()),
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
44
src/app/api/insights/saved/route.ts
Normal file
44
src/app/api/insights/saved/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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/",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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ã",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}%`}`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
153
src/features/inbox/components/inbox-bulk-actions.tsx
Normal file
153
src/features/inbox/components/inbox-bulk-actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
133
src/features/inbox/components/inbox-items-list.tsx
Normal file
133
src/features/inbox/components/inbox-items-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
122
src/features/inbox/components/inbox-pagination.tsx
Normal file
122
src/features/inbox/components/inbox-pagination.tsx
Normal 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 };
|
||||
40
src/features/inbox/components/inbox-tabs.tsx
Normal file
40
src/features/inbox/components/inbox-tabs.tsx
Normal 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 };
|
||||
@@ -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({
|
||||
|
||||
@@ -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">
|
||||
|
||||
32
src/features/insights/hooks/use-saved-insights.ts
Normal file
32
src/features/insights/hooks/use-saved-insights.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
49
src/features/insights/queries.ts
Normal file
49
src/features/insights/queries.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -33,7 +33,7 @@ interface CategoryReportExportProps {
|
||||
filters: FilterState;
|
||||
}
|
||||
|
||||
const loadXlsx = () => import("xlsx");
|
||||
const loadExcelJS = () => import("exceljs");
|
||||
|
||||
const loadPdfDeps = async () => {
|
||||
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
|
||||
@@ -134,7 +134,7 @@ export function CategoryReportExport({
|
||||
const exportToExcel = async () => {
|
||||
try {
|
||||
setIsExporting(true);
|
||||
const XLSX = await loadXlsx();
|
||||
const ExcelJS = await loadExcelJS();
|
||||
|
||||
// Build data array
|
||||
const headers = [
|
||||
@@ -179,20 +179,32 @@ export function CategoryReportExport({
|
||||
totalsRow.push(formatCurrency(data.grandTotal));
|
||||
rows.push(totalsRow);
|
||||
|
||||
// Create worksheet
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
|
||||
// Create workbook and worksheet
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const ws = workbook.addWorksheet("Relatório de Categorias");
|
||||
|
||||
ws.addRows([headers, ...rows]);
|
||||
|
||||
// Set column widths
|
||||
ws["!cols"] = [
|
||||
{ wch: 20 }, // Category
|
||||
...data.periods.map(() => ({ wch: 15 })), // Periods
|
||||
{ wch: 15 }, // Total
|
||||
];
|
||||
ws.getColumn(1).width = 20;
|
||||
for (let i = 0; i < data.periods.length; i++) {
|
||||
ws.getColumn(i + 2).width = 15;
|
||||
}
|
||||
ws.getColumn(data.periods.length + 2).width = 15;
|
||||
|
||||
// Create workbook and download
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Relatório de Categorias");
|
||||
XLSX.writeFile(wb, getFileName("xlsx"));
|
||||
// Download
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
const blob = new Blob([buffer], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = getFileName("xlsx");
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success("Relatório exportado em Excel com sucesso!");
|
||||
} catch (error) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -610,7 +610,7 @@ const revokeApiTokenSchema = z.object({
|
||||
});
|
||||
|
||||
function generateSecureToken(): string {
|
||||
const prefix = "os";
|
||||
const prefix = "opm";
|
||||
const randomPart = randomBytes(32).toString("base64url");
|
||||
return `${prefix}_${randomPart}`;
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
99
src/features/transactions/anticipation-queries.ts
Normal file
99
src/features/transactions/anticipation-queries.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
66
src/features/transactions/attachment-queries.ts
Normal file
66
src/features/transactions/attachment-queries.ts
Normal 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),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 já 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>
|
||||
|
||||
@@ -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 já 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -45,10 +45,10 @@ export function UploadZone({ onParsed }: UploadZoneProps) {
|
||||
reader.readAsText(file, "windows-1252");
|
||||
} else {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const buffer = e.target?.result as ArrayBuffer;
|
||||
const statement = parseXls(buffer);
|
||||
const statement = await parseXls(buffer);
|
||||
onParsed(statement);
|
||||
} catch (err) {
|
||||
setError(
|
||||
@@ -62,8 +62,8 @@ export function UploadZone({ onParsed }: UploadZoneProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
const bytes = generateXlsTemplate();
|
||||
const handleDownloadTemplate = async () => {
|
||||
const bytes = await generateXlsTemplate();
|
||||
const blob = new Blob([bytes], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@ interface LancamentosExportProps {
|
||||
exportContext?: TransactionsExportContext;
|
||||
}
|
||||
|
||||
const loadXlsx = () => import("xlsx");
|
||||
const loadExcelJS = () => import("exceljs");
|
||||
|
||||
const loadPdfDeps = async () => {
|
||||
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
|
||||
@@ -158,7 +158,7 @@ export function TransactionsExport({
|
||||
try {
|
||||
setIsExporting(true);
|
||||
const transactions = await loadTransactions();
|
||||
const XLSX = await loadXlsx();
|
||||
const ExcelJS = await loadExcelJS();
|
||||
|
||||
const headers = [
|
||||
"Data",
|
||||
@@ -188,23 +188,28 @@ export function TransactionsExport({
|
||||
rows.push(row);
|
||||
});
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const ws = workbook.addWorksheet("Lançamentos");
|
||||
|
||||
ws["!cols"] = [
|
||||
{ wch: 12 }, // Data
|
||||
{ wch: 42 }, // Nome
|
||||
{ wch: 15 }, // Tipo
|
||||
{ wch: 15 }, // Condição
|
||||
{ wch: 20 }, // Pagamento
|
||||
{ wch: 15 }, // Valor
|
||||
{ wch: 20 }, // Category
|
||||
{ wch: 20 }, // Conta/Cartão
|
||||
{ wch: 20 }, // Payer
|
||||
];
|
||||
ws.addRows([headers, ...rows]);
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Lançamentos");
|
||||
XLSX.writeFile(wb, getFileName("xlsx"));
|
||||
const colWidths = [12, 42, 15, 15, 20, 15, 20, 20, 20];
|
||||
colWidths.forEach((w, i) => {
|
||||
ws.getColumn(i + 1).width = w;
|
||||
});
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
const blob = new Blob([buffer], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = getFileName("xlsx");
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success("Lançamentos exportados em Excel com sucesso!");
|
||||
} catch (error) {
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user