Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c81584095b | ||
|
|
8ccc4479be | ||
|
|
2cead626ab | ||
|
|
811a035cb0 | ||
|
|
356801324c | ||
|
|
b443fb010a | ||
|
|
026dff5399 | ||
|
|
18b6a6a470 | ||
|
|
78e778311d |
@@ -17,8 +17,15 @@ POSTGRES_DB=openmonetis_db
|
||||
# Gere com: openssl rand -base64 32
|
||||
BETTER_AUTH_SECRET=your-secret-key-here-change-this
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
|
||||
# Origins adicionais confiáveis para o Better Auth.
|
||||
# Útil para Cloudflare Tunnel, reverse proxy e URLs diferentes de BETTER_AUTH_URL.
|
||||
# Separe múltiplas origins por vírgula.
|
||||
# Exemplo: https://*.trycloudflare.com,https://openmonetis.seudominio.com
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=
|
||||
# Defina como true para bloquear novos cadastros
|
||||
DISABLE_SIGNUP=false
|
||||
|
||||
# Duração de sessões persistentes quando "Manter conectado" estiver marcado
|
||||
AUTH_SESSION_EXPIRES_IN_DAYS=30
|
||||
AUTH_SESSION_UPDATE_AGE_HOURS=24
|
||||
|
||||
23
CHANGELOG.md
@@ -5,6 +5,29 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
|
||||
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
|
||||
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
|
||||
|
||||
## [2.7.3] - 2026-06-05
|
||||
|
||||
Esta versão melhora pequenos pontos de leitura e configuração para o uso diário e self-hosted. As faturas pagas ficam mais fáceis de identificar na lista de cartões, a configuração de origins confiáveis do Better Auth passa a ficar documentada para Docker e túneis, o dashboard corrige a leitura de tempo dos pré-lançamentos e as dependências seguem atualizadas sem quebrar o build da imagem.
|
||||
|
||||
### Adicionado
|
||||
- Cartões: a lista de cartões agora exibe a etiqueta `Paga` ao lado do valor da fatura atual quando ela já foi quitada.
|
||||
- Self-hosting: adicionada a variável `BETTER_AUTH_TRUSTED_ORIGINS` ao `.env.example`, ao `docker-compose.yml` e ao README para permitir origins adicionais confiáveis em cenários com Cloudflare Tunnel, reverse proxy ou URLs diferentes de `BETTER_AUTH_URL`.
|
||||
|
||||
### Alterado
|
||||
- Dependências: atualizados Next.js, React, Better Auth, AI SDK, AWS SDK, pdf.js e ferramentas de desenvolvimento usadas no build.
|
||||
|
||||
### Corrigido
|
||||
- Dashboard: o widget `Pré-lançamentos` agora calcula o rótulo `há X` a partir da chegada do item ao OpenMonetis, evitando deslocamentos causados por timestamps de notificação enviados com timezone incorreto.
|
||||
- Anexos: o preview de PDFs foi ajustado para a API atual do `pdfjs-dist`, evitando falha de TypeScript durante o build da imagem Docker.
|
||||
|
||||
## [2.7.2] - 2026-05-31
|
||||
|
||||
Esta versão atualiza as imagens de apresentação do OpenMonetis na landing page e no compartilhamento em redes sociais.
|
||||
|
||||
### Alterado
|
||||
- Landing page: atualizadas as imagens de preview do dashboard e da versão PWA.
|
||||
- Compartilhamento: ajustada a imagem usada nos metadados sociais da landing page.
|
||||
|
||||
## [2.7.1] - 2026-05-30
|
||||
|
||||
Esta versão melhora a clareza dos fluxos de lançamento e a experiência do dashboard. Boletos de receita agora diferenciam pagamentos de recebimentos, a navegação mensal ficou mais direta e o painel ganhou atalhos mais úteis com personalização simplificada.
|
||||
|
||||
20
README.md
@@ -6,9 +6,11 @@
|
||||
Projeto pessoal de gestão financeira. Self-hosted, manual e open source.
|
||||
</p>
|
||||
|
||||
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
|
||||
> **⚠️ Nota:** o OpenMonetis não está sendo encerrado, mas o desenvolvimento deve reduzir para quase zero daqui em diante. O app já cobre minhas demandas atuais de gerenciamento financeiro, então novas mudanças tendem a ser pontuais: correções, ajustes necessários e pequenas melhorias quando fizerem bastante sentido para meu uso.
|
||||
|
||||
[](CHANGELOG.md)
|
||||
> **Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](https://nextjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
@@ -451,6 +453,7 @@ POSTGRES_DB=openmonetis_db
|
||||
DISABLE_SIGNUP=false # true bloqueia novos cadastros
|
||||
AUTH_SESSION_EXPIRES_IN_DAYS=30 # duração de sessões persistentes
|
||||
AUTH_SESSION_UPDATE_AGE_HOURS=24 # frequência de renovação da sessão
|
||||
BETTER_AUTH_TRUSTED_ORIGINS= # origins adicionais confiáveis, separadas por vírgula
|
||||
|
||||
# S3 Server (opcional, necessario para anexos)
|
||||
S3_ENDPOINT=
|
||||
@@ -485,6 +488,19 @@ LOGO_DEV_TOKEN=
|
||||
LOGO_DEV_SECRET_KEY=
|
||||
```
|
||||
|
||||
### BETTER_AUTH_TRUSTED_ORIGINS
|
||||
|
||||
Use `BETTER_AUTH_TRUSTED_ORIGINS` quando o OpenMonetis for acessado por uma URL diferente de `BETTER_AUTH_URL`, como Cloudflare Tunnel, reverse proxy, domínio local ou subdomínios temporários. Isso evita falhas de login como `Invalid origin` sem precisar alterar a imagem Docker.
|
||||
|
||||
Informe apenas origins confiáveis, separadas por vírgula:
|
||||
|
||||
```env
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=https://*.trycloudflare.com,https://openmonetis.seudominio.com
|
||||
```
|
||||
|
||||
Para Google OAuth e outros callbacks externos, mantenha `BETTER_AUTH_URL` apontando para a URL pública/canônica configurada no provedor.
|
||||
|
||||
### IA local com Ollama
|
||||
|
||||
O provider Ollama permite gerar insights usando modelos locais. Instale e suba o Ollama no host onde o modelo ficará disponível:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
|
||||
@@ -36,6 +36,7 @@ services:
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql://openmonetis:openmonetis_dev_password@db:5432/openmonetis_db}
|
||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-}
|
||||
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
|
||||
BETTER_AUTH_TRUSTED_ORIGINS: ${BETTER_AUTH_TRUSTED_ORIGINS:-}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
40
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openmonetis",
|
||||
"version": "2.7.1",
|
||||
"version": "2.7.3",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@11.1.3",
|
||||
"scripts": {
|
||||
@@ -31,13 +31,13 @@
|
||||
"mockup": "tsx scripts/mock-data.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.79",
|
||||
"@ai-sdk/google": "^3.0.79",
|
||||
"@ai-sdk/openai": "^3.0.65",
|
||||
"@ai-sdk/anthropic": "^3.0.81",
|
||||
"@ai-sdk/google": "^3.0.80",
|
||||
"@ai-sdk/openai": "^3.0.67",
|
||||
"@ai-sdk/openai-compatible": "^2.0.48",
|
||||
"@aws-sdk/client-s3": "^3.1050.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1050.0",
|
||||
"@better-auth/passkey": "^1.6.11",
|
||||
"@aws-sdk/client-s3": "^3.1059.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1059.0",
|
||||
"@better-auth/passkey": "^1.6.14",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -64,27 +64,27 @@
|
||||
"@radix-ui/react-toggle-group": "1.1.11",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.26",
|
||||
"ai": "^6.0.191",
|
||||
"better-auth": "1.6.11",
|
||||
"@tanstack/react-virtual": "^3.14.2",
|
||||
"ai": "^6.0.195",
|
||||
"better-auth": "1.6.14",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.3.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"exceljs": "^4.4.0",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.8",
|
||||
"next": "16.2.6",
|
||||
"next": "16.2.7",
|
||||
"next-themes": "0.4.6",
|
||||
"pdfjs-dist": "^5.7.284",
|
||||
"pdfjs-dist": "^6.0.227",
|
||||
"pg": "8.21.0",
|
||||
"react": "19.2.6",
|
||||
"react": "19.2.7",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "19.2.6",
|
||||
"react-dom": "19.2.7",
|
||||
"recharts": "3.8.1",
|
||||
"resend": "^6.12.4",
|
||||
"sonner": "2.0.7",
|
||||
@@ -95,19 +95,19 @@
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.15",
|
||||
"@biomejs/biome": "2.4.16",
|
||||
"@tailwindcss/postcss": "4.3.0",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "25.9.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "19.2.15",
|
||||
"@types/react": "19.2.16",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"knip": "^6.14.2",
|
||||
"knip": "^6.15.0",
|
||||
"tailwindcss": "4.3.0",
|
||||
"tsx": "4.22.3",
|
||||
"tsx": "4.22.4",
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
2446
pnpm-lock.yaml
generated
@@ -7,29 +7,7 @@ allowBuilds:
|
||||
sharp: true
|
||||
unrs-resolver: true
|
||||
|
||||
minimumReleaseAgeExclude:
|
||||
- '@aws-sdk/client-s3@3.1050.0'
|
||||
- '@aws-sdk/s3-request-presigner@3.1050.0'
|
||||
- '@types/node@25.9.1'
|
||||
- '@types/react@19.2.15'
|
||||
- '@aws-sdk/client-s3@3.1054.0'
|
||||
- '@aws-sdk/core@3.974.14'
|
||||
- '@aws-sdk/credential-provider-env@3.972.40'
|
||||
- '@aws-sdk/credential-provider-http@3.972.42'
|
||||
- '@aws-sdk/credential-provider-ini@3.972.44'
|
||||
- '@aws-sdk/credential-provider-login@3.972.44'
|
||||
- '@aws-sdk/credential-provider-node@3.972.45'
|
||||
- '@aws-sdk/credential-provider-process@3.972.40'
|
||||
- '@aws-sdk/credential-provider-sso@3.972.44'
|
||||
- '@aws-sdk/credential-provider-web-identity@3.972.44'
|
||||
- '@aws-sdk/middleware-bucket-endpoint@3.972.16'
|
||||
- '@aws-sdk/middleware-flexible-checksums@3.974.22'
|
||||
- '@aws-sdk/middleware-sdk-s3@3.972.43'
|
||||
- '@aws-sdk/nested-clients@3.997.12'
|
||||
- '@aws-sdk/s3-request-presigner@3.1054.0'
|
||||
- '@aws-sdk/signature-v4-multi-region@3.996.29'
|
||||
- '@aws-sdk/token-providers@3.1054.0'
|
||||
- '@aws-sdk/xml-builder@3.972.26'
|
||||
minimumReleaseAge: 0
|
||||
|
||||
overrides:
|
||||
defu: 6.1.7
|
||||
|
||||
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 571 KiB After Width: | Height: | Size: 350 KiB |
|
Before Width: | Height: | Size: 562 KiB After Width: | Height: | Size: 355 KiB |
@@ -1,3 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200" role="img" aria-label="OpenMonetis">
|
||||
<path fill="#ff7733" d="M 77.66,165.64 L 37.77,141.54 L 63.30,108.72 L 27.13,97.44 L 46.81,50.77 L 77.66,63.08 L 81.91,30.26 L 126.40,29.23 L 126.06,33.85 L 122.87,67.69 L 158.51,50.77 L 178.19,90.26 L 140.96,104.62 L 162.23,127.18 L 132.98,162.56 L 103.19,131.79 Z"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" aria-label="OpenMonetis" role="img" viewBox="0 0 200 200"><path fill="#f73" d="M 77.66,165.64 L 37.77,141.54 L 63.30,108.72 L 27.13,97.44 L 46.81,50.77 L 77.66,63.08 L 81.91,30.26 L 126.40,29.23 L 126.06,33.85 L 122.87,67.69 L 158.51,50.77 L 178.19,90.26 L 140.96,104.62 L 162.23,127.18 L 132.98,162.56 L 103.19,131.79 Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 402 B After Width: | Height: | Size: 394 B |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.7 KiB |
BIN
public/images/pwa-preview-dark.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 88 KiB |
BIN
public/images/pwa-preview-light.png
Normal file
|
After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 17 KiB |
@@ -1 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>MiniMax</title><defs><linearGradient id="lobe-icons-minimax-gradient" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"/><stop offset="100%" stop-color="#FE603C"/></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-gradient)" fill-rule="nonzero"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="1em" style="flex:none;line-height:1" width="1em" viewBox="0 0 24 24"><title>MiniMax</title><defs><linearGradient id="lobe-icons-minimax-gradient" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"/><stop offset="100%" stop-color="#FE603C"/></linearGradient></defs><path fill="url(#lobe-icons-minimax-gradient)" fill-rule="nonzero" d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
@@ -136,6 +136,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
||||
limitAvailable: limitAmount,
|
||||
currentInvoiceAmount: 0,
|
||||
currentInvoiceLabel: "",
|
||||
currentInvoiceStatus: null,
|
||||
};
|
||||
|
||||
const { totalAmount, invoiceStatus, paymentDate } = invoiceData;
|
||||
|
||||
@@ -38,7 +38,7 @@ export const metadata: Metadata = {
|
||||
description: DESCRIPTION,
|
||||
images: [
|
||||
{
|
||||
url: "/images/dashboard-preview-light.webp",
|
||||
url: "/images/dashboard-preview-light.png",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
alt: "OpenMonetis — Dashboard de finanças pessoais",
|
||||
@@ -49,7 +49,7 @@ export const metadata: Metadata = {
|
||||
card: "summary_large_image",
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
images: ["/images/dashboard-preview-light.webp"],
|
||||
images: ["/images/dashboard-preview-light.png"],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
|
||||
@@ -32,7 +32,7 @@ function PdfCanvas({ url }: PdfCanvasProps) {
|
||||
|
||||
let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;
|
||||
try {
|
||||
pdf = await pdfjsLib.getDocument(url).promise;
|
||||
pdf = await pdfjsLib.getDocument({ url }).promise;
|
||||
} catch (err) {
|
||||
if ((err as { name?: string }).name === "PasswordException") {
|
||||
if (!cancelled) setLocked(true);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -23,6 +24,10 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
import { resolveCardBrandAsset } from "@/shared/lib/cards/brand-assets";
|
||||
import {
|
||||
INVOICE_PAYMENT_STATUS,
|
||||
type InvoicePaymentStatus,
|
||||
} from "@/shared/lib/invoices";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
@@ -37,6 +42,7 @@ interface CardItemProps {
|
||||
limitAvailable?: number;
|
||||
currentInvoiceAmount: number;
|
||||
currentInvoiceLabel: string;
|
||||
currentInvoiceStatus: InvoicePaymentStatus | null;
|
||||
accountName: string;
|
||||
logo?: string | null;
|
||||
note?: string | null;
|
||||
@@ -58,6 +64,7 @@ export function CardItem({
|
||||
limitAvailable,
|
||||
currentInvoiceAmount,
|
||||
currentInvoiceLabel,
|
||||
currentInvoiceStatus,
|
||||
accountName: _accountName,
|
||||
logo,
|
||||
note,
|
||||
@@ -80,6 +87,8 @@ export function CardItem({
|
||||
const logoPath = resolveLogoSrc(logo);
|
||||
const brandAsset = resolveCardBrandAsset(brand);
|
||||
const isInactive = status?.toLowerCase() === "inativo";
|
||||
const isCurrentInvoicePaid =
|
||||
currentInvoiceStatus === INVOICE_PAYMENT_STATUS.PAID;
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col p-6 w-full">
|
||||
@@ -175,10 +184,17 @@ export function CardItem({
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{currentInvoiceLabel}
|
||||
</span>
|
||||
<MoneyValues
|
||||
amount={currentInvoiceAmount}
|
||||
className="text-xl font-semibold text-info"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<MoneyValues
|
||||
amount={currentInvoiceAmount}
|
||||
className="text-xl font-semibold text-info"
|
||||
/>
|
||||
{isCurrentInvoicePaid ? (
|
||||
<Badge variant="success" className="text-xs">
|
||||
Paga
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-between w-full">
|
||||
|
||||
@@ -144,6 +144,7 @@ export function CardsPage({
|
||||
limitAvailable={card.limitAvailable ?? card.limit ?? null}
|
||||
currentInvoiceAmount={card.currentInvoiceAmount}
|
||||
currentInvoiceLabel={card.currentInvoiceLabel}
|
||||
currentInvoiceStatus={card.currentInvoiceStatus}
|
||||
accountName={card.accountName}
|
||||
logo={card.logo}
|
||||
note={card.note}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { InvoicePaymentStatus } from "@/shared/lib/invoices";
|
||||
|
||||
export type Card = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -14,6 +16,7 @@ export type Card = {
|
||||
limitAvailable: number;
|
||||
currentInvoiceAmount: number;
|
||||
currentInvoiceLabel: string;
|
||||
currentInvoiceStatus: InvoicePaymentStatus | null;
|
||||
};
|
||||
|
||||
export type CardFormValues = {
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
} from "drizzle-orm";
|
||||
import { cards, financialAccounts, invoices, transactions } from "@/db/schema";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
||||
import {
|
||||
INVOICE_PAYMENT_STATUS,
|
||||
INVOICE_STATUS_VALUES,
|
||||
type InvoicePaymentStatus,
|
||||
} from "@/shared/lib/invoices";
|
||||
import { loadLogoOptions } from "@/shared/lib/logo/options";
|
||||
import {
|
||||
formatPeriodMonthShort,
|
||||
@@ -33,6 +37,7 @@ type CardData = {
|
||||
limitAvailable: number;
|
||||
currentInvoiceAmount: number;
|
||||
currentInvoiceLabel: string;
|
||||
currentInvoiceStatus: InvoicePaymentStatus | null;
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
};
|
||||
@@ -48,6 +53,12 @@ function formatCurrentInvoiceLabel(period: string) {
|
||||
return `Fatura ${formatPeriodMonthShort(period)}. ${year}`;
|
||||
}
|
||||
|
||||
function parseInvoiceStatus(value: unknown): InvoicePaymentStatus | null {
|
||||
return INVOICE_STATUS_VALUES.includes(value as InvoicePaymentStatus)
|
||||
? (value as InvoicePaymentStatus)
|
||||
: null;
|
||||
}
|
||||
|
||||
async function fetchCardsByStatus(
|
||||
userId: string,
|
||||
archived: boolean,
|
||||
@@ -58,79 +69,94 @@ async function fetchCardsByStatus(
|
||||
}> {
|
||||
const currentPeriod = getCurrentPeriod();
|
||||
const currentInvoiceLabel = formatCurrentInvoiceLabel(currentPeriod);
|
||||
const [cardRows, accountRows, logoOptions, usageRows, invoiceRows] =
|
||||
await Promise.all([
|
||||
db.query.cards.findMany({
|
||||
orderBy: (table, { desc }) => [desc(table.name)],
|
||||
where: and(
|
||||
eq(cards.userId, userId),
|
||||
archived
|
||||
? ilike(cards.status, "inativo")
|
||||
: not(ilike(cards.status, "inativo")),
|
||||
),
|
||||
with: {
|
||||
financialAccount: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
const [
|
||||
cardRows,
|
||||
accountRows,
|
||||
logoOptions,
|
||||
usageRows,
|
||||
invoiceRows,
|
||||
invoiceStatusRows,
|
||||
] = await Promise.all([
|
||||
db.query.cards.findMany({
|
||||
orderBy: (table, { desc }) => [desc(table.name)],
|
||||
where: and(
|
||||
eq(cards.userId, userId),
|
||||
archived
|
||||
? ilike(cards.status, "inativo")
|
||||
: not(ilike(cards.status, "inativo")),
|
||||
),
|
||||
with: {
|
||||
financialAccount: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.query.financialAccounts.findMany({
|
||||
orderBy: (table, { desc }) => [desc(table.name)],
|
||||
where: eq(financialAccounts.userId, userId),
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
logo: true,
|
||||
},
|
||||
}),
|
||||
loadLogoOptions(),
|
||||
db
|
||||
.select({
|
||||
cardId: transactions.cardId,
|
||||
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(
|
||||
invoices,
|
||||
and(
|
||||
eq(invoices.userId, transactions.userId),
|
||||
eq(invoices.cardId, transactions.cardId),
|
||||
eq(invoices.period, transactions.period),
|
||||
},
|
||||
}),
|
||||
db.query.financialAccounts.findMany({
|
||||
orderBy: (table, { desc }) => [desc(table.name)],
|
||||
where: eq(financialAccounts.userId, userId),
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
logo: true,
|
||||
},
|
||||
}),
|
||||
loadLogoOptions(),
|
||||
db
|
||||
.select({
|
||||
cardId: transactions.cardId,
|
||||
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(
|
||||
invoices,
|
||||
and(
|
||||
eq(invoices.userId, transactions.userId),
|
||||
eq(invoices.cardId, transactions.cardId),
|
||||
eq(invoices.period, transactions.period),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
isNotNull(transactions.cardId),
|
||||
or(
|
||||
isNull(invoices.paymentStatus),
|
||||
ne(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PAID),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
isNotNull(transactions.cardId),
|
||||
or(
|
||||
isNull(invoices.paymentStatus),
|
||||
ne(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PAID),
|
||||
),
|
||||
// Recorrente no cartão: só consome limite quando a data da ocorrência já passou
|
||||
or(
|
||||
ne(transactions.condition, "Recorrente"),
|
||||
sql`${transactions.purchaseDate} <= current_date`,
|
||||
),
|
||||
// Recorrente no cartão: só consome limite quando a data da ocorrência já passou
|
||||
or(
|
||||
ne(transactions.condition, "Recorrente"),
|
||||
sql`${transactions.purchaseDate} <= current_date`,
|
||||
),
|
||||
)
|
||||
.groupBy(transactions.cardId),
|
||||
db
|
||||
.select({
|
||||
cardId: transactions.cardId,
|
||||
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
||||
})
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
eq(transactions.period, currentPeriod),
|
||||
),
|
||||
)
|
||||
.groupBy(transactions.cardId),
|
||||
]);
|
||||
),
|
||||
)
|
||||
.groupBy(transactions.cardId),
|
||||
db
|
||||
.select({
|
||||
cardId: transactions.cardId,
|
||||
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
||||
})
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
eq(transactions.period, currentPeriod),
|
||||
),
|
||||
)
|
||||
.groupBy(transactions.cardId),
|
||||
db
|
||||
.select({
|
||||
cardId: invoices.cardId,
|
||||
paymentStatus: invoices.paymentStatus,
|
||||
})
|
||||
.from(invoices)
|
||||
.where(
|
||||
and(eq(invoices.userId, userId), eq(invoices.period, currentPeriod)),
|
||||
),
|
||||
]);
|
||||
|
||||
const usageMap = new Map<string, number>();
|
||||
usageRows.forEach((row: { cardId: string | null; total: number | null }) => {
|
||||
@@ -144,6 +170,13 @@ async function fetchCardsByStatus(
|
||||
invoiceMap.set(row.cardId, Math.abs(Number(row.total ?? 0)));
|
||||
},
|
||||
);
|
||||
const invoiceStatusMap = new Map<string, InvoicePaymentStatus>();
|
||||
invoiceStatusRows.forEach((row) => {
|
||||
if (!row.cardId) return;
|
||||
const status = parseInvoiceStatus(row.paymentStatus);
|
||||
if (!status) return;
|
||||
invoiceStatusMap.set(row.cardId, status);
|
||||
});
|
||||
|
||||
const cardList = cardRows.map((card) => ({
|
||||
id: card.id,
|
||||
@@ -166,6 +199,7 @@ async function fetchCardsByStatus(
|
||||
})(),
|
||||
currentInvoiceAmount: invoiceMap.get(card.id) ?? 0,
|
||||
currentInvoiceLabel,
|
||||
currentInvoiceStatus: invoiceStatusMap.get(card.id) ?? null,
|
||||
accountId: card.accountId,
|
||||
accountName:
|
||||
(card.financialAccount as { name?: string } | null)?.name ??
|
||||
|
||||
@@ -221,7 +221,7 @@ export function InboxWidget({
|
||||
<span className="truncate">{item.sourceAppName}</span>
|
||||
)}
|
||||
<span className="text-muted-foreground/60">
|
||||
{relativeTime(item.notificationTimestamp)}
|
||||
{relativeTime(item.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,8 @@ export const landingImages = {
|
||||
dark: "/images/dashboard-preview-dark.png",
|
||||
},
|
||||
pwa: {
|
||||
light: "/images/pwa-preview-light.webp",
|
||||
dark: "/images/pwa-preview-dark.webp",
|
||||
light: "/images/pwa-preview-light.png",
|
||||
dark: "/images/pwa-preview-dark.png",
|
||||
},
|
||||
companion: {
|
||||
light: "/images/companion-preview-light.webp",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export type SectionType = "Adicionado" | "Alterado" | "Corrigido" | "Removido";
|
||||
|
||||
export const SECTION_TYPES: readonly SectionType[] = [
|
||||
const SECTION_TYPES: readonly SectionType[] = [
|
||||
"Adicionado",
|
||||
"Alterado",
|
||||
"Corrigido",
|
||||
|
||||
@@ -31,19 +31,6 @@ import { addMonthsToPeriod, MONTH_NAMES } from "@/shared/utils/period";
|
||||
// Authorization Validation Functions
|
||||
// ============================================================================
|
||||
|
||||
export async function validatePayerOwnership(
|
||||
userId: string,
|
||||
payerId: string | null | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!payerId) return true;
|
||||
|
||||
const pagador = await db.query.payers.findFirst({
|
||||
where: and(eq(payers.id, payerId), eq(payers.userId, userId)),
|
||||
});
|
||||
|
||||
return !!pagador;
|
||||
}
|
||||
|
||||
const normalizeIds = (ids: Array<string | null | undefined>) => [
|
||||
...new Set(ids.filter((id): id is string => Boolean(id))),
|
||||
];
|
||||
@@ -592,7 +579,7 @@ type SplitShareInput = {
|
||||
amount: number;
|
||||
};
|
||||
|
||||
export const resolveSplitShares = (data: {
|
||||
const resolveSplitShares = (data: {
|
||||
payerId?: string | null;
|
||||
secondaryPayerId?: string | null;
|
||||
splitShares?: SplitShareInput[];
|
||||
|
||||
@@ -137,24 +137,6 @@ export function getSplitSummaryData(
|
||||
};
|
||||
}
|
||||
|
||||
export function getSplitSummaryLabel(
|
||||
formState: FormState,
|
||||
payerOptions: SplitSummaryPayerOption[],
|
||||
totalAmount: number,
|
||||
) {
|
||||
const summary = getSplitSummaryData(formState, payerOptions, totalAmount);
|
||||
|
||||
if (summary.type === "text") return summary.label;
|
||||
|
||||
const namesLabel = summary.participants
|
||||
.map((participant) => participant.firstName)
|
||||
.join(" ");
|
||||
const remainingLabel =
|
||||
summary.remainingCount > 0 ? ` +${summary.remainingCount}` : "";
|
||||
|
||||
return `${summary.count} pessoas: ${namesLabel}${remainingLabel} · ${summary.totalLabel}`;
|
||||
}
|
||||
|
||||
export function SplitConfigDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
||||
@@ -61,13 +61,3 @@ export function groupAndSortCategories(
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters secondary payer options to exclude the primary payer
|
||||
*/
|
||||
export function filterSecondaryPayerOptions(
|
||||
allOptions: SelectOption[],
|
||||
primaryPayerId?: string,
|
||||
): SelectOption[] {
|
||||
return allOptions.filter((option) => option.value !== primaryPayerId);
|
||||
}
|
||||
|
||||
@@ -150,10 +150,7 @@ export const getSingleParam = (
|
||||
return Array.isArray(value) ? (value[0] ?? null) : value;
|
||||
};
|
||||
|
||||
export const getMultiParam = (
|
||||
params: ResolvedSearchParams,
|
||||
key: string,
|
||||
): string[] => {
|
||||
const getMultiParam = (params: ResolvedSearchParams, key: string): string[] => {
|
||||
const value = params?.[key];
|
||||
if (!value) {
|
||||
return [];
|
||||
|
||||
@@ -36,7 +36,7 @@ export const getLogoDisplayName = (logo?: string | null): string => {
|
||||
* @example
|
||||
* deriveNameFromLogo("my-company-logo.png") // "My Company Logo"
|
||||
*/
|
||||
export const deriveNameFromLogo = (logo?: string | null) => {
|
||||
const deriveNameFromLogo = (logo?: string | null) => {
|
||||
if (!logo) {
|
||||
return "";
|
||||
}
|
||||
|
||||