diff --git a/src/features/transactions/components/attachments/attachment-file-picker.tsx b/src/features/transactions/components/attachments/attachment-file-picker.tsx index 94fce57..927c331 100644 --- a/src/features/transactions/components/attachments/attachment-file-picker.tsx +++ b/src/features/transactions/components/attachments/attachment-file-picker.tsx @@ -1,13 +1,18 @@ "use client"; import { RiAttachment2, RiCloseLine } from "@remixicon/react"; -import { useRef } from "react"; +import { useEffect, useRef } from "react"; import { toast } from "sonner"; import { ALLOWED_MIME_TYPES, DEFAULT_MAX_FILE_SIZE_MB, } from "@/features/transactions/lib/attachments-config"; import { Button } from "@/shared/components/ui/button"; +import { + getFilesFromClipboard, + isTextEditingTarget, + validateAttachmentFile, +} from "./attachment-file-utils"; interface AttachmentFilePickerProps { files: File[]; @@ -22,34 +27,54 @@ export function AttachmentFilePicker({ onRemove, maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB, }: AttachmentFilePickerProps) { - const maxFileSizeBytes = maxSizeMb * 1024 * 1024; const inputRef = useRef(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) { const selected = e.target.files?.[0]; if (inputRef.current) inputRef.current.value = ""; if (!selected) return; - if ( - !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) { - toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`); - return; - } - - onAdd(selected); + addFile(selected); } + function handlePaste(event: React.ClipboardEvent) { + const pastedFiles = getFilesFromClipboard(event); + if (pastedFiles.length === 0) return; + + event.preventDefault(); + for (const file of pastedFiles) { + addFile(file); + } + } + + 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 (

Anexos

@@ -90,13 +115,15 @@ export function AttachmentFilePicker({ 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" onClick={() => inputRef.current?.click()} + onPaste={handlePaste} > Adicionar anexo - PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB + PDF, JPEG, PNG ou WebP · cole ou busque o arquivo · máx. {maxSizeMb}{" "} + MB
diff --git a/src/features/transactions/components/attachments/attachment-file-utils.ts b/src/features/transactions/components/attachments/attachment-file-utils.ts new file mode 100644 index 0000000..6909efc --- /dev/null +++ b/src/features/transactions/components/attachments/attachment-file-utils.ts @@ -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 + ); +} diff --git a/src/features/transactions/components/attachments/attachment-upload.tsx b/src/features/transactions/components/attachments/attachment-upload.tsx index e3101bc..869118b 100644 --- a/src/features/transactions/components/attachments/attachment-upload.tsx +++ b/src/features/transactions/components/attachments/attachment-upload.tsx @@ -1,7 +1,7 @@ "use client"; import { RiAttachment2 } from "@remixicon/react"; -import { useRef, useTransition } from "react"; +import { useEffect, useRef, useTransition } from "react"; import { toast } from "sonner"; import { confirmAttachmentUploadAction, @@ -11,6 +11,11 @@ import { ALLOWED_MIME_TYPES, DEFAULT_MAX_FILE_SIZE_MB, } from "@/features/transactions/lib/attachments-config"; +import { + getFilesFromClipboard, + isTextEditingTarget, + validateAttachmentFile, +} from "./attachment-file-utils"; interface AttachmentUploadProps { transactionId: string; @@ -25,7 +30,6 @@ export function AttachmentUpload({ onPendingUpload, maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB, }: AttachmentUploadProps) { - const maxFileSizeBytes = maxSizeMb * 1024 * 1024; const inputRef = useRef(null); const [isPending, startTransition] = useTransition(); @@ -36,19 +40,13 @@ export function AttachmentUpload({ if (!file) return; - if ( - !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; - } + handleFile(file); + } - if (file.size > maxFileSizeBytes) { - toast.error(`O arquivo deve ter no máximo ${maxSizeMb}MB.`); + function handleFile(file: File) { + const validation = validateAttachmentFile(file, maxSizeMb); + if (!validation.ok) { + toast.error(validation.error); return; } @@ -94,6 +92,29 @@ export function AttachmentUpload({ }); } + function handlePaste(event: React.ClipboardEvent) { + 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 ( <> inputRef.current?.click()} + onPaste={handlePaste} disabled={isPending} > @@ -115,7 +137,8 @@ export function AttachmentUpload({ {!isPending && ( - PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB + PDF, JPEG, PNG ou WebP · cole ou busque o arquivo · máx. {maxSizeMb}{" "} + MB )} diff --git a/src/features/transactions/components/dialogs/transaction-details-dialog.tsx b/src/features/transactions/components/dialogs/transaction-details-dialog.tsx index 3ea36b0..8aa47d7 100644 --- a/src/features/transactions/components/dialogs/transaction-details-dialog.tsx +++ b/src/features/transactions/components/dialogs/transaction-details-dialog.tsx @@ -10,9 +10,9 @@ import { import { currencyFormatter, formatCondition, - formatDate, formatPeriod, } from "@/features/transactions/lib/formatting-helpers"; +import { EstablishmentLogo } from "@/shared/components/entity-avatar"; import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge"; import { Avatar, @@ -34,7 +34,7 @@ import { Separator } from "@/shared/components/ui/separator"; import { resolveLogoSrc } from "@/shared/lib/logo"; import { getAvatarSrc } from "@/shared/lib/payers/utils"; 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 { AttachmentSection } from "../attachments/attachment-section"; import { InstallmentTimeline } from "../shared/installment-timeline"; @@ -55,10 +55,9 @@ export function TransactionDetailsDialog({ }: TransactionDetailsDialogProps) { const [attachmentCount, setAttachmentCount] = useState(null); - // biome-ignore lint/correctness/useExhaustiveDependencies: transaction?.id é trigger intencional para reset do contador useEffect(() => { setAttachmentCount(null); - }, [transaction?.id]); + }, []); if (!transaction) return null; @@ -87,11 +86,16 @@ export function TransactionDetailsDialog({ return ( - - {transaction.name} - - {formatDate(transaction.purchaseDate)} - + +
+ +
+ {transaction.name} + + {formatDate(transaction.purchaseDate)} + +
+
diff --git a/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog.tsx b/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog.tsx index eb98ed6..3ff83ad 100644 --- a/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog.tsx +++ b/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog.tsx @@ -1,6 +1,6 @@ "use client"; 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 { createTransactionAction, @@ -102,6 +102,8 @@ export function TransactionDialog({ const [pendingFiles, setPendingFiles] = useState([]); const [pendingDetachIds, setPendingDetachIds] = useState([]); const [pendingUploadFiles, setPendingUploadFiles] = useState([]); + const [extrasOpen, setExtrasOpen] = useState(false); + const scrollContainerRef = useRef(null); useEffect(() => { if (dialogOpen) { @@ -142,6 +144,7 @@ export function TransactionDialog({ setPendingFiles([]); setPendingDetachIds([]); setPendingUploadFiles([]); + setExtrasOpen(initial.condition !== "À vista"); } }, [ 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) => { event.preventDefault(); setErrorMessage(null); @@ -527,18 +546,21 @@ export function TransactionDialog({ return ( {trigger ? {trigger} : null} - + {title} {description}
-
+
{/* Detalhes */}
) : ( @@ -680,7 +703,7 @@ export function TransactionDialog({

{errorMessage}

) : null} - +