mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-10 03:11: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:
@@ -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[];
|
||||
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 {
|
||||
|
||||
@@ -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])}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -33,6 +33,7 @@ export type TransactionItem = {
|
||||
isAnticipated: boolean;
|
||||
anticipationId: string | null;
|
||||
seriesId: string | null;
|
||||
splitGroupId: string | null;
|
||||
hasAttachments: boolean;
|
||||
readonly?: boolean;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user