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:
Felipe Coutinho
2026-04-25 14:45:35 +00:00
parent 5b03824a72
commit b14f487824
20 changed files with 3595 additions and 86 deletions

View 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");

File diff suppressed because it is too large Load Diff

View File

@@ -183,6 +183,13 @@
"when": 1776351838548,
"tag": "0025_burly_colonel_america",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1777042423451,
"tag": "0026_bored_eternity",
"breakpoints": true
}
]
}

View File

@@ -670,6 +670,7 @@ export const transactions = pgTable(
onUpdate: "cascade",
}),
seriesId: uuid("series_id"),
splitGroupId: uuid("split_group_id"),
transferId: uuid("transfer_id"),
ofxFitId: text("ofx_fit_id"),
importBatchId: text("import_batch_id"),
@@ -702,6 +703,11 @@ export const transactions = pgTable(
),
// Índice para buscar parcelas de uma série
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
transferIdIdx: index("lancamentos_transfer_id_idx").on(table.transferId),
// Índice para filtrar por condição (aberto, realizado, cancelado)

View File

@@ -1,10 +1,11 @@
import { and, desc, eq, type SQL } from "drizzle-orm";
import { and, desc, eq, type SQL, sql } from "drizzle-orm";
import {
cards,
categories,
financialAccounts,
payerShares,
payers,
transactionAttachments,
transactions,
user as usersTable,
} from "@/db/schema";
@@ -73,6 +74,10 @@ export async function fetchPagadorLancamentos(filters: SQL[]) {
financialAccount: financialAccounts,
card: cards,
category: categories,
hasAttachments: sql<boolean>`EXISTS (
SELECT 1 FROM ${transactionAttachments}
WHERE ${transactionAttachments.transactionId} = ${transactions.id}
)`,
})
.from(transactions)
.leftJoin(payers, eq(transactions.payerId, payers.id))
@@ -85,12 +90,12 @@ export async function fetchPagadorLancamentos(filters: SQL[]) {
.where(and(...filters))
.orderBy(desc(transactions.purchaseDate), desc(transactions.createdAt));
// Transformar resultado para o formato esperado
return transactionRows.map((row) => ({
...row.transaction,
payer: row.payer,
financialAccount: row.financialAccount,
card: row.card,
category: row.category,
hasAttachments: row.hasAttachments,
}));
}

View File

