mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-10 19:21:46 +00:00
Compare commits
5 Commits
467f71493d
...
v2.5.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bb664884a | ||
|
|
f02958df1d | ||
|
|
c4c52c02ab | ||
|
|
c9239c4f3c | ||
|
|
7128cc0ae7 |
27
CHANGELOG.md
27
CHANGELOG.md
@@ -5,6 +5,33 @@ Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
|
||||
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
|
||||
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
|
||||
|
||||
## [2.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
|
||||
|
||||
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.
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://nextjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
|
||||
40
package.json
40
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openmonetis",
|
||||
"version": "2.5.5",
|
||||
"version": "2.5.6",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"scripts": {
|
||||
@@ -31,12 +31,12 @@
|
||||
"mockup": "tsx scripts/mock-data.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.74",
|
||||
"@ai-sdk/google": "^3.0.67",
|
||||
"@ai-sdk/openai": "^3.0.60",
|
||||
"@aws-sdk/client-s3": "^3.1042.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1042.0",
|
||||
"@better-auth/passkey": "^1.6.9",
|
||||
"@ai-sdk/anthropic": "^3.0.76",
|
||||
"@ai-sdk/google": "^3.0.71",
|
||||
"@ai-sdk/openai": "^3.0.63",
|
||||
"@aws-sdk/client-s3": "^3.1045.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1045.0",
|
||||
"@better-auth/passkey": "^1.6.10",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -66,8 +66,8 @@
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"ai": "^6.0.175",
|
||||
"better-auth": "1.6.9",
|
||||
"ai": "^6.0.177",
|
||||
"better-auth": "1.6.10",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
@@ -77,17 +77,17 @@
|
||||
"exceljs": "^4.4.0",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
"next": "16.2.4",
|
||||
"next": "16.2.6",
|
||||
"next-themes": "0.4.6",
|
||||
"pdfjs-dist": "^5.7.284",
|
||||
"pg": "8.20.0",
|
||||
"react": "19.2.5",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "19.2.5",
|
||||
"react": "19.2.6",
|
||||
"react-day-picker": "^10.0.0",
|
||||
"react-dom": "19.2.6",
|
||||
"recharts": "3.8.1",
|
||||
"resend": "^6.12.2",
|
||||
"resend": "^6.12.3",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.5.0",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "1.1.2",
|
||||
"zod": "4.4.3"
|
||||
@@ -98,17 +98,17 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.14",
|
||||
"@tailwindcss/postcss": "4.2.4",
|
||||
"@biomejs/biome": "2.4.15",
|
||||
"@tailwindcss/postcss": "4.3.0",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "25.6.0",
|
||||
"@types/node": "25.6.2",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"knip": "^6.11.0",
|
||||
"tailwindcss": "4.2.4",
|
||||
"knip": "^6.12.2",
|
||||
"tailwindcss": "4.3.0",
|
||||
"tsx": "4.21.0",
|
||||
"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,
|
||||
attachmentFilter: null,
|
||||
dividedFilter: null,
|
||||
amountMinFilter: null,
|
||||
amountMaxFilter: null,
|
||||
};
|
||||
|
||||
const createEmptySlugMaps = (): SlugMaps => ({
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { budgets, categories } from "@/db/schema";
|
||||
import {
|
||||
type CategoryBudgetSummary,
|
||||
fetchCategoryBudgetSummary,
|
||||
} from "@/features/budgets/queries";
|
||||
import {
|
||||
handleActionError,
|
||||
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({
|
||||
period: periodSchema,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
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 { excludeTransactionsFromExcludedAccounts } from "@/shared/lib/accounts/query-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||
|
||||
@@ -75,6 +81,10 @@ export async function fetchBudgetsForUser(
|
||||
totalAmount: sum(transactions.amount).as("totalAmount"),
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
@@ -86,6 +96,7 @@ export async function fetchBudgetsForUser(
|
||||
isNull(transactions.note),
|
||||
sql`${transactions.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
|
||||
),
|
||||
excludeTransactionsFromExcludedAccounts(),
|
||||
),
|
||||
)
|
||||
.groupBy(transactions.categoryId);
|
||||
@@ -127,3 +138,57 @@ export async function fetchBudgetsForUser(
|
||||
|
||||
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)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -124,8 +124,10 @@ function TimelineItem({
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<h3 className="font-semibold font-mono">v{version.version}</h3>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<h3 className="font-semibold font-mono text-lg">
|
||||
v{version.version}
|
||||
</h3>
|
||||
{isLatest ? (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Atual
|
||||
@@ -142,7 +144,7 @@ function TimelineItem({
|
||||
</div>
|
||||
|
||||
{version.summary ? (
|
||||
<Card className="p-4">
|
||||
<Card className="p-6">
|
||||
<blockquote className="pl-2 text-sm text-muted-foreground leading-relaxed italic">
|
||||
{version.summary}
|
||||
</blockquote>
|
||||
|
||||
@@ -34,6 +34,8 @@ const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = z.object(
|
||||
settledFilter: z.string().nullable(),
|
||||
attachmentFilter: z.string().nullable(),
|
||||
dividedFilter: z.string().nullable(),
|
||||
amountMinFilter: z.number().nullable(),
|
||||
amountMaxFilter: z.number().nullable(),
|
||||
}),
|
||||
accountId: z.string().min(1).nullable().optional(),
|
||||
cardId: z.string().min(1).nullable().optional(),
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"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 { Label } from "@/shared/components/ui/label";
|
||||
import {
|
||||
@@ -11,6 +14,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select";
|
||||
import { formatCurrency } from "@/shared/utils/currency";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import {
|
||||
CategorySelectContent,
|
||||
@@ -18,6 +22,22 @@ import {
|
||||
} from "../../select-items";
|
||||
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({
|
||||
formState,
|
||||
onFieldChange,
|
||||
@@ -28,6 +48,62 @@ export function CategorySection({
|
||||
}: CategorySectionProps) {
|
||||
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 (
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||
{showTransactionTypeField ? (
|
||||
@@ -77,12 +153,16 @@ export function CategorySection({
|
||||
const selectedOption = categoryOptions.find(
|
||||
(opt) => opt.value === formState.categoryId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<CategorySelectContent
|
||||
label={selectedOption.label}
|
||||
icon={selectedOption.icon}
|
||||
/>
|
||||
) : null;
|
||||
if (!selectedOption) return null;
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<CategorySelectContent
|
||||
label={selectedOption.label}
|
||||
icon={selectedOption.icon}
|
||||
/>
|
||||
{renderBudgetBadge()}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { RiSliceFill } from "@remixicon/react";
|
||||
import { useState } from "react";
|
||||
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
@@ -11,10 +13,124 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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 { PayerSelectContent } from "../../select-items";
|
||||
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({
|
||||
formState,
|
||||
onFieldChange,
|
||||
@@ -22,17 +138,17 @@ export function PayerSection({
|
||||
secondaryPayerOptions,
|
||||
totalAmount,
|
||||
}: PayerSectionProps) {
|
||||
const [splitMode, setSplitMode] = useState<SplitInputMode>("currency");
|
||||
|
||||
const handlePrimaryAmountChange = (value: string) => {
|
||||
onFieldChange("primarySplitAmount", value);
|
||||
const numericValue = Number.parseFloat(value) || 0;
|
||||
const remaining = Math.max(0, totalAmount - numericValue);
|
||||
const remaining = Math.max(0, totalAmount - safeToNumber(value));
|
||||
onFieldChange("secondarySplitAmount", remaining.toFixed(2));
|
||||
};
|
||||
|
||||
const handleSecondaryAmountChange = (value: string) => {
|
||||
onFieldChange("secondarySplitAmount", value);
|
||||
const numericValue = Number.parseFloat(value) || 0;
|
||||
const remaining = Math.max(0, totalAmount - numericValue);
|
||||
const remaining = Math.max(0, totalAmount - safeToNumber(value));
|
||||
onFieldChange("primarySplitAmount", remaining.toFixed(2));
|
||||
};
|
||||
|
||||
@@ -54,23 +170,28 @@ export function PayerSection({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<CheckboxPrimitive.Root
|
||||
checked={formState.isSplit}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("isSplit", Boolean(checked))
|
||||
}
|
||||
aria-label="Dividir lançamento"
|
||||
className={cn(
|
||||
"peer size-4 shrink-0 rounded-lg border shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
formState.isSplit
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-input dark:bg-input/30",
|
||||
)}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className="grid place-content-center text-current transition-none">
|
||||
<RiSliceFill className="size-3" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
<div className="flex items-center gap-2">
|
||||
{formState.isSplit ? (
|
||||
<SplitModeToggle mode={splitMode} onModeChange={setSplitMode} />
|
||||
) : null}
|
||||
<CheckboxPrimitive.Root
|
||||
checked={formState.isSplit}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("isSplit", Boolean(checked))
|
||||
}
|
||||
aria-label="Dividir lançamento"
|
||||
className={cn(
|
||||
"peer size-4 shrink-0 rounded-lg border shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
formState.isSplit
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-input dark:bg-input/30",
|
||||
)}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className="grid place-content-center text-current transition-none">
|
||||
<RiSliceFill className="size-3" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||
@@ -111,14 +232,15 @@ export function PayerSection({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formState.isSplit && (
|
||||
<CurrencyInput
|
||||
{formState.isSplit ? (
|
||||
<SplitAmountField
|
||||
mode={splitMode}
|
||||
value={formState.primarySplitAmount}
|
||||
onValueChange={handlePrimaryAmountChange}
|
||||
placeholder="R$ 0,00"
|
||||
className="h-9 w-[45%] text-sm"
|
||||
totalAmount={totalAmount}
|
||||
onAmountChange={handlePrimaryAmountChange}
|
||||
ariaLabel="Porcentagem da pessoa"
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -163,11 +285,12 @@ export function PayerSection({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<CurrencyInput
|
||||
<SplitAmountField
|
||||
mode={splitMode}
|
||||
value={formState.secondarySplitAmount}
|
||||
onValueChange={handleSecondaryAmountChange}
|
||||
placeholder="R$ 0,00"
|
||||
className="h-9 w-[45%] text-sm"
|
||||
totalAmount={totalAmount}
|
||||
onAmountChange={handleSecondaryAmountChange}
|
||||
ariaLabel="Porcentagem do segundo pagador"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,9 +4,14 @@ import {
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiExpandUpDownLine,
|
||||
RiFilter3Line,
|
||||
RiFilterLine,
|
||||
} from "@remixicon/react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
type ReadonlyURLSearchParams,
|
||||
usePathname,
|
||||
useRouter,
|
||||
useSearchParams,
|
||||
} from "next/navigation";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
@@ -16,11 +21,14 @@ import {
|
||||
useTransition,
|
||||
} from "react";
|
||||
import {
|
||||
AMOUNT_MAX_PARAM,
|
||||
AMOUNT_MIN_PARAM,
|
||||
PAYMENT_METHODS,
|
||||
SETTLED_FILTER_VALUES,
|
||||
TRANSACTION_CONDITIONS,
|
||||
TRANSACTION_TYPES,
|
||||
} from "@/features/transactions/lib/constants";
|
||||
import { parsePositiveAmount } from "@/features/transactions/lib/page-helpers";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||
import {
|
||||
@@ -70,6 +78,36 @@ import type {
|
||||
|
||||
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 {
|
||||
param: string;
|
||||
placeholder: string;
|
||||
@@ -348,6 +386,7 @@ export function TransactionsFilters({
|
||||
? `${pathname}?${nextParams.toString()}`
|
||||
: pathname;
|
||||
router.replace(target, { scroll: false });
|
||||
router.refresh();
|
||||
});
|
||||
},
|
||||
[searchParams, pathname, router],
|
||||
@@ -373,6 +412,17 @@ export function TransactionsFilters({
|
||||
return () => clearTimeout(timeout);
|
||||
}, [searchValue, currentSearchParam, handleFilterChange]);
|
||||
|
||||
const [valorMinValue, setValorMinValue] = useDebouncedAmountFilter(
|
||||
AMOUNT_MIN_PARAM,
|
||||
searchParams,
|
||||
handleFilterChange,
|
||||
);
|
||||
const [valorMaxValue, setValorMaxValue] = useDebouncedAmountFilter(
|
||||
AMOUNT_MAX_PARAM,
|
||||
searchParams,
|
||||
handleFilterChange,
|
||||
);
|
||||
|
||||
const handleReset = () => {
|
||||
const periodValue = searchParams.get("periodo");
|
||||
const pageSizeValue = searchParams.get("pageSize");
|
||||
@@ -384,6 +434,8 @@ export function TransactionsFilters({
|
||||
nextParams.set("pageSize", pageSizeValue);
|
||||
}
|
||||
setSearchValue("");
|
||||
setValorMinValue("");
|
||||
setValorMaxValue("");
|
||||
startTransition(() => {
|
||||
const target = nextParams.toString()
|
||||
? `${pathname}?${nextParams.toString()}`
|
||||
@@ -467,7 +519,9 @@ export function TransactionsFilters({
|
||||
searchParams.getAll("accountCard").length > 0 ||
|
||||
searchParams.get("settled") ||
|
||||
searchParams.get("hasAttachment") ||
|
||||
searchParams.get("isDivided");
|
||||
searchParams.get("isDivided") ||
|
||||
searchParams.get(AMOUNT_MIN_PARAM) ||
|
||||
searchParams.get(AMOUNT_MAX_PARAM);
|
||||
|
||||
const handleResetFilters = () => {
|
||||
handleReset();
|
||||
@@ -523,13 +577,27 @@ export function TransactionsFilters({
|
||||
className="flex-1 md:flex-none text-sm border-dashed relative bg-transparent"
|
||||
aria-label="Abrir filtros"
|
||||
>
|
||||
<RiFilter3Line className="size-4" />
|
||||
<RiFilterLine className="size-4" />
|
||||
Filtros
|
||||
{hasActiveFilters && (
|
||||
<span className="absolute -top-1 -right-1 size-3 rounded-full bg-primary" />
|
||||
)}
|
||||
</Button>
|
||||
</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>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Filtros</DrawerTitle>
|
||||
@@ -636,6 +704,37 @@ export function TransactionsFilters({
|
||||
/>
|
||||
</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">
|
||||
<p className="text-sm font-medium">Status</p>
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -30,3 +30,6 @@ export const SETTLED_FILTER_VALUES = {
|
||||
PAID: "pago",
|
||||
UNPAID: "nao-pago",
|
||||
} as const;
|
||||
|
||||
export const AMOUNT_MIN_PARAM = "valorMin";
|
||||
export const AMOUNT_MAX_PARAM = "valorMax";
|
||||
|
||||
@@ -9,6 +9,8 @@ type TransactionExportFilters = {
|
||||
settledFilter: string | null;
|
||||
attachmentFilter: string | null;
|
||||
dividedFilter: string | null;
|
||||
amountMinFilter: number | null;
|
||||
amountMaxFilter: number | null;
|
||||
};
|
||||
|
||||
export type TransactionsExportContext = {
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
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 {
|
||||
cards,
|
||||
type categories,
|
||||
@@ -10,6 +20,8 @@ import {
|
||||
} from "@/db/schema";
|
||||
import type { SelectOption } from "@/features/transactions/components/types";
|
||||
import {
|
||||
AMOUNT_MAX_PARAM,
|
||||
AMOUNT_MIN_PARAM,
|
||||
PAYMENT_METHODS,
|
||||
SETTLED_FILTER_VALUES,
|
||||
TRANSACTION_CONDITIONS,
|
||||
@@ -46,6 +58,8 @@ export type TransactionSearchFilters = {
|
||||
settledFilter: string | null;
|
||||
attachmentFilter: string | null;
|
||||
dividedFilter: string | null;
|
||||
amountMinFilter: number | null;
|
||||
amountMaxFilter: number | null;
|
||||
};
|
||||
|
||||
type BaseSluggedOption = {
|
||||
@@ -135,6 +149,13 @@ export const getMultiParam = (
|
||||
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 = (
|
||||
params: ResolvedSearchParams,
|
||||
): TransactionSearchFilters => ({
|
||||
@@ -148,6 +169,12 @@ export const extractTransactionSearchFilters = (
|
||||
settledFilter: getSingleParam(params, "settled"),
|
||||
attachmentFilter: getSingleParam(params, "hasAttachment"),
|
||||
dividedFilter: getSingleParam(params, "isDivided"),
|
||||
amountMinFilter: parsePositiveAmount(
|
||||
getSingleParam(params, AMOUNT_MIN_PARAM),
|
||||
),
|
||||
amountMaxFilter: parsePositiveAmount(
|
||||
getSingleParam(params, AMOUNT_MAX_PARAM),
|
||||
),
|
||||
});
|
||||
|
||||
export const resolveTransactionPagination = (
|
||||
@@ -442,6 +469,18 @@ export const buildTransactionWhere = ({
|
||||
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);
|
||||
if (searchPattern) {
|
||||
where.push(
|
||||
|
||||
@@ -11,6 +11,20 @@ type CalculatorDisplayProps = {
|
||||
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({
|
||||
history,
|
||||
expression,
|
||||
@@ -19,8 +33,10 @@ export function CalculatorDisplay({
|
||||
onCopy,
|
||||
isResultView,
|
||||
}: CalculatorDisplayProps) {
|
||||
const sizeClass = getExpressionSizeClass(expression.length, isResultView);
|
||||
|
||||
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">
|
||||
{history ?? (
|
||||
<span
|
||||
@@ -31,11 +47,11 @@ export function CalculatorDisplay({
|
||||
</span>
|
||||
)}
|
||||
</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
|
||||
className={cn(
|
||||
"truncate text-right font-semibold transition-all",
|
||||
isResultView ? "text-2xl" : "text-3xl",
|
||||
"min-w-0 flex-1 truncate text-right font-semibold transition-all",
|
||||
sizeClass,
|
||||
)}
|
||||
>
|
||||
{expression}
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
sql,
|
||||
sum,
|
||||
} 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 { excludeTransactionsFromExcludedAccounts } from "@/shared/lib/accounts/query-filters";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { toDateOnlyString } from "@/shared/utils/date";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
@@ -96,12 +97,17 @@ export async function fetchPayerMonthlyBreakdown({
|
||||
totalAmount: sum(transactions.amount).as("total"),
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
eq(transactions.payerId, payerId),
|
||||
eq(transactions.period, period),
|
||||
excludeAutoInvoiceEntries(),
|
||||
excludeTransactionsFromExcludedAccounts(),
|
||||
),
|
||||
)
|
||||
.groupBy(transactions.paymentMethod, transactions.transactionType);
|
||||
@@ -155,6 +161,10 @@ export async function fetchPayerHistory({
|
||||
totalAmount: sum(transactions.amount).as("total"),
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
@@ -162,6 +172,7 @@ export async function fetchPayerHistory({
|
||||
gte(transactions.period, start),
|
||||
lte(transactions.period, end),
|
||||
excludeAutoInvoiceEntries(),
|
||||
excludeTransactionsFromExcludedAccounts(),
|
||||
),
|
||||
)
|
||||
.groupBy(transactions.period, transactions.transactionType);
|
||||
@@ -210,6 +221,10 @@ export async function fetchPayerCardUsage({
|
||||
})
|
||||
.from(transactions)
|
||||
.innerJoin(cards, eq(transactions.cardId, cards.id))
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
@@ -217,6 +232,7 @@ export async function fetchPayerCardUsage({
|
||||
eq(transactions.period, period),
|
||||
eq(transactions.paymentMethod, PAYMENT_METHOD_CARD),
|
||||
excludeAutoInvoiceEntries(),
|
||||
excludeTransactionsFromExcludedAccounts(),
|
||||
),
|
||||
)
|
||||
.groupBy(transactions.cardId, cards.name, cards.logo);
|
||||
@@ -251,6 +267,10 @@ export async function fetchPayerBoletoStats({
|
||||
totalCount: sql<number>`count(${transactions.id})`.as("count"),
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
@@ -258,6 +278,7 @@ export async function fetchPayerBoletoStats({
|
||||
eq(transactions.period, period),
|
||||
eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||
excludeAutoInvoiceEntries(),
|
||||
excludeTransactionsFromExcludedAccounts(),
|
||||
),
|
||||
)
|
||||
.groupBy(transactions.isSettled);
|
||||
@@ -303,6 +324,10 @@ export async function fetchPayerBoletoItems({
|
||||
isSettled: transactions.isSettled,
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
@@ -310,6 +335,7 @@ export async function fetchPayerBoletoItems({
|
||||
eq(transactions.period, period),
|
||||
eq(transactions.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||
excludeAutoInvoiceEntries(),
|
||||
excludeTransactionsFromExcludedAccounts(),
|
||||
),
|
||||
)
|
||||
.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)`,
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(
|
||||
financialAccounts,
|
||||
eq(transactions.accountId, financialAccounts.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, userId),
|
||||
@@ -350,6 +380,7 @@ export async function fetchPayerPaymentStatus({
|
||||
eq(transactions.period, period),
|
||||
eq(transactions.transactionType, DESPESA),
|
||||
excludeAutoInvoiceEntries(),
|
||||
excludeTransactionsFromExcludedAccounts(),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user