diff --git a/apps/api/src/app/orders/orders.controller.ts b/apps/api/src/app/orders/orders.controller.ts index 6000991..dd7926c 100644 --- a/apps/api/src/app/orders/orders.controller.ts +++ b/apps/api/src/app/orders/orders.controller.ts @@ -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 { + 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 { + 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 { return this.orders.findOne(id, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep'); diff --git a/apps/api/src/app/orders/orders.service.ts b/apps/api/src/app/orders/orders.service.ts index c269c81..56d398a 100644 --- a/apps/api/src/app/orders/orders.service.ts +++ b/apps/api/src/app/orders/orders.service.ts @@ -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 { + 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 { + 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; diff --git a/apps/web/src/cockpits/rafael/NewOrderPage.tsx b/apps/web/src/cockpits/rafael/NewOrderPage.tsx index 2a84a1b..84784bb 100644 --- a/apps/web/src/cockpits/rafael/NewOrderPage.tsx +++ b/apps/web/src/cockpits/rafael/NewOrderPage.tsx @@ -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) => { diff --git a/apps/web/src/cockpits/rafael/OrderDetailPage.tsx b/apps/web/src/cockpits/rafael/OrderDetailPage.tsx new file mode 100644 index 0000000..e24634e --- /dev/null +++ b/apps/web/src/cockpits/rafael/OrderDetailPage.tsx @@ -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 = { + budget: 'default', + pending_approval: 'warning', + approved: 'processing', + invoiced: 'success', + cancelled: 'error', +}; +const STATUS_LABEL: Record = { + 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 = [ + { 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 ( + ({ + color: + STATUS_COLOR[h.toStatus] === 'success' + ? 'green' + : STATUS_COLOR[h.toStatus] === 'warning' + ? 'orange' + : STATUS_COLOR[h.toStatus] === 'error' + ? 'red' + : 'blue', + children: ( +
+ {STATUS_LABEL[h.toStatus]} + {h.fromStatus && (de {STATUS_LABEL[h.fromStatus]})} +
+ + {new Date(h.changedAt).toLocaleString('pt-BR')} — {h.changedById} + + {h.note && ( +
+ "{h.note}" +
+ )} +
+ ), + }))} + /> + ); +} + +// ─── 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(null); + const [note, setNote] = useState(''); + + return ( + onConfirm(disc ?? undefined, note || undefined)} + onCancel={onCancel} + okText="Confirmar Aprovação" + cancelText="Voltar" + confirmLoading={loading} + > +
+ + setDisc(v)} + addonAfter="%" + style={{ width: 160 }} + /> + + +