mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 19:01:47 +00:00
fix(lançamentos): reforçar validações e revisar formulário
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "@/shared/lib/payers/notifications";
|
} from "@/shared/lib/payers/notifications";
|
||||||
import type { ActionResult } from "@/shared/lib/types/actions";
|
import type { ActionResult } from "@/shared/lib/types/actions";
|
||||||
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
|
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
|
||||||
|
import { addMonthsToPeriod, parsePeriod } from "@/shared/utils/period";
|
||||||
import {
|
import {
|
||||||
centsToDecimalString,
|
centsToDecimalString,
|
||||||
type DeleteBulkInput,
|
type DeleteBulkInput,
|
||||||
@@ -26,6 +27,8 @@ import {
|
|||||||
fetchOwnedCardIds,
|
fetchOwnedCardIds,
|
||||||
fetchOwnedCategoryIds,
|
fetchOwnedCategoryIds,
|
||||||
fetchOwnedPayerIds,
|
fetchOwnedPayerIds,
|
||||||
|
formatPaidInvoicePeriods,
|
||||||
|
getPaidInvoicePeriods,
|
||||||
type MassAddInput,
|
type MassAddInput,
|
||||||
massAddSchema,
|
massAddSchema,
|
||||||
resolvePeriod,
|
resolvePeriod,
|
||||||
@@ -37,6 +40,12 @@ import {
|
|||||||
validateAllOwnership,
|
validateAllOwnership,
|
||||||
} from "./core";
|
} from "./core";
|
||||||
|
|
||||||
|
const getPeriodOffset = (basePeriod: string, targetPeriod: string) => {
|
||||||
|
const base = parsePeriod(basePeriod);
|
||||||
|
const target = parsePeriod(targetPeriod);
|
||||||
|
return (target.year - base.year) * 12 + (target.month - base.month);
|
||||||
|
};
|
||||||
|
|
||||||
export async function deleteTransactionBulkAction(
|
export async function deleteTransactionBulkAction(
|
||||||
input: DeleteBulkInput,
|
input: DeleteBulkInput,
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
@@ -164,8 +173,10 @@ export async function updateTransactionBulkAction(
|
|||||||
period: true,
|
period: true,
|
||||||
condition: true,
|
condition: true,
|
||||||
transactionType: true,
|
transactionType: true,
|
||||||
|
paymentMethod: true,
|
||||||
purchaseDate: true,
|
purchaseDate: true,
|
||||||
payerId: true,
|
payerId: true,
|
||||||
|
cardId: true,
|
||||||
},
|
},
|
||||||
where: and(
|
where: and(
|
||||||
eq(transactions.id, data.id),
|
eq(transactions.id, data.id),
|
||||||
@@ -204,6 +215,8 @@ export async function updateTransactionBulkAction(
|
|||||||
|
|
||||||
const hasDueDateUpdate = data.dueDate !== undefined;
|
const hasDueDateUpdate = data.dueDate !== undefined;
|
||||||
const hasBoletoPaymentDateUpdate = data.boletoPaymentDate !== undefined;
|
const hasBoletoPaymentDateUpdate = data.boletoPaymentDate !== undefined;
|
||||||
|
const hasPurchaseDateUpdate = data.purchaseDate !== undefined;
|
||||||
|
const hasPeriodUpdate = data.period !== undefined;
|
||||||
|
|
||||||
const baseDueDate =
|
const baseDueDate =
|
||||||
hasDueDateUpdate && data.dueDate
|
hasDueDateUpdate && data.dueDate
|
||||||
@@ -218,8 +231,13 @@ export async function updateTransactionBulkAction(
|
|||||||
: hasBoletoPaymentDateUpdate
|
: hasBoletoPaymentDateUpdate
|
||||||
? null
|
? null
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const referencePurchaseDate = existing.purchaseDate ?? null;
|
||||||
const basePurchaseDate = existing.purchaseDate ?? null;
|
const basePurchaseDate =
|
||||||
|
hasPurchaseDateUpdate && data.purchaseDate
|
||||||
|
? parseLocalDateString(data.purchaseDate)
|
||||||
|
: undefined;
|
||||||
|
const basePeriod = hasPeriodUpdate ? data.period : undefined;
|
||||||
|
const targetCardId = data.cardId ?? existing.cardId ?? null;
|
||||||
|
|
||||||
const buildDueDateForRecord = (recordPurchaseDate: Date | null) => {
|
const buildDueDateForRecord = (recordPurchaseDate: Date | null) => {
|
||||||
if (!hasDueDateUpdate) {
|
if (!hasDueDateUpdate) {
|
||||||
@@ -230,18 +248,48 @@ export async function updateTransactionBulkAction(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!basePurchaseDate || !recordPurchaseDate) {
|
if (!referencePurchaseDate || !recordPurchaseDate) {
|
||||||
return baseDueDate;
|
return baseDueDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
const monthDiff =
|
const monthDiff =
|
||||||
(recordPurchaseDate.getFullYear() - basePurchaseDate.getFullYear()) *
|
(recordPurchaseDate.getFullYear() -
|
||||||
|
referencePurchaseDate.getFullYear()) *
|
||||||
12 +
|
12 +
|
||||||
(recordPurchaseDate.getMonth() - basePurchaseDate.getMonth());
|
(recordPurchaseDate.getMonth() - referencePurchaseDate.getMonth());
|
||||||
|
|
||||||
return addMonthsToDate(baseDueDate, monthDiff);
|
return addMonthsToDate(baseDueDate, monthDiff);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildPurchaseDateForRecord = (record: {
|
||||||
|
purchaseDate: Date | null;
|
||||||
|
period: string;
|
||||||
|
}) => {
|
||||||
|
if (!basePurchaseDate) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.condition === "Recorrente" && existing.period) {
|
||||||
|
const offset = getPeriodOffset(existing.period, record.period);
|
||||||
|
return addMonthsToDate(basePurchaseDate, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return basePurchaseDate;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPeriodForRecord = (record: { period: string }) => {
|
||||||
|
if (!basePeriod) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.period) {
|
||||||
|
const offset = getPeriodOffset(existing.period, record.period);
|
||||||
|
return addMonthsToPeriod(basePeriod, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return basePeriod;
|
||||||
|
};
|
||||||
|
|
||||||
const serializeDateKey = (value: Date | null | undefined) => {
|
const serializeDateKey = (value: Date | null | undefined) => {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
return "undefined";
|
return "undefined";
|
||||||
@@ -252,8 +300,51 @@ export async function updateTransactionBulkAction(
|
|||||||
return String(value.getTime());
|
return String(value.getTime());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ensureTargetInvoicesAreOpen = async (
|
||||||
|
records: Array<{ period: string }>,
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
existing.paymentMethod !== "Cartão de crédito" ||
|
||||||
|
!targetCardId ||
|
||||||
|
(!hasPurchaseDateUpdate &&
|
||||||
|
!hasPeriodUpdate &&
|
||||||
|
data.cardId === undefined)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const movedPeriods = new Set<string>();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const targetPeriodForRecord =
|
||||||
|
buildPeriodForRecord(record) ?? record.period;
|
||||||
|
const cardChanged = targetCardId !== existing.cardId;
|
||||||
|
const periodChanged = targetPeriodForRecord !== record.period;
|
||||||
|
|
||||||
|
if (cardChanged || periodChanged) {
|
||||||
|
movedPeriods.add(targetPeriodForRecord);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (movedPeriods.size === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paidPeriods = await getPaidInvoicePeriods(user.id, targetCardId, [
|
||||||
|
...movedPeriods,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (paidPeriods.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `As faturas dos meses ${formatPaidInvoicePeriods(
|
||||||
|
paidPeriods,
|
||||||
|
)} já estão pagas. Desfaça o pagamento antes de mover este lançamento.`;
|
||||||
|
};
|
||||||
|
|
||||||
const applyUpdates = async (
|
const applyUpdates = async (
|
||||||
records: Array<{ id: string; purchaseDate: Date | null }>,
|
records: Array<{ id: string; purchaseDate: Date | null; period: string }>,
|
||||||
) => {
|
) => {
|
||||||
if (records.length === 0) {
|
if (records.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -269,10 +360,20 @@ export async function updateTransactionBulkAction(
|
|||||||
|
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
const dueDateForRecord = buildDueDateForRecord(record.purchaseDate);
|
const dueDateForRecord = buildDueDateForRecord(record.purchaseDate);
|
||||||
|
const purchaseDateForRecord = buildPurchaseDateForRecord(record);
|
||||||
|
const periodForRecord = buildPeriodForRecord(record);
|
||||||
const perRecordPayload: Record<string, unknown> = {
|
const perRecordPayload: Record<string, unknown> = {
|
||||||
...baseUpdatePayload,
|
...baseUpdatePayload,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (purchaseDateForRecord !== undefined) {
|
||||||
|
perRecordPayload.purchaseDate = purchaseDateForRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (periodForRecord !== undefined) {
|
||||||
|
perRecordPayload.period = periodForRecord;
|
||||||
|
}
|
||||||
|
|
||||||
if (dueDateForRecord !== undefined) {
|
if (dueDateForRecord !== undefined) {
|
||||||
perRecordPayload.dueDate = dueDateForRecord;
|
perRecordPayload.dueDate = dueDateForRecord;
|
||||||
}
|
}
|
||||||
@@ -282,6 +383,8 @@ export async function updateTransactionBulkAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const groupKey = [
|
const groupKey = [
|
||||||
|
serializeDateKey(purchaseDateForRecord),
|
||||||
|
periodForRecord ?? "undefined",
|
||||||
serializeDateKey(dueDateForRecord),
|
serializeDateKey(dueDateForRecord),
|
||||||
serializeDateKey(
|
serializeDateKey(
|
||||||
hasBoletoPaymentDateUpdate
|
hasBoletoPaymentDateUpdate
|
||||||
@@ -318,12 +421,19 @@ export async function updateTransactionBulkAction(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (data.scope === "current") {
|
if (data.scope === "current") {
|
||||||
await applyUpdates([
|
const currentRecords = [
|
||||||
{
|
{
|
||||||
id: data.id,
|
id: data.id,
|
||||||
purchaseDate: existing.purchaseDate ?? null,
|
purchaseDate: existing.purchaseDate ?? null,
|
||||||
|
period: existing.period,
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
const invoiceError = await ensureTargetInvoicesAreOpen(currentRecords);
|
||||||
|
if (invoiceError) {
|
||||||
|
return { success: false, error: invoiceError };
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyUpdates(currentRecords);
|
||||||
|
|
||||||
revalidate(user.id);
|
revalidate(user.id);
|
||||||
return { success: true, message: "Lançamento atualizado com sucesso." };
|
return { success: true, message: "Lançamento atualizado com sucesso." };
|
||||||
@@ -338,7 +448,7 @@ export async function updateTransactionBulkAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const periodLancamentos = await db.query.transactions.findMany({
|
const periodLancamentos = await db.query.transactions.findMany({
|
||||||
columns: { id: true, purchaseDate: true },
|
columns: { id: true, purchaseDate: true, period: true },
|
||||||
where: and(
|
where: and(
|
||||||
eq(transactions.seriesId, existing.seriesId),
|
eq(transactions.seriesId, existing.seriesId),
|
||||||
eq(transactions.userId, user.id),
|
eq(transactions.userId, user.id),
|
||||||
@@ -347,10 +457,16 @@ export async function updateTransactionBulkAction(
|
|||||||
orderBy: asc(transactions.purchaseDate),
|
orderBy: asc(transactions.purchaseDate),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const invoiceError = await ensureTargetInvoicesAreOpen(periodLancamentos);
|
||||||
|
if (invoiceError) {
|
||||||
|
return { success: false, error: invoiceError };
|
||||||
|
}
|
||||||
|
|
||||||
await applyUpdates(
|
await applyUpdates(
|
||||||
periodLancamentos.map((item: (typeof periodLancamentos)[number]) => ({
|
periodLancamentos.map((item: (typeof periodLancamentos)[number]) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
purchaseDate: item.purchaseDate ?? null,
|
purchaseDate: item.purchaseDate ?? null,
|
||||||
|
period: item.period,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -370,6 +486,7 @@ export async function updateTransactionBulkAction(
|
|||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
purchaseDate: true,
|
purchaseDate: true,
|
||||||
|
period: true,
|
||||||
},
|
},
|
||||||
where: and(
|
where: and(
|
||||||
eq(transactions.seriesId, existing.seriesId),
|
eq(transactions.seriesId, existing.seriesId),
|
||||||
@@ -380,10 +497,16 @@ export async function updateTransactionBulkAction(
|
|||||||
orderBy: asc(transactions.purchaseDate),
|
orderBy: asc(transactions.purchaseDate),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const invoiceError = await ensureTargetInvoicesAreOpen(futureLancamentos);
|
||||||
|
if (invoiceError) {
|
||||||
|
return { success: false, error: invoiceError };
|
||||||
|
}
|
||||||
|
|
||||||
await applyUpdates(
|
await applyUpdates(
|
||||||
futureLancamentos.map((item: (typeof futureLancamentos)[number]) => ({
|
futureLancamentos.map((item: (typeof futureLancamentos)[number]) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
purchaseDate: item.purchaseDate ?? null,
|
purchaseDate: item.purchaseDate ?? null,
|
||||||
|
period: item.period,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -399,6 +522,7 @@ export async function updateTransactionBulkAction(
|
|||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
purchaseDate: true,
|
purchaseDate: true,
|
||||||
|
period: true,
|
||||||
},
|
},
|
||||||
where: and(
|
where: and(
|
||||||
eq(transactions.seriesId, existing.seriesId),
|
eq(transactions.seriesId, existing.seriesId),
|
||||||
@@ -408,10 +532,16 @@ export async function updateTransactionBulkAction(
|
|||||||
orderBy: asc(transactions.purchaseDate),
|
orderBy: asc(transactions.purchaseDate),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const invoiceError = await ensureTargetInvoicesAreOpen(allLancamentos);
|
||||||
|
if (invoiceError) {
|
||||||
|
return { success: false, error: invoiceError };
|
||||||
|
}
|
||||||
|
|
||||||
await applyUpdates(
|
await applyUpdates(
|
||||||
allLancamentos.map((item: (typeof allLancamentos)[number]) => ({
|
allLancamentos.map((item: (typeof allLancamentos)[number]) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
purchaseDate: item.purchaseDate ?? null,
|
purchaseDate: item.purchaseDate ?? null,
|
||||||
|
period: item.period,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
cards,
|
cards,
|
||||||
categories,
|
categories,
|
||||||
financialAccounts,
|
financialAccounts,
|
||||||
|
invoices,
|
||||||
payers,
|
payers,
|
||||||
type transactions,
|
type transactions,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
@@ -20,9 +21,10 @@ import {
|
|||||||
} from "@/shared/lib/accounts/constants";
|
} from "@/shared/lib/accounts/constants";
|
||||||
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
|
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
|
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
||||||
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
|
import { noteSchema, uuidSchema } from "@/shared/lib/schemas/common";
|
||||||
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
|
import { addMonthsToDate, parseLocalDateString } from "@/shared/utils/date";
|
||||||
import { addMonthsToPeriod } from "@/shared/utils/period";
|
import { addMonthsToPeriod, MONTH_NAMES } from "@/shared/utils/period";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Authorization Validation Functions
|
// Authorization Validation Functions
|
||||||
@@ -662,6 +664,43 @@ export const buildLancamentoRecords = ({
|
|||||||
return records;
|
return records;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const formatPaidInvoicePeriods = (periods: string[]) =>
|
||||||
|
periods
|
||||||
|
.map((period) => {
|
||||||
|
const [year, month] = period.split("-");
|
||||||
|
const monthName = MONTH_NAMES[Number(month) - 1] ?? month;
|
||||||
|
return `${monthName}/${year}`;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
export async function getPaidInvoicePeriods(
|
||||||
|
userId: string,
|
||||||
|
cardId: string,
|
||||||
|
periods: string[],
|
||||||
|
) {
|
||||||
|
if (periods.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.query.invoices.findMany({
|
||||||
|
columns: { period: true },
|
||||||
|
where: and(
|
||||||
|
eq(invoices.userId, userId),
|
||||||
|
eq(invoices.cardId, cardId),
|
||||||
|
eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PAID),
|
||||||
|
inArray(invoices.period, periods),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
rows
|
||||||
|
.map((row) => row.period)
|
||||||
|
.filter((period): period is string => Boolean(period)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export const deleteBulkSchema = z.object({
|
export const deleteBulkSchema = z.object({
|
||||||
id: uuidSchema("Lançamento"),
|
id: uuidSchema("Lançamento"),
|
||||||
scope: z.enum(["current", "period", "future", "all"], {
|
scope: z.enum(["current", "period", "future", "all"], {
|
||||||
@@ -676,6 +715,20 @@ export const updateBulkSchema = z.object({
|
|||||||
scope: z.enum(["current", "period", "future", "all"], {
|
scope: z.enum(["current", "period", "future", "all"], {
|
||||||
message: "Escopo de ação inválido.",
|
message: "Escopo de ação inválido.",
|
||||||
}),
|
}),
|
||||||
|
purchaseDate: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine((value) => !value || isValidDateInput(value), {
|
||||||
|
message: "Data da transação inválida.",
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
period: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^(\d{4})-(\d{2})$/, {
|
||||||
|
message: "Selecione um período válido.",
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
name: z
|
name: z
|
||||||
.string({ message: "Informe o estabelecimento." })
|
.string({ message: "Informe o estabelecimento." })
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
attachments,
|
attachments,
|
||||||
financialAccounts,
|
financialAccounts,
|
||||||
invoices,
|
|
||||||
transactionAttachments,
|
transactionAttachments,
|
||||||
transactions,
|
transactions,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { handleActionError } from "@/shared/lib/actions/helpers";
|
import { handleActionError } from "@/shared/lib/actions/helpers";
|
||||||
import { getUser } from "@/shared/lib/auth/server";
|
import { getUser } from "@/shared/lib/auth/server";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
import { INVOICE_PAYMENT_STATUS } from "@/shared/lib/invoices";
|
|
||||||
import {
|
import {
|
||||||
buildEntriesByPayer,
|
buildEntriesByPayer,
|
||||||
sendPayerAutoEmails,
|
sendPayerAutoEmails,
|
||||||
@@ -23,7 +21,6 @@ import {
|
|||||||
getBusinessTodayDate,
|
getBusinessTodayDate,
|
||||||
parseLocalDateString,
|
parseLocalDateString,
|
||||||
} from "@/shared/utils/date";
|
} from "@/shared/utils/date";
|
||||||
import { MONTH_NAMES } from "@/shared/utils/period";
|
|
||||||
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
|
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
|
||||||
import {
|
import {
|
||||||
buildLancamentoRecords,
|
buildLancamentoRecords,
|
||||||
@@ -33,6 +30,8 @@ import {
|
|||||||
createSchema,
|
createSchema,
|
||||||
type DeleteInput,
|
type DeleteInput,
|
||||||
deleteSchema,
|
deleteSchema,
|
||||||
|
formatPaidInvoicePeriods,
|
||||||
|
getPaidInvoicePeriods,
|
||||||
isInitialBalanceLancamento,
|
isInitialBalanceLancamento,
|
||||||
resolvePeriod,
|
resolvePeriod,
|
||||||
resolveUserLabel,
|
resolveUserLabel,
|
||||||
@@ -118,27 +117,18 @@ export async function createTransactionAction(
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
const paidInvoices = await db.query.invoices.findMany({
|
const paidPeriods = await getPaidInvoicePeriods(
|
||||||
columns: { period: true },
|
user.id,
|
||||||
where: and(
|
data.cardId,
|
||||||
eq(invoices.userId, user.id),
|
uniquePeriods,
|
||||||
eq(invoices.cardId, data.cardId),
|
);
|
||||||
eq(invoices.paymentStatus, INVOICE_PAYMENT_STATUS.PAID),
|
|
||||||
inArray(invoices.period, uniquePeriods),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (paidInvoices.length > 0) {
|
if (paidPeriods.length > 0) {
|
||||||
const labels = paidInvoices
|
|
||||||
.map((inv) => {
|
|
||||||
const [year, month] = (inv.period ?? "").split("-");
|
|
||||||
const monthName = MONTH_NAMES[Number(month) - 1] ?? month;
|
|
||||||
return `${monthName}/${year}`;
|
|
||||||
})
|
|
||||||
.join(", ");
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `As faturas dos meses ${labels} já estão pagas. Desfaça o pagamento antes de adicionar este lançamento.`,
|
error: `As faturas dos meses ${formatPaidInvoicePeriods(
|
||||||
|
paidPeriods,
|
||||||
|
)} já estão pagas. Desfaça o pagamento antes de adicionar este lançamento.`,
|
||||||
} as ActionResult<{ ids: string[] }>;
|
} as ActionResult<{ ids: string[] }>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,10 +194,12 @@ export async function updateTransactionAction(
|
|||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
note: true,
|
note: true,
|
||||||
|
period: true,
|
||||||
transactionType: true,
|
transactionType: true,
|
||||||
condition: true,
|
condition: true,
|
||||||
paymentMethod: true,
|
paymentMethod: true,
|
||||||
accountId: true,
|
accountId: true,
|
||||||
|
cardId: true,
|
||||||
categoryId: true,
|
categoryId: true,
|
||||||
},
|
},
|
||||||
where: and(
|
where: and(
|
||||||
@@ -225,10 +217,12 @@ export async function updateTransactionAction(
|
|||||||
| {
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
|
period: string;
|
||||||
transactionType: string;
|
transactionType: string;
|
||||||
condition: string;
|
condition: string;
|
||||||
paymentMethod: string;
|
paymentMethod: string;
|
||||||
accountId: string | null;
|
accountId: string | null;
|
||||||
|
cardId: string | null;
|
||||||
categoryId: string | null;
|
categoryId: string | null;
|
||||||
category: { name: string } | null;
|
category: { name: string } | null;
|
||||||
}
|
}
|
||||||
@@ -264,6 +258,25 @@ export async function updateTransactionAction(
|
|||||||
? parseLocalDateString(data.boletoPaymentDate)
|
? parseLocalDateString(data.boletoPaymentDate)
|
||||||
: getBusinessTodayDate()
|
: getBusinessTodayDate()
|
||||||
: null;
|
: null;
|
||||||
|
const targetCardId = data.cardId ?? existing.cardId;
|
||||||
|
const movedInvoice =
|
||||||
|
data.paymentMethod === "Cartão de crédito" &&
|
||||||
|
targetCardId &&
|
||||||
|
(targetCardId !== existing.cardId || period !== existing.period);
|
||||||
|
|
||||||
|
if (movedInvoice) {
|
||||||
|
const paidPeriods = await getPaidInvoicePeriods(user.id, targetCardId, [
|
||||||
|
period,
|
||||||
|
]);
|
||||||
|
if (paidPeriods.length > 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `As faturas dos meses ${formatPaidInvoicePeriods(
|
||||||
|
paidPeriods,
|
||||||
|
)} já estão pagas. Desfaça o pagamento antes de mover este lançamento.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(transactions)
|
.update(transactions)
|
||||||
|
|||||||
@@ -10,14 +10,16 @@ import {
|
|||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
|
||||||
interface AttachmentFilePickerProps {
|
interface AttachmentFilePickerProps {
|
||||||
file: File | null;
|
files: File[];
|
||||||
onChange: (file: File | null) => void;
|
onAdd: (file: File) => void;
|
||||||
|
onRemove: (file: File) => void;
|
||||||
maxSizeMb?: number;
|
maxSizeMb?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AttachmentFilePicker({
|
export function AttachmentFilePicker({
|
||||||
file,
|
files,
|
||||||
onChange,
|
onAdd,
|
||||||
|
onRemove,
|
||||||
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
||||||
}: AttachmentFilePickerProps) {
|
}: AttachmentFilePickerProps) {
|
||||||
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
|
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
|
||||||
@@ -45,12 +47,12 @@ export function AttachmentFilePicker({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange(selected);
|
onAdd(selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="text-xs">Anexo</p>
|
<p className="text-xs font-medium">Anexos</p>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -59,37 +61,44 @@ export function AttachmentFilePicker({
|
|||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{file ? (
|
{files.length > 0 && (
|
||||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm">
|
<div className="space-y-1.5">
|
||||||
<RiAttachment2 className="size-4 shrink-0 text-muted-foreground" />
|
{files.map((file) => (
|
||||||
<span className="min-w-0 flex-1 truncate" title={file.name}>
|
<div
|
||||||
{file.name}
|
key={`${file.name}-${file.size}-${file.lastModified}`}
|
||||||
</span>
|
className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm"
|
||||||
<Button
|
>
|
||||||
type="button"
|
<RiAttachment2 className="size-4 shrink-0 text-muted-foreground" />
|
||||||
variant="ghost"
|
<span className="min-w-0 flex-1 truncate" title={file.name}>
|
||||||
size="icon"
|
{file.name}
|
||||||
className="size-6 shrink-0"
|
</span>
|
||||||
onClick={() => onChange(null)}
|
<Button
|
||||||
>
|
type="button"
|
||||||
<RiCloseLine className="size-4" />
|
variant="ghost"
|
||||||
</Button>
|
size="icon"
|
||||||
|
className="size-6 shrink-0"
|
||||||
|
onClick={() => onRemove(file)}
|
||||||
|
>
|
||||||
|
<RiCloseLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
|
|
||||||
onClick={() => inputRef.current?.click()}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<RiAttachment2 className="size-4" />
|
|
||||||
Adicionar anexo
|
|
||||||
</span>
|
|
||||||
<span className="text-xs">
|
|
||||||
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<RiAttachment2 className="size-4" />
|
||||||
|
Adicionar anexo
|
||||||
|
</span>
|
||||||
|
<span className="text-xs">
|
||||||
|
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function CategorySection({
|
|||||||
>
|
>
|
||||||
<Label htmlFor="categoria">Categoria</Label>
|
<Label htmlFor="categoria">Categoria</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formState.categoryId}
|
value={formState.categoryId ?? ""}
|
||||||
onValueChange={(value) => onFieldChange("categoryId", value)}
|
onValueChange={(value) => onFieldChange("categoryId", value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="categoria" className="w-full">
|
<SelectTrigger id="categoria" className="w-full">
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||||
|
import { RiSliceFill } from "@remixicon/react";
|
||||||
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
import { CurrencyInput } from "@/shared/components/ui/currency-input";
|
||||||
import { Label } from "@/shared/components/ui/label";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
import {
|
import {
|
||||||
@@ -9,6 +11,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select";
|
} from "@/shared/components/ui/select";
|
||||||
|
import { cn } from "@/shared/utils/ui";
|
||||||
import { PayerSelectContent } from "../../select-items";
|
import { PayerSelectContent } from "../../select-items";
|
||||||
import type { PayerSectionProps } from "./transaction-dialog-types";
|
import type { PayerSectionProps } from "./transaction-dialog-types";
|
||||||
|
|
||||||
@@ -34,75 +37,59 @@ export function PayerSection({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
<div className="space-y-3">
|
||||||
<div className="w-full space-y-1">
|
<div
|
||||||
<Label htmlFor="payer">Pagador</Label>
|
className={cn(
|
||||||
<div className="flex gap-2">
|
"flex items-center justify-between rounded-lg border px-3 py-2.5 transition-colors",
|
||||||
<Select
|
formState.isSplit
|
||||||
value={formState.payerId}
|
? "border-primary/20 bg-primary/5"
|
||||||
onValueChange={(value) => onFieldChange("payerId", value)}
|
: "border-border bg-transparent",
|
||||||
>
|
)}
|
||||||
<SelectTrigger
|
>
|
||||||
id="payer"
|
<div className="flex items-center gap-2">
|
||||||
className={formState.isSplit ? "min-w-0 flex-1" : "w-full"}
|
<div>
|
||||||
>
|
<p className="text-sm text-foreground">Dividir lançamento</p>
|
||||||
<SelectValue placeholder="Selecione">
|
<p className="text-xs text-muted-foreground">
|
||||||
{formState.payerId &&
|
Atribuir parte do valor a outro pagador.
|
||||||
(() => {
|
</p>
|
||||||
const selectedOption = payerOptions.find(
|
</div>
|
||||||
(opt) => opt.value === formState.payerId,
|
|
||||||
);
|
|
||||||
return selectedOption ? (
|
|
||||||
<PayerSelectContent
|
|
||||||
label={selectedOption.label}
|
|
||||||
avatarUrl={selectedOption.avatarUrl}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{payerOptions.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
<PayerSelectContent
|
|
||||||
label={option.label}
|
|
||||||
avatarUrl={option.avatarUrl}
|
|
||||||
/>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{formState.isSplit && (
|
|
||||||
<CurrencyInput
|
|
||||||
value={formState.primarySplitAmount}
|
|
||||||
onValueChange={handlePrimaryAmountChange}
|
|
||||||
placeholder="R$ 0,00"
|
|
||||||
className="h-9 w-[45%] text-sm"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
checked={formState.isSplit}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onFieldChange("isSplit", Boolean(checked))
|
||||||
|
}
|
||||||
|
aria-label="Dividir lançamento"
|
||||||
|
className={cn(
|
||||||
|
"peer size-4 shrink-0 rounded-lg border shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
formState.isSplit
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-input dark:bg-input/30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator className="grid place-content-center text-current transition-none">
|
||||||
|
<RiSliceFill className="size-3" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{formState.isSplit ? (
|
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||||
<div className="w-full space-y-1 mb-1">
|
<div className="w-full space-y-1">
|
||||||
<Label htmlFor="secondaryPayer">Dividir com</Label>
|
<Label htmlFor="payer">Pagador</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select
|
<Select
|
||||||
value={formState.secondaryPayerId}
|
value={formState.payerId ?? ""}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => onFieldChange("payerId", value)}
|
||||||
onFieldChange("secondaryPayerId", value)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
id="secondaryPayer"
|
id="payer"
|
||||||
disabled={secondaryPayerOptions.length === 0}
|
className={formState.isSplit ? "min-w-0 flex-1" : "w-full"}
|
||||||
className="w-[55%]"
|
|
||||||
>
|
>
|
||||||
<SelectValue placeholder="Selecione">
|
<SelectValue placeholder="Selecione">
|
||||||
{formState.secondaryPayerId &&
|
{formState.payerId &&
|
||||||
(() => {
|
(() => {
|
||||||
const selectedOption = secondaryPayerOptions.find(
|
const selectedOption = payerOptions.find(
|
||||||
(opt) => opt.value === formState.secondaryPayerId,
|
(opt) => opt.value === formState.payerId,
|
||||||
);
|
);
|
||||||
return selectedOption ? (
|
return selectedOption ? (
|
||||||
<PayerSelectContent
|
<PayerSelectContent
|
||||||
@@ -114,7 +101,7 @@ export function PayerSection({
|
|||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{secondaryPayerOptions.map((option) => (
|
{payerOptions.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
<PayerSelectContent
|
<PayerSelectContent
|
||||||
label={option.label}
|
label={option.label}
|
||||||
@@ -124,15 +111,68 @@ export function PayerSection({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<CurrencyInput
|
{formState.isSplit && (
|
||||||
value={formState.secondarySplitAmount}
|
<CurrencyInput
|
||||||
onValueChange={handleSecondaryAmountChange}
|
value={formState.primarySplitAmount}
|
||||||
placeholder="R$ 0,00"
|
onValueChange={handlePrimaryAmountChange}
|
||||||
className="h-9 w-[45%] text-sm"
|
placeholder="R$ 0,00"
|
||||||
/>
|
className="h-9 w-[45%] text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
{formState.isSplit ? (
|
||||||
|
<div className="w-full space-y-1 mb-1">
|
||||||
|
<Label htmlFor="secondaryPayer">Dividir com</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select
|
||||||
|
value={formState.secondaryPayerId ?? ""}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onFieldChange("secondaryPayerId", value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="secondaryPayer"
|
||||||
|
disabled={secondaryPayerOptions.length === 0}
|
||||||
|
className="w-[55%]"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Selecione">
|
||||||
|
{formState.secondaryPayerId &&
|
||||||
|
(() => {
|
||||||
|
const selectedOption = secondaryPayerOptions.find(
|
||||||
|
(opt) => opt.value === formState.secondaryPayerId,
|
||||||
|
);
|
||||||
|
return selectedOption ? (
|
||||||
|
<PayerSelectContent
|
||||||
|
label={selectedOption.label}
|
||||||
|
avatarUrl={selectedOption.avatarUrl}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{secondaryPayerOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
<PayerSelectContent
|
||||||
|
label={option.label}
|
||||||
|
avatarUrl={option.avatarUrl}
|
||||||
|
/>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<CurrencyInput
|
||||||
|
value={formState.secondarySplitAmount}
|
||||||
|
onValueChange={handleSecondaryAmountChange}
|
||||||
|
placeholder="R$ 0,00"
|
||||||
|
className="h-9 w-[45%] text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RiCheckboxBlankCircleLine,
|
||||||
|
RiCheckboxCircleFill,
|
||||||
|
} from "@remixicon/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { PAYMENT_METHODS } from "@/features/transactions/constants";
|
import { PAYMENT_METHODS } from "@/features/transactions/constants";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Label } from "@/shared/components/ui/label";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
import { MonthPicker } from "@/shared/components/ui/month-picker";
|
import { MonthPicker } from "@/shared/components/ui/month-picker";
|
||||||
import {
|
import {
|
||||||
@@ -71,6 +76,7 @@ export function PaymentMethodSection({
|
|||||||
isUpdateMode,
|
isUpdateMode,
|
||||||
disablePaymentMethod,
|
disablePaymentMethod,
|
||||||
disableCardSelect,
|
disableCardSelect,
|
||||||
|
showSettledToggle,
|
||||||
}: PaymentMethodSectionProps) {
|
}: PaymentMethodSectionProps) {
|
||||||
const isCartaoSelected = formState.paymentMethod === "Cartão de crédito";
|
const isCartaoSelected = formState.paymentMethod === "Cartão de crédito";
|
||||||
const showContaSelect = [
|
const showContaSelect = [
|
||||||
@@ -92,154 +98,200 @@ export function PaymentMethodSection({
|
|||||||
const hasSecondaryColumn = isCartaoSelected || showContaSelect;
|
const hasSecondaryColumn = isCartaoSelected || showContaSelect;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
<div className="space-y-3">
|
||||||
{!isUpdateMode ? (
|
<div className="flex w-full flex-col gap-2 md:flex-row">
|
||||||
<div
|
{!isUpdateMode ? (
|
||||||
className={cn(
|
<div
|
||||||
"w-full space-y-1",
|
className={cn(
|
||||||
hasSecondaryColumn ? "md:w-1/2" : "md:w-full",
|
"w-full space-y-1",
|
||||||
)}
|
hasSecondaryColumn ? "md:w-1/2" : "md:w-full",
|
||||||
>
|
)}
|
||||||
<Label htmlFor="paymentMethod">Forma de pagamento</Label>
|
|
||||||
<Select
|
|
||||||
value={formState.paymentMethod}
|
|
||||||
onValueChange={(value) => onFieldChange("paymentMethod", value)}
|
|
||||||
disabled={disablePaymentMethod}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<Label htmlFor="paymentMethod">Forma de pagamento</Label>
|
||||||
id="paymentMethod"
|
<Select
|
||||||
className="w-full"
|
value={formState.paymentMethod}
|
||||||
|
onValueChange={(value) => onFieldChange("paymentMethod", value)}
|
||||||
disabled={disablePaymentMethod}
|
disabled={disablePaymentMethod}
|
||||||
>
|
>
|
||||||
<SelectValue placeholder="Selecione" className="w-full">
|
<SelectTrigger
|
||||||
{formState.paymentMethod && (
|
id="paymentMethod"
|
||||||
<PaymentMethodSelectContent label={formState.paymentMethod} />
|
className="w-full"
|
||||||
)}
|
disabled={disablePaymentMethod}
|
||||||
</SelectValue>
|
>
|
||||||
</SelectTrigger>
|
<SelectValue placeholder="Selecione" className="w-full">
|
||||||
<SelectContent>
|
{formState.paymentMethod && (
|
||||||
{PAYMENT_METHODS.map((method) => (
|
<PaymentMethodSelectContent
|
||||||
<SelectItem key={method} value={method}>
|
label={formState.paymentMethod}
|
||||||
<PaymentMethodSelectContent label={method} />
|
/>
|
||||||
</SelectItem>
|
)}
|
||||||
))}
|
</SelectValue>
|
||||||
</SelectContent>
|
</SelectTrigger>
|
||||||
</Select>
|
<SelectContent>
|
||||||
</div>
|
{PAYMENT_METHODS.map((method) => (
|
||||||
) : null}
|
<SelectItem key={method} value={method}>
|
||||||
|
<PaymentMethodSelectContent label={method} />
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{isCartaoSelected ? (
|
{isCartaoSelected ? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full space-y-1",
|
"w-full space-y-1",
|
||||||
!isUpdateMode ? "md:w-1/2" : "md:w-full",
|
!isUpdateMode ? "md:w-1/2" : "md:w-full",
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<Label htmlFor="cartao">Cartão</Label>
|
|
||||||
<Select
|
|
||||||
value={formState.cardId}
|
|
||||||
onValueChange={(value) => onFieldChange("cardId", value)}
|
|
||||||
disabled={disableCardSelect}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<Label htmlFor="cartao">Cartão</Label>
|
||||||
id="cartao"
|
<Select
|
||||||
className="w-full"
|
value={formState.cardId ?? ""}
|
||||||
|
onValueChange={(value) => onFieldChange("cardId", value)}
|
||||||
disabled={disableCardSelect}
|
disabled={disableCardSelect}
|
||||||
>
|
>
|
||||||
<SelectValue placeholder="Selecione">
|
<SelectTrigger
|
||||||
{formState.cardId &&
|
id="cartao"
|
||||||
(() => {
|
className="w-full"
|
||||||
const selectedOption = cardOptions.find(
|
disabled={disableCardSelect}
|
||||||
(opt) => opt.value === formState.cardId,
|
>
|
||||||
);
|
<SelectValue placeholder="Selecione">
|
||||||
return selectedOption ? (
|
{formState.cardId &&
|
||||||
|
(() => {
|
||||||
|
const selectedOption = cardOptions.find(
|
||||||
|
(opt) => opt.value === formState.cardId,
|
||||||
|
);
|
||||||
|
return selectedOption ? (
|
||||||
|
<AccountCardSelectContent
|
||||||
|
label={selectedOption.label}
|
||||||
|
logo={selectedOption.logo}
|
||||||
|
isCartao={true}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{cardOptions.length === 0 ? (
|
||||||
|
<div className="px-2 py-6 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Nenhum cartão cadastrado
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
cardOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
<AccountCardSelectContent
|
<AccountCardSelectContent
|
||||||
label={selectedOption.label}
|
label={option.label}
|
||||||
logo={selectedOption.logo}
|
logo={option.logo}
|
||||||
isCartao={true}
|
isCartao={true}
|
||||||
/>
|
/>
|
||||||
) : null;
|
</SelectItem>
|
||||||
})()}
|
))
|
||||||
</SelectValue>
|
)}
|
||||||
</SelectTrigger>
|
</SelectContent>
|
||||||
<SelectContent>
|
</Select>
|
||||||
{cardOptions.length === 0 ? (
|
{formState.cardId ? (
|
||||||
<div className="px-2 py-6 text-center">
|
<InlinePeriodPicker
|
||||||
<p className="text-sm text-muted-foreground">
|
period={formState.period}
|
||||||
Nenhum cartão cadastrado
|
onPeriodChange={(value) => onFieldChange("period", value)}
|
||||||
</p>
|
/>
|
||||||
</div>
|
) : null}
|
||||||
) : (
|
</div>
|
||||||
cardOptions.map((option) => (
|
) : null}
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
<AccountCardSelectContent
|
|
||||||
label={option.label}
|
|
||||||
logo={option.logo}
|
|
||||||
isCartao={true}
|
|
||||||
/>
|
|
||||||
</SelectItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{formState.cardId ? (
|
|
||||||
<InlinePeriodPicker
|
|
||||||
period={formState.period}
|
|
||||||
onPeriodChange={(value) => onFieldChange("period", value)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!isCartaoSelected && showContaSelect ? (
|
{!isCartaoSelected && showContaSelect ? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full space-y-1",
|
"w-full space-y-1",
|
||||||
!isUpdateMode ? "md:w-1/2" : "md:w-full",
|
!isUpdateMode ? "md:w-1/2" : "md:w-full",
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<Label htmlFor="conta">Conta</Label>
|
|
||||||
<Select
|
|
||||||
value={formState.accountId}
|
|
||||||
onValueChange={(value) => onFieldChange("accountId", value)}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger id="conta" className="w-full">
|
<Label htmlFor="conta">Conta</Label>
|
||||||
<SelectValue placeholder="Selecione">
|
<Select
|
||||||
{formState.accountId &&
|
value={formState.accountId ?? ""}
|
||||||
(() => {
|
onValueChange={(value) => onFieldChange("accountId", value)}
|
||||||
const selectedOption = filteredContaOptions.find(
|
>
|
||||||
(opt) => opt.value === formState.accountId,
|
<SelectTrigger id="conta" className="w-full">
|
||||||
);
|
<SelectValue placeholder="Selecione">
|
||||||
return selectedOption ? (
|
{formState.accountId &&
|
||||||
|
(() => {
|
||||||
|
const selectedOption = filteredContaOptions.find(
|
||||||
|
(opt) => opt.value === formState.accountId,
|
||||||
|
);
|
||||||
|
return selectedOption ? (
|
||||||
|
<AccountCardSelectContent
|
||||||
|
label={selectedOption.label}
|
||||||
|
logo={selectedOption.logo}
|
||||||
|
isCartao={false}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{filteredContaOptions.length === 0 ? (
|
||||||
|
<div className="px-2 py-6 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Nenhuma conta cadastrada
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredContaOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
<AccountCardSelectContent
|
<AccountCardSelectContent
|
||||||
label={selectedOption.label}
|
label={option.label}
|
||||||
logo={selectedOption.logo}
|
logo={option.logo}
|
||||||
isCartao={false}
|
isCartao={false}
|
||||||
/>
|
/>
|
||||||
) : null;
|
</SelectItem>
|
||||||
})()}
|
))
|
||||||
</SelectValue>
|
)}
|
||||||
</SelectTrigger>
|
</SelectContent>
|
||||||
<SelectContent>
|
</Select>
|
||||||
{filteredContaOptions.length === 0 ? (
|
</div>
|
||||||
<div className="px-2 py-6 text-center">
|
) : null}
|
||||||
<p className="text-sm text-muted-foreground">
|
</div>
|
||||||
Nenhuma conta cadastrada
|
|
||||||
</p>
|
{showSettledToggle ? (
|
||||||
</div>
|
<div
|
||||||
) : (
|
className={cn(
|
||||||
filteredContaOptions.map((option) => (
|
"flex items-center justify-between rounded-lg border px-3 py-2.5 transition-colors",
|
||||||
<SelectItem key={option.value} value={option.value}>
|
formState.isSettled
|
||||||
<AccountCardSelectContent
|
? "border-success/20 bg-success/5"
|
||||||
label={option.label}
|
: "border-border bg-transparent",
|
||||||
logo={option.logo}
|
)}
|
||||||
isCartao={false}
|
>
|
||||||
/>
|
<div>
|
||||||
</SelectItem>
|
<p className="text-sm text-foreground text-left">
|
||||||
))
|
Marcar como pago
|
||||||
)}
|
</p>
|
||||||
</SelectContent>
|
<p className="text-xs text-muted-foreground text-left">
|
||||||
</Select>
|
Indica que o valor já foi pago.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => onFieldChange("isSettled", !formState.isSettled)}
|
||||||
|
aria-label={
|
||||||
|
formState.isSettled ? "Desfazer pagamento" : "Marcar como pago"
|
||||||
|
}
|
||||||
|
aria-pressed={Boolean(formState.isSettled)}
|
||||||
|
className={cn(
|
||||||
|
"transition-colors",
|
||||||
|
formState.isSettled
|
||||||
|
? "bg-success/10 text-success hover:bg-success/20 hover:text-success"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formState.isSettled ? (
|
||||||
|
<RiCheckboxCircleFill className="size-4" />
|
||||||
|
) : (
|
||||||
|
<RiCheckboxBlankCircleLine className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
|
||||||
import { cn } from "@/shared/utils/ui";
|
|
||||||
import type { SplitAndSettlementSectionProps } from "./transaction-dialog-types";
|
|
||||||
|
|
||||||
export function SplitAndSettlementSection({
|
|
||||||
formState,
|
|
||||||
onFieldChange,
|
|
||||||
showSettledToggle,
|
|
||||||
}: SplitAndSettlementSectionProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex w-full flex-col gap-2 md:flex-row">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"space-y-1",
|
|
||||||
showSettledToggle ? "md:w-1/2 md:pr-2" : "md:w-full",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-foreground">Dividir lançamento</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Atribuir parte do valor a outro pagador.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Checkbox
|
|
||||||
checked={formState.isSplit}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onFieldChange("isSplit", Boolean(checked))
|
|
||||||
}
|
|
||||||
aria-label="Dividir lançamento"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showSettledToggle ? (
|
|
||||||
<div className="space-y-1 md:w-1/2 md:pr-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-foreground">Marcar como pago</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Indica que o valor já foi pago.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Checkbox
|
|
||||||
checked={Boolean(formState.isSettled)}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onFieldChange("isSettled", Boolean(checked))
|
|
||||||
}
|
|
||||||
aria-label="Marcar como concluído"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -34,6 +34,8 @@ export interface TransactionDialogProps {
|
|||||||
maxSizeMb?: number;
|
maxSizeMb?: number;
|
||||||
onBulkEditRequest?: (data: {
|
onBulkEditRequest?: (data: {
|
||||||
id: string;
|
id: string;
|
||||||
|
purchaseDate: string;
|
||||||
|
period: string;
|
||||||
name: string;
|
name: string;
|
||||||
categoryId: string | undefined;
|
categoryId: string | undefined;
|
||||||
note: string;
|
note: string;
|
||||||
@@ -71,10 +73,6 @@ export interface CategorySectionProps extends BaseFieldSectionProps {
|
|||||||
hideTransactionType?: boolean;
|
hideTransactionType?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SplitAndSettlementSectionProps extends BaseFieldSectionProps {
|
|
||||||
showSettledToggle: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PayerSectionProps extends BaseFieldSectionProps {
|
export interface PayerSectionProps extends BaseFieldSectionProps {
|
||||||
payerOptions: SelectOption[];
|
payerOptions: SelectOption[];
|
||||||
secondaryPayerOptions: SelectOption[];
|
secondaryPayerOptions: SelectOption[];
|
||||||
@@ -87,6 +85,7 @@ export interface PaymentMethodSectionProps extends BaseFieldSectionProps {
|
|||||||
isUpdateMode: boolean;
|
isUpdateMode: boolean;
|
||||||
disablePaymentMethod: boolean;
|
disablePaymentMethod: boolean;
|
||||||
disableCardSelect: boolean;
|
disableCardSelect: boolean;
|
||||||
|
showSettledToggle: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BoletoFieldsSectionProps extends BaseFieldSectionProps {
|
export interface BoletoFieldsSectionProps extends BaseFieldSectionProps {
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ import { ConditionSection } from "./condition-section";
|
|||||||
import { NoteSection } from "./note-section";
|
import { NoteSection } from "./note-section";
|
||||||
import { PayerSection } from "./payer-section";
|
import { PayerSection } from "./payer-section";
|
||||||
import { PaymentMethodSection } from "./payment-method-section";
|
import { PaymentMethodSection } from "./payment-method-section";
|
||||||
import { SplitAndSettlementSection } from "./split-settlement-section";
|
|
||||||
import type {
|
import type {
|
||||||
FormState,
|
FormState,
|
||||||
TransactionDialogProps,
|
TransactionDialogProps,
|
||||||
@@ -99,7 +98,7 @@ export function TransactionDialog({
|
|||||||
);
|
);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||||
const [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]);
|
const [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]);
|
||||||
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]);
|
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]);
|
||||||
|
|
||||||
@@ -139,7 +138,7 @@ export function TransactionDialog({
|
|||||||
|
|
||||||
setFormState(initial);
|
setFormState(initial);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
setPendingFile(null);
|
setPendingFiles([]);
|
||||||
setPendingDetachIds([]);
|
setPendingDetachIds([]);
|
||||||
setPendingUploadFiles([]);
|
setPendingUploadFiles([]);
|
||||||
}
|
}
|
||||||
@@ -330,27 +329,29 @@ export function TransactionDialog({
|
|||||||
const result = await createTransactionAction(payload);
|
const result = await createTransactionAction(payload);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (pendingFile && result.data?.ids?.length) {
|
if (pendingFiles.length > 0 && result.data?.ids?.length) {
|
||||||
const firstId = result.data.ids[0];
|
const firstId = result.data.ids[0];
|
||||||
const isNewSeries =
|
const isNewSeries =
|
||||||
formState.condition === "Parcelado" ||
|
formState.condition === "Parcelado" ||
|
||||||
formState.condition === "Recorrente";
|
formState.condition === "Recorrente";
|
||||||
const presign = await getPresignedUploadUrlAction({
|
for (const file of pendingFiles) {
|
||||||
fileName: pendingFile.name,
|
const presign = await getPresignedUploadUrlAction({
|
||||||
mimeType: pendingFile.type,
|
fileName: file.name,
|
||||||
fileSize: pendingFile.size,
|
mimeType: file.type,
|
||||||
transactionId: firstId,
|
fileSize: file.size,
|
||||||
});
|
transactionId: firstId,
|
||||||
if (presign.success) {
|
|
||||||
await fetch(presign.presignedUrl, {
|
|
||||||
method: "PUT",
|
|
||||||
body: pendingFile,
|
|
||||||
headers: { "Content-Type": pendingFile.type },
|
|
||||||
});
|
|
||||||
await confirmAttachmentUploadAction({
|
|
||||||
uploadToken: presign.uploadToken,
|
|
||||||
scope: isNewSeries ? "all" : "current",
|
|
||||||
});
|
});
|
||||||
|
if (presign.success) {
|
||||||
|
await fetch(presign.presignedUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
body: file,
|
||||||
|
headers: { "Content-Type": file.type },
|
||||||
|
});
|
||||||
|
await confirmAttachmentUploadAction({
|
||||||
|
uploadToken: presign.uploadToken,
|
||||||
|
scope: isNewSeries ? "all" : "current",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
@@ -371,6 +372,8 @@ export function TransactionDialog({
|
|||||||
// o upload após o escopo ser escolhido (sem upload antecipado ao S3)
|
// o upload após o escopo ser escolhido (sem upload antecipado ao S3)
|
||||||
onBulkEditRequest({
|
onBulkEditRequest({
|
||||||
id: transaction?.id ?? "",
|
id: transaction?.id ?? "",
|
||||||
|
purchaseDate: formState.purchaseDate,
|
||||||
|
period: formState.period,
|
||||||
name: formState.name.trim(),
|
name: formState.name.trim(),
|
||||||
categoryId: formState.categoryId,
|
categoryId: formState.categoryId,
|
||||||
note: formState.note.trim() || "",
|
note: formState.note.trim() || "",
|
||||||
@@ -493,30 +496,30 @@ export function TransactionDialog({
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
noValidate
|
noValidate
|
||||||
>
|
>
|
||||||
<div className="min-w-0 space-y-3 -mx-6 max-h-[90vh] overflow-x-hidden overflow-y-auto px-6 pb-1">
|
<div className="min-w-0 -mx-6 max-h-[90vh] overflow-x-hidden overflow-y-auto px-6 pb-1">
|
||||||
<BasicFieldsSection
|
{/* Detalhes */}
|
||||||
formState={formState}
|
<div className="space-y-3">
|
||||||
onFieldChange={handleFieldChange}
|
<BasicFieldsSection
|
||||||
estabelecimentos={estabelecimentos}
|
formState={formState}
|
||||||
/>
|
onFieldChange={handleFieldChange}
|
||||||
|
estabelecimentos={estabelecimentos}
|
||||||
|
/>
|
||||||
|
|
||||||
<CategorySection
|
<CategorySection
|
||||||
formState={formState}
|
formState={formState}
|
||||||
onFieldChange={handleFieldChange}
|
onFieldChange={handleFieldChange}
|
||||||
categoryOptions={categoryOptions}
|
categoryOptions={categoryOptions}
|
||||||
categoryGroups={categoryGroups}
|
categoryGroups={categoryGroups}
|
||||||
isUpdateMode={isUpdateMode}
|
isUpdateMode={isUpdateMode}
|
||||||
hideTransactionType={
|
hideTransactionType={
|
||||||
Boolean(isNewWithType) && !forceShowTransactionType
|
Boolean(isNewWithType) && !forceShowTransactionType
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SplitAndSettlementSection
|
<div className="border-t border-border/40 my-3" />
|
||||||
formState={formState}
|
|
||||||
onFieldChange={handleFieldChange}
|
|
||||||
showSettledToggle={showSettledToggle}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
{/* Pagador */}
|
||||||
<PayerSection
|
<PayerSection
|
||||||
formState={formState}
|
formState={formState}
|
||||||
onFieldChange={handleFieldChange}
|
onFieldChange={handleFieldChange}
|
||||||
@@ -525,56 +528,66 @@ export function TransactionDialog({
|
|||||||
totalAmount={totalAmount}
|
totalAmount={totalAmount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PaymentMethodSection
|
<div className="border-t border-border/40 my-3" />
|
||||||
formState={formState}
|
|
||||||
onFieldChange={handleFieldChange}
|
|
||||||
accountOptions={accountOptions}
|
|
||||||
cardOptions={cardOptions}
|
|
||||||
isUpdateMode={isUpdateMode}
|
|
||||||
disablePaymentMethod={disablePaymentMethod}
|
|
||||||
disableCardSelect={disableCardSelect}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showDueDate ? (
|
{/* Pagamento */}
|
||||||
<BoletoFieldsSection
|
<div className="space-y-3">
|
||||||
|
<PaymentMethodSection
|
||||||
formState={formState}
|
formState={formState}
|
||||||
onFieldChange={handleFieldChange}
|
onFieldChange={handleFieldChange}
|
||||||
showPaymentDate={showPaymentDate}
|
accountOptions={accountOptions}
|
||||||
|
cardOptions={cardOptions}
|
||||||
|
isUpdateMode={isUpdateMode}
|
||||||
|
disablePaymentMethod={disablePaymentMethod}
|
||||||
|
disableCardSelect={disableCardSelect}
|
||||||
|
showSettledToggle={showSettledToggle}
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{isUpdateMode ? (
|
{showDueDate ? (
|
||||||
<>
|
<BoletoFieldsSection
|
||||||
<NoteSection
|
|
||||||
formState={formState}
|
formState={formState}
|
||||||
onFieldChange={handleFieldChange}
|
onFieldChange={handleFieldChange}
|
||||||
|
showPaymentDate={showPaymentDate}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-2">
|
) : null}
|
||||||
<Label className="text-xs font-medium leading-none">
|
</div>
|
||||||
Anexos
|
|
||||||
</Label>
|
{/* Extras */}
|
||||||
<AttachmentSection
|
{isUpdateMode ? (
|
||||||
transactionId={transaction?.id ?? ""}
|
<>
|
||||||
maxSizeMb={maxSizeMb}
|
<div className="border-t border-border/40 my-3" />
|
||||||
pendingDetachIds={pendingDetachIds}
|
<div className="space-y-3">
|
||||||
onPendingDetach={(id) =>
|
<NoteSection
|
||||||
setPendingDetachIds((prev) => [...prev, id])
|
formState={formState}
|
||||||
}
|
onFieldChange={handleFieldChange}
|
||||||
onUndoPendingDetach={(id) =>
|
|
||||||
setPendingDetachIds((prev) =>
|
|
||||||
prev.filter((x) => x !== id),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
pendingUploadFiles={pendingUploadFiles}
|
|
||||||
onPendingUpload={(file) =>
|
|
||||||
setPendingUploadFiles((prev) => [...prev, file])
|
|
||||||
}
|
|
||||||
onCancelPendingUpload={(file) =>
|
|
||||||
setPendingUploadFiles((prev) =>
|
|
||||||
prev.filter((f) => f !== file),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium leading-none">
|
||||||
|
Anexos
|
||||||
|
</Label>
|
||||||
|
<AttachmentSection
|
||||||
|
transactionId={transaction?.id ?? ""}
|
||||||
|
maxSizeMb={maxSizeMb}
|
||||||
|
pendingDetachIds={pendingDetachIds}
|
||||||
|
onPendingDetach={(id) =>
|
||||||
|
setPendingDetachIds((prev) => [...prev, id])
|
||||||
|
}
|
||||||
|
onUndoPendingDetach={(id) =>
|
||||||
|
setPendingDetachIds((prev) =>
|
||||||
|
prev.filter((x) => x !== id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pendingUploadFiles={pendingUploadFiles}
|
||||||
|
onPendingUpload={(file) =>
|
||||||
|
setPendingUploadFiles((prev) => [...prev, file])
|
||||||
|
}
|
||||||
|
onCancelPendingUpload={(file) =>
|
||||||
|
setPendingUploadFiles((prev) =>
|
||||||
|
prev.filter((f) => f !== file),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -598,8 +611,11 @@ export function TransactionDialog({
|
|||||||
onFieldChange={handleFieldChange}
|
onFieldChange={handleFieldChange}
|
||||||
/>
|
/>
|
||||||
<AttachmentFilePicker
|
<AttachmentFilePicker
|
||||||
file={pendingFile}
|
files={pendingFiles}
|
||||||
onChange={setPendingFile}
|
onAdd={(file) => setPendingFiles((prev) => [...prev, file])}
|
||||||
|
onRemove={(file) =>
|
||||||
|
setPendingFiles((prev) => prev.filter((f) => f !== file))
|
||||||
|
}
|
||||||
maxSizeMb={maxSizeMb}
|
maxSizeMb={maxSizeMb}
|
||||||
/>
|
/>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ export function TransactionsPage({
|
|||||||
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||||
const [pendingEditData, setPendingEditData] = useState<{
|
const [pendingEditData, setPendingEditData] = useState<{
|
||||||
id: string;
|
id: string;
|
||||||
|
purchaseDate: string;
|
||||||
|
period: string;
|
||||||
name: string;
|
name: string;
|
||||||
categoryId: string | undefined;
|
categoryId: string | undefined;
|
||||||
note: string;
|
note: string;
|
||||||
@@ -245,6 +247,8 @@ export function TransactionsPage({
|
|||||||
|
|
||||||
const handleBulkEditRequest = (data: {
|
const handleBulkEditRequest = (data: {
|
||||||
id: string;
|
id: string;
|
||||||
|
purchaseDate: string;
|
||||||
|
period: string;
|
||||||
name: string;
|
name: string;
|
||||||
categoryId: string | undefined;
|
categoryId: string | undefined;
|
||||||
note: string;
|
note: string;
|
||||||
@@ -278,6 +282,8 @@ export function TransactionsPage({
|
|||||||
const result = await updateTransactionBulkAction({
|
const result = await updateTransactionBulkAction({
|
||||||
id: pendingEditData.id,
|
id: pendingEditData.id,
|
||||||
scope,
|
scope,
|
||||||
|
purchaseDate: pendingEditData.purchaseDate,
|
||||||
|
period: pendingEditData.period,
|
||||||
name: pendingEditData.name,
|
name: pendingEditData.name,
|
||||||
categoryId: pendingEditData.categoryId,
|
categoryId: pendingEditData.categoryId,
|
||||||
note: pendingEditData.note,
|
note: pendingEditData.note,
|
||||||
|
|||||||
@@ -60,11 +60,6 @@ export function deriveCreditCardPeriod(
|
|||||||
return period;
|
return period;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Split type for dividing transactions between payers
|
|
||||||
*/
|
|
||||||
export type SplitType = "equal" | "60-40" | "70-30" | "80-20" | "custom";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Form state type for lancamento dialog
|
* Form state type for lancamento dialog
|
||||||
*/
|
*/
|
||||||
@@ -79,7 +74,6 @@ export type TransactionFormState = {
|
|||||||
payerId: string | undefined;
|
payerId: string | undefined;
|
||||||
secondaryPayerId: string | undefined;
|
secondaryPayerId: string | undefined;
|
||||||
isSplit: boolean;
|
isSplit: boolean;
|
||||||
splitType: SplitType;
|
|
||||||
primarySplitAmount: string;
|
primarySplitAmount: string;
|
||||||
secondarySplitAmount: string;
|
secondarySplitAmount: string;
|
||||||
accountId: string | undefined;
|
accountId: string | undefined;
|
||||||
@@ -117,7 +111,7 @@ export function buildTransactionInitialState(
|
|||||||
): TransactionFormState {
|
): TransactionFormState {
|
||||||
const purchaseDate = transaction?.purchaseDate
|
const purchaseDate = transaction?.purchaseDate
|
||||||
? transaction.purchaseDate.slice(0, 10)
|
? transaction.purchaseDate.slice(0, 10)
|
||||||
: (overrides?.defaultPurchaseDate ?? "");
|
: (overrides?.defaultPurchaseDate ?? getTodayDateString());
|
||||||
|
|
||||||
const paymentMethod =
|
const paymentMethod =
|
||||||
transaction?.paymentMethod ??
|
transaction?.paymentMethod ??
|
||||||
@@ -176,7 +170,7 @@ export function buildTransactionInitialState(
|
|||||||
payerId: fallbackPayerId ?? undefined,
|
payerId: fallbackPayerId ?? undefined,
|
||||||
secondaryPayerId: undefined,
|
secondaryPayerId: undefined,
|
||||||
isSplit: false,
|
isSplit: false,
|
||||||
splitType: "equal",
|
|
||||||
primarySplitAmount: "",
|
primarySplitAmount: "",
|
||||||
secondarySplitAmount: "",
|
secondarySplitAmount: "",
|
||||||
accountId:
|
accountId:
|
||||||
@@ -210,39 +204,6 @@ export function buildTransactionInitialState(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Split presets with their percentages
|
|
||||||
*/
|
|
||||||
const SPLIT_PRESETS: Record<SplitType, { primary: number; secondary: number }> =
|
|
||||||
{
|
|
||||||
equal: { primary: 50, secondary: 50 },
|
|
||||||
"60-40": { primary: 60, secondary: 40 },
|
|
||||||
"70-30": { primary: 70, secondary: 30 },
|
|
||||||
"80-20": { primary: 80, secondary: 20 },
|
|
||||||
custom: { primary: 50, secondary: 50 },
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates split amounts based on total and split type
|
|
||||||
*/
|
|
||||||
export function calculateSplitAmounts(
|
|
||||||
totalAmount: number,
|
|
||||||
splitType: SplitType,
|
|
||||||
): { primary: string; secondary: string } {
|
|
||||||
if (totalAmount <= 0) {
|
|
||||||
return { primary: "", secondary: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const preset = SPLIT_PRESETS[splitType];
|
|
||||||
const primaryAmount = (totalAmount * preset.primary) / 100;
|
|
||||||
const secondaryAmount = totalAmount - primaryAmount;
|
|
||||||
|
|
||||||
return {
|
|
||||||
primary: primaryAmount.toFixed(2),
|
|
||||||
secondary: secondaryAmount.toFixed(2),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies field dependencies when form state changes
|
* Applies field dependencies when form state changes
|
||||||
* This function encapsulates the business logic for field interdependencies
|
* This function encapsulates the business logic for field interdependencies
|
||||||
@@ -348,7 +309,6 @@ export function applyFieldDependencies(
|
|||||||
// When split is disabled, clear secondary pagador and split fields
|
// When split is disabled, clear secondary pagador and split fields
|
||||||
if (key === "isSplit" && value === false) {
|
if (key === "isSplit" && value === false) {
|
||||||
updates.secondaryPayerId = undefined;
|
updates.secondaryPayerId = undefined;
|
||||||
updates.splitType = "equal";
|
|
||||||
updates.primarySplitAmount = "";
|
updates.primarySplitAmount = "";
|
||||||
updates.secondarySplitAmount = "";
|
updates.secondarySplitAmount = "";
|
||||||
}
|
}
|
||||||
@@ -367,12 +327,9 @@ export function applyFieldDependencies(
|
|||||||
if (key === "amount" && typeof value === "string" && currentState.isSplit) {
|
if (key === "amount" && typeof value === "string" && currentState.isSplit) {
|
||||||
const totalAmount = Number.parseFloat(value) || 0;
|
const totalAmount = Number.parseFloat(value) || 0;
|
||||||
if (totalAmount > 0) {
|
if (totalAmount > 0) {
|
||||||
const splitAmounts = calculateSplitAmounts(
|
const half = (totalAmount / 2).toFixed(2);
|
||||||
totalAmount,
|
updates.primarySplitAmount = half;
|
||||||
currentState.splitType,
|
updates.secondarySplitAmount = half;
|
||||||
);
|
|
||||||
updates.primarySplitAmount = splitAmounts.primary;
|
|
||||||
updates.secondarySplitAmount = splitAmounts.secondary;
|
|
||||||
} else {
|
} else {
|
||||||
updates.primarySplitAmount = "";
|
updates.primarySplitAmount = "";
|
||||||
updates.secondarySplitAmount = "";
|
updates.secondarySplitAmount = "";
|
||||||
|
|||||||
Reference in New Issue
Block a user