feat(preferências): configuração de tamanho máximo de anexo por arquivo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-03-30 18:46:28 +00:00
parent 6ce132fe0c
commit 59b4dea071
10 changed files with 91 additions and 16 deletions

View File

@@ -190,6 +190,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate={false} allowCreate={false}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/> />
</section> </section>
</main> </main>

View File

@@ -202,6 +202,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate allowCreate
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
defaultCardId={card.id} defaultCardId={card.id}
defaultPaymentMethod="Cartão de crédito" defaultPaymentMethod="Cartão de crédito"
lockCardSelection lockCardSelection

View File

@@ -99,6 +99,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate={true} allowCreate={true}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/> />
</main> </main>
); );

View File

@@ -390,6 +390,7 @@ export default async function Page({ params, searchParams }: PageProps) {
allowCreate={canEdit} allowCreate={canEdit}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
importPayerOptions={loggedUserOptionSets?.payerOptions} importPayerOptions={loggedUserOptionSets?.payerOptions}
importSplitPayerOptions={loggedUserOptionSets?.splitPayerOptions} importSplitPayerOptions={loggedUserOptionSets?.splitPayerOptions}
importDefaultPayerId={loggedUserOptionSets?.defaultPayerId} importDefaultPayerId={loggedUserOptionSets?.defaultPayerId}

View File

@@ -102,6 +102,7 @@ export default async function Page({ searchParams }: PageProps) {
}} }}
noteAsColumn={userPreferences?.statementNoteAsColumn ?? false} noteAsColumn={userPreferences?.statementNoteAsColumn ?? false}
columnOrder={userPreferences?.transactionsColumnOrder ?? null} columnOrder={userPreferences?.transactionsColumnOrder ?? null}
attachmentMaxSizeMb={userPreferences?.attachmentMaxSizeMb ?? 50}
/> />
</main> </main>
); );

View File

