Connect supply planning to purchase needs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
This commit is contained in:
@@ -236,6 +236,20 @@ const initDB = async () => {
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS supply_fabric_plans (
|
||||
id SERIAL PRIMARY KEY,
|
||||
material TEXT NOT NULL,
|
||||
color VARCHAR(120),
|
||||
quantity_kg NUMERIC(14, 4) NOT NULL,
|
||||
supplier TEXT,
|
||||
priority VARCHAR(40) NOT NULL DEFAULT 'Normal',
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE production_orders
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
@@ -303,6 +317,14 @@ const initDB = async () => {
|
||||
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||
`).catch(() => {});
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE supply_fabric_plans
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||
`).catch(() => {});
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -379,6 +401,7 @@ const initDB = async () => {
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_receipts_created_at ON supply_receipts (created_at DESC);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_stock_lots_status ON supply_stock_lots (status);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_movements_created_at ON supply_movements (created_at DESC);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_fabric_plans_status ON supply_fabric_plans (status);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_cliente_fone ON orders (cliente_fone);`);
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_normalized_cliente_nome
|
||||
|
||||
@@ -2,11 +2,15 @@ const express = require('express');
|
||||
const { verifyToken } = require('../auth');
|
||||
const {
|
||||
approveReceipt,
|
||||
createFabricPlan,
|
||||
createReceipt,
|
||||
deleteFabricPlan,
|
||||
deleteReceipt,
|
||||
getSupplySummary,
|
||||
listFabricPlans,
|
||||
listLots,
|
||||
listMovements,
|
||||
listPurchaseNeeds,
|
||||
listReceipts
|
||||
} = require('../services/supplyService');
|
||||
|
||||
@@ -69,4 +73,37 @@ router.get('/supply/movements', verifyToken, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/supply/fabric-plans', verifyToken, async (req, res, next) => {
|
||||
try {
|
||||
res.json(await listFabricPlans());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/supply/fabric-plans', verifyToken, async (req, res, next) => {
|
||||
try {
|
||||
res.status(201).json(await createFabricPlan(req.body || {}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/supply/fabric-plans/:id', verifyToken, async (req, res, next) => {
|
||||
try {
|
||||
await deleteFabricPlan(req.params.id);
|
||||
res.status(204).end();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/supply/purchase-needs', verifyToken, async (req, res, next) => {
|
||||
try {
|
||||
res.json(await listPurchaseNeeds());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -8,6 +8,11 @@ const normalizeNumber = (value) => {
|
||||
return Number.isFinite(number) && number > 0 ? number : null;
|
||||
};
|
||||
|
||||
const normalizeKey = (value) => normalizeText(value)
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase();
|
||||
|
||||
const mapReceipt = (row) => ({
|
||||
id: row.id,
|
||||
category: row.category,
|
||||
@@ -50,6 +55,18 @@ const mapMovement = (row) => ({
|
||||
createdAt: row.created_at
|
||||
});
|
||||
|
||||
const mapFabricPlan = (row) => ({
|
||||
id: row.id,
|
||||
material: row.material,
|
||||
color: row.color || '',
|
||||
quantityKg: Number(row.quantity_kg),
|
||||
supplier: row.supplier || '',
|
||||
priority: row.priority,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
});
|
||||
|
||||
const createValidationError = (message) => {
|
||||
const error = new Error(message);
|
||||
error.statusCode = 400;
|
||||
@@ -84,7 +101,87 @@ const listMovements = async () => {
|
||||
return result.rows.map(mapMovement);
|
||||
};
|
||||
|
||||
const buildStats = (receipts, lots) => {
|
||||
const listFabricPlans = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT id, material, color, quantity_kg, supplier, priority, status, created_at, updated_at
|
||||
FROM supply_fabric_plans
|
||||
WHERE status = 'active'
|
||||
ORDER BY
|
||||
CASE priority
|
||||
WHEN 'Crítico' THEN 1
|
||||
WHEN 'Atenção' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
created_at DESC,
|
||||
id DESC
|
||||
`);
|
||||
return result.rows.map(mapFabricPlan);
|
||||
};
|
||||
|
||||
const buildPurchaseNeeds = (plans, lots, receipts) => {
|
||||
const needsByMaterial = new Map();
|
||||
|
||||
plans.forEach(plan => {
|
||||
const key = normalizeKey(plan.material);
|
||||
if (!key) return;
|
||||
|
||||
const current = needsByMaterial.get(key) || {
|
||||
material: plan.material,
|
||||
plannedKg: 0,
|
||||
stockKg: 0,
|
||||
pendingKg: 0,
|
||||
purchaseKg: 0,
|
||||
priority: 'Normal',
|
||||
suppliers: new Set(),
|
||||
colors: new Set()
|
||||
};
|
||||
|
||||
current.plannedKg += plan.quantityKg;
|
||||
if (plan.supplier) current.suppliers.add(plan.supplier);
|
||||
if (plan.color) current.colors.add(plan.color);
|
||||
if (plan.priority === 'Crítico') current.priority = 'Crítico';
|
||||
if (plan.priority === 'Atenção' && current.priority !== 'Crítico') current.priority = 'Atenção';
|
||||
needsByMaterial.set(key, current);
|
||||
});
|
||||
|
||||
lots.forEach(lot => {
|
||||
if (lot.unit !== 'kg') return;
|
||||
const need = needsByMaterial.get(normalizeKey(lot.product));
|
||||
if (need) need.stockKg += lot.quantity;
|
||||
});
|
||||
|
||||
receipts.forEach(receipt => {
|
||||
if (receipt.status !== 'pending' || receipt.unit !== 'kg') return;
|
||||
const need = needsByMaterial.get(normalizeKey(receipt.product));
|
||||
if (need) need.pendingKg += receipt.quantity;
|
||||
});
|
||||
|
||||
return Array.from(needsByMaterial.values())
|
||||
.map(need => {
|
||||
const purchaseKg = Math.max(need.plannedKg - need.stockKg - need.pendingKg, 0);
|
||||
let status = 'ok';
|
||||
if (purchaseKg > 0 && (need.priority === 'Crítico' || need.stockKg === 0)) status = 'critical';
|
||||
else if (purchaseKg > 0) status = 'attention';
|
||||
|
||||
return {
|
||||
material: need.material,
|
||||
plannedKg: need.plannedKg,
|
||||
stockKg: need.stockKg,
|
||||
pendingKg: need.pendingKg,
|
||||
purchaseKg,
|
||||
priority: need.priority,
|
||||
status,
|
||||
suppliers: Array.from(need.suppliers),
|
||||
colors: Array.from(need.colors)
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const statusOrder = { critical: 1, attention: 2, ok: 3 };
|
||||
return statusOrder[a.status] - statusOrder[b.status] || b.purchaseKg - a.purchaseKg || a.material.localeCompare(b.material);
|
||||
});
|
||||
};
|
||||
|
||||
const buildStats = (receipts, lots, purchaseNeeds) => {
|
||||
const totalQuantityKg = lots.reduce((total, lot) => (
|
||||
lot.unit === 'kg' ? total + lot.quantity : total
|
||||
), 0);
|
||||
@@ -93,27 +190,41 @@ const buildStats = (receipts, lots) => {
|
||||
totalQuantityKg,
|
||||
activeLots: lots.length,
|
||||
rolls: lots.filter(lot => lot.unit === 'rolos').reduce((total, lot) => total + lot.quantity, 0),
|
||||
alerts: 0,
|
||||
alerts: purchaseNeeds.filter(need => need.status !== 'ok').length,
|
||||
pendingReceipts: receipts.filter(receipt => receipt.status === 'pending').length,
|
||||
approvedReceipts: receipts.filter(receipt => receipt.status === 'approved').length
|
||||
};
|
||||
};
|
||||
|
||||
const getSupplySummary = async () => {
|
||||
const [receipts, lots, movements] = await Promise.all([
|
||||
const [receipts, lots, movements, fabricPlans] = await Promise.all([
|
||||
listReceipts(),
|
||||
listLots(),
|
||||
listMovements()
|
||||
listMovements(),
|
||||
listFabricPlans()
|
||||
]);
|
||||
const purchaseNeeds = buildPurchaseNeeds(fabricPlans, lots, receipts);
|
||||
|
||||
return {
|
||||
receipts,
|
||||
lots,
|
||||
movements,
|
||||
stats: buildStats(receipts, lots)
|
||||
fabricPlans,
|
||||
purchaseNeeds,
|
||||
stats: buildStats(receipts, lots, purchaseNeeds)
|
||||
};
|
||||
};
|
||||
|
||||
const listPurchaseNeeds = async () => {
|
||||
const [plans, lots, receipts] = await Promise.all([
|
||||
listFabricPlans(),
|
||||
listLots(),
|
||||
listReceipts()
|
||||
]);
|
||||
|
||||
return buildPurchaseNeeds(plans, lots, receipts);
|
||||
};
|
||||
|
||||
const createReceipt = async (payload) => {
|
||||
const category = normalizeText(payload.category);
|
||||
const product = normalizeText(payload.product);
|
||||
@@ -233,12 +344,49 @@ const deleteReceipt = async (id) => {
|
||||
}
|
||||
};
|
||||
|
||||
const createFabricPlan = async (payload) => {
|
||||
const material = normalizeText(payload.material);
|
||||
const quantityKg = normalizeNumber(payload.quantityKg);
|
||||
|
||||
if (!material) throw createValidationError('Malha ou tecido é obrigatório.');
|
||||
if (!quantityKg) throw createValidationError('Quantidade deve ser maior que zero.');
|
||||
|
||||
const result = await pool.query(`
|
||||
INSERT INTO supply_fabric_plans (
|
||||
material, color, quantity_kg, supplier, priority, status, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, 'active', CURRENT_TIMESTAMP)
|
||||
RETURNING id, material, color, quantity_kg, supplier, priority, status, created_at, updated_at
|
||||
`, [
|
||||
material,
|
||||
normalizeText(payload.color) || 'Todas as cores',
|
||||
quantityKg,
|
||||
normalizeText(payload.supplier) || null,
|
||||
normalizeText(payload.priority) || 'Normal'
|
||||
]);
|
||||
|
||||
return mapFabricPlan(result.rows[0]);
|
||||
};
|
||||
|
||||
const deleteFabricPlan = async (id) => {
|
||||
await pool.query(`
|
||||
UPDATE supply_fabric_plans
|
||||
SET status = 'removed',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`, [id]);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
approveReceipt,
|
||||
createFabricPlan,
|
||||
createReceipt,
|
||||
deleteFabricPlan,
|
||||
deleteReceipt,
|
||||
getSupplySummary,
|
||||
listFabricPlans,
|
||||
listLots,
|
||||
listMovements,
|
||||
listPurchaseNeeds,
|
||||
listReceipts
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, ProductionOrderSummary, RfmAnalytics, StockData, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
|
||||
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, ProductionOrderSummary, RfmAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
|
||||
import { formatDateParam } from './dateRanges';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
@@ -290,12 +290,12 @@ export const deleteConsumptionReference = async (id: number): Promise<void> => {
|
||||
};
|
||||
|
||||
export const fetchSupplySummary = async (): Promise<SupplySummary> => {
|
||||
try {
|
||||
const response = await authFetch('/supply');
|
||||
if (!response.ok) return {
|
||||
const emptySummary: SupplySummary = {
|
||||
receipts: [],
|
||||
lots: [],
|
||||
movements: [],
|
||||
fabricPlans: [],
|
||||
purchaseNeeds: [],
|
||||
stats: {
|
||||
totalQuantityKg: 0,
|
||||
activeLots: 0,
|
||||
@@ -305,22 +305,14 @@ export const fetchSupplySummary = async (): Promise<SupplySummary> => {
|
||||
approvedReceipts: 0
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await authFetch('/supply');
|
||||
if (!response.ok) return emptySummary;
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch supply summary failed', error);
|
||||
return {
|
||||
receipts: [],
|
||||
lots: [],
|
||||
movements: [],
|
||||
stats: {
|
||||
totalQuantityKg: 0,
|
||||
activeLots: 0,
|
||||
rolls: 0,
|
||||
alerts: 0,
|
||||
pendingReceipts: 0,
|
||||
approvedReceipts: 0
|
||||
}
|
||||
};
|
||||
return emptySummary;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -362,6 +354,51 @@ export const deleteSupplyReceipt = async (id: number): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchSupplyFabricPlans = async (): Promise<SupplyFabricPlan[]> => {
|
||||
try {
|
||||
const response = await authFetch('/supply/fabric-plans');
|
||||
if (!response.ok) return [];
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch supply fabric plans failed', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const createSupplyFabricPlan = async (payload: SupplyFabricPlanPayload): Promise<SupplyFabricPlan> => {
|
||||
const response = await authFetch('/supply/fabric-plans', {
|
||||
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 salvar o plano de malha.');
|
||||
}
|
||||
|
||||
return data as SupplyFabricPlan;
|
||||
};
|
||||
|
||||
export const deleteSupplyFabricPlan = async (id: number): Promise<void> => {
|
||||
const response = await authFetch(`/supply/fabric-plans/${id}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
throw new Error(data?.error || 'Não foi possível remover o plano de malha.');
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchSupplyPurchaseNeeds = async (): Promise<SupplyPurchaseNeed[]> => {
|
||||
try {
|
||||
const response = await authFetch('/supply/purchase-needs');
|
||||
if (!response.ok) return [];
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch supply purchase needs failed', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchDashboardAnalytics = async (dateRange: DateRange, options?: CacheOptions): Promise<DashboardAnalytics | null> => {
|
||||
const path = `/analytics/dashboard?${buildDateRangeParams(dateRange).toString()}`;
|
||||
return getCachedAnalytics(path, async () => {
|
||||
|
||||
@@ -20,19 +20,11 @@ import {
|
||||
Truck,
|
||||
Warehouse,
|
||||
} from 'lucide-react';
|
||||
import { approveSupplyReceipt, createSupplyReceipt, deleteSupplyReceipt, fetchSupplySummary } from '../dataService';
|
||||
import type { SupplyLot, SupplyMovement, SupplyReceipt, SupplySummary } from '../types';
|
||||
import { approveSupplyReceipt, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, fetchSupplySummary } from '../dataService';
|
||||
import type { SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyReceipt, SupplySummary } from '../types';
|
||||
|
||||
type InventoryTab = 'dashboard' | 'balance' | 'receipts' | 'inventory' | 'movements';
|
||||
type ReceiptView = 'new' | 'pending' | 'history';
|
||||
type FabricPlan = {
|
||||
id: string;
|
||||
material: string;
|
||||
color: string;
|
||||
quantityKg: number;
|
||||
supplier: string;
|
||||
priority: string;
|
||||
};
|
||||
|
||||
const pageClassName = 'mx-auto flex w-full max-w-7xl flex-col gap-6';
|
||||
const panelClassName = 'rounded-2xl border border-dark-border bg-dark-card shadow-sm';
|
||||
@@ -44,6 +36,8 @@ const emptySupplySummary: SupplySummary = {
|
||||
receipts: [],
|
||||
lots: [],
|
||||
movements: [],
|
||||
fabricPlans: [],
|
||||
purchaseNeeds: [],
|
||||
stats: {
|
||||
totalQuantityKg: 0,
|
||||
activeLots: 0,
|
||||
@@ -93,17 +87,6 @@ const exportCsv = (filename: string, rows: Array<Record<string, string | number
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const loadFabricPlans = (): FabricPlan[] => {
|
||||
try {
|
||||
const rawPlans = localStorage.getItem('nexstar_fabric_plans');
|
||||
if (!rawPlans) return [];
|
||||
const parsed = JSON.parse(rawPlans);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const inventoryTabs: Array<{ id: InventoryTab; name: string; icon: typeof BarChart3 }> = [
|
||||
{ id: 'dashboard', name: 'Dashboard', icon: Warehouse },
|
||||
{ id: 'balance', name: 'Saldo', icon: BarChart3 },
|
||||
@@ -796,7 +779,7 @@ const InventoryScreen = () => {
|
||||
};
|
||||
|
||||
const FabricPlanningScreen = () => {
|
||||
const [plans, setPlans] = useState<FabricPlan[]>(loadFabricPlans);
|
||||
const [plans, setPlans] = useState<SupplyFabricPlan[]>([]);
|
||||
const [form, setForm] = useState({
|
||||
material: '',
|
||||
color: '',
|
||||
@@ -804,36 +787,84 @@ const FabricPlanningScreen = () => {
|
||||
supplier: '',
|
||||
priority: 'Normal',
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const totalKg = plans.reduce((total, plan) => total + plan.quantityKg, 0);
|
||||
|
||||
const savePlans = (nextPlans: FabricPlan[]) => {
|
||||
setPlans(nextPlans);
|
||||
localStorage.setItem('nexstar_fabric_plans', JSON.stringify(nextPlans));
|
||||
const loadPlans = async () => {
|
||||
setErrorMessage('');
|
||||
const summary = await fetchSupplySummary();
|
||||
setPlans(summary.fabricPlans);
|
||||
};
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const summary = await fetchSupplySummary();
|
||||
if (isMounted) setPlans(summary.fabricPlans);
|
||||
} finally {
|
||||
if (isMounted) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const material = form.material.trim();
|
||||
const quantityKg = parseDecimal(form.quantityKg);
|
||||
if (!material || quantityKg <= 0) return;
|
||||
|
||||
const nextPlan: FabricPlan = {
|
||||
id: `${Date.now()}`,
|
||||
setIsBusy(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
await createSupplyFabricPlan({
|
||||
material,
|
||||
color: form.color.trim() || 'Todas as cores',
|
||||
quantityKg,
|
||||
supplier: form.supplier.trim() || 'Sem fornecedor',
|
||||
priority: form.priority,
|
||||
});
|
||||
await loadPlans();
|
||||
setForm({ material: '', color: '', quantityKg: '', supplier: '', priority: 'Normal' });
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Não foi possível salvar o plano de malha.');
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
savePlans([nextPlan, ...plans]);
|
||||
setForm({ material: '', color: '', quantityKg: '', supplier: '', priority: 'Normal' });
|
||||
const removePlan = async (planId: number) => {
|
||||
setIsBusy(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
await deleteSupplyFabricPlan(planId);
|
||||
await loadPlans();
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Não foi possível remover o plano de malha.');
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={pageClassName}>
|
||||
<Header title="Planejamento de Malha" subtitle="Fila de matéria-prima para compra, recebimento e abastecimento do corte." backTo="/supplies" />
|
||||
{errorMessage && (
|
||||
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm font-bold text-red-300">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Planos ativos</p>
|
||||
@@ -878,9 +909,9 @@ const FabricPlanningScreen = () => {
|
||||
Fornecedor
|
||||
<input value={form.supplier} onChange={(event) => setForm(current => ({ ...current, supplier: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="Opcional" />
|
||||
</label>
|
||||
<button type="submit" className="mt-2 inline-flex h-11 items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-opacity hover:opacity-90 cursor-pointer">
|
||||
<button type="submit" disabled={isBusy} className="mt-2 inline-flex h-11 items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">
|
||||
<Save className="h-4 w-4" />
|
||||
Salvar plano
|
||||
{isBusy ? 'Salvando...' : 'Salvar plano'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -890,7 +921,12 @@ const FabricPlanningScreen = () => {
|
||||
<h2 className="text-base font-bold text-dark-text">Planos de malha</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-dark-muted">Itens planejados para compra ou recebimento.</p>
|
||||
</div>
|
||||
{plans.length ? (
|
||||
{isLoading ? (
|
||||
<div className={`${emptyStateClassName} min-h-[220px]`}>
|
||||
<Ruler className="h-8 w-8 text-brand-primary" />
|
||||
<h3 className="text-base font-bold text-dark-text">Carregando planos...</h3>
|
||||
</div>
|
||||
) : plans.length ? (
|
||||
<div className="divide-y divide-dark-border">
|
||||
{plans.map(plan => (
|
||||
<div key={plan.id} className="grid grid-cols-1 gap-3 p-4 md:grid-cols-[1.4fr_1fr_100px_100px_auto] md:items-center">
|
||||
@@ -901,7 +937,7 @@ const FabricPlanningScreen = () => {
|
||||
<p className="text-sm font-semibold text-dark-muted">{plan.supplier}</p>
|
||||
<p className="text-sm font-bold text-dark-text">{formatNumber(plan.quantityKg)} kg</p>
|
||||
<span className="w-fit rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-text">{plan.priority}</span>
|
||||
<button type="button" onClick={() => savePlans(plans.filter(item => item.id !== plan.id))} className="text-sm font-bold text-red-400 transition-colors hover:text-red-300 cursor-pointer">
|
||||
<button type="button" disabled={isBusy} onClick={() => removePlan(plan.id)} className="text-sm font-bold text-red-400 transition-colors hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">
|
||||
Remover
|
||||
</button>
|
||||
</div>
|
||||
@@ -920,14 +956,58 @@ const FabricPlanningScreen = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const PurchaseNeedsScreen = () => (
|
||||
const purchaseStatusLabels: Record<SupplyPurchaseNeed['status'], string> = {
|
||||
critical: 'Crítico',
|
||||
attention: 'Atenção',
|
||||
ok: 'Coberto',
|
||||
};
|
||||
|
||||
const PurchaseNeedsScreen = () => {
|
||||
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const loadSummary = async () => {
|
||||
const nextSummary = await fetchSupplySummary();
|
||||
setSummary(nextSummary);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const nextSummary = await fetchSupplySummary();
|
||||
if (isMounted) setSummary(nextSummary);
|
||||
} finally {
|
||||
if (isMounted) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const normalizedSearch = normalizeSearch(search);
|
||||
const visibleNeeds = summary.purchaseNeeds.filter(need => (
|
||||
!normalizedSearch || normalizeSearch(`${need.material} ${need.suppliers.join(' ')} ${need.colors.join(' ')}`).includes(normalizedSearch)
|
||||
));
|
||||
const criticalCount = summary.purchaseNeeds.filter(need => need.status === 'critical').length;
|
||||
const suggestedPurchaseKg = summary.purchaseNeeds.reduce((total, need) => total + need.purchaseKg, 0);
|
||||
const pendingSupplierCount = new Set(summary.purchaseNeeds.flatMap(need => need.suppliers)).size;
|
||||
|
||||
return (
|
||||
<div className={pageClassName}>
|
||||
<Header title="Necessidade de Compra" subtitle="Materiais abaixo do mínimo e necessidade projetada para compra." backTo="/supplies" />
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{[
|
||||
{ label: 'Itens críticos', value: '0' },
|
||||
{ label: 'Compra sugerida', value: '0 kg' },
|
||||
{ label: 'Fornecedores pendentes', value: '0' },
|
||||
{ label: 'Itens críticos', value: `${criticalCount}` },
|
||||
{ label: 'Compra sugerida', value: `${formatNumber(suggestedPurchaseKg)} kg` },
|
||||
{ label: 'Fornecedores envolvidos', value: `${pendingSupplierCount}` },
|
||||
].map(stat => (
|
||||
<div key={stat.label} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{stat.label}</p>
|
||||
@@ -939,23 +1019,89 @@ const PurchaseNeedsScreen = () => (
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-dark-text">Necessidade por material</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-dark-muted">Calculada com estoque atual, mínimo configurado e consumo dos planos.</p>
|
||||
<p className="mt-1 text-sm font-semibold text-dark-muted">Planejado - estoque aprovado - recebimentos pendentes.</p>
|
||||
</div>
|
||||
<button type="button" className={buttonClassName}><Download className="h-4 w-4" /> Exportar CSV</button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={loadSummary} className={buttonClassName}><RefreshCw className="h-4 w-4" /> Atualizar</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exportCsv('necessidade-compra.csv', visibleNeeds.map(need => ({
|
||||
material: need.material,
|
||||
planejado_kg: need.plannedKg,
|
||||
estoque_kg: need.stockKg,
|
||||
pendente_kg: need.pendingKg,
|
||||
comprar_kg: need.purchaseKg,
|
||||
prioridade: need.priority,
|
||||
status: purchaseStatusLabels[need.status],
|
||||
fornecedores: need.suppliers.join(' | '),
|
||||
cores: need.colors.join(' | '),
|
||||
})))}
|
||||
className={buttonClassName}
|
||||
>
|
||||
<Download className="h-4 w-4" /> Exportar CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative mt-4 block">
|
||||
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} className={`${inputClassName} pl-9`} placeholder="Buscar por material, fornecedor ou cor..." />
|
||||
</label>
|
||||
{isLoading ? (
|
||||
<div className={emptyStateClassName}>
|
||||
<BarChart3 className="h-8 w-8 text-brand-primary" />
|
||||
<h3 className="text-base font-bold text-dark-text">Nenhuma necessidade de compra</h3>
|
||||
<p className="text-sm font-semibold text-dark-muted">Configure mínimos e registre estoque para gerar sugestões.</p>
|
||||
<h3 className="text-base font-bold text-dark-text">Calculando necessidade...</h3>
|
||||
</div>
|
||||
) : visibleNeeds.length ? (
|
||||
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
|
||||
<div className="hidden grid-cols-[1.3fr_110px_110px_110px_110px_110px] border-b border-dark-border bg-dark-input/40 px-4 py-3 text-xs font-bold uppercase tracking-widest text-dark-muted lg:grid">
|
||||
<span>Material</span>
|
||||
<span>Planejado</span>
|
||||
<span>Estoque</span>
|
||||
<span>Pendente</span>
|
||||
<span>Comprar</span>
|
||||
<span>Status</span>
|
||||
</div>
|
||||
<div className="divide-y divide-dark-border">
|
||||
{visibleNeeds.map(need => (
|
||||
<div key={need.material} className="grid grid-cols-1 gap-3 bg-dark-card px-4 py-4 lg:grid-cols-[1.3fr_110px_110px_110px_110px_110px] lg:items-center">
|
||||
<div>
|
||||
<p className="text-sm font-bold text-dark-text">{need.material}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||
{(need.colors.length ? need.colors.join(', ') : 'Todas as cores')} · {(need.suppliers.length ? need.suppliers.join(', ') : 'Sem fornecedor')}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-dark-text">{formatNumber(need.plannedKg)} kg</p>
|
||||
<p className="text-sm font-bold text-dark-text">{formatNumber(need.stockKg)} kg</p>
|
||||
<p className="text-sm font-bold text-dark-text">{formatNumber(need.pendingKg)} kg</p>
|
||||
<p className={`text-sm font-bold ${need.purchaseKg > 0 ? 'text-red-300' : 'text-emerald-300'}`}>{formatNumber(need.purchaseKg)} kg</p>
|
||||
<span className={`w-fit rounded-full border px-2.5 py-1 text-xs font-bold ${
|
||||
need.status === 'critical'
|
||||
? 'border-red-400/30 bg-red-400/10 text-red-300'
|
||||
: need.status === 'attention'
|
||||
? 'border-amber-400/30 bg-amber-400/10 text-amber-300'
|
||||
: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
|
||||
}`}>
|
||||
{purchaseStatusLabels[need.status]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={emptyStateClassName}>
|
||||
<BarChart3 className="h-8 w-8 text-brand-primary" />
|
||||
<h3 className="text-base font-bold text-dark-text">{summary.purchaseNeeds.length ? 'Nenhuma necessidade encontrada' : 'Nenhuma necessidade de compra'}</h3>
|
||||
<p className="text-sm font-semibold text-dark-muted">{summary.purchaseNeeds.length ? 'Ajuste a busca.' : 'Cadastre planos de malha para gerar demanda e aprove recebimentos para abater estoque.'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`${panelClassName} p-5`}>
|
||||
<h2 className="text-base font-bold text-dark-text">Estoque mínimo por item</h2>
|
||||
<p className="mt-2 text-sm font-semibold text-dark-muted">Defina o ponto de pedido de cada matéria-prima para aparecer aqui quando ficar abaixo do mínimo.</p>
|
||||
<button type="button" className={`${buttonClassName} mt-4`}>Configurar estoques mínimos</button>
|
||||
<h2 className="text-base font-bold text-dark-text">Como o fluxo está conectado</h2>
|
||||
<p className="mt-2 text-sm font-semibold text-dark-muted">Plano de malha cria demanda. Recebimento pendente entra como material a receber. Aprovar recebimento move para estoque e movimentações.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Supplies = () => {
|
||||
const { section } = useParams<{ section?: string }>();
|
||||
|
||||
34
src/types.ts
34
src/types.ts
@@ -210,6 +210,30 @@ export interface SupplyMovement {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SupplyFabricPlan {
|
||||
id: number;
|
||||
material: string;
|
||||
color: string;
|
||||
quantityKg: number;
|
||||
supplier: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SupplyPurchaseNeed {
|
||||
material: string;
|
||||
plannedKg: number;
|
||||
stockKg: number;
|
||||
pendingKg: number;
|
||||
purchaseKg: number;
|
||||
priority: string;
|
||||
status: 'critical' | 'attention' | 'ok';
|
||||
suppliers: string[];
|
||||
colors: string[];
|
||||
}
|
||||
|
||||
export interface SupplyStats {
|
||||
totalQuantityKg: number;
|
||||
activeLots: number;
|
||||
@@ -223,6 +247,8 @@ export interface SupplySummary {
|
||||
receipts: SupplyReceipt[];
|
||||
lots: SupplyLot[];
|
||||
movements: SupplyMovement[];
|
||||
fabricPlans: SupplyFabricPlan[];
|
||||
purchaseNeeds: SupplyPurchaseNeed[];
|
||||
stats: SupplyStats;
|
||||
}
|
||||
|
||||
@@ -236,6 +262,14 @@ export type SupplyReceiptPayload = {
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type SupplyFabricPlanPayload = {
|
||||
material: string;
|
||||
color?: string;
|
||||
quantityKg: number | string;
|
||||
supplier?: string;
|
||||
priority?: string;
|
||||
};
|
||||
|
||||
export interface DateRange {
|
||||
start: Date;
|
||||
end: Date;
|
||||
|
||||
Reference in New Issue
Block a user