Add production order defect capture

This commit is contained in:
Cauê Faleiros
2026-08-26 16:28:59 -03:00
parent 38add708ff
commit 7e65feac37
3 changed files with 110 additions and 6 deletions

View File

@@ -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, ProductionOrderExecutionPayload, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, ProductionPlan, ProductionPlanPayload, 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, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyPurchaseOrder, SupplyPurchaseOrderPayload, SupplyPurchaseOrderStatus, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
import { formatDateParam } from './dateRanges'; import { formatDateParam } from './dateRanges';
const API_URL = import.meta.env.VITE_API_URL || '/api'; const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -306,6 +306,24 @@ export const updateProductionOrderExecution = async (id: number, payload: Produc
return data as ProductionOrderItem; return data as ProductionOrderItem;
}; };
export const fetchProductionWorkstations = async (): Promise<ProductionWorkstation[]> => {
const response = await authFetch('/production-workstations', { cache: 'no-store' });
if (!response.ok) throw new Error('Não foi possível carregar os postos de trabalho.');
return response.json() as Promise<ProductionWorkstation[]>;
};
export const addProductionOrderDefect = async (id: number, payload: ProductionOrderDefectPayload): Promise<ProductionOrderItem> => {
const response = await authFetch(`/production-orders/${id}/defects`, {
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 registrar o defeito.');
analyticsCache.clear();
return data as ProductionOrderItem;
};
export const fetchCuttingSettings = async (): Promise<CuttingSettings> => { export const fetchCuttingSettings = async (): Promise<CuttingSettings> => {
try { try {
const response = await authFetch('/cutting-settings'); const response = await authFetch('/cutting-settings');

View File

@@ -1,11 +1,11 @@
import { Fragment, type FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { Fragment, type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useOutletContext } from 'react-router-dom'; import { Link, useNavigate, useOutletContext } from 'react-router-dom';
import { 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, XCircle } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls'; import PaginationControls from '../components/PaginationControls';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import { consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchSupplySummary, updateProductionOrderExecution, updateProductionOrderStatus } from '../dataService'; import { addProductionOrderDefect, consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchProductionWorkstations, fetchSupplySummary, updateProductionOrderExecution, updateProductionOrderStatus } from '../dataService';
import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary, SupplyLot } from '../types'; import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary, ProductionWorkstation, SupplyLot } from '../types';
type ProductionOrderStatusTab = 'all' | 'open' | 'cutting' | 'sewing' | 'review' | 'finished' | 'canceled'; type ProductionOrderStatusTab = 'all' | 'open' | 'cutting' | 'sewing' | 'review' | 'finished' | 'canceled';
@@ -140,11 +140,14 @@ const ProductionOrders = () => {
const [statusFilter, setStatusFilter] = useState<ProductionOrderStatusTab>('all'); const [statusFilter, setStatusFilter] = useState<ProductionOrderStatusTab>('all');
const [summary, setSummary] = useState<ProductionOrderSummary>(emptySummary); const [summary, setSummary] = useState<ProductionOrderSummary>(emptySummary);
const [supplyLots, setSupplyLots] = useState<SupplyLot[]>([]); const [supplyLots, setSupplyLots] = useState<SupplyLot[]>([]);
const [workstations, setWorkstations] = useState<ProductionWorkstation[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSupplyBusy, setIsSupplyBusy] = useState(false); const [isSupplyBusy, setIsSupplyBusy] = useState(false);
const [busyExecutionOrderId, setBusyExecutionOrderId] = useState<number | null>(null); const [busyExecutionOrderId, setBusyExecutionOrderId] = useState<number | null>(null);
const [supplyMessage, setSupplyMessage] = useState(''); const [supplyMessage, setSupplyMessage] = useState('');
const [executionMessage, setExecutionMessage] = useState(''); const [executionMessage, setExecutionMessage] = useState('');
const [busyDefectOrderId, setBusyDefectOrderId] = useState<number | null>(null);
const [defectMessage, setDefectMessage] = useState('');
const [busyStatusOrderId, setBusyStatusOrderId] = useState<number | null>(null); const [busyStatusOrderId, setBusyStatusOrderId] = useState<number | null>(null);
const [statusMessage, setStatusMessage] = useState(''); const [statusMessage, setStatusMessage] = useState('');
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null); const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null);
@@ -169,13 +172,15 @@ const ProductionOrders = () => {
const load = async () => { const load = async () => {
setIsLoading(true); setIsLoading(true);
const [nextSummary, nextSupplySummary] = await Promise.all([ const [nextSummary, nextSupplySummary, nextWorkstations] = await Promise.all([
fetchProductionOrders(dateRange, { search: searchTerm }), fetchProductionOrders(dateRange, { search: searchTerm }),
fetchSupplySummary() fetchSupplySummary(),
fetchProductionWorkstations()
]); ]);
if (isMounted) { if (isMounted) {
setSummary(nextSummary); setSummary(nextSummary);
setSupplyLots(nextSupplySummary.lots); setSupplyLots(nextSupplySummary.lots);
setWorkstations(nextWorkstations);
setIsLoading(false); setIsLoading(false);
} }
}; };
@@ -313,6 +318,35 @@ const ProductionOrders = () => {
} }
}; };
const handleDefectSave = async (event: FormEvent<HTMLFormElement>, order: ProductionOrderItem) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const quantity = Number(String(form.get('quantity') || '').replace(',', '.'));
const workstationId = Number(form.get('workstationId'));
if (!workstationId || !Number.isFinite(quantity) || quantity <= 0) return;
setBusyDefectOrderId(order.id);
setDefectMessage('');
try {
await addProductionOrderDefect(order.id, {
workstationId,
defectType: String(form.get('defectType') || '').trim(),
origin: String(form.get('origin') || '').trim(),
quantity,
lotCode: String(form.get('lotCode') || '').trim(),
notes: String(form.get('notes') || '').trim(),
});
event.currentTarget.reset();
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, { force: true });
setSummary(nextSummary);
setDefectMessage(`Defeito registrado na OP ${order.number || `#${order.id}`}.`);
} catch (error) {
setDefectMessage(error instanceof Error ? error.message : 'Não foi possível registrar o defeito.');
} finally {
setBusyDefectOrderId(null);
}
};
const printProductionSheet = (order: ProductionOrderItem) => { const printProductionSheet = (order: ProductionOrderItem) => {
const printWindow = window.open('', '_blank', 'noopener,noreferrer'); const printWindow = window.open('', '_blank', 'noopener,noreferrer');
if (!printWindow) { if (!printWindow) {
@@ -392,6 +426,27 @@ const ProductionOrders = () => {
</form> </form>
</section> </section>
<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">
<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>
<label className="text-xs font-bold text-dark-muted">Quantidade<input required name="quantity" inputMode="decimal" 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">Lote (opcional)<input name="lotCode" defaultValue={order.lotCode} 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 do lote" /></label>
<label className="text-xs font-bold text-dark-muted sm:col-span-2">Origem (opcional)<input name="origin" 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.: recebimento, corte, costura" /></label>
<label className="text-xs font-bold text-dark-muted sm:col-span-2">Observação (opcional)<input name="notes" 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="Detalhe necessário para a 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">{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>
{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>
<section> <section>
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Composição</h3> <h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Composição</h3>
{order.components.length ? <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">Material</th><th className="px-3 py-2.5 text-right font-bold">Por unidade</th><th className="px-3 py-2.5 text-right font-bold">Total</th></tr></thead><tbody className="divide-y divide-dark-border">{order.components.map(component => <tr key={component.id}><td className="px-3 py-3"><p className="font-bold text-dark-text">{component.componentName}</p><p className="mt-0.5 font-mono text-[10px] font-semibold text-dark-muted">{component.componentSku || 'Sem SKU'}</p></td><td className="px-3 py-3 text-right font-semibold text-dark-muted">{formatQuantity(component.quantityPerUnit)} {component.unit}</td><td className="px-3 py-3 text-right font-bold text-dark-text">{formatQuantity(component.totalQuantity)} {component.unit}</td></tr>)}</tbody></table></div> : <p className="mt-3 rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-semibold text-dark-muted">Sem composição sincronizada.</p>} {order.components.length ? <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">Material</th><th className="px-3 py-2.5 text-right font-bold">Por unidade</th><th className="px-3 py-2.5 text-right font-bold">Total</th></tr></thead><tbody className="divide-y divide-dark-border">{order.components.map(component => <tr key={component.id}><td className="px-3 py-3"><p className="font-bold text-dark-text">{component.componentName}</p><p className="mt-0.5 font-mono text-[10px] font-semibold text-dark-muted">{component.componentSku || 'Sem SKU'}</p></td><td className="px-3 py-3 text-right font-semibold text-dark-muted">{formatQuantity(component.quantityPerUnit)} {component.unit}</td><td className="px-3 py-3 text-right font-bold text-dark-text">{formatQuantity(component.totalQuantity)} {component.unit}</td></tr>)}</tbody></table></div> : <p className="mt-3 rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-semibold text-dark-muted">Sem composição sincronizada.</p>}

View File

@@ -90,6 +90,36 @@ export interface ProductionOrderLotReservation {
lotSupplier: string; lotSupplier: string;
} }
export interface ProductionWorkstation {
id: number;
code: string;
name: string;
color: string;
sortOrder: number;
isActive: boolean;
}
export interface ProductionOrderDefect {
id: number;
workstationId: number | null;
workstationName: string;
defectType: string;
origin: string;
quantity: number;
lotCode: string;
notes: string;
occurredAt: string | null;
}
export type ProductionOrderDefectPayload = {
workstationId?: number | null;
defectType: string;
origin?: string;
quantity: number;
lotCode?: string;
notes?: string;
};
export interface ProductionOrderItem { export interface ProductionOrderItem {
id: number; id: number;
tinyId: string; tinyId: string;
@@ -121,6 +151,7 @@ export interface ProductionOrderItem {
rejectedQuantity: number; rejectedQuantity: number;
finishedQuantity: number | null; finishedQuantity: number | null;
qualityNotes: string; qualityNotes: string;
defects: ProductionOrderDefect[];
lotReservations: ProductionOrderLotReservation[]; lotReservations: ProductionOrderLotReservation[];
markers: ProductionOrderMarker[]; markers: ProductionOrderMarker[];
components: ProductionOrderComponent[]; components: ProductionOrderComponent[];