@@ -12,6 +12,7 @@ import {
deleteTransactionAction as deleteTransactionActionImpl,
toggleTransactionSettlementAction as toggleTransactionSettlementActionImpl,
updateTransactionAction as updateTransactionActionImpl,
updateTransactionSplitPairAction as updateTransactionSplitPairActionImpl,
} from "./actions/single-actions";
export async function createTransactionAction(
@@ -62,6 +63,12 @@ export async function deleteMultipleTransactionsAction(
return deleteMultipleTransactionsActionImpl(...args);
}
export async function updateTransactionSplitPairAction(
...args: Parameters<typeof updateTransactionSplitPairActionImpl>
): ReturnType<typeof updateTransactionSplitPairActionImpl> {
return updateTransactionSplitPairActionImpl(...args);
}
export async function exportTransactionsDataAction(
...args: Parameters<typeof exportTransactionsDataActionImpl>
): ReturnType<typeof exportTransactionsDataActionImpl> {

View File

@@ -1,7 +1,7 @@
"use server";
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 { attachments, transactionAttachments, transactions } from "@/db/schema";
import {
@@ -15,7 +15,6 @@ import {
import { getUser } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import {
createPresignedGetUrl,
createPresignedPutUrl,
deleteS3Object,
headS3Object,
@@ -98,6 +97,46 @@ function signUploadToken(payload: UploadTokenPayload): string {
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 {
try {
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(
transactionIds.map((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({
attachmentId: 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) {
await db
.delete(transactionAttachments)

View File

@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import { z } from "zod";
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
.extend({
id: uuidSchema("Lançamento"),
@@ -544,6 +549,7 @@ export const buildLancamentoRecords = ({
seriesId,
}: BuildTransactionRecordsParams): TransactionInsert[] => {
const records: TransactionInsert[] = [];
const isSplit = (data.isSplit ?? false) && shares.length > 1;
const basePayload = {
name: data.name,
@@ -562,6 +568,8 @@ export const buildLancamentoRecords = ({
seriesId,
};
const cycleSplitGroupId = () => (isSplit ? randomUUID() : null);
const resolveSettledValue = (cycleIndex: number) => {
if (shouldNullifySettled) {
return null;
@@ -588,6 +596,7 @@ export const buildLancamentoRecords = ({
const installmentDueDate = dueDate
? addMonthsToDate(dueDate, installment)
: null;
const splitGroupId = cycleSplitGroupId();
shares.forEach((share, shareIndex) => {
const amountCents = amountsByShare[shareIndex]?.[installment] ?? 0;
@@ -603,6 +612,7 @@ export const buildLancamentoRecords = ({
currentInstallment: installment + 1,
recurrenceCount: null,
dueDate: installmentDueDate,
splitGroupId,
boletoPaymentDate:
data.paymentMethod === "Boleto" && settled
? boletoPaymentDate
@@ -623,6 +633,7 @@ export const buildLancamentoRecords = ({
const recurrenceDueDate = dueDate
? addMonthsToDate(dueDate, index)
: null;
const splitGroupId = cycleSplitGroupId();
shares.forEach((share) => {
const settled = resolveSettledValue(index);
@@ -635,6 +646,7 @@ export const buildLancamentoRecords = ({
isSettled: settled,
recurrenceCount: recurrenceTotal,
dueDate: recurrenceDueDate,
splitGroupId,
boletoPaymentDate:
data.paymentMethod === "Boleto" && settled
? boletoPaymentDate
@@ -646,6 +658,8 @@ export const buildLancamentoRecords = ({
return records;
}
const splitGroupId = cycleSplitGroupId();
shares.forEach((share) => {
const settled = resolveSettledValue(0);
records.push({
@@ -656,6 +670,7 @@ export const buildLancamentoRecords = ({
period,
isSettled: settled,
dueDate,
splitGroupId,
boletoPaymentDate:
data.paymentMethod === "Boleto" && settled ? boletoPaymentDate : null,
});

View File

@@ -33,6 +33,7 @@ const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = z.object(
searchFilter: z.string().nullable(),
settledFilter: z.string().nullable(),
attachmentFilter: z.string().nullable(),
dividedFilter: z.string().nullable(),
}),
accountId: z.string().min(1).nullable().optional(),
cardId: z.string().min(1).nullable().optional(),

View File

@@ -1,7 +1,7 @@
"use server";
import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { and, eq, ne } from "drizzle-orm";
import {
attachments,
financialAccounts,
@@ -21,6 +21,7 @@ import {
getBusinessTodayDate,
parseLocalDateString,
} from "@/shared/utils/date";
import { copyAttachmentsForImport } from "../attachment-copy";
import { cleanupAttachmentsAfterTransactionDelete } from "./attachments";
import {
buildLancamentoRecords,
@@ -138,6 +139,14 @@ export async function createTransactionAction(
.values(records)
.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(
records.map((record) => ({
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(
input: ToggleSettlementInput,
): Promise<ActionResult> {

View 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(() => {});
}
}
}

View File

@@ -1,6 +1,7 @@
import { and, eq } from "drizzle-orm";
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 { createPresignedGetUrl } from "@/shared/lib/storage/presign";
export type TransactionAttachmentListItem = {
@@ -17,16 +18,24 @@ export async function fetchTransactionAttachments(
transactionId: string,
): Promise<TransactionAttachmentListItem[]> {
const [transaction] = await db
.select({ id: transactions.id })
.select({
id: transactions.id,
userId: transactions.userId,
payerId: transactions.payerId,
})
.from(transactions)
.where(
and(eq(transactions.id, transactionId), eq(transactions.userId, userId)),
);
.where(eq(transactions.id, transactionId));
if (!transaction) {
return [];
}
if (transaction.userId !== userId) {
if (!transaction.payerId) return [];
const access = await getPayerAccess(userId, transaction.payerId);
if (!access) return [];
}
const rows = await db
.select({
attachmentId: transactionAttachments.attachmentId,
@@ -37,19 +46,9 @@ export async function fetchTransactionAttachments(
createdAt: attachments.createdAt,
})
.from(transactionAttachments)
.innerJoin(
transactions,
and(
eq(transactionAttachments.transactionId, transactions.id),
eq(transactions.userId, userId),
),
)
.innerJoin(
attachments,
and(
eq(transactionAttachments.attachmentId, attachments.id),
eq(attachments.userId, userId),
),
eq(transactionAttachments.attachmentId, attachments.id),
)
.where(eq(transactionAttachments.transactionId, transactionId));

View File

@@ -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>
);
}

View File

@@ -49,6 +49,26 @@ export interface TransactionDialogProps {
pendingDetachIds: string[];
pendingUploadFiles: File[];
}) => 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 {

View File

@@ -78,6 +78,7 @@ export function TransactionDialog({
onSuccess,
maxSizeMb,
onBulkEditRequest,
onSplitEditRequest,
}: TransactionDialogProps) {
const [dialogOpen, setDialogOpen] = useControlledState(
open,
@@ -321,6 +322,10 @@ export function TransactionDialog({
formState.boletoPaymentDate
? formState.boletoPaymentDate
: undefined,
importFromTransactionId:
mode === "create" && isImporting && transaction?.id
? transaction.id
: undefined,
};
startTransition(async () => {
@@ -365,6 +370,11 @@ export function TransactionDialog({
}
const hasSeriesId = Boolean(transaction?.seriesId);
const hasSplitPair = Boolean(
transaction?.isDivided &&
transaction?.splitGroupId &&
!transaction?.seriesId,
);
if (hasSeriesId && onBulkEditRequest) {
// Para lançamentos em série, passa os arquivos para a página confirmar
@@ -398,6 +408,39 @@ export function TransactionDialog({
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
const updatePayload: UpdateTransactionInput = {
id: transaction?.id ?? "",
@@ -609,6 +652,17 @@ export function TransactionDialog({
formState={formState}
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
files={pendingFiles}
onAdd={(file) => setPendingFiles((prev) => [...prev, file])}

View File

@@ -8,7 +8,9 @@ import {
deleteTransactionAction,
deleteTransactionBulkAction,
toggleTransactionSettlementAction,
updateTransactionAction,
updateTransactionBulkAction,
updateTransactionSplitPairAction,
} from "@/features/transactions/actions";
import {
confirmAttachmentUploadAction,
@@ -31,6 +33,10 @@ import {
MassAddDialog,
type MassAddFormData,
} from "../dialogs/mass-add-dialog";
import {
SplitPairDialog,
type SplitPairScope,
} from "../dialogs/split-pair-dialog";
import { TransactionDetailsDialog } from "../dialogs/transaction-details-dialog";
import { TransactionDialog } from "../dialogs/transaction-dialog/transaction-dialog";
import { TransactionsTable } from "../table/transactions-table";
@@ -125,6 +131,26 @@ export function TransactionsPage({
);
const [bulkEditOpen, setBulkEditOpen] = 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<{
id: string;
purchaseDate: string;
@@ -394,6 +420,90 @@ export function TransactionsPage({
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) => {
setSelectedTransaction(item);
setEditOpen(true);
@@ -557,6 +667,7 @@ export function TransactionsPage({
transaction={selectedTransaction ?? undefined}
defaultPeriod={selectedPeriod}
onBulkEditRequest={handleBulkEditRequest}
onSplitEditRequest={handleSplitEditRequest}
maxSizeMb={attachmentMaxSizeMb}
/>
@@ -626,6 +737,14 @@ export function TransactionsPage({
onConfirm={handleBulkEdit}
/>
<SplitPairDialog
open={pendingSplitEditData !== null}
onOpenChange={(open) => {
if (!open) setPendingSplitEditData(null);
}}
onConfirm={handleSplitEdit}
/>
{allowCreate && massAddOpen ? (
<MassAddDialog
open={massAddOpen}

View File

@@ -265,7 +265,8 @@ export function TransactionsFilters({
searchParams.get("category") ||
searchParams.get("accountCard") ||
searchParams.get("settled") ||
searchParams.get("hasAttachment");
searchParams.get("hasAttachment") ||
searchParams.get("isDivided");
const handleResetFilters = () => {
handleReset();
@@ -628,6 +629,23 @@ export function TransactionsFilters({
}}
/>
</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>
<DrawerFooter>

View File

@@ -33,6 +33,7 @@ export type TransactionItem = {
isAnticipated: boolean;
anticipationId: string | null;
seriesId: string | null;
splitGroupId: string | null;
hasAttachments: boolean;
readonly?: boolean;
};

View File

@@ -8,6 +8,7 @@ export type TransactionExportFilters = {
searchFilter: string | null;
settledFilter: string | null;
attachmentFilter: string | null;
dividedFilter: string | null;
};
export type TransactionsExportContext = {

View File

@@ -45,6 +45,7 @@ export type TransactionSearchFilters = {
searchFilter: string | null;
settledFilter: string | null;
attachmentFilter: string | null;
dividedFilter: string | null;
};
type BaseSluggedOption = {
@@ -134,6 +135,7 @@ export const extractTransactionSearchFilters = (
searchFilter: getSingleParam(params, "q"),
settledFilter: getSingleParam(params, "settled"),
attachmentFilter: getSingleParam(params, "hasAttachment"),
dividedFilter: getSingleParam(params, "isDivided"),
});
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);
if (searchPattern) {
where.push(
@@ -468,6 +474,7 @@ export const mapTransactionsData = (rows: TransactionRowWithRelations[]) =>
isAnticipated: item.isAnticipated ?? false,
anticipationId: item.anticipationId ?? null,
seriesId: item.seriesId ?? null,
splitGroupId: item.splitGroupId ?? null,
hasAttachments: item.hasAttachments ?? false,
readonly:
Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||