feat(orders): fluxo de aprovação — approve/reject endpoints + UIs (C5)

PATCH /orders/:id/approve e /reject com alçada role-gated; OrderDetailPage
com modais de aprovação e recusa; ApprovalQueuePage para Sandra; badge de
pendências na Sidebar; DevLogin com 4 perfis (rep, supervisor, gerente).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 00:01:14 +00:00
parent 6769a0d82a
commit 356c8e3c2c
9 changed files with 731 additions and 33 deletions

View File

@@ -1,19 +1,36 @@
import { Body, Controller, Get, HttpCode, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
import {
Body,
Controller,
ForbiddenException,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { createZodDto } from 'nestjs-zod';
import {
ApproveOrderSchema,
CreateOrderSchema,
OrderListQuerySchema,
RejectOrderSchema,
type ApproveOrder,
type CreateOrder,
type OrderDetail,
type OrderListQuery,
type OrderListResponse,
type RejectOrder,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { OrdersService } from './orders.service';
class OrderListQueryDto extends createZodDto(OrderListQuerySchema) {}
class CreateOrderDto extends createZodDto(CreateOrderSchema) {}
class ApproveOrderDto extends createZodDto(ApproveOrderSchema) {}
class RejectOrderDto extends createZodDto(RejectOrderSchema) {}
@Controller({ path: 'orders' })
export class OrdersController {
@@ -35,6 +52,28 @@ export class OrdersController {
return this.orders.create(parsed, this.cls.get('userId') ?? '');
}
@Patch(':id/approve')
approve(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: ApproveOrderDto,
): Promise<OrderDetail> {
const role = this.cls.get('role') ?? 'rep';
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem aprovar pedidos');
const parsed = ApproveOrderSchema.parse(body) as ApproveOrder;
return this.orders.approve(id, this.cls.get('userId') ?? '', parsed);
}
@Patch(':id/reject')
reject(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: RejectOrderDto,
): Promise<OrderDetail> {
const role = this.cls.get('role') ?? 'rep';
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem recusar pedidos');
const parsed = RejectOrderSchema.parse(body) as RejectOrder;
return this.orders.reject(id, this.cls.get('userId') ?? '', parsed);
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<OrderDetail> {
return this.orders.findOne(id, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');

View File

@@ -1,12 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { OrderStatus, Prisma } from '@prisma/client';
import type {
ApproveOrder,
CreateOrder,
OrderDetail,
OrderListQuery,
OrderListResponse,
OrderSummary,
RejectOrder,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
@@ -264,6 +266,113 @@ export class OrdersService {
return this.mapDetail(order);
}
// Aprova pedido pending_approval. Supervisor pode ajustar discountPct global (FR-5.4).
async approve(id: string, userId: string, dto: ApproveOrder): Promise<OrderDetail> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const order = await prisma.order.findFirst({ where: { id, deletedAt: null } });
if (!order) throw new NotFoundException(`Pedido ${id} não encontrado`);
if (order.status !== OrderStatus.pending_approval)
throw new BadRequestException(
`Pedido não está aguardando aprovação (status: ${order.status})`,
);
const now = new Date();
const newDiscountPct = dto.discountPct ?? Number(order.discountPct);
const newTotal = Math.round(Number(order.subtotal) * (1 - newDiscountPct / 100) * 100) / 100;
await prisma.order.update({
where: { id },
data: {
status: OrderStatus.approved,
discountPct: newDiscountPct,
total: newTotal,
approvedById: userId,
approvedAt: now,
},
});
await prisma.orderStatusHistory.create({
data: {
orderId: id,
fromStatus: OrderStatus.pending_approval,
toStatus: OrderStatus.approved,
changedById: userId,
changedAt: now,
note: dto.note ?? null,
},
});
// Atualiza desnorm openOrdersCount
const openStatuses = [OrderStatus.budget, OrderStatus.pending_approval, OrderStatus.approved];
const openCount = await prisma.order.count({
where: { clientId: order.clientId, deletedAt: null, status: { in: openStatuses } },
});
await prisma.client.update({
where: { id: order.clientId },
data: { openOrdersCount: openCount },
});
const final = await prisma.order.findUniqueOrThrow({
where: { id },
include: {
client: { select: { name: true } },
items: true,
history: { orderBy: { changedAt: 'asc' } },
},
});
return this.mapDetail(final);
}
// Recusa pedido — retorna ao status budget com motivo no histórico (FR-5.4).
async reject(id: string, userId: string, dto: RejectOrder): Promise<OrderDetail> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const order = await prisma.order.findFirst({ where: { id, deletedAt: null } });
if (!order) throw new NotFoundException(`Pedido ${id} não encontrado`);
if (order.status !== OrderStatus.pending_approval)
throw new BadRequestException(
`Pedido não está aguardando aprovação (status: ${order.status})`,
);
const now = new Date();
await prisma.order.update({ where: { id }, data: { status: OrderStatus.budget } });
await prisma.orderStatusHistory.create({
data: {
orderId: id,
fromStatus: OrderStatus.pending_approval,
toStatus: OrderStatus.budget,
changedById: userId,
changedAt: now,
note: dto.reason,
},
});
// Atualiza desnorm openOrdersCount
const openStatuses = [OrderStatus.budget, OrderStatus.pending_approval, OrderStatus.approved];
const openCount = await prisma.order.count({
where: { clientId: order.clientId, deletedAt: null, status: { in: openStatuses } },
});
await prisma.client.update({
where: { id: order.clientId },
data: { openOrdersCount: openCount },
});
const final = await prisma.order.findUniqueOrThrow({
where: { id },
include: {
client: { select: { name: true } },
items: true,
history: { orderBy: { changedAt: 'asc' } },
},
});
return this.mapDetail(final);
}
private mapDetail(o: {
id: string;
number: string;

View File

@@ -313,23 +313,14 @@ export function NewOrderPage() {
discountPct: it.discountPct,
})),
};
const res = await apiFetch('/orders', {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as { detail?: string }).detail ?? `Erro ${res.status}`);
}
return res.json();
return apiFetch('/orders', { method: 'POST', body });
},
onSuccess: (order: { id: string }) => {
void qc.invalidateQueries({ queryKey: ['orders'] });
void qc.invalidateQueries({ queryKey: ['clients', clientId] });
void navigate({ to: '/pedidos/$id', params: { id: order.id } });
},
onError: (e: Error) => setError(e.message),
onError: (e: unknown) => setError(e instanceof Error ? e.message : 'Erro ao criar pedido'),
});
const addToCart = (p: ProductSummary) => {

View File

@@ -0,0 +1,413 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Alert,
Badge,
Button,
Descriptions,
Divider,
Form,
InputNumber,
Modal,
Space,
Spin,
Table,
Tag,
Timeline,
Typography,
Input,
} from 'antd';
import type { TableColumnsType } from 'antd';
import { Link, useParams } from '@tanstack/react-router';
import type { OrderItem, OrderStatus, OrderStatusHistory } from '@sar/api-interface';
import { useOrderDetail } from '../../lib/queries/orders';
import { useClientOrders } from '../../lib/queries/orders';
import { apiFetch } from '../../lib/api-client';
import { authStore } from '../../lib/auth-store';
const { Title, Text } = Typography;
const { TextArea } = Input;
// ─── Helpers ──────────────────────────────────────────────────────────────────
const STATUS_COLOR: Record<OrderStatus, string> = {
budget: 'default',
pending_approval: 'warning',
approved: 'processing',
invoiced: 'success',
cancelled: 'error',
};
const STATUS_LABEL: Record<OrderStatus, string> = {
budget: 'Orçamento',
pending_approval: 'Ag. Aprovação',
approved: 'Aprovado',
invoiced: 'Faturado',
cancelled: 'Cancelado',
};
function fmt(v: string | number): string {
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function getRoleFromToken(): string {
const token = authStore.get();
if (!token) return 'rep';
try {
const payload = JSON.parse(atob(token.split('.')[1] ?? ''));
return (payload.role as string) ?? 'rep';
} catch {
return 'rep';
}
}
// ─── Subcomponents ────────────────────────────────────────────────────────────
const itemColumns: TableColumnsType<OrderItem> = [
{ title: 'Código', dataIndex: 'productCode', width: 100 },
{ title: 'Produto', dataIndex: 'productName', ellipsis: true },
{ title: 'Qtd', dataIndex: 'quantity', width: 90, align: 'right' },
{
title: 'Preço Unit.',
dataIndex: 'unitPrice',
width: 120,
align: 'right',
render: (v: string) => fmt(v),
},
{
title: 'Desc %',
dataIndex: 'discountPct',
width: 80,
align: 'right',
render: (v: string) => `${v}%`,
},
{
title: 'Subtotal',
dataIndex: 'subtotal',
width: 130,
align: 'right',
render: (v: string) => fmt(v),
},
];
function HistoryTimeline({ history }: { history: OrderStatusHistory[] }) {
return (
<Timeline
items={history.map((h) => ({
color:
STATUS_COLOR[h.toStatus] === 'success'
? 'green'
: STATUS_COLOR[h.toStatus] === 'warning'
? 'orange'
: STATUS_COLOR[h.toStatus] === 'error'
? 'red'
: 'blue',
children: (
<div>
<Text strong>{STATUS_LABEL[h.toStatus]}</Text>
{h.fromStatus && <Text type="secondary"> (de {STATUS_LABEL[h.fromStatus]})</Text>}
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
{new Date(h.changedAt).toLocaleString('pt-BR')} {h.changedById}
</Text>
{h.note && (
<div style={{ marginTop: 4 }}>
<Text italic>"{h.note}"</Text>
</div>
)}
</div>
),
}))}
/>
);
}
// ─── Approve Modal ────────────────────────────────────────────────────────────
function ApproveModal({
open,
originalDiscount,
onConfirm,
onCancel,
loading,
}: {
open: boolean;
originalDiscount: string;
onConfirm: (discountPct?: number, note?: string) => void;
onCancel: () => void;
loading: boolean;
}) {
const [disc, setDisc] = useState<number | null>(null);
const [note, setNote] = useState('');
return (
<Modal
title="Aprovar Pedido"
open={open}
onOk={() => onConfirm(disc ?? undefined, note || undefined)}
onCancel={onCancel}
okText="Confirmar Aprovação"
cancelText="Voltar"
confirmLoading={loading}
>
<Form layout="vertical">
<Form.Item
label={`Desconto global (original: ${originalDiscount}%)`}
help="Deixe em branco para manter o desconto solicitado."
>
<InputNumber
min={0}
max={100}
step={0.5}
placeholder={originalDiscount}
value={disc}
onChange={(v) => setDisc(v)}
addonAfter="%"
style={{ width: 160 }}
/>
</Form.Item>
<Form.Item label="Observação (opcional)">
<TextArea
rows={2}
value={note}
onChange={(e) => setNote(e.target.value)}
maxLength={300}
/>
</Form.Item>
</Form>
</Modal>
);
}
// ─── Reject Modal ─────────────────────────────────────────────────────────────
function RejectModal({
open,
onConfirm,
onCancel,
loading,
}: {
open: boolean;
onConfirm: (reason: string) => void;
onCancel: () => void;
loading: boolean;
}) {
const [reason, setReason] = useState('');
return (
<Modal
title="Recusar Pedido"
open={open}
onOk={() => reason.trim() && onConfirm(reason.trim())}
onCancel={onCancel}
okText="Confirmar Recusa"
okButtonProps={{ danger: true, disabled: !reason.trim() }}
cancelText="Voltar"
confirmLoading={loading}
>
<Form layout="vertical">
<Form.Item label="Motivo da recusa" required>
<TextArea
rows={3}
value={reason}
onChange={(e) => setReason(e.target.value)}
maxLength={500}
showCount
placeholder="Informe o motivo para o representante..."
/>
</Form.Item>
</Form>
</Modal>
);
}
// ─── OrderDetailPage ──────────────────────────────────────────────────────────
export function OrderDetailPage() {
const { id } = useParams({ from: '/pedidos/$id' });
const qc = useQueryClient();
const { data: order, isLoading, error } = useOrderDetail(id);
const { data: clientOrders } = useClientOrders(order?.clientId);
const role = getRoleFromToken();
const canAct = role !== 'rep' && order?.status === 'pending_approval';
const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const approveMutation = useMutation({
mutationFn: ({ discountPct, note }: { discountPct?: number; note?: string }) =>
apiFetch(`/orders/${id}/approve`, { method: 'PATCH', body: { discountPct, note } }),
onSuccess: () => {
setApproveOpen(false);
void qc.invalidateQueries({ queryKey: ['orders', id] });
void qc.invalidateQueries({ queryKey: ['orders'] });
},
onError: (e: unknown) => {
setApproveOpen(false);
setActionError(e instanceof Error ? e.message : 'Erro ao aprovar');
},
});
const rejectMutation = useMutation({
mutationFn: (reason: string) =>
apiFetch(`/orders/${id}/reject`, { method: 'PATCH', body: { reason } }),
onSuccess: () => {
setRejectOpen(false);
void qc.invalidateQueries({ queryKey: ['orders', id] });
void qc.invalidateQueries({ queryKey: ['orders'] });
},
onError: (e: unknown) => {
setRejectOpen(false);
setActionError(e instanceof Error ? e.message : 'Erro ao recusar');
},
});
if (isLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
if (error || !order)
return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />;
const timeWaiting =
order.status === 'pending_approval'
? Math.floor((Date.now() - new Date(order.issuedAt).getTime()) / 3_600_000)
: null;
return (
<div style={{ padding: 24, maxWidth: 960 }}>
<Space align="center" style={{ marginBottom: 16 }} wrap>
<Link to="/pedidos"> Pedidos</Link>
<Title level={3} style={{ margin: 0 }}>
{order.number}
</Title>
<Badge
status={
STATUS_COLOR[order.status] as 'default' | 'warning' | 'processing' | 'success' | 'error'
}
text={<Tag color={STATUS_COLOR[order.status]}>{STATUS_LABEL[order.status]}</Tag>}
/>
{timeWaiting !== null && timeWaiting > 2 && (
<Tag color="red"> Urgente {timeWaiting}h aguardando</Tag>
)}
{canAct && (
<Space>
<Button type="primary" onClick={() => setApproveOpen(true)}>
Aprovar
</Button>
<Button danger onClick={() => setRejectOpen(true)}>
Recusar
</Button>
</Space>
)}
</Space>
{actionError && (
<Alert
type="error"
message={actionError}
showIcon
closable
onClose={() => setActionError(null)}
style={{ marginBottom: 16 }}
/>
)}
<Descriptions bordered size="small" column={2} style={{ marginBottom: 24 }}>
<Descriptions.Item label="Cliente">
<Link to="/clientes/$id" params={{ id: order.clientId }}>
{order.clientName}
</Link>
</Descriptions.Item>
<Descriptions.Item label="Rep">{order.repId}</Descriptions.Item>
<Descriptions.Item label="Emitido em">
{new Date(order.issuedAt).toLocaleString('pt-BR')}
</Descriptions.Item>
{order.approvedAt && (
<Descriptions.Item label="Aprovado em">
{new Date(order.approvedAt).toLocaleString('pt-BR')} {order.approvedById}
</Descriptions.Item>
)}
<Descriptions.Item label="Subtotal">{fmt(order.subtotal)}</Descriptions.Item>
<Descriptions.Item label="Desc. Global">{order.discountPct}%</Descriptions.Item>
<Descriptions.Item label="Total">
<Text strong style={{ fontSize: 16 }}>
{fmt(order.total)}
</Text>
</Descriptions.Item>
{order.notes && (
<Descriptions.Item label="Observações" span={2}>
{order.notes}
</Descriptions.Item>
)}
</Descriptions>
<Divider orientation="left">Itens ({order.items.length})</Divider>
<Table<OrderItem>
rowKey="id"
columns={itemColumns}
dataSource={order.items}
pagination={false}
size="small"
style={{ marginBottom: 24 }}
/>
{clientOrders && clientOrders.length > 0 && (
<>
<Divider orientation="left">Histórico do Cliente</Divider>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={clientOrders.filter((o) => o.id !== id).slice(0, 5)}
columns={[
{
title: 'Nº',
dataIndex: 'number',
width: 110,
render: (n: string, r: { id: string }) => (
<Link to="/pedidos/$id" params={{ id: r.id }}>
{n}
</Link>
),
},
{
title: 'Status',
dataIndex: 'status',
width: 130,
render: (s: OrderStatus) => <Tag color={STATUS_COLOR[s]}>{STATUS_LABEL[s]}</Tag>,
},
{
title: 'Total',
dataIndex: 'total',
align: 'right' as const,
render: (v: string) => fmt(v),
},
{
title: 'Emitido em',
dataIndex: 'issuedAt',
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
},
]}
style={{ marginBottom: 24 }}
/>
</>
)}
<Divider orientation="left">Histórico do Pedido</Divider>
<HistoryTimeline history={order.history} />
<ApproveModal
open={approveOpen}
originalDiscount={order.discountPct}
onConfirm={(discountPct, note) => approveMutation.mutate({ discountPct, note })}
onCancel={() => setApproveOpen(false)}
loading={approveMutation.isPending}
/>
<RejectModal
open={rejectOpen}
onConfirm={(reason) => rejectMutation.mutate(reason)}
onCancel={() => setRejectOpen(false)}
loading={rejectMutation.isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { Table, Tag, Typography, Badge, Space } from 'antd';
import type { TableColumnsType } from 'antd';
import { Link } from '@tanstack/react-router';
import type { OrderSummary } from '@sar/api-interface';
import { useOrderList } from '../../lib/queries/orders';
const { Title } = Typography;
function hoursWaiting(issuedAt: string): number {
return Math.floor((Date.now() - new Date(issuedAt).getTime()) / 3_600_000);
}
const columns: TableColumnsType<OrderSummary> = [
{
title: 'Nº',
dataIndex: 'number',
width: 120,
render: (num: string, row: OrderSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
{num}
</Link>
),
},
{ title: 'Rep', dataIndex: 'repId', width: 130, ellipsis: true },
{ title: 'Cliente', dataIndex: 'clientName', ellipsis: true },
{
title: 'Total',
dataIndex: 'total',
width: 130,
align: 'right',
render: (v: string) =>
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
},
{
title: 'Desc. Global',
dataIndex: 'discountPct',
width: 110,
align: 'right',
render: (v: string) => `${v}%`,
},
{
title: 'Aguardando',
dataIndex: 'issuedAt',
width: 130,
render: (v: string) => {
const h = hoursWaiting(v);
return <Tag color={h > 2 ? 'red' : 'orange'}>{h}h</Tag>;
},
},
{
title: '',
width: 100,
render: (_: unknown, row: OrderSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
<Tag color="blue" style={{ cursor: 'pointer' }}>
Analisar
</Tag>
</Link>
),
},
];
export function ApprovalQueuePage() {
const { data, isLoading } = useOrderList({ status: 'pending_approval', limit: 200 });
const urgentCount = data?.data.filter((o) => hoursWaiting(o.issuedAt) > 2).length ?? 0;
return (
<div style={{ padding: 24 }}>
<Space align="center" style={{ marginBottom: 16 }}>
<Title level={3} style={{ margin: 0 }}>
Fila de Aprovações
</Title>
{urgentCount > 0 && (
<Badge
count={urgentCount}
style={{ backgroundColor: '#cf1322' }}
title={`${urgentCount} urgente(s) — mais de 2h aguardando`}
/>
)}
</Space>
<Table<OrderSummary>
rowKey="id"
columns={columns}
dataSource={data?.data ?? []}
loading={isLoading}
rowClassName={(row) => (hoursWaiting(row.issuedAt) > 2 ? 'row-urgent' : '')}
pagination={false}
locale={{ emptyText: 'Nenhum pedido aguardando aprovação.' }}
/>
<style>{`.row-urgent td { background: #fff1f0 !important; }`}</style>
</div>
);
}

View File

@@ -2,22 +2,31 @@
// Em produção o token vem do master-login real (fora do escopo do MVP).
import { useState } from 'react';
import { Alert, Button, Card, Flex, Space, Typography } from 'antd';
import { Alert, Button, Card, Divider, Flex, Space, Typography } from 'antd';
import { apiFetch } from '../../lib/api-client';
import { authStore } from '../../lib/auth-store';
import { AuthTokenResponseSchema } from '@sar/api-interface';
type DevUser = { userId: string; role: string; label: string };
const DEV_USERS: DevUser[] = [
{ userId: 'user-001', role: 'rep', label: 'Rafael — Rep (user-001)' },
{ userId: 'user-002', role: 'rep', label: 'Rep 2 (user-002)' },
{ userId: 'user-sandra-01', role: 'supervisor', label: 'Sandra — Supervisora' },
{ userId: 'user-manager-01', role: 'manager', label: 'Gerente (user-manager-01)' },
];
export function DevLogin({ onLogin }: { onLogin: () => void }) {
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleLogin() {
setLoading(true);
async function handleLogin(user: DevUser) {
setLoading(user.userId);
setError(null);
try {
const raw = await apiFetch('/api/v1/auth/dev/token', {
method: 'POST',
body: { userId: 'user-001', workspaceId: 'dev-workspace', role: 'rep' },
body: { userId: user.userId, workspaceId: 'dev-workspace', role: user.role },
});
const { accessToken } = AuthTokenResponseSchema.parse(raw);
authStore.set(accessToken);
@@ -25,14 +34,14 @@ export function DevLogin({ onLogin }: { onLogin: () => void }) {
} catch (e) {
setError(e instanceof Error ? e.message : 'Erro ao obter token');
} finally {
setLoading(false);
setLoading(null);
}
}
return (
<Flex justify="center" align="center" style={{ minHeight: '100vh' }}>
<Card style={{ width: 360 }}>
<Space direction="vertical" size={20} style={{ width: '100%' }}>
<Card style={{ width: 380 }}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Typography.Title level={3} style={{ margin: 0 }}>
SAR · Login Dev
</Typography.Title>
@@ -43,9 +52,18 @@ export function DevLogin({ onLogin }: { onLogin: () => void }) {
showIcon
/>
{error && <Alert type="error" message={error} showIcon />}
<Button type="primary" block loading={loading} onClick={() => void handleLogin()}>
Entrar como Rafael (rep · user-001)
</Button>
<Divider style={{ margin: '4px 0' }}>Entrar como</Divider>
{DEV_USERS.map((u) => (
<Button
key={u.userId}
block
type={u.role === 'rep' ? 'primary' : 'default'}
loading={loading === u.userId}
onClick={() => void handleLogin(u)}
>
{u.label}
</Button>
))}
</Space>
</Card>
</Flex>

View File

@@ -1,4 +1,4 @@
import { Menu } from 'antd';
import { Badge, Menu } from 'antd';
import { useLocation, useNavigate } from '@tanstack/react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
@@ -11,8 +11,10 @@ import {
faGear,
faPercent,
faFileInvoiceDollar,
faCheckCircle,
} from '@fortawesome/free-solid-svg-icons';
import type { ItemType } from 'antd/es/menu/interface';
import { useOrderList } from '../../lib/queries/orders';
/**
* Sidebar canônica do SAR (260px fixa — brand.md).
@@ -21,6 +23,8 @@ import type { ItemType } from 'antd/es/menu/interface';
export function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const { data: pendingOrders } = useOrderList({ status: 'pending_approval', limit: 1 });
const pendingCount = pendingOrders?.total ?? 0;
const items: ItemType[] = [
{
@@ -53,6 +57,18 @@ export function Sidebar() {
icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />,
label: 'Pedidos',
},
{
key: '/aprovacoes',
icon: <FontAwesomeIcon icon={faCheckCircle} fixedWidth />,
label: (
<span>
Aprovações{' '}
{pendingCount > 0 && (
<Badge count={pendingCount} size="small" style={{ marginLeft: 4 }} />
)}
</span>
),
},
{
key: '/comissao',
icon: <FontAwesomeIcon icon={faPercent} fixedWidth />,

View File

@@ -4,7 +4,9 @@ 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 { ApprovalQueuePage } from '../cockpits/sandra/ApprovalQueuePage';
const rootRoute = createRootRoute({
component: () => (
@@ -53,14 +55,13 @@ const novoOrderRoute = createRoute({
const pedidoDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/pedidos/$id',
component: () => {
const { id } = pedidoDetailRoute.useParams();
return (
<div style={{ padding: 24 }}>
<p>Detalhe do pedido {id} em construção</p>
</div>
);
},
component: OrderDetailPage,
});
const aprovacoes = createRoute({
getParentRoute: () => rootRoute,
path: '/aprovacoes',
component: ApprovalQueuePage,
});
const routeTree = rootRoute.addChildren([
@@ -71,6 +72,7 @@ const routeTree = rootRoute.addChildren([
pedidosRoute,
novoOrderRoute,
pedidoDetailRoute,
aprovacoes,
]);
export const router = createRouter({