mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-15 13:01:47 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a768bc8ba | ||
|
|
8a03a50132 | ||
|
|
246bb14a00 | ||
|
|
86bcffec66 | ||
|
|
81e7151876 | ||
|
|
0bb664884a | ||
|
|
f02958df1d | ||
|
|
c4c52c02ab | ||
|
|
c9239c4f3c | ||
|
|
7128cc0ae7 | ||
|
|
467f71493d | ||
|
|
0cec10ede3 |
41
CHANGELOG.md
41
CHANGELOG.md
@@ -5,6 +5,47 @@ 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.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.
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
- Relatórios: em `/reports/installment-analysis`, os cards de parcelas passam a usar o logo do estabelecimento como avatar principal; o logo do cartão agora aparece menor ao lado do nome do cartão, tanto no card quanto no modal de detalhes.
|
||||||
|
- Relatórios: a página de análise de parcelas pré-carrega os mapeamentos de logos de estabelecimentos para evitar troca visual após o primeiro render.
|
||||||
|
- Lançamentos: o campo de anexos no modal agora aceita arquivos colados com `Ctrl+V`, mantendo o botão para buscar arquivos normalmente.
|
||||||
|
- Lançamentos: o modal agora usa uma única área interna de rolagem, com cabeçalho e rodapé estáveis, reduzindo travadas ao rolar e ao abrir "Condições, anotações e anexos".
|
||||||
|
- Anotações: tarefas agora podem ser editadas inline no modal "Atualizar anotação"; clicar no texto abre o input e o botão de remover vira botão de salvar naquela linha.
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
- Relatórios: o join com cartões na análise de parcelas agora também valida `cards.userId`, mantendo o filtro de ownership explícito na consulta.
|
||||||
|
|
||||||
|
## [2.5.6] - 2026-05-07
|
||||||
|
|
||||||
|
Esta versão entrega um conjunto de melhorias em torno do fluxo de lançamentos: filtros mais úteis, divisão por porcentagem, indicador de orçamento dentro do modal e correção de um bug em totais por pessoa que considerava contas excluídas do saldo. Também inclui ajustes de robustez no display da calculadora (sem mais overflow do modal com valores longos) e o fix do cache de RSC nos filtros multi-seleção.
|
||||||
|
|
||||||
|
### Adicionado
|
||||||
|
- Lançamentos: filtro por faixa de valor (mín/máx) com debounce e persistência via query string (`amountMin`/`amountMax`).
|
||||||
|
- Lançamentos: botão "Limpar" discreto ao lado do botão "Filtros", visível apenas quando há filtros ativos.
|
||||||
|
- Modal de lançamento: toggle compacto R$/% no card "Dividir lançamento", permitindo distribuir o valor por porcentagem entre as pessoas. Cada input em modo % exibe o valor convertido em R$ logo abaixo, no mesmo padrão visual do `InlinePeriodPicker`.
|
||||||
|
- Modal de lançamento: indicador de orçamento ao lado do nome da categoria selecionada, mostrando `R$ gasto de R$ orçado (%)` com cores semânticas (verde / âmbar / vermelho) conforme o consumo. Suprimido quando o input divide a linha com o tipo de transação (caso pré-lançamentos). Implementado via `getCategoryBudgetSummaryAction` e `fetchCategoryBudgetSummary` em `features/budgets`.
|
||||||
|
|
||||||
|
### Alterado
|
||||||
|
- Calculadora: display com tamanho de fonte adaptativo (de `text-3xl` a `text-sm`) conforme o comprimento da expressão, mais `truncate` funcional via `min-w-0` nos containers flex. Resolve o overflow do modal com valores muito longos (ex: `9.999.999.999 × 9.999.999.999`).
|
||||||
|
|
||||||
|
### Corrigido
|
||||||
|
- Pessoas: "Totais do mês" em `/payers/[id]` deixa de somar lançamentos vinculados a contas marcadas como `excludeFromBalance` (ex: "Ajuste de saldo"). Adicionado `excludeTransactionsFromExcludedAccounts()` em 6 queries de `src/shared/lib/payers/details.ts`.
|
||||||
|
- Orçamentos: `fetchBudgetsForUser` e `fetchCategoryBudgetSummary` agora respeitam o filtro de contas excluídas do saldo, alinhando o gasto exibido na tela de Orçamentos com o badge de orçamento dentro do modal de lançamento.
|
||||||
|
- Lançamentos: tabela de resultados agora reflete corretamente a remoção de um valor em filtros multi-seleção (Pessoa, Conta/Cartão, Categoria, Condição, Forma de Pagamento). Adicionado `router.refresh()` em `handleMultiFilterChange` para invalidar o cache de segmento do router (issue #54).
|
||||||
|
|
||||||
|
### Dependências
|
||||||
|
- Stack core: `next` 16.2.4 → 16.2.6, `react`/`react-dom` 19.2.5 → 19.2.6.
|
||||||
|
- UI: `react-day-picker` 9 → 10 (major), `tailwindcss` / `@tailwindcss/postcss` 4.2.4 → 4.3.0, `tailwind-merge` 3.5.0 → 3.6.0.
|
||||||
|
- Auth: `better-auth` 1.6.9 → 1.6.10 e `@better-auth/passkey` 1.6.9 → 1.6.10.
|
||||||
|
- AI SDKs: `@ai-sdk/anthropic` 3.0.74 → 3.0.76, `@ai-sdk/google` 3.0.67 → 3.0.71, `@ai-sdk/openai` 3.0.60 → 3.0.63, `ai` 6.0.175 → 6.0.177.
|
||||||
|
- AWS: `@aws-sdk/client-s3` e `@aws-sdk/s3-request-presigner` 3.1042.0 → 3.1045.0.
|
||||||
|
- E-mail: `resend` 6.12.2 → 6.12.3.
|
||||||
|
- Dev tooling: `@biomejs/biome` 2.4.14 → 2.4.15, `knip` 6.11.0 → 6.12.2, `@types/node` 25.6.0 → 25.6.2.
|
||||||
|
|
||||||
## [2.5.5] - 2026-05-06
|
## [2.5.5] - 2026-05-06
|
||||||
|
|
||||||
Esta versão melhora a navegação por históricos e lançamentos. O changelog ganhou uma linha do tempo mais leve, colapsável e fácil de escanear; os filtros de lançamentos passam a aceitar múltiplas pessoas, categorias, formas de pagamento, condições e contas/cartões na mesma busca; e os diálogos adotam as animações compartilhadas do design system. Também há pequenos polimentos de texto e layout para deixar a interface mais consistente.
|
Esta versão melhora a navegação por históricos e lançamentos. O changelog ganhou uma linha do tempo mais leve, colapsável e fácil de escanear; os filtros de lançamentos passam a aceitar múltiplas pessoas, categorias, formas de pagamento, condições e contas/cartões na mesma busca; e os diálogos adotam as animações compartilhadas do design system. Também há pequenos polimentos de texto e layout para deixar a interface mais consistente.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
|
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
|
||||||
|
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://nextjs.org/)
|
[](https://nextjs.org/)
|
||||||
[](https://www.typescriptlang.org/)
|
[](https://www.typescriptlang.org/)
|
||||||
[](https://www.postgresql.org/)
|
[](https://www.postgresql.org/)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
|
|||||||
40
package.json
40
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "openmonetis",
|
"name": "openmonetis",
|
||||||
"version": "2.5.5",
|
"version": "2.5.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@10.33.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -31,12 +31,12 @@
|
|||||||
"mockup": "tsx scripts/mock-data.ts"
|
"mockup": "tsx scripts/mock-data.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^3.0.74",
|
"@ai-sdk/anthropic": "^3.0.76",
|
||||||
"@ai-sdk/google": "^3.0.67",
|
"@ai-sdk/google": "^3.0.71",
|
||||||
"@ai-sdk/openai": "^3.0.60",
|
"@ai-sdk/openai": "^3.0.63",
|
||||||
"@aws-sdk/client-s3": "^3.1042.0",
|
"@aws-sdk/client-s3": "^3.1045.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1042.0",
|
"@aws-sdk/s3-request-presigner": "^3.1045.0",
|
||||||
"@better-auth/passkey": "^1.6.9",
|
"@better-auth/passkey": "^1.6.10",
|
||||||
"@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",
|
||||||
@@ -66,8 +66,8 @@
|
|||||||
"@tanstack/react-query": "^5.100.9",
|
"@tanstack/react-query": "^5.100.9",
|
||||||
"@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.175",
|
"ai": "^6.0.177",
|
||||||
"better-auth": "1.6.9",
|
"better-auth": "1.6.10",
|
||||||
"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",
|
||||||
@@ -77,17 +77,17 @@
|
|||||||
"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.7",
|
||||||
"next": "16.2.4",
|
"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.20.0",
|
||||||
"react": "19.2.5",
|
"react": "19.2.6",
|
||||||
"react-day-picker": "^9.14.0",
|
"react-day-picker": "^10.0.0",
|
||||||
"react-dom": "19.2.5",
|
"react-dom": "19.2.6",
|
||||||
"recharts": "3.8.1",
|
"recharts": "3.8.1",
|
||||||
"resend": "^6.12.2",
|
"resend": "^6.12.3",
|
||||||
"sonner": "2.0.7",
|
"sonner": "2.0.7",
|
||||||
"tailwind-merge": "3.5.0",
|
"tailwind-merge": "3.6.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"zod": "4.4.3"
|
"zod": "4.4.3"
|
||||||
@@ -98,17 +98,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.14",
|
"@biomejs/biome": "2.4.15",
|
||||||
"@tailwindcss/postcss": "4.2.4",
|
"@tailwindcss/postcss": "4.3.0",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "25.6.0",
|
"@types/node": "25.6.2",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.14",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"knip": "^6.11.0",
|
"knip": "^6.12.2",
|
||||||
"tailwindcss": "4.2.4",
|
"tailwindcss": "4.3.0",
|
||||||
"tsx": "4.21.0",
|
"tsx": "4.21.0",
|
||||||
"typescript": "6.0.3"
|
"typescript": "6.0.3"
|
||||||
}
|
}
|
||||||
|
|||||||
1576
pnpm-lock.yaml
generated
1576
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,8 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
|
|||||||
settledFilter: null,
|
settledFilter: null,
|
||||||
attachmentFilter: null,
|
attachmentFilter: null,
|
||||||
dividedFilter: null,
|
dividedFilter: null,
|
||||||
|
amountMinFilter: null,
|
||||||
|
amountMaxFilter: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const createEmptySlugMaps = (): SlugMaps => ({
|
const createEmptySlugMaps = (): SlugMaps => ({
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import { connection } from "next/server";
|
import { connection } from "next/server";
|
||||||
import { InstallmentAnalysisPage } from "@/features/dashboard/components/installment-analysis/installment-analysis-page";
|
import { InstallmentAnalysisPage } from "@/features/dashboard/components/installment-analysis/installment-analysis-page";
|
||||||
import { fetchInstallmentAnalysis } from "@/features/dashboard/expenses/installment-analysis-queries";
|
import { fetchInstallmentAnalysis } from "@/features/dashboard/expenses/installment-analysis-queries";
|
||||||
|
import { LogoPrefetchProvider } from "@/shared/components/entity-avatar";
|
||||||
import { getUser } from "@/shared/lib/auth/server";
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
|
import { prefetchLogoMappings } from "@/shared/lib/logo/prefetch-server";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
await connection();
|
await connection();
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
const data = await fetchInstallmentAnalysis(user.id);
|
const data = await fetchInstallmentAnalysis(user.id);
|
||||||
|
const logoMappings = await prefetchLogoMappings(
|
||||||
|
user.id,
|
||||||
|
data.installmentGroups.map((group) => group.name),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-4 pb-8">
|
<main className="flex flex-col gap-4 pb-8">
|
||||||
<InstallmentAnalysisPage data={data} />
|
<LogoPrefetchProvider mappings={logoMappings}>
|
||||||
|
<InstallmentAnalysisPage data={data} />
|
||||||
|
</LogoPrefetchProvider>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { budgets, categories } from "@/db/schema";
|
import { budgets, categories } from "@/db/schema";
|
||||||
|
import {
|
||||||
|
type CategoryBudgetSummary,
|
||||||
|
fetchCategoryBudgetSummary,
|
||||||
|
} from "@/features/budgets/queries";
|
||||||
import {
|
import {
|
||||||
handleActionError,
|
handleActionError,
|
||||||
revalidateForEntity,
|
revalidateForEntity,
|
||||||
@@ -204,6 +208,34 @@ export async function deleteBudgetAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getCategoryBudgetSummarySchema = z.object({
|
||||||
|
categoryId: uuidSchema("Category"),
|
||||||
|
period: periodSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
type GetCategoryBudgetSummaryInput = z.input<
|
||||||
|
typeof getCategoryBudgetSummarySchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export async function getCategoryBudgetSummaryAction(
|
||||||
|
input: GetCategoryBudgetSummaryInput,
|
||||||
|
): Promise<ActionResult<CategoryBudgetSummary | null>> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = getCategoryBudgetSummarySchema.parse(input);
|
||||||
|
const summary = await fetchCategoryBudgetSummary(
|
||||||
|
user.id,
|
||||||
|
data.categoryId,
|
||||||
|
data.period,
|
||||||
|
);
|
||||||
|
return { success: true, message: "ok", data: summary };
|
||||||
|
} catch (error) {
|
||||||
|
return handleActionError(
|
||||||
|
error,
|
||||||
|
) as ActionResult<CategoryBudgetSummary | null>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const duplicatePreviousMonthSchema = z.object({
|
const duplicatePreviousMonthSchema = z.object({
|
||||||
period: periodSchema,
|
period: periodSchema,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { and, asc, eq, inArray, isNull, or, sql, sum } from "drizzle-orm";
|
import { and, asc, eq, inArray, isNull, or, sql, sum } from "drizzle-orm";
|
||||||
import { budgets, categories, transactions } from "@/db/schema";
|
import {
|
||||||
|
budgets,
|
||||||
|
categories,
|
||||||
|
financialAccounts,
|
||||||
|
transactions,
|
||||||
|
} from "@/db/schema";
|
||||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
||||||
|
import { excludeTransactionsFromExcludedAccounts } from "@/shared/lib/accounts/query-filters";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
|
|
||||||
@@ -75,6 +81,10 @@ export async function fetchBudgetsForUser(
|
|||||||
totalAmount: sum(transactions.amount).as("totalAmount"),
|
totalAmount: sum(transactions.amount).as("totalAmount"),
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(transactions.userId, userId),
|
eq(transactions.userId, userId),
|
||||||
@@ -86,6 +96,7 @@ export async function fetchBudgetsForUser(
|
|||||||
isNull(transactions.note),
|
isNull(transactions.note),
|
||||||
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||||
),
|
),
|
||||||
|
excludeTransactionsFromExcludedAccounts(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(transactions.categoryId);
|
.groupBy(transactions.categoryId);
|
||||||
@@ -127,3 +138,57 @@ export async function fetchBudgetsForUser(
|
|||||||
|
|
||||||
return { budgets: budgetList, categoriesOptions };
|
return { budgets: budgetList, categoriesOptions };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CategoryBudgetSummary = {
|
||||||
|
amount: number;
|
||||||
|
spent: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchCategoryBudgetSummary(
|
||||||
|
userId: string,
|
||||||
|
categoryId: string,
|
||||||
|
period: string,
|
||||||
|
): Promise<CategoryBudgetSummary | null> {
|
||||||
|
const [adminPayerId, budget] = await Promise.all([
|
||||||
|
getAdminPayerId(userId),
|
||||||
|
db.query.budgets.findFirst({
|
||||||
|
columns: { amount: true },
|
||||||
|
where: and(
|
||||||
|
eq(budgets.userId, userId),
|
||||||
|
eq(budgets.categoryId, categoryId),
|
||||||
|
eq(budgets.period, period),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!adminPayerId || !budget) return null;
|
||||||
|
|
||||||
|
const totals = await db
|
||||||
|
.select({
|
||||||
|
totalAmount: sum(transactions.amount).as("totalAmount"),
|
||||||
|
})
|
||||||
|
.from(transactions)
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactions.userId, userId),
|
||||||
|
eq(transactions.period, period),
|
||||||
|
eq(transactions.transactionType, "Despesa"),
|
||||||
|
eq(transactions.payerId, adminPayerId),
|
||||||
|
eq(transactions.categoryId, categoryId),
|
||||||
|
or(
|
||||||
|
isNull(transactions.note),
|
||||||
|
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||||
|
),
|
||||||
|
excludeTransactionsFromExcludedAccounts(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
amount: toNumber(budget.amount),
|
||||||
|
spent: Math.abs(toNumber(totals[0]?.totalAmount ?? 0)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
import {
|
import {
|
||||||
RiBankCard2Line,
|
RiBankCard2Line,
|
||||||
RiCheckboxCircleFill,
|
RiCheckboxCircleFill,
|
||||||
RiEyeLine,
|
RiFileList2Line,
|
||||||
RiTimeLine,
|
RiTimeLine,
|
||||||
} from "@remixicon/react";
|
} 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 Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||||
import MoneyValues from "@/shared/components/money-values";
|
import MoneyValues from "@/shared/components/money-values";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/shared/components/ui/dialog";
|
} from "@/shared/components/ui/dialog";
|
||||||
import { Progress } from "@/shared/components/ui/progress";
|
import { Progress } from "@/shared/components/ui/progress";
|
||||||
|
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||||
import { cn } from "@/shared/utils";
|
import { cn } from "@/shared/utils";
|
||||||
import type { InstallmentGroup } from "./types";
|
import type { InstallmentGroup } from "./types";
|
||||||
|
|
||||||
@@ -79,6 +81,8 @@ export function InstallmentGroupCard({
|
|||||||
(sum, i) => sum + i.amount,
|
(sum, i) => sum + i.amount,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
const cardLogoSrc = resolveLogoSrc(group.cartaoLogo);
|
||||||
|
const cardName = group.cartaoName ?? "Compra parcelada";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -111,25 +115,24 @@ export function InstallmentGroupCard({
|
|||||||
{/* Info principal */}
|
{/* Info principal */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
{group.cartaoLogo ? (
|
<EstablishmentLogo name={group.name} size={40} />
|
||||||
<Image
|
|
||||||
src={`/logos/${group.cartaoLogo}`}
|
|
||||||
alt={group.cartaoName ?? "Cartão"}
|
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
className="size-10 rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="size-10 flex items-center justify-center">
|
|
||||||
<RiBankCard2Line className="size-5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<CardTitle className="text-base truncate">
|
<CardTitle className="text-base truncate">
|
||||||
{group.name}
|
{group.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-xs">
|
<CardDescription className="flex min-w-0 items-center gap-1 text-xs">
|
||||||
{group.cartaoName ?? "Compra parcelada"}
|
{cardLogoSrc ? (
|
||||||
|
<Image
|
||||||
|
src={cardLogoSrc}
|
||||||
|
alt={`Logo do cartão ${cardName}`}
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
className="size-4.5 shrink-0 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RiBankCard2Line className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{cardName}</span>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,7 +150,7 @@ export function InstallmentGroupCard({
|
|||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{/* Grid de valores */}
|
{/* Grid de valores */}
|
||||||
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-muted/50 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 total
|
||||||
@@ -165,7 +168,7 @@ export function InstallmentGroupCard({
|
|||||||
amount={pendingAmount}
|
amount={pendingAmount}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-lg font-semibold",
|
"text-lg font-semibold",
|
||||||
pendingAmount > 0 ? "text-amber-600" : "text-success-600",
|
pendingAmount > 0 ? "text-primary" : "text-success",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,14 +186,18 @@ export function InstallmentGroupCard({
|
|||||||
</div>
|
</div>
|
||||||
{unpaidCount > 0 && (
|
{unpaidCount > 0 && (
|
||||||
<div className="flex items-center gap-1 text-muted-foreground">
|
<div className="flex items-center gap-1 text-muted-foreground">
|
||||||
<RiTimeLine className="size-3.5 text-amber-600" />
|
<RiTimeLine className="size-3.5" />
|
||||||
<span>
|
<span>
|
||||||
{unpaidCount} {unpaidCount === 1 ? "pendente" : "pendentes"}
|
{unpaidCount} {unpaidCount === 1 ? "pendente" : "pendentes"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Progress value={progress} className="h-2.5" />
|
<Progress
|
||||||
|
value={progress}
|
||||||
|
className="h-2.5 bg-muted"
|
||||||
|
indicatorClassName="bg-success"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Valor selecionado */}
|
{/* Valor selecionado */}
|
||||||
@@ -212,13 +219,13 @@ export function InstallmentGroupCard({
|
|||||||
{/* Botão para abrir detalhes */}
|
{/* Botão para abrir detalhes */}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full gap-1.5"
|
className="w-full gap-1.5"
|
||||||
onClick={() => setIsDetailsOpen(true)}
|
onClick={() => setIsDetailsOpen(true)}
|
||||||
>
|
>
|
||||||
<RiEyeLine className="size-4" />
|
<RiFileList2Line className="size-4" />
|
||||||
Ver detalhes ({group.pendingInstallments.length} parcelas)
|
detalhes ({group.pendingInstallments.length} parcelas)
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -228,18 +235,26 @@ export function InstallmentGroupCard({
|
|||||||
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
|
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{group.cartaoLogo ? (
|
<EstablishmentLogo name={group.name} size={32} />
|
||||||
<img
|
<div className="min-w-0">
|
||||||
src={`/logos/${group.cartaoLogo}`}
|
<DialogTitle className="truncate text-base">
|
||||||
alt={group.cartaoName ?? "Cartão"}
|
{group.name}
|
||||||
className="size-8 rounded-full object-cover"
|
</DialogTitle>
|
||||||
/>
|
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
) : (
|
{cardLogoSrc ? (
|
||||||
<div className="size-8 rounded-full bg-muted flex items-center justify-center">
|
<Image
|
||||||
<RiBankCard2Line className="size-4 text-muted-foreground" />
|
src={cardLogoSrc}
|
||||||
|
alt={`Logo do cartão ${cardName}`}
|
||||||
|
width={14}
|
||||||
|
height={14}
|
||||||
|
className="size-3.5 shrink-0 rounded-full object-cover opacity-75"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RiBankCard2Line className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{cardName}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
<DialogTitle className="text-base">{group.name}</DialogTitle>
|
|
||||||
</div>
|
</div>
|
||||||
<DialogDescription className="sr-only">
|
<DialogDescription className="sr-only">
|
||||||
Detalhes das parcelas do grupo {group.name}
|
Detalhes das parcelas do grupo {group.name}
|
||||||
|
|||||||
@@ -92,7 +92,10 @@ export async function fetchInstallmentAnalysis(
|
|||||||
cartaoLogo: cards.logo,
|
cartaoLogo: cards.logo,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.leftJoin(cards, eq(transactions.cardId, cards.id))
|
.leftJoin(
|
||||||
|
cards,
|
||||||
|
and(eq(transactions.cardId, cards.id), eq(cards.userId, userId)),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(transactions.userId, userId),
|
eq(transactions.userId, userId),
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiAddCircleFill, RiDeleteBinLine } from "@remixicon/react";
|
import {
|
||||||
|
RiAddCircleFill,
|
||||||
|
RiCheckLine,
|
||||||
|
RiDeleteBinLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
import {
|
import {
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -69,10 +73,13 @@ export function NoteDialog({
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [newTaskText, setNewTaskText] = useState("");
|
const [newTaskText, setNewTaskText] = useState("");
|
||||||
|
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
|
||||||
|
const [editingTaskText, setEditingTaskText] = useState("");
|
||||||
|
|
||||||
const titleRef = useRef<HTMLInputElement>(null);
|
const titleRef = useRef<HTMLInputElement>(null);
|
||||||
const descRef = useRef<HTMLTextAreaElement>(null);
|
const descRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const newTaskRef = useRef<HTMLInputElement>(null);
|
const newTaskRef = useRef<HTMLInputElement>(null);
|
||||||
|
const editingTaskRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||||
open,
|
open,
|
||||||
@@ -90,6 +97,8 @@ export function NoteDialog({
|
|||||||
resetForm(buildInitialValues(note));
|
resetForm(buildInitialValues(note));
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
setNewTaskText("");
|
setNewTaskText("");
|
||||||
|
setEditingTaskId(null);
|
||||||
|
setEditingTaskText("");
|
||||||
requestAnimationFrame(() => titleRef.current?.focus());
|
requestAnimationFrame(() => titleRef.current?.focus());
|
||||||
}
|
}
|
||||||
}, [dialogOpen, note, resetForm]);
|
}, [dialogOpen, note, resetForm]);
|
||||||
@@ -126,7 +135,12 @@ export function NoteDialog({
|
|||||||
formState.description.trim() === (note?.description ?? "").trim() &&
|
formState.description.trim() === (note?.description ?? "").trim() &&
|
||||||
JSON.stringify(formState.tasks) === JSON.stringify(note?.tasks);
|
JSON.stringify(formState.tasks) === JSON.stringify(note?.tasks);
|
||||||
|
|
||||||
const disableSubmit = isPending || onlySpaces || unchanged || invalidLen;
|
const disableSubmit =
|
||||||
|
isPending ||
|
||||||
|
onlySpaces ||
|
||||||
|
unchanged ||
|
||||||
|
invalidLen ||
|
||||||
|
Boolean(editingTaskId);
|
||||||
|
|
||||||
const handleOpenChange = (v: boolean) => {
|
const handleOpenChange = (v: boolean) => {
|
||||||
setDialogOpen(v);
|
setDialogOpen(v);
|
||||||
@@ -159,6 +173,10 @@ export function NoteDialog({
|
|||||||
"tasks",
|
"tasks",
|
||||||
(formState.tasks || []).filter((t) => t.id !== taskId),
|
(formState.tasks || []).filter((t) => t.id !== taskId),
|
||||||
);
|
);
|
||||||
|
if (editingTaskId === taskId) {
|
||||||
|
setEditingTaskId(null);
|
||||||
|
setEditingTaskText("");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleTask = (taskId: string) => {
|
const handleToggleTask = (taskId: string) => {
|
||||||
@@ -170,6 +188,40 @@ export function NoteDialog({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStartEditTask = (task: Task) => {
|
||||||
|
if (isPending) return;
|
||||||
|
|
||||||
|
setEditingTaskId(task.id);
|
||||||
|
setEditingTaskText(task.text);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
editingTaskRef.current?.focus();
|
||||||
|
editingTaskRef.current?.select();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveTask = (taskId: string) => {
|
||||||
|
const text = normalize(editingTaskText);
|
||||||
|
if (!text) {
|
||||||
|
toast.error("O texto da tarefa não pode estar vazio.");
|
||||||
|
editingTaskRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateField(
|
||||||
|
"tasks",
|
||||||
|
(formState.tasks || []).map((t) =>
|
||||||
|
t.id === taskId ? { ...t, text } : t,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setEditingTaskId(null);
|
||||||
|
setEditingTaskText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEditTask = () => {
|
||||||
|
setEditingTaskId(null);
|
||||||
|
setEditingTaskText("");
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
@@ -373,33 +425,78 @@ export function NoteDialog({
|
|||||||
key={task.id}
|
key={task.id}
|
||||||
className="flex items-center gap-3 rounded-md px-3 py-1.5 hover:bg-muted/50"
|
className="flex items-center gap-3 rounded-md px-3 py-1.5 hover:bg-muted/50"
|
||||||
>
|
>
|
||||||
<Checkbox
|
{editingTaskId === task.id ? (
|
||||||
className="data-[state=checked]:bg-success! data-[state=checked]:border-success! data-[state=checked]:text-success-foreground!"
|
<Input
|
||||||
checked={task.completed}
|
ref={editingTaskRef}
|
||||||
onCheckedChange={() => handleToggleTask(task.id)}
|
value={editingTaskText}
|
||||||
disabled={isPending}
|
onChange={(e) => setEditingTaskText(e.target.value)}
|
||||||
aria-label={`Marcar "${task.text}" como ${
|
onKeyDown={(e) => {
|
||||||
task.completed ? "não concluída" : "concluída"
|
if (e.key === "Enter") {
|
||||||
}`}
|
e.preventDefault();
|
||||||
/>
|
e.stopPropagation();
|
||||||
<span
|
handleSaveTask(task.id);
|
||||||
className={cn(
|
}
|
||||||
"flex-1 text-sm wrap-break-word",
|
if (e.key === "Escape") {
|
||||||
task.completed
|
e.preventDefault();
|
||||||
? "text-muted-foreground line-through"
|
e.stopPropagation();
|
||||||
: "text-foreground",
|
handleCancelEditTask();
|
||||||
)}
|
}
|
||||||
>
|
}}
|
||||||
{task.text}
|
disabled={isPending}
|
||||||
</span>
|
className="h-8 min-w-0 flex-1"
|
||||||
|
aria-label={`Editar "${task.text}"`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Checkbox
|
||||||
|
className="data-[state=checked]:bg-success! data-[state=checked]:border-success! data-[state=checked]:text-success-foreground!"
|
||||||
|
checked={task.completed}
|
||||||
|
onCheckedChange={() => handleToggleTask(task.id)}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label={`Marcar "${task.text}" como ${
|
||||||
|
task.completed ? "não concluída" : "concluída"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleStartEditTask(task)}
|
||||||
|
disabled={isPending}
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 flex-1 cursor-text text-left text-sm wrap-break-word transition-colors hover:text-primary disabled:cursor-not-allowed",
|
||||||
|
task.completed
|
||||||
|
? "text-muted-foreground line-through"
|
||||||
|
: "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{task.text}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRemoveTask(task.id)}
|
onClick={() =>
|
||||||
|
editingTaskId === task.id
|
||||||
|
? handleSaveTask(task.id)
|
||||||
|
: handleRemoveTask(task.id)
|
||||||
|
}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="shrink-0 text-muted-foreground/50 hover:text-destructive transition-colors"
|
className={cn(
|
||||||
aria-label={`Remover "${task.text}"`}
|
"shrink-0 transition-colors",
|
||||||
|
editingTaskId === task.id
|
||||||
|
? "text-success hover:text-success/80"
|
||||||
|
: "text-muted-foreground/50 hover:text-destructive",
|
||||||
|
)}
|
||||||
|
aria-label={
|
||||||
|
editingTaskId === task.id
|
||||||
|
? `Salvar "${task.text}"`
|
||||||
|
: `Remover "${task.text}"`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
{editingTaskId === task.id ? (
|
||||||
|
<RiCheckLine className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -124,8 +124,10 @@ function TimelineItem({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 pb-6 space-y-3 min-w-0">
|
<div className="flex-1 pb-6 space-y-3 min-w-0">
|
||||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||||
<h3 className="font-semibold font-mono">v{version.version}</h3>
|
<h3 className="font-semibold font-mono text-lg">
|
||||||
|
v{version.version}
|
||||||
|
</h3>
|
||||||
{isLatest ? (
|
{isLatest ? (
|
||||||
<Badge variant="default" className="text-xs">
|
<Badge variant="default" className="text-xs">
|
||||||
Atual
|
Atual
|
||||||
@@ -142,7 +144,7 @@ function TimelineItem({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{version.summary ? (
|
{version.summary ? (
|
||||||
<Card className="p-4">
|
<Card className="p-6">
|
||||||
<blockquote className="pl-2 text-sm text-muted-foreground leading-relaxed italic">
|
<blockquote className="pl-2 text-sm text-muted-foreground leading-relaxed italic">
|
||||||
{version.summary}
|
{version.summary}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = z.object(
|
|||||||
settledFilter: z.string().nullable(),
|
settledFilter: z.string().nullable(),
|
||||||
attachmentFilter: z.string().nullable(),
|
attachmentFilter: z.string().nullable(),
|
||||||
dividedFilter: z.string().nullable(),
|
dividedFilter: z.string().nullable(),
|
||||||
|
amountMinFilter: z.number().nullable(),
|
||||||
|
amountMaxFilter: z.number().nullable(),
|
||||||
}),
|
}),
|
||||||
accountId: z.string().min(1).nullable().optional(),
|
accountId: z.string().min(1).nullable().optional(),
|
||||||
cardId: z.string().min(1).nullable().optional(),
|
cardId: z.string().min(1).nullable().optional(),
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiAttachment2, RiCloseLine } from "@remixicon/react";
|
import { RiAttachment2, RiCloseLine } from "@remixicon/react";
|
||||||
import { useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
ALLOWED_MIME_TYPES,
|
ALLOWED_MIME_TYPES,
|
||||||
DEFAULT_MAX_FILE_SIZE_MB,
|
DEFAULT_MAX_FILE_SIZE_MB,
|
||||||
} from "@/features/transactions/lib/attachments-config";
|
} from "@/features/transactions/lib/attachments-config";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
getFilesFromClipboard,
|
||||||
|
isTextEditingTarget,
|
||||||
|
validateAttachmentFile,
|
||||||
|
} from "./attachment-file-utils";
|
||||||
|
|
||||||
interface AttachmentFilePickerProps {
|
interface AttachmentFilePickerProps {
|
||||||
files: File[];
|
files: File[];
|
||||||
@@ -22,34 +27,54 @@ export function AttachmentFilePicker({
|
|||||||
onRemove,
|
onRemove,
|
||||||
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
||||||
}: AttachmentFilePickerProps) {
|
}: AttachmentFilePickerProps) {
|
||||||
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
function addFile(file: File) {
|
||||||
|
const validation = validateAttachmentFile(file, maxSizeMb);
|
||||||
|
if (!validation.ok) {
|
||||||
|
toast.error(validation.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onAdd(file);
|
||||||
|
}
|
||||||
|
|
||||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const selected = e.target.files?.[0];
|
const selected = e.target.files?.[0];
|
||||||
if (inputRef.current) inputRef.current.value = "";
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
|
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
|
|
||||||
if (
|
addFile(selected);
|
||||||
!ALLOWED_MIME_TYPES.includes(
|
|
||||||
selected.type as (typeof ALLOWED_MIME_TYPES)[number],
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
toast.error(
|
|
||||||
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selected.size > maxFileSizeBytes) {
|
|
||||||
toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onAdd(selected);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePaste(event: React.ClipboardEvent<HTMLButtonElement>) {
|
||||||
|
const pastedFiles = getFilesFromClipboard(event);
|
||||||
|
if (pastedFiles.length === 0) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
for (const file of pastedFiles) {
|
||||||
|
addFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleDocumentPaste(event: ClipboardEvent) {
|
||||||
|
if (isTextEditingTarget(event.target)) return;
|
||||||
|
|
||||||
|
const pastedFiles = getFilesFromClipboard(event);
|
||||||
|
if (pastedFiles.length === 0) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
for (const file of pastedFiles) {
|
||||||
|
addFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("paste", handleDocumentPaste);
|
||||||
|
return () => document.removeEventListener("paste", handleDocumentPaste);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="text-xs font-medium">Anexos</p>
|
<p className="text-xs font-medium">Anexos</p>
|
||||||
@@ -90,13 +115,15 @@ export function AttachmentFilePicker({
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
|
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
|
||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onPaste={handlePaste}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<RiAttachment2 className="size-4" />
|
<RiAttachment2 className="size-4" />
|
||||||
Adicionar anexo
|
Adicionar anexo
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
|
PDF, JPEG, PNG ou WebP · cole ou busque o arquivo · máx. {maxSizeMb}{" "}
|
||||||
|
MB
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
ALLOWED_MIME_TYPES,
|
||||||
|
DEFAULT_MAX_FILE_SIZE_MB,
|
||||||
|
} from "@/features/transactions/lib/attachments-config";
|
||||||
|
|
||||||
|
type AttachmentValidationResult = { ok: true } | { ok: false; error: string };
|
||||||
|
|
||||||
|
export function validateAttachmentFile(
|
||||||
|
file: File,
|
||||||
|
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
||||||
|
): AttachmentValidationResult {
|
||||||
|
if (
|
||||||
|
!ALLOWED_MIME_TYPES.includes(
|
||||||
|
file.type as (typeof ALLOWED_MIME_TYPES)[number],
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
|
||||||
|
if (file.size > maxFileSizeBytes) {
|
||||||
|
return { ok: false, error: `O arquivo deve ter no máximo ${maxSizeMb}MB.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClipboardLikeEvent = ClipboardEvent | React.ClipboardEvent;
|
||||||
|
|
||||||
|
export function getFilesFromClipboard(event: ClipboardLikeEvent): File[] {
|
||||||
|
const files = Array.from(event.clipboardData?.files ?? []);
|
||||||
|
if (files.length > 0) return files;
|
||||||
|
|
||||||
|
return Array.from(event.clipboardData?.items ?? [])
|
||||||
|
.filter((item) => item.kind === "file")
|
||||||
|
.map((item) => item.getAsFile())
|
||||||
|
.filter((file): file is File => Boolean(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTextEditingTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
|
||||||
|
const tagName = target.tagName.toLowerCase();
|
||||||
|
return (
|
||||||
|
tagName === "input" ||
|
||||||
|
tagName === "textarea" ||
|
||||||
|
target.isContentEditable ||
|
||||||
|
target.closest('[contenteditable="true"]') !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiAttachment2 } from "@remixicon/react";
|
import { RiAttachment2 } from "@remixicon/react";
|
||||||
import { useRef, useTransition } from "react";
|
import { useEffect, useRef, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
confirmAttachmentUploadAction,
|
confirmAttachmentUploadAction,
|
||||||
@@ -11,6 +11,11 @@ import {
|
|||||||
ALLOWED_MIME_TYPES,
|
ALLOWED_MIME_TYPES,
|
||||||
DEFAULT_MAX_FILE_SIZE_MB,
|
DEFAULT_MAX_FILE_SIZE_MB,
|
||||||
} from "@/features/transactions/lib/attachments-config";
|
} from "@/features/transactions/lib/attachments-config";
|
||||||
|
import {
|
||||||
|
getFilesFromClipboard,
|
||||||
|
isTextEditingTarget,
|
||||||
|
validateAttachmentFile,
|
||||||
|
} from "./attachment-file-utils";
|
||||||
|
|
||||||
interface AttachmentUploadProps {
|
interface AttachmentUploadProps {
|
||||||
transactionId: string;
|
transactionId: string;
|
||||||
@@ -25,7 +30,6 @@ export function AttachmentUpload({
|
|||||||
onPendingUpload,
|
onPendingUpload,
|
||||||
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
||||||
}: AttachmentUploadProps) {
|
}: AttachmentUploadProps) {
|
||||||
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
@@ -36,19 +40,13 @@ export function AttachmentUpload({
|
|||||||
|
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
if (
|
handleFile(file);
|
||||||
!ALLOWED_MIME_TYPES.includes(
|
}
|
||||||
file.type as (typeof ALLOWED_MIME_TYPES)[number],
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
toast.error(
|
|
||||||
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > maxFileSizeBytes) {
|
function handleFile(file: File) {
|
||||||
toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`);
|
const validation = validateAttachmentFile(file, maxSizeMb);
|
||||||
|
if (!validation.ok) {
|
||||||
|
toast.error(validation.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +92,29 @@ export function AttachmentUpload({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePaste(event: React.ClipboardEvent<HTMLButtonElement>) {
|
||||||
|
const [file] = getFilesFromClipboard(event);
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
handleFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleDocumentPaste(event: ClipboardEvent) {
|
||||||
|
if (isPending || isTextEditingTarget(event.target)) return;
|
||||||
|
|
||||||
|
const [file] = getFilesFromClipboard(event);
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
handleFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("paste", handleDocumentPaste);
|
||||||
|
return () => document.removeEventListener("paste", handleDocumentPaste);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
@@ -107,6 +128,7 @@ export function AttachmentUpload({
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onPaste={handlePaste}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
@@ -115,7 +137,8 @@ export function AttachmentUpload({
|
|||||||
</span>
|
</span>
|
||||||
{!isPending && (
|
{!isPending && (
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
|
PDF, JPEG, PNG ou WebP · cole ou busque o arquivo · máx. {maxSizeMb}{" "}
|
||||||
|
MB
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -70,6 +70,23 @@ export function BulkActionDialog({
|
|||||||
return "Este e os próximos lançamentos";
|
return "Este e os próximos lançamentos";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getPeriodLabel = () => {
|
||||||
|
if (seriesType === "installment" && currentNumber && totalCount) {
|
||||||
|
return `Todas as pessoas desta parcela (${currentNumber}/${totalCount})`;
|
||||||
|
}
|
||||||
|
if (seriesType === "installment") {
|
||||||
|
return "Todas as pessoas desta parcela";
|
||||||
|
}
|
||||||
|
return "Todas as pessoas deste lançamento";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPeriodDescription = () => {
|
||||||
|
if (seriesType === "installment") {
|
||||||
|
return "Aplica a alteração para todas as pessoas que dividem esta parcela";
|
||||||
|
}
|
||||||
|
return "Aplica a alteração para todas as pessoas que dividem este lançamento";
|
||||||
|
};
|
||||||
|
|
||||||
const getAllLabel = () => {
|
const getAllLabel = () => {
|
||||||
if (seriesType === "installment" && totalCount) {
|
if (seriesType === "installment" && totalCount) {
|
||||||
return `Todas as parcelas (${totalCount} ${
|
return `Todas as parcelas (${totalCount} ${
|
||||||
@@ -116,10 +133,10 @@ export function BulkActionDialog({
|
|||||||
htmlFor="period"
|
htmlFor="period"
|
||||||
className="text-sm cursor-pointer font-medium"
|
className="text-sm cursor-pointer font-medium"
|
||||||
>
|
>
|
||||||
{`Todas as pessoas desta parcela (${currentNumber}/${totalCount})`}
|
{getPeriodLabel()}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Aplica a alteração para todas as pessoas que dividem esta parcela
|
{getPeriodDescription()}
|
||||||
</p>
|
</p>
|
||||||
{scope === "period" && actionType === "edit" && (
|
{scope === "period" && actionType === "edit" && (
|
||||||
<div className="mt-1.5 flex items-start gap-1.5 rounded-md bg-amber-50 px-2 py-1.5 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300">
|
<div className="mt-1.5 flex items-start gap-1.5 rounded-md bg-amber-50 px-2 py-1.5 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300">
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
currencyFormatter,
|
currencyFormatter,
|
||||||
formatCondition,
|
formatCondition,
|
||||||
formatDate,
|
|
||||||
formatPeriod,
|
formatPeriod,
|
||||||
} from "@/features/transactions/lib/formatting-helpers";
|
} from "@/features/transactions/lib/formatting-helpers";
|
||||||
|
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
|
||||||
import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge";
|
import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -34,7 +34,7 @@ import { Separator } from "@/shared/components/ui/separator";
|
|||||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||||
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
||||||
import { getCategoryColorFromName } from "@/shared/utils/category-colors";
|
import { getCategoryColorFromName } from "@/shared/utils/category-colors";
|
||||||
import { parseLocalDateString } from "@/shared/utils/date";
|
import { formatDate, parseLocalDateString } from "@/shared/utils/date";
|
||||||
import { getIconComponent, getPaymentMethodIcon } from "@/shared/utils/icons";
|
import { getIconComponent, getPaymentMethodIcon } from "@/shared/utils/icons";
|
||||||
import { AttachmentSection } from "../attachments/attachment-section";
|
import { AttachmentSection } from "../attachments/attachment-section";
|
||||||
import { InstallmentTimeline } from "../shared/installment-timeline";
|
import { InstallmentTimeline } from "../shared/installment-timeline";
|
||||||
@@ -55,10 +55,9 @@ export function TransactionDetailsDialog({
|
|||||||
}: TransactionDetailsDialogProps) {
|
}: TransactionDetailsDialogProps) {
|
||||||
const [attachmentCount, setAttachmentCount] = useState<number | null>(null);
|
const [attachmentCount, setAttachmentCount] = useState<number | null>(null);
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: transaction?.id é trigger intencional para reset do contador
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAttachmentCount(null);
|
setAttachmentCount(null);
|
||||||
}, [transaction?.id]);
|
}, []);
|
||||||
|
|
||||||
if (!transaction) return null;
|
if (!transaction) return null;
|
||||||
|
|
||||||
@@ -87,11 +86,16 @@ export function TransactionDetailsDialog({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="min-w-0 overflow-x-hidden sm:max-w-xl">
|
<DialogContent className="min-w-0 overflow-x-hidden sm:max-w-xl">
|
||||||
<DialogHeader>
|
<DialogHeader className="text-left">
|
||||||
<DialogTitle>{transaction.name}</DialogTitle>
|
<div className="flex min-w-0 items-start gap-2">
|
||||||
<DialogDescription>
|
<EstablishmentLogo size={40} name={transaction.name} />
|
||||||
{formatDate(transaction.purchaseDate)}
|
<div className="min-w-0">
|
||||||
</DialogDescription>
|
<DialogTitle className="truncate">{transaction.name}</DialogTitle>
|
||||||
|
<DialogDescription className="mt-1">
|
||||||
|
{formatDate(transaction.purchaseDate)}
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm">
|
<div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm">
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { getCategoryBudgetSummaryAction } from "@/features/budgets/actions";
|
||||||
|
import type { CategoryBudgetSummary } from "@/features/budgets/queries";
|
||||||
import { TRANSACTION_TYPES } from "@/features/transactions/lib/constants";
|
import { TRANSACTION_TYPES } from "@/features/transactions/lib/constants";
|
||||||
import { Label } from "@/shared/components/ui/label";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +14,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select";
|
} from "@/shared/components/ui/select";
|
||||||
|
import { formatCurrency } from "@/shared/utils/currency";
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils/ui";
|
||||||
import {
|
import {
|
||||||
CategorySelectContent,
|
CategorySelectContent,
|
||||||
@@ -18,6 +22,22 @@ import {
|
|||||||
} from "../../select-items";
|
} from "../../select-items";
|
||||||
import type { CategorySectionProps } from "./transaction-dialog-types";
|
import type { CategorySectionProps } from "./transaction-dialog-types";
|
||||||
|
|
||||||
|
const BUDGET_DANGER_RATIO = 1;
|
||||||
|
const BUDGET_WARNING_RATIO = 0.8;
|
||||||
|
|
||||||
|
const getBudgetTone = (ratio: number) => {
|
||||||
|
if (ratio >= BUDGET_DANGER_RATIO) return "text-red-600 dark:text-red-400";
|
||||||
|
if (ratio >= BUDGET_WARNING_RATIO)
|
||||||
|
return "text-amber-600 dark:text-amber-400";
|
||||||
|
return "text-emerald-600 dark:text-emerald-400";
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCompactCurrency = (value: number) =>
|
||||||
|
formatCurrency(value, {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
|
||||||
export function CategorySection({
|
export function CategorySection({
|
||||||
formState,
|
formState,
|
||||||
onFieldChange,
|
onFieldChange,
|
||||||
@@ -28,6 +48,62 @@ export function CategorySection({
|
|||||||
}: CategorySectionProps) {
|
}: CategorySectionProps) {
|
||||||
const showTransactionTypeField = !isUpdateMode && !hideTransactionType;
|
const showTransactionTypeField = !isUpdateMode && !hideTransactionType;
|
||||||
|
|
||||||
|
const [budgetSummary, setBudgetSummary] =
|
||||||
|
useState<CategoryBudgetSummary | null>(null);
|
||||||
|
const cacheRef = useRef<Map<string, CategoryBudgetSummary | null>>(new Map());
|
||||||
|
|
||||||
|
const { categoryId, period, transactionType } = formState;
|
||||||
|
const shouldFetchBudget =
|
||||||
|
Boolean(categoryId) && Boolean(period) && transactionType === "Despesa";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldFetchBudget || !categoryId || !period) {
|
||||||
|
setBudgetSummary(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${categoryId}::${period}`;
|
||||||
|
const cached = cacheRef.current.get(key);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
setBudgetSummary((prev) => (prev === cached ? prev : cached));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
getCategoryBudgetSummaryAction({ categoryId, period }).then((result) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const data = result.success ? (result.data ?? null) : null;
|
||||||
|
cacheRef.current.set(key, data);
|
||||||
|
setBudgetSummary(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [shouldFetchBudget, categoryId, period]);
|
||||||
|
|
||||||
|
const renderBudgetBadge = () => {
|
||||||
|
if (showTransactionTypeField) return null;
|
||||||
|
if (!shouldFetchBudget || !budgetSummary) return null;
|
||||||
|
|
||||||
|
const { amount, spent } = budgetSummary;
|
||||||
|
const ratio = amount > 0 ? spent / amount : 0;
|
||||||
|
const percent = amount > 0 ? Math.round(ratio * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title={`${formatCurrency(spent)} de ${formatCurrency(amount)} (${percent}%)`}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 text-xs font-semibold leading-none whitespace-nowrap font-mono",
|
||||||
|
getBudgetTone(ratio),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatCompactCurrency(spent)} de {formatCompactCurrency(amount)}
|
||||||
|
<span className="ml-1 opacity-70">({percent}%)</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
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">
|
||||||
{showTransactionTypeField ? (
|
{showTransactionTypeField ? (
|
||||||
@@ -77,12 +153,16 @@ export function CategorySection({
|
|||||||
const selectedOption = categoryOptions.find(
|
const selectedOption = categoryOptions.find(
|
||||||
(opt) => opt.value === formState.categoryId,
|
(opt) => opt.value === formState.categoryId,
|
||||||
);
|
);
|
||||||
return selectedOption ? (
|
if (!selectedOption) return null;
|
||||||
<CategorySelectContent
|
return (
|
||||||
label={selectedOption.label}
|
<span className="flex items-center gap-2">
|
||||||
icon={selectedOption.icon}
|
<CategorySelectContent
|
||||||
/>
|
label={selectedOption.label}
|
||||||
) : null;
|
icon={selectedOption.icon}
|
||||||
|
/>
|
||||||
|
{renderBudgetBadge()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
})()}
|
})()}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||||
import { RiSliceFill } from "@remixicon/react";
|
import { RiSliceFill } from "@remixicon/react";
|
||||||
|
import { useState } from "react";
|
||||||
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
||||||
|
import { Input } from "@/shared/components/ui/input";
|
||||||
import { Label } from "@/shared/components/ui/label";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -11,10 +13,124 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select";
|
} from "@/shared/components/ui/select";
|
||||||
|
import {
|
||||||
|
ToggleGroup,
|
||||||
|
ToggleGroupItem,
|
||||||
|
} from "@/shared/components/ui/toggle-group";
|
||||||
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
formatDecimalForDbRequired,
|
||||||
|
normalizeDecimalInput,
|
||||||
|
} from "@/shared/utils/currency";
|
||||||
|
import { safeToNumber } from "@/shared/utils/number";
|
||||||
import { cn } from "@/shared/utils/ui";
|
import { cn } from "@/shared/utils/ui";
|
||||||
import { PayerSelectContent } from "../../select-items";
|
import { PayerSelectContent } from "../../select-items";
|
||||||
import type { PayerSectionProps } from "./transaction-dialog-types";
|
import type { PayerSectionProps } from "./transaction-dialog-types";
|
||||||
|
|
||||||
|
type SplitInputMode = "currency" | "percentage";
|
||||||
|
|
||||||
|
const SPLIT_MODE_OPTIONS = [
|
||||||
|
{ value: "currency", label: "R$" },
|
||||||
|
{ value: "percentage", label: "%" },
|
||||||
|
] as const satisfies ReadonlyArray<{ value: SplitInputMode; label: string }>;
|
||||||
|
|
||||||
|
const amountToPercent = (amount: string, total: number): string => {
|
||||||
|
if (total <= 0) return "";
|
||||||
|
const numeric = safeToNumber(normalizeDecimalInput(amount), Number.NaN);
|
||||||
|
if (!Number.isFinite(numeric)) return "";
|
||||||
|
const pct = (numeric / total) * 100;
|
||||||
|
return (Math.round(pct * 10) / 10).toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const percentToAmount = (percent: string, total: number): string => {
|
||||||
|
const pct = safeToNumber(normalizeDecimalInput(percent), Number.NaN);
|
||||||
|
if (!Number.isFinite(pct) || total <= 0) return "0.00";
|
||||||
|
const clamped = Math.min(100, Math.max(0, pct));
|
||||||
|
return formatDecimalForDbRequired((total * clamped) / 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
function SplitModeToggle({
|
||||||
|
mode,
|
||||||
|
onModeChange,
|
||||||
|
}: {
|
||||||
|
mode: SplitInputMode;
|
||||||
|
onModeChange: (mode: SplitInputMode) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
value={mode}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value) onModeChange(value as SplitInputMode);
|
||||||
|
}}
|
||||||
|
aria-label="Modo de entrada do split"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
{SPLIT_MODE_OPTIONS.map((option) => (
|
||||||
|
<ToggleGroupItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
className="px-2 py-0 h-7 text-xs"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SplitAmountField({
|
||||||
|
mode,
|
||||||
|
value,
|
||||||
|
totalAmount,
|
||||||
|
onAmountChange,
|
||||||
|
ariaLabel,
|
||||||
|
}: {
|
||||||
|
mode: SplitInputMode;
|
||||||
|
value: string;
|
||||||
|
totalAmount: number;
|
||||||
|
onAmountChange: (amount: string) => void;
|
||||||
|
ariaLabel: string;
|
||||||
|
}) {
|
||||||
|
if (mode === "currency") {
|
||||||
|
return (
|
||||||
|
<CurrencyInput
|
||||||
|
value={value}
|
||||||
|
onValueChange={onAmountChange}
|
||||||
|
placeholder="R$ 0,00"
|
||||||
|
className="h-9 w-[45%] text-sm"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-[45%] space-y-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={amountToPercent(value, totalAmount)}
|
||||||
|
onChange={(event) => {
|
||||||
|
const sanitized = event.target.value.replace(/[^\d.,]/g, "");
|
||||||
|
onAmountChange(percentToAmount(sanitized, totalAmount));
|
||||||
|
}}
|
||||||
|
placeholder="0"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="h-9 w-full pr-7 text-sm"
|
||||||
|
/>
|
||||||
|
<span className="pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 text-xs text-muted-foreground">
|
||||||
|
%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="ml-1 text-xs text-muted-foreground">
|
||||||
|
{formatCurrency(safeToNumber(value))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function PayerSection({
|
export function PayerSection({
|
||||||
formState,
|
formState,
|
||||||
onFieldChange,
|
onFieldChange,
|
||||||
@@ -22,17 +138,17 @@ export function PayerSection({
|
|||||||
secondaryPayerOptions,
|
secondaryPayerOptions,
|
||||||
totalAmount,
|
totalAmount,
|
||||||
}: PayerSectionProps) {
|
}: PayerSectionProps) {
|
||||||
|
const [splitMode, setSplitMode] = useState<SplitInputMode>("currency");
|
||||||
|
|
||||||
const handlePrimaryAmountChange = (value: string) => {
|
const handlePrimaryAmountChange = (value: string) => {
|
||||||
onFieldChange("primarySplitAmount", value);
|
onFieldChange("primarySplitAmount", value);
|
||||||
const numericValue = Number.parseFloat(value) || 0;
|
const remaining = Math.max(0, totalAmount - safeToNumber(value));
|
||||||
const remaining = Math.max(0, totalAmount - numericValue);
|
|
||||||
onFieldChange("secondarySplitAmount", remaining.toFixed(2));
|
onFieldChange("secondarySplitAmount", remaining.toFixed(2));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSecondaryAmountChange = (value: string) => {
|
const handleSecondaryAmountChange = (value: string) => {
|
||||||
onFieldChange("secondarySplitAmount", value);
|
onFieldChange("secondarySplitAmount", value);
|
||||||
const numericValue = Number.parseFloat(value) || 0;
|
const remaining = Math.max(0, totalAmount - safeToNumber(value));
|
||||||
const remaining = Math.max(0, totalAmount - numericValue);
|
|
||||||
onFieldChange("primarySplitAmount", remaining.toFixed(2));
|
onFieldChange("primarySplitAmount", remaining.toFixed(2));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,23 +170,28 @@ export function PayerSection({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CheckboxPrimitive.Root
|
<div className="flex items-center gap-2">
|
||||||
checked={formState.isSplit}
|
{formState.isSplit ? (
|
||||||
onCheckedChange={(checked) =>
|
<SplitModeToggle mode={splitMode} onModeChange={setSplitMode} />
|
||||||
onFieldChange("isSplit", Boolean(checked))
|
) : null}
|
||||||
}
|
<CheckboxPrimitive.Root
|
||||||
aria-label="Dividir lançamento"
|
checked={formState.isSplit}
|
||||||
className={cn(
|
onCheckedChange={(checked) =>
|
||||||
"peer size-4 shrink-0 rounded-lg border shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
onFieldChange("isSplit", Boolean(checked))
|
||||||
formState.isSplit
|
}
|
||||||
? "border-primary bg-primary text-primary-foreground"
|
aria-label="Dividir lançamento"
|
||||||
: "border-input dark:bg-input/30",
|
className={cn(
|
||||||
)}
|
"peer size-4 shrink-0 rounded-lg border shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
>
|
formState.isSplit
|
||||||
<CheckboxPrimitive.Indicator className="grid place-content-center text-current transition-none">
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
<RiSliceFill className="size-3" />
|
: "border-input dark:bg-input/30",
|
||||||
</CheckboxPrimitive.Indicator>
|
)}
|
||||||
</CheckboxPrimitive.Root>
|
>
|
||||||
|
<CheckboxPrimitive.Indicator className="grid place-content-center text-current transition-none">
|
||||||
|
<RiSliceFill className="size-3" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||||
@@ -111,14 +232,15 @@ export function PayerSection({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{formState.isSplit && (
|
{formState.isSplit ? (
|
||||||
<CurrencyInput
|
<SplitAmountField
|
||||||
|
mode={splitMode}
|
||||||
value={formState.primarySplitAmount}
|
value={formState.primarySplitAmount}
|
||||||
onValueChange={handlePrimaryAmountChange}
|
totalAmount={totalAmount}
|
||||||
placeholder="R$ 0,00"
|
onAmountChange={handlePrimaryAmountChange}
|
||||||
className="h-9 w-[45%] text-sm"
|
ariaLabel="Porcentagem da pessoa"
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -163,11 +285,12 @@ export function PayerSection({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<CurrencyInput
|
<SplitAmountField
|
||||||
|
mode={splitMode}
|
||||||
value={formState.secondarySplitAmount}
|
value={formState.secondarySplitAmount}
|
||||||
onValueChange={handleSecondaryAmountChange}
|
totalAmount={totalAmount}
|
||||||
placeholder="R$ 0,00"
|
onAmountChange={handleSecondaryAmountChange}
|
||||||
className="h-9 w-[45%] text-sm"
|
ariaLabel="Porcentagem do segundo pagador"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { RiArrowDropDownLine } from "@remixicon/react";
|
import { RiArrowDropDownLine } from "@remixicon/react";
|
||||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
createTransactionAction,
|
createTransactionAction,
|
||||||
@@ -102,6 +102,8 @@ export function TransactionDialog({
|
|||||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||||
const [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]);
|
const [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]);
|
||||||
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]);
|
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]);
|
||||||
|
const [extrasOpen, setExtrasOpen] = useState(false);
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dialogOpen) {
|
if (dialogOpen) {
|
||||||
@@ -142,6 +144,7 @@ export function TransactionDialog({
|
|||||||
setPendingFiles([]);
|
setPendingFiles([]);
|
||||||
setPendingDetachIds([]);
|
setPendingDetachIds([]);
|
||||||
setPendingUploadFiles([]);
|
setPendingUploadFiles([]);
|
||||||
|
setExtrasOpen(initial.condition !== "À vista");
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
dialogOpen,
|
dialogOpen,
|
||||||
@@ -211,6 +214,22 @@ export function TransactionDialog({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleExtrasOpenChange(nextOpen: boolean) {
|
||||||
|
setExtrasOpen(nextOpen);
|
||||||
|
|
||||||
|
if (nextOpen) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const scrollContainer = scrollContainerRef.current;
|
||||||
|
if (!scrollContainer) return;
|
||||||
|
|
||||||
|
scrollContainer.scrollTo({
|
||||||
|
top: scrollContainer.scrollHeight,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
@@ -527,18 +546,21 @@ export function TransactionDialog({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||||
<DialogContent className="min-w-0 overflow-x-hidden">
|
<DialogContent className="flex max-h-[90vh] min-w-0 flex-col overflow-hidden p-4 sm:p-10">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>{description}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="flex min-w-0 flex-col gap-0"
|
className="flex min-h-0 min-w-0 flex-1 flex-col gap-0"
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
noValidate
|
noValidate
|
||||||
>
|
>
|
||||||
<div className="min-w-0 -mx-6 max-h-[90vh] overflow-x-hidden overflow-y-auto px-6 pb-1">
|
<div
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain pr-1 pb-1"
|
||||||
|
>
|
||||||
{/* Detalhes */}
|
{/* Detalhes */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<BasicFieldsSection
|
<BasicFieldsSection
|
||||||
@@ -634,7 +656,8 @@ export function TransactionDialog({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Collapsible
|
<Collapsible
|
||||||
defaultOpen={formState.condition !== "À vista"}
|
open={extrasOpen}
|
||||||
|
onOpenChange={handleExtrasOpenChange}
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
>
|
>
|
||||||
<CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4">
|
<CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4">
|
||||||
@@ -680,7 +703,7 @@ export function TransactionDialog({
|
|||||||
<p className="mt-3 text-sm text-destructive">{errorMessage}</p>
|
<p className="mt-3 text-sm text-destructive">{errorMessage}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
<DialogFooter className="mt-4 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ import {
|
|||||||
RiCheckLine,
|
RiCheckLine,
|
||||||
RiCloseLine,
|
RiCloseLine,
|
||||||
RiExpandUpDownLine,
|
RiExpandUpDownLine,
|
||||||
RiFilter3Line,
|
RiFilterLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import {
|
||||||
|
type ReadonlyURLSearchParams,
|
||||||
|
usePathname,
|
||||||
|
useRouter,
|
||||||
|
useSearchParams,
|
||||||
|
} from "next/navigation";
|
||||||
import {
|
import {
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -16,11 +21,14 @@ import {
|
|||||||
useTransition,
|
useTransition,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
|
AMOUNT_MAX_PARAM,
|
||||||
|
AMOUNT_MIN_PARAM,
|
||||||
PAYMENT_METHODS,
|
PAYMENT_METHODS,
|
||||||
SETTLED_FILTER_VALUES,
|
SETTLED_FILTER_VALUES,
|
||||||
TRANSACTION_CONDITIONS,
|
TRANSACTION_CONDITIONS,
|
||||||
TRANSACTION_TYPES,
|
TRANSACTION_TYPES,
|
||||||
} from "@/features/transactions/lib/constants";
|
} from "@/features/transactions/lib/constants";
|
||||||
|
import { parsePositiveAmount } from "@/features/transactions/lib/page-helpers";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
@@ -70,6 +78,36 @@ import type {
|
|||||||
|
|
||||||
const FILTER_EMPTY_VALUE = "__all";
|
const FILTER_EMPTY_VALUE = "__all";
|
||||||
|
|
||||||
|
const normalizeAmountParam = (raw: string): string | null => {
|
||||||
|
const parsed = parsePositiveAmount(raw.trim());
|
||||||
|
return parsed === null ? null : parsed.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
function useDebouncedAmountFilter(
|
||||||
|
param: string,
|
||||||
|
searchParams: URLSearchParams | ReadonlyURLSearchParams,
|
||||||
|
onChange: (key: string, value: string | null) => void,
|
||||||
|
): [string, (value: string) => void] {
|
||||||
|
const current = searchParams.get(param) ?? "";
|
||||||
|
const [value, setValue] = useState(current);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(current);
|
||||||
|
}, [current]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value === current) return;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
const normalized = normalizeAmountParam(value);
|
||||||
|
if ((normalized ?? "") === current) return;
|
||||||
|
onChange(param, normalized);
|
||||||
|
}, 400);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [value, current, param, onChange]);
|
||||||
|
|
||||||
|
return [value, setValue];
|
||||||
|
}
|
||||||
|
|
||||||
interface FilterSelectProps {
|
interface FilterSelectProps {
|
||||||
param: string;
|
param: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
@@ -348,6 +386,7 @@ export function TransactionsFilters({
|
|||||||
? `${pathname}?${nextParams.toString()}`
|
? `${pathname}?${nextParams.toString()}`
|
||||||
: pathname;
|
: pathname;
|
||||||
router.replace(target, { scroll: false });
|
router.replace(target, { scroll: false });
|
||||||
|
router.refresh();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[searchParams, pathname, router],
|
[searchParams, pathname, router],
|
||||||
@@ -373,6 +412,17 @@ export function TransactionsFilters({
|
|||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
}, [searchValue, currentSearchParam, handleFilterChange]);
|
}, [searchValue, currentSearchParam, handleFilterChange]);
|
||||||
|
|
||||||
|
const [valorMinValue, setValorMinValue] = useDebouncedAmountFilter(
|
||||||
|
AMOUNT_MIN_PARAM,
|
||||||
|
searchParams,
|
||||||
|
handleFilterChange,
|
||||||
|
);
|
||||||
|
const [valorMaxValue, setValorMaxValue] = useDebouncedAmountFilter(
|
||||||
|
AMOUNT_MAX_PARAM,
|
||||||
|
searchParams,
|
||||||
|
handleFilterChange,
|
||||||
|
);
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
const periodValue = searchParams.get("periodo");
|
const periodValue = searchParams.get("periodo");
|
||||||
const pageSizeValue = searchParams.get("pageSize");
|
const pageSizeValue = searchParams.get("pageSize");
|
||||||
@@ -384,6 +434,8 @@ export function TransactionsFilters({
|
|||||||
nextParams.set("pageSize", pageSizeValue);
|
nextParams.set("pageSize", pageSizeValue);
|
||||||
}
|
}
|
||||||
setSearchValue("");
|
setSearchValue("");
|
||||||
|
setValorMinValue("");
|
||||||
|
setValorMaxValue("");
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
const target = nextParams.toString()
|
const target = nextParams.toString()
|
||||||
? `${pathname}?${nextParams.toString()}`
|
? `${pathname}?${nextParams.toString()}`
|
||||||
@@ -467,7 +519,9 @@ export function TransactionsFilters({
|
|||||||
searchParams.getAll("accountCard").length > 0 ||
|
searchParams.getAll("accountCard").length > 0 ||
|
||||||
searchParams.get("settled") ||
|
searchParams.get("settled") ||
|
||||||
searchParams.get("hasAttachment") ||
|
searchParams.get("hasAttachment") ||
|
||||||
searchParams.get("isDivided");
|
searchParams.get("isDivided") ||
|
||||||
|
searchParams.get(AMOUNT_MIN_PARAM) ||
|
||||||
|
searchParams.get(AMOUNT_MAX_PARAM);
|
||||||
|
|
||||||
const handleResetFilters = () => {
|
const handleResetFilters = () => {
|
||||||
handleReset();
|
handleReset();
|
||||||
@@ -523,13 +577,27 @@ export function TransactionsFilters({
|
|||||||
className="flex-1 md:flex-none text-sm border-dashed relative bg-transparent"
|
className="flex-1 md:flex-none text-sm border-dashed relative bg-transparent"
|
||||||
aria-label="Abrir filtros"
|
aria-label="Abrir filtros"
|
||||||
>
|
>
|
||||||
<RiFilter3Line className="size-4" />
|
<RiFilterLine className="size-4" />
|
||||||
Filtros
|
Filtros
|
||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
<span className="absolute -top-1 -right-1 size-3 rounded-full bg-primary" />
|
<span className="absolute -top-1 -right-1 size-3 rounded-full bg-primary" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DrawerTrigger>
|
</DrawerTrigger>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label="Limpar filtros"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground h-9 px-2"
|
||||||
|
>
|
||||||
|
<RiCloseLine className="size-3.5" />
|
||||||
|
Limpar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<DrawerContent>
|
<DrawerContent>
|
||||||
<DrawerHeader>
|
<DrawerHeader>
|
||||||
<DrawerTitle>Filtros</DrawerTitle>
|
<DrawerTitle>Filtros</DrawerTitle>
|
||||||
@@ -636,6 +704,37 @@ export function TransactionsFilters({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Faixa de valor</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="Mínimo"
|
||||||
|
aria-label="Valor mínimo"
|
||||||
|
value={valorMinValue}
|
||||||
|
onChange={(event) => setValorMinValue(event.target.value)}
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-sm border-dashed"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">até</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="Máximo"
|
||||||
|
aria-label="Valor máximo"
|
||||||
|
value={valorMaxValue}
|
||||||
|
onChange={(event) => setValorMaxValue(event.target.value)}
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-sm border-dashed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm font-medium">Status</p>
|
<p className="text-sm font-medium">Status</p>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -30,3 +30,6 @@ export const SETTLED_FILTER_VALUES = {
|
|||||||
PAID: "pago",
|
PAID: "pago",
|
||||||
UNPAID: "nao-pago",
|
UNPAID: "nao-pago",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const AMOUNT_MIN_PARAM = "valorMin";
|
||||||
|
export const AMOUNT_MAX_PARAM = "valorMax";
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ type TransactionExportFilters = {
|
|||||||
settledFilter: string | null;
|
settledFilter: string | null;
|
||||||
attachmentFilter: string | null;
|
attachmentFilter: string | null;
|
||||||
dividedFilter: string | null;
|
dividedFilter: string | null;
|
||||||
|
amountMinFilter: number | null;
|
||||||
|
amountMaxFilter: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TransactionsExportContext = {
|
export type TransactionsExportContext = {
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import type { SQL } from "drizzle-orm";
|
import type { SQL } from "drizzle-orm";
|
||||||
import { and, eq, ilike, inArray, isNotNull, or, sql } from "drizzle-orm";
|
import {
|
||||||
|
and,
|
||||||
|
eq,
|
||||||
|
gte,
|
||||||
|
ilike,
|
||||||
|
inArray,
|
||||||
|
isNotNull,
|
||||||
|
lte,
|
||||||
|
or,
|
||||||
|
sql,
|
||||||
|
} from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
cards,
|
cards,
|
||||||
type categories,
|
type categories,
|
||||||
@@ -10,6 +20,8 @@ import {
|
|||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import type { SelectOption } from "@/features/transactions/components/types";
|
import type { SelectOption } from "@/features/transactions/components/types";
|
||||||
import {
|
import {
|
||||||
|
AMOUNT_MAX_PARAM,
|
||||||
|
AMOUNT_MIN_PARAM,
|
||||||
PAYMENT_METHODS,
|
PAYMENT_METHODS,
|
||||||
SETTLED_FILTER_VALUES,
|
SETTLED_FILTER_VALUES,
|
||||||
TRANSACTION_CONDITIONS,
|
TRANSACTION_CONDITIONS,
|
||||||
@@ -46,6 +58,8 @@ export type TransactionSearchFilters = {
|
|||||||
settledFilter: string | null;
|
settledFilter: string | null;
|
||||||
attachmentFilter: string | null;
|
attachmentFilter: string | null;
|
||||||
dividedFilter: string | null;
|
dividedFilter: string | null;
|
||||||
|
amountMinFilter: number | null;
|
||||||
|
amountMaxFilter: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BaseSluggedOption = {
|
type BaseSluggedOption = {
|
||||||
@@ -135,6 +149,13 @@ export const getMultiParam = (
|
|||||||
return list.filter((item): item is string => Boolean(item));
|
return list.filter((item): item is string => Boolean(item));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const parsePositiveAmount = (value: string | null): number | null => {
|
||||||
|
if (!value) return null;
|
||||||
|
const normalized = Number.parseFloat(value.replace(",", "."));
|
||||||
|
if (!Number.isFinite(normalized) || normalized < 0) return null;
|
||||||
|
return Math.round(normalized * 100) / 100;
|
||||||
|
};
|
||||||
|
|
||||||
export const extractTransactionSearchFilters = (
|
export const extractTransactionSearchFilters = (
|
||||||
params: ResolvedSearchParams,
|
params: ResolvedSearchParams,
|
||||||
): TransactionSearchFilters => ({
|
): TransactionSearchFilters => ({
|
||||||
@@ -148,6 +169,12 @@ export const extractTransactionSearchFilters = (
|
|||||||
settledFilter: getSingleParam(params, "settled"),
|
settledFilter: getSingleParam(params, "settled"),
|
||||||
attachmentFilter: getSingleParam(params, "hasAttachment"),
|
attachmentFilter: getSingleParam(params, "hasAttachment"),
|
||||||
dividedFilter: getSingleParam(params, "isDivided"),
|
dividedFilter: getSingleParam(params, "isDivided"),
|
||||||
|
amountMinFilter: parsePositiveAmount(
|
||||||
|
getSingleParam(params, AMOUNT_MIN_PARAM),
|
||||||
|
),
|
||||||
|
amountMaxFilter: parsePositiveAmount(
|
||||||
|
getSingleParam(params, AMOUNT_MAX_PARAM),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resolveTransactionPagination = (
|
export const resolveTransactionPagination = (
|
||||||
@@ -442,6 +469,18 @@ export const buildTransactionWhere = ({
|
|||||||
where.push(eq(transactions.isDivided, true));
|
where.push(eq(transactions.isDivided, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filters.amountMinFilter !== null) {
|
||||||
|
where.push(
|
||||||
|
gte(sql`abs(${transactions.amount})`, filters.amountMinFilter.toFixed(2)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.amountMaxFilter !== null) {
|
||||||
|
where.push(
|
||||||
|
lte(sql`abs(${transactions.amount})`, filters.amountMaxFilter.toFixed(2)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const searchPattern = buildSearchPattern(filters.searchFilter);
|
const searchPattern = buildSearchPattern(filters.searchFilter);
|
||||||
if (searchPattern) {
|
if (searchPattern) {
|
||||||
where.push(
|
where.push(
|
||||||
|
|||||||
@@ -11,6 +11,20 @@ type CalculatorDisplayProps = {
|
|||||||
isResultView: boolean;
|
isResultView: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getExpressionSizeClass = (length: number, compact: boolean) => {
|
||||||
|
if (compact) {
|
||||||
|
if (length <= 14) return "text-2xl";
|
||||||
|
if (length <= 20) return "text-xl";
|
||||||
|
if (length <= 28) return "text-base";
|
||||||
|
return "text-sm";
|
||||||
|
}
|
||||||
|
if (length <= 12) return "text-3xl";
|
||||||
|
if (length <= 18) return "text-2xl";
|
||||||
|
if (length <= 24) return "text-xl";
|
||||||
|
if (length <= 32) return "text-base";
|
||||||
|
return "text-sm";
|
||||||
|
};
|
||||||
|
|
||||||
export function CalculatorDisplay({
|
export function CalculatorDisplay({
|
||||||
history,
|
history,
|
||||||
expression,
|
expression,
|
||||||
@@ -19,8 +33,10 @@ export function CalculatorDisplay({
|
|||||||
onCopy,
|
onCopy,
|
||||||
isResultView,
|
isResultView,
|
||||||
}: CalculatorDisplayProps) {
|
}: CalculatorDisplayProps) {
|
||||||
|
const sizeClass = getExpressionSizeClass(expression.length, isResultView);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-24 flex-col rounded-xl border bg-muted px-4 py-4 text-right">
|
<div className="flex h-24 min-w-0 flex-col rounded-xl border bg-muted px-4 py-4 text-right">
|
||||||
<div className="min-h-5 truncate text-sm text-muted-foreground">
|
<div className="min-h-5 truncate text-sm text-muted-foreground">
|
||||||
{history ?? (
|
{history ?? (
|
||||||
<span
|
<span
|
||||||
@@ -31,11 +47,11 @@ export function CalculatorDisplay({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-auto flex items-end justify-end gap-2">
|
<div className="mt-auto flex min-w-0 items-end justify-end gap-2">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"truncate text-right font-semibold transition-all",
|
"min-w-0 flex-1 truncate text-right font-semibold transition-all",
|
||||||
isResultView ? "text-2xl" : "text-3xl",
|
sizeClass,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{expression}
|
{expression}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ function Calendar({
|
|||||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||||
defaultClassNames.caption_label,
|
defaultClassNames.caption_label,
|
||||||
),
|
),
|
||||||
table: "w-full border-collapse",
|
month_grid: "w-full border-collapse",
|
||||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
weekday: cn(
|
weekday: cn(
|
||||||
"text-muted-foreground rounded-md flex-1 font-normal text-xs select-none",
|
"text-muted-foreground rounded-md flex-1 font-normal text-xs select-none",
|
||||||
|
|||||||
@@ -172,8 +172,8 @@ export function DatePicker({
|
|||||||
month={month}
|
month={month}
|
||||||
onMonthChange={setMonth}
|
onMonthChange={setMonth}
|
||||||
onSelect={handleCalendarSelect}
|
onSelect={handleCalendarSelect}
|
||||||
fromYear={2020}
|
startMonth={new Date(2020, 0)}
|
||||||
toYear={new Date().getFullYear() + 10}
|
endMonth={new Date(new Date().getFullYear() + 10, 11)}
|
||||||
locale={ptBR}
|
locale={ptBR}
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import {
|
|||||||
sql,
|
sql,
|
||||||
sum,
|
sum,
|
||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
import { cards, transactions } from "@/db/schema";
|
import { cards, financialAccounts, transactions } from "@/db/schema";
|
||||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
||||||
|
import { excludeTransactionsFromExcludedAccounts } from "@/shared/lib/accounts/query-filters";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { toDateOnlyString } from "@/shared/utils/date";
|
import { toDateOnlyString } from "@/shared/utils/date";
|
||||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||||
@@ -96,12 +97,17 @@ export async function fetchPayerMonthlyBreakdown({
|
|||||||
totalAmount: sum(transactions.amount).as("total"),
|
totalAmount: sum(transactions.amount).as("total"),
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(transactions.userId, userId),
|
eq(transactions.userId, userId),
|
||||||
eq(transactions.payerId, payerId),
|
eq(transactions.payerId, payerId),
|
||||||
eq(transactions.period, period),
|
eq(transactions.period, period),
|
||||||
excludeAutoInvoiceEntries(),
|
excludeAutoInvoiceEntries(),
|
||||||
|
excludeTransactionsFromExcludedAccounts(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(transactions.paymentMethod, transactions.transactionType);
|
.groupBy(transactions.paymentMethod, transactions.transactionType);
|
||||||
@@ -155,6 +161,10 @@ export async function fetchPayerHistory({
|
|||||||
totalAmount: sum(transactions.amount).as("total"),
|
totalAmount: sum(transactions.amount).as("total"),
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(transactions.userId, userId),
|
eq(transactions.userId, userId),
|
||||||
@@ -162,6 +172,7 @@ export async function fetchPayerHistory({
|
|||||||
gte(transactions.period, start),
|
gte(transactions.period, start),
|
||||||
lte(transactions.period, end),
|
lte(transactions.period, end),
|
||||||
excludeAutoInvoiceEntries(),
|
excludeAutoInvoiceEntries(),
|
||||||
|
excludeTransactionsFromExcludedAccounts(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(transactions.period, transactions.transactionType);
|
.groupBy(transactions.period, transactions.transactionType);
|
||||||
@@ -210,6 +221,10 @@ export async function fetchPayerCardUsage({
|
|||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.innerJoin(cards, eq(transactions.cardId, cards.id))
|
.innerJoin(cards, eq(transactions.cardId, cards.id))
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(transactions.userId, userId),
|
eq(transactions.userId, userId),
|
||||||
@@ -217,6 +232,7 @@ export async function fetchPayerCardUsage({
|
|||||||
eq(transactions.period, period),
|
eq(transactions.period, period),
|
||||||
eq(transactions.paymentMethod, PAYMENT_METHOD_CARD),
|
eq(transactions.paymentMethod, PAYMENT_METHOD_CARD),
|
||||||
excludeAutoInvoiceEntries(),
|
excludeAutoInvoiceEntries(),
|
||||||
|
excludeTransactionsFromExcludedAccounts(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(transactions.cardId, cards.name, cards.logo);
|
.groupBy(transactions.cardId, cards.name, cards.logo);
|
||||||
@@ -251,6 +267,10 @@ export async function fetchPayerBoletoStats({
|
|||||||
totalCount: sql<number>`count(${transactions.id})`.as("count"),
|
totalCount: sql<number>`count(${transactions.id})`.as("count"),
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(transactions.userId, userId),
|
eq(transactions.userId, userId),
|
||||||
@@ -258,6 +278,7 @@ export async function fetchPayerBoletoStats({
|
|||||||
eq(transactions.period, period),
|
eq(transactions.period, period),
|
||||||
eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
|
eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||||
excludeAutoInvoiceEntries(),
|
excludeAutoInvoiceEntries(),
|
||||||
|
excludeTransactionsFromExcludedAccounts(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(transactions.isSettled);
|
.groupBy(transactions.isSettled);
|
||||||
@@ -303,6 +324,10 @@ export async function fetchPayerBoletoItems({
|
|||||||
isSettled: transactions.isSettled,
|
isSettled: transactions.isSettled,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(transactions.userId, userId),
|
eq(transactions.userId, userId),
|
||||||
@@ -310,6 +335,7 @@ export async function fetchPayerBoletoItems({
|
|||||||
eq(transactions.period, period),
|
eq(transactions.period, period),
|
||||||
eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
|
eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||||
excludeAutoInvoiceEntries(),
|
excludeAutoInvoiceEntries(),
|
||||||
|
excludeTransactionsFromExcludedAccounts(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(asc(transactions.dueDate));
|
.orderBy(asc(transactions.dueDate));
|
||||||
@@ -343,6 +369,10 @@ export async function fetchPayerPaymentStatus({
|
|||||||
pendingCount: sql<number>`sum(case when (${transactions.isSettled} = false or ${transactions.isSettled} is null) then 1 else 0 end)`,
|
pendingCount: sql<number>`sum(case when (${transactions.isSettled} = false or ${transactions.isSettled} is null) then 1 else 0 end)`,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(transactions.userId, userId),
|
eq(transactions.userId, userId),
|
||||||
@@ -350,6 +380,7 @@ export async function fetchPayerPaymentStatus({
|
|||||||
eq(transactions.period, period),
|
eq(transactions.period, period),
|
||||||
eq(transactions.transactionType, DESPESA),
|
eq(transactions.transactionType, DESPESA),
|
||||||
excludeAutoInvoiceEntries(),
|
excludeAutoInvoiceEntries(),
|
||||||
|
excludeTransactionsFromExcludedAccounts(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user