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,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;
};