mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
refactor: pagina transações e modulariza ações
This commit is contained in:
1812
scripts/mock-data.ts
Normal file
1812
scripts/mock-data.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ import { AccountStatementCard } from "@/features/accounts/components/account-sta
|
|||||||
import type { Account } from "@/features/accounts/components/types";
|
import type { Account } from "@/features/accounts/components/types";
|
||||||
import {
|
import {
|
||||||
fetchAccountData,
|
fetchAccountData,
|
||||||
fetchAccountLancamentos,
|
fetchAccountLancamentosPage,
|
||||||
fetchAccountSummary,
|
fetchAccountSummary,
|
||||||
} from "@/features/accounts/statement-queries";
|
} from "@/features/accounts/statement-queries";
|
||||||
import { fetchUserPreferences } from "@/features/settings/queries";
|
import { fetchUserPreferences } from "@/features/settings/queries";
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
getSingleParam,
|
getSingleParam,
|
||||||
mapTransactionsData,
|
mapTransactionsData,
|
||||||
type ResolvedSearchParams,
|
type ResolvedSearchParams,
|
||||||
|
resolveTransactionPagination,
|
||||||
} from "@/features/transactions/page-helpers";
|
} from "@/features/transactions/page-helpers";
|
||||||
import {
|
import {
|
||||||
fetchRecentEstablishments,
|
fetchRecentEstablishments,
|
||||||
@@ -53,6 +54,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
} = parsePeriodParam(periodoParamRaw);
|
} = parsePeriodParam(periodoParamRaw);
|
||||||
|
|
||||||
const searchFilters = extractTransactionSearchFilters(resolvedSearchParams);
|
const searchFilters = extractTransactionSearchFilters(resolvedSearchParams);
|
||||||
|
const pagination = resolveTransactionPagination(resolvedSearchParams);
|
||||||
|
|
||||||
const account = await fetchAccountData(userId, accountId);
|
const account = await fetchAccountData(userId, accountId);
|
||||||
|
|
||||||
@@ -84,9 +86,12 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const transactionRows = await fetchAccountLancamentos(filters);
|
const transactionsPage = await fetchAccountLancamentosPage(
|
||||||
|
filters,
|
||||||
|
pagination,
|
||||||
|
);
|
||||||
|
|
||||||
const transactionData = mapTransactionsData(transactionRows);
|
const transactionData = mapTransactionsData(transactionsPage.rows);
|
||||||
|
|
||||||
const { openingBalance, currentBalance, totalIncomes, totalExpenses } =
|
const { openingBalance, currentBalance, totalIncomes, totalExpenses } =
|
||||||
accountSummary;
|
accountSummary;
|
||||||
@@ -169,6 +174,19 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
accountCardFilterOptions={accountCardFilterOptions}
|
accountCardFilterOptions={accountCardFilterOptions}
|
||||||
selectedPeriod={selectedPeriod}
|
selectedPeriod={selectedPeriod}
|
||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
|
pagination={{
|
||||||
|
page: transactionsPage.page,
|
||||||
|
pageSize: transactionsPage.pageSize,
|
||||||
|
totalItems: transactionsPage.totalItems,
|
||||||
|
totalPages: transactionsPage.totalPages,
|
||||||
|
}}
|
||||||
|
exportContext={{
|
||||||
|
source: "account-statement",
|
||||||
|
period: selectedPeriod,
|
||||||
|
filters: searchFilters,
|
||||||
|
accountId: account.id,
|
||||||
|
settledOnly: true,
|
||||||
|
}}
|
||||||
allowCreate={false}
|
allowCreate={false}
|
||||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import {
|
|||||||
getSingleParam,
|
getSingleParam,
|
||||||
mapTransactionsData,
|
mapTransactionsData,
|
||||||
type ResolvedSearchParams,
|
type ResolvedSearchParams,
|
||||||
|
resolveTransactionPagination,
|
||||||
} from "@/features/transactions/page-helpers";
|
} from "@/features/transactions/page-helpers";
|
||||||
import {
|
import {
|
||||||
fetchRecentEstablishments,
|
fetchRecentEstablishments,
|
||||||
fetchTransactionFilterSources,
|
fetchTransactionFilterSources,
|
||||||
fetchTransactions,
|
fetchTransactionsPage,
|
||||||
} from "@/features/transactions/queries";
|
} from "@/features/transactions/queries";
|
||||||
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
import MonthNavigation from "@/shared/components/month-picker/month-navigation";
|
||||||
import { getUserId } from "@/shared/lib/auth/server";
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
@@ -33,6 +34,7 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
const { period: selectedPeriod } = parsePeriodParam(periodoParamRaw);
|
const { period: selectedPeriod } = parsePeriodParam(periodoParamRaw);
|
||||||
|
|
||||||
const searchFilters = extractTransactionSearchFilters(resolvedSearchParams);
|
const searchFilters = extractTransactionSearchFilters(resolvedSearchParams);
|
||||||
|
const pagination = resolveTransactionPagination(resolvedSearchParams);
|
||||||
|
|
||||||
const [filterSources, userPreferences] = await Promise.all([
|
const [filterSources, userPreferences] = await Promise.all([
|
||||||
fetchTransactionFilterSources(userId),
|
fetchTransactionFilterSources(userId),
|
||||||
@@ -49,11 +51,11 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
slugMaps,
|
slugMaps,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [transactionRows, estabelecimentos] = await Promise.all([
|
const [transactionsPage, estabelecimentos] = await Promise.all([
|
||||||
fetchTransactions(filters),
|
fetchTransactionsPage(filters, pagination),
|
||||||
fetchRecentEstablishments(userId),
|
fetchRecentEstablishments(userId),
|
||||||
]);
|
]);
|
||||||
const transactionData = mapTransactionsData(transactionRows);
|
const transactionData = mapTransactionsData(transactionsPage.rows);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
payerOptions,
|
payerOptions,
|
||||||
@@ -87,6 +89,17 @@ export default async function Page({ searchParams }: PageProps) {
|
|||||||
accountCardFilterOptions={accountCardFilterOptions}
|
accountCardFilterOptions={accountCardFilterOptions}
|
||||||
selectedPeriod={selectedPeriod}
|
selectedPeriod={selectedPeriod}
|
||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
|
pagination={{
|
||||||
|
page: transactionsPage.page,
|
||||||
|
pageSize: transactionsPage.pageSize,
|
||||||
|
totalItems: transactionsPage.totalItems,
|
||||||
|
totalPages: transactionsPage.totalPages,
|
||||||
|
}}
|
||||||
|
exportContext={{
|
||||||
|
source: "transactions",
|
||||||
|
period: selectedPeriod,
|
||||||
|
filters: searchFilters,
|
||||||
|
}}
|
||||||
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
|
||||||
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
columnOrder={userPreferences?.transactionsColumnOrder ?? null}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { and, desc, eq, lt, type SQL, sql } from "drizzle-orm";
|
import { and, eq, lt, type SQL, sql } from "drizzle-orm";
|
||||||
import { financialAccounts, payers, transactions } from "@/db/schema";
|
import { financialAccounts, transactions } from "@/db/schema";
|
||||||
|
import {
|
||||||
|
fetchTransactionsPageWithRelations,
|
||||||
|
fetchTransactionsWithRelations,
|
||||||
|
} from "@/features/transactions/queries";
|
||||||
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
|
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { PAYER_ROLE_ADMIN } from "@/shared/lib/payers/constants";
|
import { getAdminPayerId } from "@/shared/lib/payers/get-admin-id";
|
||||||
|
|
||||||
export type AccountSummaryData = {
|
export type AccountSummaryData = {
|
||||||
openingBalance: number;
|
openingBalance: number;
|
||||||
@@ -36,6 +40,22 @@ export async function fetchAccountSummary(
|
|||||||
accountId: string,
|
accountId: string,
|
||||||
selectedPeriod: string,
|
selectedPeriod: string,
|
||||||
): Promise<AccountSummaryData> {
|
): Promise<AccountSummaryData> {
|
||||||
|
const account = await fetchAccountData(userId, accountId);
|
||||||
|
if (!account) {
|
||||||
|
throw new Error("Account not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminPayerId = await getAdminPayerId(userId);
|
||||||
|
if (!adminPayerId) {
|
||||||
|
const initialBalance = Number(account.initialBalance ?? 0);
|
||||||
|
return {
|
||||||
|
openingBalance: initialBalance,
|
||||||
|
currentBalance: initialBalance,
|
||||||
|
totalIncomes: 0,
|
||||||
|
totalExpenses: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const [periodSummary] = await db
|
const [periodSummary] = await db
|
||||||
.select({
|
.select({
|
||||||
netAmount: sql<number>`
|
netAmount: sql<number>`
|
||||||
@@ -75,14 +95,13 @@ export async function fetchAccountSummary(
|
|||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.innerJoin(payers, eq(transactions.payerId, payers.id))
|
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(transactions.userId, userId),
|
eq(transactions.userId, userId),
|
||||||
eq(transactions.accountId, accountId),
|
eq(transactions.accountId, accountId),
|
||||||
eq(transactions.period, selectedPeriod),
|
eq(transactions.period, selectedPeriod),
|
||||||
eq(transactions.isSettled, true),
|
eq(transactions.isSettled, true),
|
||||||
eq(payers.role, PAYER_ROLE_ADMIN),
|
eq(transactions.payerId, adminPayerId),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -101,22 +120,16 @@ export async function fetchAccountSummary(
|
|||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.innerJoin(payers, eq(transactions.payerId, payers.id))
|
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(transactions.userId, userId),
|
eq(transactions.userId, userId),
|
||||||
eq(transactions.accountId, accountId),
|
eq(transactions.accountId, accountId),
|
||||||
lt(transactions.period, selectedPeriod),
|
lt(transactions.period, selectedPeriod),
|
||||||
eq(transactions.isSettled, true),
|
eq(transactions.isSettled, true),
|
||||||
eq(payers.role, PAYER_ROLE_ADMIN),
|
eq(transactions.payerId, adminPayerId),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const account = await fetchAccountData(userId, accountId);
|
|
||||||
if (!account) {
|
|
||||||
throw new Error("Account not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialBalance = Number(account.initialBalance ?? 0);
|
const initialBalance = Number(account.initialBalance ?? 0);
|
||||||
const previousMovements = Number(previousRow?.previousMovements ?? 0);
|
const previousMovements = Number(previousRow?.previousMovements ?? 0);
|
||||||
const openingBalance = initialBalance + previousMovements;
|
const openingBalance = initialBalance + previousMovements;
|
||||||
@@ -137,18 +150,33 @@ export async function fetchAccountLancamentos(
|
|||||||
filters: SQL[],
|
filters: SQL[],
|
||||||
settledOnly = true,
|
settledOnly = true,
|
||||||
) {
|
) {
|
||||||
const allFilters = settledOnly
|
const extraFilters = settledOnly ? [eq(transactions.isSettled, true)] : [];
|
||||||
? [...filters, eq(transactions.isSettled, true)]
|
|
||||||
: filters;
|
|
||||||
|
|
||||||
return db.query.transactions.findMany({
|
return fetchTransactionsWithRelations({
|
||||||
where: and(...allFilters),
|
filters,
|
||||||
with: {
|
extraFilters,
|
||||||
payer: true,
|
excludeInitialBalanceFromIncome: false,
|
||||||
financialAccount: true,
|
});
|
||||||
card: true,
|
}
|
||||||
category: true,
|
|
||||||
},
|
export async function fetchAccountLancamentosPage(
|
||||||
orderBy: desc(transactions.purchaseDate),
|
filters: SQL[],
|
||||||
|
{
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
},
|
||||||
|
settledOnly = true,
|
||||||
|
) {
|
||||||
|
const extraFilters = settledOnly ? [eq(transactions.isSettled, true)] : [];
|
||||||
|
|
||||||
|
return fetchTransactionsPageWithRelations({
|
||||||
|
filters,
|
||||||
|
extraFilters,
|
||||||
|
excludeInitialBalanceFromIncome: false,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,8 @@ import {
|
|||||||
RiFilePdfLine,
|
RiFilePdfLine,
|
||||||
RiFileTextLine,
|
RiFileTextLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import jsPDF from "jspdf";
|
|
||||||
import autoTable from "jspdf-autotable";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import * as XLSX from "xlsx";
|
|
||||||
import {
|
import {
|
||||||
formatPercentageChange,
|
formatPercentageChange,
|
||||||
formatPeriodLabel,
|
formatPeriodLabel,
|
||||||
@@ -36,6 +33,17 @@ interface CategoryReportExportProps {
|
|||||||
filters: FilterState;
|
filters: FilterState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadXlsx = () => import("xlsx");
|
||||||
|
|
||||||
|
const loadPdfDeps = async () => {
|
||||||
|
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
|
||||||
|
import("jspdf"),
|
||||||
|
import("jspdf-autotable"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { jsPDF, autoTable };
|
||||||
|
};
|
||||||
|
|
||||||
export function CategoryReportExport({
|
export function CategoryReportExport({
|
||||||
data,
|
data,
|
||||||
filters,
|
filters,
|
||||||
@@ -123,9 +131,10 @@ export function CategoryReportExport({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportToExcel = () => {
|
const exportToExcel = async () => {
|
||||||
try {
|
try {
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
|
const XLSX = await loadXlsx();
|
||||||
|
|
||||||
// Build data array
|
// Build data array
|
||||||
const headers = [
|
const headers = [
|
||||||
@@ -197,6 +206,7 @@ export function CategoryReportExport({
|
|||||||
const exportToPDF = async () => {
|
const exportToPDF = async () => {
|
||||||
try {
|
try {
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
|
const { jsPDF, autoTable } = await loadPdfDeps();
|
||||||
|
|
||||||
// Create PDF
|
// Create PDF
|
||||||
const doc = new jsPDF({ orientation: "landscape" });
|
const doc = new jsPDF({ orientation: "landscape" });
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
624
src/features/transactions/actions/bulk-actions.ts
Normal file
624
src/features/transactions/actions/bulk-actions.ts
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { and, asc, eq, inArray, sql } from "drizzle-orm";
|
||||||
|
import { transactions } from "@/db/schema";
|
||||||
|
import {
|
||||||
|
PAYMENT_METHODS,
|
||||||
|
TRANSACTION_CONDITIONS,
|
||||||
|
TRANSACTION_TYPES,
|
||||||
|
} from "@/features/transactions/constants";
|
||||||
|
import { handleActionError } from "@/shared/lib/actions/helpers";
|
||||||
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
|
import { db } from "@/shared/lib/db";
|
||||||
|
import {
|
||||||
|
buildEntriesByPayer,
|
||||||
|
sendPayerAutoEmails,
|
||||||
|
} from "@/shared/lib/payers/notifications";
|
||||||
|
import type { ActionResult } from "@/shared/lib/types/actions";
|
||||||
|
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
|
||||||
|
import {
|
||||||
|
centsToDecimalString,
|
||||||
|
type DeleteBulkInput,
|
||||||
|
type DeleteMultipleInput,
|
||||||
|
deleteBulkSchema,
|
||||||
|
deleteMultipleSchema,
|
||||||
|
fetchOwnedAccountIds,
|
||||||
|
fetchOwnedCardIds,
|
||||||
|
fetchOwnedCategoryIds,
|
||||||
|
fetchOwnedPayerIds,
|
||||||
|
type MassAddInput,
|
||||||
|
massAddSchema,
|
||||||
|
resolvePeriod,
|
||||||
|
resolveUserLabel,
|
||||||
|
revalidate,
|
||||||
|
type TransactionInsert,
|
||||||
|
type UpdateBulkInput,
|
||||||
|
updateBulkSchema,
|
||||||
|
validateAllOwnership,
|
||||||
|
} from "./core";
|
||||||
|
|
||||||
|
export async function deleteTransactionBulkAction(
|
||||||
|
input: DeleteBulkInput,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = deleteBulkSchema.parse(input);
|
||||||
|
|
||||||
|
const existing = await db.query.transactions.findFirst({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
seriesId: true,
|
||||||
|
period: true,
|
||||||
|
condition: true,
|
||||||
|
},
|
||||||
|
where: and(
|
||||||
|
eq(transactions.id, data.id),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return { success: false, error: "Lançamento não encontrado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing.seriesId) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Este lançamento não faz parte de uma série.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.scope === "current") {
|
||||||
|
await db
|
||||||
|
.delete(transactions)
|
||||||
|
.where(
|
||||||
|
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidate(user.id);
|
||||||
|
return { success: true, message: "Lançamento removido com sucesso." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.scope === "future") {
|
||||||
|
await db
|
||||||
|
.delete(transactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactions.seriesId, existing.seriesId),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
sql`${transactions.period} >= ${existing.period}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidate(user.id);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Lançamentos removidos com sucesso.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.scope === "all") {
|
||||||
|
await db
|
||||||
|
.delete(transactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactions.seriesId, existing.seriesId),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidate(user.id);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Todos os lançamentos da série foram removidos.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: "Escopo de ação inválido." };
|
||||||
|
} catch (error) {
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTransactionBulkAction(
|
||||||
|
input: UpdateBulkInput,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = updateBulkSchema.parse(input);
|
||||||
|
|
||||||
|
const ownershipError = await validateAllOwnership(user.id, {
|
||||||
|
payerId: data.payerId,
|
||||||
|
categoryId: data.categoryId,
|
||||||
|
accountId: data.accountId,
|
||||||
|
cardId: data.cardId,
|
||||||
|
});
|
||||||
|
if (ownershipError) {
|
||||||
|
return { success: false, error: ownershipError };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.query.transactions.findFirst({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
seriesId: true,
|
||||||
|
period: true,
|
||||||
|
condition: true,
|
||||||
|
transactionType: true,
|
||||||
|
purchaseDate: true,
|
||||||
|
},
|
||||||
|
where: and(
|
||||||
|
eq(transactions.id, data.id),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return { success: false, error: "Lançamento não encontrado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing.seriesId) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Este lançamento não faz parte de uma série.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUpdatePayload: Record<string, unknown> = {
|
||||||
|
name: data.name,
|
||||||
|
categoryId: data.categoryId ?? null,
|
||||||
|
note: data.note ?? null,
|
||||||
|
payerId: data.payerId ?? null,
|
||||||
|
accountId: data.accountId ?? null,
|
||||||
|
cardId: data.cardId ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.amount !== undefined) {
|
||||||
|
const amountSign: 1 | -1 =
|
||||||
|
existing.transactionType === "Despesa" ? -1 : 1;
|
||||||
|
const amountCents = Math.round(Math.abs(data.amount) * 100);
|
||||||
|
baseUpdatePayload.amount = centsToDecimalString(amountCents * amountSign);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasDueDateUpdate = data.dueDate !== undefined;
|
||||||
|
const hasBoletoPaymentDateUpdate = data.boletoPaymentDate !== undefined;
|
||||||
|
|
||||||
|
const baseDueDate =
|
||||||
|
hasDueDateUpdate && data.dueDate
|
||||||
|
? parseLocalDateString(data.dueDate)
|
||||||
|
: hasDueDateUpdate
|
||||||
|
? null
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const baseBoletoPaymentDate =
|
||||||
|
hasBoletoPaymentDateUpdate && data.boletoPaymentDate
|
||||||
|
? parseLocalDateString(data.boletoPaymentDate)
|
||||||
|
: hasBoletoPaymentDateUpdate
|
||||||
|
? null
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const basePurchaseDate = existing.purchaseDate ?? null;
|
||||||
|
|
||||||
|
const buildDueDateForRecord = (recordPurchaseDate: Date | null) => {
|
||||||
|
if (!hasDueDateUpdate) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!baseDueDate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!basePurchaseDate || !recordPurchaseDate) {
|
||||||
|
return baseDueDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthDiff =
|
||||||
|
(recordPurchaseDate.getFullYear() - basePurchaseDate.getFullYear()) *
|
||||||
|
12 +
|
||||||
|
(recordPurchaseDate.getMonth() - basePurchaseDate.getMonth());
|
||||||
|
|
||||||
|
return addMonthsToDate(baseDueDate, monthDiff);
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializeDateKey = (value: Date | null | undefined) => {
|
||||||
|
if (value === undefined) {
|
||||||
|
return "undefined";
|
||||||
|
}
|
||||||
|
if (value === null) {
|
||||||
|
return "null";
|
||||||
|
}
|
||||||
|
return String(value.getTime());
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyUpdates = async (
|
||||||
|
records: Array<{ id: string; purchaseDate: Date | null }>,
|
||||||
|
) => {
|
||||||
|
if (records.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedPayloads = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
ids: string[];
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const dueDateForRecord = buildDueDateForRecord(record.purchaseDate);
|
||||||
|
const perRecordPayload: Record<string, unknown> = {
|
||||||
|
...baseUpdatePayload,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dueDateForRecord !== undefined) {
|
||||||
|
perRecordPayload.dueDate = dueDateForRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasBoletoPaymentDateUpdate) {
|
||||||
|
perRecordPayload.boletoPaymentDate = baseBoletoPaymentDate ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupKey = [
|
||||||
|
serializeDateKey(dueDateForRecord),
|
||||||
|
serializeDateKey(
|
||||||
|
hasBoletoPaymentDateUpdate
|
||||||
|
? (baseBoletoPaymentDate ?? null)
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
].join("|");
|
||||||
|
|
||||||
|
const group = groupedPayloads.get(groupKey);
|
||||||
|
if (group) {
|
||||||
|
group.ids.push(record.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
groupedPayloads.set(groupKey, {
|
||||||
|
ids: [record.id],
|
||||||
|
payload: perRecordPayload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (tx: typeof db) => {
|
||||||
|
for (const group of groupedPayloads.values()) {
|
||||||
|
await tx
|
||||||
|
.update(transactions)
|
||||||
|
.set(group.payload)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(transactions.id, group.ids),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.scope === "current") {
|
||||||
|
await applyUpdates([
|
||||||
|
{
|
||||||
|
id: data.id,
|
||||||
|
purchaseDate: existing.purchaseDate ?? null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
revalidate(user.id);
|
||||||
|
return { success: true, message: "Lançamento atualizado com sucesso." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.scope === "future") {
|
||||||
|
const futureLancamentos = await db.query.transactions.findMany({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
purchaseDate: true,
|
||||||
|
},
|
||||||
|
where: and(
|
||||||
|
eq(transactions.seriesId, existing.seriesId),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
sql`${transactions.period} >= ${existing.period}`,
|
||||||
|
),
|
||||||
|
orderBy: asc(transactions.purchaseDate),
|
||||||
|
});
|
||||||
|
|
||||||
|
await applyUpdates(
|
||||||
|
futureLancamentos.map((item: (typeof futureLancamentos)[number]) => ({
|
||||||
|
id: item.id,
|
||||||
|
purchaseDate: item.purchaseDate ?? null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidate(user.id);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Lançamentos atualizados com sucesso.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.scope === "all") {
|
||||||
|
const allLancamentos = await db.query.transactions.findMany({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
purchaseDate: true,
|
||||||
|
},
|
||||||
|
where: and(
|
||||||
|
eq(transactions.seriesId, existing.seriesId),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
orderBy: asc(transactions.purchaseDate),
|
||||||
|
});
|
||||||
|
|
||||||
|
await applyUpdates(
|
||||||
|
allLancamentos.map((item: (typeof allLancamentos)[number]) => ({
|
||||||
|
id: item.id,
|
||||||
|
purchaseDate: item.purchaseDate ?? null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidate(user.id);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Todos os lançamentos da série foram atualizados.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: "Escopo de ação inválido." };
|
||||||
|
} catch (error) {
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMassTransactionsAction(
|
||||||
|
input: MassAddInput,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = massAddSchema.parse(input);
|
||||||
|
|
||||||
|
const uniquePayerIds = new Set<string>();
|
||||||
|
const uniqueCategoryIds = new Set<string>();
|
||||||
|
for (const transaction of data.transactions) {
|
||||||
|
if (transaction.payerId) uniquePayerIds.add(transaction.payerId);
|
||||||
|
if (transaction.categoryId) uniqueCategoryIds.add(transaction.categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [ownedAccountIds, ownedCardIds, ownedPayerIds, ownedCategoryIds] =
|
||||||
|
await Promise.all([
|
||||||
|
fetchOwnedAccountIds(user.id, [data.fixedFields.accountId]),
|
||||||
|
fetchOwnedCardIds(user.id, [data.fixedFields.cardId]),
|
||||||
|
fetchOwnedPayerIds(user.id, [...uniquePayerIds]),
|
||||||
|
fetchOwnedCategoryIds(user.id, [...uniqueCategoryIds]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
data.fixedFields.accountId &&
|
||||||
|
!ownedAccountIds.has(data.fixedFields.accountId)
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Conta não encontrada." };
|
||||||
|
}
|
||||||
|
if (data.fixedFields.cardId && !ownedCardIds.has(data.fixedFields.cardId)) {
|
||||||
|
return { success: false, error: "Cartão não encontrado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidPayers = new Set(
|
||||||
|
[...uniquePayerIds].filter((id) => !ownedPayerIds.has(id)),
|
||||||
|
);
|
||||||
|
const invalidCategories = new Set(
|
||||||
|
[...uniqueCategoryIds].filter((id) => !ownedCategoryIds.has(id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < data.transactions.length; i++) {
|
||||||
|
const transaction = data.transactions[i];
|
||||||
|
if (transaction.payerId && invalidPayers.has(transaction.payerId)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Payer não encontrado na transação ${i + 1}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
transaction.categoryId &&
|
||||||
|
invalidCategories.has(transaction.categoryId)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Category não encontrada na transação ${i + 1}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTransactionType = TRANSACTION_TYPES[0];
|
||||||
|
const defaultCondition = TRANSACTION_CONDITIONS[0];
|
||||||
|
const defaultPaymentMethod = PAYMENT_METHODS[0];
|
||||||
|
|
||||||
|
const allRecords: TransactionInsert[] = [];
|
||||||
|
const notificationData: Array<{
|
||||||
|
payerId: string | null;
|
||||||
|
name: string | null;
|
||||||
|
amount: string | null;
|
||||||
|
transactionType: string | null;
|
||||||
|
paymentMethod: string | null;
|
||||||
|
condition: string | null;
|
||||||
|
purchaseDate: Date | null;
|
||||||
|
period: string | null;
|
||||||
|
note: string | null;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const transaction of data.transactions) {
|
||||||
|
const transactionType =
|
||||||
|
data.fixedFields.transactionType ?? defaultTransactionType;
|
||||||
|
const condition = data.fixedFields.condition ?? defaultCondition;
|
||||||
|
const paymentMethod =
|
||||||
|
data.fixedFields.paymentMethod ?? defaultPaymentMethod;
|
||||||
|
const payerId = transaction.payerId ?? null;
|
||||||
|
const accountId =
|
||||||
|
paymentMethod === "Cartão de crédito"
|
||||||
|
? null
|
||||||
|
: (data.fixedFields.accountId ?? null);
|
||||||
|
const cardId =
|
||||||
|
paymentMethod === "Cartão de crédito"
|
||||||
|
? (data.fixedFields.cardId ?? null)
|
||||||
|
: null;
|
||||||
|
const categoryId = transaction.categoryId ?? null;
|
||||||
|
|
||||||
|
const period =
|
||||||
|
data.fixedFields.period ?? resolvePeriod(transaction.purchaseDate);
|
||||||
|
const purchaseDate = parseLocalDateString(transaction.purchaseDate);
|
||||||
|
const amountSign: 1 | -1 = transactionType === "Despesa" ? -1 : 1;
|
||||||
|
const totalCents = Math.round(Math.abs(transaction.amount) * 100);
|
||||||
|
const amount = centsToDecimalString(totalCents * amountSign);
|
||||||
|
const isSettled = paymentMethod === "Cartão de crédito" ? null : false;
|
||||||
|
|
||||||
|
const record: TransactionInsert = {
|
||||||
|
name: transaction.name,
|
||||||
|
purchaseDate,
|
||||||
|
period,
|
||||||
|
transactionType,
|
||||||
|
amount,
|
||||||
|
condition,
|
||||||
|
paymentMethod,
|
||||||
|
payerId,
|
||||||
|
accountId,
|
||||||
|
cardId,
|
||||||
|
categoryId,
|
||||||
|
note: null,
|
||||||
|
installmentCount: null,
|
||||||
|
recurrenceCount: null,
|
||||||
|
currentInstallment: null,
|
||||||
|
isSettled,
|
||||||
|
isDivided: false,
|
||||||
|
dueDate: null,
|
||||||
|
boletoPaymentDate: null,
|
||||||
|
userId: user.id,
|
||||||
|
seriesId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
allRecords.push(record);
|
||||||
|
|
||||||
|
notificationData.push({
|
||||||
|
payerId,
|
||||||
|
name: transaction.name,
|
||||||
|
amount,
|
||||||
|
transactionType,
|
||||||
|
paymentMethod,
|
||||||
|
condition,
|
||||||
|
purchaseDate,
|
||||||
|
period,
|
||||||
|
note: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allRecords.length) {
|
||||||
|
throw new Error("Não foi possível criar os lançamentos solicitados.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (tx: typeof db) => {
|
||||||
|
await tx.insert(transactions).values(allRecords);
|
||||||
|
});
|
||||||
|
|
||||||
|
const notificationEntries = buildEntriesByPayer(notificationData);
|
||||||
|
|
||||||
|
if (notificationEntries.size > 0) {
|
||||||
|
await sendPayerAutoEmails({
|
||||||
|
userLabel: resolveUserLabel(user),
|
||||||
|
action: "created",
|
||||||
|
entriesByPagador: notificationEntries,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidate(user.id);
|
||||||
|
|
||||||
|
const count = allRecords.length;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `${count} ${
|
||||||
|
count === 1 ? "lançamento criado" : "lançamentos criados"
|
||||||
|
} com sucesso.`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMultipleTransactionsAction(
|
||||||
|
input: DeleteMultipleInput,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = deleteMultipleSchema.parse(input);
|
||||||
|
|
||||||
|
const existing = await db.query.transactions.findMany({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
payerId: true,
|
||||||
|
amount: true,
|
||||||
|
transactionType: true,
|
||||||
|
paymentMethod: true,
|
||||||
|
condition: true,
|
||||||
|
purchaseDate: true,
|
||||||
|
period: true,
|
||||||
|
note: true,
|
||||||
|
},
|
||||||
|
where: and(
|
||||||
|
inArray(transactions.id, data.ids),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return { success: false, error: "Nenhum lançamento encontrado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(transactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(transactions.id, data.ids),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const notificationData = existing
|
||||||
|
.filter(
|
||||||
|
(
|
||||||
|
item: (typeof existing)[number],
|
||||||
|
): item is typeof item & {
|
||||||
|
payerId: NonNullable<typeof item.payerId>;
|
||||||
|
} => Boolean(item.payerId),
|
||||||
|
)
|
||||||
|
.map((item: (typeof existing)[number]) => ({
|
||||||
|
payerId: item.payerId,
|
||||||
|
name: item.name ?? null,
|
||||||
|
amount: item.amount ?? null,
|
||||||
|
transactionType: item.transactionType ?? null,
|
||||||
|
paymentMethod: item.paymentMethod ?? null,
|
||||||
|
condition: item.condition ?? null,
|
||||||
|
purchaseDate: item.purchaseDate ?? null,
|
||||||
|
period: item.period ?? null,
|
||||||
|
note: item.note ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (notificationData.length > 0) {
|
||||||
|
const notificationEntries = buildEntriesByPayer(notificationData);
|
||||||
|
|
||||||
|
await sendPayerAutoEmails({
|
||||||
|
userLabel: resolveUserLabel(user),
|
||||||
|
action: "deleted",
|
||||||
|
entriesByPagador: notificationEntries,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidate(user.id);
|
||||||
|
|
||||||
|
const count = existing.length;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `${count} ${
|
||||||
|
count === 1 ? "lançamento removido" : "lançamentos removidos"
|
||||||
|
} com sucesso.`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
758
src/features/transactions/actions/core.ts
Normal file
758
src/features/transactions/actions/core.ts
Normal file
@@ -0,0 +1,758 @@
|
|||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
cards,
|
||||||
|
categories,
|
||||||
|
financialAccounts,
|
||||||
|
payers,
|
||||||
|
type transactions,
|
||||||
|
} from "@/db/schema";
|
||||||
|
import {
|
||||||
|
PAYMENT_METHODS,
|
||||||
|
TRANSACTION_CONDITIONS,
|
||||||
|
TRANSACTION_TYPES,
|
||||||
|
} from "@/features/transactions/constants";
|
||||||
|
import {
|
||||||
|
INITIAL_BALANCE_CONDITION,
|
||||||
|
INITIAL_BALANCE_NOTE,
|
||||||
|
INITIAL_BALANCE_PAYMENT_METHOD,
|
||||||
|
INITIAL_BALANCE_TRANSACTION_TYPE,
|
||||||
|
} from "@/shared/lib/accounts/constants";
|
||||||
|
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
|
||||||
|
import { db } from "@/shared/lib/db";
|
||||||
|
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
|
||||||
|
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
|
||||||
|
import { addMonthsToPeriod } from "@/shared/utils/period";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Authorization Validation Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function validatePagadorOwnership(
|
||||||
|
userId: string,
|
||||||
|
payerId: string | null | undefined,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!payerId) return true;
|
||||||
|
|
||||||
|
const pagador = await db.query.payers.findFirst({
|
||||||
|
where: and(eq(payers.id, payerId), eq(payers.userId, userId)),
|
||||||
|
});
|
||||||
|
|
||||||
|
return !!pagador;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeIds = (ids: Array<string | null | undefined>) => [
|
||||||
|
...new Set(ids.filter((id): id is string => Boolean(id))),
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function fetchOwnedPayerIds(
|
||||||
|
userId: string,
|
||||||
|
payerIds: Array<string | null | undefined>,
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
const ids = normalizeIds(payerIds);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: payers.id })
|
||||||
|
.from(payers)
|
||||||
|
.where(and(eq(payers.userId, userId), inArray(payers.id, ids)));
|
||||||
|
|
||||||
|
return new Set(rows.map((row) => row.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateCategoriaOwnership(
|
||||||
|
userId: string,
|
||||||
|
categoryId: string | null | undefined,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!categoryId) return true;
|
||||||
|
|
||||||
|
const categoria = await db.query.categories.findFirst({
|
||||||
|
where: and(eq(categories.id, categoryId), eq(categories.userId, userId)),
|
||||||
|
});
|
||||||
|
|
||||||
|
return !!categoria;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOwnedCategoryIds(
|
||||||
|
userId: string,
|
||||||
|
categoryIds: Array<string | null | undefined>,
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
const ids = normalizeIds(categoryIds);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: categories.id })
|
||||||
|
.from(categories)
|
||||||
|
.where(and(eq(categories.userId, userId), inArray(categories.id, ids)));
|
||||||
|
|
||||||
|
return new Set(rows.map((row) => row.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateContaOwnership(
|
||||||
|
userId: string,
|
||||||
|
accountId: string | null | undefined,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!accountId) return true;
|
||||||
|
|
||||||
|
const conta = await db.query.financialAccounts.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(financialAccounts.id, accountId),
|
||||||
|
eq(financialAccounts.userId, userId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return !!conta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOwnedAccountIds(
|
||||||
|
userId: string,
|
||||||
|
accountIds: Array<string | null | undefined>,
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
const ids = normalizeIds(accountIds);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: financialAccounts.id })
|
||||||
|
.from(financialAccounts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(financialAccounts.userId, userId),
|
||||||
|
inArray(financialAccounts.id, ids),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Set(rows.map((row) => row.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateCartaoOwnership(
|
||||||
|
userId: string,
|
||||||
|
cardId: string | null | undefined,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!cardId) return true;
|
||||||
|
|
||||||
|
const cartao = await db.query.cards.findFirst({
|
||||||
|
where: and(eq(cards.id, cardId), eq(cards.userId, userId)),
|
||||||
|
});
|
||||||
|
|
||||||
|
return !!cartao;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOwnedCardIds(
|
||||||
|
userId: string,
|
||||||
|
cardIds: Array<string | null | undefined>,
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
const ids = normalizeIds(cardIds);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: cards.id })
|
||||||
|
.from(cards)
|
||||||
|
.where(and(eq(cards.userId, userId), inArray(cards.id, ids)));
|
||||||
|
|
||||||
|
return new Set(rows.map((row) => row.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateAllOwnership(
|
||||||
|
userId: string,
|
||||||
|
fields: {
|
||||||
|
payerId?: string | null;
|
||||||
|
secondaryPayerId?: string | null;
|
||||||
|
categoryId?: string | null;
|
||||||
|
accountId?: string | null;
|
||||||
|
cardId?: string | null;
|
||||||
|
},
|
||||||
|
): Promise<string | null> {
|
||||||
|
const [ownedPayerIds, ownedCategoryIds, ownedAccountIds, ownedCardIds] =
|
||||||
|
await Promise.all([
|
||||||
|
fetchOwnedPayerIds(userId, [fields.payerId, fields.secondaryPayerId]),
|
||||||
|
fetchOwnedCategoryIds(userId, [fields.categoryId]),
|
||||||
|
fetchOwnedAccountIds(userId, [fields.accountId]),
|
||||||
|
fetchOwnedCardIds(userId, [fields.cardId]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
!fields.payerId || ownedPayerIds.has(fields.payerId),
|
||||||
|
!fields.secondaryPayerId || ownedPayerIds.has(fields.secondaryPayerId),
|
||||||
|
!fields.categoryId || ownedCategoryIds.has(fields.categoryId),
|
||||||
|
!fields.accountId || ownedAccountIds.has(fields.accountId),
|
||||||
|
!fields.cardId || ownedCardIds.has(fields.cardId),
|
||||||
|
];
|
||||||
|
|
||||||
|
const errors = [
|
||||||
|
"Pagador não encontrado ou sem permissão.",
|
||||||
|
"Pagador secundário não encontrado ou sem permissão.",
|
||||||
|
"Categoria não encontrada.",
|
||||||
|
"Conta não encontrada.",
|
||||||
|
"Cartão não encontrado.",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < checks.length; i++) {
|
||||||
|
if (!checks[i]) return errors[i];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utility Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const resolvePeriod = (purchaseDate: string, period?: string | null) => {
|
||||||
|
if (period && /^\d{4}-\d{2}$/.test(period)) {
|
||||||
|
return period;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = parseLocalDateString(purchaseDate);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
throw new Error("Data da transação inválida.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
return `${year}-${month}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isValidDateInput = (value: string) =>
|
||||||
|
!Number.isNaN(parseLocalDateString(value).getTime());
|
||||||
|
|
||||||
|
export const baseFields = z.object({
|
||||||
|
purchaseDate: z
|
||||||
|
.string({ message: "Informe a data da transação." })
|
||||||
|
.trim()
|
||||||
|
.refine((value) => isValidDateInput(value), {
|
||||||
|
message: "Data da transação inválida.",
|
||||||
|
}),
|
||||||
|
period: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^(\d{4})-(\d{2})$/, {
|
||||||
|
message: "Selecione um período válido.",
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
name: z
|
||||||
|
.string({ message: "Informe o estabelecimento." })
|
||||||
|
.trim()
|
||||||
|
.min(1, "Informe o estabelecimento."),
|
||||||
|
transactionType: z
|
||||||
|
.enum(TRANSACTION_TYPES, {
|
||||||
|
message: "Selecione um tipo de transação válido.",
|
||||||
|
})
|
||||||
|
.default(TRANSACTION_TYPES[0]),
|
||||||
|
amount: z.coerce
|
||||||
|
.number({ message: "Informe o valor da transação." })
|
||||||
|
.min(0, "Informe um valor maior ou igual a zero."),
|
||||||
|
condition: z.enum(TRANSACTION_CONDITIONS, {
|
||||||
|
message: "Selecione uma condição válida.",
|
||||||
|
}),
|
||||||
|
paymentMethod: z.enum(PAYMENT_METHODS, {
|
||||||
|
message: "Selecione uma forma de pagamento válida.",
|
||||||
|
}),
|
||||||
|
payerId: uuidSchema("Payer").nullable().optional(),
|
||||||
|
secondaryPayerId: uuidSchema("Payer secundário").optional(),
|
||||||
|
isSplit: z.boolean().optional().default(false),
|
||||||
|
primarySplitAmount: z.coerce.number().min(0).optional(),
|
||||||
|
secondarySplitAmount: z.coerce.number().min(0).optional(),
|
||||||
|
accountId: uuidSchema("FinancialAccount").nullable().optional(),
|
||||||
|
cardId: uuidSchema("Cartão").nullable().optional(),
|
||||||
|
categoryId: uuidSchema("Category").nullable().optional(),
|
||||||
|
note: noteSchema,
|
||||||
|
installmentCount: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1, "Selecione uma quantidade válida.")
|
||||||
|
.max(60, "Selecione uma quantidade válida.")
|
||||||
|
.optional(),
|
||||||
|
recurrenceCount: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1, "Selecione uma recorrência válida.")
|
||||||
|
.max(60, "Selecione uma recorrência válida.")
|
||||||
|
.optional(),
|
||||||
|
dueDate: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine((value) => !value || isValidDateInput(value), {
|
||||||
|
message: "Informe uma data de vencimento válida.",
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
boletoPaymentDate: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine((value) => !value || isValidDateInput(value), {
|
||||||
|
message: "Informe uma data de pagamento válida.",
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
isSettled: z.boolean().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const refineLancamento = (
|
||||||
|
data: z.infer<typeof baseFields> & { id?: string },
|
||||||
|
ctx: z.RefinementCtx,
|
||||||
|
) => {
|
||||||
|
if (!data.categoryId) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["categoryId"],
|
||||||
|
message: "Selecione uma categoria.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.paymentMethod === "Cartão de crédito") {
|
||||||
|
if (!data.cardId) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["cardId"],
|
||||||
|
message: "Selecione o cartão.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (!data.accountId) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["accountId"],
|
||||||
|
message: "Selecione a conta.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.condition === "Recorrente") {
|
||||||
|
if (!data.recurrenceCount) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["recurrenceCount"],
|
||||||
|
message: "Informe por quantos meses a recorrência acontecerá.",
|
||||||
|
});
|
||||||
|
} else if (data.recurrenceCount < 2) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["recurrenceCount"],
|
||||||
|
message: "A recorrência deve ter ao menos dois meses.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.condition === "Parcelado") {
|
||||||
|
if (!data.installmentCount) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["installmentCount"],
|
||||||
|
message: "Informe a quantidade de parcelas.",
|
||||||
|
});
|
||||||
|
} else if (data.installmentCount < 2) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["installmentCount"],
|
||||||
|
message: "Selecione pelo menos duas parcelas.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.isSplit) {
|
||||||
|
if (!data.payerId) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["payerId"],
|
||||||
|
message: "Selecione o pagador principal para dividir o lançamento.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.secondaryPayerId) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["secondaryPayerId"],
|
||||||
|
message: "Selecione o pagador secundário para dividir o lançamento.",
|
||||||
|
});
|
||||||
|
} else if (data.payerId && data.secondaryPayerId === data.payerId) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["secondaryPayerId"],
|
||||||
|
message: "Escolha um pagador diferente para dividir o lançamento.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
data.primarySplitAmount !== undefined &&
|
||||||
|
data.secondarySplitAmount !== undefined
|
||||||
|
) {
|
||||||
|
const sum = data.primarySplitAmount + data.secondarySplitAmount;
|
||||||
|
const total = Math.abs(data.amount);
|
||||||
|
if (Math.abs(sum - total) > 0.01) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["primarySplitAmount"],
|
||||||
|
message: "A soma das divisões deve ser igual ao valor total.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createSchema = baseFields.superRefine(refineLancamento);
|
||||||
|
export const updateSchema = baseFields
|
||||||
|
.extend({
|
||||||
|
id: uuidSchema("Lançamento"),
|
||||||
|
})
|
||||||
|
.superRefine(refineLancamento);
|
||||||
|
|
||||||
|
export const deleteSchema = z.object({
|
||||||
|
id: uuidSchema("Lançamento"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const toggleSettlementSchema = z.object({
|
||||||
|
id: uuidSchema("Lançamento"),
|
||||||
|
value: z.boolean({
|
||||||
|
message: "Informe o status de pagamento.",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BaseInput = z.infer<typeof baseFields>;
|
||||||
|
export type CreateInput = z.infer<typeof createSchema>;
|
||||||
|
export type UpdateInput = z.infer<typeof updateSchema>;
|
||||||
|
export type DeleteInput = z.infer<typeof deleteSchema>;
|
||||||
|
export type ToggleSettlementInput = z.infer<typeof toggleSettlementSchema>;
|
||||||
|
|
||||||
|
export const revalidate = (userId: string) =>
|
||||||
|
revalidateForEntity("transactions", userId);
|
||||||
|
|
||||||
|
export const resolveUserLabel = (user: {
|
||||||
|
name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
}) => {
|
||||||
|
if (user?.name && user.name.trim().length > 0) {
|
||||||
|
return user.name;
|
||||||
|
}
|
||||||
|
if (user?.email && user.email.trim().length > 0) {
|
||||||
|
return user.email;
|
||||||
|
}
|
||||||
|
return "OpenMonetis";
|
||||||
|
};
|
||||||
|
|
||||||
|
type InitialCandidate = {
|
||||||
|
note: string | null;
|
||||||
|
transactionType: string | null;
|
||||||
|
condition: string | null;
|
||||||
|
paymentMethod: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isInitialBalanceLancamento = (record?: InitialCandidate | null) =>
|
||||||
|
!!record &&
|
||||||
|
record.note === INITIAL_BALANCE_NOTE &&
|
||||||
|
record.transactionType === INITIAL_BALANCE_TRANSACTION_TYPE &&
|
||||||
|
record.condition === INITIAL_BALANCE_CONDITION &&
|
||||||
|
record.paymentMethod === INITIAL_BALANCE_PAYMENT_METHOD;
|
||||||
|
|
||||||
|
export const centsToDecimalString = (value: number) => {
|
||||||
|
const decimal = value / 100;
|
||||||
|
const formatted = decimal.toFixed(2);
|
||||||
|
return Object.is(decimal, -0) ? "0.00" : formatted;
|
||||||
|
};
|
||||||
|
|
||||||
|
const splitAmount = (totalCents: number, parts: number) => {
|
||||||
|
if (parts <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = Math.trunc(totalCents / parts);
|
||||||
|
const remainder = totalCents % parts;
|
||||||
|
|
||||||
|
return Array.from(
|
||||||
|
{ length: parts },
|
||||||
|
(_, index) => base + (index < remainder ? 1 : 0),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Share = {
|
||||||
|
payerId: string | null;
|
||||||
|
amountCents: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildShares = ({
|
||||||
|
totalCents,
|
||||||
|
payerId,
|
||||||
|
isSplit,
|
||||||
|
secondaryPayerId,
|
||||||
|
primarySplitAmountCents,
|
||||||
|
secondarySplitAmountCents,
|
||||||
|
}: {
|
||||||
|
totalCents: number;
|
||||||
|
payerId: string | null;
|
||||||
|
isSplit: boolean;
|
||||||
|
secondaryPayerId?: string;
|
||||||
|
primarySplitAmountCents?: number;
|
||||||
|
secondarySplitAmountCents?: number;
|
||||||
|
}): Share[] => {
|
||||||
|
if (isSplit) {
|
||||||
|
if (!payerId || !secondaryPayerId) {
|
||||||
|
throw new Error("Configuração de divisão inválida para o lançamento.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
primarySplitAmountCents !== undefined &&
|
||||||
|
secondarySplitAmountCents !== undefined
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
{ payerId, amountCents: primarySplitAmountCents },
|
||||||
|
{
|
||||||
|
payerId: secondaryPayerId,
|
||||||
|
amountCents: secondarySplitAmountCents,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [primaryAmount, secondaryAmount] = splitAmount(totalCents, 2);
|
||||||
|
return [
|
||||||
|
{ payerId, amountCents: primaryAmount },
|
||||||
|
{ payerId: secondaryPayerId, amountCents: secondaryAmount },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ payerId, amountCents: totalCents }];
|
||||||
|
};
|
||||||
|
|
||||||
|
type BuildTransactionRecordsParams = {
|
||||||
|
data: BaseInput;
|
||||||
|
userId: string;
|
||||||
|
period: string;
|
||||||
|
purchaseDate: Date;
|
||||||
|
dueDate: Date | null;
|
||||||
|
boletoPaymentDate: Date | null;
|
||||||
|
shares: Share[];
|
||||||
|
amountSign: 1 | -1;
|
||||||
|
shouldNullifySettled: boolean;
|
||||||
|
seriesId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TransactionInsert = typeof transactions.$inferInsert;
|
||||||
|
|
||||||
|
export const buildLancamentoRecords = ({
|
||||||
|
data,
|
||||||
|
userId,
|
||||||
|
period,
|
||||||
|
purchaseDate,
|
||||||
|
dueDate,
|
||||||
|
boletoPaymentDate,
|
||||||
|
shares,
|
||||||
|
amountSign,
|
||||||
|
shouldNullifySettled,
|
||||||
|
seriesId,
|
||||||
|
}: BuildTransactionRecordsParams): TransactionInsert[] => {
|
||||||
|
const records: TransactionInsert[] = [];
|
||||||
|
|
||||||
|
const basePayload = {
|
||||||
|
name: data.name,
|
||||||
|
transactionType: data.transactionType,
|
||||||
|
condition: data.condition,
|
||||||
|
paymentMethod: data.paymentMethod,
|
||||||
|
note: data.note ?? null,
|
||||||
|
accountId: data.accountId ?? null,
|
||||||
|
cardId: data.cardId ?? null,
|
||||||
|
categoryId: data.categoryId ?? null,
|
||||||
|
recurrenceCount: null as number | null,
|
||||||
|
installmentCount: null as number | null,
|
||||||
|
currentInstallment: null as number | null,
|
||||||
|
isDivided: data.isSplit ?? false,
|
||||||
|
userId,
|
||||||
|
seriesId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveSettledValue = (cycleIndex: number) => {
|
||||||
|
if (shouldNullifySettled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const initialSettled = data.isSettled ?? false;
|
||||||
|
if (data.condition === "Parcelado" || data.condition === "Recorrente") {
|
||||||
|
return cycleIndex === 0 ? initialSettled : false;
|
||||||
|
}
|
||||||
|
return initialSettled;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.condition === "Parcelado") {
|
||||||
|
const installmentTotal = data.installmentCount ?? 0;
|
||||||
|
const amountsByShare = shares.map((share) =>
|
||||||
|
splitAmount(share.amountCents, installmentTotal),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (
|
||||||
|
let installment = 0;
|
||||||
|
installment < installmentTotal;
|
||||||
|
installment += 1
|
||||||
|
) {
|
||||||
|
const installmentPeriod = addMonthsToPeriod(period, installment);
|
||||||
|
const installmentDueDate = dueDate
|
||||||
|
? addMonthsToDate(dueDate, installment)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
shares.forEach((share, shareIndex) => {
|
||||||
|
const amountCents = amountsByShare[shareIndex]?.[installment] ?? 0;
|
||||||
|
const settled = resolveSettledValue(installment);
|
||||||
|
records.push({
|
||||||
|
...basePayload,
|
||||||
|
amount: centsToDecimalString(amountCents * amountSign),
|
||||||
|
payerId: share.payerId,
|
||||||
|
purchaseDate,
|
||||||
|
period: installmentPeriod,
|
||||||
|
isSettled: settled,
|
||||||
|
installmentCount: installmentTotal,
|
||||||
|
currentInstallment: installment + 1,
|
||||||
|
recurrenceCount: null,
|
||||||
|
dueDate: installmentDueDate,
|
||||||
|
boletoPaymentDate:
|
||||||
|
data.paymentMethod === "Boleto" && settled
|
||||||
|
? boletoPaymentDate
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.condition === "Recorrente") {
|
||||||
|
const recurrenceTotal = data.recurrenceCount ?? 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < recurrenceTotal; index += 1) {
|
||||||
|
const recurrencePeriod = addMonthsToPeriod(period, index);
|
||||||
|
const recurrencePurchaseDate = addMonthsToDate(purchaseDate, index);
|
||||||
|
const recurrenceDueDate = dueDate
|
||||||
|
? addMonthsToDate(dueDate, index)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
shares.forEach((share) => {
|
||||||
|
const settled = resolveSettledValue(index);
|
||||||
|
records.push({
|
||||||
|
...basePayload,
|
||||||
|
amount: centsToDecimalString(share.amountCents * amountSign),
|
||||||
|
payerId: share.payerId,
|
||||||
|
purchaseDate: recurrencePurchaseDate,
|
||||||
|
period: recurrencePeriod,
|
||||||
|
isSettled: settled,
|
||||||
|
recurrenceCount: recurrenceTotal,
|
||||||
|
dueDate: recurrenceDueDate,
|
||||||
|
boletoPaymentDate:
|
||||||
|
data.paymentMethod === "Boleto" && settled
|
||||||
|
? boletoPaymentDate
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
shares.forEach((share) => {
|
||||||
|
const settled = resolveSettledValue(0);
|
||||||
|
records.push({
|
||||||
|
...basePayload,
|
||||||
|
amount: centsToDecimalString(share.amountCents * amountSign),
|
||||||
|
payerId: share.payerId,
|
||||||
|
purchaseDate,
|
||||||
|
period,
|
||||||
|
isSettled: settled,
|
||||||
|
dueDate,
|
||||||
|
boletoPaymentDate:
|
||||||
|
data.paymentMethod === "Boleto" && settled ? boletoPaymentDate : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return records;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteBulkSchema = z.object({
|
||||||
|
id: uuidSchema("Lançamento"),
|
||||||
|
scope: z.enum(["current", "future", "all"], {
|
||||||
|
message: "Escopo de ação inválido.",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DeleteBulkInput = z.infer<typeof deleteBulkSchema>;
|
||||||
|
|
||||||
|
export const updateBulkSchema = z.object({
|
||||||
|
id: uuidSchema("Lançamento"),
|
||||||
|
scope: z.enum(["current", "future", "all"], {
|
||||||
|
message: "Escopo de ação inválido.",
|
||||||
|
}),
|
||||||
|
name: z
|
||||||
|
.string({ message: "Informe o estabelecimento." })
|
||||||
|
.trim()
|
||||||
|
.min(1, "Informe o estabelecimento."),
|
||||||
|
categoryId: uuidSchema("Category").nullable().optional(),
|
||||||
|
note: noteSchema,
|
||||||
|
payerId: uuidSchema("Payer").nullable().optional(),
|
||||||
|
accountId: uuidSchema("FinancialAccount").nullable().optional(),
|
||||||
|
cardId: uuidSchema("Cartão").nullable().optional(),
|
||||||
|
amount: z.coerce
|
||||||
|
.number({ message: "Informe o valor da transação." })
|
||||||
|
.min(0, "Informe um valor maior ou igual a zero.")
|
||||||
|
.optional(),
|
||||||
|
dueDate: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine((value) => !value || isValidDateInput(value), {
|
||||||
|
message: "Informe uma data de vencimento válida.",
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
boletoPaymentDate: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine((value) => !value || isValidDateInput(value), {
|
||||||
|
message: "Informe uma data de pagamento válida.",
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateBulkInput = z.infer<typeof updateBulkSchema>;
|
||||||
|
|
||||||
|
export const massAddTransactionSchema = z.object({
|
||||||
|
purchaseDate: z
|
||||||
|
.string({ message: "Informe a data da transação." })
|
||||||
|
.trim()
|
||||||
|
.refine((value) => isValidDateInput(value), {
|
||||||
|
message: "Data da transação inválida.",
|
||||||
|
}),
|
||||||
|
name: z
|
||||||
|
.string({ message: "Informe o estabelecimento." })
|
||||||
|
.trim()
|
||||||
|
.min(1, "Informe o estabelecimento."),
|
||||||
|
amount: z.coerce
|
||||||
|
.number({ message: "Informe o valor da transação." })
|
||||||
|
.min(0, "Informe um valor maior ou igual a zero."),
|
||||||
|
categoryId: uuidSchema("Category").nullable().optional(),
|
||||||
|
payerId: uuidSchema("Payer").nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const massAddSchema = z.object({
|
||||||
|
fixedFields: z.object({
|
||||||
|
transactionType: z.enum(TRANSACTION_TYPES).optional(),
|
||||||
|
paymentMethod: z.enum(PAYMENT_METHODS).optional(),
|
||||||
|
condition: z.enum(TRANSACTION_CONDITIONS).optional(),
|
||||||
|
period: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^(\d{4})-(\d{2})$/, {
|
||||||
|
message: "Selecione um período válido.",
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
accountId: uuidSchema("FinancialAccount").nullable().optional(),
|
||||||
|
cardId: uuidSchema("Cartão").nullable().optional(),
|
||||||
|
}),
|
||||||
|
transactions: z
|
||||||
|
.array(massAddTransactionSchema)
|
||||||
|
.min(1, "Adicione pelo menos uma transação."),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MassAddInput = z.infer<typeof massAddSchema>;
|
||||||
|
|
||||||
|
export const deleteMultipleSchema = z.object({
|
||||||
|
ids: z
|
||||||
|
.array(uuidSchema("Lançamento"))
|
||||||
|
.min(1, "Selecione pelo menos um lançamento."),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DeleteMultipleInput = z.infer<typeof deleteMultipleSchema>;
|
||||||
81
src/features/transactions/actions/export-actions.ts
Normal file
81
src/features/transactions/actions/export-actions.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fetchAccountLancamentos } from "@/features/accounts/statement-queries";
|
||||||
|
import type { TransactionsExportContext } from "@/features/transactions/export-types";
|
||||||
|
import {
|
||||||
|
buildSluggedFilters,
|
||||||
|
buildSlugMaps,
|
||||||
|
buildTransactionWhere,
|
||||||
|
mapTransactionsData,
|
||||||
|
} from "@/features/transactions/page-helpers";
|
||||||
|
import {
|
||||||
|
fetchTransactionFilterSources,
|
||||||
|
fetchTransactions,
|
||||||
|
} from "@/features/transactions/queries";
|
||||||
|
import {
|
||||||
|
type ActionResult,
|
||||||
|
handleActionError,
|
||||||
|
} from "@/shared/lib/actions/helpers";
|
||||||
|
import { getUserId } from "@/shared/lib/auth/server";
|
||||||
|
|
||||||
|
const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = z.object(
|
||||||
|
{
|
||||||
|
source: z.enum(["transactions", "account-statement"]),
|
||||||
|
period: z.string().regex(/^\d{4}-\d{2}$/),
|
||||||
|
filters: z.object({
|
||||||
|
transactionFilter: z.string().nullable(),
|
||||||
|
conditionFilter: z.string().nullable(),
|
||||||
|
paymentFilter: z.string().nullable(),
|
||||||
|
payerFilter: z.string().nullable(),
|
||||||
|
categoryFilter: z.string().nullable(),
|
||||||
|
accountCardFilter: z.string().nullable(),
|
||||||
|
searchFilter: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
accountId: z.string().min(1).nullable().optional(),
|
||||||
|
cardId: z.string().min(1).nullable().optional(),
|
||||||
|
payerId: z.string().min(1).nullable().optional(),
|
||||||
|
settledOnly: z.boolean().optional(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function exportTransactionsDataAction(
|
||||||
|
input: TransactionsExportContext,
|
||||||
|
): Promise<
|
||||||
|
ActionResult<{ transactions: ReturnType<typeof mapTransactionsData> }>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const userId = await getUserId();
|
||||||
|
const validated = exportTransactionsSchema.parse(input);
|
||||||
|
const filterSources = await fetchTransactionFilterSources(userId);
|
||||||
|
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||||
|
const slugMaps = buildSlugMaps(sluggedFilters);
|
||||||
|
|
||||||
|
const filters = buildTransactionWhere({
|
||||||
|
userId,
|
||||||
|
period: validated.period,
|
||||||
|
filters: validated.filters,
|
||||||
|
slugMaps,
|
||||||
|
accountId: validated.accountId ?? undefined,
|
||||||
|
cardId: validated.cardId ?? undefined,
|
||||||
|
payerId: validated.payerId ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows =
|
||||||
|
validated.source === "account-statement"
|
||||||
|
? await fetchAccountLancamentos(filters, validated.settledOnly ?? true)
|
||||||
|
: await fetchTransactions(filters);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Dados carregados para exportação.",
|
||||||
|
data: {
|
||||||
|
transactions: mapTransactionsData(rows),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return handleActionError(error) as ActionResult<{
|
||||||
|
transactions: ReturnType<typeof mapTransactionsData>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
}
|
||||||
422
src/features/transactions/actions/single-actions.ts
Normal file
422
src/features/transactions/actions/single-actions.ts
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { financialAccounts, transactions } from "@/db/schema";
|
||||||
|
import { handleActionError } from "@/shared/lib/actions/helpers";
|
||||||
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
|
import { db } from "@/shared/lib/db";
|
||||||
|
import {
|
||||||
|
buildEntriesByPayer,
|
||||||
|
sendPayerAutoEmails,
|
||||||
|
} from "@/shared/lib/payers/notifications";
|
||||||
|
import type { ActionResult } from "@/shared/lib/types/actions";
|
||||||
|
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
|
||||||
|
import {
|
||||||
|
getBusinessTodayDate,
|
||||||
|
parseLocalDateString,
|
||||||
|
} from "@/shared/utils/date";
|
||||||
|
import {
|
||||||
|
buildLancamentoRecords,
|
||||||
|
buildShares,
|
||||||
|
type CreateInput,
|
||||||
|
centsToDecimalString,
|
||||||
|
createSchema,
|
||||||
|
type DeleteInput,
|
||||||
|
deleteSchema,
|
||||||
|
isInitialBalanceLancamento,
|
||||||
|
resolvePeriod,
|
||||||
|
resolveUserLabel,
|
||||||
|
revalidate,
|
||||||
|
type ToggleSettlementInput,
|
||||||
|
toggleSettlementSchema,
|
||||||
|
type UpdateInput,
|
||||||
|
updateSchema,
|
||||||
|
validateAllOwnership,
|
||||||
|
} from "./core";
|
||||||
|
|
||||||
|
export async function createTransactionAction(
|
||||||
|
input: CreateInput,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = createSchema.parse(input);
|
||||||
|
|
||||||
|
const ownershipError = await validateAllOwnership(user.id, {
|
||||||
|
payerId: data.payerId,
|
||||||
|
secondaryPayerId: data.secondaryPayerId,
|
||||||
|
categoryId: data.categoryId,
|
||||||
|
accountId: data.accountId,
|
||||||
|
cardId: data.cardId,
|
||||||
|
});
|
||||||
|
if (ownershipError) {
|
||||||
|
return { success: false, error: ownershipError };
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = resolvePeriod(data.purchaseDate, data.period);
|
||||||
|
const purchaseDate = parseLocalDateString(data.purchaseDate);
|
||||||
|
const dueDate = data.dueDate ? parseLocalDateString(data.dueDate) : null;
|
||||||
|
const shouldSetBoletoPaymentDate =
|
||||||
|
data.paymentMethod === "Boleto" && (data.isSettled ?? false);
|
||||||
|
const boletoPaymentDate = shouldSetBoletoPaymentDate
|
||||||
|
? data.boletoPaymentDate
|
||||||
|
? parseLocalDateString(data.boletoPaymentDate)
|
||||||
|
: getBusinessTodayDate()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1;
|
||||||
|
const totalCents = Math.round(Math.abs(data.amount) * 100);
|
||||||
|
const shouldNullifySettled = data.paymentMethod === "Cartão de crédito";
|
||||||
|
|
||||||
|
const shares = buildShares({
|
||||||
|
totalCents,
|
||||||
|
payerId: data.payerId ?? null,
|
||||||
|
isSplit: data.isSplit ?? false,
|
||||||
|
secondaryPayerId: data.secondaryPayerId,
|
||||||
|
primarySplitAmountCents: data.primarySplitAmount
|
||||||
|
? Math.round(data.primarySplitAmount * 100)
|
||||||
|
: undefined,
|
||||||
|
secondarySplitAmountCents: data.secondarySplitAmount
|
||||||
|
? Math.round(data.secondarySplitAmount * 100)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSeriesLancamento =
|
||||||
|
data.condition === "Parcelado" || data.condition === "Recorrente";
|
||||||
|
const seriesId = isSeriesLancamento ? randomUUID() : null;
|
||||||
|
|
||||||
|
const records = buildLancamentoRecords({
|
||||||
|
data,
|
||||||
|
userId: user.id,
|
||||||
|
period,
|
||||||
|
purchaseDate,
|
||||||
|
dueDate,
|
||||||
|
shares,
|
||||||
|
amountSign,
|
||||||
|
shouldNullifySettled,
|
||||||
|
boletoPaymentDate,
|
||||||
|
seriesId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!records.length) {
|
||||||
|
throw new Error("Não foi possível criar os lançamentos solicitados.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(transactions).values(records);
|
||||||
|
|
||||||
|
const notificationEntries = buildEntriesByPayer(
|
||||||
|
records.map((record) => ({
|
||||||
|
payerId: record.payerId ?? null,
|
||||||
|
name: record.name ?? null,
|
||||||
|
amount: record.amount ?? null,
|
||||||
|
transactionType: record.transactionType ?? null,
|
||||||
|
paymentMethod: record.paymentMethod ?? null,
|
||||||
|
condition: record.condition ?? null,
|
||||||
|
purchaseDate: record.purchaseDate ?? null,
|
||||||
|
period: record.period ?? null,
|
||||||
|
note: record.note ?? null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (notificationEntries.size > 0) {
|
||||||
|
await sendPayerAutoEmails({
|
||||||
|
userLabel: resolveUserLabel(user),
|
||||||
|
action: "created",
|
||||||
|
entriesByPagador: notificationEntries,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidate(user.id);
|
||||||
|
|
||||||
|
return { success: true, message: "Lançamento criado com sucesso." };
|
||||||
|
} catch (error) {
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTransactionAction(
|
||||||
|
input: UpdateInput,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = updateSchema.parse(input);
|
||||||
|
|
||||||
|
const ownershipError = await validateAllOwnership(user.id, {
|
||||||
|
payerId: data.payerId,
|
||||||
|
secondaryPayerId: data.secondaryPayerId,
|
||||||
|
categoryId: data.categoryId,
|
||||||
|
accountId: data.accountId,
|
||||||
|
cardId: data.cardId,
|
||||||
|
});
|
||||||
|
if (ownershipError) {
|
||||||
|
return { success: false, error: ownershipError };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = (await db.query.transactions.findFirst({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
note: true,
|
||||||
|
transactionType: true,
|
||||||
|
condition: true,
|
||||||
|
paymentMethod: true,
|
||||||
|
accountId: true,
|
||||||
|
categoryId: true,
|
||||||
|
},
|
||||||
|
where: and(
|
||||||
|
eq(transactions.id, data.id),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
with: {
|
||||||
|
category: {
|
||||||
|
columns: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})) as
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
note: string | null;
|
||||||
|
transactionType: string;
|
||||||
|
condition: string;
|
||||||
|
paymentMethod: string;
|
||||||
|
accountId: string | null;
|
||||||
|
categoryId: string | null;
|
||||||
|
category: { name: string } | null;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return { success: false, error: "Lançamento não encontrado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoriasProtegidasEdicao = ["Saldo inicial", "Pagamentos"];
|
||||||
|
if (
|
||||||
|
existing.category?.name &&
|
||||||
|
categoriasProtegidasEdicao.includes(existing.category.name)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Lançamentos com a categoria '${existing.category.name}' não podem ser editados.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = resolvePeriod(data.purchaseDate, data.period);
|
||||||
|
const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1;
|
||||||
|
const amountCents = Math.round(Math.abs(data.amount) * 100);
|
||||||
|
const normalizedAmount = centsToDecimalString(amountCents * amountSign);
|
||||||
|
const normalizedSettled =
|
||||||
|
data.paymentMethod === "Cartão de crédito"
|
||||||
|
? null
|
||||||
|
: (data.isSettled ?? false);
|
||||||
|
const shouldSetBoletoPaymentDate =
|
||||||
|
data.paymentMethod === "Boleto" && Boolean(normalizedSettled);
|
||||||
|
const boletoPaymentDateValue = shouldSetBoletoPaymentDate
|
||||||
|
? data.boletoPaymentDate
|
||||||
|
? parseLocalDateString(data.boletoPaymentDate)
|
||||||
|
: getBusinessTodayDate()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(transactions)
|
||||||
|
.set({
|
||||||
|
name: data.name,
|
||||||
|
purchaseDate: parseLocalDateString(data.purchaseDate),
|
||||||
|
transactionType: data.transactionType,
|
||||||
|
amount: normalizedAmount,
|
||||||
|
condition: data.condition,
|
||||||
|
paymentMethod: data.paymentMethod,
|
||||||
|
payerId: data.payerId ?? null,
|
||||||
|
accountId: data.accountId ?? null,
|
||||||
|
cardId: data.cardId ?? null,
|
||||||
|
categoryId: data.categoryId ?? null,
|
||||||
|
note: data.note ?? null,
|
||||||
|
isSettled: normalizedSettled,
|
||||||
|
installmentCount: data.installmentCount ?? null,
|
||||||
|
recurrenceCount: data.recurrenceCount ?? null,
|
||||||
|
dueDate: data.dueDate ? parseLocalDateString(data.dueDate) : null,
|
||||||
|
boletoPaymentDate: boletoPaymentDateValue,
|
||||||
|
period,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isInitialBalanceLancamento(existing) && existing.accountId) {
|
||||||
|
const updatedInitialBalance = formatDecimalForDbRequired(
|
||||||
|
Math.abs(data.amount ?? 0),
|
||||||
|
);
|
||||||
|
await db
|
||||||
|
.update(financialAccounts)
|
||||||
|
.set({ initialBalance: updatedInitialBalance })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(financialAccounts.id, existing.accountId),
|
||||||
|
eq(financialAccounts.userId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidate(user.id);
|
||||||
|
|
||||||
|
return { success: true, message: "Lançamento atualizado com sucesso." };
|
||||||
|
} catch (error) {
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTransactionAction(
|
||||||
|
input: DeleteInput,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = deleteSchema.parse(input);
|
||||||
|
|
||||||
|
const existing = (await db.query.transactions.findFirst({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
payerId: true,
|
||||||
|
amount: true,
|
||||||
|
transactionType: true,
|
||||||
|
paymentMethod: true,
|
||||||
|
condition: true,
|
||||||
|
purchaseDate: true,
|
||||||
|
period: true,
|
||||||
|
note: true,
|
||||||
|
categoryId: true,
|
||||||
|
},
|
||||||
|
where: and(
|
||||||
|
eq(transactions.id, data.id),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
with: {
|
||||||
|
category: {
|
||||||
|
columns: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})) as
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
payerId: string | null;
|
||||||
|
amount: string | null;
|
||||||
|
transactionType: string;
|
||||||
|
paymentMethod: string;
|
||||||
|
condition: string;
|
||||||
|
purchaseDate: Date | null;
|
||||||
|
period: string;
|
||||||
|
note: string | null;
|
||||||
|
categoryId: string | null;
|
||||||
|
category: { name: string } | null;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return { success: false, error: "Lançamento não encontrado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoriasProtegidasRemocao = ["Saldo inicial", "Pagamentos"];
|
||||||
|
if (
|
||||||
|
existing.category?.name &&
|
||||||
|
categoriasProtegidasRemocao.includes(existing.category.name)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Lançamentos com a categoria '${existing.category.name}' não podem ser removidos.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(transactions)
|
||||||
|
.where(
|
||||||
|
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.payerId) {
|
||||||
|
const notificationEntries = buildEntriesByPayer([
|
||||||
|
{
|
||||||
|
payerId: existing.payerId,
|
||||||
|
name: existing.name ?? null,
|
||||||
|
amount: existing.amount ?? null,
|
||||||
|
transactionType: existing.transactionType ?? null,
|
||||||
|
paymentMethod: existing.paymentMethod ?? null,
|
||||||
|
condition: existing.condition ?? null,
|
||||||
|
purchaseDate: existing.purchaseDate ?? null,
|
||||||
|
period: existing.period ?? null,
|
||||||
|
note: existing.note ?? null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await sendPayerAutoEmails({
|
||||||
|
userLabel: resolveUserLabel(user),
|
||||||
|
action: "deleted",
|
||||||
|
entriesByPagador: notificationEntries,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidate(user.id);
|
||||||
|
|
||||||
|
return { success: true, message: "Lançamento removido com sucesso." };
|
||||||
|
} catch (error) {
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleTransactionSettlementAction(
|
||||||
|
input: ToggleSettlementInput,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = toggleSettlementSchema.parse(input);
|
||||||
|
|
||||||
|
const existing = await db.query.transactions.findFirst({
|
||||||
|
columns: { id: true, paymentMethod: true },
|
||||||
|
where: and(
|
||||||
|
eq(transactions.id, data.id),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return { success: false, error: "Lançamento não encontrado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.paymentMethod === "Cartão de crédito") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Pagamentos com cartão são conciliados automaticamente.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBoleto = existing.paymentMethod === "Boleto";
|
||||||
|
const boletoPaymentDate = isBoleto
|
||||||
|
? data.value
|
||||||
|
? getBusinessTodayDate()
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(transactions)
|
||||||
|
.set({
|
||||||
|
isSettled: data.value,
|
||||||
|
boletoPaymentDate,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidate(user.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: data.value
|
||||||
|
? "Lançamento marcado como pago."
|
||||||
|
: "Pagamento desfeito com sucesso.",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,8 +20,8 @@ export const LANCAMENTOS_COLUMN_LABELS: Record<string, string> = {
|
|||||||
amount: "Valor",
|
amount: "Valor",
|
||||||
condition: "Condição",
|
condition: "Condição",
|
||||||
paymentMethod: "Forma de Pagamento",
|
paymentMethod: "Forma de Pagamento",
|
||||||
categoriaName: "Category",
|
categoriaName: "Categoria",
|
||||||
pagadorName: "Payer",
|
pagadorName: "Pagador",
|
||||||
note: "Anotação",
|
note: "Anotação",
|
||||||
contaCartao: "Conta/Cartão",
|
contaCartao: "Conta/Cartão",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import {
|
|||||||
updateTransactionBulkAction,
|
updateTransactionBulkAction,
|
||||||
} from "@/features/transactions/actions";
|
} from "@/features/transactions/actions";
|
||||||
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
||||||
|
import type {
|
||||||
|
TransactionsExportContext,
|
||||||
|
TransactionsPaginationState,
|
||||||
|
} from "../../export-types";
|
||||||
import { AnticipateInstallmentsDialog } from "../dialogs/anticipate-installments-dialog/anticipate-installments-dialog";
|
import { AnticipateInstallmentsDialog } from "../dialogs/anticipate-installments-dialog/anticipate-installments-dialog";
|
||||||
import { AnticipationHistoryDialog } from "../dialogs/anticipate-installments-dialog/anticipation-history-dialog";
|
import { AnticipationHistoryDialog } from "../dialogs/anticipate-installments-dialog/anticipation-history-dialog";
|
||||||
import {
|
import {
|
||||||
@@ -54,6 +57,8 @@ interface TransactionsPageProps {
|
|||||||
defaultPaymentMethod?: string | null;
|
defaultPaymentMethod?: string | null;
|
||||||
lockCardSelection?: boolean;
|
lockCardSelection?: boolean;
|
||||||
lockPaymentMethod?: boolean;
|
lockPaymentMethod?: boolean;
|
||||||
|
pagination?: TransactionsPaginationState;
|
||||||
|
exportContext?: TransactionsExportContext;
|
||||||
// Opções específicas para o dialog de importação (quando visualizando dados de outro usuário)
|
// Opções específicas para o dialog de importação (quando visualizando dados de outro usuário)
|
||||||
importPayerOptions?: SelectOption[];
|
importPayerOptions?: SelectOption[];
|
||||||
importSplitPayerOptions?: SelectOption[];
|
importSplitPayerOptions?: SelectOption[];
|
||||||
@@ -84,6 +89,8 @@ export function TransactionsPage({
|
|||||||
defaultPaymentMethod,
|
defaultPaymentMethod,
|
||||||
lockCardSelection,
|
lockCardSelection,
|
||||||
lockPaymentMethod,
|
lockPaymentMethod,
|
||||||
|
pagination,
|
||||||
|
exportContext,
|
||||||
importPayerOptions,
|
importPayerOptions,
|
||||||
importSplitPayerOptions,
|
importSplitPayerOptions,
|
||||||
importDefaultPayerId,
|
importDefaultPayerId,
|
||||||
@@ -393,6 +400,8 @@ export function TransactionsPage({
|
|||||||
categoryFilterOptions={categoryFilterOptions}
|
categoryFilterOptions={categoryFilterOptions}
|
||||||
accountCardFilterOptions={accountCardFilterOptions}
|
accountCardFilterOptions={accountCardFilterOptions}
|
||||||
selectedPeriod={selectedPeriod}
|
selectedPeriod={selectedPeriod}
|
||||||
|
pagination={pagination}
|
||||||
|
exportContext={exportContext}
|
||||||
onCreate={allowCreate ? handleCreate : undefined}
|
onCreate={allowCreate ? handleCreate : undefined}
|
||||||
onMassAdd={allowCreate ? handleMassAdd : undefined}
|
onMassAdd={allowCreate ? handleMassAdd : undefined}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
|
|||||||
@@ -162,10 +162,13 @@ export function TransactionsFilters({
|
|||||||
nextParams.delete(key);
|
nextParams.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nextParams.delete("page");
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.replace(`${pathname}?${nextParams.toString()}`, {
|
const target = nextParams.toString()
|
||||||
scroll: false,
|
? `${pathname}?${nextParams.toString()}`
|
||||||
});
|
: pathname;
|
||||||
|
router.replace(target, { scroll: false });
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[searchParams, pathname, router],
|
[searchParams, pathname, router],
|
||||||
@@ -193,10 +196,14 @@ export function TransactionsFilters({
|
|||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
const periodValue = searchParams.get("periodo");
|
const periodValue = searchParams.get("periodo");
|
||||||
|
const pageSizeValue = searchParams.get("pageSize");
|
||||||
const nextParams = new URLSearchParams();
|
const nextParams = new URLSearchParams();
|
||||||
if (periodValue) {
|
if (periodValue) {
|
||||||
nextParams.set("periodo", periodValue);
|
nextParams.set("periodo", periodValue);
|
||||||
}
|
}
|
||||||
|
if (pageSizeValue) {
|
||||||
|
nextParams.set("pageSize", pageSizeValue);
|
||||||
|
}
|
||||||
setSearchValue("");
|
setSearchValue("");
|
||||||
setCategoryOpen(false);
|
setCategoryOpen(false);
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
|
|||||||
@@ -34,8 +34,13 @@ import {
|
|||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { DEFAULT_LANCAMENTOS_COLUMN_ORDER } from "@/features/transactions/column-order";
|
import { DEFAULT_LANCAMENTOS_COLUMN_ORDER } from "@/features/transactions/column-order";
|
||||||
|
import type {
|
||||||
|
TransactionsExportContext,
|
||||||
|
TransactionsPaginationState,
|
||||||
|
} from "@/features/transactions/export-types";
|
||||||
import { EmptyState } from "@/shared/components/empty-state";
|
import { EmptyState } from "@/shared/components/empty-state";
|
||||||
import {
|
import {
|
||||||
CategoryIconBadge,
|
CategoryIconBadge,
|
||||||
@@ -289,7 +294,7 @@ const buildColumns = ({
|
|||||||
<TooltipContent
|
<TooltipContent
|
||||||
side="top"
|
side="top"
|
||||||
align="start"
|
align="start"
|
||||||
className="max-w-xs whitespace-pre-line text-sm"
|
className="max-w-xs whitespace-pre-line"
|
||||||
>
|
>
|
||||||
{note}
|
{note}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
@@ -743,6 +748,8 @@ type LancamentosTableProps = {
|
|||||||
categoryFilterOptions?: TransactionFilterOption[];
|
categoryFilterOptions?: TransactionFilterOption[];
|
||||||
accountCardFilterOptions?: AccountCardFilterOption[];
|
accountCardFilterOptions?: AccountCardFilterOption[];
|
||||||
selectedPeriod?: string;
|
selectedPeriod?: string;
|
||||||
|
pagination?: TransactionsPaginationState;
|
||||||
|
exportContext?: TransactionsExportContext;
|
||||||
onCreate?: (type: "Despesa" | "Receita") => void;
|
onCreate?: (type: "Despesa" | "Receita") => void;
|
||||||
onMassAdd?: () => void;
|
onMassAdd?: () => void;
|
||||||
onEdit?: (item: TransactionItem) => void;
|
onEdit?: (item: TransactionItem) => void;
|
||||||
@@ -769,6 +776,8 @@ export function TransactionsTable({
|
|||||||
categoryFilterOptions = [],
|
categoryFilterOptions = [],
|
||||||
accountCardFilterOptions = [],
|
accountCardFilterOptions = [],
|
||||||
selectedPeriod,
|
selectedPeriod,
|
||||||
|
pagination: serverPagination,
|
||||||
|
exportContext,
|
||||||
onCreate,
|
onCreate,
|
||||||
onMassAdd,
|
onMassAdd,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -785,6 +794,9 @@ export function TransactionsTable({
|
|||||||
showActions = true,
|
showActions = true,
|
||||||
showFilters = true,
|
showFilters = true,
|
||||||
}: LancamentosTableProps) {
|
}: LancamentosTableProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [sorting, setSorting] = useState<SortingState>([
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
{ id: "purchaseDate", desc: true },
|
{ id: "purchaseDate", desc: true },
|
||||||
]);
|
]);
|
||||||
@@ -796,6 +808,7 @@ export function TransactionsTable({
|
|||||||
pageSize: 30,
|
pageSize: 30,
|
||||||
});
|
});
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
|
const isServerPaginated = Boolean(serverPagination);
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
const built = buildColumns({
|
const built = buildColumns({
|
||||||
@@ -835,30 +848,53 @@ export function TransactionsTable({
|
|||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: isServerPaginated
|
||||||
sorting,
|
? {
|
||||||
columnVisibility,
|
sorting,
|
||||||
pagination,
|
columnVisibility,
|
||||||
rowSelection,
|
rowSelection,
|
||||||
},
|
}
|
||||||
|
: {
|
||||||
|
sorting,
|
||||||
|
columnVisibility,
|
||||||
|
pagination,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: isServerPaginated ? undefined : setPagination,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
getPaginationRowModel: isServerPaginated
|
||||||
|
? undefined
|
||||||
|
: getPaginationRowModel(),
|
||||||
|
manualPagination: isServerPaginated,
|
||||||
|
pageCount: serverPagination?.totalPages,
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rowModel = table.getRowModel();
|
const rowModel = table.getRowModel();
|
||||||
const hasRows = rowModel.rows.length > 0;
|
const hasRows = rowModel.rows.length > 0;
|
||||||
const totalRows = table.getCoreRowModel().rows.length;
|
const totalRows = isServerPaginated
|
||||||
|
? (serverPagination?.totalItems ?? 0)
|
||||||
|
: table.getCoreRowModel().rows.length;
|
||||||
const selectedRows = table.getFilteredSelectedRowModel().rows;
|
const selectedRows = table.getFilteredSelectedRowModel().rows;
|
||||||
const selectedCount = selectedRows.length;
|
const selectedCount = selectedRows.length;
|
||||||
const selectedTotal = selectedRows.reduce(
|
const selectedTotal = selectedRows.reduce(
|
||||||
(total, row) => total + (row.original.amount ?? 0),
|
(total, row) => total + (row.original.amount ?? 0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
const currentPage = isServerPaginated
|
||||||
|
? (serverPagination?.page ?? 1)
|
||||||
|
: table.getState().pagination.pageIndex + 1;
|
||||||
|
const currentPageSize = isServerPaginated
|
||||||
|
? (serverPagination?.pageSize ?? pagination.pageSize)
|
||||||
|
: pagination.pageSize;
|
||||||
|
const totalPages = isServerPaginated
|
||||||
|
? Math.max(serverPagination?.totalPages ?? 1, 1)
|
||||||
|
: Math.max(table.getPageCount(), 1);
|
||||||
|
const canPreviousPage = currentPage > 1;
|
||||||
|
const canNextPage = currentPage < totalPages;
|
||||||
|
|
||||||
// Check if there's any data from other users
|
// Check if there's any data from other users
|
||||||
const hasOtherUserData = data.some((item) => item.userId !== currentUserId);
|
const hasOtherUserData = data.some((item) => item.userId !== currentUserId);
|
||||||
@@ -882,6 +918,28 @@ export function TransactionsTable({
|
|||||||
const showTopControls =
|
const showTopControls =
|
||||||
Boolean(onCreate) || Boolean(onMassAdd) || showFilters;
|
Boolean(onCreate) || Boolean(onMassAdd) || showFilters;
|
||||||
|
|
||||||
|
const navigateToPage = (nextPage: number, nextPageSize = currentPageSize) => {
|
||||||
|
const nextParams = new URLSearchParams(searchParams.toString());
|
||||||
|
|
||||||
|
if (nextPage <= 1) {
|
||||||
|
nextParams.delete("page");
|
||||||
|
} else {
|
||||||
|
nextParams.set("page", nextPage.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextPageSize === 30) {
|
||||||
|
nextParams.delete("pageSize");
|
||||||
|
} else {
|
||||||
|
nextParams.set("pageSize", nextPageSize.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = nextParams.toString()
|
||||||
|
? `${pathname}?${nextParams.toString()}`
|
||||||
|
: pathname;
|
||||||
|
router.replace(target, { scroll: false });
|
||||||
|
setRowSelection({});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
{showTopControls ? (
|
{showTopControls ? (
|
||||||
@@ -943,6 +1001,7 @@ export function TransactionsTable({
|
|||||||
<TransactionsExport
|
<TransactionsExport
|
||||||
lancamentos={data}
|
lancamentos={data}
|
||||||
period={selectedPeriod}
|
period={selectedPeriod}
|
||||||
|
exportContext={exportContext}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
@@ -1073,9 +1132,15 @@ export function TransactionsTable({
|
|||||||
{totalRows} lançamentos
|
{totalRows} lançamentos
|
||||||
</span>
|
</span>
|
||||||
<Select
|
<Select
|
||||||
value={pagination.pageSize.toString()}
|
value={currentPageSize.toString()}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
table.setPageSize(Number(value));
|
const nextPageSize = Number(value);
|
||||||
|
if (isServerPaginated) {
|
||||||
|
navigateToPage(1, nextPageSize);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.setPageSize(nextPageSize);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-max">
|
<SelectTrigger className="w-max">
|
||||||
@@ -1094,15 +1159,18 @@ export function TransactionsTable({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Página {table.getState().pagination.pageIndex + 1} de{" "}
|
Página {currentPage} de {totalPages}
|
||||||
{Math.max(table.getPageCount(), 1)}
|
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={() => table.setPageIndex(0)}
|
onClick={() =>
|
||||||
disabled={!table.getCanPreviousPage()}
|
isServerPaginated
|
||||||
|
? navigateToPage(1)
|
||||||
|
: table.setPageIndex(0)
|
||||||
|
}
|
||||||
|
disabled={!canPreviousPage}
|
||||||
aria-label="Primeira página"
|
aria-label="Primeira página"
|
||||||
>
|
>
|
||||||
<RiArrowLeftDoubleLine className="size-4" />
|
<RiArrowLeftDoubleLine className="size-4" />
|
||||||
@@ -1110,8 +1178,12 @@ export function TransactionsTable({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={() => table.previousPage()}
|
onClick={() =>
|
||||||
disabled={!table.getCanPreviousPage()}
|
isServerPaginated
|
||||||
|
? navigateToPage(currentPage - 1)
|
||||||
|
: table.previousPage()
|
||||||
|
}
|
||||||
|
disabled={!canPreviousPage}
|
||||||
aria-label="Página anterior"
|
aria-label="Página anterior"
|
||||||
>
|
>
|
||||||
<RiArrowLeftSLine className="size-4" />
|
<RiArrowLeftSLine className="size-4" />
|
||||||
@@ -1119,8 +1191,12 @@ export function TransactionsTable({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={() => table.nextPage()}
|
onClick={() =>
|
||||||
disabled={!table.getCanNextPage()}
|
isServerPaginated
|
||||||
|
? navigateToPage(currentPage + 1)
|
||||||
|
: table.nextPage()
|
||||||
|
}
|
||||||
|
disabled={!canNextPage}
|
||||||
aria-label="Próxima página"
|
aria-label="Próxima página"
|
||||||
>
|
>
|
||||||
<RiArrowRightSLine className="size-4" />
|
<RiArrowRightSLine className="size-4" />
|
||||||
@@ -1129,9 +1205,11 @@ export function TransactionsTable({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
table.setPageIndex(table.getPageCount() - 1)
|
isServerPaginated
|
||||||
|
? navigateToPage(totalPages)
|
||||||
|
: table.setPageIndex(table.getPageCount() - 1)
|
||||||
}
|
}
|
||||||
disabled={!table.getCanNextPage()}
|
disabled={!canNextPage}
|
||||||
aria-label="Última página"
|
aria-label="Última página"
|
||||||
>
|
>
|
||||||
<RiArrowRightDoubleLine className="size-4" />
|
<RiArrowRightDoubleLine className="size-4" />
|
||||||
|
|||||||
@@ -6,11 +6,10 @@ import {
|
|||||||
RiFilePdfLine,
|
RiFilePdfLine,
|
||||||
RiFileTextLine,
|
RiFileTextLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import jsPDF from "jspdf";
|
|
||||||
import autoTable from "jspdf-autotable";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import * as XLSX from "xlsx";
|
import { exportTransactionsDataAction } from "@/features/transactions/actions";
|
||||||
|
import type { TransactionsExportContext } from "@/features/transactions/export-types";
|
||||||
import { formatCurrency } from "@/features/transactions/formatting-helpers";
|
import { formatCurrency } from "@/features/transactions/formatting-helpers";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -30,11 +29,24 @@ import type { TransactionItem } from "./types";
|
|||||||
interface LancamentosExportProps {
|
interface LancamentosExportProps {
|
||||||
lancamentos: TransactionItem[];
|
lancamentos: TransactionItem[];
|
||||||
period: string;
|
period: string;
|
||||||
|
exportContext?: TransactionsExportContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadXlsx = () => import("xlsx");
|
||||||
|
|
||||||
|
const loadPdfDeps = async () => {
|
||||||
|
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
|
||||||
|
import("jspdf"),
|
||||||
|
import("jspdf-autotable"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { jsPDF, autoTable };
|
||||||
|
};
|
||||||
|
|
||||||
export function TransactionsExport({
|
export function TransactionsExport({
|
||||||
lancamentos,
|
lancamentos,
|
||||||
period,
|
period,
|
||||||
|
exportContext,
|
||||||
}: LancamentosExportProps) {
|
}: LancamentosExportProps) {
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
|
||||||
@@ -69,9 +81,24 @@ export function TransactionsExport({
|
|||||||
return `${transaction.name} (${transaction.currentInstallment ?? 1}/${transaction.installmentCount})`;
|
return `${transaction.name} (${transaction.currentInstallment ?? 1}/${transaction.installmentCount})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportToCSV = () => {
|
const loadTransactions = async () => {
|
||||||
|
if (!exportContext) {
|
||||||
|
return lancamentos;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await exportTransactionsDataAction(exportContext);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data?.transactions ?? [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportToCSV = async () => {
|
||||||
try {
|
try {
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
|
const transactions = await loadTransactions();
|
||||||
|
|
||||||
const headers = [
|
const headers = [
|
||||||
"Data",
|
"Data",
|
||||||
@@ -86,7 +113,7 @@ export function TransactionsExport({
|
|||||||
];
|
];
|
||||||
const rows: string[][] = [];
|
const rows: string[][] = [];
|
||||||
|
|
||||||
lancamentos.forEach((lancamento) => {
|
transactions.forEach((lancamento) => {
|
||||||
const row = [
|
const row = [
|
||||||
formatDate(lancamento.purchaseDate),
|
formatDate(lancamento.purchaseDate),
|
||||||
getNameWithInstallment(lancamento),
|
getNameWithInstallment(lancamento),
|
||||||
@@ -127,9 +154,11 @@ export function TransactionsExport({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportToExcel = () => {
|
const exportToExcel = async () => {
|
||||||
try {
|
try {
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
|
const transactions = await loadTransactions();
|
||||||
|
const XLSX = await loadXlsx();
|
||||||
|
|
||||||
const headers = [
|
const headers = [
|
||||||
"Data",
|
"Data",
|
||||||
@@ -144,7 +173,7 @@ export function TransactionsExport({
|
|||||||
];
|
];
|
||||||
const rows: (string | number)[][] = [];
|
const rows: (string | number)[][] = [];
|
||||||
|
|
||||||
lancamentos.forEach((lancamento) => {
|
transactions.forEach((lancamento) => {
|
||||||
const row = [
|
const row = [
|
||||||
formatDate(lancamento.purchaseDate),
|
formatDate(lancamento.purchaseDate),
|
||||||
getNameWithInstallment(lancamento),
|
getNameWithInstallment(lancamento),
|
||||||
@@ -189,6 +218,8 @@ export function TransactionsExport({
|
|||||||
const exportToPDF = async () => {
|
const exportToPDF = async () => {
|
||||||
try {
|
try {
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
|
const transactions = await loadTransactions();
|
||||||
|
const { jsPDF, autoTable } = await loadPdfDeps();
|
||||||
|
|
||||||
const doc = new jsPDF({ orientation: "landscape" });
|
const doc = new jsPDF({ orientation: "landscape" });
|
||||||
const primaryColor = getPrimaryPdfColor();
|
const primaryColor = getPrimaryPdfColor();
|
||||||
@@ -245,7 +276,7 @@ export function TransactionsExport({
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
const body = lancamentos.map((lancamento) => [
|
const body = transactions.map((lancamento) => [
|
||||||
formatDate(lancamento.purchaseDate),
|
formatDate(lancamento.purchaseDate),
|
||||||
getNameWithInstallment(lancamento),
|
getNameWithInstallment(lancamento),
|
||||||
lancamento.transactionType,
|
lancamento.transactionType,
|
||||||
@@ -285,7 +316,7 @@ export function TransactionsExport({
|
|||||||
},
|
},
|
||||||
didParseCell: (cellData) => {
|
didParseCell: (cellData) => {
|
||||||
if (cellData.section === "body" && cellData.column.index === 5) {
|
if (cellData.section === "body" && cellData.column.index === 5) {
|
||||||
const lancamento = lancamentos[cellData.row.index];
|
const lancamento = transactions[cellData.row.index];
|
||||||
if (lancamento) {
|
if (lancamento) {
|
||||||
if (lancamento.transactionType === "Despesa") {
|
if (lancamento.transactionType === "Despesa") {
|
||||||
cellData.cell.styles.textColor = [220, 38, 38];
|
cellData.cell.styles.textColor = [220, 38, 38];
|
||||||
|
|||||||
26
src/features/transactions/export-types.ts
Normal file
26
src/features/transactions/export-types.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export type TransactionExportFilters = {
|
||||||
|
transactionFilter: string | null;
|
||||||
|
conditionFilter: string | null;
|
||||||
|
paymentFilter: string | null;
|
||||||
|
payerFilter: string | null;
|
||||||
|
categoryFilter: string | null;
|
||||||
|
accountCardFilter: string | null;
|
||||||
|
searchFilter: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TransactionsExportContext = {
|
||||||
|
source: "transactions" | "account-statement";
|
||||||
|
period: string;
|
||||||
|
filters: TransactionExportFilters;
|
||||||
|
accountId?: string | null;
|
||||||
|
cardId?: string | null;
|
||||||
|
payerId?: string | null;
|
||||||
|
settledOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TransactionsPaginationState = {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
@@ -29,6 +29,9 @@ export type ResolvedSearchParams =
|
|||||||
| Record<string, string | string[] | undefined>
|
| Record<string, string | string[] | undefined>
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
|
export const TRANSACTIONS_DEFAULT_PAGE_SIZE = 30;
|
||||||
|
export const TRANSACTIONS_PAGE_SIZE_OPTIONS = [5, 10, 20, 30, 40, 50, 100];
|
||||||
|
|
||||||
export type TransactionSearchFilters = {
|
export type TransactionSearchFilters = {
|
||||||
transactionFilter: string | null;
|
transactionFilter: string | null;
|
||||||
conditionFilter: string | null;
|
conditionFilter: string | null;
|
||||||
@@ -126,6 +129,26 @@ export const extractTransactionSearchFilters = (
|
|||||||
searchFilter: getSingleParam(params, "q"),
|
searchFilter: getSingleParam(params, "q"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const resolveTransactionPagination = (
|
||||||
|
params: ResolvedSearchParams,
|
||||||
|
): {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
} => {
|
||||||
|
const pageParam = Number.parseInt(getSingleParam(params, "page") ?? "", 10);
|
||||||
|
const pageSizeParam = Number.parseInt(
|
||||||
|
getSingleParam(params, "pageSize") ?? "",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
const page = Number.isFinite(pageParam) && pageParam > 0 ? pageParam : 1;
|
||||||
|
const pageSize = TRANSACTIONS_PAGE_SIZE_OPTIONS.includes(pageSizeParam)
|
||||||
|
? pageSizeParam
|
||||||
|
: TRANSACTIONS_DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
|
return { page, pageSize };
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeLabel = (value: string | null | undefined) =>
|
const normalizeLabel = (value: string | null | undefined) =>
|
||||||
value?.trim().length ? value.trim() : "Sem descrição";
|
value?.trim().length ? value.trim() : "Sem descrição";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import { and, desc, eq, gte, isNull, ne, or, type SQL } from "drizzle-orm";
|
import {
|
||||||
|
and,
|
||||||
|
count,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
gte,
|
||||||
|
isNull,
|
||||||
|
ne,
|
||||||
|
or,
|
||||||
|
type SQL,
|
||||||
|
sql,
|
||||||
|
} from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
cards,
|
cards,
|
||||||
categories,
|
categories,
|
||||||
@@ -9,6 +20,109 @@ import {
|
|||||||
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
|
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
|
|
||||||
|
type BaseTransactionQueryInput = {
|
||||||
|
filters: SQL[];
|
||||||
|
extraFilters?: SQL[];
|
||||||
|
excludeInitialBalanceFromIncome?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TransactionQueryInput = BaseTransactionQueryInput & {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaginatedTransactionsResult = {
|
||||||
|
rows: Awaited<ReturnType<typeof fetchTransactions>>;
|
||||||
|
totalItems: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_EXCLUDE_INITIAL_BALANCE = true;
|
||||||
|
|
||||||
|
const buildInitialBalanceVisibilityFilter = () =>
|
||||||
|
or(
|
||||||
|
ne(transactions.note, INITIAL_BALANCE_NOTE),
|
||||||
|
isNull(financialAccounts.excludeInitialBalanceFromIncome),
|
||||||
|
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildTransactionsWhere = ({
|
||||||
|
filters,
|
||||||
|
extraFilters = [],
|
||||||
|
excludeInitialBalanceFromIncome = DEFAULT_EXCLUDE_INITIAL_BALANCE,
|
||||||
|
}: BaseTransactionQueryInput) => {
|
||||||
|
const whereFilters = [...filters, ...extraFilters];
|
||||||
|
|
||||||
|
if (excludeInitialBalanceFromIncome) {
|
||||||
|
const initialBalanceFilter = buildInitialBalanceVisibilityFilter();
|
||||||
|
|
||||||
|
if (initialBalanceFilter) {
|
||||||
|
whereFilters.push(initialBalanceFilter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return and(...whereFilters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapTransactionRows = (
|
||||||
|
transactionRows: {
|
||||||
|
transaction: typeof transactions.$inferSelect;
|
||||||
|
payer: typeof payers.$inferSelect | null;
|
||||||
|
financialAccount: typeof financialAccounts.$inferSelect | null;
|
||||||
|
card: typeof cards.$inferSelect | null;
|
||||||
|
category: typeof categories.$inferSelect | null;
|
||||||
|
}[],
|
||||||
|
) =>
|
||||||
|
transactionRows.map((row) => ({
|
||||||
|
...row.transaction,
|
||||||
|
payer: row.payer,
|
||||||
|
financialAccount: row.financialAccount,
|
||||||
|
card: row.card,
|
||||||
|
category: row.category,
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function selectTransactionsWithRelations({
|
||||||
|
filters,
|
||||||
|
extraFilters = [],
|
||||||
|
excludeInitialBalanceFromIncome = DEFAULT_EXCLUDE_INITIAL_BALANCE,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
}: TransactionQueryInput) {
|
||||||
|
const baseQuery = db
|
||||||
|
.select({
|
||||||
|
transaction: transactions,
|
||||||
|
payer: payers,
|
||||||
|
financialAccount: financialAccounts,
|
||||||
|
card: cards,
|
||||||
|
category: categories,
|
||||||
|
})
|
||||||
|
.from(transactions)
|
||||||
|
.leftJoin(payers, eq(transactions.payerId, payers.id))
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
|
.leftJoin(cards, eq(transactions.cardId, cards.id))
|
||||||
|
.leftJoin(categories, eq(transactions.categoryId, categories.id))
|
||||||
|
.where(
|
||||||
|
buildTransactionsWhere({
|
||||||
|
filters,
|
||||||
|
extraFilters,
|
||||||
|
excludeInitialBalanceFromIncome,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.orderBy(desc(transactions.purchaseDate), desc(transactions.createdAt));
|
||||||
|
|
||||||
|
const transactionRows =
|
||||||
|
typeof limit === "number"
|
||||||
|
? await baseQuery.limit(limit).offset(offset ?? 0)
|
||||||
|
: await baseQuery;
|
||||||
|
|
||||||
|
return mapTransactionRows(transactionRows);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchTransactionFilterSources(userId: string) {
|
export async function fetchTransactionFilterSources(userId: string) {
|
||||||
const [payerRows, accountRows, cardRows, categoryRows] = await Promise.all([
|
const [payerRows, accountRows, cardRows, categoryRows] = await Promise.all([
|
||||||
db.query.payers.findMany({
|
db.query.payers.findMany({
|
||||||
@@ -31,44 +145,98 @@ export async function fetchTransactionFilterSources(userId: string) {
|
|||||||
return { payerRows, accountRows, cardRows, categoryRows };
|
return { payerRows, accountRows, cardRows, categoryRows };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchTransactionsWithRelations(
|
||||||
|
input: BaseTransactionQueryInput,
|
||||||
|
) {
|
||||||
|
return selectTransactionsWithRelations(input);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchTransactions(filters: SQL[]) {
|
export async function fetchTransactions(filters: SQL[]) {
|
||||||
const transactionRows = await db
|
return fetchTransactionsWithRelations({ filters });
|
||||||
.select({
|
}
|
||||||
transaction: transactions,
|
|
||||||
payer: payers,
|
export async function fetchTransactionsPage(
|
||||||
financialAccount: financialAccounts,
|
filters: SQL[],
|
||||||
card: cards,
|
{
|
||||||
category: categories,
|
page,
|
||||||
})
|
pageSize,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
},
|
||||||
|
): Promise<PaginatedTransactionsResult> {
|
||||||
|
const [countRow] = await db
|
||||||
|
.select({ total: count() })
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.leftJoin(payers, eq(transactions.payerId, payers.id))
|
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
financialAccounts,
|
financialAccounts,
|
||||||
eq(transactions.accountId, financialAccounts.id),
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
)
|
)
|
||||||
.leftJoin(cards, eq(transactions.cardId, cards.id))
|
.leftJoin(cards, eq(transactions.cardId, cards.id))
|
||||||
.leftJoin(categories, eq(transactions.categoryId, categories.id))
|
.where(buildTransactionsWhere({ filters }));
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
...filters,
|
|
||||||
// Excluir saldos iniciais de financialAccounts que têm excludeInitialBalanceFromIncome = true
|
|
||||||
or(
|
|
||||||
ne(transactions.note, INITIAL_BALANCE_NOTE),
|
|
||||||
isNull(financialAccounts.excludeInitialBalanceFromIncome),
|
|
||||||
eq(financialAccounts.excludeInitialBalanceFromIncome, false),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(desc(transactions.purchaseDate), desc(transactions.createdAt));
|
|
||||||
|
|
||||||
// Transformar resultado para o formato esperado
|
const totalItems = Number(countRow?.total ?? 0);
|
||||||
return transactionRows.map((row) => ({
|
const totalPages = Math.max(Math.ceil(totalItems / pageSize), 1);
|
||||||
...row.transaction,
|
const currentPage = Math.min(page, totalPages);
|
||||||
payer: row.payer,
|
const rows = await selectTransactionsWithRelations({
|
||||||
financialAccount: row.financialAccount,
|
filters,
|
||||||
card: row.card,
|
limit: pageSize,
|
||||||
category: row.category,
|
offset: (currentPage - 1) * pageSize,
|
||||||
}));
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
totalItems,
|
||||||
|
page: currentPage,
|
||||||
|
pageSize,
|
||||||
|
totalPages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTransactionsPageWithRelations({
|
||||||
|
filters,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
extraFilters = [],
|
||||||
|
excludeInitialBalanceFromIncome = DEFAULT_EXCLUDE_INITIAL_BALANCE,
|
||||||
|
}: BaseTransactionQueryInput & {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}): Promise<PaginatedTransactionsResult> {
|
||||||
|
const [countRow] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(transactions)
|
||||||
|
.leftJoin(
|
||||||
|
financialAccounts,
|
||||||
|
eq(transactions.accountId, financialAccounts.id),
|
||||||
|
)
|
||||||
|
.leftJoin(cards, eq(transactions.cardId, cards.id))
|
||||||
|
.where(
|
||||||
|
buildTransactionsWhere({
|
||||||
|
filters,
|
||||||
|
extraFilters,
|
||||||
|
excludeInitialBalanceFromIncome,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalItems = Number(countRow?.total ?? 0);
|
||||||
|
const totalPages = Math.max(Math.ceil(totalItems / pageSize), 1);
|
||||||
|
const currentPage = Math.min(page, totalPages);
|
||||||
|
const rows = await selectTransactionsWithRelations({
|
||||||
|
filters,
|
||||||
|
extraFilters,
|
||||||
|
excludeInitialBalanceFromIncome,
|
||||||
|
limit: pageSize,
|
||||||
|
offset: (currentPage - 1) * pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
totalItems,
|
||||||
|
page: currentPage,
|
||||||
|
pageSize,
|
||||||
|
totalPages,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchRecentEstablishments(
|
export async function fetchRecentEstablishments(
|
||||||
@@ -84,22 +252,15 @@ export async function fetchRecentEstablishments(
|
|||||||
and(
|
and(
|
||||||
eq(transactions.userId, userId),
|
eq(transactions.userId, userId),
|
||||||
gte(transactions.purchaseDate, threeMonthsAgo),
|
gte(transactions.purchaseDate, threeMonthsAgo),
|
||||||
|
sql`TRIM(${transactions.name}) <> ''`,
|
||||||
|
sql`LOWER(${transactions.name}) NOT LIKE 'pagamento fatura%'`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(desc(transactions.purchaseDate));
|
.groupBy(transactions.name)
|
||||||
|
.orderBy(sql`MAX(${transactions.purchaseDate}) DESC`)
|
||||||
|
.limit(100);
|
||||||
|
|
||||||
const uniqueNames = Array.from(
|
return results
|
||||||
new Set<string>(
|
.map((row) => row.name)
|
||||||
results
|
.filter((name): name is string => name !== null);
|
||||||
.map((row) => row.name)
|
|
||||||
.filter(
|
|
||||||
(name: string | null): name is string =>
|
|
||||||
name != null &&
|
|
||||||
name.trim().length > 0 &&
|
|
||||||
!name.toLowerCase().startsWith("pagamento fatura"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return uniqueNames.slice(0, 100);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,9 @@ export default function MonthNavigation() {
|
|||||||
const returnTarget = buildHref(defaultPeriod);
|
const returnTarget = buildHref(defaultPeriod);
|
||||||
const isDifferentFromCurrent = period !== defaultPeriod;
|
const isDifferentFromCurrent = period !== defaultPeriod;
|
||||||
|
|
||||||
// Prefetch otimizado: apenas meses adjacentes (M-1, M+1) e mês atual
|
|
||||||
// Isso melhora a performance da navegação sem sobrecarregar o cliente
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Prefetch do mês anterior e próximo para navegação instantânea
|
|
||||||
router.prefetch(prevTarget);
|
router.prefetch(prevTarget);
|
||||||
router.prefetch(nextTarget);
|
router.prefetch(nextTarget);
|
||||||
|
|
||||||
// Prefetch do mês atual se não estivermos nele
|
|
||||||
if (isDifferentFromCurrent) {
|
if (isDifferentFromCurrent) {
|
||||||
router.prefetch(returnTarget);
|
router.prefetch(returnTarget);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export function useMonthPeriod() {
|
|||||||
const buildHref = (targetPeriod: string) => {
|
const buildHref = (targetPeriod: string) => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.set(PERIOD_PARAM, formatPeriodForUrl(targetPeriod));
|
params.set(PERIOD_PARAM, formatPeriodForUrl(targetPeriod));
|
||||||
|
params.delete("page");
|
||||||
|
|
||||||
return `${pathname}?${params.toString()}`;
|
return `${pathname}?${params.toString()}`;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user