Add supply purchase order workflow
This commit is contained in:
@@ -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(`
|
||||
ALTER TABLE production_orders
|
||||
ADD COLUMN IF NOT EXISTS notes TEXT,
|
||||
|
||||
@@ -5,13 +5,16 @@ const {
|
||||
approveReceipt,
|
||||
consumeLotForProduction,
|
||||
createFabricPlan,
|
||||
createPurchaseOrder,
|
||||
createReceipt,
|
||||
deleteFabricPlan,
|
||||
deleteReceipt,
|
||||
getSupplySummary,
|
||||
listFabricPlans,
|
||||
listKnownSuppliers,
|
||||
listPurchaseOrders,
|
||||
saveMaterialDetails,
|
||||
updatePurchaseOrderStatus,
|
||||
listLots,
|
||||
listMovements,
|
||||
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) => {
|
||||
try {
|
||||
res.json(await listReceipts());
|
||||
|
||||
@@ -101,6 +101,27 @@ const mapMaterialDetails = (row) => ({
|
||||
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 error = new Error(message);
|
||||
error.statusCode = 400;
|
||||
@@ -189,6 +210,63 @@ const listKnownSuppliers = async () => {
|
||||
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 normalizedKey = normalizeKey(materialKey);
|
||||
if (!normalizedKey) throw createValidationError('Material inválido para edição.');
|
||||
@@ -1161,6 +1239,7 @@ module.exports = {
|
||||
approveReceipt,
|
||||
consumeLotForProduction,
|
||||
createFabricPlan,
|
||||
createPurchaseOrder,
|
||||
createReceipt,
|
||||
deleteFabricPlan,
|
||||
deleteReceipt,
|
||||
@@ -1170,9 +1249,11 @@ module.exports = {
|
||||
listFabricPlans,
|
||||
listMaterialDetails,
|
||||
listKnownSuppliers,
|
||||
listPurchaseOrders,
|
||||
listLots,
|
||||
listMovements,
|
||||
listPurchaseNeeds,
|
||||
listReceipts,
|
||||
saveMaterialDetails
|
||||
saveMaterialDetails,
|
||||
updatePurchaseOrderStatus
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
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[]>;
|
||||
};
|
||||
|
||||
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 (
|
||||
materialKey: string,
|
||||
payload: SupplyMaterialDetailsPayload
|
||||
|
||||
@@ -31,11 +31,11 @@ import PaginationControls from '../components/PaginationControls';
|
||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||
import StockGraphsBalanceModal from '../components/StockGraphsBalanceModal';
|
||||
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 { resolveProductType, type ProductTypeKey } from '../productClassification';
|
||||
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 ReceiptView = 'new' | 'pending' | 'history';
|
||||
@@ -411,6 +411,7 @@ const SuppliesHub = () => {
|
||||
</div>
|
||||
<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="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="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" />
|
||||
@@ -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 { section } = useParams<{ section?: string }>();
|
||||
|
||||
@@ -2664,6 +2689,7 @@ const Supplies = () => {
|
||||
if (section === 'inventory') return <InventoryScreen />;
|
||||
if (section === 'fabric-planning') return <FabricPlanningScreen />;
|
||||
if (section === 'purchase-needs') return <PurchaseNeedsScreen />;
|
||||
if (section === 'purchase-orders') return <PurchaseOrdersScreen />;
|
||||
|
||||
return <Navigate to="/supplies" replace />;
|
||||
};
|
||||
|
||||
30
src/types.ts
30
src/types.ts
@@ -454,6 +454,36 @@ export interface SupplyFabricPlan {
|
||||
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 {
|
||||
material: string;
|
||||
materialKey: string;
|
||||
|
||||
Reference in New Issue
Block a user