feat: mapa termico de kits de corte e liberacao balanceada para solda

This commit is contained in:
2026-10-20 15:28:21 -03:00
parent f3eff4b805
commit fcb3fc08fd
6 changed files with 1816 additions and 43 deletions
@@ -0,0 +1,53 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { MapaTermicoKitsView } from './MapaTermicoKitsView';
import { PecaKitInfo } from '@/hooks/useKitsProducao';
import { Flame } from 'lucide-react';
interface MapaTermicoKitsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
ofNumber?: string;
fase?: string;
onSelectPecaParaApontar?: (peca: PecaKitInfo) => void;
}
export const MapaTermicoKitsModal: React.FC<MapaTermicoKitsModalProps> = ({
open,
onOpenChange,
ofNumber,
fase,
onSelectPecaParaApontar,
}) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto p-4 sm:p-6">
<DialogHeader className="pb-2 border-b border-slate-200 dark:border-slate-800">
<DialogTitle className="flex items-center gap-2 text-lg sm:text-xl font-black text-slate-900 dark:text-slate-100 uppercase tracking-tight">
<Flame className="w-5 h-5 text-orange-500 fill-orange-500 animate-pulse" />
<span>Mapa Térmico de Kits de Montagem</span>
</DialogTitle>
</DialogHeader>
<div className="py-2">
<MapaTermicoKitsView
initialOF={ofNumber}
initialFase={fase || 'all'}
readOnlyOF={Boolean(ofNumber)}
onSelectPecaParaApontar={(peca) => {
if (onSelectPecaParaApontar) {
onSelectPecaParaApontar(peca);
onOpenChange(false);
}
}}
/>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,574 @@
import React, { useState, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Flame,
CheckCircle2,
AlertTriangle,
Clock,
Layers,
Search,
RefreshCw,
ChevronDown,
ChevronUp,
PackageCheck,
ShieldCheck,
Wrench,
Scissors
} from 'lucide-react';
import { useKitsProducao, PecaKitInfo } from '@/hooks/useKitsProducao';
import { useOFs } from '@/hooks/useOFs';
interface MapaTermicoKitsViewProps {
initialOF?: string;
initialFase?: string;
readOnlyOF?: boolean;
onSelectPecaParaApontar?: (peca: PecaKitInfo) => void;
}
export const MapaTermicoKitsView: React.FC<MapaTermicoKitsViewProps> = ({
initialOF = '',
initialFase = 'all',
readOnlyOF = false,
onSelectPecaParaApontar,
}) => {
const { ofs, loading: loadingOFs } = useOFs();
const [selectedOF, setSelectedOF] = useState<string>(initialOF || (ofs[0]?.of_number || ''));
const [selectedFase, setSelectedFase] = useState<string>(initialFase);
const [busca, setBusca] = useState<string>('');
const [filtroStatus, setFiltroStatus] = useState<'todos' | 'liberados' | 'parciais' | 'travados'>('todos');
const [pecasExpandidas, setPecasExpandidas] = useState<Set<string>>(new Set());
// Atualizar OF padrão se carregar depois
React.useEffect(() => {
if (!selectedOF && ofs.length > 0) {
setSelectedOF(ofs[0].of_number);
}
}, [ofs, selectedOF]);
const { listaKits, resumoKits, loading, refetch } = useKitsProducao(selectedOF, selectedFase);
// Extrair fases únicas da lista de kits carregada
const fasesDisponiveis = useMemo(() => {
const s = new Set<string>();
listaKits.forEach((k) => {
if (k.etapa_fase) s.add(k.etapa_fase);
});
return Array.from(s).sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
}, [listaKits]);
// Alternar expansão de componentes de uma peça
const toggleExpand = (pecaId: string) => {
setPecasExpandidas((prev) => {
const next = new Set(prev);
if (next.has(pecaId)) {
next.delete(pecaId);
} else {
next.add(pecaId);
}
return next;
});
};
const expandirTodas = () => {
setPecasExpandidas(new Set(listaKits.map((k) => k.peca_id)));
};
const recolherTodas = () => {
setPecasExpandidas(new Set());
};
// Filtragem
const pecasFiltradas = useMemo(() => {
return listaKits.filter((item) => {
// Filtro de status
if (filtroStatus === 'liberados') {
if (item.kits_completos_cortados <= 0) return false;
} else if (filtroStatus === 'parciais') {
if (
item.kits_completos_cortados >= item.quantidade_total ||
item.status_termico === 'vermelho'
)
return false;
} else if (filtroStatus === 'travados') {
if (item.status_termico !== 'vermelho') return false;
}
// Filtro de busca textual
if (busca.trim()) {
const t = busca.toLowerCase().trim();
const bateMarca = item.marca.toLowerCase().includes(t);
const bateDesc = (item.descricao || '').toLowerCase().includes(t);
const batePerfil = (item.perfil_principal || '').toLowerCase().includes(t);
const bateComp = item.componentes.some((c) =>
c.marca_componente.toLowerCase().includes(t) || (c.perfil || '').toLowerCase().includes(t)
);
if (!bateMarca && !bateDesc && !batePerfil && !bateComp) return false;
}
return true;
});
}, [listaKits, filtroStatus, busca]);
return (
<div className="space-y-4 w-full">
{/* ──────────────── Barra de Controles e Seletores ──────────────── */}
<Card className="border border-slate-200 dark:border-slate-800 bg-white/90 dark:bg-slate-900/90 shadow-sm">
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<div className="p-2 rounded-xl bg-orange-100 text-orange-600 dark:bg-orange-950/60 dark:text-orange-400">
<Flame className="w-5 h-5 animate-pulse" />
</div>
<div>
<h3 className="font-black text-slate-900 dark:text-slate-100 tracking-tight text-base sm:text-lg flex items-center gap-2">
<span>Mapa Térmico de Kits de Produção</span>
<Badge variant="outline" className="border-orange-300 text-orange-700 dark:border-orange-800 dark:text-orange-400 text-xs font-bold">
Corte ➔ Solda
</Badge>
</h3>
<p className="text-xs text-slate-500 dark:text-slate-400">
Prontidão física de montagem: kits cortados disponíveis para liberar a Solda
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={loading}
className="text-xs gap-1.5 h-8 font-semibold"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
<span>Atualizar</span>
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-1">
{/* Seletor de OF */}
<div>
<label className="text-xs font-bold text-slate-600 dark:text-slate-400 block mb-1">
Ordem de Fabricação (OF)
</label>
{readOnlyOF ? (
<div className="h-9 px-3 rounded-lg border border-slate-200 dark:border-slate-800 bg-slate-100 dark:bg-slate-800 flex items-center font-bold text-xs">
OF {selectedOF}
</div>
) : (
<Select value={selectedOF} onValueChange={setSelectedOF} disabled={loadingOFs}>
<SelectTrigger className="h-9 text-xs font-semibold">
<SelectValue placeholder="Selecione uma OF..." />
</SelectTrigger>
<SelectContent>
{ofs.map((o) => (
<SelectItem key={o.id} value={o.of_number}>
OF {o.of_number} {o.cliente ? `• ${o.cliente}` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Seletor de Fase */}
<div>
<label className="text-xs font-bold text-slate-600 dark:text-slate-400 block mb-1">
Fase da OF
</label>
<Select value={selectedFase} onValueChange={setSelectedFase}>
<SelectTrigger className="h-9 text-xs font-semibold">
<SelectValue placeholder="Todas as fases" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as fases</SelectItem>
{fasesDisponiveis.map((f) => (
<SelectItem key={f} value={f}>
{f.toLowerCase().startsWith('fase') ? f : `Fase ${f}`}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Busca Rápida */}
<div>
<label className="text-xs font-bold text-slate-600 dark:text-slate-400 block mb-1">
Buscar Marca / Componente
</label>
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-3 top-3 text-slate-400" />
<Input
placeholder="Ex: V1, p2, W200..."
value={busca}
onChange={(e) => setBusca(e.target.value)}
className="h-9 pl-9 text-xs"
/>
</div>
</div>
</div>
</CardContent>
</Card>
{/* ──────────────── Cards de KPIs do Mapa Térmico ──────────────── */}
{resumoKits && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2.5 sm:gap-3">
{/* KPI 1: 100% Liberadas */}
<div className="p-3.5 rounded-2xl bg-emerald-50/80 dark:bg-emerald-950/40 border border-emerald-200 dark:border-emerald-800 shadow-sm flex items-center justify-between">
<div>
<div className="text-[11px] font-black uppercase text-emerald-700 dark:text-emerald-400">
100% Liberadas
</div>
<div className="text-2xl font-black text-emerald-900 dark:text-emerald-100 mt-0.5">
{resumoKits.totalKits100Porcento}
</div>
<div className="text-[10px] text-emerald-700/80 dark:text-emerald-400/80 font-medium">
peças com todos os kits cortados
</div>
</div>
<div className="p-2.5 rounded-xl bg-emerald-100 text-emerald-700 dark:bg-emerald-900/60 dark:text-emerald-300">
<CheckCircle2 className="w-6 h-6" />
</div>
</div>
{/* KPI 2: Kits Montáveis Liberados */}
<div className="p-3.5 rounded-2xl bg-blue-50/80 dark:bg-blue-950/40 border border-blue-200 dark:border-blue-800 shadow-sm flex items-center justify-between">
<div>
<div className="text-[11px] font-black uppercase text-blue-700 dark:text-blue-400">
Unidades Liberadas
</div>
<div className="text-2xl font-black text-blue-900 dark:text-blue-100 mt-0.5">
{resumoKits.totalUnidadesLiberadasSolda} <span className="text-xs text-blue-600 font-bold">/ {resumoKits.totalUnidadesTotal} un</span>
</div>
<div className="text-[10px] text-blue-700/80 dark:text-blue-400/80 font-medium">
kits prontos na bancada de solda
</div>
</div>
<div className="p-2.5 rounded-xl bg-blue-100 text-blue-700 dark:bg-blue-900/60 dark:text-blue-300">
<PackageCheck className="w-6 h-6" />
</div>
</div>
{/* KPI 3: Em Andamento / Quase Prontos */}
<div className="p-3.5 rounded-2xl bg-amber-50/80 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 shadow-sm flex items-center justify-between">
<div>
<div className="text-[11px] font-black uppercase text-amber-700 dark:text-amber-400">
Corte em Andamento
</div>
<div className="text-2xl font-black text-amber-900 dark:text-amber-100 mt-0.5">
{resumoKits.totalKitsParciais}
</div>
<div className="text-[10px] text-amber-700/80 dark:text-amber-400/80 font-medium">
kits parciais (prioridade de corte)
</div>
</div>
<div className="p-2.5 rounded-xl bg-amber-100 text-amber-700 dark:bg-amber-900/60 dark:text-amber-300">
<Clock className="w-6 h-6" />
</div>
</div>
{/* KPI 4: Travados / 0% */}
<div className="p-3.5 rounded-2xl bg-rose-50/80 dark:bg-rose-950/40 border border-rose-200 dark:border-rose-800 shadow-sm flex items-center justify-between">
<div>
<div className="text-[11px] font-black uppercase text-rose-700 dark:text-rose-400">
Não Iniciados
</div>
<div className="text-2xl font-black text-rose-900 dark:text-rose-100 mt-0.5">
{resumoKits.totalKitsNaoIniciados}
</div>
<div className="text-[10px] text-rose-700/80 dark:text-rose-400/80 font-medium">
sem nenhum componente cortado
</div>
</div>
<div className="p-2.5 rounded-xl bg-rose-100 text-rose-700 dark:bg-rose-900/60 dark:text-rose-300">
<AlertTriangle className="w-6 h-6" />
</div>
</div>
</div>
)}
{/* ──────────────── Filtros por Status Térmico & Ações em Massa ──────────────── */}
<div className="flex items-center justify-between gap-2 flex-wrap text-xs">
<div className="flex items-center gap-1.5 flex-wrap">
<button
type="button"
onClick={() => setFiltroStatus('todos')}
className={`px-3 py-1.5 rounded-xl font-bold transition-colors ${
filtroStatus === 'todos'
? 'bg-slate-900 text-white dark:bg-white dark:text-slate-900 shadow-sm'
: 'bg-slate-100 hover:bg-slate-200 text-slate-700 dark:bg-slate-800 dark:hover:bg-slate-700 dark:text-slate-300'
}`}
>
Todas ({listaKits.length})
</button>
<button
type="button"
onClick={() => setFiltroStatus('liberados')}
className={`px-3 py-1.5 rounded-xl font-bold flex items-center gap-1.5 transition-colors ${
filtroStatus === 'liberados'
? 'bg-emerald-600 text-white shadow-sm'
: 'bg-emerald-50 text-emerald-800 hover:bg-emerald-100 dark:bg-emerald-950/50 dark:text-emerald-300'
}`}
>
<span className="w-2 h-2 rounded-full bg-emerald-400"></span>
Liberadas para Solda ({listaKits.filter((k) => k.kits_completos_cortados > 0).length})
</button>
<button
type="button"
onClick={() => setFiltroStatus('parciais')}
className={`px-3 py-1.5 rounded-xl font-bold flex items-center gap-1.5 transition-colors ${
filtroStatus === 'parciais'
? 'bg-amber-600 text-white shadow-sm'
: 'bg-amber-50 text-amber-800 hover:bg-amber-100 dark:bg-amber-950/50 dark:text-amber-300'
}`}
>
<span className="w-2 h-2 rounded-full bg-amber-400"></span>
Em Andamento / Quase Prontas ({listaKits.filter((k) => k.status_termico === 'amarelo' || (k.kits_completos_cortados > 0 && k.kits_completos_cortados < k.quantidade_total)).length})
</button>
<button
type="button"
onClick={() => setFiltroStatus('travados')}
className={`px-3 py-1.5 rounded-xl font-bold flex items-center gap-1.5 transition-colors ${
filtroStatus === 'travados'
? 'bg-rose-600 text-white shadow-sm'
: 'bg-rose-50 text-rose-800 hover:bg-rose-100 dark:bg-rose-950/50 dark:text-rose-300'
}`}
>
<span className="w-2 h-2 rounded-full bg-rose-400"></span>
Travadas no Corte ({listaKits.filter((k) => k.status_termico === 'vermelho').length})
</button>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={expandirTodas} className="h-7 text-[11px] font-semibold text-slate-600 dark:text-slate-400">
Expandir Todos
</Button>
<Button variant="ghost" size="sm" onClick={recolherTodas} className="h-7 text-[11px] font-semibold text-slate-600 dark:text-slate-400">
Recolher Todos
</Button>
</div>
</div>
{/* ──────────────── Lista de Peças com Mapa Térmico ──────────────── */}
{loading ? (
<div className="py-16 flex flex-col items-center justify-center text-slate-400 gap-3">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-orange-500 border-t-transparent" />
<span className="text-sm font-semibold">Calculando matriz de kits e completude...</span>
</div>
) : pecasFiltradas.length === 0 ? (
<Card className="border border-dashed border-slate-300 dark:border-slate-800 p-8 text-center bg-slate-50/50 dark:bg-slate-900/50">
<Layers className="w-10 h-10 text-slate-400 mx-auto mb-2 opacity-60" />
<h4 className="font-bold text-slate-700 dark:text-slate-300 text-sm">Nenhuma peça encontrada</h4>
<p className="text-xs text-slate-500 mt-1">Tente ajustar a busca ou os filtros de status</p>
</Card>
) : (
<div className="space-y-3">
{pecasFiltradas.map((peca) => {
const isExpanded = pecasExpandidas.has(peca.peca_id);
const totalKits = peca.quantidade_total;
const kitsProntos = peca.kits_completos_cortados;
const pct = peca.percentual_termico;
const eh100 = kitsProntos >= totalKits;
const temParcial = kitsProntos > 0 && kitsProntos < totalKits;
const gargalo = peca.componente_gargalo;
// Cores térmicas
let barColor = 'bg-rose-500';
let badgeBg = 'bg-rose-100 text-rose-800 border-rose-300 dark:bg-rose-950/60 dark:text-rose-300 dark:border-rose-800';
if (eh100) {
barColor = 'bg-emerald-500';
badgeBg = 'bg-emerald-100 text-emerald-800 border-emerald-300 dark:bg-emerald-950/60 dark:text-emerald-300 dark:border-emerald-800';
} else if (temParcial) {
barColor = 'bg-emerald-500';
badgeBg = 'bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-950/60 dark:text-blue-300 dark:border-blue-800';
} else if (pct >= 40) {
barColor = 'bg-amber-500';
badgeBg = 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/60 dark:text-amber-300 dark:border-amber-800';
}
return (
<div
key={peca.peca_id}
className="rounded-2xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900/90 shadow-sm overflow-hidden transition-all hover:border-slate-300 dark:hover:border-slate-700"
>
{/* Linha Principal do Card */}
<div
onClick={() => toggleExpand(peca.peca_id)}
className="p-4 cursor-pointer flex flex-col sm:flex-row sm:items-center justify-between gap-3 hover:bg-slate-50/50 dark:hover:bg-slate-800/40 transition-colors select-none"
>
{/* Lado Esquerdo: Identificação da Peça */}
<div className="flex items-start gap-3 min-w-0">
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 font-black text-sm shadow-sm ${
eh100
? 'bg-emerald-500 text-white'
: temParcial
? 'bg-blue-500 text-white'
: pct >= 40
? 'bg-amber-500 text-slate-950'
: 'bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-400'
}`}
>
{pct}%
</div>
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-lg font-black text-slate-900 dark:text-slate-100 tracking-wide">
{peca.marca}
</span>
<Badge variant="outline" className="text-[11px] font-bold">
{peca.etapa_fase.toLowerCase().startsWith('fase') ? peca.etapa_fase : `Fase ${peca.etapa_fase}`}
</Badge>
{peca.perfil_principal && (
<span className="text-xs px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 font-semibold font-mono">
{peca.perfil_principal}
</span>
)}
{peca.tem_componentes === false && (
<Badge variant="secondary" className="bg-purple-100 text-purple-800 dark:bg-purple-950 dark:text-purple-300 text-[10px] font-black">
S/M (Sem Montagem)
</Badge>
)}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400 line-clamp-1">
{peca.descricao || 'Peça Estrutural'}
</div>
</div>
</div>
{/* Lado Direito: Status dos Kits e Métricas */}
<div className="flex items-center gap-3 shrink-0 justify-between sm:justify-end">
<div className="text-right">
<div className="flex items-center justify-end gap-1.5 flex-wrap">
<span className={`text-xs font-black px-2.5 py-0.5 rounded-full border ${badgeBg}`}>
{eh100
? `✅ 100% Liberado (${kitsProntos}/${totalKits} un)`
: temParcial
? `🟢 ${kitsProntos}/${totalKits} kits liberados na Solda`
: pct >= 40
? `🟡 0/${totalKits} kits (Corte ${pct}%)`
: `🔴 0/${totalKits} kits (Não iniciado)`}
</span>
</div>
{/* Gargalo em Destaque */}
{gargalo && kitsProntos < totalKits && (
<div className="text-[11px] font-semibold text-amber-700 dark:text-amber-400 mt-1 flex items-center justify-end gap-1">
<AlertTriangle className="w-3 h-3 text-amber-500 shrink-0" />
<span>
Gargalo: <strong>{gargalo.marca_componente}</strong> (faltam {gargalo.falta_para_prox_kit} un para próx. kit)
</span>
</div>
)}
</div>
<div className="p-1 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-200">
{isExpanded ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
</div>
</div>
</div>
{/* Barra Térmica de Progresso */}
<div className="px-4 pb-3">
<div className="w-full bg-slate-100 dark:bg-slate-800 h-2.5 rounded-full overflow-hidden flex">
<div
className={`h-full transition-all duration-300 ${barColor}`}
style={{ width: `${pct}%` }}
title={`Prontidão térmica do kit: ${pct}%`}
/>
</div>
</div>
{/* ──────────────── Detalhamento dos Componentes do Kit (Expansível) ──────────────── */}
{isExpanded && (
<div className="px-4 pb-4 pt-2 border-t border-slate-100 dark:border-slate-800/80 bg-slate-50/60 dark:bg-slate-950/40 space-y-2">
<div className="flex items-center justify-between text-xs font-bold text-slate-700 dark:text-slate-300 mb-2">
<span className="flex items-center gap-1.5">
<Wrench className="w-3.5 h-3.5 text-blue-500" />
Componentes Requeridos para {totalKits}x {peca.marca} ({peca.componentes.length} itens no kit):
</span>
{onSelectPecaParaApontar && (
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
onSelectPecaParaApontar(peca);
}}
className="h-7 text-xs bg-amber-500 hover:bg-amber-600 text-slate-950 font-black gap-1"
>
<Scissors className="w-3 h-3" />
Apontar no Corte
</Button>
)}
</div>
{peca.componentes.length === 0 ? (
<p className="text-xs text-slate-500 italic py-1">
Esta peça não possui subcomponentes cadastrados (é fabricada como peça inteira).
</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{peca.componentes.map((comp) => {
const isGargalo = gargalo?.id === comp.id && kitsProntos < totalKits;
const comp100 = comp.concluido;
return (
<div
key={comp.id}
className={`p-2.5 rounded-xl border text-xs transition-colors flex items-center justify-between gap-2 ${
isGargalo
? 'bg-amber-50 dark:bg-amber-950/40 border-amber-400 dark:border-amber-700 shadow-sm'
: comp100
? 'bg-emerald-50/50 dark:bg-emerald-950/20 border-emerald-200 dark:border-emerald-800/60'
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800'
}`}
>
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-mono font-black text-slate-900 dark:text-slate-100">
{comp.marca_componente}
</span>
{comp.perfil && (
<span className="text-[10px] px-1.5 py-0.2 rounded bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 font-mono">
{comp.perfil}
</span>
)}
{isGargalo && (
<span className="text-[9px] font-black px-1.5 py-0.5 rounded bg-amber-500 text-slate-950 uppercase tracking-tight">
Gargalo
</span>
)}
</div>
<div className="text-[11px] text-slate-500 dark:text-slate-400">
{comp.quantidade_por_peca} un/peça • Total da OF: <strong>{comp.quantidade_total_necessaria} un</strong>
</div>
</div>
<div className="text-right shrink-0">
<div className="font-mono font-black text-xs text-slate-800 dark:text-slate-200">
{comp.quantidade_cortada} / {comp.quantidade_total_necessaria} un
</div>
<div className="text-[10px] font-bold text-slate-500 dark:text-slate-400">
Dá para {comp.kits_possiveis} {comp.kits_possiveis === 1 ? 'kit' : 'kits'} ({comp.percentual_cortado}%)
</div>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
};
+461
View File
@@ -0,0 +1,461 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
export interface ComponenteKitInfo {
id: string;
peca_id: string;
marca_componente: string;
descricao?: string | null;
perfil?: string | null;
peso_unitario: number;
quantidade_por_peca: number;
quantidade_total_necessaria: number;
quantidade_cortada: number;
kits_possiveis: number;
percentual_cortado: number;
falta_para_prox_kit: number;
concluido: boolean;
}
export interface PecaKitInfo {
peca_id: string;
marca: string;
descricao?: string | null;
perfil_principal?: string | null;
quantidade_total: number;
etapa_fase: string;
tem_componentes: boolean;
componentes: ComponenteKitInfo[];
kits_completos_cortados: number; // Quantidade de kits 100% montáveis liberados
percentual_termico: number; // 0 a 100%
status_termico: 'verde' | 'verde_parcial' | 'amarelo' | 'vermelho';
componente_gargalo: ComponenteKitInfo | null;
peso_total_componentes: number;
peso_cortado_componentes: number;
ja_apontado_solda: number;
saldo_disponivel_solda: number;
bloqueado_para_solda: boolean;
motivo_bloqueio_solda: string | null;
}
export interface ResumoKitsOF {
totalPecas: number;
totalKits100Porcento: number;
totalKitsParciais: number;
totalKitsNaoIniciados: number;
pesoTotalOFKg: number;
pesoCortadoTotalKg: number;
percentualGeralOF: number;
totalUnidadesTotal: number;
totalUnidadesLiberadasSolda: number;
}
/**
* Função pura para cálculo determinístico de Kits de Produção.
* Recebe peças, componentes e apontamentos e retorna o mapa térmico de kits.
*/
export const calcularKitsOF = (
pecas: Array<{
id: string;
marca: string;
descricao?: string | null;
perfil_principal?: string | null;
quantidade: number;
etapa_fase?: string | null;
tem_componentes?: boolean | null;
}>,
componentes: Array<{
id: string;
peca_id: string;
marca_componente: string;
descricao?: string | null;
perfil?: string | null;
peso_unitario?: number | null;
quantidade_por_peca?: number | null;
}>,
apontamentos: Array<{
id?: string;
peca_id?: string | null;
componente_id?: string | null;
processo_id: string;
quantidade_produzida: number;
tipo_apontamento?: string | null;
}>,
processoCorteId?: string,
processoSoldaId?: string
): Map<string, PecaKitInfo> => {
const mapaKits = new Map<string, PecaKitInfo>();
// 1. Mapear componentes por peca_id
const componentesPorPeca = new Map<string, typeof componentes>();
componentes.forEach((c) => {
if (!componentesPorPeca.has(c.peca_id)) {
componentesPorPeca.set(c.peca_id, []);
}
componentesPorPeca.get(c.peca_id)!.push(c);
});
// 2. Mapear apontamentos de corte por componente_id
const cortadoPorComponente = new Map<string, number>();
// Apontamentos de corte por peca_id (caso seja peça sem componentes ou apontamento direto)
const cortadoPecaNoCorte = new Map<string, number>();
// Apontamentos de solda por peca_id
const apontadoNaSoldaPorPeca = new Map<string, number>();
apontamentos.forEach((ap) => {
const qtd = Number(ap.quantidade_produzida || 0);
// Se for apontamento de componente no corte (ou se não especificou processoCorteId, computa todos de componente)
if (ap.tipo_apontamento === 'componente' && ap.componente_id) {
if (!processoCorteId || ap.processo_id === processoCorteId) {
cortadoPorComponente.set(
ap.componente_id,
(cortadoPorComponente.get(ap.componente_id) || 0) + qtd
);
}
}
// Se for apontamento de peça mestre no corte
if (
(!ap.tipo_apontamento || ap.tipo_apontamento === 'peca') &&
!ap.componente_id &&
ap.peca_id
) {
if (!processoCorteId || ap.processo_id === processoCorteId) {
cortadoPecaNoCorte.set(
ap.peca_id,
(cortadoPecaNoCorte.get(ap.peca_id) || 0) + qtd
);
}
}
// Se for apontamento de solda
if (
(!ap.tipo_apontamento || ap.tipo_apontamento === 'peca') &&
!ap.componente_id &&
ap.peca_id
) {
if (processoSoldaId && ap.processo_id === processoSoldaId) {
apontadoNaSoldaPorPeca.set(
ap.peca_id,
(apontadoNaSoldaPorPeca.get(ap.peca_id) || 0) + qtd
);
}
}
});
// 3. Processar cada peça
pecas.forEach((peca) => {
const compsRaw = componentesPorPeca.get(peca.id) || [];
const qtdTotalPeca = Number(peca.quantidade) || 0;
const fase = peca.etapa_fase || 'Geral';
const temComps = peca.tem_componentes !== false && compsRaw.length > 0;
const jaApontadoSolda = apontadoNaSoldaPorPeca.get(peca.id) || 0;
// Cenário A: Peça sem componentes (monobloco / S/M)
if (!temComps) {
const qtdCortada = cortadoPecaNoCorte.get(peca.id) || 0;
const kitsCompletos = Math.min(qtdTotalPeca, qtdCortada);
const pct = qtdTotalPeca > 0 ? Math.min(100, Math.round((kitsCompletos / qtdTotalPeca) * 100)) : 0;
const saldoSolda = Math.max(0, kitsCompletos - jaApontadoSolda);
mapaKits.set(peca.id, {
peca_id: peca.id,
marca: peca.marca,
descricao: peca.descricao,
perfil_principal: peca.perfil_principal,
quantidade_total: qtdTotalPeca,
etapa_fase: fase,
tem_componentes: false,
componentes: [],
kits_completos_cortados: kitsCompletos,
percentual_termico: pct,
status_termico:
kitsCompletos >= qtdTotalPeca
? 'verde'
: kitsCompletos > 0
? 'verde_parcial'
: pct >= 40
? 'amarelo'
: 'vermelho',
componente_gargalo: null,
peso_total_componentes: 0,
peso_cortado_componentes: 0,
ja_apontado_solda: jaApontadoSolda,
saldo_disponivel_solda: peca.tem_componentes === false ? 0 : saldoSolda,
bloqueado_para_solda: peca.tem_componentes === false || saldoSolda <= 0,
motivo_bloqueio_solda:
peca.tem_componentes === false
? 'Peça S/M (Pula Solda)'
: saldoSolda <= 0 && kitsCompletos === 0
? 'Aguardando corte da peça'
: saldoSolda <= 0
? `Todos os ${kitsCompletos} kits cortados já foram soldados`
: null,
});
return;
}
// Cenário B: Peça com componentes
let pesoTotalComps = 0;
let pesoCortadoComps = 0;
let menorKits = qtdTotalPeca;
let gargaloComp: ComponenteKitInfo | null = null;
let menorRazao = 999999;
const listaCompsInfo: ComponenteKitInfo[] = [];
compsRaw.forEach((comp) => {
const porPeca = Math.max(1, Number(comp.quantidade_por_peca) || 1);
const totalNecessario = qtdTotalPeca * porPeca;
const cortado = cortadoPorComponente.get(comp.id) || 0;
const pesoUnit = Number(comp.peso_unitario) || 0;
const kitsDesteComp = Math.floor(cortado / porPeca);
if (kitsDesteComp < menorKits) {
menorKits = kitsDesteComp;
}
const pctComp = totalNecessario > 0 ? Math.min(100, Math.round((cortado / totalNecessario) * 100)) : 0;
const pesoTotalComp = totalNecessario * pesoUnit;
const pesoCortadoComp = Math.min(cortado, totalNecessario) * pesoUnit;
pesoTotalComps += pesoTotalComp;
pesoCortadoComps += pesoCortadoComp;
const infoComp: ComponenteKitInfo = {
id: comp.id,
peca_id: comp.peca_id,
marca_componente: comp.marca_componente,
descricao: comp.descricao,
perfil: comp.perfil,
peso_unitario: pesoUnit,
quantidade_por_peca: porPeca,
quantidade_total_necessaria: totalNecessario,
quantidade_cortada: cortado,
kits_possiveis: kitsDesteComp,
percentual_cortado: pctComp,
falta_para_prox_kit: 0, // calculado logo abaixo
concluido: cortado >= totalNecessario,
};
listaCompsInfo.push(infoComp);
});
const kitsCompletos = Math.min(qtdTotalPeca, Math.max(0, menorKits));
// Determinar o componente gargalo para o próximo kit
listaCompsInfo.forEach((ci) => {
const necessarioParaProxKit = Math.min(qtdTotalPeca, kitsCompletos + 1) * ci.quantidade_por_peca;
ci.falta_para_prox_kit = Math.max(0, necessarioParaProxKit - ci.quantidade_cortada);
// Critério de gargalo: quem mais segura a formação de novos kits
const razao = ci.kits_possiveis;
if (razao < menorRazao && kitsCompletos < qtdTotalPeca) {
menorRazao = razao;
gargaloComp = ci;
}
});
// Percentual térmico de prontidão ponderado por peso (ou por qtd se peso=0)
let percentualTermico = 0;
if (pesoTotalComps > 0) {
percentualTermico = Math.min(100, Math.round((pesoCortadoComps / pesoTotalComps) * 100));
} else {
const qtdTotalNecessaria = listaCompsInfo.reduce((s, c) => s + c.quantidade_total_necessaria, 0);
const qtdTotalCortada = listaCompsInfo.reduce((s, c) => s + Math.min(c.quantidade_cortada, c.quantidade_total_necessaria), 0);
percentualTermico = qtdTotalNecessaria > 0 ? Math.min(100, Math.round((qtdTotalCortada / qtdTotalNecessaria) * 100)) : 0;
}
// Status térmico
let statusTermico: 'verde' | 'verde_parcial' | 'amarelo' | 'vermelho' = 'vermelho';
if (kitsCompletos >= qtdTotalPeca) {
statusTermico = 'verde';
} else if (kitsCompletos > 0) {
statusTermico = 'verde_parcial';
} else if (percentualTermico >= 40) {
statusTermico = 'amarelo';
} else {
statusTermico = 'vermelho';
}
const saldoSolda = Math.max(0, kitsCompletos - jaApontadoSolda);
const bloqueadoSolda = saldoSolda <= 0;
let motivoBloqueioSolda: string | null = null;
if (bloqueadoSolda) {
if (kitsCompletos === 0) {
const detGargalo = gargaloComp ? ` • Gargalo: ${gargaloComp.marca_componente} (faltam ${gargaloComp.falta_para_prox_kit} un)` : '';
motivoBloqueioSolda = `Aguardando kits completos de corte (0 un liberadas)${detGargalo}`;
} else if (jaApontadoSolda >= kitsCompletos) {
motivoBloqueioSolda = `Todos os ${kitsCompletos} kits cortados já foram soldados (${jaApontadoSolda}/${qtdTotalPeca} un) • Aguardando novos kits cortados`;
}
}
mapaKits.set(peca.id, {
peca_id: peca.id,
marca: peca.marca,
descricao: peca.descricao,
perfil_principal: peca.perfil_principal,
quantidade_total: qtdTotalPeca,
etapa_fase: fase,
tem_componentes: true,
componentes: listaCompsInfo,
kits_completos_cortados: kitsCompletos,
percentual_termico: percentualTermico,
status_termico: statusTermico,
componente_gargalo: gargaloComp,
peso_total_componentes: pesoTotalComps,
peso_cortado_componentes: pesoCortadoComps,
ja_apontado_solda: jaApontadoSolda,
saldo_disponivel_solda: saldoSolda,
bloqueado_para_solda: bloqueadoSolda,
motivo_bloqueio_solda: motivoBloqueioSolda,
});
});
return mapaKits;
};
/**
* Hook completo para carregar e gerenciar os Kits de uma OF e Fase.
*/
export const useKitsProducao = (ofNumber?: string, fase?: string) => {
const { data, isLoading, refetch, error } = useQuery({
queryKey: ['kits-producao', ofNumber, fase],
enabled: Boolean(ofNumber),
staleTime: 10000, // 10 segundos
queryFn: async () => {
if (!ofNumber) return { mapaKits: new Map<string, PecaKitInfo>(), resumo: null, lista: [] };
// 1. Buscar processos para identificar IDs de Corte e Solda
const { data: procs } = await supabase
.from('processos_fabricacao')
.select('id, nome, ordem')
.order('ordem');
const procCorte = (procs || []).find(
(p) => p.nome.toLowerCase().includes('corte') || p.nome.toLowerCase().includes('prepar') || p.ordem === 1
);
const procSolda = (procs || []).find(
(p) => p.nome.toLowerCase().includes('solda') || p.nome.toLowerCase().includes('montag') || p.ordem === 3
);
// 2. Buscar peças da OF
let pecasQuery = supabase
.from('pecas')
.select('id, marca, descricao, perfil_principal, quantidade, etapa_fase, tem_componentes')
.eq('of_number', ofNumber);
if (fase && fase !== 'all' && fase !== 'Todas as fases' && fase !== 'todas') {
pecasQuery = pecasQuery.eq('etapa_fase', fase);
}
const { data: pecasData, error: pecasErr } = await pecasQuery;
if (pecasErr) throw pecasErr;
const pecas = pecasData || [];
const pecaIds = pecas.map((p) => p.id);
// 3. Buscar componentes das peças
let componentes: Array<{
id: string;
peca_id: string;
marca_componente: string;
descricao?: string | null;
perfil?: string | null;
peso_unitario?: number | null;
quantidade_por_peca?: number | null;
}> = [];
if (pecaIds.length > 0) {
for (let i = 0; i < pecaIds.length; i += 300) {
const chunk = pecaIds.slice(i, i + 300);
const { data: compsChunk, error: compsErr } = await supabase
.from('componentes_peca')
.select('id, peca_id, marca_componente, descricao, perfil, peso_unitario, quantidade_por_peca')
.in('peca_id', chunk);
if (compsErr) throw compsErr;
if (compsChunk) {
componentes = [...componentes, ...compsChunk];
}
}
}
// 4. Buscar apontamentos da OF
const { data: apontamentosData, error: aptsErr } = await supabase
.from('apontamentos_producao')
.select('id, peca_id, componente_id, processo_id, quantidade_produzida, tipo_apontamento')
.eq('of_number', ofNumber);
if (aptsErr) throw aptsErr;
// 5. Calcular kits
const mapaKits = calcularKitsOF(
pecas,
componentes,
apontamentosData || [],
procCorte?.id,
procSolda?.id
);
const lista = Array.from(mapaKits.values()).sort((a, b) =>
a.marca.localeCompare(b.marca, undefined, { numeric: true, sensitivity: 'base' })
);
// 6. Resumo geral
let total100 = 0;
let totalParciais = 0;
let totalTravados = 0;
let pesoTot = 0;
let pesoCort = 0;
let unTot = 0;
let unLib = 0;
lista.forEach((item) => {
unTot += item.quantidade_total;
unLib += item.kits_completos_cortados;
pesoTot += item.peso_total_componentes;
pesoCort += item.peso_cortado_componentes;
if (item.status_termico === 'verde') total100++;
else if (item.status_termico === 'verde_parcial' || item.status_termico === 'amarelo') totalParciais++;
else totalTravados++;
});
const resumo: ResumoKitsOF = {
totalPecas: lista.length,
totalKits100Porcento: total100,
totalKitsParciais: totalParciais,
totalKitsNaoIniciados: totalTravados,
pesoTotalOFKg: pesoTot,
pesoCortadoTotalKg: pesoCort,
percentualGeralOF: pesoTot > 0 ? Math.round((pesoCortadoTotalKg(pesoTot, pesoCort)) * 100) : 0,
totalUnidadesTotal: unTot,
totalUnidadesLiberadasSolda: unLib,
};
return {
mapaKits,
resumo,
lista,
};
},
});
return {
kitsPorPeca: data?.mapaKits || new Map<string, PecaKitInfo>(),
listaKits: data?.lista || [],
resumoKits: data?.resumo,
loading: isLoading,
refetch,
error,
};
};
function pesoCortadoTotalKg(total: number, cortado: number): number {
if (total <= 0) return 0;
return Math.min(1, cortado / total);
}
+29 -2
View File
@@ -3,10 +3,11 @@ import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ClipboardList, BarChart3, Settings } from 'lucide-react';
import { ClipboardList, BarChart3, Settings, Flame } from 'lucide-react';
import { ApontamentoForm } from '@/components/apontamento/ApontamentoForm';
import { ApontamentosListOtimizado } from '@/components/apontamento/ApontamentosListOtimizado';
import { ProcessosList } from '@/components/apontamento/ProcessosList';
import { MapaTermicoKitsView } from '@/components/apontamento/kits/MapaTermicoKitsView';
import { useApontamentosProducao } from '@/hooks/useApontamentosProducao';
import { useIsMobile } from '@/hooks/use-mobile';
import { usePermissionControl } from '@/hooks/usePermissionControl';
@@ -34,7 +35,7 @@ const ApontamentoProducao = () => {
<TabsList className={`w-full bg-muted border-border ${
isMobile
? 'grid grid-cols-1 gap-1 h-auto p-1'
: 'grid grid-cols-3 h-10'
: 'grid grid-cols-4 h-10'
}`}>
<TabsTrigger
value="apontamento"
@@ -45,6 +46,15 @@ const ApontamentoProducao = () => {
<ClipboardList className="h-4 w-4" />
Novo Apontamento
</TabsTrigger>
<TabsTrigger
value="mapa-kits"
className={`flex items-center gap-2 text-muted-foreground data-[state=active]:bg-background data-[state=active]:text-foreground ${
isMobile ? 'w-full justify-start p-3 text-sm' : ''
}`}
>
<Flame className="h-4 w-4 text-orange-500 fill-orange-500 animate-pulse" />
Mapa de Kits
</TabsTrigger>
<TabsTrigger
value="historico"
className={`flex items-center gap-2 text-muted-foreground data-[state=active]:bg-background data-[state=active]:text-foreground ${
@@ -89,6 +99,23 @@ const ApontamentoProducao = () => {
)}
</TabsContent>
<TabsContent value="mapa-kits" className="space-y-4 sm:space-y-6 mt-4">
<Card className="bg-card border-border shadow-sm">
<CardHeader className="pb-3 border-b border-border/60">
<CardTitle className="text-card-foreground flex items-center gap-2 text-lg sm:text-xl font-bold">
<Flame className="h-5 w-5 text-orange-500 fill-orange-500 animate-pulse" />
<span>Mapa Térmico de Kits de Montagem</span>
</CardTitle>
<p className="text-xs sm:text-sm text-muted-foreground">
Visão integrada do balanceamento do corte de componentes e liberação de kits completos para montagem/solda
</p>
</CardHeader>
<CardContent className="pt-4">
<MapaTermicoKitsView />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="historico" className="space-y-4 sm:space-y-6 mt-4">
<Card className="bg-card border-border">
<CardHeader className="pb-4">
@@ -0,0 +1,411 @@
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import {
Scissors,
CheckCircle2,
AlertTriangle,
Zap,
Plus,
RefreshCw,
Layers,
Wrench,
Flame
} from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { smartAudio } from '@/utils/smartAudio';
import { toast } from 'sonner';
import { PecaKitInfo, ComponenteKitInfo } from '@/hooks/useKitsProducao';
interface SmartComponentesCorteModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
pecaKit: PecaKitInfo | null;
ofNumber: string;
processoCorteId: string;
onApontamentoSalvo: () => void;
}
export const SmartComponentesCorteModal: React.FC<SmartComponentesCorteModalProps> = ({
open,
onOpenChange,
pecaKit,
ofNumber,
processoCorteId,
onApontamentoSalvo,
}) => {
const { user } = useAuth();
const [salvando, setSalvando] = useState<boolean>(false);
const [quantidadesManual, setQuantidadesManual] = useState<Record<string, number>>({});
if (!pecaKit) return null;
const hoje = new Date().toISOString().split('T')[0];
const nomeUsuario = user?.user_metadata?.nome || user?.email?.split('@')[0] || 'Operador Corte';
// Atualizar valor digitado para um componente específico
const setQtdComp = (compId: string, valor: number) => {
setQuantidadesManual((prev) => ({
...prev,
[compId]: Math.max(1, valor),
}));
};
// ─────────────────────────────────────────────────────────────
// AÇÃO 1: CORTAR 1 KIT COMPLETO (ATALHO RÁPIDO)
// ─────────────────────────────────────────────────────────────
const handleCortarKitCompleto = async (multiplicadorKits: number = 1) => {
try {
setSalvando(true);
smartAudio.playClick();
if (!pecaKit.componentes || pecaKit.componentes.length === 0) {
toast.error('Esta peça não possui subcomponentes cadastrados.');
return;
}
// Preparar lote de inserções para cada componente da peça
const batchPayloads: Array<{
of_number: string;
peca_id: string;
componente_id: string;
tipo_apontamento: 'componente';
processo_id: string;
quantidade_produzida: number;
data_apontamento: string;
created_by?: string;
usuario_nome: string;
observacoes: string;
}> = [];
let pesoTotalKg = 0;
for (const comp of pecaKit.componentes) {
const porKit = comp.quantidade_por_peca || 1;
const qtdParaApontar = porKit * multiplicadorKits;
const restante = Math.max(0, comp.quantidade_total_necessaria - comp.quantidade_cortada);
// Não apontar além do total se já estiver concluído, exceto se solicitado
const qtdFinal = Math.min(qtdParaApontar, Math.max(qtdParaApontar, restante));
if (qtdFinal > 0) {
batchPayloads.push({
of_number: ofNumber,
peca_id: pecaKit.peca_id,
componente_id: comp.id,
tipo_apontamento: 'componente',
processo_id: processoCorteId,
quantidade_produzida: qtdFinal,
data_apontamento: hoje,
created_by: user?.id,
usuario_nome: nomeUsuario,
observacoes: `Corte de Kit Completo (${multiplicadorKits}x ${pecaKit.marca}) via Modo Smart`,
});
pesoTotalKg += qtdFinal * (comp.peso_unitario || 0);
}
}
if (batchPayloads.length === 0) {
toast.info('Todos os componentes desta peça já estão 100% cortados!');
return;
}
const { error } = await supabase.from('apontamentos_producao').insert(batchPayloads);
if (error) throw error;
smartAudio.playSuccess();
toast.success(
`✅ ${multiplicadorKits} Kit Completo da peça ${pecaKit.marca} cortado com sucesso! (+${pesoTotalKg.toFixed(1)} kg)`
);
onApontamentoSalvo();
onOpenChange(false);
} catch (err: any) {
smartAudio.playAlert();
console.error('Erro ao cortar kit completo:', err);
toast.error('Erro ao registrar kit completo: ' + (err?.message || 'Falha de rede'));
} finally {
setSalvando(false);
}
};
// ─────────────────────────────────────────────────────────────
// AÇÃO 2: APONTAR COMPONENTE INDIVIDUAL (DESBALANCEADO / NESTING)
// ─────────────────────────────────────────────────────────────
const handleApontarComponenteIndividual = async (comp: ComponenteKitInfo) => {
try {
const qtd = quantidadesManual[comp.id] || 1;
if (qtd <= 0) return;
setSalvando(true);
smartAudio.playClick();
const payload = {
of_number: ofNumber,
peca_id: pecaKit.peca_id,
componente_id: comp.id,
tipo_apontamento: 'componente' as const,
processo_id: processoCorteId,
quantidade_produzida: qtd,
data_apontamento: hoje,
created_by: user?.id,
usuario_nome: nomeUsuario,
observacoes: `Corte de subcomponente ${comp.marca_componente} (${qtd} un) via Modo Smart`,
};
const { error } = await supabase.from('apontamentos_producao').insert(payload);
if (error) throw error;
const pesoKg = qtd * (comp.peso_unitario || 0);
smartAudio.playSuccess();
toast.success(
`✂️ Apontado no Corte: ${qtd}x ${comp.marca_componente} (+${pesoKg.toFixed(1)} kg)`
);
// Limpar valor digitado
setQuantidadesManual((prev) => {
const next = { ...prev };
delete next[comp.id];
return next;
});
onApontamentoSalvo();
} catch (err: any) {
smartAudio.playAlert();
console.error('Erro ao apontar componente:', err);
toast.error('Erro ao apontar componente: ' + (err?.message || 'Falha de rede'));
} finally {
setSalvando(false);
}
};
const kitsProntos = pecaKit.kits_completos_cortados;
const totalKits = pecaKit.quantidade_total;
const pct = pecaKit.percentual_termico;
const gargalo = pecaKit.componente_gargalo;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto p-4 sm:p-6">
<DialogHeader className="pb-2 border-b border-slate-200 dark:border-slate-800">
<DialogTitle className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<div className="p-2 rounded-xl bg-orange-100 text-orange-600 dark:bg-orange-950/60 dark:text-orange-400">
<Scissors className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-xl font-black text-amber-700 dark:text-amber-400 tracking-wider">
{pecaKit.marca}
</span>
<Badge variant="outline" className="text-xs font-bold">
{pecaKit.etapa_fase.toLowerCase().startsWith('fase')
? pecaKit.etapa_fase
: `Fase ${pecaKit.etapa_fase}`}
</Badge>
{pecaKit.perfil_principal && (
<span className="text-xs px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 font-mono font-bold">
{pecaKit.perfil_principal}
</span>
)}
</div>
<div className="text-xs text-slate-500">
{pecaKit.descricao || 'Peça Estrutural'} • Total: <strong>{totalKits} un</strong>
</div>
</div>
</div>
<Badge
variant="secondary"
className={`text-xs font-black ${
kitsProntos >= totalKits
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300'
: kitsProntos > 0
? 'bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'
}`}
>
<Flame className="w-3.5 h-3.5 mr-1 animate-pulse" />
{kitsProntos}/{totalKits} Kits Montáveis ({pct}%)
</Badge>
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{/* ──────────────── Barra de Prontidão e Gargalo ──────────────── */}
<div className="p-3 rounded-2xl bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-800 space-y-2">
<div className="flex items-center justify-between text-xs font-bold text-slate-700 dark:text-slate-300">
<span>Prontidão Térmica do Kit de Montagem</span>
<span>{pct}% dos componentes cortados</span>
</div>
<div className="w-full bg-slate-200 dark:bg-slate-800 h-3 rounded-full overflow-hidden flex">
<div
className={`h-full transition-all duration-300 ${
kitsProntos >= totalKits
? 'bg-emerald-500'
: kitsProntos > 0
? 'bg-blue-500'
: 'bg-amber-500'
}`}
style={{ width: `${pct}%` }}
/>
</div>
{gargalo && kitsProntos < totalKits && (
<div className="p-2 rounded-xl bg-amber-100/70 dark:bg-amber-950/40 border border-amber-300 dark:border-amber-800 flex items-center gap-2 text-xs text-amber-900 dark:text-amber-300 font-semibold">
<AlertTriangle className="w-4 h-4 text-amber-600 dark:text-amber-400 shrink-0 animate-bounce" />
<span>
<strong>Gargalo do Próximo Kit:</strong> Falta cortar{' '}
<span className="underline decoration-amber-500 font-black">
{gargalo.falta_para_prox_kit} un do componente {gargalo.marca_componente}
</span>{' '}
para liberar mais 1 kit completo na Solda!
</span>
</div>
)}
</div>
{/* ──────────────── ATALHO MESTRE: CORTAR 1 KIT COMPLETO ──────────────── */}
<div className="p-3.5 rounded-2xl bg-gradient-to-r from-amber-500/10 via-amber-500/20 to-orange-500/10 border-2 border-amber-500/40 dark:border-amber-500/30 flex flex-col sm:flex-row items-center justify-between gap-3">
<div>
<div className="flex items-center gap-1.5 text-xs font-black uppercase text-amber-800 dark:text-amber-400">
<Zap className="w-4 h-4 text-amber-600 dark:text-amber-400 fill-current" />
<span>Atalho Rápido de Corte por Conjunto</span>
</div>
<p className="text-xs text-slate-600 dark:text-slate-300 font-medium">
Corta todos os componentes necessários para fechar mais 1 kit montável de uma só vez.
</p>
</div>
<Button
type="button"
disabled={salvando || kitsProntos >= totalKits}
onClick={() => handleCortarKitCompleto(1)}
className="w-full sm:w-auto h-11 px-4 bg-amber-500 hover:bg-amber-400 active:bg-amber-600 text-slate-950 font-black text-xs uppercase tracking-wider rounded-xl shadow-md active:scale-95 transition-all flex items-center justify-center gap-1.5 shrink-0"
>
<Zap className="w-4 h-4 fill-current" />
<span>CORTAR 1 KIT COMPLETO (+1 UN)</span>
</Button>
</div>
{/* ──────────────── LISTA DE SUBCOMPONENTES PARA CORTE AVULSO ──────────────── */}
<div>
<div className="flex items-center justify-between text-xs font-bold text-slate-700 dark:text-slate-300 mb-2">
<span className="flex items-center gap-1.5">
<Wrench className="w-3.5 h-3.5 text-blue-500" />
Corte Individual de Subcomponentes (Nesting / Chapas avulsas):
</span>
<span className="text-slate-400">{pecaKit.componentes.length} subcomponentes</span>
</div>
<div className="space-y-2.5">
{pecaKit.componentes.map((comp) => {
const isGargalo = gargalo?.id === comp.id && kitsProntos < totalKits;
const comp100 = comp.concluido;
const valorAtual = quantidadesManual[comp.id] || comp.quantidade_por_peca || 1;
return (
<div
key={comp.id}
className={`p-3 rounded-2xl border transition-all ${
isGargalo
? 'bg-amber-50/70 dark:bg-amber-950/30 border-amber-400 dark:border-amber-700 shadow-sm'
: comp100
? 'bg-emerald-50/40 dark:bg-emerald-950/20 border-emerald-200 dark:border-emerald-800'
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800'
}`}
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2.5">
{/* Info do componente */}
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono font-black text-base text-slate-900 dark:text-slate-100">
{comp.marca_componente}
</span>
{comp.perfil && (
<span className="text-xs px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 font-mono text-slate-700 dark:text-slate-300 font-bold">
{comp.perfil}
</span>
)}
{isGargalo && (
<span className="text-[10px] font-black px-2 py-0.5 rounded bg-amber-500 text-slate-950 uppercase tracking-tight">
Gargalo do Kit
</span>
)}
{comp100 && (
<span className="text-[10px] font-black px-2 py-0.5 rounded bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300">
✅ 100% Cortado
</span>
)}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
{comp.quantidade_por_peca} un/peça • Total da OF:{' '}
<strong>{comp.quantidade_total_necessaria} un</strong> • Já cortado:{' '}
<strong className="text-slate-900 dark:text-slate-100 font-mono">
{comp.quantidade_cortada} un
</strong>{' '}
({comp.percentual_cortado}%) • {comp.peso_unitario > 0 ? `${comp.peso_unitario.toFixed(2)} kg/un` : ''}
</div>
</div>
{/* Teclado de corte rápido deste componente */}
<div className="flex items-center gap-1.5 shrink-0 self-end sm:self-center">
<div className="flex items-center gap-1 bg-slate-100 dark:bg-slate-800 p-1 rounded-xl border border-slate-200 dark:border-slate-700">
<button
type="button"
onClick={() => setQtdComp(comp.id, comp.quantidade_por_peca)}
className="px-2 py-1 rounded-lg text-xs font-black bg-white dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 shadow-xs"
title="1 kit da peça"
>
+{comp.quantidade_por_peca}
</button>
<button
type="button"
onClick={() => setQtdComp(comp.id, comp.quantidade_por_peca * 2)}
className="px-2 py-1 rounded-lg text-xs font-black bg-white dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 shadow-xs"
title="2 kits da peça"
>
+{comp.quantidade_por_peca * 2}
</button>
<Input
type="number"
min="1"
value={valorAtual}
onChange={(e) => setQtdComp(comp.id, parseInt(e.target.value) || 1)}
className="w-14 h-7 text-xs text-center font-bold px-1"
/>
</div>
<Button
type="button"
size="sm"
disabled={salvando}
onClick={() => handleApontarComponenteIndividual(comp)}
className="h-8 px-3 bg-slate-900 hover:bg-slate-800 text-white dark:bg-amber-500 dark:hover:bg-amber-400 dark:text-slate-950 font-black text-xs uppercase rounded-xl active:scale-95"
>
Apontar ({valorAtual})
</Button>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};
+288 -41
View File
@@ -15,7 +15,8 @@ import {
Filter,
Delete,
Pencil,
CheckCheck
CheckCheck,
Flame
} from 'lucide-react';
import { OFAtiva } from '@/hooks/useOFsAtivas';
import { useProcessosFabricacao, ProcessoFabricacao } from '@/hooks/useProcessosFabricacao';
@@ -25,6 +26,9 @@ import { smartAudio } from '@/utils/smartAudio';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { SmartForcarApontamentoModal } from './SmartForcarApontamentoModal';
import { calcularKitsOF, PecaKitInfo } from '@/hooks/useKitsProducao';
import { MapaTermicoKitsModal } from '@/components/apontamento/kits/MapaTermicoKitsModal';
import { SmartComponentesCorteModal } from './SmartComponentesCorteModal';
interface SmartProducaoFlowProps {
obra: OFAtiva;
@@ -58,7 +62,8 @@ export const calcularSaldoProcessoPeca = (
peca: PecaData,
processoAtual: ProcessoFabricacao,
todosProcessos: ProcessoFabricacao[],
mapaProd: Map<string, number>
mapaProd: Map<string, number>,
mapaKits?: Map<string, PecaKitInfo>
): CalculoSaldoPeca => {
const ehSemMontagem = peca.tem_componentes === false;
const keyAtual = `${peca.id}_${processoAtual.id}`;
@@ -68,7 +73,7 @@ export const calcularSaldoProcessoPeca = (
const indexAtual = procsOrdenados.findIndex((p) => p.id === processoAtual.id);
const nomeProcAtual = (processoAtual.nome || '').toLowerCase();
const ehSolda = nomeProcAtual.includes('solda');
const ehSolda = nomeProcAtual.includes('solda') || nomeProcAtual.includes('montag');
let qtdDisponivelParaEntrar = Number(peca.quantidade) || 0;
let motivoBloqueio: string | null = null;
@@ -77,6 +82,18 @@ export const calcularSaldoProcessoPeca = (
if (ehSemMontagem && ehSolda) {
qtdDisponivelParaEntrar = 0;
motivoBloqueio = 'Peça S/M (Pula Solda - vai direto para Pintura)';
} else if (ehSolda && !ehSemMontagem && mapaKits?.has(peca.id)) {
// REGRA 1.1: REGRA DO KIT COMPLETO DE CORTE PARA A SOLDA!
// Apenas peças com componentes 100% cortados (em lotes de kits inteiros) são liberadas para Solda!
const kitInfo = mapaKits.get(peca.id)!;
if (kitInfo.tem_componentes) {
qtdDisponivelParaEntrar = kitInfo.kits_completos_cortados;
if (kitInfo.kits_completos_cortados === 0) {
motivoBloqueio = kitInfo.motivo_bloqueio_solda || 'Aguardando corte completo dos kits';
} else if (kitInfo.kits_completos_cortados <= jaProduzidoAtual) {
motivoBloqueio = `Todos os ${kitInfo.kits_completos_cortados} kits cortados já foram soldados (${jaProduzidoAtual}/${peca.quantidade})`;
}
}
} else if (indexAtual > 0) {
// REGRA 2: Encontrar processo anterior válido
let procAnteriorValido: ProcessoFabricacao | null = null;
@@ -176,12 +193,17 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
// Modal de Forçar Apontamento
const [isForcarModalOpen, setIsForcarModalOpen] = useState<boolean>(false);
// Estados do Mapa Térmico de Kits e Corte de Componentes
const [isMapaKitsOpen, setIsMapaKitsOpen] = useState<boolean>(false);
const [pecaParaCorteModal, setPecaParaCorteModal] = useState<PecaKitInfo | null>(null);
const [componentesOF, setComponentesOF] = useState<any[]>([]);
// Dados carregados da OF
const [pecas, setPecas] = useState<PecaData[]>([]);
const [apontamentosExistentes, setApontamentosExistentes] = useState<ApontamentoItem[]>([]);
const [carregandoDados, setCarregandoDados] = useState<boolean>(true);
// Carregar peças e apontamentos da OF
// Carregar peças, componentes e apontamentos da OF
const carregarDadosOF = React.useCallback(async () => {
try {
setCarregandoDados(true);
@@ -199,6 +221,22 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
if (resPecas.data) {
setPecas(resPecas.data);
// Buscar componentes das peças da OF em lotes
const pecaIds = resPecas.data.map((p) => p.id);
if (pecaIds.length > 0) {
let todosComps: any[] = [];
for (let i = 0; i < pecaIds.length; i += 300) {
const chunk = pecaIds.slice(i, i + 300);
const { data: compsData } = await supabase
.from('componentes_peca')
.select('id, peca_id, marca_componente, descricao, perfil, peso_unitario, quantidade_por_peca')
.in('peca_id', chunk);
if (compsData) todosComps = [...todosComps, ...compsData];
}
setComponentesOF(todosComps);
}
}
if (resApontamentos.data) {
setApontamentosExistentes(resApontamentos.data as any);
@@ -292,6 +330,32 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
return map;
}, [apontamentosPendentes, processoSelecionado]);
// Identificar processos de Corte e Solda
const procCorteId = useMemo(() => {
return processos.find((p) => {
const n = (p.nome || '').toLowerCase();
return n.includes('corte') || n.includes('prepar') || p.ordem === 1;
})?.id;
}, [processos]);
const procSoldaId = useMemo(() => {
return processos.find((p) => {
const n = (p.nome || '').toLowerCase();
return n.includes('solda') || n.includes('montag') || p.ordem === 3;
})?.id;
}, [processos]);
// Mapa Térmico e de Kits completo para toda a OF
const mapaKits = useMemo(() => {
return calcularKitsOF(
pecas,
componentesOF,
apontamentosExistentes,
procCorteId,
procSoldaId
);
}, [pecas, componentesOF, apontamentosExistentes, procCorteId, procSoldaId]);
// Peças filtradas pela fase selecionada com cálculo estrito de precedência de processos e regra S/M
const pecasComSaldo = useMemo(() => {
if (!processoSelecionado) return [];
@@ -302,7 +366,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
return (p.etapa_fase || 'Geral').trim() === faseSelecionada;
})
.map((p) => {
const info = calcularSaldoProcessoPeca(p, processoSelecionado, processos, mapaProducao);
const info = calcularSaldoProcessoPeca(p, processoSelecionado, processos, mapaProducao, mapaKits);
return {
...p,
...info,
@@ -399,7 +463,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
return;
}
const info = calcularSaldoProcessoPeca(pecaSelecionada, processoSelecionado, processos, mapaProducao);
const info = calcularSaldoProcessoPeca(pecaSelecionada, processoSelecionado, processos, mapaProducao, mapaKits);
if (modoEdicao && qtdApontar < info.minPermitido) {
smartAudio.playAlert();
toast.error(`Não é possível reduzir para ${qtdApontar}. O processo seguinte (${info.procSeguinteNome}) já consumiu ${info.minPermitido} peças. Ajuste o processo seguinte primeiro.`);
@@ -760,6 +824,19 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
>
<span>🏢 {faseSelecionada}</span>
</button>
<button
type="button"
onClick={() => {
smartAudio.playClick();
setIsMapaKitsOpen(true);
}}
className="px-2.5 py-1 rounded-lg bg-orange-100 text-orange-800 dark:bg-orange-500/20 dark:text-orange-300 font-bold text-xs flex items-center gap-1 border border-orange-200 dark:border-orange-500/30 hover:bg-orange-200 dark:hover:bg-orange-500/30 shrink-0 transition-colors"
title="Abrir Mapa Térmico de Kits de Montagem"
>
<Flame className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400 fill-current animate-pulse" />
<span>Mapa de Kits</span>
</button>
</div>
<div className="flex items-center gap-2">
@@ -805,6 +882,10 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
const temPendencia = pendenciasItem.length > 0;
const concluida = item.jaProduzido >= item.quantidade;
const aguardandoAnterior = !concluida && item.saldo <= 0;
const kitInfo = mapaKits.get(item.id);
const ehCorte = processoSelecionado?.id === procCorteId;
const ehSolda = processoSelecionado?.id === procSoldaId;
const temComponentes = item.tem_componentes !== false && (kitInfo?.componentes.length || 0) > 0;
return (
<div
@@ -886,7 +967,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
</span>
) : (
<span className="inline-flex items-center gap-1 text-[11px] font-bold text-slate-600 dark:text-slate-400 px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 mt-1" title={item.motivoBloqueio || 'Aguardando processo anterior'}>
{item.motivoBloqueio ? item.motivoBloqueio.slice(0, 22) : 'Aguardando anterior'}
{item.motivoBloqueio ? item.motivoBloqueio.slice(0, 26) : 'Aguardando anterior'}
</span>
)}
</div>
@@ -904,6 +985,68 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
/>
</div>
{/* MAPA TÉRMICO DE KITS NO CARD (QUANDO PEÇA POSSUI COMPONENTES) */}
{kitInfo && temComponentes && (
<div className="mt-2.5 p-2.5 rounded-xl bg-orange-50/70 dark:bg-orange-950/20 border border-orange-200/80 dark:border-orange-800/40 flex flex-col gap-1.5">
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1.5 font-bold">
<Flame
className={`w-3.5 h-3.5 shrink-0 ${
kitInfo.kits_completos_cortados > 0
? 'text-orange-500 fill-orange-500 animate-pulse'
: 'text-slate-400'
}`}
/>
<span className="text-slate-800 dark:text-slate-200 font-extrabold text-[11px] uppercase tracking-wide">
Kits Montáveis no Corte:
</span>
<span
className={`px-1.5 py-0.5 rounded text-[11px] font-black ${
kitInfo.kits_completos_cortados >= item.quantidade
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300'
: kitInfo.kits_completos_cortados > 0
? 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'
: 'bg-slate-200 text-slate-700 dark:bg-slate-800 dark:text-slate-400'
}`}
>
{kitInfo.kits_completos_cortados} de {item.quantidade} un
</span>
</div>
<span className="text-[11px] font-black text-orange-700 dark:text-orange-400">
{kitInfo.percentual_termico}% peso
</span>
</div>
{/* Mini barra térmica do kit com gradiente */}
<div className="w-full bg-slate-200 dark:bg-slate-800 h-1.5 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ${
kitInfo.percentual_termico >= 100
? 'bg-emerald-500'
: kitInfo.percentual_termico >= 50
? 'bg-amber-500'
: kitInfo.percentual_termico > 0
? 'bg-orange-500'
: 'bg-slate-300 dark:bg-slate-700'
}`}
style={{ width: `${kitInfo.percentual_termico}%` }}
/>
</div>
{/* Gargalo se houver */}
{kitInfo.componente_gargalo && kitInfo.kits_completos_cortados < item.quantidade && (
<div className="text-[10px] text-amber-800 dark:text-amber-300 font-medium flex items-center justify-between">
<span className="truncate">
⚠️ Gargalo: <strong>{kitInfo.componente_gargalo.marca_componente || kitInfo.componente_gargalo.descricao}</strong> (falta {kitInfo.componente_gargalo.falta_para_prox_kit} un p/ próx kit)
</span>
<span className="text-[10px] text-slate-400 shrink-0 ml-1">
{kitInfo.componentes.length} itens
</span>
</div>
)}
</div>
)}
{/* ALERTA VISUAL: BAIXA FORÇADA PENDENTE DE CONFIRMAÇÃO */}
{temPendencia && (
<div className="mt-3 p-2.5 rounded-xl bg-red-100 dark:bg-red-950/60 border border-red-300 dark:border-red-800 flex items-center justify-between gap-2 flex-wrap">
@@ -927,40 +1070,107 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
{/* Botões de Ação no Card */}
<div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-2">
<Button
type="button"
disabled={concluida || item.saldo <= 0}
onClick={() => handleIniciarApontamentoPeca(item, item.saldo)}
className={`sm:col-span-2 h-13 text-sm font-black rounded-xl uppercase tracking-wider transition-all active:scale-[0.98] ${
concluida
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-500 dark:border-slate-700 cursor-not-allowed'
: item.saldo <= 0
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700 cursor-not-allowed'
: 'bg-amber-500 hover:bg-amber-400 active:bg-amber-600 text-slate-950 shadow-md shadow-amber-900/20 dark:shadow-amber-950/30'
}`}
>
{concluida
? 'Processo Concluído'
: item.saldo <= 0
? item.motivoBloqueio || 'Aguardando processo anterior'
: `DIGITAR / ESCOLHER QUANTIDADE`}
</Button>
{ehCorte && temComponentes && kitInfo ? (
<>
<Button
type="button"
onClick={() => {
smartAudio.playClick();
setPecaParaCorteModal(kitInfo);
}}
className="sm:col-span-2 h-13 text-xs sm:text-sm font-black rounded-xl uppercase tracking-wider bg-orange-600 hover:bg-orange-500 active:bg-orange-700 text-white shadow-md shadow-orange-950/20 active:scale-95 transition-all flex items-center justify-center gap-2"
title="Apontar componentes individuais cortados ou cortar 1 kit completo"
>
<Scissors className="w-4 h-4 shrink-0" />
<span>Cortar Componentes ({kitInfo.componentes.length} itens)</span>
</Button>
{!concluida && item.saldo > 0 && (
<Button
type="button"
onClick={() => {
smartAudio.playClick();
setPecaSelecionada(item);
setQtdApontar(item.saldo);
setModoEdicao(false);
setEtapaAtual('quantidade');
}}
className="h-13 bg-amber-50 hover:bg-amber-100 active:bg-amber-200 text-amber-800 border border-amber-300 dark:bg-slate-700 dark:hover:bg-slate-650 dark:active:bg-slate-600 dark:text-amber-400 dark:border-slate-600 text-xs font-black rounded-xl uppercase active:scale-95 transition-colors"
title="Apontar todo o saldo disponível de uma vez"
>
TODAS ({item.saldo})
</Button>
<Button
type="button"
disabled={concluida || item.saldo <= 0}
onClick={() => handleIniciarApontamentoPeca(item, item.saldo)}
className="h-13 bg-amber-50 hover:bg-amber-100 active:bg-amber-200 text-amber-800 border border-amber-300 dark:bg-slate-700 dark:hover:bg-slate-650 dark:active:bg-slate-600 dark:text-amber-400 dark:border-slate-600 text-xs font-black rounded-xl uppercase active:scale-95 transition-colors disabled:opacity-50"
title="Apontar peça mestre inteira"
>
Peça Cheia ({item.saldo})
</Button>
</>
) : ehSolda && temComponentes && kitInfo ? (
<>
<Button
type="button"
disabled={concluida || item.saldo <= 0}
onClick={() => handleIniciarApontamentoPeca(item, item.saldo)}
className={`sm:col-span-2 h-13 text-xs sm:text-sm font-black rounded-xl uppercase tracking-wider transition-all active:scale-[0.98] ${
concluida
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-500 dark:border-slate-700 cursor-not-allowed'
: item.saldo <= 0
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700 cursor-not-allowed'
: 'bg-amber-500 hover:bg-amber-400 active:bg-amber-600 text-slate-950 shadow-md shadow-amber-900/20 dark:shadow-amber-950/30'
}`}
>
{concluida
? 'Processo Concluído'
: item.saldo <= 0
? (item.motivoBloqueio || 'Aguardando kits completos no Corte')
: `⚡ SOLDAR (${item.saldo} KITS LIBERADOS)`}
</Button>
{!concluida && item.saldo > 0 && (
<Button
type="button"
onClick={() => {
smartAudio.playClick();
setPecaSelecionada(item);
setQtdApontar(item.saldo);
setModoEdicao(false);
setEtapaAtual('quantidade');
}}
className="h-13 bg-amber-50 hover:bg-amber-100 active:bg-amber-200 text-amber-800 border border-amber-300 dark:bg-slate-700 dark:hover:bg-slate-650 dark:active:bg-slate-600 dark:text-amber-400 dark:border-slate-600 text-xs font-black rounded-xl uppercase active:scale-95 transition-colors"
title="Apontar todos os kits disponíveis de uma vez"
>
TODAS ({item.saldo})
</Button>
)}
</>
) : (
<>
<Button
type="button"
disabled={concluida || item.saldo <= 0}
onClick={() => handleIniciarApontamentoPeca(item, item.saldo)}
className={`sm:col-span-2 h-13 text-sm font-black rounded-xl uppercase tracking-wider transition-all active:scale-[0.98] ${
concluida
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-500 dark:border-slate-700 cursor-not-allowed'
: item.saldo <= 0
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700 cursor-not-allowed'
: 'bg-amber-500 hover:bg-amber-400 active:bg-amber-600 text-slate-950 shadow-md shadow-amber-900/20 dark:shadow-amber-950/30'
}`}
>
{concluida
? 'Processo Concluído'
: item.saldo <= 0
? item.motivoBloqueio || 'Aguardando processo anterior'
: `DIGITAR / ESCOLHER QUANTIDADE`}
</Button>
{!concluida && item.saldo > 0 && (
<Button
type="button"
onClick={() => {
smartAudio.playClick();
setPecaSelecionada(item);
setQtdApontar(item.saldo);
setModoEdicao(false);
setEtapaAtual('quantidade');
}}
className="h-13 bg-amber-50 hover:bg-amber-100 active:bg-amber-200 text-amber-800 border border-amber-300 dark:bg-slate-700 dark:hover:bg-slate-650 dark:active:bg-slate-600 dark:text-amber-400 dark:border-slate-600 text-xs font-black rounded-xl uppercase active:scale-95 transition-colors"
title="Apontar todo o saldo disponível de uma vez"
>
TODAS ({item.saldo})
</Button>
)}
</>
)}
</div>
</div>
@@ -998,6 +1208,42 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
}}
/>
)}
{/* Modal de Mapa Térmico de Kits */}
<MapaTermicoKitsModal
open={isMapaKitsOpen}
onOpenChange={setIsMapaKitsOpen}
ofNumber={obra.of_number}
fase={faseSelecionada}
onSelectPecaParaApontar={(pecaKit) => {
const ehProcCorte = processoSelecionado?.id === procCorteId;
if (ehProcCorte && pecaKit.tem_componentes) {
setPecaParaCorteModal(pecaKit);
} else {
const pItem = pecasComSaldo.find((p) => p.id === pecaKit.peca_id);
if (pItem && pItem.saldo > 0) {
handleIniciarApontamentoPeca(pItem, pItem.saldo);
}
}
}}
/>
{/* Modal de Apontamento de Componentes de Corte */}
{pecaParaCorteModal && procCorteId && (
<SmartComponentesCorteModal
open={!!pecaParaCorteModal}
onOpenChange={(aberto) => {
if (!aberto) setPecaParaCorteModal(null);
}}
pecaKit={pecaParaCorteModal}
ofNumber={obra.of_number}
processoCorteId={procCorteId}
onApontamentoSalvo={() => {
carregarDadosOF();
onApontamentoRealizado(1, 0);
}}
/>
)}
</div>
);
}
@@ -1010,7 +1256,8 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
pecaSelecionada,
processoSelecionado,
processos,
mapaProducao
mapaProducao,
mapaKits
);
const saldoPendente = infoSaldoAtual.saldo;
const maxPermitido = modoEdicao ? infoSaldoAtual.jaProduzido + infoSaldoAtual.saldo : infoSaldoAtual.saldo;