mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
feat(lançamentos): escopo "period" na ação em lote e correção do fluxo de anexos em séries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,7 @@ const presignSchema = z.object({
|
|||||||
|
|
||||||
const confirmSchema = z.object({
|
const confirmSchema = z.object({
|
||||||
uploadToken: z.string().min(1),
|
uploadToken: z.string().min(1),
|
||||||
applyToSeries: z.boolean().default(false),
|
scope: z.enum(["current", "period", "future", "all"]).default("current"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const detachSchema = z.object({
|
const detachSchema = z.object({
|
||||||
@@ -183,7 +183,7 @@ export async function getPresignedUploadUrlAction(input: {
|
|||||||
|
|
||||||
export async function confirmAttachmentUploadAction(input: {
|
export async function confirmAttachmentUploadAction(input: {
|
||||||
uploadToken: string;
|
uploadToken: string;
|
||||||
applyToSeries?: boolean;
|
scope?: "current" | "period" | "future" | "all";
|
||||||
}): Promise<ActionResult> {
|
}): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
@@ -195,7 +195,11 @@ export async function confirmAttachmentUploadAction(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [transaction] = await db
|
const [transaction] = await db
|
||||||
.select({ id: transactions.id, seriesId: transactions.seriesId })
|
.select({
|
||||||
|
id: transactions.id,
|
||||||
|
seriesId: transactions.seriesId,
|
||||||
|
period: transactions.period,
|
||||||
|
})
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -253,9 +257,9 @@ export async function confirmAttachmentUploadAction(input: {
|
|||||||
|
|
||||||
let transactionIds: string[] = [uploadPayload.transactionId];
|
let transactionIds: string[] = [uploadPayload.transactionId];
|
||||||
|
|
||||||
if (data.applyToSeries && transaction.seriesId) {
|
if (data.scope !== "current" && transaction.seriesId) {
|
||||||
const seriesRows = await db
|
const seriesRows = await db
|
||||||
.select({ id: transactions.id })
|
.select({ id: transactions.id, period: transactions.period })
|
||||||
.from(transactions)
|
.from(transactions)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -263,7 +267,18 @@ export async function confirmAttachmentUploadAction(input: {
|
|||||||
eq(transactions.userId, user.id),
|
eq(transactions.userId, user.id),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
transactionIds = seriesRows.map((t) => t.id);
|
|
||||||
|
if (data.scope === "period") {
|
||||||
|
transactionIds = seriesRows
|
||||||
|
.filter((r) => r.period === transaction.period)
|
||||||
|
.map((r) => r.id);
|
||||||
|
} else if (data.scope === "future") {
|
||||||
|
transactionIds = seriesRows
|
||||||
|
.filter((r) => (r.period ?? "") >= (transaction.period ?? ""))
|
||||||
|
.map((r) => r.id);
|
||||||
|
} else {
|
||||||
|
transactionIds = seriesRows.map((r) => r.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.insert(transactionAttachments).values(
|
await db.insert(transactionAttachments).values(
|
||||||
@@ -407,6 +422,110 @@ export async function fetchTransactionAttachmentsAction(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const detachBulkSchema = z.object({
|
||||||
|
attachmentId: z.string().uuid(),
|
||||||
|
transactionId: z.string().uuid(),
|
||||||
|
scope: z.enum(["current", "period", "future", "all"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function detachAttachmentBulkAction(input: {
|
||||||
|
attachmentId: string;
|
||||||
|
transactionId: string;
|
||||||
|
scope: "current" | "period" | "future" | "all";
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
const data = detachBulkSchema.parse(input);
|
||||||
|
|
||||||
|
const [baseTransaction] = await db
|
||||||
|
.select({
|
||||||
|
id: transactions.id,
|
||||||
|
seriesId: transactions.seriesId,
|
||||||
|
period: transactions.period,
|
||||||
|
})
|
||||||
|
.from(transactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactions.id, data.transactionId),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!baseTransaction) {
|
||||||
|
return { success: false, error: "Lançamento não encontrado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [attachment] = await db
|
||||||
|
.select({ id: attachments.id, fileKey: attachments.fileKey })
|
||||||
|
.from(attachments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(attachments.id, data.attachmentId),
|
||||||
|
eq(attachments.userId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!attachment) {
|
||||||
|
return { success: false, error: "Anexo não encontrado." };
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetTransactionIds: string[];
|
||||||
|
|
||||||
|
if (data.scope === "current" || !baseTransaction.seriesId) {
|
||||||
|
targetTransactionIds = [data.transactionId];
|
||||||
|
} else {
|
||||||
|
const seriesRows = await db
|
||||||
|
.select({ id: transactions.id, period: transactions.period })
|
||||||
|
.from(transactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactions.seriesId, baseTransaction.seriesId),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.scope === "period") {
|
||||||
|
targetTransactionIds = seriesRows
|
||||||
|
.filter((r) => r.period === baseTransaction.period)
|
||||||
|
.map((r) => r.id);
|
||||||
|
} else if (data.scope === "future") {
|
||||||
|
targetTransactionIds = seriesRows
|
||||||
|
.filter((r) => (r.period ?? "") >= (baseTransaction.period ?? ""))
|
||||||
|
.map((r) => r.id);
|
||||||
|
} else {
|
||||||
|
targetTransactionIds = seriesRows.map((r) => r.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetTransactionIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.delete(transactionAttachments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(transactionAttachments.transactionId, targetTransactionIds),
|
||||||
|
eq(transactionAttachments.attachmentId, data.attachmentId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [remaining] = await db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(transactionAttachments)
|
||||||
|
.where(eq(transactionAttachments.attachmentId, data.attachmentId));
|
||||||
|
|
||||||
|
if (!remaining || remaining.total === 0) {
|
||||||
|
await deleteS3Object(attachment.fileKey);
|
||||||
|
await db.delete(attachments).where(eq(attachments.id, data.attachmentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidateForEntity("transactions", user.id);
|
||||||
|
|
||||||
|
return { success: true, message: "Anexo removido com sucesso." };
|
||||||
|
} catch (error) {
|
||||||
|
return handleActionError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Limpa anexos órfãos do S3 após deletar transações. Chame APÓS o delete. */
|
/** Limpa anexos órfãos do S3 após deletar transações. Chame APÓS o delete. */
|
||||||
export async function cleanupAttachmentsAfterTransactionDelete(
|
export async function cleanupAttachmentsAfterTransactionDelete(
|
||||||
attachmentData: Array<{ id: string; fileKey: string }>,
|
attachmentData: Array<{ id: string; fileKey: string }>,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { and, asc, eq, inArray, sql } from "drizzle-orm";
|
import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||||
import { transactions } from "@/db/schema";
|
import { transactions } from "@/db/schema";
|
||||||
import {
|
import {
|
||||||
PAYMENT_METHODS,
|
PAYMENT_METHODS,
|
||||||
@@ -80,6 +80,24 @@ export async function deleteTransactionBulkAction(
|
|||||||
return { success: true, message: "Lançamento removido com sucesso." };
|
return { success: true, message: "Lançamento removido com sucesso." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.scope === "period") {
|
||||||
|
await db
|
||||||
|
.delete(transactions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(transactions.seriesId, existing.seriesId),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
eq(transactions.period, existing.period ?? ""),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidate(user.id);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Todos os lançamentos do período foram removidos.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (data.scope === "future") {
|
if (data.scope === "future") {
|
||||||
await db
|
await db
|
||||||
.delete(transactions)
|
.delete(transactions)
|
||||||
@@ -147,6 +165,7 @@ export async function updateTransactionBulkAction(
|
|||||||
condition: true,
|
condition: true,
|
||||||
transactionType: true,
|
transactionType: true,
|
||||||
purchaseDate: true,
|
purchaseDate: true,
|
||||||
|
payerId: true,
|
||||||
},
|
},
|
||||||
where: and(
|
where: and(
|
||||||
eq(transactions.id, data.id),
|
eq(transactions.id, data.id),
|
||||||
@@ -169,7 +188,8 @@ export async function updateTransactionBulkAction(
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
categoryId: data.categoryId ?? null,
|
categoryId: data.categoryId ?? null,
|
||||||
note: data.note ?? null,
|
note: data.note ?? null,
|
||||||
payerId: data.payerId ?? null,
|
// "period" atualiza todos os pagadores do mês — preserva o payerId de cada linha
|
||||||
|
...(data.scope !== "period" && { payerId: data.payerId ?? null }),
|
||||||
accountId: data.accountId ?? null,
|
accountId: data.accountId ?? null,
|
||||||
cardId: data.cardId ?? null,
|
cardId: data.cardId ?? null,
|
||||||
...(data.isSettled !== undefined && { isSettled: data.isSettled }),
|
...(data.isSettled !== undefined && { isSettled: data.isSettled }),
|
||||||
@@ -309,6 +329,42 @@ export async function updateTransactionBulkAction(
|
|||||||
return { success: true, message: "Lançamento atualizado com sucesso." };
|
return { success: true, message: "Lançamento atualizado com sucesso." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.scope === "period") {
|
||||||
|
if (!existing.period) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Período do lançamento não encontrado.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const periodLancamentos = await db.query.transactions.findMany({
|
||||||
|
columns: { id: true, purchaseDate: true },
|
||||||
|
where: and(
|
||||||
|
eq(transactions.seriesId, existing.seriesId),
|
||||||
|
eq(transactions.userId, user.id),
|
||||||
|
eq(transactions.period, existing.period),
|
||||||
|
),
|
||||||
|
orderBy: asc(transactions.purchaseDate),
|
||||||
|
});
|
||||||
|
|
||||||
|
await applyUpdates(
|
||||||
|
periodLancamentos.map((item: (typeof periodLancamentos)[number]) => ({
|
||||||
|
id: item.id,
|
||||||
|
purchaseDate: item.purchaseDate ?? null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidate(user.id);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Todos os lançamentos do período foram atualizados.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const payerIdFilter = existing.payerId
|
||||||
|
? eq(transactions.payerId, existing.payerId)
|
||||||
|
: isNull(transactions.payerId);
|
||||||
|
|
||||||
if (data.scope === "future") {
|
if (data.scope === "future") {
|
||||||
const futureLancamentos = await db.query.transactions.findMany({
|
const futureLancamentos = await db.query.transactions.findMany({
|
||||||
columns: {
|
columns: {
|
||||||
@@ -319,6 +375,7 @@ export async function updateTransactionBulkAction(
|
|||||||
eq(transactions.seriesId, existing.seriesId),
|
eq(transactions.seriesId, existing.seriesId),
|
||||||
eq(transactions.userId, user.id),
|
eq(transactions.userId, user.id),
|
||||||
sql`${transactions.period} >= ${existing.period}`,
|
sql`${transactions.period} >= ${existing.period}`,
|
||||||
|
payerIdFilter,
|
||||||
),
|
),
|
||||||
orderBy: asc(transactions.purchaseDate),
|
orderBy: asc(transactions.purchaseDate),
|
||||||
});
|
});
|
||||||
@@ -346,6 +403,7 @@ export async function updateTransactionBulkAction(
|
|||||||
where: and(
|
where: and(
|
||||||
eq(transactions.seriesId, existing.seriesId),
|
eq(transactions.seriesId, existing.seriesId),
|
||||||
eq(transactions.userId, user.id),
|
eq(transactions.userId, user.id),
|
||||||
|
payerIdFilter,
|
||||||
),
|
),
|
||||||
orderBy: asc(transactions.purchaseDate),
|
orderBy: asc(transactions.purchaseDate),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -664,7 +664,7 @@ export const buildLancamentoRecords = ({
|
|||||||
|
|
||||||
export const deleteBulkSchema = z.object({
|
export const deleteBulkSchema = z.object({
|
||||||
id: uuidSchema("Lançamento"),
|
id: uuidSchema("Lançamento"),
|
||||||
scope: z.enum(["current", "future", "all"], {
|
scope: z.enum(["current", "period", "future", "all"], {
|
||||||
message: "Escopo de ação inválido.",
|
message: "Escopo de ação inválido.",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -673,7 +673,7 @@ export type DeleteBulkInput = z.infer<typeof deleteBulkSchema>;
|
|||||||
|
|
||||||
export const updateBulkSchema = z.object({
|
export const updateBulkSchema = z.object({
|
||||||
id: uuidSchema("Lançamento"),
|
id: uuidSchema("Lançamento"),
|
||||||
scope: z.enum(["current", "future", "all"], {
|
scope: z.enum(["current", "period", "future", "all"], {
|
||||||
message: "Escopo de ação inválido.",
|
message: "Escopo de ação inválido.",
|
||||||
}),
|
}),
|
||||||
name: z
|
name: z
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ interface AttachmentItemProps {
|
|||||||
url: string;
|
url: string;
|
||||||
onDeleted: () => void;
|
onDeleted: () => void;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
|
isPendingDelete?: boolean;
|
||||||
|
onPendingDelete?: (attachmentId: string) => void;
|
||||||
|
onUndoPendingDelete?: (attachmentId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AttachmentItem({
|
export function AttachmentItem({
|
||||||
@@ -136,6 +139,9 @@ export function AttachmentItem({
|
|||||||
url,
|
url,
|
||||||
onDeleted,
|
onDeleted,
|
||||||
readonly = false,
|
readonly = false,
|
||||||
|
isPendingDelete = false,
|
||||||
|
onPendingDelete,
|
||||||
|
onUndoPendingDelete,
|
||||||
}: AttachmentItemProps) {
|
}: AttachmentItemProps) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [previewOpen, setPreviewOpen] = useState(false);
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
@@ -145,6 +151,11 @@ export function AttachmentItem({
|
|||||||
mimeType === "application/pdf" || mimeType.startsWith("image/");
|
mimeType === "application/pdf" || mimeType.startsWith("image/");
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
|
if (onPendingDelete) {
|
||||||
|
onPendingDelete(attachmentId);
|
||||||
|
setConfirmOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await detachTransactionAttachmentAction({
|
const result = await detachTransactionAttachmentAction({
|
||||||
attachmentId,
|
attachmentId,
|
||||||
@@ -162,9 +173,18 @@ export function AttachmentItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm">
|
<div
|
||||||
|
className={`flex min-w-0 items-center gap-2 overflow-hidden rounded-md border px-3 py-2 text-sm transition-opacity ${isPendingDelete ? "opacity-50 border-dashed" : ""}`}
|
||||||
|
>
|
||||||
<AttachmentIcon mimeType={mimeType} />
|
<AttachmentIcon mimeType={mimeType} />
|
||||||
{canPreview ? (
|
{isPendingDelete ? (
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="truncate font-medium line-through">{fileName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Será removido ao salvar
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : canPreview ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="min-w-0 flex-1 text-left"
|
className="min-w-0 flex-1 text-left"
|
||||||
@@ -184,29 +204,42 @@ export function AttachmentItem({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
{!isPendingDelete && (
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="size-7 shrink-0"
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<a href={url} target="_blank" rel="noreferrer" download={fileName}>
|
|
||||||
<RiDownloadLine className="size-4" />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
{!readonly && (
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-7 shrink-0 text-destructive hover:text-destructive"
|
className="size-7 shrink-0"
|
||||||
onClick={() => setConfirmOpen(true)}
|
asChild
|
||||||
disabled={isPending}
|
|
||||||
>
|
>
|
||||||
<RiDeleteBinLine className="size-4" />
|
<a href={url} target="_blank" rel="noreferrer" download={fileName}>
|
||||||
|
<RiDownloadLine className="size-4" />
|
||||||
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{!readonly &&
|
||||||
|
(isPendingDelete ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0 text-xs h-7 px-2"
|
||||||
|
onClick={() => onUndoPendingDelete?.(attachmentId)}
|
||||||
|
>
|
||||||
|
Desfazer
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 shrink-0 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setConfirmOpen(true)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<RiDeleteBinLine className="size-4" />
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canPreview && (
|
{canPreview && (
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { RiFileAddLine } from "@remixicon/react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { fetchTransactionAttachmentsAction } from "@/features/transactions/actions/attachments";
|
import { fetchTransactionAttachmentsAction } from "@/features/transactions/actions/attachments";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { AttachmentItem } from "./attachment-item";
|
import { AttachmentItem } from "./attachment-item";
|
||||||
import { AttachmentUpload } from "./attachment-upload";
|
import { AttachmentUpload } from "./attachment-upload";
|
||||||
|
|
||||||
@@ -16,16 +18,28 @@ type AttachmentRow = {
|
|||||||
|
|
||||||
interface AttachmentSectionProps {
|
interface AttachmentSectionProps {
|
||||||
transactionId: string;
|
transactionId: string;
|
||||||
seriesId: string | null;
|
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
onLoaded?: (count: number) => void;
|
onLoaded?: (count: number) => void;
|
||||||
|
pendingDetachIds?: string[];
|
||||||
|
onPendingDetach?: (attachmentId: string) => void;
|
||||||
|
onUndoPendingDetach?: (attachmentId: string) => void;
|
||||||
|
pendingUploadFiles?: File[];
|
||||||
|
onPendingUpload?: (file: File) => void;
|
||||||
|
onCancelPendingUpload?: (file: File) => void;
|
||||||
|
maxSizeMb?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AttachmentSection({
|
export function AttachmentSection({
|
||||||
transactionId,
|
transactionId,
|
||||||
seriesId,
|
|
||||||
readonly = false,
|
readonly = false,
|
||||||
onLoaded,
|
onLoaded,
|
||||||
|
pendingDetachIds,
|
||||||
|
onPendingDetach,
|
||||||
|
onUndoPendingDetach,
|
||||||
|
pendingUploadFiles,
|
||||||
|
onPendingUpload,
|
||||||
|
onCancelPendingUpload,
|
||||||
|
maxSizeMb,
|
||||||
}: AttachmentSectionProps) {
|
}: AttachmentSectionProps) {
|
||||||
const [items, setItems] = useState<AttachmentRow[]>([]);
|
const [items, setItems] = useState<AttachmentRow[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -45,42 +59,70 @@ export function AttachmentSection({
|
|||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <p className="text-xs text-muted-foreground">Carregando...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPendingUploads = (pendingUploadFiles?.length ?? 0) > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 space-y-2 overflow-hidden">
|
<div className="min-w-0 space-y-2 overflow-hidden">
|
||||||
{isLoading ? (
|
{items.length === 0 && !hasPendingUploads && readonly && (
|
||||||
<p className="text-xs text-muted-foreground">Carregando...</p>
|
<p className="text-xs text-muted-foreground">Nenhum anexo.</p>
|
||||||
) : (
|
)}
|
||||||
<>
|
|
||||||
{items.length > 0 ? (
|
|
||||||
<div className="min-w-0 space-y-1.5">
|
|
||||||
{items.map((item) => (
|
|
||||||
<AttachmentItem
|
|
||||||
key={item.attachmentId}
|
|
||||||
attachmentId={item.attachmentId}
|
|
||||||
transactionId={transactionId}
|
|
||||||
fileName={item.fileName}
|
|
||||||
fileSize={item.fileSize}
|
|
||||||
mimeType={item.mimeType}
|
|
||||||
url={item.url}
|
|
||||||
onDeleted={load}
|
|
||||||
readonly={readonly}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
readonly && (
|
|
||||||
<p className="text-xs text-muted-foreground">Nenhum anexo.</p>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!readonly && (
|
{(items.length > 0 || hasPendingUploads) && (
|
||||||
<AttachmentUpload
|
<div className="min-w-0 space-y-1.5">
|
||||||
|
{items.map((item) => (
|
||||||
|
<AttachmentItem
|
||||||
|
key={item.attachmentId}
|
||||||
|
attachmentId={item.attachmentId}
|
||||||
transactionId={transactionId}
|
transactionId={transactionId}
|
||||||
seriesId={seriesId}
|
fileName={item.fileName}
|
||||||
onUploaded={load}
|
fileSize={item.fileSize}
|
||||||
|
mimeType={item.mimeType}
|
||||||
|
url={item.url}
|
||||||
|
onDeleted={load}
|
||||||
|
readonly={readonly}
|
||||||
|
isPendingDelete={pendingDetachIds?.includes(item.attachmentId)}
|
||||||
|
onPendingDelete={onPendingDetach}
|
||||||
|
onUndoPendingDelete={onUndoPendingDetach}
|
||||||
/>
|
/>
|
||||||
)}
|
))}
|
||||||
</>
|
|
||||||
|
{pendingUploadFiles?.map((file) => (
|
||||||
|
<div
|
||||||
|
key={`${file.name}-${file.size}`}
|
||||||
|
className="flex min-w-0 items-center gap-2 overflow-hidden rounded-md border border-dashed px-3 py-2 text-sm opacity-60"
|
||||||
|
>
|
||||||
|
<RiFileAddLine className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="truncate font-medium">{file.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Será adicionado ao salvar
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0 text-xs h-7 px-2"
|
||||||
|
onClick={() => onCancelPendingUpload?.(file)}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!readonly && (
|
||||||
|
<AttachmentUpload
|
||||||
|
transactionId={transactionId}
|
||||||
|
onUploaded={load}
|
||||||
|
onPendingUpload={onPendingUpload}
|
||||||
|
maxSizeMb={maxSizeMb}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiAttachment2 } from "@remixicon/react";
|
import { RiAttachment2 } from "@remixicon/react";
|
||||||
import { useRef, useState, useTransition } from "react";
|
import { useRef, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
confirmAttachmentUploadAction,
|
confirmAttachmentUploadAction,
|
||||||
@@ -9,27 +9,25 @@ import {
|
|||||||
} from "@/features/transactions/actions/attachments";
|
} from "@/features/transactions/actions/attachments";
|
||||||
import {
|
import {
|
||||||
ALLOWED_MIME_TYPES,
|
ALLOWED_MIME_TYPES,
|
||||||
MAX_FILE_SIZE,
|
DEFAULT_MAX_FILE_SIZE_MB,
|
||||||
} from "@/features/transactions/attachments-config";
|
} from "@/features/transactions/attachments-config";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
|
||||||
import { Label } from "@/shared/components/ui/label";
|
|
||||||
|
|
||||||
interface AttachmentUploadProps {
|
interface AttachmentUploadProps {
|
||||||
transactionId: string;
|
transactionId: string;
|
||||||
seriesId: string | null;
|
|
||||||
onUploaded: () => void;
|
onUploaded: () => void;
|
||||||
|
onPendingUpload?: (file: File) => void;
|
||||||
|
maxSizeMb?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AttachmentUpload({
|
export function AttachmentUpload({
|
||||||
transactionId,
|
transactionId,
|
||||||
seriesId,
|
|
||||||
onUploaded,
|
onUploaded,
|
||||||
|
onPendingUpload,
|
||||||
|
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
|
||||||
}: AttachmentUploadProps) {
|
}: AttachmentUploadProps) {
|
||||||
|
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [applyToSeries, setApplyToSeries] = useState(false);
|
|
||||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
|
||||||
|
|
||||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -49,19 +47,16 @@ export function AttachmentUpload({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > maxFileSizeBytes) {
|
||||||
toast.error("O arquivo deve ter no máximo 50MB.");
|
toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seriesId) {
|
if (onPendingUpload) {
|
||||||
setPendingFile(file);
|
onPendingUpload(file);
|
||||||
} else {
|
return;
|
||||||
uploadFile(file, false);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function uploadFile(file: File, toSeries: boolean) {
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const presignResult = await getPresignedUploadUrlAction({
|
const presignResult = await getPresignedUploadUrlAction({
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
@@ -88,13 +83,10 @@ export function AttachmentUpload({
|
|||||||
|
|
||||||
const confirmResult = await confirmAttachmentUploadAction({
|
const confirmResult = await confirmAttachmentUploadAction({
|
||||||
uploadToken: presignResult.uploadToken,
|
uploadToken: presignResult.uploadToken,
|
||||||
applyToSeries: toSeries,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (confirmResult.success) {
|
if (confirmResult.success) {
|
||||||
toast.success(confirmResult.message);
|
toast.success(confirmResult.message);
|
||||||
setPendingFile(null);
|
|
||||||
setApplyToSeries(false);
|
|
||||||
onUploaded();
|
onUploaded();
|
||||||
} else {
|
} else {
|
||||||
toast.error(confirmResult.error);
|
toast.error(confirmResult.error);
|
||||||
@@ -102,56 +94,6 @@ export function AttachmentUpload({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleConfirmPending() {
|
|
||||||
if (pendingFile) uploadFile(pendingFile, applyToSeries);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCancelPending() {
|
|
||||||
setPendingFile(null);
|
|
||||||
setApplyToSeries(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pendingFile) {
|
|
||||||
return (
|
|
||||||
<div className="min-w-0 space-y-2 rounded-md border border-dashed p-3 text-sm">
|
|
||||||
<div className="min-w-0 overflow-hidden">
|
|
||||||
<p className="truncate font-medium" title={pendingFile.name}>
|
|
||||||
{pendingFile.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id="apply-series"
|
|
||||||
checked={applyToSeries}
|
|
||||||
onCheckedChange={(v) => setApplyToSeries(Boolean(v))}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="apply-series" className="cursor-pointer text-xs">
|
|
||||||
Aplicar a todas as parcelas da série
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleConfirmPending}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
{isPending ? "Enviando..." : "Confirmar"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleCancelPending}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
@@ -172,7 +114,9 @@ export function AttachmentUpload({
|
|||||||
{isPending ? "Enviando..." : "Adicionar anexo"}
|
{isPending ? "Enviando..." : "Adicionar anexo"}
|
||||||
</span>
|
</span>
|
||||||
{!isPending && (
|
{!isPending && (
|
||||||
<span className="text-xs">PDF, JPEG, PNG ou WebP · máx. 50 MB</span>
|
<span className="text-xs">
|
||||||
|
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { RiErrorWarningLine } from "@remixicon/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +14,7 @@ import {
|
|||||||
import { Label } from "@/shared/components/ui/label";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group";
|
||||||
|
|
||||||
export type BulkActionScope = "current" | "future" | "all";
|
export type BulkActionScope = "current" | "period" | "future" | "all";
|
||||||
|
|
||||||
type BulkActionDialogProps = {
|
type BulkActionDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -108,6 +109,30 @@ export function BulkActionDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<RadioGroupItem value="period" id="period" className="mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label
|
||||||
|
htmlFor="period"
|
||||||
|
className="text-sm cursor-pointer font-medium"
|
||||||
|
>
|
||||||
|
Todos os pagadores deste período
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Aplica a todos os lançamentos deste mesmo mês na série
|
||||||
|
</p>
|
||||||
|
{scope === "period" && actionType === "edit" && (
|
||||||
|
<div className="mt-1.5 flex items-start gap-1.5 rounded-md bg-amber-50 px-2 py-1.5 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300">
|
||||||
|
<RiErrorWarningLine className="mt-0.5 size-3.5 shrink-0" />
|
||||||
|
<p className="text-xs">
|
||||||
|
Atenção: os valores individuais de cada pagador serão
|
||||||
|
substituídos pelos valores deste lançamento.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<RadioGroupItem value="future" id="future" className="mt-0.5" />
|
<RadioGroupItem value="future" id="future" className="mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
@@ -223,7 +223,6 @@ export function TransactionDetailsDialog({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<AttachmentSection
|
<AttachmentSection
|
||||||
transactionId={transaction.id}
|
transactionId={transaction.id}
|
||||||
seriesId={transaction.seriesId}
|
|
||||||
readonly
|
readonly
|
||||||
onLoaded={setAttachmentCount}
|
onLoaded={setAttachmentCount}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export interface TransactionDialogProps {
|
|||||||
forceShowTransactionType?: boolean;
|
forceShowTransactionType?: boolean;
|
||||||
/** Called after successful create/update. Receives the action result. */
|
/** Called after successful create/update. Receives the action result. */
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
|
/** Max attachment file size in MB for this user */
|
||||||
|
maxSizeMb?: number;
|
||||||
onBulkEditRequest?: (data: {
|
onBulkEditRequest?: (data: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -42,6 +44,8 @@ export interface TransactionDialogProps {
|
|||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
boletoPaymentDate: string | null;
|
boletoPaymentDate: string | null;
|
||||||
isSettled: boolean | null;
|
isSettled: boolean | null;
|
||||||
|
pendingDetachIds: string[];
|
||||||
|
pendingUploadFiles: File[];
|
||||||
}) => void;
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "@/features/transactions/actions";
|
} from "@/features/transactions/actions";
|
||||||
import {
|
import {
|
||||||
confirmAttachmentUploadAction,
|
confirmAttachmentUploadAction,
|
||||||
|
detachTransactionAttachmentAction,
|
||||||
getPresignedUploadUrlAction,
|
getPresignedUploadUrlAction,
|
||||||
} from "@/features/transactions/actions/attachments";
|
} from "@/features/transactions/actions/attachments";
|
||||||
import {
|
import {
|
||||||
@@ -72,10 +73,11 @@ export function TransactionDialog({
|
|||||||
defaultAmount,
|
defaultAmount,
|
||||||
lockCardSelection,
|
lockCardSelection,
|
||||||
lockPaymentMethod,
|
lockPaymentMethod,
|
||||||
isImporting = false,
|
isImporting,
|
||||||
defaultTransactionType,
|
defaultTransactionType,
|
||||||
forceShowTransactionType = false,
|
forceShowTransactionType,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
|
maxSizeMb,
|
||||||
onBulkEditRequest,
|
onBulkEditRequest,
|
||||||
}: TransactionDialogProps) {
|
}: TransactionDialogProps) {
|
||||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||||
@@ -98,6 +100,8 @@ export function TransactionDialog({
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||||
|
const [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]);
|
||||||
|
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dialogOpen) {
|
if (dialogOpen) {
|
||||||
@@ -136,6 +140,8 @@ export function TransactionDialog({
|
|||||||
setFormState(initial);
|
setFormState(initial);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
setPendingFile(null);
|
setPendingFile(null);
|
||||||
|
setPendingDetachIds([]);
|
||||||
|
setPendingUploadFiles([]);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
dialogOpen,
|
dialogOpen,
|
||||||
@@ -342,7 +348,7 @@ export function TransactionDialog({
|
|||||||
});
|
});
|
||||||
await confirmAttachmentUploadAction({
|
await confirmAttachmentUploadAction({
|
||||||
uploadToken: presign.uploadToken,
|
uploadToken: presign.uploadToken,
|
||||||
applyToSeries: isNewSeries,
|
scope: isNewSeries ? "all" : "current",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -357,11 +363,11 @@ export function TransactionDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update mode
|
|
||||||
const hasSeriesId = Boolean(transaction?.seriesId);
|
const hasSeriesId = Boolean(transaction?.seriesId);
|
||||||
|
|
||||||
if (hasSeriesId && onBulkEditRequest) {
|
if (hasSeriesId && onBulkEditRequest) {
|
||||||
// Para lançamentos em série, abre o diálogo de bulk action
|
// Para lançamentos em série, passa os arquivos para a página confirmar
|
||||||
|
// o upload após o escopo ser escolhido (sem upload antecipado ao S3)
|
||||||
onBulkEditRequest({
|
onBulkEditRequest({
|
||||||
id: transaction?.id ?? "",
|
id: transaction?.id ?? "",
|
||||||
name: formState.name.trim(),
|
name: formState.name.trim(),
|
||||||
@@ -383,11 +389,13 @@ export function TransactionDialog({
|
|||||||
formState.paymentMethod === "Cartão de crédito"
|
formState.paymentMethod === "Cartão de crédito"
|
||||||
? null
|
? null
|
||||||
: Boolean(formState.isSettled),
|
: Boolean(formState.isSettled),
|
||||||
|
pendingDetachIds,
|
||||||
|
pendingUploadFiles,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atualização normal para lançamentos únicos ou todos os campos
|
// Atualização normal para lançamentos únicos
|
||||||
const updatePayload: UpdateTransactionInput = {
|
const updatePayload: UpdateTransactionInput = {
|
||||||
id: transaction?.id ?? "",
|
id: transaction?.id ?? "",
|
||||||
...payload,
|
...payload,
|
||||||
@@ -396,6 +404,31 @@ export function TransactionDialog({
|
|||||||
const result = await updateTransactionAction(updatePayload);
|
const result = await updateTransactionAction(updatePayload);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
for (const attachmentId of pendingDetachIds) {
|
||||||
|
await detachTransactionAttachmentAction({
|
||||||
|
attachmentId,
|
||||||
|
transactionId: transaction?.id ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const file of pendingUploadFiles) {
|
||||||
|
const presign = await getPresignedUploadUrlAction({
|
||||||
|
fileName: file.name,
|
||||||
|
mimeType: file.type,
|
||||||
|
fileSize: file.size,
|
||||||
|
transactionId: transaction?.id ?? "",
|
||||||
|
});
|
||||||
|
if (presign.success) {
|
||||||
|
await fetch(presign.presignedUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
body: file,
|
||||||
|
headers: { "Content-Type": file.type },
|
||||||
|
});
|
||||||
|
await confirmAttachmentUploadAction({
|
||||||
|
uploadToken: presign.uploadToken,
|
||||||
|
scope: "current",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
@@ -521,7 +554,40 @@ export function TransactionDialog({
|
|||||||
</Label>
|
</Label>
|
||||||
<AttachmentSection
|
<AttachmentSection
|
||||||
transactionId={transaction?.id ?? ""}
|
transactionId={transaction?.id ?? ""}
|
||||||
seriesId={transaction?.seriesId ?? null}
|
maxSizeMb={maxSizeMb}
|
||||||
|
pendingDetachIds={
|
||||||
|
transaction?.seriesId ? pendingDetachIds : undefined
|
||||||
|
}
|
||||||
|
onPendingDetach={
|
||||||
|
transaction?.seriesId
|
||||||
|
? (id) => setPendingDetachIds((prev) => [...prev, id])
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onUndoPendingDetach={
|
||||||
|
transaction?.seriesId
|
||||||
|
? (id) =>
|
||||||
|
setPendingDetachIds((prev) =>
|
||||||
|
prev.filter((x) => x !== id),
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
pendingUploadFiles={
|
||||||
|
transaction?.seriesId ? pendingUploadFiles : undefined
|
||||||
|
}
|
||||||
|
onPendingUpload={
|
||||||
|
transaction?.seriesId
|
||||||
|
? (file) =>
|
||||||
|
setPendingUploadFiles((prev) => [...prev, file])
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onCancelPendingUpload={
|
||||||
|
transaction?.seriesId
|
||||||
|
? (file) =>
|
||||||
|
setPendingUploadFiles((prev) =>
|
||||||
|
prev.filter((f) => f !== file),
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -548,6 +614,7 @@ export function TransactionDialog({
|
|||||||
<AttachmentFilePicker
|
<AttachmentFilePicker
|
||||||
file={pendingFile}
|
file={pendingFile}
|
||||||
onChange={setPendingFile}
|
onChange={setPendingFile}
|
||||||
|
maxSizeMb={maxSizeMb}
|
||||||
/>
|
/>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import {
|
|||||||
toggleTransactionSettlementAction,
|
toggleTransactionSettlementAction,
|
||||||
updateTransactionBulkAction,
|
updateTransactionBulkAction,
|
||||||
} from "@/features/transactions/actions";
|
} from "@/features/transactions/actions";
|
||||||
|
import {
|
||||||
|
confirmAttachmentUploadAction,
|
||||||
|
detachAttachmentBulkAction,
|
||||||
|
getPresignedUploadUrlAction,
|
||||||
|
} from "@/features/transactions/actions/attachments";
|
||||||
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
||||||
import type {
|
import type {
|
||||||
TransactionsExportContext,
|
TransactionsExportContext,
|
||||||
@@ -59,6 +64,7 @@ interface TransactionsPageProps {
|
|||||||
lockPaymentMethod?: boolean;
|
lockPaymentMethod?: boolean;
|
||||||
pagination?: TransactionsPaginationState;
|
pagination?: TransactionsPaginationState;
|
||||||
exportContext?: TransactionsExportContext;
|
exportContext?: TransactionsExportContext;
|
||||||
|
attachmentMaxSizeMb?: number;
|
||||||
// Opções específicas para o dialog de importação (quando visualizando dados de outro usuário)
|
// Opções específicas para o dialog de importação (quando visualizando dados de outro usuário)
|
||||||
importPayerOptions?: SelectOption[];
|
importPayerOptions?: SelectOption[];
|
||||||
importSplitPayerOptions?: SelectOption[];
|
importSplitPayerOptions?: SelectOption[];
|
||||||
@@ -91,6 +97,7 @@ export function TransactionsPage({
|
|||||||
lockPaymentMethod,
|
lockPaymentMethod,
|
||||||
pagination,
|
pagination,
|
||||||
exportContext,
|
exportContext,
|
||||||
|
attachmentMaxSizeMb,
|
||||||
importPayerOptions,
|
importPayerOptions,
|
||||||
importSplitPayerOptions,
|
importSplitPayerOptions,
|
||||||
importDefaultPayerId,
|
importDefaultPayerId,
|
||||||
@@ -130,6 +137,8 @@ export function TransactionsPage({
|
|||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
boletoPaymentDate: string | null;
|
boletoPaymentDate: string | null;
|
||||||
isSettled: boolean | null;
|
isSettled: boolean | null;
|
||||||
|
pendingDetachIds: string[];
|
||||||
|
pendingUploadFiles: File[];
|
||||||
transaction: TransactionItem;
|
transaction: TransactionItem;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [pendingDeleteData, setPendingDeleteData] =
|
const [pendingDeleteData, setPendingDeleteData] =
|
||||||
@@ -246,6 +255,8 @@ export function TransactionsPage({
|
|||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
boletoPaymentDate: string | null;
|
boletoPaymentDate: string | null;
|
||||||
isSettled: boolean | null;
|
isSettled: boolean | null;
|
||||||
|
pendingDetachIds: string[];
|
||||||
|
pendingUploadFiles: File[];
|
||||||
}) => {
|
}) => {
|
||||||
if (!selectedTransaction) {
|
if (!selectedTransaction) {
|
||||||
return;
|
return;
|
||||||
@@ -284,6 +295,36 @@ export function TransactionsPage({
|
|||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Propaga remoções de anexo pendentes com o mesmo escopo
|
||||||
|
for (const attachmentId of pendingEditData.pendingDetachIds) {
|
||||||
|
await detachAttachmentBulkAction({
|
||||||
|
attachmentId,
|
||||||
|
transactionId: pendingEditData.id,
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Faz upload dos arquivos pendentes e confirma com o escopo escolhido
|
||||||
|
for (const file of pendingEditData.pendingUploadFiles) {
|
||||||
|
const presign = await getPresignedUploadUrlAction({
|
||||||
|
fileName: file.name,
|
||||||
|
mimeType: file.type,
|
||||||
|
fileSize: file.size,
|
||||||
|
transactionId: pendingEditData.id,
|
||||||
|
});
|
||||||
|
if (presign.success) {
|
||||||
|
await fetch(presign.presignedUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
body: file,
|
||||||
|
headers: { "Content-Type": file.type },
|
||||||
|
});
|
||||||
|
await confirmAttachmentUploadAction({
|
||||||
|
uploadToken: presign.uploadToken,
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
setBulkEditOpen(false);
|
setBulkEditOpen(false);
|
||||||
setPendingEditData(null);
|
setPendingEditData(null);
|
||||||
@@ -438,6 +479,7 @@ export function TransactionsPage({
|
|||||||
lockCardSelection={lockCardSelection}
|
lockCardSelection={lockCardSelection}
|
||||||
lockPaymentMethod={lockPaymentMethod}
|
lockPaymentMethod={lockPaymentMethod}
|
||||||
defaultTransactionType={transactionTypeForCreate ?? undefined}
|
defaultTransactionType={transactionTypeForCreate ?? undefined}
|
||||||
|
maxSizeMb={attachmentMaxSizeMb}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -459,6 +501,7 @@ export function TransactionsPage({
|
|||||||
estabelecimentos={estabelecimentos}
|
estabelecimentos={estabelecimentos}
|
||||||
transaction={transactionToCopy ?? undefined}
|
transaction={transactionToCopy ?? undefined}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
|
maxSizeMb={attachmentMaxSizeMb}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TransactionDialog
|
<TransactionDialog
|
||||||
@@ -480,6 +523,7 @@ export function TransactionsPage({
|
|||||||
transaction={transactionToImport ?? undefined}
|
transaction={transactionToImport ?? undefined}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
isImporting={true}
|
isImporting={true}
|
||||||
|
maxSizeMb={attachmentMaxSizeMb}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BulkImportDialog
|
<BulkImportDialog
|
||||||
@@ -507,6 +551,7 @@ export function TransactionsPage({
|
|||||||
transaction={selectedTransaction ?? undefined}
|
transaction={selectedTransaction ?? undefined}
|
||||||
defaultPeriod={selectedPeriod}
|
defaultPeriod={selectedPeriod}
|
||||||
onBulkEditRequest={handleBulkEditRequest}
|
onBulkEditRequest={handleBulkEditRequest}
|
||||||
|
maxSizeMb={attachmentMaxSizeMb}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TransactionDetailsDialog
|
<TransactionDetailsDialog
|
||||||
|
|||||||
Reference in New Issue
Block a user