feat(auth): endpoint /auth/me, cockpits renomeados e menu de logout

- GET /api/v1/auth/me retorna perfil real do ERP (vw_representantes)
- Contrato UserProfile adicionado ao shared api-interface
- Hook useCurrentUser() no frontend consome o endpoint
- Cockpit rafael → rep, sandra → supervisor (pastas e componentes)
- Topbar exibe iniciais do usuário e dropdown com nome, role e "Sair"
- Logout limpa token e recarrega para voltar ao DevLogin

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 17:48:24 +00:00
parent 20b0793227
commit a00a5c6a53
16 changed files with 156 additions and 33 deletions

View File

@@ -0,0 +1,34 @@
import { Controller, Get } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type { UserProfile } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import type { PrismaClient } from '@prisma/client';
@Controller({ path: 'auth' })
export class AuthController {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
@Get('me')
async me(): Promise<UserProfile> {
const prisma = this.cls.get('prisma') as PrismaClient;
const userId = this.cls.get('userId') ?? '';
const role = this.cls.get('role') ?? 'rep';
const idEmpresa = this.cls.get('idEmpresa');
const rows = await prisma.$queryRaw<{ codigo: number; nome: string }[]>`
SELECT codigo, nome
FROM sar.vw_representantes
WHERE codigo = ${parseInt(userId, 10)}
AND id_empresa = ${idEmpresa}
LIMIT 1
`;
const row = rows[0];
return {
codVendedor: row?.codigo ?? parseInt(userId, 10),
nome: row?.nome ?? userId,
role,
idEmpresa,
};
}
}

View File

@@ -2,10 +2,11 @@ import { Module } from '@nestjs/common';
import { WorkspaceModule } from '../workspace/workspace.module';
import { JwtAuthGuard } from './jwt-auth.guard';
import { DevAuthController } from './dev-auth.controller';
import { AuthController } from './auth.controller';
@Module({
imports: [WorkspaceModule],
controllers: [DevAuthController],
controllers: [DevAuthController, AuthController],
providers: [JwtAuthGuard],
exports: [JwtAuthGuard],
})

View File

