(
+ usuario?.role === "admin" || usuario?.role === "gestor" ? usuario.role : "usuario"
+ );
const config = MODAL_CONFIG[mode];
const Icon = config.icone;
@@ -137,22 +140,17 @@ export function UsuarioModal({ mode, usuario, carregando, erro, onSubmit, onClos
Cargo
- setRole("usuario")}
- disabled={carregando}
- className={`px-4 h-full flex items-center justify-center text-sm font-bold rounded-lg transition-all ${role === "usuario" ? "bg-card-bg text-text shadow-sm border border-glass-border" : "text-text-muted hover:text-text border border-transparent"}`}
- >
- Usuário
-
- setRole("admin")}
- disabled={carregando}
- className={`px-4 h-full flex items-center justify-center text-sm font-bold rounded-lg transition-all ${role === "admin" ? "bg-card-bg text-text shadow-sm border border-glass-border" : "text-text-muted hover:text-text border border-transparent"}`}
- >
- Administrador
-
+ {(["usuario", "gestor", "admin"] as const).map((cargo) => (
+ setRole(cargo)}
+ disabled={carregando}
+ className={`px-4 h-full flex items-center justify-center text-sm font-bold rounded-lg transition-all ${role === cargo ? "bg-card-bg text-text shadow-sm border border-glass-border" : "text-text-muted hover:text-text border border-transparent"}`}
+ >
+ {CARGO_LABEL[cargo]}
+
+ ))}
)}
diff --git a/app/components/UsuariosTable.tsx b/app/components/UsuariosTable.tsx
index 728d926..64a491a 100644
--- a/app/components/UsuariosTable.tsx
+++ b/app/components/UsuariosTable.tsx
@@ -84,10 +84,12 @@ export function UsuariosTable({
className={`text-[9px] font-bold uppercase tracking-widest px-2 py-1 rounded-md whitespace-nowrap ${
u.role === "admin"
? "bg-badge-primary-bg text-badge-primary-text"
- : "bg-surface text-text-muted border border-glass-border"
+ : u.role === "gestor"
+ ? "bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20"
+ : "bg-surface text-text-muted border border-glass-border"
}`}
>
- {u.role === "admin" ? "Admin" : "Usuário"}
+ {u.role === "admin" ? "Admin" : u.role === "gestor" ? "Gestor" : "Usuário"}
diff --git a/app/context/AuthContext.tsx b/app/context/AuthContext.tsx
index 8c0773e..d64cd00 100644
--- a/app/context/AuthContext.tsx
+++ b/app/context/AuthContext.tsx
@@ -1,7 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { api } from "~/lib/api";
-import { queryClient, queryKeys } from "~/lib/query";
+import { queryClient, queryKeys, fetchInit } from "~/lib/query";
type AuthContextType = {
user: any | null;
@@ -19,8 +19,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
let alive = true;
- api
- .get<{ user: any }>("/auth/me")
+ // Boot com uma única request: /init já traz o usuário (sem /auth/me à parte).
+ // retry: false — deslogado, /init dá 401 e não adianta tentar de novo.
+ queryClient
+ .fetchQuery({ queryKey: queryKeys.init, queryFn: fetchInit, staleTime: 60_000, retry: false })
.then((d) => {
if (alive) setUser(d.user);
})
@@ -31,6 +33,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (alive) setIsLoading(false);
});
+ //O /init é refeito periodicamente (useInit) e o servidor devolve o cargo
+ //atual — assim uma promoção/rebaixamento atualiza menus e rotas sem relogin.
+ const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
+ if (event.type !== "updated" || event.query.queryKey[0] !== queryKeys.init[0]) return;
+ const novo = (event.query.state.data as any)?.user;
+ if (!novo) return;
+ //Só atualiza quem já está logado — um /init atrasado não reloga após o logout.
+ setUser((atual: any) =>
+ !atual || atual.app_metadata?.role === novo.app_metadata?.role ? atual : novo
+ );
+ });
+
const handleUnauthorized = () => {
setUser(null);
navigate("/login");
@@ -38,6 +52,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
window.addEventListener("auth:unauthorized", handleUnauthorized);
return () => {
alive = false;
+ unsubscribe();
window.removeEventListener("auth:unauthorized", handleUnauthorized);
};
}, [navigate]);
@@ -45,9 +60,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const signIn = useCallback(
async (email: string, password: string, redirectTo = "/caixa-de-entrada") => {
await api.post("/auth/login", { email, password });
- const d = await api.get<{ user: any }>("/auth/me");
+ const d = await queryClient.fetchQuery({ queryKey: queryKeys.init, queryFn: fetchInit, staleTime: 60_000, retry: false });
setUser(d.user);
- queryClient.invalidateQueries({ queryKey: queryKeys.init });
navigate(redirectTo, { replace: true });
},
[navigate]
diff --git a/app/hooks/useOperacoesGridData.ts b/app/hooks/useOperacoesGridData.ts
index dbccb24..1f94f30 100644
--- a/app/hooks/useOperacoesGridData.ts
+++ b/app/hooks/useOperacoesGridData.ts
@@ -43,46 +43,108 @@ export interface GridMeta {
const EMPTY_META: GridMeta = { total: 0, totalVl: 0, page: 0, limit: 200, totalPages: 0 };
+// Chave de filtros/ordenação: mesma função usada pelo hook (com o estado
+// atual) e pelo prefetch (sem filtros), para as duas baterem no mesmo cache —
+// era aqui que o bug antigo estava (prefetch usava "{}" na mão).
+function computeFiltersKey(columnFilters: Record, sortColumns: any[]): string {
+ return JSON.stringify({ columnFilters, sortColumns });
+}
+
+export const EMPTY_FILTERS_KEY = computeFiltersKey({}, []);
+
+// Identifica a pasta pedida: pastaNome (rota de pasta, único no banco) tem
+// prioridade sobre pastaId; inbox = null/sem nome.
+export interface PastaIdentificador {
+ pastaId: number | null;
+ pastaNome?: string;
+}
+
+function pastaChave(pasta: PastaIdentificador): string {
+ return pasta.pastaNome ?? String(pasta.pastaId ?? "inbox");
+}
+
+function aplicarPastaNosParams(p: URLSearchParams, pasta: PastaIdentificador) {
+ if (pasta.pastaNome) p.set("pastaNome", pasta.pastaNome);
+ else if (pasta.pastaId != null) p.set("pastaId", String(pasta.pastaId));
+}
+
// Cache cliente da primeira página por pasta+filtros. O gargalo é a latência de
// rede (VPS/DB na Europa), então voltar a uma pasta já visitada deve ser
// instantâneo — os dados antigos aparecem na hora e revalidam em background.
const GRID_CACHE_TTL = 60_000;
const gridCache = new Map();
-function cacheKeyOf(pastaId: number | null, filtersKey: string) {
- return `${pastaId ?? "inbox"}|${filtersKey}`;
+function cacheKeyOf(pasta: PastaIdentificador, filtersKey: string) {
+ return `${pastaChave(pasta)}|${filtersKey}`;
}
-function buildPage1Params(pastaId: number | null, limit = "200") {
+function buildPage1Params(pasta: PastaIdentificador, limit = "200") {
const p = new URLSearchParams();
- if (pastaId != null) p.set("pastaId", String(pastaId));
+ aplicarPastaNosParams(p, pasta);
p.set("page", "1");
p.set("limit", limit);
return p.toString();
}
-//Prefetch leve usado no hover da sidebar para pré-carregar a primeira página
-//da pasta antes mesmo do usuário clicar.
-export async function prefetchOperacoes(pastaId: number | null) {
- const key = cacheKeyOf(pastaId, "{}");
+// Agendamento do prefetch (hover na sidebar): só dispara 150ms depois do
+// mouse parar numa pasta; cancelPrefetch() (onMouseLeave) aborta o timer e a
+// request em voo, evitando rajada de queries ao passar rápido pela lista.
+let prefetchTimeout: ReturnType | null = null;
+let prefetchController: AbortController | null = null;
+
+export function prefetchOperacoes(pasta: PastaIdentificador) {
+ cancelPrefetch();
+ prefetchTimeout = setTimeout(() => {
+ prefetchTimeout = null;
+ executarPrefetch(pasta);
+ }, 150);
+}
+
+export function cancelPrefetch() {
+ if (prefetchTimeout) {
+ clearTimeout(prefetchTimeout);
+ prefetchTimeout = null;
+ }
+ if (prefetchController) {
+ prefetchController.abort();
+ prefetchController = null;
+ }
+}
+
+// Prefetch em voo por chave de cache: se o clique acontecer antes do hover
+// terminar, o hook espera esta mesma Promise em vez de disparar outra request
+// igual (era a causa das 3 requests iguais por clique no HAR).
+const prefetchEmVoo = new Map>();
+
+async function executarPrefetch(pasta: PastaIdentificador) {
+ const key = cacheKeyOf(pasta, EMPTY_FILTERS_KEY);
const cached = gridCache.get(key);
if (cached && Date.now() - cached.ts < GRID_CACHE_TTL) return;
+ const controller = new AbortController();
+ prefetchController = controller;
+ const promise = api.get<{ data: any[]; meta: GridMeta }>(`/operacoes?${buildPage1Params(pasta)}`, controller.signal);
+ prefetchEmVoo.set(key, promise);
try {
- const res = await api.get<{ data: any[]; meta: GridMeta }>(`/operacoes?${buildPage1Params(pastaId)}`);
+ const res = await promise;
gridCache.set(key, { dados: res.data, meta: res.meta, ts: Date.now() });
} catch {
- // prefetch falhou — a navegação normal resolve
+ // prefetch falhou ou foi cancelado — a navegação normal resolve
+ } finally {
+ if (prefetchController === controller) prefetchController = null;
+ if (prefetchEmVoo.get(key) === promise) prefetchEmVoo.delete(key);
}
}
export function useOperacoesGridData({
pastaId,
+ pastaNome,
columnFilters,
sortColumns,
enabled = true,
}: {
pastaId: number | null;
+ pastaNome?: string;
columnFilters: Record;
sortColumns: any[];
enabled?: boolean;
@@ -95,16 +157,26 @@ export function useOperacoesGridData({
const [searchParams] = useSearchParams();
const seqRef = useRef(0);
const loadingMoreRef = useRef(false);
+ // Última combinação pasta+filtros processada — usada para só aplicar o
+ // debounce de digitação quando é a MESMA pasta e só os filtros mudaram.
+ const lastRunRef = useRef<{ pasta: string; filters: string } | null>(null);
const filtersKey = useMemo(
- () => JSON.stringify({ columnFilters, sortColumns }),
+ () => computeFiltersKey(columnFilters, sortColumns),
[columnFilters, sortColumns]
);
+ // columnFilters muda de referência a cada `setColumnFilters({})` (mesmo com
+ // o mesmo conteúdo) — ler pelo ref evita que isso recrie buildParams (e, com
+ // ele, o efeito de dados abaixo, que reexecutaria e abortaria a request em
+ // voo). filtersKey (string) nas deps já cobre mudança real de filtro/ordenação.
+ const columnFiltersRef = useRef(columnFilters);
+ columnFiltersRef.current = columnFilters;
+
const buildParams = useCallback(
(page: number) => {
const p = new URLSearchParams();
- if (pastaId != null) p.set("pastaId", String(pastaId));
+ aplicarPastaNosParams(p, { pastaId, pastaNome });
p.set("page", String(page));
p.set("limit", searchParams.get("limit") || "200");
const sort = sortColumns?.[0];
@@ -112,23 +184,44 @@ export function useOperacoesGridData({
p.set("sortCol", String(sort.columnKey));
p.set("sortDir", sort.direction === "DESC" ? "desc" : "asc");
}
- for (const [key, filter] of Object.entries(columnFilters)) {
+ for (const [key, filter] of Object.entries(columnFiltersRef.current)) {
if (isFilterEmpty(filter)) continue;
p.set(`colFilter_${key}`, `${(filter as any).type}:${(filter as any).value}`);
}
return p.toString();
},
- [pastaId, sortColumns, columnFilters, searchParams]
+ [pastaId, pastaNome, sortColumns, filtersKey, searchParams]
);
// Carrega a primeira página. Se já existe cache fresco, mostra na hora e
- // revalida em background; senão busca com um debounce curto (200ms).
+ // revalida em background; senão busca — com debounce curto (200ms) só
+ // quando é digitação de filtro na mesma pasta; troca de pasta/montagem
+ // dispara na hora.
useEffect(() => {
if (!enabled) return;
const seq = ++seqRef.current;
- const key = cacheKeyOf(pastaId, filtersKey);
+ const pasta = { pastaId, pastaNome };
+ const chave = pastaChave(pasta);
+ // Só é "digitação" quando a pasta é a mesma, os filtros mudaram e ainda há
+ // filtro ativo — limpar filtros (ex.: ao trocar de pasta) dispara na hora.
+ const isFilterChange =
+ lastRunRef.current?.pasta === chave &&
+ lastRunRef.current?.filters !== filtersKey &&
+ filtersKey !== EMPTY_FILTERS_KEY;
+ // Troca de pasta: o componente fica montado entre rotas, então `dados`
+ // guarda as linhas da pasta anterior. Precisamos saber disso para limpar o
+ // estado e deixar o skeleton aparecer (senão dados.length !== 0 o esconde).
+ const isPastaChange = lastRunRef.current !== null && lastRunRef.current.pasta !== chave;
+ lastRunRef.current = { pasta: chave, filters: filtersKey };
+
+ const key = cacheKeyOf(pasta, filtersKey);
const cached = gridCache.get(key);
const url = `/operacoes?${buildParams(1)}`;
+ const controller = new AbortController();
+ // Só usado pelo caminho do prefetch em voo abaixo: sua Promise não está
+ // amarrada a este controller, então precisamos saber se o efeito já foi
+ // desmontado/reexecutado para não atualizar estado depois disso.
+ let cleaned = false;
const storeResult = (raw: { data: any[]; meta: GridMeta }) => {
gridCache.set(key, { dados: raw.data, meta: raw.meta, ts: Date.now() });
@@ -136,14 +229,14 @@ export function useOperacoesGridData({
const fetchSilently = async () => {
try {
- const res = await api.get<{ data: any[]; meta: GridMeta }>(url);
+ const res = await api.get<{ data: any[]; meta: GridMeta }>(url, controller.signal);
if (seq !== seqRef.current) return;
storeResult(res);
setDados(processarDatas(res.data));
setMeta(res.meta);
setStatus("idle");
} catch {
- // mantém os dados em cache mesmo se o refresh falhar
+ // mantém os dados em cache mesmo se o refresh falhar (inclui abort)
}
};
@@ -151,13 +244,14 @@ export function useOperacoesGridData({
setStatus("loading");
setError(null);
try {
- const res = await api.get<{ data: any[]; meta: GridMeta }>(url);
+ const res = await api.get<{ data: any[]; meta: GridMeta }>(url, controller.signal);
if (seq !== seqRef.current) return;
storeResult(res);
setDados(processarDatas(res.data));
setMeta(res.meta);
setStatus("idle");
} catch (err: any) {
+ if (err?.name === "AbortError") return; // cancelado pelo cleanup do efeito — não é erro
if (seq !== seqRef.current) return;
setStatus("error");
setError(err?.message || "Erro ao carregar operações.");
@@ -170,13 +264,50 @@ export function useOperacoesGridData({
setStatus("idle");
setError(null);
const t = setTimeout(() => fetchSilently(), 250);
- return () => clearTimeout(t);
+ return () => { clearTimeout(t); controller.abort(); };
}
+ // Hover + clique na mesma pasta: o prefetch do hover já está em voo para
+ // esta chave — espera o resultado dele em vez de disparar outra request
+ // (a página fica com o skeleton, sem debounce, igual ao caminho normal).
+ const emVoo = prefetchEmVoo.get(key);
+ if (emVoo) {
+ if (isPastaChange) {
+ setDados([]);
+ setMeta(EMPTY_META);
+ }
+ setStatus("loading");
+ emVoo
+ .then((res) => {
+ if (cleaned || seq !== seqRef.current) return;
+ storeResult(res);
+ setDados(processarDatas(res.data));
+ setMeta(res.meta);
+ setStatus("idle");
+ })
+ .catch(() => {
+ // prefetch abortado (ex.: mouse saiu para outra pasta) — busca do zero
+ if (cleaned || seq !== seqRef.current) return;
+ fetchFresh();
+ });
+ return () => { cleaned = true; controller.abort(); };
+ }
+
+ // Sem cache fresco: ao trocar de pasta, zera os dados da pasta anterior
+ // para o skeleton aparecer (e não piscar as linhas erradas). Digitação de
+ // filtro na mesma pasta mantém os dados em tela — só a faixa do topo.
+ if (isPastaChange) {
+ setDados([]);
+ setMeta(EMPTY_META);
+ }
setStatus("loading");
- const t = setTimeout(() => fetchFresh(), 200);
- return () => clearTimeout(t);
- }, [pastaId, filtersKey, enabled, buildParams]);
+ if (isFilterChange) {
+ const t = setTimeout(() => fetchFresh(), 200);
+ return () => { clearTimeout(t); controller.abort(); };
+ }
+ fetchFresh();
+ return () => controller.abort();
+ }, [pastaId, pastaNome, filtersKey, enabled, buildParams]);
// Recarrega a primeira página (após mutações), ignorando o cache.
const refresh = useCallback(async () => {
@@ -185,7 +316,7 @@ export function useOperacoesGridData({
try {
const res = await api.get<{ data: any[]; meta: GridMeta }>(`/operacoes?${buildParams(1)}`);
if (seq !== seqRef.current) return;
- gridCache.set(cacheKeyOf(pastaId, filtersKey), { dados: res.data, meta: res.meta, ts: Date.now() });
+ gridCache.set(cacheKeyOf({ pastaId, pastaNome }, filtersKey), { dados: res.data, meta: res.meta, ts: Date.now() });
setDados(processarDatas(res.data));
setMeta(res.meta);
setStatus("idle");
@@ -194,7 +325,7 @@ export function useOperacoesGridData({
setStatus("error");
setError(err?.message || "Erro ao recarregar operações.");
}
- }, [buildParams, pastaId, filtersKey]);
+ }, [buildParams, pastaId, pastaNome, filtersKey]);
// Scroll infinito: carrega a próxima página e faz append sem duplicar
const handleScroll = useCallback(
@@ -230,4 +361,4 @@ export function useOperacoesGridData({
);
return { dados, setDados, meta, status, error, handleScroll, refresh };
-}
\ No newline at end of file
+}
diff --git a/app/lib/api.ts b/app/lib/api.ts
index 466cca8..87f39bd 100644
--- a/app/lib/api.ts
+++ b/app/lib/api.ts
@@ -1,3 +1,5 @@
+import { iniciar, terminar } from "~/lib/loading";
+
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
@@ -19,30 +21,35 @@ async function request(path: string, options: RequestOptions = {}): Promise = {};
if (body !== undefined && !formData) headers["Content-Type"] = "application/json";
- const res = await fetch(`/api${path}`, {
- method,
- credentials: "include",
- signal,
- headers,
- body: formData ?? (body !== undefined ? JSON.stringify(body) : undefined),
- });
-
- let data: any = null;
+ iniciar();
try {
- data = await res.json();
- } catch {
- data = null;
- }
+ const res = await fetch(`/api${path}`, {
+ method,
+ credentials: "include",
+ signal,
+ headers,
+ body: formData ?? (body !== undefined ? JSON.stringify(body) : undefined),
+ });
- if (!res.ok) {
- if (res.status === 401) {
- window.dispatchEvent(new Event("auth:unauthorized"));
+ let data: any = null;
+ try {
+ data = await res.json();
+ } catch {
+ data = null;
}
- const message = data?.error || data?.message || `Erro ${res.status}`;
- throw new ApiError(message, res.status);
- }
- return data as T;
+ if (!res.ok) {
+ if (res.status === 401) {
+ window.dispatchEvent(new Event("auth:unauthorized"));
+ }
+ const message = data?.error || data?.message || `Erro ${res.status}`;
+ throw new ApiError(message, res.status);
+ }
+
+ return data as T;
+ } finally {
+ terminar();
+ }
}
export const api = {
diff --git a/app/lib/loading.ts b/app/lib/loading.ts
new file mode 100644
index 0000000..763a47a
--- /dev/null
+++ b/app/lib/loading.ts
@@ -0,0 +1,36 @@
+import { useSyncExternalStore } from "react";
+
+//Contador de requests em voo. A grid faz api.get manual (fora do React
+//Query), então useIsFetching() sozinho não bastaria para acender a faixa do
+//topo — request() em lib/api.ts chama iniciar()/terminar() em toda chamada,
+//React Query ou manual, e useRequestsEmVoo() expõe o valor via
+//useSyncExternalStore.
+let emVoo = 0;
+const listeners = new Set<() => void>();
+
+function emitir() {
+ for (const listener of listeners) listener();
+}
+
+export function iniciar(): void {
+ emVoo += 1;
+ emitir();
+}
+
+export function terminar(): void {
+ emVoo = Math.max(0, emVoo - 1);
+ emitir();
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+function getSnapshot(): number {
+ return emVoo;
+}
+
+export function useRequestsEmVoo(): number {
+ return useSyncExternalStore(subscribe, getSnapshot);
+}
diff --git a/app/lib/query.ts b/app/lib/query.ts
index 380a1cd..89c1cdd 100644
--- a/app/lib/query.ts
+++ b/app/lib/query.ts
@@ -1,4 +1,4 @@
-import { QueryClient, useQuery } from "@tanstack/react-query";
+import { keepPreviousData, QueryClient, useQuery } from "@tanstack/react-query";
import { api } from "./api";
export const queryClient = new QueryClient({
@@ -30,13 +30,17 @@ export interface InitData {
emissaoAntigasPorPasta: Record;
}
+// Extraída para ser reaproveitada pelo AuthContext (boot com uma única
+// request: sem /auth/me separado, ver AuthContext.tsx).
+export const fetchInit = () => api.get("/init");
+
// Dados de boot (sidebar + ordem de colunas). Mantidos frescos com polling
// leve apenas com a aba em foco — é o que dá o "tempo real" dos contadores
// da sidebar sem custo quando o usuário não está olhando.
export function useInit() {
return useQuery({
queryKey: queryKeys.init,
- queryFn: () => api.get("/init"),
+ queryFn: fetchInit,
staleTime: 60_000,
refetchOnWindowFocus: true,
refetchInterval: 120_000,
@@ -50,12 +54,15 @@ export interface Faturista {
email: string;
}
-// Lista de usuários ativos que podem ser atribuídos como faturista de uma pasta.
-export function useFaturistas() {
+// Lista de usuários ativos que podem ser atribuídos como faturista de uma
+// pasta. Só busca quando o formulário de criar/editar pasta está aberto —
+// o select não aparece em mais lugar nenhum.
+export function useFaturistas(enabled: boolean) {
return useQuery({
queryKey: queryKeys.faturistas,
queryFn: () => api.get<{ faturistas: Faturista[] }>("/pastas/faturistas"),
staleTime: 60_000,
+ enabled,
});
}
@@ -93,6 +100,8 @@ export interface DashboardFiltros {
tipoDocumento?: string;
tipoCte?: string;
agencia?: string;
+ // "1" = só emissões antigas (fora do prazo padrão/do cliente).
+ antigas?: string;
}
export interface DashboardGeral {
@@ -160,7 +169,7 @@ export interface DashboardFaturistaDetalhe {
// Opções distintas para os selects de filtro.
export interface DashboardOpcoes {
faturistas: { id: string; nome: string }[];
- pastas: { id: number; nome: string }[];
+ pastas: { id: number; nome: string; faturistaId: string | null }[];
clientes: string[];
status: string[];
tiposDocumento: string[];
@@ -177,6 +186,7 @@ export function filtrosParaQuery(filtros: DashboardFiltros): string {
if (filtros.tipoDocumento) p.set("tipo", filtros.tipoDocumento);
if (filtros.tipoCte) p.set("tipoCte", filtros.tipoCte);
if (filtros.agencia) p.set("agencia", filtros.agencia);
+ if (filtros.antigas) p.set("antigas", filtros.antigas);
const qs = p.toString();
return qs ? `?${qs}` : "";
}
@@ -192,6 +202,8 @@ export function useDashboard(filtros: DashboardFiltros = {}) {
refetchOnWindowFocus: true,
refetchInterval: 30_000,
refetchIntervalInBackground: false,
+ // Mantém os números anteriores na tela ao trocar de filtro, até a resposta chegar.
+ placeholderData: keepPreviousData,
});
}
@@ -220,6 +232,8 @@ export function useDashboardFaturista(
api.get(`/dashboard/faturistas/${encodeURIComponent(chave)}${qs}`),
enabled,
staleTime: 60_000,
+ // Mantém os números anteriores na tela ao trocar de filtro, até a resposta chegar.
+ placeholderData: keepPreviousData,
});
}
diff --git a/app/main.tsx b/app/main.tsx
index bb90d7a..421bd37 100644
--- a/app/main.tsx
+++ b/app/main.tsx
@@ -6,6 +6,18 @@ import { AuthProvider } from "./context/AuthContext";
import { AppRoutes } from "./AppRoutes";
import "./styles/tailwind.css";
+//Um deploy pode apagar os chunks antigos enquanto uma aba aberta ainda os pede
+//(rotas lazy). Recarrega a página para buscar os chunks novos, no máximo 1x/min.
+window.addEventListener("vite:preloadError", () => {
+ const chave = "logicell-last-reload";
+ const agora = Date.now();
+ const ultima = Number(sessionStorage.getItem(chave) || 0);
+ if (agora - ultima > 60_000) {
+ sessionStorage.setItem(chave, String(agora));
+ window.location.reload();
+ }
+});
+
createRoot(document.getElementById("root")!).render(
diff --git a/app/pages/OperacoesPage.tsx b/app/pages/OperacoesPage.tsx
index e9bc4f0..618ae9c 100644
--- a/app/pages/OperacoesPage.tsx
+++ b/app/pages/OperacoesPage.tsx
@@ -6,22 +6,17 @@ export function OperacoesPage() {
const { nome } = useParams();
const { data: init } = useInit();
- // Rota de pasta (/pastas/:nome)
+ // Rota de pasta (/pastas/:nome): renderiza pelo nome da URL direto, em
+ // paralelo com o /init (não espera resolver o id da pasta); só redireciona
+ // para a inbox quando o /init chega e a pasta não existe.
if (nome) {
- const pasta = (init?.pastas || []).find((p: any) => p.nome === decodeURIComponent(nome));
- if (init && !pasta) return ;
- if (!pasta) return ;
- return ;
+ const nomePasta = decodeURIComponent(nome);
+ if (init && !(init.pastas || []).some((p: any) => p.nome === nomePasta)) {
+ return ;
+ }
+ return ;
}
// Caixa de entrada (/caixa-de-entrada)
return ;
}
-
-function GridLoading() {
- return (
-
- );
-}
diff --git a/app/pages/PerfilPage.tsx b/app/pages/PerfilPage.tsx
index e6e718b..c68a2ef 100644
--- a/app/pages/PerfilPage.tsx
+++ b/app/pages/PerfilPage.tsx
@@ -3,6 +3,7 @@ import { useAuth } from "~/context/AuthContext";
import { api } from "~/lib/api";
import { ProfileCard } from "~/components/ProfileCard";
import { RecentImportsList } from "~/components/RecentImportsList";
+import { Skeleton } from "~/components/Skeleton";
export function PerfilPage() {
const { signOut } = useAuth();
@@ -13,8 +14,11 @@ export function PerfilPage() {
if (!data) {
return (
-
-
Carregando...
+
);
}
diff --git a/app/styles/tailwind.css b/app/styles/tailwind.css
index 525c0ea..ca4f6a5 100644
--- a/app/styles/tailwind.css
+++ b/app/styles/tailwind.css
@@ -24,6 +24,7 @@
--border-focus: rgba(0, 102, 255, 0.4);
--surface: rgba(0, 0, 0, 0.03);
--surface-light: rgba(0, 0, 0, 0.05);
+ --skeleton: rgba(0, 0, 0, 0.06);
--shadow-card: 0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-card-elevated: 0 4px 16px rgba(0, 0, 0, 0.08), 0 12px 32px rgba(0, 0, 0, 0.06);
@@ -59,6 +60,8 @@
--border-focus: rgba(0, 102, 255, 0.4);
--surface: rgba(255, 255, 255, 0.03);
--surface-light: rgba(255, 255, 255, 0.06);
+ /* Skeleton: cinza-azulado (slate) sobre o fundo escuro, em vez de branco */
+ --skeleton: #1b2532;
--shadow-card: 0 4px 12px rgba(0, 0, 0, 0.1);
--shadow-card-elevated: 0 10px 40px rgba(0, 0, 0, 0.1), 0 20px 50px rgba(0, 0, 0, 0.45);
diff --git a/app/utils/cargos.ts b/app/utils/cargos.ts
new file mode 100644
index 0000000..5f6601e
--- /dev/null
+++ b/app/utils/cargos.ts
@@ -0,0 +1,19 @@
+//Espelha os cargos do servidor (supabase-admin.server.ts). A checagem aqui é só
+//para esconder menus/rotas — quem barra de verdade é o middleware da API.
+export type Cargo = "usuario" | "gestor" | "admin";
+
+export const CARGO_LABEL: Record
= {
+ usuario: "Usuário",
+ gestor: "Gestor",
+ admin: "Administrador",
+};
+
+export function cargoDe(user: any): Cargo {
+ const role = user?.app_metadata?.role;
+ return role === "admin" || role === "gestor" ? role : "usuario";
+}
+
+export const ehAdmin = (user: any) => cargoDe(user) === "admin";
+
+//Gestor ou acima: dashboard, automações e vínculo de faturistas.
+export const ehGestor = (user: any) => cargoDe(user) !== "usuario";
diff --git a/app/utils/cn.ts b/app/utils/cn.ts
new file mode 100644
index 0000000..ed3388c
--- /dev/null
+++ b/app/utils/cn.ts
@@ -0,0 +1,9 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+//Junta classes condicionais (clsx) e resolve conflitos do Tailwind (twMerge).
+//Vários arquivos já redeclaram essa função localmente; não refatorar os
+//outros para usar esta.
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/app/utils/export.ts b/app/utils/export.ts
index 147246c..5f35e92 100644
--- a/app/utils/export.ts
+++ b/app/utils/export.ts
@@ -1,8 +1,9 @@
-import * as XLSX from "xlsx";
import { formatarData } from "./formatters";
-export function exportarExcel(dados: any[], colunas: any[], nomePasta: string, showAlert: any) {
+export async function exportarExcel(dados: any[], colunas: any[], nomePasta: string, showAlert: any) {
try {
+ //xlsx só é baixado quando o usuário realmente exporta (~1 MB fora do bundle principal)
+ const XLSX = await import("xlsx");
const exportData = dados.map(row => {
const obj: any = {};
colunas.forEach(col => {
diff --git a/app/utils/formatters.ts b/app/utils/formatters.ts
index 20fd8c5..2b1cca4 100644
--- a/app/utils/formatters.ts
+++ b/app/utils/formatters.ts
@@ -1,7 +1,11 @@
//Utilitários de formatação de dados para a UI brasileira.
+//Instância única: criar um Intl.NumberFormat por chamada é caro e a grid/dashboard
+//formatam muitos valores por render.
+const BRL = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' });
+
export const formatarMoeda = (val: any) => {
if (val === null || val === undefined) return "-";
- return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(Number(val));
+ return BRL.format(Number(val));
};
export const formatarData = (val: any) => {
diff --git a/app/views/DashboardView.tsx b/app/views/DashboardView.tsx
index 8e52152..87ac9ed 100644
--- a/app/views/DashboardView.tsx
+++ b/app/views/DashboardView.tsx
@@ -1,5 +1,5 @@
-import { ChevronDown, Filter, Users, X } from "lucide-react";
-import { Fragment, useState } from "react";
+import { Check, ChevronDown, ChevronLeft, ChevronRight, Filter, Plus, Search, Users, X } from "lucide-react";
+import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
import {
useDashboard,
useDashboardFaturista,
@@ -11,6 +11,9 @@ import {
type DashboardOpcoes,
} from "~/lib/query";
import { formatarData, formatarMoeda } from "~/utils/formatters";
+import { Skeleton } from "~/components/Skeleton";
+import { useAuth } from "~/context/AuthContext";
+import { ehGestor } from "~/utils/cargos";
type ItemBarra = { label: string; valor: number; quantidade: number; cor?: string | null };
type FiltroCampo = keyof DashboardFiltros;
@@ -72,6 +75,41 @@ function BarList({
);
}
+// Skeleton do acordeão da dashboard, mostrado só no primeiro carregamento
+// (com keepPreviousData, os polls seguintes mantêm o conteúdo anterior em tela).
+function DashboardSkeleton() {
+ return (
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+
+ );
+}
+
+// Skeleton do detalhe do faturista: 5 MiniStat + 3 cards de distribuição.
+function FaturistaDetalheSkeleton() {
+ return (
+
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+
+ );
+}
+
function MiniStat({ label, valor, sub }: { label: string; valor: string; sub?: string }) {
return (
@@ -118,136 +156,332 @@ function DistribuicaoCard({
// Barra de filtros globais
// ---------------------------------------------------------------------------
-const SELECT_CLS =
- "h-9 flex-1 min-w-[140px] max-w-[220px] bg-surface border border-glass-border rounded-lg px-3 text-xs font-bold text-text outline-none focus:border-primary cursor-pointer";
+type OpcaoFiltro = { valor: string; label: string };
+type CampoFiltro = (typeof CAMPOS_FILTRO)[number];
+
+// Campos oferecidos no menu "+ Filtro". As sentinelas ("sem", "inbox",
+// "__vazio__") entram no fim de cada lista, como nos selects antigos.
+const CAMPOS_FILTRO: {
+ campo: FiltroCampo;
+ label: string;
+ busca?: boolean;
+ opcoes: (o?: DashboardOpcoes) => OpcaoFiltro[];
+}[] = [
+ {
+ campo: "faturistaId",
+ label: "Faturista",
+ opcoes: (o) => [
+ ...(o?.faturistas.map((f) => ({ valor: f.id, label: f.nome })) ?? []),
+ { valor: "sem", label: "Sem faturista" },
+ ],
+ },
+ {
+ campo: "pastaId",
+ label: "Pasta",
+ busca: true,
+ opcoes: (o) => [
+ ...(o?.pastas.map((p) => ({ valor: String(p.id), label: p.nome })) ?? []),
+ { valor: "inbox", label: "Caixa de Entrada" },
+ ],
+ },
+ {
+ campo: "pagador",
+ label: "Cliente",
+ busca: true,
+ opcoes: (o) => o?.clientes.map((c) => ({ valor: c, label: c })) ?? [],
+ },
+ {
+ campo: "status",
+ label: "Status",
+ opcoes: (o) => [
+ ...(o?.status.map((s) => ({ valor: s, label: s })) ?? []),
+ { valor: "__vazio__", label: "Sem status" },
+ ],
+ },
+ {
+ campo: "tipoDocumento",
+ label: "Tipo",
+ opcoes: (o) => [
+ ...(o?.tiposDocumento.map((t) => ({ valor: t, label: t })) ?? []),
+ { valor: "__vazio__", label: "Não informado" },
+ ],
+ },
+ {
+ campo: "tipoCte",
+ label: "Tipo de CTe",
+ opcoes: (o) => [
+ ...(o?.tiposCte.map((t) => ({ valor: t, label: t })) ?? []),
+ { valor: "__vazio__", label: "Não informado" },
+ ],
+ },
+ {
+ campo: "agencia",
+ label: "Agência",
+ busca: true,
+ opcoes: (o) => [
+ ...(o?.agencias.map((a) => ({ valor: a, label: a })) ?? []),
+ { valor: "__vazio__", label: "Sem agência" },
+ ],
+ },
+];
+
+//Usuário comum só vê as próprias pastas (o servidor força o recorte): sem filtro
+//de faturista nem a Caixa de Entrada, que não tem dono.
+const CAMPOS_FILTRO_USUARIO: CampoFiltro[] = CAMPOS_FILTRO
+ .filter((c) => c.campo !== "faturistaId")
+ .map((c) =>
+ c.campo === "pastaId"
+ ? { ...c, opcoes: (o?: DashboardOpcoes) => c.opcoes(o).filter((p) => p.valor !== "inbox") }
+ : c
+ );
+
+// Lista de clientes pode ter milhares de itens: renderiza só os primeiros.
+const MAX_OPCOES_VISIVEIS = 200;
+
+function MenuFiltro({
+ campos,
+ campoInicial,
+ filtros,
+ opcoes,
+ onEscolher,
+ onFechar,
+}: {
+ campos: CampoFiltro[];
+ campoInicial: FiltroCampo | null;
+ filtros: DashboardFiltros;
+ opcoes?: DashboardOpcoes;
+ onEscolher: (campo: FiltroCampo, valor: string) => void;
+ onFechar: () => void;
+}) {
+ const [campo, setCampo] = useState
(campoInicial);
+ const [busca, setBusca] = useState("");
+
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => e.key === "Escape" && onFechar();
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [onFechar]);
+
+ const def = campos.find((c) => c.campo === campo);
+
+ const lista = useMemo(() => {
+ if (!def) return [];
+ const termo = busca.trim().toLowerCase();
+ const todas = def.opcoes(opcoes);
+ return termo ? todas.filter((o) => o.label.toLowerCase().includes(termo)) : todas;
+ }, [def, opcoes, busca]);
+
+ return (
+ <>
+
+
+ {!def ? (
+
+
+ Filtrar por
+
+ {campos.map((c) => (
+
setCampo(c.campo)}
+ className="w-full flex items-center justify-between px-2.5 py-2 rounded-lg text-xs font-bold text-text hover:bg-surface transition-colors"
+ >
+ {c.label}
+
+ {filtros[c.campo] && }
+
+
+
+ ))}
+
+ ) : (
+
+
+
{
+ setCampo(null);
+ setBusca("");
+ }}
+ className="p-1 rounded-md text-text-muted hover:text-text hover:bg-surface transition-colors"
+ aria-label="Voltar"
+ >
+
+
+ {def.busca || lista.length > 8 ? (
+
+
+ setBusca(e.target.value)}
+ placeholder={`Buscar ${def.label.toLowerCase()}...`}
+ className="flex-1 min-w-0 bg-transparent text-xs font-medium text-text placeholder:text-text-muted outline-none"
+ />
+
+ ) : (
+
+ {def.label}
+
+ )}
+
+
+ {lista.length === 0 ? (
+
Nada encontrado.
+ ) : (
+ lista.slice(0, MAX_OPCOES_VISIVEIS).map((o) => {
+ const ativo = filtros[def.campo] === o.valor;
+ return (
+
onEscolher(def.campo, ativo ? "" : o.valor)}
+ className={`w-full flex items-center justify-between gap-2 px-2.5 py-2 rounded-lg text-xs text-left transition-colors ${
+ ativo ? "bg-badge-primary-bg text-badge-primary-text font-bold" : "text-text font-medium hover:bg-surface"
+ }`}
+ >
+ {o.label}
+ {ativo && }
+
+ );
+ })
+ )}
+ {lista.length > MAX_OPCOES_VISIVEIS && (
+
+ Mostrando {MAX_OPCOES_VISIVEIS} de {lista.length}. Refine a busca.
+
+ )}
+
+
+ )}
+
+ >
+ );
+}
function FiltrosBar({
+ campos,
filtros,
onChange,
onLimpar,
opcoes,
}: {
+ campos: CampoFiltro[];
filtros: DashboardFiltros;
onChange: OnChangeFiltro;
onLimpar: () => void;
opcoes?: DashboardOpcoes;
}) {
- const temFiltro = Object.values(filtros).some(Boolean);
-
- return (
-
-
-
- Filtros
-
-
-
onChange("faturistaId", e.target.value)}
- className={SELECT_CLS}
- >
- Todos os faturistas
- {opcoes?.faturistas.map((f) => (
-
- {f.nome}
-
- ))}
- Sem faturista
-
-
-
onChange("pastaId", e.target.value)}
- className={SELECT_CLS}
- >
- Todas as pastas
- {opcoes?.pastas.map((p) => (
-
- {p.nome}
-
- ))}
- Caixa de Entrada
-
+ // null = menu fechado; "novo" = abre na lista de campos; campo = edita o chip.
+ const [menu, setMenu] = useState
(null);
+ const fecharMenu = useCallback(() => setMenu(null), []);
- onChange("pagador", e.target.value)}
- className={SELECT_CLS}
- >
- Todos os clientes
- {opcoes?.clientes.map((c) => (
-
- {c}
-
- ))}
-
+ const temFiltro = Object.values(filtros).some(Boolean);
+ const ativos = campos.filter((c) => filtros[c.campo]);
+ const soAntigas = filtros.antigas === "1";
- onChange("status", e.target.value)}
- className={SELECT_CLS}
- >
- Todos os status
- {opcoes?.status.map((s) => (
-
- {s}
-
- ))}
- Sem status
-
+ const rotuloValor = (def: CampoFiltro, valor: string) =>
+ def.opcoes(opcoes).find((o) => o.valor === valor)?.label ?? valor;
- onChange("tipoDocumento", e.target.value)}
- className={SELECT_CLS}
- >
- Todos os tipos
- {opcoes?.tiposDocumento.map((t) => (
-
- {t}
-
- ))}
- Não informado
-
+ const escolher = (campo: FiltroCampo, valor: string) => {
+ onChange(campo, valor);
+ setMenu(null);
+ };
- onChange("tipoCte", e.target.value)}
- className={SELECT_CLS}
- >
- Todos os tipos de CTe
- {opcoes?.tiposCte.map((t) => (
-
- {t}
-
- ))}
- Não informado
-
+ return (
+
+ {ativos.map((def) => (
+
+
+ setMenu(def.campo)}
+ className="flex items-center gap-1 pl-2.5 pr-1.5 h-full max-w-[260px] hover:opacity-80 transition-opacity"
+ >
+ {def.label}:
+
+ {rotuloValor(def, filtros[def.campo]!)}
+
+
+ onChange(def.campo, "")}
+ className="h-full px-1.5 text-badge-primary-text opacity-70 hover:opacity-100 transition-opacity"
+ aria-label={`Remover filtro ${def.label}`}
+ >
+
+
+
+ {menu === def.campo && (
+
+ )}
+
+ ))}
-
onChange("agencia", e.target.value)}
- className={SELECT_CLS}
- >
- Todas as agências
- {opcoes?.agencias.map((a) => (
-
- {a}
-
- ))}
- Sem agência
-
+
+
setMenu(menu === "novo" ? null : "novo")}
+ className="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-dashed border-glass-border text-xs font-bold text-text-muted hover:text-text hover:border-primary hover:bg-surface transition-all"
+ >
+
+ {ativos.length === 0 ? "Adicionar filtro" : "Filtro"}
+
+
+ {menu === "novo" && (
+
+ )}
+
{temFiltro && (
-
- Limpar filtros
+ Limpar
)}
+
+
onChange("antigas", soAntigas ? "" : "1")}
+ className={`ml-auto flex items-center gap-2 h-8 px-3 rounded-lg border text-xs font-bold transition-all ${
+ soAntigas
+ ? "bg-badge-warning-bg border-transparent text-badge-warning-text"
+ : "bg-surface border-glass-border text-text-muted hover:text-text"
+ }`}
+ >
+
+
+
+ Só emissões antigas
+
);
}
@@ -265,16 +499,10 @@ function FaturistaDetalhe({
faturistaId: string | null;
filtros: DashboardFiltros;
}) {
- const { data, isLoading, isError } = useDashboardFaturista(faturistaId, filtros, true);
+ const { data, isLoading, isError, isPlaceholderData } = useDashboardFaturista(faturistaId, filtros, true);
if (isLoading) {
- return (
-
-
- Carregando faturista...
-
-
- );
+ return ;
}
if (isError || !data) {
@@ -286,7 +514,10 @@ function FaturistaDetalhe({
}
return (
-
+ // Ao trocar de filtro, os números anteriores ficam em tela (keepPreviousData)
+ // com opacidade menor até a resposta chegar. isPlaceholderData (e não
+ // isFetching) para o polling em segundo plano não escurecer a tela.
+
Período: {formatarData(data.primeiraEmissao)} → {formatarData(data.ultimaEmissao)}
@@ -487,9 +718,11 @@ function GeralConteudo({
// ---------------------------------------------------------------------------
export function DashboardView() {
+ const { user } = useAuth();
+ const gestor = ehGestor(user);
const [filtros, setFiltros] = useState
({});
- const { data, isLoading, isError } = useDashboard(filtros);
+ const { data, isLoading, isError, isPlaceholderData } = useDashboard(filtros);
const { data: opcoes } = useDashboardOpcoes();
const geral = data?.geral;
@@ -512,12 +745,13 @@ export function DashboardView() {
Dashboard
- Visão geral das pastas e documentos
+ {gestor ? "Visão geral das pastas e documentos" : "Visão geral das suas pastas e documentos"}
) : isLoading || !geral ? (
-
+
) : (
-
+ // keepPreviousData mantém os números do filtro anterior em tela; a
+ // opacidade cai um pouco só enquanto eles são placeholder (troca de
+ // filtro), não no polling em segundo plano.
+
+
+
)}
diff --git a/app/views/OperacoesView.tsx b/app/views/OperacoesView.tsx
index 78eb7e3..954dd96 100644
--- a/app/views/OperacoesView.tsx
+++ b/app/views/OperacoesView.tsx
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useSearchParams } from "react-router";
-import { useOperacoesGridState } from "~/hooks/useOperacoesGridState";
+import { useOperacoesGridState, COLUNAS_OPERACAO } from "~/hooks/useOperacoesGridState";
import { useOperacoesGridData, isFilterEmpty } from "~/hooks/useOperacoesGridData";
import { useOperacoesStore } from "~/store/useOperacoesStore";
import "react-data-grid/lib/styles.css";
@@ -11,6 +11,7 @@ import { useUI } from "~/hooks/use-ui";
import { exportarExcel } from "~/utils/export";
import { ColumnFilterMenu } from "~/components/ColumnFilterMenu";
import { ImportModal } from "~/components/ImportModal";
+import { Skeleton } from "~/components/Skeleton";
import { OperacoesToolbarView } from "./OperacoesToolbarView";
import { getOperacoesColumns } from "./OperacoesColumns";
import { api, errorMessage } from "~/lib/api";
@@ -20,11 +21,32 @@ import DataGrid from "react-data-grid";
interface OperacoesViewProps {
pastaId?: number | null;
+ pastaNome?: string;
nomePasta: string;
showImport?: boolean;
}
-export function OperacoesView({ pastaId = null, nomePasta, showImport = true }: OperacoesViewProps) {
+// Skeleton da grid: overlay sobre o container (que já é `relative`), visível
+// só na primeira carga da pasta (ainda sem dados) — trocar filtro com dados
+// já em tela não mostra isto, só a faixa do topo.
+const LARGURAS_SKELETON = ["w-10", "w-16", "w-24", "w-14", "w-20", "w-28", "w-12", "w-32"];
+
+function GridSkeleton() {
+ return (
+
+
+ {Array.from({ length: 15 }).map((_, i) => (
+
+ {Array.from({ length: 6 + (i % 3) }).map((_, j) => (
+
+ ))}
+
+ ))}
+
+ );
+}
+
+export function OperacoesView({ pastaId = null, pastaNome, nomePasta, showImport = true }: OperacoesViewProps) {
const { data: init } = useInit();
const pastas = init?.pastas || [];
const columnOrder = init?.columnOrder ?? null;
@@ -55,7 +77,7 @@ export function OperacoesView({ pastaId = null, nomePasta, showImport = true }:
const [sortColumns, setSortColumns] = useState([]);
const [importing, setImporting] = useState(false);
- const grid = useOperacoesGridData({ pastaId, columnFilters, sortColumns });
+ const grid = useOperacoesGridData({ pastaId, pastaNome, columnFilters, sortColumns });
const { dados, setDados, meta, status, error: gridError, handleScroll, refresh } = grid;
const [currentMetaTotal, setCurrentMetaTotal] = useState(0);
@@ -71,12 +93,19 @@ export function OperacoesView({ pastaId = null, nomePasta, showImport = true }:
useEffect(() => {
resetSelection();
if (!location.state) {
- setColumnFilters({});
+ // Só cria objeto novo quando há filtro pra limpar — {} de novo (mesma
+ // pasta ou montagem) mudaria a referência de columnFilters à toa e
+ // recriaria buildParams, reexecutando o efeito de dados do grid.
+ setColumnFilters((prev) => (Object.keys(prev).length ? {} : prev));
}
- }, [pastaId, location.pathname, location.state, setColumnFilters, resetSelection]);
+ }, [pastaId, pastaNome, location.pathname, location.state, setColumnFilters, resetSelection]);
+ // Envia pastaNome quando a pasta é identificada por nome (rota /pastas/:nome),
+ // para que mover/excluir "selecionar todas" fiquem restritos a ela.
const getActiveFilters = () => {
- const activeFilters: Record = { ...Object.fromEntries(searchParams), pastaId };
+ const activeFilters: Record = { ...Object.fromEntries(searchParams) };
+ if (pastaNome) activeFilters.pastaNome = pastaNome;
+ else activeFilters.pastaId = pastaId;
for (const [key, filter] of Object.entries(columnFilters)) {
if (isFilterEmpty(filter)) continue;
activeFilters[`colFilter_${key}`] = `${filter.type}:${filter.value}`;
@@ -121,9 +150,7 @@ export function OperacoesView({ pastaId = null, nomePasta, showImport = true }:
const onFillEnd = useMemo(() => (colKey: string) => handleFillEndRef.current(colKey), []);
const lidarExportarExcel = () => {
- import("~/hooks/useOperacoesGridState").then(({ COLUNAS_OPERACAO }) => {
- exportarExcel(dadosRef.current, COLUNAS_OPERACAO, nomePasta, showAlert);
- });
+ exportarExcel(dadosRef.current, COLUNAS_OPERACAO, nomePasta, showAlert);
};
const colDefs = useMemo(() => getOperacoesColumns({
@@ -191,7 +218,7 @@ export function OperacoesView({ pastaId = null, nomePasta, showImport = true }:
/>
@@ -257,20 +284,20 @@ export function OperacoesView({ pastaId = null, nomePasta, showImport = true }:
.catch((err) => showAlert({ title: "Erro ao salvar", message: errorMessage(err), variant: "error" }));
}}
onRowsChange={(newRows: any[], { indexes, column }: any) => {
+ // handleLocalUpdate já aplica o valor em `dados`; o setDados que
+ // varria todas as linhas com .map + .find era redundante (e quadrático).
if (indexes.length > 0 && column) {
const row = newRows[indexes[0]];
handleLocalUpdate(row.id, column.key, row[column.key]);
}
- setDados((prev: any[]) => prev.map((d: any) => {
- const updatedRow = newRows.find(nr => nr.id === d.id);
- return updatedRow ? updatedRow : d;
- }));
}}
className="h-full w-full rdg-light dark:rdg-dark rounded-none border-0"
rowHeight={36}
headerRowHeight={42}
/>
+ {status === "loading" && dados.length === 0 &&
}
+
-
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
) : clientesFiltrados.length === 0 ? (
diff --git a/index.html b/index.html
index cd00545..dc8eb9d 100644
--- a/index.html
+++ b/index.html
@@ -4,13 +4,26 @@
Logicell
-
+
+
+
+
+
+
+
+