|
|
|
|
@@ -0,0 +1,155 @@
|
|
|
|
|
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
|
|
|
|
import { Link, useOutletContext } from 'react-router-dom';
|
|
|
|
|
import { AlertTriangle, ArrowLeft, ClipboardCheck, Factory, Loader2, Pencil, Plus, Save, Settings2, X } from 'lucide-react';
|
|
|
|
|
import DateRangePicker from '../components/DateRangePicker';
|
|
|
|
|
import { createProductionWorkstation, fetchProductionOrders, fetchProductionWorkstations, updateProductionWorkstation } from '../dataService';
|
|
|
|
|
import type { DateRange, ProductionOrderSummary, ProductionWorkstation } from '../types';
|
|
|
|
|
|
|
|
|
|
const emptySummary: ProductionOrderSummary = {
|
|
|
|
|
orders: [],
|
|
|
|
|
counts: { all: 0, open: 0, cutting: 0, sewing: 0, review: 0, finished: 0, canceled: 0 }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const inputClassName = '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 placeholder:text-dark-muted focus:border-brand-primary';
|
|
|
|
|
const formatQuantity = (value: number) => 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<ProductionOrderSummary>(emptySummary);
|
|
|
|
|
const [workstations, setWorkstations] = useState<ProductionWorkstation[]>([]);
|
|
|
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
|
const [message, setMessage] = useState('');
|
|
|
|
|
const [editingStation, setEditingStation] = useState<ProductionWorkstation | null>(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<string, { label: string; quantity: number; occurrences: number }>();
|
|
|
|
|
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<string, { label: string; quantity: number; occurrences: number }>();
|
|
|
|
|
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<HTMLFormElement>) => {
|
|
|
|
|
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 (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<Link to="/production-orders" className="mb-3 inline-flex items-center gap-2 text-sm font-bold text-dark-muted transition-colors hover:text-dark-text"><ArrowLeft className="h-4 w-4" />Ordens de Produção</Link>
|
|
|
|
|
<h1 className="text-2xl font-bold text-dark-text">Qualidade e postos</h1>
|
|
|
|
|
<p className="mt-2 font-medium text-dark-muted">Acompanhe defeitos por origem e mantenha as etapas reais da operação.</p>
|
|
|
|
|
</div>
|
|
|
|
|
<DateRangePicker dateRange={dateRange} onChange={setDateRange} refreshInterval={refreshInterval} setRefreshInterval={setRefreshInterval} onManualRefresh={() => void load()} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{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>}
|
|
|
|
|
|
|
|
|
|
{isLoading ? <div className="flex min-h-72 items-center justify-center rounded-2xl border border-dark-border bg-dark-card text-brand-primary"><Loader2 className="h-8 w-8 animate-spin" /></div> : <>
|
|
|
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
|
|
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="flex items-center justify-between"><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Defeitos registrados</p><AlertTriangle className="h-5 w-5 text-amber-300" /></div><p className="mt-3 text-3xl font-bold text-dark-text">{defects.length}</p><p className="mt-1 text-xs font-semibold text-dark-muted">Ocorrências no período</p></div>
|
|
|
|
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="flex items-center justify-between"><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Peças afetadas</p><ClipboardCheck className="h-5 w-5 text-amber-300" /></div><p className="mt-3 text-3xl font-bold text-amber-200">{formatQuantity(totalDefectiveQuantity)}</p><p className="mt-1 text-xs font-semibold text-dark-muted">Somadas pelos apontamentos</p></div>
|
|
|
|
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="flex items-center justify-between"><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">OPs com ocorrência</p><Factory className="h-5 w-5 text-violet-300" /></div><p className="mt-3 text-3xl font-bold text-dark-text">{affectedOrders}</p><p className="mt-1 text-xs font-semibold text-dark-muted">De {summary.orders.length} OPs no período</p></div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
|
|
|
|
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm"><div className="border-b border-dark-border p-5"><h2 className="font-bold text-dark-text">Levantamento de defeitos</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Priorize o maior impacto antes de atacar ocorrências isoladas.</p></div><div className="divide-y divide-dark-border">{workstationSummary.length ? workstationSummary.map(item => <div key={item.label} className="flex items-center justify-between gap-4 p-4"><div><p className="text-sm font-bold text-dark-text">{item.label}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{item.occurrences} {item.occurrences === 1 ? 'ocorrência' : 'ocorrências'}</p></div><p className="text-lg font-bold text-amber-200">{formatQuantity(item.quantity)}</p></div>) : <p className="p-5 text-sm font-semibold text-dark-muted">Nenhum defeito registrado no período.</p>}</div></section>
|
|
|
|
|
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm"><div className="border-b border-dark-border p-5"><h2 className="font-bold text-dark-text">Tipos mais recorrentes</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Use estes dados para orientar revisão, fornecedor e correção de processo.</p></div><div className="divide-y divide-dark-border">{defectTypeSummary.length ? defectTypeSummary.map(item => <div key={item.label} className="flex items-center justify-between gap-4 p-4"><div><p className="text-sm font-bold text-dark-text">{item.label}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{item.occurrences} {item.occurrences === 1 ? 'ocorrência' : 'ocorrências'}</p></div><p className="text-lg font-bold text-amber-200">{formatQuantity(item.quantity)}</p></div>) : <p className="p-5 text-sm font-semibold text-dark-muted">Nenhum tipo de defeito registrado no período.</p>}</div></section>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(380px,.8fr)]">
|
|
|
|
|
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm"><div className="border-b border-dark-border p-5"><h2 className="font-bold text-dark-text">Ocorrências recentes</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Rastreabilidade por OP, produto, posto e lote.</p></div><div className="divide-y divide-dark-border">{recentDefects.length ? recentDefects.map(defect => <div key={defect.id} className="grid gap-3 p-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start"><div><div className="flex flex-wrap items-center gap-2"><span className="font-mono text-xs font-bold text-brand-primary">{defect.orderNumber}</span><span className="rounded-full border border-dark-border bg-dark-input px-2 py-0.5 text-[10px] font-bold text-dark-muted">{defect.workstationName || 'Sem posto'}</span></div><p className="mt-2 text-sm font-bold text-dark-text">{defect.defectType}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{defect.productDescription} · {defect.origin || 'origem não informada'}{defect.lotCode ? ` · lote ${defect.lotCode}` : ''}</p>{defect.notes && <p className="mt-1 text-xs font-medium text-dark-muted">{defect.notes}</p>}</div><div className="text-right"><p className="text-sm font-bold text-amber-200">{formatQuantity(defect.quantity)} {defect.unit}</p><p className="mt-1 text-[11px] font-semibold text-dark-muted">{formatDateTime(defect.occurredAt)}</p></div></div>) : <p className="p-5 text-sm font-semibold text-dark-muted">Nenhuma ocorrência registrada no período.</p>}</div></section>
|
|
|
|
|
|
|
|
|
|
<section className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="flex items-center justify-between gap-3"><div><h2 className="font-bold text-dark-text">Postos de trabalho</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Etapas disponíveis para o apontamento da operação.</p></div><Settings2 className="h-5 w-5 text-brand-primary" /></div><form onSubmit={saveStation} className="mt-5 rounded-xl border border-dark-border bg-dark-input/40 p-3"><div className="flex items-center justify-between gap-3"><p className="text-xs font-bold text-dark-text">{editingStation ? 'Editar posto' : 'Novo posto'}</p>{editingStation && <button type="button" onClick={resetStationForm} className="inline-flex h-7 items-center gap-1 text-xs font-bold text-dark-muted hover:text-dark-text cursor-pointer"><X className="h-3.5 w-3.5" />Cancelar</button>}</div><div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-1"><label className="text-xs font-bold text-dark-muted">Nome<input required value={stationForm.name} onChange={(event) => setStationForm(current => ({ ...current, name: event.target.value }))} className={inputClassName} placeholder="Ex.: Bordado" /></label><label className="text-xs font-bold text-dark-muted">Código (opcional)<input value={stationForm.code} onChange={(event) => setStationForm(current => ({ ...current, code: event.target.value }))} className={inputClassName} placeholder="bordado" /></label><div className="grid grid-cols-2 gap-3"><label className="text-xs font-bold text-dark-muted">Ordem<input inputMode="numeric" value={stationForm.sortOrder} onChange={(event) => setStationForm(current => ({ ...current, sortOrder: event.target.value }))} className={inputClassName} /></label><label className="text-xs font-bold text-dark-muted">Cor<input type="color" value={stationForm.color} onChange={(event) => setStationForm(current => ({ ...current, color: event.target.value }))} className="mt-1 h-10 w-full cursor-pointer rounded-lg border border-dark-border bg-dark-input p-1" /></label></div><label className="flex items-center gap-2 text-xs font-bold text-dark-muted"><input type="checkbox" checked={stationForm.isActive} onChange={(event) => setStationForm(current => ({ ...current, isActive: event.target.checked }))} className="h-4 w-4 accent-brand-primary" />Disponível para novos apontamentos</label></div><button type="submit" disabled={isSaving} className="mt-4 inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg border border-brand-primary/35 bg-brand-primary/10 px-3 text-sm font-bold text-brand-primary transition-colors hover:bg-brand-primary/20 disabled:opacity-50 cursor-pointer">{editingStation ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}{isSaving ? 'Salvando...' : editingStation ? 'Salvar posto' : 'Adicionar posto'}</button></form><div className="mt-4 divide-y divide-dark-border rounded-xl border border-dark-border">{workstations.map(station => <div key={station.id} className="flex items-center gap-3 p-3"><span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: station.color }} /><div className="min-w-0 flex-1"><p className="truncate text-sm font-bold text-dark-text">{station.name}</p><p className="text-xs font-semibold text-dark-muted">{station.code} · posição {station.sortOrder}{station.isActive ? '' : ' · inativo'}</p></div><button type="button" onClick={() => editStation(station)} className="inline-flex h-8 w-8 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={`Editar ${station.name}`}><Pencil className="h-3.5 w-3.5" /></button></div>)}</div></section>
|
|
|
|
|
</div>
|
|
|
|
|
</>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default ProductionQuality;
|