mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 02:51:46 +00:00
feat(transactions): edição cooperativa e visibilidade de divisões
Adiciona splitGroupId para vincular as duas shares de um lançamento dividido (schema + índice + migration 0026). Habilita: - Edição de par dividido com escolha de escopo (apenas este lado ou ambos) via novo SplitPairDialog e updateTransactionSplitPairAction - Filtro "Somente divididos" (isDivided) na tabela de lançamentos - Visibilidade de anexos para pessoas com acesso compartilhado via payerShares; upload e detach em massa expandem para shares irmãs - Cópia independente de anexos no fluxo "Importar para Minha Conta" (novo fileKey, novo userId, S3 CopyObject) com seção read-only "Anexos que serão copiados" no dialog de importação - Ícone de clipe na tabela de lançamentos da página da pessoa via EXISTS em fetchPagadorLancamentos Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2
drizzle/0026_bored_eternity.sql
Normal file
2
drizzle/0026_bored_eternity.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "lancamentos" ADD COLUMN "split_group_id" uuid;--> statement-breakpoint
|
||||||
|
CREATE INDEX "lancamentos_user_id_split_group_id_idx" ON "lancamentos" USING btree ("user_id","split_group_id");
|
||||||
2916
drizzle/meta/0026_snapshot.json
Normal file
2916
drizzle/meta/0026_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -183,6 +183,13 @@
|
|||||||
"when": 1776351838548,
|
"when": 1776351838548,
|
||||||
"tag": "0025_burly_colonel_america",
|
"tag": "0025_burly_colonel_america",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777042423451,
|
||||||
|
"tag": "0026_bored_eternity",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -670,6 +670,7 @@ export const transactions = pgTable(
|
|||||||
onUpdate: "cascade",
|
onUpdate: "cascade",
|
||||||
}),
|
}),
|
||||||
seriesId: uuid("series_id"),
|
seriesId: uuid("series_id"),
|
||||||
|
splitGroupId: uuid("split_group_id"),
|
||||||
transferId: uuid("transfer_id"),
|
transferId: uuid("transfer_id"),
|
||||||
ofxFitId: text("ofx_fit_id"),
|
ofxFitId: text("ofx_fit_id"),
|
||||||
importBatchId: text("import_batch_id"),
|
importBatchId: text("import_batch_id"),
|
||||||
@@ -702,6 +703,11 @@ export const transactions = pgTable(
|
|||||||
),
|
),
|
||||||
// Índice para buscar parcelas de uma série
|
// Índice para buscar parcelas de uma série
|
||||||
seriesIdIdx: index("lancamentos_series_id_idx").on(table.seriesId),
|
seriesIdIdx: index("lancamentos_series_id_idx").on(table.seriesId),
|
||||||
|
// Índice para buscar shares de um split (userId + splitGroupId)
|
||||||
|
userIdSplitGroupIdIdx: index("lancamentos_user_id_split_group_id_idx").on(
|
||||||
|
table.userId,
|
||||||
|
table.splitGroupId,
|
||||||
|
),
|
||||||
// Índice para buscar transferências relacionadas
|
// Índice para buscar transferências relacionadas
|
||||||
transferIdIdx: index("lancamentos_transfer_id_idx").on(table.transferId),
|
transferIdIdx: index("lancamentos_transfer_id_idx").on(table.transferId),
|
||||||
// Índice para filtrar por condição (aberto, realizado, cancelado)
|
// Índice para filtrar por condição (aberto, realizado, cancelado)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { and, desc, eq, type SQL } from "drizzle-orm";
|
import { and, desc, eq, type SQL, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
cards,
|
cards,
|
||||||
categories,
|
categories,
|
||||||
financialAccounts,
|
financialAccounts,
|
||||||
payerShares,
|
payerShares,
|
||||||
payers,
|
payers,
|
||||||
|
transactionAttachments,
|
||||||
transactions,
|
transactions,
|
||||||
user as usersTable,
|
user as usersTable,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
@@ -73,6 +74,10 @@ export async function fetchPagadorLancamentos(filters: SQL[]) {
|
|||||||
financialAccount: financialAccounts,
|
financialAccount: financialAccounts,
|
||||||
card: cards,
|
card: cards,
|
||||||
category: categories,
|
category: categories,
|
||||||
|
hasAttachments: sql<boolean>`EXISTS (
|
||||||
|
SELECT 1 FROM ${transactionAttachments}
|
||||||
|
WHERE ${transactionAttachments.transactionId} = ${transactions.id}
|
||||||
|
)`,
|
||||||
})
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.leftJoin(payers, eq(transactions.payerId, payers.id))
|
.leftJoin(payers, eq(transactions.payerId, payers.id))
|
||||||
@@ -85,12 +90,12 @@ export async function fetchPagadorLancamentos(filters: SQL[]) {
|
|||||||
.where(and(...filters))
|
.where(and(...filters))
|
||||||
.orderBy(desc(transactions.purchaseDate), desc(transactions.createdAt));
|
.orderBy(desc(transactions.purchaseDate), desc(transactions.createdAt));
|
||||||
|
|
||||||
// Transformar resultado para o formato esperado
|
|
||||||
return transactionRows.map((row) => ({
|
return transactionRows.map((row) => ({
|
||||||
...row.transaction,
|
...row.transaction,
|
||||||
payer: row.payer,
|
payer: row.payer,
|
||||||
financialAccount: row.financialAccount,
|
financialAccount: row.financialAccount,
|
||||||
card: row.card,
|
card: row.card,
|
||||||
category: row.category,
|
category: row.category,
|
||||||
|
hasAttachments: row.hasAttachments,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
deleteTransactionAction as deleteTransactionActionImpl,
|
deleteTransactionAction as deleteTransactionActionImpl,
|
||||||
toggleTransactionSettlementAction as toggleTransactionSettlementActionImpl,
|
toggleTransactionSettlementAction as toggleTransactionSettlementActionImpl,
|
||||||
updateTransactionAction as updateTransactionActionImpl,
|
updateTransactionAction as updateTransactionActionImpl,
|
||||||
|
updateTransactionSplitPairAction as updateTransactionSplitPairActionImpl,
|
||||||
} from "./actions/single-actions";
|
} from "./actions/single-actions";
|
||||||
|
|
||||||
export async function createTransactionAction(
|
export async function createTransactionAction(
|
||||||
@@ -62,6 +63,12 @@ export async function deleteMultipleTransactionsAction(
|
|||||||
return deleteMultipleTransactionsActionImpl(...args);
|
return deleteMultipleTransactionsActionImpl(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateTransactionSplitPairAction(
|
||||||
|
...args: Parameters<typeof updateTransactionSplitPairActionImpl>
|
||||||
|
): ReturnType<typeof updateTransactionSplitPairActionImpl> {
|
||||||
|
return updateTransactionSplitPairActionImpl(...args);
|
||||||
|
}
|
||||||
|
|
||||||
export async function exportTransactionsDataAction(
|
export async function exportTransactionsDataAction(
|
||||||
...args: Parameters<typeof exportTransactionsDataActionImpl>
|
...args: Parameters<typeof exportTransactionsDataActionImpl>
|
||||||
): ReturnType<typeof exportTransactionsDataActionImpl> {
|
): ReturnType<typeof exportTransactionsDataActionImpl> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import crypto, { randomUUID } from "node:crypto";
|
import crypto, { randomUUID } from "node:crypto";
|
||||||
import { and, count, eq, inArray } from "drizzle-orm";
|
import { and, count, eq, inArray, isNotNull } from "drizzle-orm";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
import { attachments, transactionAttachments, transactions } from "@/db/schema";
|
import { attachments, transactionAttachments, transactions } from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
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 {
|
import {
|
||||||
createPresignedGetUrl,
|
|
||||||
createPresignedPutUrl,
|
createPresignedPutUrl,
|
||||||
deleteS3Object,
|
deleteS3Object,
|
||||||
headS3Object,
|
headS3Object,
|
||||||
@@ -98,6 +97,46 @@ function signUploadToken(payload: UploadTokenPayload): string {
|
|||||||
return `${encodedPayload}.${signature}`;
|
return `${encodedPayload}.${signature}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expandSplitSiblings(
|
||||||
|
transactionIds: string[],
|
||||||
|
userId: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (transactionIds.length === 0) return transactionIds;
|
||||||
|
|
||||||
|
const groupRows = await db
|
||||||
|
.select({ splitGroupId: transactions.splitGroupId })
|
||||||
|
.from(transactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(transactions.id, transactionIds),
|
||||||
|
eq(transactions.userId, userId),
|
||||||
|
isNotNull(transactions.splitGroupId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const splitGroupIds = [
|
||||||
|
...new Set(
|
||||||
|
groupRows
|
||||||
|
.map((r) => r.splitGroupId)
|
||||||
|
.filter((v): v is string => v !== null),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (splitGroupIds.length === 0) return transactionIds;
|
||||||
|
|
||||||
|
const siblingRows = await db
|
||||||
|
.select({ id: transactions.id })
|
||||||
|
.from(transactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(transactions.splitGroupId, splitGroupIds),
|
||||||
|
eq(transactions.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [...new Set([...transactionIds, ...siblingRows.map((r) => r.id)])];
|
||||||
|
}
|
||||||
|
|
||||||
function verifyUploadToken(token: string): UploadTokenPayload | null {
|
function verifyUploadToken(token: string): UploadTokenPayload | null {
|
||||||
try {
|
try {
|
||||||
const [encodedPayload, signature] = token.split(".");
|
const [encodedPayload, signature] = token.split(".");
|
||||||
@@ -281,6 +320,8 @@ export async function confirmAttachmentUploadAction(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
transactionIds = await expandSplitSiblings(transactionIds, user.id);
|
||||||
|
|
||||||
await db.insert(transactionAttachments).values(
|
await db.insert(transactionAttachments).values(
|
||||||
transactionIds.map((tid) => ({
|
transactionIds.map((tid) => ({
|
||||||
transactionId: tid,
|
transactionId: tid,
|
||||||
@@ -359,69 +400,6 @@ export async function detachTransactionAttachmentAction(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchTransactionAttachmentsAction(
|
|
||||||
transactionId: string,
|
|
||||||
): Promise<
|
|
||||||
Array<{
|
|
||||||
attachmentId: string;
|
|
||||||
fileName: string;
|
|
||||||
fileSize: number;
|
|
||||||
mimeType: string;
|
|
||||||
createdAt: Date;
|
|
||||||
url: string;
|
|
||||||
}>
|
|
||||||
> {
|
|
||||||
const user = await getUser();
|
|
||||||
|
|
||||||
const [transaction] = await db
|
|
||||||
.select({ id: transactions.id })
|
|
||||||
.from(transactions)
|
|
||||||
.where(
|
|
||||||
and(eq(transactions.id, transactionId), eq(transactions.userId, user.id)),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!transaction) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await db
|
|
||||||
.select({
|
|
||||||
attachmentId: transactionAttachments.attachmentId,
|
|
||||||
fileName: attachments.fileName,
|
|
||||||
fileSize: attachments.fileSize,
|
|
||||||
mimeType: attachments.mimeType,
|
|
||||||
fileKey: attachments.fileKey,
|
|
||||||
createdAt: attachments.createdAt,
|
|
||||||
})
|
|
||||||
.from(transactionAttachments)
|
|
||||||
.innerJoin(
|
|
||||||
transactions,
|
|
||||||
and(
|
|
||||||
eq(transactionAttachments.transactionId, transactions.id),
|
|
||||||
eq(transactions.userId, user.id),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.innerJoin(
|
|
||||||
attachments,
|
|
||||||
and(
|
|
||||||
eq(transactionAttachments.attachmentId, attachments.id),
|
|
||||||
eq(attachments.userId, user.id),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.where(eq(transactionAttachments.transactionId, transactionId));
|
|
||||||
|
|
||||||
return Promise.all(
|
|
||||||
rows.map(async (row) => ({
|
|
||||||
attachmentId: row.attachmentId,
|
|
||||||
fileName: row.fileName,
|
|
||||||
fileSize: row.fileSize,
|
|
||||||
mimeType: row.mimeType,
|
|
||||||
createdAt: row.createdAt,
|
|
||||||
url: await createPresignedGetUrl(row.fileKey),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const detachBulkSchema = z.object({
|
const detachBulkSchema = z.object({
|
||||||
attachmentId: z.string().uuid(),
|
attachmentId: z.string().uuid(),
|
||||||
transactionId: z.string().uuid(),
|
transactionId: z.string().uuid(),
|
||||||
@@ -497,6 +475,11 @@ export async function detachAttachmentBulkAction(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
targetTransactionIds = await expandSplitSiblings(
|
||||||
|
targetTransactionIds,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
|
||||||
if (targetTransactionIds.length > 0) {
|
if (targetTransactionIds.length > 0) {
|
||||||
await db
|
await db
|
||||||
.delete(transactionAttachments)
|
.delete(transactionAttachments)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
@@ -394,7 +395,11 @@ const refineLancamento = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createSchema = baseFields.superRefine(refineLancamento);
|
export const createSchema = baseFields
|
||||||
|
.extend({
|
||||||
|
importFromTransactionId: uuidSchema("Lançamento fonte").optional(),
|
||||||
|
})
|
||||||
|
.superRefine(refineLancamento);
|
||||||
export const updateSchema = baseFields
|
export const updateSchema = baseFields
|
||||||
.extend({
|
.extend({
|
||||||
id: uuidSchema("Lançamento"),
|
id: uuidSchema("Lançamento"),
|
||||||
@@ -544,6 +549,7 @@ export const buildLancamentoRecords = ({
|
|||||||
seriesId,
|
seriesId,
|
||||||
}: BuildTransactionRecordsParams): TransactionInsert[] => {
|
}: BuildTransactionRecordsParams): TransactionInsert[] => {
|
||||||
const records: TransactionInsert[] = [];
|
const records: TransactionInsert[] = [];
|
||||||
|
const isSplit = (data.isSplit ?? false) && shares.length > 1;
|
||||||
|
|
||||||
const basePayload = {
|
const basePayload = {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
@@ -562,6 +568,8 @@ export const buildLancamentoRecords = ({
|
|||||||
seriesId,
|
seriesId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cycleSplitGroupId = () => (isSplit ? randomUUID() : null);
|
||||||
|
|
||||||
const resolveSettledValue = (cycleIndex: number) => {
|
const resolveSettledValue = (cycleIndex: number) => {
|
||||||
if (shouldNullifySettled) {
|
if (shouldNullifySettled) {
|
||||||
return null;
|
return null;
|
||||||
@@ -588,6 +596,7 @@ export const buildLancamentoRecords = ({
|
|||||||
const installmentDueDate = dueDate
|
const installmentDueDate = dueDate
|
||||||
? addMonthsToDate(dueDate, installment)
|
? addMonthsToDate(dueDate, installment)
|
||||||
: null;
|
: null;
|
||||||
|
const splitGroupId = cycleSplitGroupId();
|
||||||
|
|
||||||
shares.forEach((share, shareIndex) => {
|
shares.forEach((share, shareIndex) => {
|
||||||
const amountCents = amountsByShare[shareIndex]?.[installment] ?? 0;
|
const amountCents = amountsByShare[shareIndex]?.[installment] ?? 0;
|
||||||
@@ -603,6 +612,7 @@ export const buildLancamentoRecords = ({
|
|||||||
currentInstallment: installment + 1,
|
currentInstallment: installment + 1,
|
||||||
recurrenceCount: null,
|
recurrenceCount: null,
|
||||||
dueDate: installmentDueDate,
|
dueDate: installmentDueDate,
|
||||||
|
splitGroupId,
|
||||||
boletoPaymentDate:
|
boletoPaymentDate:
|
||||||
data.paymentMethod === "Boleto" && settled
|
data.paymentMethod === "Boleto" && settled
|
||||||
? boletoPaymentDate
|
? boletoPaymentDate
|
||||||
@@ -623,6 +633,7 @@ export const buildLancamentoRecords = ({
|
|||||||
const recurrenceDueDate = dueDate
|
const recurrenceDueDate = dueDate
|
||||||
? addMonthsToDate(dueDate, index)
|
? addMonthsToDate(dueDate, index)
|
||||||
: null;
|
: null;
|
||||||
|
const splitGroupId = cycleSplitGroupId();
|
||||||
|
|
||||||
shares.forEach((share) => {
|
shares.forEach((share) => {
|
||||||
const settled = resolveSettledValue(index);
|
const settled = resolveSettledValue(index);
|
||||||
@@ -635,6 +646,7 @@ export const buildLancamentoRecords = ({
|
|||||||
isSettled: settled,
|
isSettled: settled,
|
||||||
recurrenceCount: recurrenceTotal,
|
recurrenceCount: recurrenceTotal,
|
||||||
dueDate: recurrenceDueDate,
|
dueDate: recurrenceDueDate,
|
||||||
|
splitGroupId,
|
||||||
boletoPaymentDate:
|
boletoPaymentDate:
|
||||||
data.paymentMethod === "Boleto" && settled
|
data.paymentMethod === "Boleto" && settled
|
||||||
? boletoPaymentDate
|
? boletoPaymentDate
|
||||||
@@ -646,6 +658,8 @@ export const buildLancamentoRecords = ({
|
|||||||
return records;
|
return records;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const splitGroupId = cycleSplitGroupId();
|
||||||
|
|
||||||
shares.forEach((share) => {
|
shares.forEach((share) => {
|
||||||
const settled = resolveSettledValue(0);
|
const settled = resolveSettledValue(0);
|
||||||
records.push({
|
records.push({
|
||||||
@@ -656,6 +670,7 @@ export const buildLancamentoRecords = ({
|
|||||||
period,
|
period,
|
||||||
isSettled: settled,
|
isSettled: settled,
|
||||||
dueDate,
|
dueDate,
|
||||||
|
splitGroupId,
|
||||||
boletoPaymentDate:
|
boletoPaymentDate:
|
||||||
data.paymentMethod === "Boleto" && settled ? boletoPaymentDate : null,
|
data.paymentMethod === "Boleto" && settled ? boletoPaymentDate : null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = z.object(
|
|||||||
searchFilter: z.string().nullable(),
|
searchFilter: z.string().nullable(),
|
||||||
settledFilter: z.string().nullable(),
|
settledFilter: z.string().nullable(),
|
||||||
attachmentFilter: z.string().nullable(),
|
attachmentFilter: z.string().nullable(),
|
||||||
|
dividedFilter: z.string().nullable(),
|
||||||
}),
|
}),
|
||||||
accountId: z.string().min(1).nullable().optional(),
|
accountId: z.string().min(1).nullable().optional(),
|
||||||
cardId: z.string().min(1).nullable().optional(),
|
cardId: z.string().min(1).nullable().optional(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, ne } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
attachments,
|
attachments,
|
||||||
financialAccounts,
|
financialAccounts,
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
getBusinessTodayDate,
|
getBusinessTodayDate,
|
||||||
parseLocalDateString,
|
parseLocalDateString,
|
||||||
} from "@/shared/utils/date";
|
} from "@/shared/utils/date";
|
||||||
|
import { copyAttachmentsForImport } from "../attachment-copy";
|
||||||
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
|
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
|
||||||
import {
|
import {
|
||||||
buildLancamentoRecords,
|
buildLancamentoRecords,
|
||||||
@@ -138,6 +139,14 @@ export async function createTransactionAction(
|
|||||||
.values(records)
|
.values(records)
|
||||||
.returning({ id: transactions.id });
|
.returning({ id: transactions.id });
|
||||||
|
|
||||||
|
if (data.importFromTransactionId && inserted.length > 0) {
|
||||||
|
await copyAttachmentsForImport({
|
||||||
|
sourceTransactionId: data.importFromTransactionId,
|
||||||
|
targetTransactionIds: inserted.map((r) => r.id),
|
||||||
|
targetUserId: user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const notificationEntries = buildEntriesByPayer(
|
const notificationEntries = buildEntriesByPayer(
|
||||||
records.map((record) => ({
|
records.map((record) => ({
|
||||||
payerId: record.payerId ?? null,
|
payerId: record.payerId ?? null,
|
||||||
@@ -437,6 +446,134 @@ export async function deleteTransactionAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateTransactionSplitPairAction(
|
||||||
|
input: UpdateInput,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = updateSchema.parse(input);
|
||||||
|
|
||||||
|
const ownershipError = await validateAllOwnership(user.id, {
|
||||||
|
payerId: data.payerId,
|
||||||
|
categoryId: data.categoryId,
|
||||||
|
accountId: data.accountId,
|
||||||
|
cardId: data.cardId,
|
||||||
|
});
|
||||||
|
if (ownershipError) {
|
||||||
|
return { success: false, error: ownershipError };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.query.transactions.findFirst({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
period: true,
|
||||||
|
transactionType: true,
|
||||||
|
condition: true,
|
||||||
|
paymentMethod: true,
|
||||||
|
accountId: true,
|
||||||
|
cardId: true,
|
||||||
|
categoryId: true,
|
||||||
|
splitGroupId: true,
|
||||||
|
},
|
||||||
|
where: and(
|
||||||
|
eq(transactions.id, data.id),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return { success: false, error: "Lançamento não encontrado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = resolvePeriod(data.purchaseDate, data.period);
|
||||||
|
const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1;
|
||||||
|
const amountCents = Math.round(Math.abs(data.amount) * 100);
|
||||||
|
const normalizedAmount = centsToDecimalString(amountCents * amountSign);
|
||||||
|
const normalizedSettled =
|
||||||
|
data.paymentMethod === "Cartão de crédito"
|
||||||
|
? null
|
||||||
|
: (data.isSettled ?? false);
|
||||||
|
const shouldSetBoletoPaymentDate =
|
||||||
|
data.paymentMethod === "Boleto" && Boolean(normalizedSettled);
|
||||||
|
const boletoPaymentDateValue = shouldSetBoletoPaymentDate
|
||||||
|
? data.boletoPaymentDate
|
||||||
|
? parseLocalDateString(data.boletoPaymentDate)
|
||||||
|
: getBusinessTodayDate()
|
||||||
|
: 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.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchaseDate = parseLocalDateString(data.purchaseDate);
|
||||||
|
const dueDate = data.dueDate ? parseLocalDateString(data.dueDate) : null;
|
||||||
|
|
||||||
|
const sharedPayload = {
|
||||||
|
name: data.name,
|
||||||
|
purchaseDate,
|
||||||
|
transactionType: data.transactionType,
|
||||||
|
condition: data.condition,
|
||||||
|
paymentMethod: data.paymentMethod,
|
||||||
|
accountId: data.accountId ?? null,
|
||||||
|
cardId: data.cardId ?? null,
|
||||||
|
categoryId: data.categoryId ?? null,
|
||||||
|
note: data.note ?? null,
|
||||||
|
dueDate,
|
||||||
|
period,
|
||||||
|
isSettled: normalizedSettled,
|
||||||
|
boletoPaymentDate: boletoPaymentDateValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.transaction(async (tx: typeof db) => {
|
||||||
|
await tx
|
||||||
|
.update(transactions)
|
||||||
|
.set({
|
||||||
|
...sharedPayload,
|
||||||
|
amount: normalizedAmount,
|
||||||
|
payerId: data.payerId ?? null,
|
||||||
|
installmentCount: data.installmentCount ?? null,
|
||||||
|
recurrenceCount: data.recurrenceCount ?? null,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(eq(transactions.id, data.id), eq(transactions.userId, user.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.splitGroupId) {
|
||||||
|
await tx
|
||||||
|
.update(transactions)
|
||||||
|
.set(sharedPayload)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactions.splitGroupId, existing.splitGroupId),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
ne(transactions.id, data.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidate(user.id);
|
||||||
|
return { success: true, message: "Lançamentos atualizados com sucesso." };
|
||||||
|
} catch (error) {
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function toggleTransactionSettlementAction(
|
export async function toggleTransactionSettlementAction(
|
||||||
input: ToggleSettlementInput,
|
input: ToggleSettlementInput,
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
|
|||||||
107
src/features/transactions/attachment-copy.ts
Normal file
107
src/features/transactions/attachment-copy.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { CopyObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { attachments, transactionAttachments, transactions } from "@/db/schema";
|
||||||
|
import { db } from "@/shared/lib/db";
|
||||||
|
import { getPayerAccess } from "@/shared/lib/payers/access";
|
||||||
|
import { deleteS3Object } from "@/shared/lib/storage/presign";
|
||||||
|
import { S3_BUCKET, s3 } from "@/shared/lib/storage/s3-client";
|
||||||
|
|
||||||
|
const SAFE_EXTENSION = /^[a-z0-9]{1,10}$/i;
|
||||||
|
|
||||||
|
function sanitizeExtension(fileKey: string): string {
|
||||||
|
const ext = fileKey.split(".").pop() ?? "";
|
||||||
|
return SAFE_EXTENSION.test(ext) ? ext.toLowerCase() : "bin";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyAttachmentsForImport({
|
||||||
|
sourceTransactionId,
|
||||||
|
targetTransactionIds,
|
||||||
|
targetUserId,
|
||||||
|
}: {
|
||||||
|
sourceTransactionId: string;
|
||||||
|
targetTransactionIds: string[];
|
||||||
|
targetUserId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (targetTransactionIds.length === 0) return;
|
||||||
|
|
||||||
|
const [source] = await db
|
||||||
|
.select({
|
||||||
|
id: transactions.id,
|
||||||
|
userId: transactions.userId,
|
||||||
|
payerId: transactions.payerId,
|
||||||
|
})
|
||||||
|
.from(transactions)
|
||||||
|
.where(eq(transactions.id, sourceTransactionId));
|
||||||
|
|
||||||
|
if (!source) return;
|
||||||
|
|
||||||
|
if (source.userId !== targetUserId) {
|
||||||
|
if (!source.payerId) return;
|
||||||
|
const access = await getPayerAccess(targetUserId, source.payerId);
|
||||||
|
if (!access) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceAttachments = await db
|
||||||
|
.select({
|
||||||
|
fileKey: attachments.fileKey,
|
||||||
|
fileName: attachments.fileName,
|
||||||
|
fileSize: attachments.fileSize,
|
||||||
|
mimeType: attachments.mimeType,
|
||||||
|
})
|
||||||
|
.from(transactionAttachments)
|
||||||
|
.innerJoin(
|
||||||
|
attachments,
|
||||||
|
eq(transactionAttachments.attachmentId, attachments.id),
|
||||||
|
)
|
||||||
|
.where(eq(transactionAttachments.transactionId, sourceTransactionId));
|
||||||
|
|
||||||
|
if (sourceAttachments.length === 0) return;
|
||||||
|
|
||||||
|
for (const src of sourceAttachments) {
|
||||||
|
const newFileKey = `${targetUserId}/${randomUUID()}.${sanitizeExtension(src.fileKey)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await s3.send(
|
||||||
|
new CopyObjectCommand({
|
||||||
|
Bucket: S3_BUCKET,
|
||||||
|
CopySource: `${S3_BUCKET}/${src.fileKey}`,
|
||||||
|
Key: newFileKey,
|
||||||
|
ContentType: src.mimeType,
|
||||||
|
MetadataDirective: "COPY",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Falha ao copiar anexo no S3:", error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [newAttachment] = await db
|
||||||
|
.insert(attachments)
|
||||||
|
.values({
|
||||||
|
userId: targetUserId,
|
||||||
|
fileKey: newFileKey,
|
||||||
|
fileName: src.fileName,
|
||||||
|
fileSize: src.fileSize,
|
||||||
|
mimeType: src.mimeType,
|
||||||
|
})
|
||||||
|
.returning({ id: attachments.id });
|
||||||
|
|
||||||
|
if (!newAttachment) {
|
||||||
|
await deleteS3Object(newFileKey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(transactionAttachments).values(
|
||||||
|
targetTransactionIds.map((tid) => ({
|
||||||
|
transactionId: tid,
|
||||||
|
attachmentId: newAttachment.id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Falha ao registrar anexo copiado:", error);
|
||||||
|
await deleteS3Object(newFileKey).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { attachments, transactionAttachments, transactions } from "@/db/schema";
|
import { attachments, transactionAttachments, transactions } from "@/db/schema";
|
||||||
import { db } from "@/shared/lib/db";
|
import { db } from "@/shared/lib/db";
|
||||||
|
import { getPayerAccess } from "@/shared/lib/payers/access";
|
||||||
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
|
import { createPresignedGetUrl } from "@/shared/lib/storage/presign";
|
||||||
|
|
||||||
export type TransactionAttachmentListItem = {
|
export type TransactionAttachmentListItem = {
|
||||||
@@ -17,16 +18,24 @@ export async function fetchTransactionAttachments(
|
|||||||
transactionId: string,
|
transactionId: string,
|
||||||
): Promise<TransactionAttachmentListItem[]> {
|
): Promise<TransactionAttachmentListItem[]> {
|
||||||
const [transaction] = await db
|
const [transaction] = await db
|
||||||
.select({ id: transactions.id })
|
.select({
|
||||||
|
id: transactions.id,
|
||||||
|
userId: transactions.userId,
|
||||||
|
payerId: transactions.payerId,
|
||||||
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.where(
|
.where(eq(transactions.id, transactionId));
|
||||||
and(eq(transactions.id, transactionId), eq(transactions.userId, userId)),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!transaction) {
|
if (!transaction) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (transaction.userId !== userId) {
|
||||||
|
if (!transaction.payerId) return [];
|
||||||
|
const access = await getPayerAccess(userId, transaction.payerId);
|
||||||
|
if (!access) return [];
|
||||||
|
}
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
attachmentId: transactionAttachments.attachmentId,
|
attachmentId: transactionAttachments.attachmentId,
|
||||||
@@ -37,19 +46,9 @@ export async function fetchTransactionAttachments(
|
|||||||
createdAt: attachments.createdAt,
|
createdAt: attachments.createdAt,
|
||||||
})
|
})
|
||||||
.from(transactionAttachments)
|
.from(transactionAttachments)
|
||||||
.innerJoin(
|
|
||||||
transactions,
|
|
||||||
and(
|
|
||||||
eq(transactionAttachments.transactionId, transactions.id),
|
|
||||||
eq(transactions.userId, userId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
attachments,
|
attachments,
|
||||||
and(
|
eq(transactionAttachments.attachmentId, attachments.id),
|
||||||
eq(transactionAttachments.attachmentId, attachments.id),
|
|
||||||
eq(attachments.userId, userId),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.where(eq(transactionAttachments.transactionId, transactionId));
|
.where(eq(transactionAttachments.transactionId, transactionId));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog";
|
||||||
|
import { Label } from "@/shared/components/ui/label";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group";
|
||||||
|
|
||||||
|
export type SplitPairScope = "current" | "both";
|
||||||
|
|
||||||
|
type SplitPairDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onConfirm: (scope: SplitPairScope) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SplitPairDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
}: SplitPairDialogProps) {
|
||||||
|
const [scope, setScope] = useState<SplitPairScope>("current");
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onConfirm(scope);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Editar lançamento dividido</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Este lançamento está dividido com outra pessoa. Escolha o que deseja
|
||||||
|
editar:
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
value={scope}
|
||||||
|
onValueChange={(v) => setScope(v as SplitPairScope)}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<RadioGroupItem
|
||||||
|
value="current"
|
||||||
|
id="split-current"
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label
|
||||||
|
htmlFor="split-current"
|
||||||
|
className="text-sm cursor-pointer font-medium"
|
||||||
|
>
|
||||||
|
Apenas este lançamento
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Aplica a alteração somente neste lado da divisão
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<RadioGroupItem value="both" id="split-both" className="mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label
|
||||||
|
htmlFor="split-both"
|
||||||
|
className="text-sm cursor-pointer font-medium"
|
||||||
|
>
|
||||||
|
Atualizar os dois lançamentos
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Aplica nome, data, categoria e outros campos compartilhados
|
||||||
|
nos dois lados da divisão
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={handleConfirm}>
|
||||||
|
Confirmar
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,6 +49,26 @@ export interface TransactionDialogProps {
|
|||||||
pendingDetachIds: string[];
|
pendingDetachIds: string[];
|
||||||
pendingUploadFiles: File[];
|
pendingUploadFiles: File[];
|
||||||
}) => void;
|
}) => void;
|
||||||
|
onSplitEditRequest?: (data: {
|
||||||
|
id: string;
|
||||||
|
purchaseDate: string;
|
||||||
|
period: string;
|
||||||
|
name: string;
|
||||||
|
transactionType: string;
|
||||||
|
amount: number;
|
||||||
|
condition: string;
|
||||||
|
paymentMethod: string;
|
||||||
|
categoryId: string | undefined;
|
||||||
|
note: string;
|
||||||
|
payerId: string | undefined;
|
||||||
|
accountId: string | undefined;
|
||||||
|
cardId: string | undefined;
|
||||||
|
isSettled: boolean | null;
|
||||||
|
dueDate: string | null;
|
||||||
|
boletoPaymentDate: string | null;
|
||||||
|
pendingDetachIds: string[];
|
||||||
|
pendingUploadFiles: File[];
|
||||||
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BaseFieldSectionProps {
|
export interface BaseFieldSectionProps {
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export function TransactionDialog({
|
|||||||
onSuccess,
|
onSuccess,
|
||||||
maxSizeMb,
|
maxSizeMb,
|
||||||
onBulkEditRequest,
|
onBulkEditRequest,
|
||||||
|
onSplitEditRequest,
|
||||||
}: TransactionDialogProps) {
|
}: TransactionDialogProps) {
|
||||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||||
open,
|
open,
|
||||||
@@ -321,6 +322,10 @@ export function TransactionDialog({
|
|||||||
formState.boletoPaymentDate
|
formState.boletoPaymentDate
|
||||||
? formState.boletoPaymentDate
|
? formState.boletoPaymentDate
|
||||||
: undefined,
|
: undefined,
|
||||||
|
importFromTransactionId:
|
||||||
|
mode === "create" && isImporting && transaction?.id
|
||||||
|
? transaction.id
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -365,6 +370,11 @@ export function TransactionDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasSeriesId = Boolean(transaction?.seriesId);
|
const hasSeriesId = Boolean(transaction?.seriesId);
|
||||||
|
const hasSplitPair = Boolean(
|
||||||
|
transaction?.isDivided &&
|
||||||
|
transaction?.splitGroupId &&
|
||||||
|
!transaction?.seriesId,
|
||||||
|
);
|
||||||
|
|
||||||
if (hasSeriesId && onBulkEditRequest) {
|
if (hasSeriesId && onBulkEditRequest) {
|
||||||
// Para lançamentos em série, passa os arquivos para a página confirmar
|
// Para lançamentos em série, passa os arquivos para a página confirmar
|
||||||
@@ -398,6 +408,39 @@ export function TransactionDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasSplitPair && onSplitEditRequest) {
|
||||||
|
onSplitEditRequest({
|
||||||
|
id: transaction?.id ?? "",
|
||||||
|
purchaseDate: formState.purchaseDate,
|
||||||
|
period: formState.period,
|
||||||
|
name: formState.name.trim(),
|
||||||
|
transactionType: formState.transactionType,
|
||||||
|
amount: sanitizedAmount,
|
||||||
|
condition: formState.condition,
|
||||||
|
paymentMethod: formState.paymentMethod,
|
||||||
|
categoryId: formState.categoryId,
|
||||||
|
note: formState.note.trim() || "",
|
||||||
|
payerId: formState.payerId,
|
||||||
|
accountId: formState.accountId,
|
||||||
|
cardId: formState.cardId,
|
||||||
|
isSettled:
|
||||||
|
formState.paymentMethod === "Cartão de crédito"
|
||||||
|
? null
|
||||||
|
: Boolean(formState.isSettled),
|
||||||
|
dueDate:
|
||||||
|
formState.paymentMethod === "Boleto"
|
||||||
|
? formState.dueDate || null
|
||||||
|
: null,
|
||||||
|
boletoPaymentDate:
|
||||||
|
mode === "update" && formState.paymentMethod === "Boleto"
|
||||||
|
? formState.boletoPaymentDate || null
|
||||||
|
: null,
|
||||||
|
pendingDetachIds,
|
||||||
|
pendingUploadFiles,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Atualização normal para lançamentos únicos
|
// Atualização normal para lançamentos únicos
|
||||||
const updatePayload: UpdateTransactionInput = {
|
const updatePayload: UpdateTransactionInput = {
|
||||||
id: transaction?.id ?? "",
|
id: transaction?.id ?? "",
|
||||||
@@ -609,6 +652,17 @@ export function TransactionDialog({
|
|||||||
formState={formState}
|
formState={formState}
|
||||||
onFieldChange={handleFieldChange}
|
onFieldChange={handleFieldChange}
|
||||||
/>
|
/>
|
||||||
|
{isImportMode && transaction?.id && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium leading-none">
|
||||||
|
Anexos que serão copiados
|
||||||
|
</Label>
|
||||||
|
<AttachmentSection
|
||||||
|
transactionId={transaction.id}
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<AttachmentFilePicker
|
<AttachmentFilePicker
|
||||||
files={pendingFiles}
|
files={pendingFiles}
|
||||||
onAdd={(file) => setPendingFiles((prev) => [...prev, file])}
|
onAdd={(file) => setPendingFiles((prev) => [...prev, file])}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
deleteTransactionAction,
|
deleteTransactionAction,
|
||||||
deleteTransactionBulkAction,
|
deleteTransactionBulkAction,
|
||||||
toggleTransactionSettlementAction,
|
toggleTransactionSettlementAction,
|
||||||
|
updateTransactionAction,
|
||||||
updateTransactionBulkAction,
|
updateTransactionBulkAction,
|
||||||
|
updateTransactionSplitPairAction,
|
||||||
} from "@/features/transactions/actions";
|
} from "@/features/transactions/actions";
|
||||||
import {
|
import {
|
||||||
confirmAttachmentUploadAction,
|
confirmAttachmentUploadAction,
|
||||||
@@ -31,6 +33,10 @@ import {
|
|||||||
MassAddDialog,
|
MassAddDialog,
|
||||||
type MassAddFormData,
|
type MassAddFormData,
|
||||||
} from "../dialogs/mass-add-dialog";
|
} from "../dialogs/mass-add-dialog";
|
||||||
|
import {
|
||||||
|
SplitPairDialog,
|
||||||
|
type SplitPairScope,
|
||||||
|
} from "../dialogs/split-pair-dialog";
|
||||||
import { TransactionDetailsDialog } from "../dialogs/transaction-details-dialog";
|
import { TransactionDetailsDialog } from "../dialogs/transaction-details-dialog";
|
||||||
import { TransactionDialog } from "../dialogs/transaction-dialog/transaction-dialog";
|
import { TransactionDialog } from "../dialogs/transaction-dialog/transaction-dialog";
|
||||||
import { TransactionsTable } from "../table/transactions-table";
|
import { TransactionsTable } from "../table/transactions-table";
|
||||||
@@ -125,6 +131,26 @@ export function TransactionsPage({
|
|||||||
);
|
);
|
||||||
const [bulkEditOpen, setBulkEditOpen] = useState(false);
|
const [bulkEditOpen, setBulkEditOpen] = useState(false);
|
||||||
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||||
|
const [pendingSplitEditData, setPendingSplitEditData] = useState<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
purchaseDate: string;
|
||||||
|
period: string;
|
||||||
|
transactionType: string;
|
||||||
|
amount: number;
|
||||||
|
condition: string;
|
||||||
|
paymentMethod: string;
|
||||||
|
payerId: string | undefined;
|
||||||
|
accountId: string | undefined;
|
||||||
|
cardId: string | undefined;
|
||||||
|
categoryId: string | undefined;
|
||||||
|
note: string;
|
||||||
|
isSettled: boolean | null;
|
||||||
|
dueDate: string | null;
|
||||||
|
boletoPaymentDate: string | null;
|
||||||
|
pendingDetachIds: string[];
|
||||||
|
pendingUploadFiles: File[];
|
||||||
|
} | null>(null);
|
||||||
const [pendingEditData, setPendingEditData] = useState<{
|
const [pendingEditData, setPendingEditData] = useState<{
|
||||||
id: string;
|
id: string;
|
||||||
purchaseDate: string;
|
purchaseDate: string;
|
||||||
@@ -394,6 +420,90 @@ export function TransactionsPage({
|
|||||||
setMassAddOpen(true);
|
setMassAddOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSplitEditRequest = (
|
||||||
|
data: NonNullable<typeof pendingSplitEditData>,
|
||||||
|
) => {
|
||||||
|
setPendingSplitEditData(data);
|
||||||
|
setEditOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSplitEdit = async (scope: SplitPairScope) => {
|
||||||
|
if (!pendingSplitEditData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
id: pendingSplitEditData.id,
|
||||||
|
name: pendingSplitEditData.name,
|
||||||
|
purchaseDate: pendingSplitEditData.purchaseDate,
|
||||||
|
period: pendingSplitEditData.period,
|
||||||
|
transactionType: pendingSplitEditData.transactionType as Parameters<
|
||||||
|
typeof updateTransactionAction
|
||||||
|
>[0]["transactionType"],
|
||||||
|
amount: pendingSplitEditData.amount,
|
||||||
|
condition: pendingSplitEditData.condition as Parameters<
|
||||||
|
typeof updateTransactionAction
|
||||||
|
>[0]["condition"],
|
||||||
|
paymentMethod: pendingSplitEditData.paymentMethod as Parameters<
|
||||||
|
typeof updateTransactionAction
|
||||||
|
>[0]["paymentMethod"],
|
||||||
|
payerId: pendingSplitEditData.payerId ?? null,
|
||||||
|
accountId: pendingSplitEditData.accountId ?? null,
|
||||||
|
cardId: pendingSplitEditData.cardId ?? null,
|
||||||
|
categoryId: pendingSplitEditData.categoryId ?? null,
|
||||||
|
note: pendingSplitEditData.note,
|
||||||
|
isSettled: pendingSplitEditData.isSettled,
|
||||||
|
dueDate: pendingSplitEditData.dueDate ?? undefined,
|
||||||
|
boletoPaymentDate: pendingSplitEditData.boletoPaymentDate ?? undefined,
|
||||||
|
isSplit: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const action =
|
||||||
|
scope === "both"
|
||||||
|
? updateTransactionSplitPairAction
|
||||||
|
: updateTransactionAction;
|
||||||
|
const result = await action(payload);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error);
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
pendingSplitEditData.pendingDetachIds.map((attachmentId) =>
|
||||||
|
detachAttachmentBulkAction({
|
||||||
|
attachmentId,
|
||||||
|
transactionId: pendingSplitEditData.id,
|
||||||
|
scope: "current",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
pendingSplitEditData.pendingUploadFiles.map(async (file) => {
|
||||||
|
const presign = await getPresignedUploadUrlAction({
|
||||||
|
fileName: file.name,
|
||||||
|
mimeType: file.type,
|
||||||
|
fileSize: file.size,
|
||||||
|
transactionId: pendingSplitEditData.id,
|
||||||
|
});
|
||||||
|
if (!presign.success) return;
|
||||||
|
await fetch(presign.presignedUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
body: file,
|
||||||
|
headers: { "Content-Type": file.type },
|
||||||
|
});
|
||||||
|
await confirmAttachmentUploadAction({
|
||||||
|
uploadToken: presign.uploadToken,
|
||||||
|
scope: "current",
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.success(result.message);
|
||||||
|
setPendingSplitEditData(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleEdit = (item: TransactionItem) => {
|
const handleEdit = (item: TransactionItem) => {
|
||||||
setSelectedTransaction(item);
|
setSelectedTransaction(item);
|
||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
@@ -557,6 +667,7 @@ export function TransactionsPage({
|
|||||||
transaction={selectedTransaction ?? undefined}
|
transaction={selectedTransaction ?? undefined}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
onBulkEditRequest={handleBulkEditRequest}
|
onBulkEditRequest={handleBulkEditRequest}
|
||||||
|
onSplitEditRequest={handleSplitEditRequest}
|
||||||
maxSizeMb={attachmentMaxSizeMb}
|
maxSizeMb={attachmentMaxSizeMb}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -626,6 +737,14 @@ export function TransactionsPage({
|
|||||||
onConfirm={handleBulkEdit}
|
onConfirm={handleBulkEdit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SplitPairDialog
|
||||||
|
open={pendingSplitEditData !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setPendingSplitEditData(null);
|
||||||
|
}}
|
||||||
|
onConfirm={handleSplitEdit}
|
||||||
|
/>
|
||||||
|
|
||||||
{allowCreate && massAddOpen ? (
|
{allowCreate && massAddOpen ? (
|
||||||
<MassAddDialog
|
<MassAddDialog
|
||||||
open={massAddOpen}
|
open={massAddOpen}
|
||||||
|
|||||||
@@ -265,7 +265,8 @@ export function TransactionsFilters({
|
|||||||
searchParams.get("category") ||
|
searchParams.get("category") ||
|
||||||
searchParams.get("accountCard") ||
|
searchParams.get("accountCard") ||
|
||||||
searchParams.get("settled") ||
|
searchParams.get("settled") ||
|
||||||
searchParams.get("hasAttachment");
|
searchParams.get("hasAttachment") ||
|
||||||
|
searchParams.get("isDivided");
|
||||||
|
|
||||||
const handleResetFilters = () => {
|
const handleResetFilters = () => {
|
||||||
handleReset();
|
handleReset();
|
||||||
@@ -628,6 +629,23 @@ export function TransactionsFilters({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label
|
||||||
|
htmlFor="filter-is-divided"
|
||||||
|
className="text-sm font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
Somente divididos
|
||||||
|
</label>
|
||||||
|
<Switch
|
||||||
|
id="filter-is-divided"
|
||||||
|
checked={searchParams.get("isDivided") === "true"}
|
||||||
|
disabled={isPending}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
handleFilterChange("isDivided", checked ? "true" : null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DrawerFooter>
|
<DrawerFooter>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export type TransactionItem = {
|
|||||||
isAnticipated: boolean;
|
isAnticipated: boolean;
|
||||||
anticipationId: string | null;
|
anticipationId: string | null;
|
||||||
seriesId: string | null;
|
seriesId: string | null;
|
||||||
|
splitGroupId: string | null;
|
||||||
hasAttachments: boolean;
|
hasAttachments: boolean;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type TransactionExportFilters = {
|
|||||||
searchFilter: string | null;
|
searchFilter: string | null;
|
||||||
settledFilter: string | null;
|
settledFilter: string | null;
|
||||||
attachmentFilter: string | null;
|
attachmentFilter: string | null;
|
||||||
|
dividedFilter: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TransactionsExportContext = {
|
export type TransactionsExportContext = {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export type TransactionSearchFilters = {
|
|||||||
searchFilter: string | null;
|
searchFilter: string | null;
|
||||||
settledFilter: string | null;
|
settledFilter: string | null;
|
||||||
attachmentFilter: string | null;
|
attachmentFilter: string | null;
|
||||||
|
dividedFilter: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BaseSluggedOption = {
|
type BaseSluggedOption = {
|
||||||
@@ -134,6 +135,7 @@ export const extractTransactionSearchFilters = (
|
|||||||
searchFilter: getSingleParam(params, "q"),
|
searchFilter: getSingleParam(params, "q"),
|
||||||
settledFilter: getSingleParam(params, "settled"),
|
settledFilter: getSingleParam(params, "settled"),
|
||||||
attachmentFilter: getSingleParam(params, "hasAttachment"),
|
attachmentFilter: getSingleParam(params, "hasAttachment"),
|
||||||
|
dividedFilter: getSingleParam(params, "isDivided"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resolveTransactionPagination = (
|
export const resolveTransactionPagination = (
|
||||||
@@ -402,6 +404,10 @@ export const buildTransactionWhere = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filters.dividedFilter === "true") {
|
||||||
|
where.push(eq(transactions.isDivided, true));
|
||||||
|
}
|
||||||
|
|
||||||
const searchPattern = buildSearchPattern(filters.searchFilter);
|
const searchPattern = buildSearchPattern(filters.searchFilter);
|
||||||
if (searchPattern) {
|
if (searchPattern) {
|
||||||
where.push(
|
where.push(
|
||||||
@@ -468,6 +474,7 @@ export const mapTransactionsData = (rows: TransactionRowWithRelations[]) =>
|
|||||||
isAnticipated: item.isAnticipated ?? false,
|
isAnticipated: item.isAnticipated ?? false,
|
||||||
anticipationId: item.anticipationId ?? null,
|
anticipationId: item.anticipationId ?? null,
|
||||||
seriesId: item.seriesId ?? null,
|
seriesId: item.seriesId ?? null,
|
||||||
|
splitGroupId: item.splitGroupId ?? null,
|
||||||
hasAttachments: item.hasAttachments ?? false,
|
hasAttachments: item.hasAttachments ?? false,
|
||||||
readonly:
|
readonly:
|
||||||
Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
|
Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
|
||||||
|
|||||||
Reference in New Issue
Block a user