Compare commits

..

8 Commits

Author SHA1 Message Date
Cauê Faleiros
9d99f2387b perf: limit cut plan to active orders
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
2026-08-28 13:14:21 -03:00
Cauê Faleiros
4122e21dc2 perf: defer cut yield recommendations
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m10s
2026-08-28 12:53:37 -03:00
Cauê Faleiros
c5edaec7d3 Reapply "feat: open administration to all users"
This reverts commit 9f4db67f4f.
2026-08-28 12:49:22 -03:00
Cauê Faleiros
9f4db67f4f Revert "feat: open administration to all users"
This reverts commit b4b21fb21b.
2026-08-28 12:47:13 -03:00
Cauê Faleiros
b4b21fb21b feat: open administration to all users
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m33s
2026-08-28 11:33:40 -03:00
Cauê Faleiros
ba136b9d78 fix: keep sessions on forbidden responses
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m32s
2026-08-28 11:07:39 -03:00
Cauê Faleiros
5a23817f4c Show synchronized composition in production orders 2026-08-28 09:39:01 -03:00
Cauê Faleiros
35f3938f1c Widen material transformation form 2026-08-28 09:36:05 -03:00
10 changed files with 140 additions and 49 deletions

View File

@@ -161,7 +161,7 @@ yield: 4.8 units/kg
* The production-order screen uses a compact list with inline expansion rather than a side detail page. Each OP has one status control (`Em aberto`, `Em andamento`, `Finalizada`, `Cancelada`), and expanded details show product markers, composition, and material consumption context. Material consumption is an OP-level action, relevant once production has started rather than a permanent page-level form. * The production-order screen uses a compact list with inline expansion rather than a side detail page. Each OP has one status control (`Em aberto`, `Em andamento`, `Finalizada`, `Cancelada`), and expanded details show product markers, composition, and material consumption context. Material consumption is an OP-level action, relevant once production has started rather than a permanent page-level form.
### 7.2 Olist V3 connection and composition synchronisation ### 7.2 Olist V3 connection and composition synchronisation
* Graphs has a Super Admin **Olist** monitor at `/#/admin/olist`. The connection action, manual sync, reconnection, stop action, live status, run history, run logs, and affected-product view are all on this page; Cadastros does not own the Olist connection flow. * Graphs has an **Administração** section available to every signed-in user. Its **Olist** monitor at `/#/admin/olist` owns the connection action, manual sync, reconnection, stop action, live status, run history, run logs, and affected-product view; Cadastros does not own the Olist connection flow. The same section includes user management at `/#/admin/users`.
* OAuth uses Olist/Tiny V3 with an authorization code and refresh token. Tokens are encrypted before storage in `olist_connections`; access tokens are refreshed automatically before expiry. The monitor shows the token expiry as an operational status, not an indication that the connection has failed. * OAuth uses Olist/Tiny V3 with an authorization code and refresh token. Tokens are encrypted before storage in `olist_connections`; access tokens are refreshed automatically before expiry. The monitor shows the token expiry as an operational status, not an indication that the connection has failed.
* Required production variables are `OLIST_CLIENT_ID`, `OLIST_CLIENT_SECRET`, `OLIST_REDIRECT_URI`, `OLIST_FRONTEND_URL`, `OLIST_TOKEN_ENCRYPTION_KEY`, and `OLIST_SYNC_ENABLED=true`. The encryption key must be stable (a 32-byte base64 key or 64-character hex key): changing it makes already stored tokens unreadable and requires reconnection. * Required production variables are `OLIST_CLIENT_ID`, `OLIST_CLIENT_SECRET`, `OLIST_REDIRECT_URI`, `OLIST_FRONTEND_URL`, `OLIST_TOKEN_ENCRYPTION_KEY`, and `OLIST_SYNC_ENABLED=true`. The encryption key must be stable (a 32-byte base64 key or 64-character hex key): changing it makes already stored tokens unreadable and requires reconnection.
* The OAuth callback route is `GET /api/olist/oauth/callback`; `OLIST_REDIRECT_URI` must be exactly that publicly reachable backend URL. After authorization Graphs redirects to `/#/admin/olist` on `OLIST_FRONTEND_URL`. * The OAuth callback route is `GET /api/olist/oauth/callback`; `OLIST_REDIRECT_URI` must be exactly that publicly reachable backend URL. After authorization Graphs redirects to `/#/admin/olist` on `OLIST_FRONTEND_URL`.

