mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
refactor(core): move app para src e padroniza estrutura
This commit is contained in:
1642
src/features/transactions/actions.ts
Normal file
1642
src/features/transactions/actions.ts
Normal file
File diff suppressed because it is too large
Load Diff
487
src/features/transactions/anticipation-actions.ts
Normal file
487
src/features/transactions/anticipation-actions.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
"use server";
|
||||
|
||||
import { and, asc, desc, eq, inArray, isNull, or } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
antecipacoesParcelas,
|
||||
categorias,
|
||||
lancamentos,
|
||||
pagadores,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
handleActionError,
|
||||
revalidateForEntity,
|
||||
} from "@/shared/lib/actions/helpers";
|
||||
import { getUser } from "@/shared/lib/auth/server";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import {
|
||||
generateAnticipationDescription,
|
||||
generateAnticipationNote,
|
||||
} from "@/shared/lib/installments/anticipation-helpers";
|
||||
import type {
|
||||
CancelAnticipationInput,
|
||||
CreateAnticipationInput,
|
||||
EligibleInstallment,
|
||||
InstallmentAnticipationWithRelations,
|
||||
} from "@/shared/lib/installments/anticipation-types";
|
||||
import { uuidSchema } from "@/shared/lib/schemas/common";
|
||||
import type { ActionResult } from "@/shared/lib/types/actions";
|
||||
import { formatDecimalForDbRequired } from "@/shared/utils/currency";
|
||||
|
||||
/**
|
||||
* Schema de validação para criar antecipação
|
||||
*/
|
||||
const createAnticipationSchema = z.object({
|
||||
seriesId: uuidSchema("Série"),
|
||||
installmentIds: z
|
||||
.array(uuidSchema("Parcela"))
|
||||
.min(1, "Selecione pelo menos uma parcela para antecipar."),
|
||||
anticipationPeriod: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^(\d{4})-(\d{2})$/, {
|
||||
message: "Selecione um período válido.",
|
||||
}),
|
||||
discount: z.coerce
|
||||
.number()
|
||||
.min(0, "Informe um desconto maior ou igual a zero.")
|
||||
.optional()
|
||||
.default(0),
|
||||
pagadorId: uuidSchema("Pagador").optional(),
|
||||
categoriaId: uuidSchema("Categoria").optional(),
|
||||
note: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema de validação para cancelar antecipação
|
||||
*/
|
||||
const cancelAnticipationSchema = z.object({
|
||||
anticipationId: uuidSchema("Antecipação"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Busca parcelas elegíveis para antecipação de uma série
|
||||
*/
|
||||
export async function getEligibleInstallmentsAction(
|
||||
seriesId: string,
|
||||
): Promise<ActionResult<EligibleInstallment[]>> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
|
||||
// Validar seriesId
|
||||
const validatedSeriesId = uuidSchema("Série").parse(seriesId);
|
||||
|
||||
// Buscar todas as parcelas da série que estão elegíveis
|
||||
const rows = await db.query.lancamentos.findMany({
|
||||
where: and(
|
||||
eq(lancamentos.seriesId, validatedSeriesId),
|
||||
eq(lancamentos.userId, user.id),
|
||||
eq(lancamentos.condition, "Parcelado"),
|
||||
// Apenas parcelas não pagas e não antecipadas
|
||||
or(eq(lancamentos.isSettled, false), isNull(lancamentos.isSettled)),
|
||||
eq(lancamentos.isAnticipated, false),
|
||||
),
|
||||
orderBy: [asc(lancamentos.currentInstallment)],
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
amount: true,
|
||||
period: true,
|
||||
purchaseDate: true,
|
||||
dueDate: true,
|
||||
currentInstallment: true,
|
||||
installmentCount: true,
|
||||
paymentMethod: true,
|
||||
categoriaId: true,
|
||||
pagadorId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const eligibleInstallments: EligibleInstallment[] = rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount: row.amount,
|
||||
period: row.period,
|
||||
purchaseDate: row.purchaseDate,
|
||||
dueDate: row.dueDate,
|
||||
currentInstallment: row.currentInstallment,
|
||||
installmentCount: row.installmentCount,
|
||||
paymentMethod: row.paymentMethod,
|
||||
categoriaId: row.categoriaId,
|
||||
pagadorId: row.pagadorId,
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Parcelas elegíveis carregadas.",
|
||||
data: eligibleInstallments,
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error) as ActionResult<EligibleInstallment[]>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria uma antecipação de parcelas
|
||||
*/
|
||||
export async function createInstallmentAnticipationAction(
|
||||
input: CreateAnticipationInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = createAnticipationSchema.parse(input);
|
||||
|
||||
// 1. Validar parcelas selecionadas
|
||||
const installments = await db.query.lancamentos.findMany({
|
||||
where: and(
|
||||
inArray(lancamentos.id, data.installmentIds),
|
||||
eq(lancamentos.userId, user.id),
|
||||
eq(lancamentos.seriesId, data.seriesId),
|
||||
or(eq(lancamentos.isSettled, false), isNull(lancamentos.isSettled)),
|
||||
eq(lancamentos.isAnticipated, false),
|
||||
),
|
||||
});
|
||||
|
||||
if (installments.length !== data.installmentIds.length) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Algumas parcelas não estão elegíveis para antecipação.",
|
||||
};
|
||||
}
|
||||
|
||||
if (installments.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Nenhuma parcela selecionada para antecipação.",
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Calcular valor total
|
||||
const totalAmountCents = installments.reduce(
|
||||
(sum, inst) => sum + Number(inst.amount) * 100,
|
||||
0,
|
||||
);
|
||||
const totalAmount = totalAmountCents / 100;
|
||||
const totalAmountAbs = Math.abs(totalAmount);
|
||||
|
||||
// 2.1. Aplicar desconto
|
||||
const discount = data.discount || 0;
|
||||
|
||||
// 2.2. Validar que o desconto não é maior que o valor absoluto total
|
||||
if (discount > totalAmountAbs) {
|
||||
return {
|
||||
success: false,
|
||||
error: "O desconto não pode ser maior que o valor total das parcelas.",
|
||||
};
|
||||
}
|
||||
|
||||
// 2.3. Calcular valor final (se negativo, soma o desconto para reduzir a despesa)
|
||||
const finalAmount =
|
||||
totalAmount < 0
|
||||
? totalAmount + discount // Despesa: -1000 + 20 = -980
|
||||
: totalAmount - discount; // Receita: 1000 - 20 = 980
|
||||
|
||||
// 3. Pegar dados da primeira parcela para referência
|
||||
const firstInstallment = installments[0];
|
||||
|
||||
// 4. Criar lançamento e antecipação em transação
|
||||
await db.transaction(async (tx: typeof db) => {
|
||||
// 4.1. Criar o lançamento de antecipação (com desconto aplicado)
|
||||
const [newLancamento] = await tx
|
||||
.insert(lancamentos)
|
||||
.values({
|
||||
name: generateAnticipationDescription(
|
||||
firstInstallment.name,
|
||||
installments.length,
|
||||
),
|
||||
condition: "À vista",
|
||||
transactionType: firstInstallment.transactionType,
|
||||
paymentMethod: firstInstallment.paymentMethod,
|
||||
amount: formatDecimalForDbRequired(finalAmount),
|
||||
purchaseDate: new Date(),
|
||||
period: data.anticipationPeriod,
|
||||
dueDate: null,
|
||||
isSettled: false,
|
||||
pagadorId: data.pagadorId ?? firstInstallment.pagadorId,
|
||||
categoriaId: data.categoriaId ?? firstInstallment.categoriaId,
|
||||
cartaoId: firstInstallment.cartaoId,
|
||||
contaId: firstInstallment.contaId,
|
||||
note:
|
||||
data.note ||
|
||||
generateAnticipationNote(
|
||||
installments.map((inst) => ({
|
||||
id: inst.id,
|
||||
name: inst.name,
|
||||
amount: inst.amount,
|
||||
period: inst.period,
|
||||
purchaseDate: inst.purchaseDate,
|
||||
dueDate: inst.dueDate,
|
||||
currentInstallment: inst.currentInstallment,
|
||||
installmentCount: inst.installmentCount,
|
||||
paymentMethod: inst.paymentMethod,
|
||||
categoriaId: inst.categoriaId,
|
||||
pagadorId: inst.pagadorId,
|
||||
})),
|
||||
),
|
||||
userId: user.id,
|
||||
installmentCount: null,
|
||||
currentInstallment: null,
|
||||
recurrenceCount: null,
|
||||
isAnticipated: false,
|
||||
isDivided: false,
|
||||
seriesId: null,
|
||||
transferId: null,
|
||||
anticipationId: null,
|
||||
boletoPaymentDate: null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// 4.2. Criar registro de antecipação
|
||||
const [anticipation] = await tx
|
||||
.insert(antecipacoesParcelas)
|
||||
.values({
|
||||
seriesId: data.seriesId,
|
||||
anticipationPeriod: data.anticipationPeriod,
|
||||
anticipationDate: new Date(),
|
||||
anticipatedInstallmentIds: data.installmentIds,
|
||||
totalAmount: formatDecimalForDbRequired(totalAmount),
|
||||
installmentCount: installments.length,
|
||||
discount: formatDecimalForDbRequired(discount),
|
||||
lancamentoId: newLancamento.id,
|
||||
pagadorId: data.pagadorId ?? firstInstallment.pagadorId,
|
||||
categoriaId: data.categoriaId ?? firstInstallment.categoriaId,
|
||||
note: data.note || null,
|
||||
userId: user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// 4.3. Marcar parcelas como antecipadas e zerar seus valores
|
||||
await tx
|
||||
.update(lancamentos)
|
||||
.set({
|
||||
isAnticipated: true,
|
||||
anticipationId: anticipation.id,
|
||||
amount: "0", // Zera o valor para não contar em dobro
|
||||
})
|
||||
.where(inArray(lancamentos.id, data.installmentIds));
|
||||
});
|
||||
|
||||
revalidateForEntity("lancamentos");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${installments.length} ${
|
||||
installments.length === 1
|
||||
? "parcela antecipada"
|
||||
: "parcelas antecipadas"
|
||||
} com sucesso!`,
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca histórico de antecipações de uma série
|
||||
*/
|
||||
export async function getInstallmentAnticipationsAction(
|
||||
seriesId: string,
|
||||
): Promise<ActionResult<InstallmentAnticipationWithRelations[]>> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
|
||||
// Validar seriesId
|
||||
const validatedSeriesId = uuidSchema("Série").parse(seriesId);
|
||||
|
||||
// Usar query builder ao invés de db.query para evitar problemas de tipagem
|
||||
const anticipations = await db
|
||||
.select({
|
||||
id: antecipacoesParcelas.id,
|
||||
seriesId: antecipacoesParcelas.seriesId,
|
||||
anticipationPeriod: antecipacoesParcelas.anticipationPeriod,
|
||||
anticipationDate: antecipacoesParcelas.anticipationDate,
|
||||
anticipatedInstallmentIds:
|
||||
antecipacoesParcelas.anticipatedInstallmentIds,
|
||||
totalAmount: antecipacoesParcelas.totalAmount,
|
||||
installmentCount: antecipacoesParcelas.installmentCount,
|
||||
discount: antecipacoesParcelas.discount,
|
||||
lancamentoId: antecipacoesParcelas.lancamentoId,
|
||||
pagadorId: antecipacoesParcelas.pagadorId,
|
||||
categoriaId: antecipacoesParcelas.categoriaId,
|
||||
note: antecipacoesParcelas.note,
|
||||
userId: antecipacoesParcelas.userId,
|
||||
createdAt: antecipacoesParcelas.createdAt,
|
||||
// Joins
|
||||
lancamento: lancamentos,
|
||||
pagador: pagadores,
|
||||
categoria: categorias,
|
||||
})
|
||||
.from(antecipacoesParcelas)
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
eq(antecipacoesParcelas.lancamentoId, lancamentos.id),
|
||||
)
|
||||
.leftJoin(pagadores, eq(antecipacoesParcelas.pagadorId, pagadores.id))
|
||||
.leftJoin(categorias, eq(antecipacoesParcelas.categoriaId, categorias.id))
|
||||
.where(
|
||||
and(
|
||||
eq(antecipacoesParcelas.seriesId, validatedSeriesId),
|
||||
eq(antecipacoesParcelas.userId, user.id),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(antecipacoesParcelas.createdAt));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Antecipações carregadas.",
|
||||
data: anticipations,
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error) as ActionResult<
|
||||
InstallmentAnticipationWithRelations[]
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancela uma antecipação de parcelas
|
||||
* Remove o lançamento de antecipação e restaura as parcelas originais
|
||||
*/
|
||||
export async function cancelInstallmentAnticipationAction(
|
||||
input: CancelAnticipationInput,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = cancelAnticipationSchema.parse(input);
|
||||
|
||||
await db.transaction(async (tx: typeof db) => {
|
||||
// 1. Buscar antecipação usando query builder
|
||||
const anticipationRows = await tx
|
||||
.select({
|
||||
id: antecipacoesParcelas.id,
|
||||
seriesId: antecipacoesParcelas.seriesId,
|
||||
anticipationPeriod: antecipacoesParcelas.anticipationPeriod,
|
||||
anticipationDate: antecipacoesParcelas.anticipationDate,
|
||||
anticipatedInstallmentIds:
|
||||
antecipacoesParcelas.anticipatedInstallmentIds,
|
||||
totalAmount: antecipacoesParcelas.totalAmount,
|
||||
installmentCount: antecipacoesParcelas.installmentCount,
|
||||
discount: antecipacoesParcelas.discount,
|
||||
lancamentoId: antecipacoesParcelas.lancamentoId,
|
||||
pagadorId: antecipacoesParcelas.pagadorId,
|
||||
categoriaId: antecipacoesParcelas.categoriaId,
|
||||
note: antecipacoesParcelas.note,
|
||||
userId: antecipacoesParcelas.userId,
|
||||
createdAt: antecipacoesParcelas.createdAt,
|
||||
lancamento: lancamentos,
|
||||
})
|
||||
.from(antecipacoesParcelas)
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
eq(antecipacoesParcelas.lancamentoId, lancamentos.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(antecipacoesParcelas.id, data.anticipationId),
|
||||
eq(antecipacoesParcelas.userId, user.id),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const anticipation = anticipationRows[0];
|
||||
|
||||
if (!anticipation) {
|
||||
throw new Error("Antecipação não encontrada.");
|
||||
}
|
||||
|
||||
// 2. Verificar se o lançamento já foi pago
|
||||
if (anticipation.lancamento?.isSettled === true) {
|
||||
throw new Error(
|
||||
"Não é possível cancelar uma antecipação já paga. Remova o pagamento primeiro.",
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Calcular valor original por parcela (totalAmount sem desconto / quantidade)
|
||||
const originalTotalAmount = Number(anticipation.totalAmount);
|
||||
const originalValuePerInstallment =
|
||||
originalTotalAmount / anticipation.installmentCount;
|
||||
|
||||
// 4. Remover flag de antecipação e restaurar valores das parcelas
|
||||
await tx
|
||||
.update(lancamentos)
|
||||
.set({
|
||||
isAnticipated: false,
|
||||
anticipationId: null,
|
||||
amount: formatDecimalForDbRequired(originalValuePerInstallment),
|
||||
})
|
||||
.where(
|
||||
inArray(
|
||||
lancamentos.id,
|
||||
anticipation.anticipatedInstallmentIds as string[],
|
||||
),
|
||||
);
|
||||
|
||||
// 5. Deletar lançamento de antecipação
|
||||
await tx
|
||||
.delete(lancamentos)
|
||||
.where(eq(lancamentos.id, anticipation.lancamentoId));
|
||||
|
||||
// 6. Deletar registro de antecipação
|
||||
await tx
|
||||
.delete(antecipacoesParcelas)
|
||||
.where(eq(antecipacoesParcelas.id, data.anticipationId));
|
||||
});
|
||||
|
||||
revalidateForEntity("lancamentos");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Antecipação cancelada com sucesso!",
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca detalhes de uma antecipação específica
|
||||
*/
|
||||
export async function getAnticipationDetailsAction(
|
||||
anticipationId: string,
|
||||
): Promise<ActionResult<InstallmentAnticipationWithRelations>> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
|
||||
// Validar anticipationId
|
||||
const validatedId = uuidSchema("Antecipação").parse(anticipationId);
|
||||
|
||||
const anticipation = await db.query.antecipacoesParcelas.findFirst({
|
||||
where: and(
|
||||
eq(antecipacoesParcelas.id, validatedId),
|
||||
eq(antecipacoesParcelas.userId, user.id),
|
||||
),
|
||||
with: {
|
||||
lancamento: true,
|
||||
pagador: true,
|
||||
categoria: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!anticipation) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Antecipação não encontrada.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Detalhes da antecipação carregados.",
|
||||
data: anticipation,
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(
|
||||
error,
|
||||
) as ActionResult<InstallmentAnticipationWithRelations>;
|
||||
}
|
||||
}
|
||||
73
src/features/transactions/categoria-helpers.ts
Normal file
73
src/features/transactions/categoria-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 categorias
|
||||
*/
|
||||
type CategoriaGroup = {
|
||||
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 categoria options by their group property
|
||||
* @param categoriaOptions - Array of categoria select options
|
||||
* @returns Array of grouped and sorted categoria options
|
||||
*/
|
||||
export function groupAndSortCategorias(
|
||||
categoriaOptions: SelectOption[],
|
||||
): CategoriaGroup[] {
|
||||
// Group categorias by their group property
|
||||
const groups = categoriaOptions.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 pagador options to exclude the primary pagador
|
||||
*/
|
||||
export function filterSecondaryPagadorOptions(
|
||||
allOptions: SelectOption[],
|
||||
primaryPagadorId?: string,
|
||||
): SelectOption[] {
|
||||
return allOptions.filter((option) => option.value !== primaryPagadorId);
|
||||
}
|
||||
31
src/features/transactions/column-order.ts
Normal file
31
src/features/transactions/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).
|
||||
*/
|
||||
export const LANCAMENTOS_REORDERABLE_COLUMN_IDS = [
|
||||
"name",
|
||||
"transactionType",
|
||||
"amount",
|
||||
"condition",
|
||||
"paymentMethod",
|
||||
"categoriaName",
|
||||
"pagadorName",
|
||||
"note",
|
||||
"contaCartao",
|
||||
] as const;
|
||||
|
||||
export const LANCAMENTOS_COLUMN_LABELS: Record<string, string> = {
|
||||
name: "Estabelecimento",
|
||||
transactionType: "Transação",
|
||||
amount: "Valor",
|
||||
condition: "Condição",
|
||||
paymentMethod: "Forma de Pagamento",
|
||||
categoriaName: "Categoria",
|
||||
pagadorName: "Pagador",
|
||||
note: "Anotação",
|
||||
contaCartao: "Conta/Cartão",
|
||||
};
|
||||
|
||||
export const DEFAULT_LANCAMENTOS_COLUMN_ORDER: string[] = [
|
||||
...LANCAMENTOS_REORDERABLE_COLUMN_IDS,
|
||||
];
|
||||
@@ -0,0 +1,415 @@
|
||||
"use client";
|
||||
|
||||
import { RiLoader4Line } from "@remixicon/react";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CategoryIcon } from "@/features/categories/components/category-icon";
|
||||
import {
|
||||
createInstallmentAnticipationAction,
|
||||
getEligibleInstallmentsAction,
|
||||
} from "@/features/transactions/anticipation-actions";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { PeriodPicker } from "@/shared/components/period-picker";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
} from "@/shared/components/ui/field";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select";
|
||||
import { Textarea } from "@/shared/components/ui/textarea";
|
||||
import { useControlledState } from "@/shared/hooks/use-controlled-state";
|
||||
import { useFormState } from "@/shared/hooks/use-form-state";
|
||||
import type { EligibleInstallment } from "@/shared/lib/installments/anticipation-types";
|
||||
import { InstallmentSelectionTable } from "./installment-selection-table";
|
||||
|
||||
interface AnticipateInstallmentsDialogProps {
|
||||
trigger?: React.ReactNode;
|
||||
seriesId: string;
|
||||
lancamentoName: string;
|
||||
categorias: Array<{ id: string; name: string; icon: string | null }>;
|
||||
pagadores: Array<{ id: string; name: string }>;
|
||||
defaultPeriod: string;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type AnticipationFormValues = {
|
||||
anticipationPeriod: string;
|
||||
discount: string;
|
||||
pagadorId: string;
|
||||
categoriaId: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export function AnticipateInstallmentsDialog({
|
||||
trigger,
|
||||
seriesId,
|
||||
lancamentoName,
|
||||
categorias,
|
||||
pagadores,
|
||||
defaultPeriod,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AnticipateInstallmentsDialogProps) {
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isLoadingInstallments, setIsLoadingInstallments] = useState(false);
|
||||
const [eligibleInstallments, setEligibleInstallments] = useState<
|
||||
EligibleInstallment[]
|
||||
>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
|
||||
// Use controlled state hook for dialog open state
|
||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||
open,
|
||||
false,
|
||||
onOpenChange,
|
||||
);
|
||||
|
||||
// Use form state hook for form management
|
||||
const { formState, replaceForm, updateField } =
|
||||
useFormState<AnticipationFormValues>({
|
||||
anticipationPeriod: defaultPeriod,
|
||||
discount: "0",
|
||||
pagadorId: "",
|
||||
categoriaId: "",
|
||||
note: "",
|
||||
});
|
||||
|
||||
// Buscar parcelas elegíveis ao abrir o dialog
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
setIsLoadingInstallments(true);
|
||||
setSelectedIds([]);
|
||||
setErrorMessage(null);
|
||||
|
||||
getEligibleInstallmentsAction(seriesId)
|
||||
.then((result) => {
|
||||
if (!result.success) {
|
||||
toast.error(result.error || "Erro ao carregar parcelas");
|
||||
setEligibleInstallments([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const installments = result.data ?? [];
|
||||
setEligibleInstallments(installments);
|
||||
|
||||
// Pré-preencher pagador e categoria da primeira parcela
|
||||
if (installments.length > 0) {
|
||||
const first = installments[0];
|
||||
replaceForm({
|
||||
anticipationPeriod: defaultPeriod,
|
||||
discount: "0",
|
||||
pagadorId: first.pagadorId ?? "",
|
||||
categoriaId: first.categoriaId ?? "",
|
||||
note: "",
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Erro ao buscar parcelas:", error);
|
||||
toast.error("Erro ao carregar parcelas elegíveis");
|
||||
setEligibleInstallments([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingInstallments(false);
|
||||
});
|
||||
}
|
||||
}, [defaultPeriod, dialogOpen, replaceForm, seriesId]);
|
||||
|
||||
const totalAmount = useMemo(() => {
|
||||
return eligibleInstallments
|
||||
.filter((inst) => selectedIds.includes(inst.id))
|
||||
.reduce((sum, inst) => sum + Number(inst.amount), 0);
|
||||
}, [eligibleInstallments, selectedIds]);
|
||||
|
||||
const finalAmount = useMemo(() => {
|
||||
// Se for despesa (negativo), soma o desconto para reduzir
|
||||
// Se for receita (positivo), subtrai o desconto
|
||||
const discount = Number(formState.discount) || 0;
|
||||
return totalAmount < 0 ? totalAmount + discount : totalAmount - discount;
|
||||
}, [totalAmount, formState.discount]);
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
|
||||
if (selectedIds.length === 0) {
|
||||
const message = "Selecione pelo menos uma parcela para antecipar.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formState.anticipationPeriod.length === 0) {
|
||||
const message = "Informe o período da antecipação.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const discount = Number(formState.discount) || 0;
|
||||
if (discount > Math.abs(totalAmount)) {
|
||||
const message =
|
||||
"O desconto não pode ser maior que o valor total das parcelas.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await createInstallmentAnticipationAction({
|
||||
seriesId,
|
||||
installmentIds: selectedIds,
|
||||
anticipationPeriod: formState.anticipationPeriod,
|
||||
discount: Number(formState.discount) || 0,
|
||||
pagadorId: formState.pagadorId || undefined,
|
||||
categoriaId: formState.categoriaId || undefined,
|
||||
note: formState.note || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setDialogOpen(false);
|
||||
} else {
|
||||
const errorMsg = result.error || "Erro ao criar antecipação";
|
||||
setErrorMessage(errorMsg);
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Antecipar Parcelas</DialogTitle>
|
||||
<DialogDescription>{lancamentoName}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Seção 1: Seleção de Parcelas */}
|
||||
<FieldGroup className="gap-1">
|
||||
<FieldLegend>Parcelas Disponíveis</FieldLegend>
|
||||
|
||||
{isLoadingInstallments ? (
|
||||
<div className="flex items-center justify-center rounded-lg border border-dashed p-8">
|
||||
<RiLoader4Line className="size-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
Carregando parcelas...
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[280px] overflow-y-auto rounded-lg border">
|
||||
<InstallmentSelectionTable
|
||||
installments={eligibleInstallments}
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={setSelectedIds}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</FieldGroup>
|
||||
|
||||
{/* Seção 2: Configuração da Antecipação */}
|
||||
<FieldGroup className="gap-1">
|
||||
<FieldLegend>Configuração</FieldLegend>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Field className="gap-1">
|
||||
<FieldLabel htmlFor="anticipation-period">Período</FieldLabel>
|
||||
<FieldContent>
|
||||
<PeriodPicker
|
||||
value={formState.anticipationPeriod}
|
||||
onChange={(value) =>
|
||||
updateField("anticipationPeriod", value)
|
||||
}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field className="gap-1">
|
||||
<FieldLabel htmlFor="anticipation-discount">
|
||||
Desconto
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<CurrencyInput
|
||||
id="anticipation-discount"
|
||||
value={formState.discount}
|
||||
onValueChange={(value) => updateField("discount", value)}
|
||||
placeholder="R$ 0,00"
|
||||
disabled={isPending}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field className="gap-1">
|
||||
<FieldLabel htmlFor="anticipation-pagador">Pagador</FieldLabel>
|
||||
<FieldContent>
|
||||
<Select
|
||||
value={formState.pagadorId}
|
||||
onValueChange={(value) => updateField("pagadorId", value)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<SelectTrigger id="anticipation-pagador" className="w-full">
|
||||
<SelectValue placeholder="Padrão" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pagadores.map((pagador) => (
|
||||
<SelectItem key={pagador.id} value={pagador.id}>
|
||||
{pagador.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field className="gap-1">
|
||||
<FieldLabel htmlFor="anticipation-categoria">
|
||||
Categoria
|
||||
</FieldLabel>
|
||||
<FieldContent>
|
||||
<Select
|
||||
value={formState.categoriaId}
|
||||
onValueChange={(value) => updateField("categoriaId", value)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="anticipation-categoria"
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue placeholder="Padrão" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categorias.map((categoria) => (
|
||||
<SelectItem key={categoria.id} value={categoria.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<CategoryIcon
|
||||
name={categoria.icon ?? undefined}
|
||||
className="size-4"
|
||||
/>
|
||||
<span>{categoria.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field className="sm:col-span-2">
|
||||
<FieldLabel htmlFor="anticipation-note">Observação</FieldLabel>
|
||||
<FieldContent>
|
||||
<Textarea
|
||||
id="anticipation-note"
|
||||
value={formState.note}
|
||||
onChange={(e) => updateField("note", e.target.value)}
|
||||
placeholder="Observação (opcional)"
|
||||
rows={2}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
{/* Seção 3: Resumo */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="rounded-lg border bg-muted/20 p-3">
|
||||
<h4 className="text-sm font-semibold mb-2">Resumo</h4>
|
||||
<dl className="space-y-1.5 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<dt className="text-muted-foreground">
|
||||
{selectedIds.length} parcela
|
||||
{selectedIds.length > 1 ? "s" : ""}
|
||||
</dt>
|
||||
<dd className="font-medium tabular-nums">
|
||||
<MoneyValues amount={totalAmount} className="text-sm" />
|
||||
</dd>
|
||||
</div>
|
||||
{Number(formState.discount) > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<dt className="text-muted-foreground">Desconto</dt>
|
||||
<dd className="font-medium tabular-nums text-success">
|
||||
-{" "}
|
||||
<MoneyValues
|
||||
amount={Number(formState.discount)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between border-t pt-1.5">
|
||||
<dt className="font-medium">Total</dt>
|
||||
<dd className="text-base font-semibold tabular-nums text-primary">
|
||||
<MoneyValues amount={finalAmount} className="text-sm" />
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mensagem de erro */}
|
||||
{errorMessage && (
|
||||
<div
|
||||
className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending || selectedIds.length === 0}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<RiLoader4Line className="mr-2 size-4 animate-spin" />
|
||||
Antecipando...
|
||||
</>
|
||||
) : (
|
||||
"Confirmar Antecipação"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { RiCalendarCheckLine, RiLoader4Line } from "@remixicon/react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { getInstallmentAnticipationsAction } from "@/features/transactions/anticipation-actions";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/shared/components/ui/empty";
|
||||
import { useControlledState } from "@/shared/hooks/use-controlled-state";
|
||||
import type { InstallmentAnticipationWithRelations } from "@/shared/lib/installments/anticipation-types";
|
||||
import { AnticipationCard } from "../../shared/anticipation-card";
|
||||
|
||||
interface AnticipationHistoryDialogProps {
|
||||
trigger?: React.ReactNode;
|
||||
seriesId: string;
|
||||
lancamentoName: string;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onViewLancamento?: (lancamentoId: string) => void;
|
||||
}
|
||||
|
||||
export function AnticipationHistoryDialog({
|
||||
trigger,
|
||||
seriesId,
|
||||
lancamentoName,
|
||||
open,
|
||||
onOpenChange,
|
||||
onViewLancamento,
|
||||
}: AnticipationHistoryDialogProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [anticipations, setAnticipations] = useState<
|
||||
InstallmentAnticipationWithRelations[]
|
||||
>([]);
|
||||
|
||||
// Use controlled state hook for dialog open state
|
||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||
open,
|
||||
false,
|
||||
onOpenChange,
|
||||
);
|
||||
|
||||
// Define loadAnticipations before it's used in useEffect
|
||||
const loadAnticipations = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const result = await getInstallmentAnticipationsAction(seriesId);
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(
|
||||
result.error || "Erro ao carregar histórico de antecipações",
|
||||
);
|
||||
setAnticipations([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setAnticipations(result.data ?? []);
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar antecipações:", error);
|
||||
toast.error("Erro ao carregar histórico de antecipações");
|
||||
setAnticipations([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [seriesId]);
|
||||
|
||||
// Buscar antecipações ao abrir o dialog
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
loadAnticipations();
|
||||
}
|
||||
}, [dialogOpen, loadAnticipations]);
|
||||
|
||||
const handleCanceled = () => {
|
||||
// Recarregar lista após cancelamento
|
||||
loadAnticipations();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
||||
<DialogContent className="max-w-3xl px-6 py-5 sm:px-8 sm:py-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Histórico de Antecipações</DialogTitle>
|
||||
<DialogDescription>{lancamentoName}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[60vh] space-y-4 overflow-y-auto pr-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center rounded-lg border border-dashed p-12">
|
||||
<RiLoader4Line className="size-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
Carregando histórico...
|
||||
</span>
|
||||
</div>
|
||||
) : anticipations.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<RiCalendarCheckLine className="size-6 text-muted-foreground" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhuma antecipação registrada</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
As antecipações realizadas para esta compra parcelada
|
||||
aparecerão aqui.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : (
|
||||
anticipations.map((anticipation) => (
|
||||
<AnticipationCard
|
||||
key={anticipation.id}
|
||||
anticipation={anticipation}
|
||||
onViewLancamento={onViewLancamento}
|
||||
onCanceled={handleCanceled}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isLoading && anticipations.length > 0 && (
|
||||
<div className="border-t pt-4 text-center text-sm text-muted-foreground">
|
||||
{anticipations.length}{" "}
|
||||
{anticipations.length === 1
|
||||
? "antecipação encontrada"
|
||||
: "antecipações encontradas"}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table";
|
||||
import type { EligibleInstallment } from "@/shared/lib/installments/anticipation-types";
|
||||
import { formatCurrentInstallment } from "@/shared/lib/installments/utils";
|
||||
import { formatShortPeriodLabel } from "@/shared/utils/period";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
interface InstallmentSelectionTableProps {
|
||||
installments: EligibleInstallment[];
|
||||
selectedIds: string[];
|
||||
onSelectionChange: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
export function InstallmentSelectionTable({
|
||||
installments,
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
}: InstallmentSelectionTableProps) {
|
||||
const toggleSelection = (id: string) => {
|
||||
const newSelection = selectedIds.includes(id)
|
||||
? selectedIds.filter((selectedId) => selectedId !== id)
|
||||
: [...selectedIds, id];
|
||||
onSelectionChange(newSelection);
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selectedIds.length === installments.length && installments.length > 0) {
|
||||
onSelectionChange([]);
|
||||
} else {
|
||||
onSelectionChange(installments.map((inst) => inst.id));
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: Date | null) => {
|
||||
if (!date) return "—";
|
||||
return format(date, "dd/MM/yyyy", { locale: ptBR });
|
||||
};
|
||||
|
||||
if (installments.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhuma parcela elegível para antecipação encontrada.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Todas as parcelas desta compra já foram pagas ou antecipadas.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === installments.length &&
|
||||
installments.length > 0
|
||||
}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label="Selecionar todas as parcelas"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Parcela</TableHead>
|
||||
<TableHead>Período</TableHead>
|
||||
<TableHead>Vencimento</TableHead>
|
||||
<TableHead className="text-right">Valor</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{installments.map((inst) => {
|
||||
const isSelected = selectedIds.includes(inst.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={inst.id}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors",
|
||||
isSelected && "bg-muted/50",
|
||||
)}
|
||||
onClick={() => toggleSelection(inst.id)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelection(inst.id)}
|
||||
aria-label={`Selecionar parcela ${inst.currentInstallment}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{formatCurrentInstallment(
|
||||
inst.currentInstallment ?? 0,
|
||||
inst.installmentCount ?? 0,
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{formatShortPeriodLabel(inst.period)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDate(inst.dueDate)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">
|
||||
<MoneyValues amount={Number(inst.amount)} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="border-t bg-muted/20 px-4 py-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{selectedIds.length}{" "}
|
||||
{selectedIds.length === 1
|
||||
? "parcela selecionada"
|
||||
: "parcelas selecionadas"}
|
||||
</span>
|
||||
<span className="font-semibold">
|
||||
Total:{" "}
|
||||
<MoneyValues
|
||||
amount={installments
|
||||
.filter((inst) => selectedIds.includes(inst.id))
|
||||
.reduce((sum, inst) => sum + Number(inst.amount), 0)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group";
|
||||
|
||||
export type BulkActionScope = "current" | "future" | "all";
|
||||
|
||||
type BulkActionDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
actionType: "edit" | "delete";
|
||||
seriesType: "installment" | "recurring";
|
||||
currentNumber?: number;
|
||||
totalCount?: number;
|
||||
onConfirm: (scope: BulkActionScope) => void;
|
||||
};
|
||||
|
||||
export function BulkActionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
actionType,
|
||||
seriesType,
|
||||
currentNumber,
|
||||
totalCount,
|
||||
onConfirm,
|
||||
}: BulkActionDialogProps) {
|
||||
const [scope, setScope] = useState<BulkActionScope>("current");
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(scope);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const seriesLabel =
|
||||
seriesType === "installment" ? "parcelamento" : "recorrência";
|
||||
const actionLabel = actionType === "edit" ? "editar" : "remover";
|
||||
|
||||
const getDescription = () => {
|
||||
if (seriesType === "installment" && currentNumber && totalCount) {
|
||||
return `Este lançamento faz parte de um ${seriesLabel} (${currentNumber}/${totalCount}). Escolha o que deseja ${actionLabel}:`;
|
||||
}
|
||||
return `Este lançamento faz parte de uma ${seriesLabel}. Escolha o que deseja ${actionLabel}:`;
|
||||
};
|
||||
|
||||
const getCurrentLabel = () => {
|
||||
if (seriesType === "installment" && currentNumber) {
|
||||
return `Apenas esta parcela (${currentNumber}/${totalCount})`;
|
||||
}
|
||||
return "Apenas este lançamento";
|
||||
};
|
||||
|
||||
const getFutureLabel = () => {
|
||||
if (seriesType === "installment" && currentNumber && totalCount) {
|
||||
const remaining = totalCount - currentNumber + 1;
|
||||
return `Esta e as próximas parcelas (${remaining} ${
|
||||
remaining === 1 ? "parcela" : "parcelas"
|
||||
})`;
|
||||
}
|
||||
return "Este e os próximos lançamentos";
|
||||
};
|
||||
|
||||
const getAllLabel = () => {
|
||||
if (seriesType === "installment" && totalCount) {
|
||||
return `Todas as parcelas (${totalCount} ${
|
||||
totalCount === 1 ? "parcela" : "parcelas"
|
||||
})`;
|
||||
}
|
||||
return `Todos os lançamentos da ${seriesLabel}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="capitalize">
|
||||
{actionLabel} {seriesLabel}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{getDescription()}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<RadioGroup
|
||||
value={scope}
|
||||
onValueChange={(v) => setScope(v as BulkActionScope)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="current" id="current" className="mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<Label
|
||||
htmlFor="current"
|
||||
className="text-sm cursor-pointer font-medium"
|
||||
>
|
||||
{getCurrentLabel()}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Aplica a alteração apenas neste lançamento
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="future" id="future" className="mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<Label
|
||||
htmlFor="future"
|
||||
className="text-sm cursor-pointer font-medium"
|
||||
>
|
||||
{getFutureLabel()}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Aplica a alteração neste e nos próximos lançamentos da série
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="all" id="all" className="mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<Label
|
||||
htmlFor="all"
|
||||
className="text-sm cursor-pointer font-medium"
|
||||
>
|
||||
{getAllLabel()}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Aplica a alteração em todos os lançamentos da série
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
variant={actionType === "delete" ? "destructive" : "default"}
|
||||
>
|
||||
Confirmar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { createLancamentoAction } from "@/features/transactions/actions";
|
||||
import { groupAndSortCategorias } from "@/features/transactions/categoria-helpers";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select";
|
||||
import {
|
||||
CategoriaSelectContent,
|
||||
ContaCartaoSelectContent,
|
||||
PagadorSelectContent,
|
||||
} from "../select-items";
|
||||
import type { LancamentoItem, SelectOption } from "../types";
|
||||
|
||||
interface BulkImportDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
items: LancamentoItem[];
|
||||
pagadorOptions: SelectOption[];
|
||||
contaOptions: SelectOption[];
|
||||
cartaoOptions: SelectOption[];
|
||||
categoriaOptions: SelectOption[];
|
||||
defaultPagadorId?: string | null;
|
||||
}
|
||||
|
||||
export function BulkImportDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
items,
|
||||
pagadorOptions,
|
||||
contaOptions,
|
||||
cartaoOptions,
|
||||
categoriaOptions,
|
||||
defaultPagadorId,
|
||||
}: BulkImportDialogProps) {
|
||||
const [pagadorId, setPagadorId] = useState<string | undefined>(
|
||||
defaultPagadorId ?? undefined,
|
||||
);
|
||||
const [categoriaId, setCategoriaId] = useState<string | undefined>(undefined);
|
||||
const [contaId, setContaId] = useState<string | undefined>(undefined);
|
||||
const [cartaoId, setCartaoId] = useState<string | undefined>(undefined);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
type CreateLancamentoInput = Parameters<typeof createLancamentoAction>[0];
|
||||
|
||||
// Reset form when dialog opens/closes
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
setPagadorId(defaultPagadorId ?? undefined);
|
||||
setCategoriaId(undefined);
|
||||
setContaId(undefined);
|
||||
setCartaoId(undefined);
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
const categoriaGroups = useMemo(() => {
|
||||
// Get unique transaction types from items
|
||||
const transactionTypes = new Set(items.map((item) => item.transactionType));
|
||||
|
||||
// Filter categories based on transaction types
|
||||
const filtered = categoriaOptions.filter((option) => {
|
||||
if (!option.group) return false;
|
||||
return Array.from(transactionTypes).some(
|
||||
(type) => option.group?.toLowerCase() === type.toLowerCase(),
|
||||
);
|
||||
});
|
||||
|
||||
return groupAndSortCategorias(filtered);
|
||||
}, [categoriaOptions, items]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!pagadorId) {
|
||||
toast.error("Selecione o pagador.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!categoriaId) {
|
||||
toast.error("Selecione a categoria.");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const sanitizedAmount = Math.abs(item.amount);
|
||||
|
||||
// Determine payment method based on original item
|
||||
const isCredit = item.paymentMethod === "Cartão de crédito";
|
||||
|
||||
// Validate payment method fields
|
||||
if (isCredit && !cartaoId) {
|
||||
toast.error("Selecione um cartão de crédito.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCredit && !contaId) {
|
||||
toast.error("Selecione uma conta.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: CreateLancamentoInput = {
|
||||
purchaseDate: item.purchaseDate,
|
||||
period: item.period,
|
||||
name: item.name,
|
||||
transactionType:
|
||||
item.transactionType as CreateLancamentoInput["transactionType"],
|
||||
amount: sanitizedAmount,
|
||||
condition: item.condition as CreateLancamentoInput["condition"],
|
||||
paymentMethod:
|
||||
item.paymentMethod as CreateLancamentoInput["paymentMethod"],
|
||||
pagadorId: pagadorId ?? null,
|
||||
secondaryPagadorId: undefined,
|
||||
isSplit: false,
|
||||
contaId: isCredit ? null : (contaId ?? null),
|
||||
cartaoId: isCredit ? (cartaoId ?? null) : null,
|
||||
categoriaId: categoriaId ?? null,
|
||||
note: item.note ?? null,
|
||||
isSettled: isCredit ? null : Boolean(item.isSettled),
|
||||
installmentCount:
|
||||
item.condition === "Parcelado" && item.installmentCount
|
||||
? Number(item.installmentCount)
|
||||
: undefined,
|
||||
recurrenceCount:
|
||||
item.condition === "Recorrente" && item.recurrenceCount
|
||||
? Number(item.recurrenceCount)
|
||||
: undefined,
|
||||
dueDate:
|
||||
item.paymentMethod === "Boleto" && item.dueDate
|
||||
? item.dueDate
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const result = await createLancamentoAction(payload);
|
||||
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
errorCount++;
|
||||
console.error(`Failed to import ${item.name}:`, result.error);
|
||||
}
|
||||
}
|
||||
|
||||
if (errorCount === 0) {
|
||||
toast.success(
|
||||
`${successCount} ${
|
||||
successCount === 1
|
||||
? "lançamento importado"
|
||||
: "lançamentos importados"
|
||||
} com sucesso!`,
|
||||
);
|
||||
handleOpenChange(false);
|
||||
} else if (successCount > 0) {
|
||||
toast.warning(
|
||||
`${successCount} importados, ${errorCount} falharam. Verifique o console para detalhes.`,
|
||||
);
|
||||
} else {
|
||||
toast.error("Falha ao importar lançamentos. Verifique o console.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const itemCount = items.length;
|
||||
const hasCredit = items.some(
|
||||
(item) => item.paymentMethod === "Cartão de crédito",
|
||||
);
|
||||
const hasNonCredit = items.some(
|
||||
(item) => item.paymentMethod !== "Cartão de crédito",
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Importar Lançamentos</DialogTitle>
|
||||
<DialogDescription>
|
||||
Importando {itemCount}{" "}
|
||||
{itemCount === 1 ? "lançamento" : "lançamentos"}. Selecione o
|
||||
pagador, categoria e forma de pagamento para aplicar a todos.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pagador">Pagador *</Label>
|
||||
<Select value={pagadorId} onValueChange={setPagadorId}>
|
||||
<SelectTrigger id="pagador" className="w-full">
|
||||
<SelectValue placeholder="Selecione o pagador">
|
||||
{pagadorId &&
|
||||
(() => {
|
||||
const selectedOption = pagadorOptions.find(
|
||||
(opt) => opt.value === pagadorId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<PagadorSelectContent
|
||||
label={selectedOption.label}
|
||||
avatarUrl={selectedOption.avatarUrl}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pagadorOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<PagadorSelectContent
|
||||
label={option.label}
|
||||
avatarUrl={option.avatarUrl}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="categoria">Categoria *</Label>
|
||||
<Select value={categoriaId} onValueChange={setCategoriaId}>
|
||||
<SelectTrigger id="categoria" className="w-full">
|
||||
<SelectValue placeholder="Selecione a categoria">
|
||||
{categoriaId &&
|
||||
(() => {
|
||||
const selectedOption = categoriaOptions.find(
|
||||
(opt) => opt.value === categoriaId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<CategoriaSelectContent
|
||||
label={selectedOption.label}
|
||||
icon={selectedOption.icon}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoriaGroups.map((group) => (
|
||||
<SelectGroup key={group.label}>
|
||||
<SelectLabel>{group.label}</SelectLabel>
|
||||
{group.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<CategoriaSelectContent
|
||||
label={option.label}
|
||||
icon={option.icon}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{hasNonCredit && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conta">
|
||||
Conta {hasCredit ? "(para não cartão)" : "*"}
|
||||
</Label>
|
||||
<Select value={contaId} onValueChange={setContaId}>
|
||||
<SelectTrigger id="conta" className="w-full">
|
||||
<SelectValue placeholder="Selecione a conta">
|
||||
{contaId &&
|
||||
(() => {
|
||||
const selectedOption = contaOptions.find(
|
||||
(opt) => opt.value === contaId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<ContaCartaoSelectContent
|
||||
label={selectedOption.label}
|
||||
logo={selectedOption.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{contaOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<ContaCartaoSelectContent
|
||||
label={option.label}
|
||||
logo={option.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasCredit && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cartao">
|
||||
Cartão {hasNonCredit ? "(para cartão de crédito)" : "*"}
|
||||
</Label>
|
||||
<Select value={cartaoId} onValueChange={setCartaoId}>
|
||||
<SelectTrigger id="cartao" className="w-full">
|
||||
<SelectValue placeholder="Selecione o cartão">
|
||||
{cartaoId &&
|
||||
(() => {
|
||||
const selectedOption = cartaoOptions.find(
|
||||
(opt) => opt.value === cartaoId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<ContaCartaoSelectContent
|
||||
label={selectedOption.label}
|
||||
logo={selectedOption.logo}
|
||||
isCartao={true}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cartaoOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<ContaCartaoSelectContent
|
||||
label={option.label}
|
||||
logo={option.logo}
|
||||
isCartao={true}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Importando..." : "Importar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
667
src/features/transactions/components/dialogs/mass-add-dialog.tsx
Normal file
667
src/features/transactions/components/dialogs/mass-add-dialog.tsx
Normal file
@@ -0,0 +1,667 @@
|
||||
"use client";
|
||||
|
||||
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { groupAndSortCategorias } from "@/features/transactions/categoria-helpers";
|
||||
import {
|
||||
LANCAMENTO_PAYMENT_METHODS,
|
||||
type LANCAMENTO_TRANSACTION_TYPES,
|
||||
} from "@/features/transactions/constants";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
||||
import { DatePicker } from "@/shared/components/ui/date-picker";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import { MonthPicker } from "@/shared/components/ui/month-picker";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/shared/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select";
|
||||
import { Separator } from "@/shared/components/ui/separator";
|
||||
import { Spinner } from "@/shared/components/ui/spinner";
|
||||
import { getTodayDateString } from "@/shared/utils/date";
|
||||
import {
|
||||
dateToPeriod,
|
||||
displayPeriod,
|
||||
periodToDate,
|
||||
} from "@/shared/utils/period";
|
||||
import {
|
||||
CategoriaSelectContent,
|
||||
ContaCartaoSelectContent,
|
||||
PagadorSelectContent,
|
||||
PaymentMethodSelectContent,
|
||||
TransactionTypeSelectContent,
|
||||
} from "../select-items";
|
||||
import { EstabelecimentoInput } from "../shared/establishment-input";
|
||||
import type { SelectOption } from "../types";
|
||||
|
||||
/** Payment methods sem Boleto para este modal */
|
||||
const MASS_ADD_PAYMENT_METHODS = LANCAMENTO_PAYMENT_METHODS.filter(
|
||||
(m) => m !== "Boleto",
|
||||
);
|
||||
type MassAddTransactionType = (typeof LANCAMENTO_TRANSACTION_TYPES)[number];
|
||||
type MassAddPaymentMethod = (typeof LANCAMENTO_PAYMENT_METHODS)[number];
|
||||
|
||||
function InlinePeriodPicker({
|
||||
period,
|
||||
onPeriodChange,
|
||||
}: {
|
||||
period: string;
|
||||
onPeriodChange: (value: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="-mt-1">
|
||||
<span className="text-xs text-muted-foreground">Fatura de </span>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary underline-offset-2 hover:underline cursor-pointer lowercase"
|
||||
>
|
||||
{displayPeriod(period)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<MonthPicker
|
||||
selectedMonth={periodToDate(period)}
|
||||
onMonthSelect={(date) => {
|
||||
onPeriodChange(dateToPeriod(date));
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MassAddDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: MassAddFormData) => Promise<void>;
|
||||
pagadorOptions: SelectOption[];
|
||||
contaOptions: SelectOption[];
|
||||
cartaoOptions: SelectOption[];
|
||||
categoriaOptions: SelectOption[];
|
||||
estabelecimentos: string[];
|
||||
selectedPeriod: string;
|
||||
defaultPagadorId?: string | null;
|
||||
defaultCartaoId?: string | null;
|
||||
}
|
||||
|
||||
export type MassAddFormData = Parameters<
|
||||
typeof import("@/features/transactions/actions").createMassLancamentosAction
|
||||
>[0];
|
||||
|
||||
interface TransactionRow {
|
||||
id: string;
|
||||
purchaseDate: string;
|
||||
name: string;
|
||||
amount: string;
|
||||
categoriaId: string | undefined;
|
||||
pagadorId: string | undefined;
|
||||
}
|
||||
|
||||
export function MassAddDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
pagadorOptions,
|
||||
contaOptions,
|
||||
cartaoOptions,
|
||||
categoriaOptions,
|
||||
estabelecimentos,
|
||||
selectedPeriod,
|
||||
defaultPagadorId,
|
||||
defaultCartaoId,
|
||||
}: MassAddDialogProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Fixed fields state (sempre ativos, sem checkboxes)
|
||||
const [transactionType, setTransactionType] =
|
||||
useState<MassAddTransactionType>("Despesa");
|
||||
const [paymentMethod, setPaymentMethod] = useState<MassAddPaymentMethod>(
|
||||
LANCAMENTO_PAYMENT_METHODS[0],
|
||||
);
|
||||
const [period, setPeriod] = useState<string>(selectedPeriod);
|
||||
const [contaId, setContaId] = useState<string | undefined>(undefined);
|
||||
const [cartaoId, setCartaoId] = useState<string | undefined>(
|
||||
defaultCartaoId ?? undefined,
|
||||
);
|
||||
|
||||
// Quando defaultCartaoId está definido, exibe apenas o cartão específico
|
||||
const isLockedToCartao = !!defaultCartaoId;
|
||||
|
||||
const isCartaoSelected = paymentMethod === "Cartão de crédito";
|
||||
|
||||
// Transaction rows
|
||||
const [transactions, setTransactions] = useState<TransactionRow[]>([
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
purchaseDate: getTodayDateString(),
|
||||
name: "",
|
||||
amount: "",
|
||||
categoriaId: undefined,
|
||||
pagadorId: defaultPagadorId ?? undefined,
|
||||
},
|
||||
]);
|
||||
|
||||
// Categorias agrupadas e filtradas por tipo de transação
|
||||
const groupedCategorias = useMemo(() => {
|
||||
const filtered = categoriaOptions.filter(
|
||||
(option) => option.group?.toLowerCase() === transactionType.toLowerCase(),
|
||||
);
|
||||
return groupAndSortCategorias(filtered);
|
||||
}, [categoriaOptions, transactionType]);
|
||||
|
||||
const addTransaction = () => {
|
||||
setTransactions([
|
||||
...transactions,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
purchaseDate: getTodayDateString(),
|
||||
name: "",
|
||||
amount: "",
|
||||
categoriaId: undefined,
|
||||
pagadorId: defaultPagadorId ?? undefined,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeTransaction = (id: string) => {
|
||||
if (transactions.length === 1) {
|
||||
toast.error("É necessário ter pelo menos uma transação");
|
||||
return;
|
||||
}
|
||||
setTransactions(transactions.filter((t) => t.id !== id));
|
||||
};
|
||||
|
||||
const updateTransaction = (
|
||||
id: string,
|
||||
field: keyof TransactionRow,
|
||||
value: string | undefined,
|
||||
) => {
|
||||
setTransactions(
|
||||
transactions.map((t) => (t.id === id ? { ...t, [field]: value } : t)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validate conta/cartao selection
|
||||
if (isCartaoSelected && !cartaoId) {
|
||||
toast.error("Selecione um cartão para continuar");
|
||||
return;
|
||||
}
|
||||
if (!isCartaoSelected && !contaId) {
|
||||
toast.error("Selecione uma conta para continuar");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate transactions
|
||||
const invalidTransactions = transactions.filter(
|
||||
(t) => !t.name.trim() || !t.amount.trim() || !t.purchaseDate,
|
||||
);
|
||||
|
||||
if (invalidTransactions.length > 0) {
|
||||
toast.error(
|
||||
"Preencha todos os campos obrigatórios das transações (data, estabelecimento e valor)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build form data
|
||||
const formData: MassAddFormData = {
|
||||
fixedFields: {
|
||||
transactionType,
|
||||
paymentMethod,
|
||||
condition: "À vista",
|
||||
period,
|
||||
contaId,
|
||||
cartaoId,
|
||||
},
|
||||
transactions: transactions.map((t) => ({
|
||||
purchaseDate: t.purchaseDate,
|
||||
name: t.name.trim(),
|
||||
amount: Number(t.amount.trim()),
|
||||
categoriaId: t.categoriaId,
|
||||
pagadorId: t.pagadorId,
|
||||
})),
|
||||
};
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await onSubmit(formData);
|
||||
onOpenChange(false);
|
||||
// Reset form
|
||||
setTransactionType("Despesa");
|
||||
setPaymentMethod(LANCAMENTO_PAYMENT_METHODS[0]);
|
||||
setPeriod(selectedPeriod);
|
||||
setContaId(undefined);
|
||||
setCartaoId(defaultCartaoId ?? undefined);
|
||||
setTransactions([
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
purchaseDate: getTodayDateString(),
|
||||
name: "",
|
||||
amount: "",
|
||||
categoriaId: undefined,
|
||||
pagadorId: defaultPagadorId ?? undefined,
|
||||
},
|
||||
]);
|
||||
} catch (_error) {
|
||||
// Error is handled by the onSubmit function
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto p-6 sm:px-8">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Adicionar múltiplos lançamentos</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure os valores padrão e adicione várias transações de uma vez.
|
||||
Todos os lançamentos adicionados aqui são{" "}
|
||||
<span className="font-bold">sempre à vista</span>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Fixed Fields Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">Valores Padrão</h3>
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-3">
|
||||
{/* Transaction Type */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="transaction-type">Tipo de Transação</Label>
|
||||
<Select
|
||||
value={transactionType}
|
||||
onValueChange={(value) =>
|
||||
setTransactionType(value as MassAddTransactionType)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="transaction-type" className="w-full">
|
||||
<SelectValue>
|
||||
{transactionType && (
|
||||
<TransactionTypeSelectContent label={transactionType} />
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Despesa">
|
||||
<TransactionTypeSelectContent label="Despesa" />
|
||||
</SelectItem>
|
||||
<SelectItem value="Receita">
|
||||
<TransactionTypeSelectContent label="Receita" />
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment-method">Forma de Pagamento</Label>
|
||||
<Select
|
||||
value={paymentMethod}
|
||||
onValueChange={(value) => {
|
||||
setPaymentMethod(value as MassAddPaymentMethod);
|
||||
// Reset conta/cartao when changing payment method
|
||||
if (value === "Cartão de crédito") {
|
||||
setContaId(undefined);
|
||||
} else {
|
||||
setCartaoId(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="payment-method" className="w-full">
|
||||
<SelectValue>
|
||||
{paymentMethod && (
|
||||
<PaymentMethodSelectContent label={paymentMethod} />
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MASS_ADD_PAYMENT_METHODS.map((method) => (
|
||||
<SelectItem key={method} value={method}>
|
||||
<PaymentMethodSelectContent label={method} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Cartão (only for credit card) */}
|
||||
{isCartaoSelected ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cartao">Cartão</Label>
|
||||
<Select
|
||||
value={cartaoId}
|
||||
onValueChange={setCartaoId}
|
||||
disabled={isLockedToCartao}
|
||||
>
|
||||
<SelectTrigger id="cartao" className="w-full">
|
||||
<SelectValue placeholder="Selecione">
|
||||
{cartaoId &&
|
||||
(() => {
|
||||
const selectedOption = cartaoOptions.find(
|
||||
(opt) => opt.value === cartaoId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<ContaCartaoSelectContent
|
||||
label={selectedOption.label}
|
||||
logo={selectedOption.logo}
|
||||
isCartao={true}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cartaoOptions.length === 0 ? (
|
||||
<div className="px-2 py-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhum cartão cadastrado
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
cartaoOptions
|
||||
.filter(
|
||||
(option) =>
|
||||
!isLockedToCartao ||
|
||||
option.value === defaultCartaoId,
|
||||
)
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<ContaCartaoSelectContent
|
||||
label={option.label}
|
||||
logo={option.logo}
|
||||
isCartao={true}
|
||||
/>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{cartaoId ? (
|
||||
<InlinePeriodPicker
|
||||
period={period}
|
||||
onPeriodChange={setPeriod}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Conta (for non-credit-card methods) */}
|
||||
{!isCartaoSelected ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conta">Conta</Label>
|
||||
<Select value={contaId} onValueChange={setContaId}>
|
||||
<SelectTrigger id="conta" className="w-full">
|
||||
<SelectValue placeholder="Selecione">
|
||||
{contaId &&
|
||||
(() => {
|
||||
const selectedOption = contaOptions.find(
|
||||
(opt) => opt.value === contaId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<ContaCartaoSelectContent
|
||||
label={selectedOption.label}
|
||||
logo={selectedOption.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{contaOptions.length === 0 ? (
|
||||
<div className="px-2 py-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhuma conta cadastrada
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
contaOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<ContaCartaoSelectContent
|
||||
label={option.label}
|
||||
logo={option.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Transactions Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">Lançamentos</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{transactions.map((transaction, index) => (
|
||||
<div
|
||||
key={transaction.id}
|
||||
className="grid gap-2 border-b pb-3 border-dashed last:border-0"
|
||||
>
|
||||
<div className="flex gap-2 w-full">
|
||||
<div className="w-24 shrink-0">
|
||||
<Label
|
||||
htmlFor={`date-${transaction.id}`}
|
||||
className="sr-only"
|
||||
>
|
||||
Data {index + 1}
|
||||
</Label>
|
||||
<DatePicker
|
||||
id={`date-${transaction.id}`}
|
||||
value={transaction.purchaseDate}
|
||||
onChange={(value) =>
|
||||
updateTransaction(
|
||||
transaction.id,
|
||||
"purchaseDate",
|
||||
value,
|
||||
)
|
||||
}
|
||||
placeholder="Data"
|
||||
compact
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Label
|
||||
htmlFor={`name-${transaction.id}`}
|
||||
className="sr-only"
|
||||
>
|
||||
Estabelecimento {index + 1}
|
||||
</Label>
|
||||
<EstabelecimentoInput
|
||||
id={`name-${transaction.id}`}
|
||||
placeholder="Local"
|
||||
value={transaction.name}
|
||||
onChange={(value) =>
|
||||
updateTransaction(transaction.id, "name", value)
|
||||
}
|
||||
estabelecimentos={estabelecimentos}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<Label
|
||||
htmlFor={`amount-${transaction.id}`}
|
||||
className="sr-only"
|
||||
>
|
||||
Valor {index + 1}
|
||||
</Label>
|
||||
<CurrencyInput
|
||||
id={`amount-${transaction.id}`}
|
||||
placeholder="R$ 0,00"
|
||||
value={transaction.amount}
|
||||
onValueChange={(value) =>
|
||||
updateTransaction(transaction.id, "amount", value)
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<Label
|
||||
htmlFor={`pagador-${transaction.id}`}
|
||||
className="sr-only"
|
||||
>
|
||||
Pagador {index + 1}
|
||||
</Label>
|
||||
<Select
|
||||
value={transaction.pagadorId}
|
||||
onValueChange={(value) =>
|
||||
updateTransaction(transaction.id, "pagadorId", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`pagador-${transaction.id}`}
|
||||
className="w-32 truncate"
|
||||
>
|
||||
<SelectValue placeholder="Pagador">
|
||||
{transaction.pagadorId &&
|
||||
(() => {
|
||||
const selectedOption = pagadorOptions.find(
|
||||
(opt) => opt.value === transaction.pagadorId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<PagadorSelectContent
|
||||
label={selectedOption.label}
|
||||
avatarUrl={selectedOption.avatarUrl}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pagadorOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<PagadorSelectContent
|
||||
label={option.label}
|
||||
avatarUrl={option.avatarUrl}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<Label
|
||||
htmlFor={`categoria-${transaction.id}`}
|
||||
className="sr-only"
|
||||
>
|
||||
Categoria {index + 1}
|
||||
</Label>
|
||||
<Select
|
||||
value={transaction.categoriaId}
|
||||
onValueChange={(value) =>
|
||||
updateTransaction(
|
||||
transaction.id,
|
||||
"categoriaId",
|
||||
value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`categoria-${transaction.id}`}
|
||||
className="w-32 truncate"
|
||||
>
|
||||
<SelectValue placeholder="Categoria" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupedCategorias.map((group) => (
|
||||
<SelectGroup key={group.label}>
|
||||
<SelectLabel>{group.label}</SelectLabel>
|
||||
{group.options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
<CategoriaSelectContent
|
||||
label={option.label}
|
||||
icon={option.icon}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={addTransaction}
|
||||
>
|
||||
<RiAddLine className="size-3.5" />
|
||||
<span className="sr-only">Adicionar transação</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={() => removeTransaction(transaction.id)}
|
||||
disabled={transactions.length === 1}
|
||||
>
|
||||
<RiDeleteBinLine className="size-3.5" />
|
||||
<span className="sr-only">Remover transação</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading}>
|
||||
{loading && <Spinner className="size-4" />}
|
||||
Criar {transactions.length}{" "}
|
||||
{transactions.length === 1 ? "lançamento" : "lançamentos"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
currencyFormatter,
|
||||
formatCondition,
|
||||
formatDate,
|
||||
formatPeriod,
|
||||
getTransactionBadgeVariant,
|
||||
} from "@/features/transactions/formatting-helpers";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
} from "@/shared/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import { Separator } from "@/shared/components/ui/separator";
|
||||
import { parseLocalDateString } from "@/shared/utils/date";
|
||||
import { getPaymentMethodIcon } from "@/shared/utils/icons";
|
||||
import { InstallmentTimeline } from "../shared/installment-timeline";
|
||||
import type { LancamentoItem } from "../types";
|
||||
|
||||
interface LancamentoDetailsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
lancamento: LancamentoItem | null;
|
||||
}
|
||||
|
||||
export function LancamentoDetailsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
lancamento,
|
||||
}: LancamentoDetailsDialogProps) {
|
||||
if (!lancamento) return null;
|
||||
|
||||
const isInstallment =
|
||||
lancamento.condition?.toLowerCase() === "parcelado" &&
|
||||
lancamento.currentInstallment &&
|
||||
lancamento.installmentCount;
|
||||
|
||||
const valorParcela = Math.abs(lancamento.amount);
|
||||
const totalParcelas = lancamento.installmentCount ?? 1;
|
||||
const parcelaAtual = lancamento.currentInstallment ?? 1;
|
||||
const valorTotal = isInstallment
|
||||
? valorParcela * totalParcelas
|
||||
: valorParcela;
|
||||
const valorRestante = isInstallment
|
||||
? valorParcela * (totalParcelas - parcelaAtual)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="p-0 sm:max-w-xl sm:border-0 sm:p-2">
|
||||
<div className="gap-2 space-y-4 py-4">
|
||||
<CardHeader className="flex flex-row items-start border-b sm:border-b-0">
|
||||
<div>
|
||||
<DialogTitle className="group flex items-center gap-2 text-lg">
|
||||
#{lancamento.id}
|
||||
</DialogTitle>
|
||||
<CardDescription>
|
||||
{formatDate(lancamento.purchaseDate)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="text-sm">
|
||||
<div className="grid gap-3">
|
||||
<ul className="grid gap-3">
|
||||
<DetailRow label="Descrição" value={lancamento.name} />
|
||||
|
||||
<DetailRow
|
||||
label="Período"
|
||||
value={formatPeriod(lancamento.period)}
|
||||
/>
|
||||
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
Forma de Pagamento
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{getPaymentMethodIcon(lancamento.paymentMethod)}
|
||||
<span className="capitalize">
|
||||
{lancamento.paymentMethod}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
|
||||
<DetailRow
|
||||
label={lancamento.cartaoName ? "Cartão" : "Conta"}
|
||||
value={lancamento.cartaoName ?? lancamento.contaName ?? "—"}
|
||||
/>
|
||||
|
||||
<DetailRow
|
||||
label="Categoria"
|
||||
value={lancamento.categoriaName ?? "—"}
|
||||
/>
|
||||
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
Tipo de Transação
|
||||
</span>
|
||||
<span className="capitalize">
|
||||
<Badge
|
||||
variant={getTransactionBadgeVariant(
|
||||
lancamento.categoriaName === "Saldo inicial"
|
||||
? "Saldo inicial"
|
||||
: lancamento.transactionType,
|
||||
)}
|
||||
>
|
||||
{lancamento.categoriaName === "Saldo inicial"
|
||||
? "Saldo Inicial"
|
||||
: lancamento.transactionType}
|
||||
</Badge>
|
||||
</span>
|
||||
</li>
|
||||
|
||||
<DetailRow
|
||||
label="Condição"
|
||||
value={formatCondition(lancamento.condition)}
|
||||
/>
|
||||
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Responsável</span>
|
||||
<span className="flex items-center gap-2 capitalize">
|
||||
<span>{lancamento.pagadorName}</span>
|
||||
</span>
|
||||
</li>
|
||||
|
||||
<DetailRow
|
||||
label="Status"
|
||||
value={lancamento.isSettled ? "Pago" : "Pendente"}
|
||||
/>
|
||||
|
||||
{lancamento.note && (
|
||||
<DetailRow label="Notas" value={lancamento.note} />
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<ul className="mb-6 grid gap-3">
|
||||
{isInstallment && (
|
||||
<li className="mt-4">
|
||||
<InstallmentTimeline
|
||||
purchaseDate={parseLocalDateString(
|
||||
lancamento.purchaseDate,
|
||||
)}
|
||||
currentInstallment={parcelaAtual}
|
||||
totalInstallments={totalParcelas}
|
||||
period={lancamento.period}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
|
||||
<DetailRow
|
||||
label={isInstallment ? "Valor da Parcela" : "Valor"}
|
||||
value={currencyFormatter.format(valorParcela)}
|
||||
/>
|
||||
|
||||
{isInstallment && (
|
||||
<DetailRow
|
||||
label="Valor Restante"
|
||||
value={currencyFormatter.format(valorRestante)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{lancamento.recurrenceCount && (
|
||||
<DetailRow
|
||||
label="Quantidade de Recorrências"
|
||||
value={`${lancamento.recurrenceCount} meses`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isInstallment && <Separator className="my-2" />}
|
||||
|
||||
<li className="flex items-center justify-between font-semibold">
|
||||
<span className="text-muted-foreground">Total da Compra</span>
|
||||
<span className="text-lg">
|
||||
{currencyFormatter.format(valorTotal)}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button">Entendi</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</CardContent>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface DetailRowProps {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: DetailRowProps) {
|
||||
return (
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="capitalize">{value}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { RiCalculatorLine } from "@remixicon/react";
|
||||
import { CalculatorDialogButton } from "@/shared/components/calculator/calculator-dialog";
|
||||
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
||||
import { DatePicker } from "@/shared/components/ui/date-picker";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import { EstabelecimentoInput } from "../../shared/establishment-input";
|
||||
import type { BasicFieldsSectionProps } from "./transaction-dialog-types";
|
||||
|
||||
export function BasicFieldsSection({
|
||||
formState,
|
||||
onFieldChange,
|
||||
estabelecimentos,
|
||||
}: Omit<BasicFieldsSectionProps, "monthOptions">) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="name">Estabelecimento</Label>
|
||||
<EstabelecimentoInput
|
||||
id="name"
|
||||
value={formState.name}
|
||||
onChange={(value) => onFieldChange("name", value)}
|
||||
estabelecimentos={estabelecimentos}
|
||||
placeholder="Ex.: Restaurante do Zé"
|
||||
maxLength={20}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||
<div className="w-full md:w-1/2 space-y-1">
|
||||
<Label htmlFor="purchaseDate">Data</Label>
|
||||
<DatePicker
|
||||
id="purchaseDate"
|
||||
value={formState.purchaseDate}
|
||||
onChange={(value) => onFieldChange("purchaseDate", value)}
|
||||
placeholder="Data"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-1/2 space-y-1">
|
||||
<Label htmlFor="amount">Valor</Label>
|
||||
<div className="relative">
|
||||
<CurrencyInput
|
||||
id="amount"
|
||||
value={formState.amount}
|
||||
onValueChange={(value) => onFieldChange("amount", value)}
|
||||
placeholder="R$ 0,00"
|
||||
required
|
||||
className="pr-10"
|
||||
/>
|
||||
<CalculatorDialogButton
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2"
|
||||
onSelectValue={(value) => onFieldChange("amount", value)}
|
||||
>
|
||||
<RiCalculatorLine className="h-4 w-4 text-muted-foreground" />
|
||||
</CalculatorDialogButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { DatePicker } from "@/shared/components/ui/date-picker";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import type { BoletoFieldsSectionProps } from "./transaction-dialog-types";
|
||||
|
||||
export function BoletoFieldsSection({
|
||||
formState,
|
||||
onFieldChange,
|
||||
showPaymentDate,
|
||||
}: BoletoFieldsSectionProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-1 w-full",
|
||||
showPaymentDate ? "md:w-1/2" : "md:w-full",
|
||||
)}
|
||||
>
|
||||
<Label htmlFor="dueDate">Vencimento do boleto</Label>
|
||||
<DatePicker
|
||||
id="dueDate"
|
||||
value={formState.dueDate}
|
||||
onChange={(value) => onFieldChange("dueDate", value)}
|
||||
placeholder="Selecione o vencimento"
|
||||
/>
|
||||
</div>
|
||||
{showPaymentDate ? (
|
||||
<div className="space-y-2 w-full md:w-1/2">
|
||||
<Label htmlFor="boletoPaymentDate">Pagamento do boleto</Label>
|
||||
<DatePicker
|
||||
id="boletoPaymentDate"
|
||||
value={formState.boletoPaymentDate}
|
||||
onChange={(value) => onFieldChange("boletoPaymentDate", value)}
|
||||
placeholder="Selecione a data de pagamento"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { LANCAMENTO_TRANSACTION_TYPES } from "@/features/transactions/constants";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import {
|
||||
CategoriaSelectContent,
|
||||
TransactionTypeSelectContent,
|
||||
} from "../../select-items";
|
||||
import type { CategorySectionProps } from "./transaction-dialog-types";
|
||||
|
||||
export function CategorySection({
|
||||
formState,
|
||||
onFieldChange,
|
||||
categoriaOptions,
|
||||
categoriaGroups,
|
||||
isUpdateMode,
|
||||
hideTransactionType = false,
|
||||
}: CategorySectionProps) {
|
||||
const showTransactionTypeField = !isUpdateMode && !hideTransactionType;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||
{showTransactionTypeField ? (
|
||||
<div className="w-full space-y-1 md:w-1/2">
|
||||
<Label htmlFor="transactionType">Tipo de transação</Label>
|
||||
<Select
|
||||
value={formState.transactionType}
|
||||
onValueChange={(value) => onFieldChange("transactionType", value)}
|
||||
>
|
||||
<SelectTrigger id="transactionType" className="w-full">
|
||||
<SelectValue placeholder="Selecione">
|
||||
{formState.transactionType && (
|
||||
<TransactionTypeSelectContent
|
||||
label={formState.transactionType}
|
||||
/>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANCAMENTO_TRANSACTION_TYPES.filter(
|
||||
(type) => type !== "Transferência",
|
||||
).map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
<TransactionTypeSelectContent label={type} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-1 w-full",
|
||||
showTransactionTypeField ? "md:w-1/2" : "md:w-full",
|
||||
)}
|
||||
>
|
||||
<Label htmlFor="categoria">Categoria</Label>
|
||||
<Select
|
||||
value={formState.categoriaId}
|
||||
onValueChange={(value) => onFieldChange("categoriaId", value)}
|
||||
>
|
||||
<SelectTrigger id="categoria" className="w-full">
|
||||
<SelectValue placeholder="Selecione">
|
||||
{formState.categoriaId &&
|
||||
(() => {
|
||||
const selectedOption = categoriaOptions.find(
|
||||
(opt) => opt.value === formState.categoriaId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<CategoriaSelectContent
|
||||
label={selectedOption.label}
|
||||
icon={selectedOption.icon}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoriaGroups.map((group) => (
|
||||
<SelectGroup key={group.label}>
|
||||
<SelectLabel>{group.label}</SelectLabel>
|
||||
{group.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<CategoriaSelectContent
|
||||
label={option.label}
|
||||
icon={option.icon}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { LANCAMENTO_CONDITIONS } from "@/features/transactions/constants";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select";
|
||||
import { formatCurrency } from "@/shared/utils/currency";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import { ConditionSelectContent } from "../../select-items";
|
||||
import type { ConditionSectionProps } from "./transaction-dialog-types";
|
||||
|
||||
export function ConditionSection({
|
||||
formState,
|
||||
onFieldChange,
|
||||
showInstallments,
|
||||
showRecurrence,
|
||||
}: ConditionSectionProps) {
|
||||
const parsedAmount = Number(formState.amount);
|
||||
const amount =
|
||||
Number.isNaN(parsedAmount) || parsedAmount <= 0 ? null : parsedAmount;
|
||||
|
||||
const getInstallmentLabel = (count: number) => {
|
||||
if (amount) {
|
||||
const installmentValue = amount / count;
|
||||
return `${count}x de R$ ${formatCurrency(installmentValue)}`;
|
||||
}
|
||||
|
||||
return `${count}x`;
|
||||
};
|
||||
|
||||
const installmentCount = Number(formState.installmentCount);
|
||||
const installmentSummary =
|
||||
showInstallments &&
|
||||
formState.installmentCount &&
|
||||
amount &&
|
||||
!Number.isNaN(installmentCount) &&
|
||||
installmentCount > 0
|
||||
? getInstallmentLabel(installmentCount)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-1 w-full",
|
||||
showInstallments || showRecurrence ? "md:w-1/2" : "md:w-full",
|
||||
)}
|
||||
>
|
||||
<Label htmlFor="condition">Condição</Label>
|
||||
<Select
|
||||
value={formState.condition}
|
||||
onValueChange={(value) => onFieldChange("condition", value)}
|
||||
>
|
||||
<SelectTrigger id="condition" className="w-full">
|
||||
<SelectValue placeholder="Selecione">
|
||||
{formState.condition && (
|
||||
<ConditionSelectContent label={formState.condition} />
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANCAMENTO_CONDITIONS.map((condition) => (
|
||||
<SelectItem key={condition} value={condition}>
|
||||
<ConditionSelectContent label={condition} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{showInstallments ? (
|
||||
<div className="space-y-1 w-full md:w-1/2">
|
||||
<Label htmlFor="installmentCount">Parcelado em</Label>
|
||||
<Select
|
||||
value={formState.installmentCount}
|
||||
onValueChange={(value) => onFieldChange("installmentCount", value)}
|
||||
>
|
||||
<SelectTrigger id="installmentCount" className="w-full">
|
||||
<SelectValue placeholder="Selecione">
|
||||
{installmentSummary}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[...Array(24)].map((_, index) => {
|
||||
const count = index + 2;
|
||||
return (
|
||||
<SelectItem key={count} value={String(count)}>
|
||||
{getInstallmentLabel(count)}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showRecurrence ? (
|
||||
<div className="space-y-1 w-full md:w-1/2">
|
||||
<Label htmlFor="recurrenceInfo">Recorrência</Label>
|
||||
<p
|
||||
id="recurrenceInfo"
|
||||
className="text-xs text-muted-foreground rounded-md border border-dashed border-border p-2.5"
|
||||
>
|
||||
Este lançamento será repetido todo mês automaticamente até ser
|
||||
pausado ou cancelado.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import { Textarea } from "@/shared/components/ui/textarea";
|
||||
import type { NoteSectionProps } from "./transaction-dialog-types";
|
||||
|
||||
export function NoteSection({ formState, onFieldChange }: NoteSectionProps) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="note">Anotação</Label>
|
||||
<Textarea
|
||||
id="note"
|
||||
value={formState.note}
|
||||
onChange={(event) => onFieldChange("note", event.target.value)}
|
||||
placeholder="Adicione observações sobre o lançamento"
|
||||
rows={2}
|
||||
className="min-h-[36px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select";
|
||||
import { PagadorSelectContent } from "../../select-items";
|
||||
import type { PagadorSectionProps } from "./transaction-dialog-types";
|
||||
|
||||
export function PagadorSection({
|
||||
formState,
|
||||
onFieldChange,
|
||||
pagadorOptions,
|
||||
secondaryPagadorOptions,
|
||||
totalAmount,
|
||||
}: PagadorSectionProps) {
|
||||
const handlePrimaryAmountChange = (value: string) => {
|
||||
onFieldChange("primarySplitAmount", value);
|
||||
const numericValue = Number.parseFloat(value) || 0;
|
||||
const remaining = Math.max(0, totalAmount - numericValue);
|
||||
onFieldChange("secondarySplitAmount", remaining.toFixed(2));
|
||||
};
|
||||
|
||||
const handleSecondaryAmountChange = (value: string) => {
|
||||
onFieldChange("secondarySplitAmount", value);
|
||||
const numericValue = Number.parseFloat(value) || 0;
|
||||
const remaining = Math.max(0, totalAmount - numericValue);
|
||||
onFieldChange("primarySplitAmount", remaining.toFixed(2));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||
<div className="w-full space-y-1">
|
||||
<Label htmlFor="pagador">Pagador</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={formState.pagadorId}
|
||||
onValueChange={(value) => onFieldChange("pagadorId", value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="pagador"
|
||||
className={formState.isSplit ? "w-[55%]" : "w-full"}
|
||||
>
|
||||
<SelectValue placeholder="Selecione">
|
||||
{formState.pagadorId &&
|
||||
(() => {
|
||||
const selectedOption = pagadorOptions.find(
|
||||
(opt) => opt.value === formState.pagadorId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<PagadorSelectContent
|
||||
label={selectedOption.label}
|
||||
avatarUrl={selectedOption.avatarUrl}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pagadorOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<PagadorSelectContent
|
||||
label={option.label}
|
||||
avatarUrl={option.avatarUrl}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formState.isSplit && (
|
||||
<CurrencyInput
|
||||
value={formState.primarySplitAmount}
|
||||
onValueChange={handlePrimaryAmountChange}
|
||||
placeholder="R$ 0,00"
|
||||
className="h-9 w-[45%] text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formState.isSplit ? (
|
||||
<div className="w-full space-y-1 mb-1">
|
||||
<Label htmlFor="secondaryPagador">Dividir com</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={formState.secondaryPagadorId}
|
||||
onValueChange={(value) =>
|
||||
onFieldChange("secondaryPagadorId", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="secondaryPagador"
|
||||
disabled={secondaryPagadorOptions.length === 0}
|
||||
className="w-[55%]"
|
||||
>
|
||||
<SelectValue placeholder="Selecione">
|
||||
{formState.secondaryPagadorId &&
|
||||
(() => {
|
||||
const selectedOption = secondaryPagadorOptions.find(
|
||||
(opt) => opt.value === formState.secondaryPagadorId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<PagadorSelectContent
|
||||
label={selectedOption.label}
|
||||
avatarUrl={selectedOption.avatarUrl}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{secondaryPagadorOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<PagadorSelectContent
|
||||
label={option.label}
|
||||
avatarUrl={option.avatarUrl}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<CurrencyInput
|
||||
value={formState.secondarySplitAmount}
|
||||
onValueChange={handleSecondaryAmountChange}
|
||||
placeholder="R$ 0,00"
|
||||
className="h-9 w-[45%] text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { LANCAMENTO_PAYMENT_METHODS } from "@/features/transactions/constants";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import { MonthPicker } from "@/shared/components/ui/month-picker";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/shared/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select";
|
||||
import {
|
||||
dateToPeriod,
|
||||
displayPeriod,
|
||||
periodToDate,
|
||||
} from "@/shared/utils/period";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import {
|
||||
ContaCartaoSelectContent,
|
||||
PaymentMethodSelectContent,
|
||||
} from "../../select-items";
|
||||
import type { PaymentMethodSectionProps } from "./transaction-dialog-types";
|
||||
|
||||
function InlinePeriodPicker({
|
||||
period,
|
||||
onPeriodChange,
|
||||
}: {
|
||||
period: string;
|
||||
onPeriodChange: (value: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="ml-1">
|
||||
<span className="text-xs text-muted-foreground">Fatura de </span>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary underline-offset-2 hover:underline cursor-pointer lowercase"
|
||||
>
|
||||
{displayPeriod(period)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<MonthPicker
|
||||
selectedMonth={periodToDate(period)}
|
||||
onMonthSelect={(date) => {
|
||||
onPeriodChange(dateToPeriod(date));
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaymentMethodSection({
|
||||
formState,
|
||||
onFieldChange,
|
||||
contaOptions,
|
||||
cartaoOptions,
|
||||
isUpdateMode,
|
||||
disablePaymentMethod,
|
||||
disableCartaoSelect,
|
||||
}: PaymentMethodSectionProps) {
|
||||
const isCartaoSelected = formState.paymentMethod === "Cartão de crédito";
|
||||
const showContaSelect = [
|
||||
"Pix",
|
||||
"Dinheiro",
|
||||
"Boleto",
|
||||
"Cartão de débito",
|
||||
"Pré-Pago | VR/VA",
|
||||
"Transferência bancária",
|
||||
].includes(formState.paymentMethod);
|
||||
|
||||
// Filtrar contas apenas do tipo "Pré-Pago | VR/VA" quando forma de pagamento for "Pré-Pago | VR/VA"
|
||||
const filteredContaOptions =
|
||||
formState.paymentMethod === "Pré-Pago | VR/VA"
|
||||
? contaOptions.filter(
|
||||
(option) => option.accountType === "Pré-Pago | VR/VA",
|
||||
)
|
||||
: contaOptions;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isUpdateMode ? (
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-1 w-full",
|
||||
isCartaoSelected || showContaSelect ? "md:w-1/2" : "md:w-full",
|
||||
)}
|
||||
>
|
||||
<Label htmlFor="paymentMethod">Forma de pagamento</Label>
|
||||
<Select
|
||||
value={formState.paymentMethod}
|
||||
onValueChange={(value) => onFieldChange("paymentMethod", value)}
|
||||
disabled={disablePaymentMethod}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="paymentMethod"
|
||||
className="w-full"
|
||||
disabled={disablePaymentMethod}
|
||||
>
|
||||
<SelectValue placeholder="Selecione" className="w-full">
|
||||
{formState.paymentMethod && (
|
||||
<PaymentMethodSelectContent
|
||||
label={formState.paymentMethod}
|
||||
/>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANCAMENTO_PAYMENT_METHODS.map((method) => (
|
||||
<SelectItem key={method} value={method}>
|
||||
<PaymentMethodSelectContent label={method} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isCartaoSelected ? (
|
||||
<div className="space-y-1 w-full md:w-1/2">
|
||||
<Label htmlFor="cartao">Cartão</Label>
|
||||
<Select
|
||||
value={formState.cartaoId}
|
||||
onValueChange={(value) => onFieldChange("cartaoId", value)}
|
||||
disabled={disableCartaoSelect}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="cartao"
|
||||
className="w-full"
|
||||
disabled={disableCartaoSelect}
|
||||
>
|
||||
<SelectValue placeholder="Selecione">
|
||||
{formState.cartaoId &&
|
||||
(() => {
|
||||
const selectedOption = cartaoOptions.find(
|
||||
(opt) => opt.value === formState.cartaoId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<ContaCartaoSelectContent
|
||||
label={selectedOption.label}
|
||||
logo={selectedOption.logo}
|
||||
isCartao={true}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cartaoOptions.length === 0 ? (
|
||||
<div className="px-2 py-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhum cartão cadastrado
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
cartaoOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<ContaCartaoSelectContent
|
||||
label={option.label}
|
||||
logo={option.logo}
|
||||
isCartao={true}
|
||||
/>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formState.cartaoId ? (
|
||||
<InlinePeriodPicker
|
||||
period={formState.period}
|
||||
onPeriodChange={(value) => onFieldChange("period", value)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isCartaoSelected && showContaSelect ? (
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-1 w-full",
|
||||
!isUpdateMode ? "md:w-1/2" : "md:w-full",
|
||||
)}
|
||||
>
|
||||
<Label htmlFor="conta">Conta</Label>
|
||||
<Select
|
||||
value={formState.contaId}
|
||||
onValueChange={(value) => onFieldChange("contaId", value)}
|
||||
>
|
||||
<SelectTrigger id="conta" className="w-full">
|
||||
<SelectValue placeholder="Selecione">
|
||||
{formState.contaId &&
|
||||
(() => {
|
||||
const selectedOption = filteredContaOptions.find(
|
||||
(opt) => opt.value === formState.contaId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<ContaCartaoSelectContent
|
||||
label={selectedOption.label}
|
||||
logo={selectedOption.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredContaOptions.length === 0 ? (
|
||||
<div className="px-2 py-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhuma conta cadastrada
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredContaOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<ContaCartaoSelectContent
|
||||
label={option.label}
|
||||
logo={option.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isUpdateMode ? (
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||
{isCartaoSelected ? (
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-1 w-full",
|
||||
!isUpdateMode ? "md:w-1/2" : "md:w-full",
|
||||
)}
|
||||
>
|
||||
<Label htmlFor="cartaoUpdate">Cartão</Label>
|
||||
<Select
|
||||
value={formState.cartaoId}
|
||||
onValueChange={(value) => onFieldChange("cartaoId", value)}
|
||||
>
|
||||
<SelectTrigger id="cartaoUpdate" className="w-full">
|
||||
<SelectValue placeholder="Selecione">
|
||||
{formState.cartaoId &&
|
||||
(() => {
|
||||
const selectedOption = cartaoOptions.find(
|
||||
(opt) => opt.value === formState.cartaoId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<ContaCartaoSelectContent
|
||||
label={selectedOption.label}
|
||||
logo={selectedOption.logo}
|
||||
isCartao={true}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cartaoOptions.length === 0 ? (
|
||||
<div className="px-2 py-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhum cartão cadastrado
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
cartaoOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<ContaCartaoSelectContent
|
||||
label={option.label}
|
||||
logo={option.logo}
|
||||
isCartao={true}
|
||||
/>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formState.cartaoId ? (
|
||||
<InlinePeriodPicker
|
||||
period={formState.period}
|
||||
onPeriodChange={(value) => onFieldChange("period", value)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isCartaoSelected && showContaSelect ? (
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-1 w-full",
|
||||
!isUpdateMode ? "md:w-1/2" : "md:w-full",
|
||||
)}
|
||||
>
|
||||
<Label htmlFor="contaUpdate">Conta</Label>
|
||||
<Select
|
||||
value={formState.contaId}
|
||||
onValueChange={(value) => onFieldChange("contaId", value)}
|
||||
>
|
||||
<SelectTrigger id="contaUpdate" className="w-full">
|
||||
<SelectValue placeholder="Selecione">
|
||||
{formState.contaId &&
|
||||
(() => {
|
||||
const selectedOption = filteredContaOptions.find(
|
||||
(opt) => opt.value === formState.contaId,
|
||||
);
|
||||
return selectedOption ? (
|
||||
<ContaCartaoSelectContent
|
||||
label={selectedOption.label}
|
||||
logo={selectedOption.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredContaOptions.length === 0 ? (
|
||||
<div className="px-2 py-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhuma conta cadastrada
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredContaOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<ContaCartaoSelectContent
|
||||
label={option.label}
|
||||
logo={option.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import type { SplitAndSettlementSectionProps } from "./transaction-dialog-types";
|
||||
|
||||
export function SplitAndSettlementSection({
|
||||
formState,
|
||||
onFieldChange,
|
||||
showSettledToggle,
|
||||
}: SplitAndSettlementSectionProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-1",
|
||||
showSettledToggle ? "md:w-1/2 md:pr-2" : "md:w-full",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-foreground">Dividir lançamento</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Selecione para atribuir parte do valor a outro pagador.
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={formState.isSplit}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("isSplit", Boolean(checked))
|
||||
}
|
||||
aria-label="Dividir lançamento"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSettledToggle ? (
|
||||
<div className="space-y-1 md:w-1/2 md:pr-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-foreground">Marcar como pago</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Indica que o lançamento já foi pago ou recebido.
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={Boolean(formState.isSettled)}
|
||||
onCheckedChange={(checked) =>
|
||||
onFieldChange("isSettled", Boolean(checked))
|
||||
}
|
||||
aria-label="Marcar como concluído"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { LancamentoFormState } from "@/features/transactions/form-helpers";
|
||||
import type { LancamentoItem, SelectOption } from "../../types";
|
||||
|
||||
export type FormState = LancamentoFormState;
|
||||
|
||||
export interface LancamentoDialogProps {
|
||||
mode: "create" | "update";
|
||||
trigger?: React.ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
pagadorOptions: SelectOption[];
|
||||
splitPagadorOptions: SelectOption[];
|
||||
defaultPagadorId?: string | null;
|
||||
contaOptions: SelectOption[];
|
||||
cartaoOptions: SelectOption[];
|
||||
categoriaOptions: SelectOption[];
|
||||
estabelecimentos: string[];
|
||||
lancamento?: LancamentoItem;
|
||||
defaultPeriod?: string;
|
||||
defaultCartaoId?: string | null;
|
||||
defaultPaymentMethod?: string | null;
|
||||
defaultPurchaseDate?: string | null;
|
||||
defaultName?: string | null;
|
||||
defaultAmount?: string | null;
|
||||
lockCartaoSelection?: boolean;
|
||||
lockPaymentMethod?: boolean;
|
||||
isImporting?: boolean;
|
||||
defaultTransactionType?: "Despesa" | "Receita";
|
||||
/** Force showing transaction type select even when defaultTransactionType is set */
|
||||
forceShowTransactionType?: boolean;
|
||||
/** Called after successful create/update. Receives the action result. */
|
||||
onSuccess?: () => void;
|
||||
onBulkEditRequest?: (data: {
|
||||
id: string;
|
||||
name: string;
|
||||
categoriaId: string | undefined;
|
||||
note: string;
|
||||
pagadorId: string | undefined;
|
||||
contaId: string | undefined;
|
||||
cartaoId: string | undefined;
|
||||
amount: number;
|
||||
dueDate: string | null;
|
||||
boletoPaymentDate: string | null;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export interface BaseFieldSectionProps {
|
||||
formState: FormState;
|
||||
onFieldChange: <Key extends keyof FormState>(
|
||||
key: Key,
|
||||
value: FormState[Key],
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface BasicFieldsSectionProps extends BaseFieldSectionProps {
|
||||
estabelecimentos: string[];
|
||||
}
|
||||
|
||||
export interface CategorySectionProps extends BaseFieldSectionProps {
|
||||
categoriaOptions: SelectOption[];
|
||||
categoriaGroups: Array<{
|
||||
label: string;
|
||||
options: SelectOption[];
|
||||
}>;
|
||||
isUpdateMode: boolean;
|
||||
hideTransactionType?: boolean;
|
||||
}
|
||||
|
||||
export interface SplitAndSettlementSectionProps extends BaseFieldSectionProps {
|
||||
showSettledToggle: boolean;
|
||||
}
|
||||
|
||||
export interface PagadorSectionProps extends BaseFieldSectionProps {
|
||||
pagadorOptions: SelectOption[];
|
||||
secondaryPagadorOptions: SelectOption[];
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
export interface PaymentMethodSectionProps extends BaseFieldSectionProps {
|
||||
contaOptions: SelectOption[];
|
||||
cartaoOptions: SelectOption[];
|
||||
isUpdateMode: boolean;
|
||||
disablePaymentMethod: boolean;
|
||||
disableCartaoSelect: boolean;
|
||||
}
|
||||
|
||||
export interface BoletoFieldsSectionProps extends BaseFieldSectionProps {
|
||||
showPaymentDate: boolean;
|
||||
}
|
||||
|
||||
export interface ConditionSectionProps extends BaseFieldSectionProps {
|
||||
showInstallments: boolean;
|
||||
showRecurrence: boolean;
|
||||
}
|
||||
|
||||
export type NoteSectionProps = BaseFieldSectionProps;
|
||||
@@ -0,0 +1,522 @@
|
||||
"use client";
|
||||
import { RiAddLine } from "@remixicon/react";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createLancamentoAction,
|
||||
updateLancamentoAction,
|
||||
} from "@/features/transactions/actions";
|
||||
import {
|
||||
filterSecondaryPagadorOptions,
|
||||
groupAndSortCategorias,
|
||||
} from "@/features/transactions/categoria-helpers";
|
||||
import {
|
||||
applyFieldDependencies,
|
||||
buildLancamentoInitialState,
|
||||
deriveCreditCardPeriod,
|
||||
} from "@/features/transactions/form-helpers";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/shared/components/ui/collapsible";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import { useControlledState } from "@/shared/hooks/use-controlled-state";
|
||||
import { BasicFieldsSection } from "./basic-fields-section";
|
||||
import { BoletoFieldsSection } from "./boleto-fields-section";
|
||||
import { CategorySection } from "./category-section";
|
||||
import { ConditionSection } from "./condition-section";
|
||||
import { NoteSection } from "./note-section";
|
||||
import { PagadorSection } from "./pagador-section";
|
||||
import { PaymentMethodSection } from "./payment-method-section";
|
||||
import { SplitAndSettlementSection } from "./split-settlement-section";
|
||||
import type {
|
||||
FormState,
|
||||
LancamentoDialogProps,
|
||||
} from "./transaction-dialog-types";
|
||||
|
||||
export function LancamentoDialog({
|
||||
mode,
|
||||
trigger,
|
||||
open,
|
||||
onOpenChange,
|
||||
pagadorOptions,
|
||||
splitPagadorOptions,
|
||||
defaultPagadorId,
|
||||
contaOptions,
|
||||
cartaoOptions,
|
||||
categoriaOptions,
|
||||
estabelecimentos,
|
||||
lancamento,
|
||||
defaultPeriod,
|
||||
defaultCartaoId,
|
||||
defaultPaymentMethod,
|
||||
defaultPurchaseDate,
|
||||
defaultName,
|
||||
defaultAmount,
|
||||
lockCartaoSelection,
|
||||
lockPaymentMethod,
|
||||
isImporting = false,
|
||||
defaultTransactionType,
|
||||
forceShowTransactionType = false,
|
||||
onSuccess,
|
||||
onBulkEditRequest,
|
||||
}: LancamentoDialogProps) {
|
||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||
open,
|
||||
false,
|
||||
onOpenChange,
|
||||
);
|
||||
|
||||
const [formState, setFormState] = useState<FormState>(() =>
|
||||
buildLancamentoInitialState(lancamento, defaultPagadorId, defaultPeriod, {
|
||||
defaultCartaoId,
|
||||
defaultPaymentMethod,
|
||||
defaultPurchaseDate,
|
||||
defaultName,
|
||||
defaultAmount,
|
||||
defaultTransactionType,
|
||||
isImporting,
|
||||
}),
|
||||
);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
const initial = buildLancamentoInitialState(
|
||||
lancamento,
|
||||
defaultPagadorId,
|
||||
defaultPeriod,
|
||||
{
|
||||
defaultCartaoId,
|
||||
defaultPaymentMethod,
|
||||
defaultPurchaseDate,
|
||||
defaultName,
|
||||
defaultAmount,
|
||||
defaultTransactionType,
|
||||
isImporting,
|
||||
},
|
||||
);
|
||||
|
||||
// Derive credit card period on open when cartaoId is pre-filled
|
||||
if (
|
||||
initial.paymentMethod === "Cartão de crédito" &&
|
||||
initial.cartaoId &&
|
||||
initial.purchaseDate
|
||||
) {
|
||||
const card = cartaoOptions.find(
|
||||
(opt) => opt.value === initial.cartaoId,
|
||||
);
|
||||
if (card?.closingDay) {
|
||||
initial.period = deriveCreditCardPeriod(
|
||||
initial.purchaseDate,
|
||||
card.closingDay,
|
||||
card.dueDay,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setFormState(initial);
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, [
|
||||
dialogOpen,
|
||||
lancamento,
|
||||
defaultPagadorId,
|
||||
defaultPeriod,
|
||||
defaultCartaoId,
|
||||
defaultPaymentMethod,
|
||||
defaultPurchaseDate,
|
||||
defaultName,
|
||||
defaultAmount,
|
||||
defaultTransactionType,
|
||||
isImporting,
|
||||
cartaoOptions,
|
||||
]);
|
||||
|
||||
const primaryPagador = formState.pagadorId;
|
||||
|
||||
const secondaryPagadorOptions = useMemo(
|
||||
() => filterSecondaryPagadorOptions(splitPagadorOptions, primaryPagador),
|
||||
[splitPagadorOptions, primaryPagador],
|
||||
);
|
||||
|
||||
const categoriaGroups = useMemo(() => {
|
||||
const filtered = categoriaOptions.filter(
|
||||
(option) =>
|
||||
option.group?.toLowerCase() === formState.transactionType.toLowerCase(),
|
||||
);
|
||||
return groupAndSortCategorias(filtered);
|
||||
}, [categoriaOptions, formState.transactionType]);
|
||||
|
||||
type CreateLancamentoInput = Parameters<typeof createLancamentoAction>[0];
|
||||
type UpdateLancamentoInput = Parameters<typeof updateLancamentoAction>[0];
|
||||
|
||||
const totalAmount = useMemo(() => {
|
||||
const parsed = Number.parseFloat(formState.amount);
|
||||
return Number.isNaN(parsed) ? 0 : Math.abs(parsed);
|
||||
}, [formState.amount]);
|
||||
|
||||
function getCardInfo(cartaoId: string | undefined) {
|
||||
if (!cartaoId) return null;
|
||||
const card = cartaoOptions.find((opt) => opt.value === cartaoId);
|
||||
if (!card) return null;
|
||||
return {
|
||||
closingDay: card.closingDay ?? null,
|
||||
dueDay: card.dueDay ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function handleFieldChange<Key extends keyof FormState>(
|
||||
key: Key,
|
||||
value: FormState[Key],
|
||||
) {
|
||||
setFormState((prev) => {
|
||||
const effectiveCartaoId =
|
||||
key === "cartaoId" ? (value as string) : prev.cartaoId;
|
||||
const cardInfo = getCardInfo(effectiveCartaoId);
|
||||
|
||||
const dependencies = applyFieldDependencies(key, value, prev, cardInfo);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[key]: value,
|
||||
...dependencies,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
|
||||
if (!formState.purchaseDate) {
|
||||
const message = "Informe a data da transação.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formState.name.trim()) {
|
||||
const message = "Informe a descrição do lançamento.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formState.isSplit && !formState.pagadorId) {
|
||||
const message =
|
||||
"Selecione o pagador principal para dividir o lançamento.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formState.isSplit && !formState.secondaryPagadorId) {
|
||||
const message =
|
||||
"Selecione o pagador secundário para dividir o lançamento.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const amountValue = Number(formState.amount);
|
||||
if (Number.isNaN(amountValue)) {
|
||||
const message = "Informe um valor válido.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizedAmount = Math.abs(amountValue);
|
||||
|
||||
if (!formState.categoriaId) {
|
||||
const message = "Selecione uma categoria.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formState.paymentMethod === "Cartão de crédito") {
|
||||
if (!formState.cartaoId) {
|
||||
const message = "Selecione o cartão.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
} else if (!formState.contaId) {
|
||||
const message = "Selecione a conta.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: CreateLancamentoInput = {
|
||||
purchaseDate: formState.purchaseDate,
|
||||
period: formState.period,
|
||||
name: formState.name.trim(),
|
||||
transactionType:
|
||||
formState.transactionType as CreateLancamentoInput["transactionType"],
|
||||
amount: sanitizedAmount,
|
||||
condition: formState.condition as CreateLancamentoInput["condition"],
|
||||
paymentMethod:
|
||||
formState.paymentMethod as CreateLancamentoInput["paymentMethod"],
|
||||
pagadorId: formState.pagadorId ?? null,
|
||||
secondaryPagadorId: formState.isSplit
|
||||
? formState.secondaryPagadorId
|
||||
: undefined,
|
||||
isSplit: formState.isSplit,
|
||||
primarySplitAmount: formState.isSplit
|
||||
? Number.parseFloat(formState.primarySplitAmount) || undefined
|
||||
: undefined,
|
||||
secondarySplitAmount: formState.isSplit
|
||||
? Number.parseFloat(formState.secondarySplitAmount) || undefined
|
||||
: undefined,
|
||||
contaId: formState.contaId ?? null,
|
||||
cartaoId: formState.cartaoId ?? null,
|
||||
categoriaId: formState.categoriaId ?? null,
|
||||
note: formState.note.trim() || null,
|
||||
isSettled:
|
||||
formState.paymentMethod === "Cartão de crédito"
|
||||
? null
|
||||
: Boolean(formState.isSettled),
|
||||
installmentCount:
|
||||
formState.condition === "Parcelado" && formState.installmentCount
|
||||
? Number(formState.installmentCount)
|
||||
: undefined,
|
||||
recurrenceCount: undefined,
|
||||
dueDate:
|
||||
formState.paymentMethod === "Boleto" && formState.dueDate
|
||||
? formState.dueDate
|
||||
: undefined,
|
||||
boletoPaymentDate:
|
||||
mode === "update" &&
|
||||
formState.paymentMethod === "Boleto" &&
|
||||
formState.boletoPaymentDate
|
||||
? formState.boletoPaymentDate
|
||||
: undefined,
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
if (mode === "create") {
|
||||
const result = await createLancamentoAction(payload);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
onSuccess?.();
|
||||
setDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(result.error);
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update mode
|
||||
const hasSeriesId = Boolean(lancamento?.seriesId);
|
||||
|
||||
if (hasSeriesId && onBulkEditRequest) {
|
||||
// Para lançamentos em série, abre o diálogo de bulk action
|
||||
onBulkEditRequest({
|
||||
id: lancamento?.id ?? "",
|
||||
name: formState.name.trim(),
|
||||
categoriaId: formState.categoriaId,
|
||||
note: formState.note.trim() || "",
|
||||
pagadorId: formState.pagadorId,
|
||||
contaId: formState.contaId,
|
||||
cartaoId: formState.cartaoId,
|
||||
amount: sanitizedAmount,
|
||||
dueDate:
|
||||
formState.paymentMethod === "Boleto"
|
||||
? formState.dueDate || null
|
||||
: null,
|
||||
boletoPaymentDate:
|
||||
mode === "update" && formState.paymentMethod === "Boleto"
|
||||
? formState.boletoPaymentDate || null
|
||||
: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Atualização normal para lançamentos únicos ou todos os campos
|
||||
const updatePayload: UpdateLancamentoInput = {
|
||||
id: lancamento?.id ?? "",
|
||||
...payload,
|
||||
};
|
||||
|
||||
const result = await updateLancamentoAction(updatePayload);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
onSuccess?.();
|
||||
setDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(result.error);
|
||||
toast.error(result.error);
|
||||
});
|
||||
};
|
||||
|
||||
const isCopyMode = mode === "create" && Boolean(lancamento) && !isImporting;
|
||||
const isImportMode = mode === "create" && Boolean(lancamento) && isImporting;
|
||||
const isNewWithType =
|
||||
mode === "create" && !lancamento && defaultTransactionType;
|
||||
|
||||
const title =
|
||||
mode === "create"
|
||||
? isImportMode
|
||||
? "Importar para Minha Conta"
|
||||
: isCopyMode
|
||||
? "Copiar lançamento"
|
||||
: isNewWithType
|
||||
? defaultTransactionType === "Despesa"
|
||||
? "Nova Despesa"
|
||||
: "Nova Receita"
|
||||
: "Novo lançamento"
|
||||
: "Editar lançamento";
|
||||
const description =
|
||||
mode === "create"
|
||||
? isImportMode
|
||||
? "Importando lançamento de outro usuário. Ajuste a categoria, pagador e cartão/conta antes de salvar."
|
||||
: isCopyMode
|
||||
? "Os dados do lançamento foram copiados. Revise e ajuste conforme necessário antes de salvar."
|
||||
: isNewWithType
|
||||
? `Informe os dados abaixo para registrar ${defaultTransactionType === "Despesa" ? "uma nova despesa" : "uma nova receita"}.`
|
||||
: "Informe os dados abaixo para registrar um novo lançamento."
|
||||
: "Atualize as informações do lançamento selecionado.";
|
||||
const submitLabel = mode === "create" ? "Salvar lançamento" : "Atualizar";
|
||||
|
||||
const showInstallments = formState.condition === "Parcelado";
|
||||
const showRecurrence = formState.condition === "Recorrente";
|
||||
const showDueDate = formState.paymentMethod === "Boleto";
|
||||
const showPaymentDate = mode === "update" && showDueDate;
|
||||
const showSettledToggle = formState.paymentMethod !== "Cartão de crédito";
|
||||
const isUpdateMode = mode === "update";
|
||||
const disablePaymentMethod = Boolean(lockPaymentMethod && mode === "create");
|
||||
const disableCartaoSelect = Boolean(lockCartaoSelection && mode === "create");
|
||||
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="space-y-3 -mx-6 max-h-[80vh] overflow-y-auto px-6 pb-1"
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
>
|
||||
<BasicFieldsSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
estabelecimentos={estabelecimentos}
|
||||
/>
|
||||
|
||||
<CategorySection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
categoriaOptions={categoriaOptions}
|
||||
categoriaGroups={categoriaGroups}
|
||||
isUpdateMode={isUpdateMode}
|
||||
hideTransactionType={
|
||||
Boolean(isNewWithType) && !forceShowTransactionType
|
||||
}
|
||||
/>
|
||||
|
||||
{!isUpdateMode ? (
|
||||
<SplitAndSettlementSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
showSettledToggle={showSettledToggle}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<PagadorSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
pagadorOptions={pagadorOptions}
|
||||
secondaryPagadorOptions={secondaryPagadorOptions}
|
||||
totalAmount={totalAmount}
|
||||
/>
|
||||
|
||||
<PaymentMethodSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
contaOptions={contaOptions}
|
||||
cartaoOptions={cartaoOptions}
|
||||
isUpdateMode={isUpdateMode}
|
||||
disablePaymentMethod={disablePaymentMethod}
|
||||
disableCartaoSelect={disableCartaoSelect}
|
||||
/>
|
||||
|
||||
{showDueDate ? (
|
||||
<BoletoFieldsSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
showPaymentDate={showPaymentDate}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Collapsible
|
||||
defaultOpen={
|
||||
formState.condition !== "À vista" || formState.note.length > 0
|
||||
}
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4">
|
||||
<RiAddLine className="text-primary size-4 transition-transform duration-200" />
|
||||
Condições e anotações
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-3 pt-3">
|
||||
{!isUpdateMode ? (
|
||||
<ConditionSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
showInstallments={showInstallments}
|
||||
showRecurrence={showRecurrence}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<NoteSection
|
||||
formState={formState}
|
||||
onFieldChange={handleFieldChange}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Salvando..." : submitLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
629
src/features/transactions/components/page/transactions-page.tsx
Normal file
629
src/features/transactions/components/page/transactions-page.tsx
Normal file
@@ -0,0 +1,629 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createMassLancamentosAction,
|
||||
deleteLancamentoAction,
|
||||
deleteLancamentoBulkAction,
|
||||
deleteMultipleLancamentosAction,
|
||||
toggleLancamentoSettlementAction,
|
||||
updateLancamentoBulkAction,
|
||||
} from "@/features/transactions/actions";
|
||||
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
||||
|
||||
import { AnticipateInstallmentsDialog } from "../dialogs/anticipate-installments-dialog/anticipate-installments-dialog";
|
||||
import { AnticipationHistoryDialog } from "../dialogs/anticipate-installments-dialog/anticipation-history-dialog";
|
||||
import {
|
||||
BulkActionDialog,
|
||||
type BulkActionScope,
|
||||
} from "../dialogs/bulk-action-dialog";
|
||||
import { BulkImportDialog } from "../dialogs/bulk-import-dialog";
|
||||
import {
|
||||
MassAddDialog,
|
||||
type MassAddFormData,
|
||||
} from "../dialogs/mass-add-dialog";
|
||||
import { LancamentoDetailsDialog } from "../dialogs/transaction-details-dialog";
|
||||
import { LancamentoDialog } from "../dialogs/transaction-dialog/transaction-dialog";
|
||||
import { LancamentosTable } from "../table/transactions-table";
|
||||
import type {
|
||||
ContaCartaoFilterOption,
|
||||
LancamentoFilterOption,
|
||||
LancamentoItem,
|
||||
SelectOption,
|
||||
} from "../types";
|
||||
|
||||
interface LancamentosPageProps {
|
||||
currentUserId: string;
|
||||
lancamentos: LancamentoItem[];
|
||||
pagadorOptions: SelectOption[];
|
||||
splitPagadorOptions: SelectOption[];
|
||||
defaultPagadorId: string | null;
|
||||
contaOptions: SelectOption[];
|
||||
cartaoOptions: SelectOption[];
|
||||
categoriaOptions: SelectOption[];
|
||||
pagadorFilterOptions: LancamentoFilterOption[];
|
||||
categoriaFilterOptions: LancamentoFilterOption[];
|
||||
contaCartaoFilterOptions: ContaCartaoFilterOption[];
|
||||
selectedPeriod: string;
|
||||
estabelecimentos: string[];
|
||||
allowCreate?: boolean;
|
||||
noteAsColumn?: boolean;
|
||||
columnOrder?: string[] | null;
|
||||
defaultCartaoId?: string | null;
|
||||
defaultPaymentMethod?: string | null;
|
||||
lockCartaoSelection?: boolean;
|
||||
lockPaymentMethod?: boolean;
|
||||
// Opções específicas para o dialog de importação (quando visualizando dados de outro usuário)
|
||||
importPagadorOptions?: SelectOption[];
|
||||
importSplitPagadorOptions?: SelectOption[];
|
||||
importDefaultPagadorId?: string | null;
|
||||
importContaOptions?: SelectOption[];
|
||||
importCartaoOptions?: SelectOption[];
|
||||
importCategoriaOptions?: SelectOption[];
|
||||
}
|
||||
|
||||
export function LancamentosPage({
|
||||
currentUserId,
|
||||
lancamentos,
|
||||
pagadorOptions,
|
||||
splitPagadorOptions,
|
||||
defaultPagadorId,
|
||||
contaOptions,
|
||||
cartaoOptions,
|
||||
categoriaOptions,
|
||||
pagadorFilterOptions,
|
||||
categoriaFilterOptions,
|
||||
contaCartaoFilterOptions,
|
||||
selectedPeriod,
|
||||
estabelecimentos,
|
||||
allowCreate = true,
|
||||
noteAsColumn = false,
|
||||
columnOrder = null,
|
||||
defaultCartaoId,
|
||||
defaultPaymentMethod,
|
||||
lockCartaoSelection,
|
||||
lockPaymentMethod,
|
||||
importPagadorOptions,
|
||||
importSplitPagadorOptions,
|
||||
importDefaultPagadorId,
|
||||
importContaOptions,
|
||||
importCartaoOptions,
|
||||
importCategoriaOptions,
|
||||
}: LancamentosPageProps) {
|
||||
const [selectedLancamento, setSelectedLancamento] =
|
||||
useState<LancamentoItem | null>(null);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [copyOpen, setCopyOpen] = useState(false);
|
||||
const [lancamentoToCopy, setLancamentoToCopy] =
|
||||
useState<LancamentoItem | null>(null);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [lancamentoToImport, setLancamentoToImport] =
|
||||
useState<LancamentoItem | null>(null);
|
||||
const [massAddOpen, setMassAddOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [lancamentoToDelete, setLancamentoToDelete] =
|
||||
useState<LancamentoItem | null>(null);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [settlementLoadingId, setSettlementLoadingId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [bulkEditOpen, setBulkEditOpen] = useState(false);
|
||||
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||
const [pendingEditData, setPendingEditData] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
categoriaId: string | undefined;
|
||||
note: string;
|
||||
pagadorId: string | undefined;
|
||||
contaId: string | undefined;
|
||||
cartaoId: string | undefined;
|
||||
amount: number;
|
||||
dueDate: string | null;
|
||||
boletoPaymentDate: string | null;
|
||||
lancamento: LancamentoItem;
|
||||
} | null>(null);
|
||||
const [pendingDeleteData, setPendingDeleteData] =
|
||||
useState<LancamentoItem | null>(null);
|
||||
const [multipleBulkDeleteOpen, setMultipleBulkDeleteOpen] = useState(false);
|
||||
const [pendingMultipleDeleteData, setPendingMultipleDeleteData] = useState<
|
||||
LancamentoItem[]
|
||||
>([]);
|
||||
const [anticipateOpen, setAnticipateOpen] = useState(false);
|
||||
const [anticipationHistoryOpen, setAnticipationHistoryOpen] = useState(false);
|
||||
const [selectedForAnticipation, setSelectedForAnticipation] =
|
||||
useState<LancamentoItem | null>(null);
|
||||
const [bulkImportOpen, setBulkImportOpen] = useState(false);
|
||||
const [lancamentosToImport, setLancamentosToImport] = useState<
|
||||
LancamentoItem[]
|
||||
>([]);
|
||||
|
||||
const handleToggleSettlement = async (item: LancamentoItem) => {
|
||||
if (item.paymentMethod === "Cartão de crédito") {
|
||||
toast.info(
|
||||
"Pagamentos com cartão são conciliados automaticamente. Ajuste pelo cartão.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const supportedMethods = [
|
||||
"Pix",
|
||||
"Boleto",
|
||||
"Dinheiro",
|
||||
"Cartão de débito",
|
||||
"Pré-Pago | VR/VA",
|
||||
"Transferência bancária",
|
||||
];
|
||||
if (!supportedMethods.includes(item.paymentMethod)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextValue = !item.isSettled;
|
||||
|
||||
try {
|
||||
setSettlementLoadingId(item.id);
|
||||
const result = await toggleLancamentoSettlementAction({
|
||||
id: item.id,
|
||||
value: nextValue,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
toast.success(result.message);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Não foi possível atualizar o pagamento.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSettlementLoadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!lancamentoToDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await deleteLancamentoAction({
|
||||
id: lancamentoToDelete.id,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error);
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
toast.success(result.message);
|
||||
setDeleteOpen(false);
|
||||
};
|
||||
|
||||
const handleBulkDelete = async (scope: BulkActionScope) => {
|
||||
if (!pendingDeleteData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await deleteLancamentoBulkAction({
|
||||
id: pendingDeleteData.id,
|
||||
scope,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error);
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
toast.success(result.message);
|
||||
setBulkDeleteOpen(false);
|
||||
setPendingDeleteData(null);
|
||||
};
|
||||
|
||||
const handleBulkEditRequest = (data: {
|
||||
id: string;
|
||||
name: string;
|
||||
categoriaId: string | undefined;
|
||||
note: string;
|
||||
pagadorId: string | undefined;
|
||||
contaId: string | undefined;
|
||||
cartaoId: string | undefined;
|
||||
amount: number;
|
||||
dueDate: string | null;
|
||||
boletoPaymentDate: string | null;
|
||||
}) => {
|
||||
if (!selectedLancamento) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingEditData({
|
||||
...data,
|
||||
lancamento: selectedLancamento,
|
||||
});
|
||||
setEditOpen(false);
|
||||
setBulkEditOpen(true);
|
||||
};
|
||||
|
||||
const handleBulkEdit = async (scope: BulkActionScope) => {
|
||||
if (!pendingEditData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await updateLancamentoBulkAction({
|
||||
id: pendingEditData.id,
|
||||
scope,
|
||||
name: pendingEditData.name,
|
||||
categoriaId: pendingEditData.categoriaId,
|
||||
note: pendingEditData.note,
|
||||
pagadorId: pendingEditData.pagadorId,
|
||||
contaId: pendingEditData.contaId,
|
||||
cartaoId: pendingEditData.cartaoId,
|
||||
amount: pendingEditData.amount,
|
||||
dueDate: pendingEditData.dueDate,
|
||||
boletoPaymentDate: pendingEditData.boletoPaymentDate,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error);
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
toast.success(result.message);
|
||||
setBulkEditOpen(false);
|
||||
setPendingEditData(null);
|
||||
};
|
||||
|
||||
const handleMassAddSubmit = async (data: MassAddFormData) => {
|
||||
const result = await createMassLancamentosAction(data);
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error);
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
toast.success(result.message);
|
||||
};
|
||||
|
||||
const handleMultipleBulkDelete = (items: LancamentoItem[]) => {
|
||||
// Se todos os selecionados são da mesma série (parcelado/recorrente), abrir dialog de escopo
|
||||
const withSeries = items.filter((i) => i.seriesId);
|
||||
const sameSeries =
|
||||
withSeries.length > 0 &&
|
||||
withSeries.length === items.length &&
|
||||
withSeries.every((i) => i.seriesId === withSeries[0]?.seriesId);
|
||||
if (sameSeries && withSeries[0]) {
|
||||
setPendingDeleteData(withSeries[0]);
|
||||
setBulkDeleteOpen(true);
|
||||
return;
|
||||
}
|
||||
setPendingMultipleDeleteData(items);
|
||||
setMultipleBulkDeleteOpen(true);
|
||||
};
|
||||
|
||||
const confirmMultipleBulkDelete = async () => {
|
||||
if (pendingMultipleDeleteData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = pendingMultipleDeleteData.map((item) => item.id);
|
||||
const result = await deleteMultipleLancamentosAction({ ids });
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error);
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
toast.success(result.message);
|
||||
setMultipleBulkDeleteOpen(false);
|
||||
setPendingMultipleDeleteData([]);
|
||||
};
|
||||
|
||||
const [transactionTypeForCreate, setTransactionTypeForCreate] = useState<
|
||||
"Despesa" | "Receita" | null
|
||||
>(null);
|
||||
|
||||
const handleCreate = (type: "Despesa" | "Receita") => {
|
||||
setTransactionTypeForCreate(type);
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const handleMassAdd = () => {
|
||||
setMassAddOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (item: LancamentoItem) => {
|
||||
setSelectedLancamento(item);
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const handleCopy = (item: LancamentoItem) => {
|
||||
setLancamentoToCopy(item);
|
||||
setCopyOpen(true);
|
||||
};
|
||||
|
||||
const handleImport = (item: LancamentoItem) => {
|
||||
setLancamentoToImport(item);
|
||||
setImportOpen(true);
|
||||
};
|
||||
|
||||
const handleBulkImport = (items: LancamentoItem[]) => {
|
||||
setLancamentosToImport(items);
|
||||
setBulkImportOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = (item: LancamentoItem) => {
|
||||
if (item.seriesId) {
|
||||
setPendingDeleteData(item);
|
||||
setBulkDeleteOpen(true);
|
||||
} else {
|
||||
setLancamentoToDelete(item);
|
||||
setDeleteOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetails = (item: LancamentoItem) => {
|
||||
setSelectedLancamento(item);
|
||||
setDetailsOpen(true);
|
||||
};
|
||||
|
||||
const handleAnticipate = (item: LancamentoItem) => {
|
||||
setSelectedForAnticipation(item);
|
||||
setAnticipateOpen(true);
|
||||
};
|
||||
|
||||
const handleViewAnticipationHistory = (item: LancamentoItem) => {
|
||||
setSelectedForAnticipation(item);
|
||||
setAnticipationHistoryOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<LancamentosTable
|
||||
data={lancamentos}
|
||||
currentUserId={currentUserId}
|
||||
noteAsColumn={noteAsColumn}
|
||||
columnOrder={columnOrder}
|
||||
pagadorFilterOptions={pagadorFilterOptions}
|
||||
categoriaFilterOptions={categoriaFilterOptions}
|
||||
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
||||
selectedPeriod={selectedPeriod}
|
||||
onCreate={allowCreate ? handleCreate : undefined}
|
||||
onMassAdd={allowCreate ? handleMassAdd : undefined}
|
||||
onEdit={handleEdit}
|
||||
onCopy={handleCopy}
|
||||
onImport={handleImport}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onBulkDelete={handleMultipleBulkDelete}
|
||||
onBulkImport={handleBulkImport}
|
||||
onViewDetails={handleViewDetails}
|
||||
onToggleSettlement={handleToggleSettlement}
|
||||
onAnticipate={handleAnticipate}
|
||||
onViewAnticipationHistory={handleViewAnticipationHistory}
|
||||
isSettlementLoading={(id) => settlementLoadingId === id}
|
||||
/>
|
||||
|
||||
{allowCreate ? (
|
||||
<LancamentoDialog
|
||||
mode="create"
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
pagadorOptions={pagadorOptions}
|
||||
splitPagadorOptions={splitPagadorOptions}
|
||||
defaultPagadorId={defaultPagadorId}
|
||||
contaOptions={contaOptions}
|
||||
cartaoOptions={cartaoOptions}
|
||||
categoriaOptions={categoriaOptions}
|
||||
estabelecimentos={estabelecimentos}
|
||||
defaultPeriod={selectedPeriod}
|
||||
defaultCartaoId={defaultCartaoId}
|
||||
defaultPaymentMethod={defaultPaymentMethod}
|
||||
lockCartaoSelection={lockCartaoSelection}
|
||||
lockPaymentMethod={lockPaymentMethod}
|
||||
defaultTransactionType={transactionTypeForCreate ?? undefined}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<LancamentoDialog
|
||||
mode="create"
|
||||
open={copyOpen && !!lancamentoToCopy}
|
||||
onOpenChange={(open) => {
|
||||
setCopyOpen(open);
|
||||
if (!open) {
|
||||
setLancamentoToCopy(null);
|
||||
}
|
||||
}}
|
||||
pagadorOptions={pagadorOptions}
|
||||
splitPagadorOptions={splitPagadorOptions}
|
||||
defaultPagadorId={defaultPagadorId}
|
||||
contaOptions={contaOptions}
|
||||
cartaoOptions={cartaoOptions}
|
||||
categoriaOptions={categoriaOptions}
|
||||
estabelecimentos={estabelecimentos}
|
||||
lancamento={lancamentoToCopy ?? undefined}
|
||||
defaultPeriod={selectedPeriod}
|
||||
/>
|
||||
|
||||
<LancamentoDialog
|
||||
mode="create"
|
||||
open={importOpen && !!lancamentoToImport}
|
||||
onOpenChange={(open) => {
|
||||
setImportOpen(open);
|
||||
if (!open) {
|
||||
setLancamentoToImport(null);
|
||||
}
|
||||
}}
|
||||
pagadorOptions={importPagadorOptions ?? pagadorOptions}
|
||||
splitPagadorOptions={importSplitPagadorOptions ?? splitPagadorOptions}
|
||||
defaultPagadorId={importDefaultPagadorId ?? defaultPagadorId}
|
||||
contaOptions={importContaOptions ?? contaOptions}
|
||||
cartaoOptions={importCartaoOptions ?? cartaoOptions}
|
||||
categoriaOptions={importCategoriaOptions ?? categoriaOptions}
|
||||
estabelecimentos={estabelecimentos}
|
||||
lancamento={lancamentoToImport ?? undefined}
|
||||
defaultPeriod={selectedPeriod}
|
||||
isImporting={true}
|
||||
/>
|
||||
|
||||
<BulkImportDialog
|
||||
open={bulkImportOpen && lancamentosToImport.length > 0}
|
||||
onOpenChange={setBulkImportOpen}
|
||||
items={lancamentosToImport}
|
||||
pagadorOptions={importPagadorOptions ?? pagadorOptions}
|
||||
contaOptions={importContaOptions ?? contaOptions}
|
||||
cartaoOptions={importCartaoOptions ?? cartaoOptions}
|
||||
categoriaOptions={importCategoriaOptions ?? categoriaOptions}
|
||||
defaultPagadorId={importDefaultPagadorId ?? defaultPagadorId}
|
||||
/>
|
||||
|
||||
<LancamentoDialog
|
||||
mode="update"
|
||||
open={editOpen && !!selectedLancamento}
|
||||
onOpenChange={setEditOpen}
|
||||
pagadorOptions={pagadorOptions}
|
||||
splitPagadorOptions={splitPagadorOptions}
|
||||
defaultPagadorId={defaultPagadorId}
|
||||
contaOptions={contaOptions}
|
||||
cartaoOptions={cartaoOptions}
|
||||
categoriaOptions={categoriaOptions}
|
||||
estabelecimentos={estabelecimentos}
|
||||
lancamento={selectedLancamento ?? undefined}
|
||||
defaultPeriod={selectedPeriod}
|
||||
onBulkEditRequest={handleBulkEditRequest}
|
||||
/>
|
||||
|
||||
<LancamentoDetailsDialog
|
||||
open={detailsOpen && !!selectedLancamento}
|
||||
onOpenChange={(open) => {
|
||||
setDetailsOpen(open);
|
||||
if (!open) {
|
||||
setSelectedLancamento(null);
|
||||
}
|
||||
}}
|
||||
lancamento={detailsOpen ? selectedLancamento : null}
|
||||
/>
|
||||
|
||||
<ConfirmActionDialog
|
||||
open={deleteOpen && !!lancamentoToDelete}
|
||||
onOpenChange={setDeleteOpen}
|
||||
title={
|
||||
lancamentoToDelete
|
||||
? `Remover lançamento "${lancamentoToDelete.name}"?`
|
||||
: "Remover lançamento?"
|
||||
}
|
||||
description="Essa ação é irreversível e removerá o lançamento de forma permanente."
|
||||
confirmLabel="Remover"
|
||||
pendingLabel="Removendo..."
|
||||
confirmVariant="destructive"
|
||||
onConfirm={handleDelete}
|
||||
disabled={!lancamentoToDelete}
|
||||
/>
|
||||
|
||||
<BulkActionDialog
|
||||
open={bulkDeleteOpen && !!pendingDeleteData}
|
||||
onOpenChange={setBulkDeleteOpen}
|
||||
actionType="delete"
|
||||
seriesType={
|
||||
pendingDeleteData?.condition === "Parcelado"
|
||||
? "installment"
|
||||
: "recurring"
|
||||
}
|
||||
currentNumber={pendingDeleteData?.currentInstallment ?? undefined}
|
||||
totalCount={
|
||||
pendingDeleteData?.installmentCount ??
|
||||
pendingDeleteData?.recurrenceCount ??
|
||||
undefined
|
||||
}
|
||||
onConfirm={handleBulkDelete}
|
||||
/>
|
||||
|
||||
<BulkActionDialog
|
||||
open={bulkEditOpen && !!pendingEditData}
|
||||
onOpenChange={setBulkEditOpen}
|
||||
actionType="edit"
|
||||
seriesType={
|
||||
pendingEditData?.lancamento.condition === "Parcelado"
|
||||
? "installment"
|
||||
: "recurring"
|
||||
}
|
||||
currentNumber={
|
||||
pendingEditData?.lancamento.currentInstallment ?? undefined
|
||||
}
|
||||
totalCount={
|
||||
pendingEditData?.lancamento.installmentCount ??
|
||||
pendingEditData?.lancamento.recurrenceCount ??
|
||||
undefined
|
||||
}
|
||||
onConfirm={handleBulkEdit}
|
||||
/>
|
||||
|
||||
{allowCreate ? (
|
||||
<MassAddDialog
|
||||
open={massAddOpen}
|
||||
onOpenChange={setMassAddOpen}
|
||||
onSubmit={handleMassAddSubmit}
|
||||
pagadorOptions={pagadorOptions}
|
||||
contaOptions={contaOptions}
|
||||
cartaoOptions={cartaoOptions}
|
||||
categoriaOptions={categoriaOptions}
|
||||
estabelecimentos={estabelecimentos}
|
||||
selectedPeriod={selectedPeriod}
|
||||
defaultPagadorId={defaultPagadorId}
|
||||
defaultCartaoId={defaultCartaoId}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ConfirmActionDialog
|
||||
open={multipleBulkDeleteOpen && pendingMultipleDeleteData.length > 0}
|
||||
onOpenChange={setMultipleBulkDeleteOpen}
|
||||
title={`Remover ${pendingMultipleDeleteData.length} ${
|
||||
pendingMultipleDeleteData.length === 1 ? "lançamento" : "lançamentos"
|
||||
}?`}
|
||||
description="Essa ação é irreversível e removerá os lançamentos selecionados de forma permanente."
|
||||
confirmLabel="Remover"
|
||||
pendingLabel="Removendo..."
|
||||
confirmVariant="destructive"
|
||||
onConfirm={confirmMultipleBulkDelete}
|
||||
disabled={pendingMultipleDeleteData.length === 0}
|
||||
/>
|
||||
|
||||
{/* Dialogs de Antecipação */}
|
||||
{selectedForAnticipation && (
|
||||
<AnticipateInstallmentsDialog
|
||||
open={anticipateOpen}
|
||||
onOpenChange={setAnticipateOpen}
|
||||
seriesId={selectedForAnticipation.seriesId as string}
|
||||
lancamentoName={selectedForAnticipation.name}
|
||||
categorias={categoriaOptions.map((c) => ({
|
||||
id: c.value,
|
||||
name: c.label,
|
||||
icon: c.icon ?? null,
|
||||
}))}
|
||||
pagadores={pagadorOptions.map((p) => ({
|
||||
id: p.value,
|
||||
name: p.label,
|
||||
}))}
|
||||
defaultPeriod={selectedPeriod}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedForAnticipation && (
|
||||
<AnticipationHistoryDialog
|
||||
open={anticipationHistoryOpen}
|
||||
onOpenChange={setAnticipationHistoryOpen}
|
||||
seriesId={selectedForAnticipation.seriesId as string}
|
||||
lancamentoName={selectedForAnticipation.name}
|
||||
onViewLancamento={(lancamentoId) => {
|
||||
const lancamento = lancamentos.find((l) => l.id === lancamentoId);
|
||||
if (lancamento) {
|
||||
setSelectedLancamento(lancamento);
|
||||
setDetailsOpen(true);
|
||||
setAnticipationHistoryOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
116
src/features/transactions/components/select-items.tsx
Normal file
116
src/features/transactions/components/select-items.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { RiBankCard2Line, RiBankLine } from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import { CategoryIcon } from "@/features/categories/components/category-icon";
|
||||
import StatusDot from "@/shared/components/status-dot";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/shared/components/ui/avatar";
|
||||
import { resolveLogoSrc } from "@/shared/lib/logo";
|
||||
import { getAvatarSrc } from "@/shared/lib/payers/utils";
|
||||
import { getConditionIcon, getPaymentMethodIcon } from "@/shared/utils/icons";
|
||||
|
||||
type SelectItemContentProps = {
|
||||
label: string;
|
||||
avatarUrl?: string | null;
|
||||
logo?: string | null;
|
||||
icon?: string | null;
|
||||
};
|
||||
|
||||
export function PagadorSelectContent({
|
||||
label,
|
||||
avatarUrl,
|
||||
}: SelectItemContentProps) {
|
||||
const avatarSrc = getAvatarSrc(avatarUrl);
|
||||
const initial = label.charAt(0).toUpperCase() || "?";
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<Avatar className="size-5 border border-border/60 bg-background">
|
||||
<AvatarImage src={avatarSrc} alt={`Avatar de ${label}`} />
|
||||
<AvatarFallback className="text-[10px] font-medium uppercase">
|
||||
{initial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function CategoriaSelectContent({
|
||||
label,
|
||||
icon,
|
||||
}: SelectItemContentProps) {
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<CategoryIcon name={icon} className="size-4" />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function TransactionTypeSelectContent({ label }: { label: string }) {
|
||||
const colorMap: Record<string, string> = {
|
||||
Receita: "bg-success",
|
||||
Despesa: "bg-destructive",
|
||||
Transferência: "bg-info",
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<StatusDot color={colorMap[label]} />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaymentMethodSelectContent({ label }: { label: string }) {
|
||||
const icon = getPaymentMethodIcon(label);
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConditionSelectContent({ label }: { label: string }) {
|
||||
const icon = getConditionIcon(label);
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContaCartaoSelectContent({
|
||||
label,
|
||||
logo,
|
||||
isCartao,
|
||||
}: SelectItemContentProps & { isCartao?: boolean }) {
|
||||
const logoSrc = resolveLogoSrc(logo);
|
||||
const Icon = isCartao ? RiBankCard2Line : RiBankLine;
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{logoSrc ? (
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={`Logo de ${label}`}
|
||||
width={20}
|
||||
height={20}
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<Icon className="size-4 text-muted-foreground" aria-hidden />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { RiCalendarCheckLine, RiCloseLine, RiEyeLine } from "@remixicon/react";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { cancelInstallmentAnticipationAction } from "@/features/transactions/anticipation-actions";
|
||||
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
||||
import MoneyValues from "@/shared/components/money-values";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card";
|
||||
import type { InstallmentAnticipationWithRelations } from "@/shared/lib/installments/anticipation-types";
|
||||
import { displayPeriod } from "@/shared/utils/period";
|
||||
|
||||
interface AnticipationCardProps {
|
||||
anticipation: InstallmentAnticipationWithRelations;
|
||||
onViewLancamento?: (lancamentoId: string) => void;
|
||||
onCanceled?: () => void;
|
||||
}
|
||||
|
||||
export function AnticipationCard({
|
||||
anticipation,
|
||||
onViewLancamento,
|
||||
onCanceled,
|
||||
}: AnticipationCardProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const isSettled = anticipation.lancamento.isSettled === true;
|
||||
const canCancel = !isSettled;
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return format(date, "dd 'de' MMMM 'de' yyyy", { locale: ptBR });
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
startTransition(async () => {
|
||||
const result = await cancelInstallmentAnticipationAction({
|
||||
anticipationId: anticipation.id,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
onCanceled?.();
|
||||
} else {
|
||||
toast.error(result.error || "Erro ao cancelar antecipação");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleViewLancamento = () => {
|
||||
onViewLancamento?.(anticipation.lancamentoId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base">
|
||||
{anticipation.installmentCount}{" "}
|
||||
{anticipation.installmentCount === 1
|
||||
? "parcela antecipada"
|
||||
: "parcelas antecipadas"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<RiCalendarCheckLine className="mr-1 inline size-3.5" />
|
||||
{formatDate(anticipation.anticipationDate)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{displayPeriod(anticipation.anticipationPeriod)}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Valor Original</dt>
|
||||
<dd className="mt-1 font-medium tabular-nums">
|
||||
<MoneyValues amount={Number(anticipation.totalAmount)} />
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{Number(anticipation.discount) > 0 && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Desconto</dt>
|
||||
<dd className="mt-1 font-medium tabular-nums text-success">
|
||||
- <MoneyValues amount={Number(anticipation.discount)} />
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={
|
||||
Number(anticipation.discount) > 0
|
||||
? "col-span-2 border-t pt-3"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<dt className="text-muted-foreground">
|
||||
{Number(anticipation.discount) > 0
|
||||
? "Valor Final"
|
||||
: "Valor Total"}
|
||||
</dt>
|
||||
<dd className="mt-1 text-lg font-semibold tabular-nums text-primary">
|
||||
<MoneyValues
|
||||
amount={
|
||||
Number(anticipation.totalAmount) < 0
|
||||
? Number(anticipation.totalAmount) +
|
||||
Number(anticipation.discount)
|
||||
: Number(anticipation.totalAmount) -
|
||||
Number(anticipation.discount)
|
||||
}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Status do Lançamento</dt>
|
||||
<dd className="mt-1">
|
||||
<Badge variant={isSettled ? "success" : "outline"}>
|
||||
{isSettled ? "Pago" : "Pendente"}
|
||||
</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{anticipation.pagador && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Pagador</dt>
|
||||
<dd className="mt-1 font-medium">{anticipation.pagador.name}</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{anticipation.categoria && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Categoria</dt>
|
||||
<dd className="mt-1 font-medium">
|
||||
{anticipation.categoria.name}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{anticipation.note && (
|
||||
<div className="rounded-lg border bg-muted/20 p-3">
|
||||
<dt className="text-xs font-medium text-muted-foreground">
|
||||
Observação
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm">{anticipation.note}</dd>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleViewLancamento}
|
||||
disabled={isPending}
|
||||
>
|
||||
<RiEyeLine className="mr-2 size-4" />
|
||||
Ver Lançamento
|
||||
</Button>
|
||||
|
||||
{canCancel && (
|
||||
<ConfirmActionDialog
|
||||
trigger={
|
||||
<Button variant="destructive" size="sm" disabled={isPending}>
|
||||
<RiCloseLine className="mr-2 size-4" />
|
||||
Cancelar Antecipação
|
||||
</Button>
|
||||
}
|
||||
title="Cancelar antecipação?"
|
||||
description="Esta ação irá reverter a antecipação e restaurar as parcelas originais. O lançamento de antecipação será removido."
|
||||
confirmLabel="Cancelar Antecipação"
|
||||
confirmVariant="destructive"
|
||||
pendingLabel="Cancelando..."
|
||||
onConfirm={handleCancel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isSettled && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Não é possível cancelar uma antecipação paga
|
||||
</div>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { RiSearchLine } from "@remixicon/react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/shared/components/ui/command";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/shared/components/ui/popover";
|
||||
|
||||
export interface EstabelecimentoInputProps {
|
||||
id?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
estabelecimentos: string[];
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
export function EstabelecimentoInput({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
estabelecimentos = [],
|
||||
placeholder = "Ex.: Padaria, Transferência, Saldo inicial",
|
||||
required = false,
|
||||
maxLength = 20,
|
||||
}: EstabelecimentoInputProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [searchValue, setSearchValue] = React.useState("");
|
||||
|
||||
const handleSelect = (selectedValue: string) => {
|
||||
onChange(selectedValue);
|
||||
setOpen(false);
|
||||
setSearchValue("");
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
onChange(newValue);
|
||||
setSearchValue(newValue);
|
||||
|
||||
// Open popover when user types and there are suggestions
|
||||
if (newValue.length > 0 && estabelecimentos.length > 0) {
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredEstabelecimentos = React.useMemo(() => {
|
||||
if (!searchValue) return estabelecimentos;
|
||||
|
||||
const lowerSearch = searchValue.toLowerCase();
|
||||
return estabelecimentos.filter((item) =>
|
||||
item.toLowerCase().includes(lowerSearch),
|
||||
);
|
||||
}, [estabelecimentos, searchValue]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
maxLength={maxLength}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{estabelecimentos.length > 0 && (
|
||||
<RiSearchLine className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
{estabelecimentos.length > 0 && (
|
||||
<PopoverContent
|
||||
className="p-0 w-[--radix-popover-trigger-width]"
|
||||
align="start"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<Command>
|
||||
<CommandList className="max-h-[300px] overflow-y-auto">
|
||||
<CommandEmpty className="p-6">
|
||||
Nenhum estabelecimento encontrado.
|
||||
</CommandEmpty>
|
||||
<CommandGroup className="p-1">
|
||||
{filteredEstabelecimentos.map((item) => (
|
||||
<CommandItem
|
||||
key={item}
|
||||
value={item}
|
||||
onSelect={() => handleSelect(item)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span
|
||||
className={`truncate flex-1 ${value === item ? "font-semibold" : ""}`}
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
|
||||
interface EstabelecimentoLogoProps {
|
||||
name: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COLOR_PALETTE = [
|
||||
"bg-purple-400 dark:bg-purple-600",
|
||||
"bg-pink-400 dark:bg-pink-600",
|
||||
"bg-red-400 dark:bg-red-600",
|
||||
"bg-orange-400 dark:bg-orange-600",
|
||||
"bg-indigo-400 dark:bg-indigo-600",
|
||||
"bg-violet-400 dark:bg-violet-600",
|
||||
"bg-fuchsia-400 dark:bg-fuchsia-600",
|
||||
"bg-rose-400 dark:bg-rose-600",
|
||||
"bg-amber-400 dark:bg-amber-600",
|
||||
"bg-emerald-400 dark:bg-emerald-600",
|
||||
];
|
||||
|
||||
function getInitials(name: string): string {
|
||||
if (!name || !name.trim()) return "?";
|
||||
|
||||
const words = name.trim().split(/\s+/);
|
||||
|
||||
if (words.length === 1) {
|
||||
return words[0]?.[0]?.toUpperCase() || "?";
|
||||
}
|
||||
|
||||
const firstInitial = words[0]?.[0]?.toUpperCase() || "";
|
||||
const secondInitial = words[1]?.[0]?.toUpperCase() || "";
|
||||
|
||||
return `${firstInitial}${secondInitial}`;
|
||||
}
|
||||
|
||||
function generateColorFromName(name: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const index = Math.abs(hash) % COLOR_PALETTE.length;
|
||||
return COLOR_PALETTE[index] || "bg-gray-400";
|
||||
}
|
||||
|
||||
export function EstabelecimentoLogo({
|
||||
name,
|
||||
size = 32,
|
||||
className,
|
||||
}: EstabelecimentoLogoProps) {
|
||||
const initials = getInitials(name);
|
||||
const colorClass = generateColorFromName(name);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full text-white font-bold shrink-0",
|
||||
colorClass,
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
fontSize: (size ?? 32) * 0.4,
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { RiArrowDownFill, RiCheckLine } from "@remixicon/react";
|
||||
import {
|
||||
calculateLastInstallmentDate,
|
||||
formatCurrentInstallment,
|
||||
formatLastInstallmentDate,
|
||||
formatPurchaseDate,
|
||||
} from "@/shared/lib/installments/utils";
|
||||
|
||||
type InstallmentTimelineProps = {
|
||||
purchaseDate: Date;
|
||||
currentInstallment: number;
|
||||
totalInstallments: number;
|
||||
period: string;
|
||||
};
|
||||
|
||||
export function InstallmentTimeline({
|
||||
purchaseDate,
|
||||
currentInstallment,
|
||||
totalInstallments,
|
||||
period,
|
||||
}: InstallmentTimelineProps) {
|
||||
const lastInstallmentDate = calculateLastInstallmentDate(
|
||||
period,
|
||||
currentInstallment,
|
||||
totalInstallments,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-between px-4 py-4">
|
||||
{/* Linha de conexão */}
|
||||
<div className="absolute left-0 right-0 top-6 h-0.5 bg-border">
|
||||
<div
|
||||
className="h-full bg-success transition-all duration-300"
|
||||
style={{
|
||||
width: `${
|
||||
((currentInstallment - 1) / (totalInstallments - 1)) * 100
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ponto 1: Data de Compra */}
|
||||
<div className="relative z-10 flex flex-col items-center gap-2">
|
||||
<div className="flex size-4 items-center justify-center rounded-full border-2 border-success bg-success shadow-sm">
|
||||
<RiCheckLine className="size-5 text-white" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
Data de Compra
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatPurchaseDate(purchaseDate)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ponto 2: Parcela Atual */}
|
||||
<div className="relative z-10 flex flex-col items-center gap-2">
|
||||
<div
|
||||
className={`flex size-4 items-center justify-center rounded-full border-2 shadow-sm border-warning bg-warning`}
|
||||
>
|
||||
<RiArrowDownFill className="size-5 text-white" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
Parcela Atual
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatCurrentInstallment(currentInstallment, totalInstallments)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ponto 3: Última Parcela */}
|
||||
<div className="relative z-10 flex flex-col items-center gap-2">
|
||||
<div
|
||||
className={`flex size-4 items-center justify-center rounded-full border-2 shadow-sm border-success bg-success`}
|
||||
>
|
||||
<RiCheckLine className="size-5 text-white" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
Última Parcela
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatLastInstallmentDate(lastInstallmentDate)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiCheckLine,
|
||||
RiExpandUpDownLine,
|
||||
RiFilter3Line,
|
||||
} from "@remixicon/react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
import {
|
||||
LANCAMENTO_CONDITIONS,
|
||||
LANCAMENTO_PAYMENT_METHODS,
|
||||
LANCAMENTO_TRANSACTION_TYPES,
|
||||
} from "@/features/transactions/constants";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/shared/components/ui/command";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/shared/components/ui/drawer";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/shared/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
} from "@/shared/components/ui/select";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import {
|
||||
CategoriaSelectContent,
|
||||
ConditionSelectContent,
|
||||
ContaCartaoSelectContent,
|
||||
PagadorSelectContent,
|
||||
PaymentMethodSelectContent,
|
||||
TransactionTypeSelectContent,
|
||||
} from "../select-items";
|
||||
import type { ContaCartaoFilterOption, LancamentoFilterOption } from "../types";
|
||||
|
||||
const FILTER_EMPTY_VALUE = "__all";
|
||||
|
||||
const buildStaticOptions = (values: readonly string[]) =>
|
||||
values.map((value) => ({ value, label: value }));
|
||||
|
||||
interface FilterSelectProps {
|
||||
param: string;
|
||||
placeholder: string;
|
||||
options: { value: string; label: string }[];
|
||||
widthClass?: string;
|
||||
disabled?: boolean;
|
||||
getParamValue: (key: string) => string;
|
||||
onChange: (key: string, value: string | null) => void;
|
||||
renderContent?: (label: string) => ReactNode;
|
||||
}
|
||||
|
||||
function FilterSelect({
|
||||
param,
|
||||
placeholder,
|
||||
options,
|
||||
widthClass = "w-[130px]",
|
||||
disabled,
|
||||
getParamValue,
|
||||
onChange,
|
||||
renderContent,
|
||||
}: FilterSelectProps) {
|
||||
const value = getParamValue(param);
|
||||
const current = options.find((option) => option.value === value);
|
||||
const displayLabel =
|
||||
value === FILTER_EMPTY_VALUE
|
||||
? placeholder
|
||||
: (current?.label ?? placeholder);
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(nextValue) =>
|
||||
onChange(param, nextValue === FILTER_EMPTY_VALUE ? null : nextValue)
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn("text-sm border-dashed", widthClass)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="truncate">
|
||||
{value !== FILTER_EMPTY_VALUE && current && renderContent
|
||||
? renderContent(current.label)
|
||||
: displayLabel}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={FILTER_EMPTY_VALUE}>Todos</SelectItem>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{renderContent ? renderContent(option.label) : option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
interface LancamentosFiltersProps {
|
||||
pagadorOptions: LancamentoFilterOption[];
|
||||
categoriaOptions: LancamentoFilterOption[];
|
||||
contaCartaoOptions: ContaCartaoFilterOption[];
|
||||
className?: string;
|
||||
exportButton?: ReactNode;
|
||||
hideAdvancedFilters?: boolean;
|
||||
}
|
||||
|
||||
export function LancamentosFilters({
|
||||
pagadorOptions,
|
||||
categoriaOptions,
|
||||
contaCartaoOptions,
|
||||
className,
|
||||
exportButton,
|
||||
hideAdvancedFilters = false,
|
||||
}: LancamentosFiltersProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const getParamValue = (key: string) =>
|
||||
searchParams.get(key) ?? FILTER_EMPTY_VALUE;
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(key: string, value: string | null) => {
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
|
||||
if (value && value !== FILTER_EMPTY_VALUE) {
|
||||
nextParams.set(key, value);
|
||||
} else {
|
||||
nextParams.delete(key);
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.replace(`${pathname}?${nextParams.toString()}`, {
|
||||
scroll: false,
|
||||
});
|
||||
});
|
||||
},
|
||||
[searchParams, pathname, router],
|
||||
);
|
||||
|
||||
const [searchValue, setSearchValue] = useState(searchParams.get("q") ?? "");
|
||||
const currentSearchParam = searchParams.get("q") ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
setSearchValue(currentSearchParam);
|
||||
}, [currentSearchParam]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchValue === currentSearchParam) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
const normalized = searchValue.trim();
|
||||
handleFilterChange("q", normalized.length > 0 ? normalized : null);
|
||||
}, 350);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [searchValue, currentSearchParam, handleFilterChange]);
|
||||
|
||||
const handleReset = () => {
|
||||
const periodValue = searchParams.get("periodo");
|
||||
const nextParams = new URLSearchParams();
|
||||
if (periodValue) {
|
||||
nextParams.set("periodo", periodValue);
|
||||
}
|
||||
setSearchValue("");
|
||||
setCategoriaOpen(false);
|
||||
startTransition(() => {
|
||||
const target = nextParams.toString()
|
||||
? `${pathname}?${nextParams.toString()}`
|
||||
: pathname;
|
||||
router.replace(target, { scroll: false });
|
||||
});
|
||||
};
|
||||
|
||||
const pagadorSelectOptions = pagadorOptions.map((option) => ({
|
||||
value: option.slug,
|
||||
label: option.label,
|
||||
avatarUrl: option.avatarUrl,
|
||||
}));
|
||||
|
||||
const contaOptions = contaCartaoOptions
|
||||
.filter((option) => option.kind === "conta")
|
||||
.map((option) => ({
|
||||
value: option.slug,
|
||||
label: option.label,
|
||||
logo: option.logo,
|
||||
}));
|
||||
|
||||
const cartaoOptions = contaCartaoOptions
|
||||
.filter((option) => option.kind === "cartao")
|
||||
.map((option) => ({
|
||||
value: option.slug,
|
||||
label: option.label,
|
||||
logo: option.logo,
|
||||
}));
|
||||
|
||||
const categoriaValue = getParamValue("categoria");
|
||||
const selectedCategoria =
|
||||
categoriaValue !== FILTER_EMPTY_VALUE
|
||||
? categoriaOptions.find((option) => option.slug === categoriaValue)
|
||||
: null;
|
||||
|
||||
const pagadorValue = getParamValue("pagador");
|
||||
const selectedPagador =
|
||||
pagadorValue !== FILTER_EMPTY_VALUE
|
||||
? pagadorOptions.find((option) => option.slug === pagadorValue)
|
||||
: null;
|
||||
|
||||
const contaCartaoValue = getParamValue("contaCartao");
|
||||
const selectedContaCartao =
|
||||
contaCartaoValue !== FILTER_EMPTY_VALUE
|
||||
? contaCartaoOptions.find((option) => option.slug === contaCartaoValue)
|
||||
: null;
|
||||
|
||||
const [categoriaOpen, setCategoriaOpen] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const hasActiveFilters =
|
||||
searchParams.get("transacao") ||
|
||||
searchParams.get("condicao") ||
|
||||
searchParams.get("pagamento") ||
|
||||
searchParams.get("pagador") ||
|
||||
searchParams.get("categoria") ||
|
||||
searchParams.get("contaCartao");
|
||||
|
||||
const handleResetFilters = () => {
|
||||
handleReset();
|
||||
setDrawerOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 md:flex-row md:flex-wrap md:items-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
placeholder="Buscar"
|
||||
aria-label="Buscar lançamentos"
|
||||
className="w-full md:w-[250px] text-sm border-dashed"
|
||||
/>
|
||||
|
||||
<div className="flex w-full gap-2 md:w-auto">
|
||||
{exportButton && (
|
||||
<div className="flex-1 md:flex-none *:w-full *:md:w-auto">
|
||||
{exportButton}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hideAdvancedFilters && (
|
||||
<Drawer
|
||||
direction="right"
|
||||
open={drawerOpen}
|
||||
onOpenChange={setDrawerOpen}
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 md:flex-none text-sm border-dashed relative bg-transparent"
|
||||
aria-label="Abrir filtros"
|
||||
>
|
||||
<RiFilter3Line className="size-4" />
|
||||
Filtros
|
||||
{hasActiveFilters && (
|
||||
<span className="absolute -top-1 -right-1 size-3 rounded-full bg-primary" />
|
||||
)}
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Filtros</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Selecione os filtros desejados para refinar os lançamentos
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Tipo de Lançamento
|
||||
</label>
|
||||
<FilterSelect
|
||||
param="transacao"
|
||||
placeholder="Todos"
|
||||
options={buildStaticOptions(LANCAMENTO_TRANSACTION_TYPES)}
|
||||
widthClass="w-full border-dashed"
|
||||
disabled={isPending}
|
||||
getParamValue={getParamValue}
|
||||
onChange={handleFilterChange}
|
||||
renderContent={(label) => (
|
||||
<TransactionTypeSelectContent label={label} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Condição de Lançamento
|
||||
</label>
|
||||
<FilterSelect
|
||||
param="condicao"
|
||||
placeholder="Todas"
|
||||
options={buildStaticOptions(LANCAMENTO_CONDITIONS)}
|
||||
widthClass="w-full border-dashed"
|
||||
disabled={isPending}
|
||||
getParamValue={getParamValue}
|
||||
onChange={handleFilterChange}
|
||||
renderContent={(label) => (
|
||||
<ConditionSelectContent label={label} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Forma de Pagamento
|
||||
</label>
|
||||
<FilterSelect
|
||||
param="pagamento"
|
||||
placeholder="Todos"
|
||||
options={buildStaticOptions(LANCAMENTO_PAYMENT_METHODS)}
|
||||
widthClass="w-full border-dashed"
|
||||
disabled={isPending}
|
||||
getParamValue={getParamValue}
|
||||
onChange={handleFilterChange}
|
||||
renderContent={(label) => (
|
||||
<PaymentMethodSelectContent label={label} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Pagador</label>
|
||||
<Select
|
||||
value={getParamValue("pagador")}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange(
|
||||
"pagador",
|
||||
value === FILTER_EMPTY_VALUE ? null : value,
|
||||
)
|
||||
}
|
||||
disabled={isPending}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full text-sm border-dashed"
|
||||
disabled={isPending}
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedPagador ? (
|
||||
<PagadorSelectContent
|
||||
label={selectedPagador.label}
|
||||
avatarUrl={selectedPagador.avatarUrl}
|
||||
/>
|
||||
) : (
|
||||
"Todos"
|
||||
)}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={FILTER_EMPTY_VALUE}>Todos</SelectItem>
|
||||
{pagadorSelectOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<PagadorSelectContent
|
||||
label={option.label}
|
||||
avatarUrl={option.avatarUrl}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Categoria</label>
|
||||
<Popover
|
||||
open={categoriaOpen}
|
||||
onOpenChange={setCategoriaOpen}
|
||||
modal
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={categoriaOpen}
|
||||
className="w-full justify-between text-sm border-dashed"
|
||||
disabled={isPending}
|
||||
>
|
||||
<span className="truncate flex items-center gap-2">
|
||||
{selectedCategoria ? (
|
||||
<CategoriaSelectContent
|
||||
label={selectedCategoria.label}
|
||||
icon={selectedCategoria.icon}
|
||||
/>
|
||||
) : (
|
||||
"Todas"
|
||||
)}
|
||||
</span>
|
||||
<RiExpandUpDownLine className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-[220px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Buscar categoria..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>Nada encontrado.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value={FILTER_EMPTY_VALUE}
|
||||
onSelect={() => {
|
||||
handleFilterChange("categoria", null);
|
||||
setCategoriaOpen(false);
|
||||
}}
|
||||
>
|
||||
Todas
|
||||
{categoriaValue === FILTER_EMPTY_VALUE ? (
|
||||
<RiCheckLine className="ml-auto size-4" />
|
||||
) : null}
|
||||
</CommandItem>
|
||||
{categoriaOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.slug}
|
||||
value={option.slug}
|
||||
onSelect={() => {
|
||||
handleFilterChange("categoria", option.slug);
|
||||
setCategoriaOpen(false);
|
||||
}}
|
||||
>
|
||||
<CategoriaSelectContent
|
||||
label={option.label}
|
||||
icon={option.icon}
|
||||
/>
|
||||
{categoriaValue === option.slug ? (
|
||||
<RiCheckLine className="ml-auto size-4" />
|
||||
) : null}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Conta/Cartão</label>
|
||||
<Select
|
||||
value={getParamValue("contaCartao")}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange(
|
||||
"contaCartao",
|
||||
value === FILTER_EMPTY_VALUE ? null : value,
|
||||
)
|
||||
}
|
||||
disabled={isPending}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full text-sm border-dashed"
|
||||
disabled={isPending}
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedContaCartao ? (
|
||||
<ContaCartaoSelectContent
|
||||
label={selectedContaCartao.label}
|
||||
logo={selectedContaCartao.logo}
|
||||
isCartao={selectedContaCartao.kind === "cartao"}
|
||||
/>
|
||||
) : (
|
||||
"Todos"
|
||||
)}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={FILTER_EMPTY_VALUE}>Todos</SelectItem>
|
||||
{contaOptions.length > 0 ? (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Contas</SelectLabel>
|
||||
{contaOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<ContaCartaoSelectContent
|
||||
label={option.label}
|
||||
logo={option.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
) : null}
|
||||
{cartaoOptions.length > 0 ? (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Cartões</SelectLabel>
|
||||
{cartaoOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<ContaCartaoSelectContent
|
||||
label={option.label}
|
||||
logo={option.logo}
|
||||
isCartao={true}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DrawerFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleResetFilters}
|
||||
disabled={isPending || !hasActiveFilters}
|
||||
>
|
||||
Limpar filtros
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1090
src/features/transactions/components/table/transactions-table.tsx
Normal file
1090
src/features/transactions/components/table/transactions-table.tsx
Normal file
File diff suppressed because it is too large
Load Diff
341
src/features/transactions/components/transactions-export.tsx
Normal file
341
src/features/transactions/components/transactions-export.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
RiDownloadLine,
|
||||
RiFileExcelLine,
|
||||
RiFilePdfLine,
|
||||
RiFileTextLine,
|
||||
} from "@remixicon/react";
|
||||
import jsPDF from "jspdf";
|
||||
import autoTable from "jspdf-autotable";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import * as XLSX from "xlsx";
|
||||
import { formatCurrency } from "@/features/transactions/formatting-helpers";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu";
|
||||
import { formatDateOnly, formatDateTime } from "@/shared/utils/date";
|
||||
import {
|
||||
getPrimaryPdfColor,
|
||||
loadExportLogoDataUrl,
|
||||
} from "@/shared/utils/export-branding";
|
||||
import { displayPeriod } from "@/shared/utils/period";
|
||||
import type { LancamentoItem } from "./types";
|
||||
|
||||
interface LancamentosExportProps {
|
||||
lancamentos: LancamentoItem[];
|
||||
period: string;
|
||||
}
|
||||
|
||||
export function LancamentosExport({
|
||||
lancamentos,
|
||||
period,
|
||||
}: LancamentosExportProps) {
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const getFileName = (extension: string) => {
|
||||
return `lancamentos-${period}.${extension}`;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return (
|
||||
formatDateOnly(dateString, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
}) ?? dateString
|
||||
);
|
||||
};
|
||||
|
||||
const getContaCartaoName = (lancamento: LancamentoItem) => {
|
||||
if (lancamento.contaName) return lancamento.contaName;
|
||||
if (lancamento.cartaoName) return lancamento.cartaoName;
|
||||
return "-";
|
||||
};
|
||||
|
||||
const getNameWithInstallment = (lancamento: LancamentoItem) => {
|
||||
const isInstallment =
|
||||
lancamento.condition.trim().toLowerCase() === "parcelado";
|
||||
|
||||
if (!isInstallment || !lancamento.installmentCount) {
|
||||
return lancamento.name;
|
||||
}
|
||||
|
||||
return `${lancamento.name} (${lancamento.currentInstallment ?? 1}/${lancamento.installmentCount})`;
|
||||
};
|
||||
|
||||
const exportToCSV = () => {
|
||||
try {
|
||||
setIsExporting(true);
|
||||
|
||||
const headers = [
|
||||
"Data",
|
||||
"Nome",
|
||||
"Tipo",
|
||||
"Condição",
|
||||
"Pagamento",
|
||||
"Valor",
|
||||
"Categoria",
|
||||
"Conta/Cartão",
|
||||
"Pagador",
|
||||
];
|
||||
const rows: string[][] = [];
|
||||
|
||||
lancamentos.forEach((lancamento) => {
|
||||
const row = [
|
||||
formatDate(lancamento.purchaseDate),
|
||||
getNameWithInstallment(lancamento),
|
||||
lancamento.transactionType,
|
||||
lancamento.condition,
|
||||
lancamento.paymentMethod,
|
||||
formatCurrency(lancamento.amount),
|
||||
lancamento.categoriaName ?? "-",
|
||||
getContaCartaoName(lancamento),
|
||||
lancamento.pagadorName ?? "-",
|
||||
];
|
||||
rows.push(row);
|
||||
});
|
||||
|
||||
const csvContent = [
|
||||
headers.join(","),
|
||||
...rows.map((row) => row.map((cell) => `"${cell}"`).join(",")),
|
||||
].join("\n");
|
||||
|
||||
const blob = new Blob([`\uFEFF${csvContent}`], {
|
||||
type: "text/csv;charset=utf-8;",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = getFileName("csv");
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success("Lançamentos exportados em CSV com sucesso!");
|
||||
} catch (error) {
|
||||
console.error("Error exporting to CSV:", error);
|
||||
toast.error("Erro ao exportar lançamentos em CSV");
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportToExcel = () => {
|
||||
try {
|
||||
setIsExporting(true);
|
||||
|
||||
const headers = [
|
||||
"Data",
|
||||
"Nome",
|
||||
"Tipo",
|
||||
"Condição",
|
||||
"Pagamento",
|
||||
"Valor",
|
||||
"Categoria",
|
||||
"Conta/Cartão",
|
||||
"Pagador",
|
||||
];
|
||||
const rows: (string | number)[][] = [];
|
||||
|
||||
lancamentos.forEach((lancamento) => {
|
||||
const row = [
|
||||
formatDate(lancamento.purchaseDate),
|
||||
getNameWithInstallment(lancamento),
|
||||
lancamento.transactionType,
|
||||
lancamento.condition,
|
||||
lancamento.paymentMethod,
|
||||
lancamento.amount,
|
||||
lancamento.categoriaName ?? "-",
|
||||
getContaCartaoName(lancamento),
|
||||
lancamento.pagadorName ?? "-",
|
||||
];
|
||||
rows.push(row);
|
||||
});
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
|
||||
|
||||
ws["!cols"] = [
|
||||
{ wch: 12 }, // Data
|
||||
{ wch: 42 }, // Nome
|
||||
{ wch: 15 }, // Tipo
|
||||
{ wch: 15 }, // Condição
|
||||
{ wch: 20 }, // Pagamento
|
||||
{ wch: 15 }, // Valor
|
||||
{ wch: 20 }, // Categoria
|
||||
{ wch: 20 }, // Conta/Cartão
|
||||
{ wch: 20 }, // Pagador
|
||||
];
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Lançamentos");
|
||||
XLSX.writeFile(wb, getFileName("xlsx"));
|
||||
|
||||
toast.success("Lançamentos exportados em Excel com sucesso!");
|
||||
} catch (error) {
|
||||
console.error("Error exporting to Excel:", error);
|
||||
toast.error("Erro ao exportar lançamentos em Excel");
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportToPDF = async () => {
|
||||
try {
|
||||
setIsExporting(true);
|
||||
|
||||
const doc = new jsPDF({ orientation: "landscape" });
|
||||
const primaryColor = getPrimaryPdfColor();
|
||||
const [smallLogoDataUrl, textLogoDataUrl] = await Promise.all([
|
||||
loadExportLogoDataUrl("/images/logo_small.png"),
|
||||
loadExportLogoDataUrl("/images/logo_text.png"),
|
||||
]);
|
||||
let brandingEndX = 14;
|
||||
|
||||
if (smallLogoDataUrl) {
|
||||
doc.addImage(smallLogoDataUrl, "PNG", brandingEndX, 7.5, 8, 8);
|
||||
brandingEndX += 10;
|
||||
}
|
||||
|
||||
if (textLogoDataUrl) {
|
||||
doc.addImage(textLogoDataUrl, "PNG", brandingEndX, 8, 30, 8);
|
||||
brandingEndX += 32;
|
||||
}
|
||||
|
||||
const titleX = brandingEndX > 14 ? brandingEndX + 4 : 14;
|
||||
|
||||
doc.setFont("courier", "normal");
|
||||
doc.setFontSize(16);
|
||||
doc.text("Lançamentos", titleX, 15);
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.text(`Período: ${displayPeriod(period)}`, titleX, 22);
|
||||
doc.text(
|
||||
`Gerado em: ${
|
||||
formatDateTime(new Date(), {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
}) ?? "—"
|
||||
}`,
|
||||
titleX,
|
||||
27,
|
||||
);
|
||||
doc.setDrawColor(...primaryColor);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(14, 31, doc.internal.pageSize.getWidth() - 14, 31);
|
||||
|
||||
const headers = [
|
||||
[
|
||||
"Data",
|
||||
"Nome",
|
||||
"Tipo",
|
||||
"Condição",
|
||||
"Pagamento",
|
||||
"Valor",
|
||||
"Categoria",
|
||||
"Conta/Cartão",
|
||||
"Pagador",
|
||||
],
|
||||
];
|
||||
|
||||
const body = lancamentos.map((lancamento) => [
|
||||
formatDate(lancamento.purchaseDate),
|
||||
getNameWithInstallment(lancamento),
|
||||
lancamento.transactionType,
|
||||
lancamento.condition,
|
||||
lancamento.paymentMethod,
|
||||
formatCurrency(lancamento.amount),
|
||||
lancamento.categoriaName ?? "-",
|
||||
getContaCartaoName(lancamento),
|
||||
lancamento.pagadorName ?? "-",
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
head: headers,
|
||||
body: body,
|
||||
startY: 35,
|
||||
tableWidth: "auto",
|
||||
styles: {
|
||||
font: "courier",
|
||||
fontSize: 8,
|
||||
cellPadding: 2,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: primaryColor,
|
||||
textColor: 255,
|
||||
fontStyle: "bold",
|
||||
},
|
||||
columnStyles: {
|
||||
0: { cellWidth: 24 }, // Data
|
||||
1: { cellWidth: 58 }, // Nome
|
||||
2: { cellWidth: 22 }, // Tipo
|
||||
3: { cellWidth: 22 }, // Condição
|
||||
4: { cellWidth: 28 }, // Pagamento
|
||||
5: { cellWidth: 24 }, // Valor
|
||||
6: { cellWidth: 30 }, // Categoria
|
||||
7: { cellWidth: 30 }, // Conta/Cartão
|
||||
8: { cellWidth: 31 }, // Pagador
|
||||
},
|
||||
didParseCell: (cellData) => {
|
||||
if (cellData.section === "body" && cellData.column.index === 5) {
|
||||
const lancamento = lancamentos[cellData.row.index];
|
||||
if (lancamento) {
|
||||
if (lancamento.transactionType === "Despesa") {
|
||||
cellData.cell.styles.textColor = [220, 38, 38];
|
||||
} else if (lancamento.transactionType === "Receita") {
|
||||
cellData.cell.styles.textColor = [22, 163, 74];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
margin: { top: 35 },
|
||||
});
|
||||
|
||||
doc.save(getFileName("pdf"));
|
||||
|
||||
toast.success("Lançamentos exportados em PDF com sucesso!");
|
||||
} catch (error) {
|
||||
console.error("Error exporting to PDF:", error);
|
||||
toast.error("Erro ao exportar lançamentos em PDF");
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-sm border-dashed"
|
||||
disabled={isExporting || lancamentos.length === 0}
|
||||
aria-label="Exportar lançamentos"
|
||||
>
|
||||
<RiDownloadLine className="size-4" />
|
||||
{isExporting ? "Exportando..." : "Exportar"}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={exportToCSV} disabled={isExporting}>
|
||||
<RiFileTextLine className="mr-2 h-4 w-4" />
|
||||
Exportar como CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={exportToExcel} disabled={isExporting}>
|
||||
<RiFileExcelLine className="mr-2 h-4 w-4" />
|
||||
Exportar como Excel (.xlsx)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={exportToPDF} disabled={isExporting}>
|
||||
<RiFilePdfLine className="mr-2 h-4 w-4" />
|
||||
Exportar como PDF
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
63
src/features/transactions/components/types.ts
Normal file
63
src/features/transactions/components/types.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export type LancamentoItem = {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
purchaseDate: string;
|
||||
period: string;
|
||||
transactionType: string;
|
||||
amount: number;
|
||||
condition: string;
|
||||
paymentMethod: string;
|
||||
pagadorId: string | null;
|
||||
pagadorName: string | null;
|
||||
pagadorAvatar: string | null;
|
||||
pagadorRole: string | null;
|
||||
contaId: string | null;
|
||||
contaName: string | null;
|
||||
contaLogo: string | null;
|
||||
cartaoId: string | null;
|
||||
cartaoName: string | null;
|
||||
cartaoLogo: string | null;
|
||||
categoriaId: string | null;
|
||||
categoriaName: string | null;
|
||||
categoriaType: string | null;
|
||||
categoriaIcon: string | null;
|
||||
installmentCount: number | null;
|
||||
recurrenceCount: number | null;
|
||||
currentInstallment: number | null;
|
||||
dueDate: string | null;
|
||||
boletoPaymentDate: string | null;
|
||||
note: string | null;
|
||||
isSettled: boolean | null;
|
||||
isDivided: boolean;
|
||||
isAnticipated: boolean;
|
||||
anticipationId: string | null;
|
||||
seriesId: string | null;
|
||||
readonly?: boolean;
|
||||
};
|
||||
|
||||
export type SelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
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;
|
||||
};
|
||||
|
||||
export type LancamentoFilterOption = {
|
||||
slug: string;
|
||||
label: string;
|
||||
icon?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
};
|
||||
|
||||
export type ContaCartaoFilterOption = LancamentoFilterOption & {
|
||||
kind: "conta" | "cartao";
|
||||
logo?: string | null;
|
||||
};
|
||||
21
src/features/transactions/constants.ts
Normal file
21
src/features/transactions/constants.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export const LANCAMENTO_TRANSACTION_TYPES = [
|
||||
"Despesa",
|
||||
"Receita",
|
||||
"Transferência",
|
||||
] as const;
|
||||
|
||||
export const LANCAMENTO_CONDITIONS = [
|
||||
"À vista",
|
||||
"Parcelado",
|
||||
"Recorrente",
|
||||
] as const;
|
||||
|
||||
export const LANCAMENTO_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;
|
||||
404
src/features/transactions/form-helpers.ts
Normal file
404
src/features/transactions/form-helpers.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
import type { LancamentoItem } from "@/features/transactions/components/types";
|
||||
import { getTodayDateString } from "@/shared/utils/date";
|
||||
import { derivePeriodFromDate, getNextPeriod } from "@/shared/utils/period";
|
||||
import {
|
||||
LANCAMENTO_CONDITIONS,
|
||||
LANCAMENTO_PAYMENT_METHODS,
|
||||
LANCAMENTO_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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split type for dividing transactions between payers
|
||||
*/
|
||||
export type SplitType = "equal" | "60-40" | "70-30" | "80-20" | "custom";
|
||||
|
||||
/**
|
||||
* Form state type for lancamento dialog
|
||||
*/
|
||||
export type LancamentoFormState = {
|
||||
purchaseDate: string;
|
||||
period: string;
|
||||
name: string;
|
||||
transactionType: string;
|
||||
amount: string;
|
||||
condition: string;
|
||||
paymentMethod: string;
|
||||
pagadorId: string | undefined;
|
||||
secondaryPagadorId: string | undefined;
|
||||
isSplit: boolean;
|
||||
splitType: SplitType;
|
||||
primarySplitAmount: string;
|
||||
secondarySplitAmount: string;
|
||||
contaId: string | undefined;
|
||||
cartaoId: string | undefined;
|
||||
categoriaId: string | undefined;
|
||||
installmentCount: string;
|
||||
recurrenceCount: string;
|
||||
dueDate: string;
|
||||
boletoPaymentDate: string;
|
||||
note: string;
|
||||
isSettled: boolean | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initial state overrides for lancamento form
|
||||
*/
|
||||
export type LancamentoFormOverrides = {
|
||||
defaultCartaoId?: 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 buildLancamentoInitialState(
|
||||
lancamento?: LancamentoItem,
|
||||
defaultPagadorId?: string | null,
|
||||
preferredPeriod?: string,
|
||||
overrides?: LancamentoFormOverrides,
|
||||
): LancamentoFormState {
|
||||
const purchaseDate = lancamento?.purchaseDate
|
||||
? lancamento.purchaseDate.slice(0, 10)
|
||||
: (overrides?.defaultPurchaseDate ?? "");
|
||||
|
||||
const paymentMethod =
|
||||
lancamento?.paymentMethod ??
|
||||
overrides?.defaultPaymentMethod ??
|
||||
LANCAMENTO_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 fallbackPagadorId = isImporting
|
||||
? (defaultPagadorId ?? null)
|
||||
: (lancamento?.pagadorId ?? defaultPagadorId ?? null);
|
||||
|
||||
const boletoPaymentDate =
|
||||
lancamento?.boletoPaymentDate ??
|
||||
(paymentMethod === "Boleto" && (lancamento?.isSettled ?? false)
|
||||
? getTodayDateString()
|
||||
: "");
|
||||
|
||||
// Calcular o valor correto para importação de parcelados
|
||||
let amountValue = overrides?.defaultAmount ?? "";
|
||||
if (!amountValue && typeof lancamento?.amount === "number") {
|
||||
let baseAmount = Math.abs(lancamento.amount);
|
||||
|
||||
// Se está importando e é parcelado, usar o valor total (parcela * quantidade)
|
||||
if (
|
||||
isImporting &&
|
||||
lancamento.condition === "Parcelado" &&
|
||||
lancamento.installmentCount
|
||||
) {
|
||||
baseAmount = baseAmount * lancamento.installmentCount;
|
||||
}
|
||||
|
||||
amountValue = (Math.round(baseAmount * 100) / 100).toFixed(2);
|
||||
}
|
||||
|
||||
return {
|
||||
purchaseDate,
|
||||
period:
|
||||
lancamento?.period && /^\d{4}-\d{2}$/.test(lancamento.period)
|
||||
? lancamento.period
|
||||
: fallbackPeriod,
|
||||
name: lancamento?.name ?? overrides?.defaultName ?? "",
|
||||
transactionType:
|
||||
lancamento?.transactionType ??
|
||||
overrides?.defaultTransactionType ??
|
||||
LANCAMENTO_TRANSACTION_TYPES[0],
|
||||
amount: amountValue,
|
||||
condition: lancamento?.condition ?? LANCAMENTO_CONDITIONS[0],
|
||||
paymentMethod,
|
||||
pagadorId: fallbackPagadorId ?? undefined,
|
||||
secondaryPagadorId: undefined,
|
||||
isSplit: false,
|
||||
splitType: "equal",
|
||||
primarySplitAmount: "",
|
||||
secondarySplitAmount: "",
|
||||
contaId:
|
||||
paymentMethod === "Cartão de crédito"
|
||||
? undefined
|
||||
: isImporting
|
||||
? undefined
|
||||
: (lancamento?.contaId ?? undefined),
|
||||
cartaoId:
|
||||
paymentMethod === "Cartão de crédito"
|
||||
? isImporting
|
||||
? (overrides?.defaultCartaoId ?? undefined)
|
||||
: (lancamento?.cartaoId ?? overrides?.defaultCartaoId ?? undefined)
|
||||
: undefined,
|
||||
categoriaId: isImporting
|
||||
? undefined
|
||||
: (lancamento?.categoriaId ?? undefined),
|
||||
installmentCount: lancamento?.installmentCount
|
||||
? String(lancamento.installmentCount)
|
||||
: "",
|
||||
recurrenceCount: lancamento?.recurrenceCount
|
||||
? String(lancamento.recurrenceCount)
|
||||
: "",
|
||||
dueDate: lancamento?.dueDate ?? "",
|
||||
boletoPaymentDate,
|
||||
note: lancamento?.note ?? "",
|
||||
isSettled:
|
||||
paymentMethod === "Cartão de crédito"
|
||||
? null
|
||||
: (lancamento?.isSettled ?? true),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Split presets with their percentages
|
||||
*/
|
||||
const SPLIT_PRESETS: Record<SplitType, { primary: number; secondary: number }> =
|
||||
{
|
||||
equal: { primary: 50, secondary: 50 },
|
||||
"60-40": { primary: 60, secondary: 40 },
|
||||
"70-30": { primary: 70, secondary: 30 },
|
||||
"80-20": { primary: 80, secondary: 20 },
|
||||
custom: { primary: 50, secondary: 50 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates split amounts based on total and split type
|
||||
*/
|
||||
export function calculateSplitAmounts(
|
||||
totalAmount: number,
|
||||
splitType: SplitType,
|
||||
): { primary: string; secondary: string } {
|
||||
if (totalAmount <= 0) {
|
||||
return { primary: "", secondary: "" };
|
||||
}
|
||||
|
||||
const preset = SPLIT_PRESETS[splitType];
|
||||
const primaryAmount = (totalAmount * preset.primary) / 100;
|
||||
const secondaryAmount = totalAmount - primaryAmount;
|
||||
|
||||
return {
|
||||
primary: primaryAmount.toFixed(2),
|
||||
secondary: secondaryAmount.toFixed(2),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies field dependencies when form state changes
|
||||
* This function encapsulates the business logic for field interdependencies
|
||||
*/
|
||||
export function applyFieldDependencies(
|
||||
key: keyof LancamentoFormState,
|
||||
value: LancamentoFormState[keyof LancamentoFormState],
|
||||
currentState: LancamentoFormState,
|
||||
cardInfo?: { closingDay: string | null; dueDay: string | null } | null,
|
||||
): Partial<LancamentoFormState> {
|
||||
const updates: Partial<LancamentoFormState> = {};
|
||||
|
||||
// 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 cartaoId changes (credit card selected)
|
||||
if (
|
||||
key === "cartaoId" &&
|
||||
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.contaId = undefined;
|
||||
updates.isSettled = null;
|
||||
} else {
|
||||
updates.cartaoId = 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.cartaoId &&
|
||||
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.secondaryPagadorId = undefined;
|
||||
updates.splitType = "equal";
|
||||
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 splitAmounts = calculateSplitAmounts(
|
||||
totalAmount,
|
||||
currentState.splitType,
|
||||
);
|
||||
updates.primarySplitAmount = splitAmounts.primary;
|
||||
updates.secondarySplitAmount = splitAmounts.secondary;
|
||||
} else {
|
||||
updates.primarySplitAmount = "";
|
||||
updates.secondarySplitAmount = "";
|
||||
}
|
||||
}
|
||||
|
||||
// When primary pagador changes, clear secondary if it matches
|
||||
if (key === "pagadorId" && typeof value === "string") {
|
||||
const secondaryValue = currentState.secondaryPagadorId;
|
||||
if (secondaryValue && secondaryValue === value) {
|
||||
updates.secondaryPagadorId = 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;
|
||||
}
|
||||
98
src/features/transactions/formatting-helpers.ts
Normal file
98
src/features/transactions/formatting-helpers.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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 };
|
||||
|
||||
/**
|
||||
* Date formatter for pt-BR locale (dd/mm/yyyy)
|
||||
*/
|
||||
export const dateFormatter = new Intl.DateTimeFormat("pt-BR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
/**
|
||||
* Month formatter for pt-BR locale (Month Year)
|
||||
*/
|
||||
export const monthFormatter = new Intl.DateTimeFormat("pt-BR", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the badge variant for a transaction type
|
||||
* @param type - Transaction type (Receita/Despesa)
|
||||
* @returns Badge variant
|
||||
*/
|
||||
export function getTransactionBadgeVariant(
|
||||
type?: string | null,
|
||||
): "default" | "destructive" | "secondary" {
|
||||
if (!type) return "secondary";
|
||||
const normalized = type.toLowerCase();
|
||||
return normalized === "receita" || normalized === "saldo inicial"
|
||||
? "default"
|
||||
: "destructive";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
541
src/features/transactions/page-helpers.ts
Normal file
541
src/features/transactions/page-helpers.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { and, eq, ilike, isNotNull, or } from "drizzle-orm";
|
||||
import {
|
||||
cartoes,
|
||||
type categorias,
|
||||
contas,
|
||||
lancamentos,
|
||||
type pagadores,
|
||||
} from "@/db/schema";
|
||||
import type { SelectOption } from "@/features/transactions/components/types";
|
||||
import {
|
||||
LANCAMENTO_CONDITIONS,
|
||||
LANCAMENTO_PAYMENT_METHODS,
|
||||
LANCAMENTO_TRANSACTION_TYPES,
|
||||
} from "@/features/transactions/constants";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
||||
import {
|
||||
PAGADOR_ROLE_ADMIN,
|
||||
PAGADOR_ROLE_TERCEIRO,
|
||||
} from "@/shared/lib/payers/constants";
|
||||
import { toDateOnlyString } from "@/shared/utils/date";
|
||||
|
||||
type PagadorRow = typeof pagadores.$inferSelect;
|
||||
type ContaRow = typeof contas.$inferSelect;
|
||||
type CartaoRow = typeof cartoes.$inferSelect;
|
||||
type CategoriaRow = typeof categorias.$inferSelect;
|
||||
|
||||
export type ResolvedSearchParams =
|
||||
| Record<string, string | string[] | undefined>
|
||||
| undefined;
|
||||
|
||||
export type LancamentoSearchFilters = {
|
||||
transactionFilter: string | null;
|
||||
conditionFilter: string | null;
|
||||
paymentFilter: string | null;
|
||||
pagadorFilter: string | null;
|
||||
categoriaFilter: string | null;
|
||||
contaCartaoFilter: string | null;
|
||||
searchFilter: string | null;
|
||||
};
|
||||
|
||||
type BaseSluggedOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
type PagadorSluggedOption = BaseSluggedOption & {
|
||||
role: string | null;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
|
||||
type CategoriaSluggedOption = BaseSluggedOption & {
|
||||
type: string | null;
|
||||
icon: string | null;
|
||||
};
|
||||
|
||||
type ContaSluggedOption = BaseSluggedOption & {
|
||||
kind: "conta";
|
||||
logo: string | null;
|
||||
accountType: string | null;
|
||||
};
|
||||
|
||||
type CartaoSluggedOption = BaseSluggedOption & {
|
||||
kind: "cartao";
|
||||
logo: string | null;
|
||||
closingDay: string | null;
|
||||
dueDay: string | null;
|
||||
};
|
||||
|
||||
export type SluggedFilters = {
|
||||
pagadorFiltersRaw: PagadorSluggedOption[];
|
||||
categoriaFiltersRaw: CategoriaSluggedOption[];
|
||||
contaFiltersRaw: ContaSluggedOption[];
|
||||
cartaoFiltersRaw: CartaoSluggedOption[];
|
||||
};
|
||||
|
||||
export type SlugMaps = {
|
||||
pagador: Map<string, string>;
|
||||
categoria: Map<string, string>;
|
||||
conta: Map<string, string>;
|
||||
cartao: Map<string, string>;
|
||||
};
|
||||
|
||||
export type FilterOption = {
|
||||
slug: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ContaCartaoFilterOption = FilterOption & {
|
||||
kind: "conta" | "cartao";
|
||||
};
|
||||
|
||||
export type LancamentoOptionSets = {
|
||||
pagadorOptions: SelectOption[];
|
||||
splitPagadorOptions: SelectOption[];
|
||||
defaultPagadorId: string | null;
|
||||
contaOptions: SelectOption[];
|
||||
cartaoOptions: SelectOption[];
|
||||
categoriaOptions: SelectOption[];
|
||||
pagadorFilterOptions: FilterOption[];
|
||||
categoriaFilterOptions: FilterOption[];
|
||||
contaCartaoFilterOptions: ContaCartaoFilterOption[];
|
||||
};
|
||||
|
||||
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 extractLancamentoSearchFilters = (
|
||||
params: ResolvedSearchParams,
|
||||
): LancamentoSearchFilters => ({
|
||||
transactionFilter: getSingleParam(params, "transacao"),
|
||||
conditionFilter: getSingleParam(params, "condicao"),
|
||||
paymentFilter: getSingleParam(params, "pagamento"),
|
||||
pagadorFilter: getSingleParam(params, "pagador"),
|
||||
categoriaFilter: getSingleParam(params, "categoria"),
|
||||
contaCartaoFilter: getSingleParam(params, "contaCartao"),
|
||||
searchFilter: getSingleParam(params, "q"),
|
||||
});
|
||||
|
||||
const normalizeLabel = (value: string | null | undefined) =>
|
||||
value?.trim().length ? value.trim() : "Sem descrição";
|
||||
|
||||
const slugify = (value: string) => {
|
||||
const base = value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return base || "item";
|
||||
};
|
||||
|
||||
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}`;
|
||||
};
|
||||
};
|
||||
|
||||
export 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 = ({
|
||||
pagadorRows,
|
||||
categoriaRows,
|
||||
contaRows,
|
||||
cartaoRows,
|
||||
}: {
|
||||
pagadorRows: PagadorRow[];
|
||||
categoriaRows: CategoriaRow[];
|
||||
contaRows: ContaRow[];
|
||||
cartaoRows: CartaoRow[];
|
||||
}): SluggedFilters => {
|
||||
const pagadorSlugger = createSlugGenerator();
|
||||
const categoriaSlugger = createSlugGenerator();
|
||||
const contaCartaoSlugger = createSlugGenerator();
|
||||
|
||||
const pagadorFiltersRaw = pagadorRows.map((pagador) => {
|
||||
const label = normalizeLabel(pagador.name);
|
||||
return {
|
||||
id: pagador.id,
|
||||
label,
|
||||
slug: pagadorSlugger(label),
|
||||
role: pagador.role ?? null,
|
||||
avatarUrl: pagador.avatarUrl ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const categoriaFiltersRaw = categoriaRows.map((categoria) => {
|
||||
const label = normalizeLabel(categoria.name);
|
||||
return {
|
||||
id: categoria.id,
|
||||
label,
|
||||
slug: categoriaSlugger(label),
|
||||
type: categoria.type ?? null,
|
||||
icon: categoria.icon ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const contaFiltersRaw = contaRows.map((conta) => {
|
||||
const label = normalizeLabel(conta.name);
|
||||
return {
|
||||
id: conta.id,
|
||||
label,
|
||||
slug: contaCartaoSlugger(label),
|
||||
kind: "conta" as const,
|
||||
logo: conta.logo ?? null,
|
||||
accountType: conta.accountType ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const cartaoFiltersRaw = cartaoRows.map((cartao) => {
|
||||
const label = normalizeLabel(cartao.name);
|
||||
return {
|
||||
id: cartao.id,
|
||||
label,
|
||||
slug: contaCartaoSlugger(label),
|
||||
kind: "cartao" as const,
|
||||
logo: cartao.logo ?? null,
|
||||
closingDay: cartao.closingDay ?? null,
|
||||
dueDay: cartao.dueDay ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
pagadorFiltersRaw,
|
||||
categoriaFiltersRaw,
|
||||
contaFiltersRaw,
|
||||
cartaoFiltersRaw,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSlugMaps = ({
|
||||
pagadorFiltersRaw,
|
||||
categoriaFiltersRaw,
|
||||
contaFiltersRaw,
|
||||
cartaoFiltersRaw,
|
||||
}: SluggedFilters): SlugMaps => ({
|
||||
pagador: new Map(pagadorFiltersRaw.map(({ slug, id }) => [slug, id])),
|
||||
categoria: new Map(categoriaFiltersRaw.map(({ slug, id }) => [slug, id])),
|
||||
conta: new Map(contaFiltersRaw.map(({ slug, id }) => [slug, id])),
|
||||
cartao: new Map(cartaoFiltersRaw.map(({ slug, id }) => [slug, id])),
|
||||
});
|
||||
|
||||
const isValidTransaction = (
|
||||
value: string | null,
|
||||
): value is (typeof LANCAMENTO_TRANSACTION_TYPES)[number] =>
|
||||
!!value &&
|
||||
(LANCAMENTO_TRANSACTION_TYPES as readonly string[]).includes(value ?? "");
|
||||
|
||||
const isValidCondition = (
|
||||
value: string | null,
|
||||
): value is (typeof LANCAMENTO_CONDITIONS)[number] =>
|
||||
!!value && (LANCAMENTO_CONDITIONS as readonly string[]).includes(value ?? "");
|
||||
|
||||
const isValidPaymentMethod = (
|
||||
value: string | null,
|
||||
): value is (typeof LANCAMENTO_PAYMENT_METHODS)[number] =>
|
||||
!!value &&
|
||||
(LANCAMENTO_PAYMENT_METHODS as readonly string[]).includes(value ?? "");
|
||||
|
||||
const buildSearchPattern = (value: string | null) =>
|
||||
value ? `%${value.trim().replace(/\s+/g, "%")}%` : null;
|
||||
|
||||
export const buildLancamentoWhere = ({
|
||||
userId,
|
||||
period,
|
||||
filters,
|
||||
slugMaps,
|
||||
cardId,
|
||||
accountId,
|
||||
pagadorId,
|
||||
}: {
|
||||
userId: string;
|
||||
period: string;
|
||||
filters: LancamentoSearchFilters;
|
||||
slugMaps: SlugMaps;
|
||||
cardId?: string;
|
||||
accountId?: string;
|
||||
pagadorId?: string;
|
||||
}): SQL[] => {
|
||||
const where: SQL[] = [
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
];
|
||||
|
||||
if (pagadorId) {
|
||||
where.push(eq(lancamentos.pagadorId, pagadorId));
|
||||
}
|
||||
|
||||
if (cardId) {
|
||||
where.push(eq(lancamentos.cartaoId, cardId));
|
||||
}
|
||||
|
||||
if (accountId) {
|
||||
where.push(eq(lancamentos.contaId, accountId));
|
||||
}
|
||||
|
||||
if (isValidTransaction(filters.transactionFilter)) {
|
||||
where.push(eq(lancamentos.transactionType, filters.transactionFilter));
|
||||
}
|
||||
|
||||
if (isValidCondition(filters.conditionFilter)) {
|
||||
where.push(eq(lancamentos.condition, filters.conditionFilter));
|
||||
}
|
||||
|
||||
if (isValidPaymentMethod(filters.paymentFilter)) {
|
||||
where.push(eq(lancamentos.paymentMethod, filters.paymentFilter));
|
||||
}
|
||||
|
||||
if (!pagadorId && filters.pagadorFilter) {
|
||||
const id = slugMaps.pagador.get(filters.pagadorFilter);
|
||||
if (id) {
|
||||
where.push(eq(lancamentos.pagadorId, id));
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.categoriaFilter) {
|
||||
const id = slugMaps.categoria.get(filters.categoriaFilter);
|
||||
if (id) {
|
||||
where.push(eq(lancamentos.categoriaId, id));
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.contaCartaoFilter) {
|
||||
const contaId = slugMaps.conta.get(filters.contaCartaoFilter);
|
||||
const relatedCartaoId = contaId
|
||||
? null
|
||||
: slugMaps.cartao.get(filters.contaCartaoFilter);
|
||||
if (contaId) {
|
||||
where.push(eq(lancamentos.contaId, contaId));
|
||||
}
|
||||
if (!contaId && relatedCartaoId) {
|
||||
where.push(eq(lancamentos.cartaoId, relatedCartaoId));
|
||||
}
|
||||
}
|
||||
|
||||
const searchPattern = buildSearchPattern(filters.searchFilter);
|
||||
if (searchPattern) {
|
||||
where.push(
|
||||
or(
|
||||
ilike(lancamentos.name, searchPattern),
|
||||
ilike(lancamentos.note, searchPattern),
|
||||
ilike(lancamentos.paymentMethod, searchPattern),
|
||||
ilike(lancamentos.condition, searchPattern),
|
||||
and(isNotNull(contas.name), ilike(contas.name, searchPattern)),
|
||||
and(isNotNull(cartoes.name), ilike(cartoes.name, searchPattern)),
|
||||
) as SQL,
|
||||
);
|
||||
}
|
||||
|
||||
return where;
|
||||
};
|
||||
|
||||
type LancamentoRowWithRelations = typeof lancamentos.$inferSelect & {
|
||||
pagador?: PagadorRow | null;
|
||||
conta?: ContaRow | null;
|
||||
cartao?: CartaoRow | null;
|
||||
categoria?: CategoriaRow | null;
|
||||
};
|
||||
|
||||
export const mapLancamentosData = (rows: LancamentoRowWithRelations[]) =>
|
||||
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,
|
||||
pagadorId: item.pagadorId ?? null,
|
||||
pagadorName: item.pagador?.name ?? null,
|
||||
pagadorAvatar: item.pagador?.avatarUrl ?? null,
|
||||
pagadorRole: item.pagador?.role ?? null,
|
||||
contaId: item.contaId ?? null,
|
||||
contaName: item.conta?.name ?? null,
|
||||
contaLogo: item.conta?.logo ?? null,
|
||||
cartaoId: item.cartaoId ?? null,
|
||||
cartaoName: item.cartao?.name ?? null,
|
||||
cartaoLogo: item.cartao?.logo ?? null,
|
||||
categoriaId: item.categoriaId ?? null,
|
||||
categoriaName: item.categoria?.name ?? null,
|
||||
categoriaType: item.categoria?.type ?? null,
|
||||
categoriaIcon: item.categoria?.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,
|
||||
readonly:
|
||||
Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
|
||||
item.categoria?.name === "Saldo inicial" ||
|
||||
item.categoria?.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 = ({
|
||||
pagadorFiltersRaw,
|
||||
categoriaFiltersRaw,
|
||||
contaFiltersRaw,
|
||||
cartaoFiltersRaw,
|
||||
pagadorRows,
|
||||
limitCartaoId,
|
||||
limitContaId,
|
||||
}: SluggedFilters & {
|
||||
pagadorRows: PagadorRow[];
|
||||
limitCartaoId?: string;
|
||||
limitContaId?: string;
|
||||
}): LancamentoOptionSets => {
|
||||
const pagadorOptions = sortByLabel(
|
||||
pagadorFiltersRaw.map(({ id, label, role, slug, avatarUrl }) =>
|
||||
toOption(id, label, role, undefined, slug, avatarUrl),
|
||||
),
|
||||
);
|
||||
|
||||
const pagadorFilterOptions = sortByLabel(
|
||||
pagadorFiltersRaw.map(({ slug, label, avatarUrl }) => ({
|
||||
slug,
|
||||
label,
|
||||
avatarUrl,
|
||||
})),
|
||||
);
|
||||
|
||||
const defaultPagadorId =
|
||||
pagadorRows.find((pagador) => pagador.role === PAGADOR_ROLE_ADMIN)?.id ??
|
||||
null;
|
||||
|
||||
const splitPagadorOptions = pagadorOptions.filter(
|
||||
(option) => option.role === PAGADOR_ROLE_TERCEIRO,
|
||||
);
|
||||
|
||||
const contaOptionsSource = limitContaId
|
||||
? contaFiltersRaw.filter((conta) => conta.id === limitContaId)
|
||||
: contaFiltersRaw;
|
||||
|
||||
const contaOptions = sortByLabel(
|
||||
contaOptionsSource.map(({ id, label, slug, logo, accountType }) =>
|
||||
toOption(
|
||||
id,
|
||||
label,
|
||||
undefined,
|
||||
undefined,
|
||||
slug,
|
||||
undefined,
|
||||
logo,
|
||||
undefined,
|
||||
accountType,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const cartaoOptionsSource = limitCartaoId
|
||||
? cartaoFiltersRaw.filter((cartao) => cartao.id === limitCartaoId)
|
||||
: cartaoFiltersRaw;
|
||||
|
||||
const cartaoOptions = sortByLabel(
|
||||
cartaoOptionsSource.map(({ id, label, slug, logo, closingDay, dueDay }) =>
|
||||
toOption(
|
||||
id,
|
||||
label,
|
||||
undefined,
|
||||
undefined,
|
||||
slug,
|
||||
undefined,
|
||||
logo,
|
||||
undefined,
|
||||
undefined,
|
||||
closingDay,
|
||||
dueDay,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const categoriaOptions = sortByLabel(
|
||||
categoriaFiltersRaw.map(({ id, label, type, slug, icon }) =>
|
||||
toOption(id, label, undefined, type, slug, undefined, undefined, icon),
|
||||
),
|
||||
);
|
||||
|
||||
const categoriaFilterOptions = sortByLabel(
|
||||
categoriaFiltersRaw.map(({ slug, label, icon }) => ({ slug, label, icon })),
|
||||
);
|
||||
|
||||
const contaCartaoFilterOptions = sortByLabel(
|
||||
[...contaFiltersRaw, ...cartaoFiltersRaw]
|
||||
.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 {
|
||||
pagadorOptions,
|
||||
splitPagadorOptions,
|
||||
defaultPagadorId,
|
||||
contaOptions,
|
||||
cartaoOptions,
|
||||
categoriaOptions,
|
||||
pagadorFilterOptions,
|
||||
categoriaFilterOptions,
|
||||
contaCartaoFilterOptions,
|
||||
};
|
||||
};
|
||||
101
src/features/transactions/queries.ts
Normal file
101
src/features/transactions/queries.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { and, desc, eq, gte, isNull, ne, or, type SQL } from "drizzle-orm";
|
||||
import {
|
||||
cartoes,
|
||||
categorias,
|
||||
contas,
|
||||
lancamentos,
|
||||
pagadores,
|
||||
} from "@/db/schema";
|
||||
import { INITIAL_BALANCE_NOTE } from "@/shared/lib/accounts/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
|
||||
export async function fetchLancamentoFilterSources(userId: string) {
|
||||
const [pagadorRows, contaRows, cartaoRows, categoriaRows] = await Promise.all(
|
||||
[
|
||||
db.query.pagadores.findMany({
|
||||
where: eq(pagadores.userId, userId),
|
||||
}),
|
||||
db.query.contas.findMany({
|
||||
where: and(eq(contas.userId, userId), eq(contas.status, "Ativa")),
|
||||
}),
|
||||
db.query.cartoes.findMany({
|
||||
where: and(eq(cartoes.userId, userId), eq(cartoes.status, "Ativo")),
|
||||
}),
|
||||
db.query.categorias.findMany({
|
||||
where: eq(categorias.userId, userId),
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
return { pagadorRows, contaRows, cartaoRows, categoriaRows };
|
||||
}
|
||||
|
||||
export async function fetchLancamentos(filters: SQL[]) {
|
||||
const lancamentoRows = await db
|
||||
.select({
|
||||
lancamento: lancamentos,
|
||||
pagador: pagadores,
|
||||
conta: contas,
|
||||
cartao: cartoes,
|
||||
categoria: categorias,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
|
||||
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
|
||||
.leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.where(
|
||||
and(
|
||||
...filters,
|
||||
// Excluir saldos iniciais de contas que têm excludeInitialBalanceFromIncome = true
|
||||
or(
|
||||
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
|
||||
isNull(contas.excludeInitialBalanceFromIncome),
|
||||
eq(contas.excludeInitialBalanceFromIncome, false),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
|
||||
|
||||
// Transformar resultado para o formato esperado
|
||||
return lancamentoRows.map((row) => ({
|
||||
...row.lancamento,
|
||||
pagador: row.pagador,
|
||||
conta: row.conta,
|
||||
cartao: row.cartao,
|
||||
categoria: row.categoria,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchRecentEstablishments(
|
||||
userId: string,
|
||||
): Promise<string[]> {
|
||||
const threeMonthsAgo = new Date();
|
||||
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
|
||||
|
||||
const results = await db
|
||||
.select({ name: lancamentos.name })
|
||||
.from(lancamentos)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
gte(lancamentos.purchaseDate, threeMonthsAgo),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(lancamentos.purchaseDate));
|
||||
|
||||
const uniqueNames = Array.from(
|
||||
new Set<string>(
|
||||
results
|
||||
.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);
|
||||
}
|
||||
Reference in New Issue
Block a user