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 { ClsService } from 'nestjs-cls';
|
||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import {
|
import {
|
||||||
|
ApproveOrderSchema,
|
||||||
CreateOrderSchema,
|
CreateOrderSchema,
|
||||||
OrderListQuerySchema,
|
OrderListQuerySchema,
|
||||||
|
RejectOrderSchema,
|
||||||
|
type ApproveOrder,
|
||||||
type CreateOrder,
|
type CreateOrder,
|
||||||
type OrderDetail,
|
type OrderDetail,
|
||||||
type OrderListQuery,
|
type OrderListQuery,
|
||||||
type OrderListResponse,
|
type OrderListResponse,
|
||||||
|
type RejectOrder,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
import { OrdersService } from './orders.service';
|
import { OrdersService } from './orders.service';
|
||||||
|
|
||||||
class OrderListQueryDto extends createZodDto(OrderListQuerySchema) {}
|
class OrderListQueryDto extends createZodDto(OrderListQuerySchema) {}
|
||||||
class CreateOrderDto extends createZodDto(CreateOrderSchema) {}
|
class CreateOrderDto extends createZodDto(CreateOrderSchema) {}
|
||||||
|
class ApproveOrderDto extends createZodDto(ApproveOrderSchema) {}
|
||||||
|
class RejectOrderDto extends createZodDto(RejectOrderSchema) {}
|
||||||
|
|
||||||
@Controller({ path: 'orders' })
|
@Controller({ path: 'orders' })
|
||||||
export class OrdersController {
|
export class OrdersController {
|
||||||
@@ -35,6 +52,28 @@ export class OrdersController {
|
|||||||
return this.orders.create(parsed, this.cls.get('userId') ?? '');
|
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')
|
@Get(':id')
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<OrderDetail> {
|
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<OrderDetail> {
|
||||||
return this.orders.findOne(id, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
|
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 { ClsService } from 'nestjs-cls';
|
||||||
import { OrderStatus, Prisma } from '@prisma/client';
|
import { OrderStatus, Prisma } from '@prisma/client';
|
||||||
import type {
|
import type {
|
||||||
|
ApproveOrder,
|
||||||
CreateOrder,
|
CreateOrder,
|
||||||
OrderDetail,
|
OrderDetail,
|
||||||
OrderListQuery,
|
OrderListQuery,
|
||||||
OrderListResponse,
|
OrderListResponse,
|
||||||
OrderSummary,
|
OrderSummary,
|
||||||
|
RejectOrder,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
|
|
||||||
@@ -264,6 +266,113 @@ export class OrdersService {
|
|||||||
return this.mapDetail(order);
|
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: {
|
private mapDetail(o: {
|
||||||
id: string;
|
id: string;
|
||||||
number: string;
|
number: string;
|
||||||
|
|||||||
@@ -313,23 +313,14 @@ export function NewOrderPage() {
|
|||||||
discountPct: it.discountPct,
|
discountPct: it.discountPct,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
const res = await apiFetch('/orders', {
|
return apiFetch('/orders', { method: 'POST', body });
|
||||||
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();
|
|
||||||
},
|
},
|
||||||
onSuccess: (order: { id: string }) => {
|
onSuccess: (order: { id: string }) => {
|
||||||
void qc.invalidateQueries({ queryKey: ['orders'] });
|
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||||||
void qc.invalidateQueries({ queryKey: ['clients', clientId] });
|
void qc.invalidateQueries({ queryKey: ['clients', clientId] });
|
||||||
void navigate({ to: '/pedidos/$id', params: { id: order.id } });
|
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) => {
|
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).
|
// Em produção o token vem do master-login real (fora do escopo do MVP).
|
||||||
|
|
||||||
import { useState } from 'react';
|
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 { apiFetch } from '../../lib/api-client';
|
||||||
import { authStore } from '../../lib/auth-store';
|
import { authStore } from '../../lib/auth-store';
|
||||||
import { AuthTokenResponseSchema } from '@sar/api-interface';
|
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 }) {
|
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);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin(user: DevUser) {
|
||||||
setLoading(true);
|
setLoading(user.userId);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const raw = await apiFetch('/api/v1/auth/dev/token', {
|
const raw = await apiFetch('/api/v1/auth/dev/token', {
|
||||||
method: 'POST',
|
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);
|
const { accessToken } = AuthTokenResponseSchema.parse(raw);
|
||||||
authStore.set(accessToken);
|
authStore.set(accessToken);
|
||||||
@@ -25,14 +34,14 @@ export function DevLogin({ onLogin }: { onLogin: () => void }) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Erro ao obter token');
|
setError(e instanceof Error ? e.message : 'Erro ao obter token');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex justify="center" align="center" style={{ minHeight: '100vh' }}>
|
<Flex justify="center" align="center" style={{ minHeight: '100vh' }}>
|
||||||
<Card style={{ width: 360 }}>
|
<Card style={{ width: 380 }}>
|
||||||
<Space direction="vertical" size={20} style={{ width: '100%' }}>
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
<Typography.Title level={3} style={{ margin: 0 }}>
|
<Typography.Title level={3} style={{ margin: 0 }}>
|
||||||
SAR · Login Dev
|
SAR · Login Dev
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
@@ -43,9 +52,18 @@ export function DevLogin({ onLogin }: { onLogin: () => void }) {
|
|||||||
showIcon
|
showIcon
|
||||||
/>
|
/>
|
||||||
{error && <Alert type="error" message={error} showIcon />}
|
{error && <Alert type="error" message={error} showIcon />}
|
||||||
<Button type="primary" block loading={loading} onClick={() => void handleLogin()}>
|
<Divider style={{ margin: '4px 0' }}>Entrar como</Divider>
|
||||||
Entrar como Rafael (rep · user-001)
|
{DEV_USERS.map((u) => (
|
||||||
</Button>
|
<Button
|
||||||
|
key={u.userId}
|
||||||
|
block
|
||||||
|
type={u.role === 'rep' ? 'primary' : 'default'}
|
||||||
|
loading={loading === u.userId}
|
||||||
|
onClick={() => void handleLogin(u)}
|
||||||
|
>
|
||||||
|
{u.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Menu } from 'antd';
|
import { Badge, Menu } from 'antd';
|
||||||
import { useLocation, useNavigate } from '@tanstack/react-router';
|
import { useLocation, useNavigate } from '@tanstack/react-router';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import {
|
import {
|
||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
faGear,
|
faGear,
|
||||||
faPercent,
|
faPercent,
|
||||||
faFileInvoiceDollar,
|
faFileInvoiceDollar,
|
||||||
|
faCheckCircle,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import type { ItemType } from 'antd/es/menu/interface';
|
import type { ItemType } from 'antd/es/menu/interface';
|
||||||
|
import { useOrderList } from '../../lib/queries/orders';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sidebar canônica do SAR (260px fixa — brand.md).
|
* Sidebar canônica do SAR (260px fixa — brand.md).
|
||||||
@@ -21,6 +23,8 @@ import type { ItemType } from 'antd/es/menu/interface';
|
|||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { data: pendingOrders } = useOrderList({ status: 'pending_approval', limit: 1 });
|
||||||
|
const pendingCount = pendingOrders?.total ?? 0;
|
||||||
|
|
||||||
const items: ItemType[] = [
|
const items: ItemType[] = [
|
||||||
{
|
{
|
||||||
@@ -53,6 +57,18 @@ export function Sidebar() {
|
|||||||
icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />,
|
icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />,
|
||||||
label: 'Pedidos',
|
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',
|
key: '/comissao',
|
||||||
icon: <FontAwesomeIcon icon={faPercent} fixedWidth />,
|
icon: <FontAwesomeIcon icon={faPercent} fixedWidth />,
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { RafaelPainel } from '../cockpits/rafael/RafaelPainel';
|
|||||||
import { ClientsPage } from '../cockpits/rafael/ClientsPage';
|
import { ClientsPage } from '../cockpits/rafael/ClientsPage';
|
||||||
import { ClientDetailPage } from '../cockpits/rafael/ClientDetailPage';
|
import { ClientDetailPage } from '../cockpits/rafael/ClientDetailPage';
|
||||||
import { OrdersPage } from '../cockpits/rafael/OrdersPage';
|
import { OrdersPage } from '../cockpits/rafael/OrdersPage';
|
||||||
|
import { OrderDetailPage } from '../cockpits/rafael/OrderDetailPage';
|
||||||
import { NewOrderPage } from '../cockpits/rafael/NewOrderPage';
|
import { NewOrderPage } from '../cockpits/rafael/NewOrderPage';
|
||||||
|
import { ApprovalQueuePage } from '../cockpits/sandra/ApprovalQueuePage';
|
||||||
|
|
||||||
const rootRoute = createRootRoute({
|
const rootRoute = createRootRoute({
|
||||||
component: () => (
|
component: () => (
|
||||||
@@ -53,14 +55,13 @@ const novoOrderRoute = createRoute({
|
|||||||
const pedidoDetailRoute = createRoute({
|
const pedidoDetailRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/pedidos/$id',
|
path: '/pedidos/$id',
|
||||||
component: () => {
|
component: OrderDetailPage,
|
||||||
const { id } = pedidoDetailRoute.useParams();
|
});
|
||||||
return (
|
|
||||||
<div style={{ padding: 24 }}>
|
const aprovacoes = createRoute({
|
||||||
<p>Detalhe do pedido {id} — em construção</p>
|
getParentRoute: () => rootRoute,
|
||||||
</div>
|
path: '/aprovacoes',
|
||||||
);
|
component: ApprovalQueuePage,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
@@ -71,6 +72,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
pedidosRoute,
|
pedidosRoute,
|
||||||
novoOrderRoute,
|
novoOrderRoute,
|
||||||
pedidoDetailRoute,
|
pedidoDetailRoute,
|
||||||
|
aprovacoes,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
|
|||||||
@@ -112,3 +112,17 @@ export const CreateOrderSchema = z.object({
|
|||||||
items: z.array(CreateOrderItemSchema).min(1),
|
items: z.array(CreateOrderItemSchema).min(1),
|
||||||
});
|
});
|
||||||
export type CreateOrder = z.infer<typeof CreateOrderSchema>;
|
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