View File

@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { verifySuperAdmin } = require('../auth'); const { verifyToken } = require('../auth');
const { const {
completeAuthorization, completeAuthorization,
createAuthorizationUrl, createAuthorizationUrl,
@@ -13,7 +13,7 @@ const { exportMissingProductionOrderDataCsv, importFinalizedProductionOrderCsv,
const router = express.Router(); const router = express.Router();
router.get('/olist/status', verifySuperAdmin, async (req, res, next) => { router.get('/olist/status', verifyToken, async (req, res, next) => {
try { try {
res.json(await getOlistStatus({ res.json(await getOlistStatus({
runsPage: req.query.runsPage, runsPage: req.query.runsPage,
@@ -24,7 +24,7 @@ router.get('/olist/status', verifySuperAdmin, async (req, res, next) => {
} }
}); });
router.post('/olist/authorization-url', verifySuperAdmin, async (req, res, next) => { router.post('/olist/authorization-url', verifyToken, async (req, res, next) => {
try { try {
res.json({ url: await createAuthorizationUrl() }); res.json({ url: await createAuthorizationUrl() });
} catch (error) { } catch (error) {
@@ -32,7 +32,7 @@ router.post('/olist/authorization-url', verifySuperAdmin, async (req, res, next)
} }
}); });
router.post('/olist/sync', verifySuperAdmin, async (req, res, next) => { router.post('/olist/sync', verifyToken, async (req, res, next) => {
try { try {
const result = await startOlistSync({ const result = await startOlistSync({
trigger: 'manual', trigger: 'manual',
@@ -44,7 +44,7 @@ router.post('/olist/sync', verifySuperAdmin, async (req, res, next) => {
} }
}); });
router.post('/olist/sync/stop', verifySuperAdmin, async (req, res, next) => { router.post('/olist/sync/stop', verifyToken, async (req, res, next) => {
try { try {
res.status(202).json(await requestOlistSyncStop()); res.status(202).json(await requestOlistSyncStop());
} catch (error) { } catch (error) {
@@ -52,7 +52,7 @@ router.post('/olist/sync/stop', verifySuperAdmin, async (req, res, next) => {
} }
}); });
router.get('/olist/production-order-imports', verifySuperAdmin, async (req, res, next) => { router.get('/olist/production-order-imports', verifyToken, async (req, res, next) => {
try { try {
res.json(await listProductionOrderImportData({ res.json(await listProductionOrderImportData({
runsPage: req.query.runsPage, runsPage: req.query.runsPage,
@@ -63,7 +63,7 @@ router.get('/olist/production-order-imports', verifySuperAdmin, async (req, res,
} }
}); });
router.get('/olist/production-order-imports/pending-csv', verifySuperAdmin, async (req, res, next) => { router.get('/olist/production-order-imports/pending-csv', verifyToken, async (req, res, next) => {
try { try {
res.json(await exportMissingProductionOrderDataCsv()); res.json(await exportMissingProductionOrderDataCsv());
} catch (error) { } catch (error) {
@@ -71,7 +71,7 @@ router.get('/olist/production-order-imports/pending-csv', verifySuperAdmin, asyn
} }
}); });
router.post('/olist/production-order-imports', verifySuperAdmin, async (req, res, next) => { router.post('/olist/production-order-imports', verifyToken, async (req, res, next) => {
try { try {
res.status(201).json(await importFinalizedProductionOrderCsv(req.body || {})); res.status(201).json(await importFinalizedProductionOrderCsv(req.body || {}));
} catch (error) { } catch (error) {
@@ -79,7 +79,7 @@ router.post('/olist/production-order-imports', verifySuperAdmin, async (req, res
} }
}); });
router.get('/olist/runs/:runId', verifySuperAdmin, async (req, res, next) => { router.get('/olist/runs/:runId', verifyToken, async (req, res, next) => {
try { try {
res.json(await getOlistRunDetails(req.params.runId, { res.json(await getOlistRunDetails(req.params.runId, {
eventsPage: req.query.eventsPage, eventsPage: req.query.eventsPage,

View File

@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { verifySuperAdmin, verifyToken } = require('../auth'); const { verifyToken } = require('../auth');
const { createUser, deleteUser, listActiveOperationalUsers, listUsers, updateUser } = require('../services/userService'); const { createUser, deleteUser, listActiveOperationalUsers, listUsers, updateUser } = require('../services/userService');
const router = express.Router(); const router = express.Router();
@@ -12,7 +12,7 @@ router.get('/users/operational', verifyToken, async (req, res, next) => {
} }
}); });
router.get('/users', verifySuperAdmin, async (req, res, next) => { router.get('/users', verifyToken, async (req, res, next) => {
try { try {
const users = await listUsers(); const users = await listUsers();
res.json({ users }); res.json({ users });
@@ -21,7 +21,7 @@ router.get('/users', verifySuperAdmin, async (req, res, next) => {
} }
}); });
router.post('/users', verifySuperAdmin, async (req, res, next) => { router.post('/users', verifyToken, async (req, res, next) => {
try { try {
const { name, email, password } = req.body || {}; const { name, email, password } = req.body || {};
const { user, password: userPassword, generatedPassword } = await createUser({ name, email, password }); const { user, password: userPassword, generatedPassword } = await createUser({ name, email, password });
@@ -40,7 +40,7 @@ router.post('/users', verifySuperAdmin, async (req, res, next) => {
} }
}); });
router.patch('/users/:id', verifySuperAdmin, async (req, res, next) => { router.patch('/users/:id', verifyToken, async (req, res, next) => {
try { try {
const user = await updateUser(req.params.id, req.body || {}); const user = await updateUser(req.params.id, req.body || {});
res.json({ user }); res.json({ user });
@@ -49,7 +49,7 @@ router.patch('/users/:id', verifySuperAdmin, async (req, res, next) => {
} }
}); });
router.delete('/users/:id', verifySuperAdmin, async (req, res, next) => { router.delete('/users/:id', verifyToken, async (req, res, next) => {
try { try {
await deleteUser(req.params.id); await deleteUser(req.params.id);
res.status(204).send(); res.status(204).send();

View File

@@ -560,8 +560,50 @@ const getOrderById = async (id, client = pool) => {
) )
ORDER BY component.id ORDER BY component.id
) )
FROM production_order_components component FROM (
WHERE component.production_order_id = po.id SELECT
order_component.id,
order_component.component_tiny_id,
order_component.component_sku,
order_component.component_name,
order_component.quantity_per_unit,
order_component.total_quantity,
order_component.unit
FROM production_order_components order_component
WHERE order_component.production_order_id = po.id
UNION ALL
SELECT
-composition_component.id AS id,
composition_component.component_tiny_id,
composition_component.component_sku,
composition_component.component_name,
composition_component.quantity_per_unit,
composition_component.quantity_per_unit * po.quantity AS total_quantity,
composition_component.unit
FROM product_composition_components composition_component
JOIN product_compositions composition
ON composition.id = (
SELECT matched_composition.id
FROM product_compositions matched_composition
WHERE matched_composition.source IN ('manual', 'tiny_olist_v3')
AND (
matched_composition.finished_tiny_product_id = po.product_sku
OR matched_composition.finished_tiny_product_id = po.tiny_id
OR matched_composition.external_source_id = po.product_sku
OR matched_composition.external_source_id = po.tiny_id
OR matched_composition.finished_product_sku = UPPER(po.product_sku)
)
ORDER BY CASE matched_composition.source WHEN 'manual' THEN 0 ELSE 1 END, matched_composition.id
LIMIT 1
)
WHERE NOT EXISTS (
SELECT 1
FROM production_order_components order_component
WHERE order_component.production_order_id = po.id
)
) component
), ),
'[]'::json '[]'::json
) as components, ) as components,
@@ -700,8 +742,50 @@ const baseProductionOrderSelect = `
) )
ORDER BY component.id ORDER BY component.id
) )
FROM production_order_components component FROM (
WHERE component.production_order_id = po.id SELECT
order_component.id,
order_component.component_tiny_id,
order_component.component_sku,
order_component.component_name,
order_component.quantity_per_unit,
order_component.total_quantity,
order_component.unit
FROM production_order_components order_component
WHERE order_component.production_order_id = po.id
UNION ALL
SELECT
-composition_component.id AS id,
composition_component.component_tiny_id,
composition_component.component_sku,
composition_component.component_name,
composition_component.quantity_per_unit,
composition_component.quantity_per_unit * po.quantity AS total_quantity,
composition_component.unit
FROM product_composition_components composition_component
JOIN product_compositions composition
ON composition.id = (
SELECT matched_composition.id
FROM product_compositions matched_composition
WHERE matched_composition.source IN ('manual', 'tiny_olist_v3')
AND (
matched_composition.finished_tiny_product_id = po.product_sku
OR matched_composition.finished_tiny_product_id = po.tiny_id
OR matched_composition.external_source_id = po.product_sku
OR matched_composition.external_source_id = po.tiny_id
OR matched_composition.finished_product_sku = UPPER(po.product_sku)
)
ORDER BY CASE matched_composition.source WHEN 'manual' THEN 0 ELSE 1 END, matched_composition.id
LIMIT 1
)
WHERE NOT EXISTS (
SELECT 1
FROM production_order_components order_component
WHERE order_component.production_order_id = po.id
)
) component
), ),
'[]'::json '[]'::json
) as components, ) as components,
@@ -800,6 +884,10 @@ const listProductionOrders = async (filters = {}) => {
)`); )`);
} }
if (filters.activeOnly === 'true') {
where.push(`po.status IN ('open', 'cutting', 'sewing', 'review', 'in_progress')`);
}
const result = await pool.query(` const result = await pool.query(`
${baseProductionOrderSelect} ${baseProductionOrderSelect}
${where.length ? `WHERE ${where.join(' AND ')}` : ''} ${where.length ? `WHERE ${where.join(' AND ')}` : ''}

View File

@@ -62,8 +62,7 @@ const listUsers = async () => {
}; };
// This deliberately exposes only the identity needed to assign operational // This deliberately exposes only the identity needed to assign operational
// responsibility. It is available to signed-in Graphs users, unlike the full // responsibility. It is available to every signed-in Graphs user.
// user-management list which remains super-admin only.
const listActiveOperationalUsers = async () => { const listActiveOperationalUsers = async () => {
const result = await pool.query( const result = await pool.query(
`SELECT id, name `SELECT id, name

View File

@@ -2,7 +2,7 @@ import React, { Suspense } from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import Layout from './components/Layout'; import Layout from './components/Layout';
import { isAuthenticated, isSuperAdmin } from './dataService'; import { isAuthenticated } from './dataService';
const Dashboard = React.lazy(() => import('./pages/Dashboard')); const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Products = React.lazy(() => import('./pages/Products')); const Products = React.lazy(() => import('./pages/Products'));
@@ -34,13 +34,6 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
return children; return children;
} }
function SuperAdminRoute({ children }: { children: React.ReactNode }) {
if (!isSuperAdmin()) {
return <Navigate to="/graph" replace />;
}
return children;
}
const RouteFallback = () => ( const RouteFallback = () => (
<div className="flex min-h-screen items-center justify-center bg-dark-bg text-brand-primary"> <div className="flex min-h-screen items-center justify-center bg-dark-bg text-brand-primary">
<Loader2 className="h-8 w-8 animate-spin" /> <Loader2 className="h-8 w-8 animate-spin" />
@@ -75,8 +68,8 @@ function App() {
<Route path="rfm" element={<Rfm />} /> <Route path="rfm" element={<Rfm />} />
<Route path="campaigns" element={<Campaigns />} /> <Route path="campaigns" element={<Campaigns />} />
<Route path="registrations" element={<Registrations />} /> <Route path="registrations" element={<Registrations />} />
<Route path="admin/users" element={<SuperAdminRoute><AdminUsers /></SuperAdminRoute>} /> <Route path="admin/users" element={<AdminUsers />} />
<Route path="admin/olist" element={<SuperAdminRoute><OlistSync /></SuperAdminRoute>} /> <Route path="admin/olist" element={<OlistSync />} />
</Route> </Route>
</Routes> </Routes>
</Suspense> </Suspense>

View File

@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom'; import { Outlet, Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, Tags, Boxes, ClipboardList, Menu, X, BriefcaseBusiness, Radio } from 'lucide-react'; import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, Tags, Boxes, ClipboardList, Menu, X, BriefcaseBusiness, Radio } from 'lucide-react';
import type { DateRange, OrderData } from '../types'; import type { DateRange, OrderData } from '../types';
import { isSuperAdmin, logout } from '../dataService'; import { logout } from '../dataService';
import { rangeForLastDays } from '../dateRanges'; import { rangeForLastDays } from '../dateRanges';
const emptyOrdersData: OrderData[] = []; const emptyOrdersData: OrderData[] = [];
@@ -61,12 +61,10 @@ const Layout = () => {
}; };
const showSidebarLabels = !isSidebarCollapsed || isMobileNavOpen; const showSidebarLabels = !isSidebarCollapsed || isMobileNavOpen;
const adminNavigation = isSuperAdmin() const adminNavigation = [
? [ { name: 'Olist', href: '/admin/olist', icon: Radio },
{ name: 'Olist', href: '/admin/olist', icon: Radio }, { name: 'Usuários', href: '/admin/users', icon: Shield }
{ name: 'Usuários', href: '/admin/users', icon: Shield } ];
]
: [];
const navigationSections = [ const navigationSections = [
{ {
label: 'Painel', label: 'Painel',
@@ -92,7 +90,7 @@ const Layout = () => {
{ name: 'Campanhas', href: '/campaigns', icon: Megaphone }, { name: 'Campanhas', href: '/campaigns', icon: Megaphone },
], ],
}, },
...(adminNavigation.length ? [{ label: 'Super admin', items: adminNavigation }] : []), { label: 'Administração', items: adminNavigation },
]; ];
return ( return (

View File

@@ -128,7 +128,7 @@ export const fetchStock = async (): Promise<StockData[]> => {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
} }
}); });
if (response.status === 401 || response.status === 403) { if (response.status === 401) {
logout(); logout();
return []; return [];
} }
@@ -171,7 +171,7 @@ export const fetchData = async (): Promise<OrderData[]> => {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
} }
}); });
if (response.status === 401 || response.status === 403) { if (response.status === 401) {
logout(); logout();
return []; return [];
} }
@@ -193,7 +193,10 @@ const authFetch = async (path: string, options: RequestInit = {}): Promise<Respo
} }
}); });
if (response.status === 401 || response.status === 403) { // A 403 means the signed-in user lacks permission for this specific
// resource. Keep their session intact so protected optional data cannot
// force a logout from an otherwise accessible page.
if (response.status === 401) {
logout(); logout();
} }
@@ -219,12 +222,13 @@ export const downloadDatabaseDiagnostic = async (): Promise<void> => {
export const fetchProductionOrders = async ( export const fetchProductionOrders = async (
dateRange: DateRange, dateRange: DateRange,
filters?: { search?: string }, filters?: { search?: string; activeOnly?: boolean },
options?: CacheOptions options?: CacheOptions
): Promise<ProductionOrderSummary> => { ): Promise<ProductionOrderSummary> => {
const params = buildDateRangeParams(dateRange); const params = buildDateRangeParams(dateRange);
const search = filters?.search?.trim(); const search = filters?.search?.trim();
if (search) params.set('search', search); if (search) params.set('search', search);
if (filters?.activeOnly) params.set('activeOnly', 'true');
const path = `/production-orders?${params.toString()}`; const path = `/production-orders?${params.toString()}`;
return getCachedAnalytics(path, async () => { return getCachedAnalytics(path, async () => {

View File

@@ -157,6 +157,7 @@ const Cutting = () => {
const [settingsSection, setSettingsSection] = useState<SettingsSection>('rules'); const [settingsSection, setSettingsSection] = useState<SettingsSection>('rules');
const [cuttingSettings, setCuttingSettings] = useState<CuttingSettings>(loadCuttingSettings); const [cuttingSettings, setCuttingSettings] = useState<CuttingSettings>(loadCuttingSettings);
const [yieldRecommendations, setYieldRecommendations] = useState<ProductionYieldRecommendation[]>([]); const [yieldRecommendations, setYieldRecommendations] = useState<ProductionYieldRecommendation[]>([]);
const [hasLoadedYieldRecommendations, setHasLoadedYieldRecommendations] = useState(false);
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle'); const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false); const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false);
const [correctionIssueFilter, setCorrectionIssueFilter] = useState<CorrectionIssueFilter>('all'); const [correctionIssueFilter, setCorrectionIssueFilter] = useState<CorrectionIssueFilter>('all');
@@ -214,14 +215,22 @@ const Cutting = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!isSettingsOpen || settingsSection !== 'rules' || hasLoadedYieldRecommendations) return;
let isMounted = true; let isMounted = true;
void fetchProductionOrderImportData().then(data => { void fetchProductionOrderImportData().then(data => {
if (isMounted) setYieldRecommendations(data.recommendations); if (isMounted) {
setYieldRecommendations(data.recommendations);
setHasLoadedYieldRecommendations(true);
}
}).catch(() => { }).catch(() => {
if (isMounted) setYieldRecommendations([]); if (isMounted) {
setYieldRecommendations([]);
setHasLoadedYieldRecommendations(true);
}
}); });
return () => { isMounted = false; }; return () => { isMounted = false; };
}, []); }, [hasLoadedYieldRecommendations, isSettingsOpen, settingsSection]);
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
@@ -230,7 +239,7 @@ const Cutting = () => {
setIsLoading(true); setIsLoading(true);
const [productData, productionOrderData, compositionData, stockData, supplySummary] = await Promise.all([ const [productData, productionOrderData, compositionData, stockData, supplySummary] = await Promise.all([
fetchProductAnalytics(dateRange), fetchProductAnalytics(dateRange),
fetchProductionOrders(allProductionOrdersRange), fetchProductionOrders(allProductionOrdersRange, { activeOnly: true }),
fetchProductCompositions(), fetchProductCompositions(),
fetchStock(), fetchStock(),
fetchSupplySummary() fetchSupplySummary()
@@ -785,7 +794,7 @@ const Cutting = () => {
source: 'cut_plan' source: 'cut_plan'
}); });
const refreshedOrders = await fetchProductionOrders(allProductionOrdersRange, undefined, { force: true }); const refreshedOrders = await fetchProductionOrders(allProductionOrdersRange, { activeOnly: true }, { force: true });
setProductionOrders(refreshedOrders.orders); setProductionOrders(refreshedOrders.orders);
setGenerationMessage(`${result.plan?.number || 'Plano'} criado com ${result.created.length} OPs${result.skipped.length ? ` · ${result.skipped.length} já existiam abertas` : ''}.`); setGenerationMessage(`${result.plan?.number || 'Plano'} criado com ${result.created.length} OPs${result.skipped.length ? ` · ${result.skipped.length} já existiam abertas` : ''}.`);
setIsOrderPreviewOpen(false); setIsOrderPreviewOpen(false);

View File

@@ -2786,7 +2786,7 @@ const MaterialTransformationsScreen = () => {
<div className={`${panelClassName} p-5`}><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Saída esperada</p><p className="mt-2 text-3xl font-bold text-emerald-300">{formatNumber(expectedOutputKg)} kg</p><p className="mt-1 text-xs font-semibold text-dark-muted">após a perda prevista</p></div> <div className={`${panelClassName} p-5`}><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Saída esperada</p><p className="mt-2 text-3xl font-bold text-emerald-300">{formatNumber(expectedOutputKg)} kg</p><p className="mt-1 text-xs font-semibold text-dark-muted">após a perda prevista</p></div>
<div className={`${panelClassName} p-5`}><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Perda prevista</p><p className="mt-2 text-3xl font-bold text-amber-300">{formatNumber(expectedLossKg)} kg</p><p className="mt-1 text-xs font-semibold text-dark-muted">a confirmar na execução e no recebimento</p></div> <div className={`${panelClassName} p-5`}><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Perda prevista</p><p className="mt-2 text-3xl font-bold text-amber-300">{formatNumber(expectedLossKg)} kg</p><p className="mt-1 text-xs font-semibold text-dark-muted">a confirmar na execução e no recebimento</p></div>
</div> </div>
<div className="grid grid-cols-1 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_390px]"> <div className="grid grid-cols-1 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_460px]">
<section className={`${panelClassName} overflow-hidden`}> <section className={`${panelClassName} overflow-hidden`}>
<div className="border-b border-dark-border p-5"><h2 className="text-base font-bold text-dark-text">Fila de transformação</h2><p className="mt-1 text-sm font-semibold text-dark-muted">Cada linha registra uma etapa; use o mesmo lote para encadear tecelagem, tinturaria e acabamento.</p></div> <div className="border-b border-dark-border p-5"><h2 className="text-base font-bold text-dark-text">Fila de transformação</h2><p className="mt-1 text-sm font-semibold text-dark-muted">Cada linha registra uma etapa; use o mesmo lote para encadear tecelagem, tinturaria e acabamento.</p></div>
{isLoading ? <div className={emptyStateClassName}><Factory className="h-8 w-8 text-brand-primary" /><h3 className="text-base font-bold text-dark-text">Carregando transformações...</h3></div> {isLoading ? <div className={emptyStateClassName}><Factory className="h-8 w-8 text-brand-primary" /><h3 className="text-base font-bold text-dark-text">Carregando transformações...</h3></div>