Compare commits
19 Commits
9960fb7986
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d99f2387b | ||
|
|
4122e21dc2 | ||
|
|
c5edaec7d3 | ||
|
|
9f4db67f4f | ||
|
|
b4b21fb21b | ||
|
|
ba136b9d78 | ||
|
|
5a23817f4c | ||
|
|
35f3938f1c | ||
|
|
56ce81850b | ||
|
|
6b3f84a666 | ||
|
|
bc6d53b840 | ||
|
|
5e742feb60 | ||
|
|
9592b2eb7d | ||
|
|
314288147a | ||
|
|
acf542f2d5 | ||
|
|
7ae230ed2e | ||
|
|
c77bc752ae | ||
|
|
4b66e91442 | ||
|
|
a938925904 |
@@ -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.
|
||||
|
||||
### 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.
|
||||
* 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`.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express');
|
||||
const { verifySuperAdmin } = require('../auth');
|
||||
const { verifyToken } = require('../auth');
|
||||
const {
|
||||
completeAuthorization,
|
||||
createAuthorizationUrl,
|
||||
@@ -13,7 +13,7 @@ const { exportMissingProductionOrderDataCsv, importFinalizedProductionOrderCsv,
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/olist/status', verifySuperAdmin, async (req, res, next) => {
|
||||
router.get('/olist/status', verifyToken, async (req, res, next) => {
|
||||
try {
|
||||
res.json(await getOlistStatus({
|
||||
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 {
|
||||
res.json({ url: await createAuthorizationUrl() });
|
||||
} 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 {
|
||||
const result = await startOlistSync({
|
||||
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 {
|
||||
res.status(202).json(await requestOlistSyncStop());
|
||||
} 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 {
|
||||
res.json(await listProductionOrderImportData({
|
||||
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 {
|
||||
res.json(await exportMissingProductionOrderDataCsv());
|
||||
} 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 {
|
||||
res.status(201).json(await importFinalizedProductionOrderCsv(req.body || {}));
|
||||
} 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 {
|
||||
res.json(await getOlistRunDetails(req.params.runId, {
|
||||
eventsPage: req.query.eventsPage,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express');
|
||||
const { verifySuperAdmin, verifyToken } = require('../auth');
|
||||
const { verifyToken } = require('../auth');
|
||||
const { createUser, deleteUser, listActiveOperationalUsers, listUsers, updateUser } = require('../services/userService');
|
||||
|
||||
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 {
|
||||
const users = await listUsers();
|
||||
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 {
|
||||
const { name, email, password } = req.body || {};
|
||||
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 {
|
||||
const user = await updateUser(req.params.id, req.body || {});
|
||||
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 {
|
||||
await deleteUser(req.params.id);
|
||||
res.status(204).send();
|
||||
|
||||
@@ -560,8 +560,50 @@ const getOrderById = async (id, client = pool) => {
|
||||
)
|
||||
ORDER BY component.id
|
||||
)
|
||||
FROM production_order_components component
|
||||
WHERE component.production_order_id = po.id
|
||||
FROM (
|
||||
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
|
||||
) as components,
|
||||
@@ -700,8 +742,50 @@ const baseProductionOrderSelect = `
|
||||
)
|
||||
ORDER BY component.id
|
||||
)
|
||||
FROM production_order_components component
|
||||
WHERE component.production_order_id = po.id
|
||||
FROM (
|
||||
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
|
||||
) 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(`
|
||||
${baseProductionOrderSelect}
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
@@ -1195,7 +1283,9 @@ const isSellableComposition = (order) => {
|
||||
};
|
||||
|
||||
const getTinyProductIdFromCompositionReference = (tinyId) => (
|
||||
tinyId.slice(TINY_OLIST_V3_COMPOSITION_PREFIX.length)
|
||||
tinyId.startsWith(TINY_OLIST_V3_COMPOSITION_PREFIX)
|
||||
? tinyId.slice(TINY_OLIST_V3_COMPOSITION_PREFIX.length)
|
||||
: tinyId
|
||||
);
|
||||
|
||||
const normalizeCompositionComponents = (components) => {
|
||||
@@ -1291,6 +1381,7 @@ const findProductCompositionsBySource = async (source, productId, client = pool)
|
||||
AND (
|
||||
$2 = ''
|
||||
OR composition.finished_tiny_product_id = $2
|
||||
OR composition.external_source_id = $2
|
||||
OR composition.finished_product_sku = UPPER($2)
|
||||
)
|
||||
ORDER BY composition.finished_product_sku, composition.finished_product_description;
|
||||
|
||||
@@ -62,8 +62,7 @@ const listUsers = async () => {
|
||||
};
|
||||
|
||||
// This deliberately exposes only the identity needed to assign operational
|
||||
// responsibility. It is available to signed-in Graphs users, unlike the full
|
||||
// user-management list which remains super-admin only.
|
||||
// responsibility. It is available to every signed-in Graphs user.
|
||||
const listActiveOperationalUsers = async () => {
|
||||
const result = await pool.query(
|
||||
`SELECT id, name
|
||||
|
||||
@@ -208,7 +208,7 @@ const withFakeDatabase = async (run) => {
|
||||
if (normalizedSql.includes('FROM product_compositions composition')) {
|
||||
const compositions = state.compositions.filter(item => (
|
||||
item.source === params[0]
|
||||
&& (params[1] === '' || item.finished_tiny_product_id === params[1] || item.finished_product_sku === String(params[1]).toUpperCase())
|
||||
&& (params[1] === '' || item.finished_tiny_product_id === params[1] || item.external_source_id === params[1] || item.finished_product_sku === String(params[1]).toUpperCase())
|
||||
));
|
||||
return {
|
||||
rows: compositions.map(composition => ({
|
||||
@@ -342,6 +342,36 @@ test('product composition detail lookup returns stored components', async () =>
|
||||
});
|
||||
});
|
||||
|
||||
test('product composition detail lookup supports an exported composition linked by external product ID', async () => {
|
||||
await withFakeDatabase(async () => {
|
||||
const response = await invokeHandler(tinyCompositionImportHandler, {
|
||||
body: {
|
||||
compositions: [{
|
||||
externalSourceId: '919483307',
|
||||
finishedProductSku: 'BLCS.PTO.G',
|
||||
finishedProductDescription: 'BASE LISA CAMISETA COR PRETO TAMANHO - G',
|
||||
finishedProductUnit: 'UN',
|
||||
finishedTinyProductId: '',
|
||||
components: [{
|
||||
componentSku: 'ETIQ.T.G',
|
||||
componentName: 'ETIQUETA DE TAMANHO G',
|
||||
quantityPerUnit: 1,
|
||||
unit: 'UN'
|
||||
}]
|
||||
}]
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 201);
|
||||
|
||||
const detail = await invokeHandler(productCompositionHandler, { params: { productId: '919483307' } });
|
||||
assert.equal(detail.statusCode, 200);
|
||||
assert.equal(detail.body.composition.finishedProductSku, 'BLCS.PTO.G');
|
||||
assert.equal(detail.body.composition.finishedTinyProductId, '919483307');
|
||||
assert.equal(detail.body.composition.components.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('product composition export endpoint returns every stored composition', async () => {
|
||||
await withFakeDatabase(async () => {
|
||||
await invokeHandler(tinySyncHandler, { body: compositionPayload() });
|
||||
|
||||
13
src/App.tsx
13
src/App.tsx
@@ -2,7 +2,7 @@ import React, { Suspense } from 'react';
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import Layout from './components/Layout';
|
||||
import { isAuthenticated, isSuperAdmin } from './dataService';
|
||||
import { isAuthenticated } from './dataService';
|
||||
|
||||
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
|
||||
const Products = React.lazy(() => import('./pages/Products'));
|
||||
@@ -34,13 +34,6 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
|
||||
function SuperAdminRoute({ children }: { children: React.ReactNode }) {
|
||||
if (!isSuperAdmin()) {
|
||||
return <Navigate to="/graph" replace />;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
const RouteFallback = () => (
|
||||
<div className="flex min-h-screen items-center justify-center bg-dark-bg text-brand-primary">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
@@ -75,8 +68,8 @@ function App() {
|
||||
<Route path="rfm" element={<Rfm />} />
|
||||
<Route path="campaigns" element={<Campaigns />} />
|
||||
<Route path="registrations" element={<Registrations />} />
|
||||
<Route path="admin/users" element={<SuperAdminRoute><AdminUsers /></SuperAdminRoute>} />
|
||||
<Route path="admin/olist" element={<SuperAdminRoute><OlistSync /></SuperAdminRoute>} />
|
||||
<Route path="admin/users" element={<AdminUsers />} />
|
||||
<Route path="admin/olist" element={<OlistSync />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
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 type { DateRange, OrderData } from '../types';
|
||||
import { isSuperAdmin, logout } from '../dataService';
|
||||
import { logout } from '../dataService';
|
||||
import { rangeForLastDays } from '../dateRanges';
|
||||
|
||||
const emptyOrdersData: OrderData[] = [];
|
||||
@@ -61,12 +61,10 @@ const Layout = () => {
|
||||
};
|
||||
const showSidebarLabels = !isSidebarCollapsed || isMobileNavOpen;
|
||||
|
||||
const adminNavigation = isSuperAdmin()
|
||||
? [
|
||||
{ name: 'Olist', href: '/admin/olist', icon: Radio },
|
||||
{ name: 'Usuários', href: '/admin/users', icon: Shield }
|
||||
]
|
||||
: [];
|
||||
const adminNavigation = [
|
||||
{ name: 'Olist', href: '/admin/olist', icon: Radio },
|
||||
{ name: 'Usuários', href: '/admin/users', icon: Shield }
|
||||
];
|
||||
const navigationSections = [
|
||||
{
|
||||
label: 'Painel',
|
||||
@@ -92,7 +90,7 @@ const Layout = () => {
|
||||
{ name: 'Campanhas', href: '/campaigns', icon: Megaphone },
|
||||
],
|
||||
},
|
||||
...(adminNavigation.length ? [{ label: 'Super admin', items: adminNavigation }] : []),
|
||||
{ label: 'Administração', items: adminNavigation },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -128,7 +128,7 @@ export const fetchStock = async (): Promise<StockData[]> => {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
if (response.status === 401) {
|
||||
logout();
|
||||
return [];
|
||||
}
|
||||
@@ -171,7 +171,7 @@ export const fetchData = async (): Promise<OrderData[]> => {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
if (response.status === 401) {
|
||||
logout();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -219,12 +222,13 @@ export const downloadDatabaseDiagnostic = async (): Promise<void> => {
|
||||
|
||||
export const fetchProductionOrders = async (
|
||||
dateRange: DateRange,
|
||||
filters?: { search?: string },
|
||||
filters?: { search?: string; activeOnly?: boolean },
|
||||
options?: CacheOptions
|
||||
): Promise<ProductionOrderSummary> => {
|
||||
const params = buildDateRangeParams(dateRange);
|
||||
const search = filters?.search?.trim();
|
||||
if (search) params.set('search', search);
|
||||
if (filters?.activeOnly) params.set('activeOnly', 'true');
|
||||
|
||||
const path = `/production-orders?${params.toString()}`;
|
||||
return getCachedAnalytics(path, async () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ type CorrectionIssueFilter = CutProductIssue | 'all';
|
||||
type SettingsSection = 'rules' | 'corrections';
|
||||
type CuttingView = 'plan' | 'families' | 'issues';
|
||||
type PreviewStep = 'selection' | 'confirmation';
|
||||
type PreviewReviewFilter = 'all' | 'needs_review' | 'complete';
|
||||
type MaterialReadiness = { status: 'ready' | 'material_ready' | 'blocked' | 'missing'; blockers: string[] };
|
||||
type CutMaterialRequirement = { key: string; label: string; unit: string; quantity: number };
|
||||
|
||||
@@ -156,6 +157,7 @@ const Cutting = () => {
|
||||
const [settingsSection, setSettingsSection] = useState<SettingsSection>('rules');
|
||||
const [cuttingSettings, setCuttingSettings] = useState<CuttingSettings>(loadCuttingSettings);
|
||||
const [yieldRecommendations, setYieldRecommendations] = useState<ProductionYieldRecommendation[]>([]);
|
||||
const [hasLoadedYieldRecommendations, setHasLoadedYieldRecommendations] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
|
||||
const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false);
|
||||
const [correctionIssueFilter, setCorrectionIssueFilter] = useState<CorrectionIssueFilter>('all');
|
||||
@@ -167,9 +169,11 @@ const Cutting = () => {
|
||||
const [previewStep, setPreviewStep] = useState<PreviewStep>('selection');
|
||||
const [previewPage, setPreviewPage] = useState(1);
|
||||
const [previewItemsPerPage, setPreviewItemsPerPage] = useState(10);
|
||||
const [confirmationPage, setConfirmationPage] = useState(1);
|
||||
const [confirmationItemsPerPage, setConfirmationItemsPerPage] = useState(10);
|
||||
const [previewReviewFilter, setPreviewReviewFilter] = useState<PreviewReviewFilter>('all');
|
||||
const [selectedPreviewRowIds, setSelectedPreviewRowIds] = useState<string[]>([]);
|
||||
const [previewQuantityOverrides, setPreviewQuantityOverrides] = useState<Record<string, string>>({});
|
||||
const [previewLayers, setPreviewLayers] = useState<Record<string, string>>({});
|
||||
const [lotSelections, setLotSelections] = useState<Record<string, { lotId: string; quantity: string }>>({});
|
||||
const [productionPlanForm, setProductionPlanForm] = useState({ name: '', responsibleUserId: null as number | null, responsibleName: '', expectedDate: '', notes: '' });
|
||||
const [operationalUsers, setOperationalUsers] = useState<OperationalUser[]>([]);
|
||||
@@ -211,14 +215,22 @@ const Cutting = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSettingsOpen || settingsSection !== 'rules' || hasLoadedYieldRecommendations) return;
|
||||
|
||||
let isMounted = true;
|
||||
void fetchProductionOrderImportData().then(data => {
|
||||
if (isMounted) setYieldRecommendations(data.recommendations);
|
||||
if (isMounted) {
|
||||
setYieldRecommendations(data.recommendations);
|
||||
setHasLoadedYieldRecommendations(true);
|
||||
}
|
||||
}).catch(() => {
|
||||
if (isMounted) setYieldRecommendations([]);
|
||||
if (isMounted) {
|
||||
setYieldRecommendations([]);
|
||||
setHasLoadedYieldRecommendations(true);
|
||||
}
|
||||
});
|
||||
return () => { isMounted = false; };
|
||||
}, []);
|
||||
}, [hasLoadedYieldRecommendations, isSettingsOpen, settingsSection]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
@@ -227,7 +239,7 @@ const Cutting = () => {
|
||||
setIsLoading(true);
|
||||
const [productData, productionOrderData, compositionData, stockData, supplySummary] = await Promise.all([
|
||||
fetchProductAnalytics(dateRange),
|
||||
fetchProductionOrders(allProductionOrdersRange),
|
||||
fetchProductionOrders(allProductionOrdersRange, { activeOnly: true }),
|
||||
fetchProductCompositions(),
|
||||
fetchStock(),
|
||||
fetchSupplySummary()
|
||||
@@ -402,6 +414,10 @@ const Cutting = () => {
|
||||
const selectedIds = new Set(selectedPreviewRowIds);
|
||||
return rowsAvailableForOrder.filter(row => selectedIds.has(row.id));
|
||||
}, [rowsAvailableForOrder, selectedPreviewRowIds]);
|
||||
const confirmationTotalPages = Math.ceil(selectedPreviewRows.length / confirmationItemsPerPage);
|
||||
const safeConfirmationPage = Math.min(confirmationPage, confirmationTotalPages || 1);
|
||||
const confirmationStartIndex = (safeConfirmationPage - 1) * confirmationItemsPerPage;
|
||||
const paginatedConfirmationRows = selectedPreviewRows.slice(confirmationStartIndex, confirmationStartIndex + confirmationItemsPerPage);
|
||||
const orderPreviewTotalUnits = useMemo(() => (
|
||||
selectedPreviewRows.reduce((total, row) => total + getPreviewQuantity(row), 0)
|
||||
), [getPreviewQuantity, selectedPreviewRows]);
|
||||
@@ -449,13 +465,17 @@ const Cutting = () => {
|
||||
const previewRowsWithIssues = useMemo(() => (
|
||||
selectedPreviewRows.filter(row => row.issues.length).length
|
||||
), [selectedPreviewRows]);
|
||||
const previewTotalPages = Math.ceil(rowsAvailableForOrder.length / previewItemsPerPage);
|
||||
const previewRows = useMemo(() => rowsAvailableForOrder.filter(row => {
|
||||
if (previewReviewFilter === 'needs_review') return row.issues.length > 0;
|
||||
if (previewReviewFilter === 'complete') return row.issues.length === 0;
|
||||
return true;
|
||||
}), [previewReviewFilter, rowsAvailableForOrder]);
|
||||
const previewTotalPages = Math.ceil(previewRows.length / previewItemsPerPage);
|
||||
const safePreviewPage = Math.min(previewPage, previewTotalPages || 1);
|
||||
const previewStartIndex = (safePreviewPage - 1) * previewItemsPerPage;
|
||||
const paginatedPreviewRows = rowsAvailableForOrder.slice(previewStartIndex, previewStartIndex + previewItemsPerPage);
|
||||
const paginatedPreviewRows = previewRows.slice(previewStartIndex, previewStartIndex + previewItemsPerPage);
|
||||
const selectedPreviewRowIdSet = useMemo(() => new Set(selectedPreviewRowIds), [selectedPreviewRowIds]);
|
||||
const areAllPreviewRowsSelected = rowsAvailableForOrder.length > 0 && selectedPreviewRows.length === rowsAvailableForOrder.length;
|
||||
const areAllPagePreviewRowsSelected = paginatedPreviewRows.length > 0 && paginatedPreviewRows.every(row => selectedPreviewRowIdSet.has(row.id));
|
||||
|
||||
const totalPages = Math.ceil(filteredRows.length / itemsPerPage);
|
||||
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||
@@ -681,9 +701,10 @@ const Cutting = () => {
|
||||
|
||||
setGenerationMessage('');
|
||||
setPreviewPage(1);
|
||||
setConfirmationPage(1);
|
||||
setPreviewReviewFilter('all');
|
||||
setSelectedPreviewRowIds(rowsAvailableForOrder.map(row => row.id));
|
||||
setPreviewQuantityOverrides({});
|
||||
setPreviewLayers({});
|
||||
setLotSelections({});
|
||||
setProductionPlanForm({ name: cutPlanReference, responsibleUserId: null, responsibleName: '', expectedDate: '', notes: '' });
|
||||
setIsPlanDetailsOpen(false);
|
||||
@@ -694,10 +715,6 @@ const Cutting = () => {
|
||||
setIsOrderPreviewOpen(true);
|
||||
};
|
||||
|
||||
const setPreviewSelection = (rows: CutPlanSkuRow[]) => {
|
||||
setSelectedPreviewRowIds(rows.map(row => row.id));
|
||||
};
|
||||
|
||||
const togglePreviewRow = (productId: string) => {
|
||||
setSelectedPreviewRowIds(current => (
|
||||
current.includes(productId)
|
||||
@@ -706,16 +723,11 @@ const Cutting = () => {
|
||||
));
|
||||
};
|
||||
|
||||
const togglePreviewPage = () => {
|
||||
const pageIds = paginatedPreviewRows.map(row => row.id);
|
||||
const toggleAllPreviewRows = () => {
|
||||
setSelectedPreviewRowIds(current => {
|
||||
const currentIds = new Set(current);
|
||||
if (pageIds.every(id => currentIds.has(id))) {
|
||||
pageIds.forEach(id => currentIds.delete(id));
|
||||
} else {
|
||||
pageIds.forEach(id => currentIds.add(id));
|
||||
}
|
||||
return Array.from(currentIds);
|
||||
const allRowsSelected = rowsAvailableForOrder.length > 0 && rowsAvailableForOrder.every(row => currentIds.has(row.id));
|
||||
return allRowsSelected ? [] : rowsAvailableForOrder.map(row => row.id);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -754,7 +766,6 @@ const Cutting = () => {
|
||||
markers: [
|
||||
{ label: row.family.materialLabel, color: '#38bdf8' },
|
||||
...(row.color ? [{ label: row.color, color: '#52DFA0' }] : []),
|
||||
...(Number(previewLayers[row.id]) > 0 ? [{ label: `${previewLayers[row.id]} camadas`, color: '#c084fc' }] : []),
|
||||
{ label: 'Material confirmado', color: '#52DFA0' },
|
||||
...(row.issues.length ? [{ label: 'Dados pendentes', color: '#f59e0b' }] : [])
|
||||
],
|
||||
@@ -770,7 +781,6 @@ const Cutting = () => {
|
||||
stock: row.stock,
|
||||
openProductionQuantity: row.openProductionQuantity,
|
||||
availableQuantity: row.availableQuantity,
|
||||
plannedLayers: Number(previewLayers[row.id]) || null,
|
||||
manuallyAdjustedQuantity: getPreviewQuantity(row) !== row.suggestedCutQuantity,
|
||||
issues: row.issues
|
||||
}
|
||||
@@ -784,7 +794,7 @@ const Cutting = () => {
|
||||
source: 'cut_plan'
|
||||
});
|
||||
|
||||
const refreshedOrders = await fetchProductionOrders(allProductionOrdersRange, undefined, { force: true });
|
||||
const refreshedOrders = await fetchProductionOrders(allProductionOrdersRange, { activeOnly: true }, { force: true });
|
||||
setProductionOrders(refreshedOrders.orders);
|
||||
setGenerationMessage(`${result.plan?.number || 'Plano'} criado com ${result.created.length} OPs${result.skipped.length ? ` · ${result.skipped.length} já existiam abertas` : ''}.`);
|
||||
setIsOrderPreviewOpen(false);
|
||||
@@ -1003,8 +1013,8 @@ const Cutting = () => {
|
||||
{previewRowsWithIssues > 0 && <span className="text-xs font-bold text-amber-300">{formatNumber(previewRowsWithIssues)} para revisar</span>}
|
||||
</div>
|
||||
<div className="mt-3 divide-y divide-dark-border overflow-hidden rounded-lg border border-dark-border bg-dark-card">
|
||||
{selectedPreviewRows.map(row => (
|
||||
<div key={row.id} className="grid gap-3 px-3 py-3 lg:grid-cols-[minmax(0,1fr)_100px_100px] lg:items-center">
|
||||
{paginatedConfirmationRows.map(row => (
|
||||
<div key={row.id} className="grid gap-3 px-3 py-3 lg:grid-cols-[minmax(0,1fr)_100px] lg:items-center">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold text-dark-text" title={row.name}>{row.name}</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
@@ -1017,23 +1027,46 @@ const Cutting = () => {
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Cortar
|
||||
<input inputMode="numeric" value={previewQuantityOverrides[row.id] ?? ''} onChange={(event) => setPreviewQuantityOverrides(current => ({ ...current, [row.id]: event.target.value }))} className="mt-1 h-9 w-full rounded-lg border border-dark-border bg-dark-input px-2 text-right text-sm font-bold text-brand-primary outline-none focus:border-brand-primary" placeholder={formatNumber(row.suggestedCutQuantity)} aria-label={`Quantidade planejada para ${row.name}`} />
|
||||
</label>
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Camadas
|
||||
<input inputMode="numeric" value={previewLayers[row.id] ?? ''} onChange={(event) => setPreviewLayers(current => ({ ...current, [row.id]: event.target.value }))} className="mt-1 h-9 w-full rounded-lg border border-dark-border bg-dark-input px-2 text-right text-sm font-bold text-dark-text outline-none focus:border-brand-primary" placeholder="Opcional" aria-label={`Camadas planejadas para ${row.name}`} />
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-xs font-semibold text-dark-muted">A quantidade sugerida vem da cobertura. Ajustes manuais ficam marcados na OP; as camadas orientam a preparação, e o resultado real é confirmado durante a execução.</p>
|
||||
<PaginationControls
|
||||
totalItems={selectedPreviewRows.length}
|
||||
currentPage={safeConfirmationPage}
|
||||
totalPages={confirmationTotalPages}
|
||||
pageSize={confirmationItemsPerPage}
|
||||
pageSizeOptions={[10, 20, 50]}
|
||||
itemLabel="OPs"
|
||||
pageSizeLabel="por página"
|
||||
startIndex={confirmationStartIndex}
|
||||
endIndex={Math.min(confirmationStartIndex + confirmationItemsPerPage, selectedPreviewRows.length)}
|
||||
onPageChange={setConfirmationPage}
|
||||
onPageSizeChange={(pageSize) => { setConfirmationItemsPerPage(pageSize); setConfirmationPage(1); }}
|
||||
className="mt-3 border-t border-dark-border pt-3"
|
||||
/>
|
||||
<p className="mt-3 text-xs font-semibold text-dark-muted">A quantidade sugerida vem da cobertura. Ajustes manuais ficam marcados na OP, e o resultado real é confirmado durante a execução.</p>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{previewStep === 'selection' && <>
|
||||
<div className="flex flex-col gap-3 border-b border-dark-border bg-dark-input/40 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs font-semibold text-dark-muted">Todos os SKUs abaixo estão aptos para gerar OP. A seleção permanece ao trocar de página.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => setPreviewSelection(rowsAvailableForOrder)} className="rounded-lg border border-dark-border bg-dark-card px-3 py-1.5 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer">{areAllPreviewRowsSelected ? 'Todos selecionados' : 'Selecionar todos'}</button>
|
||||
<button type="button" onClick={() => setPreviewSelection([])} disabled={!selectedPreviewRows.length} className="rounded-lg border border-dark-border bg-dark-card px-3 py-1.5 text-xs font-bold text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer">Limpar</button>
|
||||
</div>
|
||||
<p className="text-xs font-semibold text-dark-muted">A seleção é mantida em todas as páginas. Use a caixa no cabeçalho para marcar ou desmarcar todos os SKUs aptos.</p>
|
||||
<label className="flex shrink-0 items-center gap-2 text-xs font-bold text-dark-muted">
|
||||
Revisão de dados
|
||||
<select
|
||||
value={previewReviewFilter}
|
||||
onChange={(event) => {
|
||||
setPreviewReviewFilter(event.target.value as PreviewReviewFilter);
|
||||
setPreviewPage(1);
|
||||
}}
|
||||
className="h-9 rounded-lg border border-dark-border bg-dark-card px-2.5 text-xs font-bold text-dark-text outline-none transition-colors focus:border-brand-primary cursor-pointer"
|
||||
aria-label="Filtrar produtos por revisão de dados"
|
||||
>
|
||||
<option value="all">Todos</option>
|
||||
<option value="needs_review">Precisam de revisão</option>
|
||||
<option value="complete">Sem revisão pendente</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto">
|
||||
@@ -1048,7 +1081,7 @@ const Cutting = () => {
|
||||
</colgroup>
|
||||
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-center"><input type="checkbox" checked={areAllPagePreviewRowsSelected} onChange={togglePreviewPage} aria-label="Selecionar OPs desta página" className="h-4 w-4 accent-brand-primary cursor-pointer" /></th>
|
||||
<th className="px-4 py-3 text-center"><input type="checkbox" checked={areAllPreviewRowsSelected} onChange={toggleAllPreviewRows} aria-label="Selecionar ou desmarcar todas as OPs aptas" className="h-4 w-4 accent-brand-primary cursor-pointer" /></th>
|
||||
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Produto</th>
|
||||
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Cortar agora</th>
|
||||
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Demanda</th>
|
||||
@@ -1075,7 +1108,20 @@ const Cutting = () => {
|
||||
<td className="px-4 py-3 font-bold whitespace-nowrap text-brand-primary">{formatNumber(row.suggestedCutQuantity)} un.</td>
|
||||
<td className="px-4 py-3 font-bold whitespace-nowrap text-dark-text">{formatNumber(row.projectedDemand, 1)} un.</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap"><div className="font-bold text-dark-text">{formatNumber(row.availableQuantity)} un.</div>{row.openProductionQuantity ? <div className="mt-1 text-[10px] font-semibold text-dark-muted">{formatNumber(row.openProductionQuantity)} em OP</div> : null}</td>
|
||||
<td className="px-4 py-3">{renderIssueBadge(row)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{renderIssueBadge(row)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openQuickCutEdit(row)}
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"
|
||||
title={`Revisar dados do SKU ${row.id}`}
|
||||
aria-label={`Revisar dados do SKU ${row.id}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -1095,7 +1141,7 @@ const Cutting = () => {
|
||||
</section>}
|
||||
|
||||
{previewStep === 'selection' && <PaginationControls
|
||||
totalItems={rowsAvailableForOrder.length}
|
||||
totalItems={previewRows.length}
|
||||
currentPage={safePreviewPage}
|
||||
totalPages={previewTotalPages}
|
||||
pageSize={previewItemsPerPage}
|
||||
@@ -1103,7 +1149,7 @@ const Cutting = () => {
|
||||
itemLabel="OPs"
|
||||
pageSizeLabel="por página"
|
||||
startIndex={previewStartIndex}
|
||||
endIndex={Math.min(previewStartIndex + previewItemsPerPage, rowsAvailableForOrder.length)}
|
||||
endIndex={Math.min(previewStartIndex + previewItemsPerPage, previewRows.length)}
|
||||
onPageChange={setPreviewPage}
|
||||
onPageSizeChange={(pageSize) => { setPreviewItemsPerPage(pageSize); setPreviewPage(1); }}
|
||||
className="border-t border-dark-border px-4 py-3"
|
||||
@@ -1125,7 +1171,7 @@ const Cutting = () => {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => previewStep === 'selection' ? setPreviewStep('confirmation') : void generateProductionOrders()}
|
||||
onClick={() => previewStep === 'selection' ? (setConfirmationPage(1), setPreviewStep('confirmation')) : void generateProductionOrders()}
|
||||
disabled={isGeneratingOrders || !selectedPreviewRows.length}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-4 py-2 text-sm font-bold text-brand-primary transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Fragment, type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import { AlertTriangle, ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Clock3, Download, PackageCheck, PlayCircle, Printer, Scissors, Search, Undo2, XCircle } from 'lucide-react';
|
||||
import { AlertTriangle, ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Clock3, Download, PackageCheck, PlayCircle, Printer, Scissors, Search, Undo2, X, XCircle } from 'lucide-react';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
@@ -88,6 +88,31 @@ const getStatusStyle = (status: ProductionOrderStatus, fallbackLabel: string) =>
|
||||
}
|
||||
);
|
||||
|
||||
const getNextStage = (status: ProductionOrderStatus): { status: Exclude<ProductionOrderStatusTab, 'all'>; label: string; description: string } | null => {
|
||||
switch (status) {
|
||||
case 'open': return { status: 'cutting', label: 'Iniciar corte', description: 'Libera o apontamento de corte e a baixa física de materiais.' };
|
||||
case 'cutting': return { status: 'sewing', label: 'Avançar para costura', description: 'Use quando o corte desta OP estiver concluído.' };
|
||||
case 'sewing': return { status: 'review', label: 'Enviar para revisão', description: 'Use quando as peças estiverem prontas para a conferência final.' };
|
||||
case 'review': return { status: 'finished', label: 'Finalizar OP', description: 'Confirme a quantidade aprovada antes de encerrar a ordem.' };
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getPreviousStage = (status: ProductionOrderStatus): { status: Exclude<ProductionOrderStatusTab, 'all'>; label: string; description: string } | null => {
|
||||
switch (status) {
|
||||
case 'cutting': return { status: 'open', label: 'Voltar para em aberto', description: 'Use quando a produção ainda não foi iniciada.' };
|
||||
case 'sewing': return { status: 'cutting', label: 'Voltar para corte', description: 'Use quando esta OP precisa retornar à etapa de corte.' };
|
||||
case 'review': return { status: 'sewing', label: 'Voltar para costura', description: 'Use quando a revisão identificar um ajuste na montagem.' };
|
||||
case 'finished': return { status: 'review', label: 'Reabrir em revisão', description: 'Use para corrigir uma OP finalizada antes de encerrá-la novamente.' };
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const canReturnToCutPlan = (order: ProductionOrderItem) => (
|
||||
!['finished', 'canceled'].includes(order.status)
|
||||
&& (order.integrationStatus || '').toLowerCase() === 'local'
|
||||
);
|
||||
|
||||
const ProductionOrdersSkeleton = () => (
|
||||
<div className="space-y-6" aria-label="Carregando ordens de produção">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
@@ -216,8 +241,11 @@ const ProductionOrders = () => {
|
||||
const openCount = summary.counts.open || 0;
|
||||
const progressCount = (summary.counts.cutting || 0) + (summary.counts.sewing || 0) + (summary.counts.review || 0);
|
||||
const finishedCount = summary.counts.finished || 0;
|
||||
const totalQuantity = summary.orders.reduce((total, order) => total + order.quantity, 0);
|
||||
const finishedQuantity = summary.orders.filter(order => order.status === 'finished').reduce((total, order) => total + (order.finishedQuantity ?? order.quantity), 0);
|
||||
const selectedOrder = summary.orders.find(order => order.id === selectedOrderId) || null;
|
||||
const selectedOrderNextStage = selectedOrder ? getNextStage(selectedOrder.status) : null;
|
||||
const selectedOrderPreviousStage = selectedOrder ? getPreviousStage(selectedOrder.status) : null;
|
||||
const selectedOrderCanReturnToCutPlan = Boolean(selectedOrder && canReturnToCutPlan(selectedOrder));
|
||||
|
||||
const handleManualRefresh = () => {
|
||||
void loadProductionOrders({ force: true });
|
||||
@@ -242,7 +270,7 @@ const ProductionOrders = () => {
|
||||
};
|
||||
|
||||
const returnOrderToCutPlan = async (order: ProductionOrderItem) => {
|
||||
if (order.status !== 'open' || (order.integrationStatus || '').toLowerCase() !== 'local') return;
|
||||
if (!canReturnToCutPlan(order)) return;
|
||||
const orderNumber = order.number || `#${order.id}`;
|
||||
if (!window.confirm(`Devolver a OP ${orderNumber} ao Plano de Corte?\n\nA OP será cancelada, preservando o histórico. A necessidade voltará para o Plano de Corte e poderá ser gerada novamente quando você decidir.`)) return;
|
||||
|
||||
@@ -307,7 +335,6 @@ const ProductionOrders = () => {
|
||||
rejectedQuantity: parseOptionalQuantity('rejectedQuantity'),
|
||||
finishedQuantity: parseOptionalQuantity('finishedQuantity'),
|
||||
qualityNotes: String(form.get('qualityNotes') || '').trim(),
|
||||
cutLayers: parseOptionalQuantity('cutLayers'),
|
||||
markerReference: String(form.get('markerReference') || '').trim(),
|
||||
});
|
||||
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, { force: true });
|
||||
@@ -380,18 +407,10 @@ const ProductionOrders = () => {
|
||||
};
|
||||
|
||||
const renderOrderDetails = (order: ProductionOrderItem) => {
|
||||
const canReturnToCutPlan = order.status === 'open' && (order.integrationStatus || '').toLowerCase() === 'local';
|
||||
const canRecordOperationalData = ['cutting', 'sewing', 'review'].includes(order.status);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 rounded-xl border border-dark-border bg-dark-input/25 p-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Detalhes da OP</p>
|
||||
<p className="mt-1 text-sm font-bold text-dark-text">{order.number || `OP #${order.id}`} · {order.productDescription}</p>
|
||||
</div>
|
||||
{canReturnToCutPlan && <button type="button" onClick={() => void returnOrderToCutPlan(order)} disabled={busyStatusOrderId === order.id} className="inline-flex h-9 shrink-0 items-center justify-center gap-2 rounded-lg border border-amber-400/30 bg-amber-400/10 px-3 text-xs font-bold text-amber-200 transition-colors hover:border-amber-400/60 hover:bg-amber-400/15 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"><Undo2 className="h-4 w-4" />Devolver ao Plano de Corte</button>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Quantidade</p><p className="mt-1 text-lg font-bold text-dark-text">{formatQuantity(order.quantity)} <span className="text-xs text-dark-muted">{order.unit}</span></p></div>
|
||||
<div className="relative rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Status</p><button type="button" onClick={() => setStatusEditorOrderId(current => current === order.id ? null : order.id)} aria-expanded={statusEditorOrderId === order.id} className={`mt-2 inline-flex cursor-pointer items-center gap-2 rounded-lg border px-2.5 py-1 text-xs font-bold ${getStatusStyle(order.status, order.statusLabel).className}`}><span className={`h-2 w-2 rounded-full ${getStatusStyle(order.status, order.statusLabel).dotClass}`} />{getStatusStyle(order.status, order.statusLabel).label}{statusEditorOrderId === order.id ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}</button>{statusEditorOrderId === order.id && <div className="absolute left-0 top-full z-20 mt-2 w-64 overflow-hidden rounded-xl border border-dark-border bg-dark-card shadow-xl"><p className="border-b border-dark-border px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-dark-muted">Alterar status</p>{editableStatusOptions.map(option => { const style = getStatusStyle(option.value, option.label); const isCurrent = order.status === option.value; const Icon = option.value === 'finished' ? CheckCircle2 : option.value === 'canceled' ? XCircle : option.value === 'cutting' ? Scissors : option.value === 'sewing' ? PlayCircle : option.value === 'review' ? ClipboardList : Clock3; return <button key={option.value} type="button" disabled={isCurrent || busyStatusOrderId === order.id} onClick={() => void handleStatusChange(order, option.value)} className={`flex w-full cursor-pointer items-center gap-3 px-3 py-2.5 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${isCurrent ? style.className : 'hover:bg-dark-input/70'}`}><Icon className="h-4 w-4 shrink-0" /><span className="text-xs font-bold text-dark-text">{option.label}{isCurrent && ' · Atual'}</span></button>; })}</div>}</div>
|
||||
@@ -399,6 +418,8 @@ const ProductionOrders = () => {
|
||||
<div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Previsão</p><p className="mt-1 text-sm font-bold text-dark-text">{formatDate(order.expectedDate)}</p></div>
|
||||
</div>
|
||||
|
||||
{order.status === 'finished' && <section><h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Resultado registrado</h3><div className="mt-3 grid gap-3 sm:grid-cols-3"><div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Cortado</p><p className="mt-1 text-lg font-bold text-dark-text">{formatQuantity(order.actualCutQuantity ?? 0)} <span className="text-xs text-dark-muted">{order.unit}</span></p></div><div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Perdas</p><p className="mt-1 text-lg font-bold text-amber-200">{formatQuantity(order.rejectedQuantity || 0)} <span className="text-xs text-dark-muted">{order.unit}</span></p></div><div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Aprovado</p><p className="mt-1 text-lg font-bold text-emerald-300">{formatQuantity(order.finishedQuantity ?? 0)} <span className="text-xs text-dark-muted">{order.unit}</span></p></div></div>{(order.markerReference || order.qualityNotes) && <div className="mt-3 rounded-xl border border-dark-border bg-dark-card p-3 text-xs font-semibold text-dark-muted">{order.markerReference && <p>Referência do risco: <span className="font-bold text-dark-text">{order.markerReference}</span></p>}{order.qualityNotes && <p className={order.markerReference ? 'mt-2' : ''}>{order.qualityNotes}</p>}</div>}</section>}
|
||||
|
||||
<section>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Produto e marcadores</h3>
|
||||
<div className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4">
|
||||
@@ -408,34 +429,27 @@ const ProductionOrders = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Execução e qualidade</h3>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">Registre o corte real, perdas e a quantidade aprovada antes de finalizar.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => printProductionSheet(order)} className="inline-flex h-9 items-center gap-2 rounded-lg border border-dark-border bg-dark-card px-3 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"><Printer className="h-4 w-4" />Imprimir ficha</button>
|
||||
</div>
|
||||
{canRecordOperationalData && <section>
|
||||
<div><h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Execução e qualidade</h3><p className="mt-1 text-xs font-semibold text-dark-muted">Registre o corte real, perdas e a quantidade aprovada antes de avançar a OP.</p></div>
|
||||
<form onSubmit={(event) => void handleExecutionSave(event, order)} className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<label className="text-xs font-bold text-dark-muted">Cortado real<input name="actualCutQuantity" inputMode="decimal" defaultValue={order.actualCutQuantity ?? ''} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder={formatQuantity(order.quantity)} /></label>
|
||||
<label className="text-xs font-bold text-dark-muted">Perdas / defeitos<input name="rejectedQuantity" inputMode="decimal" defaultValue={order.rejectedQuantity || ''} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="0" /></label>
|
||||
<label className="text-xs font-bold text-dark-muted">Aprovado final<input name="finishedQuantity" inputMode="decimal" defaultValue={order.finishedQuantity ?? ''} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Após revisão" /></label>
|
||||
<label className="text-xs font-bold text-dark-muted">Camadas cortadas<input name="cutLayers" inputMode="numeric" defaultValue={order.cutLayers ?? ''} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: 12" /></label>
|
||||
<label className="text-xs font-bold text-dark-muted sm:col-span-2">Referência do risco<input name="markerReference" defaultValue={order.markerReference} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Código ou arquivo do risco" /></label>
|
||||
<label className="text-xs font-bold text-dark-muted sm:col-span-3">Motivo de perda / observação<textarea name="qualityNotes" defaultValue={order.qualityNotes} className="mt-1.5 min-h-20 w-full rounded-lg border border-dark-border bg-dark-input px-3 py-2.5 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: 4 peças com mancha de tinta; 2 recuperadas na revisão." /></label>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3"><p className="text-xs font-semibold text-dark-muted">Planejado: {formatQuantity(order.quantity)} {order.unit}</p><button type="submit" disabled={busyExecutionOrderId === order.id} className="inline-flex h-10 items-center gap-2 rounded-lg border border-brand-primary/35 bg-brand-primary/10 px-4 text-sm font-bold text-brand-primary transition-colors hover:bg-brand-primary/20 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"><CheckCircle2 className="h-4 w-4" />{busyExecutionOrderId === order.id ? 'Salvando...' : 'Salvar execução'}</button></div>
|
||||
{executionMessage && <p className="mt-3 rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">{executionMessage}</p>}
|
||||
</form>
|
||||
</section>
|
||||
</section>}
|
||||
|
||||
<section>
|
||||
{(canRecordOperationalData || order.defects.length > 0) && <section>
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Defeitos e origem</h3>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">Registre cada ocorrência para acompanhar perdas por posto, lote e tipo de defeito.</p>
|
||||
</div>
|
||||
<form onSubmit={(event) => void handleDefectSave(event, order)} className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4">
|
||||
{canRecordOperationalData && <form onSubmit={(event) => void handleDefectSave(event, order)} className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<label className="text-xs font-bold text-dark-muted">Posto de trabalho<select required name="workstationId" defaultValue="" className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary"><option value="">Selecione...</option>{workstations.map(workstation => <option key={workstation.id} value={workstation.id}>{workstation.name}</option>)}</select></label>
|
||||
<label className="text-xs font-bold text-dark-muted">Tipo de defeito<input required name="defectType" className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: furo, mancha" /></label>
|
||||
@@ -446,10 +460,10 @@ const ProductionOrders = () => {
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3"><p className="text-xs font-semibold text-dark-muted">{workstations.length ? `${workstations.length} postos ativos disponíveis.` : 'Nenhum posto de trabalho ativo cadastrado.'}</p><button type="submit" disabled={busyDefectOrderId === order.id || !workstations.length} className="inline-flex h-10 items-center gap-2 rounded-lg border border-amber-400/35 bg-amber-400/10 px-4 text-sm font-bold text-amber-200 transition-colors hover:bg-amber-400/20 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"><AlertTriangle className="h-4 w-4" />{busyDefectOrderId === order.id ? 'Registrando...' : 'Registrar defeito'}</button></div>
|
||||
{defectMessage && <p className="mt-3 rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">{defectMessage}</p>}
|
||||
</form>
|
||||
</form>}
|
||||
{order.defects.length > 0 && <div className="mt-3 overflow-hidden rounded-xl border border-dark-border bg-dark-card"><div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 border-b border-dark-border bg-dark-input/40 px-4 py-2.5 text-[10px] font-bold uppercase tracking-widest text-dark-muted"><span>Ocorrência</span><span>Quantidade</span></div><div className="divide-y divide-dark-border">{order.defects.map(defect => <div key={defect.id} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-4 py-3"><div><p className="text-sm font-bold text-dark-text">{defect.defectType}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{[defect.workstationName || 'Sem posto', defect.origin, defect.lotCode ? `Lote ${defect.lotCode}` : ''].filter(Boolean).join(' · ')}</p>{defect.notes && <p className="mt-1 text-xs font-medium text-dark-muted">{defect.notes}</p>}</div><p className="text-sm font-bold text-amber-200">{formatQuantity(defect.quantity)} {order.unit}</p></div>)}</div></div>}
|
||||
{!order.defects.length && <p className="mt-3 rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-semibold text-dark-muted">Nenhum defeito registrado nesta OP.</p>}
|
||||
</section>
|
||||
{!order.defects.length && canRecordOperationalData && <p className="mt-3 rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-semibold text-dark-muted">Nenhum defeito registrado nesta OP.</p>}
|
||||
</section>}
|
||||
|
||||
<section>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Composição</h3>
|
||||
@@ -461,10 +475,10 @@ const ProductionOrders = () => {
|
||||
<div className="mt-3 overflow-hidden rounded-xl border border-dark-border"><table className="w-full text-xs"><thead className="bg-dark-header text-dark-muted"><tr><th className="px-3 py-2.5 text-left font-bold">Lote</th><th className="px-3 py-2.5 text-left font-bold">Material</th><th className="px-3 py-2.5 text-right font-bold">Reservado</th><th className="px-3 py-2.5 text-right font-bold">Consumido</th></tr></thead><tbody className="divide-y divide-dark-border">{order.lotReservations.map(reservation => <tr key={reservation.id}><td className="px-3 py-3"><p className="font-bold text-dark-text">#{reservation.lotId}</p><p className="mt-0.5 text-[10px] font-semibold text-dark-muted">{reservation.lotSupplier || 'Sem fornecedor'}</p></td><td className="px-3 py-3"><p className="font-bold text-dark-text">{reservation.lotProduct}</p><p className="mt-0.5 font-mono text-[10px] font-semibold text-dark-muted">{reservation.materialKey}</p></td><td className="px-3 py-3 text-right font-bold text-brand-primary">{formatQuantity(reservation.quantity)} {reservation.lotUnit}</td><td className="px-3 py-3 text-right font-semibold text-dark-muted">{formatQuantity(reservation.consumedQuantity)} {reservation.lotUnit}</td></tr>)}</tbody></table></div>
|
||||
</section>}
|
||||
|
||||
<section>
|
||||
{order.status === 'cutting' && <section>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Baixa de material</h3>
|
||||
{order.status === 'cutting' ? <form onSubmit={handleProductionExit} className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4"><p className="text-sm font-semibold text-dark-muted">Registre uma saída somente quando o material for realmente consumido pela OP.</p><div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2"><label className="text-xs font-bold text-dark-muted">Lote de material<select value={exitForm.lotId} onChange={(event) => setExitForm(current => ({ ...current, lotId: event.target.value }))} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary"><option value="">Selecione...</option>{supplyLots.map(lot => <option key={lot.id} value={lot.id}>#{lot.id} · {lot.product} · {formatQuantity(lot.quantity)} {lot.unit}</option>)}</select></label><label className="text-xs font-bold text-dark-muted">Quantidade<input inputMode="decimal" value={exitForm.quantity} onChange={(event) => setExitForm(current => ({ ...current, quantity: event.target.value }))} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="kg" /></label><label className="sm:col-span-2 text-xs font-bold text-dark-muted">Motivo (opcional)<input value={exitForm.reason} onChange={(event) => setExitForm(current => ({ ...current, reason: event.target.value }))} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: corte do pedido" /></label></div>{selectedLot && <p className="mt-3 text-xs font-bold text-dark-muted">Saldo do lote #{selectedLot.id}: {formatQuantity(selectedLot.quantity)} {selectedLot.unit}</p>}<button type="submit" disabled={isSupplyBusy || !supplyLots.length || !exitForm.lotId || !exitForm.quantity} className="mt-4 inline-flex h-11 items-center gap-2 rounded-lg border border-brand-primary/35 bg-brand-primary/10 px-4 text-sm font-bold text-brand-primary transition-colors hover:bg-brand-primary/20 disabled:cursor-not-allowed disabled:opacity-50"><PackageCheck className="h-4 w-4" />{isSupplyBusy ? 'Registrando...' : 'Registrar baixa'}</button>{supplyMessage && <p className="mt-3 rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">{supplyMessage}</p>}</form> : <div className="mt-3 rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-semibold text-dark-muted">Altere a OP para <span className="font-bold text-amber-200">Em corte</span> antes de registrar a baixa de materiais.</div>}
|
||||
</section>
|
||||
</section>}
|
||||
|
||||
{(order.steps.length > 0 || order.notes || order.supplier || order.lotCode || order.rollQuantity || order.fabricKg || order.ribKg || order.yieldPiecesPerKg) && <section><h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Informações adicionais</h3><div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">{(order.supplier || order.lotCode || order.rollQuantity || order.fabricKg || order.ribKg || order.yieldPiecesPerKg) && <dl className="grid grid-cols-2 gap-3 rounded-xl border border-dark-border bg-dark-card p-4 text-xs sm:col-span-2">{order.supplier && <div><dt className="font-bold text-dark-muted">Fornecedor</dt><dd className="mt-1 font-bold text-dark-text">{order.supplier}</dd></div>}{order.lotCode && <div><dt className="font-bold text-dark-muted">Lote</dt><dd className="mt-1 font-bold text-dark-text">{order.lotCode}</dd></div>}{order.rollQuantity !== null && <div><dt className="font-bold text-dark-muted">Rolos</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.rollQuantity)}</dd></div>}{order.fabricKg !== null && <div><dt className="font-bold text-dark-muted">Malha</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.fabricKg)} kg</dd></div>}{order.ribKg !== null && <div><dt className="font-bold text-dark-muted">Ribana</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.ribKg)} kg</dd></div>}{order.yieldPiecesPerKg !== null && <div><dt className="font-bold text-dark-muted">Rendimento</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.yieldPiecesPerKg)} un./kg</dd></div>}</dl>}{order.steps.length > 0 && <div className="rounded-xl border border-dark-border bg-dark-card p-4 sm:col-span-2"><p className="text-xs font-bold text-dark-text">Etapas</p><div className="mt-3 divide-y divide-dark-border">{order.steps.map(step => <div key={step.id} className="flex items-center gap-3 py-2 text-xs"><span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: step.color || 'var(--color-brand-primary)' }} /><span className="font-bold text-dark-text">{step.name}</span><span className="ml-auto font-semibold text-dark-muted">{formatDate(step.startDate)} - {formatDate(step.endDate)}</span></div>)}</div></div>}{order.notes && <p className="whitespace-pre-wrap rounded-xl border border-dark-border bg-dark-card p-4 text-sm font-semibold text-dark-muted sm:col-span-2">{order.notes}</p>}</div></section>}
|
||||
</div>
|
||||
@@ -480,7 +494,7 @@ const ProductionOrders = () => {
|
||||
Suprimentos
|
||||
</Link>
|
||||
<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 locais geradas pelo plano de corte.</p>
|
||||
<p className="mt-2 text-dark-muted font-medium">Acompanhe a fila de produção e o histórico das ordens.</p>
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2"><Link to="/production-plans" className="inline-flex items-center gap-2 text-sm font-bold text-brand-primary transition-colors hover:text-brand-primary/80"><ClipboardList className="h-4 w-4" />Ver planos de produção</Link><Link to="/production-quality" className="inline-flex items-center gap-2 text-sm font-bold text-brand-primary transition-colors hover:text-brand-primary/80"><AlertTriangle className="h-4 w-4" />Ver qualidade</Link></div>
|
||||
</div>
|
||||
<DateRangePicker
|
||||
@@ -543,7 +557,7 @@ const ProductionOrders = () => {
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Finalizadas</p>
|
||||
<p className="mt-2 text-3xl font-bold text-emerald-300">{finishedCount}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatQuantity(totalQuantity)} un. no período</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatQuantity(finishedQuantity)} un. aprovadas</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-emerald-400/25 bg-emerald-400/10 p-3 text-emerald-300">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
@@ -660,8 +674,7 @@ const ProductionOrders = () => {
|
||||
{paginatedOrders.map((order: ProductionOrderItem) => {
|
||||
const statusStyle = getStatusStyle(order.status, order.statusLabel);
|
||||
return (
|
||||
<Fragment key={order.id}>
|
||||
<tr className="transition-colors hover:bg-dark-input/50">
|
||||
<tr key={order.id} className="transition-colors hover:bg-dark-input/50">
|
||||
<td className="px-6 py-3 font-mono text-xs font-bold text-dark-text">{order.number || '-'}</td>
|
||||
<td className="px-6 py-3">
|
||||
<div className="font-bold text-dark-text">{order.productDescription}</div>
|
||||
@@ -685,22 +698,20 @@ const ProductionOrders = () => {
|
||||
<td className="px-6 py-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedOrderId(current => current === order.id ? null : order.id);
|
||||
onClick={() => {
|
||||
setSelectedOrderId(order.id);
|
||||
setSupplyMessage('');
|
||||
setExitForm({ lotId: '', quantity: '', reason: '' });
|
||||
}}
|
||||
title={selectedOrderId === order.id ? 'Fechar detalhes da OP' : 'Abrir detalhes da OP'}
|
||||
aria-label={`${selectedOrderId === order.id ? 'Fechar' : 'Abrir'} detalhes da OP ${order.number || order.id}`}
|
||||
aria-expanded={selectedOrderId === order.id}
|
||||
className={`inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border transition-colors hover:border-brand-primary hover:text-brand-primary ${selectedOrderId === order.id ? 'border-brand-primary bg-brand-primary/10 text-brand-primary' : 'border-dark-border bg-dark-input text-dark-muted'}`}
|
||||
title="Abrir ficha da OP"
|
||||
aria-label={`Abrir ficha da OP ${order.number || order.id}`}
|
||||
className="inline-flex h-9 items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-input px-3 text-xs font-bold text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"
|
||||
>
|
||||
{selectedOrderId === order.id ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
Abrir OP
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{selectedOrderId === order.id && <tr className="bg-dark-input/20"><td colSpan={5} className="px-6 pb-5 pt-3">{renderOrderDetails(order)}</td></tr>}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
@@ -719,7 +730,7 @@ const ProductionOrders = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PaginationControls
|
||||
{totalPages > 1 && <PaginationControls
|
||||
totalItems={filteredOrders.length}
|
||||
currentPage={safeCurrentPage}
|
||||
totalPages={totalPages}
|
||||
@@ -734,13 +745,28 @@ const ProductionOrders = () => {
|
||||
setItemsPerPage(pageSize);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
/>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedOrder && <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label={`Ficha da OP ${selectedOrder.number || selectedOrder.id}`} onClick={() => busyStatusOrderId !== selectedOrder.id && setSelectedOrderId(null)}>
|
||||
<section className="flex max-h-[calc(100vh-2rem)] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl" onClick={(event) => event.stopPropagation()}>
|
||||
<header className="flex items-start justify-between gap-4 border-b border-dark-border px-5 py-4">
|
||||
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><span className="font-mono text-xs font-bold text-brand-primary">{selectedOrder.number || `OP #${selectedOrder.id}`}</span><span className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[10px] font-bold ${getStatusStyle(selectedOrder.status, selectedOrder.statusLabel).className}`}><span className={`h-1.5 w-1.5 rounded-full ${getStatusStyle(selectedOrder.status, selectedOrder.statusLabel).dotClass}`} />{getStatusStyle(selectedOrder.status, selectedOrder.statusLabel).label}</span></div><h2 className="mt-2 truncate text-lg font-bold text-dark-text" title={selectedOrder.productDescription}>{selectedOrder.productDescription}</h2><div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-semibold text-dark-muted"><span className="font-mono">{selectedOrder.productSku || 'Sem SKU'}</span>{selectedOrder.productionPlanNumber && <Link to="/production-plans" className="text-brand-primary hover:underline">{selectedOrder.productionPlanNumber}</Link>}<span>Previsão: {formatDate(selectedOrder.expectedDate)}</span></div></div>
|
||||
<div className="flex shrink-0 items-center gap-2"><button type="button" onClick={() => printProductionSheet(selectedOrder)} title="Imprimir ficha" aria-label="Imprimir ficha" className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"><Printer className="h-4 w-4" /></button><button type="button" onClick={() => setSelectedOrderId(null)} disabled={busyStatusOrderId === selectedOrder.id} title="Fechar" aria-label="Fechar ficha da OP" className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><X className="h-4 w-4" /></button></div>
|
||||
</header>
|
||||
<div className="overflow-y-auto 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>}
|
||||
{selectedOrderNextStage || selectedOrderPreviousStage ? <section className="flex flex-col gap-3 rounded-xl border border-brand-primary/25 bg-brand-primary/[0.055] p-4 sm:flex-row sm:items-center sm:justify-between"><div><p className="text-[10px] font-bold uppercase tracking-wider text-brand-primary">Fluxo de produção</p><p className="mt-1 text-sm font-bold text-dark-text">{selectedOrderNextStage?.label || selectedOrderPreviousStage?.label}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{selectedOrderNextStage?.description || selectedOrderPreviousStage?.description}</p></div><div className="flex shrink-0 flex-wrap gap-2"><button type="button" onClick={() => selectedOrderPreviousStage && void handleStatusChange(selectedOrder, selectedOrderPreviousStage.status)} disabled={!selectedOrderPreviousStage || busyStatusOrderId === selectedOrder.id} className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-input px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><Undo2 className="h-4 w-4" />{busyStatusOrderId === selectedOrder.id ? 'Atualizando...' : selectedOrderPreviousStage?.label || 'Sem etapa anterior'}</button>{selectedOrderNextStage && <button type="button" onClick={() => void handleStatusChange(selectedOrder, selectedOrderNextStage.status)} disabled={busyStatusOrderId === selectedOrder.id} className="inline-flex h-10 items-center justify-center gap-2 rounded-xl bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">{busyStatusOrderId === selectedOrder.id ? 'Atualizando...' : selectedOrderNextStage.label}<ChevronDown className="h-4 w-4 -rotate-90" /></button>}</div></section> : <section className="rounded-xl border border-dark-border bg-dark-input/45 px-4 py-3 text-sm font-semibold text-dark-muted">Esta OP foi cancelada e não possui próxima etapa operacional.</section>}
|
||||
<div className="mt-5">{renderOrderDetails(selectedOrder)}</div>
|
||||
</div>
|
||||
<footer className="flex flex-col-reverse gap-2 border-t border-dark-border px-5 py-4 sm:flex-row sm:items-center sm:justify-between"><div>{selectedOrderCanReturnToCutPlan && <button type="button" onClick={() => void returnOrderToCutPlan(selectedOrder)} disabled={busyStatusOrderId === selectedOrder.id} className="inline-flex h-10 items-center gap-2 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-muted transition-colors hover:border-amber-400/45 hover:text-amber-200 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><Undo2 className="h-4 w-4" />Devolver ao Plano de Corte</button>}</div><button type="button" onClick={() => setSelectedOrderId(null)} disabled={busyStatusOrderId === selectedOrder.id} className="h-10 rounded-xl border border-dark-border bg-dark-input px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">Fechar</button></footer>
|
||||
</section>
|
||||
</div>}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,18 @@ const statusOptions: Array<{ value: ProductionOrderStatus; label: string; classN
|
||||
{ value: 'canceled', label: 'Cancelada', className: 'border-zinc-500/35 bg-zinc-500/10 text-zinc-400' }
|
||||
];
|
||||
|
||||
type ProductionPlanStatusTab = 'all' | 'open' | 'cutting' | 'sewing' | 'review' | 'finished' | 'canceled';
|
||||
|
||||
const statusTabs: Array<{ key: ProductionPlanStatusTab; label: string; dotClass: string }> = [
|
||||
{ key: 'all', label: 'Todos', dotClass: 'bg-dark-muted' },
|
||||
{ key: 'open', label: 'Em aberto', dotClass: 'bg-amber-400' },
|
||||
{ key: 'cutting', label: 'Em corte', dotClass: 'bg-amber-300' },
|
||||
{ key: 'sewing', label: 'Em costura', dotClass: 'bg-sky-400' },
|
||||
{ key: 'review', label: 'Em revisão', dotClass: 'bg-violet-400' },
|
||||
{ key: 'finished', label: 'Finalizada', dotClass: 'bg-emerald-400' },
|
||||
{ key: 'canceled', label: 'Cancelada', dotClass: 'bg-zinc-500' }
|
||||
];
|
||||
|
||||
const statusStyle = (status: string) => statusOptions.find(option => option.value === status)?.className || 'border-dark-border bg-dark-input text-dark-muted';
|
||||
const formatNumber = (value: number) => new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 }).format(value);
|
||||
const formatDate = (value: string | null) => value ? new Intl.DateTimeFormat('pt-BR').format(new Date(`${value.slice(0, 10)}T00:00:00`)) : '-';
|
||||
@@ -31,6 +43,8 @@ const ProductionPlans = () => {
|
||||
const [operationalUsers, setOperationalUsers] = useState<OperationalUser[]>([]);
|
||||
const [responsiblePickerPlanId, setResponsiblePickerPlanId] = useState<number | null>(null);
|
||||
const [reviewingPlanId, setReviewingPlanId] = useState<number | null>(null);
|
||||
const [isReviewUserPickerOpen, setIsReviewUserPickerOpen] = useState(false);
|
||||
const [statusFilter, setStatusFilter] = useState<ProductionPlanStatusTab>('all');
|
||||
const [reviewForm, setReviewForm] = useState({
|
||||
decision: 'pending' as ProductionPlanReviewDecision,
|
||||
materialsReserved: false,
|
||||
@@ -67,8 +81,18 @@ const ProductionPlans = () => {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [loadOperationalUsers, loadPlans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!message) return undefined;
|
||||
const timer = window.setTimeout(() => setMessage(''), 5_000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [message]);
|
||||
|
||||
const activePlans = useMemo(() => plans.filter(plan => !['finished', 'canceled'].includes(plan.status)), [plans]);
|
||||
const totalOpenQuantity = useMemo(() => activePlans.reduce((total, plan) => total + plan.totalQuantity, 0), [activePlans]);
|
||||
const reviewingPlan = useMemo(() => plans.find(plan => plan.id === reviewingPlanId) || null, [plans, reviewingPlanId]);
|
||||
const filteredPlans = useMemo(() => (
|
||||
statusFilter === 'all' ? plans : plans.filter(plan => plan.status === statusFilter)
|
||||
), [plans, statusFilter]);
|
||||
|
||||
const refreshPlans = async () => {
|
||||
setIsRefreshing(true);
|
||||
@@ -84,6 +108,11 @@ const ProductionPlans = () => {
|
||||
if (!operationalUsers.length) void loadOperationalUsers();
|
||||
};
|
||||
|
||||
const toggleReviewUserPicker = () => {
|
||||
setIsReviewUserPickerOpen(current => !current);
|
||||
if (!operationalUsers.length) void loadOperationalUsers();
|
||||
};
|
||||
|
||||
const movePlan = async (plan: ProductionPlan, status: ProductionOrderStatus) => {
|
||||
if (status === plan.status) return;
|
||||
setBusyPlanId(plan.id);
|
||||
@@ -123,6 +152,29 @@ const ProductionPlans = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const changeExpectedDate = async (plan: ProductionPlan, expectedDate: string) => {
|
||||
setBusyPlanId(plan.id);
|
||||
setMessage('');
|
||||
try {
|
||||
const updated = await updateProductionPlan(plan.id, {
|
||||
name: plan.name,
|
||||
contractor: plan.contractor,
|
||||
responsibleUserId: plan.responsibleUserId,
|
||||
responsibleName: plan.responsibleName,
|
||||
plannedStartDate: plan.plannedStartDate,
|
||||
expectedDate: expectedDate || null,
|
||||
notes: plan.notes,
|
||||
source: plan.source
|
||||
});
|
||||
setPlans(current => current.map(item => item.id === updated.id ? updated : item));
|
||||
setMessage(expectedDate ? `Prazo de ${updated.number} atualizado.` : `Prazo de ${updated.number} removido.`);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : 'Não foi possível atualizar o prazo.');
|
||||
} finally {
|
||||
setBusyPlanId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openReview = (plan: ProductionPlan) => {
|
||||
const checklist = plan.review?.checklist || {};
|
||||
setReviewForm({
|
||||
@@ -135,6 +187,7 @@ const ProductionPlans = () => {
|
||||
reviewedBy: plan.review?.reviewedBy || plan.responsibleName || '',
|
||||
});
|
||||
setResponsiblePickerPlanId(null);
|
||||
setIsReviewUserPickerOpen(false);
|
||||
setReviewingPlanId(plan.id);
|
||||
};
|
||||
|
||||
@@ -175,8 +228,7 @@ const ProductionPlans = () => {
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<Link to="/supplies" className="mb-3 inline-flex items-center gap-2 text-sm font-bold text-zinc-500 transition-colors hover:text-zinc-900 dark:text-dark-muted dark:hover:text-dark-text"><ArrowLeft className="h-4 w-4" />Suprimentos</Link>
|
||||
<p className="text-[11px] font-bold uppercase tracking-[0.16em] text-brand-primary">PCP</p>
|
||||
<h1 className="mt-1 text-2xl font-bold text-zinc-900 dark:text-dark-text">Planos de Produção</h1>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">Planos de Produção</h1>
|
||||
<p className="mt-2 max-w-3xl font-medium text-zinc-500 dark:text-dark-muted">Acompanhe cada frente de produção, o responsável e o estágio das OPs sem perder o histórico do plano.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => void refreshPlans()} disabled={isRefreshing} title="Atualizar planos" aria-label="Atualizar planos" className="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-dark-border bg-dark-card text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} /></button>
|
||||
@@ -198,28 +250,39 @@ const ProductionPlans = () => {
|
||||
{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="rounded-2xl border border-dark-border bg-dark-card shadow-sm">
|
||||
<div className="flex flex-col gap-2 border-b border-dark-border px-5 py-4 sm:flex-row sm:items-center sm:justify-between"><div><h2 className="font-bold text-dark-text">Fila de planos</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Mover um plano atualiza as OPs locais ativas. OPs finalizadas ou canceladas preservam o histórico.</p></div><span className="w-fit rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-muted">{plans.length} {plans.length === 1 ? 'plano' : 'planos'}</span></div>
|
||||
{isLoading ? <div className="flex min-h-56 items-center justify-center text-brand-primary"><Loader2 className="h-7 w-7 animate-spin" /></div> : plans.length ? (
|
||||
<div className="border-b border-dark-border px-5 py-4"><div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"><div><h2 className="font-bold text-dark-text">Fila de planos</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Mover um plano atualiza as OPs locais ativas. OPs finalizadas ou canceladas preservam o histórico.</p></div><span className="w-fit rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-muted">{filteredPlans.length} {filteredPlans.length === 1 ? 'plano' : 'planos'}</span></div><div className="mt-4 flex gap-1 overflow-x-auto" role="tablist" aria-label="Status dos planos">{statusTabs.map(tab => { const count = tab.key === 'all' ? plans.length : plans.filter(plan => plan.status === tab.key).length; const isSelected = statusFilter === tab.key; return <button key={tab.key} type="button" role="tab" aria-selected={isSelected} onClick={() => setStatusFilter(tab.key)} className={`shrink-0 border-b-2 px-3 py-2 text-left text-xs font-bold transition-colors cursor-pointer ${isSelected ? 'border-brand-primary text-dark-text' : 'border-transparent text-dark-muted hover:text-dark-text'}`}><span className="flex items-center gap-2"><span className={`h-2 w-2 rounded-full ${tab.dotClass}`} />{tab.label}</span><span className="mt-1 block text-sm text-dark-text">{count}</span></button>; })}</div></div>
|
||||
{isLoading ? <div className="flex min-h-56 items-center justify-center text-brand-primary"><Loader2 className="h-7 w-7 animate-spin" /></div> : filteredPlans.length ? (
|
||||
<div className="divide-y divide-dark-border">
|
||||
{plans.map(plan => (
|
||||
{filteredPlans.map(plan => (
|
||||
<div key={plan.id} className="p-5">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><span className="font-mono text-xs font-bold text-brand-primary">{plan.number}</span><span className={`rounded-full border px-2.5 py-1 text-xs font-bold ${statusStyle(plan.status)}`}>{plan.statusLabel}</span>{plan.review && <span className={`rounded-full border px-2 py-1 text-[10px] font-bold ${reviewDecisionStyle(plan.review.decision)}`}>{reviewDecisionLabel[plan.review.decision]}</span>}</div><p className="mt-2 text-base font-bold text-dark-text">{plan.name}</p>{plan.notes && <p className="mt-1 max-w-3xl text-xs font-semibold leading-5 text-dark-muted">{plan.notes}</p>}</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2"><button type="button" disabled={busyPlanId === plan.id} onClick={() => openReview(plan)} className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-input px-3 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><ClipboardCheck className="h-4 w-4 text-brand-primary" />Checklist</button><label className="min-w-[156px] text-[10px] font-bold uppercase tracking-wider text-dark-muted">Mover plano<select value={plan.status} disabled={busyPlanId === plan.id} onChange={(event) => void movePlan(plan, event.target.value as ProductionOrderStatus)} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold normal-case tracking-normal text-dark-text outline-none focus:border-brand-primary disabled:opacity-60 cursor-pointer"><option value={plan.status}>{busyPlanId === plan.id ? 'Atualizando...' : plan.statusLabel}</option>{statusOptions.filter(option => option.value !== plan.status).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label></div>
|
||||
</div>
|
||||
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><span className="font-mono text-xs font-bold text-brand-primary">{plan.number}</span><span className={`rounded-full border px-2.5 py-1 text-xs font-bold ${statusStyle(plan.status)}`}>{plan.statusLabel}</span>{plan.review && <span className={`rounded-full border px-2 py-1 text-[10px] font-bold ${reviewDecisionStyle(plan.review.decision)}`}>{reviewDecisionLabel[plan.review.decision]}</span>}</div><p className="mt-2 text-base font-bold text-dark-text">{plan.name}</p>{plan.notes && <p className="mt-1 max-w-3xl text-xs font-semibold leading-5 text-dark-muted">{plan.notes}</p>}</div>
|
||||
<div className="mt-4 grid gap-3 border-t border-dark-border pt-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-xl border border-dark-border bg-dark-input/45 p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Escopo</p><p className="mt-1 text-sm font-bold text-dark-text">{plan.orderCount} {plan.orderCount === 1 ? 'OP' : 'OPs'} · {formatNumber(plan.totalQuantity)} un.</p><p className="mt-1 text-xs font-semibold text-dark-muted">Corte previsto pelo plano</p></div>
|
||||
<div className="rounded-xl border border-dark-border bg-dark-input/45 p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Andamento</p><p className="mt-1 text-sm font-bold text-dark-text">Cortado {formatNumber(plan.actualCutQuantity)} · final {formatNumber(plan.finishedQuantity)}</p><div className="mt-2 flex flex-wrap gap-1.5">{statusOptions.filter(option => Number(plan.statusCounts[option.value] || 0) > 0).map(option => <span key={option.value} className={`rounded-full border px-2 py-0.5 text-[10px] font-bold ${statusStyle(option.value)}`}>{plan.statusCounts[option.value]} {option.label.toLowerCase()}</span>)}</div></div>
|
||||
<div className="rounded-xl border border-dark-border bg-dark-input/45 p-3"><p className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-dark-muted"><CalendarDays className="h-3.5 w-3.5" />Prazo</p><p className="mt-1 text-sm font-bold text-dark-text">{formatDate(plan.expectedDate)}</p><p className="mt-1 text-xs font-semibold text-dark-muted">Início: {formatDate(plan.plannedStartDate)}</p></div>
|
||||
<div className="rounded-xl border border-dark-border bg-dark-input/45 p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Responsável</p><button type="button" disabled={busyPlanId === plan.id} onClick={() => toggleResponsiblePicker(plan.id)} className="mt-1 flex h-9 w-full items-center justify-between gap-2 rounded-lg border border-dark-border bg-dark-card px-2.5 text-left text-sm font-semibold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><span className="flex min-w-0 items-center gap-2"><UserRound className="h-4 w-4 shrink-0 text-brand-primary" /><span className="truncate">{plan.responsibleName || 'Selecionar responsável'}</span></span><ChevronDown className={`h-4 w-4 shrink-0 text-dark-muted transition-transform ${responsiblePickerPlanId === plan.id ? 'rotate-180' : ''}`} /></button><p className="mt-1 text-xs font-semibold text-dark-muted">{plan.contractor || 'Sem facção definida'}</p></div>
|
||||
<div className="rounded-xl border border-dark-border bg-dark-input/45 p-3"><div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-dark-muted"><CalendarDays className="h-3.5 w-3.5 shrink-0" /><span>Prazo de conclusão</span></div><input type="date" defaultValue={plan.expectedDate || ''} disabled={busyPlanId === plan.id} onChange={(event) => void changeExpectedDate(plan, event.target.value)} className="mt-3 h-10 w-full rounded-lg border border-dark-border bg-dark-card px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer" aria-label={`Prazo de conclusão do plano ${plan.number}`} /><div className="mt-2 flex items-center justify-between gap-2 text-xs font-semibold text-dark-muted"><span>Início</span><span>{formatDate(plan.plannedStartDate)}</span></div></div>
|
||||
<div className="relative rounded-xl border border-dark-border bg-dark-input/45 p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Responsável</p><button type="button" disabled={busyPlanId === plan.id} onClick={() => toggleResponsiblePicker(plan.id)} aria-expanded={responsiblePickerPlanId === plan.id} className="mt-3 flex h-10 w-full items-center justify-between gap-2 rounded-lg border border-dark-border bg-dark-card px-3 text-left text-sm font-semibold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><span className="flex min-w-0 items-center gap-2"><UserRound className="h-4 w-4 shrink-0 text-brand-primary" /><span className="truncate">{plan.responsibleName || 'Selecionar responsável'}</span></span><ChevronDown className={`h-4 w-4 shrink-0 text-dark-muted transition-transform ${responsiblePickerPlanId === plan.id ? 'rotate-180' : ''}`} /></button>{responsiblePickerPlanId === plan.id && <div className="absolute inset-x-3 top-[calc(100%-0.75rem)] z-30 mt-2 overflow-hidden rounded-xl border border-dark-border bg-dark-card shadow-2xl"><div className="border-b border-dark-border px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-dark-muted">Usuários ativos do Graphs</div><div className="max-h-56 overflow-y-auto p-1.5">{isLoadingOperationalUsers ? <p className="flex items-center gap-2 px-2 py-3 text-xs font-semibold text-dark-muted"><Loader2 className="h-4 w-4 animate-spin text-brand-primary" />Carregando usuários...</p> : operationalUsers.length ? operationalUsers.map(user => <button key={user.id ?? 'super-admin'} type="button" disabled={busyPlanId === plan.id} onClick={() => void changeResponsible(plan, user)} className="flex w-full items-center justify-between gap-3 rounded-lg px-2.5 py-2 text-left text-sm font-semibold text-dark-text transition-colors hover:bg-dark-input disabled:opacity-60 cursor-pointer"><span className="truncate">{user.name}</span>{plan.responsibleUserId === user.id && plan.responsibleName === user.name && <Check className="h-4 w-4 shrink-0 text-brand-primary" />}</button>) : <p className="px-2 py-3 text-xs font-semibold text-dark-muted">Nenhum usuário ativo encontrado.</p>}</div>{plan.responsibleName && <button type="button" disabled={busyPlanId === plan.id} onClick={() => void changeResponsible(plan, null)} className="w-full border-t border-dark-border px-3 py-2 text-left text-xs font-bold text-dark-muted transition-colors hover:bg-dark-input hover:text-red-300 disabled:opacity-60 cursor-pointer">Remover responsável</button>}</div>}</div>
|
||||
</div>
|
||||
{responsiblePickerPlanId === plan.id && <div className="mt-3 rounded-xl border border-brand-primary/25 bg-brand-primary/[0.045] p-3"><div className="flex items-center justify-between gap-3"><div><p className="text-sm font-bold text-dark-text">Atribuir responsável</p><p className="mt-0.5 text-xs font-semibold text-dark-muted">Selecione um usuário ativo do Graphs para acompanhar este plano.</p></div><button type="button" onClick={() => setResponsiblePickerPlanId(null)} className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border bg-dark-card text-dark-muted transition-colors hover:text-dark-text cursor-pointer" aria-label="Fechar responsáveis"><X className="h-4 w-4" /></button></div><div className="mt-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">{isLoadingOperationalUsers ? <div className="flex items-center gap-2 px-2 py-3 text-xs font-bold text-dark-muted"><Loader2 className="h-4 w-4 animate-spin text-brand-primary" />Carregando usuários...</div> : operationalUsers.map(user => <button key={user.id ?? 'super-admin'} type="button" disabled={busyPlanId === plan.id} onClick={() => void changeResponsible(plan, user)} className="flex min-h-10 items-center justify-between gap-3 rounded-lg border border-dark-border bg-dark-card px-3 py-2 text-left text-sm font-semibold text-dark-text transition-colors hover:border-brand-primary hover:bg-dark-input disabled:opacity-60 cursor-pointer"><span className="truncate">{user.name}</span>{plan.responsibleUserId === user.id && plan.responsibleName === user.name && <Check className="h-4 w-4 shrink-0 text-brand-primary" />}</button>)}{!isLoadingOperationalUsers && !operationalUsers.length && <p className="px-2 py-3 text-xs font-semibold text-dark-muted">Nenhum usuário ativo encontrado. Cadastre ou reative um usuário para atribuir o plano.</p>}</div>{plan.responsibleName && <button type="button" disabled={busyPlanId === plan.id} onClick={() => void changeResponsible(plan, null)} className="mt-3 inline-flex h-9 items-center rounded-lg border border-dark-border bg-dark-card px-3 text-xs font-bold text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 disabled:opacity-60 cursor-pointer">Remover responsável</button>}</div>}
|
||||
{reviewingPlanId === plan.id && <div className="mt-5 rounded-xl border border-brand-primary/25 bg-brand-primary/[0.045] p-4"><div className="flex items-start justify-between gap-4"><div><h3 className="text-sm font-bold text-dark-text">Liberação operacional</h3><p className="mt-1 text-xs font-semibold text-dark-muted">Registre a decisão antes de avançar o plano. Este registro não muda o estoque nem substitui o recebimento.</p></div><button type="button" onClick={() => setReviewingPlanId(null)} className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted hover:text-dark-text cursor-pointer" aria-label="Fechar checklist"><X className="h-4 w-4" /></button></div><div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">{[{ key: 'materialsReserved', label: 'Materiais reservados' }, { key: 'cutSpecificationReady', label: 'Ficha e corte definidos' }, { key: 'sizeAndColorReviewed', label: 'Tamanhos e cores revisados' }, { key: 'responsibleAndDateDefined', label: 'Responsável e data definidos' }].map(item => <label key={item.key} className="flex items-center gap-2 rounded-lg border border-dark-border bg-dark-card px-3 py-2.5 text-xs font-bold text-dark-text"><input type="checkbox" checked={reviewForm[item.key as keyof Pick<typeof reviewForm, 'materialsReserved' | 'cutSpecificationReady' | 'sizeAndColorReviewed' | 'responsibleAndDateDefined'>]} onChange={(event) => setReviewForm(current => ({ ...current, [item.key]: event.target.checked }))} className="h-4 w-4 accent-brand-primary" />{item.label}</label>)}</div><div className="mt-4 grid gap-3 md:grid-cols-[180px_minmax(0,1fr)_minmax(0,1fr)_auto]"><label className="text-xs font-bold text-dark-muted">Decisão<select value={reviewForm.decision} onChange={(event) => setReviewForm(current => ({ ...current, decision: event.target.value as ProductionPlanReviewDecision }))} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none focus:border-brand-primary"><option value="pending">Em revisão</option><option value="approved">Liberar para operação</option><option value="adjustments_required">Solicitar ajustes</option></select></label><label className="text-xs font-bold text-dark-muted">Revisado por<input value={reviewForm.reviewedBy} onChange={(event) => setReviewForm(current => ({ ...current, reviewedBy: event.target.value }))} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary" placeholder="Nome do usuário" /></label><label className="text-xs font-bold text-dark-muted">Observação<input value={reviewForm.notes} onChange={(event) => setReviewForm(current => ({ ...current, notes: event.target.value }))} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary" placeholder="Ajuste, orientação ou motivo" /></label><button type="button" disabled={busyPlanId === plan.id} onClick={() => void submitReview(plan)} className="mt-5 inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-xs font-bold text-brand-contrast disabled:opacity-60 cursor-pointer"><Check className="h-4 w-4" />Salvar</button></div></div>}
|
||||
<div className="mt-3 flex flex-col gap-3 rounded-xl border border-dark-border bg-dark-input/30 p-3 sm:flex-row sm:items-end sm:justify-between"><div><p className="text-xs font-bold text-dark-text">Ações do plano</p><p className="mt-0.5 text-xs font-semibold text-dark-muted">Revise a liberação antes de avançar a operação.</p></div><div className="flex flex-col gap-2 sm:flex-row sm:items-end"><button type="button" disabled={busyPlanId === plan.id} onClick={() => openReview(plan)} className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-card px-3 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><ClipboardCheck className="h-4 w-4 text-brand-primary" />Checklist</button><label className="min-w-[156px] text-[10px] font-bold uppercase tracking-wider text-dark-muted">Mover plano<select value={plan.status} disabled={busyPlanId === plan.id} onChange={(event) => void movePlan(plan, event.target.value as ProductionOrderStatus)} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-card px-3 text-sm font-bold normal-case tracking-normal text-dark-text outline-none focus:border-brand-primary disabled:opacity-60 cursor-pointer"><option value={plan.status}>{busyPlanId === plan.id ? 'Atualizando...' : plan.statusLabel}</option>{statusOptions.filter(option => option.value !== plan.status).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label></div></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <div className="flex min-h-56 flex-col items-center justify-center p-8 text-center"><CheckCircle2 className="h-8 w-8 text-brand-primary" /><h3 className="mt-3 font-bold text-dark-text">Nenhum plano confirmado</h3><p className="mt-1 text-sm font-semibold text-dark-muted">Gere OPs a partir do Plano de Corte para criar o primeiro plano PCP.</p></div>}
|
||||
) : <div className="flex min-h-56 flex-col items-center justify-center p-8 text-center"><CheckCircle2 className="h-8 w-8 text-brand-primary" /><h3 className="mt-3 font-bold text-dark-text">{plans.length ? 'Nenhum plano neste status' : 'Nenhum plano confirmado'}</h3><p className="mt-1 text-sm font-semibold text-dark-muted">{plans.length ? 'Escolha outro status para visualizar os demais planos.' : 'Gere OPs a partir do Plano de Corte para criar o primeiro plano PCP.'}</p></div>}
|
||||
</div>
|
||||
|
||||
{reviewingPlan && <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label={`Checklist do plano ${reviewingPlan.number}`} onClick={() => busyPlanId !== reviewingPlan.id && setReviewingPlanId(null)}>
|
||||
<section className="flex max-h-[calc(100vh-2rem)] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl" onClick={(event) => event.stopPropagation()}>
|
||||
<header className="flex items-start justify-between gap-4 border-b border-dark-border px-5 py-4">
|
||||
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><span className="font-mono text-xs font-bold text-brand-primary">{reviewingPlan.number}</span><span className={`rounded-full border px-2 py-1 text-[10px] font-bold ${statusStyle(reviewingPlan.status)}`}>{reviewingPlan.statusLabel}</span></div><h2 className="mt-2 text-lg font-bold text-dark-text">Checklist de liberação</h2><p className="mt-1 truncate text-sm font-semibold text-dark-muted" title={reviewingPlan.name}>{reviewingPlan.name}</p></div>
|
||||
<button type="button" onClick={() => setReviewingPlanId(null)} disabled={busyPlanId === reviewingPlan.id} title="Fechar" aria-label="Fechar checklist" className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><X className="h-4 w-4" /></button>
|
||||
</header>
|
||||
<div className="overflow-y-auto p-5">
|
||||
<p className="text-sm font-medium leading-6 text-dark-muted">Revise os pontos de liberação antes de avançar a operação. Este registro não movimenta estoque nem substitui o recebimento físico.</p>
|
||||
<section className="mt-5"><h3 className="text-sm font-bold text-dark-text">Conferências</h3><div className="mt-3 grid gap-2 sm:grid-cols-2">{[{ key: 'materialsReserved', label: 'Materiais reservados' }, { key: 'cutSpecificationReady', label: 'Ficha e corte definidos' }, { key: 'sizeAndColorReviewed', label: 'Tamanhos e cores revisados' }, { key: 'responsibleAndDateDefined', label: 'Responsável e prazo definidos' }].map(item => <label key={item.key} className="flex min-h-12 items-center gap-3 rounded-xl border border-dark-border bg-dark-input/45 px-3 py-2.5 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary/50 cursor-pointer"><input type="checkbox" checked={reviewForm[item.key as keyof Pick<typeof reviewForm, 'materialsReserved' | 'cutSpecificationReady' | 'sizeAndColorReviewed' | 'responsibleAndDateDefined'>]} onChange={(event) => setReviewForm(current => ({ ...current, [item.key]: event.target.checked }))} className="h-4 w-4 shrink-0 accent-brand-primary cursor-pointer" />{item.label}</label>)}</div></section>
|
||||
<section className="mt-5 grid gap-4 sm:grid-cols-2"><label className="text-xs font-bold text-dark-muted">Decisão<select value={reviewForm.decision} onChange={(event) => setReviewForm(current => ({ ...current, decision: event.target.value as ProductionPlanReviewDecision }))} className="mt-1.5 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary cursor-pointer"><option value="pending">Em revisão</option><option value="approved">Liberar para operação</option><option value="adjustments_required">Solicitar ajustes</option></select></label><div className="relative text-xs font-bold text-dark-muted"><p>Revisado por</p><button type="button" onClick={toggleReviewUserPicker} aria-expanded={isReviewUserPickerOpen} className="mt-1.5 flex h-10 w-full items-center justify-between gap-2 rounded-lg border border-dark-border bg-dark-input px-3 text-left text-sm font-semibold text-dark-text outline-none transition-colors hover:border-brand-primary cursor-pointer"><span className="flex min-w-0 items-center gap-2"><UserRound className="h-4 w-4 shrink-0 text-brand-primary" /><span className="truncate">{reviewForm.reviewedBy || 'Selecionar usuário'}</span></span><ChevronDown className={`h-4 w-4 shrink-0 text-dark-muted transition-transform ${isReviewUserPickerOpen ? 'rotate-180' : ''}`} /></button>{isReviewUserPickerOpen && <div className="absolute inset-x-0 top-full z-20 mt-2 overflow-hidden rounded-xl border border-dark-border bg-dark-card shadow-2xl"><div className="border-b border-dark-border px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-dark-muted">Usuários ativos do Graphs</div><div className="max-h-52 overflow-y-auto p-1.5">{isLoadingOperationalUsers ? <p className="flex items-center gap-2 px-2 py-3 text-xs font-semibold text-dark-muted"><Loader2 className="h-4 w-4 animate-spin text-brand-primary" />Carregando usuários...</p> : operationalUsers.length ? operationalUsers.map(user => <button key={user.id ?? 'super-admin'} type="button" onClick={() => { setReviewForm(current => ({ ...current, reviewedBy: user.name })); setIsReviewUserPickerOpen(false); }} className="flex w-full items-center justify-between gap-3 rounded-lg px-2.5 py-2 text-left text-sm font-semibold text-dark-text transition-colors hover:bg-dark-input cursor-pointer"><span className="truncate">{user.name}</span>{reviewForm.reviewedBy === user.name && <Check className="h-4 w-4 shrink-0 text-brand-primary" />}</button>) : <p className="px-2 py-3 text-xs font-semibold text-dark-muted">Nenhum usuário ativo encontrado.</p>}</div>{reviewForm.reviewedBy && <button type="button" onClick={() => { setReviewForm(current => ({ ...current, reviewedBy: '' })); setIsReviewUserPickerOpen(false); }} className="w-full border-t border-dark-border px-3 py-2 text-left text-xs font-bold text-dark-muted transition-colors hover:bg-dark-input hover:text-red-300 cursor-pointer">Remover revisor</button>}</div>}</div><label className="sm:col-span-2 text-xs font-bold text-dark-muted">Observação<input value={reviewForm.notes} onChange={(event) => setReviewForm(current => ({ ...current, notes: event.target.value }))} className="mt-1.5 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none transition-colors focus:border-brand-primary" placeholder="Ajuste, orientação ou motivo" /></label></section>
|
||||
</div>
|
||||
<footer className="flex flex-col-reverse gap-2 border-t border-dark-border px-5 py-4 sm:flex-row sm:items-center sm:justify-end"><button type="button" onClick={() => setReviewingPlanId(null)} disabled={busyPlanId === reviewingPlan.id} className="h-10 rounded-xl border border-dark-border bg-dark-input px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">Cancelar</button><button type="button" disabled={busyPlanId === reviewingPlan.id} onClick={() => void submitReview(reviewingPlan)} className="inline-flex h-10 items-center justify-center gap-2 rounded-xl bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><Check className="h-4 w-4" />{busyPlanId === reviewingPlan.id ? 'Salvando...' : 'Salvar checklist'}</button></footer>
|
||||
</section>
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">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 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`}>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user