From da6f3b29556f7d15629341f1082000d58c07cb4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Wed, 26 Aug 2026 16:34:25 -0300 Subject: [PATCH] Add production quality and workstation screen --- src/App.tsx | 2 + src/components/Layout.tsx | 5 +- src/dataService.ts | 28 +++++- src/pages/ProductionOrders.tsx | 2 +- src/pages/ProductionQuality.tsx | 155 ++++++++++++++++++++++++++++++++ src/types.ts | 8 ++ 6 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 src/pages/ProductionQuality.tsx diff --git a/src/App.tsx b/src/App.tsx index d871ba9..08b5249 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ const Cutting = React.lazy(() => import('./pages/Cutting')); const PlanningIssues = React.lazy(() => import('./pages/PlanningIssues')); const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders')); const ProductionPlans = React.lazy(() => import('./pages/ProductionPlans')); +const ProductionQuality = React.lazy(() => import('./pages/ProductionQuality')); const Supplies = React.lazy(() => import('./pages/Supplies')); const Clients = React.lazy(() => import('./pages/Clients')); const ClientDetails = React.lazy(() => import('./pages/ClientDetails')); @@ -66,6 +67,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index ba422b0..1d6d2d1 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,6 +1,6 @@ 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 { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, ShieldCheck, Moon, Sun, Tags, Boxes, ClipboardList, Menu, X, BriefcaseBusiness, Radio } from 'lucide-react'; import type { DateRange, OrderData } from '../types'; import { isSuperAdmin, logout } from '../dataService'; import { rangeForLastDays } from '../dateRanges'; @@ -81,6 +81,9 @@ const Layout = () => { { name: 'Dados Pendentes', href: '/planning-issues', icon: ClipboardList }, { name: 'Cadastros', href: '/registrations', icon: Tags }, { name: 'Suprimentos', href: '/supplies', icon: Boxes }, + { name: 'Planos de Produção', href: '/production-plans', icon: ClipboardList }, + { name: 'Ordens de Produção', href: '/production-orders', icon: Package }, + { name: 'Qualidade', href: '/production-quality', icon: ShieldCheck }, ], }, { diff --git a/src/dataService.ts b/src/dataService.ts index 9f31211..012fab5 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -1,4 +1,4 @@ -import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CutPlanLotReservationPayload, CuttingSettings, DashboardAnalytics, DateRange, GraphsStockBalanceHistoryItem, GraphsStockBalancePayload, ManagedUser, OperationalUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderDefectPayload, ProductionOrderExecutionPayload, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, ProductionPlan, ProductionPlanPayload, ProductionWorkstation, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyPurchaseOrder, SupplyPurchaseOrderPayload, SupplyPurchaseOrderStatus, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; +import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CutPlanLotReservationPayload, CuttingSettings, DashboardAnalytics, DateRange, GraphsStockBalanceHistoryItem, GraphsStockBalancePayload, ManagedUser, OperationalUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderDefectPayload, ProductionOrderExecutionPayload, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, ProductionPlan, ProductionPlanPayload, ProductionWorkstation, ProductionWorkstationPayload, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyPurchaseOrder, SupplyPurchaseOrderPayload, SupplyPurchaseOrderStatus, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -306,12 +306,34 @@ export const updateProductionOrderExecution = async (id: number, payload: Produc return data as ProductionOrderItem; }; -export const fetchProductionWorkstations = async (): Promise => { - const response = await authFetch('/production-workstations', { cache: 'no-store' }); +export const fetchProductionWorkstations = async (includeInactive = false): Promise => { + const response = await authFetch(`/production-workstations${includeInactive ? '?includeInactive=true' : ''}`, { cache: 'no-store' }); if (!response.ok) throw new Error('Não foi possível carregar os postos de trabalho.'); return response.json() as Promise; }; +export const createProductionWorkstation = async (payload: ProductionWorkstationPayload): Promise => { + const response = await authFetch('/production-workstations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await response.json().catch(() => null); + if (!response.ok) throw new Error(data?.error || 'Não foi possível criar o posto de trabalho.'); + return data as ProductionWorkstation; +}; + +export const updateProductionWorkstation = async (id: number, payload: ProductionWorkstationPayload): Promise => { + const response = await authFetch(`/production-workstations/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await response.json().catch(() => null); + if (!response.ok) throw new Error(data?.error || 'Não foi possível atualizar o posto de trabalho.'); + return data as ProductionWorkstation; +}; + export const addProductionOrderDefect = async (id: number, payload: ProductionOrderDefectPayload): Promise => { const response = await authFetch(`/production-orders/${id}/defects`, { method: 'POST', diff --git a/src/pages/ProductionOrders.tsx b/src/pages/ProductionOrders.tsx index 7b83328..c47750e 100644 --- a/src/pages/ProductionOrders.tsx +++ b/src/pages/ProductionOrders.tsx @@ -477,7 +477,7 @@ const ProductionOrders = () => {

Ordens de Produção

Acompanhe as ordens locais geradas pelo plano de corte.

- Ver planos de produção +
Ver planos de produçãoVer qualidade
new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 }).format(value); +const formatDateTime = (value: string | null) => value ? new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(value)) : '—'; + +const ProductionQuality = () => { + const { dateRange, setDateRange, refreshInterval, setRefreshInterval } = useOutletContext<{ + dateRange: DateRange; + setDateRange: (range: DateRange) => void; + refreshInterval: number; + setRefreshInterval: (interval: number) => void; + }>(); + const [summary, setSummary] = useState(emptySummary); + const [workstations, setWorkstations] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [message, setMessage] = useState(''); + const [editingStation, setEditingStation] = useState(null); + const [stationForm, setStationForm] = useState({ name: '', code: '', color: '#38bdf8', sortOrder: '50', isActive: true }); + + const load = useCallback(async () => { + setIsLoading(true); + try { + const [nextSummary, nextWorkstations] = await Promise.all([ + fetchProductionOrders(dateRange, undefined, { force: true }), + fetchProductionWorkstations(true) + ]); + setSummary(nextSummary); + setWorkstations(nextWorkstations); + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Não foi possível carregar a qualidade da produção.'); + } finally { + setIsLoading(false); + } + }, [dateRange]); + + useEffect(() => { + const timer = window.setTimeout(() => void load(), 0); + return () => window.clearTimeout(timer); + }, [load]); + + useEffect(() => { + if (!refreshInterval) return undefined; + const interval = window.setInterval(() => void load(), refreshInterval); + return () => window.clearInterval(interval); + }, [load, refreshInterval]); + + const defects = useMemo(() => summary.orders.flatMap(order => order.defects.map(defect => ({ + ...defect, + orderId: order.id, + orderNumber: order.number || `OP #${order.id}`, + productDescription: order.productDescription, + unit: order.unit + }))), [summary.orders]); + const totalDefectiveQuantity = useMemo(() => defects.reduce((total, defect) => total + defect.quantity, 0), [defects]); + const affectedOrders = useMemo(() => new Set(defects.map(defect => defect.orderId)).size, [defects]); + const workstationSummary = useMemo(() => { + const grouped = new Map(); + defects.forEach(defect => { + const label = defect.workstationName || 'Sem posto'; + const current = grouped.get(label) || { label, quantity: 0, occurrences: 0 }; + current.quantity += defect.quantity; + current.occurrences += 1; + grouped.set(label, current); + }); + return [...grouped.values()].sort((a, b) => b.quantity - a.quantity); + }, [defects]); + const defectTypeSummary = useMemo(() => { + const grouped = new Map(); + defects.forEach(defect => { + const label = defect.defectType || 'Sem tipo'; + const current = grouped.get(label) || { label, quantity: 0, occurrences: 0 }; + current.quantity += defect.quantity; + current.occurrences += 1; + grouped.set(label, current); + }); + return [...grouped.values()].sort((a, b) => b.quantity - a.quantity); + }, [defects]); + const recentDefects = useMemo(() => [...defects].sort((a, b) => new Date(b.occurredAt || 0).getTime() - new Date(a.occurredAt || 0).getTime()).slice(0, 12), [defects]); + + const resetStationForm = () => { + setEditingStation(null); + setStationForm({ name: '', code: '', color: '#38bdf8', sortOrder: String((workstations.at(-1)?.sortOrder || 0) + 10), isActive: true }); + }; + + const editStation = (station: ProductionWorkstation) => { + setEditingStation(station); + setStationForm({ name: station.name, code: station.code, color: station.color, sortOrder: String(station.sortOrder), isActive: station.isActive }); + }; + + const saveStation = async (event: FormEvent) => { + event.preventDefault(); + setIsSaving(true); + setMessage(''); + try { + const payload = { ...stationForm, sortOrder: Number(stationForm.sortOrder || 0) }; + if (editingStation) await updateProductionWorkstation(editingStation.id, payload); + else await createProductionWorkstation(payload); + await load(); + setMessage(editingStation ? 'Posto de trabalho atualizado.' : 'Posto de trabalho criado.'); + resetStationForm(); + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Não foi possível salvar o posto de trabalho.'); + } finally { + setIsSaving(false); + } + }; + + return ( +
+
+
+ Ordens de Produção +

Qualidade e postos

+

Acompanhe defeitos por origem e mantenha as etapas reais da operação.

+
+ void load()} /> +
+ + {message &&
{message}
} + + {isLoading ?
: <> +
+

Defeitos registrados

{defects.length}

Ocorrências no período

+

Peças afetadas

{formatQuantity(totalDefectiveQuantity)}

Somadas pelos apontamentos

+

OPs com ocorrência

{affectedOrders}

De {summary.orders.length} OPs no período

+
+ +
+

Levantamento de defeitos

Priorize o maior impacto antes de atacar ocorrências isoladas.

{workstationSummary.length ? workstationSummary.map(item =>

{item.label}

{item.occurrences} {item.occurrences === 1 ? 'ocorrência' : 'ocorrências'}

{formatQuantity(item.quantity)}

) :

Nenhum defeito registrado no período.

}
+

Tipos mais recorrentes

Use estes dados para orientar revisão, fornecedor e correção de processo.

{defectTypeSummary.length ? defectTypeSummary.map(item =>

{item.label}

{item.occurrences} {item.occurrences === 1 ? 'ocorrência' : 'ocorrências'}

{formatQuantity(item.quantity)}

) :

Nenhum tipo de defeito registrado no período.

}
+
+ +
+

Ocorrências recentes

Rastreabilidade por OP, produto, posto e lote.

{recentDefects.length ? recentDefects.map(defect =>
{defect.orderNumber}{defect.workstationName || 'Sem posto'}

{defect.defectType}

{defect.productDescription} · {defect.origin || 'origem não informada'}{defect.lotCode ? ` · lote ${defect.lotCode}` : ''}

{defect.notes &&

{defect.notes}

}

{formatQuantity(defect.quantity)} {defect.unit}

{formatDateTime(defect.occurredAt)}

) :

Nenhuma ocorrência registrada no período.

}
+ +

Postos de trabalho

Etapas disponíveis para o apontamento da operação.

{editingStation ? 'Editar posto' : 'Novo posto'}

{editingStation && }
{workstations.map(station =>

{station.name}

{station.code} · posição {station.sortOrder}{station.isActive ? '' : ' · inativo'}

)}
+
+ } +
+ ); +}; + +export default ProductionQuality; diff --git a/src/types.ts b/src/types.ts index 362fed2..7cd3df2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -99,6 +99,14 @@ export interface ProductionWorkstation { isActive: boolean; } +export type ProductionWorkstationPayload = { + code?: string; + name: string; + color?: string; + sortOrder?: number; + isActive?: boolean; +}; + export interface ProductionOrderDefect { id: number; workstationId: number | null;