Add local production order workflow
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m40s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m40s
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { verifyToken } = require('../auth');
|
const { verifyToken } = require('../auth');
|
||||||
const { listProductionOrders } = require('../services/productionOrderService');
|
const { createProductionOrders, listProductionOrders, updateProductionOrderStatus } = require('../services/productionOrderService');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -13,4 +13,23 @@ router.get('/production-orders', verifyToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/production-orders', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const orders = Array.isArray(req.body?.orders) ? req.body.orders : [];
|
||||||
|
res.status(201).json(await createProductionOrders(orders));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating production orders:', error);
|
||||||
|
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/production-orders/:id/status', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await updateProductionOrderStatus(req.params.id, req.body?.status));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating production order status:', error);
|
||||||
|
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ const formatDate = (value) => {
|
|||||||
return String(value).slice(0, 10);
|
return String(value).slice(0, 10);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeText = (value) => String(value || '').trim();
|
||||||
|
|
||||||
|
const normalizeQuantity = (value) => {
|
||||||
|
const quantity = Number(value);
|
||||||
|
if (!Number.isFinite(quantity) || quantity <= 0) return 0;
|
||||||
|
return quantity;
|
||||||
|
};
|
||||||
|
|
||||||
const mapProductionOrderRow = (row) => {
|
const mapProductionOrderRow = (row) => {
|
||||||
const status = normalizeStatus(row.status);
|
const status = normalizeStatus(row.status);
|
||||||
|
|
||||||
@@ -54,6 +62,42 @@ const mapProductionOrderRow = (row) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getOrderById = async (id, client = pool) => {
|
||||||
|
const result = await client.query(`
|
||||||
|
SELECT
|
||||||
|
po.id,
|
||||||
|
po.tiny_id,
|
||||||
|
po.number,
|
||||||
|
po.status,
|
||||||
|
po.order_reference,
|
||||||
|
po.issue_date,
|
||||||
|
po.expected_date,
|
||||||
|
po.product_sku,
|
||||||
|
po.product_description,
|
||||||
|
po.quantity,
|
||||||
|
po.unit,
|
||||||
|
po.integration_status,
|
||||||
|
po.created_at,
|
||||||
|
po.updated_at,
|
||||||
|
COALESCE(
|
||||||
|
JSON_AGG(
|
||||||
|
JSON_BUILD_OBJECT(
|
||||||
|
'label', pom.label,
|
||||||
|
'color', pom.color
|
||||||
|
)
|
||||||
|
ORDER BY pom.label
|
||||||
|
) FILTER (WHERE pom.id IS NOT NULL),
|
||||||
|
'[]'::json
|
||||||
|
) as markers
|
||||||
|
FROM production_orders po
|
||||||
|
LEFT JOIN production_order_markers pom ON pom.production_order_id = po.id
|
||||||
|
WHERE po.id = $1
|
||||||
|
GROUP BY po.id;
|
||||||
|
`, [id]);
|
||||||
|
|
||||||
|
return result.rows[0] ? mapProductionOrderRow(result.rows[0]) : null;
|
||||||
|
};
|
||||||
|
|
||||||
const listProductionOrders = async (filters = {}) => {
|
const listProductionOrders = async (filters = {}) => {
|
||||||
const params = [];
|
const params = [];
|
||||||
const where = [];
|
const where = [];
|
||||||
@@ -133,7 +177,142 @@ const listProductionOrders = async (filters = {}) => {
|
|||||||
return { orders, counts };
|
return { orders, counts };
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
const createProductionOrders = async (orders = []) => {
|
||||||
listProductionOrders,
|
const client = await pool.connect();
|
||||||
normalizeStatus
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const created = [];
|
||||||
|
const skipped = [];
|
||||||
|
|
||||||
|
for (const order of orders) {
|
||||||
|
const productDescription = normalizeText(order.productDescription);
|
||||||
|
const productSku = normalizeText(order.productSku);
|
||||||
|
const orderReference = normalizeText(order.orderReference);
|
||||||
|
const quantity = normalizeQuantity(order.quantity);
|
||||||
|
|
||||||
|
if (!productDescription || !quantity) {
|
||||||
|
skipped.push({ productSku, productDescription, reason: 'invalid_payload' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateResult = await client.query(`
|
||||||
|
SELECT id
|
||||||
|
FROM production_orders
|
||||||
|
WHERE COALESCE(order_reference, '') = $1
|
||||||
|
AND COALESCE(product_sku, '') = $2
|
||||||
|
AND status IN ('open', 'in_progress')
|
||||||
|
LIMIT 1;
|
||||||
|
`, [orderReference, productSku]);
|
||||||
|
|
||||||
|
if (duplicateResult.rows.length) {
|
||||||
|
skipped.push({ productSku, productDescription, reason: 'duplicate_open_order' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertResult = await client.query(`
|
||||||
|
INSERT INTO production_orders (
|
||||||
|
tiny_id,
|
||||||
|
number,
|
||||||
|
status,
|
||||||
|
order_reference,
|
||||||
|
issue_date,
|
||||||
|
expected_date,
|
||||||
|
product_sku,
|
||||||
|
product_description,
|
||||||
|
quantity,
|
||||||
|
unit,
|
||||||
|
integration_status,
|
||||||
|
tiny_payload
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
$1,
|
||||||
|
$2,
|
||||||
|
$3,
|
||||||
|
$4,
|
||||||
|
$5,
|
||||||
|
$6,
|
||||||
|
$7,
|
||||||
|
$8,
|
||||||
|
$9,
|
||||||
|
$10::jsonb
|
||||||
|
)
|
||||||
|
RETURNING id;
|
||||||
|
`, [
|
||||||
|
normalizeStatus(order.status || 'open'),
|
||||||
|
orderReference,
|
||||||
|
normalizeDateParam(order.issueDate) || formatDate(new Date()),
|
||||||
|
normalizeDateParam(order.expectedDate),
|
||||||
|
productSku,
|
||||||
|
productDescription,
|
||||||
|
quantity,
|
||||||
|
normalizeText(order.unit) || 'UN',
|
||||||
|
normalizeText(order.integrationStatus) || 'Local',
|
||||||
|
JSON.stringify(order.metadata || {})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const orderId = insertResult.rows[0].id;
|
||||||
|
await client.query(`
|
||||||
|
UPDATE production_orders
|
||||||
|
SET number = $1, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2;
|
||||||
|
`, [normalizeText(order.number) || `OP-${String(orderId).padStart(5, '0')}`, orderId]);
|
||||||
|
|
||||||
|
const markers = Array.isArray(order.markers) ? order.markers : [];
|
||||||
|
for (const marker of markers) {
|
||||||
|
const label = normalizeText(marker.label);
|
||||||
|
if (!label) continue;
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO production_order_markers (production_order_id, label, color)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (production_order_id, label) DO UPDATE SET color = EXCLUDED.color;
|
||||||
|
`, [orderId, label, normalizeText(marker.color) || null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdOrder = await getOrderById(orderId, client);
|
||||||
|
if (createdOrder) created.push(createdOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return { created, skipped };
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateProductionOrderStatus = async (id, status) => {
|
||||||
|
const normalizedStatus = normalizeStatus(status);
|
||||||
|
|
||||||
|
if (!STATUS_LABELS[normalizedStatus]) {
|
||||||
|
const error = new Error('Invalid production order status');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(`
|
||||||
|
UPDATE production_orders
|
||||||
|
SET status = $1, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING id;
|
||||||
|
`, [normalizedStatus, id]);
|
||||||
|
|
||||||
|
if (!result.rows.length) {
|
||||||
|
const error = new Error('Production order not found');
|
||||||
|
error.statusCode = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getOrderById(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createProductionOrders,
|
||||||
|
listProductionOrders,
|
||||||
|
normalizeStatus,
|
||||||
|
updateProductionOrderStatus
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, 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, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyProductionExitPayload, SupplyPurchaseNeed, 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';
|
||||||
@@ -183,6 +183,30 @@ export const fetchProductionOrders = async (
|
|||||||
}, options);
|
}, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createProductionOrders = async (orders: ProductionOrderPayload[]): Promise<CreateProductionOrdersResult> => {
|
||||||
|
const response = await authFetch('/production-orders', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ orders })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Não foi possível criar as ordens de produção.');
|
||||||
|
analyticsCache.clear();
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateProductionOrderStatus = async (id: number, status: ProductionOrderStatus): Promise<ProductionOrderItem> => {
|
||||||
|
const response = await authFetch(`/production-orders/${id}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Não foi possível atualizar o status da ordem.');
|
||||||
|
analyticsCache.clear();
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchCuttingSettings = async (): Promise<CuttingSettings> => {
|
export const fetchCuttingSettings = async (): Promise<CuttingSettings> => {
|
||||||
try {
|
try {
|
||||||
const response = await authFetch('/cutting-settings');
|
const response = await authFetch('/cutting-settings');
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import ProductColorBadge from '../components/ProductColorBadge';
|
|||||||
import RefreshStatus from '../components/RefreshStatus';
|
import RefreshStatus from '../components/RefreshStatus';
|
||||||
import { buildCuttingSkuConfigPath } from '../catalogLinks';
|
import { buildCuttingSkuConfigPath } from '../catalogLinks';
|
||||||
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
|
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
|
||||||
import { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService';
|
import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService';
|
||||||
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||||
|
|
||||||
type CutFilter = 'need' | 'all' | 'issues' | 'covered';
|
type CutFilter = 'need' | 'all' | 'issues' | 'covered';
|
||||||
@@ -65,6 +65,13 @@ const formatNumber = (value: number, maximumFractionDigits = 0) => (
|
|||||||
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
|
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const formatDateKey = (date: Date) => {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
const formatDays = (value: number | null) => {
|
const formatDays = (value: number | null) => {
|
||||||
if (value === null) return '-';
|
if (value === null) return '-';
|
||||||
if (value > 999) return '999+ dias';
|
if (value > 999) return '999+ dias';
|
||||||
@@ -135,6 +142,8 @@ const Cutting = () => {
|
|||||||
const [correctionPage, setCorrectionPage] = useState(1);
|
const [correctionPage, setCorrectionPage] = useState(1);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||||
|
const [isGeneratingOrders, setIsGeneratingOrders] = useState(false);
|
||||||
|
const [generationMessage, setGenerationMessage] = useState('');
|
||||||
const cutConfigSection = searchParams.get('config');
|
const cutConfigSection = searchParams.get('config');
|
||||||
const targetCorrectionSku = (searchParams.get('sku') || '').trim();
|
const targetCorrectionSku = (searchParams.get('sku') || '').trim();
|
||||||
|
|
||||||
@@ -241,6 +250,10 @@ const Cutting = () => {
|
|||||||
});
|
});
|
||||||
}, [cutFilter, cutPlan.rows, familyFilter, searchTerm, sortBy]);
|
}, [cutFilter, cutPlan.rows, familyFilter, searchTerm, sortBy]);
|
||||||
|
|
||||||
|
const rowsAvailableForOrder = useMemo(() => (
|
||||||
|
filteredRows.filter(row => row.suggestedCutQuantity > 0)
|
||||||
|
), [filteredRows]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(filteredRows.length / itemsPerPage);
|
const totalPages = Math.ceil(filteredRows.length / itemsPerPage);
|
||||||
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||||
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
|
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
|
||||||
@@ -400,6 +413,60 @@ const Cutting = () => {
|
|||||||
})), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`);
|
})), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const generateProductionOrders = async () => {
|
||||||
|
if (!rowsAvailableForOrder.length) {
|
||||||
|
setGenerationMessage('Não há necessidade de corte no filtro atual.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsGeneratingOrders(true);
|
||||||
|
setGenerationMessage('');
|
||||||
|
|
||||||
|
const issueDate = formatDateKey(new Date());
|
||||||
|
const orderReference = `Plano de corte ${formatDateKey(dateRange.start)} a ${formatDateKey(dateRange.end)} · ${targetCoverageDays} dias`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await createProductionOrders(rowsAvailableForOrder.map(row => ({
|
||||||
|
status: 'open',
|
||||||
|
orderReference,
|
||||||
|
issueDate,
|
||||||
|
productSku: row.id,
|
||||||
|
productDescription: row.name,
|
||||||
|
quantity: row.suggestedCutQuantity,
|
||||||
|
unit: 'UN',
|
||||||
|
integrationStatus: 'Local',
|
||||||
|
markers: [
|
||||||
|
{ label: row.family.materialLabel, color: '#38bdf8' },
|
||||||
|
...(row.color ? [{ label: row.color, color: '#52DFA0' }] : []),
|
||||||
|
{ label: 'Material pendente', color: '#facc15' },
|
||||||
|
...(row.issues.length ? [{ label: 'Dados pendentes', color: '#f59e0b' }] : [])
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
source: 'cut_plan',
|
||||||
|
familyKey: row.family.key,
|
||||||
|
familyLabel: row.family.label,
|
||||||
|
materialLabel: row.family.materialLabel,
|
||||||
|
color: row.color,
|
||||||
|
size: row.size,
|
||||||
|
targetCoverageDays,
|
||||||
|
projectedDemand: row.projectedDemand,
|
||||||
|
stock: row.stock,
|
||||||
|
openProductionQuantity: row.openProductionQuantity,
|
||||||
|
availableQuantity: row.availableQuantity,
|
||||||
|
issues: row.issues
|
||||||
|
}
|
||||||
|
})));
|
||||||
|
|
||||||
|
const refreshedOrders = await fetchProductionOrders(allProductionOrdersRange, undefined, { force: true });
|
||||||
|
setProductionOrders(refreshedOrders.orders);
|
||||||
|
setGenerationMessage(`${result.created.length} OPs criadas${result.skipped.length ? ` · ${result.skipped.length} já existiam abertas` : ''}.`);
|
||||||
|
} catch (error) {
|
||||||
|
setGenerationMessage(error instanceof Error ? error.message : 'Não foi possível gerar as ordens de produção.');
|
||||||
|
} finally {
|
||||||
|
setIsGeneratingOrders(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renderIssueBadge = (row: CutPlanSkuRow) => {
|
const renderIssueBadge = (row: CutPlanSkuRow) => {
|
||||||
if (!row.issues.length) {
|
if (!row.issues.length) {
|
||||||
return <span className="text-xs font-bold text-emerald-300">OK</span>;
|
return <span className="text-xs font-bold text-emerald-300">OK</span>;
|
||||||
@@ -444,6 +511,17 @@ const Cutting = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void generateProductionOrders()}
|
||||||
|
disabled={isGeneratingOrders || !rowsAvailableForOrder.length}
|
||||||
|
className="flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-4 py-2.5 text-sm font-medium text-brand-primary shadow-sm transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||||
|
title="Gerar ordens locais para as necessidades do filtro atual"
|
||||||
|
>
|
||||||
|
<ClipboardList size={16} />
|
||||||
|
<span className="hidden sm:inline">{isGeneratingOrders ? 'Gerando' : 'Gerar OPs'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={exportRows}
|
onClick={exportRows}
|
||||||
@@ -466,6 +544,12 @@ const Cutting = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{generationMessage && (
|
||||||
|
<div className="rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-bold text-dark-muted">
|
||||||
|
{generationMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<RefreshStatus isRefreshing={isRefreshing} />
|
<RefreshStatus isRefreshing={isRefreshing} />
|
||||||
|
|
||||||
{isSettingsOpen && (
|
{isSettingsOpen && (
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ArrowLeft, CalendarDays, CheckCircle2, ClipboardList, Clock3, Download,
|
|||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
import PaginationControls from '../components/PaginationControls';
|
import PaginationControls from '../components/PaginationControls';
|
||||||
import RefreshStatus from '../components/RefreshStatus';
|
import RefreshStatus from '../components/RefreshStatus';
|
||||||
import { consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchSupplySummary } from '../dataService';
|
import { consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchSupplySummary, updateProductionOrderStatus } from '../dataService';
|
||||||
import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary, SupplyLot } from '../types';
|
import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary, SupplyLot } from '../types';
|
||||||
|
|
||||||
type ProductionOrderStatusTab = 'all' | 'open' | 'in_progress' | 'finished' | 'canceled';
|
type ProductionOrderStatusTab = 'all' | 'open' | 'in_progress' | 'finished' | 'canceled';
|
||||||
@@ -22,6 +22,10 @@ const statusTabs: Array<{ key: ProductionOrderStatusTab; label: string; dotClass
|
|||||||
{ key: 'canceled', label: 'Cancelada', dotClass: 'bg-zinc-500' }
|
{ key: 'canceled', label: 'Cancelada', dotClass: 'bg-zinc-500' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const editableStatusOptions: Array<{ value: ProductionOrderStatusTab; label: string }> = statusTabs
|
||||||
|
.filter(tab => tab.key !== 'all')
|
||||||
|
.map(tab => ({ value: tab.key, label: tab.label }));
|
||||||
|
|
||||||
const statusStyles: Record<string, { label: string; className: string; dotClass: string }> = {
|
const statusStyles: Record<string, { label: string; className: string; dotClass: string }> = {
|
||||||
open: {
|
open: {
|
||||||
label: 'Em aberto',
|
label: 'Em aberto',
|
||||||
@@ -121,6 +125,8 @@ const ProductionOrders = () => {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSupplyBusy, setIsSupplyBusy] = useState(false);
|
const [isSupplyBusy, setIsSupplyBusy] = useState(false);
|
||||||
const [supplyMessage, setSupplyMessage] = useState('');
|
const [supplyMessage, setSupplyMessage] = useState('');
|
||||||
|
const [busyStatusOrderId, setBusyStatusOrderId] = useState<number | null>(null);
|
||||||
|
const [statusMessage, setStatusMessage] = useState('');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(20);
|
const [itemsPerPage, setItemsPerPage] = useState(20);
|
||||||
const [exitForm, setExitForm] = useState({
|
const [exitForm, setExitForm] = useState({
|
||||||
@@ -190,6 +196,23 @@ const ProductionOrders = () => {
|
|||||||
void loadProductionOrders({ force: true });
|
void loadProductionOrders({ force: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = async (order: ProductionOrderItem, status: ProductionOrderStatusTab) => {
|
||||||
|
if (status === 'all' || status === order.status) return;
|
||||||
|
|
||||||
|
setBusyStatusOrderId(order.id);
|
||||||
|
setStatusMessage('');
|
||||||
|
try {
|
||||||
|
await updateProductionOrderStatus(order.id, status);
|
||||||
|
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, { force: true });
|
||||||
|
setSummary(nextSummary);
|
||||||
|
setStatusMessage(`OP ${order.number || `#${order.id}`} atualizada para ${statusStyles[status]?.label || status}.`);
|
||||||
|
} catch (error) {
|
||||||
|
setStatusMessage(error instanceof Error ? error.message : 'Não foi possível atualizar o status da OP.');
|
||||||
|
} finally {
|
||||||
|
setBusyStatusOrderId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const refreshSupplyLots = async () => {
|
const refreshSupplyLots = async () => {
|
||||||
const supplySummary = await fetchSupplySummary();
|
const supplySummary = await fetchSupplySummary();
|
||||||
setSupplyLots(supplySummary.lots);
|
setSupplyLots(supplySummary.lots);
|
||||||
@@ -248,7 +271,7 @@ const ProductionOrders = () => {
|
|||||||
Suprimentos
|
Suprimentos
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-2xl font-bold text-dark-text">Ordens de Produção</h1>
|
<h1 className="text-2xl font-bold text-dark-text">Ordens de Produção</h1>
|
||||||
<p className="mt-2 text-dark-muted font-medium">Acompanhe as ordens de produção sincronizadas do Tiny.</p>
|
<p className="mt-2 text-dark-muted font-medium">Acompanhe as ordens locais geradas pelo plano de corte.</p>
|
||||||
</div>
|
</div>
|
||||||
<DateRangePicker
|
<DateRangePicker
|
||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
@@ -399,6 +422,11 @@ const ProductionOrders = () => {
|
|||||||
|
|
||||||
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
|
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
|
||||||
<div className="border-b border-dark-border p-5">
|
<div className="border-b border-dark-border p-5">
|
||||||
|
{statusMessage && (
|
||||||
|
<div className="mb-4 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">
|
||||||
|
{statusMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||||
<div className="relative w-full xl:max-w-xl">
|
<div className="relative w-full xl:max-w-xl">
|
||||||
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-dark-muted" />
|
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-dark-muted" />
|
||||||
@@ -515,10 +543,18 @@ const ProductionOrders = () => {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-3">
|
<td className="px-6 py-3">
|
||||||
<span className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide ${statusStyle.className}`}>
|
<label className="sr-only" htmlFor={`production-order-status-${order.id}`}>Status da OP</label>
|
||||||
<span className={`h-1.5 w-1.5 rounded-full ${statusStyle.dotClass}`} />
|
<select
|
||||||
{statusStyle.label}
|
id={`production-order-status-${order.id}`}
|
||||||
</span>
|
value={order.status}
|
||||||
|
disabled={busyStatusOrderId === order.id}
|
||||||
|
onChange={(event) => void handleStatusChange(order, event.target.value as ProductionOrderStatusTab)}
|
||||||
|
className={`h-8 rounded-lg border bg-dark-input px-2 text-[10px] font-bold uppercase tracking-wide outline-none transition-colors focus:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 ${statusStyle.className}`}
|
||||||
|
>
|
||||||
|
{editableStatusOptions.map(option => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -534,7 +570,7 @@ const ProductionOrders = () => {
|
|||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-sm font-bold text-dark-text">Nenhuma ordem de produção encontrada.</p>
|
<p className="mt-4 text-sm font-bold text-dark-text">Nenhuma ordem de produção encontrada.</p>
|
||||||
<p className="mt-1 max-w-md text-sm font-medium text-dark-muted">
|
<p className="mt-1 max-w-md text-sm font-medium text-dark-muted">
|
||||||
Quando a sincronização com o Tiny estiver ativa, as ordens aparecerão aqui com status, produto, quantidade e marcadores.
|
Gere ordens pelo Plano de Corte para acompanhar status, produto, quantidade e baixa de material dentro do Graphs.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
29
src/types.ts
29
src/types.ts
@@ -67,6 +67,35 @@ export interface ProductionOrderSummary {
|
|||||||
counts: ProductionOrderCounts;
|
counts: ProductionOrderCounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProductionOrderMarkerPayload = {
|
||||||
|
label: string;
|
||||||
|
color?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductionOrderPayload = {
|
||||||
|
number?: string;
|
||||||
|
status?: ProductionOrderStatus;
|
||||||
|
orderReference?: string;
|
||||||
|
issueDate?: string | null;
|
||||||
|
expectedDate?: string | null;
|
||||||
|
productSku?: string;
|
||||||
|
productDescription: string;
|
||||||
|
quantity: number;
|
||||||
|
unit?: string;
|
||||||
|
integrationStatus?: string;
|
||||||
|
markers?: ProductionOrderMarkerPayload[];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CreateProductionOrdersResult {
|
||||||
|
created: ProductionOrderItem[];
|
||||||
|
skipped: Array<{
|
||||||
|
productSku?: string;
|
||||||
|
productDescription?: string;
|
||||||
|
reason: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
export type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'OUTROS';
|
export type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'OUTROS';
|
||||||
|
|
||||||
export interface CutProductOverride {
|
export interface CutProductOverride {
|
||||||
|
|||||||
Reference in New Issue
Block a user