Add supply purchase order workflow

This commit is contained in:
Cauê Faleiros
2026-08-25 11:10:36 -03:00
parent 91f3e5e00c
commit 21d9960145
6 changed files with 223 additions and 4 deletions

View File

@@ -490,6 +490,33 @@ const initDB = async () => {
); );
`); `);
await pool.query(`
CREATE TABLE IF NOT EXISTS supply_purchase_orders (
id SERIAL PRIMARY KEY,
number VARCHAR(40) UNIQUE,
supplier TEXT NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'draft',
expected_date DATE,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS supply_purchase_order_items (
id SERIAL PRIMARY KEY,
purchase_order_id INTEGER NOT NULL REFERENCES supply_purchase_orders(id) ON DELETE CASCADE,
material TEXT NOT NULL,
quantity NUMERIC(14, 4) NOT NULL CHECK (quantity > 0),
unit VARCHAR(30) NOT NULL DEFAULT 'kg',
unit_cost NUMERIC(14, 4),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_purchase_orders_status ON supply_purchase_orders (status, created_at DESC);`);
await pool.query(` await pool.query(`
ALTER TABLE production_orders ALTER TABLE production_orders
ADD COLUMN IF NOT EXISTS notes TEXT, ADD COLUMN IF NOT EXISTS notes TEXT,

View File

@@ -5,13 +5,16 @@ const {
approveReceipt, approveReceipt,
consumeLotForProduction, consumeLotForProduction,
createFabricPlan, createFabricPlan,
createPurchaseOrder,
createReceipt, createReceipt,
deleteFabricPlan, deleteFabricPlan,
deleteReceipt, deleteReceipt,
getSupplySummary, getSupplySummary,
listFabricPlans, listFabricPlans,
listKnownSuppliers, listKnownSuppliers,
listPurchaseOrders,
saveMaterialDetails, saveMaterialDetails,
updatePurchaseOrderStatus,
listLots, listLots,
listMovements, listMovements,
listPurchaseNeeds, listPurchaseNeeds,
@@ -36,6 +39,30 @@ router.get('/supply/suppliers', verifyToken, async (req, res, next) => {
} }
}); });
router.get('/supply/purchase-orders', verifyToken, async (req, res, next) => {
try {
res.json(await listPurchaseOrders());
} catch (error) {
next(error);
}
});
router.post('/supply/purchase-orders', verifyToken, async (req, res, next) => {
try {
res.status(201).json(await createPurchaseOrder(req.body || {}));
} catch (error) {
next(error);
}
});
router.patch('/supply/purchase-orders/:id/status', verifyToken, async (req, res, next) => {
try {
res.json(await updatePurchaseOrderStatus(req.params.id, req.body?.status));
} catch (error) {
next(error);
}
});
router.get('/supply/receipts', verifyToken, async (req, res, next) => { router.get('/supply/receipts', verifyToken, async (req, res, next) => {
try { try {
res.json(await listReceipts()); res.json(await listReceipts());

View File

@@ -101,6 +101,27 @@ const mapMaterialDetails = (row) => ({
updatedAt: row.updated_at updatedAt: row.updated_at
}); });
const PURCHASE_ORDER_STATUSES = new Set(['draft', 'sent', 'approved', 'received', 'canceled']);
const mapPurchaseOrder = (row) => ({
id: row.id,
number: row.number || '',
supplier: row.supplier || '',
status: row.status || 'draft',
expectedDate: row.expected_date ? String(row.expected_date).slice(0, 10) : null,
notes: row.notes || '',
total: Number(row.total || 0),
items: Array.isArray(row.items) ? row.items.map(item => ({
id: item.id,
material: item.material || '',
quantity: Number(item.quantity || 0),
unit: item.unit || 'kg',
unitCost: item.unit_cost === null || item.unit_cost === undefined ? null : Number(item.unit_cost)
})) : [],
createdAt: row.created_at,
updatedAt: row.updated_at
});
const createValidationError = (message) => { const createValidationError = (message) => {
const error = new Error(message); const error = new Error(message);
error.statusCode = 400; error.statusCode = 400;
@@ -189,6 +210,63 @@ const listKnownSuppliers = async () => {
return result.rows.map(row => row.supplier); return result.rows.map(row => row.supplier);
}; };
const listPurchaseOrders = async () => {
const result = await pool.query(`
SELECT
purchase_order.id, purchase_order.number, purchase_order.supplier, purchase_order.status,
purchase_order.expected_date, purchase_order.notes, purchase_order.created_at, purchase_order.updated_at,
COALESCE((SELECT SUM(item.quantity * COALESCE(item.unit_cost, 0)) FROM supply_purchase_order_items item WHERE item.purchase_order_id = purchase_order.id), 0) AS total,
COALESCE((SELECT JSON_AGG(JSON_BUILD_OBJECT('id', item.id, 'material', item.material, 'quantity', item.quantity, 'unit', item.unit, 'unit_cost', item.unit_cost) ORDER BY item.id) FROM supply_purchase_order_items item WHERE item.purchase_order_id = purchase_order.id), '[]'::json) AS items
FROM supply_purchase_orders purchase_order
ORDER BY purchase_order.created_at DESC, purchase_order.id DESC;
`);
return result.rows.map(mapPurchaseOrder);
};
const createPurchaseOrder = async (payload = {}) => {
const supplier = normalizeText(payload.supplier);
const expectedDate = /^\d{4}-\d{2}-\d{2}$/.test(normalizeText(payload.expectedDate)) ? normalizeText(payload.expectedDate) : null;
const notes = normalizeText(payload.notes);
const items = (Array.isArray(payload.items) ? payload.items : []).map(item => ({
material: normalizeText(item?.material),
quantity: normalizeNumber(item?.quantity),
unit: normalizeText(item?.unit) || 'kg',
unitCost: normalizeNonNegativeNumber(item?.unitCost)
})).filter(item => item.material && item.quantity);
if (!supplier) throw createValidationError('Fornecedor é obrigatório para a ordem de compra.');
if (!items.length) throw createValidationError('Inclua pelo menos um material na ordem de compra.');
const client = await pool.connect();
try {
await client.query('BEGIN');
const orderResult = await client.query(`INSERT INTO supply_purchase_orders (supplier, status, expected_date, notes, updated_at) VALUES ($1, 'draft', $2, NULLIF($3, ''), CURRENT_TIMESTAMP) RETURNING id;`, [supplier, expectedDate, notes]);
const id = orderResult.rows[0].id;
await client.query(`UPDATE supply_purchase_orders SET number = $1 WHERE id = $2;`, [`OC-${String(id).padStart(5, '0')}`, id]);
for (const item of items) {
await client.query(`INSERT INTO supply_purchase_order_items (purchase_order_id, material, quantity, unit, unit_cost) VALUES ($1, $2, $3, $4, $5);`, [id, item.material, item.quantity, item.unit, item.unitCost]);
}
await client.query('COMMIT');
return (await listPurchaseOrders()).find(order => order.id === id);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
const updatePurchaseOrderStatus = async (id, status) => {
const normalizedStatus = normalizeText(status).toLowerCase();
if (!PURCHASE_ORDER_STATUSES.has(normalizedStatus)) throw createValidationError('Status de ordem de compra inválido.');
const result = await pool.query(`UPDATE supply_purchase_orders SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING id;`, [normalizedStatus, id]);
if (!result.rowCount) {
const error = new Error('Ordem de compra não encontrada.');
error.statusCode = 404;
throw error;
}
return (await listPurchaseOrders()).find(order => order.id === Number(id));
};
const saveMaterialDetails = async (materialKey, payload) => { const saveMaterialDetails = async (materialKey, payload) => {
const normalizedKey = normalizeKey(materialKey); const normalizedKey = normalizeKey(materialKey);
if (!normalizedKey) throw createValidationError('Material inválido para edição.'); if (!normalizedKey) throw createValidationError('Material inválido para edição.');
@@ -1161,6 +1239,7 @@ module.exports = {
approveReceipt, approveReceipt,
consumeLotForProduction, consumeLotForProduction,
createFabricPlan, createFabricPlan,
createPurchaseOrder,
createReceipt, createReceipt,
deleteFabricPlan, deleteFabricPlan,
deleteReceipt, deleteReceipt,
@@ -1170,9 +1249,11 @@ module.exports = {
listFabricPlans, listFabricPlans,
listMaterialDetails, listMaterialDetails,
listKnownSuppliers, listKnownSuppliers,
listPurchaseOrders,
listLots, listLots,
listMovements, listMovements,
listPurchaseNeeds, listPurchaseNeeds,
listReceipts, listReceipts,
saveMaterialDetails saveMaterialDetails,
updatePurchaseOrderStatus
}; };

View File

@@ -1,4 +1,4 @@
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CutPlanLotReservationPayload, CuttingSettings, DashboardAnalytics, DateRange, GraphsStockBalanceHistoryItem, GraphsStockBalancePayload, ManagedUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderExecutionPayload, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CutPlanLotReservationPayload, CuttingSettings, DashboardAnalytics, DateRange, GraphsStockBalanceHistoryItem, GraphsStockBalancePayload, ManagedUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderExecutionPayload, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyPurchaseOrder, SupplyPurchaseOrderPayload, SupplyPurchaseOrderStatus, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
import { formatDateParam } from './dateRanges'; import { formatDateParam } from './dateRanges';
const API_URL = import.meta.env.VITE_API_URL || '/api'; const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -544,6 +544,34 @@ export const fetchSupplySuppliers = async (): Promise<string[]> => {
return response.json() as Promise<string[]>; return response.json() as Promise<string[]>;
}; };
export const fetchSupplyPurchaseOrders = async (): Promise<SupplyPurchaseOrder[]> => {
const response = await authFetch('/supply/purchase-orders');
if (!response.ok) throw new Error('Não foi possível carregar as ordens de compra.');
return response.json() as Promise<SupplyPurchaseOrder[]>;
};
export const createSupplyPurchaseOrder = async (payload: SupplyPurchaseOrderPayload): Promise<SupplyPurchaseOrder> => {
const response = await authFetch('/supply/purchase-orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível criar a ordem de compra.');
return data as SupplyPurchaseOrder;
};
export const updateSupplyPurchaseOrderStatus = async (id: number, status: SupplyPurchaseOrderStatus): Promise<SupplyPurchaseOrder> => {
const response = await authFetch(`/supply/purchase-orders/${id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível atualizar a ordem de compra.');
return data as SupplyPurchaseOrder;
};
export const saveSupplyMaterialDetails = async ( export const saveSupplyMaterialDetails = async (
materialKey: string, materialKey: string,
payload: SupplyMaterialDetailsPayload payload: SupplyMaterialDetailsPayload

View File

@@ -31,11 +31,11 @@ import PaginationControls from '../components/PaginationControls';
import ProductTypeBadge from '../components/ProductTypeBadge'; import ProductTypeBadge from '../components/ProductTypeBadge';
import StockGraphsBalanceModal from '../components/StockGraphsBalanceModal'; import StockGraphsBalanceModal from '../components/StockGraphsBalanceModal';
import { classifyCutFamily } from '../analytics/cutting'; import { classifyCutFamily } from '../analytics/cutting';
import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, downloadDatabaseDiagnostic, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, fetchStock, fetchSupplySummary, fetchSupplySuppliers, isSuperAdmin, saveSupplyMaterialDetails } from '../dataService'; import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyPurchaseOrder, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, downloadDatabaseDiagnostic, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, fetchStock, fetchSupplyPurchaseOrders, fetchSupplySummary, fetchSupplySuppliers, isSuperAdmin, saveSupplyMaterialDetails, updateSupplyPurchaseOrderStatus } from '../dataService';
import { parseProductName } from '../productParsing'; import { parseProductName } from '../productParsing';
import { resolveProductType, type ProductTypeKey } from '../productClassification'; import { resolveProductType, type ProductTypeKey } from '../productClassification';
import { getPlanningStock } from '../planningStock'; import { getPlanningStock } from '../planningStock';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, StockData, SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyReceipt, SupplySummary } from '../types'; import type { CuttingSettings, DateRange, ProductAnalyticsItem, StockData, SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyPurchaseOrder, SupplyPurchaseOrderStatus, SupplyReceipt, SupplySummary } from '../types';
type InventoryTab = 'dashboard' | 'stock' | 'balance' | 'receipts' | 'inventory' | 'movements'; type InventoryTab = 'dashboard' | 'stock' | 'balance' | 'receipts' | 'inventory' | 'movements';
type ReceiptView = 'new' | 'pending' | 'history'; type ReceiptView = 'new' | 'pending' | 'history';
@@ -411,6 +411,7 @@ const SuppliesHub = () => {
</div> </div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-4">
<ModuleCard title="Estoque e recebimentos" description="Saldo, lotes, recebimentos, inventário e movimentações de materiais." icon={Package} to="/supplies/inventory" meta={`${formatNumber(summary.stats.pendingReceipts, 0)} recebimentos pendentes · ${formatNumber(summary.stats.activeLots, 0)} lotes ativos`} /> <ModuleCard title="Estoque e recebimentos" description="Saldo, lotes, recebimentos, inventário e movimentações de materiais." icon={Package} to="/supplies/inventory" meta={`${formatNumber(summary.stats.pendingReceipts, 0)} recebimentos pendentes · ${formatNumber(summary.stats.activeLots, 0)} lotes ativos`} />
<ModuleCard title="Ordens de compra" description="Formalize pedidos de materiais, fornecedor, prazo e valor antes do recebimento." icon={ClipboardList} to="/supplies/purchase-orders" meta="Criar e acompanhar OCs" />
<ModuleCard title="Planejamento de malha" description="Organize a fila de matéria-prima para abastecer compras e corte." icon={Ruler} to="/supplies/fabric-planning" /> <ModuleCard title="Planejamento de malha" description="Organize a fila de matéria-prima para abastecer compras e corte." icon={Ruler} to="/supplies/fabric-planning" />
<ModuleCard title="Prioridades de estoque" description="Analise reposição, cobertura e itens que precisam de revisão de cadastro." icon={PackageSearch} to="/supplies/current-planning" /> <ModuleCard title="Prioridades de estoque" description="Analise reposição, cobertura e itens que precisam de revisão de cadastro." icon={PackageSearch} to="/supplies/current-planning" />
<ModuleCard title="Dados para revisar" description="Consulte exceções de estoque, composição e cadastro fora do cálculo atual." icon={ClipboardList} to="/planning-issues" meta="Abrir pendências" /> <ModuleCard title="Dados para revisar" description="Consulte exceções de estoque, composição e cadastro fora do cálculo atual." icon={ClipboardList} to="/planning-issues" meta="Abrir pendências" />
@@ -2656,6 +2657,30 @@ const PurchaseNeedsScreen = () => {
); );
}; };
const PurchaseOrdersScreen = () => {
const [orders, setOrders] = useState<SupplyPurchaseOrder[]>([]);
const [suppliers, setSuppliers] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const [form, setForm] = useState({ supplier: '', material: '', quantity: '', unit: 'kg', unitCost: '', expectedDate: '', notes: '' });
const load = async () => {
const [nextOrders, nextSuppliers] = await Promise.all([fetchSupplyPurchaseOrders(), fetchSupplySuppliers()]);
setOrders(nextOrders); setSuppliers(nextSuppliers);
};
useEffect(() => { void load().catch(error => setMessage(error instanceof Error ? error.message : 'Não foi possível carregar as OCs.')); }, []);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); setBusy(true); setMessage('');
try {
await createSupplyPurchaseOrder({ supplier: form.supplier, expectedDate: form.expectedDate || null, notes: form.notes, items: [{ material: form.material, quantity: parseDecimal(form.quantity), unit: form.unit, unitCost: form.unitCost ? parseDecimal(form.unitCost) : null }] });
setForm({ supplier: '', material: '', quantity: '', unit: 'kg', unitCost: '', expectedDate: '', notes: '' }); await load(); setMessage('Ordem de compra criada. Envie ao fornecedor e atualize o status conforme o andamento.');
} catch (error) { setMessage(error instanceof Error ? error.message : 'Não foi possível criar a OC.'); } finally { setBusy(false); }
};
const changeStatus = async (id: number, status: SupplyPurchaseOrderStatus) => { setBusy(true); try { await updateSupplyPurchaseOrderStatus(id, status); await load(); } catch (error) { setMessage(error instanceof Error ? error.message : 'Não foi possível atualizar a OC.'); } finally { setBusy(false); } };
return <div className={pageClassName}><Header title="Ordens de Compra" subtitle="Formalize compras a partir das necessidades de material e acompanhe o compromisso com o fornecedor." backTo="/supplies" />
{message && <div className="rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-bold text-dark-muted">{message}</div>}
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[390px_minmax(0,1fr)]"><form onSubmit={submit} className={`${panelClassName} p-5`}><h2 className="text-base font-bold text-dark-text">Nova ordem de compra</h2><p className="mt-1 text-sm font-semibold text-dark-muted">Uma OC começa com um material. Use uma OC por fornecedor para manter o recebimento rastreável.</p><div className="mt-5 space-y-3"><label className="block text-xs font-bold text-dark-muted">Fornecedor<input required list="purchase-order-suppliers" value={form.supplier} onChange={event => setForm(current => ({ ...current, supplier: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="Selecione ou informe" /></label><datalist id="purchase-order-suppliers">{suppliers.map(supplier => <option key={supplier} value={supplier} />)}</datalist><label className="block text-xs font-bold text-dark-muted">Material<input required value={form.material} onChange={event => setForm(current => ({ ...current, material: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="Ex.: Malha 30.1 preta" /></label><div className="grid grid-cols-2 gap-3"><label className="text-xs font-bold text-dark-muted">Quantidade<input required inputMode="decimal" value={form.quantity} onChange={event => setForm(current => ({ ...current, quantity: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="0" /></label><label className="text-xs font-bold text-dark-muted">Unidade<select value={form.unit} onChange={event => setForm(current => ({ ...current, unit: event.target.value }))} className={`${inputClassName} mt-1`}><option value="kg">kg</option><option value="un.">un.</option><option value="rolos">rolos</option></select></label></div><div className="grid grid-cols-2 gap-3"><label className="text-xs font-bold text-dark-muted">Custo unitário<input inputMode="decimal" value={form.unitCost} onChange={event => setForm(current => ({ ...current, unitCost: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="Opcional" /></label><label className="text-xs font-bold text-dark-muted">Previsão<input type="date" value={form.expectedDate} onChange={event => setForm(current => ({ ...current, expectedDate: event.target.value }))} className={`${inputClassName} mt-1`} /></label></div><label className="block text-xs font-bold text-dark-muted">Observações<textarea value={form.notes} onChange={event => setForm(current => ({ ...current, notes: event.target.value }))} className={`${inputClassName} mt-1 min-h-20`} placeholder="Condição, cor, lote ou referência do fornecedor" /></label><button disabled={busy} className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast disabled:opacity-60 cursor-pointer"><ClipboardList className="h-4 w-4" />Criar OC</button></div></form><div className={panelClassName}><div className="border-b border-dark-border p-5"><h2 className="text-base font-bold text-dark-text">OCs em andamento</h2><p className="mt-1 text-sm font-semibold text-dark-muted">A aprovação financeira e o recebimento físico continuam sendo controlados em Estoque e recebimentos.</p></div>{orders.length ? <div className="divide-y divide-dark-border">{orders.map(order => <div key={order.id} className="p-5"><div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"><div><p className="font-mono text-xs font-bold text-brand-primary">{order.number}</p><h3 className="mt-1 text-sm font-bold text-dark-text">{order.supplier}</h3><p className="mt-1 text-xs font-semibold text-dark-muted">{order.items.map(item => `${item.material} · ${formatNumber(item.quantity)} ${item.unit}`).join(' | ')}</p></div><select value={order.status} disabled={busy} onChange={event => void changeStatus(order.id, event.target.value as SupplyPurchaseOrderStatus)} className={`${inputClassName} h-9 w-auto text-xs`}><option value="draft">Rascunho</option><option value="sent">Enviada</option><option value="approved">Aprovada</option><option value="received">Recebida</option><option value="canceled">Cancelada</option></select></div><div className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-xs font-semibold text-dark-muted"><span>Previsão: {order.expectedDate || '-'}</span><span>Total estimado: R$ {formatNumber(order.total, 2)}</span>{order.notes && <span>{order.notes}</span>}</div></div>)}</div> : <div className={emptyStateClassName}><ClipboardList className="h-8 w-8 text-brand-primary" /><h3 className="text-base font-bold text-dark-text">Nenhuma OC criada</h3><p className="text-sm font-semibold text-dark-muted">Use a necessidade de compra para decidir o que pedir e registre a OC aqui.</p></div>}</div></div></div>;
};
const Supplies = () => { const Supplies = () => {
const { section } = useParams<{ section?: string }>(); const { section } = useParams<{ section?: string }>();
@@ -2664,6 +2689,7 @@ const Supplies = () => {
if (section === 'inventory') return <InventoryScreen />; if (section === 'inventory') return <InventoryScreen />;
if (section === 'fabric-planning') return <FabricPlanningScreen />; if (section === 'fabric-planning') return <FabricPlanningScreen />;
if (section === 'purchase-needs') return <PurchaseNeedsScreen />; if (section === 'purchase-needs') return <PurchaseNeedsScreen />;
if (section === 'purchase-orders') return <PurchaseOrdersScreen />;
return <Navigate to="/supplies" replace />; return <Navigate to="/supplies" replace />;
}; };

View File

@@ -454,6 +454,36 @@ export interface SupplyFabricPlan {
updatedAt: string; updatedAt: string;
} }
export type SupplyPurchaseOrderStatus = 'draft' | 'sent' | 'approved' | 'received' | 'canceled';
export interface SupplyPurchaseOrderItem {
id: number;
material: string;
quantity: number;
unit: string;
unitCost: number | null;
}
export interface SupplyPurchaseOrder {
id: number;
number: string;
supplier: string;
status: SupplyPurchaseOrderStatus;
expectedDate: string | null;
notes: string;
total: number;
items: SupplyPurchaseOrderItem[];
createdAt: string;
updatedAt: string;
}
export type SupplyPurchaseOrderPayload = {
supplier: string;
expectedDate?: string | null;
notes?: string;
items: Array<{ material: string; quantity: number; unit?: string; unitCost?: number | null }>;
};
export interface SupplyPurchaseNeed { export interface SupplyPurchaseNeed {
material: string; material: string;
materialKey: string; materialKey: string;