@@ -9,6 +9,7 @@ import { Link } from '@tanstack/react-router';
import type { PedidoSummary } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
import { useRepDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth';
const { Title, Text } = Typography;
@@ -37,8 +38,9 @@ function today(): string {
});
}
export function RafaelPainel() {
export function RepPainel() {
const { data, isLoading } = useRepDashboard();
const { data: user } = useCurrentUser();
if (isLoading || !data) {
return (
@@ -66,7 +68,7 @@ export function RafaelPainel() {
{/* Saudação */}
<Flex vertical gap={4}>
<Title level={2} style={{ margin: 0 }}>
{greeting()}, Rafael
{greeting()}, {user?.nome?.split(' ')[0] ?? '...'}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
{today()}

View File

@@ -9,6 +9,7 @@ import {
import { Link } from '@tanstack/react-router';
import type { PedidoSummary } from '@sar/api-interface';
import { useSupervisorDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth';
const { Title, Text } = Typography;
@@ -26,6 +27,13 @@ function delta(current: number, previous: number): { label: string; positive: bo
return { label: `${pct >= 0 ? '+' : ''}${pct}% vs semana passada`, positive: pct >= 0 };
}
function greeting(): string {
const h = new Date().getHours();
if (h < 12) return 'Bom dia';
if (h < 18) return 'Boa tarde';
return 'Boa noite';
}
function today(): string {
return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
}
@@ -72,8 +80,9 @@ const queueColumns: TableColumnsType<PedidoSummary> = [
},
];
export function SandraPainel() {
export function SupervisorPainel() {
const { data, isLoading } = useSupervisorDashboard();
const { data: user } = useCurrentUser();
if (isLoading || !data) {
return (
@@ -100,7 +109,7 @@ export function SandraPainel() {
{/* Saudação */}
<Flex vertical gap={4}>
<Title level={2} style={{ margin: 0 }}>
Bom dia, Sandra
{greeting()}, {user?.nome?.split(' ')[0] ?? '...'}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
{today()}

View File

@@ -12,9 +12,9 @@ type DevUser = { key: string; userId: string; role: string; label: string };
// userId = cod_vendedor como string; idEmpresa = empresa no ERP (dev default = 1)
// Em dev, o backend força DEV_REP_CODE=29 independente do userId enviado.
const DEV_USERS: DevUser[] = [
{ key: 'rep-29', userId: '29', role: 'rep', label: 'PAVEI COMERCIO (cod 29)' },
{ key: 'sup-29', userId: '29', role: 'supervisor', label: 'PAVEI — Supervisor (cod 29)' },
{ key: 'mgr-29', userId: '29', role: 'manager', label: 'PAVEI — Gerente (cod 29)' },
{ key: 'rep-29', userId: '29', role: 'rep', label: 'Representante (cód. 29)' },
{ key: 'sup-29', userId: '29', role: 'supervisor', label: 'Supervisor (cód. 29)' },
{ key: 'mgr-29', userId: '29', role: 'manager', label: 'Gerente (cód. 29)' },
];
export function DevLogin({ onLogin }: { onLogin: () => void }) {

View File

@@ -1,9 +1,16 @@
import { Avatar, Badge, Button, Flex, Input } from 'antd';
import { Avatar, Badge, Button, Dropdown, Flex, Input, Typography } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faBell, faMagnifyingGlass, faBars } from '@fortawesome/free-solid-svg-icons';
import {
faBell,
faMagnifyingGlass,
faBars,
faRightFromBracket,
} from '@fortawesome/free-solid-svg-icons';
import { brandTokens } from '../../lib/theme';
import { FoundationStatus } from './FoundationStatus';
import { usePendingCount } from '../../lib/queries/notifications';
import { useCurrentUser } from '../../lib/queries/auth';
import { authStore } from '../../lib/auth-store';
interface TopbarProps {
onToggleSidebar?: () => void;
@@ -14,9 +21,51 @@ interface TopbarProps {
* Apple-inspired clean: logo à esquerda, search central, notif + perfil à direita.
* Variantes por cockpit: search desabilitada para Rafael mobile (vai pro bottom nav).
*/
function logout() {
authStore.clear();
window.location.reload();
}
export function Topbar({ onToggleSidebar }: TopbarProps) {
const { data: pendingData } = usePendingCount();
const pendingCount = pendingData?.count ?? 0;
const { data: user } = useCurrentUser();
const initials = user?.nome
? user.nome
.split(' ')
.slice(0, 2)
.map((w) => w[0])
.join('')
.toUpperCase()
: '?';
const userMenuItems = [
{
key: 'profile',
label: (
<Flex vertical gap={2} style={{ padding: '4px 0', minWidth: 180 }}>
<Typography.Text strong style={{ fontSize: 'var(--text-sm)' }}>
{user?.nome?.trim() ?? '—'}
</Typography.Text>
<Typography.Text
type="secondary"
style={{ fontSize: 'var(--text-xs)', textTransform: 'capitalize' }}
>
{user?.role ?? ''}
</Typography.Text>
</Flex>
),
disabled: true,
},
{ type: 'divider' as const },
{
key: 'logout',
danger: true,
icon: <FontAwesomeIcon icon={faRightFromBracket} />,
label: 'Sair',
onClick: logout,
},
];
return (
<Flex
@@ -69,7 +118,7 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
</Flex>
</Flex>
{/* Centro: search (Sandra/Daniel/Alice) */}
{/* Centro: search (Supervisor/Admin) */}
<Flex flex={1} justify="center" style={{ maxWidth: 480, margin: '0 var(--space-2xl)' }}>
<Input
size="large"
@@ -93,6 +142,7 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
aria-label="Notificações"
/>
</Badge>
<Dropdown menu={{ items: userMenuItems }} trigger={['click']} placement="bottomRight">
<Avatar
size={40}
style={{
@@ -102,8 +152,9 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
cursor: 'pointer',
}}
>
R
{initials}
</Avatar>
</Dropdown>
</Flex>
</Flex>
);

View File

@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import { UserProfileSchema, type UserProfile } from '@sar/api-interface';
import { apiFetch } from '../api-client';
export const AUTH_KEYS = {
me: ['auth', 'me'] as const,
};
export function useCurrentUser() {
return useQuery<UserProfile, Error>({
queryKey: AUTH_KEYS.me,
queryFn: async () => {
const res = await apiFetch('/auth/me');
return UserProfileSchema.parse(res);
},
staleTime: 5 * 60 * 1000,
});
}

View File

@@ -7,15 +7,15 @@ import {
} from '@tanstack/react-router';
import { Typography } from 'antd';
import { AppShell } from '../components/layout/AppShell';
import { RafaelPainel } from '../cockpits/rafael/RafaelPainel';
import { ClientsPage } from '../cockpits/rafael/ClientsPage';
import { ClientDetailPage } from '../cockpits/rafael/ClientDetailPage';
import { OrdersPage } from '../cockpits/rafael/OrdersPage';
import { OrderDetailPage } from '../cockpits/rafael/OrderDetailPage';
import { NewOrderPage } from '../cockpits/rafael/NewOrderPage';
import { CatalogPage } from '../cockpits/rafael/CatalogPage';
import { ApprovalQueuePage } from '../cockpits/sandra/ApprovalQueuePage';
import { SandraPainel } from '../cockpits/sandra/SandraPainel';
import { RepPainel } from '../cockpits/rep/RepPainel';
import { ClientsPage } from '../cockpits/rep/ClientsPage';
import { ClientDetailPage } from '../cockpits/rep/ClientDetailPage';
import { OrdersPage } from '../cockpits/rep/OrdersPage';
import { OrderDetailPage } from '../cockpits/rep/OrderDetailPage';
import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
import { CatalogPage } from '../cockpits/rep/CatalogPage';
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
import { authStore } from './auth-store';
function getRoleFromToken(): string {
@@ -31,7 +31,7 @@ function getRoleFromToken(): string {
function HomeRoute() {
const role = getRoleFromToken();
return role === 'supervisor' || role === 'manager' ? <SandraPainel /> : <RafaelPainel />;
return role === 'supervisor' || role === 'manager' ? <SupervisorPainel /> : <RepPainel />;
}
function NotFoundPage() {
@@ -65,7 +65,7 @@ const indexRoute = createRoute({
const rafaelRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/rep',
component: RafaelPainel,
component: RepPainel,
});
const clientesRoute = createRoute({

View File

@@ -19,6 +19,14 @@ export const AuthTokenResponseSchema = z.object({
expiresIn: z.number().int().positive(),
});
export const UserProfileSchema = z.object({
codVendedor: z.number().int(),
nome: z.string(),
role: JwtRoleSchema,
idEmpresa: z.number().int(),
});
export type DevTokenRequest = z.infer<typeof DevTokenRequestSchema>;
export type AuthTokenResponse = z.infer<typeof AuthTokenResponseSchema>;
export type JwtRole = z.infer<typeof JwtRoleSchema>;
export type UserProfile = z.infer<typeof UserProfileSchema>;