refactor: optimize codebase for React 19 compiler (v1.2.6)
React 19 compiler auto-optimizes memoization, making manual hooks unnecessary. Changes: - Remove ~60 useCallback/useMemo across 16 files - Remove React.memo from nav-button and return-button - Simplify hydration with useSyncExternalStore (privacy-provider) - Add CHANGELOG.md for version tracking No functional changes - internal optimization only. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,6 @@
|
||||
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -121,24 +120,18 @@ export function NoteDialog({
|
||||
|
||||
const disableSubmit = isPending || onlySpaces || unchanged || invalidLen;
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(v: boolean) => {
|
||||
setDialogOpen(v);
|
||||
if (!v) setErrorMessage(null);
|
||||
},
|
||||
[setDialogOpen],
|
||||
);
|
||||
const handleOpenChange = (v: boolean) => {
|
||||
setDialogOpen(v);
|
||||
if (!v) setErrorMessage(null);
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter")
|
||||
(e.currentTarget as HTMLFormElement).requestSubmit();
|
||||
if (e.key === "Escape") handleOpenChange(false);
|
||||
},
|
||||
[handleOpenChange],
|
||||
);
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter")
|
||||
(e.currentTarget as HTMLFormElement).requestSubmit();
|
||||
if (e.key === "Escape") handleOpenChange(false);
|
||||
};
|
||||
|
||||
const handleAddTask = useCallback(() => {
|
||||
const handleAddTask = () => {
|
||||
const text = normalize(newTaskText);
|
||||
if (!text) return;
|
||||
|
||||
@@ -151,97 +144,77 @@ export function NoteDialog({
|
||||
updateField("tasks", [...(formState.tasks || []), newTask]);
|
||||
setNewTaskText("");
|
||||
requestAnimationFrame(() => newTaskRef.current?.focus());
|
||||
}, [newTaskText, formState.tasks, updateField]);
|
||||
};
|
||||
|
||||
const handleRemoveTask = useCallback(
|
||||
(taskId: string) => {
|
||||
updateField(
|
||||
"tasks",
|
||||
(formState.tasks || []).filter((t) => t.id !== taskId),
|
||||
);
|
||||
},
|
||||
[formState.tasks, updateField],
|
||||
);
|
||||
const handleRemoveTask = (taskId: string) => {
|
||||
updateField(
|
||||
"tasks",
|
||||
(formState.tasks || []).filter((t) => t.id !== taskId),
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleTask = useCallback(
|
||||
(taskId: string) => {
|
||||
updateField(
|
||||
"tasks",
|
||||
(formState.tasks || []).map((t) =>
|
||||
t.id === taskId ? { ...t, completed: !t.completed } : t,
|
||||
),
|
||||
);
|
||||
},
|
||||
[formState.tasks, updateField],
|
||||
);
|
||||
const handleToggleTask = (taskId: string) => {
|
||||
updateField(
|
||||
"tasks",
|
||||
(formState.tasks || []).map((t) =>
|
||||
t.id === taskId ? { ...t, completed: !t.completed } : t,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage(null);
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage(null);
|
||||
|
||||
const payload = {
|
||||
title: normalize(formState.title),
|
||||
description: formState.description.trim(),
|
||||
type: formState.type,
|
||||
tasks: formState.tasks,
|
||||
};
|
||||
const payload = {
|
||||
title: normalize(formState.title),
|
||||
description: formState.description.trim(),
|
||||
type: formState.type,
|
||||
tasks: formState.tasks,
|
||||
};
|
||||
|
||||
if (onlySpaces || invalidLen) {
|
||||
setErrorMessage("Preencha os campos respeitando os limites.");
|
||||
titleRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (onlySpaces || invalidLen) {
|
||||
setErrorMessage("Preencha os campos respeitando os limites.");
|
||||
titleRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "update" && !note?.id) {
|
||||
const msg = "Não foi possível identificar a anotação a ser editada.";
|
||||
setErrorMessage(msg);
|
||||
toast.error(msg);
|
||||
return;
|
||||
}
|
||||
if (mode === "update" && !note?.id) {
|
||||
const msg = "Não foi possível identificar a anotação a ser editada.";
|
||||
setErrorMessage(msg);
|
||||
toast.error(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (unchanged) {
|
||||
toast.info("Nada para atualizar.");
|
||||
return;
|
||||
}
|
||||
if (unchanged) {
|
||||
toast.info("Nada para atualizar.");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
let result;
|
||||
if (mode === "create") {
|
||||
result = await createNoteAction(payload);
|
||||
} else {
|
||||
if (!note?.id) {
|
||||
const msg = "ID da anotação não encontrado.";
|
||||
setErrorMessage(msg);
|
||||
toast.error(msg);
|
||||
return;
|
||||
}
|
||||
result = await updateNoteAction({ id: note.id, ...payload });
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setDialogOpen(false);
|
||||
startTransition(async () => {
|
||||
let result;
|
||||
if (mode === "create") {
|
||||
result = await createNoteAction(payload);
|
||||
} else {
|
||||
if (!note?.id) {
|
||||
const msg = "ID da anotação não encontrado.";
|
||||
setErrorMessage(msg);
|
||||
toast.error(msg);
|
||||
return;
|
||||
}
|
||||
setErrorMessage(result.error);
|
||||
toast.error(result.error);
|
||||
titleRef.current?.focus();
|
||||
});
|
||||
},
|
||||
[
|
||||
formState.title,
|
||||
formState.description,
|
||||
formState.type,
|
||||
formState.tasks,
|
||||
mode,
|
||||
note,
|
||||
setDialogOpen,
|
||||
onlySpaces,
|
||||
unchanged,
|
||||
invalidLen,
|
||||
],
|
||||
);
|
||||
result = await updateNoteAction({ id: note.id, ...payload });
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
setErrorMessage(result.error);
|
||||
toast.error(result.error);
|
||||
titleRef.current?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={handleOpenChange}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { RiCheckLine, RiCloseLine, RiLoader4Line } from "@remixicon/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type FormEvent, useMemo, useState } from "react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -90,10 +90,7 @@ export function SignupForm({ className, ...props }: DivProps) {
|
||||
const [loadingEmail, setLoadingEmail] = useState(false);
|
||||
const [loadingGoogle, setLoadingGoogle] = useState(false);
|
||||
|
||||
const passwordValidation = useMemo(
|
||||
() => validatePassword(password),
|
||||
[password],
|
||||
);
|
||||
const passwordValidation = validatePassword(password);
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createCategoryAction,
|
||||
@@ -76,14 +70,10 @@ export function CategoryDialog({
|
||||
onOpenChange,
|
||||
);
|
||||
|
||||
const initialState = useMemo(
|
||||
() =>
|
||||
buildInitialValues({
|
||||
category,
|
||||
defaultType,
|
||||
}),
|
||||
[category, defaultType],
|
||||
);
|
||||
const initialState = buildInitialValues({
|
||||
category,
|
||||
defaultType,
|
||||
});
|
||||
|
||||
// Use form state hook for form management
|
||||
const { formState, updateField, setFormState } =
|
||||
@@ -95,7 +85,7 @@ export function CategoryDialog({
|
||||
setFormState(initialState);
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, [dialogOpen, initialState, setFormState]);
|
||||
}, [dialogOpen, setFormState, category, defaultType]);
|
||||
|
||||
// Clear error when dialog closes
|
||||
useEffect(() => {
|
||||
@@ -104,46 +94,43 @@ export function CategoryDialog({
|
||||
}
|
||||
}, [dialogOpen]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
|
||||
if (mode === "update" && !category?.id) {
|
||||
const message = "Categoria inválida.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
if (mode === "update" && !category?.id) {
|
||||
const message = "Categoria inválida.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: formState.name.trim(),
|
||||
type: formState.type,
|
||||
icon: formState.icon.trim(),
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
const result =
|
||||
mode === "create"
|
||||
? await createCategoryAction(payload)
|
||||
: await updateCategoryAction({
|
||||
id: category?.id ?? "",
|
||||
...payload,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setDialogOpen(false);
|
||||
setFormState(initialState);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: formState.name.trim(),
|
||||
type: formState.type,
|
||||
icon: formState.icon.trim(),
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
const result =
|
||||
mode === "create"
|
||||
? await createCategoryAction(payload)
|
||||
: await updateCategoryAction({
|
||||
id: category?.id ?? "",
|
||||
...payload,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setDialogOpen(false);
|
||||
setFormState(initialState);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(result.error);
|
||||
toast.error(result.error);
|
||||
});
|
||||
},
|
||||
[category?.id, formState, initialState, mode, setDialogOpen, setFormState],
|
||||
);
|
||||
setErrorMessage(result.error);
|
||||
toast.error(result.error);
|
||||
});
|
||||
};
|
||||
|
||||
const title = mode === "create" ? "Nova categoria" : "Editar categoria";
|
||||
const description =
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import { useCallback, useMemo, useState, useTransition } from "react";
|
||||
import { useState, useTransition } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -49,22 +49,16 @@ export function ConfirmActionDialog({
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const dialogOpen = open ?? internalOpen;
|
||||
|
||||
const setDialogOpen = useCallback(
|
||||
(value: boolean) => {
|
||||
if (open === undefined) {
|
||||
setInternalOpen(value);
|
||||
}
|
||||
onOpenChange?.(value);
|
||||
},
|
||||
[onOpenChange, open],
|
||||
);
|
||||
const setDialogOpen = (value: boolean) => {
|
||||
if (open === undefined) {
|
||||
setInternalOpen(value);
|
||||
}
|
||||
onOpenChange?.(value);
|
||||
};
|
||||
|
||||
const resolvedPendingLabel = useMemo(
|
||||
() => pendingLabel ?? confirmLabel,
|
||||
[pendingLabel, confirmLabel],
|
||||
);
|
||||
const resolvedPendingLabel = pendingLabel ?? confirmLabel;
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
const handleConfirm = () => {
|
||||
if (!onConfirm) {
|
||||
setDialogOpen(false);
|
||||
return;
|
||||
@@ -78,7 +72,7 @@ export function ConfirmActionDialog({
|
||||
// Mantém o diálogo aberto para que o chamador trate o erro.
|
||||
}
|
||||
});
|
||||
}, [onConfirm, setDialogOpen]);
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { RiAddCircleLine, RiBankLine } from "@remixicon/react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { deleteAccountAction } from "@/app/(dashboard)/contas/actions";
|
||||
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
||||
@@ -47,45 +47,43 @@ export function AccountsPage({
|
||||
|
||||
const hasAccounts = accounts.length > 0;
|
||||
|
||||
const orderedAccounts = useMemo(() => {
|
||||
return [...accounts].sort((a, b) => {
|
||||
// Coloca inativas no final
|
||||
const aIsInactive = a.status?.toLowerCase() === "inativa";
|
||||
const bIsInactive = b.status?.toLowerCase() === "inativa";
|
||||
const orderedAccounts = [...accounts].sort((a, b) => {
|
||||
// Coloca inativas no final
|
||||
const aIsInactive = a.status?.toLowerCase() === "inativa";
|
||||
const bIsInactive = b.status?.toLowerCase() === "inativa";
|
||||
|
||||
if (aIsInactive && !bIsInactive) return 1;
|
||||
if (!aIsInactive && bIsInactive) return -1;
|
||||
if (aIsInactive && !bIsInactive) return 1;
|
||||
if (!aIsInactive && bIsInactive) return -1;
|
||||
|
||||
// Mesma ordem alfabética dentro de cada grupo
|
||||
return a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" });
|
||||
});
|
||||
}, [accounts]);
|
||||
// Mesma ordem alfabética dentro de cada grupo
|
||||
return a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" });
|
||||
});
|
||||
|
||||
const handleEdit = useCallback((account: Account) => {
|
||||
const handleEdit = (account: Account) => {
|
||||
setSelectedAccount(account);
|
||||
setEditOpen(true);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const handleEditOpenChange = useCallback((open: boolean) => {
|
||||
const handleEditOpenChange = (open: boolean) => {
|
||||
setEditOpen(open);
|
||||
if (!open) {
|
||||
setSelectedAccount(null);
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
const handleRemoveRequest = useCallback((account: Account) => {
|
||||
const handleRemoveRequest = (account: Account) => {
|
||||
setAccountToRemove(account);
|
||||
setRemoveOpen(true);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const handleRemoveOpenChange = useCallback((open: boolean) => {
|
||||
const handleRemoveOpenChange = (open: boolean) => {
|
||||
setRemoveOpen(open);
|
||||
if (!open) {
|
||||
setAccountToRemove(null);
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
const handleRemoveConfirm = useCallback(async () => {
|
||||
const handleRemoveConfirm = async () => {
|
||||
if (!accountToRemove) {
|
||||
return;
|
||||
}
|
||||
@@ -99,19 +97,19 @@ export function AccountsPage({
|
||||
|
||||
toast.error(result.error);
|
||||
throw new Error(result.error);
|
||||
}, [accountToRemove]);
|
||||
};
|
||||
|
||||
const handleTransferRequest = useCallback((account: Account) => {
|
||||
const handleTransferRequest = (account: Account) => {
|
||||
setTransferFromAccount(account);
|
||||
setTransferOpen(true);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const handleTransferOpenChange = useCallback((open: boolean) => {
|
||||
const handleTransferOpenChange = (open: boolean) => {
|
||||
setTransferOpen(open);
|
||||
if (!open) {
|
||||
setTransferFromAccount(null);
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
const removeTitle = accountToRemove
|
||||
? `Remover conta "${accountToRemove.name}"?`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { transferBetweenAccountsAction } from "@/app/(dashboard)/contas/actions";
|
||||
import type { AccountData } from "@/app/(dashboard)/contas/data";
|
||||
@@ -62,15 +62,13 @@ export function TransferDialog({
|
||||
const [period, setPeriod] = useState(currentPeriod);
|
||||
|
||||
// Available destination accounts (exclude source account)
|
||||
const availableAccounts = useMemo(
|
||||
() => accounts.filter((account) => account.id !== fromAccountId),
|
||||
[accounts, fromAccountId],
|
||||
const availableAccounts = accounts.filter(
|
||||
(account) => account.id !== fromAccountId,
|
||||
);
|
||||
|
||||
// Source account info
|
||||
const fromAccount = useMemo(
|
||||
() => accounts.find((account) => account.id === fromAccountId),
|
||||
[accounts, fromAccountId],
|
||||
const fromAccount = accounts.find(
|
||||
(account) => account.id === fromAccountId,
|
||||
);
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
RiBarChartBoxLine,
|
||||
RiCloseLine,
|
||||
} from "@remixicon/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -49,36 +49,37 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
// Load selected categories from sessionStorage on mount
|
||||
// Load from sessionStorage on mount and save on changes
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY_SELECTED);
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (Array.isArray(parsed)) {
|
||||
const validCategories = parsed.filter((id) =>
|
||||
data.allCategories.some((cat) => cat.id === id),
|
||||
);
|
||||
setSelectedCategories(validCategories.slice(0, 5));
|
||||
// Only load from storage on first render
|
||||
if (isFirstRender.current) {
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY_SELECTED);
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (Array.isArray(parsed)) {
|
||||
const validCategories = parsed.filter((id) =>
|
||||
data.allCategories.some((cat) => cat.id === id),
|
||||
);
|
||||
setSelectedCategories(validCategories.slice(0, 5));
|
||||
}
|
||||
} catch (_e) {
|
||||
// Invalid JSON, ignore
|
||||
}
|
||||
} catch (_e) {
|
||||
// Invalid JSON, ignore
|
||||
}
|
||||
}
|
||||
}, [data.allCategories]);
|
||||
|
||||
// Save to sessionStorage when selection changes
|
||||
useEffect(() => {
|
||||
if (isClient) {
|
||||
isFirstRender.current = false;
|
||||
} else {
|
||||
// Save to storage on subsequent changes
|
||||
sessionStorage.setItem(
|
||||
STORAGE_KEY_SELECTED,
|
||||
JSON.stringify(selectedCategories),
|
||||
);
|
||||
}
|
||||
}, [selectedCategories, isClient]);
|
||||
}, [selectedCategories, data.allCategories]);
|
||||
|
||||
// Filter data to show only selected categories with vibrant colors
|
||||
const filteredCategories = useMemo(() => {
|
||||
|
||||
@@ -6,14 +6,7 @@ import {
|
||||
RiFilter3Line,
|
||||
} from "@remixicon/react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
import { type ReactNode, useEffect, useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
@@ -147,29 +140,24 @@ export function LancamentosFilters({
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const getParamValue = useCallback(
|
||||
(key: string) => searchParams.get(key) ?? FILTER_EMPTY_VALUE,
|
||||
[searchParams],
|
||||
);
|
||||
const getParamValue = (key: string) =>
|
||||
searchParams.get(key) ?? FILTER_EMPTY_VALUE;
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(key: string, value: string | null) => {
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
const handleFilterChange = (key: string, value: string | null) => {
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
|
||||
if (value && value !== FILTER_EMPTY_VALUE) {
|
||||
nextParams.set(key, value);
|
||||
} else {
|
||||
nextParams.delete(key);
|
||||
}
|
||||
if (value && value !== FILTER_EMPTY_VALUE) {
|
||||
nextParams.set(key, value);
|
||||
} else {
|
||||
nextParams.delete(key);
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.replace(`${pathname}?${nextParams.toString()}`, {
|
||||
scroll: false,
|
||||
});
|
||||
startTransition(() => {
|
||||
router.replace(`${pathname}?${nextParams.toString()}`, {
|
||||
scroll: false,
|
||||
});
|
||||
},
|
||||
[pathname, router, searchParams],
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const [searchValue, setSearchValue] = useState(searchParams.get("q") ?? "");
|
||||
const currentSearchParam = searchParams.get("q") ?? "";
|
||||
@@ -191,7 +179,7 @@ export function LancamentosFilters({
|
||||
return () => clearTimeout(timeout);
|
||||
}, [searchValue, currentSearchParam, handleFilterChange]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
const handleReset = () => {
|
||||
const periodValue = searchParams.get("periodo");
|
||||
const nextParams = new URLSearchParams();
|
||||
if (periodValue) {
|
||||
@@ -205,41 +193,29 @@ export function LancamentosFilters({
|
||||
: pathname;
|
||||
router.replace(target, { scroll: false });
|
||||
});
|
||||
}, [pathname, router, searchParams]);
|
||||
};
|
||||
|
||||
const pagadorSelectOptions = useMemo(
|
||||
() =>
|
||||
pagadorOptions.map((option) => ({
|
||||
value: option.slug,
|
||||
label: option.label,
|
||||
avatarUrl: option.avatarUrl,
|
||||
})),
|
||||
[pagadorOptions],
|
||||
);
|
||||
const pagadorSelectOptions = pagadorOptions.map((option) => ({
|
||||
value: option.slug,
|
||||
label: option.label,
|
||||
avatarUrl: option.avatarUrl,
|
||||
}));
|
||||
|
||||
const contaOptions = useMemo(
|
||||
() =>
|
||||
contaCartaoOptions
|
||||
.filter((option) => option.kind === "conta")
|
||||
.map((option) => ({
|
||||
value: option.slug,
|
||||
label: option.label,
|
||||
logo: option.logo,
|
||||
})),
|
||||
[contaCartaoOptions],
|
||||
);
|
||||
const contaOptions = contaCartaoOptions
|
||||
.filter((option) => option.kind === "conta")
|
||||
.map((option) => ({
|
||||
value: option.slug,
|
||||
label: option.label,
|
||||
logo: option.logo,
|
||||
}));
|
||||
|
||||
const cartaoOptions = useMemo(
|
||||
() =>
|
||||
contaCartaoOptions
|
||||
.filter((option) => option.kind === "cartao")
|
||||
.map((option) => ({
|
||||
value: option.slug,
|
||||
label: option.label,
|
||||
logo: option.logo,
|
||||
})),
|
||||
[contaCartaoOptions],
|
||||
);
|
||||
const cartaoOptions = contaCartaoOptions
|
||||
.filter((option) => option.kind === "cartao")
|
||||
.map((option) => ({
|
||||
value: option.slug,
|
||||
label: option.label,
|
||||
logo: option.logo,
|
||||
}));
|
||||
|
||||
const categoriaValue = getParamValue("categoria");
|
||||
const selectedCategoria =
|
||||
@@ -262,21 +238,18 @@ export function LancamentosFilters({
|
||||
const [categoriaOpen, setCategoriaOpen] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const hasActiveFilters = useMemo(() => {
|
||||
return (
|
||||
searchParams.get("transacao") ||
|
||||
searchParams.get("condicao") ||
|
||||
searchParams.get("pagamento") ||
|
||||
searchParams.get("pagador") ||
|
||||
searchParams.get("categoria") ||
|
||||
searchParams.get("contaCartao")
|
||||
);
|
||||
}, [searchParams]);
|
||||
const hasActiveFilters =
|
||||
searchParams.get("transacao") ||
|
||||
searchParams.get("condicao") ||
|
||||
searchParams.get("pagamento") ||
|
||||
searchParams.get("pagador") ||
|
||||
searchParams.get("categoria") ||
|
||||
searchParams.get("contaCartao");
|
||||
|
||||
const handleResetFilters = useCallback(() => {
|
||||
const handleResetFilters = () => {
|
||||
handleReset();
|
||||
setDrawerOpen(false);
|
||||
}, [handleReset]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center gap-2", className)}>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from "@remixicon/react";
|
||||
import React from "react";
|
||||
|
||||
interface NavigationButtonProps {
|
||||
direction: "left" | "right";
|
||||
@@ -9,25 +8,23 @@ interface NavigationButtonProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const NavigationButton = React.memo(
|
||||
({ direction, disabled, onClick }: NavigationButtonProps) => {
|
||||
const Icon = direction === "left" ? RiArrowLeftSLine : RiArrowRightSLine;
|
||||
export default function NavigationButton({
|
||||
direction,
|
||||
disabled,
|
||||
onClick,
|
||||
}: NavigationButtonProps) {
|
||||
const Icon = direction === "left" ? RiArrowLeftSLine : RiArrowRightSLine;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="text-month-picker-foreground transition-all duration-200 cursor-pointer rounded-lg p-1 hover:bg-month-picker-foreground/10 focus:outline-hidden focus:ring-2 focus:ring-month-picker-foreground/30 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
disabled={disabled}
|
||||
aria-label={`Navegar para o mês ${
|
||||
direction === "left" ? "anterior" : "seguinte"
|
||||
}`}
|
||||
>
|
||||
<Icon className="text-primary" size={18} />
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
NavigationButton.displayName = "NavigationButton";
|
||||
|
||||
export default NavigationButton;
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="text-month-picker-foreground transition-all duration-200 cursor-pointer rounded-lg p-1 hover:bg-month-picker-foreground/10 focus:outline-hidden focus:ring-2 focus:ring-month-picker-foreground/30 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
disabled={disabled}
|
||||
aria-label={`Navegar para o mês ${
|
||||
direction === "left" ? "anterior" : "seguinte"
|
||||
}`}
|
||||
>
|
||||
<Icon className="text-primary" size={18} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface ReturnButtonProps {
|
||||
@@ -8,7 +7,7 @@ interface ReturnButtonProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const ReturnButton = React.memo(({ disabled, onClick }: ReturnButtonProps) => {
|
||||
export default function ReturnButton({ disabled, onClick }: ReturnButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
className="w-32 h-6 rounded-sm lowercase"
|
||||
@@ -20,8 +19,4 @@ const ReturnButton = React.memo(({ disabled, onClick }: ReturnButtonProps) => {
|
||||
Ir para Mês Atual
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
ReturnButton.displayName = "ReturnButton";
|
||||
|
||||
export default ReturnButton;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createBudgetAction,
|
||||
@@ -79,14 +73,10 @@ export function BudgetDialog({
|
||||
onOpenChange,
|
||||
);
|
||||
|
||||
const initialState = useMemo(
|
||||
() =>
|
||||
buildInitialValues({
|
||||
budget,
|
||||
defaultPeriod,
|
||||
}),
|
||||
[budget, defaultPeriod],
|
||||
);
|
||||
const initialState = buildInitialValues({
|
||||
budget,
|
||||
defaultPeriod,
|
||||
});
|
||||
|
||||
// Use form state hook for form management
|
||||
const { formState, updateField, setFormState } =
|
||||
@@ -98,7 +88,7 @@ export function BudgetDialog({
|
||||
setFormState(initialState);
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, [dialogOpen, initialState, setFormState]);
|
||||
}, [dialogOpen, setFormState, budget, defaultPeriod]);
|
||||
|
||||
// Clear error when dialog closes
|
||||
useEffect(() => {
|
||||
@@ -107,67 +97,64 @@ export function BudgetDialog({
|
||||
}
|
||||
}, [dialogOpen]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
|
||||
if (mode === "update" && !budget?.id) {
|
||||
const message = "Orçamento inválido.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
if (mode === "update" && !budget?.id) {
|
||||
const message = "Orçamento inválido.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formState.categoriaId.length === 0) {
|
||||
const message = "Selecione uma categoria.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formState.period.length === 0) {
|
||||
const message = "Informe o período.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formState.amount.length === 0) {
|
||||
const message = "Informe o valor limite.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
categoriaId: formState.categoriaId,
|
||||
period: formState.period,
|
||||
amount: formState.amount,
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
const result =
|
||||
mode === "create"
|
||||
? await createBudgetAction(payload)
|
||||
: await updateBudgetAction({
|
||||
id: budget?.id ?? "",
|
||||
...payload,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setDialogOpen(false);
|
||||
setFormState(initialState);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formState.categoriaId.length === 0) {
|
||||
const message = "Selecione uma categoria.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formState.period.length === 0) {
|
||||
const message = "Informe o período.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formState.amount.length === 0) {
|
||||
const message = "Informe o valor limite.";
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
categoriaId: formState.categoriaId,
|
||||
period: formState.period,
|
||||
amount: formState.amount,
|
||||
};
|
||||
|
||||
startTransition(async () => {
|
||||
const result =
|
||||
mode === "create"
|
||||
? await createBudgetAction(payload)
|
||||
: await updateBudgetAction({
|
||||
id: budget?.id ?? "",
|
||||
...payload,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setDialogOpen(false);
|
||||
setFormState(initialState);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(result.error);
|
||||
toast.error(result.error);
|
||||
});
|
||||
},
|
||||
[budget?.id, formState, initialState, mode, setDialogOpen, setFormState],
|
||||
);
|
||||
setErrorMessage(result.error);
|
||||
toast.error(result.error);
|
||||
});
|
||||
};
|
||||
|
||||
const title = mode === "create" ? "Novo orçamento" : "Editar orçamento";
|
||||
const description =
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
|
||||
interface PrivacyContextType {
|
||||
privacyMode: boolean;
|
||||
@@ -13,25 +20,41 @@ const PrivacyContext = createContext<PrivacyContextType | undefined>(undefined);
|
||||
|
||||
const STORAGE_KEY = "app:privacyMode";
|
||||
|
||||
// Read from localStorage safely (returns false on server)
|
||||
function getStoredValue(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return localStorage.getItem(STORAGE_KEY) === "true";
|
||||
}
|
||||
|
||||
// Subscribe to storage changes
|
||||
function subscribeToStorage(callback: () => void) {
|
||||
window.addEventListener("storage", callback);
|
||||
return () => window.removeEventListener("storage", callback);
|
||||
}
|
||||
|
||||
export function PrivacyProvider({ children }: { children: React.ReactNode }) {
|
||||
const [privacyMode, setPrivacyMode] = useState(false);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
// useSyncExternalStore handles hydration safely
|
||||
const storedValue = useSyncExternalStore(
|
||||
subscribeToStorage,
|
||||
getStoredValue,
|
||||
() => false, // Server snapshot
|
||||
);
|
||||
|
||||
// Sincronizar com localStorage na montagem (evitar mismatch SSR/CSR)
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored !== null) {
|
||||
setPrivacyMode(stored === "true");
|
||||
}
|
||||
setHydrated(true);
|
||||
}, []);
|
||||
const [privacyMode, setPrivacyMode] = useState(storedValue);
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
// Persistir mudanças no localStorage
|
||||
// Sync with stored value on mount
|
||||
useEffect(() => {
|
||||
if (hydrated) {
|
||||
localStorage.setItem(STORAGE_KEY, String(privacyMode));
|
||||
if (isFirstRender.current) {
|
||||
setPrivacyMode(storedValue);
|
||||
isFirstRender.current = false;
|
||||
}
|
||||
}, [privacyMode, hydrated]);
|
||||
}, [storedValue]);
|
||||
|
||||
// Persist to localStorage when privacyMode changes
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, String(privacyMode));
|
||||
}, [privacyMode]);
|
||||
|
||||
const toggle = () => {
|
||||
setPrivacyMode((prev) => !prev);
|
||||
|
||||
@@ -63,50 +63,45 @@ export function NavMain({ sections }: { sections: NavSection[] }) {
|
||||
const searchParams = useSearchParams();
|
||||
const periodParam = searchParams.get(MONTH_PERIOD_PARAM);
|
||||
|
||||
const isLinkActive = React.useCallback(
|
||||
(url: string) => {
|
||||
const normalizedPathname =
|
||||
pathname.endsWith("/") && pathname !== "/"
|
||||
? pathname.slice(0, -1)
|
||||
: pathname;
|
||||
const normalizedUrl =
|
||||
url.endsWith("/") && url !== "/" ? url.slice(0, -1) : url;
|
||||
const normalizedPathname =
|
||||
pathname.endsWith("/") && pathname !== "/"
|
||||
? pathname.slice(0, -1)
|
||||
: pathname;
|
||||
|
||||
// Verifica se é exatamente igual ou se o pathname começa com a URL
|
||||
return (
|
||||
normalizedPathname === normalizedUrl ||
|
||||
normalizedPathname.startsWith(`${normalizedUrl}/`)
|
||||
);
|
||||
},
|
||||
[pathname],
|
||||
);
|
||||
const isLinkActive = (url: string) => {
|
||||
const normalizedUrl =
|
||||
url.endsWith("/") && url !== "/" ? url.slice(0, -1) : url;
|
||||
|
||||
const buildHrefWithPeriod = React.useCallback(
|
||||
(url: string) => {
|
||||
if (!periodParam) {
|
||||
return url;
|
||||
}
|
||||
// Verifica se é exatamente igual ou se o pathname começa com a URL
|
||||
return (
|
||||
normalizedPathname === normalizedUrl ||
|
||||
normalizedPathname.startsWith(`${normalizedUrl}/`)
|
||||
);
|
||||
};
|
||||
|
||||
const [rawPathname, existingSearch = ""] = url.split("?");
|
||||
const normalizedPathname =
|
||||
rawPathname.endsWith("/") && rawPathname !== "/"
|
||||
? rawPathname.slice(0, -1)
|
||||
: rawPathname;
|
||||
const buildHrefWithPeriod = (url: string) => {
|
||||
if (!periodParam) {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (!PERIOD_AWARE_PATHS.has(normalizedPathname)) {
|
||||
return url;
|
||||
}
|
||||
const [rawPathname, existingSearch = ""] = url.split("?");
|
||||
const normalizedRawPathname =
|
||||
rawPathname.endsWith("/") && rawPathname !== "/"
|
||||
? rawPathname.slice(0, -1)
|
||||
: rawPathname;
|
||||
|
||||
const params = new URLSearchParams(existingSearch);
|
||||
params.set(MONTH_PERIOD_PARAM, periodParam);
|
||||
if (!PERIOD_AWARE_PATHS.has(normalizedRawPathname)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
return queryString
|
||||
? `${normalizedPathname}?${queryString}`
|
||||
: normalizedPathname;
|
||||
},
|
||||
[periodParam],
|
||||
);
|
||||
const params = new URLSearchParams(existingSearch);
|
||||
params.set(MONTH_PERIOD_PARAM, periodParam);
|
||||
|
||||
const queryString = params.toString();
|
||||
return queryString
|
||||
? `${normalizedRawPathname}?${queryString}`
|
||||
: normalizedRawPathname;
|
||||
};
|
||||
|
||||
const activeLinkClasses =
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-dark! hover:text-primary!";
|
||||
|
||||
Reference in New Issue
Block a user