mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43697b4fd2 | ||
|
|
27e3ba5f0d | ||
|
|
31485eec8f | ||
|
|
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:
|
env:
|
||||||
DOCKER_IMAGE_NAME: openmonetis
|
DOCKER_IMAGE_NAME: openmonetis
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
jobs:
|
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:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: quality
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
|||||||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -5,6 +5,9 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
86
CHANGELOG.md
86
CHANGELOG.md
@@ -7,6 +7,92 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.3.5] - 2026-04-07
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- CSP: movido `Content-Security-Policy` do `next.config.ts` (build time) para `proxy.ts` (runtime), corrigindo bloqueio de upload de anexos quando `S3_ENDPOINT` não estava disponível durante o build do Docker
|
||||||
|
|
||||||
|
## [2.3.4] - 2026-04-05
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
|
||||||
|
- Anexos: corrigido upload que falhava com `NetworkError` — CSP `connect-src` bloqueava fetch para o Storage
|
||||||
|
|
||||||
|
## [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
|
## [2.2.0] - 2026-04-01
|
||||||
|
|
||||||
### Adicionado
|
### Adicionado
|
||||||
|
|||||||
56
Dockerfile
56
Dockerfile
@@ -5,14 +5,16 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
FROM node:22-alpine AS deps
|
FROM node:22-alpine AS deps
|
||||||
|
|
||||||
# Instalar pnpm globalmente
|
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copiar apenas arquivos de dependências para aproveitar cache
|
# Copiar apenas arquivos de dependências para aproveitar cache
|
||||||
COPY package.json pnpm-lock.yaml* ./
|
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)
|
# Instalar dependências (production + dev para o build)
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
@@ -21,8 +23,7 @@ RUN pnpm install --frozen-lockfile
|
|||||||
# ============================================
|
# ============================================
|
||||||
FROM node:22-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
# Instalar pnpm globalmente
|
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -32,13 +33,14 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
# Copiar todo o código fonte
|
# Copiar todo o código fonte
|
||||||
COPY . .
|
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
|
# 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 \
|
ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
|
||||||
# Build da aplicação Next.js
|
# Build da aplicação Next.js
|
||||||
# Nota: Se houver erros de tipo, ajuste typescript.ignoreBuildErrors no next.config.ts
|
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -46,8 +48,7 @@ RUN pnpm build
|
|||||||
# ============================================
|
# ============================================
|
||||||
FROM node:22-alpine AS runner
|
FROM node:22-alpine AS runner
|
||||||
|
|
||||||
# Instalar pnpm globalmente
|
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -55,12 +56,27 @@ WORKDIR /app
|
|||||||
RUN addgroup --system --gid 1001 nodejs && \
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
adduser --system --uid 1001 nextjs
|
adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
# Copiar apenas arquivos necessários para produção
|
# Instalar deps do drizzle-kit em diretório separado ANTES de copiar o standalone
|
||||||
COPY --from=builder /app/public ./public
|
# Isso evita que o pnpm install sobrescreva o node_modules do Next.js standalone
|
||||||
COPY --from=builder /app/package.json ./package.json
|
COPY --from=builder /app/package.json /tmp/pkg.json
|
||||||
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
|
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/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
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/drizzle.config.ts ./drizzle.config.ts
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/src/db ./src/db
|
COPY --from=builder --chown=nextjs:nodejs /app/src/db ./src/db
|
||||||
|
|
||||||
# Copiar node_modules para ter drizzle-kit disponível para migrations
|
# Copiar entrypoint de migrations
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
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
|
# Definir variáveis de ambiente de produção
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
@@ -81,16 +98,13 @@ ENV NODE_ENV=production \
|
|||||||
# Expor porta
|
# Expor porta
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Ajustar permissões para o usuário nextjs
|
|
||||||
RUN chown -R nextjs:nodejs /app
|
|
||||||
|
|
||||||
# Mudar para usuário não-root
|
# Mudar para usuário não-root
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
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
|
# Entrypoint: roda migrations e depois executa o CMD
|
||||||
# Nota: Em produção com standalone build, o servidor é iniciado pelo arquivo server.js
|
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
|
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
|
||||||
|
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://nextjs.org/)
|
[](https://nextjs.org/)
|
||||||
[](https://www.typescriptlang.org/)
|
[](https://www.typescriptlang.org/)
|
||||||
[](https://www.postgresql.org/)
|
[](https://www.postgresql.org/)
|
||||||
|
|||||||
@@ -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": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
|
|||||||
@@ -4,23 +4,28 @@ name: openmonetis
|
|||||||
# MODOS DE USO:
|
# MODOS DE USO:
|
||||||
# 1. Banco LOCAL (PostgreSQL em container):
|
# 1. Banco LOCAL (PostgreSQL em container):
|
||||||
# - Configure DATABASE_URL com host "db" no .env
|
# - 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):
|
# 2. Banco REMOTO (ex: Supabase, Neon, etc):
|
||||||
# - Configure DATABASE_URL com a URL do banco remoto no .env
|
# - 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
|
# - 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
|
# - Execute: docker compose down -v
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# ============================================
|
# ============================================
|
||||||
# Serviço: PostgreSQL (Banco de dados local)
|
# Serviço: PostgreSQL (Banco de dados local)
|
||||||
|
# Ativado apenas com: --profile local
|
||||||
# ============================================
|
# ============================================
|
||||||
db:
|
db:
|
||||||
|
profiles: ["local"]
|
||||||
image: postgres:18-alpine
|
image: postgres:18-alpine
|
||||||
container_name: openmonetis_postgres
|
container_name: openmonetis_postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -63,6 +68,7 @@ services:
|
|||||||
# Serviço: Aplicação Next.js
|
# Serviço: Aplicação Next.js
|
||||||
# ============================================
|
# ============================================
|
||||||
app:
|
app:
|
||||||
|
build: .
|
||||||
image: felipegcoutinho/openmonetis:latest
|
image: felipegcoutinho/openmonetis:latest
|
||||||
|
|
||||||
container_name: openmonetis_app
|
container_name: openmonetis_app
|
||||||
@@ -80,6 +86,13 @@ services:
|
|||||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||||
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
|
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)
|
# Email (opcional)
|
||||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||||
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
|
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
|
||||||
@@ -96,24 +109,11 @@ services:
|
|||||||
GOOGLE_GENERATIVE_AI_API_KEY: ${GOOGLE_GENERATIVE_AI_API_KEY:-}
|
GOOGLE_GENERATIVE_AI_API_KEY: ${GOOGLE_GENERATIVE_AI_API_KEY:-}
|
||||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
||||||
|
|
||||||
# Só depende do 'db' se estiver usando banco local
|
# required: false permite subir sem banco local (banco remoto via DATABASE_URL)
|
||||||
# Para banco remoto, comente as linhas abaixo
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
required: false
|
||||||
# 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
|
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
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
|
||||||
|
}
|
||||||
@@ -8,12 +8,18 @@ const nextConfig: NextConfig = {
|
|||||||
output: "standalone",
|
output: "standalone",
|
||||||
cacheComponents: true,
|
cacheComponents: true,
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
|
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [new URL("https://lh3.googleusercontent.com/**")],
|
remotePatterns: [new URL("https://lh3.googleusercontent.com/**")],
|
||||||
},
|
},
|
||||||
devIndicators: {
|
devIndicators: {
|
||||||
position: "bottom-right",
|
position: "bottom-right",
|
||||||
},
|
},
|
||||||
|
experimental: {
|
||||||
|
prefetchInlining: true,
|
||||||
|
turbopackFileSystemCacheForDev: true,
|
||||||
|
},
|
||||||
|
|
||||||
// Headers for Safari compatibility
|
// Headers for Safari compatibility
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
@@ -37,8 +43,12 @@ const nextConfig: NextConfig = {
|
|||||||
value: "DENY",
|
value: "DENY",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "Content-Security-Policy",
|
key: "Referrer-Policy",
|
||||||
value: "frame-ancestors 'none';",
|
value: "strict-origin-when-cross-origin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "X-Permitted-Cross-Domain-Policies",
|
||||||
|
value: "none",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "Permissions-Policy",
|
key: "Permissions-Policy",
|
||||||
|
|||||||
28
package.json
28
package.json
@@ -1,13 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "openmonetis",
|
"name": "openmonetis",
|
||||||
"version": "2.2.0",
|
"version": "2.3.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"packageManager": "pnpm@10.33.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"db:seed": "tsx scripts/mock-data.ts",
|
"db:seed": "tsx scripts/mock-data.ts",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
|
"lint:deadcode": "knip --reporter compact",
|
||||||
"lint:fix": "biome check --write .",
|
"lint:fix": "biome check --write .",
|
||||||
"env:setup": "bash scripts/setup-env.sh",
|
"env:setup": "bash scripts/setup-env.sh",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
@@ -29,11 +31,11 @@
|
|||||||
"backup": "bash scripts/backup.sh"
|
"backup": "bash scripts/backup.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^3.0.64",
|
"@ai-sdk/anthropic": "^3.0.65",
|
||||||
"@ai-sdk/google": "^3.0.53",
|
"@ai-sdk/google": "^3.0.55",
|
||||||
"@ai-sdk/openai": "^3.0.48",
|
"@ai-sdk/openai": "^3.0.49",
|
||||||
"@aws-sdk/client-s3": "^3.1019.0",
|
"@aws-sdk/client-s3": "^3.1022.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1019.0",
|
"@aws-sdk/s3-request-presigner": "^3.1022.0",
|
||||||
"@better-auth/passkey": "^1.5.6",
|
"@better-auth/passkey": "^1.5.6",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -59,9 +61,10 @@
|
|||||||
"@radix-ui/react-toggle-group": "1.1.11",
|
"@radix-ui/react-toggle-group": "1.1.11",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
|
"@tanstack/react-query": "^5.96.2",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"@tanstack/react-virtual": "^3.13.23",
|
"@tanstack/react-virtual": "^3.13.23",
|
||||||
"ai": "^6.0.141",
|
"ai": "^6.0.143",
|
||||||
"better-auth": "1.5.6",
|
"better-auth": "1.5.6",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
@@ -69,9 +72,10 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"next": "16.1.7",
|
"next": "16.2.2",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"pdfjs-dist": "^5.6.205",
|
"pdfjs-dist": "^5.6.205",
|
||||||
"pg": "8.20.0",
|
"pg": "8.20.0",
|
||||||
@@ -80,23 +84,23 @@
|
|||||||
"react-day-picker": "^9.14.0",
|
"react-day-picker": "^9.14.0",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"recharts": "3.8.1",
|
"recharts": "3.8.1",
|
||||||
"resend": "^6.9.4",
|
"resend": "^6.10.0",
|
||||||
"sonner": "2.0.7",
|
"sonner": "2.0.7",
|
||||||
"tailwind-merge": "3.5.0",
|
"tailwind-merge": "3.5.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"xlsx": "^0.18.5",
|
|
||||||
"zod": "4.3.6"
|
"zod": "4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.9",
|
"@biomejs/biome": "2.4.10",
|
||||||
"@tailwindcss/postcss": "4.2.2",
|
"@tailwindcss/postcss": "4.2.2",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "25.5.0",
|
"@types/node": "25.5.0",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.14",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.4.0",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
|
"knip": "^6.3.0",
|
||||||
"tailwindcss": "4.2.2",
|
"tailwindcss": "4.2.2",
|
||||||
"tsx": "4.21.0",
|
"tsx": "4.21.0",
|
||||||
"typescript": "6.0.2"
|
"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",
|
style: "normal",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
display: "swap",
|
display: "fallback",
|
||||||
variable: "--font-america",
|
variable: "--font-america",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const americaFontVariable = america.variable;
|
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { attachments } from "@/db/schema";
|
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 { db } from "@/shared/lib/db";
|
||||||
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
|
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
|
||||||
|
|
||||||
|
const PRIVATE_RESPONSE_HEADERS = {
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
};
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: Request,
|
_request: Request,
|
||||||
{ params }: { params: Promise<{ attachmentId: string }> },
|
{ 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
|
const [row] = await db
|
||||||
.select({ fileKey: attachments.fileKey })
|
.select({ fileKey: attachments.fileKey })
|
||||||
@@ -19,9 +35,20 @@ export async function GET(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!row) {
|
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);
|
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 { and, eq } from "drizzle-orm";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { NextResponse } from "next/server";
|
import { connection, NextResponse } from "next/server";
|
||||||
import { apiTokens } from "@/db/schema";
|
import { apiTokens } from "@/db/schema";
|
||||||
import { auth } from "@/shared/lib/auth/config";
|
import { auth } from "@/shared/lib/auth/config";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
@@ -10,16 +10,19 @@ interface RouteParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(_request: Request, { params }: RouteParams) {
|
export async function DELETE(_request: Request, { params }: RouteParams) {
|
||||||
try {
|
await connection();
|
||||||
|
|
||||||
const { tokenId } = await params;
|
const { tokenId } = await params;
|
||||||
|
|
||||||
// Verificar autenticação via sessão web
|
// Verificar autenticação via sessão web
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const requestHeaders = new Headers(await headers());
|
||||||
|
const session = await auth.api.getSession({ headers: requestHeaders });
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
// Verificar se token pertence ao usuário
|
// Verificar se token pertence ao usuário
|
||||||
const token = await db.query.apiTokens.findFirst({
|
const token = await db.query.apiTokens.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { NextResponse } from "next/server";
|
import { connection, NextResponse } from "next/server";
|
||||||
import { apiTokens } from "@/db/schema";
|
import { apiTokens } from "@/db/schema";
|
||||||
import { auth } from "@/shared/lib/auth/config";
|
import { auth } from "@/shared/lib/auth/config";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
await connection();
|
||||||
|
|
||||||
// Verificar autenticação via sessão web
|
// Verificar autenticação via sessão web
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const requestHeaders = new Headers(await headers());
|
||||||
|
const session = await auth.api.getSession({ headers: requestHeaders });
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
// Buscar tokens ativos do usuário
|
// Buscar tokens ativos do usuário
|
||||||
const activeTokens = await db
|
const activeTokens = await db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
@@ -17,15 +17,14 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validar token os_xxx via hash lookup
|
// Validar token opm_xxx via hash
|
||||||
if (!token.startsWith("os_")) {
|
if (!token.startsWith("opm_")) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ valid: false, error: "Formato de token inválido" },
|
{ valid: false, error: "Formato de token inválido" },
|
||||||
{ status: 401 },
|
{ status: 401 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash do token para buscar no DB
|
|
||||||
const tokenHash = hashToken(token);
|
const tokenHash = hashToken(token);
|
||||||
|
|
||||||
// Buscar token no banco
|
// Buscar token no banco
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { version as APP_VERSION } from "@/package.json";
|
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,7 +19,6 @@ export async function GET() {
|
|||||||
{
|
{
|
||||||
status: "ok",
|
status: "ok",
|
||||||
name: "OpenMonetis",
|
name: "OpenMonetis",
|
||||||
version: APP_VERSION,
|
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
{ status: 200 },
|
{ status: 200 },
|
||||||
@@ -33,7 +31,6 @@ export async function GET() {
|
|||||||
{
|
{
|
||||||
status: "error",
|
status: "error",
|
||||||
name: "OpenMonetis",
|
name: "OpenMonetis",
|
||||||
version: APP_VERSION,
|
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
message: "Database connection failed",
|
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 { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { apiTokens, inboxItems } from "@/db/schema";
|
import { apiTokens, inboxItems } from "@/db/schema";
|
||||||
@@ -48,8 +48,8 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validar token os_xxx via hash
|
// Validar token opm_xxx via hash
|
||||||
if (!token.startsWith("os_")) {
|
if (!token.startsWith("opm_")) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Formato de token inválido" },
|
{ error: "Formato de token inválido" },
|
||||||
{ status: 401 },
|
{ status: 401 },
|
||||||
@@ -63,7 +63,7 @@ export async function POST(request: Request) {
|
|||||||
where: and(
|
where: and(
|
||||||
eq(apiTokens.tokenHash, tokenHash),
|
eq(apiTokens.tokenHash, tokenHash),
|
||||||
isNull(apiTokens.revokedAt),
|
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 { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { apiTokens, inboxItems } from "@/db/schema";
|
import { apiTokens, inboxItems } from "@/db/schema";
|
||||||
@@ -41,8 +41,8 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validar token os_xxx via hash
|
// Validar token opm_xxx via hash
|
||||||
if (!token.startsWith("os_")) {
|
if (!token.startsWith("opm_")) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Formato de token inválido" },
|
{ error: "Formato de token inválido" },
|
||||||
{ status: 401 },
|
{ status: 401 },
|
||||||
@@ -56,7 +56,7 @@ export async function POST(request: Request) {
|
|||||||
where: and(
|
where: and(
|
||||||
eq(apiTokens.tokenHash, tokenHash),
|
eq(apiTokens.tokenHash, tokenHash),
|
||||||
isNull(apiTokens.revokedAt),
|
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 type { Metadata } from "next";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import { QueryProvider } from "@/shared/components/providers/query-provider";
|
||||||
import { ThemeProvider } from "@/shared/components/providers/theme-provider";
|
import { ThemeProvider } from "@/shared/components/providers/theme-provider";
|
||||||
import { Toaster } from "@/shared/components/ui/sonner";
|
import { Toaster } from "@/shared/components/ui/sonner";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
@@ -21,6 +22,7 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
|
data-scroll-behavior="smooth"
|
||||||
lang="pt-BR"
|
lang="pt-BR"
|
||||||
className={`${america.variable} ${america.className} `}
|
className={`${america.variable} ${america.className} `}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
@@ -36,8 +38,10 @@ export default function RootLayout({
|
|||||||
</head>
|
</head>
|
||||||
<body className="antialiased" suppressHydrationWarning>
|
<body className="antialiased" suppressHydrationWarning>
|
||||||
<ThemeProvider attribute="class" defaultTheme="light">
|
<ThemeProvider attribute="class" defaultTheme="light">
|
||||||
|
<QueryProvider>
|
||||||
<Suspense>{children}</Suspense>
|
<Suspense>{children}</Suspense>
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
|
</QueryProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -6,25 +6,7 @@ export default function robots(): MetadataRoute.Robots {
|
|||||||
{
|
{
|
||||||
userAgent: "*",
|
userAgent: "*",
|
||||||
allow: "/",
|
allow: "/",
|
||||||
disallow: [
|
disallow: "/api/",
|
||||||
"/dashboard",
|
|
||||||
"/transactions",
|
|
||||||
"/accounts",
|
|
||||||
"/cards",
|
|
||||||
"/categories",
|
|
||||||
"/budgets",
|
|
||||||
"/payers",
|
|
||||||
"/notes",
|
|
||||||
"/insights",
|
|
||||||
"/calendar",
|
|
||||||
"/attachments",
|
|
||||||
"/settings",
|
|
||||||
"/reports",
|
|
||||||
"/inbox",
|
|
||||||
"/login",
|
|
||||||
"/signup",
|
|
||||||
"/api/",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
const BASE_URL = process.env.PUBLIC_DOMAIN
|
const BASE_URL = process.env.PUBLIC_DOMAIN
|
||||||
? `https://${process.env.PUBLIC_DOMAIN}`
|
? `https://${process.env.PUBLIC_DOMAIN.replace(/^https?:\/\//, "")}`
|
||||||
: "https://openmonetis.com";
|
: "https://openmonetis.com";
|
||||||
|
|
||||||
export default function sitemap(): MetadataRoute.Sitemap {
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export const userPreferences = pgTable("preferencias_usuario", {
|
|||||||
dashboardWidgets: jsonb("dashboard_widgets").$type<{
|
dashboardWidgets: jsonb("dashboard_widgets").$type<{
|
||||||
order: string[];
|
order: string[];
|
||||||
hidden: string[];
|
hidden: string[];
|
||||||
|
myAccountsShowExcluded?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
createdAt: timestamp("created_at", {
|
createdAt: timestamp("created_at", {
|
||||||
mode: "date",
|
mode: "date",
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ export function AttachmentGridItem({
|
|||||||
src={url}
|
src={url}
|
||||||
alt={attachment.fileName}
|
alt={attachment.fileName}
|
||||||
fill
|
fill
|
||||||
|
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, (max-width: 1280px) 25vw, 20vw"
|
||||||
unoptimized
|
unoptimized
|
||||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
RiExternalLinkLine,
|
RiExternalLinkLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useAttachmentUrlQuery } from "@/features/attachments/hooks/use-attachment-url";
|
||||||
import type { AttachmentForPeriod } from "@/features/attachments/queries";
|
import type { AttachmentForPeriod } from "@/features/attachments/queries";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -30,7 +31,6 @@ export function AttachmentPreview({
|
|||||||
onClose,
|
onClose,
|
||||||
}: AttachmentPreviewProps) {
|
}: AttachmentPreviewProps) {
|
||||||
const [currentIndex, setCurrentIndex] = useState(selectedIndex);
|
const [currentIndex, setCurrentIndex] = useState(selectedIndex);
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
|
||||||
const open = selectedIndex >= 0;
|
const open = selectedIndex >= 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -52,17 +52,11 @@ export function AttachmentPreview({
|
|||||||
|
|
||||||
const attachment = attachments[currentIndex];
|
const attachment = attachments[currentIndex];
|
||||||
const attachmentId = attachment?.attachmentId;
|
const attachmentId = attachment?.attachmentId;
|
||||||
|
const {
|
||||||
// Busca URL fresca a cada troca de anexo
|
data: previewUrl,
|
||||||
useEffect(() => {
|
isLoading: isPreviewLoading,
|
||||||
if (!attachmentId) return;
|
isError: isPreviewError,
|
||||||
setPreviewUrl(null);
|
} = useAttachmentUrlQuery(attachmentId ?? "", open && Boolean(attachmentId));
|
||||||
|
|
||||||
fetch(`/api/attachments/${attachmentId}/presign`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data: { url: string }) => setPreviewUrl(data.url))
|
|
||||||
.catch(() => {});
|
|
||||||
}, [attachmentId]);
|
|
||||||
|
|
||||||
if (!attachment) return null;
|
if (!attachment) return null;
|
||||||
|
|
||||||
@@ -170,11 +164,16 @@ export function AttachmentPreview({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="min-h-0 min-w-0 flex-1">
|
<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="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 className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-foreground" />
|
||||||
</div>
|
</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 && (
|
{isPdf && previewUrl && (
|
||||||
<iframe
|
<iframe
|
||||||
key={attachment.attachmentId}
|
key={attachment.attachmentId}
|
||||||
|
|||||||
@@ -1,13 +1,37 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useEffect, useRef, useState } from "react";
|
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) {
|
export function useAttachmentUrl(attachmentId: string) {
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUrl(null);
|
void attachmentId;
|
||||||
|
setIsVisible(false);
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
@@ -15,10 +39,7 @@ export function useAttachmentUrl(attachmentId: string) {
|
|||||||
(entries) => {
|
(entries) => {
|
||||||
if (!entries[0].isIntersecting) return;
|
if (!entries[0].isIntersecting) return;
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
fetch(`/api/attachments/${attachmentId}/presign`)
|
setIsVisible(true);
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data: { url: string }) => setUrl(data.url))
|
|
||||||
.catch(() => {});
|
|
||||||
},
|
},
|
||||||
{ rootMargin: "150px" },
|
{ rootMargin: "150px" },
|
||||||
);
|
);
|
||||||
@@ -27,5 +48,7 @@ export function useAttachmentUrl(attachmentId: string) {
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [attachmentId]);
|
}, [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",
|
cardName: "Nubank",
|
||||||
logo: "nubank.png",
|
logo: "nubank.png",
|
||||||
amount: 1898,
|
amount: 1898,
|
||||||
dueLabel: "Vence em 3 dias",
|
dueLabel: "Vence hoje",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
cardName: "Itaú",
|
cardName: "Itaú",
|
||||||
logo: "itau.png",
|
logo: "itau.png",
|
||||||
amount: 1923,
|
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 { getBusinessDateString, isDateOnlyPast } from "@/shared/utils/date";
|
||||||
import {
|
import {
|
||||||
buildFinancialStatusLabel,
|
buildFinancialStatusLabel,
|
||||||
|
buildRelativeFinancialStatusLabel,
|
||||||
formatFinancialDateLabel,
|
formatFinancialDateLabel,
|
||||||
} from "@/shared/utils/financial-dates";
|
} 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 getCurrentBillDateString = () => getBusinessDateString();
|
||||||
|
|
||||||
export const isBillOverdue = (bill: DashboardBill) => {
|
export const isBillOverdue = (bill: DashboardBill) => {
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { and, asc, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { transactions } from "@/db/schema";
|
import { transactions } from "@/db/schema";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
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";
|
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||||
|
|
||||||
const PAYMENT_METHOD_BOLETO = "Boleto";
|
const PAYMENT_METHOD_BOLETO = "Boleto";
|
||||||
@@ -33,10 +38,31 @@ export type DashboardBillsSnapshot = {
|
|||||||
pendingCount: number;
|
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(
|
export async function fetchDashboardBills(
|
||||||
userId: string,
|
userId: string,
|
||||||
period: string,
|
period: string,
|
||||||
): Promise<DashboardBillsSnapshot> {
|
): Promise<DashboardBillsSnapshot> {
|
||||||
|
const today = getBusinessDateString();
|
||||||
const adminPayerId = await getAdminPayerId(userId);
|
const adminPayerId = await getAdminPayerId(userId);
|
||||||
if (!adminPayerId) {
|
if (!adminPayerId) {
|
||||||
return { bills: [], totalPendingAmount: 0, pendingCount: 0 };
|
return { bills: [], totalPendingAmount: 0, pendingCount: 0 };
|
||||||
@@ -59,11 +85,6 @@ export async function fetchDashboardBills(
|
|||||||
eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
|
eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||||
eq(transactions.payerId, adminPayerId),
|
eq(transactions.payerId, adminPayerId),
|
||||||
),
|
),
|
||||||
)
|
|
||||||
.orderBy(
|
|
||||||
asc(transactions.isSettled),
|
|
||||||
asc(transactions.dueDate),
|
|
||||||
asc(transactions.name),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const bills = rows.map((row: RawDashboardBill): DashboardBill => {
|
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 totalPendingAmount = 0;
|
||||||
let pendingCount = 0;
|
let pendingCount = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||||
import { budgets, categories, transactions } from "@/db/schema";
|
import {
|
||||||
|
budgets,
|
||||||
|
categories,
|
||||||
|
financialAccounts,
|
||||||
|
transactions,
|
||||||
|
} from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
buildCategoryBreakdownData,
|
buildCategoryBreakdownData,
|
||||||
type DashboardCategoryBreakdownData,
|
type DashboardCategoryBreakdownData,
|
||||||
@@ -8,6 +13,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildDashboardAdminFilters,
|
buildDashboardAdminFilters,
|
||||||
excludeAutoInvoiceEntries,
|
excludeAutoInvoiceEntries,
|
||||||
|
excludeTransactionsFromExcludedAccounts,
|
||||||
} from "@/features/dashboard/transaction-filters";
|
} from "@/features/dashboard/transaction-filters";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
@@ -39,6 +45,10 @@ export async function fetchExpensesByCategory(
|
|||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.innerJoin(categories, eq(transactions.categoryId, categories.id))
|
.innerJoin(categories, eq(transactions.categoryId, categories.id))
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
...buildDashboardAdminFilters({ userId, adminPayerId }),
|
...buildDashboardAdminFilters({ userId, adminPayerId }),
|
||||||
@@ -46,6 +56,7 @@ export async function fetchExpensesByCategory(
|
|||||||
eq(transactions.transactionType, "Despesa"),
|
eq(transactions.transactionType, "Despesa"),
|
||||||
eq(categories.type, "despesa"),
|
eq(categories.type, "despesa"),
|
||||||
excludeAutoInvoiceEntries(),
|
excludeAutoInvoiceEntries(),
|
||||||
|
excludeTransactionsFromExcludedAccounts(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(
|
.groupBy(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
buildDashboardAdminFilters,
|
buildDashboardAdminFilters,
|
||||||
excludeAutoInvoiceEntries,
|
excludeAutoInvoiceEntries,
|
||||||
excludeInitialBalanceWhenConfigured,
|
excludeInitialBalanceWhenConfigured,
|
||||||
|
excludeTransactionsFromExcludedAccounts,
|
||||||
} from "@/features/dashboard/transaction-filters";
|
} from "@/features/dashboard/transaction-filters";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
@@ -57,6 +58,7 @@ export async function fetchIncomeByCategory(
|
|||||||
eq(categories.type, "receita"),
|
eq(categories.type, "receita"),
|
||||||
excludeAutoInvoiceEntries(),
|
excludeAutoInvoiceEntries(),
|
||||||
excludeInitialBalanceWhenConfigured(),
|
excludeInitialBalanceWhenConfigured(),
|
||||||
|
excludeTransactionsFromExcludedAccounts(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(
|
.groupBy(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
buildDashboardAdminFilters,
|
buildDashboardAdminFilters,
|
||||||
excludeAutoInvoiceEntries,
|
excludeAutoInvoiceEntries,
|
||||||
excludeInitialBalanceWhenConfigured,
|
excludeInitialBalanceWhenConfigured,
|
||||||
|
excludeTransactionsFromExcludedAccounts,
|
||||||
} from "@/features/dashboard/transaction-filters";
|
} from "@/features/dashboard/transaction-filters";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
@@ -156,6 +157,7 @@ export async function fetchDashboardCategoryOverview(
|
|||||||
and(
|
and(
|
||||||
...buildDashboardAdminFilters({ userId, adminPayerId }),
|
...buildDashboardAdminFilters({ userId, adminPayerId }),
|
||||||
inArray(transactions.period, [period, previousPeriod]),
|
inArray(transactions.period, [period, previousPeriod]),
|
||||||
|
excludeTransactionsFromExcludedAccounts(),
|
||||||
or(
|
or(
|
||||||
and(
|
and(
|
||||||
eq(transactions.transactionType, "Despesa"),
|
eq(transactions.transactionType, "Despesa"),
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { RiCheckboxCircleFill } from "@remixicon/react";
|
import { RiCheckboxCircleFill } from "@remixicon/react";
|
||||||
import {
|
import {
|
||||||
buildBillStatusLabel,
|
buildBillStatusLabel,
|
||||||
|
buildBillWidgetStatusLabel,
|
||||||
isBillOverdue,
|
isBillOverdue,
|
||||||
} from "@/features/dashboard/bills-helpers";
|
} from "@/features/dashboard/bills-helpers";
|
||||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||||
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip";
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils/ui";
|
||||||
|
|
||||||
type BillListItemProps = {
|
type BillListItemProps = {
|
||||||
@@ -15,8 +21,13 @@ type BillListItemProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function BillListItem({ bill, onPay }: BillListItemProps) {
|
export function BillListItem({ bill, onPay }: BillListItemProps) {
|
||||||
const statusLabel = buildBillStatusLabel(bill);
|
const statusLabel = buildBillWidgetStatusLabel(bill);
|
||||||
|
const absoluteStatusLabel = buildBillStatusLabel(bill);
|
||||||
const overdue = isBillOverdue(bill);
|
const overdue = isBillOverdue(bill);
|
||||||
|
const statusTooltipLabel =
|
||||||
|
statusLabel && statusLabel !== absoluteStatusLabel
|
||||||
|
? absoluteStatusLabel
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="flex items-center justify-between transition-all duration-300 py-1.5">
|
<li className="flex items-center justify-between transition-all duration-300 py-1.5">
|
||||||
@@ -29,6 +40,23 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
{statusLabel ? (
|
{statusLabel ? (
|
||||||
|
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
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full py-0.5",
|
"rounded-full py-0.5",
|
||||||
@@ -37,6 +65,7 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
|
|||||||
>
|
>
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
</span>
|
</span>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function BillPaymentDialog({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent
|
<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) => {
|
onEscapeKeyDown={(event) => {
|
||||||
if (isProcessing) {
|
if (isProcessing) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -93,7 +93,7 @@ export function BillPaymentDialog({
|
|||||||
{bill ? (
|
{bill ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Card principal */}
|
{/* 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">
|
<p className="mb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
Boleto
|
Boleto
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ export function DashboardGridEditable({
|
|||||||
const [hiddenWidgets, setHiddenWidgets] = useState<string[]>(
|
const [hiddenWidgets, setHiddenWidgets] = useState<string[]>(
|
||||||
initialPreferences?.hidden ?? [],
|
initialPreferences?.hidden ?? [],
|
||||||
);
|
);
|
||||||
|
const [myAccountsShowExcluded, setMyAccountsShowExcluded] = useState(
|
||||||
|
initialPreferences?.myAccountsShowExcluded ?? true,
|
||||||
|
);
|
||||||
|
|
||||||
// Keep track of original state for cancel
|
// Keep track of original state for cancel
|
||||||
const [originalOrder, setOriginalOrder] = useState(widgetOrder);
|
const [originalOrder, setOriginalOrder] = useState(widgetOrder);
|
||||||
@@ -186,6 +189,7 @@ export function DashboardGridEditable({
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setWidgetOrder(DEFAULT_WIDGET_ORDER);
|
setWidgetOrder(DEFAULT_WIDGET_ORDER);
|
||||||
setHiddenWidgets([]);
|
setHiddenWidgets([]);
|
||||||
|
setMyAccountsShowExcluded(true);
|
||||||
toast.success("Preferências restauradas!");
|
toast.success("Preferências restauradas!");
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error ?? "Erro ao restaurar");
|
toast.error(result.error ?? "Erro ao restaurar");
|
||||||
@@ -361,7 +365,16 @@ export function DashboardGridEditable({
|
|||||||
icon={widget.icon}
|
icon={widget.icon}
|
||||||
action={widget.action}
|
action={widget.action}
|
||||||
>
|
>
|
||||||
{widget.component({ data, period })}
|
{widget.component({
|
||||||
|
data,
|
||||||
|
period,
|
||||||
|
widgetPreferences: {
|
||||||
|
order: widgetOrder,
|
||||||
|
hidden: hiddenWidgets,
|
||||||
|
myAccountsShowExcluded,
|
||||||
|
},
|
||||||
|
onMyAccountsShowExcludedChange: setMyAccountsShowExcluded,
|
||||||
|
})}
|
||||||
</ExpandableWidgetCard>
|
</ExpandableWidgetCard>
|
||||||
</div>
|
</div>
|
||||||
</SortableWidget>
|
</SortableWidget>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
RiScalesLine,
|
RiScalesLine,
|
||||||
RiSubtractLine,
|
RiSubtractLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
|
import { MetricsCardInfoButton } from "@/features/dashboard/components/metrics-card-info-button";
|
||||||
import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries";
|
import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +37,14 @@ const CARDS = [
|
|||||||
icon: RiArrowDownLine,
|
icon: RiArrowDownLine,
|
||||||
invertTrend: false,
|
invertTrend: false,
|
||||||
iconClass: "text-success",
|
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",
|
label: "Despesas",
|
||||||
@@ -44,14 +53,29 @@ const CARDS = [
|
|||||||
icon: RiArrowUpLine,
|
icon: RiArrowUpLine,
|
||||||
invertTrend: true,
|
invertTrend: true,
|
||||||
iconClass: "text-destructive",
|
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",
|
label: "Balanço",
|
||||||
subtitle: "Receitas menos despesas",
|
subtitle: "Receitas, despesas e ajustes entre contas",
|
||||||
key: "balanco",
|
key: "balanco",
|
||||||
icon: RiScalesLine,
|
icon: RiScalesLine,
|
||||||
invertTrend: false,
|
invertTrend: false,
|
||||||
iconClass: "text-warning",
|
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",
|
label: "Previsto",
|
||||||
@@ -60,6 +84,13 @@ const CARDS = [
|
|||||||
icon: RiCalendarCheckLine,
|
icon: RiCalendarCheckLine,
|
||||||
invertTrend: false,
|
invertTrend: false,
|
||||||
iconClass: "text-cyan-600",
|
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;
|
] as const;
|
||||||
|
|
||||||
@@ -104,7 +135,16 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
<div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
||||||
{CARDS.map(
|
{CARDS.map(
|
||||||
({ label, subtitle, key, icon: Icon, invertTrend, iconClass }) => {
|
({
|
||||||
|
label,
|
||||||
|
subtitle,
|
||||||
|
key,
|
||||||
|
icon: Icon,
|
||||||
|
invertTrend,
|
||||||
|
iconClass,
|
||||||
|
helpTitle,
|
||||||
|
helpLines,
|
||||||
|
}) => {
|
||||||
const metric = metrics[key];
|
const metric = metrics[key];
|
||||||
const trend = getTrend(metric.current, metric.previous);
|
const trend = getTrend(metric.current, metric.previous);
|
||||||
const TrendIcon = TREND_ICONS[trend];
|
const TrendIcon = TREND_ICONS[trend];
|
||||||
@@ -119,9 +159,14 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<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 />
|
<Icon className={cn("size-4", iconClass)} aria-hidden />
|
||||||
{label}
|
{label}
|
||||||
|
<MetricsCardInfoButton
|
||||||
|
label={label}
|
||||||
|
helpTitle={helpTitle}
|
||||||
|
helpLines={helpLines}
|
||||||
|
/>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="mt-1.5 tracking-tight">
|
<CardDescription className="mt-1.5 tracking-tight">
|
||||||
{subtitle}
|
{subtitle}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import {
|
|||||||
buildInvoiceDetailsHref,
|
buildInvoiceDetailsHref,
|
||||||
buildInvoiceInitials,
|
buildInvoiceInitials,
|
||||||
formatInvoicePaymentDate,
|
formatInvoicePaymentDate,
|
||||||
|
formatInvoiceWidgetPaymentDate,
|
||||||
getInvoiceShareLabel,
|
getInvoiceShareLabel,
|
||||||
parseInvoiceDueDate,
|
parseInvoiceDueDate,
|
||||||
|
parseInvoiceWidgetDueDate,
|
||||||
} from "@/features/dashboard/invoices-helpers";
|
} from "@/features/dashboard/invoices-helpers";
|
||||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
@@ -20,6 +22,11 @@ import {
|
|||||||
HoverCardContent,
|
HoverCardContent,
|
||||||
HoverCardTrigger,
|
HoverCardTrigger,
|
||||||
} from "@/shared/components/ui/hover-card";
|
} from "@/shared/components/ui/hover-card";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip";
|
||||||
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
||||||
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
||||||
import { isDateOnlyPast } from "@/shared/utils/date";
|
import { isDateOnlyPast } from "@/shared/utils/date";
|
||||||
@@ -31,14 +38,22 @@ type InvoiceListItemProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function InvoiceListItem({ invoice, onPay }: 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 isPaid = invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID;
|
||||||
const isOverdue =
|
const isOverdue =
|
||||||
!isPaid && dueInfo.date !== null && isDateOnlyPast(dueInfo.date);
|
!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 breakdown = invoice.pagadorBreakdown ?? [];
|
||||||
const hasBreakdown = breakdown.length > 0;
|
const hasBreakdown = breakdown.length > 0;
|
||||||
const detailHref = buildInvoiceDetailsHref(invoice.cardId, invoice.period);
|
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 = (
|
const linkNode = (
|
||||||
<Link
|
<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">
|
<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 ? (
|
{isPaid && paymentInfo ? (
|
||||||
|
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>
|
<span className="text-success">{paymentInfo.label}</span>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function InvoicePaymentDialog({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent
|
<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) => {
|
onEscapeKeyDown={(event) => {
|
||||||
if (isProcessing) {
|
if (isProcessing) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -100,7 +100,7 @@ export function InvoicePaymentDialog({
|
|||||||
{invoice ? (
|
{invoice ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Card principal */}
|
{/* 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
|
<InvoiceLogo
|
||||||
cardName={invoice.cardName}
|
cardName={invoice.cardName}
|
||||||
logo={invoice.logo}
|
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 Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import type { DashboardAccount } from "@/features/dashboard/accounts-queries";
|
import type { DashboardAccount } from "@/features/dashboard/accounts-queries";
|
||||||
|
import { updateMyAccountsWidgetPreference } from "@/features/dashboard/widgets/actions";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
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 { CardFooter } from "@/shared/components/ui/card";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip";
|
||||||
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
import { WidgetEmptyState } from "@/shared/components/widget-empty-state";
|
||||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||||
import { formatPeriodForUrl } from "@/shared/utils/period";
|
import { formatPeriodForUrl } from "@/shared/utils/period";
|
||||||
|
|
||||||
type MyAccountsWidgetProps = {
|
type MyAccountsWidgetProps = {
|
||||||
accounts: DashboardAccount[];
|
accounts: DashboardAccount[];
|
||||||
|
showExcludedAccounts: boolean;
|
||||||
|
onShowExcludedAccountsChange?: (value: boolean) => void;
|
||||||
totalBalance: number;
|
totalBalance: number;
|
||||||
period: string;
|
period: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MyAccountsWidget({
|
export function MyAccountsWidget({
|
||||||
accounts,
|
accounts,
|
||||||
|
showExcludedAccounts,
|
||||||
|
onShowExcludedAccountsChange,
|
||||||
totalBalance,
|
totalBalance,
|
||||||
period,
|
period,
|
||||||
}: MyAccountsWidgetProps) {
|
}: MyAccountsWidgetProps) {
|
||||||
const visibleAccounts = accounts.filter(
|
const [isPending, startTransition] = useTransition();
|
||||||
(account) => !account.excludeFromBalance,
|
|
||||||
);
|
const excludedAccountsCount = accounts.filter(
|
||||||
|
(account) => account.excludeFromBalance,
|
||||||
|
).length;
|
||||||
|
const visibleAccounts = showExcludedAccounts
|
||||||
|
? accounts
|
||||||
|
: accounts.filter((account) => !account.excludeFromBalance);
|
||||||
const displayedAccounts = visibleAccounts.slice(0, 5);
|
const displayedAccounts = visibleAccounts.slice(0, 5);
|
||||||
const remainingCount = visibleAccounts.length - displayedAccounts.length;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between py-1">
|
<div className="flex items-start justify-between gap-3 py-1">
|
||||||
Saldo Total
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">Saldo Total</p>
|
||||||
<MoneyValues className="text-2xl" amount={totalBalance} />
|
<MoneyValues className="text-2xl" amount={totalBalance} />
|
||||||
</div>
|
</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>
|
<div>
|
||||||
{displayedAccounts.length === 0 ? (
|
{accounts.length === 0 ? (
|
||||||
<div className="-mt-10">
|
<div className="-mt-10">
|
||||||
<WidgetEmptyState
|
<WidgetEmptyState
|
||||||
icon={
|
icon={
|
||||||
@@ -43,6 +127,14 @@ export function MyAccountsWidget({
|
|||||||
description="Cadastre suas contas bancárias para acompanhar os saldos e movimentações."
|
description="Cadastre suas contas bancárias para acompanhar os saldos e movimentações."
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<ul className="flex flex-col">
|
||||||
{displayedAccounts.map((account) => {
|
{displayedAccounts.map((account) => {
|
||||||
@@ -60,6 +152,7 @@ export function MyAccountsWidget({
|
|||||||
src={logoSrc}
|
src={logoSrc}
|
||||||
alt={`Logo da conta ${account.name}`}
|
alt={`Logo da conta ${account.name}`}
|
||||||
fill
|
fill
|
||||||
|
sizes="38px"
|
||||||
className="object-contain rounded-full"
|
className="object-contain rounded-full"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -79,6 +172,26 @@ export function MyAccountsWidget({
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
</Link>
|
</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">
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span className="truncate">{account.accountType}</span>
|
<span className="truncate">{account.accountType}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,7 +208,7 @@ export function MyAccountsWidget({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{visibleAccounts.length > displayedAccounts.length ? (
|
{remainingCount > 0 ? (
|
||||||
<CardFooter className="border-border/60 border-t pt-4 text-sm text-muted-foreground">
|
<CardFooter className="border-border/60 border-t pt-4 text-sm text-muted-foreground">
|
||||||
+{remainingCount} contas não exibidas
|
+{remainingCount} contas não exibidas
|
||||||
</CardFooter>
|
</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 { PaymentStatusData } from "@/features/dashboard/payments/payment-status-queries";
|
||||||
import type { PurchasesByCategoryData } from "@/features/dashboard/purchases-by-category-queries";
|
import type { PurchasesByCategoryData } from "@/features/dashboard/purchases-by-category-queries";
|
||||||
import type { TopEstablishmentsData } from "@/features/dashboard/top-establishments-queries";
|
import type { TopEstablishmentsData } from "@/features/dashboard/top-establishments-queries";
|
||||||
|
import { excludeTransactionsFromExcludedAccounts } from "@/features/dashboard/transaction-filters";
|
||||||
import {
|
import {
|
||||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||||
INITIAL_BALANCE_NOTE,
|
INITIAL_BALANCE_NOTE,
|
||||||
} from "@/shared/lib/accounts/constants";
|
} from "@/shared/lib/accounts/constants";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
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";
|
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||||
|
|
||||||
const PAYMENT_METHOD_BOLETO = "Boleto";
|
const PAYMENT_METHOD_BOLETO = "Boleto";
|
||||||
@@ -54,6 +60,7 @@ type CurrentPeriodTransactionRow = {
|
|||||||
categoryType: string | null;
|
categoryType: string | null;
|
||||||
cardLogo: string | null;
|
cardLogo: string | null;
|
||||||
accountLogo: string | null;
|
accountLogo: string | null;
|
||||||
|
accountExcludeInitialBalanceFromIncome: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CategoryOption = PurchasesByCategoryData["categories"][number];
|
type CategoryOption = PurchasesByCategoryData["categories"][number];
|
||||||
@@ -112,6 +119,21 @@ const shouldIncludeWithoutAutoInvoice = (note: string | null | undefined) =>
|
|||||||
const shouldIncludeWithoutAutoGenerated = (note: string | null | undefined) =>
|
const shouldIncludeWithoutAutoGenerated = (note: string | null | undefined) =>
|
||||||
!isInitialBalanceNote(note) && !isAutoInvoiceNote(note);
|
!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 shouldIncludeNamedItem = (name: string) => {
|
||||||
const normalized = name.trim().toLowerCase();
|
const normalized = name.trim().toLowerCase();
|
||||||
|
|
||||||
@@ -126,9 +148,30 @@ const shouldIncludeNamedItem = (name: string) => {
|
|||||||
return true;
|
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 = (
|
const buildBillsSnapshot = (
|
||||||
rows: CurrentPeriodTransactionRow[],
|
rows: CurrentPeriodTransactionRow[],
|
||||||
): DashboardBillsSnapshot => {
|
): DashboardBillsSnapshot => {
|
||||||
|
const today = getBusinessDateString();
|
||||||
const bills = rows
|
const bills = rows
|
||||||
.filter((row) => row.paymentMethod === PAYMENT_METHOD_BOLETO)
|
.filter((row) => row.paymentMethod === PAYMENT_METHOD_BOLETO)
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
@@ -143,17 +186,44 @@ const buildBillsSnapshot = (
|
|||||||
}))
|
}))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (a.isSettled !== b.isSettled) {
|
if (a.isSettled !== b.isSettled) {
|
||||||
return Number(a.isSettled) - Number(b.isSettled);
|
return a.isSettled ? 1 : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dueA = a.dueDate
|
if (!a.isSettled && !b.isSettled) {
|
||||||
? new Date(a.dueDate).getTime()
|
const aIsOverdue = a.dueDate ? isDateOnlyPast(a.dueDate, today) : false;
|
||||||
: Number.POSITIVE_INFINITY;
|
const bIsOverdue = b.dueDate ? isDateOnlyPast(b.dueDate, today) : false;
|
||||||
const dueB = b.dueDate
|
|
||||||
? new Date(b.dueDate).getTime()
|
if (aIsOverdue !== bIsOverdue) {
|
||||||
: Number.POSITIVE_INFINITY;
|
return aIsOverdue ? -1 : 1;
|
||||||
if (dueA !== dueB) {
|
}
|
||||||
return dueA - dueB;
|
|
||||||
|
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");
|
return a.name.localeCompare(b.name, "pt-BR");
|
||||||
@@ -181,7 +251,7 @@ const buildPaymentStatusData = (
|
|||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (
|
if (
|
||||||
!shouldIncludeWithoutAutoInvoice(row.note) ||
|
!shouldIncludeInPaymentStatus(row) ||
|
||||||
(row.transactionType !== TRANSACTION_TYPE_INCOME &&
|
(row.transactionType !== TRANSACTION_TYPE_INCOME &&
|
||||||
row.transactionType !== TRANSACTION_TYPE_EXPENSE)
|
row.transactionType !== TRANSACTION_TYPE_EXPENSE)
|
||||||
) {
|
) {
|
||||||
@@ -496,6 +566,8 @@ export async function fetchDashboardCurrentPeriodOverview(
|
|||||||
categoryType: categories.type,
|
categoryType: categories.type,
|
||||||
cardLogo: cards.logo,
|
cardLogo: cards.logo,
|
||||||
accountLogo: financialAccounts.logo,
|
accountLogo: financialAccounts.logo,
|
||||||
|
accountExcludeInitialBalanceFromIncome:
|
||||||
|
financialAccounts.excludeInitialBalanceFromIncome,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.leftJoin(cards, eq(transactions.cardId, cards.id))
|
.leftJoin(cards, eq(transactions.cardId, cards.id))
|
||||||
@@ -509,6 +581,7 @@ export async function fetchDashboardCurrentPeriodOverview(
|
|||||||
eq(transactions.userId, userId),
|
eq(transactions.userId, userId),
|
||||||
eq(transactions.period, period),
|
eq(transactions.period, period),
|
||||||
eq(transactions.payerId, adminPayerId),
|
eq(transactions.payerId, adminPayerId),
|
||||||
|
excludeTransactionsFromExcludedAccounts(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(
|
.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 { financialAccounts, transactions } from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
buildDashboardAdminFilters,
|
buildDashboardAdminFilters,
|
||||||
excludeAutoInvoiceEntries,
|
excludeAutoInvoiceEntries,
|
||||||
excludeInitialBalanceWhenConfigured,
|
excludeInitialBalanceWhenConfigured,
|
||||||
|
excludeTransactionsFromExcludedAccounts,
|
||||||
} from "@/features/dashboard/transaction-filters";
|
} from "@/features/dashboard/transaction-filters";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
@@ -36,12 +37,14 @@ export type DashboardCardMetrics = {
|
|||||||
type PeriodTotals = {
|
type PeriodTotals = {
|
||||||
receitas: number;
|
receitas: number;
|
||||||
despesas: number;
|
despesas: number;
|
||||||
|
transferAdjustment: number;
|
||||||
balanco: number;
|
balanco: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createEmptyTotals = (): PeriodTotals => ({
|
const createEmptyTotals = (): PeriodTotals => ({
|
||||||
receitas: 0,
|
receitas: 0,
|
||||||
despesas: 0,
|
despesas: 0,
|
||||||
|
transferAdjustment: 0,
|
||||||
balanco: 0,
|
balanco: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,6 +93,7 @@ export async function fetchDashboardCardMetrics(
|
|||||||
period: transactions.period,
|
period: transactions.period,
|
||||||
transactionType: transactions.transactionType,
|
transactionType: transactions.transactionType,
|
||||||
totalAmount: sum(transactions.amount).as("total"),
|
totalAmount: sum(transactions.amount).as("total"),
|
||||||
|
accountExcludeFromBalance: financialAccounts.excludeFromBalance,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
@@ -101,12 +105,21 @@ export async function fetchDashboardCardMetrics(
|
|||||||
...buildDashboardAdminFilters({ userId, adminPayerId }),
|
...buildDashboardAdminFilters({ userId, adminPayerId }),
|
||||||
gte(transactions.period, startPeriod),
|
gte(transactions.period, startPeriod),
|
||||||
lte(transactions.period, period),
|
lte(transactions.period, period),
|
||||||
ne(transactions.transactionType, TRANSFERENCIA),
|
inArray(transactions.transactionType, [
|
||||||
|
RECEITA,
|
||||||
|
DESPESA,
|
||||||
|
TRANSFERENCIA,
|
||||||
|
]),
|
||||||
excludeAutoInvoiceEntries(),
|
excludeAutoInvoiceEntries(),
|
||||||
excludeInitialBalanceWhenConfigured(),
|
excludeInitialBalanceWhenConfigured(),
|
||||||
|
excludeTransactionsFromExcludedAccounts(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(transactions.period, transactions.transactionType)
|
.groupBy(
|
||||||
|
transactions.period,
|
||||||
|
transactions.transactionType,
|
||||||
|
financialAccounts.excludeFromBalance,
|
||||||
|
)
|
||||||
.orderBy(asc(transactions.period), asc(transactions.transactionType));
|
.orderBy(asc(transactions.period), asc(transactions.transactionType));
|
||||||
|
|
||||||
const periodTotals = new Map<string, PeriodTotals>();
|
const periodTotals = new Map<string, PeriodTotals>();
|
||||||
@@ -119,6 +132,11 @@ export async function fetchDashboardCardMetrics(
|
|||||||
totals.receitas += total;
|
totals.receitas += total;
|
||||||
} else if (row.transactionType === DESPESA) {
|
} else if (row.transactionType === DESPESA) {
|
||||||
totals.despesas += Math.abs(total);
|
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) {
|
for (const key of periodRange) {
|
||||||
const totals = ensurePeriodTotals(periodTotals, key);
|
const totals = ensurePeriodTotals(periodTotals, key);
|
||||||
totals.balanco = totals.receitas - totals.despesas;
|
totals.balanco =
|
||||||
|
totals.receitas - totals.despesas + totals.transferAdjustment;
|
||||||
runningForecast += totals.balanco;
|
runningForecast += totals.balanco;
|
||||||
forecastByPeriod.set(key, runningForecast);
|
forecastByPeriod.set(key, runningForecast);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
buildDashboardAdminFilters,
|
buildDashboardAdminFilters,
|
||||||
excludeAutoInvoiceEntries,
|
excludeAutoInvoiceEntries,
|
||||||
excludeInitialBalanceWhenConfigured,
|
excludeInitialBalanceWhenConfigured,
|
||||||
|
excludeTransactionsFromExcludedAccounts,
|
||||||
} from "@/features/dashboard/transaction-filters";
|
} from "@/features/dashboard/transaction-filters";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
@@ -51,6 +52,7 @@ export async function fetchIncomeExpenseBalance(
|
|||||||
period: transactions.period,
|
period: transactions.period,
|
||||||
transactionType: transactions.transactionType,
|
transactionType: transactions.transactionType,
|
||||||
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
||||||
|
accountExcludeFromBalance: financialAccounts.excludeFromBalance,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
@@ -61,37 +63,62 @@ export async function fetchIncomeExpenseBalance(
|
|||||||
and(
|
and(
|
||||||
...buildDashboardAdminFilters({ userId, adminPayerId }),
|
...buildDashboardAdminFilters({ userId, adminPayerId }),
|
||||||
inArray(transactions.period, periods),
|
inArray(transactions.period, periods),
|
||||||
inArray(transactions.transactionType, ["Receita", "Despesa"]),
|
inArray(transactions.transactionType, [
|
||||||
|
"Receita",
|
||||||
|
"Despesa",
|
||||||
|
"Transferência",
|
||||||
|
]),
|
||||||
excludeAutoInvoiceEntries(),
|
excludeAutoInvoiceEntries(),
|
||||||
excludeInitialBalanceWhenConfigured(),
|
excludeInitialBalanceWhenConfigured(),
|
||||||
|
excludeTransactionsFromExcludedAccounts(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(transactions.period, transactions.transactionType);
|
.groupBy(
|
||||||
|
transactions.period,
|
||||||
|
transactions.transactionType,
|
||||||
|
financialAccounts.excludeFromBalance,
|
||||||
|
);
|
||||||
|
|
||||||
// Build lookup from query results
|
// 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) {
|
for (const row of rows) {
|
||||||
if (!row.period) continue;
|
if (!row.period) continue;
|
||||||
const entry = dataMap.get(row.period) ?? { income: 0, expense: 0 };
|
const entry = dataMap.get(row.period) ?? {
|
||||||
const total = Math.abs(toNumber(row.total));
|
income: 0,
|
||||||
|
expense: 0,
|
||||||
|
transferAdjustment: 0,
|
||||||
|
};
|
||||||
|
const total = toNumber(row.total);
|
||||||
if (row.transactionType === "Receita") {
|
if (row.transactionType === "Receita") {
|
||||||
entry.income = total;
|
entry.income += Math.abs(total);
|
||||||
} else if (row.transactionType === "Despesa") {
|
} 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);
|
dataMap.set(row.period, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build result array preserving period order
|
// Build result array preserving period order
|
||||||
const months = periods.map((period) => {
|
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 {
|
return {
|
||||||
month: period,
|
month: period,
|
||||||
monthLabel: formatPeriodMonthShort(period).toLowerCase(),
|
monthLabel: formatPeriodMonthShort(period).toLowerCase(),
|
||||||
income: entry.income,
|
income: entry.income,
|
||||||
expense: entry.expense,
|
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 { getBusinessDateString } from "@/shared/utils/date";
|
||||||
import {
|
import {
|
||||||
buildDueDateInfoFromPeriodDay,
|
buildDueDateInfoFromPeriodDay,
|
||||||
|
buildRelativeDueDateInfoFromPeriodDay,
|
||||||
formatFinancialDateLabel,
|
formatFinancialDateLabel,
|
||||||
|
formatRelativeFinancialDateLabel,
|
||||||
} from "@/shared/utils/financial-dates";
|
} from "@/shared/utils/financial-dates";
|
||||||
import { formatPercentage } from "@/shared/utils/percentage";
|
import { formatPercentage } from "@/shared/utils/percentage";
|
||||||
import { formatPeriodForUrl } from "@/shared/utils/period";
|
import { formatPeriodForUrl } from "@/shared/utils/period";
|
||||||
@@ -45,6 +47,13 @@ export const parseInvoiceDueDate = (
|
|||||||
return buildDueDateInfoFromPeriodDay(period, dueDay);
|
return buildDueDateInfoFromPeriodDay(period, dueDay);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const parseInvoiceWidgetDueDate = (
|
||||||
|
period: string,
|
||||||
|
dueDay: string,
|
||||||
|
): InvoiceDueDateInfo => {
|
||||||
|
return buildRelativeDueDateInfoFromPeriodDay(period, dueDay);
|
||||||
|
};
|
||||||
|
|
||||||
export const formatInvoicePaymentDate = (
|
export const formatInvoicePaymentDate = (
|
||||||
value: string | null,
|
value: string | null,
|
||||||
): InvoicePaymentDateInfo | 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();
|
export const getCurrentDateString = () => getBusinessDateString();
|
||||||
|
|
||||||
const formatInvoiceSharePercentage = (value: number) => {
|
const formatInvoiceSharePercentage = (value: number) => {
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ import {
|
|||||||
INVOICE_STATUS_VALUES,
|
INVOICE_STATUS_VALUES,
|
||||||
type InvoicePaymentStatus,
|
type InvoicePaymentStatus,
|
||||||
} from "@/shared/lib/invoices";
|
} 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";
|
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||||
|
|
||||||
type RawDashboardInvoice = {
|
type RawDashboardInvoice = {
|
||||||
@@ -68,10 +74,31 @@ const isInvoiceStatus = (value: unknown): value is InvoicePaymentStatus =>
|
|||||||
const buildFallbackId = (cardId: string, period: string) =>
|
const buildFallbackId = (cardId: string, period: string) =>
|
||||||
`${cardId}:${period}`;
|
`${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(
|
export async function fetchDashboardInvoices(
|
||||||
userId: string,
|
userId: string,
|
||||||
period: string,
|
period: string,
|
||||||
): Promise<DashboardInvoicesSnapshot> {
|
): Promise<DashboardInvoicesSnapshot> {
|
||||||
|
const today = getBusinessDateString();
|
||||||
const paymentRows = await db
|
const paymentRows = await db
|
||||||
.select({
|
.select({
|
||||||
note: transactions.note,
|
note: transactions.note,
|
||||||
@@ -258,8 +285,53 @@ export async function fetchDashboardInvoices(
|
|||||||
}
|
}
|
||||||
|
|
||||||
invoiceList.sort((a, b) => {
|
invoiceList.sort((a, b) => {
|
||||||
// Ordena do maior valor para o menor
|
const aIsPending = a.paymentStatus === INVOICE_PAYMENT_STATUS.PENDING;
|
||||||
return Math.abs(b.totalAmount) - Math.abs(a.totalAmount);
|
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) => {
|
const totalPending = invoiceList.reduce((total, invoice) => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
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 { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
|
import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
|
||||||
@@ -41,11 +42,16 @@ export async function fetchDashboardPayers(
|
|||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.innerJoin(payers, eq(transactions.payerId, payers.id))
|
.innerJoin(payers, eq(transactions.payerId, payers.id))
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(transactions.userId, userId),
|
eq(transactions.userId, userId),
|
||||||
inArray(transactions.period, [period, previousPeriod]),
|
inArray(transactions.period, [period, previousPeriod]),
|
||||||
eq(transactions.transactionType, "Despesa"),
|
eq(transactions.transactionType, "Despesa"),
|
||||||
|
excludeTransactionsFromExcludedAccounts(),
|
||||||
or(
|
or(
|
||||||
isNull(transactions.note),
|
isNull(transactions.note),
|
||||||
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { and, inArray, sql } from "drizzle-orm";
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||||
import { transactions } from "@/db/schema";
|
import { financialAccounts, transactions } from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
buildDashboardAdminPeriodFilters,
|
buildDashboardAdminPeriodFilters,
|
||||||
excludeAutoInvoiceEntries,
|
excludeAutoInvoiceEntries,
|
||||||
|
excludeInitialBalanceWhenConfigured,
|
||||||
|
excludeTransactionsFromExcludedAccounts,
|
||||||
} from "@/features/dashboard/transaction-filters";
|
} from "@/features/dashboard/transaction-filters";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
@@ -52,6 +54,10 @@ export async function fetchPaymentStatus(
|
|||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
...buildDashboardAdminPeriodFilters({
|
...buildDashboardAdminPeriodFilters({
|
||||||
@@ -61,6 +67,8 @@ export async function fetchPaymentStatus(
|
|||||||
}),
|
}),
|
||||||
inArray(transactions.transactionType, ["Receita", "Despesa"]),
|
inArray(transactions.transactionType, ["Receita", "Despesa"]),
|
||||||
excludeAutoInvoiceEntries(),
|
excludeAutoInvoiceEntries(),
|
||||||
|
excludeInitialBalanceWhenConfigured(),
|
||||||
|
excludeTransactionsFromExcludedAccounts(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(transactions.transactionType);
|
.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 { financialAccounts, transactions } from "@/db/schema";
|
||||||
import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries";
|
import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries";
|
||||||
import type {
|
import type {
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
buildDashboardAdminFilters,
|
buildDashboardAdminFilters,
|
||||||
excludeAutoInvoiceEntries,
|
excludeAutoInvoiceEntries,
|
||||||
excludeInitialBalanceWhenConfigured,
|
excludeInitialBalanceWhenConfigured,
|
||||||
|
excludeTransactionsFromExcludedAccounts,
|
||||||
} from "@/features/dashboard/transaction-filters";
|
} from "@/features/dashboard/transaction-filters";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
@@ -30,6 +31,7 @@ const TRANSACTION_TYPE_TRANSFER = "Transferência";
|
|||||||
type PeriodTotals = {
|
type PeriodTotals = {
|
||||||
receitas: number;
|
receitas: number;
|
||||||
despesas: number;
|
despesas: number;
|
||||||
|
transferAdjustment: number;
|
||||||
balanco: number;
|
balanco: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ type PeriodSummaryRow = {
|
|||||||
period: string | null;
|
period: string | null;
|
||||||
transactionType: string;
|
transactionType: string;
|
||||||
totalAmount: string | number | null;
|
totalAmount: string | number | null;
|
||||||
|
accountExcludeFromBalance: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DashboardPeriodOverview = {
|
export type DashboardPeriodOverview = {
|
||||||
@@ -47,6 +50,7 @@ export type DashboardPeriodOverview = {
|
|||||||
const createEmptyTotals = (): PeriodTotals => ({
|
const createEmptyTotals = (): PeriodTotals => ({
|
||||||
receitas: 0,
|
receitas: 0,
|
||||||
despesas: 0,
|
despesas: 0,
|
||||||
|
transferAdjustment: 0,
|
||||||
balanco: 0,
|
balanco: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,6 +110,7 @@ export async function fetchDashboardPeriodOverview(
|
|||||||
period: transactions.period,
|
period: transactions.period,
|
||||||
transactionType: transactions.transactionType,
|
transactionType: transactions.transactionType,
|
||||||
totalAmount: sum(transactions.amount).as("total"),
|
totalAmount: sum(transactions.amount).as("total"),
|
||||||
|
accountExcludeFromBalance: financialAccounts.excludeFromBalance,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
@@ -120,13 +125,18 @@ export async function fetchDashboardPeriodOverview(
|
|||||||
inArray(transactions.transactionType, [
|
inArray(transactions.transactionType, [
|
||||||
TRANSACTION_TYPE_INCOME,
|
TRANSACTION_TYPE_INCOME,
|
||||||
TRANSACTION_TYPE_EXPENSE,
|
TRANSACTION_TYPE_EXPENSE,
|
||||||
|
TRANSACTION_TYPE_TRANSFER,
|
||||||
]),
|
]),
|
||||||
ne(transactions.transactionType, TRANSACTION_TYPE_TRANSFER),
|
|
||||||
excludeAutoInvoiceEntries(),
|
excludeAutoInvoiceEntries(),
|
||||||
excludeInitialBalanceWhenConfigured(),
|
excludeInitialBalanceWhenConfigured(),
|
||||||
|
excludeTransactionsFromExcludedAccounts(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(transactions.period, transactions.transactionType)
|
.groupBy(
|
||||||
|
transactions.period,
|
||||||
|
transactions.transactionType,
|
||||||
|
financialAccounts.excludeFromBalance,
|
||||||
|
)
|
||||||
.orderBy(
|
.orderBy(
|
||||||
asc(transactions.period),
|
asc(transactions.period),
|
||||||
asc(transactions.transactionType),
|
asc(transactions.transactionType),
|
||||||
@@ -146,6 +156,11 @@ export async function fetchDashboardPeriodOverview(
|
|||||||
totals.receitas += total;
|
totals.receitas += total;
|
||||||
} else if (row.transactionType === TRANSACTION_TYPE_EXPENSE) {
|
} else if (row.transactionType === TRANSACTION_TYPE_EXPENSE) {
|
||||||
totals.despesas += Math.abs(total);
|
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) {
|
for (const key of periodRange) {
|
||||||
const totals = ensurePeriodTotals(periodTotals, key);
|
const totals = ensurePeriodTotals(periodTotals, key);
|
||||||
totals.balanco = totals.receitas - totals.despesas;
|
totals.balanco =
|
||||||
|
totals.receitas - totals.despesas + totals.transferAdjustment;
|
||||||
runningForecast += totals.balanco;
|
runningForecast += totals.balanco;
|
||||||
forecastByPeriod.set(key, runningForecast);
|
forecastByPeriod.set(key, runningForecast);
|
||||||
}
|
}
|
||||||
@@ -179,7 +195,7 @@ export async function fetchDashboardPeriodOverview(
|
|||||||
monthLabel: formatPeriodMonthShort(chartPeriod).toLowerCase(),
|
monthLabel: formatPeriodMonthShort(chartPeriod).toLowerCase(),
|
||||||
income: entry.receitas,
|
income: entry.receitas,
|
||||||
expense: entry.despesas,
|
expense: entry.despesas,
|
||||||
balance: entry.receitas - entry.despesas,
|
balance: entry.balanco,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
INITIAL_BALANCE_NOTE,
|
INITIAL_BALANCE_NOTE,
|
||||||
} from "@/shared/lib/accounts/constants";
|
} from "@/shared/lib/accounts/constants";
|
||||||
|
|
||||||
|
export { excludeTransactionsFromExcludedAccounts } from "@/shared/lib/accounts/query-filters";
|
||||||
|
|
||||||
type DashboardAdminFiltersParams = {
|
type DashboardAdminFiltersParams = {
|
||||||
userId: string;
|
userId: string;
|
||||||
adminPayerId: string;
|
adminPayerId: string;
|
||||||
|
|||||||
@@ -8,36 +8,44 @@ import { db, schema } from "@/shared/lib/db";
|
|||||||
export type WidgetPreferences = {
|
export type WidgetPreferences = {
|
||||||
order: string[];
|
order: string[];
|
||||||
hidden: 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(
|
export async function updateWidgetPreferences(
|
||||||
preferences: WidgetPreferences,
|
preferences: WidgetLayoutPreferences,
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
|
await upsertUserWidgetPreferences(user.id, preferences);
|
||||||
// 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} 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<{
|
export async function resetWidgetPreferences(): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
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 { PurchasesByCategoryWidget } from "@/features/dashboard/components/purchases-by-category-widget";
|
||||||
import { RecurringExpensesWidget } from "@/features/dashboard/components/recurring-expenses-widget";
|
import { RecurringExpensesWidget } from "@/features/dashboard/components/recurring-expenses-widget";
|
||||||
import { SpendingOverviewWidget } from "@/features/dashboard/components/spending-overview-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";
|
import type { DashboardData } from "../fetch-dashboard-data";
|
||||||
|
|
||||||
export type WidgetConfig = {
|
export type WidgetConfig = {
|
||||||
@@ -38,7 +39,12 @@ export type WidgetConfig = {
|
|||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
component: (props: { data: DashboardData; period: string }) => ReactNode;
|
component: (props: {
|
||||||
|
data: DashboardData;
|
||||||
|
period: string;
|
||||||
|
widgetPreferences: WidgetPreferences;
|
||||||
|
onMyAccountsShowExcludedChange?: (value: boolean) => void;
|
||||||
|
}) => ReactNode;
|
||||||
action?: ReactNode;
|
action?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -48,9 +54,16 @@ export const widgetsConfig: WidgetConfig[] = [
|
|||||||
title: "Minhas Contas",
|
title: "Minhas Contas",
|
||||||
subtitle: "Saldo consolidado disponível",
|
subtitle: "Saldo consolidado disponível",
|
||||||
icon: <RiBarChartBoxLine className="size-4" />,
|
icon: <RiBarChartBoxLine className="size-4" />,
|
||||||
component: ({ data, period }) => (
|
component: ({
|
||||||
|
data,
|
||||||
|
period,
|
||||||
|
widgetPreferences,
|
||||||
|
onMyAccountsShowExcludedChange,
|
||||||
|
}) => (
|
||||||
<MyAccountsWidget
|
<MyAccountsWidget
|
||||||
accounts={data.accountsSnapshot.accounts}
|
accounts={data.accountsSnapshot.accounts}
|
||||||
|
showExcludedAccounts={widgetPreferences.myAccountsShowExcluded ?? true}
|
||||||
|
onShowExcludedAccountsChange={onMyAccountsShowExcludedChange}
|
||||||
totalBalance={data.accountsSnapshot.totalBalance}
|
totalBalance={data.accountsSnapshot.totalBalance}
|
||||||
period={period}
|
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"
|
className="shrink-0"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<div className="relative size-8 shrink-0 overflow-hidden rounded-full">
|
||||||
<Image
|
<Image
|
||||||
src={displayLogo}
|
src={displayLogo}
|
||||||
alt=""
|
alt=""
|
||||||
width={32}
|
fill
|
||||||
height={32}
|
sizes="32px"
|
||||||
className="shrink-0 rounded-full"
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{item.sourceAppName || item.sourceApp}
|
{item.sourceAppName || item.sourceApp}
|
||||||
</span>
|
</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";
|
"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 { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -30,31 +18,15 @@ import {
|
|||||||
markInboxAsProcessedAction,
|
markInboxAsProcessedAction,
|
||||||
restoreDiscardedInboxItemAction,
|
restoreDiscardedInboxItemAction,
|
||||||
} from "@/features/inbox/actions";
|
} from "@/features/inbox/actions";
|
||||||
import {
|
import { INBOX_DEFAULT_PAGE_SIZE } from "@/features/inbox/page-helpers";
|
||||||
INBOX_DEFAULT_PAGE_SIZE,
|
|
||||||
INBOX_PAGE_SIZE_OPTIONS,
|
|
||||||
} from "@/features/inbox/page-helpers";
|
|
||||||
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
|
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
|
||||||
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
||||||
import { EmptyState } from "@/shared/components/empty-state";
|
import { Tabs, TabsContent } from "@/shared/components/ui/tabs";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { InboxBulkActions } from "./inbox-bulk-actions";
|
||||||
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 { InboxDetailsDialog } from "./inbox-details-dialog";
|
import { InboxDetailsDialog } from "./inbox-details-dialog";
|
||||||
|
import { InboxItemsList } from "./inbox-items-list";
|
||||||
|
import { InboxPagination } from "./inbox-pagination";
|
||||||
|
import { InboxTabs } from "./inbox-tabs";
|
||||||
import type {
|
import type {
|
||||||
InboxItem,
|
InboxItem,
|
||||||
InboxPaginationState,
|
InboxPaginationState,
|
||||||
@@ -63,76 +35,6 @@ import type {
|
|||||||
SelectOption,
|
SelectOption,
|
||||||
} from "./types";
|
} 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 {
|
interface InboxPageProps {
|
||||||
activeStatus: InboxStatus;
|
activeStatus: InboxStatus;
|
||||||
activeApp: string | null;
|
activeApp: string | null;
|
||||||
@@ -197,24 +99,14 @@ export function InboxPage({
|
|||||||
useState<InboxStatus>("pending");
|
useState<InboxStatus>("pending");
|
||||||
|
|
||||||
const normalizedSourceApps = useMemo(() => {
|
const normalizedSourceApps = useMemo(() => {
|
||||||
if (!Array.isArray(sourceApps)) {
|
if (!Array.isArray(sourceApps)) return [];
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueApps = new Set<string>();
|
const uniqueApps = new Set<string>();
|
||||||
for (const app of sourceApps) {
|
for (const app of sourceApps) {
|
||||||
if (typeof app !== "string") {
|
if (typeof app !== "string") continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedApp = app.trim();
|
const trimmedApp = app.trim();
|
||||||
if (!trimmedApp) {
|
if (!trimmedApp) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
uniqueApps.add(trimmedApp);
|
uniqueApps.add(trimmedApp);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...uniqueApps].sort((left, right) =>
|
return [...uniqueApps].sort((left, right) =>
|
||||||
left.localeCompare(right, "pt-BR"),
|
left.localeCompare(right, "pt-BR"),
|
||||||
);
|
);
|
||||||
@@ -225,28 +117,19 @@ export function InboxPage({
|
|||||||
? [activeApp, ...normalizedSourceApps]
|
? [activeApp, ...normalizedSourceApps]
|
||||||
: normalizedSourceApps;
|
: normalizedSourceApps;
|
||||||
|
|
||||||
const getAppLogo = (appName: string | null) =>
|
|
||||||
findMatchingLogo(appName, appLogoMap) ?? DEFAULT_INBOX_APP_LOGO;
|
|
||||||
|
|
||||||
const handleProcessOpenChange = (open: boolean) => {
|
const handleProcessOpenChange = (open: boolean) => {
|
||||||
setProcessOpen(open);
|
setProcessOpen(open);
|
||||||
if (!open) {
|
if (!open) setItemToProcess(null);
|
||||||
setItemToProcess(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDetailsOpenChange = (open: boolean) => {
|
const handleDetailsOpenChange = (open: boolean) => {
|
||||||
setDetailsOpen(open);
|
setDetailsOpen(open);
|
||||||
if (!open) {
|
if (!open) setItemDetails(null);
|
||||||
setItemDetails(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDiscardOpenChange = (open: boolean) => {
|
const handleDiscardOpenChange = (open: boolean) => {
|
||||||
setDiscardOpen(open);
|
setDiscardOpen(open);
|
||||||
if (!open) {
|
if (!open) setItemToDiscard(null);
|
||||||
setItemToDiscard(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProcessRequest = useCallback((item: InboxItem) => {
|
const handleProcessRequest = useCallback((item: InboxItem) => {
|
||||||
@@ -266,25 +149,20 @@ export function InboxPage({
|
|||||||
|
|
||||||
const handleDiscardConfirm = async () => {
|
const handleDiscardConfirm = async () => {
|
||||||
if (!itemToDiscard) return;
|
if (!itemToDiscard) return;
|
||||||
|
|
||||||
const result = await discardInboxItemAction({
|
const result = await discardInboxItemAction({
|
||||||
inboxItemId: itemToDiscard.id,
|
inboxItemId: itemToDiscard.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteOpenChange = (open: boolean) => {
|
const handleDeleteOpenChange = (open: boolean) => {
|
||||||
setDeleteOpen(open);
|
setDeleteOpen(open);
|
||||||
if (!open) {
|
if (!open) setItemToDelete(null);
|
||||||
setItemToDelete(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteRequest = useCallback((item: InboxItem) => {
|
const handleDeleteRequest = useCallback((item: InboxItem) => {
|
||||||
@@ -294,25 +172,20 @@ export function InboxPage({
|
|||||||
|
|
||||||
const handleDeleteConfirm = async () => {
|
const handleDeleteConfirm = async () => {
|
||||||
if (!itemToDelete) return;
|
if (!itemToDelete) return;
|
||||||
|
|
||||||
const result = await deleteInboxItemAction({
|
const result = await deleteInboxItemAction({
|
||||||
inboxItemId: itemToDelete.id,
|
inboxItemId: itemToDelete.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRestoreOpenChange = (open: boolean) => {
|
const handleRestoreOpenChange = (open: boolean) => {
|
||||||
setRestoreOpen(open);
|
setRestoreOpen(open);
|
||||||
if (!open) {
|
if (!open) setItemToRestore(null);
|
||||||
setItemToRestore(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRestoreRequest = useCallback((item: InboxItem) => {
|
const handleRestoreRequest = useCallback((item: InboxItem) => {
|
||||||
@@ -322,16 +195,13 @@ export function InboxPage({
|
|||||||
|
|
||||||
const handleRestoreToPendingConfirm = async () => {
|
const handleRestoreToPendingConfirm = async () => {
|
||||||
if (!itemToRestore) return;
|
if (!itemToRestore) return;
|
||||||
|
|
||||||
const result = await restoreDiscardedInboxItemAction({
|
const result = await restoreDiscardedInboxItemAction({
|
||||||
inboxItemId: itemToRestore.id,
|
inboxItemId: itemToRestore.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
};
|
};
|
||||||
@@ -365,25 +235,21 @@ export function InboxPage({
|
|||||||
nextPageSize: number,
|
nextPageSize: number,
|
||||||
) => {
|
) => {
|
||||||
const nextParams = new URLSearchParams(searchParams.toString());
|
const nextParams = new URLSearchParams(searchParams.toString());
|
||||||
|
|
||||||
if (nextStatus === "pending") {
|
if (nextStatus === "pending") {
|
||||||
nextParams.delete("status");
|
nextParams.delete("status");
|
||||||
} else {
|
} else {
|
||||||
nextParams.set("status", nextStatus);
|
nextParams.set("status", nextStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextPage <= 1) {
|
if (nextPage <= 1) {
|
||||||
nextParams.delete("page");
|
nextParams.delete("page");
|
||||||
} else {
|
} else {
|
||||||
nextParams.set("page", nextPage.toString());
|
nextParams.set("page", nextPage.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextPageSize === INBOX_DEFAULT_PAGE_SIZE) {
|
if (nextPageSize === INBOX_DEFAULT_PAGE_SIZE) {
|
||||||
nextParams.delete("pageSize");
|
nextParams.delete("pageSize");
|
||||||
} else {
|
} else {
|
||||||
nextParams.set("pageSize", nextPageSize.toString());
|
nextParams.set("pageSize", nextPageSize.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
const target = nextParams.toString()
|
const target = nextParams.toString()
|
||||||
? `${pathname}?${nextParams.toString()}`
|
? `${pathname}?${nextParams.toString()}`
|
||||||
@@ -431,10 +297,7 @@ export function InboxPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectionBulkRequest = (status: InboxStatus) => {
|
const handleSelectionBulkRequest = (status: InboxStatus) => {
|
||||||
if (selectedIds.length === 0) {
|
if (selectedIds.length === 0) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectionBulkStatus(status);
|
setSelectionBulkStatus(status);
|
||||||
setSelectionBulkOpen(true);
|
setSelectionBulkOpen(true);
|
||||||
};
|
};
|
||||||
@@ -465,10 +328,6 @@ export function InboxPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBulkDeleteOpenChange = (open: boolean) => {
|
|
||||||
setBulkDeleteOpen(open);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBulkDeleteRequest = (status: "processed" | "discarded") => {
|
const handleBulkDeleteRequest = (status: "processed" | "discarded") => {
|
||||||
setBulkDeleteStatus(status);
|
setBulkDeleteStatus(status);
|
||||||
setBulkDeleteOpen(true);
|
setBulkDeleteOpen(true);
|
||||||
@@ -478,23 +337,19 @@ export function InboxPage({
|
|||||||
const result = await bulkDeleteInboxItemsAction({
|
const result = await bulkDeleteInboxItemsAction({
|
||||||
status: bulkDeleteStatus,
|
status: bulkDeleteStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLancamentoSuccess = async () => {
|
const handleLancamentoSuccess = async () => {
|
||||||
if (!itemToProcess) return;
|
if (!itemToProcess) return;
|
||||||
|
|
||||||
const result = await markInboxAsProcessedAction({
|
const result = await markInboxAsProcessedAction({
|
||||||
inboxItemId: itemToProcess.id,
|
inboxItemId: itemToProcess.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success("Notificação processada!");
|
toast.success("Notificação processada!");
|
||||||
} else {
|
} 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 = (
|
const getDateString = (
|
||||||
date: Date | string | null | undefined,
|
date: Date | string | null | undefined,
|
||||||
): string | null => {
|
): string | null => {
|
||||||
@@ -516,140 +367,29 @@ export function InboxPage({
|
|||||||
|
|
||||||
const defaultPurchaseDate =
|
const defaultPurchaseDate =
|
||||||
getDateString(itemToProcess?.notificationTimestamp) ?? null;
|
getDateString(itemToProcess?.notificationTimestamp) ?? null;
|
||||||
|
|
||||||
const defaultName = itemToProcess?.parsedName
|
const defaultName = itemToProcess?.parsedName
|
||||||
? itemToProcess.parsedName
|
? itemToProcess.parsedName
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/\b\w/g, (char) => char.toUpperCase())
|
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const defaultAmount = itemToProcess?.parsedAmount
|
const defaultAmount = itemToProcess?.parsedAmount
|
||||||
? String(Math.abs(Number(itemToProcess.parsedAmount)))
|
? String(Math.abs(Number(itemToProcess.parsedAmount)))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Match sourceAppName with a cartão to pre-fill card select
|
|
||||||
const matchedCartaoId = useMemo(() => {
|
const matchedCartaoId = useMemo(() => {
|
||||||
const appName = itemToProcess?.sourceAppName?.toLowerCase();
|
const appName = itemToProcess?.sourceAppName?.toLowerCase();
|
||||||
if (!appName) return null;
|
if (!appName) return null;
|
||||||
|
|
||||||
for (const option of cardOptions) {
|
for (const option of cardOptions) {
|
||||||
const label = option.label.toLowerCase();
|
const label = option.label.toLowerCase();
|
||||||
if (label.includes(appName) || appName.includes(label)) {
|
if (label.includes(appName) || appName.includes(label))
|
||||||
return option.value;
|
return option.value;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}, [itemToProcess?.sourceAppName, cardOptions]);
|
}, [itemToProcess?.sourceAppName, cardOptions]);
|
||||||
|
|
||||||
const renderEmptyState = (message: string) => (
|
const showTabActions = (status: InboxStatus) =>
|
||||||
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
|
activeStatus === status &&
|
||||||
<EmptyState
|
(appFilterOptions.length > 0 || items.length > 0);
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -658,229 +398,106 @@ export function InboxPage({
|
|||||||
onValueChange={handleTabChange}
|
onValueChange={handleTabChange}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<TabsList className="grid h-auto w-full grid-cols-3 sm:inline-flex sm:h-9 sm:grid-cols-none">
|
<InboxTabs counts={counts} isPending={isPending} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<TabsContent value="pending" className="mt-4">
|
<TabsContent value="pending" className="mt-4">
|
||||||
{activeStatus === "pending" &&
|
{showTabActions("pending") && (
|
||||||
(appFilterOptions.length > 0 || items.length > 0) && (
|
<InboxBulkActions
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
status="pending"
|
||||||
{renderAppFilter()}
|
items={items}
|
||||||
{items.length > 0 ? (
|
activeApp={activeApp}
|
||||||
<div className="ml-auto flex items-center gap-2">
|
appFilterOptions={appFilterOptions}
|
||||||
<Button
|
selectedIds={selectedIds}
|
||||||
variant="outline"
|
allSelected={allSelected}
|
||||||
size="sm"
|
appLogoMap={appLogoMap}
|
||||||
onClick={toggleSelectAll}
|
onAppChange={handleAppChange}
|
||||||
>
|
onToggleSelectAll={toggleSelectAll}
|
||||||
{allSelected ? "Cancelar seleção" : "Selecionar página"}
|
onSelectionBulkRequest={handleSelectionBulkRequest}
|
||||||
</Button>
|
onBulkDeleteRequest={handleBulkDeleteRequest}
|
||||||
{selectedIds.length > 0 && (
|
/>
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleSelectionBulkRequest("pending")}
|
|
||||||
>
|
|
||||||
<RiDeleteBinLine className="mr-1.5 size-4" />
|
|
||||||
Descartar selecionados ({selectedIds.length})
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
{activeStatus === "pending" && (
|
||||||
) : null}
|
<InboxItemsList
|
||||||
</div>
|
items={items}
|
||||||
|
readonly={false}
|
||||||
|
activeApp={activeApp}
|
||||||
|
appLogoMap={appLogoMap}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
onProcess={handleProcessRequest}
|
||||||
|
onDiscard={handleDiscardRequest}
|
||||||
|
onViewDetails={handleDetailsRequest}
|
||||||
|
onSelectToggle={toggleSelection}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{activeStatus === "pending" ? renderGroupedGrid(items, false) : null}
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="processed" className="mt-4">
|
<TabsContent value="processed" className="mt-4">
|
||||||
{activeStatus === "processed" &&
|
{showTabActions("processed") && (
|
||||||
(appFilterOptions.length > 0 || items.length > 0) && (
|
<InboxBulkActions
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
status="processed"
|
||||||
{renderAppFilter()}
|
items={items}
|
||||||
{items.length > 0 ? (
|
activeApp={activeApp}
|
||||||
<div className="ml-auto flex items-center gap-2">
|
appFilterOptions={appFilterOptions}
|
||||||
<Button
|
selectedIds={selectedIds}
|
||||||
variant="outline"
|
allSelected={allSelected}
|
||||||
size="sm"
|
appLogoMap={appLogoMap}
|
||||||
onClick={toggleSelectAll}
|
onAppChange={handleAppChange}
|
||||||
>
|
onToggleSelectAll={toggleSelectAll}
|
||||||
{allSelected ? "Cancelar seleção" : "Selecionar página"}
|
onSelectionBulkRequest={handleSelectionBulkRequest}
|
||||||
</Button>
|
onBulkDeleteRequest={handleBulkDeleteRequest}
|
||||||
{selectedIds.length > 0 && (
|
/>
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleSelectionBulkRequest("processed")}
|
|
||||||
>
|
|
||||||
<RiDeleteBinLine className="mr-1.5 size-4" />
|
|
||||||
Excluir selecionados ({selectedIds.length})
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
<Button
|
{activeStatus === "processed" && (
|
||||||
variant="outline"
|
<InboxItemsList
|
||||||
size="sm"
|
items={items}
|
||||||
onClick={() => handleBulkDeleteRequest("processed")}
|
readonly
|
||||||
>
|
activeApp={activeApp}
|
||||||
<RiDeleteBinLine className="mr-1.5 size-4" />
|
appLogoMap={appLogoMap}
|
||||||
Limpar processados
|
selectedIds={selectedIds}
|
||||||
</Button>
|
onDelete={handleDeleteRequest}
|
||||||
</div>
|
onRestoreToPending={handleRestoreRequest}
|
||||||
) : null}
|
onSelectToggle={toggleSelection}
|
||||||
</div>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeStatus === "processed" ? renderGroupedGrid(items, true) : null}
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="discarded" className="mt-4">
|
<TabsContent value="discarded" className="mt-4">
|
||||||
{activeStatus === "discarded" &&
|
{showTabActions("discarded") && (
|
||||||
(appFilterOptions.length > 0 || items.length > 0) && (
|
<InboxBulkActions
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
status="discarded"
|
||||||
{renderAppFilter()}
|
items={items}
|
||||||
{items.length > 0 ? (
|
activeApp={activeApp}
|
||||||
<div className="ml-auto flex items-center gap-2">
|
appFilterOptions={appFilterOptions}
|
||||||
<Button
|
selectedIds={selectedIds}
|
||||||
variant="outline"
|
allSelected={allSelected}
|
||||||
size="sm"
|
appLogoMap={appLogoMap}
|
||||||
onClick={toggleSelectAll}
|
onAppChange={handleAppChange}
|
||||||
>
|
onToggleSelectAll={toggleSelectAll}
|
||||||
{allSelected ? "Cancelar seleção" : "Selecionar página"}
|
onSelectionBulkRequest={handleSelectionBulkRequest}
|
||||||
</Button>
|
onBulkDeleteRequest={handleBulkDeleteRequest}
|
||||||
{selectedIds.length > 0 && (
|
/>
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleSelectionBulkRequest("discarded")}
|
|
||||||
>
|
|
||||||
<RiDeleteBinLine className="mr-1.5 size-4" />
|
|
||||||
Excluir selecionados ({selectedIds.length})
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
<Button
|
{activeStatus === "discarded" && (
|
||||||
variant="outline"
|
<InboxItemsList
|
||||||
size="sm"
|
items={items}
|
||||||
onClick={() => handleBulkDeleteRequest("discarded")}
|
readonly
|
||||||
>
|
activeApp={activeApp}
|
||||||
<RiDeleteBinLine className="mr-1.5 size-4" />
|
appLogoMap={appLogoMap}
|
||||||
Limpar descartados
|
selectedIds={selectedIds}
|
||||||
</Button>
|
onDelete={handleDeleteRequest}
|
||||||
</div>
|
onRestoreToPending={handleRestoreRequest}
|
||||||
) : null}
|
onSelectToggle={toggleSelection}
|
||||||
</div>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeStatus === "discarded" ? renderGroupedGrid(items, true) : null}
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{pagination.totalItems > 0 ? (
|
<InboxPagination
|
||||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
pagination={pagination}
|
||||||
<div className="flex items-center gap-2">
|
activeStatus={activeStatus}
|
||||||
<span className="text-sm text-muted-foreground">
|
isPending={isPending}
|
||||||
{pagination.totalItems} notificações
|
onNavigate={updateUrl}
|
||||||
</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}
|
|
||||||
|
|
||||||
<TransactionDialog
|
<TransactionDialog
|
||||||
mode="create"
|
mode="create"
|
||||||
@@ -944,7 +561,7 @@ export function InboxPage({
|
|||||||
|
|
||||||
<ConfirmActionDialog
|
<ConfirmActionDialog
|
||||||
open={bulkDeleteOpen}
|
open={bulkDeleteOpen}
|
||||||
onOpenChange={handleBulkDeleteOpenChange}
|
onOpenChange={setBulkDeleteOpen}
|
||||||
title={`Limpar ${bulkDeleteStatus === "processed" ? "processados" : "descartados"}?`}
|
title={`Limpar ${bulkDeleteStatus === "processed" ? "processados" : "descartados"}?`}
|
||||||
description={`Todos os itens ${bulkDeleteStatus === "processed" ? "processados" : "descartados"} serão excluídos permanentemente.`}
|
description={`Todos os itens ${bulkDeleteStatus === "processed" ? "processados" : "descartados"} serão excluídos permanentemente.`}
|
||||||
confirmLabel="Limpar tudo"
|
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,
|
transactions,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
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 { db } from "@/shared/lib/db";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
import { safeToNumber } from "@/shared/utils/number";
|
import { safeToNumber } from "@/shared/utils/number";
|
||||||
@@ -36,12 +37,14 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
|
|||||||
transactionType,
|
transactionType,
|
||||||
excludeTransfers = true,
|
excludeTransfers = true,
|
||||||
excludeAutoInvoice = true,
|
excludeAutoInvoice = true,
|
||||||
|
excludeExcludedAccounts = true,
|
||||||
}: {
|
}: {
|
||||||
period?: string;
|
period?: string;
|
||||||
periods?: string[];
|
periods?: string[];
|
||||||
transactionType?: string;
|
transactionType?: string;
|
||||||
excludeTransfers?: boolean;
|
excludeTransfers?: boolean;
|
||||||
excludeAutoInvoice?: boolean;
|
excludeAutoInvoice?: boolean;
|
||||||
|
excludeExcludedAccounts?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const conditions = [eq(transactions.userId, userId), adminPayerCondition];
|
const conditions = [eq(transactions.userId, userId), adminPayerCondition];
|
||||||
|
|
||||||
@@ -60,6 +63,9 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
|
|||||||
if (excludeAutoInvoice) {
|
if (excludeAutoInvoice) {
|
||||||
conditions.push(autoInvoiceExclusion);
|
conditions.push(autoInvoiceExclusion);
|
||||||
}
|
}
|
||||||
|
if (excludeExcludedAccounts) {
|
||||||
|
conditions.push(excludeTransactionsFromExcludedAccounts());
|
||||||
|
}
|
||||||
|
|
||||||
return conditions;
|
return conditions;
|
||||||
};
|
};
|
||||||
@@ -84,6 +90,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
|
|||||||
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
.where(and(...buildAdminTransactionConditions({ period })))
|
.where(and(...buildAdminTransactionConditions({ period })))
|
||||||
.groupBy(transactions.transactionType),
|
.groupBy(transactions.transactionType),
|
||||||
db
|
db
|
||||||
@@ -92,6 +102,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
|
|||||||
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(...buildAdminTransactionConditions({ period: previousPeriod })),
|
and(...buildAdminTransactionConditions({ period: previousPeriod })),
|
||||||
)
|
)
|
||||||
@@ -102,6 +116,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
|
|||||||
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
.where(and(...buildAdminTransactionConditions({ period: twoMonthsAgo })))
|
.where(and(...buildAdminTransactionConditions({ period: twoMonthsAgo })))
|
||||||
.groupBy(transactions.transactionType),
|
.groupBy(transactions.transactionType),
|
||||||
db
|
db
|
||||||
@@ -110,6 +128,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
|
|||||||
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
totalAmount: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(...buildAdminTransactionConditions({ period: threeMonthsAgo })),
|
and(...buildAdminTransactionConditions({ period: threeMonthsAgo })),
|
||||||
)
|
)
|
||||||
@@ -121,6 +143,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
|
|||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.innerJoin(categories, eq(transactions.categoryId, categories.id))
|
.innerJoin(categories, eq(transactions.categoryId, categories.id))
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
...buildAdminTransactionConditions({
|
...buildAdminTransactionConditions({
|
||||||
@@ -137,7 +163,7 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
|
|||||||
.select({
|
.select({
|
||||||
categoryName: categories.name,
|
categoryName: categories.name,
|
||||||
budgetAmount: budgets.amount,
|
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)
|
.from(budgets)
|
||||||
.innerJoin(categories, eq(budgets.categoryId, categories.id))
|
.innerJoin(categories, eq(budgets.categoryId, categories.id))
|
||||||
@@ -152,6 +178,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
|
|||||||
autoInvoiceExclusion,
|
autoInvoiceExclusion,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
.where(and(eq(budgets.userId, userId), eq(budgets.period, period)))
|
.where(and(eq(budgets.userId, userId), eq(budgets.period, period)))
|
||||||
.groupBy(categories.name, budgets.amount),
|
.groupBy(categories.name, budgets.amount),
|
||||||
db
|
db
|
||||||
@@ -180,6 +210,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
|
|||||||
transactionCount: sql<number>`count(*)`,
|
transactionCount: sql<number>`count(*)`,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
.where(and(...buildAdminTransactionConditions({ period }))),
|
.where(and(...buildAdminTransactionConditions({ period }))),
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
@@ -187,6 +221,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
|
|||||||
amount: transactions.amount,
|
amount: transactions.amount,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
...buildAdminTransactionConditions({
|
...buildAdminTransactionConditions({
|
||||||
@@ -201,6 +239,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
|
|||||||
total: sql<number>`coalesce(sum(abs(${transactions.amount})), 0)`,
|
total: sql<number>`coalesce(sum(abs(${transactions.amount})), 0)`,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
...buildAdminTransactionConditions({
|
...buildAdminTransactionConditions({
|
||||||
@@ -222,6 +264,10 @@ async function aggregateMonthDataInternal(userId: string, period: string) {
|
|||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.leftJoin(categories, eq(transactions.categoryId, categories.id))
|
.leftJoin(categories, eq(transactions.categoryId, categories.id))
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
...buildAdminTransactionConditions({
|
...buildAdminTransactionConditions({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
RiSaveLine,
|
RiSaveLine,
|
||||||
RiSparklingLine,
|
RiSparklingLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
@@ -13,10 +14,13 @@ import { toast } from "sonner";
|
|||||||
import {
|
import {
|
||||||
deleteSavedInsightsAction,
|
deleteSavedInsightsAction,
|
||||||
generateInsightsAction,
|
generateInsightsAction,
|
||||||
loadSavedInsightsAction,
|
|
||||||
saveInsightsAction,
|
saveInsightsAction,
|
||||||
} from "@/features/insights/actions";
|
} from "@/features/insights/actions";
|
||||||
import { DEFAULT_MODEL } from "@/features/insights/constants";
|
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 { EmptyState } from "@/shared/components/empty-state";
|
||||||
import { Alert, AlertDescription } from "@/shared/components/ui/alert";
|
import { Alert, AlertDescription } from "@/shared/components/ui/alert";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
@@ -32,47 +36,47 @@ interface InsightsPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
||||||
const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL);
|
const queryClient = useQueryClient();
|
||||||
const [insights, setInsights] = useState<InsightsResponse | null>(null);
|
const savedInsightsQuery = useSavedInsights(period);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [isSaving, startSaveTransition] = 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 [error, setError] = useState<string | null>(null);
|
||||||
const [isSaved, setIsSaved] = useState(false);
|
const savedInsights = savedInsightsQuery.data ?? null;
|
||||||
const [savedDate, setSavedDate] = useState<Date | null>(null);
|
const insights = draftInsights ?? savedInsights?.insights ?? null;
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
const loadSaved = async () => {
|
void period;
|
||||||
try {
|
setDraftInsights(null);
|
||||||
const result = await loadSavedInsightsAction(period);
|
setSelectedModelOverride(null);
|
||||||
if (result.success && result.data) {
|
setError(null);
|
||||||
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();
|
|
||||||
}, [period]);
|
}, [period]);
|
||||||
|
|
||||||
const handleAnalyze = () => {
|
const handleAnalyze = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsSaved(false);
|
|
||||||
setSavedDate(null);
|
|
||||||
onAnalyze?.();
|
onAnalyze?.();
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await generateInsightsAction(period, selectedModel);
|
const result = await generateInsightsAction(period, selectedModel);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setInsights(result.data);
|
setDraftInsights(result.data);
|
||||||
|
setSelectedModelOverride(selectedModel);
|
||||||
toast.success("Insights gerados com sucesso!");
|
toast.success("Insights gerados com sucesso!");
|
||||||
} else {
|
} else {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
@@ -99,8 +103,13 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setIsSaved(true);
|
queryClient.setQueryData(savedInsightsQueryKey(period), {
|
||||||
setSavedDate(result.data.createdAt);
|
insights,
|
||||||
|
modelId: selectedModel,
|
||||||
|
createdAt: result.data.createdAt.toISOString(),
|
||||||
|
});
|
||||||
|
setDraftInsights(null);
|
||||||
|
setSelectedModelOverride(null);
|
||||||
toast.success("Análise salva com sucesso!");
|
toast.success("Análise salva com sucesso!");
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
@@ -113,13 +122,16 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
|
if (!insights) return;
|
||||||
|
|
||||||
startSaveTransition(async () => {
|
startSaveTransition(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await deleteSavedInsightsAction(period);
|
const result = await deleteSavedInsightsAction(period);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setIsSaved(false);
|
queryClient.setQueryData(savedInsightsQueryKey(period), null);
|
||||||
setSavedDate(null);
|
setDraftInsights(insights);
|
||||||
|
setSelectedModelOverride(selectedModel);
|
||||||
toast.success("Análise removida com sucesso!");
|
toast.success("Análise removida com sucesso!");
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
@@ -148,7 +160,7 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
|||||||
{/* Model Selector */}
|
{/* Model Selector */}
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
value={selectedModel}
|
value={selectedModel}
|
||||||
onValueChange={setSelectedModel}
|
onValueChange={setSelectedModelOverride}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -156,7 +168,7 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
|||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleAnalyze}
|
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"
|
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" />
|
<RiSparklingLine className="mr-2 size-5" aria-hidden="true" />
|
||||||
@@ -166,7 +178,7 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
|||||||
{insights && !error && (
|
{insights && !error && (
|
||||||
<Button
|
<Button
|
||||||
onClick={isSaved ? handleDelete : handleSave}
|
onClick={isSaved ? handleDelete : handleSave}
|
||||||
disabled={isSaving || isPending || isLoading}
|
disabled={isSaving || isPending || isLoadingSavedInsights}
|
||||||
variant={isSaved ? "destructive" : "outline"}
|
variant={isSaved ? "destructive" : "outline"}
|
||||||
>
|
>
|
||||||
{isSaved ? (
|
{isSaved ? (
|
||||||
@@ -195,8 +207,12 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
|||||||
|
|
||||||
{/* Content Area */}
|
{/* Content Area */}
|
||||||
<div className="min-h-[400px]">
|
<div className="min-h-[400px]">
|
||||||
{(isPending || isLoading) && <LoadingState />}
|
{(isPending || isLoadingSavedInsights) && <LoadingState />}
|
||||||
{!isPending && !isLoading && !insights && !error && (
|
{!isPending &&
|
||||||
|
!isLoadingSavedInsights &&
|
||||||
|
!insights &&
|
||||||
|
!error &&
|
||||||
|
!savedInsightsError && (
|
||||||
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
|
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
media={<RiSparklingLine className="size-6 text-primary" />}
|
media={<RiSparklingLine className="size-6 text-primary" />}
|
||||||
@@ -206,12 +222,28 @@ export function InsightsPage({ period, onAnalyze }: InsightsPageProps) {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
{!isPending && !isLoading && error && (
|
{!isPending && !isLoadingSavedInsights && error && (
|
||||||
<ErrorState error={error} onRetry={handleAnalyze} />
|
<ErrorState
|
||||||
|
title="Erro ao gerar insights"
|
||||||
|
error={error}
|
||||||
|
onRetry={handleAnalyze}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{!isPending && !isLoading && insights && !error && (
|
{!isPending &&
|
||||||
<InsightsGrid insights={insights} />
|
!isLoadingSavedInsights &&
|
||||||
|
!error &&
|
||||||
|
savedInsightsError && (
|
||||||
|
<ErrorState
|
||||||
|
title="Erro ao carregar insights salvos"
|
||||||
|
error={savedInsightsError}
|
||||||
|
onRetry={() => void savedInsightsQuery.refetch()}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
{!isPending &&
|
||||||
|
!isLoadingSavedInsights &&
|
||||||
|
insights &&
|
||||||
|
!error &&
|
||||||
|
!savedInsightsError && <InsightsGrid insights={insights} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -258,18 +290,18 @@ function LoadingState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ErrorState({
|
function ErrorState({
|
||||||
|
title,
|
||||||
error,
|
error,
|
||||||
onRetry,
|
onRetry,
|
||||||
}: {
|
}: {
|
||||||
|
title: string;
|
||||||
error: string;
|
error: string;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 py-12 px-4 text-center">
|
<div className="flex flex-col items-center justify-center gap-4 py-12 px-4 text-center">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h3 className="text-lg font-medium text-destructive">
|
<h3 className="text-lg font-medium text-destructive">{title}</h3>
|
||||||
Erro ao gerar insights
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground max-w-md">{error}</p>
|
<p className="text-sm text-muted-foreground max-w-md">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={onRetry} variant="outline">
|
<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 { 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 { 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 { db } from "@/shared/lib/db";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||||
@@ -49,6 +50,7 @@ export async function fetchCategoryChartData(
|
|||||||
isNull(transactions.note),
|
isNull(transactions.note),
|
||||||
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||||
),
|
),
|
||||||
|
excludeTransactionsFromExcludedAccounts(),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (categoryIds && categoryIds.length > 0) {
|
if (categoryIds && categoryIds.length > 0) {
|
||||||
@@ -67,6 +69,10 @@ export async function fetchCategoryChartData(
|
|||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.innerJoin(categories, eq(transactions.categoryId, categories.id))
|
.innerJoin(categories, eq(transactions.categoryId, categories.id))
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
.where(and(...whereConditions))
|
.where(and(...whereConditions))
|
||||||
.groupBy(
|
.groupBy(
|
||||||
categories.id,
|
categories.id,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
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 { 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 { db } from "@/shared/lib/db";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||||
@@ -43,6 +44,7 @@ export async function fetchCategoryReport(
|
|||||||
isNull(transactions.note),
|
isNull(transactions.note),
|
||||||
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||||
),
|
),
|
||||||
|
excludeTransactionsFromExcludedAccounts(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add optional category filter
|
// Add optional category filter
|
||||||
@@ -62,6 +64,10 @@ export async function fetchCategoryReport(
|
|||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.innerJoin(categories, eq(transactions.categoryId, categories.id))
|
.innerJoin(categories, eq(transactions.categoryId, categories.id))
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
.where(and(...whereConditions))
|
.where(and(...whereConditions))
|
||||||
.groupBy(
|
.groupBy(
|
||||||
categories.id,
|
categories.id,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ interface CategoryReportExportProps {
|
|||||||
filters: FilterState;
|
filters: FilterState;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadXlsx = () => import("xlsx");
|
const loadExcelJS = () => import("exceljs");
|
||||||
|
|
||||||
const loadPdfDeps = async () => {
|
const loadPdfDeps = async () => {
|
||||||
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
|
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
|
||||||
@@ -134,7 +134,7 @@ export function CategoryReportExport({
|
|||||||
const exportToExcel = async () => {
|
const exportToExcel = async () => {
|
||||||
try {
|
try {
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
const XLSX = await loadXlsx();
|
const ExcelJS = await loadExcelJS();
|
||||||
|
|
||||||
// Build data array
|
// Build data array
|
||||||
const headers = [
|
const headers = [
|
||||||
@@ -179,20 +179,32 @@ export function CategoryReportExport({
|
|||||||
totalsRow.push(formatCurrency(data.grandTotal));
|
totalsRow.push(formatCurrency(data.grandTotal));
|
||||||
rows.push(totalsRow);
|
rows.push(totalsRow);
|
||||||
|
|
||||||
// Create worksheet
|
// Create workbook and worksheet
|
||||||
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const ws = workbook.addWorksheet("Relatório de Categorias");
|
||||||
|
|
||||||
|
ws.addRows([headers, ...rows]);
|
||||||
|
|
||||||
// Set column widths
|
// Set column widths
|
||||||
ws["!cols"] = [
|
ws.getColumn(1).width = 20;
|
||||||
{ wch: 20 }, // Category
|
for (let i = 0; i < data.periods.length; i++) {
|
||||||
...data.periods.map(() => ({ wch: 15 })), // Periods
|
ws.getColumn(i + 2).width = 15;
|
||||||
{ wch: 15 }, // Total
|
}
|
||||||
];
|
ws.getColumn(data.periods.length + 2).width = 15;
|
||||||
|
|
||||||
// Create workbook and download
|
// Download
|
||||||
const wb = XLSX.utils.book_new();
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
XLSX.utils.book_append_sheet(wb, ws, "Relatório de Categorias");
|
const blob = new Blob([buffer], {
|
||||||
XLSX.writeFile(wb, getFileName("xlsx"));
|
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!");
|
toast.success("Relatório exportado em Excel com sucesso!");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||||
INITIAL_BALANCE_NOTE,
|
INITIAL_BALANCE_NOTE,
|
||||||
} from "@/shared/lib/accounts/constants";
|
} from "@/shared/lib/accounts/constants";
|
||||||
|
import { excludeTransactionsFromExcludedAccounts } from "@/shared/lib/accounts/query-filters";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
import { safeToNumber } from "@/shared/utils/number";
|
import { safeToNumber } from "@/shared/utils/number";
|
||||||
@@ -118,6 +119,7 @@ export async function fetchTopEstablishmentsData(
|
|||||||
isNull(financialAccounts.excludeInitialBalanceFromIncome),
|
isNull(financialAccounts.excludeInitialBalanceFromIncome),
|
||||||
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
|
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
|
||||||
),
|
),
|
||||||
|
excludeTransactionsFromExcludedAccounts(),
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Fetch establishments with transaction count and total amount
|
// Fetch establishments with transaction count and total amount
|
||||||
|
|||||||
@@ -610,7 +610,7 @@ const revokeApiTokenSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function generateSecureToken(): string {
|
function generateSecureToken(): string {
|
||||||
const prefix = "os";
|
const prefix = "opm";
|
||||||
const randomPart = randomBytes(32).toString("base64url");
|
const randomPart = randomBytes(32).toString("base64url");
|
||||||
return `${prefix}_${randomPart}`;
|
return `${prefix}_${randomPart}`;
|
||||||
}
|
}
|
||||||
@@ -649,7 +649,7 @@ export async function createApiTokenAction(
|
|||||||
name: validated.name,
|
name: validated.name,
|
||||||
tokenHash,
|
tokenHash,
|
||||||
tokenPrefix,
|
tokenPrefix,
|
||||||
expiresAt: null, // No expiration for now
|
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 ano
|
||||||
})
|
})
|
||||||
.returning({ id: apiTokens.id });
|
.returning({ id: apiTokens.id });
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "@/shared/lib/payers/notifications";
|
} from "@/shared/lib/payers/notifications";
|
||||||
import type { ActionResult } from "@/shared/lib/types/actions";
|
import type { ActionResult } from "@/shared/lib/types/actions";
|
||||||
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
|
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
|
||||||
|
import { addMonthsToPeriod, parsePeriod } from "@/shared/utils/period";
|
||||||
import {
|
import {
|
||||||
centsToDecimalString,
|
centsToDecimalString,
|
||||||
type DeleteBulkInput,
|
type DeleteBulkInput,
|
||||||
@@ -26,6 +27,8 @@ import {
|
|||||||
fetchOwnedCardIds,
|
fetchOwnedCardIds,
|
||||||
fetchOwnedCategoryIds,
|
fetchOwnedCategoryIds,
|
||||||
fetchOwnedPayerIds,
|
fetchOwnedPayerIds,
|
||||||
|
formatPaidInvoicePeriods,
|
||||||
|
getPaidInvoicePeriods,
|
||||||
type MassAddInput,
|
type MassAddInput,
|
||||||
massAddSchema,
|
massAddSchema,
|
||||||
resolvePeriod,
|
resolvePeriod,
|
||||||
@@ -37,6 +40,12 @@ import {
|
|||||||
validateAllOwnership,
|
validateAllOwnership,
|
||||||
} from "./core";
|
} 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(
|
export async function deleteTransactionBulkAction(
|
||||||
input: DeleteBulkInput,
|
input: DeleteBulkInput,
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
@@ -164,8 +173,10 @@ export async function updateTransactionBulkAction(
|
|||||||
period: true,
|
period: true,
|
||||||
condition: true,
|
condition: true,
|
||||||
transactionType: true,
|
transactionType: true,
|
||||||
|
paymentMethod: true,
|
||||||
purchaseDate: true,
|
purchaseDate: true,
|
||||||
payerId: true,
|
payerId: true,
|
||||||
|
cardId: true,
|
||||||
},
|
},
|
||||||
where: and(
|
where: and(
|
||||||
eq(transactions.id, data.id),
|
eq(transactions.id, data.id),
|
||||||
@@ -204,6 +215,8 @@ export async function updateTransactionBulkAction(
|
|||||||
|
|
||||||
const hasDueDateUpdate = data.dueDate !== undefined;
|
const hasDueDateUpdate = data.dueDate !== undefined;
|
||||||
const hasBoletoPaymentDateUpdate = data.boletoPaymentDate !== undefined;
|
const hasBoletoPaymentDateUpdate = data.boletoPaymentDate !== undefined;
|
||||||
|
const hasPurchaseDateUpdate = data.purchaseDate !== undefined;
|
||||||
|
const hasPeriodUpdate = data.period !== undefined;
|
||||||
|
|
||||||
const baseDueDate =
|
const baseDueDate =
|
||||||
hasDueDateUpdate && data.dueDate
|
hasDueDateUpdate && data.dueDate
|
||||||
@@ -218,8 +231,13 @@ export async function updateTransactionBulkAction(
|
|||||||
: hasBoletoPaymentDateUpdate
|
: hasBoletoPaymentDateUpdate
|
||||||
? null
|
? null
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const referencePurchaseDate = existing.purchaseDate ?? null;
|
||||||
const basePurchaseDate = 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) => {
|
const buildDueDateForRecord = (recordPurchaseDate: Date | null) => {
|
||||||
if (!hasDueDateUpdate) {
|
if (!hasDueDateUpdate) {
|
||||||
@@ -230,18 +248,48 @@ export async function updateTransactionBulkAction(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!basePurchaseDate || !recordPurchaseDate) {
|
if (!referencePurchaseDate || !recordPurchaseDate) {
|
||||||
return baseDueDate;
|
return baseDueDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
const monthDiff =
|
const monthDiff =
|
||||||
(recordPurchaseDate.getFullYear() - basePurchaseDate.getFullYear()) *
|
(recordPurchaseDate.getFullYear() -
|
||||||
|
referencePurchaseDate.getFullYear()) *
|
||||||
12 +
|
12 +
|
||||||
(recordPurchaseDate.getMonth() - basePurchaseDate.getMonth());
|
(recordPurchaseDate.getMonth() - referencePurchaseDate.getMonth());
|
||||||
|
|
||||||
return addMonthsToDate(baseDueDate, monthDiff);
|
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) => {
|
const serializeDateKey = (value: Date | null | undefined) => {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
return "undefined";
|
return "undefined";
|
||||||
@@ -252,8 +300,51 @@ export async function updateTransactionBulkAction(
|
|||||||
return String(value.getTime());
|
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 (
|
const applyUpdates = async (
|
||||||
records: Array<{ id: string; purchaseDate: Date | null }>,
|
records: Array<{ id: string; purchaseDate: Date | null; period: string }>,
|
||||||
) => {
|
) => {
|
||||||
if (records.length === 0) {
|
if (records.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -269,10 +360,20 @@ export async function updateTransactionBulkAction(
|
|||||||
|
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
const dueDateForRecord = buildDueDateForRecord(record.purchaseDate);
|
const dueDateForRecord = buildDueDateForRecord(record.purchaseDate);
|
||||||
|
const purchaseDateForRecord = buildPurchaseDateForRecord(record);
|
||||||
|
const periodForRecord = buildPeriodForRecord(record);
|
||||||
const perRecordPayload: Record<string, unknown> = {
|
const perRecordPayload: Record<string, unknown> = {
|
||||||
...baseUpdatePayload,
|
...baseUpdatePayload,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (purchaseDateForRecord !== undefined) {
|
||||||
|
perRecordPayload.purchaseDate = purchaseDateForRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (periodForRecord !== undefined) {
|
||||||
|
perRecordPayload.period = periodForRecord;
|
||||||
|
}
|
||||||
|
|
||||||
if (dueDateForRecord !== undefined) {
|
if (dueDateForRecord !== undefined) {
|
||||||
perRecordPayload.dueDate = dueDateForRecord;
|
perRecordPayload.dueDate = dueDateForRecord;
|
||||||
}
|
}
|
||||||
@@ -282,6 +383,8 @@ export async function updateTransactionBulkAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const groupKey = [
|
const groupKey = [
|
||||||
|
serializeDateKey(purchaseDateForRecord),
|
||||||
|
periodForRecord ?? "undefined",
|
||||||
serializeDateKey(dueDateForRecord),
|
serializeDateKey(dueDateForRecord),
|
||||||
serializeDateKey(
|
serializeDateKey(
|
||||||
hasBoletoPaymentDateUpdate
|
hasBoletoPaymentDateUpdate
|
||||||
@@ -318,12 +421,19 @@ export async function updateTransactionBulkAction(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (data.scope === "current") {
|
if (data.scope === "current") {
|
||||||
await applyUpdates([
|
const currentRecords = [
|
||||||
{
|
{
|
||||||
id: data.id,
|
id: data.id,
|
||||||
purchaseDate: existing.purchaseDate ?? null,
|
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);
|
revalidate(user.id);
|
||||||
return { success: true, message: "Lançamento atualizado com sucesso." };
|
return { success: true, message: "Lançamento atualizado com sucesso." };
|
||||||
@@ -338,7 +448,7 @@ export async function updateTransactionBulkAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const periodLancamentos = await db.query.transactions.findMany({
|
const periodLancamentos = await db.query.transactions.findMany({
|
||||||
columns: { id: true, purchaseDate: true },
|
columns: { id: true, purchaseDate: true, period: true },
|
||||||
where: and(
|
where: and(
|
||||||
eq(transactions.seriesId, existing.seriesId),
|
eq(transactions.seriesId, existing.seriesId),
|
||||||
eq(transactions.userId, user.id),
|
eq(transactions.userId, user.id),
|
||||||
@@ -347,10 +457,16 @@ export async function updateTransactionBulkAction(
|
|||||||
orderBy: asc(transactions.purchaseDate),
|
orderBy: asc(transactions.purchaseDate),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const invoiceError = await ensureTargetInvoicesAreOpen(periodLancamentos);
|
||||||
|
if (invoiceError) {
|
||||||
|
return { success: false, error: invoiceError };
|
||||||
|
}
|
||||||
|
|
||||||
await applyUpdates(
|
await applyUpdates(
|
||||||
periodLancamentos.map((item: (typeof periodLancamentos)[number]) => ({
|
periodLancamentos.map((item: (typeof periodLancamentos)[number]) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
purchaseDate: item.purchaseDate ?? null,
|
purchaseDate: item.purchaseDate ?? null,
|
||||||
|
period: item.period,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -370,6 +486,7 @@ export async function updateTransactionBulkAction(
|
|||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
purchaseDate: true,
|
purchaseDate: true,
|
||||||
|
period: true,
|
||||||
},
|
},
|
||||||
where: and(
|
where: and(
|
||||||
eq(transactions.seriesId, existing.seriesId),
|
eq(transactions.seriesId, existing.seriesId),
|
||||||
@@ -380,10 +497,16 @@ export async function updateTransactionBulkAction(
|
|||||||
orderBy: asc(transactions.purchaseDate),
|
orderBy: asc(transactions.purchaseDate),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const invoiceError = await ensureTargetInvoicesAreOpen(futureLancamentos);
|
||||||
|
if (invoiceError) {
|
||||||
|
return { success: false, error: invoiceError };
|
||||||
|
}
|
||||||
|
|
||||||
await applyUpdates(
|
await applyUpdates(
|
||||||
futureLancamentos.map((item: (typeof futureLancamentos)[number]) => ({
|
futureLancamentos.map((item: (typeof futureLancamentos)[number]) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
purchaseDate: item.purchaseDate ?? null,
|
purchaseDate: item.purchaseDate ?? null,
|
||||||
|
period: item.period,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -399,6 +522,7 @@ export async function updateTransactionBulkAction(
|
|||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
purchaseDate: true,
|
purchaseDate: true,
|
||||||
|
period: true,
|
||||||
},
|
},
|
||||||
where: and(
|
where: and(
|
||||||
eq(transactions.seriesId, existing.seriesId),
|
eq(transactions.seriesId, existing.seriesId),
|
||||||
@@ -408,10 +532,16 @@ export async function updateTransactionBulkAction(
|
|||||||
orderBy: asc(transactions.purchaseDate),
|
orderBy: asc(transactions.purchaseDate),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const invoiceError = await ensureTargetInvoicesAreOpen(allLancamentos);
|
||||||
|
if (invoiceError) {
|
||||||
|
return { success: false, error: invoiceError };
|
||||||
|
}
|
||||||
|
|
||||||
await applyUpdates(
|
await applyUpdates(
|
||||||
allLancamentos.map((item: (typeof allLancamentos)[number]) => ({
|
allLancamentos.map((item: (typeof allLancamentos)[number]) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
purchaseDate: item.purchaseDate ?? null,
|
purchaseDate: item.purchaseDate ?? null,
|
||||||
|
period: item.period,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
cards,
|
cards,
|
||||||
categories,
|
categories,
|
||||||
financialAccounts,
|
financialAccounts,
|
||||||
|
invoices,
|
||||||
payers,
|
payers,
|
||||||
type transactions,
|
type transactions,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
@@ -20,9 +21,10 @@ import {
|
|||||||
} from "@/shared/lib/accounts/constants";
|
} from "@/shared/lib/accounts/constants";
|
||||||
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
|
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
|
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
||||||
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
|
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
|
||||||
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
|
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
|
||||||
import { addMonthsToPeriod } from "@/shared/utils/period";
|
import { addMonthsToPeriod, MONTH_NAMES } from "@/shared/utils/period";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Authorization Validation Functions
|
// Authorization Validation Functions
|
||||||
@@ -662,6 +664,43 @@ export const buildLancamentoRecords = ({
|
|||||||
return records;
|
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({
|
export const deleteBulkSchema = z.object({
|
||||||
id: uuidSchema("Lançamento"),
|
id: uuidSchema("Lançamento"),
|
||||||
scope: z.enum(["current", "period", "future", "all"], {
|
scope: z.enum(["current", "period", "future", "all"], {
|
||||||
@@ -676,6 +715,20 @@ export const updateBulkSchema = z.object({
|
|||||||
scope: z.enum(["current", "period", "future", "all"], {
|
scope: z.enum(["current", "period", "future", "all"], {
|
||||||
message: "Escopo de ação inválido.",
|
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
|
name: z
|
||||||
.string({ message: "Informe o estabelecimento." })
|
.string({ message: "Informe o estabelecimento." })
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
attachments,
|
attachments,
|
||||||
financialAccounts,
|
financialAccounts,
|
||||||
invoices,
|
|
||||||
transactionAttachments,
|
transactionAttachments,
|
||||||
transactions,
|
transactions,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { handleActionError } from "@/shared/lib/actions/helpers";
|
import { handleActionError } from "@/shared/lib/actions/helpers";
|
||||||
import { getUser } from "@/shared/lib/auth/server";
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
|
||||||
import {
|
import {
|
||||||
buildEntriesByPayer,
|
buildEntriesByPayer,
|
||||||
sendPayerAutoEmails,
|
sendPayerAutoEmails,
|
||||||
@@ -23,7 +21,6 @@ import {
|
|||||||
getBusinessTodayDate,
|
getBusinessTodayDate,
|
||||||
parseLocalDateString,
|
parseLocalDateString,
|
||||||
} from "@/shared/utils/date";
|
} from "@/shared/utils/date";
|
||||||
import { MONTH_NAMES } from "@/shared/utils/period";
|
|
||||||
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
|
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
|
||||||
import {
|
import {
|
||||||
buildLancamentoRecords,
|
buildLancamentoRecords,
|
||||||
@@ -33,6 +30,8 @@ import {
|
|||||||
createSchema,
|
createSchema,
|
||||||
type DeleteInput,
|
type DeleteInput,
|
||||||
deleteSchema,
|
deleteSchema,
|
||||||
|
formatPaidInvoicePeriods,
|
||||||
|
getPaidInvoicePeriods,
|
||||||
isInitialBalanceLancamento,
|
isInitialBalanceLancamento,
|
||||||
resolvePeriod,
|
resolvePeriod,
|
||||||
resolveUserLabel,
|
resolveUserLabel,
|
||||||
@@ -118,27 +117,18 @@ export async function createTransactionAction(
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
const paidInvoices = await db.query.invoices.findMany({
|
const paidPeriods = await getPaidInvoicePeriods(
|
||||||
columns: { period: true },
|
user.id,
|
||||||
where: and(
|
data.cardId,
|
||||||
eq(invoices.userId, user.id),
|
uniquePeriods,
|
||||||
eq(invoices.cardId, data.cardId),
|
);
|
||||||
eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PAID),
|
|
||||||
inArray(invoices.period, uniquePeriods),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (paidInvoices.length > 0) {
|
if (paidPeriods.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(", ");
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
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[] }>;
|
} as ActionResult<{ ids: string[] }>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,10 +194,12 @@ export async function updateTransactionAction(
|
|||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
note: true,
|
note: true,
|
||||||
|
period: true,
|
||||||
transactionType: true,
|
transactionType: true,
|
||||||
condition: true,
|
condition: true,
|
||||||
paymentMethod: true,
|
paymentMethod: true,
|
||||||
accountId: true,
|
accountId: true,
|
||||||
|
cardId: true,
|
||||||
categoryId: true,
|
categoryId: true,
|
||||||
},
|
},
|
||||||
where: and(
|
where: and(
|
||||||
@@ -225,10 +217,12 @@ export async function updateTransactionAction(
|
|||||||
| {
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
|
period: string;
|
||||||
transactionType: string;
|
transactionType: string;
|
||||||
condition: string;
|
condition: string;
|
||||||
paymentMethod: string;
|
paymentMethod: string;
|
||||||
accountId: string | null;
|
accountId: string | null;
|
||||||
|
cardId: string | null;
|
||||||
categoryId: string | null;
|
categoryId: string | null;
|
||||||
category: { name: string } | null;
|
category: { name: string } | null;
|
||||||
}
|
}
|
||||||
@@ -264,6 +258,25 @@ export async function updateTransactionAction(
|
|||||||
? parseLocalDateString(data.boletoPaymentDate)
|
? parseLocalDateString(data.boletoPaymentDate)
|
||||||
: getBusinessTodayDate()
|
: getBusinessTodayDate()
|
||||||
: null;
|
: 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
|
await db
|
||||||
.update(transactions)
|
.update(transactions)
|
||||||
|
|||||||
@@ -131,6 +131,46 @@ export async function createInstallmentAnticipationAction(
|
|||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
const data = createAnticipationSchema.parse(input);
|
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
|
// 1. Validar parcelas selecionadas
|
||||||
const installments = await db.query.transactions.findMany({
|
const installments = await db.query.transactions.findMany({
|
||||||
where: and(
|
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";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
|
||||||
interface AttachmentFilePickerProps {
|
interface AttachmentFilePickerProps {
|
||||||
file: File | null;
|
files: File[];
|
||||||
onChange: (file: File | null) => void;
|
onAdd: (file: File) => void;
|
||||||
|
onRemove: (file: File) => void;
|
||||||
maxSizeMb?: number;
|
maxSizeMb?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AttachmentFilePicker({
|
export function AttachmentFilePicker({
|
||||||
file,
|
files,
|
||||||
onChange,
|
onAdd,
|
||||||
|
onRemove,
|
||||||
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
||||||
}: AttachmentFilePickerProps) {
|
}: AttachmentFilePickerProps) {
|
||||||
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
|
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
|
||||||
@@ -45,12 +47,12 @@ export function AttachmentFilePicker({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange(selected);
|
onAdd(selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="text-xs">Anexo</p>
|
<p className="text-xs font-medium">Anexos</p>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -59,8 +61,13 @@ export function AttachmentFilePicker({
|
|||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{file ? (
|
{files.length > 0 && (
|
||||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm">
|
<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" />
|
<RiAttachment2 className="size-4 shrink-0 text-muted-foreground" />
|
||||||
<span className="min-w-0 flex-1 truncate" title={file.name}>
|
<span className="min-w-0 flex-1 truncate" title={file.name}>
|
||||||
{file.name}
|
{file.name}
|
||||||
@@ -70,12 +77,15 @@ export function AttachmentFilePicker({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-6 shrink-0"
|
className="size-6 shrink-0"
|
||||||
onClick={() => onChange(null)}
|
onClick={() => onRemove(file)}
|
||||||
>
|
>
|
||||||
<RiCloseLine className="size-4" />
|
<RiCloseLine className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="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"
|
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"
|
||||||
@@ -89,7 +99,6 @@ export function AttachmentFilePicker({
|
|||||||
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
|
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiFileAddLine } from "@remixicon/react";
|
import { RiFileAddLine } from "@remixicon/react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { fetchTransactionAttachmentsAction } from "@/features/transactions/actions/attachments";
|
import { useEffect } from "react";
|
||||||
|
import {
|
||||||
|
transactionAttachmentsQueryKey,
|
||||||
|
useTransactionAttachments,
|
||||||
|
} from "@/features/transactions/hooks/use-transaction-attachments";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { AttachmentItem } from "./attachment-item";
|
import { AttachmentItem } from "./attachment-item";
|
||||||
import { AttachmentUpload } from "./attachment-upload";
|
import { AttachmentUpload } from "./attachment-upload";
|
||||||
|
|
||||||
type AttachmentRow = {
|
|
||||||
attachmentId: string;
|
|
||||||
fileName: string;
|
|
||||||
fileSize: number;
|
|
||||||
mimeType: string;
|
|
||||||
createdAt: Date;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface AttachmentSectionProps {
|
interface AttachmentSectionProps {
|
||||||
transactionId: string;
|
transactionId: string;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
@@ -41,28 +36,35 @@ export function AttachmentSection({
|
|||||||
onCancelPendingUpload,
|
onCancelPendingUpload,
|
||||||
maxSizeMb,
|
maxSizeMb,
|
||||||
}: AttachmentSectionProps) {
|
}: AttachmentSectionProps) {
|
||||||
const [items, setItems] = useState<AttachmentRow[]>([]);
|
const queryClient = useQueryClient();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const {
|
||||||
|
data: items = [],
|
||||||
const load = useCallback(async () => {
|
isLoading,
|
||||||
setIsLoading(true);
|
isError,
|
||||||
try {
|
} = useTransactionAttachments(transactionId);
|
||||||
const data = await fetchTransactionAttachmentsAction(transactionId);
|
|
||||||
setItems(data);
|
|
||||||
onLoaded?.(data.length);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [transactionId, onLoaded]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
onLoaded?.(items.length);
|
||||||
}, [load]);
|
}, [items.length, onLoaded]);
|
||||||
|
|
||||||
|
const invalidateAttachments = () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: transactionAttachmentsQueryKey(transactionId),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <p className="text-xs text-muted-foreground">Carregando...</p>;
|
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;
|
const hasPendingUploads = (pendingUploadFiles?.length ?? 0) > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -82,7 +84,7 @@ export function AttachmentSection({
|
|||||||
fileSize={item.fileSize}
|
fileSize={item.fileSize}
|
||||||
mimeType={item.mimeType}
|
mimeType={item.mimeType}
|
||||||
url={item.url}
|
url={item.url}
|
||||||
onDeleted={load}
|
onDeleted={invalidateAttachments}
|
||||||
readonly={readonly}
|
readonly={readonly}
|
||||||
isPendingDelete={pendingDetachIds?.includes(item.attachmentId)}
|
isPendingDelete={pendingDetachIds?.includes(item.attachmentId)}
|
||||||
onPendingDelete={onPendingDetach}
|
onPendingDelete={onPendingDetach}
|
||||||
@@ -119,7 +121,7 @@ export function AttachmentSection({
|
|||||||
{!readonly && (
|
{!readonly && (
|
||||||
<AttachmentUpload
|
<AttachmentUpload
|
||||||
transactionId={transactionId}
|
transactionId={transactionId}
|
||||||
onUploaded={load}
|
onUploaded={invalidateAttachments}
|
||||||
onPendingUpload={onPendingUpload}
|
onPendingUpload={onPendingUpload}
|
||||||
maxSizeMb={maxSizeMb}
|
maxSizeMb={maxSizeMb}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiCalendarCheckLine, RiLoader4Line } from "@remixicon/react";
|
import { RiCalendarCheckLine, RiLoader4Line } from "@remixicon/react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import {
|
||||||
import { getInstallmentAnticipationsAction } from "@/features/transactions/anticipation-actions";
|
installmentAnticipationsQueryKey,
|
||||||
|
useInstallmentAnticipations,
|
||||||
|
} from "@/features/transactions/hooks/use-installment-anticipations";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -20,7 +23,6 @@ import {
|
|||||||
EmptyTitle,
|
EmptyTitle,
|
||||||
} from "@/shared/components/ui/empty";
|
} from "@/shared/components/ui/empty";
|
||||||
import { useControlledState } from "@/shared/hooks/use-controlled-state";
|
import { useControlledState } from "@/shared/hooks/use-controlled-state";
|
||||||
import type { InstallmentAnticipationWithRelations } from "@/shared/lib/installments/anticipation-types";
|
|
||||||
import { AnticipationCard } from "../../shared/anticipation-card";
|
import { AnticipationCard } from "../../shared/anticipation-card";
|
||||||
|
|
||||||
interface AnticipationHistoryDialogProps {
|
interface AnticipationHistoryDialogProps {
|
||||||
@@ -40,53 +42,23 @@ export function AnticipationHistoryDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onViewLancamento,
|
onViewLancamento,
|
||||||
}: AnticipationHistoryDialogProps) {
|
}: AnticipationHistoryDialogProps) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const queryClient = useQueryClient();
|
||||||
const [anticipations, setAnticipations] = useState<
|
|
||||||
InstallmentAnticipationWithRelations[]
|
|
||||||
>([]);
|
|
||||||
|
|
||||||
// Use controlled state hook for dialog open state
|
|
||||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||||
open,
|
open,
|
||||||
false,
|
false,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
);
|
);
|
||||||
|
const {
|
||||||
// Define loadAnticipations before it's used in useEffect
|
data: anticipations = [],
|
||||||
const loadAnticipations = useCallback(async () => {
|
isLoading,
|
||||||
setIsLoading(true);
|
isError,
|
||||||
|
refetch,
|
||||||
try {
|
} = useInstallmentAnticipations(seriesId, dialogOpen);
|
||||||
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 handleCanceled = () => {
|
const handleCanceled = () => {
|
||||||
// Recarregar lista após cancelamento
|
void queryClient.invalidateQueries({
|
||||||
loadAnticipations();
|
queryKey: installmentAnticipationsQueryKey(seriesId),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -106,6 +78,26 @@ export function AnticipationHistoryDialog({
|
|||||||
Carregando histórico...
|
Carregando histórico...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 ? (
|
) : anticipations.length === 0 ? (
|
||||||
<Empty>
|
<Empty>
|
||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function CategorySection({
|
|||||||
>
|
>
|
||||||
<Label htmlFor="categoria">Categoria</Label>
|
<Label htmlFor="categoria">Categoria</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formState.categoryId}
|
value={formState.categoryId ?? ""}
|
||||||
onValueChange={(value) => onFieldChange("categoryId", value)}
|
onValueChange={(value) => onFieldChange("categoryId", value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="categoria" className="w-full">
|
<SelectTrigger id="categoria" className="w-full">
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||||
|
import { RiSliceFill } from "@remixicon/react";
|
||||||
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
||||||
import { Label } from "@/shared/components/ui/label";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
import {
|
import {
|
||||||
@@ -9,6 +11,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select";
|
} from "@/shared/components/ui/select";
|
||||||
|
import { cn } from "@/shared/utils/ui";
|
||||||
import { PayerSelectContent } from "../../select-items";
|
import { PayerSelectContent } from "../../select-items";
|
||||||
import type { PayerSectionProps } from "./transaction-dialog-types";
|
import type { PayerSectionProps } from "./transaction-dialog-types";
|
||||||
|
|
||||||
@@ -34,12 +37,48 @@ export function PayerSection({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||||
<div className="w-full space-y-1">
|
<div className="w-full space-y-1">
|
||||||
<Label htmlFor="payer">Pagador</Label>
|
<Label htmlFor="payer">Pagador</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select
|
<Select
|
||||||
value={formState.payerId}
|
value={formState.payerId ?? ""}
|
||||||
onValueChange={(value) => onFieldChange("payerId", value)}
|
onValueChange={(value) => onFieldChange("payerId", value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
@@ -88,7 +127,7 @@ export function PayerSection({
|
|||||||
<Label htmlFor="secondaryPayer">Dividir com</Label>
|
<Label htmlFor="secondaryPayer">Dividir com</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select
|
<Select
|
||||||
value={formState.secondaryPayerId}
|
value={formState.secondaryPayerId ?? ""}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
onFieldChange("secondaryPayerId", value)
|
onFieldChange("secondaryPayerId", value)
|
||||||
}
|
}
|
||||||
@@ -134,5 +173,6 @@ export function PayerSection({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RiCheckboxBlankCircleLine,
|
||||||
|
RiCheckboxCircleFill,
|
||||||
|
} from "@remixicon/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { PAYMENT_METHODS } from "@/features/transactions/constants";
|
import { PAYMENT_METHODS } from "@/features/transactions/constants";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Label } from "@/shared/components/ui/label";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
import { MonthPicker } from "@/shared/components/ui/month-picker";
|
import { MonthPicker } from "@/shared/components/ui/month-picker";
|
||||||
import {
|
import {
|
||||||
@@ -71,6 +76,7 @@ export function PaymentMethodSection({
|
|||||||
isUpdateMode,
|
isUpdateMode,
|
||||||
disablePaymentMethod,
|
disablePaymentMethod,
|
||||||
disableCardSelect,
|
disableCardSelect,
|
||||||
|
showSettledToggle,
|
||||||
}: PaymentMethodSectionProps) {
|
}: PaymentMethodSectionProps) {
|
||||||
const isCartaoSelected = formState.paymentMethod === "Cartão de crédito";
|
const isCartaoSelected = formState.paymentMethod === "Cartão de crédito";
|
||||||
const showContaSelect = [
|
const showContaSelect = [
|
||||||
@@ -92,6 +98,7 @@ export function PaymentMethodSection({
|
|||||||
const hasSecondaryColumn = isCartaoSelected || showContaSelect;
|
const hasSecondaryColumn = isCartaoSelected || showContaSelect;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||||
{!isUpdateMode ? (
|
{!isUpdateMode ? (
|
||||||
<div
|
<div
|
||||||
@@ -113,7 +120,9 @@ export function PaymentMethodSection({
|
|||||||
>
|
>
|
||||||
<SelectValue placeholder="Selecione" className="w-full">
|
<SelectValue placeholder="Selecione" className="w-full">
|
||||||
{formState.paymentMethod && (
|
{formState.paymentMethod && (
|
||||||
<PaymentMethodSelectContent label={formState.paymentMethod} />
|
<PaymentMethodSelectContent
|
||||||
|
label={formState.paymentMethod}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -137,7 +146,7 @@ export function PaymentMethodSection({
|
|||||||
>
|
>
|
||||||
<Label htmlFor="cartao">Cartão</Label>
|
<Label htmlFor="cartao">Cartão</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formState.cardId}
|
value={formState.cardId ?? ""}
|
||||||
onValueChange={(value) => onFieldChange("cardId", value)}
|
onValueChange={(value) => onFieldChange("cardId", value)}
|
||||||
disabled={disableCardSelect}
|
disabled={disableCardSelect}
|
||||||
>
|
>
|
||||||
@@ -200,7 +209,7 @@ export function PaymentMethodSection({
|
|||||||
>
|
>
|
||||||
<Label htmlFor="conta">Conta</Label>
|
<Label htmlFor="conta">Conta</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formState.accountId}
|
value={formState.accountId ?? ""}
|
||||||
onValueChange={(value) => onFieldChange("accountId", value)}
|
onValueChange={(value) => onFieldChange("accountId", value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="conta" className="w-full">
|
<SelectTrigger id="conta" className="w-full">
|
||||||
@@ -243,5 +252,48 @@ export function PaymentMethodSection({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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;
|
maxSizeMb?: number;
|
||||||
onBulkEditRequest?: (data: {
|
onBulkEditRequest?: (data: {
|
||||||
id: string;
|
id: string;
|
||||||
|
purchaseDate: string;
|
||||||
|
period: string;
|
||||||
name: string;
|
name: string;
|
||||||
categoryId: string | undefined;
|
categoryId: string | undefined;
|
||||||
note: string;
|
note: string;
|
||||||
@@ -71,10 +73,6 @@ export interface CategorySectionProps extends BaseFieldSectionProps {
|
|||||||
hideTransactionType?: boolean;
|
hideTransactionType?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SplitAndSettlementSectionProps extends BaseFieldSectionProps {
|
|
||||||
showSettledToggle: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PayerSectionProps extends BaseFieldSectionProps {
|
export interface PayerSectionProps extends BaseFieldSectionProps {
|
||||||
payerOptions: SelectOption[];
|
payerOptions: SelectOption[];
|
||||||
secondaryPayerOptions: SelectOption[];
|
secondaryPayerOptions: SelectOption[];
|
||||||
@@ -87,6 +85,7 @@ export interface PaymentMethodSectionProps extends BaseFieldSectionProps {
|
|||||||
isUpdateMode: boolean;
|
isUpdateMode: boolean;
|
||||||
disablePaymentMethod: boolean;
|
disablePaymentMethod: boolean;
|
||||||
disableCardSelect: boolean;
|
disableCardSelect: boolean;
|
||||||
|
showSettledToggle: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BoletoFieldsSectionProps extends BaseFieldSectionProps {
|
export interface BoletoFieldsSectionProps extends BaseFieldSectionProps {
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ import { ConditionSection } from "./condition-section";
|
|||||||
import { NoteSection } from "./note-section";
|
import { NoteSection } from "./note-section";
|
||||||
import { PayerSection } from "./payer-section";
|
import { PayerSection } from "./payer-section";
|
||||||
import { PaymentMethodSection } from "./payment-method-section";
|
import { PaymentMethodSection } from "./payment-method-section";
|
||||||
import { SplitAndSettlementSection } from "./split-settlement-section";
|
|
||||||
import type {
|
import type {
|
||||||
FormState,
|
FormState,
|
||||||
TransactionDialogProps,
|
TransactionDialogProps,
|
||||||
@@ -99,7 +98,7 @@ export function TransactionDialog({
|
|||||||
);
|
);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
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 [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]);
|
||||||
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]);
|
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]);
|
||||||
|
|
||||||
@@ -139,7 +138,7 @@ export function TransactionDialog({
|
|||||||
|
|
||||||
setFormState(initial);
|
setFormState(initial);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
setPendingFile(null);
|
setPendingFiles([]);
|
||||||
setPendingDetachIds([]);
|
setPendingDetachIds([]);
|
||||||
setPendingUploadFiles([]);
|
setPendingUploadFiles([]);
|
||||||
}
|
}
|
||||||
@@ -330,22 +329,23 @@ export function TransactionDialog({
|
|||||||
const result = await createTransactionAction(payload);
|
const result = await createTransactionAction(payload);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (pendingFile && result.data?.ids?.length) {
|
if (pendingFiles.length > 0 && result.data?.ids?.length) {
|
||||||
const firstId = result.data.ids[0];
|
const firstId = result.data.ids[0];
|
||||||
const isNewSeries =
|
const isNewSeries =
|
||||||
formState.condition === "Parcelado" ||
|
formState.condition === "Parcelado" ||
|
||||||
formState.condition === "Recorrente";
|
formState.condition === "Recorrente";
|
||||||
|
for (const file of pendingFiles) {
|
||||||
const presign = await getPresignedUploadUrlAction({
|
const presign = await getPresignedUploadUrlAction({
|
||||||
fileName: pendingFile.name,
|
fileName: file.name,
|
||||||
mimeType: pendingFile.type,
|
mimeType: file.type,
|
||||||
fileSize: pendingFile.size,
|
fileSize: file.size,
|
||||||
transactionId: firstId,
|
transactionId: firstId,
|
||||||
});
|
});
|
||||||
if (presign.success) {
|
if (presign.success) {
|
||||||
await fetch(presign.presignedUrl, {
|
await fetch(presign.presignedUrl, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: pendingFile,
|
body: file,
|
||||||
headers: { "Content-Type": pendingFile.type },
|
headers: { "Content-Type": file.type },
|
||||||
});
|
});
|
||||||
await confirmAttachmentUploadAction({
|
await confirmAttachmentUploadAction({
|
||||||
uploadToken: presign.uploadToken,
|
uploadToken: presign.uploadToken,
|
||||||
@@ -353,6 +353,7 @@ export function TransactionDialog({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
@@ -371,6 +372,8 @@ export function TransactionDialog({
|
|||||||
// o upload após o escopo ser escolhido (sem upload antecipado ao S3)
|
// o upload após o escopo ser escolhido (sem upload antecipado ao S3)
|
||||||
onBulkEditRequest({
|
onBulkEditRequest({
|
||||||
id: transaction?.id ?? "",
|
id: transaction?.id ?? "",
|
||||||
|
purchaseDate: formState.purchaseDate,
|
||||||
|
period: formState.period,
|
||||||
name: formState.name.trim(),
|
name: formState.name.trim(),
|
||||||
categoryId: formState.categoryId,
|
categoryId: formState.categoryId,
|
||||||
note: formState.note.trim() || "",
|
note: formState.note.trim() || "",
|
||||||
@@ -493,7 +496,9 @@ export function TransactionDialog({
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
noValidate
|
noValidate
|
||||||
>
|
>
|
||||||
<div className="min-w-0 space-y-3 -mx-6 max-h-[90vh] overflow-x-hidden overflow-y-auto px-6 pb-1">
|
<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
|
<BasicFieldsSection
|
||||||
formState={formState}
|
formState={formState}
|
||||||
onFieldChange={handleFieldChange}
|
onFieldChange={handleFieldChange}
|
||||||
@@ -510,13 +515,11 @@ export function TransactionDialog({
|
|||||||
Boolean(isNewWithType) && !forceShowTransactionType
|
Boolean(isNewWithType) && !forceShowTransactionType
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SplitAndSettlementSection
|
<div className="border-t border-border/40 my-3" />
|
||||||
formState={formState}
|
|
||||||
onFieldChange={handleFieldChange}
|
|
||||||
showSettledToggle={showSettledToggle}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
{/* Pagador */}
|
||||||
<PayerSection
|
<PayerSection
|
||||||
formState={formState}
|
formState={formState}
|
||||||
onFieldChange={handleFieldChange}
|
onFieldChange={handleFieldChange}
|
||||||
@@ -525,6 +528,10 @@ export function TransactionDialog({
|
|||||||
totalAmount={totalAmount}
|
totalAmount={totalAmount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="border-t border-border/40 my-3" />
|
||||||
|
|
||||||
|
{/* Pagamento */}
|
||||||
|
<div className="space-y-3">
|
||||||
<PaymentMethodSection
|
<PaymentMethodSection
|
||||||
formState={formState}
|
formState={formState}
|
||||||
onFieldChange={handleFieldChange}
|
onFieldChange={handleFieldChange}
|
||||||
@@ -533,6 +540,7 @@ export function TransactionDialog({
|
|||||||
isUpdateMode={isUpdateMode}
|
isUpdateMode={isUpdateMode}
|
||||||
disablePaymentMethod={disablePaymentMethod}
|
disablePaymentMethod={disablePaymentMethod}
|
||||||
disableCardSelect={disableCardSelect}
|
disableCardSelect={disableCardSelect}
|
||||||
|
showSettledToggle={showSettledToggle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showDueDate ? (
|
{showDueDate ? (
|
||||||
@@ -542,9 +550,13 @@ export function TransactionDialog({
|
|||||||
showPaymentDate={showPaymentDate}
|
showPaymentDate={showPaymentDate}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Extras */}
|
||||||
{isUpdateMode ? (
|
{isUpdateMode ? (
|
||||||
<>
|
<>
|
||||||
|
<div className="border-t border-border/40 my-3" />
|
||||||
|
<div className="space-y-3">
|
||||||
<NoteSection
|
<NoteSection
|
||||||
formState={formState}
|
formState={formState}
|
||||||
onFieldChange={handleFieldChange}
|
onFieldChange={handleFieldChange}
|
||||||
@@ -576,6 +588,7 @@ export function TransactionDialog({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Collapsible
|
<Collapsible
|
||||||
@@ -598,8 +611,11 @@ export function TransactionDialog({
|
|||||||
onFieldChange={handleFieldChange}
|
onFieldChange={handleFieldChange}
|
||||||
/>
|
/>
|
||||||
<AttachmentFilePicker
|
<AttachmentFilePicker
|
||||||
file={pendingFile}
|
files={pendingFiles}
|
||||||
onChange={setPendingFile}
|
onAdd={(file) => setPendingFiles((prev) => [...prev, file])}
|
||||||
|
onRemove={(file) =>
|
||||||
|
setPendingFiles((prev) => prev.filter((f) => f !== file))
|
||||||
|
}
|
||||||
maxSizeMb={maxSizeMb}
|
maxSizeMb={maxSizeMb}
|
||||||
/>
|
/>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ export function UploadZone({ onParsed }: UploadZoneProps) {
|
|||||||
reader.readAsText(file, "windows-1252");
|
reader.readAsText(file, "windows-1252");
|
||||||
} else {
|
} else {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = async (e) => {
|
||||||
try {
|
try {
|
||||||
const buffer = e.target?.result as ArrayBuffer;
|
const buffer = e.target?.result as ArrayBuffer;
|
||||||
const statement = parseXls(buffer);
|
const statement = await parseXls(buffer);
|
||||||
onParsed(statement);
|
onParsed(statement);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
@@ -62,8 +62,8 @@ export function UploadZone({ onParsed }: UploadZoneProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadTemplate = () => {
|
const handleDownloadTemplate = async () => {
|
||||||
const bytes = generateXlsTemplate();
|
const bytes = await generateXlsTemplate();
|
||||||
const blob = new Blob([bytes], {
|
const blob = new Blob([bytes], {
|
||||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ export function TransactionsPage({
|
|||||||
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||||
const [pendingEditData, setPendingEditData] = useState<{
|
const [pendingEditData, setPendingEditData] = useState<{
|
||||||
id: string;
|
id: string;
|
||||||
|
purchaseDate: string;
|
||||||
|
period: string;
|
||||||
name: string;
|
name: string;
|
||||||
categoryId: string | undefined;
|
categoryId: string | undefined;
|
||||||
note: string;
|
note: string;
|
||||||
@@ -245,6 +247,8 @@ export function TransactionsPage({
|
|||||||
|
|
||||||
const handleBulkEditRequest = (data: {
|
const handleBulkEditRequest = (data: {
|
||||||
id: string;
|
id: string;
|
||||||
|
purchaseDate: string;
|
||||||
|
period: string;
|
||||||
name: string;
|
name: string;
|
||||||
categoryId: string | undefined;
|
categoryId: string | undefined;
|
||||||
note: string;
|
note: string;
|
||||||
@@ -278,6 +282,8 @@ export function TransactionsPage({
|
|||||||
const result = await updateTransactionBulkAction({
|
const result = await updateTransactionBulkAction({
|
||||||
id: pendingEditData.id,
|
id: pendingEditData.id,
|
||||||
scope,
|
scope,
|
||||||
|
purchaseDate: pendingEditData.purchaseDate,
|
||||||
|
period: pendingEditData.period,
|
||||||
name: pendingEditData.name,
|
name: pendingEditData.name,
|
||||||
categoryId: pendingEditData.categoryId,
|
categoryId: pendingEditData.categoryId,
|
||||||
note: pendingEditData.note,
|
note: pendingEditData.note,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ptBR } from "date-fns/locale";
|
|||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cancelInstallmentAnticipationAction } from "@/features/transactions/anticipation-actions";
|
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 { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
@@ -18,11 +19,10 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/shared/components/ui/card";
|
} from "@/shared/components/ui/card";
|
||||||
import type { InstallmentAnticipationWithRelations } from "@/shared/lib/installments/anticipation-types";
|
|
||||||
import { displayPeriod } from "@/shared/utils/period";
|
import { displayPeriod } from "@/shared/utils/period";
|
||||||
|
|
||||||
interface AnticipationCardProps {
|
interface AnticipationCardProps {
|
||||||
anticipation: InstallmentAnticipationWithRelations;
|
anticipation: InstallmentAnticipationListItem;
|
||||||
onViewLancamento?: (transactionId: string) => void;
|
onViewLancamento?: (transactionId: string) => void;
|
||||||
onCanceled?: () => void;
|
onCanceled?: () => void;
|
||||||
}
|
}
|
||||||
@@ -37,8 +37,8 @@ export function AnticipationCard({
|
|||||||
const isSettled = anticipation.transaction?.isSettled === true;
|
const isSettled = anticipation.transaction?.isSettled === true;
|
||||||
const canCancel = !isSettled;
|
const canCancel = !isSettled;
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
const formatDate = (date: string) => {
|
||||||
return format(date, "dd 'de' MMMM 'de' yyyy", { locale: ptBR });
|
return format(new Date(date), "dd 'de' MMMM 'de' yyyy", { locale: ptBR });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = async () => {
|
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;
|
exportContext?: TransactionsExportContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadXlsx = () => import("xlsx");
|
const loadExcelJS = () => import("exceljs");
|
||||||
|
|
||||||
const loadPdfDeps = async () => {
|
const loadPdfDeps = async () => {
|
||||||
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
|
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
|
||||||
@@ -158,7 +158,7 @@ export function TransactionsExport({
|
|||||||
try {
|
try {
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
const transactions = await loadTransactions();
|
const transactions = await loadTransactions();
|
||||||
const XLSX = await loadXlsx();
|
const ExcelJS = await loadExcelJS();
|
||||||
|
|
||||||
const headers = [
|
const headers = [
|
||||||
"Data",
|
"Data",
|
||||||
@@ -188,23 +188,28 @@ export function TransactionsExport({
|
|||||||
rows.push(row);
|
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"] = [
|
ws.addRows([headers, ...rows]);
|
||||||
{ 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
|
|
||||||
];
|
|
||||||
|
|
||||||
const wb = XLSX.utils.book_new();
|
const colWidths = [12, 42, 15, 15, 20, 15, 20, 20, 20];
|
||||||
XLSX.utils.book_append_sheet(wb, ws, "Lançamentos");
|
colWidths.forEach((w, i) => {
|
||||||
XLSX.writeFile(wb, getFileName("xlsx"));
|
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!");
|
toast.success("Lançamentos exportados em Excel com sucesso!");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -60,11 +60,6 @@ export function deriveCreditCardPeriod(
|
|||||||
return period;
|
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
|
* Form state type for lancamento dialog
|
||||||
*/
|
*/
|
||||||
@@ -79,7 +74,6 @@ export type TransactionFormState = {
|
|||||||
payerId: string | undefined;
|
payerId: string | undefined;
|
||||||
secondaryPayerId: string | undefined;
|
secondaryPayerId: string | undefined;
|
||||||
isSplit: boolean;
|
isSplit: boolean;
|
||||||
splitType: SplitType;
|
|
||||||
primarySplitAmount: string;
|
primarySplitAmount: string;
|
||||||
secondarySplitAmount: string;
|
secondarySplitAmount: string;
|
||||||
accountId: string | undefined;
|
accountId: string | undefined;
|
||||||
@@ -117,7 +111,7 @@ export function buildTransactionInitialState(
|
|||||||
): TransactionFormState {
|
): TransactionFormState {
|
||||||
const purchaseDate = transaction?.purchaseDate
|
const purchaseDate = transaction?.purchaseDate
|
||||||
? transaction.purchaseDate.slice(0, 10)
|
? transaction.purchaseDate.slice(0, 10)
|
||||||
: (overrides?.defaultPurchaseDate ?? "");
|
: (overrides?.defaultPurchaseDate ?? getTodayDateString());
|
||||||
|
|
||||||
const paymentMethod =
|
const paymentMethod =
|
||||||
transaction?.paymentMethod ??
|
transaction?.paymentMethod ??
|
||||||
@@ -176,7 +170,7 @@ export function buildTransactionInitialState(
|
|||||||
payerId: fallbackPayerId ?? undefined,
|
payerId: fallbackPayerId ?? undefined,
|
||||||
secondaryPayerId: undefined,
|
secondaryPayerId: undefined,
|
||||||
isSplit: false,
|
isSplit: false,
|
||||||
splitType: "equal",
|
|
||||||
primarySplitAmount: "",
|
primarySplitAmount: "",
|
||||||
secondarySplitAmount: "",
|
secondarySplitAmount: "",
|
||||||
accountId:
|
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
|
* Applies field dependencies when form state changes
|
||||||
* This function encapsulates the business logic for field interdependencies
|
* 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
|
// When split is disabled, clear secondary pagador and split fields
|
||||||
if (key === "isSplit" && value === false) {
|
if (key === "isSplit" && value === false) {
|
||||||
updates.secondaryPayerId = undefined;
|
updates.secondaryPayerId = undefined;
|
||||||
updates.splitType = "equal";
|
|
||||||
updates.primarySplitAmount = "";
|
updates.primarySplitAmount = "";
|
||||||
updates.secondarySplitAmount = "";
|
updates.secondarySplitAmount = "";
|
||||||
}
|
}
|
||||||
@@ -367,12 +327,9 @@ export function applyFieldDependencies(
|
|||||||
if (key === "amount" && typeof value === "string" && currentState.isSplit) {
|
if (key === "amount" && typeof value === "string" && currentState.isSplit) {
|
||||||
const totalAmount = Number.parseFloat(value) || 0;
|
const totalAmount = Number.parseFloat(value) || 0;
|
||||||
if (totalAmount > 0) {
|
if (totalAmount > 0) {
|
||||||
const splitAmounts = calculateSplitAmounts(
|
const half = (totalAmount / 2).toFixed(2);
|
||||||
totalAmount,
|
updates.primarySplitAmount = half;
|
||||||
currentState.splitType,
|
updates.secondarySplitAmount = half;
|
||||||
);
|
|
||||||
updates.primarySplitAmount = splitAmounts.primary;
|
|
||||||
updates.secondarySplitAmount = splitAmounts.secondary;
|
|
||||||
} else {
|
} else {
|
||||||
updates.primarySplitAmount = "";
|
updates.primarySplitAmount = "";
|
||||||
updates.secondarySplitAmount = "";
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user