mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
Compare commits
40 Commits
49731238e4
...
v2.3.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31485eec8f | ||
|
|
3be64aa8d0 | ||
|
|
85f6dcfc22 | ||
|
|
df996df93d | ||
|
|
10afef9fec | ||
|
|
fd4d90a53e | ||
|
|
a24406271c | ||
|
|
a09942e3d8 | ||
|
|
96febd5904 | ||
|
|
c3cfbc878c | ||
|
|
55bbfabe9f | ||
|
|
f5cdae4853 | ||
|
|
5c4995961c | ||
|
|
1b4dfaaba7 | ||
|
|
549a5bdba1 | ||
|
|
acaf9d5c27 | ||
|
|
e4c6a91350 | ||
|
|
ba369e8a83 | ||
|
|
d01bc8a669 | ||
|
|
e024e0d54e | ||
|
|
c44089169f | ||
|
|
d04e30e3c9 | ||
|
|
229b6c5bc0 | ||
|
|
c3b133d8d9 | ||
|
|
e9a2ab1782 | ||
|
|
c7d6e23398 | ||
|
|
0514efb1c4 | ||
|
|
e32fb85006 | ||
|
|
96df6a1798 | ||
|
|
1f8a97bd16 | ||
|
|
0ab3298cef | ||
|
|
cad41680eb | ||
|
|
3b00f328c5 | ||
|
|
20d0c3e0a7 | ||
|
|
71b5a004e3 | ||
|
|
65b1506d75 | ||
|
|
2a458d5a3c | ||
|
|
f418987f47 | ||
|
|
59b4dea071 | ||
|
|
6ce132fe0c |
58
.github/copilot-instructions.md
vendored
58
.github/copilot-instructions.md
vendored
@@ -1,58 +0,0 @@
|
||||
# AI Coding Assistant Instructions for OpenMonetis
|
||||
|
||||
## Project Overview
|
||||
|
||||
OpenMonetis is a self-hosted personal finance management application built with Next.js 16, TypeScript, PostgreSQL, and Drizzle ORM. It provides manual transaction tracking, account management, budgeting, and financial insights with a Portuguese interface.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Frontend**: Next.js App Router with React 19, shadcn/ui components, Tailwind CSS
|
||||
- **Backend**: Server actions in Next.js, API routes for auth/health
|
||||
- **Database**: PostgreSQL with Drizzle ORM, schema in `db/schema.ts`
|
||||
- **Auth**: Better Auth (OAuth + email magic links)
|
||||
- **Deployment**: Docker multi-stage build, health checks
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- **Server Actions**: Use `"use server"` for mutations, validate with Zod schemas, handle errors with `handleActionError`
|
||||
- **Database Queries**: Use Drizzle's query API with relations, e.g., `db.query.lancamentos.findMany({ with: { categoria: true } })`
|
||||
- **Authentication**: Import from `lib/auth/server`, redirect on failure
|
||||
- **Revalidation**: Call `revalidateForEntity("lancamentos")` after mutations
|
||||
- **Portuguese Naming**: DB fields like `nome`, `tipo_conta`, `pagador` (payer), `lancamento` (transaction)
|
||||
- **Component Structure**: Feature-based folders in `components/`, shared UI in `components/ui/`
|
||||
|
||||
## Development Workflow
|
||||
|
||||
- **Start Dev**: `pnpm dev` (Turbopack), `docker compose up db -d` for DB
|
||||
- **Database**: `pnpm db:push` to sync schema, `pnpm db:studio` for visual editor
|
||||
- **Build**: `pnpm build`, `pnpm start` for production
|
||||
- **Docker**: `pnpm docker:up` for full stack, `pnpm docker:logs` for monitoring
|
||||
|
||||
## Common Tasks
|
||||
|
||||
- **Add Transaction**: Create server action in `app/(dashboard)/lancamentos/actions.ts`, validate with Zod, insert via Drizzle
|
||||
- **New Entity**: Add to `db/schema.ts`, define relations, create CRUD actions in `lib/[entity]/actions.ts`
|
||||
- **UI Component**: Use shadcn/ui, place in `components/[feature]/`, export from `components/ui/`
|
||||
- **API Route**: Add to `app/api/`, use `getUserSession()` for auth
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Imports**: Absolute paths with `@/`, group by external/internal
|
||||
- **Error Handling**: Return `{ success: false, error: string }` from actions
|
||||
- **Currency**: Store as decimal strings (e.g., "123.45"), convert to cents for calculations
|
||||
- **Periods**: Format as "YYYY-MM", use `parsePeriodParam()` for URL params
|
||||
- **Notifications**: Send emails via `sendPagadorAutoEmails()` for payer updates
|
||||
|
||||
## External Integrations
|
||||
|
||||
- **Better Auth**: Config in `lib/auth/config.ts`, session handling
|
||||
- **Drizzle**: Migrations in `drizzle/`, studio at `pnpm db:studio`
|
||||
- **AI Features**: Use `@ai-sdk/*` for insights, configured in environment
|
||||
- **Email**: Resend for notifications, configured via `RESEND_API_KEY`
|
||||
|
||||
## File Examples
|
||||
|
||||
- Schema: `db/schema.ts` (relations, indexes)
|
||||
- Actions: `app/(dashboard)/lancamentos/actions.ts` (CRUD with validation)
|
||||
- Components: `components/lancamentos/page/lancamentos-page.tsx` (client component)
|
||||
- Utils: `lib/lancamentos/page-helpers.ts` (data transformation)
|
||||
25
.github/workflows/docker-publish.yml
vendored
25
.github/workflows/docker-publish.yml
vendored
@@ -13,10 +13,35 @@ on:
|
||||
|
||||
env:
|
||||
DOCKER_IMAGE_NAME: openmonetis
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: pnpm run lint
|
||||
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
needs: quality
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -5,6 +5,9 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -29,4 +29,4 @@
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
116
CHANGELOG.md
116
CHANGELOG.md
@@ -7,6 +7,122 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Anexos: nova página de galeria em `/attachments` com miniaturas, visualização inline de imagem e PDF, download direto e acesso a partir do lançamento
|
||||
- Anexos: suporte a visualização de PDF diretamente no app via `pdfjs-dist`
|
||||
- Autenticação: sidebar redesenhado com mockup de faturas e três itens de funcionalidade; páginas de login e cadastro ganham gradiente decorativo e logo visível no mobile
|
||||
- Notificações: alertas de vencimento para boletos e faturas do período seguinte exibidos quando o vencimento está dentro de 5 dias
|
||||
- Documentação: novo arquivo público `public/llms.txt` com resumo do projeto e links curados para documentação, setup e arquitetura
|
||||
|
||||
### Alterado
|
||||
|
||||
- Performance: queries de cache do dashboard migradas de `unstable_cache` para a diretiva `use cache` com `cacheTag` e `cacheLife`; todas as páginas do dashboard passam a chamar `connection()` para renderização dinâmica; `next.config.ts` adota `cacheComponents: true`
|
||||
- Tipografia: adicionada fonte America Medium (weight 500); pesos tipográficos padronizados para `font-medium` em títulos, valores e rótulos em todos os componentes
|
||||
- Anexos: `AttachmentPreview` foi simplificado para exibir apenas nome da transação, nome do arquivo, navegação entre anexos e ações de download, abrir em nova aba e fechar com ícone `X`
|
||||
|
||||
### Corrigido
|
||||
|
||||
- Lançamentos: uploads e remoções de anexo agora funcionam para todos os lançamentos, não apenas os pertencentes a séries
|
||||
|
||||
## [2.1.2] - 2026-03-30
|
||||
|
||||
### Adicionado
|
||||
|
||||
- Preferências: nova configuração de tamanho máximo por arquivo de anexo (5, 10, 25, 50 ou 100 MB), persistida no banco e respeitada em todos os pontos de upload
|
||||
- Lançamentos: novo escopo `"period"` na ação em lote, que aplica a alteração a todos os lançamentos do período sem sobrescrever o pagador individual de cada um
|
||||
### Corrigido
|
||||
|
||||
- Lançamentos: ao editar um lançamento de série, uploads e remoções de anexo agora aguardam a escolha de escopo da ação em lote antes de serem executados, evitando que o anexo fosse aplicado no lançamento errado
|
||||
- Lançamentos: ação em lote com escopo `"period"` não sobrescreve mais o `payerId` individual de cada lançamento ao alterar o pagador
|
||||
|
||||
### Alterado
|
||||
|
||||
- Configurações: redesign visual da página com separadores entre seções e títulos maiores
|
||||
- Configurações: seção "Extrato e lançamentos" renomeada para "Lançamentos"
|
||||
|
||||
## [2.1.1] - 2026-03-29
|
||||
|
||||
### Adicionado
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`.
|
||||
4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`.
|
||||
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
|
||||
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog.
|
||||
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também altere o `package.json`.
|
||||
7. **Comunicacao**: responder em portugues clara e direta com o time.
|
||||
8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema.
|
||||
|
||||
@@ -44,6 +44,10 @@ Use esta pergunta:
|
||||
|
||||
Se um contrato cruza dominios, ele deve morar em `src/shared/`.
|
||||
|
||||
**Excecao intencional: `attachments` depende de `transactions`**
|
||||
|
||||
`src/features/attachments` importa `TransactionDialog`, `TransactionDetailsDialog` e `TransactionItem` diretamente de `src/features/transactions`. Isso e uma dependencia explicita e aceita: anexos sao semanticamente uma extensao de lancamentos — existem por causa deles e nao fazem sentido sem esse contexto. Mover esses componentes para `shared/` seria errado (eles pertencem a transactions). Nao tratar isso como bug a corrigir.
|
||||
|
||||
Exemplos comuns:
|
||||
|
||||
- auth: `src/shared/lib/auth/*`
|
||||
|
||||
56
Dockerfile
56
Dockerfile
@@ -5,14 +5,16 @@
|
||||
# ============================================
|
||||
FROM node:22-alpine AS deps
|
||||
|
||||
# Instalar pnpm globalmente
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copiar apenas arquivos de dependências para aproveitar cache
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
|
||||
# Criar pasta public para o postinstall do pdfjs-dist
|
||||
RUN mkdir -p public
|
||||
|
||||
# Instalar dependências (production + dev para o build)
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
@@ -21,8 +23,7 @@ RUN pnpm install --frozen-lockfile
|
||||
# ============================================
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
# Instalar pnpm globalmente
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -32,13 +33,14 @@ COPY --from=deps /app/node_modules ./node_modules
|
||||
# Copiar todo o código fonte
|
||||
COPY . .
|
||||
|
||||
# Garantir que o pdf.worker vem da versão instalada no stage 1, não do host
|
||||
COPY --from=deps /app/public/pdf.worker.min.mjs ./public/pdf.worker.min.mjs
|
||||
|
||||
# Variáveis de ambiente necessárias para o build
|
||||
# DATABASE_URL será fornecida em runtime, mas precisa estar definida para validação
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||
NODE_ENV=production
|
||||
|
||||
# Build da aplicação Next.js
|
||||
# Nota: Se houver erros de tipo, ajuste typescript.ignoreBuildErrors no next.config.ts
|
||||
RUN pnpm build
|
||||
|
||||
# ============================================
|
||||
@@ -46,8 +48,7 @@ RUN pnpm build
|
||||
# ============================================
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
# Instalar pnpm globalmente
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -55,12 +56,27 @@ WORKDIR /app
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copiar apenas arquivos necessários para produção
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
# Instalar deps do drizzle-kit em diretório separado ANTES de copiar o standalone
|
||||
# Isso evita que o pnpm install sobrescreva o node_modules do Next.js standalone
|
||||
COPY --from=builder /app/package.json /tmp/pkg.json
|
||||
RUN mkdir -p /app/migrate && \
|
||||
node -e "\
|
||||
const p=JSON.parse(require('fs').readFileSync('/tmp/pkg.json','utf8'));\
|
||||
require('fs').writeFileSync('/app/migrate/package.json',JSON.stringify({\
|
||||
name:'openmonetis-migrate',version:p.version,\
|
||||
dependencies:{\
|
||||
'drizzle-orm':p.dependencies['drizzle-orm'],\
|
||||
'pg':p.dependencies['pg']\
|
||||
},\
|
||||
devDependencies:{'drizzle-kit':p.devDependencies['drizzle-kit']}\
|
||||
}));" && \
|
||||
cd /app/migrate && pnpm install --no-frozen-lockfile --ignore-scripts && \
|
||||
chown -R nextjs:nodejs /app/migrate
|
||||
|
||||
# Copiar arquivos de build do Next.js
|
||||
# Copiar apenas arquivos necessários para produção
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
|
||||
# Copiar arquivos de build do Next.js (inclui node_modules standalone com next)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
@@ -69,8 +85,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/drizzle.config.ts ./drizzle.config.ts
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/src/db ./src/db
|
||||
|
||||
# Copiar node_modules para ter drizzle-kit disponível para migrations
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
# Copiar entrypoint de migrations
|
||||
COPY docker-entrypoint.sh ./
|
||||
RUN chmod +x /app/docker-entrypoint.sh && chown nextjs:nodejs /app/docker-entrypoint.sh
|
||||
|
||||
# Definir variáveis de ambiente de produção
|
||||
ENV NODE_ENV=production \
|
||||
@@ -81,16 +98,13 @@ ENV NODE_ENV=production \
|
||||
# Expor porta
|
||||
EXPOSE 3000
|
||||
|
||||
# Ajustar permissões para o usuário nextjs
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
|
||||
# Mudar para usuário não-root
|
||||
USER nextjs
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" || exit 1
|
||||
CMD wget --quiet --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||
|
||||
# Comando de inicialização
|
||||
# Nota: Em produção com standalone build, o servidor é iniciado pelo arquivo server.js
|
||||
# Entrypoint: roda migrations e depois executa o CMD
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://nextjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
|
||||
@@ -4,23 +4,28 @@ name: openmonetis
|
||||
# MODOS DE USO:
|
||||
# 1. Banco LOCAL (PostgreSQL em container):
|
||||
# - Configure DATABASE_URL com host "db" no .env
|
||||
# - Execute: docker compose up
|
||||
# - Execute: docker compose --profile local up
|
||||
#
|
||||
# 2. Banco REMOTO (ex: Supabase, Neon, etc):
|
||||
# - Configure DATABASE_URL com a URL do banco remoto no .env
|
||||
# - Execute: docker compose up app (apenas o serviço app)
|
||||
# - Execute: docker compose up
|
||||
#
|
||||
# 3. Para parar todos os serviços:
|
||||
# 3. Build local (desenvolvimento):
|
||||
# - Execute: docker compose --profile local up --build
|
||||
#
|
||||
# 4. Para parar todos os serviços:
|
||||
# - Execute: docker compose down
|
||||
#
|
||||
# 4. Para remover volumes (CUIDADO: apaga dados do banco local):
|
||||
# 5. Para remover volumes (CUIDADO: apaga dados do banco local):
|
||||
# - Execute: docker compose down -v
|
||||
|
||||
services:
|
||||
# ============================================
|
||||
# Serviço: PostgreSQL (Banco de dados local)
|
||||
# Ativado apenas com: --profile local
|
||||
# ============================================
|
||||
db:
|
||||
profiles: ["local"]
|
||||
image: postgres:18-alpine
|
||||
container_name: openmonetis_postgres
|
||||
restart: unless-stopped
|
||||
@@ -63,6 +68,7 @@ services:
|
||||
# Serviço: Aplicação Next.js
|
||||
# ============================================
|
||||
app:
|
||||
build: .
|
||||
image: felipegcoutinho/openmonetis:latest
|
||||
|
||||
container_name: openmonetis_app
|
||||
@@ -80,6 +86,13 @@ services:
|
||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
|
||||
|
||||
# S3 (opcional)
|
||||
S3_ENDPOINT: ${S3_ENDPOINT:-}
|
||||
S3_REGION: ${S3_REGION:-}
|
||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
||||
S3_BUCKET: ${S3_BUCKET:-}
|
||||
|
||||
# Email (opcional)
|
||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
|
||||
@@ -96,24 +109,11 @@ services:
|
||||
GOOGLE_GENERATIVE_AI_API_KEY: ${GOOGLE_GENERATIVE_AI_API_KEY:-}
|
||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
||||
|
||||
# Só depende do 'db' se estiver usando banco local
|
||||
# Para banco remoto, comente as linhas abaixo
|
||||
# required: false permite subir sem banco local (banco remoto via DATABASE_URL)
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
# Script de inicialização: roda migrations antes de iniciar o app
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
echo "Aguardando banco de dados..."
|
||||
sleep 5
|
||||
|
||||
echo "Rodando migrations..."
|
||||
pnpm db:push || echo "Migrations falharam ou já estão atualizadas"
|
||||
|
||||
echo "Iniciando aplicação Next.js..."
|
||||
node server.js
|
||||
required: false
|
||||
|
||||
healthcheck:
|
||||
test:
|
||||
|
||||
15
docker-entrypoint.sh
Normal file
15
docker-entrypoint.sh
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo "Rodando migrations..."
|
||||
RETRIES=5
|
||||
until /app/migrate/node_modules/.bin/drizzle-kit push || [ "$RETRIES" -eq 0 ]; do
|
||||
RETRIES=$((RETRIES - 1))
|
||||
echo "Migration falhou, aguardando banco... ($RETRIES tentativas restantes)"
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if [ "$RETRIES" -eq 0 ]; then
|
||||
echo "Aviso: migrations nao foram aplicadas"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
1
drizzle/0024_petite_lucky_pierre.sql
Normal file
1
drizzle/0024_petite_lucky_pierre.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "preferencias_usuario" ADD COLUMN "attachment_max_size_mb" integer DEFAULT 50 NOT NULL;
|
||||
2711
drizzle/meta/0024_snapshot.json
Normal file
2711
drizzle/meta/0024_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -169,6 +169,13 @@
|
||||
"when": 1774529878374,
|
||||
"tag": "0023_sturdy_wolfpack",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "7",
|
||||
"when": 1774891206703,
|
||||
"tag": "0024_petite_lucky_pierre",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
22
knip.jsonc
Normal file
22
knip.jsonc
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@6/schema-jsonc.json",
|
||||
// Exclude shared UI primitives from dead code reporting while we focus the
|
||||
// cleanup on feature and domain code first.
|
||||
"ignore": [
|
||||
"src/shared/components/ui/**"
|
||||
],
|
||||
// Runtime asset referenced by string in the PDF viewer.
|
||||
"ignoreFiles": [
|
||||
"public/pdf.worker.min.mjs",
|
||||
"setup.mjs"
|
||||
],
|
||||
// PostCSS is inferred from the config file, but the project only depends on
|
||||
// the Tailwind PostCSS plugin directly.
|
||||
"ignoreDependencies": [
|
||||
"postcss"
|
||||
],
|
||||
"next": true,
|
||||
"postcss": true,
|
||||
"biome": true,
|
||||
"drizzle": true
|
||||
}
|
||||
@@ -4,18 +4,24 @@ import type { NextConfig } from "next";
|
||||
// Carregar variáveis de ambiente explicitamente
|
||||
dotenv.config();
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
experimental: {
|
||||
turbopackFileSystemCacheForDev: true,
|
||||
},
|
||||
cacheComponents: true,
|
||||
reactCompiler: true,
|
||||
|
||||
images: {
|
||||
remotePatterns: [new URL("https://lh3.googleusercontent.com/**")],
|
||||
},
|
||||
devIndicators: {
|
||||
position: "bottom-right",
|
||||
},
|
||||
experimental: {
|
||||
prefetchInlining: true,
|
||||
turbopackFileSystemCacheForDev: true,
|
||||
},
|
||||
|
||||
// Headers for Safari compatibility
|
||||
async headers() {
|
||||
return [
|
||||
@@ -40,7 +46,23 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: "frame-ancestors 'none';",
|
||||
value: [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://umami.felipecoutinho.com`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' https://lh3.googleusercontent.com data: blob:",
|
||||
"font-src 'self'",
|
||||
`connect-src 'self' https://umami.felipecoutinho.com ${process.env.S3_ENDPOINT ? new URL(process.env.S3_ENDPOINT).origin : ""}`.trim(),
|
||||
"frame-ancestors 'none'",
|
||||
].join("; "),
|
||||
},
|
||||
{
|
||||
key: "Referrer-Policy",
|
||||
value: "strict-origin-when-cross-origin",
|
||||
},
|
||||
{
|
||||
key: "X-Permitted-Cross-Domain-Policies",
|
||||
value: "none",
|
||||
},
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
|
||||
30
package.json
30
package.json
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "openmonetis",
|
||||
"version": "2.1.1",
|
||||
"version": "2.3.4",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"db:seed": "tsx scripts/mock-data.ts",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "biome check .",
|
||||
"lint:deadcode": "knip --reporter compact",
|
||||
"lint:fix": "biome check --write .",
|
||||
"env:setup": "bash scripts/setup-env.sh",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
@@ -16,6 +18,7 @@
|
||||
"db:extensions": "tsx scripts/postgres/enable-extensions.ts",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"docker:up": "docker compose up --build",
|
||||
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
|
||||
"docker:up:db": "docker compose up -d db",
|
||||
"docker:up:d": "docker compose up --build -d",
|
||||
"docker:down": "docker compose down",
|
||||
@@ -28,11 +31,11 @@
|
||||
"backup": "bash scripts/backup.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.64",
|
||||
"@ai-sdk/google": "^3.0.53",
|
||||
"@ai-sdk/openai": "^3.0.48",
|
||||
"@aws-sdk/client-s3": "^3.1019.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1019.0",
|
||||
"@ai-sdk/anthropic": "^3.0.65",
|
||||
"@ai-sdk/google": "^3.0.55",
|
||||
"@ai-sdk/openai": "^3.0.49",
|
||||
"@aws-sdk/client-s3": "^3.1022.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1022.0",
|
||||
"@better-auth/passkey": "^1.5.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -58,9 +61,10 @@
|
||||
"@radix-ui/react-toggle-group": "1.1.11",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "^5.96.2",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"ai": "^6.0.141",
|
||||
"ai": "^6.0.143",
|
||||
"better-auth": "1.5.6",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
@@ -68,33 +72,35 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"exceljs": "^4.4.0",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
"next": "16.1.7",
|
||||
"next": "16.2.2",
|
||||
"next-themes": "0.4.6",
|
||||
"pdfjs-dist": "^5.6.205",
|
||||
"pg": "8.20.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "19.2.4",
|
||||
"recharts": "3.8.1",
|
||||
"resend": "^6.9.4",
|
||||
"resend": "^6.10.0",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.5.0",
|
||||
"vaul": "1.1.2",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.9",
|
||||
"@biomejs/biome": "2.4.10",
|
||||
"@tailwindcss/postcss": "4.2.2",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "25.5.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"dotenv": "^17.3.1",
|
||||
"dotenv": "^17.4.0",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"knip": "^6.3.0",
|
||||
"tailwindcss": "4.2.2",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "6.0.2"
|
||||
|
||||
2129
pnpm-lock.yaml
generated
2129
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
|
||||
BIN
public/fonts/america-medium.woff2
Normal file
BIN
public/fonts/america-medium.woff2
Normal file
Binary file not shown.
@@ -7,9 +7,12 @@ export const america = localFont({
|
||||
weight: "400",
|
||||
style: "normal",
|
||||
},
|
||||
{
|
||||
path: "./america-medium.woff2",
|
||||
weight: "500",
|
||||
style: "normal",
|
||||
},
|
||||
],
|
||||
display: "swap",
|
||||
display: "fallback",
|
||||
variable: "--font-america",
|
||||
});
|
||||
|
||||
export const americaFontVariable = america.variable;
|
||||
|
||||
37
public/llms.txt
Normal file
37
public/llms.txt
Normal file
@@ -0,0 +1,37 @@
|
||||
# OpenMonetis
|
||||
|
||||
> OpenMonetis is a self-hosted personal finance web app for manual financial control. It helps users manage accounts, cards, invoices, budgets, notes, reports, attachments, and AI-generated insights. The product UI is in Brazilian Portuguese, the codebase uses English folder and import names, and there is no hosted SaaS version.
|
||||
>
|
||||
> **Stack:** Next.js 16, React 19, PostgreSQL, Drizzle ORM, Better Auth, Tailwind CSS 4, shadcn/ui. Package manager: pnpm. Linter: Biome.
|
||||
|
||||
OpenMonetis is meant to be deployed by the user on their own machine or server.
|
||||
There is no Open Finance or automatic bank synchronization.
|
||||
Transactions can be entered manually or imported from OFX and XLS/XLSX files.
|
||||
Attachments are optional and require S3-compatible storage.
|
||||
The public website is mainly a landing page; the main technical documentation lives in the GitHub repository.
|
||||
|
||||
## Docs
|
||||
|
||||
- [Landing page](/): Public homepage and high-level product overview
|
||||
- [README](https://github.com/felipegcoutinho/openmonetis/blob/main/README.md): Main project documentation covering features, installation, Docker, environment variables, architecture, contributing, and license
|
||||
- [CHANGELOG](https://github.com/felipegcoutinho/openmonetis/blob/main/CHANGELOG.md): Release history and notable changes
|
||||
- [LICENSE](https://github.com/felipegcoutinho/openmonetis/blob/main/LICENSE): CC BY-NC-SA 4.0 license terms
|
||||
|
||||
## Setup
|
||||
|
||||
- [Setup script](https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/setup.mjs): Interactive installer for local or self-hosted setup
|
||||
- [Environment example](https://github.com/felipegcoutinho/openmonetis/blob/main/.env.example): Required and optional environment variables
|
||||
- [Docker Compose](https://github.com/felipegcoutinho/openmonetis/blob/main/docker-compose.yml): Local app and PostgreSQL stack definition
|
||||
|
||||
## Architecture
|
||||
|
||||
- [CLAUDE.md](https://github.com/felipegcoutinho/openmonetis/blob/main/CLAUDE.md): Project architecture, naming rules, query rules, and feature checklist
|
||||
|
||||
## Optional
|
||||
|
||||
- [robots.txt](/robots.txt): Crawl policy for the public site
|
||||
|
||||
## Related Projects
|
||||
|
||||
- [OpenMonetis Companion](https://github.com/felipegcoutinho/openmonetis-companion): Android app that captures bank notifications and sends them to the OpenMonetis inbox for review
|
||||
|
||||
28
public/pdf.worker.min.mjs
Normal file
28
public/pdf.worker.min.mjs
Normal file
File diff suppressed because one or more lines are too long
@@ -1,9 +1,19 @@
|
||||
import { LoginForm } from "@/features/auth/components/login-form";
|
||||
import { Logo } from "@/shared/components/logo";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
|
||||
<div className="w-full max-w-sm md:max-w-5xl">
|
||||
<div className="relative flex min-h-svh flex-col items-center justify-center overflow-hidden bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<div className="absolute -right-32 -top-32 h-72 w-72 rounded-full bg-primary/10 blur-3xl" />
|
||||
<div className="absolute -bottom-32 -left-32 h-72 w-72 rounded-full bg-primary/7 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative mb-6 flex md:hidden">
|
||||
<Logo variant="compact" colorIcon />
|
||||
</div>
|
||||
|
||||
<div className="relative w-full max-w-sm md:max-w-5xl">
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { SignupForm } from "@/features/auth/components/signup-form";
|
||||
import { Logo } from "@/shared/components/logo";
|
||||
|
||||
export default function Page() {
|
||||
export default function SignupPage() {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
|
||||
<div className="w-full max-w-sm md:max-w-5xl">
|
||||
<div className="relative flex min-h-svh flex-col items-center justify-center overflow-hidden bg-linear-to-b from-background via-background to-muted/20 px-5 py-8 md:px-8 md:py-10">
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<div className="absolute -right-32 -top-32 h-72 w-72 rounded-full bg-primary/10 blur-3xl" />
|
||||
<div className="absolute -bottom-32 -left-32 h-72 w-72 rounded-full bg-primary/7 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative mb-6 flex md:hidden">
|
||||
<Logo variant="compact" colorIcon />
|
||||
</div>
|
||||
|
||||
<div className="relative w-full max-w-sm md:max-w-5xl">
|
||||
<SignupForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RiPencilLine } from "@remixicon/react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import { AccountDialog } from "@/features/accounts/components/account-dialog";
|
||||
import { AccountStatementCard } from "@/features/accounts/components/account-statement-card";
|
||||
import type { Account } from "@/features/accounts/components/types";
|
||||
@@ -42,6 +43,7 @@ const capitalize = (value: string) =>
|
||||
value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
|
||||
|
||||
export default async function Page({ params, searchParams }: PageProps) {
|
||||
await connection();
|
||||
const { accountId } = await params;
|
||||
const userId = await getUserId();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
@@ -190,6 +192,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
allowCreate={false}
|
||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { connection } from "next/server";
|
||||
import { AccountsPage } from "@/features/accounts/components/accounts-page";
|
||||
import { fetchAllAccountsForUser } from "@/features/accounts/queries";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
|
||||
export default async function Page() {
|
||||
await connection();
|
||||
const userId = await getUserId();
|
||||
const { activeAccounts, archivedAccounts, logoOptions } =
|
||||
await fetchAllAccountsForUser(userId);
|
||||
|
||||
38
src/app/(dashboard)/attachments/loading.tsx
Normal file
38
src/app/(dashboard)/attachments/loading.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||
|
||||
export default function AnexosLoading() {
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<div className="w-full space-y-6">
|
||||
{/* Header */}
|
||||
<Skeleton className="h-10 w-40 rounded-md bg-foreground/10" />
|
||||
|
||||
{/* Month navigation */}
|
||||
<Skeleton className="h-10 w-64 rounded-md bg-foreground/10" />
|
||||
|
||||
{/* Count */}
|
||||
<Skeleton className="h-4 w-20 rounded-md bg-foreground/10" />
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col overflow-hidden rounded-lg border"
|
||||
>
|
||||
<Skeleton className="aspect-square w-full bg-foreground/10" />
|
||||
<div className="space-y-1.5 p-2.5">
|
||||
<Skeleton className="h-3 w-3/4 rounded bg-foreground/10" />
|
||||
<Skeleton className="h-3 w-full rounded bg-foreground/10" />
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-3 w-16 rounded bg-foreground/10" />
|
||||
<Skeleton className="h-3 w-12 rounded bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
36
src/app/(dashboard)/attachments/page.tsx
Normal file
36
src/app/(dashboard)/attachments/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { connection } from "next/server";
|
||||
import { AttachmentsPage } from "@/features/attachments/components/attachments-page";
|
||||
import { fetchAttachmentsForPeriod } from "@/features/attachments/queries";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
import { parsePeriodParam } from "@/shared/utils/period";
|
||||
|
||||
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: PageSearchParams;
|
||||
};
|
||||
|
||||
const getSingleParam = (
|
||||
params: Record<string, string | string[] | undefined> | undefined,
|
||||
key: string,
|
||||
) => {
|
||||
const value = params?.[key];
|
||||
if (!value) return null;
|
||||
return Array.isArray(value) ? (value[0] ?? null) : value;
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
await connection();
|
||||
const userId = await getUserId();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||
const { period } = parsePeriodParam(periodoParam);
|
||||
|
||||
const attachments = await fetchAttachmentsForPeriod(userId, period);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<AttachmentsPage attachments={attachments} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { connection } from "next/server";
|
||||
import { BudgetsPage } from "@/features/budgets/components/budgets-page";
|
||||
import { fetchBudgetsForUser } from "@/features/budgets/queries";
|
||||
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||
@@ -23,6 +24,7 @@ const capitalize = (value: string) =>
|
||||
value.length === 0 ? value : value[0]?.toUpperCase() + value.slice(1);
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
await connection();
|
||||
const userId = await getUserId();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { connection } from "next/server";
|
||||
import { MonthlyCalendar } from "@/features/calendar/components/monthly-calendar";
|
||||
import { fetchCalendarData } from "@/features/calendar/queries";
|
||||
import {
|
||||
@@ -16,6 +17,7 @@ type PageProps = {
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
await connection();
|
||||
const userId = await getUserId();
|
||||
const resolvedParams = searchParams ? await searchParams : undefined;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RiPencilLine } from "@remixicon/react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import type { FinancialAccount } from "@/db/schema";
|
||||
import { CardDialog } from "@/features/cards/components/card-dialog";
|
||||
import type { Card } from "@/features/cards/components/types";
|
||||
@@ -39,6 +40,7 @@ type PageProps = {
|
||||
};
|
||||
|
||||
export default async function Page({ params, searchParams }: PageProps) {
|
||||
await connection();
|
||||
const { cardId } = await params;
|
||||
const userId = await getUserId();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
@@ -202,6 +204,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
allowCreate
|
||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||
defaultCardId={card.id}
|
||||
defaultPaymentMethod="Cartão de crédito"
|
||||
lockCardSelection
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { connection } from "next/server";
|
||||
import { CardsPage } from "@/features/cards/components/cards-page";
|
||||
import { fetchAllCardsForUser } from "@/features/cards/queries";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
|
||||
export default async function Page() {
|
||||
await connection();
|
||||
const userId = await getUserId();
|
||||
const { activeCards, archivedCards, accounts, logoOptions } =
|
||||
await fetchAllCardsForUser(userId);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import { CategoryDetailHeader } from "@/features/categories/components/category-detail-header";
|
||||
import { fetchCategoryDetails } from "@/features/dashboard/categories/category-details-queries";
|
||||
import { fetchUserPreferences } from "@/features/settings/queries";
|
||||
@@ -32,6 +33,7 @@ const getSingleParam = (
|
||||
};
|
||||
|
||||
export default async function Page({ params, searchParams }: PageProps) {
|
||||
await connection();
|
||||
const { categoryId } = await params;
|
||||
const userId = await getUserId();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
@@ -99,6 +101,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
allowCreate={true}
|
||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { connection } from "next/server";
|
||||
import { fetchCategoryHistory } from "@/features/dashboard/categories/category-history-queries";
|
||||
import { CategoryHistoryWidget } from "@/features/dashboard/components/category-history-widget";
|
||||
import { getUser } from "@/shared/lib/auth/server";
|
||||
import { getCurrentPeriod } from "@/shared/utils/period";
|
||||
|
||||
export default async function HistoricoCategoriasPage() {
|
||||
await connection();
|
||||
const user = await getUser();
|
||||
const currentPeriod = getCurrentPeriod();
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { connection } from "next/server";
|
||||
import { CategoriesPage } from "@/features/categories/components/categories-page";
|
||||
import { fetchCategoriesForUser } from "@/features/categories/queries";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
|
||||
export default async function Page() {
|
||||
await connection();
|
||||
const userId = await getUserId();
|
||||
const categories = await fetchCategoriesForUser(userId);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { connection } from "next/server";
|
||||
import { DashboardGridEditable } from "@/features/dashboard/components/dashboard-grid-editable";
|
||||
import { DashboardMetricsCards } from "@/features/dashboard/components/dashboard-metrics-cards";
|
||||
import { DashboardWelcome } from "@/features/dashboard/components/dashboard-welcome";
|
||||
@@ -14,6 +15,7 @@ type PageProps = {
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
await connection();
|
||||
const user = await getUser();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { connection } from "next/server";
|
||||
import { InboxPage } from "@/features/inbox/components/inbox-page";
|
||||
import {
|
||||
type ResolvedInboxSearchParams,
|
||||
@@ -31,6 +32,7 @@ const EMPTY_DIALOG_DATA = {
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
await connection();
|
||||
const userId = await getUserId();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const activeStatus = resolveInboxStatus(resolvedSearchParams);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { connection } from "next/server";
|
||||
import { InsightsPage } from "@/features/insights/components/insights-page";
|
||||
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||
import { parsePeriodParam } from "@/shared/utils/period";
|
||||
@@ -18,6 +19,7 @@ const getSingleParam = (
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
await connection();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { connection } from "next/server";
|
||||
import { fetchDashboardNavbarData } from "@/features/dashboard/navbar-queries";
|
||||
import { AppNavbar } from "@/shared/components/navigation/navbar/app-navbar";
|
||||
import { PrivacyProvider } from "@/shared/components/providers/privacy-provider";
|
||||
@@ -9,6 +10,7 @@ export default async function DashboardLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
await connection();
|
||||
const session = await getUserSession();
|
||||
const navbarData = await fetchDashboardNavbarData(session.user.id);
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { connection } from "next/server";
|
||||
import { NotesPage } from "@/features/notes/components/notes-page";
|
||||
import { fetchAllNotesForUser } from "@/features/notes/queries";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
|
||||
export default async function Page() {
|
||||
await connection();
|
||||
const userId = await getUserId();
|
||||
const { activeNotes, archivedNotes } = await fetchAllNotesForUser(userId);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
RiWallet3Line,
|
||||
} from "@remixicon/react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import { PayerCardUsageCard } from "@/features/payers/components/details/payer-card-usage-card";
|
||||
import { PayerHeaderCard } from "@/features/payers/components/details/payer-header-card";
|
||||
import { PayerHistoryCard } from "@/features/payers/components/details/payer-history-card";
|
||||
@@ -91,6 +92,7 @@ const createEmptySlugMaps = (): SlugMaps => ({
|
||||
type OptionSet = ReturnType<typeof buildOptionSets>;
|
||||
|
||||
export default async function Page({ params, searchParams }: PageProps) {
|
||||
await connection();
|
||||
const { payerId } = await params;
|
||||
const userId = await getUserId();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
@@ -390,6 +392,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
allowCreate={canEdit}
|
||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||
importPayerOptions={loggedUserOptionSets?.payerOptions}
|
||||
importSplitPayerOptions={loggedUserOptionSets?.splitPayerOptions}
|
||||
importDefaultPayerId={loggedUserOptionSets?.defaultPayerId}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { connection } from "next/server";
|
||||
import { PayersPage } from "@/features/payers/components/payers-page";
|
||||
import { fetchPayersForUser } from "@/features/payers/queries";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
|
||||
export default async function Page() {
|
||||
await connection();
|
||||
const userId = await getUserId();
|
||||
const { payers, avatarOptions } = await fetchPayersForUser(userId);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RiBankCard2Line } from "@remixicon/react";
|
||||
import { connection } from "next/server";
|
||||
import { fetchCartoesReportData } from "@/features/reports/cards-report-queries";
|
||||
import { CardCategoryBreakdown } from "@/features/reports/components/cards/card-category-breakdown";
|
||||
import { CardInvoiceStatus } from "@/features/reports/components/cards/card-invoice-status";
|
||||
@@ -28,6 +29,7 @@ const getSingleParam = (
|
||||
export default async function RelatorioCartoesPage({
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
await connection();
|
||||
const user = await getUser();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import type { Category } from "@/db/schema";
|
||||
import { fetchCategoryChartData } from "@/features/reports/category-chart-queries";
|
||||
import { fetchCategoryReport } from "@/features/reports/category-report-queries";
|
||||
@@ -29,6 +30,7 @@ const getSingleParam = (
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
await connection();
|
||||
// Get authenticated user
|
||||
const userId = await getUserId();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { connection } from "next/server";
|
||||
import { EstablishmentsList } from "@/features/reports/components/establishments/establishments-list";
|
||||
import { HighlightsCards } from "@/features/reports/components/establishments/highlights-cards";
|
||||
import { PeriodFilterButtons } from "@/features/reports/components/establishments/period-filter";
|
||||
@@ -36,6 +37,7 @@ const validatePeriodFilter = (value: string | null): PeriodFilter => {
|
||||
export default async function TopEstabelecimentosPage({
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
await connection();
|
||||
const user = await getUser();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { connection } from "next/server";
|
||||
import { InstallmentAnalysisPage } from "@/features/dashboard/components/installment-analysis/installment-analysis-page";
|
||||
import { fetchInstallmentAnalysis } from "@/features/dashboard/expenses/installment-analysis-queries";
|
||||
import { getUser } from "@/shared/lib/auth/server";
|
||||
|
||||
export default async function Page() {
|
||||
await connection();
|
||||
const user = await getUser();
|
||||
const data = await fetchInstallmentAnalysis(user.id);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RiArrowRightSLine } from "@remixicon/react";
|
||||
import { RiAndroidLine, RiArrowRightSLine } from "@remixicon/react";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
|
||||
import { CompanionTab } from "@/features/settings/components/companion-tab";
|
||||
import { DeleteAccountForm } from "@/features/settings/components/delete-account-form";
|
||||
@@ -11,6 +12,7 @@ import { UpdateNameForm } from "@/features/settings/components/update-name-form"
|
||||
import { UpdatePasswordForm } from "@/features/settings/components/update-password-form";
|
||||
import { fetchSettingsPageData } from "@/features/settings/queries";
|
||||
import { Card } from "@/shared/components/ui/card";
|
||||
import { Separator } from "@/shared/components/ui/separator";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
import { auth } from "@/shared/lib/auth/config";
|
||||
|
||||
export default async function Page() {
|
||||
await connection();
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
@@ -64,12 +67,13 @@ export default async function Page() {
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-1">Preferências</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
<h2 className="text-xl font-medium mb-1">Preferências</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Personalize sua experiência no OpenMonetis ajustando as
|
||||
configurações de acordo com suas necessidades.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<PreferencesForm
|
||||
statementNoteAsColumn={
|
||||
userPreferences?.statementNoteAsColumn ?? false
|
||||
@@ -77,25 +81,46 @@ export default async function Page() {
|
||||
transactionsColumnOrder={
|
||||
userPreferences?.transactionsColumnOrder ?? null
|
||||
}
|
||||
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="companion" className="mt-4">
|
||||
<CompanionTab tokens={userApiTokens} />
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h2 className="text-xl font-medium">OpenMonetis Companion</h2>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-0.5 text-xs font-medium text-success dark:bg-success/10">
|
||||
<RiAndroidLine className="h-3 w-3" />
|
||||
Android
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Capture notificações de transações dos seus apps de banco
|
||||
(Nubank, Itaú, Bradesco, Inter, C6 e outros) e envie para sua
|
||||
caixa de entrada.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<CompanionTab tokens={userApiTokens} />
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="nome" className="mt-4">
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-1">Alterar nome</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
<h2 className="text-xl font-medium mb-1">Alterar nome</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Atualize como seu nome aparece no OpenMonetis. Esse nome pode
|
||||
ser exibido em diferentes seções do app e em comunicações.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<UpdateNameForm currentName={userName} />
|
||||
</div>
|
||||
</Card>
|
||||
@@ -105,12 +130,13 @@ export default async function Page() {
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-1">Alterar senha</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
<h2 className="text-xl font-medium mb-1">Alterar senha</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Defina uma nova senha para sua conta. Guarde-a em local
|
||||
seguro.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<UpdatePasswordForm authProvider={authProvider} />
|
||||
</div>
|
||||
</Card>
|
||||
@@ -120,12 +146,13 @@ export default async function Page() {
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-1">Passkeys</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
<h2 className="text-xl font-medium mb-1">Passkeys</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Passkeys permitem login sem senha, usando biometria (Face ID,
|
||||
Touch ID, Windows Hello) ou chaves de segurança.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<PasskeysForm />
|
||||
</div>
|
||||
</Card>
|
||||
@@ -135,13 +162,14 @@ export default async function Page() {
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-1">Alterar e-mail</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
<h2 className="text-xl font-medium mb-1">Alterar e-mail</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Atualize o e-mail associado à sua conta. Você precisará
|
||||
confirmar os links enviados para o novo e também para o e-mail
|
||||
atual (quando aplicável) para concluir a alteração.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<UpdateEmailForm
|
||||
currentEmail={userEmail}
|
||||
authProvider={authProvider}
|
||||
@@ -154,14 +182,15 @@ export default async function Page() {
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-1 text-destructive">
|
||||
<h2 className="text-xl font-medium mb-1 text-destructive">
|
||||
Ações perigosas
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Você pode zerar os dados do OpenMonetis e manter seu acesso,
|
||||
ou excluir sua conta inteira de forma irreversível.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<DeleteAccountForm />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { connection } from "next/server";
|
||||
import { ImportPage } from "@/features/transactions/components/import/import-page";
|
||||
import {
|
||||
buildOptionSets,
|
||||
@@ -7,6 +8,7 @@ import { fetchTransactionFilterSources } from "@/features/transactions/queries";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
|
||||
export default async function Page() {
|
||||
await connection();
|
||||
const userId = await getUserId();
|
||||
const filterSources = await fetchTransactionFilterSources(userId);
|
||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { connection } from "next/server";
|
||||
import { fetchUserPreferences } from "@/features/settings/queries";
|
||||
import { TransactionsPage } from "@/features/transactions/components/page/transactions-page";
|
||||
import {
|
||||
@@ -27,6 +28,7 @@ type PageProps = {
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
await connection();
|
||||
const userId = await getUserId();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
|
||||
@@ -102,6 +104,7 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
}}
|
||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -126,7 +126,7 @@ export default async function Page() {
|
||||
Projeto Open Source
|
||||
</Badge>
|
||||
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight">
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-medium tracking-tight">
|
||||
Suas finanças,
|
||||
<span className="text-primary"> do seu jeito</span>
|
||||
</h1>
|
||||
@@ -207,7 +207,7 @@ export default async function Page() {
|
||||
className="flex flex-col items-center text-center gap-1.5"
|
||||
>
|
||||
<Icon className="size-5" style={{ color: colorVar }} />
|
||||
<span className="text-2xl md:text-3xl font-bold">
|
||||
<span className="text-2xl md:text-3xl font-medium">
|
||||
{value}
|
||||
</span>
|
||||
<span className="text-xs md:text-sm text-muted-foreground">
|
||||
@@ -229,7 +229,7 @@ export default async function Page() {
|
||||
<Badge variant="outline" className="mb-4">
|
||||
Conheça as telas
|
||||
</Badge>
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
||||
Veja o que você pode fazer
|
||||
</h2>
|
||||
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
||||
@@ -254,7 +254,7 @@ export default async function Page() {
|
||||
<Badge variant="outline" className="mb-4">
|
||||
O que tem aqui
|
||||
</Badge>
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
||||
Funcionalidades que importam
|
||||
</h2>
|
||||
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
||||
@@ -282,7 +282,7 @@ export default async function Page() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-base md:text-lg mb-1.5 md:mb-2">
|
||||
<h3 className="font-medium text-base md:text-lg mb-1.5 md:mb-2">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
@@ -346,7 +346,7 @@ export default async function Page() {
|
||||
<RiSmartphoneLine className="size-3.5 mr-1" />
|
||||
Mobile
|
||||
</Badge>
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
||||
Use o OpenMonetis no celular sem perder o fluxo
|
||||
</h2>
|
||||
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
||||
@@ -384,7 +384,7 @@ export default async function Page() {
|
||||
<RiSmartphoneLine className="size-3.5 mr-1" />
|
||||
PWA instalável
|
||||
</Badge>
|
||||
<h3 className="text-2xl md:text-3xl font-bold tracking-tight mb-3">
|
||||
<h3 className="text-2xl md:text-3xl font-medium tracking-tight mb-3">
|
||||
Leve o OpenMonetis para a tela inicial
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-6 leading-relaxed">
|
||||
@@ -430,7 +430,7 @@ export default async function Page() {
|
||||
Companion Android
|
||||
</Badge>
|
||||
</div>
|
||||
<h3 className="text-2xl md:text-3xl font-bold tracking-tight mb-3">
|
||||
<h3 className="text-2xl md:text-3xl font-medium tracking-tight mb-3">
|
||||
Capture, envie e revise no mesmo fluxo
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-6 leading-relaxed">
|
||||
@@ -441,7 +441,7 @@ export default async function Page() {
|
||||
{companionSteps.map((step, index) => (
|
||||
<li key={step.title} className="flex items-start gap-3">
|
||||
<span
|
||||
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-bold"
|
||||
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: `color-mix(in oklch, ${step.colorVar} 14%, transparent)`,
|
||||
color: step.colorVar,
|
||||
@@ -529,7 +529,7 @@ export default async function Page() {
|
||||
<Badge variant="outline" className="mb-4">
|
||||
Stack técnica
|
||||
</Badge>
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
||||
O que roda por baixo
|
||||
</h2>
|
||||
<p className="text-base md:text-lg text-muted-foreground max-w-2xl mx-auto px-4 sm:px-0">
|
||||
@@ -556,7 +556,7 @@ export default async function Page() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-base md:text-lg mb-1.5 md:mb-2">
|
||||
<h3 className="font-medium text-base md:text-lg mb-1.5 md:mb-2">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2 md:mb-3">
|
||||
@@ -582,7 +582,7 @@ export default async function Page() {
|
||||
<Badge variant="outline" className="mb-4">
|
||||
Como usar
|
||||
</Badge>
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
||||
Rode no seu computador
|
||||
</h2>
|
||||
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
|
||||
@@ -617,7 +617,7 @@ export default async function Page() {
|
||||
<Badge variant="outline" className="mb-4">
|
||||
Para quem é?
|
||||
</Badge>
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
||||
Feito para quem gosta de controle
|
||||
</h2>
|
||||
<p className="text-base md:text-lg text-muted-foreground px-4 sm:px-0">
|
||||
@@ -644,7 +644,7 @@ export default async function Page() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">{item.title}</h3>
|
||||
<h3 className="font-medium mb-1">{item.title}</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
{item.description}
|
||||
</p>
|
||||
@@ -664,7 +664,7 @@ export default async function Page() {
|
||||
<div className="max-w-8xl mx-auto px-4">
|
||||
<AnimateOnScroll>
|
||||
<div className="mx-auto max-w-4xl rounded-2xl border bg-card px-8 py-12 md:py-16 text-center">
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight mb-3 md:mb-4">
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight mb-3 md:mb-4">
|
||||
Pronto para testar?
|
||||
</h2>
|
||||
<p className="text-base md:text-lg text-muted-foreground mb-6 md:mb-8">
|
||||
@@ -715,7 +715,7 @@ export default async function Page() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3 md:mb-4">Projeto</h3>
|
||||
<h3 className="font-medium mb-3 md:mb-4">Projeto</h3>
|
||||
<ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground">
|
||||
<li>
|
||||
<Link
|
||||
@@ -749,7 +749,7 @@ export default async function Page() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3 md:mb-4">Companion</h3>
|
||||
<h3 className="font-medium mb-3 md:mb-4">Companion</h3>
|
||||
<ul className="space-y-2.5 md:space-y-3 text-sm text-muted-foreground">
|
||||
<li>
|
||||
<Link
|
||||
|
||||
54
src/app/api/attachments/[attachmentId]/presign/route.ts
Normal file
54
src/app/api/attachments/[attachmentId]/presign/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { attachments } from "@/db/schema";
|
||||
import { getOptionalUserSession } from "@/shared/lib/auth/server";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
|
||||
|
||||
const PRIVATE_RESPONSE_HEADERS = {
|
||||
"Cache-Control": "private, no-store",
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ attachmentId: string }> },
|
||||
) {
|
||||
const [session, { attachmentId }] = await Promise.all([
|
||||
getOptionalUserSession(),
|
||||
params,
|
||||
]);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "Não autenticado" },
|
||||
{ status: 401, headers: PRIVATE_RESPONSE_HEADERS },
|
||||
);
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
const [row] = await db
|
||||
.select({ fileKey: attachments.fileKey })
|
||||
.from(attachments)
|
||||
.where(
|
||||
and(eq(attachments.id, attachmentId), eq(attachments.userId, userId)),
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
return NextResponse.json(
|
||||
{ error: "Not found" },
|
||||
{
|
||||
status: 404,
|
||||
headers: PRIVATE_RESPONSE_HEADERS,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const url = await createPresignedGetUrl(row.fileKey);
|
||||
return NextResponse.json(
|
||||
{ url },
|
||||
{
|
||||
headers: PRIVATE_RESPONSE_HEADERS,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import {
|
||||
extractBearerToken,
|
||||
hashToken,
|
||||
refreshAccessToken,
|
||||
verifyJwt,
|
||||
} from "@/shared/lib/auth/api-token";
|
||||
import { db } from "@/shared/lib/db";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Extrair refresh token do header
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
const token = extractBearerToken(authHeader);
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: "Refresh token não fornecido" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validar refresh token
|
||||
const payload = verifyJwt(token);
|
||||
|
||||
if (!payload || payload.type !== "api_refresh") {
|
||||
return NextResponse.json(
|
||||
{ error: "Refresh token inválido ou expirado" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Verificar se token não foi revogado
|
||||
const tokenRecord = await db.query.apiTokens.findFirst({
|
||||
where: and(
|
||||
eq(apiTokens.id, payload.tokenId),
|
||||
eq(apiTokens.userId, payload.sub),
|
||||
isNull(apiTokens.revokedAt),
|
||||
gt(apiTokens.expiresAt, new Date()),
|
||||
),
|
||||
});
|
||||
|
||||
if (!tokenRecord) {
|
||||
return NextResponse.json(
|
||||
{ error: "Token revogado ou não encontrado" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Gerar novo access token
|
||||
const result = refreshAccessToken(token);
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json(
|
||||
{ error: "Não foi possível renovar o token" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Atualizar hash do token e último uso
|
||||
await db
|
||||
.update(apiTokens)
|
||||
.set({
|
||||
tokenHash: hashToken(result.accessToken),
|
||||
lastUsedAt: new Date(),
|
||||
lastUsedIp:
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
null,
|
||||
expiresAt: result.expiresAt,
|
||||
})
|
||||
.where(eq(apiTokens.id, payload.tokenId));
|
||||
|
||||
return NextResponse.json({
|
||||
accessToken: result.accessToken,
|
||||
expiresAt: result.expiresAt.toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[API] Error refreshing device token:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Erro ao renovar token" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import {
|
||||
generateTokenPair,
|
||||
getTokenPrefix,
|
||||
hashToken,
|
||||
} from "@/shared/lib/auth/api-token";
|
||||
import { auth } from "@/shared/lib/auth/config";
|
||||
import { db } from "@/shared/lib/db";
|
||||
|
||||
const createTokenSchema = z.object({
|
||||
name: z.string().min(1, "Nome é obrigatório").max(100, "Nome muito longo"),
|
||||
deviceId: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Verificar autenticação via sessão web
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Validar body
|
||||
const body = await request.json();
|
||||
const { name, deviceId } = createTokenSchema.parse(body);
|
||||
|
||||
// Gerar par de tokens
|
||||
const { accessToken, refreshToken, tokenId, expiresAt } = generateTokenPair(
|
||||
session.user.id,
|
||||
deviceId,
|
||||
);
|
||||
|
||||
// Salvar hash do token no banco
|
||||
await db.insert(apiTokens).values({
|
||||
id: tokenId,
|
||||
userId: session.user.id,
|
||||
name,
|
||||
tokenHash: hashToken(accessToken),
|
||||
tokenPrefix: getTokenPrefix(accessToken),
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// Retornar tokens (mostrados apenas uma vez)
|
||||
return NextResponse.json(
|
||||
{
|
||||
accessToken,
|
||||
refreshToken,
|
||||
tokenId,
|
||||
name,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
message:
|
||||
"Token criado com sucesso. Guarde-o em local seguro, ele não será mostrado novamente.",
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.issues[0]?.message ?? "Dados inválidos" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
console.error("[API] Error creating device token:", error);
|
||||
return NextResponse.json({ error: "Erro ao criar token" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { connection, NextResponse } from "next/server";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import { auth } from "@/shared/lib/auth/config";
|
||||
import { db } from "@/shared/lib/db";
|
||||
@@ -10,16 +10,19 @@ interface RouteParams {
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, { params }: RouteParams) {
|
||||
await connection();
|
||||
|
||||
const { tokenId } = await params;
|
||||
|
||||
// Verificar autenticação via sessão web
|
||||
const requestHeaders = new Headers(await headers());
|
||||
const session = await auth.api.getSession({ headers: requestHeaders });
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { tokenId } = await params;
|
||||
|
||||
// Verificar autenticação via sessão web
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Verificar se token pertence ao usuário
|
||||
const token = await db.query.apiTokens.findFirst({
|
||||
where: and(
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { connection, NextResponse } from "next/server";
|
||||
import { apiTokens } from "@/db/schema";
|
||||
import { auth } from "@/shared/lib/auth/config";
|
||||
import { db } from "@/shared/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
await connection();
|
||||
|
||||
// Verificar autenticação via sessão web
|
||||
const requestHeaders = new Headers(await headers());
|
||||
const session = await auth.api.getSession({ headers: requestHeaders });
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verificar autenticação via sessão web
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Buscar tokens ativos do usuário
|
||||
const activeTokens = await db
|
||||
.select({
|
||||
|
||||
@@ -17,15 +17,14 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Validar token os_xxx via hash lookup
|
||||
if (!token.startsWith("os_")) {
|
||||
// Validar token opm_xxx via hash
|
||||
if (!token.startsWith("opm_")) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Formato de token inválido" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Hash do token para buscar no DB
|
||||
const tokenHash = hashToken(token);
|
||||
|
||||
// Buscar token no banco
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { version as APP_VERSION } from "@/package.json";
|
||||
import { db } from "@/shared/lib/db";
|
||||
|
||||
/**
|
||||
@@ -20,7 +19,6 @@ export async function GET() {
|
||||
{
|
||||
status: "ok",
|
||||
name: "OpenMonetis",
|
||||
version: APP_VERSION,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 200 },
|
||||
@@ -33,7 +31,6 @@ export async function GET() {
|
||||
{
|
||||
status: "error",
|
||||
name: "OpenMonetis",
|
||||
version: APP_VERSION,
|
||||
timestamp: new Date().toISOString(),
|
||||
message: "Database connection failed",
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, gt, isNull, or } from "drizzle-orm";
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { apiTokens, inboxItems } from "@/db/schema";
|
||||
@@ -48,8 +48,8 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Validar token os_xxx via hash
|
||||
if (!token.startsWith("os_")) {
|
||||
// Validar token opm_xxx via hash
|
||||
if (!token.startsWith("opm_")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Formato de token inválido" },
|
||||
{ status: 401 },
|
||||
@@ -63,7 +63,7 @@ export async function POST(request: Request) {
|
||||
where: and(
|
||||
eq(apiTokens.tokenHash, tokenHash),
|
||||
isNull(apiTokens.revokedAt),
|
||||
or(isNull(apiTokens.expiresAt), gt(apiTokens.expiresAt, new Date())),
|
||||
gt(apiTokens.expiresAt, new Date()),
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, gt, isNull, or } from "drizzle-orm";
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { apiTokens, inboxItems } from "@/db/schema";
|
||||
@@ -41,8 +41,8 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Validar token os_xxx via hash
|
||||
if (!token.startsWith("os_")) {
|
||||
// Validar token opm_xxx via hash
|
||||
if (!token.startsWith("opm_")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Formato de token inválido" },
|
||||
{ status: 401 },
|
||||
@@ -56,7 +56,7 @@ export async function POST(request: Request) {
|
||||
where: and(
|
||||
eq(apiTokens.tokenHash, tokenHash),
|
||||
isNull(apiTokens.revokedAt),
|
||||
or(isNull(apiTokens.expiresAt), gt(apiTokens.expiresAt, new Date())),
|
||||
gt(apiTokens.expiresAt, new Date()),
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
44
src/app/api/insights/saved/route.ts
Normal file
44
src/app/api/insights/saved/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
fetchSavedInsights,
|
||||
savedInsightsPeriodSchema,
|
||||
} from "@/features/insights/queries";
|
||||
import { getOptionalUserSession } from "@/shared/lib/auth/server";
|
||||
|
||||
const PRIVATE_RESPONSE_HEADERS = {
|
||||
"Cache-Control": "private, no-store",
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const period = new URL(request.url).searchParams.get("period") ?? "";
|
||||
const validatedPeriod = savedInsightsPeriodSchema.safeParse(period);
|
||||
|
||||
if (!validatedPeriod.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: validatedPeriod.error.issues[0]?.message ?? "Período inválido.",
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
headers: PRIVATE_RESPONSE_HEADERS,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const session = await getOptionalUserSession();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "Não autenticado" },
|
||||
{ status: 401, headers: PRIVATE_RESPONSE_HEADERS },
|
||||
);
|
||||
}
|
||||
|
||||
const insights = await fetchSavedInsights(
|
||||
session.user.id,
|
||||
validatedPeriod.data,
|
||||
);
|
||||
|
||||
return NextResponse.json(insights, {
|
||||
headers: PRIVATE_RESPONSE_HEADERS,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchTransactionAttachments } from "@/features/transactions/attachment-queries";
|
||||
import { getOptionalUserSession } from "@/shared/lib/auth/server";
|
||||
|
||||
const PRIVATE_RESPONSE_HEADERS = {
|
||||
"Cache-Control": "private, no-store",
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ transactionId: string }> },
|
||||
) {
|
||||
const [session, { transactionId }] = await Promise.all([
|
||||
getOptionalUserSession(),
|
||||
params,
|
||||
]);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "Não autenticado" },
|
||||
{ status: 401, headers: PRIVATE_RESPONSE_HEADERS },
|
||||
);
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
const attachments = await fetchTransactionAttachments(userId, transactionId);
|
||||
|
||||
return NextResponse.json(attachments, {
|
||||
headers: PRIVATE_RESPONSE_HEADERS,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchInstallmentAnticipations } from "@/features/transactions/anticipation-queries";
|
||||
import { getOptionalUserSession } from "@/shared/lib/auth/server";
|
||||
|
||||
const PRIVATE_RESPONSE_HEADERS = {
|
||||
"Cache-Control": "private, no-store",
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ seriesId: string }> },
|
||||
) {
|
||||
try {
|
||||
const [session, { seriesId }] = await Promise.all([
|
||||
getOptionalUserSession(),
|
||||
params,
|
||||
]);
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "Não autenticado" },
|
||||
{ status: 401, headers: PRIVATE_RESPONSE_HEADERS },
|
||||
);
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
const anticipations = await fetchInstallmentAnticipations(userId, seriesId);
|
||||
|
||||
return NextResponse.json(anticipations, {
|
||||
headers: PRIVATE_RESPONSE_HEADERS,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar histórico de antecipações:", error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Erro ao carregar histórico de antecipações.",
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
headers: PRIVATE_RESPONSE_HEADERS,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { QueryProvider } from "@/shared/components/providers/query-provider";
|
||||
import { ThemeProvider } from "@/shared/components/providers/theme-provider";
|
||||
import { Toaster } from "@/shared/components/ui/sonner";
|
||||
import "./globals.css";
|
||||
@@ -20,6 +22,7 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
data-scroll-behavior="smooth"
|
||||
lang="pt-BR"
|
||||
className={`${america.variable} ${america.className} `}
|
||||
suppressHydrationWarning
|
||||
@@ -33,10 +36,12 @@ export default function RootLayout({
|
||||
data-domains="openmonetis.com"
|
||||
/>
|
||||
</head>
|
||||
<body className="subpixel-antialiased" suppressHydrationWarning>
|
||||
<body className="antialiased" suppressHydrationWarning>
|
||||
<ThemeProvider attribute="class" defaultTheme="light">
|
||||
{children}
|
||||
<Toaster position="top-right" />
|
||||
<QueryProvider>
|
||||
<Suspense>{children}</Suspense>
|
||||
<Toaster position="top-right" />
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,35 +1,13 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
const BASE_URL = process.env.PUBLIC_DOMAIN
|
||||
? `https://${process.env.PUBLIC_DOMAIN}`
|
||||
: "https://openmonetis.com";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: [
|
||||
"/dashboard",
|
||||
"/transactions",
|
||||
"/accounts",
|
||||
"/cards",
|
||||
"/categories",
|
||||
"/budgets",
|
||||
"/payers",
|
||||
"/notes",
|
||||
"/insights",
|
||||
"/calendar",
|
||||
"/consultor",
|
||||
"/settings",
|
||||
"/reports",
|
||||
"/inbox",
|
||||
"/login",
|
||||
"/api/",
|
||||
],
|
||||
disallow: "/api/",
|
||||
},
|
||||
],
|
||||
sitemap: `${BASE_URL}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
const BASE_URL = process.env.PUBLIC_DOMAIN
|
||||
? `https://${process.env.PUBLIC_DOMAIN}`
|
||||
? `https://${process.env.PUBLIC_DOMAIN.replace(/^https?:\/\//, "")}`
|
||||
: "https://openmonetis.com";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
|
||||
@@ -135,9 +135,11 @@ export const userPreferences = pgTable("preferencias_usuario", {
|
||||
transactionsColumnOrder: jsonb("lancamentos_column_order").$type<
|
||||
string[] | null
|
||||
>(),
|
||||
attachmentMaxSizeMb: integer("attachment_max_size_mb").notNull().default(50),
|
||||
dashboardWidgets: jsonb("dashboard_widgets").$type<{
|
||||
order: string[];
|
||||
hidden: string[];
|
||||
myAccountsShowExcluded?: boolean;
|
||||
}>(),
|
||||
createdAt: timestamp("created_at", {
|
||||
mode: "date",
|
||||
|
||||
@@ -88,9 +88,7 @@ export function AccountCard({
|
||||
{icon}
|
||||
</div>
|
||||
) : null}
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{accountName}
|
||||
</h2>
|
||||
<h2 className="text-lg font-medium text-foreground">{accountName}</h2>
|
||||
|
||||
{(excludeFromBalance || excludeInitialBalanceFromIncome) && (
|
||||
<Tooltip>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function AccountStatementCard({
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold text-foreground">
|
||||
<h2 className="truncate text-sm font-medium text-foreground">
|
||||
{accountName}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -86,12 +86,12 @@ export function AccountStatementCard({
|
||||
</p>
|
||||
<MoneyValues
|
||||
amount={currentBalance}
|
||||
className="text-3xl leading-none font-semibold tracking-tight sm:text-[2rem]"
|
||||
className="text-3xl leading-none font-medium tracking-tight sm:text-[2rem]"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={getAccountStatusBadgeVariant(status)}
|
||||
className="text-[11px]"
|
||||
className="text-xs"
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
@@ -107,7 +107,7 @@ export function AccountStatementCard({
|
||||
label="Saldo inicial"
|
||||
tooltip="Saldo inicial cadastrado na conta somado aos lançamentos pagos anteriores a este mês."
|
||||
>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{formatCurrency(openingBalance)}
|
||||
</span>
|
||||
</MetaItem>
|
||||
@@ -116,7 +116,7 @@ export function AccountStatementCard({
|
||||
label="Entradas"
|
||||
tooltip="Total de receitas deste mês classificadas como pagas para esta conta."
|
||||
>
|
||||
<span className="text-sm font-semibold text-success">
|
||||
<span className="text-sm font-medium text-success">
|
||||
{formatCurrency(totalIncomes)}
|
||||
</span>
|
||||
</MetaItem>
|
||||
@@ -125,7 +125,7 @@ export function AccountStatementCard({
|
||||
label="Saídas"
|
||||
tooltip="Total de despesas pagas neste mês (considerando divisão entre pagadores)."
|
||||
>
|
||||
<span className="text-sm font-semibold text-destructive">
|
||||
<span className="text-sm font-medium text-destructive">
|
||||
{formatCurrency(totalExpenses)}
|
||||
</span>
|
||||
</MetaItem>
|
||||
@@ -136,7 +136,7 @@ export function AccountStatementCard({
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold",
|
||||
"text-sm font-medium",
|
||||
resultado >= 0 ? "text-success" : "text-destructive",
|
||||
)}
|
||||
>
|
||||
|
||||
209
src/features/attachments/components/attachment-grid-item.tsx
Normal file
209
src/features/attachments/components/attachment-grid-item.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { RiFileLine, RiFilePdf2Line, RiImageLine } from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { AttachmentForPeriod } from "@/features/attachments/queries";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { formatCurrency } from "@/shared/utils/currency";
|
||||
import { formatDate } from "@/shared/utils/date";
|
||||
import { formatBytes } from "@/shared/utils/number";
|
||||
|
||||
interface PdfCanvasProps {
|
||||
url: string;
|
||||
}
|
||||
|
||||
function PdfCanvas({ url }: PdfCanvasProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [locked, setLocked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLocked(false);
|
||||
|
||||
async function render() {
|
||||
const pdfjsLib = await import("pdfjs-dist");
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs";
|
||||
|
||||
let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;
|
||||
try {
|
||||
pdf = await pdfjsLib.getDocument(url).promise;
|
||||
} catch (err) {
|
||||
if ((err as { name?: string }).name === "PasswordException") {
|
||||
if (!cancelled) setLocked(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const page = await pdf.getPage(1);
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || cancelled) return;
|
||||
|
||||
const containerWidth = canvas.parentElement?.offsetWidth ?? 200;
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const scale = containerWidth / viewport.width;
|
||||
const scaled = page.getViewport({ scale });
|
||||
|
||||
canvas.width = scaled.width;
|
||||
canvas.height = scaled.height;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
await page.render({ canvasContext: ctx, canvas, viewport: scaled })
|
||||
.promise;
|
||||
}
|
||||
|
||||
render().catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
if (locked) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-muted/50">
|
||||
<RiFilePdf2Line className="size-12 text-muted-foreground/40" />
|
||||
<span className="text-xs font-medium text-muted-foreground/60">
|
||||
PDF Protegido
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface AttachmentGridItemProps {
|
||||
attachment: AttachmentForPeriod;
|
||||
url?: string;
|
||||
onClick: () => void;
|
||||
onDetails: () => void;
|
||||
isLoadingDetails?: boolean;
|
||||
}
|
||||
|
||||
export function AttachmentGridItem({
|
||||
attachment,
|
||||
url,
|
||||
onClick,
|
||||
onDetails,
|
||||
isLoadingDetails = false,
|
||||
}: AttachmentGridItemProps) {
|
||||
const isPdf = attachment.mimeType === "application/pdf";
|
||||
const isImage = attachment.mimeType.startsWith("image/");
|
||||
const amount = Number.parseFloat(attachment.transactionAmount);
|
||||
|
||||
return (
|
||||
<div className="group flex flex-col overflow-hidden rounded-lg border bg-card transition-all duration-200 hover:border-primary">
|
||||
{/* Thumbnail */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="relative aspect-4/3 w-full border-b overflow-hidden bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset cursor-pointer"
|
||||
>
|
||||
{/* Conteúdo do thumbnail */}
|
||||
{isImage && url && (
|
||||
<Image
|
||||
src={url}
|
||||
alt={attachment.fileName}
|
||||
fill
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, (max-width: 1280px) 25vw, 20vw"
|
||||
unoptimized
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
)}
|
||||
{isImage && !url && (
|
||||
<div className="h-full w-full animate-pulse bg-muted-foreground/10" />
|
||||
)}
|
||||
{isPdf && url && <PdfCanvas url={url} />}
|
||||
{isPdf && !url && (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-red-50 dark:bg-red-950/20">
|
||||
<RiFilePdf2Line className="size-14 text-red-400/60" />
|
||||
</div>
|
||||
)}
|
||||
{!isImage && !isPdf && (
|
||||
<div className="flex h-full w-full items-center justify-center bg-muted">
|
||||
<RiFileLine className="size-14 text-muted-foreground/40" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay no hover */}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors duration-200 group-hover:bg-black/10" />
|
||||
</button>
|
||||
|
||||
{/* Informações */}
|
||||
<div className="flex flex-1 flex-col gap-3 px-4 py-3">
|
||||
{/* Nome do arquivo + tipo */}
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<div className="shrink-0 gap-0.5 text-xs opacity-60">
|
||||
{isPdf && <RiFilePdf2Line className="size-4 text-red-500" />}
|
||||
{isImage && <RiImageLine className="size-4 text-blue-500" />}
|
||||
{!isPdf && !isImage && <RiFileLine className="size-4" />}
|
||||
</div>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="truncate text-sm font-medium leading-tight text-foreground">
|
||||
{attachment.fileName}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
{attachment.fileName}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Data */}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(attachment.purchaseDate)}
|
||||
</span>
|
||||
|
||||
{/* Transação e Valor */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{attachment.transactionName}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{attachment.transactionName}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-sm font-medium tracking-tighter tabular-nums",
|
||||
)}
|
||||
>
|
||||
{formatCurrency(amount)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Footer: Tamanho + Botão Detalhes */}
|
||||
<div className="mt-auto flex items-center justify-between border-t pt-3">
|
||||
<span className="text-xs font-medium text-muted-foreground/70">
|
||||
{formatBytes(attachment.fileSize)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDetails}
|
||||
disabled={isLoadingDetails}
|
||||
className="text-xs font-medium text-muted-foreground/70 underline-offset-2 hover:underline focus-visible:outline-none disabled:opacity-50"
|
||||
>
|
||||
{isLoadingDetails ? "Carregando..." : "Detalhes"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
200
src/features/attachments/components/attachment-preview.tsx
Normal file
200
src/features/attachments/components/attachment-preview.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiArrowLeftSLine,
|
||||
RiArrowRightSLine,
|
||||
RiCloseLine,
|
||||
RiDownloadLine,
|
||||
RiExternalLinkLine,
|
||||
} from "@remixicon/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAttachmentUrlQuery } from "@/features/attachments/hooks/use-attachment-url";
|
||||
import type { AttachmentForPeriod } from "@/features/attachments/queries";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
|
||||
interface AttachmentPreviewProps {
|
||||
attachments: AttachmentForPeriod[];
|
||||
selectedIndex: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AttachmentPreview({
|
||||
attachments,
|
||||
selectedIndex,
|
||||
onClose,
|
||||
}: AttachmentPreviewProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(selectedIndex);
|
||||
const open = selectedIndex >= 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIndex >= 0) setCurrentIndex(selectedIndex);
|
||||
}, [selectedIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "ArrowLeft") setCurrentIndex((i) => Math.max(0, i - 1));
|
||||
if (e.key === "ArrowRight")
|
||||
setCurrentIndex((i) => Math.min(attachments.length - 1, i + 1));
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [open, attachments.length]);
|
||||
|
||||
const attachment = attachments[currentIndex];
|
||||
const attachmentId = attachment?.attachmentId;
|
||||
const {
|
||||
data: previewUrl,
|
||||
isLoading: isPreviewLoading,
|
||||
isError: isPreviewError,
|
||||
} = useAttachmentUrlQuery(attachmentId ?? "", open && Boolean(attachmentId));
|
||||
|
||||
if (!attachment) return null;
|
||||
|
||||
const isPdf = attachment.mimeType === "application/pdf";
|
||||
const isImage = attachment.mimeType.startsWith("image/");
|
||||
const hasPrev = currentIndex > 0;
|
||||
const hasNext = currentIndex < attachments.length - 1;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="flex h-[92vh] w-[min(96vw,1400px)] max-w-none flex-col gap-0 overflow-hidden p-0 sm:p-0"
|
||||
>
|
||||
<DialogHeader className="flex-row items-start justify-between gap-3 border-b px-4 py-3 sm:px-5">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<DialogTitle
|
||||
className="truncate text-sm font-medium"
|
||||
title={attachment.transactionName}
|
||||
>
|
||||
{attachment.transactionName}
|
||||
</DialogTitle>
|
||||
<p
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
title={attachment.fileName}
|
||||
>
|
||||
{attachment.fileName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{attachments.length > 1 && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={!hasPrev}
|
||||
onClick={() => setCurrentIndex((i) => i - 1)}
|
||||
title="Anterior (←)"
|
||||
>
|
||||
<RiArrowLeftSLine className="size-4" />
|
||||
</Button>
|
||||
<span className="select-none text-xs text-muted-foreground tabular-nums">
|
||||
{currentIndex + 1} / {attachments.length}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={!hasNext}
|
||||
onClick={() => setCurrentIndex((i) => i + 1)}
|
||||
title="Próximo (→)"
|
||||
>
|
||||
<RiArrowRightSLine className="size-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={!previewUrl}
|
||||
asChild={!!previewUrl}
|
||||
>
|
||||
{previewUrl ? (
|
||||
<a
|
||||
href={previewUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
download={attachment.fileName}
|
||||
>
|
||||
<RiDownloadLine className="size-4" />
|
||||
</a>
|
||||
) : (
|
||||
<RiDownloadLine className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={!previewUrl}
|
||||
asChild={!!previewUrl}
|
||||
>
|
||||
{previewUrl ? (
|
||||
<a href={previewUrl} target="_blank" rel="noreferrer">
|
||||
<RiExternalLinkLine className="size-4" />
|
||||
</a>
|
||||
) : (
|
||||
<RiExternalLinkLine className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="ghost" size="icon">
|
||||
<RiCloseLine className="size-4" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
{isPreviewLoading && (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{isPreviewError && (
|
||||
<div className="flex h-full w-full items-center justify-center px-6 text-center text-sm text-muted-foreground">
|
||||
Não foi possível carregar a visualização deste anexo.
|
||||
</div>
|
||||
)}
|
||||
{isPdf && previewUrl && (
|
||||
<iframe
|
||||
key={attachment.attachmentId}
|
||||
src={previewUrl}
|
||||
className="h-full w-full border-0 bg-background"
|
||||
title={attachment.fileName}
|
||||
/>
|
||||
)}
|
||||
{isImage && previewUrl && (
|
||||
<div className="flex h-full w-full items-center justify-center bg-black/85 p-4 sm:p-6">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
key={attachment.attachmentId}
|
||||
src={previewUrl}
|
||||
alt={attachment.fileName}
|
||||
className="max-h-full max-w-full rounded-md object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
275
src/features/attachments/components/attachments-page.tsx
Normal file
275
src/features/attachments/components/attachments-page.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiAttachmentLine,
|
||||
RiFilePdf2Line,
|
||||
RiImageLine,
|
||||
} from "@remixicon/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type React from "react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { AttachmentGridItem } from "@/features/attachments/components/attachment-grid-item";
|
||||
import { AttachmentPreview } from "@/features/attachments/components/attachment-preview";
|
||||
import { useAttachmentUrl } from "@/features/attachments/hooks/use-attachment-url";
|
||||
import type { AttachmentForPeriod } from "@/features/attachments/queries";
|
||||
import { fetchTransactionByIdAction } from "@/features/transactions/actions/fetch-by-id";
|
||||
import type { TransactionDialogOptions } from "@/features/transactions/actions/fetch-dialog-options";
|
||||
import { fetchTransactionDialogOptionsAction } from "@/features/transactions/actions/fetch-dialog-options";
|
||||
import { TransactionDetailsDialog } from "@/features/transactions/components/dialogs/transaction-details-dialog";
|
||||
import { TransactionDialog } from "@/features/transactions/components/dialogs/transaction-dialog/transaction-dialog";
|
||||
import type { TransactionItem } from "@/features/transactions/components/types";
|
||||
import { EmptyState } from "@/shared/components/empty-state";
|
||||
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||
import PageDescription from "@/shared/components/page-description";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
type FilterType = "all" | "images" | "pdfs";
|
||||
|
||||
function AttachmentGridItemWithUrl({
|
||||
attachment,
|
||||
onClick,
|
||||
onDetails,
|
||||
isLoadingDetails,
|
||||
}: {
|
||||
attachment: AttachmentForPeriod;
|
||||
onClick: () => void;
|
||||
onDetails: () => void;
|
||||
isLoadingDetails: boolean;
|
||||
}) {
|
||||
const { url, containerRef } = useAttachmentUrl(attachment.attachmentId);
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<AttachmentGridItem
|
||||
attachment={attachment}
|
||||
url={url ?? undefined}
|
||||
onClick={onClick}
|
||||
onDetails={onDetails}
|
||||
isLoadingDetails={isLoadingDetails}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FILTERS: {
|
||||
value: FilterType;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}[] = [
|
||||
{
|
||||
value: "all",
|
||||
label: "Todos",
|
||||
icon: <RiAttachmentLine className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
value: "images",
|
||||
label: "Imagens",
|
||||
icon: <RiImageLine className="size-3.5 text-blue-500" />,
|
||||
},
|
||||
{
|
||||
value: "pdfs",
|
||||
label: "PDFs",
|
||||
icon: <RiFilePdf2Line className="size-3.5 text-red-500" />,
|
||||
},
|
||||
];
|
||||
|
||||
interface AttachmentsPageProps {
|
||||
attachments: AttachmentForPeriod[];
|
||||
}
|
||||
|
||||
export function AttachmentsPage({ attachments }: AttachmentsPageProps) {
|
||||
const router = useRouter();
|
||||
const [filter, setFilter] = useState<FilterType>("all");
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [transactionDetails, setTransactionDetails] =
|
||||
useState<TransactionItem | null>(null);
|
||||
const [loadingTransactionId, setLoadingTransactionId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// Edit dialog state
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [transactionToEdit, setTransactionToEdit] =
|
||||
useState<TransactionItem | null>(null);
|
||||
const [dialogOptions, setDialogOptions] =
|
||||
useState<TransactionDialogOptions | null>(null);
|
||||
|
||||
const filteredAttachments = attachments.filter((a) => {
|
||||
if (filter === "images") return a.mimeType.startsWith("image/");
|
||||
if (filter === "pdfs") return a.mimeType === "application/pdf";
|
||||
return true;
|
||||
});
|
||||
|
||||
const imageCount = attachments.filter((a) =>
|
||||
a.mimeType.startsWith("image/"),
|
||||
).length;
|
||||
const pdfCount = attachments.filter(
|
||||
(a) => a.mimeType === "application/pdf",
|
||||
).length;
|
||||
|
||||
const counts: Record<FilterType, number> = {
|
||||
all: attachments.length,
|
||||
images: imageCount,
|
||||
pdfs: pdfCount,
|
||||
};
|
||||
|
||||
function handleSelect(attachment: AttachmentForPeriod) {
|
||||
const idx = filteredAttachments.findIndex(
|
||||
(a) =>
|
||||
a.attachmentId === attachment.attachmentId &&
|
||||
a.transactionId === attachment.transactionId,
|
||||
);
|
||||
setSelectedIndex(idx);
|
||||
}
|
||||
|
||||
function handleDetails(transactionId: string) {
|
||||
setLoadingTransactionId(transactionId);
|
||||
startTransition(async () => {
|
||||
const transaction = await fetchTransactionByIdAction(transactionId);
|
||||
setLoadingTransactionId(null);
|
||||
if (transaction) setTransactionDetails(transaction);
|
||||
});
|
||||
}
|
||||
|
||||
function handleEdit(transaction: TransactionItem) {
|
||||
setTransactionToEdit(transaction);
|
||||
startTransition(async () => {
|
||||
const options = await fetchTransactionDialogOptionsAction();
|
||||
setDialogOptions(options);
|
||||
setEditOpen(true);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<PageDescription
|
||||
icon={<RiAttachmentLine className="size-5" />}
|
||||
title="Anexos"
|
||||
subtitle="Comprovantes e documentos dos seus lançamentos no mês."
|
||||
/>
|
||||
|
||||
<MonthNavigation />
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
{attachments.length === 0 ? (
|
||||
<div className="flex w-full items-center justify-center py-12">
|
||||
<EmptyState
|
||||
media={<RiAttachmentLine className="size-6 text-primary" />}
|
||||
title="Nenhum anexo neste mês"
|
||||
description="Adicione comprovantes nos seus lançamentos para vê-los aqui."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Header: filtros + contagem */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredAttachments.length}{" "}
|
||||
{filteredAttachments.length === 1 ? "anexo" : "anexos"}
|
||||
{filter !== "all" &&
|
||||
` · ${FILTERS.find((f) => f.value === filter)?.label.toLowerCase()}`}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 rounded-lg border p-1">
|
||||
{FILTERS.map(({ value, label, icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFilter(value);
|
||||
setSelectedIndex(-1);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
filter === value
|
||||
? "bg-primary text-primary-foreground [&_svg]:opacity-100"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className={cn(filter !== value && "opacity-60")}>
|
||||
{icon}
|
||||
</span>
|
||||
{label}{" "}
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
filter === value ? "opacity-80" : "opacity-60",
|
||||
)}
|
||||
>
|
||||
({counts[value]})
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredAttachments.length === 0 ? (
|
||||
<div className="flex w-full items-center justify-center py-12">
|
||||
<EmptyState
|
||||
media={<RiAttachmentLine className="size-6 text-primary" />}
|
||||
title="Nenhum anexo encontrado"
|
||||
description="Não há anexos do tipo selecionado neste mês."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{filteredAttachments.map((attachment) => (
|
||||
<AttachmentGridItemWithUrl
|
||||
key={`${attachment.attachmentId}-${attachment.transactionId}`}
|
||||
attachment={attachment}
|
||||
onClick={() => handleSelect(attachment)}
|
||||
onDetails={() => handleDetails(attachment.transactionId)}
|
||||
isLoadingDetails={
|
||||
isPending &&
|
||||
loadingTransactionId === attachment.transactionId
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AttachmentPreview
|
||||
attachments={filteredAttachments}
|
||||
selectedIndex={selectedIndex}
|
||||
onClose={() => setSelectedIndex(-1)}
|
||||
/>
|
||||
|
||||
<TransactionDetailsDialog
|
||||
open={!!transactionDetails}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setTransactionDetails(null);
|
||||
}}
|
||||
transaction={transactionDetails}
|
||||
onEdit={handleEdit}
|
||||
/>
|
||||
|
||||
{dialogOptions && transactionToEdit && (
|
||||
<TransactionDialog
|
||||
mode="update"
|
||||
open={editOpen}
|
||||
onOpenChange={(open) => {
|
||||
setEditOpen(open);
|
||||
if (!open) {
|
||||
setTransactionToEdit(null);
|
||||
setDialogOptions(null);
|
||||
router.refresh();
|
||||
}
|
||||
}}
|
||||
transaction={transactionToEdit}
|
||||
payerOptions={dialogOptions.payerOptions}
|
||||
splitPayerOptions={dialogOptions.splitPayerOptions}
|
||||
defaultPayerId={dialogOptions.defaultPayerId}
|
||||
accountOptions={dialogOptions.accountOptions}
|
||||
cardOptions={dialogOptions.cardOptions}
|
||||
categoryOptions={dialogOptions.categoryOptions}
|
||||
estabelecimentos={dialogOptions.estabelecimentos}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/features/attachments/hooks/use-attachment-url.ts
Normal file
54
src/features/attachments/hooks/use-attachment-url.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { fetchJson } from "@/shared/lib/fetch-json";
|
||||
|
||||
const ATTACHMENT_URL_STALE_TIME = 4 * 60 * 1000;
|
||||
|
||||
export const attachmentUrlQueryKey = (attachmentId: string) =>
|
||||
["attachments", "url", attachmentId] as const;
|
||||
|
||||
export function useAttachmentUrlQuery(attachmentId: string, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: attachmentUrlQueryKey(attachmentId),
|
||||
queryFn: async () => {
|
||||
const payload = await fetchJson<{ url: string }>(
|
||||
`/api/attachments/${attachmentId}/presign`,
|
||||
);
|
||||
|
||||
return payload.url;
|
||||
},
|
||||
enabled: enabled && Boolean(attachmentId),
|
||||
staleTime: ATTACHMENT_URL_STALE_TIME,
|
||||
gcTime: ATTACHMENT_URL_STALE_TIME * 2,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAttachmentUrl(attachmentId: string) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void attachmentId;
|
||||
setIsVisible(false);
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!entries[0].isIntersecting) return;
|
||||
observer.disconnect();
|
||||
setIsVisible(true);
|
||||
},
|
||||
{ rootMargin: "150px" },
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [attachmentId]);
|
||||
|
||||
const { data: url } = useAttachmentUrlQuery(attachmentId, isVisible);
|
||||
|
||||
return { url: url ?? null, containerRef };
|
||||
}
|
||||
70
src/features/attachments/queries.ts
Normal file
70
src/features/attachments/queries.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { cacheLife, cacheTag } from "next/cache";
|
||||
import {
|
||||
attachments,
|
||||
categories,
|
||||
transactionAttachments,
|
||||
transactions,
|
||||
} from "@/db/schema";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
|
||||
export type AttachmentForPeriod = {
|
||||
attachmentId: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
transactionId: string;
|
||||
transactionName: string;
|
||||
transactionAmount: string;
|
||||
transactionPeriod: string;
|
||||
purchaseDate: Date;
|
||||
categoryName: string | null;
|
||||
categoryIcon: string | null;
|
||||
};
|
||||
|
||||
export async function fetchAttachmentsForPeriod(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<AttachmentForPeriod[]> {
|
||||
"use cache";
|
||||
cacheTag(`dashboard-${userId}`);
|
||||
cacheLife({ revalidate: 3 });
|
||||
|
||||
const adminPayerId = await getAdminPayerId(userId);
|
||||
if (!adminPayerId) return [];
|
||||
|
||||
return db
|
||||
.select({
|
||||
attachmentId: attachments.id,
|
||||
fileName: attachments.fileName,
|
||||
fileSize: attachments.fileSize,
|
||||
mimeType: attachments.mimeType,
|
||||
transactionId: transactions.id,
|
||||
transactionName: transactions.name,
|
||||
transactionAmount: transactions.amount,
|
||||
transactionPeriod: transactions.period,
|
||||
purchaseDate: transactions.purchaseDate,
|
||||
categoryName: categories.name,
|
||||
categoryIcon: categories.icon,
|
||||
})
|
||||
.from(transactionAttachments)
|
||||
.innerJoin(
|
||||
attachments,
|
||||
and(
|
||||
eq(transactionAttachments.attachmentId, attachments.id),
|
||||
eq(attachments.userId, userId),
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
transactions,
|
||||
and(
|
||||
eq(transactionAttachments.transactionId, transactions.id),
|
||||
eq(transactions.userId, userId),
|
||||
eq(transactions.payerId, adminPayerId),
|
||||
eq(transactions.period, period),
|
||||
),
|
||||
)
|
||||
.leftJoin(categories, eq(transactions.categoryId, categories.id))
|
||||
.orderBy(desc(transactions.purchaseDate), desc(attachments.id));
|
||||
}
|
||||
@@ -7,8 +7,8 @@ interface AuthHeaderProps {
|
||||
|
||||
export function AuthHeader({ title, description }: AuthHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2")}>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-card-foreground">
|
||||
<div className={cn("flex flex-col gap-2.5")}>
|
||||
<h1 className="text-2xl font-medium tracking-tight text-card-foreground">
|
||||
{title}
|
||||
</h1>
|
||||
{description ? (
|
||||
|
||||
89
src/features/auth/components/auth-sidebar-invoices-mock.tsx
Normal file
89
src/features/auth/components/auth-sidebar-invoices-mock.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import Image from "next/image";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
import { formatCurrency } from "@/shared/utils/currency";
|
||||
|
||||
type MockInvoice = {
|
||||
cardName: string;
|
||||
logo: string;
|
||||
amount: number;
|
||||
dueLabel: string;
|
||||
};
|
||||
|
||||
const MOCK_INVOICES: MockInvoice[] = [
|
||||
{
|
||||
cardName: "Nubank",
|
||||
logo: "nubank.png",
|
||||
amount: 1898,
|
||||
dueLabel: "Vence hoje",
|
||||
},
|
||||
{
|
||||
cardName: "Itaú",
|
||||
logo: "itau.png",
|
||||
amount: 1923,
|
||||
dueLabel: "Vence amanhã",
|
||||
},
|
||||
];
|
||||
|
||||
function MockInvoiceItem({
|
||||
invoice,
|
||||
divider,
|
||||
}: {
|
||||
invoice: MockInvoice;
|
||||
divider: boolean;
|
||||
}) {
|
||||
const logoSrc = resolveLogoSrc(invoice.logo);
|
||||
|
||||
return (
|
||||
<div className={divider ? "border-b border-border/60" : undefined}>
|
||||
<div className="flex items-center justify-between py-2.5">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 py-0.5">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full">
|
||||
{logoSrc && (
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={`Logo ${invoice.cardName}`}
|
||||
width={36}
|
||||
height={36}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{invoice.cardName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{invoice.dueLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-0.5">
|
||||
<span className="text-sm font-medium tracking-tighter text-foreground">
|
||||
{formatCurrency(invoice.amount)}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-primary">Pagar</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthSidebarInvoicesMock() {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card shadow-sm">
|
||||
<div className="border-b px-4 py-3">
|
||||
<span className="text-sm font-medium text-foreground">Faturas</span>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Resumo das faturas do período
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
{MOCK_INVOICES.map((invoice, index) => (
|
||||
<MockInvoiceItem
|
||||
key={invoice.cardName}
|
||||
invoice={invoice}
|
||||
divider={index < MOCK_INVOICES.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,28 @@
|
||||
import {
|
||||
RiBankCardLine,
|
||||
RiBarChart2Line,
|
||||
RiShieldCheckLine,
|
||||
} from "@remixicon/react";
|
||||
import { Logo } from "@/shared/components/logo";
|
||||
import { DotPattern } from "@/shared/components/ui/dot-pattern";
|
||||
import { AuthSidebarInvoicesMock } from "./auth-sidebar-invoices-mock";
|
||||
|
||||
function FeatureItem({
|
||||
icon: Icon,
|
||||
text,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-black/12">
|
||||
<Icon className="h-3.5 w-3.5 text-black/55" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-black/68">{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthSidebar() {
|
||||
return (
|
||||
@@ -15,6 +38,7 @@ function AuthSidebar() {
|
||||
/>
|
||||
<div className="absolute inset-0 bg-linear-to-br from-white/9 via-transparent to-black/7" />
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-1 flex-col justify-between p-10 lg:p-12">
|
||||
<Logo
|
||||
variant="compact"
|
||||
@@ -22,14 +46,25 @@ function AuthSidebar() {
|
||||
className="opacity-92 [&_img]:brightness-0 [&_img]:saturate-0"
|
||||
/>
|
||||
|
||||
<div className="max-w-sm space-y-4.5">
|
||||
<h2 className="text-[2rem] font-semibold leading-[1.04] tracking-[-0.03em] text-black/84 lg:text-[2.35rem]">
|
||||
Controle suas finanças com clareza e foco diário.
|
||||
</h2>
|
||||
<p className="max-w-2xs text-sm leading-6 text-black/68">
|
||||
Centralize despesas, organize cartões e acompanhe metas mensais em
|
||||
um painel inteligente feito para o seu dia a dia.
|
||||
</p>
|
||||
<div className="flex flex-1 items-center justify-center py-10">
|
||||
<div className="w-full rotate-[1.5deg]">
|
||||
<AuthSidebarInvoicesMock />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<FeatureItem
|
||||
icon={RiBarChart2Line}
|
||||
text="Controle de gastos por categoria"
|
||||
/>
|
||||
<FeatureItem
|
||||
icon={RiBankCardLine}
|
||||
text="Faturas e cartões centralizados"
|
||||
/>
|
||||
<FeatureItem
|
||||
icon={RiShieldCheckLine}
|
||||
text="Seus dados, sem rastreamento"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -125,7 +125,9 @@ export function LoginForm({ className, ...props }: DivProps) {
|
||||
});
|
||||
|
||||
if (passkeyError) {
|
||||
setError((passkeyError.message as string) || "Erro ao entrar com passkey.");
|
||||
setError(
|
||||
(passkeyError.message as string) || "Erro ao entrar com passkey.",
|
||||
);
|
||||
setLoadingPasskey(false);
|
||||
}
|
||||
}
|
||||
@@ -238,7 +240,7 @@ export function LoginForm({ className, ...props }: DivProps) {
|
||||
</a>
|
||||
</FieldDescription>
|
||||
|
||||
<FieldDescription className="text-center text-[13px] text-muted-foreground">
|
||||
<FieldDescription className="text-center text-sm text-muted-foreground">
|
||||
<a href="/" className={authLinkClassName}>
|
||||
Voltar para a página inicial
|
||||
</a>
|
||||
|
||||
@@ -277,7 +277,7 @@ export function SignupForm({ className, ...props }: DivProps) {
|
||||
</a>
|
||||
</FieldDescription>
|
||||
|
||||
<FieldDescription className="text-center text-[13px] text-muted-foreground">
|
||||
<FieldDescription className="text-center text-sm text-muted-foreground">
|
||||
<a href="/" className={authLinkClassName}>
|
||||
Voltar para a página inicial
|
||||
</a>
|
||||
|
||||
@@ -52,7 +52,7 @@ export function BudgetCard({
|
||||
size="lg"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-base font-semibold leading-tight">
|
||||
<h3 className="text-base font-medium leading-tight">
|
||||
{formatCategoryName(budget)}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -244,7 +244,7 @@ export function BudgetDialog({
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Limite atual</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatCurrency(sliderValue)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ export function CalendarGrid({
|
||||
}: CalendarGridProps) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg bg-card drop-shadow-xs border-none">
|
||||
<div className="grid grid-cols-7 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<div className="grid grid-cols-7 text-sm font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{WEEK_DAYS_SHORT.map((dayName) => (
|
||||
<span key={dayName} className="px-3 py-2 text-center text-primary">
|
||||
{dayName}
|
||||
|
||||
@@ -110,9 +110,7 @@ const DayEventPreview = ({ event }: { event: CalendarEvent }) => {
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
{complement ? (
|
||||
<span
|
||||
className={cn("shrink-0 font-semibold", style.accent ?? "text-xs")}
|
||||
>
|
||||
<span className={cn("shrink-0 font-medium", style.accent ?? "text-xs")}>
|
||||
{complement}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -153,7 +151,7 @@ export function DayCell({ day, onSelect, onCreate }: DayCellProps) {
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold leading-none",
|
||||
"text-sm font-medium leading-none",
|
||||
day.isToday
|
||||
? "text-primary-foreground bg-primary size-5 rounded-full flex items-center justify-center"
|
||||
: "text-foreground/90",
|
||||
|
||||
@@ -61,7 +61,7 @@ const renderLancamento = (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span
|
||||
className={`text-sm font-semibold leading-tight ${
|
||||
className={`text-sm font-medium leading-tight ${
|
||||
isPagamentoFatura && "text-success"
|
||||
}`}
|
||||
>
|
||||
@@ -74,7 +74,7 @@ const renderLancamento = (
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold whitespace-nowrap",
|
||||
"text-sm font-medium whitespace-nowrap",
|
||||
isReceita ? "text-success" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -103,7 +103,7 @@ const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-1 items-center">
|
||||
<span className="text-sm font-semibold leading-tight">
|
||||
<span className="text-sm font-medium leading-tight">
|
||||
{event.transaction.name}
|
||||
</span>
|
||||
|
||||
@@ -116,7 +116,7 @@ const renderBoleto = (event: Extract<CalendarEvent, { type: "boleto" }>) => {
|
||||
|
||||
<Badge variant={"outline"}>{isPaid ? "Pago" : "Pendente"}</Badge>
|
||||
</div>
|
||||
<span className="font-semibold">
|
||||
<span className="font-medium">
|
||||
<MoneyValues amount={event.transaction.amount} />
|
||||
</span>
|
||||
</div>
|
||||
@@ -129,7 +129,7 @@ const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-1 items-center">
|
||||
<span className="text-sm font-semibold leading-tight">
|
||||
<span className="text-sm font-medium leading-tight">
|
||||
Vencimento Invoice - {event.card.name}
|
||||
</span>
|
||||
</div>
|
||||
@@ -137,7 +137,7 @@ const renderCard = (event: Extract<CalendarEvent, { type: "card" }>) => (
|
||||
<Badge variant={"outline"}>{event.card.status ?? "Invoice"}</Badge>
|
||||
</div>
|
||||
{event.card.totalDue !== null ? (
|
||||
<span className="font-semibold">
|
||||
<span className="font-medium">
|
||||
<MoneyValues amount={event.card.totalDue} />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -136,7 +136,7 @@ export function CardItem({
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h3 className="truncate text-sm font-semibold text-foreground sm:text-base">
|
||||
<h3 className="truncate text-sm font-medium text-foreground sm:text-base">
|
||||
{name}
|
||||
</h3>
|
||||
{note ? (
|
||||
@@ -188,13 +188,13 @@ export function CardItem({
|
||||
<div className="flex items-center justify-between border-y py-3 text-xs font-medium text-muted-foreground sm:text-sm">
|
||||
<span>
|
||||
Fecha dia{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatDay(closingDay)}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Vence dia{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatDay(dueDay)}
|
||||
</span>
|
||||
</span>
|
||||
@@ -206,7 +206,7 @@ export function CardItem({
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
<MoneyValues amount={metrics[0].value} />
|
||||
</p>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
@@ -215,7 +215,7 @@ export function CardItem({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
|
||||
<p className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<span className="size-2 rounded-full bg-primary" />
|
||||
<MoneyValues amount={metrics[1].value} />
|
||||
</p>
|
||||
@@ -225,7 +225,7 @@ export function CardItem({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
<MoneyValues amount={metrics[2].value} />
|
||||
</p>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
|
||||
@@ -80,7 +80,7 @@ export function CategoryDetailHeader({
|
||||
size="lg"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl font-semibold leading-tight">
|
||||
<h1 className="text-xl font-medium leading-tight">
|
||||
{category.name}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
@@ -99,7 +99,7 @@ export function CategoryDetailHeader({
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Total em {currentPeriodLabel}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold">
|
||||
<p className="mt-1 text-2xl font-medium">
|
||||
{currencyFormatter.format(currentTotal)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -117,7 +117,7 @@ export function CategoryDetailHeader({
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 flex items-center gap-1 text-xl font-semibold",
|
||||
"mt-1 flex items-center gap-1 text-xl font-medium",
|
||||
variationColor,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { PaymentDialogState } from "@/features/dashboard/use-payment-dialog
|
||||
import { getBusinessDateString, isDateOnlyPast } from "@/shared/utils/date";
|
||||
import {
|
||||
buildFinancialStatusLabel,
|
||||
buildRelativeFinancialStatusLabel,
|
||||
formatFinancialDateLabel,
|
||||
} from "@/shared/utils/financial-dates";
|
||||
|
||||
@@ -24,6 +25,14 @@ export const buildBillStatusLabel = (bill: BillStatusDateItem) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const buildBillWidgetStatusLabel = (bill: BillStatusDateItem) => {
|
||||
return buildRelativeFinancialStatusLabel({
|
||||
isSettled: bill.isSettled,
|
||||
dueDate: bill.dueDate,
|
||||
paidAt: bill.boletoPaymentDate,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCurrentBillDateString = () => getBusinessDateString();
|
||||
|
||||
export const isBillOverdue = (bill: DashboardBill) => {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"use server";
|
||||
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { transactions } from "@/db/schema";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
import { toDateOnlyString } from "@/shared/utils/date";
|
||||
import {
|
||||
compareDateOnly,
|
||||
getBusinessDateString,
|
||||
isDateOnlyPast,
|
||||
toDateOnlyString,
|
||||
} from "@/shared/utils/date";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
|
||||
const PAYMENT_METHOD_BOLETO = "Boleto";
|
||||
@@ -33,10 +38,31 @@ export type DashboardBillsSnapshot = {
|
||||
pendingCount: number;
|
||||
};
|
||||
|
||||
const compareDateOnlyAscWithNullsLast = (
|
||||
left: string | null,
|
||||
right: string | null,
|
||||
) => {
|
||||
if (!left && !right) return 0;
|
||||
if (!left) return 1;
|
||||
if (!right) return -1;
|
||||
return compareDateOnly(left, right);
|
||||
};
|
||||
|
||||
const compareDateOnlyDescWithNullsLast = (
|
||||
left: string | null,
|
||||
right: string | null,
|
||||
) => {
|
||||
if (!left && !right) return 0;
|
||||
if (!left) return 1;
|
||||
if (!right) return -1;
|
||||
return compareDateOnly(right, left);
|
||||
};
|
||||
|
||||
export async function fetchDashboardBills(
|
||||
userId: string,
|
||||
period: string,
|
||||
): Promise<DashboardBillsSnapshot> {
|
||||
const today = getBusinessDateString();
|
||||
const adminPayerId = await getAdminPayerId(userId);
|
||||
if (!adminPayerId) {
|
||||
return { bills: [], totalPendingAmount: 0, pendingCount: 0 };
|
||||
@@ -59,11 +85,6 @@ export async function fetchDashboardBills(
|
||||
eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||
eq(transactions.payerId, adminPayerId),
|
||||
),
|
||||
)
|
||||
.orderBy(
|
||||
asc(transactions.isSettled),
|
||||
asc(transactions.dueDate),
|
||||
asc(transactions.name),
|
||||
);
|
||||
|
||||
const bills = rows.map((row: RawDashboardBill): DashboardBill => {
|
||||
@@ -78,6 +99,55 @@ export async function fetchDashboardBills(
|
||||
};
|
||||
});
|
||||
|
||||
bills.sort((a, b) => {
|
||||
if (a.isSettled !== b.isSettled) {
|
||||
return a.isSettled ? 1 : -1;
|
||||
}
|
||||
|
||||
if (!a.isSettled && !b.isSettled) {
|
||||
const aIsOverdue = a.dueDate ? isDateOnlyPast(a.dueDate, today) : false;
|
||||
const bIsOverdue = b.dueDate ? isDateOnlyPast(b.dueDate, today) : false;
|
||||
|
||||
if (aIsOverdue !== bIsOverdue) {
|
||||
return aIsOverdue ? -1 : 1;
|
||||
}
|
||||
|
||||
const dueDateDiff = compareDateOnlyAscWithNullsLast(a.dueDate, b.dueDate);
|
||||
if (dueDateDiff !== 0) {
|
||||
return dueDateDiff;
|
||||
}
|
||||
|
||||
const amountDiff = b.amount - a.amount;
|
||||
if (amountDiff !== 0) {
|
||||
return amountDiff;
|
||||
}
|
||||
}
|
||||
|
||||
if (a.isSettled && b.isSettled) {
|
||||
const paidAtDiff = compareDateOnlyDescWithNullsLast(
|
||||
a.boletoPaymentDate,
|
||||
b.boletoPaymentDate,
|
||||
);
|
||||
if (paidAtDiff !== 0) {
|
||||
return paidAtDiff;
|
||||
}
|
||||
|
||||
const amountDiff = b.amount - a.amount;
|
||||
if (amountDiff !== 0) {
|
||||
return amountDiff;
|
||||
}
|
||||
}
|
||||
|
||||
const nameDiff = a.name.localeCompare(b.name, "pt-BR", {
|
||||
sensitivity: "base",
|
||||
});
|
||||
if (nameDiff !== 0) {
|
||||
return nameDiff;
|
||||
}
|
||||
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
let totalPendingAmount = 0;
|
||||
let pendingCount = 0;
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { budgets, categories, transactions } from "@/db/schema";
|
||||
import {
|
||||
budgets,
|
||||
categories,
|
||||
financialAccounts,
|
||||
transactions,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
buildCategoryBreakdownData,
|
||||
type DashboardCategoryBreakdownData,
|
||||
@@ -8,6 +13,7 @@ import {
|
||||
import {
|
||||
buildDashboardAdminFilters,
|
||||
excludeAutoInvoiceEntries,
|
||||
excludeTransactionsFromExcludedAccounts,
|
||||
} from "@/features/dashboard/transaction-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
@@ -39,6 +45,10 @@ export async function fetchExpensesByCategory(
|
||||
})
|
||||
.from(transactions)
|
||||
.innerJoin(categories, eq(transactions.categoryId, categories.id))
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
...buildDashboardAdminFilters({ userId, adminPayerId }),
|
||||
@@ -46,6 +56,7 @@ export async function fetchExpensesByCategory(
|
||||
eq(transactions.transactionType, "Despesa"),
|
||||
eq(categories.type, "despesa"),
|
||||
excludeAutoInvoiceEntries(),
|
||||
excludeTransactionsFromExcludedAccounts(),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
buildDashboardAdminFilters,
|
||||
excludeAutoInvoiceEntries,
|
||||
excludeInitialBalanceWhenConfigured,
|
||||
excludeTransactionsFromExcludedAccounts,
|
||||
} from "@/features/dashboard/transaction-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
@@ -57,6 +58,7 @@ export async function fetchIncomeByCategory(
|
||||
eq(categories.type, "receita"),
|
||||
excludeAutoInvoiceEntries(),
|
||||
excludeInitialBalanceWhenConfigured(),
|
||||
excludeTransactionsFromExcludedAccounts(),
|
||||
),
|
||||
)
|
||||
.groupBy(
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
buildDashboardAdminFilters,
|
||||
excludeAutoInvoiceEntries,
|
||||
excludeInitialBalanceWhenConfigured,
|
||||
excludeTransactionsFromExcludedAccounts,
|
||||
} from "@/features/dashboard/transaction-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
@@ -156,6 +157,7 @@ export async function fetchDashboardCategoryOverview(
|
||||
and(
|
||||
...buildDashboardAdminFilters({ userId, adminPayerId }),
|
||||
inArray(transactions.period, [period, previousPeriod]),
|
||||
excludeTransactionsFromExcludedAccounts(),
|
||||
or(
|
||||
and(
|
||||
eq(transactions.transactionType, "Despesa"),
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { RiCheckboxCircleFill } from "@remixicon/react";
|
||||
import {
|
||||
buildBillStatusLabel,
|
||||
buildBillWidgetStatusLabel,
|
||||
isBillOverdue,
|
||||
} from "@/features/dashboard/bills-helpers";
|
||||
import type { DashboardBill } from "@/features/dashboard/bills-queries";
|
||||
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
type BillListItemProps = {
|
||||
@@ -15,8 +21,13 @@ type BillListItemProps = {
|
||||
};
|
||||
|
||||
export function BillListItem({ bill, onPay }: BillListItemProps) {
|
||||
const statusLabel = buildBillStatusLabel(bill);
|
||||
const statusLabel = buildBillWidgetStatusLabel(bill);
|
||||
const absoluteStatusLabel = buildBillStatusLabel(bill);
|
||||
const overdue = isBillOverdue(bill);
|
||||
const statusTooltipLabel =
|
||||
statusLabel && statusLabel !== absoluteStatusLabel
|
||||
? absoluteStatusLabel
|
||||
: null;
|
||||
|
||||
return (
|
||||
<li className="flex items-center justify-between transition-all duration-300 py-1.5">
|
||||
@@ -29,14 +40,32 @@ export function BillListItem({ bill, onPay }: BillListItemProps) {
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{statusLabel ? (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full py-0.5",
|
||||
bill.isSettled && "text-success",
|
||||
)}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
statusTooltipLabel ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"cursor-help rounded-full py-0.5",
|
||||
bill.isSettled && "text-success",
|
||||
)}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{statusTooltipLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full py-0.5",
|
||||
bill.isSettled && "text-success",
|
||||
)}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,7 @@ export function BillPaymentDialog({
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-[calc(100%-2rem)] sm:max-w-md"
|
||||
className="max-w-[calc(100%-2rem)] sm:max-w-md sm:p-8"
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (isProcessing) {
|
||||
event.preventDefault();
|
||||
@@ -93,11 +93,11 @@ export function BillPaymentDialog({
|
||||
{bill ? (
|
||||
<div className="space-y-3">
|
||||
{/* Card principal */}
|
||||
<div className="rounded-xl border bg-muted/30 p-4">
|
||||
<div className="rounded-xl border p-3">
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Boleto
|
||||
</p>
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
<p className="text-base font-medium text-foreground">
|
||||
{bill.name}
|
||||
</p>
|
||||
</div>
|
||||
@@ -107,24 +107,24 @@ export function BillPaymentDialog({
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
|
||||
<RiMoneyDollarCircleLine className="size-3.5" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide">
|
||||
<span className="text-xs font-medium uppercase tracking-wide">
|
||||
Valor
|
||||
</span>
|
||||
</div>
|
||||
<MoneyValues
|
||||
amount={bill.amount}
|
||||
className="text-lg font-bold"
|
||||
className="text-lg font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
|
||||
<RiCalendarLine className="size-3.5" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide">
|
||||
<span className="text-xs font-medium uppercase tracking-wide">
|
||||
Vencimento
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{dueLabel?.replace("Vencimento: ", "") ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -343,10 +343,10 @@ export function CategoryBreakdownWidgetView({
|
||||
<div className="rounded-lg border bg-background p-2 shadow-sm">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[0.70rem] uppercase text-muted-foreground">
|
||||
<span className="text-xs uppercase text-muted-foreground">
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="font-bold text-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatCurrency(entry.value)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -76,6 +76,9 @@ export function DashboardGridEditable({
|
||||
const [hiddenWidgets, setHiddenWidgets] = useState<string[]>(
|
||||
initialPreferences?.hidden ?? [],
|
||||
);
|
||||
const [myAccountsShowExcluded, setMyAccountsShowExcluded] = useState(
|
||||
initialPreferences?.myAccountsShowExcluded ?? true,
|
||||
);
|
||||
|
||||
// Keep track of original state for cancel
|
||||
const [originalOrder, setOriginalOrder] = useState(widgetOrder);
|
||||
@@ -186,6 +189,7 @@ export function DashboardGridEditable({
|
||||
if (result.success) {
|
||||
setWidgetOrder(DEFAULT_WIDGET_ORDER);
|
||||
setHiddenWidgets([]);
|
||||
setMyAccountsShowExcluded(true);
|
||||
toast.success("Preferências restauradas!");
|
||||
} else {
|
||||
toast.error(result.error ?? "Erro ao restaurar");
|
||||
@@ -337,7 +341,7 @@ export function DashboardGridEditable({
|
||||
<div className="absolute inset-0 z-10 bg-background/50 backdrop-blur-[1px] rounded-lg border-2 border-dashed border-primary/50 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<RiDragMove2Line className="size-8 text-primary" />
|
||||
<span className="text-xs font-bold">
|
||||
<span className="text-xs font-medium">
|
||||
Arraste para mover
|
||||
</span>
|
||||
<Button
|
||||
@@ -361,7 +365,16 @@ export function DashboardGridEditable({
|
||||
icon={widget.icon}
|
||||
action={widget.action}
|
||||
>
|
||||
{widget.component({ data, period })}
|
||||
{widget.component({
|
||||
data,
|
||||
period,
|
||||
widgetPreferences: {
|
||||
order: widgetOrder,
|
||||
hidden: hiddenWidgets,
|
||||
myAccountsShowExcluded,
|
||||
},
|
||||
onMyAccountsShowExcludedChange: setMyAccountsShowExcluded,
|
||||
})}
|
||||
</ExpandableWidgetCard>
|
||||
</div>
|
||||
</SortableWidget>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
RiScalesLine,
|
||||
RiSubtractLine,
|
||||
} from "@remixicon/react";
|
||||
import { MetricsCardInfoButton } from "@/features/dashboard/components/metrics-card-info-button";
|
||||
import type { DashboardCardMetrics } from "@/features/dashboard/dashboard-metrics-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import {
|
||||
@@ -36,6 +37,14 @@ const CARDS = [
|
||||
icon: RiArrowDownLine,
|
||||
invertTrend: false,
|
||||
iconClass: "text-success",
|
||||
helpTitle: "Como calculamos receitas",
|
||||
helpLines: [
|
||||
"Somamos os lançamentos do tipo Receita no período selecionado.",
|
||||
"Consideramos lançamentos efetivados e não efetivados do pagador principal (admin).",
|
||||
"Movimentações de contas marcadas como não consideradas no saldo total ficam fora deste card.",
|
||||
"Não entram transferências internas nem lançamentos automáticos de fatura.",
|
||||
"Saldo inicial também fica fora quando a conta está marcada para desconsiderá-lo das receitas.",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Despesas",
|
||||
@@ -44,14 +53,29 @@ const CARDS = [
|
||||
icon: RiArrowUpLine,
|
||||
invertTrend: true,
|
||||
iconClass: "text-destructive",
|
||||
helpTitle: "Como calculamos despesas",
|
||||
helpLines: [
|
||||
"Somamos os lançamentos do tipo Despesa no período selecionado.",
|
||||
"Consideramos lançamentos efetivados e não efetivados do pagador principal (admin).",
|
||||
"Movimentações de contas marcadas como não consideradas no saldo total ficam fora deste card.",
|
||||
"Não entram transferências internas nem lançamentos automáticos de fatura.",
|
||||
"O valor mostrado é a saída efetiva do período, sempre em número positivo no card.",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Balanço",
|
||||
subtitle: "Receitas menos despesas",
|
||||
subtitle: "Receitas, despesas e ajustes entre contas",
|
||||
key: "balanco",
|
||||
icon: RiScalesLine,
|
||||
invertTrend: false,
|
||||
iconClass: "text-warning",
|
||||
helpTitle: "Como calculamos o balanço",
|
||||
helpLines: [
|
||||
"Partimos de receitas menos despesas do período.",
|
||||
"Receitas e despesas de contas marcadas como não consideradas no saldo total ficam fora do cálculo base.",
|
||||
"Depois aplicamos ajustes de transferências entre contas consideradas e não consideradas no saldo total.",
|
||||
"Se a transferência entra em conta considerada, soma. Se sai de conta considerada para conta não considerada, subtrai.",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Previsto",
|
||||
@@ -60,6 +84,13 @@ const CARDS = [
|
||||
icon: RiCalendarCheckLine,
|
||||
invertTrend: false,
|
||||
iconClass: "text-cyan-600",
|
||||
helpTitle: "Como calculamos o previsto",
|
||||
helpLines: [
|
||||
"Acumulamos o balanço mês a mês até o período atual.",
|
||||
"Ele usa a mesma regra do card de balanço em cada mês do histórico.",
|
||||
"Receitas e despesas de contas marcadas como não consideradas no saldo total ficam fora desse acumulado.",
|
||||
"Por isso também reflete ajustes de transferências entre contas consideradas e não consideradas.",
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -104,7 +135,16 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
||||
{CARDS.map(
|
||||
({ label, subtitle, key, icon: Icon, invertTrend, iconClass }) => {
|
||||
({
|
||||
label,
|
||||
subtitle,
|
||||
key,
|
||||
icon: Icon,
|
||||
invertTrend,
|
||||
iconClass,
|
||||
helpTitle,
|
||||
helpLines,
|
||||
}) => {
|
||||
const metric = metrics[key];
|
||||
const trend = getTrend(metric.current, metric.previous);
|
||||
const TrendIcon = TREND_ICONS[trend];
|
||||
@@ -119,9 +159,14 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-1 tracking-tight">
|
||||
<CardTitle className="flex items-center gap-1.5 tracking-tight">
|
||||
<Icon className={cn("size-4", iconClass)} aria-hidden />
|
||||
{label}
|
||||
<MetricsCardInfoButton
|
||||
label={label}
|
||||
helpTitle={helpTitle}
|
||||
helpLines={helpLines}
|
||||
/>
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5 tracking-tight">
|
||||
{subtitle}
|
||||
@@ -134,12 +179,12 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mt-1">
|
||||
<MoneyValues
|
||||
className="text-[1.55rem] leading-none font-medium"
|
||||
className="text-2xl leading-none"
|
||||
amount={metric.current}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 text-xs font-medium",
|
||||
"inline-flex items-center gap-1 text-xs ",
|
||||
trendBadgeClass,
|
||||
)}
|
||||
>
|
||||
@@ -150,7 +195,7 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<MoneyValues
|
||||
className="inline text-xs font-medium text-muted-foreground"
|
||||
className="inline text-xs text-muted-foreground"
|
||||
amount={metric.previous}
|
||||
/>
|
||||
<span className="ml-1">no mês anterior</span>
|
||||
|
||||
@@ -8,7 +8,7 @@ export function DashboardWelcome({ name }: { name?: string | null }) {
|
||||
return (
|
||||
<section className="py-4">
|
||||
<div className="tracking-tight">
|
||||
<h1 className="text-xl">
|
||||
<h1 className="text-xl font-medium">
|
||||
{greeting}, {displayName}
|
||||
</h1>
|
||||
<h2 className="text-sm mt-1 text-muted-foreground">{formattedDate}</h2>
|
||||
|
||||
@@ -137,7 +137,7 @@ export function InstallmentAnalysisPage({
|
||||
</p>
|
||||
<MoneyValues
|
||||
amount={grandTotal}
|
||||
className="text-3xl font-bold text-primary"
|
||||
className="text-3xl font-medium text-primary"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedCount} {selectedCount === 1 ? "parcela" : "parcelas"}{" "}
|
||||
|
||||
@@ -95,7 +95,7 @@ export function InstallmentGroupCard({
|
||||
<span className="text-xs text-muted-foreground">Total:</span>
|
||||
<MoneyValues
|
||||
amount={totalAmount}
|
||||
className="text-base font-bold"
|
||||
className="text-base font-medium"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
buildInvoiceDetailsHref,
|
||||
buildInvoiceInitials,
|
||||
formatInvoicePaymentDate,
|
||||
formatInvoiceWidgetPaymentDate,
|
||||
getInvoiceShareLabel,
|
||||
parseInvoiceDueDate,
|
||||
parseInvoiceWidgetDueDate,
|
||||
} from "@/features/dashboard/invoices-helpers";
|
||||
import type { DashboardInvoice } from "@/features/dashboard/invoices-queries";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
@@ -20,6 +22,11 @@ import {
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/shared/components/ui/hover-card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
||||
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
||||
import { isDateOnlyPast } from "@/shared/utils/date";
|
||||
@@ -31,14 +38,22 @@ type InvoiceListItemProps = {
|
||||
};
|
||||
|
||||
export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
|
||||
const dueInfo = parseInvoiceDueDate(invoice.period, invoice.dueDay);
|
||||
const dueInfo = parseInvoiceWidgetDueDate(invoice.period, invoice.dueDay);
|
||||
const absoluteDueInfo = parseInvoiceDueDate(invoice.period, invoice.dueDay);
|
||||
const isPaid = invoice.paymentStatus === INVOICE_PAYMENT_STATUS.PAID;
|
||||
const isOverdue =
|
||||
!isPaid && dueInfo.date !== null && isDateOnlyPast(dueInfo.date);
|
||||
const paymentInfo = formatInvoicePaymentDate(invoice.paidAt);
|
||||
const paymentInfo = formatInvoiceWidgetPaymentDate(invoice.paidAt);
|
||||
const absolutePaymentInfo = formatInvoicePaymentDate(invoice.paidAt);
|
||||
const breakdown = invoice.pagadorBreakdown ?? [];
|
||||
const hasBreakdown = breakdown.length > 0;
|
||||
const detailHref = buildInvoiceDetailsHref(invoice.cardId, invoice.period);
|
||||
const dueTooltipLabel =
|
||||
dueInfo.label !== absoluteDueInfo.label ? absoluteDueInfo.label : null;
|
||||
const paymentTooltipLabel =
|
||||
paymentInfo?.label && paymentInfo.label !== absolutePaymentInfo?.label
|
||||
? absolutePaymentInfo?.label
|
||||
: null;
|
||||
|
||||
const linkNode = (
|
||||
<Link
|
||||
@@ -100,7 +115,7 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
<MoneyValues amount={share.amount} />
|
||||
</div>
|
||||
</li>
|
||||
@@ -113,9 +128,33 @@ export function InvoiceListItem({ invoice, onPay }: InvoiceListItemProps) {
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{!isPaid ? <span>{dueInfo.label}</span> : null}
|
||||
{!isPaid ? (
|
||||
dueTooltipLabel ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-help">{dueInfo.label}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{dueTooltipLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span>{dueInfo.label}</span>
|
||||
)
|
||||
) : null}
|
||||
{isPaid && paymentInfo ? (
|
||||
<span className="text-success">{paymentInfo.label}</span>
|
||||
paymentTooltipLabel ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-help text-success">
|
||||
{paymentInfo.label}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{paymentTooltipLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="text-success">{paymentInfo.label}</span>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user