fix(api,web): corrige autorização da área do rep e falhas silenciosas no front
Auditoria de segurança + robustez da área do representante antes de produção. API (autorização e injeção): - IDOR em clients: endpoints de detalhe (findOne, notas, ctr, top-produtos, histórico, contatos) validam carteira do rep via assertClienteDaCarteira; cliente alheio retorna 404. orders.create idem antes de aceitar idCliente. - Role guard em dashboards supervisor/manager e ger/chamados (rep -> 403). - Datas em $queryRawUnsafe agora exigem YYYY-MM-DD (DateOnlySchema + revalidação); dtAniversario de contato validado e escapado. - id_empresa de CTR/NF usa matrizEmpresa() em vez de 1/9001 hardcoded. - Alçada de desconto: transmit valida desconto efetivo por item (preço vs tabela pauta>promo>base + promoção ativa), não só o desconto global. - Idempotency-Key escopado a empresa+vendedor; unsubscribe com DTO e dono. Web (erros nunca silenciosos): - Remove dupla serialização (apiFetch já faz JSON.stringify) em funil, sac e politicas — corrige criar oportunidade, abrir chamado e editar políticas. - Handler global de erro no QueryClient (query e mutation viram toast). - Error boundary por rota (payload divergente não derruba o app em branco). - Alert usa message= em vez de title= (AntD 6); sw.js usa globalThis. Qualidade: - Corrige ping.contract.spec (idEmpresa) e zera erros de lint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -49,11 +49,15 @@ function TabDescontos() {
|
||||
}
|
||||
|
||||
async function saveEdit(row: AlcadaDescontoItem) {
|
||||
await upsert.mutateAsync({
|
||||
codVendedor: row.codVendedor,
|
||||
codGrupo: row.codGrupo,
|
||||
limitePerc: editValue,
|
||||
});
|
||||
try {
|
||||
await upsert.mutateAsync({
|
||||
codVendedor: row.codVendedor,
|
||||
codGrupo: row.codGrupo,
|
||||
limitePerc: editValue,
|
||||
});
|
||||
} catch {
|
||||
return; // erro já notificado pelo handler global do QueryClient
|
||||
}
|
||||
setEditingKey(null);
|
||||
void msg.success('Limite atualizado');
|
||||
}
|
||||
@@ -197,18 +201,26 @@ function TabPromocoes() {
|
||||
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||
dataFim: fim.format('YYYY-MM-DD'),
|
||||
};
|
||||
if (editTarget) {
|
||||
await updateMutation.mutateAsync({ id: editTarget.id, body });
|
||||
void msg.success('Promocao atualizada');
|
||||
} else {
|
||||
await createMutation.mutateAsync(body);
|
||||
void msg.success('Promocao criada');
|
||||
try {
|
||||
if (editTarget) {
|
||||
await updateMutation.mutateAsync({ id: editTarget.id, body });
|
||||
void msg.success('Promocao atualizada');
|
||||
} else {
|
||||
await createMutation.mutateAsync(body);
|
||||
void msg.success('Promocao criada');
|
||||
}
|
||||
} catch {
|
||||
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||
}
|
||||
setModalOpen(false);
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
try {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
} catch {
|
||||
return; // erro já notificado pelo handler global do QueryClient
|
||||
}
|
||||
void msg.success('Promocao removida');
|
||||
}
|
||||
|
||||
|
||||
@@ -315,7 +315,7 @@ export function CarteirePage() {
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
title="Erro ao carregar carteira"
|
||||
message="Erro ao carregar carteira"
|
||||
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { Grid, Table, Input, Select, Space, Typography, Tag, Button, Tooltip } from 'antd';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
import { EyeOutlined } from '@ant-design/icons';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import type { ProdutoSummary } from '@sar/api-interface';
|
||||
import { useCatalog, usePautas } from '../../lib/queries/catalog';
|
||||
import { ProductDetailDrawer } from './ProductDetailDrawer';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Search } = Input;
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
Badge,
|
||||
} from 'antd';
|
||||
import { useState } from 'react';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { CopyOutlined, UserAddOutlined } from '@ant-design/icons';
|
||||
import { Link, useNavigate, useParams } from '@tanstack/react-router';
|
||||
@@ -32,6 +30,8 @@ import {
|
||||
} from '../../lib/queries/clients';
|
||||
import { ClientContacts } from '../../components/contacts/ClientContacts';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const ACTIVITY_COLOR: Record<string, string> = {
|
||||
|
||||
@@ -1087,7 +1087,7 @@ export function ClientsPage() {
|
||||
limit,
|
||||
});
|
||||
|
||||
const rows = data?.data ?? [];
|
||||
const rows = useMemo(() => data?.data ?? [], [data]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const r = [...rows];
|
||||
|
||||
@@ -231,6 +231,8 @@ function OportModal({
|
||||
observacoes: values.observacoes ?? null,
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
// erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -479,7 +481,7 @@ export function FunilPage() {
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
title="Erro ao carregar funil"
|
||||
message="Erro ao carregar funil"
|
||||
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
||||
style={{ marginBottom: 16, flexShrink: 0 }}
|
||||
/>
|
||||
|
||||
@@ -129,7 +129,12 @@ export function NewClientPage() {
|
||||
codPauta: values.codPauta ? Number(values.codPauta) : undefined,
|
||||
};
|
||||
|
||||
const result = await createMutation.mutateAsync(payload);
|
||||
let result: Awaited<ReturnType<typeof createMutation.mutateAsync>>;
|
||||
try {
|
||||
result = await createMutation.mutateAsync(payload);
|
||||
} catch {
|
||||
return; // erro já notificado pelo handler global do QueryClient
|
||||
}
|
||||
|
||||
if (!result.sincronizado && result.erroSync) {
|
||||
setSyncError(result.erroSync);
|
||||
|
||||
@@ -22,8 +22,6 @@ import {
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
@@ -46,6 +44,8 @@ import { usePendingOrders } from '../../lib/hooks/usePendingOrders';
|
||||
import { removePendingOrder, retryPendingOrder } from '../../lib/offline/order-queue';
|
||||
import { apiFetch } from '../../lib/api-client';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
@@ -667,7 +667,7 @@ export function OrdersPage() {
|
||||
limit,
|
||||
});
|
||||
|
||||
const rows = data?.data ?? [];
|
||||
const rows = useMemo(() => data?.data ?? [], [data]);
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
const hasFilters = !!query || !!situaFilter || !!period || !!range;
|
||||
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
import { Chart } from 'react-chartjs-2';
|
||||
import type { MetaItem, ClienteNaoPositivado, PedidoSummary, MesAno } from '@sar/api-interface';
|
||||
import { SITUA_LABEL } from '@sar/api-interface';
|
||||
import { useRepDashboard } from '../../lib/queries/dashboard';
|
||||
import { useCurrentUser } from '../../lib/queries/auth';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
@@ -47,8 +49,6 @@ ChartJS.register(
|
||||
ChartTooltip,
|
||||
Legend,
|
||||
);
|
||||
import { useRepDashboard } from '../../lib/queries/dashboard';
|
||||
import { useCurrentUser } from '../../lib/queries/auth';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
|
||||
@@ -94,7 +94,12 @@ export function ClientContacts({ idCliente, modalOpen = false, onModalClose }: P
|
||||
anotacoes: values.anotacoes || undefined,
|
||||
};
|
||||
|
||||
const result = await createMutation.mutateAsync(payload);
|
||||
let result: Awaited<ReturnType<typeof createMutation.mutateAsync>>;
|
||||
try {
|
||||
result = await createMutation.mutateAsync(payload);
|
||||
} catch {
|
||||
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||
}
|
||||
|
||||
if (!result.sincronizado && result.erroSync) {
|
||||
void message.warning(`Contato salvo, mas não sincronizou com ERP: ${result.erroSync}`);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { Alert, Button, Flex, Grid, Tooltip } from 'antd';
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { Alert, App, Button, Flex, Grid, Tooltip } from 'antd';
|
||||
import { PlusOutlined, WifiOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { Topbar } from './Topbar';
|
||||
@@ -7,6 +7,7 @@ import { Sidebar } from './Sidebar';
|
||||
import { BottomNav } from './BottomNav';
|
||||
import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus';
|
||||
import { useOfflineSync } from '../../lib/hooks/useOfflineSync';
|
||||
import { registerMessageApi } from '../../lib/feedback';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
@@ -18,10 +19,16 @@ export function AppShell({ children }: AppShellProps) {
|
||||
const navigate = useNavigate();
|
||||
const isOnline = useNetworkStatus();
|
||||
const screens = useBreakpoint();
|
||||
const { message } = App.useApp();
|
||||
// sidebar a partir de lg (992px) — abaixo disso usa bottom nav
|
||||
const isDesktop = !!screens.lg;
|
||||
useOfflineSync();
|
||||
|
||||
// Disponibiliza o message (com tema) para os caches do TanStack Query
|
||||
useEffect(() => {
|
||||
registerMessageApi(message);
|
||||
}, [message]);
|
||||
|
||||
return (
|
||||
<Flex vertical style={{ height: '100vh', overflow: 'hidden', background: 'var(--bg-body)' }}>
|
||||
{!isOnline && (
|
||||
|
||||
15
apps/web/src/lib/feedback.ts
Normal file
15
apps/web/src/lib/feedback.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { MessageInstance } from 'antd/es/message/interface';
|
||||
|
||||
// Instância do message obtida via App.useApp() (herda tema/contexto), registrada
|
||||
// pelo AppShell no mount. Fora do React (ex.: caches do TanStack Query) usamos
|
||||
// esta referência em vez do message estático do AntD.
|
||||
let messageApi: MessageInstance | null = null;
|
||||
|
||||
export function registerMessageApi(api: MessageInstance): void {
|
||||
messageApi = api;
|
||||
}
|
||||
|
||||
// key fixa colapsa erros repetidos (várias queries falhando juntas) num toast só.
|
||||
export function notifyError(content: string, key?: string): void {
|
||||
messageApi?.error({ content, key, duration: 5 });
|
||||
}
|
||||
@@ -19,9 +19,7 @@ export function useCreateOportunidade() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (dto: CreateOportunidadeDto): Promise<Oportunidade> =>
|
||||
apiFetch('/funil', { method: 'POST', body: JSON.stringify(dto) }).then((r) =>
|
||||
OportunidadeSchema.parse(r),
|
||||
),
|
||||
apiFetch('/funil', { method: 'POST', body: dto }).then((r) => OportunidadeSchema.parse(r)),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),
|
||||
});
|
||||
}
|
||||
@@ -30,7 +28,7 @@ export function useUpdateOportunidade() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, dto }: { id: number; dto: UpdateOportunidadeDto }): Promise<Oportunidade> =>
|
||||
apiFetch(`/funil/${id}`, { method: 'PATCH', body: JSON.stringify(dto) }).then((r) =>
|
||||
apiFetch(`/funil/${id}`, { method: 'PATCH', body: dto }).then((r) =>
|
||||
OportunidadeSchema.parse(r),
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),
|
||||
|
||||
@@ -67,7 +67,7 @@ export function useUpsertDesconto() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: UpsertDescontoBody) =>
|
||||
apiFetch('/politicas/descontos', { method: 'POST', body: JSON.stringify(body) }),
|
||||
apiFetch('/politicas/descontos', { method: 'POST', body }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['politicas', 'descontos'] }),
|
||||
});
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export function useCreatePromocao() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CreatePromocaoBody) =>
|
||||
apiFetch('/politicas/promocoes', { method: 'POST', body: JSON.stringify(body) }),
|
||||
apiFetch('/politicas/promocoes', { method: 'POST', body }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
|
||||
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
|
||||
@@ -88,7 +88,7 @@ export function useUpdatePromocao() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: number; body: UpdatePromocaoBody }) =>
|
||||
apiFetch(`/politicas/promocoes/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
apiFetch(`/politicas/promocoes/${id}`, { method: 'PATCH', body }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
|
||||
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
|
||||
|
||||
@@ -29,9 +29,7 @@ export function useCreateChamado() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (dto: CreateChamadoDto): Promise<Chamado> =>
|
||||
apiFetch('/chamados', { method: 'POST', body: JSON.stringify(dto) }).then((r) =>
|
||||
ChamadoSchema.parse(r),
|
||||
),
|
||||
apiFetch('/chamados', { method: 'POST', body: dto }).then((r) => ChamadoSchema.parse(r)),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['chamados'] }),
|
||||
});
|
||||
}
|
||||
@@ -40,8 +38,8 @@ export function useAddMensagemRep(id: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (dto: AddMensagemDto): Promise<Chamado> =>
|
||||
apiFetch(`/chamados/${id}/mensagens`, { method: 'POST', body: JSON.stringify(dto) }).then(
|
||||
(r) => ChamadoSchema.parse(r),
|
||||
apiFetch(`/chamados/${id}/mensagens`, { method: 'POST', body: dto }).then((r) =>
|
||||
ChamadoSchema.parse(r),
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['chamados', id] });
|
||||
@@ -79,8 +77,8 @@ export function useAddMensagemEmpresa(id: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (dto: AddMensagemDto): Promise<Chamado> =>
|
||||
apiFetch(`/ger/chamados/${id}/mensagens`, { method: 'POST', body: JSON.stringify(dto) }).then(
|
||||
(r) => ChamadoSchema.parse(r),
|
||||
apiFetch(`/ger/chamados/${id}/mensagens`, { method: 'POST', body: dto }).then((r) =>
|
||||
ChamadoSchema.parse(r),
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['ger', 'chamados', id] });
|
||||
@@ -93,8 +91,8 @@ export function useResolverChamado(id: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (dto: ResolverChamadoDto): Promise<Chamado> =>
|
||||
apiFetch(`/ger/chamados/${id}/resolver`, { method: 'PATCH', body: JSON.stringify(dto) }).then(
|
||||
(r) => ChamadoSchema.parse(r),
|
||||
apiFetch(`/ger/chamados/${id}/resolver`, { method: 'PATCH', body: dto }).then((r) =>
|
||||
ChamadoSchema.parse(r),
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['ger', 'chamados', id] });
|
||||
|
||||
@@ -1,11 +1,34 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query';
|
||||
import { ApiError } from './api-client';
|
||||
import { notifyError } from './feedback';
|
||||
|
||||
function describeError(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.problem.detail ?? error.problem.title ?? 'Erro no servidor';
|
||||
}
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return 'Erro inesperado';
|
||||
}
|
||||
|
||||
/**
|
||||
* QueryClient canônico do SAR.
|
||||
* Defaults conservadores: refetch on focus desabilitado (Visual DNA: "sereno"),
|
||||
* stale-while-revalidate para tempo real bater no Socket.IO, não em polling.
|
||||
* Erros nunca são silenciosos: falha de query/mutation sem tratamento local
|
||||
* vira toast — Sandra/Daniel jamais devem "achar que salvou".
|
||||
*/
|
||||
export const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => {
|
||||
notifyError(`Falha ao carregar dados: ${describeError(error)}`, 'query-error');
|
||||
},
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: (error, _variables, _context, mutation) => {
|
||||
if (mutation.options.onError) return; // a tela já dá feedback próprio
|
||||
notifyError(describeError(error), 'mutation-error');
|
||||
},
|
||||
}),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000, // 30s — Socket.IO atualiza antes na maioria dos casos
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Outlet,
|
||||
notFound,
|
||||
} from '@tanstack/react-router';
|
||||
import { Typography } from 'antd';
|
||||
import { Button, Result, Typography } from 'antd';
|
||||
import { AppShell } from '../components/layout/AppShell';
|
||||
import { RepPainel } from '../cockpits/rep/RepPainel';
|
||||
import { ClientsPage } from '../cockpits/rep/ClientsPage';
|
||||
@@ -47,6 +47,23 @@ function HomeRoute() {
|
||||
return <RepPainel />;
|
||||
}
|
||||
|
||||
// Error boundary por rota: uma exceção de render (ex.: ZodError de payload
|
||||
// inesperado) mostra esta tela em vez de derrubar o app inteiro em branco.
|
||||
function RouteErrorPage({ error }: { error: Error }) {
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title="Algo deu errado"
|
||||
subTitle={error.message || 'Erro inesperado ao exibir esta tela.'}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => window.location.reload()}>
|
||||
Recarregar
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NotFoundPage() {
|
||||
return (
|
||||
<div style={{ padding: 48, textAlign: 'center' }}>
|
||||
@@ -67,6 +84,7 @@ const rootRoute = createRootRoute({
|
||||
</AppShell>
|
||||
),
|
||||
notFoundComponent: NotFoundPage,
|
||||
errorComponent: RouteErrorPage,
|
||||
});
|
||||
|
||||
const indexRoute = createRoute({
|
||||
@@ -216,6 +234,7 @@ export const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: 'intent',
|
||||
defaultPreloadStaleTime: 0,
|
||||
defaultErrorComponent: RouteErrorPage,
|
||||
});
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
|
||||
Reference in New Issue
Block a user