feat(transactions): suporte a Ctrl+V em anexos e melhorias de UX no modal

This commit is contained in:
Felipe Coutinho
2026-05-14 19:13:26 +00:00
parent 86bcffec66
commit 246bb14a00
5 changed files with 181 additions and 50 deletions

View File

@@ -1,13 +1,18 @@
"use client"; "use client";
import { RiAttachment2, RiCloseLine } from "@remixicon/react"; import { RiAttachment2, RiCloseLine } from "@remixicon/react";
import { useRef } from "react"; import { useEffect, useRef } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
ALLOWED_MIME_TYPES, ALLOWED_MIME_TYPES,
DEFAULT_MAX_FILE_SIZE_MB, DEFAULT_MAX_FILE_SIZE_MB,
} from "@/features/transactions/lib/attachments-config"; } from "@/features/transactions/lib/attachments-config";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import {
getFilesFromClipboard,
isTextEditingTarget,
validateAttachmentFile,
} from "./attachment-file-utils";
interface AttachmentFilePickerProps { interface AttachmentFilePickerProps {
files: File[]; files: File[];
@@ -22,33 +27,53 @@ export function AttachmentFilePicker({
onRemove, onRemove,
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB, maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
}: AttachmentFilePickerProps) { }: AttachmentFilePickerProps) {
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
function addFile(file: File) {
const validation = validateAttachmentFile(file, maxSizeMb);
if (!validation.ok) {
toast.error(validation.error);
return;
}
onAdd(file);
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) { function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const selected = e.target.files?.[0]; const selected = e.target.files?.[0];
if (inputRef.current) inputRef.current.value = ""; if (inputRef.current) inputRef.current.value = "";
if (!selected) return; if (!selected) return;
if ( addFile(selected);
!ALLOWED_MIME_TYPES.includes(
selected.type as (typeof ALLOWED_MIME_TYPES)[number],
)
) {
toast.error(
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
);
return;
} }
if (selected.size > maxFileSizeBytes) { function handlePaste(event: React.ClipboardEvent<HTMLButtonElement>) {
toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`); const pastedFiles = getFilesFromClipboard(event);
return; if (pastedFiles.length === 0) return;
event.preventDefault();
for (const file of pastedFiles) {
addFile(file);
}
} }
onAdd(selected); useEffect(() => {
function handleDocumentPaste(event: ClipboardEvent) {
if (isTextEditingTarget(event.target)) return;
const pastedFiles = getFilesFromClipboard(event);
if (pastedFiles.length === 0) return;
event.preventDefault();
for (const file of pastedFiles) {
addFile(file);
} }
}
document.addEventListener("paste", handleDocumentPaste);
return () => document.removeEventListener("paste", handleDocumentPaste);
});
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -90,13 +115,15 @@ export function AttachmentFilePicker({
type="button" type="button"
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground" className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
onPaste={handlePaste}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<RiAttachment2 className="size-4" /> <RiAttachment2 className="size-4" />
Adicionar anexo Adicionar anexo
</span> </span>
<span className="text-xs"> <span className="text-xs">
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB PDF, JPEG, PNG ou WebP · cole ou busque o arquivo · máx. {maxSizeMb}{" "}
MB
</span> </span>
</button> </button>
</div> </div>

View File

@@ -0,0 +1,54 @@
import {
ALLOWED_MIME_TYPES,
DEFAULT_MAX_FILE_SIZE_MB,
} from "@/features/transactions/lib/attachments-config";
type AttachmentValidationResult = { ok: true } | { ok: false; error: string };
export function validateAttachmentFile(
file: File,
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
): AttachmentValidationResult {
if (
!ALLOWED_MIME_TYPES.includes(
file.type as (typeof ALLOWED_MIME_TYPES)[number],
)
) {
return {
ok: false,
error:
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
};
}
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
if (file.size > maxFileSizeBytes) {
return { ok: false, error: `O arquivo deve ter no máximo ${maxSizeMb}MB.` };
}
return { ok: true };
}
type ClipboardLikeEvent = ClipboardEvent | React.ClipboardEvent;
export function getFilesFromClipboard(event: ClipboardLikeEvent): File[] {
const files = Array.from(event.clipboardData?.files ?? []);
if (files.length > 0) return files;
return Array.from(event.clipboardData?.items ?? [])
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
}
export function isTextEditingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tagName = target.tagName.toLowerCase();
return (
tagName === "input" ||
tagName === "textarea" ||
target.isContentEditable ||
target.closest('[contenteditable="true"]') !== null
);
}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { RiAttachment2 } from "@remixicon/react"; import { RiAttachment2 } from "@remixicon/react";
import { useRef, useTransition } from "react"; import { useEffect, useRef, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
confirmAttachmentUploadAction, confirmAttachmentUploadAction,
@@ -11,6 +11,11 @@ import {
ALLOWED_MIME_TYPES, ALLOWED_MIME_TYPES,
DEFAULT_MAX_FILE_SIZE_MB, DEFAULT_MAX_FILE_SIZE_MB,
} from "@/features/transactions/lib/attachments-config"; } from "@/features/transactions/lib/attachments-config";
import {
getFilesFromClipboard,
isTextEditingTarget,
validateAttachmentFile,
} from "./attachment-file-utils";
interface AttachmentUploadProps { interface AttachmentUploadProps {
transactionId: string; transactionId: string;
@@ -25,7 +30,6 @@ export function AttachmentUpload({
onPendingUpload, onPendingUpload,
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB, 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();
@@ -36,19 +40,13 @@ export function AttachmentUpload({
if (!file) return; if (!file) return;
if ( handleFile(file);
!ALLOWED_MIME_TYPES.includes(
file.type as (typeof ALLOWED_MIME_TYPES)[number],
)
) {
toast.error(
"Tipo de arquivo não suportado. Use PDF ou imagem (JPEG, PNG, WebP).",
);
return;
} }
if (file.size > maxFileSizeBytes) { function handleFile(file: File) {
toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`); const validation = validateAttachmentFile(file, maxSizeMb);
if (!validation.ok) {
toast.error(validation.error);
return; return;
} }
@@ -94,6 +92,29 @@ export function AttachmentUpload({
}); });
} }
function handlePaste(event: React.ClipboardEvent<HTMLButtonElement>) {
const [file] = getFilesFromClipboard(event);
if (!file) return;
event.preventDefault();
handleFile(file);
}
useEffect(() => {
function handleDocumentPaste(event: ClipboardEvent) {
if (isPending || isTextEditingTarget(event.target)) return;
const [file] = getFilesFromClipboard(event);
if (!file) return;
event.preventDefault();
handleFile(file);
}
document.addEventListener("paste", handleDocumentPaste);
return () => document.removeEventListener("paste", handleDocumentPaste);
});
return ( return (
<> <>
<input <input
@@ -107,6 +128,7 @@ export function AttachmentUpload({
type="button" type="button"
className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground disabled:pointer-events-none disabled:opacity-50" className="flex w-full cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-dashed py-4 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
onPaste={handlePaste}
disabled={isPending} disabled={isPending}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
@@ -115,7 +137,8 @@ export function AttachmentUpload({
</span> </span>
{!isPending && ( {!isPending && (
<span className="text-xs"> <span className="text-xs">
PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB PDF, JPEG, PNG ou WebP · cole ou busque o arquivo · máx. {maxSizeMb}{" "}
MB
</span> </span>
)} )}
</button> </button>

View File

@@ -10,9 +10,9 @@ import {
import { import {
currencyFormatter, currencyFormatter,
formatCondition, formatCondition,
formatDate,
formatPeriod, formatPeriod,
} from "@/features/transactions/lib/formatting-helpers"; } from "@/features/transactions/lib/formatting-helpers";
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge"; import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge";
import { import {
Avatar, Avatar,
@@ -34,7 +34,7 @@ import { Separator } from "@/shared/components/ui/separator";
import { resolveLogoSrc } from "@/shared/lib/logo"; import { resolveLogoSrc } from "@/shared/lib/logo";
import { getAvatarSrc } from "@/shared/lib/payers/utils"; import { getAvatarSrc } from "@/shared/lib/payers/utils";
import { getCategoryColorFromName } from "@/shared/utils/category-colors"; import { getCategoryColorFromName } from "@/shared/utils/category-colors";
import { parseLocalDateString } from "@/shared/utils/date"; import { formatDate, parseLocalDateString } from "@/shared/utils/date";
import { getIconComponent, getPaymentMethodIcon } from "@/shared/utils/icons"; import { getIconComponent, getPaymentMethodIcon } from "@/shared/utils/icons";
import { AttachmentSection } from "../attachments/attachment-section"; import { AttachmentSection } from "../attachments/attachment-section";
import { InstallmentTimeline } from "../shared/installment-timeline"; import { InstallmentTimeline } from "../shared/installment-timeline";
@@ -55,10 +55,9 @@ export function TransactionDetailsDialog({
}: TransactionDetailsDialogProps) { }: TransactionDetailsDialogProps) {
const [attachmentCount, setAttachmentCount] = useState<number | null>(null); const [attachmentCount, setAttachmentCount] = useState<number | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: transaction?.id é trigger intencional para reset do contador
useEffect(() => { useEffect(() => {
setAttachmentCount(null); setAttachmentCount(null);
}, [transaction?.id]); }, []);
if (!transaction) return null; if (!transaction) return null;
@@ -87,11 +86,16 @@ export function TransactionDetailsDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="min-w-0 overflow-x-hidden sm:max-w-xl"> <DialogContent className="min-w-0 overflow-x-hidden sm:max-w-xl">
<DialogHeader> <DialogHeader className="text-left">
<DialogTitle>{transaction.name}</DialogTitle> <div className="flex min-w-0 items-start gap-2">
<DialogDescription> <EstablishmentLogo size={40} name={transaction.name} />
<div className="min-w-0">
<DialogTitle className="truncate">{transaction.name}</DialogTitle>
<DialogDescription className="mt-1">
{formatDate(transaction.purchaseDate)} {formatDate(transaction.purchaseDate)}
</DialogDescription> </DialogDescription>
</div>
</div>
</DialogHeader> </DialogHeader>
<div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm"> <div className="min-w-0 max-h-[60vh] overflow-x-hidden overflow-y-auto text-sm">

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { RiArrowDropDownLine } from "@remixicon/react"; import { RiArrowDropDownLine } from "@remixicon/react";
import { useEffect, useMemo, useState, useTransition } from "react"; import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
createTransactionAction, createTransactionAction,
@@ -102,6 +102,8 @@ export function TransactionDialog({
const [pendingFiles, setPendingFiles] = useState<File[]>([]); const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]); const [pendingDetachIds, setPendingDetachIds] = useState<string[]>([]);
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]); const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([]);
const [extrasOpen, setExtrasOpen] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (dialogOpen) { if (dialogOpen) {
@@ -142,6 +144,7 @@ export function TransactionDialog({
setPendingFiles([]); setPendingFiles([]);
setPendingDetachIds([]); setPendingDetachIds([]);
setPendingUploadFiles([]); setPendingUploadFiles([]);
setExtrasOpen(initial.condition !== "À vista");
} }
}, [ }, [
dialogOpen, dialogOpen,
@@ -211,6 +214,22 @@ export function TransactionDialog({
}); });
} }
function handleExtrasOpenChange(nextOpen: boolean) {
setExtrasOpen(nextOpen);
if (nextOpen) {
requestAnimationFrame(() => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) return;
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: "smooth",
});
});
}
}
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => { const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
setErrorMessage(null); setErrorMessage(null);
@@ -527,18 +546,21 @@ export function TransactionDialog({
return ( return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null} {trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
<DialogContent className="min-w-0 overflow-x-hidden"> <DialogContent className="flex max-h-[90vh] min-w-0 flex-col overflow-hidden p-4 sm:p-10">
<DialogHeader> <DialogHeader>
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription> <DialogDescription>{description}</DialogDescription>
</DialogHeader> </DialogHeader>
<form <form
className="flex min-w-0 flex-col gap-0" className="flex min-h-0 min-w-0 flex-1 flex-col gap-0"
onSubmit={handleSubmit} onSubmit={handleSubmit}
noValidate noValidate
> >
<div className="min-w-0 -mx-6 max-h-[90vh] overflow-x-hidden overflow-y-auto px-6 pb-1"> <div
ref={scrollContainerRef}
className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain pr-1 pb-1"
>
{/* Detalhes */} {/* Detalhes */}
<div className="space-y-3"> <div className="space-y-3">
<BasicFieldsSection <BasicFieldsSection
@@ -634,7 +656,8 @@ export function TransactionDialog({
</> </>
) : ( ) : (
<Collapsible <Collapsible
defaultOpen={formState.condition !== "À vista"} open={extrasOpen}
onOpenChange={handleExtrasOpenChange}
className="min-w-0" className="min-w-0"
> >
<CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4"> <CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4">
@@ -680,7 +703,7 @@ export function TransactionDialog({
<p className="mt-3 text-sm text-destructive">{errorMessage}</p> <p className="mt-3 text-sm text-destructive">{errorMessage}</p>
) : null} ) : null}
<DialogFooter className="mt-4"> <DialogFooter className="mt-4 shrink-0">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"