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:
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
413
apps/web/src/cockpits/rafael/OrderDetailPage.tsx
Normal file
413
apps/web/src/cockpits/rafael/OrderDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
apps/web/src/cockpits/sandra/ApprovalQueuePage.tsx
Normal file
96
apps/web/src/cockpits/sandra/ApprovalQueuePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -112,3 +112,17 @@ export const CreateOrderSchema = z.object({
|
||||
items: z.array(CreateOrderItemSchema).min(1),
|
||||
});
|
||||
export type CreateOrder = z.infer<typeof CreateOrderSchema>;
|
||||
|
||||
// ─── Approve / Reject (PATCH /orders/:id/approve|reject) ─────────────────────
|
||||
|
||||
export const ApproveOrderSchema = z.object({
|
||||
// Opcional — supervisor pode ajustar o desconto global. Se omitido, mantém o original.
|
||||
discountPct: z.number().min(0).max(100).optional(),
|
||||
note: z.string().optional(),
|
||||
});
|
||||
export type ApproveOrder = z.infer<typeof ApproveOrderSchema>;
|
||||
|
||||
export const RejectOrderSchema = z.object({
|
||||
reason: z.string().min(1, 'Motivo é obrigatório'), // FR-5.4
|
||||
});
|
||||
export type RejectOrder = z.infer<typeof RejectOrderSchema>;
|
||||
|
||||
Reference in New Issue
Block a user