- Adicionar tokens semânticos de estado (success, warning, info) no globals.css - Migrar ~60+ componentes de cores hardcoded do Tailwind para tokens semânticos - Unificar 3 arrays duplicados de cores de categorias em importação única - Corrigir widgets de boleto/fatura que não atualizavam após pagamento (actions de fatura e antecipação não invalidavam cache do dashboard) - Corrigir scroll em listas Popover+Command (modal prop) - Adicionar link "detalhes" no card de orçamento para página da categoria - Adicionar indicadores de tendência coloridos nos cards de métricas - Estender cores de chart de 6 para 10 - Normalizar dark mode e remover tokens não utilizados Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
293 lines
7.6 KiB
TypeScript
293 lines
7.6 KiB
TypeScript
"use server";
|
|
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import { z } from "zod";
|
|
import {
|
|
cartoes,
|
|
categorias,
|
|
faturas,
|
|
lancamentos,
|
|
pagadores,
|
|
} from "@/db/schema";
|
|
import { buildInvoicePaymentNote } from "@/lib/accounts/constants";
|
|
import { revalidateForEntity } from "@/lib/actions/helpers";
|
|
import { getUser } from "@/lib/auth/server";
|
|
import { db } from "@/lib/db";
|
|
import {
|
|
INVOICE_PAYMENT_STATUS,
|
|
INVOICE_STATUS_VALUES,
|
|
type InvoicePaymentStatus,
|
|
PERIOD_FORMAT_REGEX,
|
|
} from "@/lib/faturas";
|
|
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
|
import { parseLocalDateString } from "@/lib/utils/date";
|
|
|
|
const updateInvoicePaymentStatusSchema = z.object({
|
|
cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."),
|
|
period: z
|
|
.string({ message: "Período inválido." })
|
|
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
|
|
status: z.enum(
|
|
INVOICE_STATUS_VALUES as [InvoicePaymentStatus, ...InvoicePaymentStatus[]],
|
|
),
|
|
paymentDate: z.string().optional(),
|
|
});
|
|
|
|
type UpdateInvoicePaymentStatusInput = z.infer<
|
|
typeof updateInvoicePaymentStatusSchema
|
|
>;
|
|
|
|
type ActionResult =
|
|
| { success: true; message: string }
|
|
| { success: false; error: string };
|
|
|
|
const successMessageByStatus: Record<InvoicePaymentStatus, string> = {
|
|
[INVOICE_PAYMENT_STATUS.PAID]: "Fatura marcada como paga.",
|
|
[INVOICE_PAYMENT_STATUS.PENDING]: "Pagamento da fatura foi revertido.",
|
|
};
|
|
|
|
const formatDecimal = (value: number) =>
|
|
(Math.round(value * 100) / 100).toFixed(2);
|
|
|
|
export async function updateInvoicePaymentStatusAction(
|
|
input: UpdateInvoicePaymentStatusInput,
|
|
): Promise<ActionResult> {
|
|
try {
|
|
const user = await getUser();
|
|
const data = updateInvoicePaymentStatusSchema.parse(input);
|
|
|
|
await db.transaction(async (tx: typeof db) => {
|
|
const card = await tx.query.cartoes.findFirst({
|
|
columns: { id: true, contaId: true, name: true },
|
|
where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)),
|
|
});
|
|
|
|
if (!card) {
|
|
throw new Error("Cartão não encontrado.");
|
|
}
|
|
|
|
const existingInvoice = await tx.query.faturas.findFirst({
|
|
columns: {
|
|
id: true,
|
|
},
|
|
where: and(
|
|
eq(faturas.cartaoId, data.cartaoId),
|
|
eq(faturas.userId, user.id),
|
|
eq(faturas.period, data.period),
|
|
),
|
|
});
|
|
|
|
if (existingInvoice) {
|
|
await tx
|
|
.update(faturas)
|
|
.set({
|
|
paymentStatus: data.status,
|
|
})
|
|
.where(eq(faturas.id, existingInvoice.id));
|
|
} else {
|
|
await tx.insert(faturas).values({
|
|
cartaoId: data.cartaoId,
|
|
period: data.period,
|
|
paymentStatus: data.status,
|
|
userId: user.id,
|
|
});
|
|
}
|
|
|
|
const shouldMarkAsPaid = data.status === INVOICE_PAYMENT_STATUS.PAID;
|
|
|
|
await tx
|
|
.update(lancamentos)
|
|
.set({ isSettled: shouldMarkAsPaid })
|
|
.where(
|
|
and(
|
|
eq(lancamentos.userId, user.id),
|
|
eq(lancamentos.cartaoId, card.id),
|
|
eq(lancamentos.period, data.period),
|
|
),
|
|
);
|
|
|
|
const invoiceNote = buildInvoicePaymentNote(card.id, data.period);
|
|
|
|
if (shouldMarkAsPaid) {
|
|
const [adminShareRow] = await tx
|
|
.select({
|
|
total: sql<number>`
|
|
coalesce(
|
|
sum(
|
|
case
|
|
when ${lancamentos.transactionType} = 'Despesa' then ${lancamentos.amount}
|
|
else 0
|
|
end
|
|
),
|
|
0
|
|
)
|
|
`,
|
|
})
|
|
.from(lancamentos)
|
|
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
|
.where(
|
|
and(
|
|
eq(lancamentos.userId, user.id),
|
|
eq(lancamentos.cartaoId, card.id),
|
|
eq(lancamentos.period, data.period),
|
|
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
|
),
|
|
);
|
|
|
|
const adminShare = Math.abs(Number(adminShareRow?.total ?? 0));
|
|
|
|
if (adminShare > 0 && card.contaId) {
|
|
const adminPagador = await tx.query.pagadores.findFirst({
|
|
columns: { id: true },
|
|
where: and(
|
|
eq(pagadores.userId, user.id),
|
|
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
|
),
|
|
});
|
|
|
|
const paymentCategory = await tx.query.categorias.findFirst({
|
|
columns: { id: true },
|
|
where: and(
|
|
eq(categorias.userId, user.id),
|
|
eq(categorias.name, "Pagamentos"),
|
|
),
|
|
});
|
|
|
|
if (adminPagador) {
|
|
// Usar a data customizada ou a data atual como data de pagamento
|
|
const invoiceDate = data.paymentDate
|
|
? parseLocalDateString(data.paymentDate)
|
|
: new Date();
|
|
|
|
const amount = `-${formatDecimal(adminShare)}`;
|
|
const payload = {
|
|
condition: "À vista",
|
|
name: `Pagamento fatura - ${card.name}`,
|
|
paymentMethod: "Pix",
|
|
note: invoiceNote,
|
|
amount,
|
|
purchaseDate: invoiceDate,
|
|
transactionType: "Despesa" as const,
|
|
period: data.period,
|
|
isSettled: true,
|
|
userId: user.id,
|
|
contaId: card.contaId,
|
|
categoriaId: paymentCategory?.id ?? null,
|
|
pagadorId: adminPagador.id,
|
|
};
|
|
|
|
const existingPayment = await tx.query.lancamentos.findFirst({
|
|
columns: { id: true },
|
|
where: and(
|
|
eq(lancamentos.userId, user.id),
|
|
eq(lancamentos.note, invoiceNote),
|
|
),
|
|
});
|
|
|
|
if (existingPayment) {
|
|
await tx
|
|
.update(lancamentos)
|
|
.set(payload)
|
|
.where(eq(lancamentos.id, existingPayment.id));
|
|
} else {
|
|
await tx.insert(lancamentos).values(payload);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
await tx
|
|
.delete(lancamentos)
|
|
.where(
|
|
and(
|
|
eq(lancamentos.userId, user.id),
|
|
eq(lancamentos.note, invoiceNote),
|
|
),
|
|
);
|
|
}
|
|
});
|
|
|
|
revalidateForEntity("cartoes");
|
|
|
|
return { success: true, message: successMessageByStatus[data.status] };
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return {
|
|
success: false,
|
|
error: error.issues[0]?.message ?? "Dados inválidos.",
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Erro inesperado.",
|
|
};
|
|
}
|
|
}
|
|
|
|
const updatePaymentDateSchema = z.object({
|
|
cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."),
|
|
period: z
|
|
.string({ message: "Período inválido." })
|
|
.regex(PERIOD_FORMAT_REGEX, "Período inválido."),
|
|
paymentDate: z.string({ message: "Data de pagamento inválida." }),
|
|
});
|
|
|
|
type UpdatePaymentDateInput = z.infer<typeof updatePaymentDateSchema>;
|
|
|
|
export async function updatePaymentDateAction(
|
|
input: UpdatePaymentDateInput,
|
|
): Promise<ActionResult> {
|
|
try {
|
|
const user = await getUser();
|
|
const data = updatePaymentDateSchema.parse(input);
|
|
|
|
await db.transaction(async (tx: typeof db) => {
|
|
const card = await tx.query.cartoes.findFirst({
|
|
columns: { id: true },
|
|
where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)),
|
|
});
|
|
|
|
if (!card) {
|
|
throw new Error("Cartão não encontrado.");
|
|
}
|
|
|
|
const invoiceNote = buildInvoicePaymentNote(card.id, data.period);
|
|
|
|
const existingPayment = await tx.query.lancamentos.findFirst({
|
|
columns: { id: true },
|
|
where: and(
|
|
eq(lancamentos.userId, user.id),
|
|
eq(lancamentos.note, invoiceNote),
|
|
),
|
|
});
|
|
|
|
if (!existingPayment) {
|
|
throw new Error("Pagamento não encontrado.");
|
|
}
|
|
|
|
await tx
|
|
.update(lancamentos)
|
|
.set({
|
|
purchaseDate: parseLocalDateString(data.paymentDate),
|
|
})
|
|
.where(eq(lancamentos.id, existingPayment.id));
|
|
});
|
|
|
|
revalidateForEntity("cartoes");
|
|
|
|
return { success: true, message: "Data de pagamento atualizada." };
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return {
|
|
success: false,
|
|
error: error.issues[0]?.message ?? "Dados inválidos.",
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Erro inesperado.",
|
|
};
|
|
}
|
|
}
|