feat: mapa termico de kits de corte e liberacao balanceada para solda
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user