mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
feat(finance): refina fluxos de transacoes e pagadores
This commit is contained in:
@@ -20,7 +20,10 @@ import {
|
|||||||
PERIOD_FORMAT_REGEX,
|
PERIOD_FORMAT_REGEX,
|
||||||
} from "@/lib/faturas";
|
} from "@/lib/faturas";
|
||||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||||
import { parseLocalDateString } from "@/lib/utils/date";
|
import { getBusinessTodayDate, parseLocalDateString } from "@/lib/utils/date";
|
||||||
|
|
||||||
|
const isValidPaymentDate = (value: string) =>
|
||||||
|
!Number.isNaN(parseLocalDateString(value).getTime());
|
||||||
|
|
||||||
const updateInvoicePaymentStatusSchema = z.object({
|
const updateInvoicePaymentStatusSchema = z.object({
|
||||||
cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."),
|
cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."),
|
||||||
@@ -30,7 +33,12 @@ const updateInvoicePaymentStatusSchema = z.object({
|
|||||||
status: z.enum(
|
status: z.enum(
|
||||||
INVOICE_STATUS_VALUES as [InvoicePaymentStatus, ...InvoicePaymentStatus[]],
|
INVOICE_STATUS_VALUES as [InvoicePaymentStatus, ...InvoicePaymentStatus[]],
|
||||||
),
|
),
|
||||||
paymentDate: z.string().optional(),
|
paymentDate: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine((value) => !value || isValidPaymentDate(value), {
|
||||||
|
message: "Data de pagamento inválida.",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
type UpdateInvoicePaymentStatusInput = z.infer<
|
type UpdateInvoicePaymentStatusInput = z.infer<
|
||||||
@@ -157,7 +165,7 @@ export async function updateInvoicePaymentStatusAction(
|
|||||||
// Usar a data customizada ou a data atual como data de pagamento
|
// Usar a data customizada ou a data atual como data de pagamento
|
||||||
const invoiceDate = data.paymentDate
|
const invoiceDate = data.paymentDate
|
||||||
? parseLocalDateString(data.paymentDate)
|
? parseLocalDateString(data.paymentDate)
|
||||||
: new Date();
|
: getBusinessTodayDate();
|
||||||
|
|
||||||
const amount = `-${formatDecimal(adminShare)}`;
|
const amount = `-${formatDecimal(adminShare)}`;
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -229,7 +237,11 @@ const updatePaymentDateSchema = z.object({
|
|||||||
period: z
|
period: z
|
||||||
.string({ message: "Período inválido." })
|
.string({ message: "Período inválido." })
|
||||||
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
|
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
|
||||||
paymentDate: z.string({ message: "Data de pagamento inválida." }),
|
paymentDate: z
|
||||||
|
.string({ message: "Data de pagamento inválida." })
|
||||||
|
.refine((value) => isValidPaymentDate(value), {
|
||||||
|
message: "Data de pagamento inválida.",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
type UpdatePaymentDateInput = z.infer<typeof updatePaymentDateSchema>;
|
type UpdatePaymentDateInput = z.infer<typeof updatePaymentDateSchema>;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
pagadores,
|
pagadores,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
|
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
|
||||||
import type { ActionResult } from "@/lib/actions/types";
|
|
||||||
import { getUser } from "@/lib/auth/server";
|
import { getUser } from "@/lib/auth/server";
|
||||||
import {
|
import {
|
||||||
INITIAL_BALANCE_CONDITION,
|
INITIAL_BALANCE_CONDITION,
|
||||||
@@ -30,8 +29,10 @@ import {
|
|||||||
sendPagadorAutoEmails,
|
sendPagadorAutoEmails,
|
||||||
} from "@/lib/pagadores/notifications";
|
} from "@/lib/pagadores/notifications";
|
||||||
import { noteSchema, uuidSchema } from "@/lib/schemas/common";
|
import { noteSchema, uuidSchema } from "@/lib/schemas/common";
|
||||||
|
import type { ActionResult } from "@/lib/types/actions";
|
||||||
import { formatDecimalForDbRequired } from "@/lib/utils/currency";
|
import { formatDecimalForDbRequired } from "@/lib/utils/currency";
|
||||||
import { getTodayDate, parseLocalDateString } from "@/lib/utils/date";
|
import { getBusinessTodayDate, parseLocalDateString } from "@/lib/utils/date";
|
||||||
|
import { addMonthsToPeriod } from "@/lib/utils/period";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Authorization Validation Functions
|
// Authorization Validation Functions
|
||||||
@@ -108,11 +109,14 @@ const resolvePeriod = (purchaseDate: string, period?: string | null) => {
|
|||||||
return `${year}-${month}`;
|
return `${year}-${month}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isValidDateInput = (value: string) =>
|
||||||
|
!Number.isNaN(parseLocalDateString(value).getTime());
|
||||||
|
|
||||||
const baseFields = z.object({
|
const baseFields = z.object({
|
||||||
purchaseDate: z
|
purchaseDate: z
|
||||||
.string({ message: "Informe a data da transação." })
|
.string({ message: "Informe a data da transação." })
|
||||||
.trim()
|
.trim()
|
||||||
.refine((value) => !Number.isNaN(new Date(value).getTime()), {
|
.refine((value) => isValidDateInput(value), {
|
||||||
message: "Data da transação inválida.",
|
message: "Data da transação inválida.",
|
||||||
}),
|
}),
|
||||||
period: z
|
period: z
|
||||||
@@ -164,14 +168,14 @@ const baseFields = z.object({
|
|||||||
dueDate: z
|
dueDate: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.refine((value) => !value || !Number.isNaN(new Date(value).getTime()), {
|
.refine((value) => !value || isValidDateInput(value), {
|
||||||
message: "Informe uma data de vencimento válida.",
|
message: "Informe uma data de vencimento válida.",
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
boletoPaymentDate: z
|
boletoPaymentDate: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.refine((value) => !value || !Number.isNaN(new Date(value).getTime()), {
|
.refine((value) => !value || isValidDateInput(value), {
|
||||||
message: "Informe uma data de pagamento válida.",
|
message: "Informe uma data de pagamento válida.",
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
@@ -353,23 +357,6 @@ const splitAmount = (totalCents: number, parts: number) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addMonthsToPeriod = (period: string, offset: number) => {
|
|
||||||
const [yearStr, monthStr] = period.split("-");
|
|
||||||
const baseYear = Number(yearStr);
|
|
||||||
const baseMonth = Number(monthStr);
|
|
||||||
|
|
||||||
if (!baseYear || !baseMonth) {
|
|
||||||
throw new Error("Período inválido.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(baseYear, baseMonth - 1, 1);
|
|
||||||
date.setMonth(date.getMonth() + offset);
|
|
||||||
|
|
||||||
const nextYear = date.getFullYear();
|
|
||||||
const nextMonth = String(date.getMonth() + 1).padStart(2, "0");
|
|
||||||
return `${nextYear}-${nextMonth}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const addMonthsToDate = (value: Date, offset: number) => {
|
const addMonthsToDate = (value: Date, offset: number) => {
|
||||||
const result = new Date(value);
|
const result = new Date(value);
|
||||||
const originalDay = result.getDate();
|
const originalDay = result.getDate();
|
||||||
@@ -648,7 +635,7 @@ export async function createLancamentoAction(
|
|||||||
const boletoPaymentDate = shouldSetBoletoPaymentDate
|
const boletoPaymentDate = shouldSetBoletoPaymentDate
|
||||||
? data.boletoPaymentDate
|
? data.boletoPaymentDate
|
||||||
? parseLocalDateString(data.boletoPaymentDate)
|
? parseLocalDateString(data.boletoPaymentDate)
|
||||||
: getTodayDate()
|
: getBusinessTodayDate()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1;
|
const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1;
|
||||||
@@ -828,7 +815,7 @@ export async function updateLancamentoAction(
|
|||||||
const boletoPaymentDateValue = shouldSetBoletoPaymentDate
|
const boletoPaymentDateValue = shouldSetBoletoPaymentDate
|
||||||
? data.boletoPaymentDate
|
? data.boletoPaymentDate
|
||||||
? parseLocalDateString(data.boletoPaymentDate)
|
? parseLocalDateString(data.boletoPaymentDate)
|
||||||
: getTodayDate()
|
: getBusinessTodayDate()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
@@ -982,7 +969,7 @@ export async function toggleLancamentoSettlementAction(
|
|||||||
const isBoleto = existing.paymentMethod === "Boleto";
|
const isBoleto = existing.paymentMethod === "Boleto";
|
||||||
const boletoPaymentDate = isBoleto
|
const boletoPaymentDate = isBoleto
|
||||||
? data.value
|
? data.value
|
||||||
? getTodayDate()
|
? getBusinessTodayDate()
|
||||||
: null
|
: null
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -1118,7 +1105,7 @@ const updateBulkSchema = z.object({
|
|||||||
dueDate: z
|
dueDate: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.refine((value) => !value || !Number.isNaN(new Date(value).getTime()), {
|
.refine((value) => !value || isValidDateInput(value), {
|
||||||
message: "Informe uma data de vencimento válida.",
|
message: "Informe uma data de vencimento válida.",
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
@@ -1126,7 +1113,7 @@ const updateBulkSchema = z.object({
|
|||||||
boletoPaymentDate: z
|
boletoPaymentDate: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.refine((value) => !value || !Number.isNaN(new Date(value).getTime()), {
|
.refine((value) => !value || isValidDateInput(value), {
|
||||||
message: "Informe uma data de pagamento válida.",
|
message: "Informe uma data de pagamento válida.",
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
@@ -1284,7 +1271,7 @@ export async function updateLancamentoBulkAction(
|
|||||||
});
|
});
|
||||||
|
|
||||||
await applyUpdates(
|
await applyUpdates(
|
||||||
futureLancamentos.map((item) => ({
|
futureLancamentos.map((item: (typeof futureLancamentos)[number]) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
purchaseDate: item.purchaseDate ?? null,
|
purchaseDate: item.purchaseDate ?? null,
|
||||||
})),
|
})),
|
||||||
@@ -1311,7 +1298,7 @@ export async function updateLancamentoBulkAction(
|
|||||||
});
|
});
|
||||||
|
|
||||||
await applyUpdates(
|
await applyUpdates(
|
||||||
allLancamentos.map((item) => ({
|
allLancamentos.map((item: (typeof allLancamentos)[number]) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
purchaseDate: item.purchaseDate ?? null,
|
purchaseDate: item.purchaseDate ?? null,
|
||||||
})),
|
})),
|
||||||
@@ -1335,7 +1322,7 @@ const massAddTransactionSchema = z.object({
|
|||||||
purchaseDate: z
|
purchaseDate: z
|
||||||
.string({ message: "Informe a data da transação." })
|
.string({ message: "Informe a data da transação." })
|
||||||
.trim()
|
.trim()
|
||||||
.refine((value) => !Number.isNaN(new Date(value).getTime()), {
|
.refine((value) => isValidDateInput(value), {
|
||||||
message: "Data da transação inválida.",
|
message: "Data da transação inválida.",
|
||||||
}),
|
}),
|
||||||
name: z
|
name: z
|
||||||
@@ -1598,12 +1585,12 @@ export async function deleteMultipleLancamentosAction(
|
|||||||
const notificationData = existing
|
const notificationData = existing
|
||||||
.filter(
|
.filter(
|
||||||
(
|
(
|
||||||
item,
|
item: (typeof existing)[number],
|
||||||
): item is typeof item & {
|
): item is typeof item & {
|
||||||
pagadorId: NonNullable<typeof item.pagadorId>;
|
pagadorId: NonNullable<typeof item.pagadorId>;
|
||||||
} => Boolean(item.pagadorId),
|
} => Boolean(item.pagadorId),
|
||||||
)
|
)
|
||||||
.map((item) => ({
|
.map((item: (typeof existing)[number]) => ({
|
||||||
pagadorId: item.pagadorId,
|
pagadorId: item.pagadorId,
|
||||||
name: item.name ?? null,
|
name: item.name ?? null,
|
||||||
amount: item.amount ?? null,
|
amount: item.amount ?? null,
|
||||||
@@ -1662,11 +1649,11 @@ export async function getRecentEstablishmentsAction(): Promise<string[]> {
|
|||||||
|
|
||||||
// Remove duplicates and filter empty names
|
// Remove duplicates and filter empty names
|
||||||
const uniqueNames = Array.from(
|
const uniqueNames = Array.from(
|
||||||
new Set(
|
new Set<string>(
|
||||||
results
|
results
|
||||||
.map((r) => r.name)
|
.map((r: (typeof results)[number]) => r.name)
|
||||||
.filter(
|
.filter(
|
||||||
(name): name is string =>
|
(name: string | null): name is string =>
|
||||||
name != null &&
|
name != null &&
|
||||||
name.trim().length > 0 &&
|
name.trim().length > 0 &&
|
||||||
!name.toLowerCase().startsWith("pagamento fatura"),
|
!name.toLowerCase().startsWith("pagamento fatura"),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
pagadores,
|
pagadores,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
|
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
|
||||||
import type { ActionResult } from "@/lib/actions/types";
|
import type { ActionResult } from "@/lib/types/actions";
|
||||||
import { getUser } from "@/lib/auth/server";
|
import { getUser } from "@/lib/auth/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -3,18 +3,16 @@
|
|||||||
import { and, eq, ne } from "drizzle-orm";
|
import { and, eq, ne } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { categorias, orcamentos } from "@/db/schema";
|
import { categorias, orcamentos } from "@/db/schema";
|
||||||
import {
|
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
|
||||||
type ActionResult,
|
|
||||||
handleActionError,
|
|
||||||
revalidateForEntity,
|
|
||||||
} from "@/lib/actions/helpers";
|
|
||||||
import { getUser } from "@/lib/auth/server";
|
import { getUser } from "@/lib/auth/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { periodSchema, uuidSchema } from "@/lib/schemas/common";
|
import { periodSchema, uuidSchema } from "@/lib/schemas/common";
|
||||||
|
import type { ActionResult } from "@/lib/types/actions";
|
||||||
import {
|
import {
|
||||||
formatDecimalForDbRequired,
|
formatDecimalForDbRequired,
|
||||||
normalizeDecimalInput,
|
normalizeDecimalInput,
|
||||||
} from "@/lib/utils/currency";
|
} from "@/lib/utils/currency";
|
||||||
|
import { getPreviousPeriod } from "@/lib/utils/period";
|
||||||
|
|
||||||
const budgetBaseSchema = z.object({
|
const budgetBaseSchema = z.object({
|
||||||
categoriaId: uuidSchema("Categoria"),
|
categoriaId: uuidSchema("Categoria"),
|
||||||
@@ -43,9 +41,13 @@ const deleteBudgetSchema = z.object({
|
|||||||
id: uuidSchema("Orçamento"),
|
id: uuidSchema("Orçamento"),
|
||||||
});
|
});
|
||||||
|
|
||||||
type BudgetCreateInput = z.infer<typeof createBudgetSchema>;
|
type BudgetCreateInput = z.input<typeof createBudgetSchema>;
|
||||||
type BudgetUpdateInput = z.infer<typeof updateBudgetSchema>;
|
type BudgetUpdateInput = z.input<typeof updateBudgetSchema>;
|
||||||
type BudgetDeleteInput = z.infer<typeof deleteBudgetSchema>;
|
type BudgetDeleteInput = z.input<typeof deleteBudgetSchema>;
|
||||||
|
type BudgetCopyRow = {
|
||||||
|
categoriaId: string | null;
|
||||||
|
amount: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
const ensureCategory = async (userId: string, categoriaId: string) => {
|
const ensureCategory = async (userId: string, categoriaId: string) => {
|
||||||
const category = await db.query.categorias.findFirst({
|
const category = await db.query.categorias.findFirst({
|
||||||
@@ -193,7 +195,7 @@ const duplicatePreviousMonthSchema = z.object({
|
|||||||
period: periodSchema,
|
period: periodSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
type DuplicatePreviousMonthInput = z.infer<typeof duplicatePreviousMonthSchema>;
|
type DuplicatePreviousMonthInput = z.input<typeof duplicatePreviousMonthSchema>;
|
||||||
|
|
||||||
export async function duplicatePreviousMonthBudgetsAction(
|
export async function duplicatePreviousMonthBudgetsAction(
|
||||||
input: DuplicatePreviousMonthInput,
|
input: DuplicatePreviousMonthInput,
|
||||||
@@ -203,22 +205,15 @@ export async function duplicatePreviousMonthBudgetsAction(
|
|||||||
const data = duplicatePreviousMonthSchema.parse(input);
|
const data = duplicatePreviousMonthSchema.parse(input);
|
||||||
|
|
||||||
// Calcular mês anterior
|
// Calcular mês anterior
|
||||||
const [year, month] = data.period.split("-").map(Number);
|
const previousPeriod = getPreviousPeriod(data.period);
|
||||||
const currentDate = new Date(year, month - 1, 1);
|
|
||||||
const previousDate = new Date(currentDate);
|
|
||||||
previousDate.setMonth(previousDate.getMonth() - 1);
|
|
||||||
|
|
||||||
const prevYear = previousDate.getFullYear();
|
|
||||||
const prevMonth = String(previousDate.getMonth() + 1).padStart(2, "0");
|
|
||||||
const previousPeriod = `${prevYear}-${prevMonth}`;
|
|
||||||
|
|
||||||
// Buscar orçamentos do mês anterior
|
// Buscar orçamentos do mês anterior
|
||||||
const previousBudgets = await db.query.orcamentos.findMany({
|
const previousBudgets = (await db.query.orcamentos.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(orcamentos.userId, user.id),
|
eq(orcamentos.userId, user.id),
|
||||||
eq(orcamentos.period, previousPeriod),
|
eq(orcamentos.period, previousPeriod),
|
||||||
),
|
),
|
||||||
});
|
})) as BudgetCopyRow[];
|
||||||
|
|
||||||
if (previousBudgets.length === 0) {
|
if (previousBudgets.length === 0) {
|
||||||
return {
|
return {
|
||||||
@@ -228,12 +223,12 @@ export async function duplicatePreviousMonthBudgetsAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Buscar orçamentos existentes do mês atual
|
// Buscar orçamentos existentes do mês atual
|
||||||
const currentBudgets = await db.query.orcamentos.findMany({
|
const currentBudgets = (await db.query.orcamentos.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(orcamentos.userId, user.id),
|
eq(orcamentos.userId, user.id),
|
||||||
eq(orcamentos.period, data.period),
|
eq(orcamentos.period, data.period),
|
||||||
),
|
),
|
||||||
});
|
})) as BudgetCopyRow[];
|
||||||
|
|
||||||
// Filtrar para evitar duplicatas
|
// Filtrar para evitar duplicatas
|
||||||
const existingCategoryIds = new Set(
|
const existingCategoryIds = new Set(
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
fetchPagadorHistory,
|
fetchPagadorHistory,
|
||||||
fetchPagadorMonthlyBreakdown,
|
fetchPagadorMonthlyBreakdown,
|
||||||
} from "@/lib/pagadores/details";
|
} from "@/lib/pagadores/details";
|
||||||
|
import { formatCurrency } from "@/lib/utils/currency";
|
||||||
|
import { formatDateTime } from "@/lib/utils/date";
|
||||||
import { displayPeriod } from "@/lib/utils/period";
|
import { displayPeriod } from "@/lib/utils/period";
|
||||||
|
|
||||||
const inputSchema = z.object({
|
const inputSchema = z.object({
|
||||||
@@ -27,20 +29,14 @@ type ActionResult =
|
|||||||
| { success: true; message: string }
|
| { success: true; message: string }
|
||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
|
|
||||||
const formatCurrency = (value: number) =>
|
|
||||||
value.toLocaleString("pt-BR", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "BRL",
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatDate = (value: Date | null | undefined) => {
|
const formatDate = (value: Date | null | undefined) => {
|
||||||
if (!value) return "—";
|
return (
|
||||||
return value.toLocaleDateString("pt-BR", {
|
formatDateTime(value, {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
month: "short",
|
month: "short",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
});
|
}) ?? "—"
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Escapa HTML para prevenir XSS
|
// Escapa HTML para prevenir XSS
|
||||||
@@ -515,25 +511,47 @@ export async function sendPagadorSummaryAction(
|
|||||||
.orderBy(desc(lancamentos.purchaseDate)),
|
.orderBy(desc(lancamentos.purchaseDate)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const normalizedBoletos: BoletoItem[] = boletoRows.map((row) => ({
|
const normalizedBoletos: BoletoItem[] = (
|
||||||
|
boletoRows as Array<{
|
||||||
|
name: string | null;
|
||||||
|
amount: unknown;
|
||||||
|
dueDate: Date | null;
|
||||||
|
}>
|
||||||
|
).map((row) => ({
|
||||||
name: row.name ?? "Sem descrição",
|
name: row.name ?? "Sem descrição",
|
||||||
amount: Math.abs(Number(row.amount ?? 0)),
|
amount: Math.abs(Number(row.amount ?? 0)),
|
||||||
dueDate: row.dueDate,
|
dueDate: row.dueDate,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const normalizedLancamentos: LancamentoRow[] = lancamentoRows.map(
|
const normalizedLancamentos: LancamentoRow[] = (
|
||||||
(row) => ({
|
lancamentoRows as Array<{
|
||||||
id: row.id,
|
id: string;
|
||||||
name: row.name,
|
name: string | null;
|
||||||
paymentMethod: row.paymentMethod,
|
paymentMethod: string | null;
|
||||||
condition: row.condition,
|
condition: string | null;
|
||||||
transactionType: row.transactionType,
|
transactionType: string | null;
|
||||||
purchaseDate: row.purchaseDate,
|
purchaseDate: Date | null;
|
||||||
amount: Number(row.amount ?? 0),
|
amount: unknown;
|
||||||
}),
|
}>
|
||||||
);
|
).map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
paymentMethod: row.paymentMethod,
|
||||||
|
condition: row.condition,
|
||||||
|
transactionType: row.transactionType,
|
||||||
|
purchaseDate: row.purchaseDate,
|
||||||
|
amount: Number(row.amount ?? 0),
|
||||||
|
}));
|
||||||
|
|
||||||
const normalizedParcelados: ParceladoItem[] = parceladoRows.map((row) => {
|
const normalizedParcelados: ParceladoItem[] = (
|
||||||
|
parceladoRows as Array<{
|
||||||
|
name: string | null;
|
||||||
|
amount: unknown;
|
||||||
|
installmentCount: number | null;
|
||||||
|
currentInstallment: number | null;
|
||||||
|
purchaseDate: Date | null;
|
||||||
|
}>
|
||||||
|
).map((row) => {
|
||||||
const installmentAmount = Math.abs(Number(row.amount ?? 0));
|
const installmentAmount = Math.abs(Number(row.amount ?? 0));
|
||||||
const installmentCount = row.installmentCount ?? 1;
|
const installmentCount = row.installmentCount ?? 1;
|
||||||
const totalAmount = installmentAmount * installmentCount;
|
const totalAmount = installmentAmount * installmentCount;
|
||||||
|
|||||||
@@ -1,56 +1,47 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loading state para a página de detalhes do pagador
|
* Loading state para a página de detalhes do pagador.
|
||||||
* Layout: MonthPicker + Info do pagador + Tabs (Visão Geral / Lançamentos)
|
* Layout: navegação mensal + tabs com card compartilhado do pagador.
|
||||||
*/
|
*/
|
||||||
export default function PagadorDetailsLoading() {
|
export default function PagadorDetailsLoading() {
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col gap-6">
|
<main className="flex flex-col gap-6">
|
||||||
{/* Month Picker placeholder */}
|
|
||||||
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
|
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
|
||||||
|
|
||||||
{/* Info do Pagador (sempre visível) */}
|
|
||||||
<div className="rounded-2xl border p-6 space-y-4">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
{/* Avatar */}
|
|
||||||
<Skeleton className="size-20 rounded-full bg-foreground/10" />
|
|
||||||
|
|
||||||
<div className="flex-1 space-y-3">
|
|
||||||
{/* Nome + Badge */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Skeleton className="h-7 w-48 rounded-2xl bg-foreground/10" />
|
|
||||||
<Skeleton className="h-6 w-20 rounded-2xl bg-foreground/10" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Email */}
|
|
||||||
<Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" />
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Skeleton className="size-2 rounded-full bg-foreground/10" />
|
|
||||||
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Botões de ação */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
|
|
||||||
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="space-y-6 pt-4">
|
<div className="space-y-6 pt-4">
|
||||||
<div className="flex gap-2 border-b">
|
<div className="flex gap-2 border-b">
|
||||||
<Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" />
|
<Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" />
|
||||||
<Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" />
|
<Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" />
|
||||||
|
<Skeleton className="h-10 w-36 rounded-t-2xl bg-foreground/10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border p-6 space-y-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Skeleton className="size-20 rounded-full bg-foreground/10" />
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="h-7 w-48 rounded-2xl bg-foreground/10" />
|
||||||
|
<Skeleton className="h-6 w-20 rounded-2xl bg-foreground/10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="size-2 rounded-full bg-foreground/10" />
|
||||||
|
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
|
||||||
|
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conteúdo da aba Visão Geral (grid de cards) */}
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{/* Card de resumo mensal */}
|
|
||||||
<div className="rounded-2xl border p-6 space-y-4 lg:col-span-2">
|
<div className="rounded-2xl border p-6 space-y-4 lg:col-span-2">
|
||||||
<Skeleton className="h-6 w-48 rounded-2xl bg-foreground/10" />
|
<Skeleton className="h-6 w-48 rounded-2xl bg-foreground/10" />
|
||||||
<div className="grid grid-cols-3 gap-4 pt-4">
|
<div className="grid grid-cols-3 gap-4 pt-4">
|
||||||
@@ -63,7 +54,6 @@ export default function PagadorDetailsLoading() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Outros cards */}
|
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
<div key={i} className="rounded-2xl border p-6 space-y-4">
|
<div key={i} className="rounded-2xl border p-6 space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
|
import { fetchUserPreferences } from "@/app/(dashboard)/ajustes/data";
|
||||||
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||||
|
import { ExpandableWidgetCard } from "@/components/shared/expandable-widget-card";
|
||||||
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
|
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
|
||||||
import type {
|
import type {
|
||||||
ContaCartaoFilterOption,
|
ContaCartaoFilterOption,
|
||||||
@@ -15,6 +16,7 @@ import type {
|
|||||||
} from "@/components/lancamentos/types";
|
} from "@/components/lancamentos/types";
|
||||||
import MonthNavigation from "@/components/month-picker/month-navigation";
|
import MonthNavigation from "@/components/month-picker/month-navigation";
|
||||||
import { PagadorCardUsageCard } from "@/components/pagadores/details/pagador-card-usage-card";
|
import { PagadorCardUsageCard } from "@/components/pagadores/details/pagador-card-usage-card";
|
||||||
|
import { PagadorHeaderCard } from "@/components/pagadores/details/pagador-header-card";
|
||||||
import { PagadorHistoryCard } from "@/components/pagadores/details/pagador-history-card";
|
import { PagadorHistoryCard } from "@/components/pagadores/details/pagador-history-card";
|
||||||
import { PagadorInfoCard } from "@/components/pagadores/details/pagador-info-card";
|
import { PagadorInfoCard } from "@/components/pagadores/details/pagador-info-card";
|
||||||
import { PagadorLeaveShareCard } from "@/components/pagadores/details/pagador-leave-share-card";
|
import { PagadorLeaveShareCard } from "@/components/pagadores/details/pagador-leave-share-card";
|
||||||
@@ -25,7 +27,6 @@ import {
|
|||||||
} from "@/components/pagadores/details/pagador-payment-method-cards";
|
} from "@/components/pagadores/details/pagador-payment-method-cards";
|
||||||
import { PagadorSharingCard } from "@/components/pagadores/details/pagador-sharing-card";
|
import { PagadorSharingCard } from "@/components/pagadores/details/pagador-sharing-card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import WidgetCard from "@/components/widget-card";
|
|
||||||
import type { pagadores } from "@/db/schema";
|
import type { pagadores } from "@/db/schema";
|
||||||
import { getUserId } from "@/lib/auth/server";
|
import { getUserId } from "@/lib/auth/server";
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +51,7 @@ import {
|
|||||||
fetchPagadorHistory,
|
fetchPagadorHistory,
|
||||||
fetchPagadorMonthlyBreakdown,
|
fetchPagadorMonthlyBreakdown,
|
||||||
fetchPagadorPaymentStatus,
|
fetchPagadorPaymentStatus,
|
||||||
|
type PagadorCardUsageItem,
|
||||||
} from "@/lib/pagadores/details";
|
} from "@/lib/pagadores/details";
|
||||||
import { parsePeriodParam } from "@/lib/utils/period";
|
import { parsePeriodParam } from "@/lib/utils/period";
|
||||||
import {
|
import {
|
||||||
@@ -232,6 +234,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
label: pagador.name,
|
label: pagador.name,
|
||||||
slug: pagador.id,
|
slug: pagador.id,
|
||||||
role: pagador.role,
|
role: pagador.role,
|
||||||
|
avatarUrl: pagador.avatarUrl,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
categoriaFiltersRaw: [],
|
categoriaFiltersRaw: [],
|
||||||
@@ -284,7 +287,7 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
periodLabel,
|
periodLabel,
|
||||||
totalExpenses: monthlyBreakdown.totalExpenses,
|
totalExpenses: monthlyBreakdown.totalExpenses,
|
||||||
paymentSplits: monthlyBreakdown.paymentSplits,
|
paymentSplits: monthlyBreakdown.paymentSplits,
|
||||||
cardUsage: cardUsage.slice(0, 3).map((item) => ({
|
cardUsage: cardUsage.slice(0, 3).map((item: PagadorCardUsageItem) => ({
|
||||||
name: item.name,
|
name: item.name,
|
||||||
amount: item.amount,
|
amount: item.amount,
|
||||||
})),
|
})),
|
||||||
@@ -308,15 +311,14 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
<TabsTrigger value="painel">Painel</TabsTrigger>
|
<TabsTrigger value="painel">Painel</TabsTrigger>
|
||||||
<TabsTrigger value="lancamentos">Lançamentos</TabsTrigger>
|
<TabsTrigger value="lancamentos">Lançamentos</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
<PagadorHeaderCard
|
||||||
|
pagador={pagadorData}
|
||||||
|
selectedPeriod={selectedPeriod}
|
||||||
|
summary={summaryPreview}
|
||||||
|
/>
|
||||||
|
|
||||||
<TabsContent value="profile" className="space-y-4">
|
<TabsContent value="profile" className="space-y-4">
|
||||||
<section>
|
<PagadorInfoCard pagador={pagadorData} />
|
||||||
<PagadorInfoCard
|
|
||||||
pagador={pagadorData}
|
|
||||||
selectedPeriod={selectedPeriod}
|
|
||||||
summary={summaryPreview}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
{canEdit && pagadorData.shareCode ? (
|
{canEdit && pagadorData.shareCode ? (
|
||||||
<PagadorSharingCard
|
<PagadorSharingCard
|
||||||
pagadorId={pagador.id}
|
pagadorId={pagador.id}
|
||||||
@@ -343,27 +345,27 @@ export default async function Page({ params, searchParams }: PageProps) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-3 lg:grid-cols-3">
|
<section className="grid gap-3 lg:grid-cols-3">
|
||||||
<WidgetCard
|
<ExpandableWidgetCard
|
||||||
title="Minhas Faturas"
|
title="Minhas Faturas"
|
||||||
subtitle="Valores por cartão neste período"
|
subtitle="Valores por cartão neste período"
|
||||||
icon={<RiBankCard2Line className="size-4" />}
|
icon={<RiBankCard2Line className="size-4" />}
|
||||||
>
|
>
|
||||||
<PagadorCardUsageCard items={cardUsage} />
|
<PagadorCardUsageCard items={cardUsage} />
|
||||||
</WidgetCard>
|
</ExpandableWidgetCard>
|
||||||
<WidgetCard
|
<ExpandableWidgetCard
|
||||||
title="Boletos"
|
title="Boletos"
|
||||||
subtitle="Boletos registrados neste período"
|
subtitle="Boletos registrados neste período"
|
||||||
icon={<RiBarcodeLine className="size-4" />}
|
icon={<RiBarcodeLine className="size-4" />}
|
||||||
>
|
>
|
||||||
<PagadorBoletoCard items={boletoItems} />
|
<PagadorBoletoCard items={boletoItems} />
|
||||||
</WidgetCard>
|
</ExpandableWidgetCard>
|
||||||
<WidgetCard
|
<ExpandableWidgetCard
|
||||||
title="Status de Pagamento"
|
title="Status de Pagamento"
|
||||||
subtitle="Situação das despesas no período"
|
subtitle="Situação das despesas no período"
|
||||||
icon={<RiWallet3Line className="size-4" />}
|
icon={<RiWallet3Line className="size-4" />}
|
||||||
>
|
>
|
||||||
<PagadorPaymentStatusCard data={paymentStatus} />
|
<PagadorPaymentStatusCard data={paymentStatus} />
|
||||||
</WidgetCard>
|
</ExpandableWidgetCard>
|
||||||
</section>
|
</section>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { compartilhamentosPagador, pagadores, user } from "@/db/schema";
|
import { compartilhamentosPagador, pagadores, user } from "@/db/schema";
|
||||||
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
|
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
|
||||||
import type { ActionResult } from "@/lib/actions/types";
|
import type { ActionResult } from "@/lib/types/actions";
|
||||||
import { getUser } from "@/lib/auth/server";
|
import { getUser } from "@/lib/auth/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { and, eq, inArray } from "drizzle-orm";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { preLancamentos } from "@/db/schema";
|
import { preLancamentos } from "@/db/schema";
|
||||||
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
|
import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers";
|
||||||
import type { ActionResult } from "@/lib/actions/types";
|
import type { ActionResult } from "@/lib/types/actions";
|
||||||
import { getUser } from "@/lib/auth/server";
|
import { getUser } from "@/lib/auth/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
DAYS_IN_MONTH,
|
DAYS_IN_MONTH,
|
||||||
DEFAULT_CARD_BRANDS,
|
DEFAULT_CARD_BRANDS,
|
||||||
DEFAULT_CARD_STATUS,
|
DEFAULT_CARD_STATUS,
|
||||||
} from "./constants";
|
} from "@/lib/cartoes/constants";
|
||||||
import type { CardFormValues } from "./types";
|
import type { CardFormValues } from "./types";
|
||||||
|
|
||||||
interface AccountOption {
|
interface AccountOption {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
RiPencilLine,
|
RiPencilLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useMemo } from "react";
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -20,8 +20,9 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { resolveCardBrandAsset } from "@/lib/cartoes/brand-assets";
|
||||||
|
import { resolveLogoSrc } from "@/lib/logo";
|
||||||
import { cn } from "@/lib/utils/ui";
|
import { cn } from "@/lib/utils/ui";
|
||||||
import MoneyValues from "../money-values";
|
|
||||||
|
|
||||||
interface CardItemProps {
|
interface CardItemProps {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -40,26 +41,6 @@ interface CardItemProps {
|
|||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BRAND_ASSETS: Record<string, string> = {
|
|
||||||
visa: "/bandeiras/visa.svg",
|
|
||||||
mastercard: "/bandeiras/mastercard.svg",
|
|
||||||
amex: "/bandeiras/amex.svg",
|
|
||||||
american: "/bandeiras/amex.svg",
|
|
||||||
elo: "/bandeiras/elo.svg",
|
|
||||||
hipercard: "/bandeiras/hipercard.svg",
|
|
||||||
hiper: "/bandeiras/hipercard.svg",
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveBrandAsset = (brand: string) => {
|
|
||||||
const normalized = brand.trim().toLowerCase();
|
|
||||||
|
|
||||||
const match = (
|
|
||||||
Object.keys(BRAND_ASSETS) as Array<keyof typeof BRAND_ASSETS>
|
|
||||||
).find((entry) => normalized.includes(entry));
|
|
||||||
|
|
||||||
return match ? BRAND_ASSETS[match] : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDay = (value: string) => value.padStart(2, "0");
|
const formatDay = (value: string) => value.padStart(2, "0");
|
||||||
|
|
||||||
export function CardItem({
|
export function CardItem({
|
||||||
@@ -83,7 +64,7 @@ export function CardItem({
|
|||||||
const limitTotal = limit ?? null;
|
const limitTotal = limit ?? null;
|
||||||
const used =
|
const used =
|
||||||
limitInUse ??
|
limitInUse ??
|
||||||
(limitTotal !== null && limitAvailable !== null
|
(limitTotal !== null && limitAvailable != null
|
||||||
? Math.max(limitTotal - limitAvailable, 0)
|
? Math.max(limitTotal - limitAvailable, 0)
|
||||||
: limitTotal !== null
|
: limitTotal !== null
|
||||||
? 0
|
? 0
|
||||||
@@ -100,62 +81,38 @@ export function CardItem({
|
|||||||
? Math.min(Math.max((used / limitTotal) * 100, 0), 100)
|
? Math.min(Math.max((used / limitTotal) * 100, 0), 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const logoPath = useMemo(() => {
|
const logoPath = resolveLogoSrc(logo);
|
||||||
if (!logo) {
|
const brandAsset = resolveCardBrandAsset(brand);
|
||||||
return null;
|
const isInactive = status?.toLowerCase() === "inativo";
|
||||||
}
|
const metrics =
|
||||||
|
limitTotal === null || used === null || available === null
|
||||||
|
? null
|
||||||
|
: [
|
||||||
|
{ label: "Limite Total", value: limitTotal },
|
||||||
|
{ label: "Em uso", value: used },
|
||||||
|
{ label: "Disponível", value: available },
|
||||||
|
];
|
||||||
|
|
||||||
if (
|
const actions = [
|
||||||
logo.startsWith("http://") ||
|
{
|
||||||
logo.startsWith("https://") ||
|
label: "editar",
|
||||||
logo.startsWith("data:")
|
icon: <RiPencilLine className="size-4" aria-hidden />,
|
||||||
) {
|
onClick: onEdit,
|
||||||
return logo;
|
className: "text-primary",
|
||||||
}
|
},
|
||||||
|
{
|
||||||
return logo.startsWith("/") ? logo : `/logos/${logo}`;
|
label: "ver fatura",
|
||||||
}, [logo]);
|
icon: <RiFileList2Line className="size-4" aria-hidden />,
|
||||||
|
onClick: onInvoice,
|
||||||
const brandAsset = useMemo(() => resolveBrandAsset(brand), [brand]);
|
className: "text-primary",
|
||||||
|
},
|
||||||
const isInactive = useMemo(
|
{
|
||||||
() => status?.toLowerCase() === "inativo",
|
label: "remover",
|
||||||
[status],
|
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
|
||||||
);
|
onClick: onRemove,
|
||||||
|
className: "text-destructive",
|
||||||
const metrics = useMemo(() => {
|
},
|
||||||
if (limitTotal === null) return null;
|
];
|
||||||
|
|
||||||
return [
|
|
||||||
{ label: "Limite Total", value: limitTotal },
|
|
||||||
{ label: "Em uso", value: used },
|
|
||||||
{ label: "Disponível", value: available },
|
|
||||||
];
|
|
||||||
}, [available, limitTotal, used]);
|
|
||||||
|
|
||||||
const actions = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
label: "editar",
|
|
||||||
icon: <RiPencilLine className="size-4" aria-hidden />,
|
|
||||||
onClick: onEdit,
|
|
||||||
className: "text-primary",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "ver fatura",
|
|
||||||
icon: <RiFileList2Line className="size-4" aria-hidden />,
|
|
||||||
onClick: onInvoice,
|
|
||||||
className: "text-primary",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "remover",
|
|
||||||
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
|
|
||||||
onClick: onRemove,
|
|
||||||
className: "text-destructive",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[onEdit, onInvoice, onRemove],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-col p-6 w-full">
|
<Card className="flex flex-col p-6 w-full">
|
||||||
|
|||||||
@@ -2,35 +2,17 @@
|
|||||||
|
|
||||||
import { RiBankLine } from "@remixicon/react";
|
import { RiBankLine } from "@remixicon/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import DotIcon from "@/components/dot-icon";
|
import StatusDot from "@/components/shared/status-dot";
|
||||||
|
import { resolveCardBrandLogoSrc } from "@/lib/cartoes/brand-assets";
|
||||||
|
import { resolveLogoSrc } from "@/lib/logo";
|
||||||
|
|
||||||
type SelectItemContentProps = {
|
type SelectItemContentProps = {
|
||||||
label: string;
|
label: string;
|
||||||
logo?: string | null;
|
logo?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveLogoSrc = (logo: string | null) => {
|
|
||||||
if (!logo) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
|
|
||||||
return `/logos/${fileName}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBrandLogo = (brand: string): string | null => {
|
|
||||||
const brandMap: Record<string, string> = {
|
|
||||||
Visa: "visa.png",
|
|
||||||
Mastercard: "mastercard.png",
|
|
||||||
Elo: "elo.png",
|
|
||||||
};
|
|
||||||
|
|
||||||
return brandMap[brand] ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function BrandSelectContent({ label }: { label: string }) {
|
export function BrandSelectContent({ label }: { label: string }) {
|
||||||
const brandLogo = getBrandLogo(label);
|
const logoSrc = resolveCardBrandLogoSrc(label);
|
||||||
const logoSrc = brandLogo ? `/logos/${brandLogo}` : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
@@ -55,7 +37,7 @@ export function StatusSelectContent({ label }: { label: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<DotIcon
|
<StatusDot
|
||||||
color={isActive ? "bg-success" : "bg-slate-400 dark:bg-slate-500"}
|
color={isActive ? "bg-success" : "bg-slate-400 dark:bg-slate-500"}
|
||||||
/>
|
/>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
|
|||||||
@@ -2,25 +2,27 @@
|
|||||||
|
|
||||||
import { RiAddCircleLine, RiBankCard2Line } from "@remixicon/react";
|
import { RiAddCircleLine, RiBankCard2Line } from "@remixicon/react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { deleteCardAction } from "@/app/(dashboard)/cartoes/actions";
|
import { deleteCardAction } from "@/app/(dashboard)/cartoes/actions";
|
||||||
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
|
||||||
import { EmptyState } from "@/components/shared/empty-state";
|
import { EmptyState } from "@/components/shared/empty-state";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card as UiCard } from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { CardDialog } from "./card-dialog";
|
import { CardDialog } from "./card-dialog";
|
||||||
import { CardItem } from "./card-item";
|
import { CardItem } from "./card-item";
|
||||||
|
import type { Card as CreditCard } from "./types";
|
||||||
|
|
||||||
type AccountOption = {
|
type AccountOption = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
logo: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CardsPageProps {
|
interface CardsPageProps {
|
||||||
cards: Card[];
|
cards: CreditCard[];
|
||||||
archivedCards: Card[];
|
archivedCards: CreditCard[];
|
||||||
accounts: AccountOption[];
|
accounts: AccountOption[];
|
||||||
logoOptions: string[];
|
logoOptions: string[];
|
||||||
}
|
}
|
||||||
@@ -34,56 +36,54 @@ export function CardsPage({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [activeTab, setActiveTab] = useState("ativos");
|
const [activeTab, setActiveTab] = useState("ativos");
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [selectedCard, setSelectedCard] = useState<Card | null>(null);
|
const [selectedCard, setSelectedCard] = useState<CreditCard | null>(null);
|
||||||
const [removeOpen, setRemoveOpen] = useState(false);
|
const [removeOpen, setRemoveOpen] = useState(false);
|
||||||
const [cardToRemove, setCardToRemove] = useState<Card | null>(null);
|
const [cardToRemove, setCardToRemove] = useState<CreditCard | null>(null);
|
||||||
|
|
||||||
const sortCards = useCallback(
|
const orderedCards = useMemo(
|
||||||
(list: Card[]) =>
|
() =>
|
||||||
[...list].sort((a, b) =>
|
[...cards].sort((a, b) =>
|
||||||
a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" }),
|
a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" }),
|
||||||
),
|
),
|
||||||
[],
|
[cards],
|
||||||
);
|
);
|
||||||
|
|
||||||
const orderedCards = useMemo(() => sortCards(cards), [cards, sortCards]);
|
|
||||||
const orderedArchivedCards = useMemo(
|
const orderedArchivedCards = useMemo(
|
||||||
() => sortCards(archivedCards),
|
() =>
|
||||||
[archivedCards, sortCards],
|
[...archivedCards].sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" }),
|
||||||
|
),
|
||||||
|
[archivedCards],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleEdit = useCallback((card: Card) => {
|
const handleEdit = (card: CreditCard) => {
|
||||||
setSelectedCard(card);
|
setSelectedCard(card);
|
||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleEditOpenChange = useCallback((open: boolean) => {
|
const handleEditOpenChange = (open: boolean) => {
|
||||||
setEditOpen(open);
|
setEditOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setSelectedCard(null);
|
setSelectedCard(null);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleRemoveRequest = useCallback((card: Card) => {
|
const handleRemoveRequest = (card: CreditCard) => {
|
||||||
setCardToRemove(card);
|
setCardToRemove(card);
|
||||||
setRemoveOpen(true);
|
setRemoveOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleInvoice = useCallback(
|
const handleInvoice = (card: CreditCard) => {
|
||||||
(card: Card) => {
|
router.push(`/cartoes/${card.id}/fatura`);
|
||||||
router.push(`/cartoes/${card.id}/fatura`);
|
};
|
||||||
},
|
|
||||||
[router],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRemoveOpenChange = useCallback((open: boolean) => {
|
const handleRemoveOpenChange = (open: boolean) => {
|
||||||
setRemoveOpen(open);
|
setRemoveOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setCardToRemove(null);
|
setCardToRemove(null);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleRemoveConfirm = useCallback(async () => {
|
const handleRemoveConfirm = async () => {
|
||||||
if (!cardToRemove) {
|
if (!cardToRemove) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -97,16 +97,16 @@ export function CardsPage({
|
|||||||
|
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
}, [cardToRemove]);
|
};
|
||||||
|
|
||||||
const removeTitle = cardToRemove
|
const removeTitle = cardToRemove
|
||||||
? `Remover cartão "${cardToRemove.name}"?`
|
? `Remover cartão "${cardToRemove.name}"?`
|
||||||
: "Remover cartão?";
|
: "Remover cartão?";
|
||||||
|
|
||||||
const renderCardList = (list: Card[], isArchived: boolean) => {
|
const renderCardList = (list: CreditCard[], isArchived: boolean) => {
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card className="flex w-full items-center justify-center py-12">
|
<UiCard className="flex w-full items-center justify-center py-12">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
media={<RiBankCard2Line className="size-6 text-primary" />}
|
media={<RiBankCard2Line className="size-6 text-primary" />}
|
||||||
title={
|
title={
|
||||||
@@ -120,7 +120,7 @@ export function CardsPage({
|
|||||||
: "Adicione seu primeiro cartão para acompanhar limites e faturas com mais controle."
|
: "Adicione seu primeiro cartão para acompanhar limites e faturas com mais controle."
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</UiCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
export const DEFAULT_CARD_BRANDS = ["Visa", "Mastercard", "Elo"] as const;
|
|
||||||
|
|
||||||
export const DEFAULT_CARD_STATUS = ["Ativo", "Inativo"] as const;
|
|
||||||
|
|
||||||
export const DAYS_IN_MONTH = Array.from({ length: 31 }, (_, index) =>
|
|
||||||
String(index + 1).padStart(2, "0"),
|
|
||||||
);
|
|
||||||
@@ -7,10 +7,10 @@ import {
|
|||||||
RiPencilLine,
|
RiPencilLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { deleteCategoryAction } from "@/app/(dashboard)/categorias/actions";
|
import { deleteCategoryAction } from "@/app/(dashboard)/categorias/actions";
|
||||||
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
@@ -69,31 +69,31 @@ export function CategoriesPage({ categories }: CategoriesPageProps) {
|
|||||||
return base;
|
return base;
|
||||||
}, [categories]);
|
}, [categories]);
|
||||||
|
|
||||||
const handleEdit = useCallback((category: Category) => {
|
const handleEdit = (category: Category) => {
|
||||||
setSelectedCategory(category);
|
setSelectedCategory(category);
|
||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleEditOpenChange = useCallback((open: boolean) => {
|
const handleEditOpenChange = (open: boolean) => {
|
||||||
setEditOpen(open);
|
setEditOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setSelectedCategory(null);
|
setSelectedCategory(null);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleRemoveRequest = useCallback((category: Category) => {
|
const handleRemoveRequest = (category: Category) => {
|
||||||
setCategoryToRemove(category);
|
setCategoryToRemove(category);
|
||||||
setRemoveOpen(true);
|
setRemoveOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleRemoveOpenChange = useCallback((open: boolean) => {
|
const handleRemoveOpenChange = (open: boolean) => {
|
||||||
setRemoveOpen(open);
|
setRemoveOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setCategoryToRemove(null);
|
setCategoryToRemove(null);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleRemoveConfirm = useCallback(async () => {
|
const handleRemoveConfirm = async () => {
|
||||||
if (!categoryToRemove) {
|
if (!categoryToRemove) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -107,7 +107,7 @@ export function CategoriesPage({ categories }: CategoriesPageProps) {
|
|||||||
|
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
}, [categoryToRemove]);
|
};
|
||||||
|
|
||||||
const removeTitle = categoryToRemove
|
const removeTitle = categoryToRemove
|
||||||
? `Remover categoria "${categoryToRemove.name}"?`
|
? `Remover categoria "${categoryToRemove.name}"?`
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { RiArrowDownSFill, RiArrowUpSFill } from "@remixicon/react";
|
import { RiArrowDownSFill, RiArrowUpSFill } from "@remixicon/react";
|
||||||
|
import { TypeBadge } from "@/components/shared/type-badge";
|
||||||
import type { CategoryType } from "@/lib/categorias/constants";
|
import type { CategoryType } from "@/lib/categorias/constants";
|
||||||
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
|
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
|
||||||
|
import { formatPercentage } from "@/lib/utils/percentage";
|
||||||
import { cn } from "@/lib/utils/ui";
|
import { cn } from "@/lib/utils/ui";
|
||||||
import { TypeBadge } from "../type-badge";
|
|
||||||
import { Card } from "../ui/card";
|
import { Card } from "../ui/card";
|
||||||
import { CategoryIconBadge } from "./category-icon-badge";
|
import { CategoryIconBadge } from "./category-icon-badge";
|
||||||
|
|
||||||
@@ -61,9 +62,12 @@ export function CategoryDetailHeader({
|
|||||||
|
|
||||||
const variationLabel =
|
const variationLabel =
|
||||||
typeof percentageChange === "number"
|
typeof percentageChange === "number"
|
||||||
? `${percentageChange > 0 ? "+" : ""}${Math.abs(percentageChange).toFixed(
|
? formatPercentage(percentageChange, {
|
||||||
1,
|
minimumFractionDigits: 1,
|
||||||
)}%`
|
maximumFractionDigits: 1,
|
||||||
|
absolute: true,
|
||||||
|
signDisplay: percentageChange === 0 ? "auto" : "always",
|
||||||
|
})
|
||||||
: "—";
|
: "—";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import DotIcon from "@/components/dot-icon";
|
import StatusDot from "@/components/shared/status-dot";
|
||||||
|
|
||||||
export function TypeSelectContent({ label }: { label: string }) {
|
export function TypeSelectContent({ label }: { label: string }) {
|
||||||
const isReceita = label === "Receita";
|
const isReceita = label === "Receita";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<DotIcon color={isReceita ? "bg-success" : "bg-destructive"} />
|
<StatusDot color={isReceita ? "bg-success" : "bg-destructive"} />
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { CategoryType } from "@/lib/categorias/constants";
|
||||||
|
|
||||||
export type { CategoryType } from "@/lib/categorias/constants";
|
export type { CategoryType } from "@/lib/categorias/constants";
|
||||||
export {
|
export {
|
||||||
CATEGORY_TYPE_LABEL,
|
CATEGORY_TYPE_LABEL,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { cn } from "@/lib/utils/ui";
|
import { cn } from "@/lib/utils/ui";
|
||||||
import MoneyValues from "../money-values";
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
import { Card, CardContent, CardFooter } from "../ui/card";
|
import { Card, CardContent, CardFooter } from "../ui/card";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import DotIcon from "@/components/dot-icon";
|
import StatusDot from "@/components/shared/status-dot";
|
||||||
|
|
||||||
export function StatusSelectContent({ label }: { label: string }) {
|
export function StatusSelectContent({ label }: { label: string }) {
|
||||||
const isActive = label === "Ativa";
|
const isActive = label === "Ativa";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<DotIcon
|
<StatusDot
|
||||||
color={isActive ? "bg-success" : "bg-slate-400 dark:bg-slate-500"}
|
color={isActive ? "bg-success" : "bg-slate-400 dark:bg-slate-500"}
|
||||||
/>
|
/>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { RiInformationLine } from "@remixicon/react";
|
import { RiInformationLine } from "@remixicon/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { type ReactNode, useMemo } from "react";
|
import type { ReactNode } from "react";
|
||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { resolveLogoSrc } from "@/lib/logo";
|
||||||
|
import { formatCurrency } from "@/lib/utils/currency";
|
||||||
import { cn } from "@/lib/utils/ui";
|
import { cn } from "@/lib/utils/ui";
|
||||||
|
|
||||||
type DetailValue = string | number | ReactNode;
|
type DetailValue = string | number | ReactNode;
|
||||||
@@ -27,22 +29,9 @@ type AccountStatementCardProps = {
|
|||||||
actions?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveLogoPath = (logo?: string | null) => {
|
|
||||||
if (!logo) return null;
|
|
||||||
if (
|
|
||||||
logo.startsWith("http://") ||
|
|
||||||
logo.startsWith("https://") ||
|
|
||||||
logo.startsWith("data:")
|
|
||||||
) {
|
|
||||||
return logo;
|
|
||||||
}
|
|
||||||
|
|
||||||
return logo.startsWith("/") ? logo : `/logos/${logo}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAccountStatusBadgeVariant = (
|
const getAccountStatusBadgeVariant = (
|
||||||
status: string,
|
status: string,
|
||||||
): "success" | "secondary" => {
|
): "success" | "outline" => {
|
||||||
const normalizedStatus = status.toLowerCase();
|
const normalizedStatus = status.toLowerCase();
|
||||||
if (normalizedStatus === "ativa") {
|
if (normalizedStatus === "ativa") {
|
||||||
return "success";
|
return "success";
|
||||||
@@ -62,13 +51,7 @@ export function AccountStatementCard({
|
|||||||
logo,
|
logo,
|
||||||
actions,
|
actions,
|
||||||
}: AccountStatementCardProps) {
|
}: AccountStatementCardProps) {
|
||||||
const logoPath = useMemo(() => resolveLogoPath(logo), [logo]);
|
const logoPath = resolveLogoSrc(logo);
|
||||||
|
|
||||||
const formatCurrency = (value: number) =>
|
|
||||||
value.toLocaleString("pt-BR", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "BRL",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border">
|
<Card className="border">
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { deleteAccountAction } from "@/app/(dashboard)/contas/actions";
|
import { deleteAccountAction } from "@/app/(dashboard)/contas/actions";
|
||||||
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
|
||||||
import { AccountCard } from "@/components/contas/account-card";
|
import { AccountCard } from "@/components/contas/account-card";
|
||||||
|
import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
|
||||||
import { EmptyState } from "@/components/shared/empty-state";
|
import { EmptyState } from "@/components/shared/empty-state";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { resolveLogoSrc } from "@/lib/logo";
|
||||||
import { getCurrentPeriod } from "@/lib/utils/period";
|
import { getCurrentPeriod } from "@/lib/utils/period";
|
||||||
import { Card } from "../ui/card";
|
import { Card } from "../ui/card";
|
||||||
import { AccountDialog } from "./account-dialog";
|
import { AccountDialog } from "./account-dialog";
|
||||||
@@ -23,15 +24,6 @@ interface AccountsPageProps {
|
|||||||
logoOptions: string[];
|
logoOptions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveLogoSrc = (logo: string | null) => {
|
|
||||||
if (!logo) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
|
|
||||||
return `/logos/${fileName}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AccountsPage({
|
export function AccountsPage({
|
||||||
accounts,
|
accounts,
|
||||||
archivedAccounts,
|
archivedAccounts,
|
||||||
@@ -135,7 +127,7 @@ export function AccountsPage({
|
|||||||
return (
|
return (
|
||||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{list.map((account) => {
|
{list.map((account) => {
|
||||||
const logoSrc = resolveLogoSrc(account.logo);
|
const logoSrc = resolveLogoSrc(account.logo) ?? undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccountCard
|
<AccountCard
|
||||||
@@ -229,6 +221,8 @@ export function AccountsPage({
|
|||||||
...a,
|
...a,
|
||||||
balance: a.balance ?? a.initialBalance ?? 0,
|
balance: a.balance ?? a.initialBalance ?? 0,
|
||||||
excludeFromBalance: a.excludeFromBalance ?? false,
|
excludeFromBalance: a.excludeFromBalance ?? false,
|
||||||
|
excludeInitialBalanceFromIncome:
|
||||||
|
a.excludeInitialBalanceFromIncome ?? false,
|
||||||
}))}
|
}))}
|
||||||
fromAccountId={transferFromAccount.id}
|
fromAccountId={transferFromAccount.id}
|
||||||
currentPeriod={getCurrentPeriod()}
|
currentPeriod={getCurrentPeriod()}
|
||||||
|
|||||||
@@ -3,17 +3,18 @@
|
|||||||
import { RiEditLine } from "@remixicon/react";
|
import { RiEditLine } from "@remixicon/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
updateInvoicePaymentStatusAction,
|
updateInvoicePaymentStatusAction,
|
||||||
updatePaymentDateAction,
|
updatePaymentDateAction,
|
||||||
} from "@/app/(dashboard)/cartoes/[cartaoId]/fatura/actions";
|
} from "@/app/(dashboard)/cartoes/[cartaoId]/fatura/actions";
|
||||||
import DotIcon from "@/components/dot-icon";
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
import MoneyValues from "@/components/money-values";
|
import StatusDot from "@/components/shared/status-dot";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { resolveCardBrandAsset } from "@/lib/cartoes/brand-assets";
|
||||||
import {
|
import {
|
||||||
INVOICE_PAYMENT_STATUS,
|
INVOICE_PAYMENT_STATUS,
|
||||||
INVOICE_STATUS_BADGE_VARIANT,
|
INVOICE_STATUS_BADGE_VARIANT,
|
||||||
@@ -21,6 +22,8 @@ import {
|
|||||||
INVOICE_STATUS_LABEL,
|
INVOICE_STATUS_LABEL,
|
||||||
type InvoicePaymentStatus,
|
type InvoicePaymentStatus,
|
||||||
} from "@/lib/faturas";
|
} from "@/lib/faturas";
|
||||||
|
import { resolveLogoSrc } from "@/lib/logo";
|
||||||
|
import { formatCurrency } from "@/lib/utils/currency";
|
||||||
import { cn } from "@/lib/utils/ui";
|
import { cn } from "@/lib/utils/ui";
|
||||||
import { EditPaymentDateDialog } from "./edit-payment-date-dialog";
|
import { EditPaymentDateDialog } from "./edit-payment-date-dialog";
|
||||||
|
|
||||||
@@ -41,26 +44,6 @@ type InvoiceSummaryCardProps = {
|
|||||||
actions?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BRAND_ASSETS: Record<string, string> = {
|
|
||||||
visa: "/bandeiras/visa.svg",
|
|
||||||
mastercard: "/bandeiras/mastercard.svg",
|
|
||||||
amex: "/bandeiras/amex.svg",
|
|
||||||
american: "/bandeiras/amex.svg",
|
|
||||||
elo: "/bandeiras/elo.svg",
|
|
||||||
hipercard: "/bandeiras/hipercard.svg",
|
|
||||||
hiper: "/bandeiras/hipercard.svg",
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveBrandAsset = (brand: string) => {
|
|
||||||
const normalized = brand.trim().toLowerCase();
|
|
||||||
|
|
||||||
const match = (
|
|
||||||
Object.keys(BRAND_ASSETS) as Array<keyof typeof BRAND_ASSETS>
|
|
||||||
).find((entry) => normalized.includes(entry));
|
|
||||||
|
|
||||||
return match ? BRAND_ASSETS[match] : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const actionLabelByStatus: Record<InvoicePaymentStatus, string> = {
|
const actionLabelByStatus: Record<InvoicePaymentStatus, string> = {
|
||||||
[INVOICE_PAYMENT_STATUS.PENDING]: "Marcar como paga",
|
[INVOICE_PAYMENT_STATUS.PENDING]: "Marcar como paga",
|
||||||
[INVOICE_PAYMENT_STATUS.PAID]: "Desfazer pagamento",
|
[INVOICE_PAYMENT_STATUS.PAID]: "Desfazer pagamento",
|
||||||
@@ -76,19 +59,6 @@ const actionVariantByStatus: Record<
|
|||||||
|
|
||||||
const formatDay = (value: string) => value.padStart(2, "0");
|
const formatDay = (value: string) => value.padStart(2, "0");
|
||||||
|
|
||||||
const resolveLogoPath = (logo?: string | null) => {
|
|
||||||
if (!logo) return null;
|
|
||||||
if (
|
|
||||||
logo.startsWith("http://") ||
|
|
||||||
logo.startsWith("https://") ||
|
|
||||||
logo.startsWith("data:")
|
|
||||||
) {
|
|
||||||
return logo;
|
|
||||||
}
|
|
||||||
|
|
||||||
return logo.startsWith("/") ? logo : `/logos/${logo}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCardStatusDotColor = (status: string | null) => {
|
const getCardStatusDotColor = (status: string | null) => {
|
||||||
if (!status) return "bg-gray-400";
|
if (!status) return "bg-gray-400";
|
||||||
const normalizedStatus = status.toLowerCase();
|
const normalizedStatus = status.toLowerCase();
|
||||||
@@ -122,26 +92,13 @@ export function InvoiceSummaryCard({
|
|||||||
|
|
||||||
// Atualizar estado quando initialPaymentDate mudar
|
// Atualizar estado quando initialPaymentDate mudar
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialPaymentDate) {
|
setPaymentDate(initialPaymentDate ?? new Date());
|
||||||
setPaymentDate(initialPaymentDate);
|
|
||||||
}
|
|
||||||
}, [initialPaymentDate]);
|
}, [initialPaymentDate]);
|
||||||
|
|
||||||
const logoPath = useMemo(() => resolveLogoPath(logo), [logo]);
|
const logoPath = resolveLogoSrc(logo);
|
||||||
|
const brandAsset = resolveCardBrandAsset(cardBrand);
|
||||||
const brandAsset = useMemo(
|
const limitLabel =
|
||||||
() => (cardBrand ? resolveBrandAsset(cardBrand) : null),
|
typeof limitAmount === "number" ? formatCurrency(limitAmount) : "—";
|
||||||
[cardBrand],
|
|
||||||
);
|
|
||||||
|
|
||||||
const limitLabel = useMemo(() => {
|
|
||||||
if (typeof limitAmount !== "number") return "—";
|
|
||||||
return limitAmount.toLocaleString("pt-BR", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "BRL",
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
});
|
|
||||||
}, [limitAmount]);
|
|
||||||
|
|
||||||
const targetStatus =
|
const targetStatus =
|
||||||
invoiceStatus === INVOICE_PAYMENT_STATUS.PAID
|
invoiceStatus === INVOICE_PAYMENT_STATUS.PAID
|
||||||
@@ -286,7 +243,7 @@ export function InvoiceSummaryCard({
|
|||||||
value={
|
value={
|
||||||
cardStatus ? (
|
cardStatus ? (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<DotIcon color={getCardStatusDotColor(cardStatus)} />
|
<StatusDot color={getCardStatusDotColor(cardStatus)} />
|
||||||
<span className="truncate">{cardStatus}</span>
|
<span className="truncate">{cardStatus}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import type { EligibleInstallment } from "@/lib/installments/anticipation-types";
|
import type { EligibleInstallment } from "@/lib/installments/anticipation-types";
|
||||||
import { formatCurrentInstallment } from "@/lib/installments/utils";
|
import { formatCurrentInstallment } from "@/lib/installments/utils";
|
||||||
|
import { formatShortPeriodLabel } from "@/lib/utils/period";
|
||||||
import { cn } from "@/lib/utils/ui";
|
import { cn } from "@/lib/utils/ui";
|
||||||
|
|
||||||
interface InstallmentSelectionTableProps {
|
interface InstallmentSelectionTableProps {
|
||||||
@@ -43,12 +44,6 @@ export function InstallmentSelectionTable({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatPeriod = (period: string) => {
|
|
||||||
const [year, month] = period.split("-");
|
|
||||||
const date = new Date(Number(year), Number(month) - 1);
|
|
||||||
return format(date, "MMM/yyyy", { locale: ptBR });
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (date: Date | null) => {
|
const formatDate = (date: Date | null) => {
|
||||||
if (!date) return "—";
|
if (!date) return "—";
|
||||||
return format(date, "dd/MM/yyyy", { locale: ptBR });
|
return format(date, "dd/MM/yyyy", { locale: ptBR });
|
||||||
@@ -116,7 +111,7 @@ export function InstallmentSelectionTable({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
{formatPeriod(inst.period)}
|
{formatShortPeriodLabel(inst.period)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">
|
<TableCell className="text-muted-foreground">
|
||||||
{formatDate(inst.dueDate)}
|
{formatDate(inst.dueDate)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { createLancamentoAction } from "@/app/(dashboard)/lancamentos/actions";
|
import { createLancamentoAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -58,20 +58,18 @@ export function BulkImportDialog({
|
|||||||
const [contaId, setContaId] = useState<string | undefined>(undefined);
|
const [contaId, setContaId] = useState<string | undefined>(undefined);
|
||||||
const [cartaoId, setCartaoId] = useState<string | undefined>(undefined);
|
const [cartaoId, setCartaoId] = useState<string | undefined>(undefined);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
type CreateLancamentoInput = Parameters<typeof createLancamentoAction>[0];
|
||||||
|
|
||||||
// Reset form when dialog opens/closes
|
// Reset form when dialog opens/closes
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = (newOpen: boolean) => {
|
||||||
(newOpen: boolean) => {
|
if (!newOpen) {
|
||||||
if (!newOpen) {
|
setPagadorId(defaultPagadorId ?? undefined);
|
||||||
setPagadorId(defaultPagadorId ?? undefined);
|
setCategoriaId(undefined);
|
||||||
setCategoriaId(undefined);
|
setContaId(undefined);
|
||||||
setContaId(undefined);
|
setCartaoId(undefined);
|
||||||
setCartaoId(undefined);
|
}
|
||||||
}
|
onOpenChange(newOpen);
|
||||||
onOpenChange(newOpen);
|
};
|
||||||
},
|
|
||||||
[onOpenChange, defaultPagadorId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const categoriaGroups = useMemo(() => {
|
const categoriaGroups = useMemo(() => {
|
||||||
// Get unique transaction types from items
|
// Get unique transaction types from items
|
||||||
@@ -88,111 +86,100 @@ export function BulkImportDialog({
|
|||||||
return groupAndSortCategorias(filtered);
|
return groupAndSortCategorias(filtered);
|
||||||
}, [categoriaOptions, items]);
|
}, [categoriaOptions, items]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
async (event: React.FormEvent<HTMLFormElement>) => {
|
event.preventDefault();
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (!pagadorId) {
|
if (!pagadorId) {
|
||||||
toast.error("Selecione o pagador.");
|
toast.error("Selecione o pagador.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!categoriaId) {
|
if (!categoriaId) {
|
||||||
toast.error("Selecione a categoria.");
|
toast.error("Selecione a categoria.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const sanitizedAmount = Math.abs(item.amount);
|
const sanitizedAmount = Math.abs(item.amount);
|
||||||
|
|
||||||
// Determine payment method based on original item
|
// Determine payment method based on original item
|
||||||
const isCredit = item.paymentMethod === "Cartão de crédito";
|
const isCredit = item.paymentMethod === "Cartão de crédito";
|
||||||
|
|
||||||
// Validate payment method fields
|
// Validate payment method fields
|
||||||
if (isCredit && !cartaoId) {
|
if (isCredit && !cartaoId) {
|
||||||
toast.error("Selecione um cartão de crédito.");
|
toast.error("Selecione um cartão de crédito.");
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
if (!isCredit && !contaId) {
|
|
||||||
toast.error("Selecione uma conta.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
purchaseDate: item.purchaseDate,
|
|
||||||
period: item.period,
|
|
||||||
name: item.name,
|
|
||||||
transactionType: item.transactionType as
|
|
||||||
| "Despesa"
|
|
||||||
| "Receita"
|
|
||||||
| "Transferência",
|
|
||||||
amount: sanitizedAmount,
|
|
||||||
condition: item.condition as "À vista" | "Parcelado" | "Recorrente",
|
|
||||||
paymentMethod: item.paymentMethod as
|
|
||||||
| "Cartão de crédito"
|
|
||||||
| "Cartão de débito"
|
|
||||||
| "Pix"
|
|
||||||
| "Dinheiro"
|
|
||||||
| "Boleto"
|
|
||||||
| "Pré-Pago | VR/VA"
|
|
||||||
| "Transferência bancária",
|
|
||||||
pagadorId,
|
|
||||||
secondaryPagadorId: undefined,
|
|
||||||
isSplit: false,
|
|
||||||
contaId: isCredit ? undefined : contaId,
|
|
||||||
cartaoId: isCredit ? cartaoId : undefined,
|
|
||||||
categoriaId,
|
|
||||||
note: item.note || undefined,
|
|
||||||
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) {
|
if (!isCredit && !contaId) {
|
||||||
toast.success(
|
toast.error("Selecione uma conta.");
|
||||||
`${successCount} ${
|
return;
|
||||||
successCount === 1
|
}
|
||||||
? "lançamento importado"
|
|
||||||
: "lançamentos importados"
|
const payload: CreateLancamentoInput = {
|
||||||
} com sucesso!`,
|
purchaseDate: item.purchaseDate,
|
||||||
);
|
period: item.period,
|
||||||
handleOpenChange(false);
|
name: item.name,
|
||||||
} else if (successCount > 0) {
|
transactionType:
|
||||||
toast.warning(
|
item.transactionType as CreateLancamentoInput["transactionType"],
|
||||||
`${successCount} importados, ${errorCount} falharam. Verifique o console para detalhes.`,
|
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 {
|
} else {
|
||||||
toast.error("Falha ao importar lançamentos. Verifique o console.");
|
errorCount++;
|
||||||
|
console.error(`Failed to import ${item.name}:`, result.error);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
},
|
|
||||||
[items, pagadorId, categoriaId, contaId, cartaoId, handleOpenChange],
|
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 itemCount = items.length;
|
||||||
const hasCredit = items.some(
|
const hasCredit = items.some(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -10,73 +9,48 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { LANCAMENTO_CONDITIONS } from "@/lib/lancamentos/constants";
|
import { LANCAMENTO_CONDITIONS } from "@/lib/lancamentos/constants";
|
||||||
|
import { formatCurrency } from "@/lib/utils/currency";
|
||||||
import { cn } from "@/lib/utils/ui";
|
import { cn } from "@/lib/utils/ui";
|
||||||
import { ConditionSelectContent } from "../../select-items";
|
import { ConditionSelectContent } from "../../select-items";
|
||||||
import type { ConditionSectionProps } from "./lancamento-dialog-types";
|
import type { ConditionSectionProps } from "./lancamento-dialog-types";
|
||||||
|
|
||||||
function formatCurrency(value: number): string {
|
|
||||||
return value.toLocaleString("pt-BR", {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConditionSection({
|
export function ConditionSection({
|
||||||
formState,
|
formState,
|
||||||
onFieldChange,
|
onFieldChange,
|
||||||
showInstallments,
|
showInstallments,
|
||||||
showRecurrence,
|
showRecurrence,
|
||||||
}: ConditionSectionProps) {
|
}: ConditionSectionProps) {
|
||||||
const amount = useMemo(() => {
|
const parsedAmount = Number(formState.amount);
|
||||||
const value = Number(formState.amount);
|
const amount =
|
||||||
return Number.isNaN(value) || value <= 0 ? null : value;
|
Number.isNaN(parsedAmount) || parsedAmount <= 0 ? null : parsedAmount;
|
||||||
}, [formState.amount]);
|
|
||||||
|
|
||||||
const getInstallmentLabel = useCallback(
|
const getInstallmentLabel = (count: number) => {
|
||||||
(count: number) => {
|
if (amount) {
|
||||||
if (amount) {
|
const installmentValue = amount / count;
|
||||||
const installmentValue = amount / count;
|
return `${count}x de R$ ${formatCurrency(installmentValue)}`;
|
||||||
return `${count}x de R$ ${formatCurrency(installmentValue)}`;
|
}
|
||||||
}
|
|
||||||
return `${count}x`;
|
|
||||||
},
|
|
||||||
[amount],
|
|
||||||
);
|
|
||||||
|
|
||||||
const _getRecurrenceLabel = (count: number) => {
|
return `${count}x`;
|
||||||
return `${count} meses`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const installmentSummary = useMemo(() => {
|
const installmentCount = Number(formState.installmentCount);
|
||||||
if (!showInstallments || !formState.installmentCount || !amount) {
|
const installmentSummary =
|
||||||
return null;
|
showInstallments &&
|
||||||
}
|
formState.installmentCount &&
|
||||||
|
amount &&
|
||||||
|
!Number.isNaN(installmentCount) &&
|
||||||
|
installmentCount > 0
|
||||||
|
? getInstallmentLabel(installmentCount)
|
||||||
|
: null;
|
||||||
|
|
||||||
const count = Number(formState.installmentCount);
|
const recurrenceCount = Number(formState.recurrenceCount);
|
||||||
if (Number.isNaN(count) || count <= 0) {
|
const recurrenceSummary =
|
||||||
return null;
|
showRecurrence &&
|
||||||
}
|
formState.recurrenceCount &&
|
||||||
|
!Number.isNaN(recurrenceCount) &&
|
||||||
return getInstallmentLabel(count);
|
recurrenceCount > 0
|
||||||
}, [
|
? `Por ${recurrenceCount} meses`
|
||||||
showInstallments,
|
: null;
|
||||||
formState.installmentCount,
|
|
||||||
amount,
|
|
||||||
getInstallmentLabel,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const recurrenceSummary = useMemo(() => {
|
|
||||||
if (!showRecurrence || !formState.recurrenceCount) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = Number(formState.recurrenceCount);
|
|
||||||
if (Number.isNaN(count) || count <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `Por ${count} meses`;
|
|
||||||
}, [showRecurrence, formState.recurrenceCount]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { CurrencyInput } from "@/components/ui/currency-input";
|
import { CurrencyInput } from "@/components/ui/currency-input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
@@ -20,25 +19,19 @@ export function PagadorSection({
|
|||||||
secondaryPagadorOptions,
|
secondaryPagadorOptions,
|
||||||
totalAmount,
|
totalAmount,
|
||||||
}: PagadorSectionProps) {
|
}: PagadorSectionProps) {
|
||||||
const handlePrimaryAmountChange = useCallback(
|
const handlePrimaryAmountChange = (value: string) => {
|
||||||
(value: string) => {
|
onFieldChange("primarySplitAmount", value);
|
||||||
onFieldChange("primarySplitAmount", value);
|
const numericValue = Number.parseFloat(value) || 0;
|
||||||
const numericValue = Number.parseFloat(value) || 0;
|
const remaining = Math.max(0, totalAmount - numericValue);
|
||||||
const remaining = Math.max(0, totalAmount - numericValue);
|
onFieldChange("secondarySplitAmount", remaining.toFixed(2));
|
||||||
onFieldChange("secondarySplitAmount", remaining.toFixed(2));
|
};
|
||||||
},
|
|
||||||
[totalAmount, onFieldChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSecondaryAmountChange = useCallback(
|
const handleSecondaryAmountChange = (value: string) => {
|
||||||
(value: string) => {
|
onFieldChange("secondarySplitAmount", value);
|
||||||
onFieldChange("secondarySplitAmount", value);
|
const numericValue = Number.parseFloat(value) || 0;
|
||||||
const numericValue = Number.parseFloat(value) || 0;
|
const remaining = Math.max(0, totalAmount - numericValue);
|
||||||
const remaining = Math.max(0, totalAmount - numericValue);
|
onFieldChange("primarySplitAmount", remaining.toFixed(2));
|
||||||
onFieldChange("primarySplitAmount", remaining.toFixed(2));
|
};
|
||||||
},
|
|
||||||
[totalAmount, onFieldChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { LANCAMENTO_PAYMENT_METHODS } from "@/lib/lancamentos/constants";
|
import { LANCAMENTO_PAYMENT_METHODS } from "@/lib/lancamentos/constants";
|
||||||
import { displayPeriod } from "@/lib/utils/period";
|
import { dateToPeriod, displayPeriod, periodToDate } from "@/lib/utils/period";
|
||||||
import { cn } from "@/lib/utils/ui";
|
import { cn } from "@/lib/utils/ui";
|
||||||
import {
|
import {
|
||||||
ContaCartaoSelectContent,
|
ContaCartaoSelectContent,
|
||||||
@@ -24,17 +24,6 @@ import {
|
|||||||
} from "../../select-items";
|
} from "../../select-items";
|
||||||
import type { PaymentMethodSectionProps } from "./lancamento-dialog-types";
|
import type { PaymentMethodSectionProps } from "./lancamento-dialog-types";
|
||||||
|
|
||||||
function periodToDate(period: string): Date {
|
|
||||||
const [year, month] = period.split("-").map(Number);
|
|
||||||
return new Date(year, month - 1, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function dateToPeriod(date: Date): string {
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
||||||
return `${year}-${month}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function InlinePeriodPicker({
|
function InlinePeriodPicker({
|
||||||
period,
|
period,
|
||||||
onPeriodChange,
|
onPeriodChange,
|
||||||
|
|||||||
@@ -33,9 +33,12 @@ import {
|
|||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { groupAndSortCategorias } from "@/lib/lancamentos/categoria-helpers";
|
import { groupAndSortCategorias } from "@/lib/lancamentos/categoria-helpers";
|
||||||
import { LANCAMENTO_PAYMENT_METHODS } from "@/lib/lancamentos/constants";
|
import {
|
||||||
|
LANCAMENTO_PAYMENT_METHODS,
|
||||||
|
type LANCAMENTO_TRANSACTION_TYPES,
|
||||||
|
} from "@/lib/lancamentos/constants";
|
||||||
import { getTodayDateString } from "@/lib/utils/date";
|
import { getTodayDateString } from "@/lib/utils/date";
|
||||||
import { displayPeriod } from "@/lib/utils/period";
|
import { dateToPeriod, displayPeriod, periodToDate } from "@/lib/utils/period";
|
||||||
import {
|
import {
|
||||||
CategoriaSelectContent,
|
CategoriaSelectContent,
|
||||||
ContaCartaoSelectContent,
|
ContaCartaoSelectContent,
|
||||||
@@ -50,17 +53,8 @@ import type { SelectOption } from "../types";
|
|||||||
const MASS_ADD_PAYMENT_METHODS = LANCAMENTO_PAYMENT_METHODS.filter(
|
const MASS_ADD_PAYMENT_METHODS = LANCAMENTO_PAYMENT_METHODS.filter(
|
||||||
(m) => m !== "Boleto",
|
(m) => m !== "Boleto",
|
||||||
);
|
);
|
||||||
|
type MassAddTransactionType = (typeof LANCAMENTO_TRANSACTION_TYPES)[number];
|
||||||
function periodToDate(period: string): Date {
|
type MassAddPaymentMethod = (typeof LANCAMENTO_PAYMENT_METHODS)[number];
|
||||||
const [year, month] = period.split("-").map(Number);
|
|
||||||
return new Date(year, month - 1, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function dateToPeriod(date: Date): string {
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
||||||
return `${year}-${month}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function InlinePeriodPicker({
|
function InlinePeriodPicker({
|
||||||
period,
|
period,
|
||||||
@@ -111,23 +105,9 @@ interface MassAddDialogProps {
|
|||||||
defaultCartaoId?: string | null;
|
defaultCartaoId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MassAddFormData {
|
export type MassAddFormData = Parameters<
|
||||||
fixedFields: {
|
typeof import("@/app/(dashboard)/lancamentos/actions").createMassLancamentosAction
|
||||||
transactionType?: string;
|
>[0];
|
||||||
paymentMethod?: string;
|
|
||||||
condition?: string;
|
|
||||||
period?: string;
|
|
||||||
contaId?: string;
|
|
||||||
cartaoId?: string;
|
|
||||||
};
|
|
||||||
transactions: Array<{
|
|
||||||
purchaseDate: string;
|
|
||||||
name: string;
|
|
||||||
amount: string;
|
|
||||||
categoriaId?: string;
|
|
||||||
pagadorId?: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TransactionRow {
|
interface TransactionRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -154,8 +134,9 @@ export function MassAddDialog({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
// Fixed fields state (sempre ativos, sem checkboxes)
|
// Fixed fields state (sempre ativos, sem checkboxes)
|
||||||
const [transactionType, setTransactionType] = useState<string>("Despesa");
|
const [transactionType, setTransactionType] =
|
||||||
const [paymentMethod, setPaymentMethod] = useState<string>(
|
useState<MassAddTransactionType>("Despesa");
|
||||||
|
const [paymentMethod, setPaymentMethod] = useState<MassAddPaymentMethod>(
|
||||||
LANCAMENTO_PAYMENT_METHODS[0],
|
LANCAMENTO_PAYMENT_METHODS[0],
|
||||||
);
|
);
|
||||||
const [period, setPeriod] = useState<string>(selectedPeriod);
|
const [period, setPeriod] = useState<string>(selectedPeriod);
|
||||||
@@ -257,7 +238,7 @@ export function MassAddDialog({
|
|||||||
transactions: transactions.map((t) => ({
|
transactions: transactions.map((t) => ({
|
||||||
purchaseDate: t.purchaseDate,
|
purchaseDate: t.purchaseDate,
|
||||||
name: t.name.trim(),
|
name: t.name.trim(),
|
||||||
amount: t.amount.trim(),
|
amount: Number(t.amount.trim()),
|
||||||
categoriaId: t.categoriaId,
|
categoriaId: t.categoriaId,
|
||||||
pagadorId: t.pagadorId,
|
pagadorId: t.pagadorId,
|
||||||
})),
|
})),
|
||||||
@@ -312,7 +293,9 @@ export function MassAddDialog({
|
|||||||
<Label htmlFor="transaction-type">Tipo de Transação</Label>
|
<Label htmlFor="transaction-type">Tipo de Transação</Label>
|
||||||
<Select
|
<Select
|
||||||
value={transactionType}
|
value={transactionType}
|
||||||
onValueChange={setTransactionType}
|
onValueChange={(value) =>
|
||||||
|
setTransactionType(value as MassAddTransactionType)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="transaction-type" className="w-full">
|
<SelectTrigger id="transaction-type" className="w-full">
|
||||||
<SelectValue>
|
<SelectValue>
|
||||||
@@ -338,7 +321,7 @@ export function MassAddDialog({
|
|||||||
<Select
|
<Select
|
||||||
value={paymentMethod}
|
value={paymentMethod}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
setPaymentMethod(value);
|
setPaymentMethod(value as MassAddPaymentMethod);
|
||||||
// Reset conta/cartao when changing payment method
|
// Reset conta/cartao when changing payment method
|
||||||
if (value === "Cartão de crédito") {
|
if (value === "Cartão de crédito") {
|
||||||
setContaId(undefined);
|
setContaId(undefined);
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { formatCurrency } from "@/lib/lancamentos/formatting-helpers";
|
import { formatCurrency } from "@/lib/lancamentos/formatting-helpers";
|
||||||
|
import { formatDateOnly, formatDateTime } from "@/lib/utils/date";
|
||||||
import {
|
import {
|
||||||
getPrimaryPdfColor,
|
getPrimaryPdfColor,
|
||||||
loadExportLogoDataUrl,
|
loadExportLogoDataUrl,
|
||||||
} from "@/lib/utils/export-branding";
|
} from "@/lib/utils/export-branding";
|
||||||
|
import { displayPeriod } from "@/lib/utils/period";
|
||||||
import type { LancamentoItem } from "./types";
|
import type { LancamentoItem } from "./types";
|
||||||
|
|
||||||
interface LancamentosExportProps {
|
interface LancamentosExportProps {
|
||||||
@@ -41,12 +43,13 @@ export function LancamentosExport({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
const date = new Date(dateString);
|
return (
|
||||||
return date.toLocaleDateString("pt-BR", {
|
formatDateOnly(dateString, {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
});
|
}) ?? dateString
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getContaCartaoName = (lancamento: LancamentoItem) => {
|
const getContaCartaoName = (lancamento: LancamentoItem) => {
|
||||||
@@ -190,8 +193,8 @@ export function LancamentosExport({
|
|||||||
const doc = new jsPDF({ orientation: "landscape" });
|
const doc = new jsPDF({ orientation: "landscape" });
|
||||||
const primaryColor = getPrimaryPdfColor();
|
const primaryColor = getPrimaryPdfColor();
|
||||||
const [smallLogoDataUrl, textLogoDataUrl] = await Promise.all([
|
const [smallLogoDataUrl, textLogoDataUrl] = await Promise.all([
|
||||||
loadExportLogoDataUrl("/logo_small.png"),
|
loadExportLogoDataUrl("/imagens/logo_small.png"),
|
||||||
loadExportLogoDataUrl("/logo_text.png"),
|
loadExportLogoDataUrl("/imagens/logo_text.png"),
|
||||||
]);
|
]);
|
||||||
let brandingEndX = 14;
|
let brandingEndX = 14;
|
||||||
|
|
||||||
@@ -212,28 +215,15 @@ export function LancamentosExport({
|
|||||||
doc.text("Lançamentos", titleX, 15);
|
doc.text("Lançamentos", titleX, 15);
|
||||||
|
|
||||||
doc.setFontSize(10);
|
doc.setFontSize(10);
|
||||||
const periodParts = period.split("-");
|
doc.text(`Período: ${displayPeriod(period)}`, titleX, 22);
|
||||||
const monthNames = [
|
|
||||||
"Janeiro",
|
|
||||||
"Fevereiro",
|
|
||||||
"Março",
|
|
||||||
"Abril",
|
|
||||||
"Maio",
|
|
||||||
"Junho",
|
|
||||||
"Julho",
|
|
||||||
"Agosto",
|
|
||||||
"Setembro",
|
|
||||||
"Outubro",
|
|
||||||
"Novembro",
|
|
||||||
"Dezembro",
|
|
||||||
];
|
|
||||||
const formattedPeriod =
|
|
||||||
periodParts.length === 2
|
|
||||||
? `${monthNames[Number.parseInt(periodParts[1], 10) - 1]}/${periodParts[0]}`
|
|
||||||
: period;
|
|
||||||
doc.text(`Período: ${formattedPeriod}`, titleX, 22);
|
|
||||||
doc.text(
|
doc.text(
|
||||||
`Gerado em: ${new Date().toLocaleDateString("pt-BR")}`,
|
`Gerado em: ${
|
||||||
|
formatDateTime(new Date(), {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
}) ?? "—"
|
||||||
|
}`,
|
||||||
titleX,
|
titleX,
|
||||||
27,
|
27,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
createMassLancamentosAction,
|
createMassLancamentosAction,
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
toggleLancamentoSettlementAction,
|
toggleLancamentoSettlementAction,
|
||||||
updateLancamentoBulkAction,
|
updateLancamentoBulkAction,
|
||||||
} from "@/app/(dashboard)/lancamentos/actions";
|
} from "@/app/(dashboard)/lancamentos/actions";
|
||||||
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
|
||||||
|
|
||||||
import { AnticipateInstallmentsDialog } from "../dialogs/anticipate-installments-dialog/anticipate-installments-dialog";
|
import { AnticipateInstallmentsDialog } from "../dialogs/anticipate-installments-dialog/anticipate-installments-dialog";
|
||||||
import { AnticipationHistoryDialog } from "../dialogs/anticipate-installments-dialog/anticipation-history-dialog";
|
import { AnticipationHistoryDialog } from "../dialogs/anticipate-installments-dialog/anticipation-history-dialog";
|
||||||
@@ -139,7 +139,7 @@ export function LancamentosPage({
|
|||||||
LancamentoItem[]
|
LancamentoItem[]
|
||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
const handleToggleSettlement = useCallback(async (item: LancamentoItem) => {
|
const handleToggleSettlement = async (item: LancamentoItem) => {
|
||||||
if (item.paymentMethod === "Cartão de crédito") {
|
if (item.paymentMethod === "Cartão de crédito") {
|
||||||
toast.info(
|
toast.info(
|
||||||
"Pagamentos com cartão são conciliados automaticamente. Ajuste pelo cartão.",
|
"Pagamentos com cartão são conciliados automaticamente. Ajuste pelo cartão.",
|
||||||
@@ -182,9 +182,9 @@ export function LancamentosPage({
|
|||||||
} finally {
|
} finally {
|
||||||
setSettlementLoadingId(null);
|
setSettlementLoadingId(null);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleDelete = useCallback(async () => {
|
const handleDelete = async () => {
|
||||||
if (!lancamentoToDelete) {
|
if (!lancamentoToDelete) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -200,91 +200,82 @@ export function LancamentosPage({
|
|||||||
|
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
setDeleteOpen(false);
|
setDeleteOpen(false);
|
||||||
}, [lancamentoToDelete]);
|
};
|
||||||
|
|
||||||
const handleBulkDelete = useCallback(
|
const handleBulkDelete = async (scope: BulkActionScope) => {
|
||||||
async (scope: BulkActionScope) => {
|
if (!pendingDeleteData) {
|
||||||
if (!pendingDeleteData) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const result = await deleteLancamentoBulkAction({
|
const result = await deleteLancamentoBulkAction({
|
||||||
id: pendingDeleteData.id,
|
id: pendingDeleteData.id,
|
||||||
scope,
|
scope,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
setBulkDeleteOpen(false);
|
setBulkDeleteOpen(false);
|
||||||
setPendingDeleteData(null);
|
setPendingDeleteData(null);
|
||||||
},
|
};
|
||||||
[pendingDeleteData],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleBulkEditRequest = useCallback(
|
const handleBulkEditRequest = (data: {
|
||||||
(data: {
|
id: string;
|
||||||
id: string;
|
name: string;
|
||||||
name: string;
|
categoriaId: string | undefined;
|
||||||
categoriaId: string | undefined;
|
note: string;
|
||||||
note: string;
|
pagadorId: string | undefined;
|
||||||
pagadorId: string | undefined;
|
contaId: string | undefined;
|
||||||
contaId: string | undefined;
|
cartaoId: string | undefined;
|
||||||
cartaoId: string | undefined;
|
amount: number;
|
||||||
amount: number;
|
dueDate: string | null;
|
||||||
dueDate: string | null;
|
boletoPaymentDate: string | null;
|
||||||
boletoPaymentDate: string | null;
|
}) => {
|
||||||
}) => {
|
if (!selectedLancamento) {
|
||||||
if (!selectedLancamento) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
setPendingEditData({
|
setPendingEditData({
|
||||||
...data,
|
...data,
|
||||||
lancamento: selectedLancamento,
|
lancamento: selectedLancamento,
|
||||||
});
|
});
|
||||||
setEditOpen(false);
|
setEditOpen(false);
|
||||||
setBulkEditOpen(true);
|
setBulkEditOpen(true);
|
||||||
},
|
};
|
||||||
[selectedLancamento],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleBulkEdit = useCallback(
|
const handleBulkEdit = async (scope: BulkActionScope) => {
|
||||||
async (scope: BulkActionScope) => {
|
if (!pendingEditData) {
|
||||||
if (!pendingEditData) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const result = await updateLancamentoBulkAction({
|
const result = await updateLancamentoBulkAction({
|
||||||
id: pendingEditData.id,
|
id: pendingEditData.id,
|
||||||
scope,
|
scope,
|
||||||
name: pendingEditData.name,
|
name: pendingEditData.name,
|
||||||
categoriaId: pendingEditData.categoriaId,
|
categoriaId: pendingEditData.categoriaId,
|
||||||
note: pendingEditData.note,
|
note: pendingEditData.note,
|
||||||
pagadorId: pendingEditData.pagadorId,
|
pagadorId: pendingEditData.pagadorId,
|
||||||
contaId: pendingEditData.contaId,
|
contaId: pendingEditData.contaId,
|
||||||
cartaoId: pendingEditData.cartaoId,
|
cartaoId: pendingEditData.cartaoId,
|
||||||
amount: pendingEditData.amount,
|
amount: pendingEditData.amount,
|
||||||
dueDate: pendingEditData.dueDate,
|
dueDate: pendingEditData.dueDate,
|
||||||
boletoPaymentDate: pendingEditData.boletoPaymentDate,
|
boletoPaymentDate: pendingEditData.boletoPaymentDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
setBulkEditOpen(false);
|
setBulkEditOpen(false);
|
||||||
setPendingEditData(null);
|
setPendingEditData(null);
|
||||||
},
|
};
|
||||||
[pendingEditData],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMassAddSubmit = useCallback(async (data: MassAddFormData) => {
|
const handleMassAddSubmit = async (data: MassAddFormData) => {
|
||||||
const result = await createMassLancamentosAction(data);
|
const result = await createMassLancamentosAction(data);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -293,9 +284,9 @@ export function LancamentosPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleMultipleBulkDelete = useCallback((items: LancamentoItem[]) => {
|
const handleMultipleBulkDelete = (items: LancamentoItem[]) => {
|
||||||
// Se todos os selecionados são da mesma série (parcelado/recorrente), abrir dialog de escopo
|
// Se todos os selecionados são da mesma série (parcelado/recorrente), abrir dialog de escopo
|
||||||
const withSeries = items.filter((i) => i.seriesId);
|
const withSeries = items.filter((i) => i.seriesId);
|
||||||
const sameSeries =
|
const sameSeries =
|
||||||
@@ -309,9 +300,9 @@ export function LancamentosPage({
|
|||||||
}
|
}
|
||||||
setPendingMultipleDeleteData(items);
|
setPendingMultipleDeleteData(items);
|
||||||
setMultipleBulkDeleteOpen(true);
|
setMultipleBulkDeleteOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const confirmMultipleBulkDelete = useCallback(async () => {
|
const confirmMultipleBulkDelete = async () => {
|
||||||
if (pendingMultipleDeleteData.length === 0) {
|
if (pendingMultipleDeleteData.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -327,42 +318,42 @@ export function LancamentosPage({
|
|||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
setMultipleBulkDeleteOpen(false);
|
setMultipleBulkDeleteOpen(false);
|
||||||
setPendingMultipleDeleteData([]);
|
setPendingMultipleDeleteData([]);
|
||||||
}, [pendingMultipleDeleteData]);
|
};
|
||||||
|
|
||||||
const [transactionTypeForCreate, setTransactionTypeForCreate] = useState<
|
const [transactionTypeForCreate, setTransactionTypeForCreate] = useState<
|
||||||
"Despesa" | "Receita" | null
|
"Despesa" | "Receita" | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
const handleCreate = useCallback((type: "Despesa" | "Receita") => {
|
const handleCreate = (type: "Despesa" | "Receita") => {
|
||||||
setTransactionTypeForCreate(type);
|
setTransactionTypeForCreate(type);
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleMassAdd = useCallback(() => {
|
const handleMassAdd = () => {
|
||||||
setMassAddOpen(true);
|
setMassAddOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleEdit = useCallback((item: LancamentoItem) => {
|
const handleEdit = (item: LancamentoItem) => {
|
||||||
setSelectedLancamento(item);
|
setSelectedLancamento(item);
|
||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleCopy = useCallback((item: LancamentoItem) => {
|
const handleCopy = (item: LancamentoItem) => {
|
||||||
setLancamentoToCopy(item);
|
setLancamentoToCopy(item);
|
||||||
setCopyOpen(true);
|
setCopyOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleImport = useCallback((item: LancamentoItem) => {
|
const handleImport = (item: LancamentoItem) => {
|
||||||
setLancamentoToImport(item);
|
setLancamentoToImport(item);
|
||||||
setImportOpen(true);
|
setImportOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleBulkImport = useCallback((items: LancamentoItem[]) => {
|
const handleBulkImport = (items: LancamentoItem[]) => {
|
||||||
setLancamentosToImport(items);
|
setLancamentosToImport(items);
|
||||||
setBulkImportOpen(true);
|
setBulkImportOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleConfirmDelete = useCallback((item: LancamentoItem) => {
|
const handleConfirmDelete = (item: LancamentoItem) => {
|
||||||
if (item.seriesId) {
|
if (item.seriesId) {
|
||||||
setPendingDeleteData(item);
|
setPendingDeleteData(item);
|
||||||
setBulkDeleteOpen(true);
|
setBulkDeleteOpen(true);
|
||||||
@@ -370,22 +361,22 @@ export function LancamentosPage({
|
|||||||
setLancamentoToDelete(item);
|
setLancamentoToDelete(item);
|
||||||
setDeleteOpen(true);
|
setDeleteOpen(true);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleViewDetails = useCallback((item: LancamentoItem) => {
|
const handleViewDetails = (item: LancamentoItem) => {
|
||||||
setSelectedLancamento(item);
|
setSelectedLancamento(item);
|
||||||
setDetailsOpen(true);
|
setDetailsOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleAnticipate = useCallback((item: LancamentoItem) => {
|
const handleAnticipate = (item: LancamentoItem) => {
|
||||||
setSelectedForAnticipation(item);
|
setSelectedForAnticipation(item);
|
||||||
setAnticipateOpen(true);
|
setAnticipateOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleViewAnticipationHistory = useCallback((item: LancamentoItem) => {
|
const handleViewAnticipationHistory = (item: LancamentoItem) => {
|
||||||
setSelectedForAnticipation(item);
|
setSelectedForAnticipation(item);
|
||||||
setAnticipationHistoryOpen(true);
|
setAnticipationHistoryOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
import { RiBankCard2Line, RiBankLine } from "@remixicon/react";
|
import { RiBankCard2Line, RiBankLine } from "@remixicon/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { CategoryIcon } from "@/components/categorias/category-icon";
|
import { CategoryIcon } from "@/components/categorias/category-icon";
|
||||||
import DotIcon from "@/components/dot-icon";
|
import StatusDot from "@/components/shared/status-dot";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { resolveLogoSrc } from "@/lib/logo";
|
||||||
import { getAvatarSrc } from "@/lib/pagadores/utils";
|
import { getAvatarSrc } from "@/lib/pagadores/utils";
|
||||||
import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons";
|
import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons";
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ export function TransactionTypeSelectContent({ label }: { label: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<DotIcon color={colorMap[label]} />
|
<StatusDot color={colorMap[label]} />
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -89,15 +90,6 @@ export function ContaCartaoSelectContent({
|
|||||||
logo,
|
logo,
|
||||||
isCartao,
|
isCartao,
|
||||||
}: SelectItemContentProps & { isCartao?: boolean }) {
|
}: SelectItemContentProps & { isCartao?: boolean }) {
|
||||||
const resolveLogoSrc = (logoPath: string | null) => {
|
|
||||||
if (!logoPath) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileName = logoPath.split("/").filter(Boolean).pop() ?? logoPath;
|
|
||||||
return `/logos/${fileName}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const logoSrc = resolveLogoSrc(logo);
|
const logoSrc = resolveLogoSrc(logo);
|
||||||
const Icon = isCartao ? RiBankCard2Line : RiBankLine;
|
const Icon = isCartao ? RiBankCard2Line : RiBankLine;
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { ptBR } from "date-fns/locale";
|
|||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cancelInstallmentAnticipationAction } from "@/app/(dashboard)/lancamentos/anticipation-actions";
|
import { cancelInstallmentAnticipationAction } from "@/app/(dashboard)/lancamentos/anticipation-actions";
|
||||||
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
|
||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { CategoryIcon } from "@/components/categorias/category-icon";
|
import { CategoryIcon } from "@/components/categorias/category-icon";
|
||||||
import MoneyValues from "@/components/money-values";
|
|
||||||
import { EmptyState } from "@/components/shared/empty-state";
|
import { EmptyState } from "@/components/shared/empty-state";
|
||||||
import { TypeBadge } from "@/components/type-badge";
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
|
import { TypeBadge } from "@/components/shared/type-badge";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -69,6 +69,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { DEFAULT_LANCAMENTOS_COLUMN_ORDER } from "@/lib/lancamentos/column-order";
|
import { DEFAULT_LANCAMENTOS_COLUMN_ORDER } from "@/lib/lancamentos/column-order";
|
||||||
|
import { resolveLogoSrc } from "@/lib/logo";
|
||||||
import { getAvatarSrc } from "@/lib/pagadores/utils";
|
import { getAvatarSrc } from "@/lib/pagadores/utils";
|
||||||
import { formatDate } from "@/lib/utils/date";
|
import { formatDate } from "@/lib/utils/date";
|
||||||
import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons";
|
import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons";
|
||||||
@@ -82,15 +83,6 @@ import type {
|
|||||||
} from "../types";
|
} from "../types";
|
||||||
import { LancamentosFilters } from "./lancamentos-filters";
|
import { LancamentosFilters } from "./lancamentos-filters";
|
||||||
|
|
||||||
const resolveLogoSrc = (logo: string | null) => {
|
|
||||||
if (!logo) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
|
|
||||||
return `/logos/${fileName}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
type BuildColumnsArgs = {
|
type BuildColumnsArgs = {
|
||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
noteAsColumn: boolean;
|
noteAsColumn: boolean;
|
||||||
@@ -386,7 +378,7 @@ const buildColumns = ({
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const { pagadorId, pagadorName, pagadorAvatar } = row.original;
|
const { pagadorId, pagadorName, pagadorAvatar } = row.original;
|
||||||
|
|
||||||
const label = pagadorName.trim() || "Sem pagador";
|
const label = pagadorName?.trim() || "Sem pagador";
|
||||||
const displayName = label.split(/\s+/)[0] ?? label;
|
const displayName = label.split(/\s+/)[0] ?? label;
|
||||||
const avatarSrc = getAvatarSrc(pagadorAvatar);
|
const avatarSrc = getAvatarSrc(pagadorAvatar);
|
||||||
const initial = displayName.charAt(0).toUpperCase() || "?";
|
const initial = displayName.charAt(0).toUpperCase() || "?";
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ import type {
|
|||||||
BudgetNotification,
|
BudgetNotification,
|
||||||
DashboardNotification,
|
DashboardNotification,
|
||||||
} from "@/lib/dashboard/notifications";
|
} from "@/lib/dashboard/notifications";
|
||||||
|
import { resolveLogoSrc } from "@/lib/logo";
|
||||||
|
import { formatCurrency } from "@/lib/utils/currency";
|
||||||
|
import { formatDateOnly } from "@/lib/utils/date";
|
||||||
|
import { formatPercentage } from "@/lib/utils/percentage";
|
||||||
import { cn } from "@/lib/utils/ui";
|
import { cn } from "@/lib/utils/ui";
|
||||||
|
|
||||||
type NotificationBellProps = {
|
type NotificationBellProps = {
|
||||||
@@ -47,27 +51,13 @@ type NotificationBellProps = {
|
|||||||
preLancamentosCount?: number;
|
preLancamentosCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveLogoPath = (logo: string | null | undefined) => {
|
|
||||||
if (!logo) return null;
|
|
||||||
if (/^(https?:\/\/|data:)/.test(logo)) return logo;
|
|
||||||
return logo.startsWith("/") ? logo : `/logos/${logo}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatDate(dateString: string): string {
|
function formatDate(dateString: string): string {
|
||||||
const [year, month, day] = dateString.split("-").map(Number);
|
return (
|
||||||
const date = new Date(Date.UTC(year, month - 1, day));
|
formatDateOnly(dateString, {
|
||||||
return date.toLocaleDateString("pt-BR", {
|
day: "2-digit",
|
||||||
day: "2-digit",
|
month: "short",
|
||||||
month: "short",
|
}) ?? dateString
|
||||||
timeZone: "UTC",
|
);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCurrency(amount: number): string {
|
|
||||||
return new Intl.NumberFormat("pt-BR", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "BRL",
|
|
||||||
}).format(amount);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionLabel({
|
function SectionLabel({
|
||||||
@@ -115,9 +105,9 @@ export function NotificationBell({
|
|||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant: "ghost", size: "icon-sm" }),
|
buttonVariants({ variant: "ghost", size: "icon-sm" }),
|
||||||
"group relative text-muted-foreground transition-all duration-200",
|
"group relative border border-black/10 text-black/75 shadow-none transition-all duration-200",
|
||||||
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
|
"hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-2 focus-visible:ring-black/20",
|
||||||
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border",
|
"data-[state=open]:bg-black/10 data-[state=open]:text-black",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<RiNotification3Line
|
<RiNotification3Line
|
||||||
@@ -130,7 +120,7 @@ export function NotificationBell({
|
|||||||
<>
|
<>
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="absolute -right-1.5 -top-1.5 inline-flex min-h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold text-destructive-foreground shadow-xs ring-2 ring-background"
|
className="absolute -right-1.5 -top-1.5 inline-flex min-h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-xs font-semibold text-destructive-foreground "
|
||||||
>
|
>
|
||||||
{displayCount}
|
{displayCount}
|
||||||
</span>
|
</span>
|
||||||
@@ -148,7 +138,7 @@ export function NotificationBell({
|
|||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="end"
|
align="end"
|
||||||
sideOffset={12}
|
sideOffset={12}
|
||||||
className="w-76 overflow-hidden rounded-lg border border-border/60 bg-popover/95 p-0 shadow-lg backdrop-blur-lg supports-backdrop-filter:backdrop-blur-md"
|
className="w-76 overflow-hidden rounded-lg border border-border/60 bg-popover p-0 shadow-none"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<DropdownMenuLabel className="flex items-center justify-between gap-2 border-b border-border/50 px-3 py-2.5 text-sm font-semibold">
|
<DropdownMenuLabel className="flex items-center justify-between gap-2 border-b border-border/50 px-3 py-2.5 text-sm font-semibold">
|
||||||
@@ -223,13 +213,22 @@ export function NotificationBell({
|
|||||||
excedido —{" "}
|
excedido —{" "}
|
||||||
<strong>{formatCurrency(n.spentAmount)}</strong> de{" "}
|
<strong>{formatCurrency(n.spentAmount)}</strong> de{" "}
|
||||||
{formatCurrency(n.budgetAmount)} (
|
{formatCurrency(n.budgetAmount)} (
|
||||||
{Math.round(n.usedPercentage)}%)
|
{formatPercentage(n.usedPercentage, {
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
})}
|
||||||
|
)
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<strong>{n.categoryName}</strong> atingiu{" "}
|
<strong>{n.categoryName}</strong> atingiu{" "}
|
||||||
<strong>{Math.round(n.usedPercentage)}%</strong> do
|
<strong>
|
||||||
orçamento —{" "}
|
{formatPercentage(n.usedPercentage, {
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
})}
|
||||||
|
</strong>{" "}
|
||||||
|
do orçamento —{" "}
|
||||||
<strong>{formatCurrency(n.spentAmount)}</strong> de{" "}
|
<strong>{formatCurrency(n.spentAmount)}</strong> de{" "}
|
||||||
{formatCurrency(n.budgetAmount)}
|
{formatCurrency(n.budgetAmount)}
|
||||||
</>
|
</>
|
||||||
@@ -250,7 +249,7 @@ export function NotificationBell({
|
|||||||
/>
|
/>
|
||||||
<div className="mx-1 mb-1 overflow-hidden rounded-md">
|
<div className="mx-1 mb-1 overflow-hidden rounded-md">
|
||||||
{invoiceNotifications.map((n) => {
|
{invoiceNotifications.map((n) => {
|
||||||
const logo = resolveLogoPath(n.cardLogo);
|
const logo = resolveLogoSrc(n.cardLogo);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={n.id}
|
key={n.id}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
|
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
|
||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { cn } from "@/lib/utils/ui";
|
import { cn } from "@/lib/utils/ui";
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiAddCircleLine, RiFileCopyLine, RiFundsLine } from "@remixicon/react";
|
import { RiAddCircleLine, RiFileCopyLine, RiFundsLine } from "@remixicon/react";
|
||||||
import { useCallback, useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
deleteBudgetAction,
|
deleteBudgetAction,
|
||||||
duplicatePreviousMonthBudgetsAction,
|
duplicatePreviousMonthBudgetsAction,
|
||||||
} from "@/app/(dashboard)/orcamentos/actions";
|
} from "@/app/(dashboard)/orcamentos/actions";
|
||||||
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
|
||||||
import { EmptyState } from "@/components/shared/empty-state";
|
import { EmptyState } from "@/components/shared/empty-state";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "../ui/card";
|
import { Card } from "../ui/card";
|
||||||
@@ -36,31 +36,31 @@ export function BudgetsPage({
|
|||||||
|
|
||||||
const hasBudgets = budgets.length > 0;
|
const hasBudgets = budgets.length > 0;
|
||||||
|
|
||||||
const handleEdit = useCallback((budget: Budget) => {
|
const handleEdit = (budget: Budget) => {
|
||||||
setSelectedBudget(budget);
|
setSelectedBudget(budget);
|
||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleEditOpenChange = useCallback((open: boolean) => {
|
const handleEditOpenChange = (open: boolean) => {
|
||||||
setEditOpen(open);
|
setEditOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setSelectedBudget(null);
|
setSelectedBudget(null);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleRemoveRequest = useCallback((budget: Budget) => {
|
const handleRemoveRequest = (budget: Budget) => {
|
||||||
setBudgetToRemove(budget);
|
setBudgetToRemove(budget);
|
||||||
setRemoveOpen(true);
|
setRemoveOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleRemoveOpenChange = useCallback((open: boolean) => {
|
const handleRemoveOpenChange = (open: boolean) => {
|
||||||
setRemoveOpen(open);
|
setRemoveOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setBudgetToRemove(null);
|
setBudgetToRemove(null);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleRemoveConfirm = useCallback(async () => {
|
const handleRemoveConfirm = async () => {
|
||||||
if (!budgetToRemove) {
|
if (!budgetToRemove) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -74,9 +74,9 @@ export function BudgetsPage({
|
|||||||
|
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
}, [budgetToRemove]);
|
};
|
||||||
|
|
||||||
const handleDuplicateConfirm = useCallback(async () => {
|
const handleDuplicateConfirm = async () => {
|
||||||
const result = await duplicatePreviousMonthBudgetsAction({
|
const result = await duplicatePreviousMonthBudgetsAction({
|
||||||
period: selectedPeriod,
|
period: selectedPeriod,
|
||||||
});
|
});
|
||||||
@@ -89,7 +89,7 @@ export function BudgetsPage({
|
|||||||
|
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
}, [selectedPeriod]);
|
};
|
||||||
|
|
||||||
const removeTitle = budgetToRemove
|
const removeTitle = budgetToRemove
|
||||||
? `Remover orçamento de "${
|
? `Remover orçamento de "${
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
import { RiBankCard2Line } from "@remixicon/react";
|
import { RiBankCard2Line } from "@remixicon/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
|
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
|
||||||
import { CardContent } from "@/components/ui/card";
|
import { CardContent } from "@/components/ui/card";
|
||||||
import { WidgetEmptyState } from "@/components/widget-empty-state";
|
import { resolveLogoSrc } from "@/lib/logo";
|
||||||
import type { PagadorCardUsageItem } from "@/lib/pagadores/details";
|
import type { PagadorCardUsageItem } from "@/lib/pagadores/details";
|
||||||
|
|
||||||
const resolveLogoPath = (logo?: string | null) => {
|
|
||||||
if (!logo) return null;
|
|
||||||
if (
|
|
||||||
logo.startsWith("http://") ||
|
|
||||||
logo.startsWith("https://") ||
|
|
||||||
logo.startsWith("data:")
|
|
||||||
) {
|
|
||||||
return logo;
|
|
||||||
}
|
|
||||||
return logo.startsWith("/") ? logo : `/logos/${logo}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildInitials = (value: string) => {
|
const buildInitials = (value: string) => {
|
||||||
const parts = value.trim().split(/\s+/).filter(Boolean);
|
const parts = value.trim().split(/\s+/).filter(Boolean);
|
||||||
if (parts.length === 0) return "CC";
|
if (parts.length === 0) return "CC";
|
||||||
@@ -50,7 +39,7 @@ export function PagadorCardUsageCard({ items }: PagadorCardUsageCardProps) {
|
|||||||
<CardContent className="flex flex-col gap-4 px-0">
|
<CardContent className="flex flex-col gap-4 px-0">
|
||||||
<ul className="flex flex-col">
|
<ul className="flex flex-col">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const logoPath = resolveLogoPath(item.logo);
|
const logoPath = resolveLogoSrc(item.logo);
|
||||||
const initials = buildInitials(item.name);
|
const initials = buildInitials(item.name);
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
|
|||||||
365
components/pagadores/details/pagador-header-card.tsx
Normal file
365
components/pagadores/details/pagador-header-card.tsx
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RiBankCard2Line,
|
||||||
|
RiBillLine,
|
||||||
|
RiExchangeDollarLine,
|
||||||
|
RiMailLine,
|
||||||
|
RiMailSendLine,
|
||||||
|
RiVerifiedBadgeFill,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { sendPagadorSummaryAction } from "@/app/(dashboard)/pagadores/[pagadorId]/actions";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||||
|
import { getAvatarSrc } from "@/lib/pagadores/utils";
|
||||||
|
import { formatCurrency } from "@/lib/utils/currency";
|
||||||
|
import { formatDateTime } from "@/lib/utils/date";
|
||||||
|
import type { PagadorInfo, PagadorSummaryPreview } from "./types";
|
||||||
|
|
||||||
|
type PagadorHeaderCardProps = {
|
||||||
|
pagador: PagadorInfo;
|
||||||
|
selectedPeriod: string;
|
||||||
|
summary: PagadorSummaryPreview;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PagadorHeaderCard({
|
||||||
|
pagador,
|
||||||
|
selectedPeriod,
|
||||||
|
summary,
|
||||||
|
}: PagadorHeaderCardProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isSending, startTransition] = useTransition();
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
|
||||||
|
const avatarSrc = getAvatarSrc(pagador.avatarUrl);
|
||||||
|
const createdAtLabel = formatDate(pagador.createdAt);
|
||||||
|
const isAdmin = pagador.role === PAGADOR_ROLE_ADMIN;
|
||||||
|
|
||||||
|
const lastMailLabel =
|
||||||
|
formatDateTime(pagador.lastMailAt, {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
}) ?? "Nunca enviado";
|
||||||
|
|
||||||
|
const disableSend = isSending || !pagador.email || !pagador.canEdit;
|
||||||
|
|
||||||
|
const openConfirmDialog = () => {
|
||||||
|
if (!pagador.email) {
|
||||||
|
toast.error("Cadastre um e-mail para este pagador antes de enviar.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendSummary = () => {
|
||||||
|
if (!pagador.email) {
|
||||||
|
toast.error("Cadastre um e-mail para este pagador antes de enviar.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await sendPagadorSummaryAction({
|
||||||
|
pagadorId: pagador.id,
|
||||||
|
period: selectedPeriod,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(result.message);
|
||||||
|
setConfirmOpen(false);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadgeVariant = (status: string): "success" | "outline" => {
|
||||||
|
const normalizedStatus = status.toLowerCase();
|
||||||
|
if (normalizedStatus === "ativo") {
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
|
return "outline";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mb-2 border gap-4">
|
||||||
|
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="flex flex-1 items-start gap-4">
|
||||||
|
<div className="relative flex size-16 shrink-0 items-center justify-center overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={avatarSrc}
|
||||||
|
alt={`Avatar de ${pagador.name}`}
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className="h-full w-full rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<CardTitle className="text-xl font-semibold text-foreground">
|
||||||
|
{pagador.name}
|
||||||
|
</CardTitle>
|
||||||
|
{isAdmin ? (
|
||||||
|
<RiVerifiedBadgeFill
|
||||||
|
className="size-4 text-sky-500"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Badge
|
||||||
|
variant={getStatusBadgeVariant(pagador.status)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{pagador.status}
|
||||||
|
</Badge>
|
||||||
|
{pagador.isAutoSend ? (
|
||||||
|
<Badge variant="info" className="gap-1 text-xs">
|
||||||
|
<RiMailSendLine className="size-3.5" aria-hidden />
|
||||||
|
Envio automático
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardDescription className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||||
|
<span>Criado em {createdAtLabel}</span>
|
||||||
|
<span className="hidden text-border/80 sm:inline">•</span>
|
||||||
|
{pagador.email ? (
|
||||||
|
<Link
|
||||||
|
prefetch
|
||||||
|
href={`mailto:${pagador.email}`}
|
||||||
|
className="inline-flex items-center gap-1.5 text-primary"
|
||||||
|
>
|
||||||
|
<RiMailLine className="size-4" aria-hidden />
|
||||||
|
{pagador.email}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span>Sem e-mail cadastrado</span>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col items-stretch gap-2 lg:w-auto lg:items-end">
|
||||||
|
{pagador.canEdit ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={openConfirmDialog}
|
||||||
|
disabled={disableSend}
|
||||||
|
className="w-full min-w-[180px] lg:w-auto"
|
||||||
|
>
|
||||||
|
{isSending ? "Enviando..." : "Enviar resumo"}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Último envio: {lastMailLabel}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="justify-center text-xs">
|
||||||
|
Acesso somente leitura
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{pagador.canEdit ? (
|
||||||
|
<Dialog
|
||||||
|
open={confirmOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (isSending) return;
|
||||||
|
setConfirmOpen(open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Confirmar envio do resumo</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Resumo de{" "}
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
{summary.periodLabel}
|
||||||
|
</span>{" "}
|
||||||
|
para{" "}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{pagador.email}
|
||||||
|
</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
|
||||||
|
<RiExchangeDollarLine className="size-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
Total de Despesas
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-foreground">
|
||||||
|
{formatCurrency(summary.totalExpenses)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{summary.lancamentoCount} lançamentos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||||
|
<RiBankCard2Line className="size-4" />
|
||||||
|
<span className="text-xs font-semibold uppercase">
|
||||||
|
Cartões
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-foreground">
|
||||||
|
{formatCurrency(summary.paymentSplits.card)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||||
|
<RiBillLine className="size-4" />
|
||||||
|
<span className="text-xs font-semibold uppercase">
|
||||||
|
Boletos
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-foreground">
|
||||||
|
{formatCurrency(summary.paymentSplits.boleto)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-muted-foreground">
|
||||||
|
<RiExchangeDollarLine className="size-4" />
|
||||||
|
<span className="text-xs font-semibold uppercase">
|
||||||
|
Pix/Débito
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-foreground">
|
||||||
|
{formatCurrency(summary.paymentSplits.instant)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{summary.cardUsage.length > 0 && (
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<RiBankCard2Line className="size-4 text-muted-foreground" />
|
||||||
|
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||||
|
Cartões Utilizados
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{summary.cardUsage.map((card, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-foreground">{card.name}</span>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{formatCurrency(card.amount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(summary.boletoStats.paidCount > 0 ||
|
||||||
|
summary.boletoStats.pendingCount > 0) && (
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<RiBillLine className="size-4 text-muted-foreground" />
|
||||||
|
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||||
|
Status de Boletos
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Pagos</p>
|
||||||
|
<p className="text-sm font-semibold text-success">
|
||||||
|
{formatCurrency(summary.boletoStats.paidAmount)}{" "}
|
||||||
|
<span className="text-xs font-normal">
|
||||||
|
({summary.boletoStats.paidCount})
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Pendentes
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-semibold text-warning">
|
||||||
|
{formatCurrency(summary.boletoStats.pendingAmount)}{" "}
|
||||||
|
<span className="text-xs font-normal">
|
||||||
|
({summary.boletoStats.pendingCount})
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isSending}
|
||||||
|
onClick={() => setConfirmOpen(false)}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSendSummary}
|
||||||
|
disabled={disableSend}
|
||||||
|
>
|
||||||
|
{isSending ? "Enviando..." : "Confirmar envio"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (value: string) => {
|
||||||
|
return (
|
||||||
|
formatDateTime(value, {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
}) ?? "—"
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
} from "@/components/ui/chart";
|
} from "@/components/ui/chart";
|
||||||
import { WidgetEmptyState } from "@/components/widget-empty-state";
|
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
|
||||||
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
|
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
|
||||||
import type { PagadorHistoryPoint } from "@/lib/pagadores/details";
|
import type { PagadorHistoryPoint } from "@/lib/pagadores/details";
|
||||||
|
|
||||||
|
|||||||
@@ -1,136 +1,26 @@
|
|||||||
"use client";
|
import { RiUser3Line } from "@remixicon/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import {
|
|
||||||
RiBankCard2Line,
|
|
||||||
RiBillLine,
|
|
||||||
RiExchangeDollarLine,
|
|
||||||
RiMailLine,
|
|
||||||
RiMailSendLine,
|
|
||||||
RiUser3Line,
|
|
||||||
RiVerifiedBadgeFill,
|
|
||||||
} from "@remixicon/react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { type ReactNode, useMemo, useState, useTransition } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { sendPagadorSummaryAction } from "@/app/(dashboard)/pagadores/[pagadorId]/actions";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Card,
|
||||||
DialogContent,
|
CardContent,
|
||||||
DialogDescription,
|
CardDescription,
|
||||||
DialogFooter,
|
CardHeader,
|
||||||
DialogHeader,
|
CardTitle,
|
||||||
DialogTitle,
|
} from "@/components/ui/card";
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||||
import { getAvatarSrc } from "@/lib/pagadores/utils";
|
import { formatDateTime } from "@/lib/utils/date";
|
||||||
import { cn } from "@/lib/utils/ui";
|
import { cn } from "@/lib/utils/ui";
|
||||||
|
import type { PagadorInfo } from "./types";
|
||||||
type PagadorInfo = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: string | null;
|
|
||||||
avatarUrl: string | null;
|
|
||||||
status: string;
|
|
||||||
note: string | null;
|
|
||||||
role: string | null;
|
|
||||||
isAutoSend: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
lastMailAt: string | null;
|
|
||||||
shareCode: string | null;
|
|
||||||
canEdit: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PagadorSummaryPreview = {
|
|
||||||
periodLabel: string;
|
|
||||||
totalExpenses: number;
|
|
||||||
paymentSplits: {
|
|
||||||
card: number;
|
|
||||||
boleto: number;
|
|
||||||
instant: number;
|
|
||||||
};
|
|
||||||
cardUsage: { name: string; amount: number }[];
|
|
||||||
boletoStats: {
|
|
||||||
totalAmount: number;
|
|
||||||
paidAmount: number;
|
|
||||||
pendingAmount: number;
|
|
||||||
paidCount: number;
|
|
||||||
pendingCount: number;
|
|
||||||
};
|
|
||||||
lancamentoCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PagadorInfoCardProps = {
|
type PagadorInfoCardProps = {
|
||||||
pagador: PagadorInfo;
|
pagador: PagadorInfo;
|
||||||
selectedPeriod: string;
|
|
||||||
summary: PagadorSummaryPreview;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PagadorInfoCard({
|
export function PagadorInfoCard({ pagador }: PagadorInfoCardProps) {
|
||||||
pagador,
|
const showSensitiveDetails = pagador.canEdit;
|
||||||
selectedPeriod,
|
|
||||||
summary,
|
|
||||||
}: PagadorInfoCardProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [isSending, startTransition] = useTransition();
|
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
||||||
|
|
||||||
const avatarSrc = getAvatarSrc(pagador.avatarUrl);
|
const getStatusBadgeVariant = (status: string): "success" | "outline" => {
|
||||||
const createdAtLabel = formatDate(pagador.createdAt);
|
|
||||||
const isAdmin = pagador.role === PAGADOR_ROLE_ADMIN;
|
|
||||||
|
|
||||||
const lastMailLabel = useMemo(() => {
|
|
||||||
if (!pagador.lastMailAt) {
|
|
||||||
return "Nunca enviado";
|
|
||||||
}
|
|
||||||
const date = new Date(pagador.lastMailAt);
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return "Nunca enviado";
|
|
||||||
}
|
|
||||||
return date.toLocaleString("pt-BR", {
|
|
||||||
dateStyle: "short",
|
|
||||||
timeStyle: "short",
|
|
||||||
});
|
|
||||||
}, [pagador.lastMailAt]);
|
|
||||||
|
|
||||||
const disableSend = isSending || !pagador.email || !pagador.canEdit;
|
|
||||||
|
|
||||||
const openConfirmDialog = () => {
|
|
||||||
if (!pagador.email) {
|
|
||||||
toast.error("Cadastre um e-mail para este pagador antes de enviar.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setConfirmOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendSummary = () => {
|
|
||||||
if (!pagador.email) {
|
|
||||||
toast.error("Cadastre um e-mail para este pagador antes de enviar.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = await sendPagadorSummaryAction({
|
|
||||||
pagadorId: pagador.id,
|
|
||||||
period: selectedPeriod,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
toast.error(result.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(result.message);
|
|
||||||
setConfirmOpen(false);
|
|
||||||
router.refresh();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadgeVariant = (status: string): "success" | "secondary" => {
|
|
||||||
const normalizedStatus = status.toLowerCase();
|
const normalizedStatus = status.toLowerCase();
|
||||||
if (normalizedStatus === "ativo") {
|
if (normalizedStatus === "ativo") {
|
||||||
return "success";
|
return "success";
|
||||||
@@ -140,84 +30,18 @@ export function PagadorInfoCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border gap-4">
|
<Card className="border gap-4">
|
||||||
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
<CardHeader className="gap-1.5">
|
||||||
<div className="flex flex-1 items-start gap-4">
|
<CardTitle className="text-lg font-semibold">
|
||||||
<div className="relative flex size-16 shrink-0 items-center justify-center overflow-hidden">
|
Detalhes do pagador
|
||||||
<Image
|
</CardTitle>
|
||||||
src={avatarSrc}
|
<CardDescription>
|
||||||
alt={`Avatar de ${pagador.name}`}
|
{showSensitiveDetails
|
||||||
width={64}
|
? "Informações cadastrais e preferências de envio."
|
||||||
height={64}
|
: "Informações cadastrais visíveis para este compartilhamento."}
|
||||||
className="h-full w-full object-cover rounded-full"
|
</CardDescription>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-1">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<CardTitle className="text-xl font-semibold text-foreground">
|
|
||||||
{pagador.name}
|
|
||||||
</CardTitle>
|
|
||||||
{isAdmin ? (
|
|
||||||
<RiVerifiedBadgeFill
|
|
||||||
className="size-4 text-sky-500"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{pagador.isAutoSend ? (
|
|
||||||
<RiMailSendLine
|
|
||||||
className="size-4 text-primary"
|
|
||||||
aria-label="Envio automático habilitado"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Criado em {createdAtLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full flex-col items-stretch gap-2 lg:w-auto lg:items-end">
|
|
||||||
{pagador.canEdit ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
onClick={openConfirmDialog}
|
|
||||||
disabled={disableSend}
|
|
||||||
className="w-full min-w-[180px] lg:w-auto"
|
|
||||||
>
|
|
||||||
{isSending ? "Enviando..." : "Enviar resumo"}
|
|
||||||
</Button>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Último envio: {lastMailLabel}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs font-medium text-warning">
|
|
||||||
Acesso somente leitura
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="grid gap-4 border-t border-dashed border-border/60 pt-6 text-sm sm:grid-cols-2">
|
<CardContent className="grid gap-4 border-t border-dashed border-border/60 pt-6 text-sm sm:grid-cols-2">
|
||||||
<InfoItem
|
|
||||||
label="E-mail"
|
|
||||||
value={
|
|
||||||
pagador.email ? (
|
|
||||||
<Link
|
|
||||||
prefetch
|
|
||||||
href={`mailto:${pagador.email}`}
|
|
||||||
className="inline-flex items-center gap-2 text-primary"
|
|
||||||
>
|
|
||||||
<RiMailLine className="size-4" aria-hidden />
|
|
||||||
{pagador.email}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
"Sem e-mail cadastrado"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<InfoItem
|
<InfoItem
|
||||||
label="Status"
|
label="Status"
|
||||||
value={
|
value={
|
||||||
@@ -239,11 +63,19 @@ export function PagadorInfoCard({
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<InfoItem
|
{showSensitiveDetails ? (
|
||||||
label="Envio automático"
|
<InfoItem
|
||||||
value={pagador.isAutoSend ? "Ativado" : "Desativado"}
|
label="Envio automático"
|
||||||
/>
|
value={pagador.isAutoSend ? "Ativado" : "Desativado"}
|
||||||
{!pagador.email ? (
|
/>
|
||||||
|
) : null}
|
||||||
|
{showSensitiveDetails ? (
|
||||||
|
<InfoItem
|
||||||
|
label="Último envio"
|
||||||
|
value={formatDateTime(pagador.lastMailAt) ?? "Nunca enviado"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{showSensitiveDetails && !pagador.email ? (
|
||||||
<InfoItem
|
<InfoItem
|
||||||
label="Aviso"
|
label="Aviso"
|
||||||
value={
|
value={
|
||||||
@@ -254,219 +86,29 @@ export function PagadorInfoCard({
|
|||||||
className="sm:col-span-2"
|
className="sm:col-span-2"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<InfoItem
|
{showSensitiveDetails ? (
|
||||||
label="Observações"
|
<InfoItem
|
||||||
value={
|
label="Observações"
|
||||||
pagador.note ? (
|
value={
|
||||||
<span className="text-muted-foreground">{pagador.note}</span>
|
pagador.note ? (
|
||||||
) : (
|
<span className="text-muted-foreground">{pagador.note}</span>
|
||||||
"Sem observações"
|
) : (
|
||||||
)
|
"Sem observações"
|
||||||
}
|
)
|
||||||
className="sm:col-span-2"
|
}
|
||||||
/>
|
className="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
{pagador.canEdit ? (
|
|
||||||
<Dialog
|
|
||||||
open={confirmOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (isSending) return;
|
|
||||||
setConfirmOpen(open);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Confirmar envio do resumo</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Resumo de{" "}
|
|
||||||
<span className="font-semibold text-foreground">
|
|
||||||
{summary.periodLabel}
|
|
||||||
</span>{" "}
|
|
||||||
para{" "}
|
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
{pagador.email}
|
|
||||||
</span>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Total Geral */}
|
|
||||||
<div className="rounded-lg border p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
|
|
||||||
<RiExchangeDollarLine className="size-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
|
||||||
Total de Despesas
|
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-bold text-foreground">
|
|
||||||
{formatCurrency(summary.totalExpenses)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{summary.lancamentoCount} lançamentos
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Grid de Formas de Pagamento */}
|
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
|
||||||
{/* Cartões */}
|
|
||||||
<div className="rounded-lg border p-3">
|
|
||||||
<div className="flex items-center gap-2 text-muted-foreground mb-2">
|
|
||||||
<RiBankCard2Line className="size-4" />
|
|
||||||
<span className="text-xs font-semibold uppercase">
|
|
||||||
Cartões
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-lg font-bold text-foreground">
|
|
||||||
{formatCurrency(summary.paymentSplits.card)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Boletos */}
|
|
||||||
<div className="rounded-lg border p-3">
|
|
||||||
<div className="flex items-center gap-2 text-muted-foreground mb-2">
|
|
||||||
<RiBillLine className="size-4" />
|
|
||||||
<span className="text-xs font-semibold uppercase">
|
|
||||||
Boletos
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-lg font-bold text-foreground">
|
|
||||||
{formatCurrency(summary.paymentSplits.boleto)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Instantâneo */}
|
|
||||||
<div className="rounded-lg border p-3">
|
|
||||||
<div className="flex items-center gap-2 text-muted-foreground mb-2">
|
|
||||||
<RiExchangeDollarLine className="size-4" />
|
|
||||||
<span className="text-xs font-semibold uppercase">
|
|
||||||
Pix/Débito
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-lg font-bold text-foreground">
|
|
||||||
{formatCurrency(summary.paymentSplits.instant)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Detalhes Adicionais */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Cartões Utilizados */}
|
|
||||||
{summary.cardUsage.length > 0 && (
|
|
||||||
<div className="rounded-lg border p-3">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<RiBankCard2Line className="size-4 text-muted-foreground" />
|
|
||||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
|
||||||
Cartões Utilizados
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{summary.cardUsage.map((card, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="flex items-center justify-between text-sm"
|
|
||||||
>
|
|
||||||
<span className="text-foreground">{card.name}</span>
|
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
{formatCurrency(card.amount)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Status de Boletos */}
|
|
||||||
{(summary.boletoStats.paidCount > 0 ||
|
|
||||||
summary.boletoStats.pendingCount > 0) && (
|
|
||||||
<div className="rounded-lg border p-3">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<RiBillLine className="size-4 text-muted-foreground" />
|
|
||||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
|
||||||
Status de Boletos
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Pagos</p>
|
|
||||||
<p className="text-sm font-semibold text-success">
|
|
||||||
{formatCurrency(summary.boletoStats.paidAmount)}{" "}
|
|
||||||
<span className="text-xs font-normal">
|
|
||||||
({summary.boletoStats.paidCount})
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Pendentes
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-semibold text-warning">
|
|
||||||
{formatCurrency(summary.boletoStats.pendingAmount)}{" "}
|
|
||||||
<span className="text-xs font-normal">
|
|
||||||
({summary.boletoStats.pendingCount})
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
disabled={isSending}
|
|
||||||
onClick={() => setConfirmOpen(false)}
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSendSummary}
|
|
||||||
disabled={disableSend}
|
|
||||||
>
|
|
||||||
{isSending ? "Enviando..." : "Confirmar envio"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
) : null}
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (value: string) => {
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return "—";
|
|
||||||
return date.toLocaleDateString("pt-BR", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveRoleLabel = (role: string | null) => {
|
const resolveRoleLabel = (role: string | null) => {
|
||||||
if (role === PAGADOR_ROLE_ADMIN) return "Administrador";
|
if (role === PAGADOR_ROLE_ADMIN) return "Administrador";
|
||||||
return "Pagador";
|
return "Pagador";
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (value: number) =>
|
|
||||||
value.toLocaleString("pt-BR", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "BRL",
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
type InfoItemProps = {
|
type InfoItemProps = {
|
||||||
label: string;
|
label: string;
|
||||||
value: ReactNode;
|
value: ReactNode;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { toast } from "sonner";
|
|||||||
import { deletePagadorShareAction } from "@/app/(dashboard)/pagadores/actions";
|
import { deletePagadorShareAction } from "@/app/(dashboard)/pagadores/actions";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { formatDateTime } from "@/lib/utils/date";
|
||||||
|
|
||||||
interface PagadorLeaveShareCardProps {
|
interface PagadorLeaveShareCardProps {
|
||||||
shareId: string;
|
shareId: string;
|
||||||
@@ -37,11 +38,12 @@ export function PagadorLeaveShareCard({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const formattedDate = new Date(createdAt).toLocaleDateString("pt-BR", {
|
const formattedDate =
|
||||||
day: "2-digit",
|
formatDateTime(createdAt, {
|
||||||
month: "long",
|
day: "2-digit",
|
||||||
year: "numeric",
|
month: "long",
|
||||||
});
|
year: "numeric",
|
||||||
|
}) ?? "—";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border">
|
<Card className="border">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import type { PagadorMonthlyBreakdown } from "@/lib/pagadores/details";
|
import type { PagadorMonthlyBreakdown } from "@/lib/pagadores/details";
|
||||||
import { cn } from "@/lib/utils/ui";
|
import { cn } from "@/lib/utils/ui";
|
||||||
|
|||||||
@@ -5,40 +5,17 @@ import {
|
|||||||
RiWallet3Line,
|
RiWallet3Line,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
|
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
|
||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
|
import { WidgetEmptyState } from "@/components/shared/widget-empty-state";
|
||||||
import { CardContent } from "@/components/ui/card";
|
import { CardContent } from "@/components/ui/card";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { WidgetEmptyState } from "@/components/widget-empty-state";
|
import { buildBillStatusLabel } from "@/lib/dashboard/bills-helpers";
|
||||||
import type {
|
import type {
|
||||||
PagadorBoletoItem,
|
PagadorBoletoItem,
|
||||||
PagadorPaymentStatusData,
|
PagadorPaymentStatusData,
|
||||||
} from "@/lib/pagadores/details";
|
} from "@/lib/pagadores/details";
|
||||||
import { cn } from "@/lib/utils/ui";
|
import { cn } from "@/lib/utils/ui";
|
||||||
|
|
||||||
// --- Boleto helpers ---
|
|
||||||
|
|
||||||
const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "short",
|
|
||||||
year: "numeric",
|
|
||||||
timeZone: "UTC",
|
|
||||||
});
|
|
||||||
|
|
||||||
const buildDateLabel = (value: string | null, prefix?: string) => {
|
|
||||||
if (!value) return null;
|
|
||||||
const [year, month, day] = value.split("-").map((part) => Number(part));
|
|
||||||
if (!year || !month || !day) return null;
|
|
||||||
const formatted = DATE_FORMATTER.format(
|
|
||||||
new Date(Date.UTC(year, month - 1, day)),
|
|
||||||
);
|
|
||||||
return prefix ? `${prefix} ${formatted}` : formatted;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildStatusLabel = (item: PagadorBoletoItem) => {
|
|
||||||
if (item.isSettled) return buildDateLabel(item.boletoPaymentDate, "Pago em");
|
|
||||||
return buildDateLabel(item.dueDate, "Vence em");
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- PagadorBoletoCard ---
|
// --- PagadorBoletoCard ---
|
||||||
|
|
||||||
type PagadorBoletoCardProps = {
|
type PagadorBoletoCardProps = {
|
||||||
@@ -62,7 +39,7 @@ export function PagadorBoletoCard({ items }: PagadorBoletoCardProps) {
|
|||||||
<CardContent className="flex flex-col gap-4 px-0">
|
<CardContent className="flex flex-col gap-4 px-0">
|
||||||
<ul className="flex flex-col">
|
<ul className="flex flex-col">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const statusLabel = buildStatusLabel(item);
|
const statusLabel = buildBillStatusLabel(item);
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function PagadorSharingCard({
|
|||||||
return (
|
return (
|
||||||
<Card className="border">
|
<Card className="border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base font-semibold">
|
<CardTitle className="text-lg font-semibold">
|
||||||
Compartilhamentos
|
Compartilhamentos
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
33
components/pagadores/details/types.ts
Normal file
33
components/pagadores/details/types.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export type PagadorInfo = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
status: string;
|
||||||
|
note: string | null;
|
||||||
|
role: string | null;
|
||||||
|
isAutoSend: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
lastMailAt: string | null;
|
||||||
|
shareCode: string | null;
|
||||||
|
canEdit: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PagadorSummaryPreview = {
|
||||||
|
periodLabel: string;
|
||||||
|
totalExpenses: number;
|
||||||
|
paymentSplits: {
|
||||||
|
card: number;
|
||||||
|
boleto: number;
|
||||||
|
instant: number;
|
||||||
|
};
|
||||||
|
cardUsage: { name: string; amount: number }[];
|
||||||
|
boletoStats: {
|
||||||
|
totalAmount: number;
|
||||||
|
paidAmount: number;
|
||||||
|
pendingAmount: number;
|
||||||
|
paidCount: number;
|
||||||
|
pendingCount: number;
|
||||||
|
};
|
||||||
|
lancamentoCount: number;
|
||||||
|
};
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMemo } from "react";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||||
@@ -23,11 +22,7 @@ interface PagadorCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PagadorCard({ pagador, onEdit, onRemove }: PagadorCardProps) {
|
export function PagadorCard({ pagador, onEdit, onRemove }: PagadorCardProps) {
|
||||||
const avatarSrc = useMemo(
|
const avatarSrc = getAvatarSrc(pagador.avatarUrl);
|
||||||
() => getAvatarSrc(pagador.avatarUrl),
|
|
||||||
[pagador.avatarUrl],
|
|
||||||
);
|
|
||||||
|
|
||||||
const isAdmin = pagador.role === PAGADOR_ROLE_ADMIN;
|
const isAdmin = pagador.role === PAGADOR_ROLE_ADMIN;
|
||||||
const isReadOnly = !pagador.canEdit;
|
const isReadOnly = !pagador.canEdit;
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import DotIcon from "@/components/dot-icon";
|
import StatusDot from "@/components/shared/status-dot";
|
||||||
|
|
||||||
export function StatusSelectContent({ label }: { label: string }) {
|
export function StatusSelectContent({ label }: { label: string }) {
|
||||||
const isActive = label === "Ativo";
|
const isActive = label === "Ativo";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<DotIcon
|
<StatusDot
|
||||||
color={isActive ? "bg-success" : "bg-slate-400 dark:bg-slate-500"}
|
color={isActive ? "bg-success" : "bg-slate-400 dark:bg-slate-500"}
|
||||||
/>
|
/>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
import { RiAddCircleLine } from "@remixicon/react";
|
import { RiAddCircleLine } from "@remixicon/react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
deletePagadorAction,
|
deletePagadorAction,
|
||||||
joinPagadorByShareCodeAction,
|
joinPagadorByShareCodeAction,
|
||||||
} from "@/app/(dashboard)/pagadores/actions";
|
} from "@/app/(dashboard)/pagadores/actions";
|
||||||
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
|
||||||
import { PagadorCard } from "@/components/pagadores/pagador-card";
|
import { PagadorCard } from "@/components/pagadores/pagador-card";
|
||||||
import { PagadorDialog } from "@/components/pagadores/pagador-dialog";
|
import { PagadorDialog } from "@/components/pagadores/pagador-dialog";
|
||||||
|
import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||||
@@ -49,35 +49,35 @@ export function PagadoresPage({
|
|||||||
[pagadores],
|
[pagadores],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleEdit = useCallback((pagador: Pagador) => {
|
const handleEdit = (pagador: Pagador) => {
|
||||||
setSelectedPagador(pagador);
|
setSelectedPagador(pagador);
|
||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleEditOpenChange = useCallback((open: boolean) => {
|
const handleEditOpenChange = (open: boolean) => {
|
||||||
setEditOpen(open);
|
setEditOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setSelectedPagador(null);
|
setSelectedPagador(null);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleRemoveRequest = useCallback((pagador: Pagador) => {
|
const handleRemoveRequest = (pagador: Pagador) => {
|
||||||
if (pagador.role === PAGADOR_ROLE_ADMIN) {
|
if (pagador.role === PAGADOR_ROLE_ADMIN) {
|
||||||
toast.error("Pagadores administradores não podem ser removidos.");
|
toast.error("Pagadores administradores não podem ser removidos.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPagadorToRemove(pagador);
|
setPagadorToRemove(pagador);
|
||||||
setRemoveOpen(true);
|
setRemoveOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleRemoveOpenChange = useCallback((open: boolean) => {
|
const handleRemoveOpenChange = (open: boolean) => {
|
||||||
setRemoveOpen(open);
|
setRemoveOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setPagadorToRemove(null);
|
setPagadorToRemove(null);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleRemoveConfirm = useCallback(async () => {
|
const handleRemoveConfirm = async () => {
|
||||||
if (!pagadorToRemove) {
|
if (!pagadorToRemove) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -91,37 +91,34 @@ export function PagadoresPage({
|
|||||||
|
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
}, [pagadorToRemove]);
|
};
|
||||||
|
|
||||||
const removeTitle = pagadorToRemove
|
const removeTitle = pagadorToRemove
|
||||||
? `Remover pagador "${pagadorToRemove.name}"?`
|
? `Remover pagador "${pagadorToRemove.name}"?`
|
||||||
: "Remover pagador?";
|
: "Remover pagador?";
|
||||||
|
|
||||||
const handleJoinByCode = useCallback(
|
const handleJoinByCode = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
(event: React.FormEvent<HTMLFormElement>) => {
|
event.preventDefault();
|
||||||
event.preventDefault();
|
if (!shareCodeInput.trim()) {
|
||||||
if (!shareCodeInput.trim()) {
|
toast.error("Informe um código válido.");
|
||||||
toast.error("Informe um código válido.");
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startJoin(async () => {
|
||||||
|
const result = await joinPagadorByShareCodeAction({
|
||||||
|
code: shareCodeInput.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
startJoin(async () => {
|
toast.success(result.message);
|
||||||
const result = await joinPagadorByShareCodeAction({
|
setShareCodeInput("");
|
||||||
code: shareCodeInput.trim(),
|
router.refresh();
|
||||||
});
|
});
|
||||||
|
};
|
||||||
if (!result.success) {
|
|
||||||
toast.error(result.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(result.message);
|
|
||||||
setShareCodeInput("");
|
|
||||||
router.refresh();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[shareCodeInput, router],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import {
|
|||||||
import { format, formatDistanceToNow } from "date-fns";
|
import { format, formatDistanceToNow } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useMemo } from "react";
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
import MoneyValues from "@/components/money-values";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +27,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { resolveLogoSrc } from "@/lib/logo";
|
||||||
import type { InboxItem } from "./types";
|
import type { InboxItem } from "./types";
|
||||||
|
|
||||||
interface InboxCardProps {
|
interface InboxCardProps {
|
||||||
@@ -41,17 +41,6 @@ interface InboxCardProps {
|
|||||||
onRestoreToPending?: (item: InboxItem) => void | Promise<void>;
|
onRestoreToPending?: (item: InboxItem) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveLogoPath(logo: string): string {
|
|
||||||
if (
|
|
||||||
logo.startsWith("http") ||
|
|
||||||
logo.startsWith("data:") ||
|
|
||||||
logo.startsWith("/")
|
|
||||||
) {
|
|
||||||
return logo;
|
|
||||||
}
|
|
||||||
return `/logos/${logo}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findMatchingLogo(
|
function findMatchingLogo(
|
||||||
sourceAppName: string | null,
|
sourceAppName: string | null,
|
||||||
appLogoMap: Record<string, string>,
|
appLogoMap: Record<string, string>,
|
||||||
@@ -61,12 +50,12 @@ function findMatchingLogo(
|
|||||||
const appName = sourceAppName.toLowerCase();
|
const appName = sourceAppName.toLowerCase();
|
||||||
|
|
||||||
// Exact match first
|
// Exact match first
|
||||||
if (appLogoMap[appName]) return resolveLogoPath(appLogoMap[appName]);
|
if (appLogoMap[appName]) return resolveLogoSrc(appLogoMap[appName]);
|
||||||
|
|
||||||
// Partial match: card/account name contains app name or vice versa
|
// Partial match: card/account name contains app name or vice versa
|
||||||
for (const [name, logo] of Object.entries(appLogoMap)) {
|
for (const [name, logo] of Object.entries(appLogoMap)) {
|
||||||
if (name.includes(appName) || appName.includes(name)) {
|
if (name.includes(appName) || appName.includes(name)) {
|
||||||
return resolveLogoPath(logo);
|
return resolveLogoSrc(logo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,11 +72,9 @@ export function InboxCard({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onRestoreToPending,
|
onRestoreToPending,
|
||||||
}: InboxCardProps) {
|
}: InboxCardProps) {
|
||||||
const matchedLogo = useMemo(
|
const matchedLogo = appLogoMap
|
||||||
() =>
|
? findMatchingLogo(item.sourceAppName, appLogoMap)
|
||||||
appLogoMap ? findMatchingLogo(item.sourceAppName, appLogoMap) : null,
|
: null;
|
||||||
[item.sourceAppName, appLogoMap],
|
|
||||||
);
|
|
||||||
|
|
||||||
const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null;
|
const amount = item.parsedAmount ? parseFloat(item.parsedAmount) : null;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiAtLine, RiDeleteBinLine } from "@remixicon/react";
|
import { RiAtLine, RiDeleteBinLine } from "@remixicon/react";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
bulkDeleteInboxItemsAction,
|
bulkDeleteInboxItemsAction,
|
||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
markInboxAsProcessedAction,
|
markInboxAsProcessedAction,
|
||||||
restoreDiscardedInboxItemAction,
|
restoreDiscardedInboxItemAction,
|
||||||
} from "@/app/(dashboard)/pre-lancamentos/actions";
|
} from "@/app/(dashboard)/pre-lancamentos/actions";
|
||||||
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
|
||||||
import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog";
|
import { LancamentoDialog } from "@/components/lancamentos/dialogs/lancamento-dialog/lancamento-dialog";
|
||||||
|
import { ConfirmActionDialog } from "@/components/shared/confirm-action-dialog";
|
||||||
import { EmptyState } from "@/components/shared/empty-state";
|
import { EmptyState } from "@/components/shared/empty-state";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
@@ -67,66 +67,71 @@ export function InboxPage({
|
|||||||
"processed" | "discarded"
|
"processed" | "discarded"
|
||||||
>("processed");
|
>("processed");
|
||||||
|
|
||||||
const sortByTimestamp = useCallback(
|
const sortedPending = useMemo(
|
||||||
(list: InboxItem[]) =>
|
() =>
|
||||||
[...list].sort(
|
[...pendingItems].sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
new Date(b.notificationTimestamp).getTime() -
|
new Date(b.notificationTimestamp).getTime() -
|
||||||
new Date(a.notificationTimestamp).getTime(),
|
new Date(a.notificationTimestamp).getTime(),
|
||||||
),
|
),
|
||||||
[],
|
[pendingItems],
|
||||||
);
|
|
||||||
|
|
||||||
const sortedPending = useMemo(
|
|
||||||
() => sortByTimestamp(pendingItems),
|
|
||||||
[pendingItems, sortByTimestamp],
|
|
||||||
);
|
);
|
||||||
const sortedProcessed = useMemo(
|
const sortedProcessed = useMemo(
|
||||||
() => sortByTimestamp(processedItems),
|
() =>
|
||||||
[processedItems, sortByTimestamp],
|
[...processedItems].sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.notificationTimestamp).getTime() -
|
||||||
|
new Date(a.notificationTimestamp).getTime(),
|
||||||
|
),
|
||||||
|
[processedItems],
|
||||||
);
|
);
|
||||||
const sortedDiscarded = useMemo(
|
const sortedDiscarded = useMemo(
|
||||||
() => sortByTimestamp(discardedItems),
|
() =>
|
||||||
[discardedItems, sortByTimestamp],
|
[...discardedItems].sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.notificationTimestamp).getTime() -
|
||||||
|
new Date(a.notificationTimestamp).getTime(),
|
||||||
|
),
|
||||||
|
[discardedItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleProcessOpenChange = useCallback((open: boolean) => {
|
const handleProcessOpenChange = (open: boolean) => {
|
||||||
setProcessOpen(open);
|
setProcessOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setItemToProcess(null);
|
setItemToProcess(null);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleDetailsOpenChange = useCallback((open: boolean) => {
|
const handleDetailsOpenChange = (open: boolean) => {
|
||||||
setDetailsOpen(open);
|
setDetailsOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setItemDetails(null);
|
setItemDetails(null);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleDiscardOpenChange = useCallback((open: boolean) => {
|
const handleDiscardOpenChange = (open: boolean) => {
|
||||||
setDiscardOpen(open);
|
setDiscardOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setItemToDiscard(null);
|
setItemToDiscard(null);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleProcessRequest = useCallback((item: InboxItem) => {
|
const handleProcessRequest = (item: InboxItem) => {
|
||||||
setItemToProcess(item);
|
setItemToProcess(item);
|
||||||
setProcessOpen(true);
|
setProcessOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleDetailsRequest = useCallback((item: InboxItem) => {
|
const handleDetailsRequest = (item: InboxItem) => {
|
||||||
setItemDetails(item);
|
setItemDetails(item);
|
||||||
setDetailsOpen(true);
|
setDetailsOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleDiscardRequest = useCallback((item: InboxItem) => {
|
const handleDiscardRequest = (item: InboxItem) => {
|
||||||
setItemToDiscard(item);
|
setItemToDiscard(item);
|
||||||
setDiscardOpen(true);
|
setDiscardOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleDiscardConfirm = useCallback(async () => {
|
const handleDiscardConfirm = async () => {
|
||||||
if (!itemToDiscard) return;
|
if (!itemToDiscard) return;
|
||||||
|
|
||||||
const result = await discardInboxItemAction({
|
const result = await discardInboxItemAction({
|
||||||
@@ -140,21 +145,21 @@ export function InboxPage({
|
|||||||
|
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
}, [itemToDiscard]);
|
};
|
||||||
|
|
||||||
const handleDeleteOpenChange = useCallback((open: boolean) => {
|
const handleDeleteOpenChange = (open: boolean) => {
|
||||||
setDeleteOpen(open);
|
setDeleteOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setItemToDelete(null);
|
setItemToDelete(null);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleDeleteRequest = useCallback((item: InboxItem) => {
|
const handleDeleteRequest = (item: InboxItem) => {
|
||||||
setItemToDelete(item);
|
setItemToDelete(item);
|
||||||
setDeleteOpen(true);
|
setDeleteOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleDeleteConfirm = useCallback(async () => {
|
const handleDeleteConfirm = async () => {
|
||||||
if (!itemToDelete) return;
|
if (!itemToDelete) return;
|
||||||
|
|
||||||
const result = await deleteInboxItemAction({
|
const result = await deleteInboxItemAction({
|
||||||
@@ -168,21 +173,21 @@ export function InboxPage({
|
|||||||
|
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
}, [itemToDelete]);
|
};
|
||||||
|
|
||||||
const handleRestoreOpenChange = useCallback((open: boolean) => {
|
const handleRestoreOpenChange = (open: boolean) => {
|
||||||
setRestoreOpen(open);
|
setRestoreOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setItemToRestore(null);
|
setItemToRestore(null);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleRestoreRequest = useCallback((item: InboxItem) => {
|
const handleRestoreRequest = (item: InboxItem) => {
|
||||||
setItemToRestore(item);
|
setItemToRestore(item);
|
||||||
setRestoreOpen(true);
|
setRestoreOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleRestoreToPendingConfirm = useCallback(async () => {
|
const handleRestoreToPendingConfirm = async () => {
|
||||||
if (!itemToRestore) return;
|
if (!itemToRestore) return;
|
||||||
|
|
||||||
const result = await restoreDiscardedInboxItemAction({
|
const result = await restoreDiscardedInboxItemAction({
|
||||||
@@ -196,21 +201,18 @@ export function InboxPage({
|
|||||||
|
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
}, [itemToRestore]);
|
};
|
||||||
|
|
||||||
const handleBulkDeleteOpenChange = useCallback((open: boolean) => {
|
const handleBulkDeleteOpenChange = (open: boolean) => {
|
||||||
setBulkDeleteOpen(open);
|
setBulkDeleteOpen(open);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const handleBulkDeleteRequest = useCallback(
|
const handleBulkDeleteRequest = (status: "processed" | "discarded") => {
|
||||||
(status: "processed" | "discarded") => {
|
setBulkDeleteStatus(status);
|
||||||
setBulkDeleteStatus(status);
|
setBulkDeleteOpen(true);
|
||||||
setBulkDeleteOpen(true);
|
};
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleBulkDeleteConfirm = useCallback(async () => {
|
const handleBulkDeleteConfirm = async () => {
|
||||||
const result = await bulkDeleteInboxItemsAction({
|
const result = await bulkDeleteInboxItemsAction({
|
||||||
status: bulkDeleteStatus,
|
status: bulkDeleteStatus,
|
||||||
});
|
});
|
||||||
@@ -222,9 +224,9 @@ export function InboxPage({
|
|||||||
|
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
}, [bulkDeleteStatus]);
|
};
|
||||||
|
|
||||||
const handleLancamentoSuccess = useCallback(async () => {
|
const handleLancamentoSuccess = async () => {
|
||||||
if (!itemToProcess) return;
|
if (!itemToProcess) return;
|
||||||
|
|
||||||
const result = await markInboxAsProcessedAction({
|
const result = await markInboxAsProcessedAction({
|
||||||
@@ -236,7 +238,7 @@ export function InboxPage({
|
|||||||
} else {
|
} else {
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
}
|
}
|
||||||
}, [itemToProcess]);
|
};
|
||||||
|
|
||||||
// Prepare default values from inbox item
|
// Prepare default values from inbox item
|
||||||
const getDateString = (
|
const getDateString = (
|
||||||
|
|||||||
32
lib/faturas/index.ts
Normal file
32
lib/faturas/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export const INVOICE_PAYMENT_STATUS = {
|
||||||
|
PENDING: "pendente",
|
||||||
|
PAID: "pago",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const INVOICE_STATUS_VALUES = Object.values(INVOICE_PAYMENT_STATUS);
|
||||||
|
|
||||||
|
export type InvoicePaymentStatus =
|
||||||
|
(typeof INVOICE_PAYMENT_STATUS)[keyof typeof INVOICE_PAYMENT_STATUS];
|
||||||
|
|
||||||
|
export const INVOICE_STATUS_LABEL: Record<InvoicePaymentStatus, string> = {
|
||||||
|
[INVOICE_PAYMENT_STATUS.PENDING]: "Em aberto",
|
||||||
|
[INVOICE_PAYMENT_STATUS.PAID]: "Pago",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const INVOICE_STATUS_BADGE_VARIANT: Record<
|
||||||
|
InvoicePaymentStatus,
|
||||||
|
"default" | "secondary" | "success" | "info"
|
||||||
|
> = {
|
||||||
|
[INVOICE_PAYMENT_STATUS.PENDING]: "info",
|
||||||
|
[INVOICE_PAYMENT_STATUS.PAID]: "success",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const INVOICE_STATUS_DESCRIPTION: Record<InvoicePaymentStatus, string> =
|
||||||
|
{
|
||||||
|
[INVOICE_PAYMENT_STATUS.PENDING]:
|
||||||
|
"Esta fatura ainda não foi quitada. Você pode realizar o pagamento assim que revisar os lançamentos.",
|
||||||
|
[INVOICE_PAYMENT_STATUS.PAID]:
|
||||||
|
"Esta fatura está quitada. Caso tenha sido um engano, é possível desfazer o pagamento.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PERIOD_FORMAT_REGEX = /^\d{4}-\d{2}$/;
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { displayPeriod, periodToDate } from "@/lib/utils/period";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calcula a data da última parcela baseado no período da parcela atual
|
* Calcula a data da última parcela baseado no período da parcela atual
|
||||||
* @param currentPeriod - Período da parcela atual no formato YYYY-MM (ex: "2025-11")
|
* @param currentPeriod - Período da parcela atual no formato YYYY-MM (ex: "2025-11")
|
||||||
@@ -10,18 +12,13 @@ export function calculateLastInstallmentDate(
|
|||||||
currentInstallment: number,
|
currentInstallment: number,
|
||||||
totalInstallments: number,
|
totalInstallments: number,
|
||||||
): Date {
|
): Date {
|
||||||
// Parse do período atual (formato: "YYYY-MM")
|
let currentDate: Date;
|
||||||
const [yearStr, monthStr] = currentPeriod.split("-");
|
try {
|
||||||
const year = Number.parseInt(yearStr ?? "", 10);
|
currentDate = periodToDate(currentPeriod);
|
||||||
const monthIndex = Number.parseInt(monthStr ?? "", 10) - 1; // 0-indexed
|
} catch {
|
||||||
|
|
||||||
if (Number.isNaN(year) || Number.isNaN(monthIndex)) {
|
|
||||||
return new Date();
|
return new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cria data do período atual (parcela atual)
|
|
||||||
const currentDate = new Date(year, monthIndex, 1);
|
|
||||||
|
|
||||||
// Calcula quantas parcelas faltam (incluindo a atual)
|
// Calcula quantas parcelas faltam (incluindo a atual)
|
||||||
// Ex: parcela 2 de 6 -> restam 5 parcelas (2, 3, 4, 5, 6)
|
// Ex: parcela 2 de 6 -> restam 5 parcelas (2, 3, 4, 5, 6)
|
||||||
const remainingInstallments = totalInstallments - currentInstallment + 1;
|
const remainingInstallments = totalInstallments - currentInstallment + 1;
|
||||||
@@ -41,15 +38,9 @@ export function calculateLastInstallmentDate(
|
|||||||
* Exemplo: "Março de 2026"
|
* Exemplo: "Março de 2026"
|
||||||
*/
|
*/
|
||||||
export function formatLastInstallmentDate(date: Date): string {
|
export function formatLastInstallmentDate(date: Date): string {
|
||||||
const formatter = new Intl.DateTimeFormat("pt-BR", {
|
return displayPeriod(
|
||||||
month: "long",
|
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`,
|
||||||
year: "numeric",
|
);
|
||||||
timeZone: "UTC",
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatted = formatter.format(date);
|
|
||||||
// Capitaliza a primeira letra
|
|
||||||
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,6 +62,9 @@ export function formatPurchaseDate(date: Date): string {
|
|||||||
* Formata o texto da parcela atual
|
* Formata o texto da parcela atual
|
||||||
* Exemplo: "1 de 6"
|
* Exemplo: "1 de 6"
|
||||||
*/
|
*/
|
||||||
export function formatCurrentInstallment(current: number, total: number): string {
|
export function formatCurrentInstallment(
|
||||||
|
current: number,
|
||||||
|
total: number,
|
||||||
|
): string {
|
||||||
return `${current} de ${total}`;
|
return `${current} de ${total}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Formatting helpers for displaying lancamento data
|
* Formatting helpers for displaying lancamento data
|
||||||
*/
|
*/
|
||||||
|
import {
|
||||||
|
currencyFormatter,
|
||||||
|
formatCurrency as formatCurrencyValue,
|
||||||
|
} from "@/lib/utils/currency";
|
||||||
|
import { formatDateOnly } from "@/lib/utils/date";
|
||||||
|
import { formatMonthYearLabel } from "@/lib/utils/period";
|
||||||
|
|
||||||
|
export { currencyFormatter };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Capitalizes the first letter of a string
|
* Capitalizes the first letter of a string
|
||||||
@@ -11,14 +19,6 @@ function capitalize(value: string): string {
|
|||||||
: value;
|
: value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Currency formatter for pt-BR locale (BRL)
|
|
||||||
*/
|
|
||||||
export const currencyFormatter = new Intl.NumberFormat("pt-BR", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "BRL",
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Date formatter for pt-BR locale (dd/mm/yyyy)
|
* Date formatter for pt-BR locale (dd/mm/yyyy)
|
||||||
*/
|
*/
|
||||||
@@ -44,9 +44,13 @@ export const monthFormatter = new Intl.DateTimeFormat("pt-BR", {
|
|||||||
*/
|
*/
|
||||||
export function formatDate(value?: string | null): string {
|
export function formatDate(value?: string | null): string {
|
||||||
if (!value) return "—";
|
if (!value) return "—";
|
||||||
const date = new Date(value);
|
return (
|
||||||
if (Number.isNaN(date.getTime())) return "—";
|
formatDateOnly(value, {
|
||||||
return dateFormatter.format(date);
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
}) ?? "—"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,10 +61,11 @@ export function formatDate(value?: string | null): string {
|
|||||||
*/
|
*/
|
||||||
export function formatPeriod(value?: string | null): string {
|
export function formatPeriod(value?: string | null): string {
|
||||||
if (!value) return "—";
|
if (!value) return "—";
|
||||||
const [year, month] = value.split("-").map(Number);
|
try {
|
||||||
if (!year || !month) return value;
|
return formatMonthYearLabel(value);
|
||||||
const date = new Date(year, month - 1, 1);
|
} catch {
|
||||||
return capitalize(monthFormatter.format(date));
|
return value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,5 +102,5 @@ export function getTransactionBadgeVariant(
|
|||||||
* @example formatCurrency(1234.56) => "R$ 1.234,56"
|
* @example formatCurrency(1234.56) => "R$ 1.234,56"
|
||||||
*/
|
*/
|
||||||
export function formatCurrency(value: number): string {
|
export function formatCurrency(value: number): string {
|
||||||
return currencyFormatter.format(value);
|
return formatCurrencyValue(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
PAGADOR_ROLE_ADMIN,
|
PAGADOR_ROLE_ADMIN,
|
||||||
PAGADOR_ROLE_TERCEIRO,
|
PAGADOR_ROLE_TERCEIRO,
|
||||||
} from "@/lib/pagadores/constants";
|
} from "@/lib/pagadores/constants";
|
||||||
|
import { toDateOnlyString } from "@/lib/utils/date";
|
||||||
|
|
||||||
type PagadorRow = typeof pagadores.$inferSelect;
|
type PagadorRow = typeof pagadores.$inferSelect;
|
||||||
type ContaRow = typeof contas.$inferSelect;
|
type ContaRow = typeof contas.$inferSelect;
|
||||||
@@ -185,12 +186,10 @@ export const fetchLancamentoFilterSources = async (userId: string) => {
|
|||||||
where: eq(pagadores.userId, userId),
|
where: eq(pagadores.userId, userId),
|
||||||
}),
|
}),
|
||||||
db.query.contas.findMany({
|
db.query.contas.findMany({
|
||||||
where: (contas, { eq, and }) =>
|
where: and(eq(contas.userId, userId), eq(contas.status, "Ativa")),
|
||||||
and(eq(contas.userId, userId), eq(contas.status, "Ativa")),
|
|
||||||
}),
|
}),
|
||||||
db.query.cartoes.findMany({
|
db.query.cartoes.findMany({
|
||||||
where: (cartoes, { eq, and }) =>
|
where: and(eq(cartoes.userId, userId), eq(cartoes.status, "Ativo")),
|
||||||
and(eq(cartoes.userId, userId), eq(cartoes.status, "Ativo")),
|
|
||||||
}),
|
}),
|
||||||
db.query.categorias.findMany({
|
db.query.categorias.findMany({
|
||||||
where: eq(categorias.userId, userId),
|
where: eq(categorias.userId, userId),
|
||||||
@@ -405,7 +404,7 @@ export const mapLancamentosData = (rows: LancamentoRowWithRelations[]) =>
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
userId: item.userId,
|
userId: item.userId,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
purchaseDate: item.purchaseDate?.toISOString() ?? new Date().toISOString(),
|
purchaseDate: toDateOnlyString(item.purchaseDate) ?? "",
|
||||||
period: item.period ?? "",
|
period: item.period ?? "",
|
||||||
transactionType: item.transactionType,
|
transactionType: item.transactionType,
|
||||||
amount: Number(item.amount ?? 0),
|
amount: Number(item.amount ?? 0),
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ import {
|
|||||||
import { cartoes, lancamentos } from "@/db/schema";
|
import { cartoes, lancamentos } from "@/db/schema";
|
||||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
|
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
|
import { toDateOnlyString } from "@/lib/utils/date";
|
||||||
|
import { safeToNumber as toNumber } from "@/lib/utils/number";
|
||||||
|
import {
|
||||||
|
addMonthsToPeriod,
|
||||||
|
buildPeriodRange,
|
||||||
|
formatCompactPeriodLabel,
|
||||||
|
} from "@/lib/utils/period";
|
||||||
|
|
||||||
const RECEITA = "Receita";
|
const RECEITA = "Receita";
|
||||||
const DESPESA = "Despesa";
|
const DESPESA = "Despesa";
|
||||||
@@ -65,76 +72,6 @@ export type PagadorPaymentStatusData = {
|
|||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toISODate = (value: Date | string | null | undefined): string | null => {
|
|
||||||
if (!value) return null;
|
|
||||||
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
|
||||||
return typeof value === "string" ? value : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toNumber = (value: string | number | bigint | null) => {
|
|
||||||
if (typeof value === "number") {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (typeof value === "bigint") {
|
|
||||||
return Number(value);
|
|
||||||
}
|
|
||||||
if (!value) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const parsed = Number(value);
|
|
||||||
return Number.isNaN(parsed) ? 0 : parsed;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatPeriod = (year: number, month: number) =>
|
|
||||||
`${year}-${String(month).padStart(2, "0")}`;
|
|
||||||
|
|
||||||
const normalizePeriod = (period: string) => {
|
|
||||||
const [yearStr, monthStr] = period.split("-");
|
|
||||||
const year = Number.parseInt(yearStr ?? "", 10);
|
|
||||||
const month = Number.parseInt(monthStr ?? "", 10);
|
|
||||||
if (Number.isNaN(year) || Number.isNaN(month)) {
|
|
||||||
throw new Error(`Período inválido: ${period}`);
|
|
||||||
}
|
|
||||||
return { year, month };
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildPeriodWindow = (period: string, months: number) => {
|
|
||||||
const { year, month } = normalizePeriod(period);
|
|
||||||
const items: string[] = [];
|
|
||||||
let currentYear = year;
|
|
||||||
let currentMonth = month;
|
|
||||||
|
|
||||||
for (let i = 0; i < months; i += 1) {
|
|
||||||
items.unshift(formatPeriod(currentYear, currentMonth));
|
|
||||||
currentMonth -= 1;
|
|
||||||
if (currentMonth < 1) {
|
|
||||||
currentMonth = 12;
|
|
||||||
currentYear -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatPeriodLabel = (period: string) => {
|
|
||||||
try {
|
|
||||||
const { year, month } = normalizePeriod(period);
|
|
||||||
const formatter = new Intl.DateTimeFormat("pt-BR", {
|
|
||||||
month: "short",
|
|
||||||
});
|
|
||||||
const date = new Date(year, month - 1, 1);
|
|
||||||
const rawLabel = formatter.format(date).replace(".", "");
|
|
||||||
const label =
|
|
||||||
rawLabel.length > 0
|
|
||||||
? rawLabel.charAt(0).toUpperCase().concat(rawLabel.slice(1))
|
|
||||||
: rawLabel;
|
|
||||||
const suffix = String(year).slice(-2);
|
|
||||||
return `${label}/${suffix}`;
|
|
||||||
} catch {
|
|
||||||
return period;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const excludeAutoInvoiceEntries = () =>
|
const excludeAutoInvoiceEntries = () =>
|
||||||
or(
|
or(
|
||||||
isNull(lancamentos.note),
|
isNull(lancamentos.note),
|
||||||
@@ -206,9 +143,10 @@ export async function fetchPagadorHistory({
|
|||||||
period,
|
period,
|
||||||
months = 6,
|
months = 6,
|
||||||
}: BaseFilters & { months?: number }): Promise<PagadorHistoryPoint[]> {
|
}: BaseFilters & { months?: number }): Promise<PagadorHistoryPoint[]> {
|
||||||
const window = buildPeriodWindow(period, months);
|
const startPeriod = addMonthsToPeriod(period, -(Math.max(months, 1) - 1));
|
||||||
const start = window[0];
|
const windowPeriods = buildPeriodRange(startPeriod, period);
|
||||||
const end = window[window.length - 1];
|
const start = windowPeriods[0];
|
||||||
|
const end = windowPeriods[windowPeriods.length - 1];
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -233,7 +171,7 @@ export async function fetchPagadorHistory({
|
|||||||
{ receitas: number; despesas: number }
|
{ receitas: number; despesas: number }
|
||||||
>();
|
>();
|
||||||
|
|
||||||
for (const key of window) {
|
for (const key of windowPeriods) {
|
||||||
totalsByPeriod.set(key, { receitas: 0, despesas: 0 });
|
totalsByPeriod.set(key, { receitas: 0, despesas: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,9 +188,9 @@ export async function fetchPagadorHistory({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return window.map((key) => ({
|
return windowPeriods.map((key) => ({
|
||||||
period: key,
|
period: key,
|
||||||
label: formatPeriodLabel(key),
|
label: formatCompactPeriodLabel(key),
|
||||||
receitas: totalsByPeriod.get(key)?.receitas ?? 0,
|
receitas: totalsByPeriod.get(key)?.receitas ?? 0,
|
||||||
despesas: totalsByPeriod.get(key)?.despesas ?? 0,
|
despesas: totalsByPeriod.get(key)?.despesas ?? 0,
|
||||||
}));
|
}));
|
||||||
@@ -283,20 +221,22 @@ export async function fetchPagadorCardUsage({
|
|||||||
)
|
)
|
||||||
.groupBy(lancamentos.cartaoId, cartoes.name, cartoes.logo);
|
.groupBy(lancamentos.cartaoId, cartoes.name, cartoes.logo);
|
||||||
|
|
||||||
return rows
|
const items: PagadorCardUsageItem[] = [];
|
||||||
.filter((row) => Boolean(row.cartaoId))
|
|
||||||
.map((row) => {
|
for (const row of rows) {
|
||||||
if (!row.cartaoId) {
|
if (!row.cartaoId) {
|
||||||
throw new Error("cartaoId should not be null after filter");
|
continue;
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
id: row.cartaoId,
|
items.push({
|
||||||
name: row.cardName ?? "Cartão",
|
id: row.cartaoId,
|
||||||
logo: row.cardLogo ?? null,
|
name: row.cardName ?? "Cartão",
|
||||||
amount: Math.abs(toNumber(row.totalAmount)),
|
logo: row.cardLogo ?? null,
|
||||||
};
|
amount: Math.abs(toNumber(row.totalAmount)),
|
||||||
})
|
});
|
||||||
.sort((a, b) => b.amount - a.amount);
|
}
|
||||||
|
|
||||||
|
return items.sort((a, b) => b.amount - a.amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchPagadorBoletoStats({
|
export async function fetchPagadorBoletoStats({
|
||||||
@@ -374,14 +314,20 @@ export async function fetchPagadorBoletoItems({
|
|||||||
)
|
)
|
||||||
.orderBy(asc(lancamentos.dueDate));
|
.orderBy(asc(lancamentos.dueDate));
|
||||||
|
|
||||||
return rows.map((row) => ({
|
const items: PagadorBoletoItem[] = [];
|
||||||
id: row.id,
|
|
||||||
name: row.name,
|
for (const row of rows) {
|
||||||
amount: Math.abs(toNumber(row.amount)),
|
items.push({
|
||||||
dueDate: toISODate(row.dueDate),
|
id: row.id,
|
||||||
boletoPaymentDate: toISODate(row.boletoPaymentDate),
|
name: row.name,
|
||||||
isSettled: Boolean(row.isSettled),
|
amount: Math.abs(toNumber(row.amount)),
|
||||||
}));
|
dueDate: toDateOnlyString(row.dueDate),
|
||||||
|
boletoPaymentDate: toDateOnlyString(row.boletoPaymentDate),
|
||||||
|
isSettled: Boolean(row.isSettled),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchPagadorPaymentStatus({
|
export async function fetchPagadorPaymentStatus({
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Resend } from "resend";
|
|||||||
import { pagadores } from "@/db/schema";
|
import { pagadores } from "@/db/schema";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getResendFromEmail } from "@/lib/email/resend";
|
import { getResendFromEmail } from "@/lib/email/resend";
|
||||||
|
import { formatCurrency } from "@/lib/utils/currency";
|
||||||
|
import { formatDateTime } from "@/lib/utils/date";
|
||||||
|
|
||||||
type ActionType = "created" | "deleted";
|
type ActionType = "created" | "deleted";
|
||||||
|
|
||||||
@@ -24,20 +26,21 @@ export type PagadorNotificationRequest = {
|
|||||||
entriesByPagador: Map<string, NotificationEntry[]>;
|
entriesByPagador: Map<string, NotificationEntry[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (value: number) =>
|
type PagadorNotificationRecipient = {
|
||||||
value.toLocaleString("pt-BR", {
|
id: string;
|
||||||
style: "currency",
|
name: string | null;
|
||||||
currency: "BRL",
|
email: string | null;
|
||||||
maximumFractionDigits: 2,
|
isAutoSend: boolean | null;
|
||||||
});
|
};
|
||||||
|
|
||||||
const formatDate = (value: Date | null) => {
|
const formatDate = (value: Date | null) => {
|
||||||
if (!value) return "—";
|
return (
|
||||||
return value.toLocaleDateString("pt-BR", {
|
formatDateTime(value, {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
month: "short",
|
month: "short",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
});
|
}) ?? "—"
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildHtmlBody = ({
|
const buildHtmlBody = ({
|
||||||
@@ -133,9 +136,9 @@ export async function sendPagadorAutoEmails({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pagadorRows = await db.query.pagadores.findMany({
|
const pagadorRows = (await db.query.pagadores.findMany({
|
||||||
where: inArray(pagadores.id, pagadorIds),
|
where: inArray(pagadores.id, pagadorIds),
|
||||||
});
|
})) as PagadorNotificationRecipient[];
|
||||||
|
|
||||||
if (pagadorRows.length === 0) {
|
if (pagadorRows.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -146,7 +149,7 @@ export async function sendPagadorAutoEmails({
|
|||||||
action === "created" ? "Novo lançamento" : "Lançamento removido";
|
action === "created" ? "Novo lançamento" : "Lançamento removido";
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
pagadorRows.map(async (pagador) => {
|
pagadorRows.map(async (pagador: PagadorNotificationRecipient) => {
|
||||||
if (!pagador.email || !pagador.isAutoSend) {
|
if (!pagador.email || !pagador.isAutoSend) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -172,7 +175,7 @@ export async function sendPagadorAutoEmails({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Log any failed email sends
|
// Log any failed email sends
|
||||||
results.forEach((result, index) => {
|
results.forEach((result: PromiseSettledResult<void>, index: number) => {
|
||||||
if (result.status === "rejected") {
|
if (result.status === "rejected") {
|
||||||
const pagador = pagadorRows[index];
|
const pagador = pagadorRows[index];
|
||||||
console.error(
|
console.error(
|
||||||
|
|||||||
Reference in New Issue
Block a user