mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-06-09 23:06:01 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a10d431ab | ||
|
|
b7343eb235 | ||
|
|
3bcc392f38 | ||
|
|
5241de44af | ||
|
|
1a75662120 | ||
|
|
7ca3f92467 | ||
|
|
6b044f3bc5 | ||
|
|
4e8f9cc5fa | ||
|
|
b6659ef66e | ||
|
|
21d7396c80 |
@@ -17,6 +17,8 @@ POSTGRES_DB=openmonetis_db
|
|||||||
# Gere com: openssl rand -base64 32
|
# Gere com: openssl rand -base64 32
|
||||||
BETTER_AUTH_SECRET=your-secret-key-here-change-this
|
BETTER_AUTH_SECRET=your-secret-key-here-change-this
|
||||||
BETTER_AUTH_URL=http://localhost:3000
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
# Defina como true para bloquear novos cadastros
|
||||||
|
DISABLE_SIGNUP=false
|
||||||
|
|
||||||
# === Portas ===
|
# === Portas ===
|
||||||
APP_PORT=3000
|
APP_PORT=3000
|
||||||
|
|||||||
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -24,8 +24,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: 10.33.0
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
32
.vscode/settings.json
vendored
32
.vscode/settings.json
vendored
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"files.exclude": {
|
|
||||||
"**/.git": true,
|
|
||||||
"**/.svn": true,
|
|
||||||
"**/.hg": true,
|
|
||||||
"**/.DS_Store": true,
|
|
||||||
"**/Thumbs.db": true,
|
|
||||||
"**/node_modules": true,
|
|
||||||
"node_modules": true,
|
|
||||||
"**/.vscode": true,
|
|
||||||
".vscode": true,
|
|
||||||
"**/.next": true,
|
|
||||||
".next": true
|
|
||||||
},
|
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
|
||||||
"editor.formatOnSave": true,
|
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"source.organizeImports": "never",
|
|
||||||
"source.organizeImports.biome": "always",
|
|
||||||
"source.fixAll": "never",
|
|
||||||
"source.fixAll.biome": "always",
|
|
||||||
"source.fixAll.eslint": "never"
|
|
||||||
},
|
|
||||||
"[json]": {
|
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
|
||||||
},
|
|
||||||
"prettier.enable": false,
|
|
||||||
"editor.fontSize": 15,
|
|
||||||
"[jsonc]": {
|
|
||||||
"editor.defaultFormatter": "vscode.json-language-features"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -5,6 +5,45 @@ 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/),
|
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/).
|
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
|
||||||
|
|
||||||
|
## [2.6.1] - 2026-05-21
|
||||||
|
|
||||||
|
Esta versão corrige o pipeline de publicação após o salto para a `2.6.0`. O build do GitHub Actions falhava antes mesmo de instalar as dependências porque o workflow ainda fixava uma versão antiga do `pnpm`, enquanto o projeto já declarava `pnpm@11.1.3` no `packageManager`.
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
- CI: o workflow de build deixou de fixar uma versão diferente do `pnpm`, usando a versão declarada em `packageManager` para evitar conflito no `pnpm/action-setup`.
|
||||||
|
- CI: a política de builds do `pnpm` foi migrada para `allowBuilds`, permitindo explicitamente os scripts necessários de `core-js`, `esbuild`, `sharp` e `unrs-resolver` durante o install no GitHub Actions.
|
||||||
|
|
||||||
|
## [2.6.0] - 2026-05-21
|
||||||
|
|
||||||
|
Esta versão implementa melhorias pensadas para o uso real do dia a dia. O OpenMonetis passa a lidar melhor com parcelamentos que já começaram, recupera atalhos importantes no extrato, deixa a revisão de importações mais precisa e dá mais controle para quem mantém uma instância self-hosted. Também entram correções de consistência no dashboard, em cartões, no tratamento da categoria `Pagamentos` e nas datas vindas de planilhas.
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
- Autenticação: nova variável `DISABLE_SIGNUP=true` para bloquear novos cadastros. Quando ativa, a tela de cadastro deixa de aparecer na navegação, `/signup` redireciona para login/dashboard e a API de signup responde `403`.
|
||||||
|
- Lançamentos: compras parceladas agora podem começar em uma parcela intermediária, como `5 de 10`. O sistema gera apenas as parcelas restantes e preserva o cálculo do valor unitário com base no total original.
|
||||||
|
- Logos: adicionado o logo da Bipa à biblioteca local de marcas.
|
||||||
|
- Relatórios: a análise de parcelas agora separa parcelas acompanhadas daquelas que ficaram fora do acompanhamento quando o parcelamento começa no meio da série.
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
- Contas: a página de extrato em `/accounts/[accountId]` voltou a exibir os botões "Nova Receita" e "Nova Despesa", alinhando o fluxo com as demais telas de lançamentos.
|
||||||
|
- Cartões: os cards de `/cards` agora mostram o valor da fatura do mês atual junto dos indicadores de limite. O limite utilizado passa a considerar faturas em aberto, não apenas o status interno do lançamento.
|
||||||
|
- Lançamentos: ao criar um lançamento a partir do extrato de uma conta, o diálogo já abre com essa conta selecionada como destino padrão.
|
||||||
|
- Importação: os controles globais da revisão de extrato foram realinhados à esquerda, com espaçamento mais compacto e larguras mais consistentes.
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
- Dashboard: o widget "Status de Pagamento" voltou a mostrar corretamente os valores em "A Pagar", somando despesas pelo valor absoluto e mantendo reembolsos como abatimento.
|
||||||
|
- Importação: datas vindas de planilhas agora preservam o dia informado no Excel, evitando que `20/05/2026` apareça como `19/05/2026` em fusos como `America/Sao_Paulo`.
|
||||||
|
- Importação: o seletor de categoria por linha agora mostra apenas categorias compatíveis com o tipo detectado do lançamento, separando receitas e despesas durante a revisão do extrato.
|
||||||
|
- Importação: cada linha da revisão de extrato agora permite escolher uma pessoa específica, enquanto o campo global continua servindo como atalho para aplicar a pessoa nos lançamentos selecionados.
|
||||||
|
- Lançamentos: despesas comuns na categoria `Pagamentos` voltaram a poder ser editadas, removidas, copiadas e importadas. A proteção continua valendo apenas para pagamentos automáticos de fatura com nota técnica `AUTO_FATURA:`.
|
||||||
|
|
||||||
|
### Dependências
|
||||||
|
- Stack core: `pnpm` 10.33.0 → 11.1.3.
|
||||||
|
- Auth: `better-auth` e `@better-auth/passkey` 1.6.10 → 1.6.11.
|
||||||
|
- AI SDKs: `@ai-sdk/anthropic` 3.0.76 → 3.0.78, `@ai-sdk/google` 3.0.71 → 3.0.75, `@ai-sdk/openai` 3.0.63 → 3.0.64 e `ai` 6.0.177 → 6.0.185.
|
||||||
|
- AWS: `@aws-sdk/client-s3` e `@aws-sdk/s3-request-presigner` 3.1045.0 → 3.1050.0.
|
||||||
|
- UI e dados: `@tanstack/react-query` 5.100.9 → 5.100.11, `date-fns` 4.1.0 → 4.2.1, `jspdf-autotable` 5.0.7 → 5.0.8, `pg` 8.20.0 → 8.21.0 e `react-day-picker` 10.0.0 → 10.0.1.
|
||||||
|
- Dev tooling: `@types/node` 25.6.2 → 25.9.1, `@types/react` 19.2.14 → 19.2.15, `knip` 6.12.2 → 6.14.1, `tsx` 4.21.0 → 4.22.3 e novo `babel-plugin-react-compiler` 1.0.0.
|
||||||
|
|
||||||
## [2.5.7] - 2026-05-14
|
## [2.5.7] - 2026-05-14
|
||||||
|
|
||||||
Esta versão faz um polimento visual no relatório de análise de parcelas, deixando o estabelecimento como referência principal do card e mantendo o cartão visível de forma mais discreta no contexto da compra.
|
Esta versão faz um polimento visual no relatório de análise de parcelas, deixando o estabelecimento como referência principal do card e mantendo o cartão visível de forma mais discreta no contexto da compra.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
|
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
|
||||||
|
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://nextjs.org/)
|
[](https://nextjs.org/)
|
||||||
[](https://www.typescriptlang.org/)
|
[](https://www.typescriptlang.org/)
|
||||||
[](https://www.postgresql.org/)
|
[](https://www.postgresql.org/)
|
||||||
@@ -127,10 +127,11 @@ Só quer rodar o OpenMonetis. **Não precisa clonar o repositório nem instalar
|
|||||||
# 1. Baixe o compose
|
# 1. Baixe o compose
|
||||||
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
|
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
|
||||||
|
|
||||||
# 2. Crie um .en na mesma pasta.
|
# 2. Crie um .env na mesma pasta.
|
||||||
# .env mínimo recomendado para produção
|
# .env mínimo recomendado para produção
|
||||||
BETTER_AUTH_SECRET=gere-um-valor-com-openssl-rand-base64-32
|
BETTER_AUTH_SECRET=gere-um-valor-com-openssl-rand-base64-32
|
||||||
BETTER_AUTH_URL=http://seu-dominio.com
|
BETTER_AUTH_URL=http://seu-dominio.com
|
||||||
|
DISABLE_SIGNUP=false # opcional: true bloqueia novos cadastros
|
||||||
|
|
||||||
# 3. Suba tudo
|
# 3. Suba tudo
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@@ -443,6 +444,9 @@ POSTGRES_USER=openmonetis
|
|||||||
POSTGRES_PASSWORD=openmonetis_dev_password
|
POSTGRES_PASSWORD=openmonetis_dev_password
|
||||||
POSTGRES_DB=openmonetis_db
|
POSTGRES_DB=openmonetis_db
|
||||||
|
|
||||||
|
# Autenticação
|
||||||
|
DISABLE_SIGNUP=false # true bloqueia novos cadastros
|
||||||
|
|
||||||
# S3 Server (opcional, necessario para anexos)
|
# S3 Server (opcional, necessario para anexos)
|
||||||
S3_ENDPOINT=
|
S3_ENDPOINT=
|
||||||
S3_REGION=
|
S3_REGION=
|
||||||
|
|||||||
44
package.json
44
package.json
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "openmonetis",
|
"name": "openmonetis",
|
||||||
"version": "2.5.7",
|
"version": "2.6.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@11.1.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"db:seed": "tsx scripts/mock-data.ts",
|
"db:seed": "tsx scripts/mock-data.ts",
|
||||||
@@ -31,12 +31,12 @@
|
|||||||
"mockup": "tsx scripts/mock-data.ts"
|
"mockup": "tsx scripts/mock-data.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^3.0.76",
|
"@ai-sdk/anthropic": "^3.0.78",
|
||||||
"@ai-sdk/google": "^3.0.71",
|
"@ai-sdk/google": "^3.0.75",
|
||||||
"@ai-sdk/openai": "^3.0.63",
|
"@ai-sdk/openai": "^3.0.64",
|
||||||
"@aws-sdk/client-s3": "^3.1045.0",
|
"@aws-sdk/client-s3": "^3.1050.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1045.0",
|
"@aws-sdk/s3-request-presigner": "^3.1050.0",
|
||||||
"@better-auth/passkey": "^1.6.10",
|
"@better-auth/passkey": "^1.6.11",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -63,26 +63,26 @@
|
|||||||
"@radix-ui/react-toggle-group": "1.1.11",
|
"@radix-ui/react-toggle-group": "1.1.11",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-query": "^5.100.9",
|
"@tanstack/react-query": "^5.100.11",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"@tanstack/react-virtual": "^3.13.24",
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"ai": "^6.0.177",
|
"ai": "^6.0.185",
|
||||||
"better-auth": "1.6.10",
|
"better-auth": "1.6.11",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.2.1",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.8",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"pdfjs-dist": "^5.7.284",
|
"pdfjs-dist": "^5.7.284",
|
||||||
"pg": "8.20.0",
|
"pg": "8.21.0",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
"react-day-picker": "^10.0.0",
|
"react-day-picker": "^10.0.1",
|
||||||
"react-dom": "19.2.6",
|
"react-dom": "19.2.6",
|
||||||
"recharts": "3.8.1",
|
"recharts": "3.8.1",
|
||||||
"resend": "^6.12.3",
|
"resend": "^6.12.3",
|
||||||
@@ -92,24 +92,20 @@
|
|||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"zod": "4.4.3"
|
"zod": "4.4.3"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
|
||||||
"overrides": {
|
|
||||||
"defu": "6.1.7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.15",
|
"@biomejs/biome": "2.4.15",
|
||||||
"@tailwindcss/postcss": "4.3.0",
|
"@tailwindcss/postcss": "4.3.0",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "25.6.2",
|
"@types/node": "25.9.1",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.15",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"knip": "^6.12.2",
|
"knip": "^6.14.1",
|
||||||
"tailwindcss": "4.3.0",
|
"tailwindcss": "4.3.0",
|
||||||
"tsx": "4.21.0",
|
"tsx": "4.22.3",
|
||||||
"typescript": "6.0.3"
|
"typescript": "6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3965
pnpm-lock.yaml
generated
3965
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,17 @@
|
|||||||
onlyBuiltDependencies:
|
packages:
|
||||||
- core-js
|
- '.'
|
||||||
- esbuild
|
|
||||||
- sharp
|
allowBuilds:
|
||||||
- unrs-resolver
|
core-js: true
|
||||||
|
esbuild: true
|
||||||
|
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'
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
defu: 6.1.7
|
||||||
|
|||||||
BIN
public/logos/bipa.png
Normal file
BIN
public/logos/bipa.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
@@ -1,5 +1,6 @@
|
|||||||
import { LoginForm } from "@/features/auth/components/login-form";
|
import { LoginForm } from "@/features/auth/components/login-form";
|
||||||
|
import { isSignupDisabled } from "@/shared/lib/auth/signup";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
return <LoginForm />;
|
return <LoginForm signupDisabled={isSignupDisabled()} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
import { SignupForm } from "@/features/auth/components/signup-form";
|
import { SignupForm } from "@/features/auth/components/signup-form";
|
||||||
|
import { isSignupDisabled } from "@/shared/lib/auth/signup";
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
|
if (isSignupDisabled()) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
return <SignupForm />;
|
return <SignupForm />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ type PageProps = {
|
|||||||
const capitalize = (value: string) =>
|
const capitalize = (value: string) =>
|
||||||
value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
|
value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
|
||||||
|
|
||||||
|
const resolveDefaultPaymentMethod = (
|
||||||
|
accountType: string | null | undefined,
|
||||||
|
) => {
|
||||||
|
if (accountType === "Dinheiro") return "Dinheiro";
|
||||||
|
if (accountType === "Pré-Pago | VR/VA") return "Pré-Pago | VR/VA";
|
||||||
|
|
||||||
|
return "Pix";
|
||||||
|
};
|
||||||
|
|
||||||
export default async function Page({ params, searchParams }: PageProps) {
|
export default async function Page({ params, searchParams }: PageProps) {
|
||||||
await connection();
|
await connection();
|
||||||
const { accountId } = await params;
|
const { accountId } = await params;
|
||||||
@@ -197,7 +206,11 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
settledOnly: true,
|
settledOnly: true,
|
||||||
}}
|
}}
|
||||||
allowCreate={false}
|
allowCreate
|
||||||
|
defaultAccountId={account.id}
|
||||||
|
defaultPaymentMethod={resolveDefaultPaymentMethod(
|
||||||
|
account.accountType,
|
||||||
|
)}
|
||||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||||
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
accountName,
|
accountName,
|
||||||
limitInUse: 0,
|
limitInUse: 0,
|
||||||
limitAvailable: limitAmount,
|
limitAvailable: limitAmount,
|
||||||
|
currentInvoiceAmount: 0,
|
||||||
|
currentInvoiceLabel: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const { totalAmount, invoiceStatus, paymentDate } = invoiceData;
|
const { totalAmount, invoiceStatus, paymentDate } = invoiceData;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { Badge } from "@/shared/components/ui/badge";
|
|||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
import { getOptionalUserSession } from "@/shared/lib/auth/server";
|
import { getOptionalUserSession } from "@/shared/lib/auth/server";
|
||||||
|
import { isSignupDisabled } from "@/shared/lib/auth/signup";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
const [session, headersList, githubStats] = await Promise.all([
|
const [session, headersList, githubStats] = await Promise.all([
|
||||||
@@ -43,6 +44,7 @@ export default async function Page() {
|
|||||||
"",
|
"",
|
||||||
).replace(/:\d+$/, "");
|
).replace(/:\d+$/, "");
|
||||||
const isPublicDomain = !!(publicDomain && hostname === publicDomain);
|
const isPublicDomain = !!(publicDomain && hostname === publicDomain);
|
||||||
|
const signupDisabled = isSignupDisabled();
|
||||||
const metricsItems = getMetricsItems(githubStats.stars, githubStats.forks);
|
const metricsItems = getMetricsItems(githubStats.stars, githubStats.forks);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -86,6 +88,7 @@ export default async function Page() {
|
|||||||
Entrar
|
Entrar
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
{!signupDisabled && (
|
||||||
<Link href="/signup">
|
<Link href="/signup">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -95,11 +98,13 @@ export default async function Page() {
|
|||||||
Começar
|
Começar
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<MobileNav
|
<MobileNav
|
||||||
isPublicDomain={isPublicDomain}
|
isPublicDomain={isPublicDomain}
|
||||||
isLoggedIn={!!session?.user}
|
isLoggedIn={!!session?.user}
|
||||||
|
signupDisabled={signupDisabled}
|
||||||
/>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
</NavbarShell>
|
</NavbarShell>
|
||||||
|
|||||||
@@ -41,9 +41,9 @@
|
|||||||
--input: var(--border);
|
--input: var(--border);
|
||||||
--ring: var(--primary);
|
--ring: var(--primary);
|
||||||
|
|
||||||
--chart-1: var(--color-emerald-500);
|
--chart-1: var(--color-orange-600);
|
||||||
--chart-2: var(--color-red-500);
|
--chart-2: var(--color-orange-400);
|
||||||
--chart-3: var(--color-amber-500);
|
--chart-3: var(--color-orange-200);
|
||||||
--chart-4: var(--color-blue-500);
|
--chart-4: var(--color-blue-500);
|
||||||
--chart-5: var(--color-pink-500);
|
--chart-5: var(--color-pink-500);
|
||||||
--chart-6: var(--color-stone-500);
|
--chart-6: var(--color-stone-500);
|
||||||
@@ -117,13 +117,13 @@
|
|||||||
--destructive: oklch(62% 0.2 28);
|
--destructive: oklch(62% 0.2 28);
|
||||||
--destructive-foreground: oklch(98% 0.005 30);
|
--destructive-foreground: oklch(98% 0.005 30);
|
||||||
|
|
||||||
--border: oklch(24.957% 0.00355 48.274);
|
--border: oklch(31.987% 0.00462 39.069);
|
||||||
--input: var(--border);
|
--input: var(--border);
|
||||||
--ring: var(--primary);
|
--ring: var(--primary);
|
||||||
|
|
||||||
--chart-1: var(--color-emerald-500);
|
--chart-1: var(--color-orange-600);
|
||||||
--chart-2: var(--color-orange-500);
|
--chart-2: var(--color-orange-400);
|
||||||
--chart-3: var(--color-indigo-500);
|
--chart-3: var(--color-orange-200);
|
||||||
--chart-4: var(--color-amber-500);
|
--chart-4: var(--color-amber-500);
|
||||||
--chart-5: var(--color-pink-500);
|
--chart-5: var(--color-pink-500);
|
||||||
--chart-6: var(--color-stone-500);
|
--chart-6: var(--color-stone-500);
|
||||||
|
|||||||
@@ -21,10 +21,18 @@ import { GoogleAuthButton } from "./google-auth-button";
|
|||||||
|
|
||||||
type DivProps = React.ComponentProps<"div">;
|
type DivProps = React.ComponentProps<"div">;
|
||||||
|
|
||||||
|
interface LoginFormProps extends DivProps {
|
||||||
|
signupDisabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const authLinkClassName =
|
const authLinkClassName =
|
||||||
"font-medium text-foreground/88 underline decoration-border underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/30 focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";
|
"font-medium text-foreground/88 underline decoration-border underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/30 focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";
|
||||||
|
|
||||||
export function LoginForm({ className, ...props }: DivProps) {
|
export function LoginForm({
|
||||||
|
className,
|
||||||
|
signupDisabled = false,
|
||||||
|
...props
|
||||||
|
}: LoginFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isGoogleAvailable = googleSignInAvailable;
|
const isGoogleAvailable = googleSignInAvailable;
|
||||||
|
|
||||||
@@ -233,12 +241,14 @@ export function LoginForm({ className, ...props }: DivProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
{!signupDisabled && (
|
||||||
<FieldDescription className="pt-1 text-center">
|
<FieldDescription className="pt-1 text-center">
|
||||||
Não tem uma conta?{" "}
|
Não tem uma conta?{" "}
|
||||||
<a href="/signup" className={authLinkClassName}>
|
<a href="/signup" className={authLinkClassName}>
|
||||||
Inscreva-se
|
Inscreva-se
|
||||||
</a>
|
</a>
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
|
)}
|
||||||
|
|
||||||
<FieldDescription className="text-center text-sm text-muted-foreground">
|
<FieldDescription className="text-center text-sm text-muted-foreground">
|
||||||
<a href="/" className={authLinkClassName}>
|
<a href="/" className={authLinkClassName}>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
RiCalendarCloseLine,
|
||||||
|
RiCalendarScheduleLine,
|
||||||
RiChat3Line,
|
RiChat3Line,
|
||||||
RiDeleteBin5Line,
|
RiDeleteBin5Line,
|
||||||
RiFileList2Line,
|
RiFileList2Line,
|
||||||
@@ -33,6 +35,8 @@ interface CardItemProps {
|
|||||||
limit: number;
|
limit: number;
|
||||||
limitInUse?: number;
|
limitInUse?: number;
|
||||||
limitAvailable?: number;
|
limitAvailable?: number;
|
||||||
|
currentInvoiceAmount: number;
|
||||||
|
currentInvoiceLabel: string;
|
||||||
accountName: string;
|
accountName: string;
|
||||||
logo?: string | null;
|
logo?: string | null;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
@@ -52,6 +56,8 @@ export function CardItem({
|
|||||||
limit,
|
limit,
|
||||||
limitInUse,
|
limitInUse,
|
||||||
limitAvailable,
|
limitAvailable,
|
||||||
|
currentInvoiceAmount,
|
||||||
|
currentInvoiceLabel,
|
||||||
accountName: _accountName,
|
accountName: _accountName,
|
||||||
logo,
|
logo,
|
||||||
note,
|
note,
|
||||||
@@ -77,7 +83,7 @@ export function CardItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-col p-6 w-full">
|
<Card className="flex flex-col p-6 w-full">
|
||||||
<CardHeader className="space-y-2 p-0">
|
<CardHeader className="space-y-1 p-0">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex flex-1 items-center gap-2">
|
<div className="flex flex-1 items-center gap-2">
|
||||||
{logoPath ? (
|
{logoPath ? (
|
||||||
@@ -146,15 +152,17 @@ export function CardItem({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-y py-3 text-sm text-muted-foreground">
|
<div className="flex items-center justify-between text-sm text-muted-foreground rounded-lg py-4 px-2 bg-primary/5">
|
||||||
<span>
|
<span className="inline-flex items-center gap-1">
|
||||||
Fecha em{" "}
|
<RiCalendarCloseLine className="size-4" aria-hidden />
|
||||||
|
Fecha{" "}
|
||||||
<span className="font-semibold text-foreground">
|
<span className="font-semibold text-foreground">
|
||||||
dia {formatDay(closingDay)}
|
dia {formatDay(closingDay)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span className="inline-flex items-center gap-1">
|
||||||
Vence em{" "}
|
<RiCalendarScheduleLine className="size-4" aria-hidden />
|
||||||
|
Vence{" "}
|
||||||
<span className="font-semibold text-foreground">
|
<span className="font-semibold text-foreground">
|
||||||
dia {formatDay(dueDay)}
|
dia {formatDay(dueDay)}
|
||||||
</span>
|
</span>
|
||||||
@@ -165,29 +173,40 @@ export function CardItem({
|
|||||||
<CardContent className="flex flex-1 flex-col gap-4 px-0">
|
<CardContent className="flex flex-1 flex-col gap-4 px-0">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Limite disponível
|
{currentInvoiceLabel}
|
||||||
</span>
|
</span>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={available}
|
amount={currentInvoiceAmount}
|
||||||
className="text-xl font-semibold text-success"
|
className="text-xl font-semibold text-info"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="flex gap-2 justify-between w-full">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
<span className="text-xs text-muted-foreground">Limite total</span>
|
<span className="text-xs text-muted-foreground">Limite total</span>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={limit}
|
amount={limit}
|
||||||
className="text-sm font-semibold text-foreground"
|
className="text-sm font-semibold text-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Limite utilizado
|
Limite utilizado
|
||||||
</span>
|
</span>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={used}
|
amount={used}
|
||||||
className="text-sm font-semibold text-destructive"
|
className="text-sm font-semibold text-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Limite disponível
|
||||||
|
</span>
|
||||||
|
<MoneyValues
|
||||||
|
amount={available}
|
||||||
|
className="text-sm font-semibold text-success"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,7 +219,7 @@ export function CardItem({
|
|||||||
aria-label={`${usagePercent.toFixed(0)}% do limite utilizado`}
|
aria-label={`${usagePercent.toFixed(0)}% do limite utilizado`}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{usagePercent.toFixed(1)}% utilizado
|
{usagePercent.toFixed(0)}% utilizado
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -220,7 +239,7 @@ export function CardItem({
|
|||||||
className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
|
className="flex items-center gap-1 font-medium text-primary transition-opacity hover:opacity-80"
|
||||||
>
|
>
|
||||||
<RiFileList2Line className="size-4" aria-hidden />
|
<RiFileList2Line className="size-4" aria-hidden />
|
||||||
ver fatura
|
fatura
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export function CardsPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-1 xl:grid-cols-3">
|
||||||
{list.map((card) => (
|
{list.map((card) => (
|
||||||
<CardItem
|
<CardItem
|
||||||
key={card.id}
|
key={card.id}
|
||||||
@@ -142,6 +142,8 @@ export function CardsPage({
|
|||||||
limit={card.limit}
|
limit={card.limit}
|
||||||
limitInUse={card.limitInUse ?? null}
|
limitInUse={card.limitInUse ?? null}
|
||||||
limitAvailable={card.limitAvailable ?? card.limit ?? null}
|
limitAvailable={card.limitAvailable ?? card.limit ?? null}
|
||||||
|
currentInvoiceAmount={card.currentInvoiceAmount}
|
||||||
|
currentInvoiceLabel={card.currentInvoiceLabel}
|
||||||
accountName={card.accountName}
|
accountName={card.accountName}
|
||||||
logo={card.logo}
|
logo={card.logo}
|
||||||
note={card.note}
|
note={card.note}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export type Card = {
|
|||||||
accountName: string;
|
accountName: string;
|
||||||
limitInUse: number;
|
limitInUse: number;
|
||||||
limitAvailable: number;
|
limitAvailable: number;
|
||||||
|
currentInvoiceAmount: number;
|
||||||
|
currentInvoiceLabel: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CardFormValues = {
|
export type CardFormValues = {
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
import { and, eq, ilike, isNull, ne, not, or, sql } from "drizzle-orm";
|
import {
|
||||||
import { cards, financialAccounts, transactions } from "@/db/schema";
|
and,
|
||||||
|
eq,
|
||||||
|
ilike,
|
||||||
|
isNotNull,
|
||||||
|
isNull,
|
||||||
|
ne,
|
||||||
|
not,
|
||||||
|
or,
|
||||||
|
sql,
|
||||||
|
} from "drizzle-orm";
|
||||||
|
import { cards, financialAccounts, invoices, transactions } from "@/db/schema";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
|
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
||||||
import { loadLogoOptions } from "@/shared/lib/logo/options";
|
import { loadLogoOptions } from "@/shared/lib/logo/options";
|
||||||
|
import {
|
||||||
|
formatPeriodMonthShort,
|
||||||
|
getCurrentPeriod,
|
||||||
|
parsePeriod,
|
||||||
|
} from "@/shared/utils/period";
|
||||||
|
|
||||||
type CardData = {
|
type CardData = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,6 +31,8 @@ type CardData = {
|
|||||||
limit: number;
|
limit: number;
|
||||||
limitInUse: number;
|
limitInUse: number;
|
||||||
limitAvailable: number;
|
limitAvailable: number;
|
||||||
|
currentInvoiceAmount: number;
|
||||||
|
currentInvoiceLabel: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
accountName: string;
|
accountName: string;
|
||||||
};
|
};
|
||||||
@@ -25,6 +43,11 @@ type AccountSimple = {
|
|||||||
logo: string | null;
|
logo: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function formatCurrentInvoiceLabel(period: string) {
|
||||||
|
const { year } = parsePeriod(period);
|
||||||
|
return `Fatura ${formatPeriodMonthShort(period)}. ${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchCardsByStatus(
|
async function fetchCardsByStatus(
|
||||||
userId: string,
|
userId: string,
|
||||||
archived: boolean,
|
archived: boolean,
|
||||||
@@ -33,7 +56,10 @@ async function fetchCardsByStatus(
|
|||||||
accounts: AccountSimple[];
|
accounts: AccountSimple[];
|
||||||
logoOptions: string[];
|
logoOptions: string[];
|
||||||
}> {
|
}> {
|
||||||
const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([
|
const currentPeriod = getCurrentPeriod();
|
||||||
|
const currentInvoiceLabel = formatCurrentInvoiceLabel(currentPeriod);
|
||||||
|
const [cardRows, accountRows, logoOptions, usageRows, invoiceRows] =
|
||||||
|
await Promise.all([
|
||||||
db.query.cards.findMany({
|
db.query.cards.findMany({
|
||||||
orderBy: (table, { desc }) => [desc(table.name)],
|
orderBy: (table, { desc }) => [desc(table.name)],
|
||||||
where: and(
|
where: and(
|
||||||
@@ -67,10 +93,22 @@ async function fetchCardsByStatus(
|
|||||||
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
total: sql<number>`coalesce(sum(${transactions.amount}), 0)`,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
|
.leftJoin(
|
||||||
|
invoices,
|
||||||
|
and(
|
||||||
|
eq(invoices.userId, transactions.userId),
|
||||||
|
eq(invoices.cardId, transactions.cardId),
|
||||||
|
eq(invoices.period, transactions.period),
|
||||||
|
),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(transactions.userId, userId),
|
eq(transactions.userId, userId),
|
||||||
or(isNull(transactions.isSettled), eq(transactions.isSettled, false)),
|
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
|
// Recorrente no cartão: só consome limite quando a data da ocorrência já passou
|
||||||
or(
|
or(
|
||||||
ne(transactions.condition, "Recorrente"),
|
ne(transactions.condition, "Recorrente"),
|
||||||
@@ -79,6 +117,19 @@ async function fetchCardsByStatus(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.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),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const usageMap = new Map<string, number>();
|
const usageMap = new Map<string, number>();
|
||||||
@@ -86,6 +137,13 @@ async function fetchCardsByStatus(
|
|||||||
if (!row.cardId) return;
|
if (!row.cardId) return;
|
||||||
usageMap.set(row.cardId, Number(row.total ?? 0));
|
usageMap.set(row.cardId, Number(row.total ?? 0));
|
||||||
});
|
});
|
||||||
|
const invoiceMap = new Map<string, number>();
|
||||||
|
invoiceRows.forEach(
|
||||||
|
(row: { cardId: string | null; total: number | null }) => {
|
||||||
|
if (!row.cardId) return;
|
||||||
|
invoiceMap.set(row.cardId, Math.abs(Number(row.total ?? 0)));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const cardList = cardRows.map((card) => ({
|
const cardList = cardRows.map((card) => ({
|
||||||
id: card.id,
|
id: card.id,
|
||||||
@@ -99,13 +157,15 @@ async function fetchCardsByStatus(
|
|||||||
limit: Number(card.limit),
|
limit: Number(card.limit),
|
||||||
limitInUse: (() => {
|
limitInUse: (() => {
|
||||||
const total = usageMap.get(card.id) ?? 0;
|
const total = usageMap.get(card.id) ?? 0;
|
||||||
return total < 0 ? Math.abs(total) : 0;
|
return Math.abs(total);
|
||||||
})(),
|
})(),
|
||||||
limitAvailable: (() => {
|
limitAvailable: (() => {
|
||||||
const total = usageMap.get(card.id) ?? 0;
|
const total = usageMap.get(card.id) ?? 0;
|
||||||
const inUse = total < 0 ? Math.abs(total) : 0;
|
const inUse = Math.abs(total);
|
||||||
return Math.max(Number(card.limit) - inUse, 0);
|
return Math.max(Number(card.limit) - inUse, 0);
|
||||||
})(),
|
})(),
|
||||||
|
currentInvoiceAmount: invoiceMap.get(card.id) ?? 0,
|
||||||
|
currentInvoiceLabel,
|
||||||
accountId: card.accountId,
|
accountId: card.accountId,
|
||||||
accountName:
|
accountName:
|
||||||
(card.financialAccount as { name?: string } | null)?.name ??
|
(card.financialAccount as { name?: string } | null)?.name ??
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ export function DashboardGridEditable({
|
|||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className="absolute inset-0 z-10 bg-background/50 backdrop-blur-xs rounded-lg border border-dashed border-primary flex items-center justify-center">
|
<div className="absolute inset-0 z-10 bg-background/60 backdrop-blur-[1.5px] rounded-lg border border-dashed border-primary flex items-center justify-center">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<RiDragMove2Line className="size-8 text-primary" />
|
<RiDragMove2Line className="size-8 text-primary" />
|
||||||
<span className="text-xs font-medium">
|
<span className="text-xs font-medium">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { MetricsCardInfoButton } from "@/features/dashboard/components/metrics-c
|
|||||||
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
|
import { PercentageChangeIndicator } from "@/features/dashboard/components/percentage-change-indicator";
|
||||||
import type { DashboardCardMetrics } from "@/features/dashboard/overview/dashboard-metrics-queries";
|
import type { DashboardCardMetrics } from "@/features/dashboard/overview/dashboard-metrics-queries";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -102,21 +103,22 @@ const getTrend = (current: number, previous: number): Trend => {
|
|||||||
return "flat";
|
return "flat";
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPercentChange = (current: number, previous: number): string => {
|
const getPercentChange = (current: number, previous: number): string | null => {
|
||||||
const EPSILON = 0.01;
|
const EPSILON = 0.01;
|
||||||
|
|
||||||
if (Math.abs(previous) < EPSILON) {
|
if (Math.abs(previous) < EPSILON) {
|
||||||
if (Math.abs(current) < EPSILON) return "0%";
|
if (Math.abs(current) < EPSILON) return "0%";
|
||||||
return "—";
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const change = ((current - previous) / Math.abs(previous)) * 100;
|
const change = ((current - previous) / Math.abs(previous)) * 100;
|
||||||
if (!Number.isFinite(change)) return "—";
|
if (!Number.isFinite(change)) return null;
|
||||||
|
if (Math.abs(change) < TREND_THRESHOLD) return "0%";
|
||||||
if (change > 999) return "+999%";
|
if (change > 999) return "+999%";
|
||||||
if (change < -999) return "-999%";
|
if (change < -999) return "-999%";
|
||||||
return formatPercentage(change, {
|
return formatPercentage(change, {
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 0,
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 0,
|
||||||
signDisplay: "always",
|
signDisplay: "always",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -160,28 +162,45 @@ export function DashboardMetricsCards({ metrics }: DashboardMetricsCardsProps) {
|
|||||||
<Separator className="mt-1" />
|
<Separator className="mt-1" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="flex flex-col gap-3">
|
<CardContent className="flex flex-col">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 mt-1">
|
<div className="flex items-start justify-between mt-1">
|
||||||
|
<div className="flex flex-col gap-2 min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center">
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
className="text-2xl leading-none font-medium"
|
className="text-2xl leading-none"
|
||||||
amount={metric.current}
|
amount={metric.current}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground gap-1 flex items-center">
|
||||||
|
<span className="text-muted-foreground/50">vs</span>
|
||||||
|
<MoneyValues
|
||||||
|
className="inline text-xs"
|
||||||
|
amount={metric.previous}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
aria-hidden={!percentChange}
|
||||||
|
className={cn(
|
||||||
|
"w-14 justify-center px-0 text-xs",
|
||||||
|
!percentChange && "invisible",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{percentChange ? (
|
||||||
<PercentageChangeIndicator
|
<PercentageChangeIndicator
|
||||||
trend={trend}
|
trend={trend}
|
||||||
label={percentChange}
|
label={percentChange}
|
||||||
positiveTrend={invertTrend ? "down" : "up"}
|
positiveTrend={invertTrend ? "down" : "up"}
|
||||||
showFlatIcon
|
showFlatIcon={false}
|
||||||
className="gap-1"
|
className="shrink-0 justify-center text-center text-xs tabular-nums"
|
||||||
iconClassName="size-3.5"
|
iconClassName="hidden"
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="tabular-nums">0%</span>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
<MoneyValues
|
|
||||||
className="inline text-xs font-medium text-muted-foreground"
|
|
||||||
amount={metric.previous}
|
|
||||||
/>
|
|
||||||
<span className="ml-1">no mês anterior</span>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export function InstallmentAnalysisPage({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* Card de resumo principal */}
|
{/* Card de resumo principal */}
|
||||||
<Card className="border-none bg-primary/10 dark:bg-primary/10">
|
<Card className="border-none bg-primary/10 shadow-none">
|
||||||
<CardContent className="flex flex-col items-start justify-center gap-2 py-2">
|
<CardContent className="flex flex-col items-start justify-center gap-2 py-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Se você pagar tudo que está selecionado:
|
Se você pagar tudo que está selecionado:
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ export function InstallmentGroupCard({
|
|||||||
const hasSelection = selectedInstallments.size > 0;
|
const hasSelection = selectedInstallments.size > 0;
|
||||||
|
|
||||||
const progress =
|
const progress =
|
||||||
group.totalInstallments > 0
|
group.trackedInstallments > 0
|
||||||
? (group.paidInstallments / group.totalInstallments) * 100
|
? (group.paidInstallments / group.trackedInstallments) * 100
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const selectedAmount = group.pendingInstallments
|
const selectedAmount = group.pendingInstallments
|
||||||
@@ -83,6 +83,10 @@ export function InstallmentGroupCard({
|
|||||||
);
|
);
|
||||||
const cardLogoSrc = resolveLogoSrc(group.cartaoLogo);
|
const cardLogoSrc = resolveLogoSrc(group.cartaoLogo);
|
||||||
const cardName = group.cartaoName ?? "Compra parcelada";
|
const cardName = group.cartaoName ?? "Compra parcelada";
|
||||||
|
const untrackedLabel =
|
||||||
|
group.untrackedInstallments === 1
|
||||||
|
? "1 parcela anterior fora do acompanhamento"
|
||||||
|
: `${group.untrackedInstallments} parcelas anteriores fora do acompanhamento`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -153,7 +157,7 @@ export function InstallmentGroupCard({
|
|||||||
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-primary/5 mb-4">
|
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-primary/5 mb-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-xs text-muted-foreground font-medium">
|
<p className="text-xs text-muted-foreground font-medium">
|
||||||
Valor total
|
Valor acompanhado
|
||||||
</p>
|
</p>
|
||||||
<MoneyValues
|
<MoneyValues
|
||||||
amount={totalAmount}
|
amount={totalAmount}
|
||||||
@@ -180,8 +184,8 @@ export function InstallmentGroupCard({
|
|||||||
<div className="flex items-center gap-1 text-muted-foreground">
|
<div className="flex items-center gap-1 text-muted-foreground">
|
||||||
<RiCheckboxCircleFill className="size-3.5 text-success" />
|
<RiCheckboxCircleFill className="size-3.5 text-success" />
|
||||||
<span>
|
<span>
|
||||||
{group.paidInstallments} de {group.totalInstallments} parcelas
|
{group.paidInstallments} de {group.trackedInstallments}{" "}
|
||||||
pagas
|
parcelas acompanhadas pagas
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{unpaidCount > 0 && (
|
{unpaidCount > 0 && (
|
||||||
@@ -198,6 +202,9 @@ export function InstallmentGroupCard({
|
|||||||
className="h-2.5 bg-muted"
|
className="h-2.5 bg-muted"
|
||||||
indicatorClassName="bg-success"
|
indicatorClassName="bg-success"
|
||||||
/>
|
/>
|
||||||
|
{group.untrackedInstallments > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">{untrackedLabel}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Valor selecionado */}
|
{/* Valor selecionado */}
|
||||||
|
|||||||
@@ -19,15 +19,15 @@ type IncomeExpenseBalanceWidgetProps = {
|
|||||||
const chartConfig = {
|
const chartConfig = {
|
||||||
receita: {
|
receita: {
|
||||||
label: "Receita",
|
label: "Receita",
|
||||||
color: "var(--success)",
|
color: "var(--chart-1)",
|
||||||
},
|
},
|
||||||
despesa: {
|
despesa: {
|
||||||
label: "Despesa",
|
label: "Despesa",
|
||||||
color: "var(--destructive)",
|
color: "var(--chart-2)",
|
||||||
},
|
},
|
||||||
balanco: {
|
balanco: {
|
||||||
label: "Balanço",
|
label: "Balanço",
|
||||||
color: "var(--warning)",
|
color: "var(--chart-3)",
|
||||||
},
|
},
|
||||||
} satisfies ChartConfig;
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ export type InstallmentGroup = {
|
|||||||
cartaoDueDay: string | null;
|
cartaoDueDay: string | null;
|
||||||
cartaoLogo: string | null;
|
cartaoLogo: string | null;
|
||||||
totalInstallments: number;
|
totalInstallments: number;
|
||||||
|
trackedStartInstallment: number;
|
||||||
|
trackedInstallments: number;
|
||||||
|
untrackedInstallments: number;
|
||||||
paidInstallments: number;
|
paidInstallments: number;
|
||||||
pendingInstallments: InstallmentDetail[];
|
pendingInstallments: InstallmentDetail[];
|
||||||
totalPendingAmount: number;
|
totalPendingAmount: number;
|
||||||
@@ -153,6 +156,12 @@ export async function fetchInstallmentAnalysis(
|
|||||||
cartaoDueDay: row.cartaoDueDay,
|
cartaoDueDay: row.cartaoDueDay,
|
||||||
cartaoLogo: row.cartaoLogo,
|
cartaoLogo: row.cartaoLogo,
|
||||||
totalInstallments: row.installmentCount ?? 0,
|
totalInstallments: row.installmentCount ?? 0,
|
||||||
|
trackedStartInstallment: installmentDetail.currentInstallment,
|
||||||
|
trackedInstallments: 1,
|
||||||
|
untrackedInstallments: Math.max(
|
||||||
|
0,
|
||||||
|
installmentDetail.currentInstallment - 1,
|
||||||
|
),
|
||||||
paidInstallments: 0,
|
paidInstallments: 0,
|
||||||
pendingInstallments: [installmentDetail],
|
pendingInstallments: [installmentDetail],
|
||||||
totalPendingAmount: amount,
|
totalPendingAmount: amount,
|
||||||
@@ -168,7 +177,13 @@ export async function fetchInstallmentAnalysis(
|
|||||||
const paidCount = group.pendingInstallments.filter(
|
const paidCount = group.pendingInstallments.filter(
|
||||||
(i) => i.isSettled,
|
(i) => i.isSettled,
|
||||||
).length;
|
).length;
|
||||||
|
const trackedStartInstallment = Math.min(
|
||||||
|
...group.pendingInstallments.map((i) => i.currentInstallment),
|
||||||
|
);
|
||||||
group.paidInstallments = paidCount;
|
group.paidInstallments = paidCount;
|
||||||
|
group.trackedStartInstallment = trackedStartInstallment;
|
||||||
|
group.trackedInstallments = group.pendingInstallments.length;
|
||||||
|
group.untrackedInstallments = Math.max(0, trackedStartInstallment - 1);
|
||||||
return group;
|
return group;
|
||||||
})
|
})
|
||||||
// Filtrar apenas séries que têm pelo menos uma parcela em aberto (não paga)
|
// Filtrar apenas séries que têm pelo menos uma parcela em aberto (não paga)
|
||||||
|
|||||||
@@ -274,15 +274,14 @@ const buildPaymentStatusData = (
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target =
|
const isExpense = row.transactionType === TRANSACTION_TYPE_EXPENSE;
|
||||||
row.transactionType === TRANSACTION_TYPE_INCOME
|
const target = isExpense ? result.expenses : result.income;
|
||||||
? result.income
|
const displayAmount = isExpense ? Math.abs(amount) : amount;
|
||||||
: result.expenses;
|
|
||||||
|
|
||||||
if (row.isSettled === true) {
|
if (row.isSettled === true) {
|
||||||
target.confirmed += amount;
|
target.confirmed += displayAmount;
|
||||||
} else {
|
} else {
|
||||||
target.pending += amount;
|
target.pending += displayAmount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -213,8 +213,8 @@ export const InboxCard = memo(function InboxCard({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => onViewDetails?.(item)}
|
onClick={() => onViewDetails?.(item)}
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
aria-label="Ver detalhes"
|
aria-label="detalhes"
|
||||||
title="Ver detalhes"
|
title="detalhes"
|
||||||
>
|
>
|
||||||
<RiFileList2Line className="size-4" />
|
<RiFileList2Line className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -23,9 +23,14 @@ const navLinks = [
|
|||||||
interface MobileNavProps {
|
interface MobileNavProps {
|
||||||
isPublicDomain: boolean;
|
isPublicDomain: boolean;
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
|
signupDisabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MobileNav({ isPublicDomain, isLoggedIn }: MobileNavProps) {
|
export function MobileNav({
|
||||||
|
isPublicDomain,
|
||||||
|
isLoggedIn,
|
||||||
|
signupDisabled,
|
||||||
|
}: MobileNavProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -75,12 +80,14 @@ export function MobileNav({ isPublicDomain, isLoggedIn }: MobileNavProps) {
|
|||||||
Entrar
|
Entrar
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
{!signupDisabled && (
|
||||||
<Link href="/signup" onClick={() => setOpen(false)}>
|
<Link href="/signup" onClick={() => setOpen(false)}>
|
||||||
<Button className="w-full gap-2">
|
<Button className="w-full gap-2">
|
||||||
Começar
|
Começar
|
||||||
<RiArrowRightSLine size={16} />
|
<RiArrowRightSLine size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export function DeleteAccountForm() {
|
|||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{isResetAction ? "Zerar sua conta?" : "Você tem certeza?"}
|
{isResetAction ? "ZERAR sua conta?" : "Você tem certeza?"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isResetAction
|
{isResetAction
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
TRANSACTION_CONDITIONS,
|
TRANSACTION_CONDITIONS,
|
||||||
TRANSACTION_TYPES,
|
TRANSACTION_TYPES,
|
||||||
} from "@/features/transactions/lib/constants";
|
} from "@/features/transactions/lib/constants";
|
||||||
|
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
||||||
import { handleActionError } from "@/shared/lib/actions/helpers";
|
import { handleActionError } from "@/shared/lib/actions/helpers";
|
||||||
import { getUser } from "@/shared/lib/auth/server";
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
fetchOwnedPayerIds,
|
fetchOwnedPayerIds,
|
||||||
formatPaidInvoicePeriods,
|
formatPaidInvoicePeriods,
|
||||||
getPaidInvoicePeriods,
|
getPaidInvoicePeriods,
|
||||||
|
isInitialBalanceTransaction,
|
||||||
type MassAddInput,
|
type MassAddInput,
|
||||||
massAddSchema,
|
massAddSchema,
|
||||||
resolvePeriod,
|
resolvePeriod,
|
||||||
@@ -47,6 +49,19 @@ const getPeriodOffset = (basePeriod: string, targetPeriod: string) => {
|
|||||||
return (target.year - base.year) * 12 + (target.month - base.month);
|
return (target.year - base.year) * 12 + (target.month - base.month);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ProtectedTransactionCandidate = {
|
||||||
|
note: string | null;
|
||||||
|
transactionType: string | null;
|
||||||
|
condition: string | null;
|
||||||
|
paymentMethod: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isProtectedTransaction = (
|
||||||
|
record: ProtectedTransactionCandidate,
|
||||||
|
): boolean =>
|
||||||
|
Boolean(record.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
|
||||||
|
isInitialBalanceTransaction(record);
|
||||||
|
|
||||||
export async function deleteTransactionBulkAction(
|
export async function deleteTransactionBulkAction(
|
||||||
input: DeleteBulkInput,
|
input: DeleteBulkInput,
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
@@ -61,6 +76,9 @@ export async function deleteTransactionBulkAction(
|
|||||||
seriesId: true,
|
seriesId: true,
|
||||||
period: true,
|
period: true,
|
||||||
condition: true,
|
condition: true,
|
||||||
|
transactionType: true,
|
||||||
|
paymentMethod: true,
|
||||||
|
note: true,
|
||||||
},
|
},
|
||||||
where: and(
|
where: and(
|
||||||
eq(transactions.id, data.id),
|
eq(transactions.id, data.id),
|
||||||
@@ -79,6 +97,13 @@ export async function deleteTransactionBulkAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isProtectedTransaction(existing)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Lançamentos protegidos não podem ser removidos em massa.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let scopeFilter: ReturnType<typeof and>;
|
let scopeFilter: ReturnType<typeof and>;
|
||||||
let successMessage: string;
|
let successMessage: string;
|
||||||
|
|
||||||
@@ -171,6 +196,7 @@ export async function updateTransactionBulkAction(
|
|||||||
purchaseDate: true,
|
purchaseDate: true,
|
||||||
payerId: true,
|
payerId: true,
|
||||||
cardId: true,
|
cardId: true,
|
||||||
|
note: true,
|
||||||
},
|
},
|
||||||
where: and(
|
where: and(
|
||||||
eq(transactions.id, data.id),
|
eq(transactions.id, data.id),
|
||||||
@@ -189,6 +215,13 @@ export async function updateTransactionBulkAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isProtectedTransaction(existing)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Lançamentos protegidos não podem ser atualizados em massa.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const baseUpdatePayload: Record<string, unknown> = {
|
const baseUpdatePayload: Record<string, unknown> = {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
categoryId: data.categoryId ?? null,
|
categoryId: data.categoryId ?? null,
|
||||||
@@ -753,6 +786,13 @@ export async function deleteMultipleTransactionsAction(
|
|||||||
return { success: false, error: "Nenhum lançamento encontrado." };
|
return { success: false, error: "Nenhum lançamento encontrado." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (existing.some(isProtectedTransaction)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Lançamentos protegidos não podem ser removidos em massa.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const linkedAttachments = await db
|
const linkedAttachments = await db
|
||||||
.select({ id: attachments.id, fileKey: attachments.fileKey })
|
.select({ id: attachments.id, fileKey: attachments.fileKey })
|
||||||
.from(transactionAttachments)
|
.from(transactionAttachments)
|
||||||
|
|||||||
@@ -335,6 +335,12 @@ const baseFields = z.object({
|
|||||||
.min(1, "Selecione uma quantidade válida.")
|
.min(1, "Selecione uma quantidade válida.")
|
||||||
.max(60, "Selecione uma quantidade válida.")
|
.max(60, "Selecione uma quantidade válida.")
|
||||||
.optional(),
|
.optional(),
|
||||||
|
startInstallment: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1, "Selecione uma parcela válida.")
|
||||||
|
.max(60, "Selecione uma parcela válida.")
|
||||||
|
.optional(),
|
||||||
recurrenceCount: z.coerce
|
recurrenceCount: z.coerce
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
@@ -415,6 +421,15 @@ const refineLancamento = (
|
|||||||
path: ["installmentCount"],
|
path: ["installmentCount"],
|
||||||
message: "Selecione pelo menos duas parcelas.",
|
message: "Selecione pelo menos duas parcelas.",
|
||||||
});
|
});
|
||||||
|
} else if (
|
||||||
|
data.startInstallment &&
|
||||||
|
data.startInstallment > data.installmentCount
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["startInstallment"],
|
||||||
|
message: "A parcela inicial não pode ser maior que o total.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,24 +666,27 @@ export const buildTransactionRecords = ({
|
|||||||
|
|
||||||
if (data.condition === "Parcelado") {
|
if (data.condition === "Parcelado") {
|
||||||
const installmentTotal = data.installmentCount ?? 0;
|
const installmentTotal = data.installmentCount ?? 0;
|
||||||
|
const startInstallment = data.startInstallment ?? 1;
|
||||||
const amountsByShare = shares.map((share) =>
|
const amountsByShare = shares.map((share) =>
|
||||||
splitAmount(share.amountCents, installmentTotal),
|
splitAmount(share.amountCents, installmentTotal),
|
||||||
);
|
);
|
||||||
|
|
||||||
for (
|
for (
|
||||||
let installment = 0;
|
let index = 0;
|
||||||
installment < installmentTotal;
|
index <= installmentTotal - startInstallment;
|
||||||
installment += 1
|
index += 1
|
||||||
) {
|
) {
|
||||||
const installmentPeriod = addMonthsToPeriod(period, installment);
|
const currentInstallment = startInstallment + index;
|
||||||
|
const installmentPeriod = addMonthsToPeriod(period, index);
|
||||||
const installmentDueDate = dueDate
|
const installmentDueDate = dueDate
|
||||||
? addMonthsToDate(dueDate, installment)
|
? addMonthsToDate(dueDate, index)
|
||||||
: null;
|
: null;
|
||||||
const splitGroupId = cycleSplitGroupId();
|
const splitGroupId = cycleSplitGroupId();
|
||||||
|
|
||||||
shares.forEach((share, shareIndex) => {
|
shares.forEach((share, shareIndex) => {
|
||||||
const amountCents = amountsByShare[shareIndex]?.[installment] ?? 0;
|
const amountCents =
|
||||||
const settled = resolveSettledValue(installment);
|
amountsByShare[shareIndex]?.[currentInstallment - 1] ?? 0;
|
||||||
|
const settled = resolveSettledValue(index);
|
||||||
records.push({
|
records.push({
|
||||||
...basePayload,
|
...basePayload,
|
||||||
amount: centsToDecimalString(amountCents * amountSign),
|
amount: centsToDecimalString(amountCents * amountSign),
|
||||||
@@ -677,7 +695,7 @@ export const buildTransactionRecords = ({
|
|||||||
period: installmentPeriod,
|
period: installmentPeriod,
|
||||||
isSettled: settled,
|
isSettled: settled,
|
||||||
installmentCount: installmentTotal,
|
installmentCount: installmentTotal,
|
||||||
currentInstallment: installment + 1,
|
currentInstallment,
|
||||||
recurrenceCount: null,
|
recurrenceCount: null,
|
||||||
dueDate: installmentDueDate,
|
dueDate: installmentDueDate,
|
||||||
splitGroupId,
|
splitGroupId,
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { and, eq, inArray } from "drizzle-orm";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { transactions } from "@/db/schema";
|
import { transactions } from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
|
fetchOwnedCategoryIds,
|
||||||
|
fetchOwnedPayerIds,
|
||||||
validateCartaoOwnership,
|
validateCartaoOwnership,
|
||||||
validateContaOwnership,
|
validateContaOwnership,
|
||||||
validatePayerOwnership,
|
|
||||||
} from "@/features/transactions/actions/core";
|
} from "@/features/transactions/actions/core";
|
||||||
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
|
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
|
||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
@@ -21,6 +22,7 @@ const importRowSchema = z.object({
|
|||||||
description: z.string().min(1, "Descrição obrigatória."),
|
description: z.string().min(1, "Descrição obrigatória."),
|
||||||
transactionType: z.enum(["income", "expense"]),
|
transactionType: z.enum(["income", "expense"]),
|
||||||
categoryId: uuidSchema("Category").nullable().optional(),
|
categoryId: uuidSchema("Category").nullable().optional(),
|
||||||
|
payerId: uuidSchema("Payer").nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const importSchema = z.object({
|
const importSchema = z.object({
|
||||||
@@ -76,14 +78,34 @@ export async function importTransactionsAction(
|
|||||||
const { rows, payerId, accountId, cardId, paymentMethod, invoicePeriod } =
|
const { rows, payerId, accountId, cardId, paymentMethod, invoicePeriod } =
|
||||||
parsed.data;
|
parsed.data;
|
||||||
|
|
||||||
|
const payerIdsByRow = rows.map((row) => row.payerId ?? payerId ?? null);
|
||||||
|
|
||||||
|
if (payerIdsByRow.some((id) => !id)) {
|
||||||
|
return { success: false, error: "Pessoa obrigatória." };
|
||||||
|
}
|
||||||
|
|
||||||
// Valida ownership
|
// Valida ownership
|
||||||
const [payerOk, accountOk, cardOk] = await Promise.all([
|
const [ownedPayerIds, ownedCategoryIds, accountOk, cardOk] =
|
||||||
validatePayerOwnership(userId, payerId),
|
await Promise.all([
|
||||||
|
fetchOwnedPayerIds(userId, payerIdsByRow),
|
||||||
|
fetchOwnedCategoryIds(
|
||||||
|
userId,
|
||||||
|
rows.map((row) => row.categoryId),
|
||||||
|
),
|
||||||
validateContaOwnership(userId, accountId),
|
validateContaOwnership(userId, accountId),
|
||||||
validateCartaoOwnership(userId, cardId),
|
validateCartaoOwnership(userId, cardId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!payerOk) return { success: false, error: "Pessoa não encontrada." };
|
if (payerIdsByRow.some((id) => id && !ownedPayerIds.has(id))) {
|
||||||
|
return { success: false, error: "Pessoa não encontrada." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
rows.some((row) => row.categoryId && !ownedCategoryIds.has(row.categoryId))
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Categoria não encontrada." };
|
||||||
|
}
|
||||||
|
|
||||||
if (!accountOk) return { success: false, error: "Conta não encontrada." };
|
if (!accountOk) return { success: false, error: "Conta não encontrada." };
|
||||||
if (!cardOk) return { success: false, error: "Cartão não encontrado." };
|
if (!cardOk) return { success: false, error: "Cartão não encontrado." };
|
||||||
|
|
||||||
@@ -96,7 +118,7 @@ export async function importTransactionsAction(
|
|||||||
// Cartão de crédito: fatura pode ainda não ter sido paga
|
// Cartão de crédito: fatura pode ainda não ter sido paga
|
||||||
const isSettled = paymentMethod !== "Cartão de crédito";
|
const isSettled = paymentMethod !== "Cartão de crédito";
|
||||||
|
|
||||||
const records = rows.map((row) => {
|
const records = rows.map((row, index) => {
|
||||||
const purchaseDate = parseLocalDateString(row.date);
|
const purchaseDate = parseLocalDateString(row.date);
|
||||||
const period =
|
const period =
|
||||||
invoicePeriod ??
|
invoicePeriod ??
|
||||||
@@ -115,7 +137,7 @@ export async function importTransactionsAction(
|
|||||||
period,
|
period,
|
||||||
isSettled,
|
isSettled,
|
||||||
userId,
|
userId,
|
||||||
payerId: payerId ?? null,
|
payerId: payerIdsByRow[index],
|
||||||
accountId: accountId ?? null,
|
accountId: accountId ?? null,
|
||||||
cardId: cardId ?? null,
|
cardId: cardId ?? null,
|
||||||
categoryId: row.categoryId ?? null,
|
categoryId: row.categoryId ?? null,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
transactionAttachments,
|
transactionAttachments,
|
||||||
transactions,
|
transactions,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
|
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
||||||
import { handleActionError } from "@/shared/lib/actions/helpers";
|
import { handleActionError } from "@/shared/lib/actions/helpers";
|
||||||
import { getUser } from "@/shared/lib/auth/server";
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
@@ -230,13 +231,6 @@ export async function updateTransactionAction(
|
|||||||
eq(transactions.id, data.id),
|
eq(transactions.id, data.id),
|
||||||
eq(transactions.userId, user.id),
|
eq(transactions.userId, user.id),
|
||||||
),
|
),
|
||||||
with: {
|
|
||||||
category: {
|
|
||||||
columns: {
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})) as
|
})) as
|
||||||
| {
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -248,7 +242,6 @@ export async function updateTransactionAction(
|
|||||||
accountId: string | null;
|
accountId: string | null;
|
||||||
cardId: string | null;
|
cardId: string | null;
|
||||||
categoryId: string | null;
|
categoryId: string | null;
|
||||||
category: { name: string } | null;
|
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
@@ -256,14 +249,17 @@ export async function updateTransactionAction(
|
|||||||
return { success: false, error: "Lançamento não encontrado." };
|
return { success: false, error: "Lançamento não encontrado." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoriasProtegidasEdicao = ["Saldo inicial", "Pagamentos"];
|
if (existing.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
|
||||||
if (
|
|
||||||
existing.category?.name &&
|
|
||||||
categoriasProtegidasEdicao.includes(existing.category.name)
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `Lançamentos com a categoria '${existing.category.name}' não podem ser editados.`,
|
error: "Pagamentos automáticos de fatura não podem ser editados.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInitialBalanceTransaction(existing)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Lançamentos de saldo inicial não podem ser editados.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,13 +387,6 @@ export async function deleteTransactionAction(
|
|||||||
eq(transactions.id, data.id),
|
eq(transactions.id, data.id),
|
||||||
eq(transactions.userId, user.id),
|
eq(transactions.userId, user.id),
|
||||||
),
|
),
|
||||||
with: {
|
|
||||||
category: {
|
|
||||||
columns: {
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})) as
|
})) as
|
||||||
| {
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -411,7 +400,6 @@ export async function deleteTransactionAction(
|
|||||||
period: string;
|
period: string;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
categoryId: string | null;
|
categoryId: string | null;
|
||||||
category: { name: string } | null;
|
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
@@ -419,14 +407,17 @@ export async function deleteTransactionAction(
|
|||||||
return { success: false, error: "Lançamento não encontrado." };
|
return { success: false, error: "Lançamento não encontrado." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoriasProtegidasRemocao = ["Saldo inicial", "Pagamentos"];
|
if (existing.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
|
||||||
if (
|
|
||||||
existing.category?.name &&
|
|
||||||
categoriasProtegidasRemocao.includes(existing.category.name)
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `Lançamentos com a categoria '${existing.category.name}' não podem ser removidos.`,
|
error: "Pagamentos automáticos de fatura não podem ser removidos.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInitialBalanceTransaction(existing)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Lançamentos de saldo inicial não podem ser removidos.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { TRANSACTION_CONDITIONS } from "@/features/transactions/lib/constants";
|
import { TRANSACTION_CONDITIONS } from "@/features/transactions/lib/constants";
|
||||||
import { Label } from "@/shared/components/ui/label";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/shared/components/ui/popover";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -14,6 +20,61 @@ import { cn } from "@/shared/utils/ui";
|
|||||||
import { ConditionSelectContent } from "../../select-items";
|
import { ConditionSelectContent } from "../../select-items";
|
||||||
import type { ConditionSectionProps } from "./transaction-dialog-types";
|
import type { ConditionSectionProps } from "./transaction-dialog-types";
|
||||||
|
|
||||||
|
function InlineStartInstallmentPicker({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
options: number[];
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const selected = Number(value || "1");
|
||||||
|
const selectedLabel =
|
||||||
|
!Number.isNaN(selected) && selected > 0
|
||||||
|
? `${selected}ª parcela`
|
||||||
|
: "1ª parcela";
|
||||||
|
const disabled = options.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ml-1">
|
||||||
|
<span className="text-xs text-muted-foreground">Começar em </span>
|
||||||
|
<Popover modal open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cursor-pointer text-xs text-primary underline-offset-2 hover:underline disabled:cursor-not-allowed disabled:text-muted-foreground disabled:hover:no-underline"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{selectedLabel}
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-40 p-1" align="start">
|
||||||
|
<div className="max-h-56 overflow-y-auto">
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
option === selected && "font-medium text-primary",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(String(option));
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option}ª parcela
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ConditionSection({
|
export function ConditionSection({
|
||||||
formState,
|
formState,
|
||||||
onFieldChange,
|
onFieldChange,
|
||||||
@@ -37,11 +98,17 @@ export function ConditionSection({
|
|||||||
const installmentSummary =
|
const installmentSummary =
|
||||||
showInstallments &&
|
showInstallments &&
|
||||||
formState.installmentCount &&
|
formState.installmentCount &&
|
||||||
amount &&
|
|
||||||
!Number.isNaN(installmentCount) &&
|
!Number.isNaN(installmentCount) &&
|
||||||
installmentCount > 0
|
installmentCount > 0
|
||||||
? getInstallmentLabel(installmentCount)
|
? getInstallmentLabel(installmentCount)
|
||||||
: null;
|
: null;
|
||||||
|
const startInstallmentOptions =
|
||||||
|
showInstallments &&
|
||||||
|
formState.installmentCount &&
|
||||||
|
!Number.isNaN(installmentCount) &&
|
||||||
|
installmentCount > 0
|
||||||
|
? Array.from({ length: installmentCount }, (_, index) => index + 1)
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||||
@@ -96,6 +163,11 @@ export function ConditionSection({
|
|||||||
})}
|
})}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<InlineStartInstallmentPicker
|
||||||
|
value={formState.startInstallment}
|
||||||
|
options={startInstallmentOptions}
|
||||||
|
onChange={(value) => onFieldChange("startInstallment", value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface TransactionDialogProps {
|
|||||||
estabelecimentos: string[];
|
estabelecimentos: string[];
|
||||||
transaction?: TransactionItem;
|
transaction?: TransactionItem;
|
||||||
defaultPeriod?: string;
|
defaultPeriod?: string;
|
||||||
|
defaultAccountId?: string | null;
|
||||||
defaultCardId?: string | null;
|
defaultCardId?: string | null;
|
||||||
defaultPaymentMethod?: string | null;
|
defaultPaymentMethod?: string | null;
|
||||||
defaultPurchaseDate?: string | null;
|
defaultPurchaseDate?: string | null;
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export function TransactionDialog({
|
|||||||
estabelecimentos,
|
estabelecimentos,
|
||||||
transaction,
|
transaction,
|
||||||
defaultPeriod,
|
defaultPeriod,
|
||||||
|
defaultAccountId,
|
||||||
defaultCardId,
|
defaultCardId,
|
||||||
defaultPaymentMethod,
|
defaultPaymentMethod,
|
||||||
defaultPurchaseDate,
|
defaultPurchaseDate,
|
||||||
@@ -88,6 +89,7 @@ export function TransactionDialog({
|
|||||||
|
|
||||||
const [formState, setFormState] = useState<FormState>(() =>
|
const [formState, setFormState] = useState<FormState>(() =>
|
||||||
buildTransactionInitialState(transaction, defaultPayerId, defaultPeriod, {
|
buildTransactionInitialState(transaction, defaultPayerId, defaultPeriod, {
|
||||||
|
defaultAccountId,
|
||||||
defaultCardId,
|
defaultCardId,
|
||||||
defaultPaymentMethod,
|
defaultPaymentMethod,
|
||||||
defaultPurchaseDate,
|
defaultPurchaseDate,
|
||||||
@@ -112,6 +114,7 @@ export function TransactionDialog({
|
|||||||
defaultPayerId,
|
defaultPayerId,
|
||||||
defaultPeriod,
|
defaultPeriod,
|
||||||
{
|
{
|
||||||
|
defaultAccountId,
|
||||||
defaultCardId,
|
defaultCardId,
|
||||||
defaultPaymentMethod,
|
defaultPaymentMethod,
|
||||||
defaultPurchaseDate,
|
defaultPurchaseDate,
|
||||||
@@ -151,6 +154,7 @@ export function TransactionDialog({
|
|||||||
transaction,
|
transaction,
|
||||||
defaultPayerId,
|
defaultPayerId,
|
||||||
defaultPeriod,
|
defaultPeriod,
|
||||||
|
defaultAccountId,
|
||||||
defaultCardId,
|
defaultCardId,
|
||||||
defaultPaymentMethod,
|
defaultPaymentMethod,
|
||||||
defaultPurchaseDate,
|
defaultPurchaseDate,
|
||||||
@@ -327,6 +331,12 @@ export function TransactionDialog({
|
|||||||
formState.condition === "Parcelado" && formState.installmentCount
|
formState.condition === "Parcelado" && formState.installmentCount
|
||||||
? Number(formState.installmentCount)
|
? Number(formState.installmentCount)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
startInstallment:
|
||||||
|
mode === "create" &&
|
||||||
|
formState.condition === "Parcelado" &&
|
||||||
|
formState.startInstallment
|
||||||
|
? Number(formState.startInstallment)
|
||||||
|
: undefined,
|
||||||
recurrenceCount:
|
recurrenceCount:
|
||||||
formState.condition === "Recorrente" && formState.recurrenceCount
|
formState.condition === "Recorrente" && formState.recurrenceCount
|
||||||
? Number(formState.recurrenceCount)
|
? Number(formState.recurrenceCount)
|
||||||
|
|||||||
@@ -74,16 +74,16 @@ export function GlobalFields({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Aplicado a todos os lançamentos importados.
|
Aplicado aos lançamentos selecionados.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="grid w-full grid-cols-1 items-end justify-start gap-3 sm:grid-cols-[repeat(2,minmax(0,14rem))] lg:grid-cols-[16rem_14rem_18rem_14rem]">
|
||||||
<div className="flex min-w-44 flex-col gap-1.5">
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
<Label>Conta / Cartão</Label>
|
<Label>Conta / Cartão</Label>
|
||||||
<Select
|
<Select
|
||||||
value={accountCardValue ?? ""}
|
value={accountCardValue ?? ""}
|
||||||
onValueChange={(v) => onAccountCardChange(v || null)}
|
onValueChange={(v) => onAccountCardChange(v || null)}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Selecionar conta ou cartão…" />
|
<SelectValue placeholder="Selecionar conta ou cartão…" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -122,14 +122,14 @@ export function GlobalFields({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-w-44 flex-col gap-1.5">
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
<Label>Pessoa</Label>
|
<Label>Pessoa</Label>
|
||||||
<Select
|
<Select
|
||||||
value={payerId ?? ""}
|
value={payerId ?? ""}
|
||||||
onValueChange={(v) => onPayerChange(v || null)}
|
onValueChange={(v) => onPayerChange(v || null)}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Selecionar pessoa…" />
|
<SelectValue placeholder="Aplicar pessoa…" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{payerOptions.map((opt) => (
|
{payerOptions.map((opt) => (
|
||||||
@@ -144,10 +144,10 @@ export function GlobalFields({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-w-44 flex-col gap-1.5">
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
<Label>Categoria</Label>
|
<Label>Categoria</Label>
|
||||||
<Select onValueChange={onBulkCategoryChange}>
|
<Select onValueChange={onBulkCategoryChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Aplicar a todas selecionadas…" />
|
<SelectValue placeholder="Aplicar a todas selecionadas…" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -185,7 +185,7 @@ export function GlobalFields({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isCard && (
|
{isCard && (
|
||||||
<div className="flex min-w-44 flex-col gap-1.5">
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
<Label>Fatura</Label>
|
<Label>Fatura</Label>
|
||||||
<PeriodPicker
|
<PeriodPicker
|
||||||
value={invoicePeriod ?? ""}
|
value={invoicePeriod ?? ""}
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ import {
|
|||||||
import { Skeleton } from "@/shared/components/ui/skeleton";
|
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||||
import type { ImportStatement } from "@/shared/lib/import/types";
|
import type { ImportStatement } from "@/shared/lib/import/types";
|
||||||
|
|
||||||
|
const categoryGroupByTransactionType = {
|
||||||
|
expense: "despesa",
|
||||||
|
income: "receita",
|
||||||
|
} as const;
|
||||||
|
|
||||||
interface ImportPageProps {
|
interface ImportPageProps {
|
||||||
payerOptions: SelectOption[];
|
payerOptions: SelectOption[];
|
||||||
accountOptions: SelectOption[];
|
accountOptions: SelectOption[];
|
||||||
@@ -69,7 +74,25 @@ export function ImportPage({
|
|||||||
const [accountCardValue, setAccountCardValue] = useState<string | null>(null);
|
const [accountCardValue, setAccountCardValue] = useState<string | null>(null);
|
||||||
const [invoicePeriod, setInvoicePeriod] = useState<string | null>(null);
|
const [invoicePeriod, setInvoicePeriod] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleParsed = useCallback(async (stmt: ImportStatement) => {
|
const categoryGroupById = useMemo(
|
||||||
|
() =>
|
||||||
|
new Map(categoryOptions.map((option) => [option.value, option.group])),
|
||||||
|
[categoryOptions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isCategoryCompatible = useCallback(
|
||||||
|
(
|
||||||
|
categoryId: string | null,
|
||||||
|
transactionType: ReviewRow["transactionType"],
|
||||||
|
) =>
|
||||||
|
!categoryId ||
|
||||||
|
categoryGroupById.get(categoryId) ===
|
||||||
|
categoryGroupByTransactionType[transactionType],
|
||||||
|
[categoryGroupById],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleParsed = useCallback(
|
||||||
|
async (stmt: ImportStatement) => {
|
||||||
setStatement(stmt);
|
setStatement(stmt);
|
||||||
setIsChecking(true);
|
setIsChecking(true);
|
||||||
|
|
||||||
@@ -84,18 +107,30 @@ export function ImportPage({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
setRows(
|
setRows(
|
||||||
stmt.transactions.map((t) => ({
|
stmt.transactions.map((t) => {
|
||||||
|
const mappedCategoryId =
|
||||||
|
categoryMappings[normalizeDescriptionKey(t.description)] ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
...t,
|
...t,
|
||||||
isDuplicate: t.externalId ? duplicates.has(t.externalId) : false,
|
isDuplicate: t.externalId ? duplicates.has(t.externalId) : false,
|
||||||
selected: t.externalId ? !duplicates.has(t.externalId) : true,
|
selected: t.externalId ? !duplicates.has(t.externalId) : true,
|
||||||
categoryId:
|
payerId,
|
||||||
categoryMappings[normalizeDescriptionKey(t.description)] ?? null,
|
categoryId: isCategoryCompatible(
|
||||||
})),
|
mappedCategoryId,
|
||||||
|
t.transactionType,
|
||||||
|
)
|
||||||
|
? mappedCategoryId
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsChecking(false);
|
setIsChecking(false);
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[isCategoryCompatible, payerId],
|
||||||
|
);
|
||||||
|
|
||||||
// Pré-seleciona cartão ou conta com base no tipo detectado no OFX
|
// Pré-seleciona cartão ou conta com base no tipo detectado no OFX
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -121,7 +156,17 @@ export function ImportPage({
|
|||||||
|
|
||||||
const handleCategoryChange = (index: number, categoryId: string | null) => {
|
const handleCategoryChange = (index: number, categoryId: string | null) => {
|
||||||
setRows((prev) =>
|
setRows((prev) =>
|
||||||
prev.map((r, i) => (i === index ? { ...r, categoryId } : r)),
|
prev.map((r, i) =>
|
||||||
|
i === index && isCategoryCompatible(categoryId, r.transactionType)
|
||||||
|
? { ...r, categoryId }
|
||||||
|
: r,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePayerChange = (index: number, payerId: string | null) => {
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((r, i) => (i === index ? { ...r, payerId } : r)),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,17 +195,36 @@ export function ImportPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBulkCategoryChange = (categoryId: string) => {
|
const handleBulkCategoryChange = (categoryId: string) => {
|
||||||
setRows((prev) => prev.map((r) => (r.selected ? { ...r, categoryId } : r)));
|
setRows((prev) =>
|
||||||
|
prev.map((r) =>
|
||||||
|
r.selected && isCategoryCompatible(categoryId, r.transactionType)
|
||||||
|
? { ...r, categoryId }
|
||||||
|
: r,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkPayerChange = (nextPayerId: string | null) => {
|
||||||
|
setPayerId(nextPayerId);
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((r) => (r.selected ? { ...r, payerId: nextPayerId } : r)),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isCard = accountCardValue?.startsWith("card:") ?? false;
|
const isCard = accountCardValue?.startsWith("card:") ?? false;
|
||||||
|
|
||||||
const { selectedRows, duplicateCount, uncategorizedCount } = useMemo(() => {
|
const {
|
||||||
|
selectedRows,
|
||||||
|
duplicateCount,
|
||||||
|
uncategorizedCount,
|
||||||
|
withoutPayerCount,
|
||||||
|
} = useMemo(() => {
|
||||||
const selected = rows.filter((r) => r.selected);
|
const selected = rows.filter((r) => r.selected);
|
||||||
return {
|
return {
|
||||||
selectedRows: selected,
|
selectedRows: selected,
|
||||||
duplicateCount: rows.filter((r) => r.isDuplicate).length,
|
duplicateCount: rows.filter((r) => r.isDuplicate).length,
|
||||||
uncategorizedCount: selected.filter((r) => !r.categoryId).length,
|
uncategorizedCount: selected.filter((r) => !r.categoryId).length,
|
||||||
|
withoutPayerCount: selected.filter((r) => !r.payerId).length,
|
||||||
};
|
};
|
||||||
}, [rows]);
|
}, [rows]);
|
||||||
|
|
||||||
@@ -168,6 +232,7 @@ export function ImportPage({
|
|||||||
selectedRows.length > 0 &&
|
selectedRows.length > 0 &&
|
||||||
!!accountCardValue &&
|
!!accountCardValue &&
|
||||||
uncategorizedCount === 0 &&
|
uncategorizedCount === 0 &&
|
||||||
|
withoutPayerCount === 0 &&
|
||||||
(!isCard || !!invoicePeriod) &&
|
(!isCard || !!invoicePeriod) &&
|
||||||
!isPending;
|
!isPending;
|
||||||
|
|
||||||
@@ -191,6 +256,7 @@ export function ImportPage({
|
|||||||
description: r.description,
|
description: r.description,
|
||||||
transactionType: r.transactionType,
|
transactionType: r.transactionType,
|
||||||
categoryId: r.categoryId,
|
categoryId: r.categoryId,
|
||||||
|
payerId: r.payerId,
|
||||||
})),
|
})),
|
||||||
payerId,
|
payerId,
|
||||||
accountId,
|
accountId,
|
||||||
@@ -280,6 +346,7 @@ export function ImportPage({
|
|||||||
selected={selectedRows.length}
|
selected={selectedRows.length}
|
||||||
duplicates={duplicateCount}
|
duplicates={duplicateCount}
|
||||||
uncategorized={uncategorizedCount}
|
uncategorized={uncategorizedCount}
|
||||||
|
withoutPayer={withoutPayerCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<GlobalFields
|
<GlobalFields
|
||||||
@@ -291,23 +358,25 @@ export function ImportPage({
|
|||||||
payerId={payerId}
|
payerId={payerId}
|
||||||
invoicePeriod={invoicePeriod}
|
invoicePeriod={invoicePeriod}
|
||||||
onAccountCardChange={setAccountCardValue}
|
onAccountCardChange={setAccountCardValue}
|
||||||
onPayerChange={setPayerId}
|
onPayerChange={handleBulkPayerChange}
|
||||||
onInvoicePeriodChange={setInvoicePeriod}
|
onInvoicePeriodChange={setInvoicePeriod}
|
||||||
onBulkCategoryChange={handleBulkCategoryChange}
|
onBulkCategoryChange={handleBulkCategoryChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ReviewTable
|
<ReviewTable
|
||||||
rows={rows}
|
rows={rows}
|
||||||
|
payerOptions={payerOptions}
|
||||||
categoryOptions={categoryOptions}
|
categoryOptions={categoryOptions}
|
||||||
onToggle={toggleRow}
|
onToggle={toggleRow}
|
||||||
onToggleAll={toggleAll}
|
onToggleAll={toggleAll}
|
||||||
|
onPayerChange={handlePayerChange}
|
||||||
onCategoryChange={handleCategoryChange}
|
onCategoryChange={handleCategoryChange}
|
||||||
onDescriptionChange={handleDescriptionChange}
|
onDescriptionChange={handleDescriptionChange}
|
||||||
onUndoDuplicate={handleUndoDuplicate}
|
onUndoDuplicate={handleUndoDuplicate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Sticky footer */}
|
{/* Sticky footer */}
|
||||||
<div className="sticky bottom-0 -mx-6 border-t bg-background px-6 py-4">
|
<div className="sticky bottom-0 -mx-6 px-6">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface ImportSummaryProps {
|
|||||||
selected: number;
|
selected: number;
|
||||||
duplicates: number;
|
duplicates: number;
|
||||||
uncategorized: number;
|
uncategorized: number;
|
||||||
|
withoutPayer: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImportSummary({
|
export function ImportSummary({
|
||||||
@@ -18,9 +19,10 @@ export function ImportSummary({
|
|||||||
selected,
|
selected,
|
||||||
duplicates,
|
duplicates,
|
||||||
uncategorized,
|
uncategorized,
|
||||||
|
withoutPayer,
|
||||||
}: ImportSummaryProps) {
|
}: ImportSummaryProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-col gap-1 p-5 text-sm bg-linear-to-br from-primary/5 to-transparent">
|
<Card className="flex flex-col gap-1 p-5 text-sm bg-primary/10 shadow-none ">
|
||||||
{/* Linha 1: título */}
|
{/* Linha 1: título */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium">{statement.source}</span>
|
<span className="font-medium">{statement.source}</span>
|
||||||
@@ -40,8 +42,7 @@ export function ImportSummary({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
<span className="font-medium text-foreground">{selected}</span>/
|
{selected}/{total} selecionadas
|
||||||
{total} selecionadas
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{duplicates > 0 && (
|
{duplicates > 0 && (
|
||||||
@@ -59,6 +60,16 @@ export function ImportSummary({
|
|||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{withoutPayer > 0 ? (
|
||||||
|
<span>{withoutPayer} sem pessoa</span>
|
||||||
|
) : (
|
||||||
|
selected > 0 && (
|
||||||
|
<span className="text-emerald-600 dark:text-emerald-400">
|
||||||
|
todas com pessoa ✓
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { CategorySelectContent } from "@/features/transactions/components/select-items";
|
import {
|
||||||
|
CategorySelectContent,
|
||||||
|
PayerSelectContent,
|
||||||
|
} from "@/features/transactions/components/select-items";
|
||||||
import type { SelectOption } from "@/features/transactions/components/types";
|
import type { SelectOption } from "@/features/transactions/components/types";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge";
|
import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge";
|
||||||
@@ -31,17 +34,28 @@ import {
|
|||||||
import type { ImportedTransaction } from "@/shared/lib/import/types";
|
import type { ImportedTransaction } from "@/shared/lib/import/types";
|
||||||
import { formatDate } from "@/shared/utils/date";
|
import { formatDate } from "@/shared/utils/date";
|
||||||
|
|
||||||
|
const categoryGroupByTransactionType: Record<
|
||||||
|
ImportedTransaction["transactionType"],
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
expense: "despesa",
|
||||||
|
income: "receita",
|
||||||
|
};
|
||||||
|
|
||||||
export type ReviewRow = ImportedTransaction & {
|
export type ReviewRow = ImportedTransaction & {
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
isDuplicate: boolean;
|
isDuplicate: boolean;
|
||||||
categoryId: string | null;
|
categoryId: string | null;
|
||||||
|
payerId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ReviewTableProps {
|
interface ReviewTableProps {
|
||||||
rows: ReviewRow[];
|
rows: ReviewRow[];
|
||||||
|
payerOptions: SelectOption[];
|
||||||
categoryOptions: SelectOption[];
|
categoryOptions: SelectOption[];
|
||||||
onToggle: (index: number) => void;
|
onToggle: (index: number) => void;
|
||||||
onToggleAll: (selected: boolean) => void;
|
onToggleAll: (selected: boolean) => void;
|
||||||
|
onPayerChange: (index: number, payerId: string | null) => void;
|
||||||
onCategoryChange: (index: number, categoryId: string | null) => void;
|
onCategoryChange: (index: number, categoryId: string | null) => void;
|
||||||
onDescriptionChange: (index: number, description: string) => void;
|
onDescriptionChange: (index: number, description: string) => void;
|
||||||
onUndoDuplicate: (index: number) => void;
|
onUndoDuplicate: (index: number) => void;
|
||||||
@@ -49,9 +63,11 @@ interface ReviewTableProps {
|
|||||||
|
|
||||||
export function ReviewTable({
|
export function ReviewTable({
|
||||||
rows,
|
rows,
|
||||||
|
payerOptions,
|
||||||
categoryOptions,
|
categoryOptions,
|
||||||
onToggle,
|
onToggle,
|
||||||
onToggleAll,
|
onToggleAll,
|
||||||
|
onPayerChange,
|
||||||
onCategoryChange,
|
onCategoryChange,
|
||||||
onDescriptionChange,
|
onDescriptionChange,
|
||||||
onUndoDuplicate,
|
onUndoDuplicate,
|
||||||
@@ -97,6 +113,7 @@ export function ReviewTable({
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-24">Data</TableHead>
|
<TableHead className="w-24">Data</TableHead>
|
||||||
<TableHead>Descrição</TableHead>
|
<TableHead>Descrição</TableHead>
|
||||||
|
<TableHead className="w-44">Pessoa</TableHead>
|
||||||
<TableHead className="w-44">Categoria</TableHead>
|
<TableHead className="w-44">Categoria</TableHead>
|
||||||
<TableHead className="w-20">Tipo</TableHead>
|
<TableHead className="w-20">Tipo</TableHead>
|
||||||
<TableHead className="w-28 text-right">Valor</TableHead>
|
<TableHead className="w-28 text-right">Valor</TableHead>
|
||||||
@@ -106,7 +123,7 @@ export function ReviewTable({
|
|||||||
{paddingTop > 0 && (
|
{paddingTop > 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={6}
|
colSpan={7}
|
||||||
style={{ height: paddingTop, padding: 0 }}
|
style={{ height: paddingTop, padding: 0 }}
|
||||||
/>
|
/>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -117,6 +134,11 @@ export function ReviewTable({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const index = virtualRow.index;
|
const index = virtualRow.index;
|
||||||
|
const categoryOptionsForRow = categoryOptions.filter(
|
||||||
|
(option) =>
|
||||||
|
option.group ===
|
||||||
|
categoryGroupByTransactionType[row.transactionType],
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={row.externalId ?? `${row.date}-${index}`}
|
key={row.externalId ?? `${row.date}-${index}`}
|
||||||
@@ -177,6 +199,26 @@ export function ReviewTable({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Select
|
||||||
|
value={row.payerId ?? ""}
|
||||||
|
onValueChange={(v) => onPayerChange(index, v || null)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="Pessoa…" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{payerOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
<PayerSelectContent
|
||||||
|
label={opt.label}
|
||||||
|
avatarUrl={opt.avatarUrl}
|
||||||
|
/>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Select
|
<Select
|
||||||
value={row.categoryId ?? ""}
|
value={row.categoryId ?? ""}
|
||||||
@@ -186,7 +228,7 @@ export function ReviewTable({
|
|||||||
<SelectValue placeholder="Categoria…" />
|
<SelectValue placeholder="Categoria…" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{categoryOptions.map((opt) => (
|
{categoryOptionsForRow.map((opt) => (
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
<CategorySelectContent
|
<CategorySelectContent
|
||||||
label={opt.label}
|
label={opt.label}
|
||||||
@@ -225,7 +267,7 @@ export function ReviewTable({
|
|||||||
{paddingBottom > 0 && (
|
{paddingBottom > 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={6}
|
colSpan={7}
|
||||||
style={{ height: paddingBottom, padding: 0 }}
|
style={{ height: paddingBottom, padding: 0 }}
|
||||||
/>
|
/>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ interface TransactionsPageProps {
|
|||||||
categoryFilterOptions: TransactionFilterOption[];
|
categoryFilterOptions: TransactionFilterOption[];
|
||||||
accountCardFilterOptions: AccountCardFilterOption[];
|
accountCardFilterOptions: AccountCardFilterOption[];
|
||||||
selectedPeriod: string;
|
selectedPeriod: string;
|
||||||
|
defaultAccountId?: string | null;
|
||||||
estabelecimentos: string[];
|
estabelecimentos: string[];
|
||||||
allowCreate?: boolean;
|
allowCreate?: boolean;
|
||||||
noteAsColumn?: boolean;
|
noteAsColumn?: boolean;
|
||||||
@@ -96,6 +97,7 @@ export function TransactionsPage({
|
|||||||
categoryFilterOptions,
|
categoryFilterOptions,
|
||||||
accountCardFilterOptions,
|
accountCardFilterOptions,
|
||||||
selectedPeriod,
|
selectedPeriod,
|
||||||
|
defaultAccountId,
|
||||||
estabelecimentos,
|
estabelecimentos,
|
||||||
allowCreate = true,
|
allowCreate = true,
|
||||||
noteAsColumn = false,
|
noteAsColumn = false,
|
||||||
@@ -562,6 +564,7 @@ export function TransactionsPage({
|
|||||||
categoryOptions={categoryOptions}
|
categoryOptions={categoryOptions}
|
||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
|
defaultAccountId={defaultAccountId}
|
||||||
defaultCardId={defaultCardId}
|
defaultCardId={defaultCardId}
|
||||||
defaultPaymentMethod={defaultPaymentMethod}
|
defaultPaymentMethod={defaultPaymentMethod}
|
||||||
lockCardSelection={lockCardSelection}
|
lockCardSelection={lockCardSelection}
|
||||||
@@ -585,6 +588,7 @@ export function TransactionsPage({
|
|||||||
categoryOptions={categoryOptions}
|
categoryOptions={categoryOptions}
|
||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
|
defaultAccountId={defaultAccountId}
|
||||||
defaultCardId={defaultCardId}
|
defaultCardId={defaultCardId}
|
||||||
defaultPaymentMethod={defaultPaymentMethod}
|
defaultPaymentMethod={defaultPaymentMethod}
|
||||||
lockCardSelection={lockCardSelection}
|
lockCardSelection={lockCardSelection}
|
||||||
@@ -648,6 +652,7 @@ export function TransactionsPage({
|
|||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
transaction={transactionToCopy ?? undefined}
|
transaction={transactionToCopy ?? undefined}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
|
defaultAccountId={defaultAccountId}
|
||||||
maxSizeMb={attachmentMaxSizeMb}
|
maxSizeMb={attachmentMaxSizeMb}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -669,6 +674,7 @@ export function TransactionsPage({
|
|||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
transaction={transactionToImport ?? undefined}
|
transaction={transactionToImport ?? undefined}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
|
defaultAccountId={defaultAccountId}
|
||||||
isImporting={true}
|
isImporting={true}
|
||||||
maxSizeMb={attachmentMaxSizeMb}
|
maxSizeMb={attachmentMaxSizeMb}
|
||||||
/>
|
/>
|
||||||
@@ -697,6 +703,7 @@ export function TransactionsPage({
|
|||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
transaction={selectedTransaction ?? undefined}
|
transaction={selectedTransaction ?? undefined}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
|
defaultAccountId={defaultAccountId}
|
||||||
onBulkEditRequest={handleBulkEditRequest}
|
onBulkEditRequest={handleBulkEditRequest}
|
||||||
onSplitEditRequest={handleSplitEditRequest}
|
onSplitEditRequest={handleSplitEditRequest}
|
||||||
maxSizeMb={attachmentMaxSizeMb}
|
maxSizeMb={attachmentMaxSizeMb}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiCalendarCheckLine, RiCloseLine, RiEyeLine } from "@remixicon/react";
|
import { RiCalendarCheckLine } from "@remixicon/react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
@@ -164,16 +164,14 @@ export function AnticipationCard({
|
|||||||
onClick={handleViewLancamento}
|
onClick={handleViewLancamento}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
<RiEyeLine className="mr-2 size-4" />
|
Cancelar
|
||||||
Ver Lançamento
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{canCancel && (
|
{canCancel && (
|
||||||
<ConfirmActionDialog
|
<ConfirmActionDialog
|
||||||
trigger={
|
trigger={
|
||||||
<Button variant="destructive" size="sm" disabled={isPending}>
|
<Button variant="destructive" size="sm" disabled={isPending}>
|
||||||
<RiCloseLine className="mr-2 size-4" />
|
Desfazer Antecipação
|
||||||
Cancelar Antecipação
|
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
title="Cancelar antecipação?"
|
title="Cancelar antecipação?"
|
||||||
|
|||||||
@@ -426,7 +426,7 @@ function buildColumns({
|
|||||||
const initial = displayName.charAt(0).toUpperCase() || "?";
|
const initial = displayName.charAt(0).toUpperCase() || "?";
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
<Avatar className="size-7">
|
<Avatar className="size-8">
|
||||||
<AvatarImage src={avatarSrc} alt={`Avatar de ${label}`} />
|
<AvatarImage src={avatarSrc} alt={`Avatar de ${label}`} />
|
||||||
<AvatarFallback className="text-xs font-medium uppercase">
|
<AvatarFallback className="text-xs font-medium uppercase">
|
||||||
{initial}
|
{initial}
|
||||||
@@ -477,15 +477,21 @@ function buildColumns({
|
|||||||
const content = (
|
const content = (
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
{logoSrc && (
|
{logoSrc && (
|
||||||
<Image
|
<Avatar className="size-8">
|
||||||
src={logoSrc}
|
<AvatarImage src={logoSrc} alt={`Logo de ${label}`} />
|
||||||
alt={`Logo de ${label}`}
|
<AvatarFallback className="text-xs font-medium uppercase">
|
||||||
width={30}
|
{label}
|
||||||
height={30}
|
</AvatarFallback>
|
||||||
className="rounded-full"
|
</Avatar>
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<span className="truncate">{label}</span>
|
<span
|
||||||
|
className={cn(
|
||||||
|
"truncate underline-offset-2",
|
||||||
|
isOwnData && href && "group-hover:underline",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -503,7 +509,7 @@ function buildColumns({
|
|||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Link href={href} className="hover:underline">
|
<Link href={href} className="group">
|
||||||
{content}
|
{content}
|
||||||
</Link>
|
</Link>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -654,14 +660,14 @@ function buildColumns({
|
|||||||
Editar
|
Editar
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{row.original.categoriaName !== "Pagamentos" &&
|
{!row.original.readonly &&
|
||||||
row.original.userId === currentUserId && (
|
row.original.userId === currentUserId && (
|
||||||
<DropdownMenuItem onSelect={() => handleCopy(row.original)}>
|
<DropdownMenuItem onSelect={() => handleCopy(row.original)}>
|
||||||
<RiFileCopyLine className="size-4" />
|
<RiFileCopyLine className="size-4" />
|
||||||
Copiar
|
Copiar
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{row.original.categoriaName !== "Pagamentos" &&
|
{!row.original.readonly &&
|
||||||
row.original.userId !== currentUserId && (
|
row.original.userId !== currentUserId && (
|
||||||
<DropdownMenuItem onSelect={() => handleImport(row.original)}>
|
<DropdownMenuItem onSelect={() => handleImport(row.original)}>
|
||||||
<RiFileCopyLine className="size-4" />
|
<RiFileCopyLine className="size-4" />
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export function TransactionsTable({
|
|||||||
: getPaginationRowModel(),
|
: getPaginationRowModel(),
|
||||||
manualPagination: isServerPaginated,
|
manualPagination: isServerPaginated,
|
||||||
pageCount: serverPagination?.totalPages,
|
pageCount: serverPagination?.totalPages,
|
||||||
enableRowSelection: true,
|
enableRowSelection: (row) => !row.original.readonly,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rowModel = table.getRowModel();
|
const rowModel = table.getRowModel();
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export type TransactionFormState = {
|
|||||||
cardId: string | undefined;
|
cardId: string | undefined;
|
||||||
categoryId: string | undefined;
|
categoryId: string | undefined;
|
||||||
installmentCount: string;
|
installmentCount: string;
|
||||||
|
startInstallment: string;
|
||||||
recurrenceCount: string;
|
recurrenceCount: string;
|
||||||
dueDate: string;
|
dueDate: string;
|
||||||
boletoPaymentDate: string;
|
boletoPaymentDate: string;
|
||||||
@@ -92,6 +93,7 @@ export type TransactionFormState = {
|
|||||||
*/
|
*/
|
||||||
type TransactionFormOverrides = {
|
type TransactionFormOverrides = {
|
||||||
defaultCardId?: string | null;
|
defaultCardId?: string | null;
|
||||||
|
defaultAccountId?: string | null;
|
||||||
defaultPaymentMethod?: string | null;
|
defaultPaymentMethod?: string | null;
|
||||||
defaultPurchaseDate?: string | null;
|
defaultPurchaseDate?: string | null;
|
||||||
defaultName?: string | null;
|
defaultName?: string | null;
|
||||||
@@ -178,7 +180,9 @@ export function buildTransactionInitialState(
|
|||||||
? undefined
|
? undefined
|
||||||
: isImporting
|
: isImporting
|
||||||
? undefined
|
? undefined
|
||||||
: (transaction?.accountId ?? undefined),
|
: (transaction?.accountId ??
|
||||||
|
overrides?.defaultAccountId ??
|
||||||
|
undefined),
|
||||||
cardId:
|
cardId:
|
||||||
paymentMethod === "Cartão de crédito"
|
paymentMethod === "Cartão de crédito"
|
||||||
? isImporting
|
? isImporting
|
||||||
@@ -191,6 +195,12 @@ export function buildTransactionInitialState(
|
|||||||
installmentCount: transaction?.installmentCount
|
installmentCount: transaction?.installmentCount
|
||||||
? String(transaction.installmentCount)
|
? String(transaction.installmentCount)
|
||||||
: "",
|
: "",
|
||||||
|
startInstallment:
|
||||||
|
isImporting &&
|
||||||
|
transaction?.condition === "Parcelado" &&
|
||||||
|
transaction.currentInstallment
|
||||||
|
? String(transaction.currentInstallment)
|
||||||
|
: "1",
|
||||||
recurrenceCount: transaction?.recurrenceCount
|
recurrenceCount: transaction?.recurrenceCount
|
||||||
? String(transaction.recurrenceCount)
|
? String(transaction.recurrenceCount)
|
||||||
: "",
|
: "",
|
||||||
@@ -252,12 +262,25 @@ export function applyFieldDependencies(
|
|||||||
if (key === "condition" && typeof value === "string") {
|
if (key === "condition" && typeof value === "string") {
|
||||||
if (value !== "Parcelado") {
|
if (value !== "Parcelado") {
|
||||||
updates.installmentCount = "";
|
updates.installmentCount = "";
|
||||||
|
updates.startInstallment = "1";
|
||||||
}
|
}
|
||||||
if (value !== "Recorrente") {
|
if (value !== "Recorrente") {
|
||||||
updates.recurrenceCount = "";
|
updates.recurrenceCount = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === "installmentCount" && typeof value === "string" && value) {
|
||||||
|
const nextCount = Number.parseInt(value, 10);
|
||||||
|
const currentStart = Number.parseInt(currentState.startInstallment, 10);
|
||||||
|
if (
|
||||||
|
!Number.isNaN(nextCount) &&
|
||||||
|
!Number.isNaN(currentStart) &&
|
||||||
|
currentStart > nextCount
|
||||||
|
) {
|
||||||
|
updates.startInstallment = String(nextCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// When payment method changes, adjust related fields
|
// When payment method changes, adjust related fields
|
||||||
if (key === "paymentMethod" && typeof value === "string") {
|
if (key === "paymentMethod" && typeof value === "string") {
|
||||||
if (value === "Cartão de crédito") {
|
if (value === "Cartão de crédito") {
|
||||||
|
|||||||
@@ -27,7 +27,13 @@ import {
|
|||||||
TRANSACTION_CONDITIONS,
|
TRANSACTION_CONDITIONS,
|
||||||
TRANSACTION_TYPES,
|
TRANSACTION_TYPES,
|
||||||
} from "@/features/transactions/lib/constants";
|
} from "@/features/transactions/lib/constants";
|
||||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
import {
|
||||||
|
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
|
||||||
|
INITIAL_BALANCE_CONDITION,
|
||||||
|
INITIAL_BALANCE_NOTE,
|
||||||
|
INITIAL_BALANCE_PAYMENT_METHOD,
|
||||||
|
INITIAL_BALANCE_TRANSACTION_TYPE,
|
||||||
|
} from "@/shared/lib/accounts/constants";
|
||||||
import {
|
import {
|
||||||
PAYER_ROLE_ADMIN,
|
PAYER_ROLE_ADMIN,
|
||||||
PAYER_ROLE_THIRD_PARTY,
|
PAYER_ROLE_THIRD_PARTY,
|
||||||
@@ -551,8 +557,10 @@ export const mapTransactionsData = (rows: TransactionRowWithRelations[]) =>
|
|||||||
hasAttachments: item.hasAttachments ?? false,
|
hasAttachments: item.hasAttachments ?? false,
|
||||||
readonly:
|
readonly:
|
||||||
Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
|
Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
|
||||||
item.category?.name === "Saldo inicial" ||
|
(item.note === INITIAL_BALANCE_NOTE &&
|
||||||
item.category?.name === "Pagamentos",
|
item.transactionType === INITIAL_BALANCE_TRANSACTION_TYPE &&
|
||||||
|
item.condition === INITIAL_BALANCE_CONDITION &&
|
||||||
|
item.paymentMethod === INITIAL_BALANCE_PAYMENT_METHOD),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const sortByLabel = <T extends { label: string }>(items: T[]) =>
|
const sortByLabel = <T extends { label: string }>(items: T[]) =>
|
||||||
|
|||||||
17
src/proxy.ts
17
src/proxy.ts
@@ -1,5 +1,6 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/shared/lib/auth/config";
|
import { auth } from "@/shared/lib/auth/config";
|
||||||
|
import { isSignupDisabled } from "@/shared/lib/auth/signup";
|
||||||
|
|
||||||
// Rotas protegidas que requerem autenticação
|
// Rotas protegidas que requerem autenticação
|
||||||
const PROTECTED_ROUTES = [
|
const PROTECTED_ROUTES = [
|
||||||
@@ -85,6 +86,22 @@ export default async function proxy(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isAuthenticated = !!session?.user;
|
const isAuthenticated = !!session?.user;
|
||||||
|
const signupDisabled = isSignupDisabled();
|
||||||
|
|
||||||
|
if (signupDisabled) {
|
||||||
|
if (pathname === "/signup" || pathname.startsWith("/signup/")) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL(isAuthenticated ? "/dashboard" : "/login", request.url),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith("/api/auth/sign-up")) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Novos cadastros estão desativados." },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect authenticated users away from login/signup pages
|
// Redirect authenticated users away from login/signup pages
|
||||||
if (isAuthenticated && PUBLIC_AUTH_ROUTES.includes(pathname)) {
|
if (isAuthenticated && PUBLIC_AUTH_ROUTES.includes(pathname)) {
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ export function NotificationBellTrigger({
|
|||||||
"group relative shadow-none transition-all duration-200",
|
"group relative shadow-none transition-all duration-200",
|
||||||
"hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-2 focus-visible:ring-black/20 dark:hover:border-white/20 dark:hover:bg-white/10 dark:hover:text-white dark:focus-visible:ring-white/20",
|
"hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-2 focus-visible:ring-black/20 dark:hover:border-white/20 dark:hover:bg-white/10 dark:hover:text-white dark:focus-visible:ring-white/20",
|
||||||
"data-[state=open]:bg-black/10 data-[state=open]:text-black dark:data-[state=open]:bg-white/10 dark:data-[state=open]:text-white",
|
"data-[state=open]:bg-black/10 data-[state=open]:text-black dark:data-[state=open]:bg-white/10 dark:data-[state=open]:text-white",
|
||||||
hasAnySourceItems
|
|
||||||
? "text-black dark:text-white"
|
|
||||||
: "text-black/75 dark:text-white/75",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<RiNotification2Line
|
<RiNotification2Line
|
||||||
@@ -55,7 +52,7 @@ export function NotificationBellTrigger({
|
|||||||
>
|
>
|
||||||
{displayCount}
|
{displayCount}
|
||||||
</span>
|
</span>
|
||||||
<span className="absolute -right-1.5 -top-1.5 size-5 animate-ping rounded-full bg-destructive/5 [animation-iteration-count:3]" />
|
<span className="absolute -right-1.5 -top-1.5 size-5 animate-ping rounded-full bg-destructive/5 repeat-3" />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-card text-card-foreground flex flex-col gap-4 border border-transparent shadow-sm dark:border-border py-6 rounded-lg hover:border-primary/50 transition-colors duration-200",
|
"bg-card text-card-foreground flex flex-col gap-4 border border-transparent shadow-xs dark:border-border py-6 rounded-lg hover:border-primary/50 transition-colors duration-200",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function Separator({
|
|||||||
decorative={decorative}
|
decorative={decorative}
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
"bg-border/50 dark:bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { passkey } from "@better-auth/passkey";
|
import { passkey } from "@better-auth/passkey";
|
||||||
import { betterAuth } from "better-auth";
|
import { APIError, betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import type { GoogleProfile } from "better-auth/social-providers";
|
import type { GoogleProfile } from "better-auth/social-providers";
|
||||||
|
import { isSignupDisabled } from "@/shared/lib/auth/signup";
|
||||||
import { seedDefaultCategoriesForUser } from "@/shared/lib/categories/defaults";
|
import { seedDefaultCategoriesForUser } from "@/shared/lib/categories/defaults";
|
||||||
import { db, schema } from "@/shared/lib/db";
|
import { db, schema } from "@/shared/lib/db";
|
||||||
import { ensureDefaultPayerForUser } from "@/shared/lib/payers/defaults";
|
import { ensureDefaultPayerForUser } from "@/shared/lib/payers/defaults";
|
||||||
@@ -122,6 +123,13 @@ export const auth = betterAuth({
|
|||||||
databaseHooks: {
|
databaseHooks: {
|
||||||
user: {
|
user: {
|
||||||
create: {
|
create: {
|
||||||
|
before: async () => {
|
||||||
|
if (!isSignupDisabled()) return;
|
||||||
|
|
||||||
|
throw new APIError("FORBIDDEN", {
|
||||||
|
message: "Novos cadastros estão desativados.",
|
||||||
|
});
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Após criar novo usuário, inicializa:
|
* Após criar novo usuário, inicializa:
|
||||||
* 1. Categorias padrão (Receitas/Despesas)
|
* 1. Categorias padrão (Receitas/Despesas)
|
||||||
|
|||||||
4
src/shared/lib/auth/signup.ts
Normal file
4
src/shared/lib/auth/signup.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export function isSignupDisabled(): boolean {
|
||||||
|
const value = process.env.DISABLE_SIGNUP?.trim().toLowerCase();
|
||||||
|
return value === "true";
|
||||||
|
}
|
||||||
@@ -14,12 +14,12 @@ function excelSerialToDate(
|
|||||||
if (serial < 1) return null;
|
if (serial < 1) return null;
|
||||||
let adjusted = serial;
|
let adjusted = serial;
|
||||||
if (serial > 60) adjusted--;
|
if (serial > 60) adjusted--;
|
||||||
const baseDate = new Date(1899, 11, 31);
|
const baseDate = Date.UTC(1899, 11, 31);
|
||||||
const date = new Date(baseDate.getTime() + adjusted * 86400000);
|
const date = new Date(baseDate + adjusted * 86400000);
|
||||||
return {
|
return {
|
||||||
y: date.getFullYear(),
|
y: date.getUTCFullYear(),
|
||||||
m: date.getMonth() + 1,
|
m: date.getUTCMonth() + 1,
|
||||||
d: date.getDate(),
|
d: date.getUTCDate(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,9 +38,9 @@ function parseDateValue(value: unknown): string | null {
|
|||||||
|
|
||||||
// ExcelJS pode retornar Date objects
|
// ExcelJS pode retornar Date objects
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
const y = value.getFullYear();
|
const y = value.getUTCFullYear();
|
||||||
const m = String(value.getMonth() + 1).padStart(2, "0");
|
const m = String(value.getUTCMonth() + 1).padStart(2, "0");
|
||||||
const d = String(value.getDate()).padStart(2, "0");
|
const d = String(value.getUTCDate()).padStart(2, "0");
|
||||||
return `${y}-${m}-${d}`;
|
return `${y}-${m}-${d}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user