@@ -65,6 +65,7 @@ const resetAccountSchema = z.object({
const updatePreferencesSchema = z.object({ const updatePreferencesSchema = z.object({
statementNoteAsColumn: z.boolean(), statementNoteAsColumn: z.boolean(),
transactionsColumnOrder: z.array(z.string()).nullable(), transactionsColumnOrder: z.array(z.string()).nullable(),
attachmentMaxSizeMb: z.number().int().min(1).max(100),
}); });
type ResettableUser = { type ResettableUser = {
@@ -561,6 +562,7 @@ export async function updatePreferencesAction(
.set({ .set({
statementNoteAsColumn: validated.statementNoteAsColumn, statementNoteAsColumn: validated.statementNoteAsColumn,
transactionsColumnOrder: validated.transactionsColumnOrder, transactionsColumnOrder: validated.transactionsColumnOrder,
attachmentMaxSizeMb: validated.attachmentMaxSizeMb,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(schema.userPreferences.userId, session.user.id)); .where(eq(schema.userPreferences.userId, session.user.id));
@@ -570,6 +572,7 @@ export async function updatePreferencesAction(
userId: session.user.id, userId: session.user.id,
statementNoteAsColumn: validated.statementNoteAsColumn, statementNoteAsColumn: validated.statementNoteAsColumn,
transactionsColumnOrder: validated.transactionsColumnOrder, transactionsColumnOrder: validated.transactionsColumnOrder,
attachmentMaxSizeMb: validated.attachmentMaxSizeMb,
}); });
} }

View File

@@ -21,17 +21,27 @@ import { useRouter } from "next/navigation";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { updatePreferencesAction } from "@/features/settings/actions"; import { updatePreferencesAction } from "@/features/settings/actions";
import {
ATTACHMENT_SIZE_OPTIONS,
type AttachmentSizeOption,
} from "@/features/transactions/attachments-config";
import { import {
DEFAULT_LANCAMENTOS_COLUMN_ORDER, DEFAULT_LANCAMENTOS_COLUMN_ORDER,
LANCAMENTOS_COLUMN_LABELS, LANCAMENTOS_COLUMN_LABELS,
} from "@/features/transactions/column-order"; } from "@/features/transactions/column-order";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Label } from "@/shared/components/ui/label"; import { Label } from "@/shared/components/ui/label";
import { Separator } from "@/shared/components/ui/separator";
import { Switch } from "@/shared/components/ui/switch"; import { Switch } from "@/shared/components/ui/switch";
import {
ToggleGroup,
ToggleGroupItem,
} from "@/shared/components/ui/toggle-group";
interface PreferencesFormProps { interface PreferencesFormProps {
statementNoteAsColumn: boolean; statementNoteAsColumn: boolean;
transactionsColumnOrder: string[] | null; transactionsColumnOrder: string[] | null;
attachmentMaxSizeMb: number;
} }
function SortableColumnItem({ id }: { id: string }) { function SortableColumnItem({ id }: { id: string }) {
@@ -74,6 +84,7 @@ function SortableColumnItem({ id }: { id: string }) {
export function PreferencesForm({ export function PreferencesForm({
statementNoteAsColumn: initialExtratoNoteAsColumn, statementNoteAsColumn: initialExtratoNoteAsColumn,
transactionsColumnOrder: initialColumnOrder, transactionsColumnOrder: initialColumnOrder,
attachmentMaxSizeMb: initialAttachmentMaxSizeMb,
}: PreferencesFormProps) { }: PreferencesFormProps) {
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@@ -85,6 +96,14 @@ export function PreferencesForm({
? initialColumnOrder ? initialColumnOrder
: DEFAULT_LANCAMENTOS_COLUMN_ORDER, : DEFAULT_LANCAMENTOS_COLUMN_ORDER,
); );
const [attachmentMaxSizeMb, setAttachmentMaxSizeMb] =
useState<AttachmentSizeOption>(
(ATTACHMENT_SIZE_OPTIONS.includes(
initialAttachmentMaxSizeMb as AttachmentSizeOption,
)
? initialAttachmentMaxSizeMb
: 50) as AttachmentSizeOption,
);
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
@@ -109,6 +128,7 @@ export function PreferencesForm({
const result = await updatePreferencesAction({ const result = await updatePreferencesAction({
statementNoteAsColumn, statementNoteAsColumn,
transactionsColumnOrder: columnOrder, transactionsColumnOrder: columnOrder,
attachmentMaxSizeMb,
}); });
if (result.success) { if (result.success) {
@@ -122,19 +142,18 @@ export function PreferencesForm({
return ( return (
<form onSubmit={handleSubmit} className="flex flex-col gap-8"> <form onSubmit={handleSubmit} className="flex flex-col gap-8">
{/* Seção: Extrato / Lançamentos */} {/* Seção: Lançamentos */}
<section className="space-y-4"> <section className="space-y-4">
<div> <div>
<h3 className="text-base font-semibold">Extrato e lançamentos</h3> <h3 className="text-base font-semibold">Lançamentos</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Como exibir anotações e a ordem das colunas na tabela de Configurações de exibição da tabela de movimentações.
movimentações.
</p> </p>
</div> </div>
<div className="flex items-center justify-between rounded-lg border p-4 max-w-md"> <section className="flex items-center justify-between max-w-md">
<div className="space-y-0.5"> <div className="space-y-2">
<Label htmlFor="extrato-note-column" className="text-base"> <Label htmlFor="extrato-note-column" className="text-sm">
Anotações em coluna Anotações em coluna
</Label> </Label>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -149,10 +168,12 @@ export function PreferencesForm({
onCheckedChange={setExtratoNoteAsColumn} onCheckedChange={setExtratoNoteAsColumn}
disabled={isPending} disabled={isPending}
/> />
</div> </section>
<div className="space-y-2 max-w-md"> <Separator />
<Label className="text-base">Ordem das colunas</Label>
<section className="space-y-2 max-w-md">
<Label className="text-sm">Ordem das colunas</Label>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Arraste os itens para definir a ordem em que as colunas aparecem na Arraste os itens para definir a ordem em que as colunas aparecem na
tabela do extrato e dos lançamentos. tabela do extrato e dos lançamentos.
@@ -173,7 +194,43 @@ export function PreferencesForm({
</div> </div>
</SortableContext> </SortableContext>
</DndContext> </DndContext>
</div> </section>
<Separator />
<section className="space-y-2">
<Label className="text-sm">Anexos</Label>
<p className="text-sm text-muted-foreground">
Configurações de upload de arquivos nos lançamentos.
</p>
<div className="space-y-2 max-w-md mt-4">
<Label>Tamanho máximo por arquivo</Label>
<p className="text-sm text-muted-foreground">
Limite aplicado ao upload de PDFs e imagens.
</p>
<ToggleGroup
type="single"
value={String(attachmentMaxSizeMb)}
onValueChange={(val) => {
if (val)
setAttachmentMaxSizeMb(Number(val) as AttachmentSizeOption);
}}
className="flex flex-wrap gap-2 justify-start"
>
{ATTACHMENT_SIZE_OPTIONS.map((size) => (
<ToggleGroupItem
key={size}
value={String(size)}
aria-label={`${size} MB`}
className="min-w-14"
>
{size} MB
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
</section>
</section> </section>
<div className="flex justify-end"> <div className="flex justify-end">

View File

@@ -5,6 +5,7 @@ import { db, schema } from "@/shared/lib/db";
export interface UserPreferences { export interface UserPreferences {
statementNoteAsColumn: boolean; statementNoteAsColumn: boolean;
transactionsColumnOrder: string[] | null; transactionsColumnOrder: string[] | null;
attachmentMaxSizeMb: number;
} }
export interface ApiToken { export interface ApiToken {
@@ -32,6 +33,7 @@ export async function fetchUserPreferences(
.select({ .select({
statementNoteAsColumn: schema.userPreferences.statementNoteAsColumn, statementNoteAsColumn: schema.userPreferences.statementNoteAsColumn,
transactionsColumnOrder: schema.userPreferences.transactionsColumnOrder, transactionsColumnOrder: schema.userPreferences.transactionsColumnOrder,
attachmentMaxSizeMb: schema.userPreferences.attachmentMaxSizeMb,
}) })
.from(schema.userPreferences) .from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, userId)) .where(eq(schema.userPreferences.userId, userId))

View File

@@ -5,4 +5,9 @@ export const ALLOWED_MIME_TYPES = [
"image/webp", "image/webp",
] as const; ] as const;
export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB export const DEFAULT_MAX_FILE_SIZE_MB = 50;
export const MAX_FILE_SIZE = DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024; // 50MB (fallback)
export const ATTACHMENT_SIZE_OPTIONS = [5, 10, 25, 50, 100] as const;
export type AttachmentSizeOption = (typeof ATTACHMENT_SIZE_OPTIONS)[number];

View File

@@ -5,19 +5,22 @@ import { useRef } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
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 { Button } from "@/shared/components/ui/button";
interface AttachmentFilePickerProps { interface AttachmentFilePickerProps {
file: File | null; file: File | null;
onChange: (file: File | null) => void; onChange: (file: File | null) => void;
maxSizeMb?: number;
} }
export function AttachmentFilePicker({ export function AttachmentFilePicker({
file, file,
onChange, onChange,
maxSizeMb = DEFAULT_MAX_FILE_SIZE_MB,
}: AttachmentFilePickerProps) { }: AttachmentFilePickerProps) {
const maxFileSizeBytes = maxSizeMb * 1024 * 1024;
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) { function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
@@ -37,8 +40,8 @@ export function AttachmentFilePicker({
return; return;
} }
if (selected.size > MAX_FILE_SIZE) { if (selected.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;
} }
@@ -83,7 +86,7 @@ export function AttachmentFilePicker({
Adicionar anexo Adicionar anexo
</span> </span>
<span className="text-[11px]"> <span className="text-[11px]">
PDF, JPEG, PNG ou WebP · máx. 50 MB PDF, JPEG, PNG ou WebP · máx. {maxSizeMb} MB
</span> </span>
</button> </button>
)} )}