mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-07-10 11:56:00 +00:00
refactor: faxina arquitetural — código morto, identificadores em inglês e estrutura padronizada
Refatoração estrutural sem mudanças funcionais. Saldo líquido: −428 linhas. Removido: - 14 funções/constantes mortas verificadas via grep no repo todo: validateCategoriaOwnership, getInstallmentAnticipationsAction, getAnticipationDetailsAction, formatDecimalForDb, currencyFormatterNoCents, optionalDecimalSchema, formatMonthLabel, getGoalProgressStatusColorClass, MONTH_PERIOD_PARAM, calculateRemainingInstallments, e 5 funções fetch* não usadas em inbox/queries.ts. - 1 tipo morto (ImportRow) + 2 órfãos consequentes (InstallmentAnticipationWithRelations, GoalProgressStatus convertido em interno). - ~30 export keywords desnecessários (símbolos usados apenas no próprio arquivo). - Re-exports mortos em barrels: EstablishmentLogoPicker, CategoryReportSkeleton, WidgetSkeleton, toNameKey. - Arquivo features/reports/types.ts (barrel inteiro era órfão). Padronizado (PT-BR→EN em identificadores expostos): - 4 constantes globais (LANCAMENTOS_* → TRANSACTIONS_*). - 12 tipos/interfaces (Lancamento*/Pagador*/Estabelecimento* → equivalentes EN). - 13 funções/components exportados (fetchPagador*, EstabelecimentoInput, PagadorInfoCard, etc.). - 5 props cross-file (preLancamentosCount → inboxPendingCount, pagadorAvatarUrl → payerAvatarUrl, etc.). - Mantidas em PT-BR conforme exceção do CLAUDE.md: variáveis locais (pagador, categoria, lancamento), accessor key pagadorName (persistida em preferências), strings de UI. Reorganizado: - transactions/: 14 helpers soltos na raiz movidos para lib/; barrel actions.ts reduzido de 76 linhas de wrappers para 14 linhas de re-exports puros; anticipation-actions.ts movido para actions/anticipation.ts. - dashboard/: 8 helpers soltos consolidados em dashboard/lib/. - reports/: 5 query files na raiz consolidados em reports/lib/. - payers/: detail-actions.ts (21KB) e detail-queries.ts movidos para payers/lib/. - shared/components/: 9 dos 16 componentes soltos agrupados em brand/, widgets/, feedback/. - shared/lib/fetch-json.ts movido para shared/utils/fetch-json.ts. Validação: pnpm exec tsc --noEmit (0 erros), biome check (0 issues), knip (sem unused). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
99
src/features/transactions/lib/anticipation-queries.ts
Normal file
99
src/features/transactions/lib/anticipation-queries.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import {
|
||||
categories,
|
||||
installmentAnticipations,
|
||||
payers,
|
||||
transactions,
|
||||
} from "@/db/schema";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { uuidSchema } from "@/shared/lib/schemas/common";
|
||||
|
||||
type InstallmentAnticipationListItem = {
|
||||
id: string;
|
||||
anticipationPeriod: string;
|
||||
anticipationDate: string;
|
||||
installmentCount: number;
|
||||
totalAmount: string;
|
||||
discount: string;
|
||||
transactionId: string;
|
||||
note: string | null;
|
||||
transaction: {
|
||||
isSettled: boolean | null;
|
||||
} | null;
|
||||
payer: {
|
||||
name: string;
|
||||
} | null;
|
||||
category: {
|
||||
name: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export async function fetchInstallmentAnticipations(
|
||||
userId: string,
|
||||
seriesId: string,
|
||||
): Promise<InstallmentAnticipationListItem[]> {
|
||||
const validatedSeriesId = uuidSchema("Série").parse(seriesId);
|
||||
|
||||
const anticipations = await db
|
||||
.select({
|
||||
id: installmentAnticipations.id,
|
||||
anticipationPeriod: installmentAnticipations.anticipationPeriod,
|
||||
anticipationDate: installmentAnticipations.anticipationDate,
|
||||
installmentCount: installmentAnticipations.installmentCount,
|
||||
totalAmount: installmentAnticipations.totalAmount,
|
||||
discount: installmentAnticipations.discount,
|
||||
transactionId: installmentAnticipations.transactionId,
|
||||
note: installmentAnticipations.note,
|
||||
transactionRecordId: transactions.id,
|
||||
transactionIsSettled: transactions.isSettled,
|
||||
payerName: payers.name,
|
||||
categoryName: categories.name,
|
||||
})
|
||||
.from(installmentAnticipations)
|
||||
.leftJoin(
|
||||
transactions,
|
||||
and(
|
||||
eq(installmentAnticipations.transactionId, transactions.id),
|
||||
eq(transactions.userId, userId),
|
||||
),
|
||||
)
|
||||
.leftJoin(
|
||||
payers,
|
||||
and(
|
||||
eq(installmentAnticipations.payerId, payers.id),
|
||||
eq(payers.userId, userId),
|
||||
),
|
||||
)
|
||||
.leftJoin(
|
||||
categories,
|
||||
and(
|
||||
eq(installmentAnticipations.categoryId, categories.id),
|
||||
eq(categories.userId, userId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(installmentAnticipations.seriesId, validatedSeriesId),
|
||||
eq(installmentAnticipations.userId, userId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(installmentAnticipations.createdAt));
|
||||
|
||||
return anticipations.map((anticipation) => ({
|
||||
id: anticipation.id,
|
||||
anticipationPeriod: anticipation.anticipationPeriod,
|
||||
anticipationDate: anticipation.anticipationDate.toISOString(),
|
||||
installmentCount: anticipation.installmentCount,
|
||||
totalAmount: anticipation.totalAmount,
|
||||
discount: anticipation.discount,
|
||||
transactionId: anticipation.transactionId,
|
||||
note: anticipation.note,
|
||||
transaction: anticipation.transactionRecordId
|
||||
? { isSettled: anticipation.transactionIsSettled }
|
||||
: null,
|
||||
payer: anticipation.payerName ? { name: anticipation.payerName } : null,
|
||||
category: anticipation.categoryName
|
||||
? { name: anticipation.categoryName }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
107
src/features/transactions/lib/attachment-copy.ts
Normal file
107
src/features/transactions/lib/attachment-copy.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { CopyObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { attachments, transactionAttachments, transactions } from "@/db/schema";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getPayerAccess } from "@/shared/lib/payers/access";
|
||||
import { deleteS3Object } from "@/shared/lib/storage/presign";
|
||||
import { S3_BUCKET, s3 } from "@/shared/lib/storage/s3-client";
|
||||
|
||||
const SAFE_EXTENSION = /^[a-z0-9]{1,10}$/i;
|
||||
|
||||
function sanitizeExtension(fileKey: string): string {
|
||||
const ext = fileKey.split(".").pop() ?? "";
|
||||
return SAFE_EXTENSION.test(ext) ? ext.toLowerCase() : "bin";
|
||||
}
|
||||
|
||||
export async function copyAttachmentsForImport({
|
||||
sourceTransactionId,
|
||||
targetTransactionIds,
|
||||
targetUserId,
|
||||
}: {
|
||||
sourceTransactionId: string;
|
||||
targetTransactionIds: string[];
|
||||
targetUserId: string;
|
||||
}): Promise<void> {
|
||||
if (targetTransactionIds.length === 0) return;
|
||||
|
||||
const [source] = await db
|
||||
.select({
|
||||
id: transactions.id,
|
||||
userId: transactions.userId,
|
||||
payerId: transactions.payerId,
|
||||
})
|
||||
.from(transactions)
|
||||
.where(eq(transactions.id, sourceTransactionId));
|
||||
|
||||
if (!source) return;
|
||||
|
||||
if (source.userId !== targetUserId) {
|
||||
if (!source.payerId) return;
|
||||
const access = await getPayerAccess(targetUserId, source.payerId);
|
||||
if (!access) return;
|
||||
}
|
||||
|
||||
const sourceAttachments = await db
|
||||
.select({
|
||||
fileKey: attachments.fileKey,
|
||||
fileName: attachments.fileName,
|
||||
fileSize: attachments.fileSize,
|
||||
mimeType: attachments.mimeType,
|
||||
})
|
||||
.from(transactionAttachments)
|
||||
.innerJoin(
|
||||
attachments,
|
||||
eq(transactionAttachments.attachmentId, attachments.id),
|
||||
)
|
||||
.where(eq(transactionAttachments.transactionId, sourceTransactionId));
|
||||
|
||||
if (sourceAttachments.length === 0) return;
|
||||
|
||||
for (const src of sourceAttachments) {
|
||||
const newFileKey = `${targetUserId}/${randomUUID()}.${sanitizeExtension(src.fileKey)}`;
|
||||
|
||||
try {
|
||||
await s3.send(
|
||||
new CopyObjectCommand({
|
||||
Bucket: S3_BUCKET,
|
||||
CopySource: `${S3_BUCKET}/${src.fileKey}`,
|
||||
Key: newFileKey,
|
||||
ContentType: src.mimeType,
|
||||
MetadataDirective: "COPY",
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Falha ao copiar anexo no S3:", error);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const [newAttachment] = await db
|
||||
.insert(attachments)
|
||||
.values({
|
||||
userId: targetUserId,
|
||||
fileKey: newFileKey,
|
||||
fileName: src.fileName,
|
||||
fileSize: src.fileSize,
|
||||
mimeType: src.mimeType,
|
||||
})
|
||||
.returning({ id: attachments.id });
|
||||
|
||||
if (!newAttachment) {
|
||||
await deleteS3Object(newFileKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
await db.insert(transactionAttachments).values(
|
||||
targetTransactionIds.map((tid) => ({
|
||||
transactionId: tid,
|
||||
attachmentId: newAttachment.id,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Falha ao registrar anexo copiado:", error);
|
||||
await deleteS3Object(newFileKey).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
65
src/features/transactions/lib/attachment-queries.ts
Normal file
65
src/features/transactions/lib/attachment-queries.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { attachments, transactionAttachments, transactions } from "@/db/schema";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getPayerAccess } from "@/shared/lib/payers/access";
|
||||
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
|
||||
|
||||
export type TransactionAttachmentListItem = {
|
||||
attachmentId: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
createdAt: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export async function fetchTransactionAttachments(
|
||||
userId: string,
|
||||
transactionId: string,
|
||||
): Promise<TransactionAttachmentListItem[]> {
|
||||
const [transaction] = await db
|
||||
.select({
|
||||
id: transactions.id,
|
||||
userId: transactions.userId,
|
||||
payerId: transactions.payerId,
|
||||
})
|
||||
.from(transactions)
|
||||
.where(eq(transactions.id, transactionId));
|
||||
|
||||
if (!transaction) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (transaction.userId !== userId) {
|
||||
if (!transaction.payerId) return [];
|
||||
const access = await getPayerAccess(userId, transaction.payerId);
|
||||
if (!access) return [];
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
attachmentId: transactionAttachments.attachmentId,
|
||||
fileName: attachments.fileName,
|
||||
fileSize: attachments.fileSize,
|
||||
mimeType: attachments.mimeType,
|
||||
fileKey: attachments.fileKey,
|
||||
createdAt: attachments.createdAt,
|
||||
})
|
||||
.from(transactionAttachments)
|
||||
.innerJoin(
|
||||
attachments,
|
||||
eq(transactionAttachments.attachmentId, attachments.id),
|
||||
)
|
||||
.where(eq(transactionAttachments.transactionId, transactionId));
|
||||
|
||||
return Promise.all(
|
||||
rows.map(async (row) => ({
|
||||
attachmentId: row.attachmentId,
|
||||
fileName: row.fileName,
|
||||
fileSize: row.fileSize,
|
||||
mimeType: row.mimeType,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
url: await createPresignedGetUrl(row.fileKey),
|
||||
})),
|
||||
);
|
||||
}
|
||||
13
src/features/transactions/lib/attachments-config.ts
Normal file
13
src/features/transactions/lib/attachments-config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const ALLOWED_MIME_TYPES = [
|
||||
"application/pdf",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_MAX_FILE_SIZE_MB = 50;
|
||||
|
||||
export const MAX_FILE_SIZE = DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024; // 50MB (fallback)
|
||||
|
||||
export const ATTACHMENT_SIZE_OPTIONS = [5, 10, 25, 50, 100] as const;
|
||||
export type AttachmentSizeOption = (typeof ATTACHMENT_SIZE_OPTIONS)[number];
|
||||
73
src/features/transactions/lib/category-helpers.ts
Normal file
73
src/features/transactions/lib/category-helpers.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { SelectOption } from "@/features/transactions/components/types";
|
||||
import { capitalize } from "@/shared/utils/string";
|
||||
|
||||
/**
|
||||
* Group label for category options
|
||||
*/
|
||||
type CategoryGroup = {
|
||||
label: string;
|
||||
options: SelectOption[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes category group labels (Despesa -> Despesas, Receita -> Receitas)
|
||||
*/
|
||||
function normalizeCategoryGroupLabel(value: string): string {
|
||||
const lower = value.toLowerCase();
|
||||
if (lower === "despesa") {
|
||||
return "Despesas";
|
||||
}
|
||||
if (lower === "receita") {
|
||||
return "Receitas";
|
||||
}
|
||||
return capitalize(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups and sorts category options by their group property
|
||||
* @param categoryOptions - Array of category select options
|
||||
* @returns Array of grouped and sorted category options
|
||||
*/
|
||||
export function groupAndSortCategories(
|
||||
categoryOptions: SelectOption[],
|
||||
): CategoryGroup[] {
|
||||
// Group category options by their group property
|
||||
const groups = categoryOptions.reduce<Record<string, SelectOption[]>>(
|
||||
(acc, option) => {
|
||||
const key = option.group ?? "Outros";
|
||||
if (!acc[key]) {
|
||||
acc[key] = [];
|
||||
}
|
||||
acc[key].push(option);
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
// Define preferred order (Despesa first, then Receita, then others)
|
||||
const preferredOrder = ["Despesa", "Receita"];
|
||||
const orderedKeys = [
|
||||
...preferredOrder.filter((key) => groups[key]?.length),
|
||||
...Object.keys(groups).filter((key) => !preferredOrder.includes(key)),
|
||||
];
|
||||
|
||||
// Map to final structure with normalized labels and sorted options
|
||||
return orderedKeys.map((key) => ({
|
||||
label: normalizeCategoryGroupLabel(key),
|
||||
options: groups[key]
|
||||
.slice()
|
||||
.sort((a, b) =>
|
||||
a.label.localeCompare(b.label, "pt-BR", { sensitivity: "base" }),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters secondary payer options to exclude the primary payer
|
||||
*/
|
||||
export function filterSecondaryPayerOptions(
|
||||
allOptions: SelectOption[],
|
||||
primaryPayerId?: string,
|
||||
): SelectOption[] {
|
||||
return allOptions.filter((option) => option.value !== primaryPayerId);
|
||||
}
|
||||
31
src/features/transactions/lib/column-order.ts
Normal file
31
src/features/transactions/lib/column-order.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Ids das colunas reordenáveis da tabela de lançamentos (extrato).
|
||||
* select, purchaseDate e actions são fixos (início, oculto, fim).
|
||||
*/
|
||||
const TRANSACTIONS_REORDERABLE_COLUMN_IDS = [
|
||||
"name",
|
||||
"transactionType",
|
||||
"amount",
|
||||
"condition",
|
||||
"paymentMethod",
|
||||
"categoriaName",
|
||||
"pagadorName",
|
||||
"note",
|
||||
"contaCartao",
|
||||
] as const;
|
||||
|
||||
export const TRANSACTIONS_COLUMN_LABELS: Record<string, string> = {
|
||||
name: "Estabelecimento",
|
||||
transactionType: "Transação",
|
||||
amount: "Valor",
|
||||
condition: "Condição",
|
||||
paymentMethod: "Forma de Pagamento",
|
||||
categoriaName: "Categoria",
|
||||
pagadorName: "Pessoa",
|
||||
note: "Anotação",
|
||||
contaCartao: "Conta/Cartão",
|
||||
};
|
||||
|
||||
export const DEFAULT_TRANSACTIONS_COLUMN_ORDER: string[] = [
|
||||
...TRANSACTIONS_REORDERABLE_COLUMN_IDS,
|
||||
];
|
||||
32
src/features/transactions/lib/constants.ts
Normal file
32
src/features/transactions/lib/constants.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export const TRANSACTION_TYPES = [
|
||||
"Despesa",
|
||||
"Receita",
|
||||
"Transferência",
|
||||
] as const;
|
||||
|
||||
export const TRANSACTION_CONDITIONS = [
|
||||
"À vista",
|
||||
"Parcelado",
|
||||
"Recorrente",
|
||||
] as const;
|
||||
|
||||
export const PAYMENT_METHODS = [
|
||||
"Cartão de crédito",
|
||||
"Cartão de débito",
|
||||
"Pix",
|
||||
"Dinheiro",
|
||||
"Boleto",
|
||||
"Pré-Pago | VR/VA",
|
||||
"Transferência bancária",
|
||||
] as const;
|
||||
|
||||
export const CREDIT_CARD_PAYMENT_METHOD = "Cartão de crédito" as const;
|
||||
|
||||
export const SETTLEABLE_PAYMENT_METHODS = PAYMENT_METHODS.filter(
|
||||
(method) => method !== CREDIT_CARD_PAYMENT_METHOD,
|
||||
);
|
||||
|
||||
export const SETTLED_FILTER_VALUES = {
|
||||
PAID: "pago",
|
||||
UNPAID: "nao-pago",
|
||||
} as const;
|
||||
29
src/features/transactions/lib/export-types.ts
Normal file
29
src/features/transactions/lib/export-types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
type TransactionExportFilters = {
|
||||
transactionFilter: string | null;
|
||||
conditionFilter: string | null;
|
||||
paymentFilter: string | null;
|
||||
payerFilter: string | null;
|
||||
categoryFilter: string | null;
|
||||
accountCardFilter: string | null;
|
||||
searchFilter: string | null;
|
||||
settledFilter: string | null;
|
||||
attachmentFilter: string | null;
|
||||
dividedFilter: 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;
|
||||
};
|
||||
358
src/features/transactions/lib/form-helpers.ts
Normal file
358
src/features/transactions/lib/form-helpers.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import type { TransactionItem } from "@/features/transactions/components/types";
|
||||
import { getTodayDateString } from "@/shared/utils/date";
|
||||
import { derivePeriodFromDate, getNextPeriod } from "@/shared/utils/period";
|
||||
import {
|
||||
PAYMENT_METHODS,
|
||||
TRANSACTION_CONDITIONS,
|
||||
TRANSACTION_TYPES,
|
||||
} from "./constants";
|
||||
|
||||
/**
|
||||
* Derives the fatura period for a credit card purchase based on closing day
|
||||
* and due day. The period represents the month the fatura is due (vencimento).
|
||||
*
|
||||
* Steps:
|
||||
* 1. If purchase day >= closing day → the purchase missed this month's closing,
|
||||
* so it enters the NEXT month's billing cycle (+1 month from purchase).
|
||||
* 2. Then, if dueDay < closingDay, the due date falls in the month AFTER the
|
||||
* closing month (e.g., closes 22nd, due 1st → closes Mar/22, due Apr/1),
|
||||
* so we add another +1 month.
|
||||
*
|
||||
* @example
|
||||
* // Card closes day 22, due day 1 (dueDay < closingDay → +1 extra)
|
||||
* deriveCreditCardPeriod("2026-02-25", "22", "1") // "2026-04" (missed Feb closing → Mar cycle → due Apr)
|
||||
* deriveCreditCardPeriod("2026-02-15", "22", "1") // "2026-03" (in Feb cycle → due Mar)
|
||||
*
|
||||
* // Card closes day 5, due day 15 (dueDay >= closingDay → no extra)
|
||||
* deriveCreditCardPeriod("2026-02-10", "5", "15") // "2026-03" (missed Feb closing → Mar cycle → due Mar)
|
||||
* deriveCreditCardPeriod("2026-02-05", "5", "15") // "2026-03" (closing day itself already goes to next cycle)
|
||||
* deriveCreditCardPeriod("2026-02-03", "5", "15") // "2026-02" (in Feb cycle → due Feb)
|
||||
*/
|
||||
export function deriveCreditCardPeriod(
|
||||
purchaseDate: string,
|
||||
closingDay: string | null | undefined,
|
||||
dueDay?: string | null | undefined,
|
||||
): string {
|
||||
const basePeriod = derivePeriodFromDate(purchaseDate);
|
||||
if (!closingDay) return basePeriod;
|
||||
|
||||
const closingDayNum = Number.parseInt(closingDay, 10);
|
||||
if (Number.isNaN(closingDayNum)) return basePeriod;
|
||||
|
||||
const dayPart = purchaseDate.split("-")[2];
|
||||
const purchaseDayNum = Number.parseInt(dayPart ?? "1", 10);
|
||||
|
||||
// Start with the purchase month as the billing cycle
|
||||
let period = basePeriod;
|
||||
|
||||
// If purchase is on/after closing day, it enters the next billing cycle
|
||||
if (purchaseDayNum >= closingDayNum) {
|
||||
period = getNextPeriod(period);
|
||||
}
|
||||
|
||||
// If due day < closing day, the due date falls in the month after closing
|
||||
// (e.g., closes 22nd, due 1st → closing in March means due in April)
|
||||
const dueDayNum = Number.parseInt(dueDay ?? "", 10);
|
||||
if (!Number.isNaN(dueDayNum) && dueDayNum < closingDayNum) {
|
||||
period = getNextPeriod(period);
|
||||
}
|
||||
|
||||
return period;
|
||||
}
|
||||
|
||||
/**
|
||||
* Form state type for lancamento dialog
|
||||
*/
|
||||
export type TransactionFormState = {
|
||||
purchaseDate: string;
|
||||
period: string;
|
||||
name: string;
|
||||
transactionType: string;
|
||||
amount: string;
|
||||
condition: string;
|
||||
paymentMethod: string;
|
||||
payerId: string | undefined;
|
||||
secondaryPayerId: string | undefined;
|
||||
isSplit: boolean;
|
||||
primarySplitAmount: string;
|
||||
secondarySplitAmount: string;
|
||||
accountId: string | undefined;
|
||||
cardId: string | undefined;
|
||||
categoryId: string | undefined;
|
||||
installmentCount: string;
|
||||
recurrenceCount: string;
|
||||
dueDate: string;
|
||||
boletoPaymentDate: string;
|
||||
note: string;
|
||||
isSettled: boolean | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initial state overrides for lancamento form
|
||||
*/
|
||||
type TransactionFormOverrides = {
|
||||
defaultCardId?: string | null;
|
||||
defaultPaymentMethod?: string | null;
|
||||
defaultPurchaseDate?: string | null;
|
||||
defaultName?: string | null;
|
||||
defaultAmount?: string | null;
|
||||
defaultTransactionType?: "Despesa" | "Receita";
|
||||
isImporting?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds initial form state from lancamento data and defaults
|
||||
*/
|
||||
export function buildTransactionInitialState(
|
||||
transaction?: TransactionItem,
|
||||
defaultPayerId?: string | null,
|
||||
preferredPeriod?: string,
|
||||
overrides?: TransactionFormOverrides,
|
||||
): TransactionFormState {
|
||||
const purchaseDate = transaction?.purchaseDate
|
||||
? transaction.purchaseDate.slice(0, 10)
|
||||
: (overrides?.defaultPurchaseDate ?? getTodayDateString());
|
||||
|
||||
const paymentMethod =
|
||||
transaction?.paymentMethod ??
|
||||
overrides?.defaultPaymentMethod ??
|
||||
PAYMENT_METHODS[0];
|
||||
|
||||
const derivedPeriod = derivePeriodFromDate(purchaseDate);
|
||||
const fallbackPeriod =
|
||||
preferredPeriod && /^\d{4}-\d{2}$/.test(preferredPeriod)
|
||||
? preferredPeriod
|
||||
: derivedPeriod;
|
||||
|
||||
// Quando importando, usar valores padrão do usuário logado ao invés dos valores do lançamento original
|
||||
const isImporting = overrides?.isImporting ?? false;
|
||||
const fallbackPayerId = isImporting
|
||||
? (defaultPayerId ?? null)
|
||||
: (transaction?.payerId ?? defaultPayerId ?? null);
|
||||
|
||||
const boletoPaymentDate =
|
||||
transaction?.boletoPaymentDate ??
|
||||
(paymentMethod === "Boleto" && (transaction?.isSettled ?? false)
|
||||
? getTodayDateString()
|
||||
: "");
|
||||
|
||||
// Calcular o valor correto para importação de parcelados
|
||||
let amountValue = overrides?.defaultAmount ?? "";
|
||||
if (!amountValue && typeof transaction?.amount === "number") {
|
||||
let baseAmount = Math.abs(transaction.amount);
|
||||
|
||||
// Se está importando e é parcelado, usar o valor total (parcela * quantidade)
|
||||
if (
|
||||
isImporting &&
|
||||
transaction.condition === "Parcelado" &&
|
||||
transaction.installmentCount
|
||||
) {
|
||||
baseAmount = baseAmount * transaction.installmentCount;
|
||||
}
|
||||
|
||||
amountValue = (Math.round(baseAmount * 100) / 100).toFixed(2);
|
||||
}
|
||||
|
||||
return {
|
||||
purchaseDate,
|
||||
period:
|
||||
transaction?.period && /^\d{4}-\d{2}$/.test(transaction.period)
|
||||
? transaction.period
|
||||
: fallbackPeriod,
|
||||
name: transaction?.name ?? overrides?.defaultName ?? "",
|
||||
transactionType:
|
||||
transaction?.transactionType ??
|
||||
overrides?.defaultTransactionType ??
|
||||
TRANSACTION_TYPES[0],
|
||||
amount: amountValue,
|
||||
condition: transaction?.condition ?? TRANSACTION_CONDITIONS[0],
|
||||
paymentMethod,
|
||||
payerId: fallbackPayerId ?? undefined,
|
||||
secondaryPayerId: undefined,
|
||||
isSplit: false,
|
||||
|
||||
primarySplitAmount: "",
|
||||
secondarySplitAmount: "",
|
||||
accountId:
|
||||
paymentMethod === "Cartão de crédito"
|
||||
? undefined
|
||||
: isImporting
|
||||
? undefined
|
||||
: (transaction?.accountId ?? undefined),
|
||||
cardId:
|
||||
paymentMethod === "Cartão de crédito"
|
||||
? isImporting
|
||||
? (overrides?.defaultCardId ?? undefined)
|
||||
: (transaction?.cardId ?? overrides?.defaultCardId ?? undefined)
|
||||
: undefined,
|
||||
categoryId: isImporting
|
||||
? undefined
|
||||
: (transaction?.categoryId ?? undefined),
|
||||
installmentCount: transaction?.installmentCount
|
||||
? String(transaction.installmentCount)
|
||||
: "",
|
||||
recurrenceCount: transaction?.recurrenceCount
|
||||
? String(transaction.recurrenceCount)
|
||||
: "",
|
||||
dueDate: transaction?.dueDate ?? "",
|
||||
boletoPaymentDate,
|
||||
note: transaction?.note ?? "",
|
||||
isSettled:
|
||||
paymentMethod === "Cartão de crédito"
|
||||
? null
|
||||
: (transaction?.isSettled ?? true),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies field dependencies when form state changes
|
||||
* This function encapsulates the business logic for field interdependencies
|
||||
*/
|
||||
export function applyFieldDependencies(
|
||||
key: keyof TransactionFormState,
|
||||
value: TransactionFormState[keyof TransactionFormState],
|
||||
currentState: TransactionFormState,
|
||||
cardInfo?: { closingDay: string | null; dueDay: string | null } | null,
|
||||
): Partial<TransactionFormState> {
|
||||
const updates: Partial<TransactionFormState> = {};
|
||||
|
||||
// Auto-derive period from purchaseDate
|
||||
if (key === "purchaseDate" && typeof value === "string" && value) {
|
||||
const method = currentState.paymentMethod;
|
||||
if (method === "Cartão de crédito") {
|
||||
updates.period = deriveCreditCardPeriod(
|
||||
value,
|
||||
cardInfo?.closingDay,
|
||||
cardInfo?.dueDay,
|
||||
);
|
||||
} else if (method !== "Boleto") {
|
||||
updates.period = derivePeriodFromDate(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-derive period from dueDate when payment method is boleto
|
||||
if (key === "dueDate" && typeof value === "string" && value) {
|
||||
if (currentState.paymentMethod === "Boleto") {
|
||||
updates.period = derivePeriodFromDate(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-derive period when cardId changes (credit card selected)
|
||||
if (key === "cardId" && currentState.paymentMethod === "Cartão de crédito") {
|
||||
if (typeof value === "string" && value && currentState.purchaseDate) {
|
||||
updates.period = deriveCreditCardPeriod(
|
||||
currentState.purchaseDate,
|
||||
cardInfo?.closingDay,
|
||||
cardInfo?.dueDay,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// When condition changes, clear irrelevant fields
|
||||
if (key === "condition" && typeof value === "string") {
|
||||
if (value !== "Parcelado") {
|
||||
updates.installmentCount = "";
|
||||
}
|
||||
if (value !== "Recorrente") {
|
||||
updates.recurrenceCount = "";
|
||||
}
|
||||
}
|
||||
|
||||
// When payment method changes, adjust related fields
|
||||
if (key === "paymentMethod" && typeof value === "string") {
|
||||
if (value === "Cartão de crédito") {
|
||||
updates.accountId = undefined;
|
||||
updates.isSettled = null;
|
||||
} else {
|
||||
updates.cardId = undefined;
|
||||
updates.isSettled = currentState.isSettled ?? true;
|
||||
}
|
||||
|
||||
// Re-derive period based on new payment method
|
||||
if (value === "Cartão de crédito") {
|
||||
if (
|
||||
currentState.purchaseDate &&
|
||||
currentState.cardId &&
|
||||
cardInfo?.closingDay
|
||||
) {
|
||||
updates.period = deriveCreditCardPeriod(
|
||||
currentState.purchaseDate,
|
||||
cardInfo.closingDay,
|
||||
cardInfo.dueDay,
|
||||
);
|
||||
} else if (currentState.purchaseDate) {
|
||||
updates.period = derivePeriodFromDate(currentState.purchaseDate);
|
||||
}
|
||||
} else if (value === "Boleto" && currentState.dueDate) {
|
||||
updates.period = derivePeriodFromDate(currentState.dueDate);
|
||||
} else if (currentState.purchaseDate) {
|
||||
updates.period = derivePeriodFromDate(currentState.purchaseDate);
|
||||
}
|
||||
|
||||
// Clear boleto-specific fields if not boleto
|
||||
if (value !== "Boleto") {
|
||||
updates.dueDate = "";
|
||||
updates.boletoPaymentDate = "";
|
||||
} else if (
|
||||
currentState.isSettled ||
|
||||
(updates.isSettled !== null && updates.isSettled !== undefined)
|
||||
) {
|
||||
// Set today's date for boleto payment if settled
|
||||
const settled = updates.isSettled ?? currentState.isSettled;
|
||||
if (settled) {
|
||||
updates.boletoPaymentDate =
|
||||
currentState.boletoPaymentDate || getTodayDateString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When split is disabled, clear secondary pagador and split fields
|
||||
if (key === "isSplit" && value === false) {
|
||||
updates.secondaryPayerId = undefined;
|
||||
updates.primarySplitAmount = "";
|
||||
updates.secondarySplitAmount = "";
|
||||
}
|
||||
|
||||
// When split is enabled and amount exists, calculate initial split amounts
|
||||
if (key === "isSplit" && value === true) {
|
||||
const totalAmount = Number.parseFloat(currentState.amount) || 0;
|
||||
if (totalAmount > 0) {
|
||||
const half = (totalAmount / 2).toFixed(2);
|
||||
updates.primarySplitAmount = half;
|
||||
updates.secondarySplitAmount = half;
|
||||
}
|
||||
}
|
||||
|
||||
// When amount changes and split is enabled, recalculate split amounts
|
||||
if (key === "amount" && typeof value === "string" && currentState.isSplit) {
|
||||
const totalAmount = Number.parseFloat(value) || 0;
|
||||
if (totalAmount > 0) {
|
||||
const half = (totalAmount / 2).toFixed(2);
|
||||
updates.primarySplitAmount = half;
|
||||
updates.secondarySplitAmount = half;
|
||||
} else {
|
||||
updates.primarySplitAmount = "";
|
||||
updates.secondarySplitAmount = "";
|
||||
}
|
||||
}
|
||||
|
||||
// When primary pagador changes, clear secondary if it matches
|
||||
if (key === "payerId" && typeof value === "string") {
|
||||
const secondaryValue = currentState.secondaryPayerId;
|
||||
if (secondaryValue && secondaryValue === value) {
|
||||
updates.secondaryPayerId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// When isSettled changes and payment method is Boleto
|
||||
if (key === "isSettled" && currentState.paymentMethod === "Boleto") {
|
||||
if (value === true) {
|
||||
updates.boletoPaymentDate =
|
||||
currentState.boletoPaymentDate || getTodayDateString();
|
||||
} else if (value === false) {
|
||||
updates.boletoPaymentDate = "";
|
||||
}
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
66
src/features/transactions/lib/formatting-helpers.ts
Normal file
66
src/features/transactions/lib/formatting-helpers.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Formatting helpers for displaying lancamento data
|
||||
*/
|
||||
import {
|
||||
currencyFormatter,
|
||||
formatCurrency as formatCurrencyValue,
|
||||
} from "@/shared/utils/currency";
|
||||
import { formatDateOnly } from "@/shared/utils/date";
|
||||
import { formatMonthYearLabel } from "@/shared/utils/period";
|
||||
import { capitalize } from "@/shared/utils/string";
|
||||
|
||||
export { currencyFormatter };
|
||||
|
||||
/**
|
||||
* Formats a date string to localized format
|
||||
* @param value - ISO date string or null
|
||||
* @returns Formatted date string or "—"
|
||||
* @example formatDate("2024-01-15") => "15/01/2024"
|
||||
*/
|
||||
export function formatDate(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
return (
|
||||
formatDateOnly(value, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
}) ?? "—"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a period (YYYY-MM) to localized month label
|
||||
* @param value - Period string (YYYY-MM) or null
|
||||
* @returns Formatted period string or "—"
|
||||
* @example formatPeriod("2024-01") => "Janeiro 2024"
|
||||
*/
|
||||
export function formatPeriod(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
try {
|
||||
return formatMonthYearLabel(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a condition string with proper capitalization
|
||||
* @param value - Condition string or null
|
||||
* @returns Formatted condition string or "—"
|
||||
* @example formatCondition("vista") => "À vista"
|
||||
*/
|
||||
export function formatCondition(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
if (value.toLowerCase() === "vista") return "À vista";
|
||||
return capitalize(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats currency value
|
||||
* @param value - Numeric value
|
||||
* @returns Formatted currency string
|
||||
* @example formatCurrency(1234.56) => "R$ 1.234,56"
|
||||
*/
|
||||
export function formatCurrency(value: number): string {
|
||||
return formatCurrencyValue(value);
|
||||
}
|
||||
601
src/features/transactions/lib/page-helpers.ts
Normal file
601
src/features/transactions/lib/page-helpers.ts
Normal file
@@ -0,0 +1,601 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { and, eq, ilike, isNotNull, or, sql } from "drizzle-orm";
|
||||
import {
|
||||
cards,
|
||||
type categories,
|
||||
financialAccounts,
|
||||
type payers,
|
||||
transactionAttachments,
|
||||
transactions,
|
||||
} from "@/db/schema";
|
||||
import type { SelectOption } from "@/features/transactions/components/types";
|
||||
import {
|
||||
PAYMENT_METHODS,
|
||||
SETTLED_FILTER_VALUES,
|
||||
TRANSACTION_CONDITIONS,
|
||||
TRANSACTION_TYPES,
|
||||
} from "@/features/transactions/lib/constants";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
||||
import {
|
||||
PAYER_ROLE_ADMIN,
|
||||
PAYER_ROLE_THIRD_PARTY,
|
||||
} from "@/shared/lib/payers/constants";
|
||||
import { toDateOnlyString } from "@/shared/utils/date";
|
||||
import { slugify } from "@/shared/utils/string";
|
||||
|
||||
type PayerRow = typeof payers.$inferSelect;
|
||||
type AccountRow = typeof financialAccounts.$inferSelect;
|
||||
type CardRow = typeof cards.$inferSelect;
|
||||
type CategoryRow = typeof categories.$inferSelect;
|
||||
|
||||
export type ResolvedSearchParams =
|
||||
| Record<string, string | string[] | undefined>
|
||||
| undefined;
|
||||
|
||||
const TRANSACTIONS_DEFAULT_PAGE_SIZE = 30;
|
||||
const TRANSACTIONS_PAGE_SIZE_OPTIONS = [5, 10, 20, 30, 40, 50, 100];
|
||||
|
||||
export type TransactionSearchFilters = {
|
||||
transactionFilter: string | null;
|
||||
conditionFilter: string | null;
|
||||
paymentFilter: string | null;
|
||||
payerFilter: string | null;
|
||||
categoryFilter: string | null;
|
||||
accountCardFilter: string | null;
|
||||
searchFilter: string | null;
|
||||
settledFilter: string | null;
|
||||
attachmentFilter: string | null;
|
||||
dividedFilter: string | null;
|
||||
};
|
||||
|
||||
type BaseSluggedOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
type PayerSluggedOption = BaseSluggedOption & {
|
||||
role: string | null;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
|
||||
type CategorySluggedOption = BaseSluggedOption & {
|
||||
type: string | null;
|
||||
icon: string | null;
|
||||
};
|
||||
|
||||
type AccountSluggedOption = BaseSluggedOption & {
|
||||
kind: "conta";
|
||||
logo: string | null;
|
||||
accountType: string | null;
|
||||
};
|
||||
|
||||
type CardSluggedOption = BaseSluggedOption & {
|
||||
kind: "cartao";
|
||||
logo: string | null;
|
||||
closingDay: string | null;
|
||||
dueDay: string | null;
|
||||
};
|
||||
|
||||
export type SluggedFilters = {
|
||||
payerFiltersRaw: PayerSluggedOption[];
|
||||
categoryFiltersRaw: CategorySluggedOption[];
|
||||
accountFiltersRaw: AccountSluggedOption[];
|
||||
cardFiltersRaw: CardSluggedOption[];
|
||||
};
|
||||
|
||||
export type SlugMaps = {
|
||||
payer: Map<string, string>;
|
||||
category: Map<string, string>;
|
||||
financialAccount: Map<string, string>;
|
||||
card: Map<string, string>;
|
||||
};
|
||||
|
||||
type FilterOption = {
|
||||
slug: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type AccountCardFilterOption = FilterOption & {
|
||||
kind: "conta" | "cartao";
|
||||
};
|
||||
|
||||
type TransactionOptionSets = {
|
||||
payerOptions: SelectOption[];
|
||||
splitPayerOptions: SelectOption[];
|
||||
defaultPayerId: string | null;
|
||||
accountOptions: SelectOption[];
|
||||
cardOptions: SelectOption[];
|
||||
categoryOptions: SelectOption[];
|
||||
payerFilterOptions: FilterOption[];
|
||||
categoryFilterOptions: FilterOption[];
|
||||
accountCardFilterOptions: AccountCardFilterOption[];
|
||||
};
|
||||
|
||||
export const getSingleParam = (
|
||||
params: ResolvedSearchParams,
|
||||
key: string,
|
||||
): string | null => {
|
||||
const value = params?.[key];
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return Array.isArray(value) ? (value[0] ?? null) : value;
|
||||
};
|
||||
|
||||
export const extractTransactionSearchFilters = (
|
||||
params: ResolvedSearchParams,
|
||||
): TransactionSearchFilters => ({
|
||||
transactionFilter: getSingleParam(params, "type"),
|
||||
conditionFilter: getSingleParam(params, "condition"),
|
||||
paymentFilter: getSingleParam(params, "payment"),
|
||||
payerFilter: getSingleParam(params, "payer"),
|
||||
categoryFilter: getSingleParam(params, "category"),
|
||||
accountCardFilter: getSingleParam(params, "accountCard"),
|
||||
searchFilter: getSingleParam(params, "q"),
|
||||
settledFilter: getSingleParam(params, "settled"),
|
||||
attachmentFilter: getSingleParam(params, "hasAttachment"),
|
||||
dividedFilter: getSingleParam(params, "isDivided"),
|
||||
});
|
||||
|
||||
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) =>
|
||||
value?.trim().length ? value.trim() : "Sem descrição";
|
||||
|
||||
const typeSlugToValue = Object.fromEntries(
|
||||
TRANSACTION_TYPES.map((t) => [slugify(t), t]),
|
||||
) as Record<string, (typeof TRANSACTION_TYPES)[number]>;
|
||||
|
||||
const conditionSlugToValue = Object.fromEntries(
|
||||
TRANSACTION_CONDITIONS.map((c) => [slugify(c), c]),
|
||||
) as Record<string, (typeof TRANSACTION_CONDITIONS)[number]>;
|
||||
|
||||
const paymentSlugToValue = Object.fromEntries(
|
||||
PAYMENT_METHODS.map((m) => [slugify(m), m]),
|
||||
) as Record<string, (typeof PAYMENT_METHODS)[number]>;
|
||||
|
||||
const createSlugGenerator = () => {
|
||||
const seen = new Map<string, number>();
|
||||
return (label: string) => {
|
||||
const base = slugify(label);
|
||||
const count = seen.get(base) ?? 0;
|
||||
seen.set(base, count + 1);
|
||||
if (count === 0) {
|
||||
return base;
|
||||
}
|
||||
return `${base}-${count + 1}`;
|
||||
};
|
||||
};
|
||||
|
||||
const toOption = (
|
||||
value: string,
|
||||
label: string | null | undefined,
|
||||
role?: string | null,
|
||||
group?: string | null,
|
||||
slug?: string | null,
|
||||
avatarUrl?: string | null,
|
||||
logo?: string | null,
|
||||
icon?: string | null,
|
||||
accountType?: string | null,
|
||||
closingDay?: string | null,
|
||||
dueDay?: string | null,
|
||||
): SelectOption => ({
|
||||
value,
|
||||
label: normalizeLabel(label),
|
||||
role: role ?? null,
|
||||
group: group ?? null,
|
||||
slug: slug ?? null,
|
||||
avatarUrl: avatarUrl ?? null,
|
||||
logo: logo ?? null,
|
||||
icon: icon ?? null,
|
||||
accountType: accountType ?? null,
|
||||
closingDay: closingDay ?? null,
|
||||
dueDay: dueDay ?? null,
|
||||
});
|
||||
|
||||
export const buildSluggedFilters = ({
|
||||
payerRows,
|
||||
categoryRows,
|
||||
accountRows,
|
||||
cardRows,
|
||||
}: {
|
||||
payerRows: PayerRow[];
|
||||
categoryRows: CategoryRow[];
|
||||
accountRows: AccountRow[];
|
||||
cardRows: CardRow[];
|
||||
}): SluggedFilters => {
|
||||
const payerSlugger = createSlugGenerator();
|
||||
const categorySlugger = createSlugGenerator();
|
||||
const accountCardSlugger = createSlugGenerator();
|
||||
|
||||
const payerFiltersRaw = payerRows.map((payer) => {
|
||||
const label = normalizeLabel(payer.name);
|
||||
return {
|
||||
id: payer.id,
|
||||
label,
|
||||
slug: payerSlugger(label),
|
||||
role: payer.role ?? null,
|
||||
avatarUrl: payer.avatarUrl ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const categoryFiltersRaw = categoryRows.map((category) => {
|
||||
const label = normalizeLabel(category.name);
|
||||
return {
|
||||
id: category.id,
|
||||
label,
|
||||
slug: categorySlugger(label),
|
||||
type: category.type ?? null,
|
||||
icon: category.icon ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const accountFiltersRaw = accountRows.map((account) => {
|
||||
const label = normalizeLabel(account.name);
|
||||
return {
|
||||
id: account.id,
|
||||
label,
|
||||
slug: accountCardSlugger(label),
|
||||
kind: "conta" as const,
|
||||
logo: account.logo ?? null,
|
||||
accountType: account.accountType ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const cardFiltersRaw = cardRows.map((card) => {
|
||||
const label = normalizeLabel(card.name);
|
||||
return {
|
||||
id: card.id,
|
||||
label,
|
||||
slug: accountCardSlugger(label),
|
||||
kind: "cartao" as const,
|
||||
logo: card.logo ?? null,
|
||||
closingDay: card.closingDay ?? null,
|
||||
dueDay: card.dueDay ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
payerFiltersRaw,
|
||||
categoryFiltersRaw,
|
||||
accountFiltersRaw,
|
||||
cardFiltersRaw,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSlugMaps = ({
|
||||
payerFiltersRaw,
|
||||
categoryFiltersRaw,
|
||||
accountFiltersRaw,
|
||||
cardFiltersRaw,
|
||||
}: SluggedFilters): SlugMaps => ({
|
||||
payer: new Map(payerFiltersRaw.map(({ slug, id }) => [slug, id])),
|
||||
category: new Map(categoryFiltersRaw.map(({ slug, id }) => [slug, id])),
|
||||
financialAccount: new Map(
|
||||
accountFiltersRaw.map(({ slug, id }) => [slug, id]),
|
||||
),
|
||||
card: new Map(cardFiltersRaw.map(({ slug, id }) => [slug, id])),
|
||||
});
|
||||
|
||||
const isValidTransaction = (
|
||||
value: string | null,
|
||||
): value is (typeof TRANSACTION_TYPES)[number] =>
|
||||
!!value && (TRANSACTION_TYPES as readonly string[]).includes(value ?? "");
|
||||
|
||||
const isValidCondition = (
|
||||
value: string | null,
|
||||
): value is (typeof TRANSACTION_CONDITIONS)[number] =>
|
||||
!!value &&
|
||||
(TRANSACTION_CONDITIONS as readonly string[]).includes(value ?? "");
|
||||
|
||||
const isValidPaymentMethod = (
|
||||
value: string | null,
|
||||
): value is (typeof PAYMENT_METHODS)[number] =>
|
||||
!!value && (PAYMENT_METHODS as readonly string[]).includes(value ?? "");
|
||||
|
||||
const buildSearchPattern = (value: string | null) =>
|
||||
value ? `%${value.trim().replace(/\s+/g, "%")}%` : null;
|
||||
|
||||
export const buildTransactionWhere = ({
|
||||
userId,
|
||||
period,
|
||||
filters,
|
||||
slugMaps,
|
||||
cardId,
|
||||
accountId,
|
||||
payerId,
|
||||
}: {
|
||||
userId: string;
|
||||
period: string;
|
||||
filters: TransactionSearchFilters;
|
||||
slugMaps: SlugMaps;
|
||||
cardId?: string;
|
||||
accountId?: string;
|
||||
payerId?: string;
|
||||
}): SQL[] => {
|
||||
const where: SQL[] = [
|
||||
eq(transactions.userId, userId),
|
||||
eq(transactions.period, period),
|
||||
];
|
||||
|
||||
if (payerId) {
|
||||
where.push(eq(transactions.payerId, payerId));
|
||||
}
|
||||
|
||||
if (cardId) {
|
||||
where.push(eq(transactions.cardId, cardId));
|
||||
}
|
||||
|
||||
if (accountId) {
|
||||
where.push(eq(transactions.accountId, accountId));
|
||||
}
|
||||
|
||||
const typeValue = typeSlugToValue[filters.transactionFilter ?? ""] ?? null;
|
||||
if (isValidTransaction(typeValue)) {
|
||||
where.push(eq(transactions.transactionType, typeValue));
|
||||
}
|
||||
|
||||
const conditionValue =
|
||||
conditionSlugToValue[filters.conditionFilter ?? ""] ?? null;
|
||||
if (isValidCondition(conditionValue)) {
|
||||
where.push(eq(transactions.condition, conditionValue));
|
||||
}
|
||||
|
||||
const paymentValue = paymentSlugToValue[filters.paymentFilter ?? ""] ?? null;
|
||||
if (isValidPaymentMethod(paymentValue)) {
|
||||
where.push(eq(transactions.paymentMethod, paymentValue));
|
||||
}
|
||||
|
||||
if (!payerId && filters.payerFilter) {
|
||||
const id = slugMaps.payer.get(filters.payerFilter);
|
||||
if (id) {
|
||||
where.push(eq(transactions.payerId, id));
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.categoryFilter) {
|
||||
const id = slugMaps.category.get(filters.categoryFilter);
|
||||
if (id) {
|
||||
where.push(eq(transactions.categoryId, id));
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.accountCardFilter) {
|
||||
const accountId = slugMaps.financialAccount.get(filters.accountCardFilter);
|
||||
const relatedCardId = accountId
|
||||
? null
|
||||
: slugMaps.card.get(filters.accountCardFilter);
|
||||
if (accountId) {
|
||||
where.push(eq(transactions.accountId, accountId));
|
||||
}
|
||||
if (!accountId && relatedCardId) {
|
||||
where.push(eq(transactions.cardId, relatedCardId));
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.settledFilter === SETTLED_FILTER_VALUES.PAID) {
|
||||
where.push(eq(transactions.isSettled, true));
|
||||
} else if (filters.settledFilter === SETTLED_FILTER_VALUES.UNPAID) {
|
||||
where.push(eq(transactions.isSettled, false));
|
||||
}
|
||||
|
||||
if (filters.attachmentFilter === "true") {
|
||||
where.push(
|
||||
sql`EXISTS (SELECT 1 FROM ${transactionAttachments} WHERE ${transactionAttachments.transactionId} = ${transactions.id})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.dividedFilter === "true") {
|
||||
where.push(eq(transactions.isDivided, true));
|
||||
}
|
||||
|
||||
const searchPattern = buildSearchPattern(filters.searchFilter);
|
||||
if (searchPattern) {
|
||||
where.push(
|
||||
or(
|
||||
ilike(transactions.name, searchPattern),
|
||||
ilike(transactions.note, searchPattern),
|
||||
ilike(transactions.paymentMethod, searchPattern),
|
||||
ilike(transactions.condition, searchPattern),
|
||||
and(
|
||||
isNotNull(financialAccounts.name),
|
||||
ilike(financialAccounts.name, searchPattern),
|
||||
),
|
||||
and(isNotNull(cards.name), ilike(cards.name, searchPattern)),
|
||||
) as SQL,
|
||||
);
|
||||
}
|
||||
|
||||
return where;
|
||||
};
|
||||
|
||||
type TransactionRowWithRelations = Partial<typeof transactions.$inferSelect> & {
|
||||
payer?: PayerRow | null;
|
||||
financialAccount?: AccountRow | null;
|
||||
card?: CardRow | null;
|
||||
category?: CategoryRow | null;
|
||||
hasAttachments?: boolean;
|
||||
};
|
||||
|
||||
export const mapTransactionsData = (rows: TransactionRowWithRelations[]) =>
|
||||
rows.map((item) => ({
|
||||
id: item.id ?? "",
|
||||
userId: item.userId ?? "",
|
||||
name: item.name ?? "",
|
||||
purchaseDate: toDateOnlyString(item.purchaseDate) ?? "",
|
||||
period: item.period ?? "",
|
||||
transactionType: item.transactionType ?? "",
|
||||
amount: Number(item.amount ?? 0),
|
||||
condition: item.condition ?? "",
|
||||
paymentMethod: item.paymentMethod ?? "",
|
||||
payerId: item.payerId ?? null,
|
||||
pagadorName: item.payer?.name ?? null,
|
||||
pagadorAvatar: item.payer?.avatarUrl ?? null,
|
||||
pagadorRole: item.payer?.role ?? null,
|
||||
accountId: item.accountId ?? null,
|
||||
contaName: item.financialAccount?.name ?? null,
|
||||
contaLogo: item.financialAccount?.logo ?? null,
|
||||
cardId: item.cardId ?? null,
|
||||
cartaoName: item.card?.name ?? null,
|
||||
cartaoLogo: item.card?.logo ?? null,
|
||||
categoryId: item.categoryId ?? null,
|
||||
categoriaName: item.category?.name ?? null,
|
||||
categoriaType: item.category?.type ?? null,
|
||||
categoriaIcon: item.category?.icon ?? null,
|
||||
installmentCount: item.installmentCount ?? null,
|
||||
recurrenceCount: item.recurrenceCount ?? null,
|
||||
currentInstallment: item.currentInstallment ?? null,
|
||||
dueDate: item.dueDate ? item.dueDate.toISOString().slice(0, 10) : null,
|
||||
boletoPaymentDate: item.boletoPaymentDate
|
||||
? item.boletoPaymentDate.toISOString().slice(0, 10)
|
||||
: null,
|
||||
note: item.note ?? null,
|
||||
isSettled: item.isSettled ?? null,
|
||||
isDivided: item.isDivided ?? false,
|
||||
isAnticipated: item.isAnticipated ?? false,
|
||||
anticipationId: item.anticipationId ?? null,
|
||||
seriesId: item.seriesId ?? null,
|
||||
splitGroupId: item.splitGroupId ?? null,
|
||||
hasAttachments: item.hasAttachments ?? false,
|
||||
readonly:
|
||||
Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
|
||||
item.category?.name === "Saldo inicial" ||
|
||||
item.category?.name === "Pagamentos",
|
||||
}));
|
||||
|
||||
const sortByLabel = <T extends { label: string }>(items: T[]) =>
|
||||
items.sort((a, b) =>
|
||||
a.label.localeCompare(b.label, "pt-BR", { sensitivity: "base" }),
|
||||
);
|
||||
|
||||
export const buildOptionSets = ({
|
||||
payerFiltersRaw,
|
||||
categoryFiltersRaw,
|
||||
accountFiltersRaw,
|
||||
cardFiltersRaw,
|
||||
payerRows,
|
||||
limitCartaoId,
|
||||
limitContaId,
|
||||
}: SluggedFilters & {
|
||||
payerRows: PayerRow[];
|
||||
limitCartaoId?: string;
|
||||
limitContaId?: string;
|
||||
}): TransactionOptionSets => {
|
||||
const payerOptions = sortByLabel(
|
||||
payerFiltersRaw.map(({ id, label, role, slug, avatarUrl }) =>
|
||||
toOption(id, label, role, undefined, slug, avatarUrl),
|
||||
),
|
||||
);
|
||||
|
||||
const payerFilterOptions = sortByLabel(
|
||||
payerFiltersRaw.map(({ slug, label, avatarUrl }) => ({
|
||||
slug,
|
||||
label,
|
||||
avatarUrl,
|
||||
})),
|
||||
);
|
||||
|
||||
const defaultPayerId =
|
||||
payerRows.find((payer) => payer.role === PAYER_ROLE_ADMIN)?.id ?? null;
|
||||
|
||||
const splitPayerOptions = payerOptions.filter(
|
||||
(option) => option.role === PAYER_ROLE_THIRD_PARTY,
|
||||
);
|
||||
|
||||
const contaOptionsSource = limitContaId
|
||||
? accountFiltersRaw.filter((conta) => conta.id === limitContaId)
|
||||
: accountFiltersRaw;
|
||||
|
||||
const accountOptions = sortByLabel(
|
||||
contaOptionsSource.map(({ id, label, slug, logo, accountType }) =>
|
||||
toOption(
|
||||
id,
|
||||
label,
|
||||
undefined,
|
||||
undefined,
|
||||
slug,
|
||||
undefined,
|
||||
logo,
|
||||
undefined,
|
||||
accountType,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const cartaoOptionsSource = limitCartaoId
|
||||
? cardFiltersRaw.filter((cartao) => cartao.id === limitCartaoId)
|
||||
: cardFiltersRaw;
|
||||
|
||||
const cardOptions = sortByLabel(
|
||||
cartaoOptionsSource.map(({ id, label, slug, logo, closingDay, dueDay }) =>
|
||||
toOption(
|
||||
id,
|
||||
label,
|
||||
undefined,
|
||||
undefined,
|
||||
slug,
|
||||
undefined,
|
||||
logo,
|
||||
undefined,
|
||||
undefined,
|
||||
closingDay,
|
||||
dueDay,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const categoryOptions = sortByLabel(
|
||||
categoryFiltersRaw.map(({ id, label, type, slug, icon }) =>
|
||||
toOption(id, label, undefined, type, slug, undefined, undefined, icon),
|
||||
),
|
||||
);
|
||||
|
||||
const categoryFilterOptions = sortByLabel(
|
||||
categoryFiltersRaw.map(({ slug, label, icon }) => ({ slug, label, icon })),
|
||||
);
|
||||
|
||||
const accountCardFilterOptions = sortByLabel(
|
||||
[...accountFiltersRaw, ...cardFiltersRaw]
|
||||
.filter(
|
||||
(option) =>
|
||||
(limitCartaoId && option.kind === "cartao"
|
||||
? option.id === limitCartaoId
|
||||
: true) &&
|
||||
(limitContaId && option.kind === "conta"
|
||||
? option.id === limitContaId
|
||||
: true),
|
||||
)
|
||||
.map(({ slug, label, kind, logo }) => ({ slug, label, kind, logo })),
|
||||
);
|
||||
|
||||
return {
|
||||
payerOptions,
|
||||
splitPayerOptions,
|
||||
defaultPayerId,
|
||||
accountOptions,
|
||||
cardOptions,
|
||||
categoryOptions,
|
||||
payerFilterOptions,
|
||||
categoryFilterOptions,
|
||||
accountCardFilterOptions,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user