Compare commits

...

21 Commits

Author SHA1 Message Date
admtracksteel 8ebc8dec5c fix(auth): harden profile sync and fallback on non-uuid id; restore kong gateway 2026-10-30 13:51:00 -03:00
admtracksteel 6a8290f394 feat(expedicao): adicionar badge de maior comprimento nas pecas do romaneio 2026-10-28 10:51:22 -03:00
admtracksteel 7f98ae9803 fix(expedicao): suporte a modo claro e escuro no modal PecasRomaneioExpandido 2026-10-28 10:49:45 -03:00
admtracksteel 2bf0ccd8ce fix(expedicao): isolar peca principal no seletor de romaneio sem somar componentes 2026-10-28 10:33:03 -03:00
admtracksteel ea447df298 fix(producao): liberacao automatica de pecas na solda por kits completos no corte 2026-10-26 11:59:05 -03:00
admtracksteel 1e191fe562 feat(expedicao): fluxo inteligente de confirmacao de entrega de romaneio com data real e apontamento em cascata 2026-10-21 16:05:21 -03:00
admtracksteel ada8e4c460 fix(apontamento): isolar estado de quantidade no card para evitar perda de foco ao digitar 2026-10-21 15:30:01 -03:00
admtracksteel d28e131d7a feat: suporte a pecas e OFs sem componentes cadastrados no corte e solda 2026-10-20 16:42:21 -03:00
admtracksteel 8a707894e0 feat: adicionar somatorias de peso nos 4 cards de KPIs do mapa termico de kits 2026-10-20 15:53:21 -03:00
admtracksteel 9ade5d0d5a fix: seletor de OFs no Mapa Termico de Kits 2026-10-20 15:37:40 -03:00
admtracksteel fcb3fc08fd feat: mapa termico de kits de corte e liberacao balanceada para solda 2026-10-20 15:28:21 -03:00
admtracksteel f3eff4b805 feat: adicionar exibição da fase da OF no histórico de apontamentos 2026-10-20 13:34:22 -03:00
admtracksteel 264c41cc80 fix: isolar componentes e aplicar trava de teto na disponibilidade do Modo Smart 2026-10-20 11:05:25 -03:00
admtracksteel 9745964837 feat: blindagem de peso em dashboards e kit completo de corte para solda 2026-10-16 19:13:24 -03:00
admtracksteel 400be62a0e fix(diff-modal): adicionar scroll vertical e thead sticky para todas as pecas 2026-10-16 16:40:38 -03:00
admtracksteel 1203650c2d fix: resolucao definitiva da extracao e persistencia de comprimento de pecas e componentes 2026-10-16 16:24:38 -03:00
admtracksteel ebcfe3c9d2 fix: persistencia e extracao de comprimento de pecas e subcomponentes na importacao 2026-10-16 15:43:15 -03:00
admtracksteel ec980cba05 feat: sistema de deteccao de conflitos, conciliacao e ordem de intervencao fabril na importacao de pecas 2026-10-16 15:27:30 -03:00
admtracksteel ac8bacc9e1 feat(smart-3d): replica card de engenharia e adiciona ferramenta girar 90 com trava 2026-10-15 16:29:17 -03:00
admtracksteel 16414ca4ba feat(3d-viewer): enriquecer identificacao de pecas WRL com marca principal e perfil oficial 2026-10-15 16:05:27 -03:00
admtracksteel a5e82bf440 feat(3d-viewer): replica selecao e hover para modelos WRL com destaque magenta e apontamentos 2026-10-15 10:21:49 -03:00
50 changed files with 6842 additions and 894 deletions
+140 -65
View File
@@ -49,10 +49,10 @@ export const ApontamentoForm = () => {
const [saving, setSaving] = useState(false);
const [cacheValido, setCacheValido] = useState(false);
const { criarApontamento, refetch, processos, apontamentos, loading: loadingApontamentos } = useApontamentosProducao();
const { criarApontamento, refetch, processos, apontamentos, loading: loadingApontamentos, sincronizarKitsOF } = useApontamentosProducao();
const { pecas, loading: loadingPecas } = usePecas();
const { ofs } = useOFs();
const { componentesAgrupados, loading: loadingComponentes } = useComponentesAgrupados(formData.of_number, formData.fase, pecas);
const { componentesAgrupados, componentesRaw, loading: loadingComponentes } = useComponentesAgrupados(formData.of_number, formData.fase, pecas);
// Buscar fases únicas da OF selecionada
const fasesDisponiveis = useMemo(() => {
@@ -118,6 +118,8 @@ export const ApontamentoForm = () => {
const processosOrdenados = [...processos].sort((a, b) => a.ordem - b.ordem);
const indexAtual = processosOrdenados.findIndex(p => p.id === processo_id);
const procAtual = processosOrdenados[indexAtual];
const procNomeLower = (procAtual?.nome || '').toLowerCase();
const isCorte = procNomeLower.includes('corte') || procNomeLower.includes('prepar') || indexAtual === 0;
// 1. Peças da OF e Fase selecionadas
const pecasDaFase = pecas.filter(
@@ -134,25 +136,60 @@ export const ApontamentoForm = () => {
let qtdDisponivelParaEntrar = Number(peca.quantidade) || 0;
// Regra: peças sem componentes não passam por solda
if (!peca.tem_componentes && procAtual && procAtual.nome.toLowerCase().includes('solda')) {
// Identificar se a peça de fato possui subcomponentes cadastrados nesta OF
const compsDaPeca = componentesAgrupados?.filter(c => c.peca_ids?.includes(peca.id)) || [];
const temComponentesCadastrados = peca.tem_componentes !== false && compsDaPeca.length > 0;
const ehSemMontagem = peca.tem_componentes === false;
// Se for CORTE:
// Se tiver componentes cadastrados, são apontados os subcomponentes.
// Se NÃO tiver componentes cadastrados (opção do usuário não controlar componentes), aponta a peça mestre normalmente!
if (isCorte && temComponentesCadastrados) {
qtdDisponivelParaEntrar = 0;
} else if (ehSemMontagem && procNomeLower.includes('solda')) {
// Regra: peças sem montagem (S/M) não passam por solda
qtdDisponivelParaEntrar = 0;
} else if (indexAtual > 0) {
// Encontrar processo anterior válido
let processoAnteriorValido = null;
for (let i = indexAtual - 1; i >= 0; i--) {
const p = processosOrdenados[i];
if (!peca.tem_componentes && p.nome.toLowerCase().includes('solda')) {
continue; // Pula a solda na busca do processo anterior para peças simples
if (ehSemMontagem && p.nome.toLowerCase().includes('solda')) {
continue; // Pula a solda na busca do processo anterior para peças simples (S/M)
}
processoAnteriorValido = p;
break;
}
if (processoAnteriorValido) {
qtdDisponivelParaEntrar = apontamentos
let qtdApontadaAnterior = apontamentos
.filter(a => a.tipo_apontamento === 'peca' && a.peca_id === peca.id && a.processo_id === processoAnteriorValido.id)
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
// Se a peça tem componentes e o processo anterior for Corte:
// Calcula estritamente os kits completos disponíveis baseados no corte de todos os componentes (regra do gargalo)
if (temComponentesCadastrados && processoAnteriorValido.nome.toLowerCase().includes('corte')) {
const compsDestaPeca = (componentesRaw || []).filter(c => c.peca_id === peca.id);
if (compsDestaPeca.length > 0) {
let menorKits = Number(peca.quantidade) || 0;
compsDestaPeca.forEach(comp => {
const cortado = apontamentos
.filter(a => a.tipo_apontamento === 'componente' && a.componente_id === comp.id && a.processo_id === processoAnteriorValido.id)
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
const porPeca = Math.max(1, Number(comp.quantidade_por_peca) || 1);
const kitsDeste = Math.floor(cortado / porPeca);
if (kitsDeste < menorKits) {
menorKits = kitsDeste;
}
});
const kitsCompletosCalculados = Math.max(0, Math.min(Number(peca.quantidade) || 0, menorKits));
if (kitsCompletosCalculados > qtdApontadaAnterior) {
qtdApontadaAnterior = kitsCompletosCalculados;
}
}
}
qtdDisponivelParaEntrar = qtdApontadaAnterior;
}
}
@@ -173,21 +210,14 @@ export const ApontamentoForm = () => {
});
// 2. Componentes da OF e Fase selecionadas
// RESTRIÇÃO INDUSTRIAL: Componentes só existem no processo de CORTE / PREPARAÇÃO!
// Em Solda, Pintura, Expedição, Montagem etc., componentes não são exibidos nem apontados.
const componentesDisponiveis: ItemDisponivel[] = [];
if (componentesAgrupados && componentesAgrupados.length > 0) {
if (isCorte && componentesAgrupados && componentesAgrupados.length > 0) {
componentesAgrupados.forEach(comp => {
let qtdDisponivelParaEntrarComp = Number(comp.quantidade_total) || 0;
if (indexAtual > 0) {
const processoAnteriorValido = processosOrdenados[indexAtual - 1];
if (processoAnteriorValido) {
qtdDisponivelParaEntrarComp = apontamentos
.filter(a => a.tipo_apontamento === 'componente' && comp.componente_ids.includes(a.componente_id || '') && a.processo_id === processoAnteriorValido.id)
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
}
}
const totalApontadoComp = apontamentos
.filter(a => a.tipo_apontamento === 'componente' && comp.componente_ids.includes(a.componente_id || '') && a.processo_id === processo_id)
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
@@ -263,22 +293,11 @@ export const ApontamentoForm = () => {
try {
for (const item of items) {
try {
const apontamentoData: Parameters<typeof criarApontamento>[0] = {
of_number: formData.of_number,
tipo_apontamento: item.tipo,
processo_id: formData.processo_id,
quantidade_produzida: item.quantidade_disponivel,
data_apontamento: formData.data_apontamento,
observacoes: `Registro em lote - ${tipoTexto}`
};
if (item.tipo === 'componente') {
apontamentoData.componente_id = item.id;
} else {
apontamentoData.peca_id = item.id;
}
const result = await criarApontamento(apontamentoData);
const result = await registrarItemComDistribuicao(
item,
item.quantidade_disponivel,
`Registro em lote - ${tipoTexto}`
);
if (result.success) {
sucessos++;
@@ -341,6 +360,13 @@ export const ApontamentoForm = () => {
setFormData(prev => ({ ...prev, ...updates }));
updateCache({ processo_id: processoId });
setItemSelecionado(null);
// Ao selecionar Solda, acionar sincronização de kits da OF em segundo plano
const proc = processos.find(p => p.id === processoId);
const procNome = (proc?.nome || '').toLowerCase();
if ((procNome.includes('solda') || procNome.includes('montag')) && formData.of_number) {
sincronizarKitsOF(formData.of_number);
}
};
const handleItemSelect = (item: ItemDisponivel) => {
@@ -367,6 +393,81 @@ export const ApontamentoForm = () => {
}));
};
// Função auxiliar para registrar apontamento distribuindo componentes entre peças irmãs se necessário
const registrarItemComDistribuicao = async (
item: ItemDisponivel,
quantidade: number,
observacoes?: string
): Promise<{ success: boolean }> => {
if (item.tipo === 'componente') {
const grupo = componentesAgrupados.find(g => g.marca_componente === item.marca);
if (grupo && grupo.vinculos && grupo.vinculos.length > 1) {
let restante = quantidade;
let sucessos = 0;
for (const v of grupo.vinculos) {
if (restante <= 0) break;
const jaCortado = apontamentos
.filter(a => a.tipo_apontamento === 'componente' && a.componente_id === v.componente_id && a.processo_id === formData.processo_id)
.reduce((sum, a) => sum + (Number(a.quantidade_produzida) || 0), 0);
const faltaParaPeca = Math.max(0, v.quantidade_necessaria - jaCortado);
if (faltaParaPeca <= 0) continue;
const qtdParaV = Math.min(restante, faltaParaPeca);
restante -= qtdParaV;
const res = await criarApontamento({
of_number: formData.of_number,
tipo_apontamento: 'componente',
processo_id: formData.processo_id,
quantidade_produzida: qtdParaV,
data_apontamento: formData.data_apontamento,
componente_id: v.componente_id,
observacoes: observacoes || undefined
});
if (res.success) sucessos++;
}
// Se sobrou saldo além das necessidades das peças vinculadas, aplica ao primeiro vínculo
if (restante > 0 && grupo.vinculos.length > 0) {
const res = await criarApontamento({
of_number: formData.of_number,
tipo_apontamento: 'componente',
processo_id: formData.processo_id,
quantidade_produzida: restante,
data_apontamento: formData.data_apontamento,
componente_id: grupo.vinculos[0].componente_id,
observacoes: observacoes || undefined
});
if (res.success) sucessos++;
}
return { success: sucessos > 0 };
}
}
// Caso padrão (peça ou componente monovinculado)
const apontamentoData: Parameters<typeof criarApontamento>[0] = {
of_number: formData.of_number,
tipo_apontamento: item.tipo,
processo_id: formData.processo_id,
quantidade_produzida: quantidade,
data_apontamento: formData.data_apontamento,
observacoes: observacoes || undefined
};
if (item.tipo === 'componente') {
apontamentoData.componente_id = item.id;
} else {
apontamentoData.peca_id = item.id;
}
return await criarApontamento(apontamentoData);
};
const handleApontarItemDireto = async (item: ItemDisponivel, quantidade: number): Promise<boolean> => {
if (!formData.processo_id) {
toast.error('Selecione um processo antes de apontar');
@@ -380,22 +481,7 @@ export const ApontamentoForm = () => {
setSaving(true);
try {
const apontamentoData: Parameters<typeof criarApontamento>[0] = {
of_number: formData.of_number,
tipo_apontamento: item.tipo,
processo_id: formData.processo_id,
quantidade_produzida: quantidade,
data_apontamento: formData.data_apontamento,
observacoes: formData.observacoes || undefined
};
if (item.tipo === 'componente') {
apontamentoData.componente_id = item.id;
} else {
apontamentoData.peca_id = item.id;
}
const result = await criarApontamento(apontamentoData);
const result = await registrarItemComDistribuicao(item, quantidade, formData.observacoes || undefined);
if (result.success) {
toast.success(`${quantidade} un de ${item.marca} apontada(s) com sucesso!`);
@@ -443,22 +529,11 @@ export const ApontamentoForm = () => {
setSaving(true);
try {
const apontamentoData: Parameters<typeof criarApontamento>[0] = {
of_number: formData.of_number,
tipo_apontamento: itemSelecionado.tipo,
processo_id: formData.processo_id,
quantidade_produzida: quantidade,
data_apontamento: formData.data_apontamento,
observacoes: formData.observacoes || undefined
};
if (itemSelecionado.tipo === 'componente') {
apontamentoData.componente_id = itemSelecionado.id;
} else {
apontamentoData.peca_id = itemSelecionado.id;
}
const result = await criarApontamento(apontamentoData);
const result = await registrarItemComDistribuicao(
itemSelecionado,
quantidade,
formData.observacoes || undefined
);
if (result.success) {
toast.success('Apontamento registrado com sucesso!');
@@ -12,12 +12,15 @@ import { ptBR } from 'date-fns/locale';
export const ApontamentosList = () => {
const { apontamentos, loading } = useApontamentosProducao();
const [filtroOF, setFiltroOF] = useState('');
const [filtroFase, setFiltroFase] = useState('');
const [filtroProcesso, setFiltroProcesso] = useState('');
const [filtroData, setFiltroData] = useState('');
const [filtroPecaComponente, setFiltroPecaComponente] = useState('');
const apontamentosFiltrados = apontamentos.filter(apt => {
const matchOF = !filtroOF || apt.of_number.toLowerCase().includes(filtroOF.toLowerCase());
const faseStr = apt.peca?.etapa_fase || (apt.componente as any)?.peca?.etapa_fase || '';
const matchFase = !filtroFase || faseStr.toLowerCase().includes(filtroFase.toLowerCase());
const matchProcesso = !filtroProcesso || apt.processo?.nome.toLowerCase().includes(filtroProcesso.toLowerCase());
const matchData = !filtroData || apt.data_apontamento.includes(filtroData);
@@ -25,7 +28,7 @@ export const ApontamentosList = () => {
const matchPecaComponente = !filtroPecaComponente ||
getMarcaExibida(apt).toLowerCase().includes(filtroPecaComponente.toLowerCase());
return matchOF && matchProcesso && matchData && matchPecaComponente;
return matchOF && matchFase && matchProcesso && matchData && matchPecaComponente;
});
// Função para determinar a marca exibida baseada no tipo de apontamento
@@ -90,7 +93,7 @@ export const ApontamentosList = () => {
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
<div className="grid grid-cols-1 md:grid-cols-6 gap-3">
<div>
<label className="text-sm font-medium mb-2 block">OF</label>
<Input
@@ -100,6 +103,15 @@ export const ApontamentosList = () => {
className="h-8"
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">Fase</label>
<Input
placeholder="Filtrar por fase..."
value={filtroFase}
onChange={(e) => setFiltroFase(e.target.value)}
className="h-8"
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">Processo</label>
<Input
@@ -163,6 +175,7 @@ export const ApontamentosList = () => {
<TableHeader>
<TableRow>
<TableHead>OF</TableHead>
<TableHead>Fase</TableHead>
<TableHead>Peça/Componente</TableHead>
<TableHead>Tipo</TableHead>
<TableHead>Processo</TableHead>
@@ -184,6 +197,17 @@ export const ApontamentosList = () => {
<TableCell className="font-medium py-1">
{apontamento.of_number}
</TableCell>
<TableCell className="py-1">
{apontamento.peca?.etapa_fase ? (
<Badge variant="outline" className="text-xs">
{apontamento.peca.etapa_fase.toLowerCase().startsWith('fase') || apontamento.peca.etapa_fase.toLowerCase().startsWith('etapa')
? apontamento.peca.etapa_fase
: `Fase ${apontamento.peca.etapa_fase}`}
</Badge>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="py-1">
<div className="font-medium">
{getMarcaExibida(apontamento)}
@@ -4,7 +4,7 @@ import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { ScrollArea } from "@/components/ui/scroll-area";
import { toast } from 'sonner';
import { Search, Package, Shield, AlertTriangle, Settings, Check, Loader2 } from 'lucide-react';
import { Search, Package, Shield, AlertTriangle, Settings, Check, Loader2, Layers } from 'lucide-react';
import { useDebounce } from '@/hooks/useDebounce';
import { Peca } from '@/hooks/usePecas';
import { useComponentesPeca } from '@/hooks/useComponentesPeca';
@@ -67,6 +67,257 @@ interface PecaWithComponents extends Peca {
processo_atual_permitido?: number;
}
const getPrioridadeColor = (prioridade?: string) => {
switch (prioridade?.toLowerCase()) {
case 'alta': return 'text-red-600 dark:text-red-400';
case 'média': return 'text-amber-600 dark:text-yellow-400';
case 'baixa': return 'text-emerald-600 dark:text-green-400';
default: return 'text-muted-foreground';
}
};
const PecaItem = React.memo(({ peca, onSelect }: { peca: PecaWithComponents; onSelect: (peca: PecaWithComponents) => void }) => {
return (
<div
className="p-2 border border-border bg-card hover:bg-accent/50 rounded-md cursor-pointer transition-colors"
onClick={() => onSelect(peca)}
>
<div className="space-y-1">
<div className="font-semibold text-sm text-foreground">{peca.marca}</div>
<div className="text-xs text-muted-foreground">
Prioridade: <span className={getPrioridadeColor(peca.prioridade)}>{peca.prioridade || 'N/A'}</span>
</div>
{peca.descricao && (
<div className="text-xs text-muted-foreground truncate" title={peca.descricao}>
{peca.descricao}
</div>
)}
<div className="text-xs font-bold text-emerald-600 dark:text-emerald-400">
Qtd Disponível: {peca.quantidadeDisponivel || 0}
</div>
</div>
</div>
);
});
PecaItem.displayName = 'PecaItem';
const ComponenteItem = React.memo(({ componente, onSelect }: { componente: ComponenteItemData; onSelect: (componente: ComponenteItemData) => void }) => (
<div
className="p-2 border border-border bg-card hover:bg-accent/50 rounded-md cursor-pointer transition-colors"
onClick={() => onSelect(componente)}
>
<div className="space-y-1">
<div className="font-semibold text-sm text-foreground">{componente.marca_componente}</div>
<div className="text-xs text-muted-foreground">
Perfil: {componente.perfil || 'N/A'}
</div>
<div className="text-xs text-muted-foreground">
Peso Unitário: {componente.peso_unitario || 0}kg | Qtd por Peça: {componente.quantidade_por_peca || 1}
</div>
{componente.descricao && (
<div className="text-xs text-muted-foreground truncate" title={componente.descricao}>
{componente.descricao}
</div>
)}
</div>
</div>
));
ComponenteItem.displayName = 'ComponenteItem';
interface ItemDisponivelCardProps {
item: ItemDisponivel;
isSelected: boolean;
isAdmin?: boolean;
onSelect?: (item: ItemDisponivel) => void;
onAdminAction?: (item: ItemDisponivel) => void;
onApontarItemDireto?: (item: ItemDisponivel, quantidade: number) => Promise<boolean>;
}
const ItemDisponivelCard = React.memo(({
item,
isSelected,
isAdmin,
onSelect,
onAdminAction,
onApontarItemDireto,
}: ItemDisponivelCardProps) => {
const [qtdValue, setQtdValue] = useState<string>('');
const [isTodas, setIsTodas] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
// Mantém sincronizado se a quantidade disponível mudar ou modo 'todas' estiver ativo
useEffect(() => {
if (isTodas) {
setQtdValue(item.quantidade_disponivel.toString());
} else if (qtdValue) {
const parsed = parseInt(qtdValue, 10);
if (!isNaN(parsed) && parsed > item.quantidade_disponivel) {
setQtdValue(item.quantidade_disponivel.toString());
}
}
}, [item.quantidade_disponivel, isTodas]);
const handleToggleTodas = () => {
setIsTodas((prev) => {
const novo = !prev;
setQtdValue(novo ? item.quantidade_disponivel.toString() : '');
return novo;
});
};
const handleQtdChange = (val: string) => {
setQtdValue(val);
if (isTodas) {
setIsTodas(false);
}
};
const handleExecutarBaixa = async () => {
if (!onApontarItemDireto || isLoading) return;
const quantidade = isTodas ? item.quantidade_disponivel : parseInt(qtdValue, 10);
if (isNaN(quantidade) || quantidade <= 0) {
toast.error(`Informe a quantidade para ${item.marca} ou ative 'Todas'`);
return;
}
if (quantidade > item.quantidade_disponivel) {
toast.error(`Quantidade não pode ser maior que ${item.quantidade_disponivel} disponível`);
return;
}
setIsLoading(true);
try {
const sucesso = await onApontarItemDireto(item, quantidade);
if (sucesso) {
setQtdValue('');
setIsTodas(false);
}
} catch (error) {
console.error('Erro ao executar baixa direta:', error);
toast.error('Erro ao registrar apontamento');
} finally {
setIsLoading(false);
}
};
return (
<div className="relative">
<div
className={`p-3 border rounded-md transition-colors ${
isSelected
? 'border-primary bg-primary/10 ring-1 ring-primary'
: 'border-border bg-card hover:bg-accent/40'
}`}
onClick={() => onSelect?.(item)}
>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="font-semibold text-sm text-foreground flex items-center gap-2">
<span>{item.marca}</span>
{item.perfil && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-mono font-normal">
{item.perfil}
</span>
)}
</div>
<div className="text-xs font-bold text-emerald-600 dark:text-emerald-400">
{item.quantidade_disponivel} disponível
</div>
</div>
{item.descricao && item.descricao !== item.perfil && (
<div className="text-xs text-muted-foreground truncate" title={item.descricao}>
{item.descricao}
</div>
)}
{/* Linha com processo e controles rápidos de apontamento */}
<div className="flex items-center justify-between gap-2 pt-1 flex-wrap">
<div className="text-xs text-muted-foreground font-medium">
Processo: {item.nome_processo || item.processo_atual_permitido}
</div>
{onApontarItemDireto && (
<div
className="flex items-center gap-1.5 ml-auto"
onClick={(e) => e.stopPropagation()}
>
<Input
type="number"
min="1"
max={item.quantidade_disponivel}
placeholder="Quant."
value={qtdValue}
onChange={(e) => handleQtdChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleExecutarBaixa();
}
}}
disabled={isTodas || isLoading}
className="h-7 w-20 text-xs px-2 text-center font-medium bg-background border-input shadow-none"
/>
<Button
type="button"
size="sm"
variant={isTodas ? "default" : "outline"}
onClick={handleToggleTodas}
disabled={isLoading}
className={`h-7 px-2.5 text-xs font-semibold transition-colors ${
isTodas
? "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600 shadow-sm"
: "hover:bg-accent text-foreground"
}`}
title="Habilitar/desabilitar apontar todas as unidades disponíveis"
>
Todas
</Button>
<Button
type="button"
size="sm"
disabled={isLoading || (!isTodas && (!qtdValue || parseInt(qtdValue, 10) <= 0))}
onClick={handleExecutarBaixa}
className="h-7 px-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold text-xs flex items-center gap-1 shadow-sm transition-all"
title="Baixar quantidade no apontamento"
>
{isLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Check className="h-3.5 w-3.5" />
<span>Baixar</span>
</>
)}
</Button>
</div>
)}
</div>
</div>
</div>
{isAdmin && onAdminAction && (
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 h-6 w-6 p-0 text-orange-500 hover:text-orange-600 hover:bg-orange-500/10"
onClick={(e) => {
e.stopPropagation();
onAdminAction(item);
}}
title="Verificar apontamentos e forçar exclusão"
>
<Settings className="h-3 w-3" />
</Button>
)}
</div>
);
});
ItemDisponivelCard.displayName = 'ItemDisponivelCard';
export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
pecasDisponiveis = [],
componentesDisponiveis = [],
@@ -86,11 +337,6 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
const [loadingPecas, setLoadingPecas] = useState(false);
const [loadingComponentes, setLoadingComponentes] = useState(false);
const [componentes, setComponentes] = useState<ComponenteItemData[]>([]);
// Estados locais para apontamento direto por card (quantidade, modo todas e loading)
const [qtdCards, setQtdCards] = useState<Record<string, string>>({});
const [todasCards, setTodasCards] = useState<Record<string, boolean>>({});
const [loadingApontandoId, setLoadingApontandoId] = useState<string | null>(null);
const [showComponentes, setShowComponentes] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
@@ -184,59 +430,6 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
return comps.sort((a, b) => a.marca.localeCompare(b.marca, undefined, { numeric: true, sensitivity: 'base' }));
}, [componentesDisponiveis, filtroNumeroPeca]);
const handleQtdCardChange = useCallback((itemId: string, value: string) => {
setQtdCards(prev => ({ ...prev, [itemId]: value }));
setTodasCards(prev => ({ ...prev, [itemId]: false }));
}, []);
const handleToggleTodas = useCallback((itemId: string, qtdDisponivel: number) => {
setTodasCards(prev => {
const novoEstado = !prev[itemId];
if (novoEstado) {
setQtdCards(q => ({ ...q, [itemId]: qtdDisponivel.toString() }));
} else {
setQtdCards(q => ({ ...q, [itemId]: '' }));
}
return { ...prev, [itemId]: novoEstado };
});
}, []);
const handleExecutarBaixa = useCallback(async (item: ItemDisponivel) => {
if (!onApontarItemDireto) return;
const isTodas = todasCards[item.id];
const qtdDigitada = qtdCards[item.id];
const quantidade = isTodas ? item.quantidade_disponivel : parseInt(qtdDigitada);
if (isNaN(quantidade) || quantidade <= 0) {
toast.error(`Informe a quantidade para ${item.marca} ou ative 'Todas'`);
return;
}
if (quantidade > item.quantidade_disponivel) {
toast.error(`Quantidade não pode ser maior que ${item.quantidade_disponivel} disponível`);
return;
}
setLoadingApontandoId(item.id);
try {
const sucesso = await onApontarItemDireto(item, quantidade);
if (sucesso) {
setQtdCards(prev => {
const copia = { ...prev };
delete copia[item.id];
return copia;
});
setTodasCards(prev => {
const copia = { ...prev };
delete copia[item.id];
return copia;
});
}
} finally {
setLoadingApontandoId(null);
}
}, [onApontarItemDireto, todasCards, qtdCards]);
const fetchPecas = useCallback(async (term: string) => {
if (!term.trim()) {
setPecasFiltradas([]);
@@ -318,174 +511,6 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
}
}, [onBatchSelect, pecasOrdenadas, componentesOrdenados]);
const getPrioridadeColor = (prioridade: string) => {
switch (prioridade?.toLowerCase()) {
case 'alta': return 'text-red-600 dark:text-red-400';
case 'média': return 'text-amber-600 dark:text-yellow-400';
case 'baixa': return 'text-emerald-600 dark:text-green-400';
default: return 'text-muted-foreground';
}
};
const PecaItem = ({ peca, onSelect }: { peca: PecaWithComponents; onSelect: (peca: PecaWithComponents) => void }) => {
return (
<div className="p-2 border border-border bg-card hover:bg-accent/50 rounded-md cursor-pointer transition-colors"
onClick={() => onSelect(peca)}>
<div className="space-y-1">
<div className="font-semibold text-sm text-foreground">{peca.marca}</div>
<div className="text-xs text-muted-foreground">
Prioridade: <span className={getPrioridadeColor(peca.prioridade)}>{peca.prioridade || 'N/A'}</span>
</div>
{peca.descricao && (
<div className="text-xs text-muted-foreground truncate" title={peca.descricao}>
{peca.descricao}
</div>
)}
<div className="text-xs font-bold text-emerald-600 dark:text-emerald-400">
Qtd Disponível: {peca.quantidadeDisponivel || 0}
</div>
</div>
</div>
);
};
const ComponenteItem = ({ componente }: { componente: ComponenteItemData }) => (
<div
className="p-2 border border-border bg-card hover:bg-accent/50 rounded-md cursor-pointer transition-colors"
onClick={() => handleComponenteSelect(componente)}
>
<div className="space-y-1">
<div className="font-semibold text-sm text-foreground">{componente.marca_componente}</div>
<div className="text-xs text-muted-foreground">
Perfil: {componente.perfil || 'N/A'}
</div>
<div className="text-xs text-muted-foreground">
Peso Unitário: {componente.peso_unitario || 0}kg | Qtd por Peça: {componente.quantidade_por_peca || 1}
</div>
{componente.descricao && (
<div className="text-xs text-muted-foreground truncate" title={componente.descricao}>
{componente.descricao}
</div>
)}
</div>
</div>
);
const ItemDisponivelComponent = ({ item, isSelected }: { item: ItemDisponivel; isSelected: boolean }) => {
const isTodas = Boolean(todasCards[item.id]);
const qtdValue = qtdCards[item.id] ?? (isTodas ? item.quantidade_disponivel.toString() : '');
const isLoading = loadingApontandoId === item.id;
return (
<div className="relative">
<div
className={`p-3 border rounded-md transition-colors ${
isSelected
? 'border-primary bg-primary/10 ring-1 ring-primary'
: 'border-border bg-card hover:bg-accent/40'
}`}
onClick={() => handleItemSelect(item)}
>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="font-semibold text-sm text-foreground flex items-center gap-2">
<span>{item.marca}</span>
{item.perfil && (
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-mono font-normal">
{item.perfil}
</span>
)}
</div>
<div className="text-xs font-bold text-emerald-600 dark:text-emerald-400">
{item.quantidade_disponivel} disponível
</div>
</div>
{item.descricao && item.descricao !== item.perfil && (
<div className="text-xs text-muted-foreground truncate" title={item.descricao}>
{item.descricao}
</div>
)}
{/* Linha com processo e controles rápidos de apontamento desenhados pelo usuário */}
<div className="flex items-center justify-between gap-2 pt-1 flex-wrap">
<div className="text-xs text-muted-foreground font-medium">
Processo: {item.nome_processo || item.processo_atual_permitido}
</div>
{onApontarItemDireto && (
<div
className="flex items-center gap-1.5 ml-auto"
onClick={(e) => e.stopPropagation()}
>
<Input
type="number"
min="1"
max={item.quantidade_disponivel}
placeholder="Quant."
value={qtdValue}
onChange={(e) => handleQtdCardChange(item.id, e.target.value)}
disabled={isTodas || isLoading}
className="h-7 w-20 text-xs px-2 text-center font-medium bg-background border-input shadow-none"
/>
<Button
type="button"
size="sm"
variant={isTodas ? "default" : "outline"}
onClick={() => handleToggleTodas(item.id, item.quantidade_disponivel)}
disabled={isLoading}
className={`h-7 px-2.5 text-xs font-semibold transition-colors ${
isTodas
? "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600 shadow-sm"
: "hover:bg-accent text-foreground"
}`}
title="Habilitar/desabilitar apontar todas as unidades disponíveis"
>
Todas
</Button>
<Button
type="button"
size="sm"
disabled={isLoading || (!isTodas && (!qtdValue || parseInt(qtdValue) <= 0))}
onClick={() => handleExecutarBaixa(item)}
className="h-7 px-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold text-xs flex items-center gap-1 shadow-sm transition-all"
title="Baixar quantidade no apontamento"
>
{isLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Check className="h-3.5 w-3.5" />
<span>Baixar</span>
</>
)}
</Button>
</div>
)}
</div>
</div>
</div>
{isAdmin && (
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 h-6 w-6 p-0 text-orange-500 hover:text-orange-600 hover:bg-orange-500/10"
onClick={(e) => {
e.stopPropagation();
handleAdminAction(item);
}}
title="Verificar apontamentos e forçar exclusão"
>
<Settings className="h-3 w-3" />
</Button>
)}
</div>
);
};
if (onSelectPeca) {
return (
<div className="space-y-4">
@@ -527,7 +552,7 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
<ScrollArea className="rounded-md border border-border h-[200px] p-2">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{componentes.map((componente) => (
<ComponenteItem key={componente.id} componente={componente} />
<ComponenteItem key={componente.id} componente={componente} onSelect={handleComponenteSelect} />
))}
{componentes.length === 0 && (
<div className="text-center text-muted-foreground col-span-full">
@@ -592,27 +617,15 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
<ScrollArea className="rounded-md border border-border h-[300px] p-2">
<div className="space-y-2">
{pecasOrdenadas.map((item) => (
<div key={item.id} className="relative">
<ItemDisponivelComponent
<ItemDisponivelCard
key={item.id}
item={item}
isSelected={itemSelecionado?.id === item.id}
isAdmin={isAdmin}
onSelect={handleItemSelect}
onAdminAction={handleAdminAction}
onApontarItemDireto={onApontarItemDireto}
/>
{isAdmin && (
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2 h-6 w-6 p-0 text-orange-500 hover:text-orange-600 hover:bg-orange-500/10"
onClick={(e) => {
e.stopPropagation();
handleAdminAction(item);
}}
title="Verificar apontamentos e forçar exclusão"
>
<AlertTriangle className="h-3 w-3" />
</Button>
)}
</div>
))}
{pecasOrdenadas.length === 0 && (
<div className="text-center text-muted-foreground py-8">
@@ -628,7 +641,10 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
{componentesDisponiveis.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-foreground">Componentes Disponíveis</h3>
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Layers className="h-5 w-5 text-blue-500" />
Componentes para Corte (Controle de Kit)
</h3>
<Button
size="sm"
variant="outline"
@@ -642,10 +658,14 @@ export const SeletorItensOtimizado: React.FC<SeletorItensOtimizadoProps> = ({
<ScrollArea className="rounded-md border border-border h-[300px] p-2">
<div className="space-y-2">
{componentesOrdenados.map((item) => (
<ItemDisponivelComponent
<ItemDisponivelCard
key={item.id}
item={item}
isSelected={itemSelecionado?.id === item.id}
isAdmin={isAdmin}
onSelect={handleItemSelect}
onAdminAction={handleAdminAction}
onApontarItemDireto={onApontarItemDireto}
/>
))}
</div>
@@ -95,7 +95,9 @@ export const ApontamentosFilters: React.FC<ApontamentosFiltersProps> = ({
<SelectContent>
<SelectItem value="all">Todas as fases</SelectItem>
{uniqueFases.map((fase) => (
<SelectItem key={fase} value={fase}>{fase}</SelectItem>
<SelectItem key={fase} value={fase}>
{fase.toLowerCase().startsWith('fase') || fase.toLowerCase().startsWith('etapa') ? fase : `Fase ${fase}`}
</SelectItem>
))}
</SelectContent>
</Select>
@@ -35,6 +35,15 @@ export const ApontamentosListItem: React.FC<ApontamentosListItemProps> = ({
}
};
const getFaseItem = (apontamento: ApontamentoProducao) => {
const rawFase = apontamento.peca?.etapa_fase || (apontamento.componente as any)?.peca?.etapa_fase;
if (!rawFase || !String(rawFase).trim()) return null;
const str = String(rawFase).trim();
return str.toLowerCase().startsWith('fase') || str.toLowerCase().startsWith('etapa')
? str
: `Fase ${str}`;
};
return (
<div className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3">
@@ -52,6 +61,21 @@ export const ApontamentosListItem: React.FC<ApontamentosListItemProps> = ({
<Badge variant="outline">
{apontamento.of_number || 'N/A'}
</Badge>
{getFaseItem(apontamento) ? (
<Badge
variant="secondary"
className="bg-sky-50 text-sky-700 border-sky-200 dark:bg-sky-950/60 dark:text-sky-300 dark:border-sky-800 text-xs font-semibold"
>
{getFaseItem(apontamento)}
</Badge>
) : (
<Badge
variant="outline"
className="text-muted-foreground border-dashed text-xs"
>
Fase -
</Badge>
)}
{apontamento.is_forcado && (
<span className={`text-[10px] font-black px-2 py-0.5 rounded-full border ${
apontamento.status_confirmacao === 'pendente_confirmacao'
@@ -100,7 +124,7 @@ export const ApontamentosListItem: React.FC<ApontamentosListItemProps> = ({
Tem certeza de que deseja reverter este apontamento?
Esta ação não pode ser desfeita e irá remover permanentemente o registro de:
<br />
<strong>{getMarcaItem(apontamento)}</strong> - {apontamento.quantidade_produzida} unidades
<strong>{getMarcaItem(apontamento)}</strong> (OF: {apontamento.of_number || 'N/A'}{getFaseItem(apontamento) ? ` • ${getFaseItem(apontamento)}` : ''}) - {apontamento.quantidade_produzida} unidades
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -89,12 +89,12 @@ export const useApontamentosFilters = (apontamentos: ApontamentoProducao[]) => {
: apontamentos;
const fases = apontamentosFiltrados
.map(apt => apt.peca?.etapa_fase)
.map(apt => apt.peca?.etapa_fase || (apt.componente as any)?.peca?.etapa_fase)
.filter((fase): fase is string => {
return typeof fase === 'string' && fase.trim() !== '';
})
.filter((fase, index, array) => array.indexOf(fase) === index)
.sort();
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
console.log(`📋 Fases únicas encontradas para OF "${filterOF}": ${fases.length}`, fases);
return fases;
@@ -166,7 +166,7 @@ export const useApontamentosFilters = (apontamentos: ApontamentoProducao[]) => {
// Aplicar filtro por Fase APENAS se selecionado
if (filterFase && filterFase !== '' && filterFase !== 'all') {
const beforeFase = resultado.length;
resultado = resultado.filter(apt => apt.peca?.etapa_fase === filterFase);
resultado = resultado.filter(apt => (apt.peca?.etapa_fase || (apt.componente as any)?.peca?.etapa_fase) === filterFase);
console.log(`📋 Filtro Fase "${filterFase}": ${beforeFase} → ${resultado.length} registros`);
}
@@ -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,681 @@
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,
Info
} from 'lucide-react';
import { useKitsProducao, PecaKitInfo } from '@/hooks/useKitsProducao';
import { useOFsAtivas, OFAtiva } from '@/hooks/useOFsAtivas';
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 { ofsAtivas, isLoading: loadingOFs } = useOFsAtivas();
const [selectedOF, setSelectedOF] = useState<string>(initialOF || '');
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 (initialOF) {
setSelectedOF(initialOF);
} else if (!selectedOF && ofsAtivas.length > 0) {
setSelectedOF(ofsAtivas[0].of_number);
}
}, [initialOF, ofsAtivas, selectedOF]);
const { listaKits, resumoKits, loading, refetch } = useKitsProducao(selectedOF);
// Extrair fases únicas da lista de kits carregada de toda a OF
const fasesDisponiveis = useMemo(() => {
const s = new Set<string>();
listaKits.forEach((k) => {
if (k.etapa_fase && k.etapa_fase.trim()) s.add(k.etapa_fase.trim());
});
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 combinada (Fase + Status + Busca textual)
const pecasFiltradas = useMemo(() => {
return listaKits.filter((item) => {
// Filtro de fase
if (selectedFase && selectedFase !== 'all' && selectedFase !== 'todas') {
if (item.etapa_fase !== selectedFase) return false;
}
// 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, selectedFase, filtroStatus, busca]);
// Formatação de pesos em kg
const formatarKg = (valorKg: number) => {
if (!valorKg || valorKg <= 0) return '0 kg';
return `${valorKg.toLocaleString('pt-BR', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} kg`;
};
// KPIs calculados com base nas peças filtradas
const resumoExibido = useMemo(() => {
let total100 = 0;
let totalParciais = 0;
let totalTravados = 0;
let pesoTot = 0;
let pesoCort = 0;
let peso100 = 0;
let pesoUnLiberadas = 0;
let pesoParciais = 0;
let pesoNaoIniciados = 0;
let unTot = 0;
let unLib = 0;
pecasFiltradas.forEach((item) => {
unTot += item.quantidade_total;
unLib += item.kits_completos_cortados;
pesoTot += item.peso_total_peca;
pesoCort += item.peso_cortado_componentes;
pesoUnLiberadas += item.peso_kits_liberados;
if (item.status_termico === 'verde') {
total100++;
peso100 += item.peso_total_peca;
} else if (item.status_termico === 'verde_parcial' || item.status_termico === 'amarelo') {
totalParciais++;
pesoParciais += item.peso_total_peca;
} else {
totalTravados++;
pesoNaoIniciados += item.peso_total_peca;
}
});
return {
totalPecas: pecasFiltradas.length,
totalKits100Porcento: total100,
totalKitsParciais: totalParciais,
totalKitsNaoIniciados: totalTravados,
pesoTotalOFKg: pesoTot,
pesoCortadoTotalKg: pesoCort,
peso100PorcentoKg: peso100,
pesoUnidadesLiberadasKg: pesoUnLiberadas,
pesoParciaisKg: pesoParciais,
pesoNaoIniciadosKg: pesoNaoIniciados,
percentualGeralOF: pesoTot > 0 ? Math.min(100, Math.round((pesoCort / pesoTot) * 100)) : 0,
totalUnidadesTotal: unTot,
totalUnidadesLiberadasSolda: unLib,
};
}, [pecasFiltradas]);
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={(val) => {
setSelectedOF(val);
setSelectedFase('all');
}}
disabled={loadingOFs}
>
<SelectTrigger className="h-9 text-xs font-semibold">
<SelectValue placeholder={loadingOFs ? "Carregando OFs..." : "Selecione uma OF..."}>
{selectedOF ? `OF ${selectedOF}` : undefined}
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-72">
{ofsAtivas.map((o) => (
<SelectItem key={o.of_number} value={o.of_number}>
OF {o.of_number} {o.cliente ? `• ${o.cliente}` : o.descricao_resumida ? `• ${o.descricao_resumida}` : ''}
</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 ──────────────── */}
{resumoExibido && (
<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 className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] font-black uppercase text-emerald-700 dark:text-emerald-400">
100% Liberadas
</span>
<span className="text-[11px] font-black text-emerald-800 dark:text-emerald-300 bg-emerald-200/80 dark:bg-emerald-900/70 px-1.5 py-0.5 rounded-md border border-emerald-300/80 dark:border-emerald-700 shadow-2xs">
{formatarKg(resumoExibido.peso100PorcentoKg)}
</span>
</div>
<div className="text-2xl font-black text-emerald-900 dark:text-emerald-100 mt-1">
{resumoExibido.totalKits100Porcento}
</div>
<div className="text-[10px] text-emerald-700/80 dark:text-emerald-400/80 font-medium truncate">
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 shrink-0 ml-2">
<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 className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] font-black uppercase text-blue-700 dark:text-blue-400">
Unidades Liberadas
</span>
<span className="text-[11px] font-black text-blue-800 dark:text-blue-300 bg-blue-200/80 dark:bg-blue-900/70 px-1.5 py-0.5 rounded-md border border-blue-300/80 dark:border-blue-700 shadow-2xs">
{formatarKg(resumoExibido.pesoUnidadesLiberadasKg)}
</span>
</div>
<div className="text-2xl font-black text-blue-900 dark:text-blue-100 mt-1">
{resumoExibido.totalUnidadesLiberadasSolda} <span className="text-xs text-blue-600 font-bold">/ {resumoExibido.totalUnidadesTotal} un</span>
</div>
<div className="text-[10px] text-blue-700/80 dark:text-blue-400/80 font-medium truncate">
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 shrink-0 ml-2">
<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 className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] font-black uppercase text-amber-700 dark:text-amber-400">
Corte em Andamento
</span>
<span className="text-[11px] font-black text-amber-800 dark:text-amber-300 bg-amber-200/80 dark:bg-amber-900/70 px-1.5 py-0.5 rounded-md border border-amber-300/80 dark:border-amber-700 shadow-2xs">
{formatarKg(resumoExibido.pesoParciaisKg)}
</span>
</div>
<div className="text-2xl font-black text-amber-900 dark:text-amber-100 mt-1">
{resumoExibido.totalKitsParciais}
</div>
<div className="text-[10px] text-amber-700/80 dark:text-amber-400/80 font-medium truncate">
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 shrink-0 ml-2">
<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 className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] font-black uppercase text-rose-700 dark:text-rose-400">
Não Iniciados
</span>
<span className="text-[11px] font-black text-rose-800 dark:text-rose-300 bg-rose-200/80 dark:bg-rose-900/70 px-1.5 py-0.5 rounded-md border border-rose-300/80 dark:border-rose-700 shadow-2xs">
{formatarKg(resumoExibido.pesoNaoIniciadosKg)}
</span>
</div>
<div className="text-2xl font-black text-rose-900 dark:text-rose-100 mt-1">
{resumoExibido.totalKitsNaoIniciados}
</div>
<div className="text-[10px] text-rose-700/80 dark:text-rose-400/80 font-medium truncate">
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 shrink-0 ml-2">
<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 && listaKits.length > 0 && !listaKits.some((k) => k.componentes && k.componentes.length > 0) && (
<div className="p-3.5 rounded-2xl bg-amber-500/10 border border-amber-500/30 text-amber-900 dark:text-amber-200 flex items-center gap-3 shadow-xs">
<Info className="w-5 h-5 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="text-xs leading-relaxed">
<strong className="font-bold">Modo Peça Direta:</strong> Esta OF não possui subcomponentes cadastrados no sistema. O avanço no <strong>Corte</strong> e a liberação das peças para a <strong>Solda</strong> são gerenciados diretamente pelo apontamento das peças principais.
</div>
</div>
)}
{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 flex items-center gap-1.5 flex-wrap">
<span className="line-clamp-1">{peca.descricao || 'Peça Estrutural'}</span>
{peca.peso_unitario > 0 && (
<span className="font-semibold text-slate-600 dark:text-slate-400 whitespace-nowrap">
• {peca.peso_unitario.toFixed(1)} kg/un (Total: {peca.peso_total_peca.toFixed(1)} kg)
</span>
)}
</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>
);
};
@@ -26,6 +26,9 @@ import * as XLSX from 'xlsx';
import { useAppLabels } from '@/hooks/useAppLabels';
import { usePecas } from '@/hooks/usePecas';
import { supabase } from '@/integrations/supabase/client';
import { analisarConflitosImportacao, consolidarPecasEComponentes, extrairComprimentoSeguro, extrairComprimentoComponenteSeguro } from '@/utils/importDiffService';
import { ResultadoAnaliseImportacao } from '@/types/importDiff';
import { ImportarPecasDiffModal } from '@/components/pecas/ImportarPecasDiffModal';
export interface ComponentItem {
marca: string;
@@ -145,6 +148,9 @@ const AdvanceSteelConverterContent: React.FC = () => {
const [statusText, setStatusText] = useState<string>('Aguardando arquivo PDF...');
const [currentFileName, setCurrentFileName] = useState<string>('Lista_Pecas');
const [useIframeMode, setUseIframeMode] = useState<boolean>(false);
const [analiseDiff, setAnaliseDiff] = useState<ResultadoAnaliseImportacao | null>(null);
const [componentesMapDiff, setComponentesMapDiff] = useState<Map<string, any[]>>(new Map());
const [showDiffModal, setShowDiffModal] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const logEndRef = useRef<HTMLDivElement>(null);
@@ -753,6 +759,7 @@ const AdvanceSteelConverterContent: React.FC = () => {
// Aba 3: Formato Plano Oficial para Importação TrackSteel
const excelRowsImportacao: any[] = [];
extractedData.forEach((item) => {
const comprPecaNum = item.comprimentoMax === '-' ? '' : Number(item.comprimentoMax) || '';
if (item.components && item.components.length > 0) {
item.components.forEach((comp) => {
excelRowsImportacao.push({
@@ -763,6 +770,8 @@ const AdvanceSteelConverterContent: React.FC = () => {
quantidade: Number(item.quantidade),
peso_unitario: item.pesoUnit === '-' ? 0 : Number(item.pesoUnit),
peso_total: item.pesoTotal === '-' ? 0 : Number(item.pesoTotal),
comprimento: comprPecaNum,
comprimento_ref: comprPecaNum,
tratamento_superficial: item.tratamentoSuperficial || tratamentoGlobal,
material: item.material,
perfil_principal: item.perfilPrincipal,
@@ -771,6 +780,7 @@ const AdvanceSteelConverterContent: React.FC = () => {
descricao_componente: comp.perfil || item.descricao,
perfil_componente: comp.perfil || item.descricao,
peso_unitario_componente: comp.pesoUnitario || 0,
comprimento_componente: comp.comprimento || '',
quantidade_por_peca: Number(comp.quantidade) || 1
});
});
@@ -783,6 +793,8 @@ const AdvanceSteelConverterContent: React.FC = () => {
quantidade: Number(item.quantidade),
peso_unitario: item.pesoUnit === '-' ? 0 : Number(item.pesoUnit),
peso_total: item.pesoTotal === '-' ? 0 : Number(item.pesoTotal),
comprimento: comprPecaNum,
comprimento_ref: comprPecaNum,
tratamento_superficial: item.tratamentoSuperficial || tratamentoGlobal,
material: item.material,
perfil_principal: item.perfilPrincipal,
@@ -791,6 +803,7 @@ const AdvanceSteelConverterContent: React.FC = () => {
descricao_componente: '',
perfil_componente: '',
peso_unitario_componente: 0,
comprimento_componente: '',
quantidade_por_peca: 0
});
}
@@ -829,8 +842,12 @@ const AdvanceSteelConverterContent: React.FC = () => {
try {
const importacaoRows: any[] = [];
extractedData.forEach((item) => {
const comprPeca = extrairComprimentoSeguro(item.comprimentoMax) ?? extrairComprimentoSeguro(item);
if (item.components && item.components.length > 0) {
item.components.forEach((comp) => {
const comprComp = extrairComprimentoComponenteSeguro(comp.comprimento) ?? extrairComprimentoComponenteSeguro(comp);
importacaoRows.push({
of_number: item.of,
etapa_fase: item.fase,
@@ -839,6 +856,8 @@ const AdvanceSteelConverterContent: React.FC = () => {
quantidade: Number(item.quantidade),
peso_unitario: item.pesoUnit === '-' ? 0 : Number(item.pesoUnit),
peso_total: item.pesoTotal === '-' ? 0 : Number(item.pesoTotal),
comprimento: comprPeca,
comprimento_ref: comprPeca,
tratamento_superficial: item.tratamentoSuperficial || tratamentoGlobal,
material: item.material,
perfil_principal: item.perfilPrincipal,
@@ -847,6 +866,7 @@ const AdvanceSteelConverterContent: React.FC = () => {
descricao_componente: comp.perfil || item.descricao,
perfil_componente: comp.perfil || item.descricao,
peso_unitario_componente: comp.pesoUnitario || 0,
comprimento_componente: comprComp,
quantidade_por_peca: Number(comp.quantidade) || 1
});
});
@@ -859,6 +879,8 @@ const AdvanceSteelConverterContent: React.FC = () => {
quantidade: Number(item.quantidade),
peso_unitario: item.pesoUnit === '-' ? 0 : Number(item.pesoUnit),
peso_total: item.pesoTotal === '-' ? 0 : Number(item.pesoTotal),
comprimento: comprPeca,
comprimento_ref: comprPeca,
tratamento_superficial: item.tratamentoSuperficial || tratamentoGlobal,
material: item.material,
perfil_principal: item.perfilPrincipal,
@@ -867,11 +889,24 @@ const AdvanceSteelConverterContent: React.FC = () => {
descricao_componente: '',
perfil_componente: '',
peso_unitario_componente: 0,
comprimento_componente: null,
quantidade_por_peca: 0
});
}
});
toast.info('Verificando existência e status de produção das peças...');
const analise = await analisarConflitosImportacao(importacaoRows);
const { componentesMap } = consolidarPecasEComponentes(importacaoRows);
if (analise.totalComDivergencia > 0 || analise.totalIdenticas > 0 || analise.totalComApontamento > 0) {
setAnaliseDiff(analise);
setComponentesMapDiff(componentesMap);
setShowDiffModal(true);
setIsDirectImporting(false);
return;
}
await importPecas(importacaoRows);
toast.success(`${extractedData.length} peças e seus componentes foram importados com sucesso!`);
addLog(`✅ Importação concluída: ${extractedData.length} peças gravadas no banco.`);
@@ -1160,6 +1195,19 @@ const AdvanceSteelConverterContent: React.FC = () => {
)}
</CardContent>
</Card>
{/* Modal de Detecção de Conflitos e Conciliação */}
<ImportarPecasDiffModal
open={showDiffModal}
onOpenChange={setShowDiffModal}
analise={analiseDiff}
componentesMap={componentesMapDiff}
ofDefault={headerOf || ''}
onConcluido={() => {
setShowDiffModal(false);
addLog(`✅ Importação conciliada concluída com sucesso.`);
}}
/>
</div>
);
};
@@ -25,6 +25,9 @@ import {
import { toast } from 'sonner';
import * as XLSX from 'xlsx';
import { usePecas } from '@/hooks/usePecas';
import { analisarConflitosImportacao, consolidarPecasEComponentes, extrairComprimentoSeguro, extrairComprimentoComponenteSeguro } from '@/utils/importDiffService';
import { ResultadoAnaliseImportacao } from '@/types/importDiff';
import { ImportarPecasDiffModal } from '@/components/pecas/ImportarPecasDiffModal';
export interface BocadConvertedPiece {
of: string;
@@ -78,6 +81,9 @@ export const BocadConverter: React.FC = () => {
const [headerFase, setHeaderFase] = useState<string>('');
const [processingLogs, setProcessingLogs] = useState<string[]>([]);
const [showLogs, setShowLogs] = useState(false);
const [analiseDiff, setAnaliseDiff] = useState<ResultadoAnaliseImportacao | null>(null);
const [componentesMapDiff, setComponentesMapDiff] = useState<Map<string, any[]>>(new Map());
const [showDiffModal, setShowDiffModal] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -526,8 +532,12 @@ export const BocadConverter: React.FC = () => {
try {
const importacaoRows: any[] = [];
extractedPieces.forEach((item) => {
const comprPeca = extrairComprimentoSeguro(item.comprimentoRef) ?? extrairComprimentoSeguro(item);
if (item.compostoPorComponentes === 'SIM' && item.components && item.components.length > 0) {
item.components.forEach((comp) => {
const comprComp = extrairComprimentoComponenteSeguro(comp.compr) ?? extrairComprimentoComponenteSeguro(comp);
importacaoRows.push({
of_number: item.of,
etapa_fase: item.fase,
@@ -536,6 +546,8 @@ export const BocadConverter: React.FC = () => {
quantidade: Number(item.quantidade),
peso_unitario: item.pesoUnitario || 0,
peso_total: item.pesoTotal || 0,
comprimento: comprPeca,
comprimento_ref: comprPeca,
tratamento_superficial: item.tratamentoSuperficial,
material: item.material,
perfil_principal: item.perfilPrincipal,
@@ -544,6 +556,7 @@ export const BocadConverter: React.FC = () => {
descricao_componente: comp.perfil || item.descricao,
perfil_componente: comp.perfil || item.descricao,
peso_unitario_componente: comp.pesoUnit || 0,
comprimento_componente: comprComp,
quantidade_por_peca: Number(comp.quant) || 1
});
});
@@ -556,6 +569,8 @@ export const BocadConverter: React.FC = () => {
quantidade: Number(item.quantidade),
peso_unitario: item.pesoUnitario || 0,
peso_total: item.pesoTotal || 0,
comprimento: comprPeca,
comprimento_ref: comprPeca,
tratamento_superficial: item.tratamentoSuperficial,
material: item.material,
perfil_principal: item.perfilPrincipal,
@@ -569,6 +584,18 @@ export const BocadConverter: React.FC = () => {
}
});
toast.info('Verificando existência e status de produção das peças...');
const analise = await analisarConflitosImportacao(importacaoRows);
const { componentesMap } = consolidarPecasEComponentes(importacaoRows);
if (analise.totalComDivergencia > 0 || analise.totalIdenticas > 0 || analise.totalComApontamento > 0) {
setAnaliseDiff(analise);
setComponentesMapDiff(componentesMap);
setShowDiffModal(true);
setIsDirectImporting(false);
return;
}
await importPecas(importacaoRows);
toast.success(`${extractedPieces.length} peças foram importadas com sucesso!`);
addLog(`✅ Importação concluída: ${extractedPieces.length} peças gravadas no banco.`);
@@ -866,6 +893,19 @@ export const BocadConverter: React.FC = () => {
)}
</CardContent>
</Card>
{/* Modal de Detecção de Conflitos e Conciliação */}
<ImportarPecasDiffModal
open={showDiffModal}
onOpenChange={setShowDiffModal}
analise={analiseDiff}
componentesMap={componentesMapDiff}
ofDefault={headerOf || ''}
onConcluido={() => {
setShowDiffModal(false);
addLog(`✅ Importação conciliada concluída com sucesso.`);
}}
/>
</div>
);
};
@@ -0,0 +1,385 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Truck,
Calendar,
AlertTriangle,
CheckCircle2,
Loader2,
Sparkles,
Info
} from 'lucide-react';
import { toast } from 'sonner';
import { useQueryClient } from '@tanstack/react-query';
import { useAuth } from '@/hooks/useAuth';
import {
validarRomaneioParaEntrega,
executarApontamentoRomaneioEntregue,
ResultadoValidacaoEntrega
} from '@/utils/validacaoRomaneio';
export interface RomaneioEntregaInfo {
id: string;
numero_romaneio: string;
of_number: string;
data_prevista_entrega?: string | null;
data_romaneio?: string | null;
status?: string;
}
interface ConfirmacaoEntregaRomaneioModalProps {
isOpen: boolean;
onClose: () => void;
romaneio: RomaneioEntregaInfo | null;
onSucesso?: () => void;
}
export const ConfirmacaoEntregaRomaneioModal: React.FC<ConfirmacaoEntregaRomaneioModalProps> = ({
isOpen,
onClose,
romaneio,
onSucesso
}) => {
const { user } = useAuth();
const queryClient = useQueryClient();
const [loadingAnalise, setLoadingAnalise] = useState(false);
const [analise, setAnalise] = useState<ResultadoValidacaoEntrega | null>(null);
const [dataRealEntrega, setDataRealEntrega] = useState<string>('');
const [processando, setProcessando] = useState(false);
// Inicializar dados ao abrir
useEffect(() => {
if (isOpen && romaneio) {
const hoje = new Date().toISOString().split('T')[0];
const dataInicial = romaneio.data_prevista_entrega || hoje;
setDataRealEntrega(dataInicial);
carregarAnalise(romaneio.id, romaneio.of_number);
} else {
setAnalise(null);
setDataRealEntrega('');
}
}, [isOpen, romaneio]);
const carregarAnalise = async (romaneioId: string, ofNumber: string) => {
try {
setLoadingAnalise(true);
const res = await validarRomaneioParaEntrega(romaneioId, ofNumber);
setAnalise(res);
} catch (e: any) {
console.error('Erro ao analisar romaneio:', e);
toast.error('Erro ao verificar status das peças: ' + (e?.message || 'Falha de comunicação'));
} finally {
setLoadingAnalise(false);
}
};
const handleExecutar = async (autorizarCascata: boolean, apontarApenasLiberadas: boolean) => {
if (!romaneio) return;
if (!dataRealEntrega) {
toast.error('Informe a data real de entrega para gravar o apontamento');
return;
}
try {
setProcessando(true);
const res = await executarApontamentoRomaneioEntregue({
romaneioId: romaneio.id,
ofNumber: romaneio.of_number,
numeroRomaneio: romaneio.numero_romaneio,
dataEntregaReal: dataRealEntrega,
autorizarCascata,
apontarApenasLiberadas,
userId: user?.id
});
if (res.sucesso) {
toast.success(res.mensagem);
// Atualizar queries ativas para atualizar tabelas e gráficos
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['romaneios'] }),
queryClient.invalidateQueries({ queryKey: ['apontamentos'] }),
queryClient.invalidateQueries({ queryKey: ['dashboard-producao'] }),
queryClient.invalidateQueries({ queryKey: ['pecas'] })
]);
onClose();
if (onSucesso) onSucesso();
} else {
toast.warning(res.mensagem);
}
} catch (e: any) {
console.error('Erro ao processar entrega:', e);
toast.error('Erro ao concluir apontamento: ' + (e?.message || 'Falha no banco de dados'));
} finally {
setProcessando(false);
}
};
if (!romaneio) return null;
const dataPrevistaFormatada = romaneio.data_prevista_entrega
? new Date(romaneio.data_prevista_entrega + 'T12:00:00').toLocaleDateString('pt-BR')
: null;
const hoje = new Date().toISOString().split('T')[0];
const dataDiferenteDePrevista = romaneio.data_prevista_entrega && dataRealEntrega !== romaneio.data_prevista_entrega;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col p-6">
<DialogHeader className="border-b pb-3">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20">
<Truck className="h-6 w-6" />
</div>
<div>
<DialogTitle className="text-xl font-bold flex items-center gap-2">
<span>Confirmar Entrega & Apontamento de Expedição</span>
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm text-muted-foreground mt-0.5">
Romaneio <strong>{romaneio.numero_romaneio}</strong> • OF <strong>{romaneio.of_number}</strong>
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="py-4 space-y-4 overflow-y-auto flex-1 pr-1">
{/* Seção 1: Data Real da Entrega */}
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<Label htmlFor="data-real-entrega" className="font-semibold text-sm flex items-center gap-2">
<Calendar className="h-4 w-4 text-primary" />
Data Real da Entrega *
</Label>
<div className="flex items-center gap-1.5">
{romaneio.data_prevista_entrega && (
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-xs px-2"
onClick={() => setDataRealEntrega(romaneio.data_prevista_entrega!)}
>
Usar Data Prevista ({dataPrevistaFormatada})
</Button>
)}
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-xs px-2"
onClick={() => setDataRealEntrega(hoje)}
>
Usar Hoje
</Button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 items-center">
<Input
id="data-real-entrega"
type="date"
value={dataRealEntrega}
onChange={(e) => setDataRealEntrega(e.target.value)}
className="h-9 font-medium"
/>
<div className="text-xs text-muted-foreground">
{dataDiferenteDePrevista ? (
<span className="text-amber-600 dark:text-amber-400 font-medium flex items-center gap-1">
<Info className="h-3.5 w-3.5 flex-shrink-0" />
Data real difere da data prevista ({dataPrevistaFormatada}).
</span>
) : (
<span>Os apontamentos serão gravados exatamente nesta data.</span>
)}
</div>
</div>
</div>
{/* Seção 2: Diagnóstico dos Processos de Fabricação */}
{loadingAnalise ? (
<div className="p-8 text-center space-y-2">
<Loader2 className="h-7 w-7 animate-spin mx-auto text-primary" />
<p className="text-xs text-muted-foreground">Analisando processos de fabricação das peças...</p>
</div>
) : analise ? (
<div className="space-y-3">
{analise.totalPendentes === 0 ? (
// Cenário A: Todas as peças liberadas
<div className="p-4 bg-emerald-500/10 border border-emerald-500/30 rounded-lg flex items-start gap-3">
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400 flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<h4 className="font-semibold text-emerald-700 dark:text-emerald-300 text-sm">
Tudo pronto para Expedição!
</h4>
<p className="text-xs text-muted-foreground">
Todas as <strong>{analise.totalItens} peças</strong> deste romaneio já concluíram os processos de fabricação anteriores na fábrica. Ao confirmar, o apontamento de Expedição será registrado imediatamente para todas as unidades na data informada.
</p>
</div>
</div>
) : (
// Cenário B: Existem peças com pendências nos processos anteriores
<div className="space-y-3">
<div className="p-4 bg-amber-500/10 border border-amber-500/30 rounded-lg flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<h4 className="font-semibold text-amber-700 dark:text-amber-300 text-sm">
Peças Pendentes nos Processos Anteriores ({analise.totalPendentes} de {analise.totalItens} itens)
</h4>
<p className="text-xs text-muted-foreground">
Algumas peças deste romaneio ainda não possuem apontamentos completos nos processos de fabricação anteriores (Corte, Solda ou Pintura/Galv).
</p>
</div>
</div>
{/* Tabela de Peças Pendentes */}
<div className="border rounded-md overflow-hidden bg-card">
<div className="bg-muted/50 px-3 py-2 text-xs font-semibold text-foreground border-b flex justify-between items-center">
<span>Detalhamento das Peças com Pendência</span>
<Badge variant="outline" className="text-xs">
{analise.totalPendentes} peça(s) a regularizar
</Badge>
</div>
<ScrollArea className="max-h-[220px]">
<table className="w-full text-xs text-left">
<thead className="bg-muted/30 text-muted-foreground uppercase border-b sticky top-0">
<tr>
<th className="py-2 px-3">Marca</th>
<th className="py-2 px-3 text-center">Fase</th>
<th className="py-2 px-3 text-center">No Romaneio</th>
<th className="py-2 px-3">Processos que Faltam Apontar</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{analise.pecasAnalise
?.filter(p => !p.liberada)
.map((peca) => (
<tr key={peca.peca_id} className="hover:bg-accent/40">
<td className="py-2 px-3 font-semibold text-foreground">
{peca.marca}
</td>
<td className="py-2 px-3 text-center">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
Fase {peca.fase}
</Badge>
</td>
<td className="py-2 px-3 text-center font-bold">
{peca.quantidade_expedida} un
</td>
<td className="py-2 px-3">
<div className="flex flex-wrap gap-1">
{peca.processosFaltantes.map((f, i) => (
<Badge
key={i}
variant="outline"
className="text-[10px] bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20"
>
{f.nome}: faltam {f.qtdFaltante} un
</Badge>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</ScrollArea>
</div>
{/* Pergunta de Decisão com Destaque */}
<div className="p-3.5 bg-blue-500/10 border border-blue-500/30 rounded-lg flex items-center gap-3">
<Sparkles className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<div className="text-xs">
<p className="font-semibold text-blue-900 dark:text-blue-200">
Autorizar Apontamento em Cascata?
</p>
<p className="text-muted-foreground mt-0.5">
O sistema irá criar automaticamente os apontamentos faltantes nos processos pendentes para regularizar a produção e registrar a entrega completa de todas as peças na Expedição.
</p>
</div>
</div>
</div>
)}
</div>
) : null}
</div>
<DialogFooter className="border-t pt-3 flex flex-wrap gap-2 justify-between items-center sm:justify-between">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={processando}
size="sm"
>
Cancelar
</Button>
<div className="flex flex-wrap gap-2 ml-auto">
{analise && analise.totalPendentes === 0 ? (
// Botão para caso sem pendências
<Button
type="button"
onClick={() => handleExecutar(false, false)}
disabled={processando || !dataRealEntrega}
className="bg-emerald-600 hover:bg-emerald-700 text-white font-semibold text-xs shadow-sm"
size="sm"
>
{processando ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
) : (
<CheckCircle2 className="h-3.5 w-3.5 mr-1.5" />
)}
Confirmar Entrega & Apontar Expedição
</Button>
) : analise && analise.totalPendentes > 0 ? (
<>
{analise.totalLiberadas && analise.totalLiberadas > 0 ? (
<Button
type="button"
variant="secondary"
onClick={() => handleExecutar(false, true)}
disabled={processando || !dataRealEntrega}
className="text-xs"
size="sm"
title="Aponta somente as peças que já concluíram Pintura"
>
Apontar Apenas Liberadas ({analise.totalLiberadas})
</Button>
) : null}
<Button
type="button"
onClick={() => handleExecutar(true, false)}
disabled={processando || !dataRealEntrega}
className="bg-blue-600 hover:bg-blue-700 text-white font-bold text-xs shadow-sm flex items-center gap-1.5"
size="sm"
>
{processando ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
Autorizar Cascata e Apontar Todas ({analise.totalItens} peças)
</Button>
</>
) : null}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+122 -28
View File
@@ -28,7 +28,10 @@ interface PecaDisponivel {
id: string;
marca: string;
descricao: string;
perfil_principal?: string | null;
etapa_fase: string;
quantidade_total_peca: number;
quantidade_ja_expedida: number;
quantidade_disponivel: number;
peso_unitario: number;
comprimento?: number | null;
@@ -104,18 +107,24 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
.from('apontamentos_producao')
.select(`
peca_id,
componente_id,
tipo_apontamento,
quantidade_produzida,
processo_id,
processo:processos_fabricacao!inner!apontamentos_producao_processo_id_fkey(nome),
componente:componentes_peca!apontamentos_producao_componente_id_fkey(id, quantidade_por_peca),
peca:pecas!inner!apontamentos_producao_peca_id_fkey(
id,
marca,
descricao,
perfil_principal,
etapa_fase,
quantidade,
peso_unitario,
comprimento,
prioridade,
of_number
of_number,
tem_componentes
)
`)
.eq('peca.of_number', romaneio.of_number)
@@ -141,49 +150,99 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(expedidosData as any[]).forEach((item: any) => {
const pid = item.peca_id;
const qtd = item.quantidade_expedida || 0;
const qtd = Number(item.quantidade_expedida) || 0;
const current = expedidosMap.get(pid) || 0;
expedidosMap.set(pid, current + qtd);
});
}
// Agrupar por peça e calcular quantidade total produzida
const pecasAgrupadas = new Map<string, PecaDisponivel>();
// 3. Agrupar por peça principal (NUNCA somar unidades de subcomponentes na peça principal)
const pecasAgrupadas = new Map<
string,
{
peca: any;
qtdPecaDireta: number;
componentesCortados: Map<string, { cortado: number; porPeca: number }>;
}
>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(apontamentosData as any[])?.forEach((apontamento: any) => {
const pecaId = apontamento.peca?.id;
const pecaObj = apontamento.peca;
const pecaId = pecaObj?.id;
if (!pecaId) return;
const quantidade = apontamento.quantidade_produzida || 0;
if (pecasAgrupadas.has(pecaId)) {
const pecaExistente = pecasAgrupadas.get(pecaId)!;
pecaExistente.quantidade_disponivel += quantidade; // Aqui salva o Bruto temporariamente
} else {
if (!pecasAgrupadas.has(pecaId)) {
pecasAgrupadas.set(pecaId, {
id: apontamento.peca.id,
marca: apontamento.peca.marca,
descricao: apontamento.peca.descricao || '',
etapa_fase: apontamento.peca.etapa_fase || '',
quantidade_disponivel: quantidade, // Bruto temporário
peso_unitario: apontamento.peca.peso_unitario || 0,
comprimento: apontamento.peca.comprimento || null,
prioridade: apontamento.peca.prioridade || 'P4'
peca: pecaObj,
qtdPecaDireta: 0,
componentesCortados: new Map()
});
}
const grupo = pecasAgrupadas.get(pecaId)!;
const quantidade = Number(apontamento.quantidade_produzida) || 0;
const isComponente =
apontamento.tipo_apontamento === 'componente' || Boolean(apontamento.componente_id);
if (isComponente) {
const compId = apontamento.componente_id || 'comp_default';
const porPeca = Math.max(1, Number(apontamento.componente?.quantidade_por_peca) || 1);
const atualComp = grupo.componentesCortados.get(compId)?.cortado || 0;
grupo.componentesCortados.set(compId, {
cortado: atualComp + quantidade,
porPeca
});
} else {
grupo.qtdPecaDireta += quantidade;
}
});
// 4. Calcular o saldo Real (Produzido - Expedido) e limpar zerados
// 4. Calcular o saldo Real da peça principal (Produzido da Peça Principal - Expedido em Romaneios)
const pecasDisponiveisFinal: PecaDisponivel[] = [];
pecasAgrupadas.forEach((peca, pecaId) => {
pecasAgrupadas.forEach((grupo, pecaId) => {
const pecaObj = grupo.peca;
const qtdTotalPeca = Math.max(1, Number(pecaObj.quantidade) || 1);
let qtdProduzidaPecaPrincipal = 0;
if (grupo.qtdPecaDireta > 0) {
// Apontamento direto da peça principal (ou kit sincronizado)
qtdProduzidaPecaPrincipal = Math.min(qtdTotalPeca, grupo.qtdPecaDireta);
} else if (grupo.componentesCortados.size > 0) {
// Caso haja apenas apontamentos de subcomponentes no processo (ex: Corte antes da soldagem),
// converte para a quantidade equivalente da peça principal (limitada à quantidade total da peça na OF)
let maxPecasEquivalentes = 0;
grupo.componentesCortados.forEach(({ cortado, porPeca }) => {
const equivalentes = Math.floor(cortado / porPeca);
if (equivalentes > maxPecasEquivalentes) {
maxPecasEquivalentes = equivalentes;
}
});
qtdProduzidaPecaPrincipal = Math.min(
qtdTotalPeca,
maxPecasEquivalentes > 0 ? maxPecasEquivalentes : qtdTotalPeca
);
}
const qtdExpedida = expedidosMap.get(pecaId) || 0;
const saldoReal = peca.quantidade_disponivel - qtdExpedida;
const saldoReal = Math.min(
qtdTotalPeca - qtdExpedida,
qtdProduzidaPecaPrincipal - qtdExpedida
);
if (saldoReal > 0) {
pecasDisponiveisFinal.push({
...peca,
quantidade_disponivel: saldoReal // Retorna apenas o saldo disponível
id: pecaObj.id,
marca: pecaObj.marca,
descricao: pecaObj.descricao || '',
perfil_principal: pecaObj.perfil_principal || null,
etapa_fase: pecaObj.etapa_fase || '',
quantidade_total_peca: qtdTotalPeca,
quantidade_ja_expedida: qtdExpedida,
quantidade_disponivel: saldoReal,
peso_unitario: Number(pecaObj.peso_unitario) || 0,
comprimento: pecaObj.comprimento || null,
prioridade: pecaObj.prioridade || 'P4'
});
}
});
@@ -344,6 +403,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
quantidade_expedida: peca.quantidade_disponivel,
peso_unitario: peca.peso_unitario,
peso_total: peca.quantidade_disponivel * peca.peso_unitario,
comprimento: peca.comprimento || null,
marca: peca.marca,
descricao: peca.descricao,
fase: peca.etapa_fase
@@ -400,6 +460,7 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
quantidade_expedida: quantidade,
peso_unitario: pecaSelecionada.peso_unitario,
peso_total: quantidade * pecaSelecionada.peso_unitario,
comprimento: pecaSelecionada.comprimento || null,
marca: pecaSelecionada.marca,
descricao: pecaSelecionada.descricao,
fase: pecaSelecionada.etapa_fase
@@ -499,6 +560,10 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
const qtdTotalPecas = itensPeca.reduce((total, item) => total + item.quantidade, 0);
const pesoTotalPecas = itensPeca.reduce((total, item) => total + item.peso_total, 0);
const maiorComprimentoPecas = itensPeca.reduce(
(max, item) => Math.max(max, Number(item.comprimento) || 0),
0
);
return (
<>
@@ -629,7 +694,16 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
<Label className={`font-medium mb-2 block ${isMobile ? 'text-sm' : 'text-sm'}`}>
Peça Disponível
</Label>
<Select value={pecaDisponivel} onValueChange={setPecaDisponivel}>
<Select
value={pecaDisponivel}
onValueChange={(val) => {
setPecaDisponivel(val);
const pSel = pecasDisponiveis.find((p) => p.id === val);
if (pSel) {
setQuantidadePeca(String(pSel.quantidade_disponivel));
}
}}
>
<SelectTrigger className={isMobile ? 'h-10' : 'h-11'}>
<SelectValue placeholder="Selecionar peça" />
</SelectTrigger>
@@ -646,13 +720,30 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
pecasFiltradas.map((peca) => {
const limite = romaneio.comprimento_maximo_veiculo || 12000;
const naoCabe = Boolean(peca.comprimento && peca.comprimento > limite);
const descClean = (peca.descricao || '').trim();
const perfilClean = (peca.perfil_principal || '').trim();
const descricaoCompleta =
descClean && perfilClean && descClean.toLowerCase() !== perfilClean.toLowerCase()
? `${descClean} - ${perfilClean}`
: descClean || perfilClean || 'Peça Principal';
const faseFormatada = peca.etapa_fase
? peca.etapa_fase.toLowerCase().startsWith('fase')
? peca.etapa_fase
: `Fase ${peca.etapa_fase}`
: '';
const infoSaldo =
peca.quantidade_ja_expedida > 0
? `Disp: ${peca.quantidade_disponivel} de ${peca.quantidade_total_peca} un`
: `Qtd Disp: ${peca.quantidade_disponivel} un`;
return (
<SelectItem key={peca.id} value={peca.id}>
<div className="flex items-center justify-between gap-2 w-full">
<span className="truncate">
{peca.marca} - {peca.descricao} (Qtd: {peca.quantidade_disponivel})
<span className="truncate font-medium">
{peca.marca}
{faseFormatada ? ` [${faseFormatada}]` : ''} - {descricaoCompleta} ({infoSaldo})
</span>
{peca.comprimento && (
{peca.comprimento != null && Number(peca.comprimento) > 0 && (
<span className={`text-[11px] font-mono shrink-0 ml-2 px-1.5 py-0.5 rounded ${
naoCabe ? 'bg-destructive/20 text-destructive font-bold' : 'text-muted-foreground bg-muted'
}`}>
@@ -733,6 +824,9 @@ export const ItensRomaneioModal: React.FC<ItensRomaneioModalProps> = ({
<Badge variant="outline" className={isMobile ? 'text-xs px-2 py-1' : ''}>
Peso Total: {pesoTotalPecas.toFixed(2)} kg
</Badge>
<Badge variant="outline" className={isMobile ? 'text-xs px-2 py-1' : ''}>
Maior Comprimento: {maiorComprimentoPecas > 0 ? `${maiorComprimentoPecas} mm` : '-'}
</Badge>
</div>
</div>
@@ -1,9 +1,10 @@
import React, { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Edit2, Trash2, X } from 'lucide-react';
import { Edit2, Trash2, Check, X, Package } from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { RomaneioExpedicao } from '@/hooks/useRomaneios';
@@ -20,6 +21,7 @@ interface ItemRomaneioPeca {
id: string;
peca_id: string;
quantidade_expedida: number;
quantidade_max_peca?: number;
peso_unitario: number;
peso_total: number;
comprimento?: number | null;
@@ -39,13 +41,16 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
const [itensPeca, setItensPeca] = useState<ItemRomaneioPeca[]>([]);
const [loading, setLoading] = useState(false);
const [editingItemId, setEditingItemId] = useState<string | null>(null);
const [editQuantidade, setEditQuantidade] = useState<string>('');
const [savingEdit, setSavingEdit] = useState(false);
const carregarItensPecas = async () => {
setLoading(true);
try {
const { data, error } = await supabase
.from('itens_romaneio_pecas')
.select('*, pecas(comprimento)')
.select('*, pecas(comprimento, prioridade, quantidade)')
.eq('romaneio_id', romaneio.id)
.order('marca');
@@ -56,13 +61,14 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
id: item.id,
peca_id: item.peca_id,
quantidade_expedida: item.quantidade_expedida,
quantidade_max_peca: item.pecas?.quantidade || undefined,
peso_unitario: item.peso_unitario,
peso_total: item.peso_total,
comprimento: item.pecas?.comprimento || item.comprimento || null,
marca: item.marca,
descricao: item.descricao || '',
fase: item.fase || '',
prioridade: 'P4'
prioridade: item.pecas?.prioridade || 'P4'
}));
setItensPeca(itensFormatados);
}
@@ -76,8 +82,10 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
useEffect(() => {
if (isOpen) {
setEditingItemId(null);
carregarItensPecas();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, romaneio.id]);
const handleRemoverItem = async (itemId: string) => {
@@ -99,21 +107,68 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
};
const handleEditarItem = (item: ItemRomaneioPeca) => {
// Funcionalidade de edição pode ser implementada aqui
toast.info('Funcionalidade de edição em desenvolvimento');
setEditingItemId(item.id);
setEditQuantidade(String(item.quantidade_expedida));
};
const handleCancelarEdicao = () => {
setEditingItemId(null);
setEditQuantidade('');
};
const handleSalvarEdicao = async (item: ItemRomaneioPeca) => {
const novaQtd = parseInt(editQuantidade, 10);
if (isNaN(novaQtd) || novaQtd <= 0) {
toast.error('Informe uma quantidade válida maior que zero');
return;
}
if (item.quantidade_max_peca && novaQtd > item.quantidade_max_peca) {
toast.error(`A quantidade não pode exceder o total da peça na OF (${item.quantidade_max_peca} un)`);
return;
}
try {
setSavingEdit(true);
const novoPesoTotal = novaQtd * item.peso_unitario;
const { error } = await supabase
.from('itens_romaneio_pecas')
.update({
quantidade_expedida: novaQtd,
peso_total: novoPesoTotal
})
.eq('id', item.id);
if (error) throw error;
toast.success(`Quantidade da peça ${item.marca} atualizada para ${novaQtd} un`);
setEditingItemId(null);
setEditQuantidade('');
carregarItensPecas();
onRefresh();
} catch (error) {
console.error('Erro ao atualizar item:', error);
toast.error('Erro ao atualizar quantidade da peça');
} finally {
setSavingEdit(false);
}
};
const getPrioridadeColor = (prioridade: string) => {
switch (prioridade) {
case 'P1': return 'bg-red-100 text-red-800';
case 'P2': return 'bg-orange-100 text-orange-800';
case 'P3': return 'bg-blue-100 text-blue-800';
default: return 'bg-gray-100 text-gray-800';
case 'P1': return 'bg-red-100 text-red-800 border-red-200 dark:bg-red-950/60 dark:text-red-300 dark:border-red-800';
case 'P2': return 'bg-orange-100 text-orange-800 border-orange-200 dark:bg-orange-950/60 dark:text-orange-300 dark:border-orange-800';
case 'P3': return 'bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-950/60 dark:text-blue-300 dark:border-blue-800';
default: return 'bg-slate-100 text-slate-800 border-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-700';
}
};
const qtdTotal = itensPeca.reduce((total, item) => total + item.quantidade_expedida, 0);
const pesoTotal = itensPeca.reduce((total, item) => total + item.peso_total, 0);
const maiorComprimento = itensPeca.reduce(
(max, item) => Math.max(max, Number(item.comprimento) || 0),
0
);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
@@ -122,47 +177,58 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
? 'w-[95vw] max-w-none h-[95vh] m-2'
: isTablet
? 'w-[90vw] max-w-5xl h-[90vh]'
: 'max-w-6xl h-[95vh]'
: 'max-w-6xl h-[90vh]'
}
bg-slate-900 text-white border-slate-700 overflow-hidden flex flex-col
bg-background text-foreground border-border shadow-xl overflow-hidden flex flex-col
`}>
<DialogHeader className="border-b border-slate-700 pb-3 shrink-0">
<DialogHeader className="border-b border-border pb-3 shrink-0 pr-6">
<div className="flex justify-between items-center">
<DialogTitle className={`font-bold text-white ${isMobile ? 'text-lg' : 'text-xl'}`}>
<DialogTitle className={`font-bold text-foreground flex items-center gap-2 ${isMobile ? 'text-lg' : 'text-xl'}`}>
<Package className="h-5 w-5 text-primary shrink-0" />
<span className="truncate">
Peças do Romaneio {romaneio.numero_romaneio} - OF: {romaneio.of_number}
</span>
</DialogTitle>
<Button
onClick={onClose}
variant="ghost"
size="sm"
className={`text-gray-400 hover:text-white p-0 ${isMobile ? 'h-6 w-6' : 'h-8 w-8'}`}
>
<X className={isMobile ? 'h-3 w-3' : 'h-4 w-4'} />
</Button>
</div>
<div className={`flex gap-2 mt-3 ${isMobile ? 'flex-wrap' : ''}`}>
<Badge variant="outline" className={`border-blue-500 text-blue-400 ${isMobile ? 'text-xs px-2 py-1' : ''}`}>
<Badge
variant="outline"
className={`border-blue-500/40 bg-blue-500/10 text-blue-700 dark:text-blue-400 dark:border-blue-500/50 font-semibold ${
isMobile ? 'text-xs px-2.5 py-1' : 'px-3 py-1'
}`}
>
Qtd Total: {qtdTotal}
</Badge>
<Badge variant="outline" className={`border-green-500 text-green-400 ${isMobile ? 'text-xs px-2 py-1' : ''}`}>
<Badge
variant="outline"
className={`border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 dark:border-emerald-500/50 font-semibold ${
isMobile ? 'text-xs px-2.5 py-1' : 'px-3 py-1'
}`}
>
Peso Total: {pesoTotal.toFixed(2)} kg
</Badge>
<Badge
variant="outline"
className={`border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400 dark:border-amber-500/50 font-semibold ${
isMobile ? 'text-xs px-2.5 py-1' : 'px-3 py-1'
}`}
>
Maior Comprimento: {maiorComprimento > 0 ? `${maiorComprimento} mm` : '-'}
</Badge>
</div>
</DialogHeader>
<div className="flex-1 min-h-0">
{loading ? (
<div className="flex justify-center items-center h-64">
<div className={`text-gray-400 ${isMobile ? 'text-sm' : 'text-base'}`}>
<div className={`text-muted-foreground ${isMobile ? 'text-sm' : 'text-base'}`}>
Carregando...
</div>
</div>
) : itensPeca.length === 0 ? (
<div className="flex justify-center items-center h-64">
<div className={`text-gray-400 ${isMobile ? 'text-sm' : 'text-base'}`}>
<div className={`text-muted-foreground ${isMobile ? 'text-sm' : 'text-base'}`}>
Nenhuma peça encontrada no romaneio
</div>
</div>
@@ -171,7 +237,7 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
<div className="space-y-1 p-1 pb-4">
{/* Cabeçalho da tabela - Desktop/Tablet */}
{!isMobile && (
<div className={`grid gap-3 font-medium text-gray-400 bg-slate-800 p-3 rounded-t-md sticky top-0 z-10 ${
<div className={`grid gap-3 font-semibold text-muted-foreground bg-muted/80 dark:bg-slate-800/90 border border-border p-3 rounded-t-md sticky top-0 z-10 backdrop-blur-sm ${
isTablet ? 'grid-cols-9 text-xs' : 'grid-cols-11 text-xs'
}`}>
<div>Marca</div>
@@ -190,120 +256,209 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
{itensPeca.map((item, index) => {
const limite = romaneio.comprimento_maximo_veiculo || 12000;
const naoCabe = Boolean(item.comprimento && item.comprimento > limite);
const isEditing = editingItemId === item.id;
return (
<div key={item.id}>
{isMobile ? (
// Layout mobile - card style
<div className={`p-3 border rounded-md mb-2 ${
naoCabe ? 'border-red-500/50 bg-red-950/20' : 'border-slate-700'
<div className={`p-3 border rounded-md mb-2 transition-colors ${
naoCabe
? 'border-destructive/50 bg-destructive/10'
: 'border-border'
} ${
index % 2 === 0 ? 'bg-slate-800/20' : 'bg-slate-800/40'
index % 2 === 0 ? 'bg-card' : 'bg-muted/30'
}`}>
<div className="flex justify-between items-start mb-2">
<div className="flex-1">
<div className="font-medium text-white text-sm flex items-center gap-1.5">
<div className="font-semibold text-foreground text-sm flex items-center gap-1.5">
{item.marca}
{naoCabe && (
<span className="text-[10px] bg-red-600 text-white font-bold px-1.5 py-0.5 rounded">
<span className="text-[10px] bg-destructive text-destructive-foreground font-bold px-1.5 py-0.5 rounded">
Excede Veículo
</span>
)}
</div>
<div className="text-gray-300 text-xs">{item.fase}</div>
<div className="text-muted-foreground text-xs">Fase {item.fase}</div>
</div>
<div className="flex gap-1">
{isEditing ? (
<>
<Button
size="sm"
variant="ghost"
disabled={savingEdit}
onClick={() => handleSalvarEdicao(item)}
className="h-7 w-7 p-0 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/15"
title="Salvar quantidade"
>
<Check className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
disabled={savingEdit}
onClick={handleCancelarEdicao}
className="h-7 w-7 p-0 text-muted-foreground hover:bg-muted"
title="Cancelar"
>
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
<>
<Button
size="sm"
variant="ghost"
onClick={() => handleEditarItem(item)}
className="h-6 w-6 p-0 text-blue-400 hover:text-blue-300 hover:bg-blue-500/20"
className="h-7 w-7 p-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:bg-blue-500/15"
title="Editar quantidade"
>
<Edit2 className="h-3 w-3" />
<Edit2 className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoverItem(item.id)}
className="h-6 w-6 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/20"
className="h-7 w-7 p-0 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-500/15"
title="Remover peça"
>
<Trash2 className="h-3 w-3" />
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</div>
<div className="text-gray-300 text-xs mb-2 line-clamp-2">
<div className="text-muted-foreground text-xs mb-2 line-clamp-2">
{item.descricao}
</div>
<div className="grid grid-cols-4 gap-2 text-xs">
<div className="grid grid-cols-4 gap-2 text-xs items-center">
<div>
<div className="text-gray-400">Comp:</div>
<div className={`font-mono font-medium ${naoCabe ? 'text-red-400 font-bold' : 'text-gray-300'}`}>
<div className="text-muted-foreground">Comp:</div>
<div className={`font-mono font-medium ${naoCabe ? 'text-destructive font-bold' : 'text-foreground'}`}>
{item.comprimento ? `${item.comprimento}mm` : '-'}
</div>
</div>
<div>
<div className="text-gray-400">Qtd:</div>
<div className="font-bold text-blue-400">{item.quantidade_expedida}</div>
<div className="text-muted-foreground">Qtd:</div>
{isEditing ? (
<Input
type="number"
min="1"
max={item.quantidade_max_peca}
value={editQuantidade}
onChange={(e) => setEditQuantidade(e.target.value)}
className="h-7 w-16 text-center text-xs px-1"
autoFocus
/>
) : (
<div className="font-bold text-blue-600 dark:text-blue-400">{item.quantidade_expedida}</div>
)}
</div>
<div>
<div className="text-gray-400">Peso Unit:</div>
<div className="text-gray-300">{item.peso_unitario.toFixed(2)} kg</div>
<div className="text-muted-foreground">Peso Unit:</div>
<div className="text-foreground">{item.peso_unitario.toFixed(2)} kg</div>
</div>
<div>
<div className="text-gray-400">Peso Total:</div>
<div className="font-medium text-white">{item.peso_total.toFixed(2)} kg</div>
<div className="text-muted-foreground">Peso Total:</div>
<div className="font-semibold text-foreground">{item.peso_total.toFixed(2)} kg</div>
</div>
</div>
</div>
) : (
// Layout desktop/tablet - table style
<div className={`grid gap-3 text-sm p-3 border-b border-slate-700 hover:bg-slate-800/50 transition-colors ${
index % 2 === 0 ? 'bg-slate-800/20' : ''
} ${isTablet ? 'grid-cols-9' : 'grid-cols-11'} ${naoCabe ? 'bg-red-950/20' : ''}`}>
<div className="font-medium text-white truncate flex items-center gap-1">
<div className={`grid gap-3 items-center text-sm p-3 border-b border-x border-border hover:bg-muted/50 transition-colors ${
index % 2 === 0 ? 'bg-card' : 'bg-muted/20'
} ${isTablet ? 'grid-cols-9' : 'grid-cols-11'} ${naoCabe ? 'bg-destructive/10 border-destructive/40' : ''}`}>
<div className="font-semibold text-foreground truncate flex items-center gap-1">
{item.marca}
{naoCabe && <span title="Não cabe no veículo" className="text-red-400 text-xs">⚠️</span>}
{naoCabe && <span title="Não cabe no veículo" className="text-destructive text-xs">⚠️</span>}
</div>
<div className="text-gray-300 truncate">{item.fase}</div>
<div className={`text-gray-300 truncate ${isTablet ? 'col-span-1' : 'col-span-2'}`}>
<div className="text-muted-foreground truncate">{item.fase}</div>
<div className={`text-foreground truncate ${isTablet ? 'col-span-1' : 'col-span-2'}`}>
{item.descricao}
</div>
<div className={`text-right font-mono text-xs ${naoCabe ? 'text-red-400 font-bold' : 'text-gray-300'}`}>
<div className={`text-right font-mono text-xs ${naoCabe ? 'text-destructive font-bold' : 'text-muted-foreground'}`}>
{item.comprimento ? `${item.comprimento}` : '-'}
</div>
<div className="text-center font-bold text-blue-400">
{item.quantidade_expedida}
<div className="text-center font-bold text-blue-600 dark:text-blue-400 flex justify-center">
{isEditing ? (
<Input
type="number"
min="1"
max={item.quantidade_max_peca}
value={editQuantidade}
onChange={(e) => setEditQuantidade(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSalvarEdicao(item);
if (e.key === 'Escape') handleCancelarEdicao();
}}
className="h-7 w-20 text-center text-xs px-1"
autoFocus
/>
) : (
item.quantidade_expedida
)}
</div>
<div className="text-right text-gray-300">
<div className="text-right text-muted-foreground">
{item.peso_unitario.toFixed(2)} kg
</div>
<div className="text-right font-medium text-white">
<div className="text-right font-semibold text-foreground">
{item.peso_total.toFixed(2)} kg
</div>
{!isTablet && (
<div className="flex justify-center">
<Badge className={`text-xs ${getPrioridadeColor(item.prioridade)}`}>
<Badge variant="outline" className={`text-xs ${getPrioridadeColor(item.prioridade)}`}>
{item.prioridade}
</Badge>
</div>
)}
<div className={`flex items-center justify-center gap-2 ${isTablet ? 'col-span-2' : 'col-span-2'}`}>
{isEditing ? (
<>
<Button
size="sm"
variant="ghost"
disabled={savingEdit}
onClick={() => handleSalvarEdicao(item)}
className="h-7 w-7 p-0 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/15"
title="Salvar quantidade"
>
<Check className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
disabled={savingEdit}
onClick={handleCancelarEdicao}
className="h-7 w-7 p-0 text-muted-foreground hover:bg-muted"
title="Cancelar edição"
>
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
<>
<Button
size="sm"
variant="ghost"
onClick={() => handleEditarItem(item)}
className="h-7 w-7 p-0 text-blue-400 hover:text-blue-300 hover:bg-blue-500/20"
className="h-7 w-7 p-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:bg-blue-500/15"
title="Editar quantidade"
>
<Edit2 className="h-3 w-3" />
<Edit2 className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoverItem(item.id)}
className="h-7 w-7 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/20"
className="h-7 w-7 p-0 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-500/15"
title="Remover item"
>
<Trash2 className="h-3 w-3" />
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</div>
)}
@@ -315,11 +470,11 @@ export const PecasRomaneioExpandido: React.FC<PecasRomaneioExpandidoProps> = ({
)}
</div>
<div className={`flex justify-end pt-3 border-t border-slate-700 shrink-0 ${isMobile ? 'px-1' : ''}`}>
<div className={`flex justify-end pt-3 border-t border-border shrink-0 ${isMobile ? 'px-1' : ''}`}>
<Button
variant="outline"
onClick={onClose}
className={`border-slate-600 text-gray-300 hover:bg-slate-800 ${isMobile ? 'h-10 px-4 text-sm' : ''}`}
className={`font-medium text-foreground border-border hover:bg-accent hover:text-accent-foreground ${isMobile ? 'h-10 px-4 text-sm' : 'px-6'}`}
>
Fechar
</Button>
+32 -49
View File
@@ -10,8 +10,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { Save, X, AlertTriangle, Loader2 } from 'lucide-react';
import { RomaneioExpedicao } from '@/hooks/useRomaneios';
import { toast } from 'sonner';
import { validarRomaneioParaEntrega } from '@/utils/validacaoRomaneio';
import { PendenciasProducaoModal, ItemPendenciaProducao } from './PendenciasProducaoModal';
import { ConfirmacaoEntregaRomaneioModal } from './ConfirmacaoEntregaRomaneioModal';
interface RomaneioFormProps {
romaneio?: RomaneioExpedicao;
@@ -118,34 +117,23 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
return Array.from(new Set(list)).filter(Boolean);
}, [ofNumbers, romaneio?.of_number, selectedOF, formData.of_number]);
// Estados para controle de validação de entrega
const [validandoEntrega, setValidandoEntrega] = useState(false);
const [modalPendenciasAberto, setModalPendenciasAberto] = useState(false);
const [pendenciasEncontradas, setPendenciasEncontradas] = useState<ItemPendenciaProducao[]>([]);
const [mensagemBloqueio, setMensagemBloqueio] = useState<string | undefined>(undefined);
// Estados para controle de validação e confirmação de entrega
const [modalConfirmacaoAberto, setModalConfirmacaoAberto] = useState(false);
const handleStatusChange = async (novoStatus: any) => {
const handleStatusChange = (novoStatus: any) => {
if (novoStatus === 'Entregue') {
if (!romaneio?.id) {
toast.warning('Novo romaneio deve ser criado como "Em planejamento"', {
description: 'Cadastre o romaneio primeiro, adicione as peças e confirme os apontamentos de produção antes de marcá-lo como entregue.'
description: 'Cadastre o romaneio primeiro, adicione as peças e confirme os apontamentos antes de marcá-lo como entregue.'
});
setFormData(prev => ({ ...prev, status: 'Em planejamento' }));
return;
}
setValidandoEntrega(true);
const resultado = await validarRomaneioParaEntrega(romaneio.id, formData.of_number || romaneio.of_number);
setValidandoEntrega(false);
if (!resultado.valido) {
setPendenciasEncontradas(resultado.pendencias || []);
setMensagemBloqueio(resultado.mensagem);
setModalPendenciasAberto(true);
// Não altera o status para Entregue, mantém o anterior
// Abre o modal inteligente de confirmação de entrega
setModalConfirmacaoAberto(true);
return;
}
}
setFormData(prev => ({ ...prev, status: novoStatus }));
};
@@ -153,25 +141,17 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (formData.status === 'Entregue') {
if (formData.status === 'Entregue' && romaneio?.status !== 'Entregue') {
if (!romaneio?.id) {
toast.error('Não é possível criar um novo romaneio diretamente como "Entregue".');
setFormData(prev => ({ ...prev, status: 'Em planejamento' }));
return;
}
setValidandoEntrega(true);
const resultado = await validarRomaneioParaEntrega(romaneio.id, formData.of_number || romaneio.of_number);
setValidandoEntrega(false);
if (!resultado.valido) {
setPendenciasEncontradas(resultado.pendencias || []);
setMensagemBloqueio(resultado.mensagem);
setModalPendenciasAberto(true);
toast.error('Não é possível definir como "Entregue": existem pendências na produção.');
// Se tentar salvar como Entregue sem ter passado pela confirmação, abrir modal
setModalConfirmacaoAberto(true);
return;
}
}
console.log('💾 Salvando romaneio com dados:', formData);
@@ -322,7 +302,6 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
<Select
value={formData.status}
onValueChange={handleStatusChange}
disabled={validandoEntrega}
>
<SelectTrigger>
<SelectValue />
@@ -339,10 +318,10 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
</div>
{/* Badge de Alerta */}
<Alert className="border-orange-500 bg-orange-50 dark:bg-orange-950/20 dark:border-orange-400 mt-4">
<AlertTriangle className="h-4 w-4 text-orange-600 dark:text-orange-400" />
<AlertDescription className="text-orange-800 dark:text-orange-200 font-medium">
QUANDO O STATUS PASSAR PARA "ENTREGUE" AS PEÇAS SERÃO APONTADAS AUTOMATICAMENTE NO PROCESSO DE EXPEDIÇÃO (DESDE QUE TODOS OS PROCESSOS ANTERIORES ESTEJAM CONCLUÍDOS)
<Alert className="border-blue-500 bg-blue-50 dark:bg-blue-950/20 dark:border-blue-400 mt-4">
<AlertTriangle className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<AlertDescription className="text-blue-900 dark:text-blue-200 font-medium">
AO PASSAR O STATUS PARA "ENTREGUE", VOCÊ ESCOLHE A DATA REAL DA ENTREGA E PODE AUTORIZAR O APONTAMENTO AUTOMÁTICO (INCLUSIVE EM CASCATA CASO HAJA PROCESSOS ANTERIORES PENDENTES).
</AlertDescription>
</Alert>
</div>
@@ -491,13 +470,8 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
</div>
<div className="flex gap-2 pt-4">
<Button type="submit" disabled={loading || validandoEntrega || !formData.of_number}>
{validandoEntrega ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Validando processos...
</>
) : loading ? (
<Button type="submit" disabled={loading || !formData.of_number}>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Salvando...
@@ -515,19 +489,28 @@ export function RomaneioForm({ romaneio, ofNumbers, selectedOF, onSave, onCancel
</DialogContent>
</Dialog>
{/* Modal de bloqueio/alerta caso existam peças pendentes */}
<PendenciasProducaoModal
isOpen={modalPendenciasAberto}
{/* Modal de confirmação inteligente com Data Real e Cascata */}
<ConfirmacaoEntregaRomaneioModal
isOpen={modalConfirmacaoAberto}
onClose={() => {
setModalPendenciasAberto(false);
setModalConfirmacaoAberto(false);
setFormData(prev => ({
...prev,
status: (romaneio?.status as any) || 'Em planejamento'
}));
}}
pendencias={pendenciasEncontradas}
numeroRomaneio={romaneio?.numero_romaneio}
mensagemSemItens={mensagemBloqueio}
romaneio={romaneio ? {
id: romaneio.id,
numero_romaneio: romaneio.numero_romaneio,
of_number: formData.of_number || romaneio.of_number,
data_prevista_entrega: formData.data_prevista_entrega || romaneio.data_prevista_entrega,
data_romaneio: formData.data_romaneio || romaneio.data_romaneio,
status: formData.status
} : null}
onSucesso={() => {
setModalConfirmacaoAberto(false);
onClose();
}}
/>
</>
);
+23 -2
View File
@@ -3,10 +3,11 @@ import React, { useState } from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Edit, Eye, Trash2, Printer } from 'lucide-react';
import { Edit, Eye, Trash2, Printer, Truck } from 'lucide-react';
import { RomaneioExpedicao } from '@/hooks/useRomaneios';
import { formatBrazilianDateFromString } from '@/utils/dateTimeUtils';
import { RomaneioReportModal } from './RomaneioReportModal';
import { ConfirmacaoEntregaRomaneioModal } from './ConfirmacaoEntregaRomaneioModal';
interface RomaneioTableProps {
@@ -28,6 +29,7 @@ export const RomaneioTable: React.FC<RomaneioTableProps> = ({
}) => {
const [selectedRomaneioForReport, setSelectedRomaneioForReport] = useState<RomaneioExpedicao | null>(null);
const [showReportModal, setShowReportModal] = useState(false);
const [romaneioParaConfirmacao, setRomaneioParaConfirmacao] = useState<RomaneioExpedicao | null>(null);
const handlePrintReport = (romaneio: RomaneioExpedicao) => {
setSelectedRomaneioForReport(romaneio);
@@ -126,7 +128,16 @@ export const RomaneioTable: React.FC<RomaneioTableProps> = ({
{romaneio.peso_total_romaneio?.toFixed(2) || '0.00'} kg
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setRomaneioParaConfirmacao(romaneio)}
title="Confirmar Entrega / Apontamentos"
className="h-8 w-8 p-0 text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-950/40"
>
<Truck className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
@@ -175,6 +186,16 @@ export const RomaneioTable: React.FC<RomaneioTableProps> = ({
romaneio={selectedRomaneioForReport}
/>
)}
{/* Modal de Confirmação de Entrega com Data Real e Cascata */}
{romaneioParaConfirmacao && (
<ConfirmacaoEntregaRomaneioModal
isOpen={!!romaneioParaConfirmacao}
onClose={() => setRomaneioParaConfirmacao(null)}
romaneio={romaneioParaConfirmacao}
onSucesso={() => setRomaneioParaConfirmacao(null)}
/>
)}
</>
);
};
@@ -0,0 +1,691 @@
import React, { useState, useMemo } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
AlertTriangle,
CheckCircle2,
XCircle,
Search,
Printer,
ChevronDown,
ChevronRight,
Factory,
Layers,
ArrowRight,
ShieldAlert,
Sparkles,
Info,
} from 'lucide-react';
import { toast } from 'sonner';
import {
ItemAnaliseImportacao,
NivelRiscoImportacao,
ResultadoAnaliseImportacao,
} from '@/types/importDiff';
import { IntervencaoFabrilPrintModal } from './IntervencaoFabrilPrintModal';
import { executarImportacaoConciliada } from '@/utils/importExecutionService';
import { useAuth } from '@/hooks/useAuth';
interface ImportarPecasDiffModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
analise: ResultadoAnaliseImportacao | null;
componentesMap: Map<string, any[]>;
onConcluido: () => void;
ofDefault?: string;
}
type TabFiltro = 'todas' | 'divergencias' | 'apontadas' | 'criticas' | 'novas' | 'identicas';
export const ImportarPecasDiffModal: React.FC<ImportarPecasDiffModalProps> = ({
open,
onOpenChange,
analise,
componentesMap,
onConcluido,
ofDefault = '',
}) => {
const { user } = useAuth();
const [itens, setItens] = useState<ItemAnaliseImportacao[]>([]);
const [tabAtiva, setTabFiltro] = useState<TabFiltro>('todas');
const [busca, setBusca] = useState('');
const [linhasExpandidas, setLinhasExpandidas] = useState<Set<string>>(new Set());
const [showPrintModal, setShowPrintModal] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
// Inicializar itens locais quando a análise for fornecida
React.useEffect(() => {
if (analise?.itens) {
setItens(analise.itens);
}
}, [analise]);
// Contadores dinâmicos baseados no estado atual dos itens
const counts = useMemo(() => {
return {
total: itens.length,
novas: itens.filter((i) => i.isNova).length,
divergencias: itens.filter((i) => i.temDivergencias).length,
apontadas: itens.filter((i) => i.apontamentoResumo.temApontamento).length,
criticas: itens.filter((i) => i.nivelRisco === 'critico').length,
identicas: itens.filter((i) => i.isIdentica).length,
sobreporTotal: itens.filter((i) => i.sobrepor).length,
atencaoEspecialTotal: itens.filter((i) => i.atencaoEspecial).length,
};
}, [itens]);
// Filtro dos itens exibidos
const itensFiltrados = useMemo(() => {
return itens.filter((item) => {
// Filtro de Busca
const matchBusca =
!busca ||
item.marca.toLowerCase().includes(busca.toLowerCase()) ||
item.of_number.toLowerCase().includes(busca.toLowerCase()) ||
item.descricao.toLowerCase().includes(busca.toLowerCase()) ||
(item.dadosImportados.perfil_principal &&
item.dadosImportados.perfil_principal.toLowerCase().includes(busca.toLowerCase()));
if (!matchBusca) return false;
// Filtro de Aba
if (tabAtiva === 'novas') return item.isNova;
if (tabAtiva === 'divergencias') return item.temDivergencias;
if (tabAtiva === 'apontadas') return item.apontamentoResumo.temApontamento;
if (tabAtiva === 'criticas') return item.nivelRisco === 'critico';
if (tabAtiva === 'identicas') return item.isIdentica;
return true;
});
}, [itens, busca, tabAtiva]);
// Toggle do checkbox Sobrepor por item
const handleToggleSobrepor = (key: string) => {
setItens((prev) =>
prev.map((item) => {
if (item.key === key) {
return { ...item, sobrepor: !item.sobrepor };
}
return item;
})
);
};
// Toggle do checkbox Atenção Especial por item
const handleToggleAtencaoEspecial = (key: string) => {
setItens((prev) =>
prev.map((item) => {
if (item.key === key) {
return { ...item, atencaoEspecial: !item.atencaoEspecial };
}
return item;
})
);
};
// Ações em massa
const handleMarcarTodasSobrepor = (valor: boolean) => {
const keysFiltradas = new Set(itensFiltrados.map((i) => i.key));
setItens((prev) =>
prev.map((item) => {
if (keysFiltradas.has(item.key)) {
return { ...item, sobrepor: valor };
}
return item;
})
);
};
const handleMarcarCriticasAtencaoEspecial = () => {
setItens((prev) =>
prev.map((item) => {
if (item.nivelRisco === 'critico' && item.sobrepor) {
return { ...item, atencaoEspecial: true };
}
return item;
})
);
toast.info('Todas as peças críticas marcadas para emissão de Ordem de Intervenção!');
};
const handleToggleExpandir = (key: string) => {
setLinhasExpandidas((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
// Executar a importação conciliada
const handleConfirmarImportacao = async () => {
if (!user) {
toast.error('Usuário não autenticado');
return;
}
const sobreporCount = itens.filter((i) => i.sobrepor).length;
if (sobreporCount === 0) {
toast.warning('Nenhuma peça foi selecionada para importação ou sobreposição.');
return;
}
setIsProcessing(true);
try {
const resultado = await executarImportacaoConciliada({
itens,
componentesMap,
userId: user.id,
userNome: user.name || user.email || 'Operador',
userEmail: user.email || '',
motivoRevisao: 'Conciliação e atualização de lista técnica de peças',
});
if (resultado.sucesso) {
toast.success(resultado.mensagem);
// Se houver peças marcadas com atenção especial, perguntar ou abrir modal de impressão
if (resultado.totalComAtencaoEspecial > 0) {
toast.info(
`Atenção: ${resultado.totalComAtencaoEspecial} peça(s) requerem atenção do chão de fábrica! Abrindo Ordem de Intervenção...`
);
setShowPrintModal(true);
} else {
onOpenChange(false);
onConcluido();
}
} else {
toast.error(resultado.mensagem);
}
} catch (err: any) {
console.error('Erro na confirmação:', err);
toast.error(`Falha ao salvar dados: ${err?.message || 'Erro desconhecido'}`);
} finally {
setIsProcessing(false);
}
};
const itensParaImpressao = useMemo(() => {
return itens.filter((i) => i.atencaoEspecial);
}, [itens]);
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-6xl h-[90vh] max-h-[92vh] flex flex-col p-0 overflow-hidden bg-background border-border">
{/* Cabeçalho */}
<DialogHeader className="p-4 border-b border-border bg-card flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-xl bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20">
<Layers className="h-6 w-6" />
</div>
<div>
<DialogTitle className="text-xl font-bold text-foreground flex items-center gap-2">
Detecção de Conflitos e Conciliação de Peças
<Badge variant="outline" className="text-xs font-semibold">
{ofDefault ? `OF ${ofDefault}` : 'Importação de Lista'}
</Badge>
</DialogTitle>
<p className="text-xs text-muted-foreground mt-0.5">
Foram identificadas peças já cadastradas. Analise as diferenças, selecione quais sobrepor e proteja os apontamentos fabris.
</p>
</div>
</div>
{/* Botão de emissão rápida da lista de atenção */}
{counts.atencaoEspecialTotal > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => setShowPrintModal(true)}
className="bg-amber-500/10 border-amber-500/30 text-amber-700 dark:text-amber-400 hover:bg-amber-500/20 text-xs flex items-center gap-1.5 font-bold"
>
<Printer className="h-4 w-4 text-amber-600 dark:text-amber-400" />
Imprimir Lembretes Fabris ({counts.atencaoEspecialTotal})
</Button>
)}
</div>
{/* Cards de Métricas (KPIs) */}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 mt-4">
<div
onClick={() => setTabFiltro('criticas')}
className={`p-2.5 rounded-lg border cursor-pointer transition-all ${
tabAtiva === 'criticas'
? 'bg-red-500/15 border-red-500/50 dark:bg-red-950/40 shadow-sm'
: 'bg-card hover:bg-muted/50 border-border'
}`}
>
<div className="flex items-center justify-between text-xs text-red-600 dark:text-red-400 font-bold">
<span className="flex items-center gap-1">
<ShieldAlert className="h-3.5 w-3.5" /> Atenção Crítica
</span>
<Badge className="bg-red-600 text-white font-extrabold text-[11px] h-5 px-1.5">
{counts.criticas}
</Badge>
</div>
<div className="text-[11px] text-muted-foreground mt-1">
Alteração em peças já iniciadas
</div>
</div>
<div
onClick={() => setTabFiltro('apontadas')}
className={`p-2.5 rounded-lg border cursor-pointer transition-all ${
tabAtiva === 'apontadas'
? 'bg-amber-500/15 border-amber-500/50 dark:bg-amber-950/40 shadow-sm'
: 'bg-card hover:bg-muted/50 border-border'
}`}
>
<div className="flex items-center justify-between text-xs text-amber-600 dark:text-amber-400 font-bold">
<span className="flex items-center gap-1">
<Factory className="h-3.5 w-3.5" /> Em Produção
</span>
<Badge variant="secondary" className="font-extrabold text-[11px] h-5 px-1.5">
{counts.apontadas}
</Badge>
</div>
<div className="text-[11px] text-muted-foreground mt-1">
Possuem apontamentos
</div>
</div>
<div
onClick={() => setTabFiltro('divergencias')}
className={`p-2.5 rounded-lg border cursor-pointer transition-all ${
tabAtiva === 'divergencias'
? 'bg-blue-500/15 border-blue-500/50 dark:bg-blue-950/40 shadow-sm'
: 'bg-card hover:bg-muted/50 border-border'
}`}
>
<div className="flex items-center justify-between text-xs text-blue-600 dark:text-blue-400 font-bold">
<span>Com Divergência</span>
<Badge variant="secondary" className="font-extrabold text-[11px] h-5 px-1.5">
{counts.divergencias}
</Badge>
</div>
<div className="text-[11px] text-muted-foreground mt-1">
Mudança em relação à base
</div>
</div>
<div
onClick={() => setTabFiltro('novas')}
className={`p-2.5 rounded-lg border cursor-pointer transition-all ${
tabAtiva === 'novas'
? 'bg-purple-500/15 border-purple-500/50 dark:bg-purple-950/40 shadow-sm'
: 'bg-card hover:bg-muted/50 border-border'
}`}
>
<div className="flex items-center justify-between text-xs text-purple-600 dark:text-purple-400 font-bold">
<span className="flex items-center gap-1">
<Sparkles className="h-3.5 w-3.5" /> Peças Novas
</span>
<Badge variant="secondary" className="font-extrabold text-[11px] h-5 px-1.5">
{counts.novas}
</Badge>
</div>
<div className="text-[11px] text-muted-foreground mt-1">
Inexistentes no cadastro
</div>
</div>
<div
onClick={() => setTabFiltro('identicas')}
className={`p-2.5 rounded-lg border cursor-pointer transition-all ${
tabAtiva === 'identicas'
? 'bg-emerald-500/15 border-emerald-500/50 dark:bg-emerald-950/40 shadow-sm'
: 'bg-card hover:bg-muted/50 border-border'
}`}
>
<div className="flex items-center justify-between text-xs text-emerald-600 dark:text-emerald-400 font-bold">
<span className="flex items-center gap-1">
<CheckCircle2 className="h-3.5 w-3.5" /> 100% Idênticas
</span>
<Badge variant="secondary" className="font-extrabold text-[11px] h-5 px-1.5">
{counts.identicas}
</Badge>
</div>
<div className="text-[11px] text-muted-foreground mt-1">
Sem alterações necessárias
</div>
</div>
</div>
</DialogHeader>
{/* Barra de Filtros e Ferramentas */}
<div className="p-3 border-b border-border bg-muted/20 flex flex-wrap items-center justify-between gap-2 text-xs flex-shrink-0">
{/* Input de Busca */}
<div className="relative w-72">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Buscar por marca, perfil, fase..."
value={busca}
onChange={(e) => setBusca(e.target.value)}
className="pl-8 h-8 text-xs bg-background"
/>
</div>
{/* Botões de Ação Rápida */}
<div className="flex items-center gap-1.5 flex-wrap">
<Button
variant="outline"
size="sm"
onClick={() => handleMarcarTodasSobrepor(true)}
className="h-7 text-[11px] px-2.5"
>
Sobrepor Todas Filtradas
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleMarcarTodasSobrepor(false)}
className="h-7 text-[11px] px-2.5"
>
Ignorar Todas (Manter Base)
</Button>
{counts.criticas > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleMarcarCriticasAtencaoEspecial}
className="h-7 text-[11px] px-2.5 bg-red-500/10 text-red-600 border-red-500/30 hover:bg-red-500/20 font-semibold"
>
<ShieldAlert className="h-3 w-3 mr-1" />
Marcar Críticas p/ Lembrete
</Button>
)}
</div>
</div>
{/* Tabela de Conciliação e Diff com Scroll Vertical e Cabeçalho Sticky */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-auto w-full">
<div className="p-4">
<table className="w-full text-left text-xs border-collapse">
<thead className="sticky top-0 z-10 bg-muted/95 backdrop-blur-sm shadow-sm">
<tr className="border-b border-border text-muted-foreground font-semibold">
<th className="p-2 w-10 text-center bg-muted/95">Sobrepor</th>
<th className="p-2 w-28 bg-muted/95">Peça / Fase</th>
<th className="p-2 w-44 bg-muted/95">Perfil / Bitola</th>
<th className="p-2 w-40 bg-muted/95">Status de Produção</th>
<th className="p-2 bg-muted/95">Divergências Detectadas (Diff)</th>
<th className="p-2 w-28 text-center bg-muted/95">Lembrete Fabril</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{itensFiltrados.length === 0 ? (
<tr>
<td colSpan={6} className="py-8 text-center text-muted-foreground">
Nenhuma peça encontrada com os filtros selecionados.
</td>
</tr>
) : (
itensFiltrados.map((item) => {
const isExpandida = linhasExpandidas.has(item.key);
const ultimoProc = item.apontamentoResumo.ultimoProcessoNome;
const totalApontado = item.apontamentoResumo.totalProduzido;
// Cor da linha de acordo com o risco
let linhaBg = 'hover:bg-muted/30';
if (item.nivelRisco === 'critico') {
linhaBg = 'bg-red-500/5 hover:bg-red-500/10 dark:bg-red-950/20';
} else if (item.isNova) {
linhaBg = 'bg-purple-500/5 hover:bg-purple-500/10 dark:bg-purple-950/20';
}
return (
<React.Fragment key={item.key}>
<tr className={`transition-colors ${linhaBg}`}>
{/* Checkbox de Sobreposição */}
<td className="p-2 text-center">
<Checkbox
checked={item.sobrepor}
onCheckedChange={() => handleToggleSobrepor(item.key)}
title={item.sobrepor ? 'Sobrepor no cadastro' : 'Ignorar e manter atual'}
/>
</td>
{/* Identificação da Peça */}
<td className="p-2">
<div className="flex items-center gap-1.5">
<span className="font-extrabold text-sm text-foreground">
{item.marca}
</span>
{item.nivelRisco === 'critico' && (
<Badge className="bg-red-600 text-white text-[9px] h-4 px-1 font-bold">
ALERTA
</Badge>
)}
{item.isNova && (
<Badge className="bg-purple-600 text-white text-[9px] h-4 px-1 font-bold">
NOVA
</Badge>
)}
</div>
<div className="text-[11px] text-muted-foreground">
OF {item.of_number} • Fase {item.etapa_fase}
</div>
</td>
{/* Perfil e Descrição */}
<td className="p-2">
<div className="font-semibold text-foreground">
{item.dadosImportados.perfil_principal || item.descricao || '-'}
</div>
<div className="text-[10px] text-muted-foreground">
{item.dadosImportados.comprimento
? `Comp: ${item.dadosImportados.comprimento} mm`
: 'Sem comp. ref.'}
</div>
</td>
{/* Status Fabril (Apontamentos) */}
<td className="p-2">
{item.apontamentoResumo.temApontamento ? (
<div className="space-y-0.5">
<div className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-bold bg-amber-500/15 text-amber-700 dark:text-amber-400 border border-amber-500/30">
<Factory className="h-3 w-3" />
{ultimoProc ? ultimoProc.toUpperCase() : 'PRODUÇÃO'}
</div>
<div className="text-[10px] text-muted-foreground">
{totalApontado} un apontadas
</div>
{item.apontamentoResumo.emRomaneio && (
<div className="text-[10px] font-bold text-red-600">
⚠️ Já expedida em Romaneio
</div>
)}
</div>
) : (
<span className="text-[11px] text-muted-foreground italic flex items-center gap-1">
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
Não iniciada (0%)
</span>
)}
</td>
{/* Coluna de Divergências (Diff) */}
<td className="p-2">
{item.isNova ? (
<span className="text-[11px] text-purple-600 dark:text-purple-400 font-medium">
Peça nova para cadastro
</span>
) : item.isIdentica ? (
<span className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium flex items-center gap-1">
<CheckCircle2 className="h-3.5 w-3.5" /> Dados 100% idênticos à base
</span>
) : (
<div className="space-y-1">
<div className="flex flex-wrap gap-1.5">
{item.camposDivergentes.map((c) => (
<div
key={c.campo}
className={`px-2 py-0.5 rounded text-[11px] font-medium border flex items-center gap-1 ${
c.isCritico
? 'bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-400'
: 'bg-amber-500/10 border-amber-500/30 text-amber-700 dark:text-amber-400'
}`}
>
<span className="font-bold">{c.label}:</span>
<span className="line-through opacity-75">{c.valorAntigo}</span>
<ArrowRight className="h-2.5 w-2.5" />
<span className="font-extrabold">{c.valorNovo}</span>
</div>
))}
</div>
{/* Resumo de componentes se houver */}
{item.componentesDivergentes.length > 0 && (
<button
type="button"
onClick={() => handleToggleExpandir(item.key)}
className="text-[11px] text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 font-semibold"
>
{isExpandida ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
{item.componentesDivergentes.length} alteração(ões) em subcomponentes
</button>
)}
</div>
)}
</td>
{/* Checkbox de Lembrete Fabril (Atenção Especial) */}
<td className="p-2 text-center">
{item.apontamentoResumo.temApontamento || item.nivelRisco === 'critico' ? (
<div className="flex flex-col items-center justify-center">
<Checkbox
checked={item.atencaoEspecial}
onCheckedChange={() => handleToggleAtencaoEspecial(item.key)}
title="Incluir na Ordem de Intervenção Fabril Impressa"
/>
<span className="text-[9px] text-muted-foreground mt-0.5">
Lembrete
</span>
</div>
) : (
<span className="text-muted-foreground/40 text-[10px]">-</span>
)}
</td>
</tr>
{/* Linha Expansível para Detalhe de Componentes */}
{isExpandida && item.componentesDivergentes.length > 0 && (
<tr className="bg-muted/30">
<td colSpan={6} className="p-3 pl-12">
<div className="bg-background rounded-lg p-3 border border-border space-y-1.5">
<div className="font-bold text-xs text-foreground flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5 text-blue-500" />
Detalhamento de Subcomponentes da Peça {item.marca}:
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-xs">
{item.componentesDivergentes.map((comp) => {
let badgeCor = 'bg-blue-500/10 text-blue-700 border-blue-300';
if (comp.tipo === 'adicionado') badgeCor = 'bg-emerald-500/10 text-emerald-700 border-emerald-300';
if (comp.tipo === 'removido') badgeCor = 'bg-red-500/10 text-red-700 border-red-300';
return (
<div
key={comp.marca_componente}
className={`p-2 rounded border flex items-start gap-2 ${badgeCor}`}
>
<Badge variant="outline" className="text-[10px] font-bold">
Pos. {comp.marca_componente}
</Badge>
<div className="text-[11px] space-y-0.5">
<div className="font-semibold">{comp.descricao || comp.perfil || 'Componente'}</div>
<div className="text-[10px] opacity-90">{comp.detalhe}</div>
</div>
</div>
);
})}
</div>
</div>
</td>
</tr>
)}
</React.Fragment>
);
})
)}
</tbody>
</table>
</div>
</div>
{/* Rodapé da Janela com Decisão e Ações */}
<DialogFooter className="p-3 border-t border-border bg-card flex flex-row items-center justify-between flex-shrink-0">
<div className="text-xs text-muted-foreground flex items-center gap-3">
<span>
<strong className="text-foreground">{counts.sobreporTotal}</strong> a sobrepor
</span>
<span>•</span>
<span>
<strong className="text-foreground">{counts.novas}</strong> novas
</span>
<span>•</span>
<span>
<strong className="text-foreground">{counts.total - counts.sobreporTotal}</strong> mantidas originais
</span>
{counts.atencaoEspecialTotal > 0 && (
<>
<span>•</span>
<Badge variant="outline" className="bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/30 text-[10px] font-bold">
{counts.atencaoEspecialTotal} na lista impressa
</Badge>
</>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
disabled={isProcessing}
className="text-xs"
>
Cancelar
</Button>
<Button
size="sm"
onClick={handleConfirmarImportacao}
disabled={isProcessing || counts.sobreporTotal === 0}
className="bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-bold shadow-sm"
>
{isProcessing ? 'Gravando e Conciliando...' : `Confirmar Importação (${counts.sobreporTotal})`}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modal de Impressão da Ordem de Intervenção */}
<IntervencaoFabrilPrintModal
open={showPrintModal}
onOpenChange={(openState) => {
setShowPrintModal(openState);
if (!openState) {
// Se fechou a janela de impressão após a importação, fecha o diff modal e conclui
onOpenChange(false);
onConcluido();
}
}}
itens={itensParaImpressao}
ofNumber={ofDefault || itens[0]?.of_number || ''}
usuarioNome={user?.name || user?.email || 'Operador'}
/>
</>
);
};
+44 -8
View File
@@ -9,6 +9,9 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { AlertTriangle, Check, X, Upload, FileSpreadsheet, Weight, AlertCircle, Edit3 } from 'lucide-react';
import { toast } from 'sonner';
import { parseCSV } from '@/utils/csvUtils';
import { analisarConflitosImportacao, consolidarPecasEComponentes } from '@/utils/importDiffService';
import { ResultadoAnaliseImportacao } from '@/types/importDiff';
import { ImportarPecasDiffModal } from '@/components/pecas/ImportarPecasDiffModal';
interface PecaCSVData {
of_number: string;
@@ -22,10 +25,13 @@ interface PecaCSVData {
material: string;
perfil_principal: string;
tem_componentes: boolean;
comprimento?: number | null;
comprimento_ref?: number | string;
marca_componente?: string;
descricao_componente?: string;
perfil_componente?: string;
peso_unitario_componente?: number;
comprimento_componente?: number | null;
quantidade_por_peca?: number;
}
@@ -62,6 +68,9 @@ export function ImportarPecasModal({ open, onOpenChange, onImport, pecasExistent
const [isLoading, setIsLoading] = useState(false);
const [step, setStep] = useState<'upload' | 'preview'>('upload');
const [showErrors, setShowErrors] = useState(false);
const [analiseDiff, setAnaliseDiff] = useState<ResultadoAnaliseImportacao | null>(null);
const [componentesMapDiff, setComponentesMapDiff] = useState<Map<string, any[]>>(new Map());
const [showDiffModal, setShowDiffModal] = useState(false);
const requiredHeaders = [
'of_number', 'etapa_fase', 'marca', 'descricao', 'quantidade',
@@ -102,7 +111,7 @@ export function ImportarPecasModal({ open, onOpenChange, onImport, pecasExistent
});
// Validar e converter campos numéricos
const numericFields = ['quantidade', 'peso_unitario', 'peso_total', 'peso_unitario_componente', 'quantidade_por_peca'];
const numericFields = ['quantidade', 'peso_unitario', 'peso_total', 'peso_unitario_componente', 'quantidade_por_peca', 'comprimento', 'comprimento_ref', 'comprimento_componente'];
const convertedData: any = { ...rawRow };
numericFields.forEach(field => {
@@ -171,10 +180,12 @@ export function ImportarPecasModal({ open, onOpenChange, onImport, pecasExistent
material: convertedData.material?.toString().trim() || '',
perfil_principal: convertedData.perfil_principal?.toString().trim() || '',
tem_componentes: convertedData.tem_componentes,
comprimento: convertedData.comprimento ? Number(convertedData.comprimento) : (convertedData.comprimento_ref ? Number(convertedData.comprimento_ref) : null),
marca_componente: convertedData.marca_componente?.toString().trim() || '',
descricao_componente: convertedData.descricao_componente?.toString().trim() || '',
perfil_componente: convertedData.perfil_componente?.toString().trim() || '',
peso_unitario_componente: convertedData.peso_unitario_componente || 0,
comprimento_componente: convertedData.comprimento_componente ? Number(convertedData.comprimento_componente) : null,
quantidade_por_peca: convertedData.quantidade_por_peca || 0
};
@@ -307,19 +318,29 @@ export function ImportarPecasModal({ open, onOpenChange, onImport, pecasExistent
};
const handleImport = async () => {
const validPecas = csvData.filter((_, index) => !duplicates[index]?.isDuplicate);
console.log('🚀 Iniciando importação de', validPecas.length, 'peças...');
if (validPecas.length === 0) {
if (csvData.length === 0) {
toast.error('Nenhuma peça válida para importar');
return;
}
setIsLoading(true);
try {
await onImport(validPecas);
console.log('✅ Importação concluída com sucesso!');
// Analisar conflitos e status na base de dados
toast.info('Verificando existência e status de produção das peças...');
const analise = await analisarConflitosImportacao(csvData);
const { componentesMap } = consolidarPecasEComponentes(csvData);
// Se houver peças já cadastradas na base
if (analise.totalComDivergencia > 0 || analise.totalIdenticas > 0 || analise.totalComApontamento > 0) {
setAnaliseDiff(analise);
setComponentesMapDiff(componentesMap);
setShowDiffModal(true);
setIsLoading(false);
return;
}
await onImport(csvData);
toast.success(`${csvData.length} novas peças importadas com sucesso!`);
handleClose();
} catch (error) {
console.error('❌ Erro na importação:', error);
@@ -352,6 +373,7 @@ export function ImportarPecasModal({ open, onOpenChange, onImport, pecasExistent
.reduce((sum, peca) => sum + peca.peso_total, 0);
return (
<>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-6xl max-h-[80vh]">
<DialogHeader>
@@ -601,5 +623,19 @@ export function ImportarPecasModal({ open, onOpenChange, onImport, pecasExistent
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modal de Detecção de Conflitos e Conciliação */}
<ImportarPecasDiffModal
open={showDiffModal}
onOpenChange={setShowDiffModal}
analise={analiseDiff}
componentesMap={componentesMapDiff}
ofDefault={csvData[0]?.of_number || ''}
onConcluido={() => {
setShowDiffModal(false);
handleClose();
}}
/>
</>
);
}
+40 -3
View File
@@ -15,6 +15,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { FileSpreadsheet, Upload, Download, CheckCircle2, AlertCircle, Trash2, ArrowLeft, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import * as XLSX from 'xlsx';
import { analisarConflitosImportacao, consolidarPecasEComponentes, extrairComprimentoSeguro, extrairComprimentoComponenteSeguro } from '@/utils/importDiffService';
import { ResultadoAnaliseImportacao } from '@/types/importDiff';
import { ImportarPecasDiffModal } from '@/components/pecas/ImportarPecasDiffModal';
export interface PecaXLSData {
of_number: string;
@@ -55,6 +58,9 @@ export function ImportarXLSModal({
const [isProcessing, setIsProcessing] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [step, setStep] = useState<'upload' | 'preview'>('upload');
const [analiseDiff, setAnaliseDiff] = useState<ResultadoAnaliseImportacao | null>(null);
const [componentesMapDiff, setComponentesMapDiff] = useState<Map<string, any[]>>(new Map());
const [showDiffModal, setShowDiffModal] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const resetState = () => {
@@ -465,8 +471,8 @@ export function ImportarXLSModal({
quantidade: p.quantidade,
peso_unitario: p.peso_unitario,
peso_total: p.peso_total,
comprimento: p.comprimento !== undefined && p.comprimento !== null ? Number(p.comprimento) : (p.comprimento_ref ? Number(p.comprimento_ref) : null),
comprimento_ref: p.comprimento_ref ? Number(p.comprimento_ref) : (p.comprimento ? Number(p.comprimento) : null),
comprimento: extrairComprimentoSeguro(p),
comprimento_ref: extrairComprimentoSeguro(p),
tratamento_superficial: p.tratamento_superficial || 'pintura',
material: p.material,
perfil_principal: p.perfil_principal,
@@ -475,11 +481,27 @@ export function ImportarXLSModal({
descricao_componente: p.descricao_componente,
perfil_componente: p.perfil_componente,
peso_unitario_componente: p.peso_unitario_componente,
comprimento_componente: extrairComprimentoComponenteSeguro(p),
quantidade_por_peca: p.quantidade_por_peca,
}));
// Analisar conflitos com a base de dados
toast.info('Verificando existência e status de produção das peças...');
const analise = await analisarConflitosImportacao(pecasPayload);
const { componentesMap } = consolidarPecasEComponentes(pecasPayload);
// Se houver qualquer peça já existente na base (com divergência, idêntica ou com apontamento fabril)
if (analise.totalComDivergencia > 0 || analise.totalIdenticas > 0 || analise.totalComApontamento > 0) {
setAnaliseDiff(analise);
setComponentesMapDiff(componentesMap);
setShowDiffModal(true);
setIsImporting(false);
return;
}
// Se todas forem 100% novas
await onImport(pecasPayload);
toast.success(`${pecasProcessadas.length} registros importados com sucesso para a OF!`);
toast.success(`${pecasProcessadas.length} novas peças cadastradas com sucesso para a OF!`);
handleModalOpenChange(false);
} catch (err: unknown) {
console.error('Erro ao importar peças do Excel:', err);
@@ -544,6 +566,7 @@ export function ImportarXLSModal({
const totalQuantidade = pecasProcessadas.reduce((acc, p) => acc + (p.quantidade || 0), 0);
return (
<>
<Dialog open={open} onOpenChange={handleModalOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col p-6 bg-card text-card-foreground border-border">
<DialogHeader className="pb-3 border-b border-border">
@@ -767,5 +790,19 @@ export function ImportarXLSModal({
)}
</DialogContent>
</Dialog>
{/* Modal Comparador e Conciliador de Peças Repetidas */}
<ImportarPecasDiffModal
open={showDiffModal}
onOpenChange={setShowDiffModal}
analise={analiseDiff}
componentesMap={componentesMapDiff}
ofDefault={ofDefault}
onConcluido={() => {
setShowDiffModal(false);
handleModalOpenChange(false);
}}
/>
</>
);
}
@@ -0,0 +1,286 @@
import React, { useRef } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Printer, AlertTriangle, CheckCircle2, Copy, FileText, Factory } from 'lucide-react';
import { toast } from 'sonner';
import { ItemAnaliseImportacao } from '@/types/importDiff';
interface IntervencaoFabrilPrintModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
itens: ItemAnaliseImportacao[];
ofNumber?: string;
usuarioNome?: string;
}
export const IntervencaoFabrilPrintModal: React.FC<IntervencaoFabrilPrintModalProps> = ({
open,
onOpenChange,
itens,
ofNumber = '',
usuarioNome = 'Operador PCP',
}) => {
const printRef = useRef<HTMLDivElement>(null);
const handlePrint = () => {
window.print();
};
const handleCopyText = () => {
if (itens.length === 0) return;
const linhas = itens.map((item, idx) => {
const diffs = item.camposDivergentes.map((c) => `${c.label}: ${c.valorAntigo} ➔ ${c.valorNovo}`).join('; ');
const comps = item.componentesDivergentes.map((c) => c.detalhe).join('; ');
const ultimoProc = item.apontamentoResumo.ultimoProcessoNome || 'Não iniciado';
return `${idx + 1}. [${item.of_number}] Marca: ${item.marca} (Fase ${item.etapa_fase})
- Status Fabril: ${ultimoProc} (${item.apontamentoResumo.totalProduzido} un apontadas)
- Divergências: ${diffs || 'Nenhuma alteração de campo'} ${comps ? '| Subcomponentes: ' + comps : ''}
- Ação Recomendada: ${item.acaoRecomendadaFabril || item.motivoAlerta}
`;
});
const textoFinal = `ORDEM DE INTERVENÇÃO FABRIL - TRACKSTEEL
Data: ${new Date().toLocaleString('pt-BR')} | Responsável: ${usuarioNome}
OFs: ${ofNumber || 'Múltiplas'} | Total de Peças em Alerta: ${itens.length}
${linhas.join('\n')}
Aviso: Interromper avanço fabril das peças listadas até a conferência física.`;
navigator.clipboard.writeText(textoFinal);
toast.success('Lista de lembretes copiada para a área de transferência!');
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col p-0 overflow-hidden bg-background border-border">
{/* Cabeçalho da Janela */}
<DialogHeader className="p-4 border-b border-border bg-muted/40 flex-row items-center justify-between space-y-0">
<div className="flex items-center gap-2">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20">
<AlertTriangle className="h-5 w-5" />
</div>
<div>
<DialogTitle className="text-lg font-bold text-foreground flex items-center gap-2">
Ordem de Intervenção Fabril & Lista de Lembretes
<Badge variant="outline" className="bg-red-500/10 text-red-600 border-red-500/30 text-xs font-bold">
{itens.length} peça(s) com atenção especial
</Badge>
</DialogTitle>
<p className="text-xs text-muted-foreground">
Documento de alerta para o chão de fábrica sobre peças alteradas que já possuem apontamentos de produção.
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCopyText}
className="text-xs flex items-center gap-1.5"
>
<Copy className="h-3.5 w-3.5" />
Copiar Texto
</Button>
<Button
size="sm"
onClick={handlePrint}
className="bg-blue-600 hover:bg-blue-700 text-white text-xs flex items-center gap-1.5 shadow-sm"
>
<Printer className="h-3.5 w-3.5" />
Imprimir (A4 / Ficha)
</Button>
</div>
</DialogHeader>
{/* Conteúdo Imprimível */}
<ScrollArea className="flex-1 p-6">
<div
ref={printRef}
className="print-container bg-white text-slate-900 p-6 rounded-lg border border-slate-200 shadow-sm print:p-0 print:border-none print:shadow-none font-sans"
>
{/* Cabeçalho Oficial do Relatório */}
<div className="border-b-2 border-slate-900 pb-4 mb-4 flex justify-between items-start">
<div>
<div className="flex items-center gap-2">
<Factory className="h-6 w-6 text-slate-900" />
<span className="font-black text-xl tracking-tight text-slate-900">TRACKSTEEL</span>
<span className="text-xs uppercase bg-slate-100 font-bold px-2 py-0.5 rounded text-slate-700 border border-slate-300">
Industrial PCP
</span>
</div>
<h1 className="text-base font-extrabold uppercase mt-1 text-slate-900">
ORDEM DE INTERVENÇÃO FABRIL & ALERTA DE REVISÃO
</h1>
<p className="text-xs text-slate-600">
Atenção especial por atualização de engenharia em peças com processo fabril em andamento
</p>
</div>
<div className="text-right text-xs space-y-0.5 text-slate-700">
<p><span className="font-bold">OF:</span> {ofNumber || 'Geral / Múltiplas'}</p>
<p><span className="font-bold">Data/Hora:</span> {new Date().toLocaleString('pt-BR')}</p>
<p><span className="font-bold">Responsável:</span> {usuarioNome}</p>
<p><span className="font-bold text-red-600">Peças Afetadas:</span> {itens.length}</p>
</div>
</div>
{/* Aviso de Destaque */}
<div className="bg-amber-50 border-l-4 border-amber-500 p-3 mb-4 rounded text-xs text-amber-900 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 shrink-0 mt-0.5" />
<div>
<span className="font-bold">COMUNICADO URGENTE AOS LÍDERES DE PRODUÇÃO:</span> As peças abaixo sofreram alterações de projeto (dimensional, peso, componentes ou quantidade) mas já possuem avanço de fabricação registrado. Interrompa o avanço físico destas peças até a conferência técnica recomendada.
</div>
</div>
{/* Tabela de Ações e Lembretes */}
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-100 border-b-2 border-slate-300 text-slate-800 font-bold uppercase">
<th className="py-2 px-2.5 w-12 text-center">Item</th>
<th className="py-2 px-2.5 w-28">OF / Marca</th>
<th className="py-2 px-2.5 w-40">Perfil / Descrição</th>
<th className="py-2 px-2.5 w-36">Status no Chão de Fábrica</th>
<th className="py-2 px-2.5">O Que Mudou (Engenharia)</th>
<th className="py-2 px-2.5 w-52">Ação Recomendada</th>
<th className="py-2 px-2.5 w-28 text-center">Visto do Líder</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{itens.map((item, index) => {
const ultimoProc = item.apontamentoResumo.ultimoProcessoNome || 'Não iniciado';
const totalApontado = item.apontamentoResumo.totalProduzido;
return (
<tr key={item.key} className={index % 2 === 1 ? 'bg-slate-50/50' : 'bg-white'}>
<td className="py-2 px-2.5 text-center font-bold text-slate-500">
{index + 1}
</td>
<td className="py-2 px-2.5">
<div className="font-extrabold text-slate-900 text-sm">{item.marca}</div>
<div className="text-[10px] text-slate-500">
{item.of_number} • Fase {item.etapa_fase}
</div>
</td>
<td className="py-2 px-2.5">
<div className="font-semibold text-slate-800">
{item.dadosImportados.perfil_principal || item.descricao || '-'}
</div>
{item.dadosImportados.comprimento && (
<div className="text-[10px] text-slate-500">
Comp: {item.dadosImportados.comprimento} mm
</div>
)}
</td>
<td className="py-2 px-2.5">
<div className="inline-block px-2 py-0.5 rounded text-[11px] font-bold bg-amber-100 text-amber-900 border border-amber-300">
{ultimoProc.toUpperCase()}
</div>
<div className="text-[10px] text-slate-600 mt-0.5">
{totalApontado} de {item.dadosExistentes?.quantidade || item.dadosImportados.quantidade} un apontadas
</div>
{item.apontamentoResumo.emRomaneio && (
<div className="text-[10px] font-bold text-red-600">
⚠️ EM ROMANEIO
</div>
)}
</td>
<td className="py-2 px-2.5 space-y-1">
{item.camposDivergentes.map((c) => (
<div key={c.campo} className="text-[11px]">
<span className="font-bold text-slate-700">{c.label}:</span>{' '}
<span className="line-through text-red-600">{c.valorAntigo}</span>{' '}
➔ <span className="font-bold text-emerald-700">{c.valorNovo}</span>
</div>
))}
{item.componentesDivergentes.map((comp) => (
<div key={comp.marca_componente} className="text-[10px] text-blue-700 font-medium">
• {comp.detalhe}
</div>
))}
</td>
<td className="py-2 px-2.5">
<div className="text-[11px] font-semibold text-slate-900 bg-red-50 p-1.5 rounded border border-red-200">
{item.acaoRecomendadaFabril || item.motivoAlerta || 'Verificar peça na fábrica.'}
</div>
</td>
<td className="py-2 px-2.5 text-center">
<div className="h-10 border-b border-dashed border-slate-400 mt-2"></div>
<div className="text-[9px] text-slate-400 mt-0.5">Assinatura / Data</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Rodapé de Assinaturas */}
<div className="mt-8 pt-4 border-t border-slate-300 grid grid-cols-3 gap-6 text-center text-xs text-slate-600">
<div>
<div className="h-10 border-b border-slate-400 mb-1"></div>
<div className="font-bold text-slate-800">Engenharia / Projetista</div>
<div className="text-[10px]">Liberação de Revisão</div>
</div>
<div>
<div className="h-10 border-b border-slate-400 mb-1"></div>
<div className="font-bold text-slate-800">PCP / Planejamento</div>
<div className="text-[10px]">Ajuste de Fila & Diário</div>
</div>
<div>
<div className="h-10 border-b border-slate-400 mb-1"></div>
<div className="font-bold text-slate-800">Líder de Fabricação</div>
<div className="text-[10px]">Ciente & Intervenção Física</div>
</div>
</div>
</div>
</ScrollArea>
{/* Rodapé da Janela */}
<DialogFooter className="p-3 border-t border-border bg-muted/20 flex items-center justify-between">
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<FileText className="h-4 w-4" />
Esta folha pode ser impressa em qualquer impressora A4 ou salva em PDF pelo navegador.
</div>
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
Fechar Janela
</Button>
</DialogFooter>
{/* Estilo Global de Impressão (@media print) */}
<style dangerouslySetInnerHTML={{
__html: `
@media print {
body * {
visibility: hidden !important;
}
.print-container, .print-container * {
visibility: visible !important;
}
.print-container {
position: absolute !important;
left: 0 !important;
top: 0 !important;
width: 100% !important;
margin: 0 !important;
padding: 15mm !important;
background: white !important;
color: black !important;
}
}
`
}} />
</DialogContent>
</Dialog>
);
};
+137 -31
View File
@@ -18,6 +18,8 @@ interface ProductionPieceStatus {
currentProcessName?: string;
processColor?: string;
processOrdem?: number;
perfilPrincipal?: string;
descricao?: string;
}
/**
@@ -67,6 +69,42 @@ function findProductionDataForPiece(
return undefined;
}
/**
* Helper para formatar a Marca Principal da Peça no padrão de Engenharia (OF-Fase-Marca, ex: B138-2-1 / B135-2-8)
* e obter o Perfil/Bitola oficial a partir do banco de dados ou do modelo 3D.
*/
function resolvePieceEngineeringDetails(
rawPieceMark: string,
rawSection: string | undefined,
phase: string | undefined,
singlePartMark: string | undefined,
prod: ProductionPieceStatus | undefined,
selectedOF: string | undefined
): {
displayMark: string;
displaySection: string;
} {
const baseMark = prod?.marca || rawPieceMark || 'Peça';
let displayMark = baseMark;
// Se tivermos OF e Fase e a marca for pura (ex: "1" ou "8"), formata como "B138-2-1" idêntico ao Print 2 "B135-2-8"
if (selectedOF && phase && !baseMark.toUpperCase().startsWith(selectedOF.toUpperCase())) {
displayMark = `${selectedOF}-${phase}-${baseMark}`;
}
// Perfil / Bitola oficial: prioriza banco de produção (TS_ERP.pecas.perfil_principal), seguido de section limpo do modelo
let displaySection = prod?.perfilPrincipal || '';
if (!displaySection || displaySection === '1001' || displaySection === 'Perfil') {
if (rawSection && rawSection !== '1001' && rawSection !== 'Perfil') {
displaySection = rawSection;
} else {
displaySection = prod?.descricao || 'Perfil';
}
}
return { displayMark, displaySection };
}
interface ModelViewer3DProps {
modelData: LoadedIFCResult | null;
productionData?: Map<string, ProductionPieceStatus>;
@@ -940,17 +978,40 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
const pmark = hitMesh.userData.pieceMark;
if (modelData && expressID !== undefined) {
const piece = modelData.pieceByExpressID.get(expressID) || {
const rawPiece = modelData.pieceByExpressID.get(expressID);
const rawMark = hitMesh.userData.pieceMark || rawPiece?.pieceMark || '';
const phase = hitMesh.userData.phase || rawPiece?.phase;
const singlePartMark = hitMesh.userData.singlePartMark || rawPiece?.singlePartMark;
const prod = findProductionDataForPiece(productionData, {
pieceMark: rawMark,
cleanMark: hitMesh.userData.cleanMark || rawPiece?.cleanMark,
assemblyMark: hitMesh.userData.assemblyMark || rawPiece?.assemblyMark,
cleanAssemblyMark: hitMesh.userData.cleanAssemblyMark || rawPiece?.cleanAssemblyMark,
phase,
}, selectedOF);
const { displayMark, displaySection } = resolvePieceEngineeringDetails(
rawMark,
hitMesh.userData.section || rawPiece?.section,
phase,
singlePartMark,
prod,
selectedOF
);
const piece: PieceInfo = {
expressID,
guid: hitMesh.userData.guid || '',
name: hitMesh.userData.name || hitMesh.userData.section || 'Peça',
type: hitMesh.userData.type || 'PIECE',
pieceMark: pmark,
cleanMark: hitMesh.userData.cleanMark,
assemblyMark: hitMesh.userData.assemblyMark,
cleanAssemblyMark: hitMesh.userData.cleanAssemblyMark,
section: hitMesh.userData.section || hitMesh.userData.name,
phase: hitMesh.userData.phase,
guid: hitMesh.userData.guid || rawPiece?.guid || '',
name: hitMesh.userData.name || rawPiece?.name || 'Peça',
type: hitMesh.userData.type || rawPiece?.type || 'PIECE',
pieceMark: displayMark,
cleanMark: hitMesh.userData.cleanMark || rawPiece?.cleanMark,
assemblyMark: hitMesh.userData.assemblyMark || rawPiece?.assemblyMark,
cleanAssemblyMark: hitMesh.userData.cleanAssemblyMark || rawPiece?.cleanAssemblyMark,
section: displaySection,
phase,
singlePartMark,
};
setHoveredPiece(piece);
setHoverPosition({ x: clientX, y: clientY });
@@ -1040,18 +1101,40 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
});
}
const pmark = hitMesh.userData.pieceMark;
const piece: PieceInfo = (modelData && modelData.pieceByExpressID.get(targetIfcId)) || {
const rawPiece = modelData?.pieceByExpressID.get(targetIfcId);
const rawMark = hitMesh.userData.pieceMark || rawPiece?.pieceMark || '';
const phase = hitMesh.userData.phase || rawPiece?.phase;
const singlePartMark = hitMesh.userData.singlePartMark || rawPiece?.singlePartMark;
const prod = findProductionDataForPiece(productionData, {
pieceMark: rawMark,
cleanMark: hitMesh.userData.cleanMark || rawPiece?.cleanMark,
assemblyMark: hitMesh.userData.assemblyMark || rawPiece?.assemblyMark,
cleanAssemblyMark: hitMesh.userData.cleanAssemblyMark || rawPiece?.cleanAssemblyMark,
phase,
}, selectedOF);
const { displayMark, displaySection } = resolvePieceEngineeringDetails(
rawMark,
hitMesh.userData.section || rawPiece?.section,
phase,
singlePartMark,
prod,
selectedOF
);
const piece: PieceInfo = {
expressID: targetIfcId,
guid: hitMesh.userData.guid || '',
name: hitMesh.userData.name || hitMesh.userData.section || 'Peça',
type: hitMesh.userData.type || 'PIECE',
pieceMark: pmark,
cleanMark: hitMesh.userData.cleanMark,
assemblyMark: hitMesh.userData.assemblyMark,
cleanAssemblyMark: hitMesh.userData.cleanAssemblyMark,
section: hitMesh.userData.section,
phase: hitMesh.userData.phase,
guid: hitMesh.userData.guid || rawPiece?.guid || '',
name: hitMesh.userData.name || rawPiece?.name || 'Peça',
type: hitMesh.userData.type || rawPiece?.type || 'PIECE',
pieceMark: displayMark,
cleanMark: hitMesh.userData.cleanMark || rawPiece?.cleanMark,
assemblyMark: hitMesh.userData.assemblyMark || rawPiece?.assemblyMark,
cleanAssemblyMark: hitMesh.userData.cleanAssemblyMark || rawPiece?.cleanAssemblyMark,
section: displaySection,
phase,
singlePartMark,
};
nextMap.set(targetIfcId, {
@@ -1165,17 +1248,40 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
m.material = magentaMaterialRef.current;
});
const piece: PieceInfo = (modelData && modelData.pieceByExpressID.get(elId)) || {
const rawPiece = modelData?.pieceByExpressID.get(elId);
const rawMark = sampleMesh.userData.pieceMark || rawPiece?.pieceMark || '';
const phase = sampleMesh.userData.phase || rawPiece?.phase || targetPhase;
const singlePartMark = sampleMesh.userData.singlePartMark || rawPiece?.singlePartMark;
const prod = findProductionDataForPiece(productionData, {
pieceMark: rawMark,
cleanMark: sampleMesh.userData.cleanMark || rawPiece?.cleanMark,
assemblyMark: sampleMesh.userData.assemblyMark || rawPiece?.assemblyMark,
cleanAssemblyMark: sampleMesh.userData.cleanAssemblyMark || rawPiece?.cleanAssemblyMark,
phase,
}, selectedOF);
const { displayMark, displaySection } = resolvePieceEngineeringDetails(
rawMark,
sampleMesh.userData.section || rawPiece?.section,
phase,
singlePartMark,
prod,
selectedOF
);
const piece: PieceInfo = {
expressID: elId,
guid: sampleMesh.userData.guid || '',
name: sampleMesh.userData.name || sampleMesh.userData.section || 'Peça',
type: sampleMesh.userData.type || 'PIECE',
pieceMark: sampleMesh.userData.pieceMark,
cleanMark: sampleMesh.userData.cleanMark,
assemblyMark: sampleMesh.userData.assemblyMark,
cleanAssemblyMark: sampleMesh.userData.cleanAssemblyMark,
section: sampleMesh.userData.section,
phase: sampleMesh.userData.phase || targetPhase,
guid: sampleMesh.userData.guid || rawPiece?.guid || '',
name: sampleMesh.userData.name || rawPiece?.name || 'Peça',
type: sampleMesh.userData.type || rawPiece?.type || 'PIECE',
pieceMark: displayMark,
cleanMark: sampleMesh.userData.cleanMark || rawPiece?.cleanMark,
assemblyMark: sampleMesh.userData.assemblyMark || rawPiece?.assemblyMark,
cleanAssemblyMark: sampleMesh.userData.cleanAssemblyMark || rawPiece?.cleanAssemblyMark,
section: displaySection,
phase,
singlePartMark,
};
nextMap.set(elId, {
@@ -62,6 +62,15 @@ export const PieceHoverTooltip: React.FC<PieceHoverTooltipProps> = ({
</div>
)}
{piece.singlePartMark && piece.singlePartMark !== piece.pieceMark && (
<div className="flex items-center justify-between text-slate-300">
<span className="text-slate-400">Sub-peça / Corte:</span>
<span className="font-semibold text-slate-200 font-mono bg-slate-950/60 px-2 py-0.5 rounded border border-slate-800">
Pos. {piece.singlePartMark}
</span>
</div>
)}
{piece.childrenIDs && piece.childrenIDs.length > 0 && (
<div className="flex items-center justify-between text-slate-300">
<span className="text-slate-400">Componentes:</span>
+327 -78
View File
@@ -22,8 +22,12 @@ import {
Moon,
FileSpreadsheet,
Grid as GridIcon,
Bell
Bell,
RotateCw,
Lock,
Unlock
} from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
export interface ProductionPieceStatus {
pecaId?: string;
@@ -38,6 +42,91 @@ export interface ProductionPieceStatus {
processesCompleted?: string[];
processPointedQtds?: Record<string, number>;
hasDetalhamento?: boolean;
perfilPrincipal?: string;
descricao?: string;
}
/**
* Busca de apontamento e status fabril com máxima tolerância e amostragem de chaves para IFC e WRL.
*/
function findProductionDataForPiece(
productionData?: Map<string, ProductionPieceStatus>,
piece?: {
pieceMark?: string;
cleanMark?: string;
assemblyMark?: string;
cleanAssemblyMark?: string;
phase?: string;
} | null,
selectedOF?: string
): ProductionPieceStatus | undefined {
if (!productionData || !piece) return undefined;
const pmark = String(piece.pieceMark || '').trim().toUpperCase();
const cleanMark = String(piece.cleanMark || pmark).trim().toUpperCase();
const assMark = String(piece.assemblyMark || '').trim().toUpperCase();
const cleanAssMark = String(piece.cleanAssemblyMark || cleanMark).trim().toUpperCase();
const elementPhase = piece.phase ? String(piece.phase).trim() : '';
const searchKeys = [
elementPhase && selectedOF ? `${selectedOF}-${elementPhase}-${cleanAssMark}` : '',
elementPhase ? `${elementPhase}-${cleanAssMark}` : '',
elementPhase && selectedOF ? `${selectedOF}-${elementPhase}-${cleanMark}` : '',
elementPhase ? `${elementPhase}-${cleanMark}` : '',
selectedOF ? `${selectedOF}-${cleanAssMark}` : '',
selectedOF ? `${selectedOF}-${cleanMark}` : '',
assMark,
pmark,
cleanAssMark,
cleanMark,
].filter(Boolean).map((k) => k.toUpperCase());
for (const key of searchKeys) {
const match = productionData.get(key);
if (match) {
if (!elementPhase || !match.fase || String(match.fase) === String(elementPhase)) {
return match;
}
}
}
return undefined;
}
/**
* Helper para formatar a Marca Principal da Peça no padrão de Engenharia (OF-Fase-Marca, ex: B138-1-59 / B138-2-10)
* e obter o Perfil/Bitola oficial a partir do banco de dados ou do modelo 3D.
*/
function resolvePieceEngineeringDetails(
rawPieceMark: string,
rawSection: string | undefined,
phase: string | undefined,
singlePartMark: string | undefined,
prod: ProductionPieceStatus | undefined,
selectedOF: string | undefined
): {
displayMark: string;
displaySection: string;
} {
const baseMark = prod?.marca || rawPieceMark || 'Peça';
let displayMark = baseMark;
// Se tivermos OF e Fase e a marca for pura (ex: "1" ou "8" ou "59"), formata como "B138-1-59"
if (selectedOF && phase && !baseMark.toUpperCase().startsWith(selectedOF.toUpperCase())) {
displayMark = `${selectedOF}-${phase}-${baseMark}`;
}
// Perfil / Bitola oficial: prioriza banco de produção (TS_ERP.pecas.perfil_principal), seguido de section limpo do modelo
let displaySection = prod?.perfilPrincipal || '';
if (!displaySection || displaySection === '1001' || displaySection === 'Perfil') {
if (rawSection && rawSection !== '1001' && rawSection !== 'Perfil') {
displaySection = rawSection;
} else {
displaySection = prod?.descricao || 'Perfil';
}
}
return { displayMark, displaySection };
}
interface SmartModelViewer3DProps {
@@ -46,6 +135,7 @@ interface SmartModelViewer3DProps {
selectedOF: string;
cliente?: string;
phasesList: string[];
initialRotation?: { x: number; y: number; z: number } | null;
onVoltar: () => void;
}
@@ -65,6 +155,7 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
selectedOF,
cliente,
phasesList,
initialRotation,
onVoltar,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
@@ -97,6 +188,11 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
// Modal de Lista da Seleção
const [isSelectionModalOpen, setIsSelectionModalOpen] = useState<boolean>(false);
// Controles de Rotação 90° e Trava com Cadeado (Memória de Posição)
const [rotationX, setRotationX] = useState<number>(0);
const [rotationY, setRotationY] = useState<number>(0);
const [isRotationLocked, setIsRotationLocked] = useState<boolean>(false);
// Seleção e Toast Discreto
interface SelectedElement {
expressID: number;
@@ -372,7 +468,7 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
controlsRef.current.update();
}, []);
// 3. Montar malhas do modelo IFC na cena Three.js
// 3. Montar malhas do modelo IFC/WRL na cena Three.js & Aplicar Rotação Memorizada
useEffect(() => {
if (!modelData || !sceneRef.current) return;
@@ -384,11 +480,87 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
modelGroupRef.current = group;
sceneRef.current.add(group);
// Recupera rotação memorizada para esta OF (localStorage ou banco de dados)
const storageKey = selectedOF ? `tracksteel_model_rot_${selectedOF}` : null;
let savedX = 0;
let savedY = 0;
let isLocked = false;
if (storageKey) {
const stored = localStorage.getItem(storageKey);
if (stored) {
try {
const parsed = JSON.parse(stored);
if (typeof parsed.x === 'number') savedX = parsed.x;
if (typeof parsed.y === 'number') savedY = parsed.y;
isLocked = true;
} catch (e) {}
}
}
if (!isLocked && initialRotation) {
if (typeof initialRotation.x === 'number') savedX = initialRotation.x;
if (typeof initialRotation.y === 'number') savedY = initialRotation.y;
isLocked = true;
}
setRotationX(savedX);
setRotationY(savedY);
setIsRotationLocked(isLocked);
// Aplica rotação no grupo do modelo Three.js (corrige modelos tombados)
group.rotation.set(
THREE.MathUtils.degToRad(savedX),
THREE.MathUtils.degToRad(savedY),
0
);
group.updateMatrixWorld(true);
// Enquadra a câmera automaticamente após carregar o modelo
setTimeout(() => {
fitModelToView();
}, 150);
}, [modelData, fitModelToView]);
}, [modelData, selectedOF, initialRotation, fitModelToView]);
// Gira o modelo em 90 graus no eixo X (tombamento vertical para alinhar Z-Up do CAD com Y-Up)
const handleRotate90X = useCallback(() => {
if (!modelGroupRef.current) return;
const nextX = (rotationX + 90) % 360;
setRotationX(nextX);
setIsRotationLocked(false);
modelGroupRef.current.rotation.x = THREE.MathUtils.degToRad(nextX);
modelGroupRef.current.rotation.y = THREE.MathUtils.degToRad(rotationY);
modelGroupRef.current.updateMatrixWorld(true);
fitModelToView();
smartAudio.playClick();
}, [rotationX, rotationY, fitModelToView]);
// Alterna o bloqueio/gravação da posição ideal de visualização no banco e no navegador
const handleToggleLockRotation = useCallback(async () => {
if (!selectedOF) return;
if (!isRotationLocked) {
setIsRotationLocked(true);
const rotData = { x: rotationX, y: rotationY, z: 0 };
localStorage.setItem(`tracksteel_model_rot_${selectedOF}`, JSON.stringify(rotData));
try {
await supabase
.from('ordens_fabricacao' as any)
.update({ model_3d_rotation: rotData })
.eq('num_of', selectedOF);
} catch (err) {
console.error('Erro ao salvar rotação no banco:', err);
}
smartAudio.playSuccess();
} else {
setIsRotationLocked(false);
smartAudio.playClick();
}
}, [selectedOF, isRotationLocked, rotationX, rotationY]);
// 4. Aplicação de Filtros (Fase e Processo) e Cores de Produção idêntico ao modo desktop
useEffect(() => {
@@ -655,16 +827,40 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
}
});
const pmark = hitMesh.userData.pieceMark;
const phase = hitMesh.userData.phase;
const piece: PieceInfo = (modelData && modelData.pieceByExpressID.get(targetIfcId)) || {
const rawPiece = modelData?.pieceByExpressID.get(targetIfcId);
const rawMark = hitMesh.userData.pieceMark || rawPiece?.pieceMark || '';
const phase = hitMesh.userData.phase || rawPiece?.phase;
const singlePartMark = hitMesh.userData.singlePartMark || rawPiece?.singlePartMark;
const prodInfo = findProductionDataForPiece(productionData, {
pieceMark: rawMark,
cleanMark: hitMesh.userData.cleanMark || rawPiece?.cleanMark,
assemblyMark: hitMesh.userData.assemblyMark || rawPiece?.assemblyMark,
cleanAssemblyMark: hitMesh.userData.cleanAssemblyMark || rawPiece?.cleanAssemblyMark,
phase,
}, selectedOF);
const { displayMark, displaySection } = resolvePieceEngineeringDetails(
rawMark,
hitMesh.userData.section || rawPiece?.section,
phase,
singlePartMark,
prodInfo,
selectedOF
);
const piece: PieceInfo = {
expressID: targetIfcId,
guid: '',
name: hitMesh.userData.section || 'Peça',
type: 'PIECE',
pieceMark: pmark,
section: hitMesh.userData.section,
phase: phase,
guid: hitMesh.userData.guid || rawPiece?.guid || '',
name: hitMesh.userData.name || rawPiece?.name || 'Peça',
type: hitMesh.userData.type || rawPiece?.type || 'PIECE',
pieceMark: displayMark,
cleanMark: hitMesh.userData.cleanMark || rawPiece?.cleanMark,
assemblyMark: hitMesh.userData.assemblyMark || rawPiece?.assemblyMark,
cleanAssemblyMark: hitMesh.userData.cleanAssemblyMark || rawPiece?.cleanAssemblyMark,
section: displaySection,
phase,
singlePartMark,
};
nextMap.set(targetIfcId, {
@@ -675,22 +871,7 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
setSelectedElementsMap(nextMap);
smartAudio.playSuccess();
// Abre o Toast discreto ao lado do toque com matching enriquecido
let prodInfo: ProductionPieceStatus | undefined = undefined;
if (productionData && productionData.size > 0 && pmark) {
const searchKeys = [
phase && selectedOF ? `${selectedOF}-${phase}-${pmark}` : '',
phase ? `${phase}-${pmark}` : '',
selectedOF ? `${selectedOF}-${pmark}` : '',
pmark,
pmark.toUpperCase(),
].filter(Boolean);
for (const k of searchKeys) {
const m = productionData.get(k);
if (m) { prodInfo = m; break; }
}
}
// Abre o Toast com dados de engenharia enriquecidos
setToastInfo({
piece,
position: { x: e.clientX, y: e.clientY },
@@ -718,26 +899,10 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
// Lista consolidada de peças físicas para o modal de seleção (Print 3)
const selectedPiecesList: SelectedPieceItem[] = useMemo(() => {
return Array.from(selectedElementsMap.values()).map(({ expressID, piece }) => {
const pmark = piece.pieceMark || 'Sem Marca';
const phase = piece.phase;
let prod: ProductionPieceStatus | undefined = undefined;
if (productionData && productionData.size > 0 && pmark) {
const searchKeys = [
phase && selectedOF ? `${selectedOF}-${phase}-${pmark}` : '',
phase ? `${phase}-${pmark}` : '',
selectedOF ? `${selectedOF}-${pmark}` : '',
pmark,
pmark.toUpperCase(),
].filter(Boolean);
for (const k of searchKeys) {
const m = productionData.get(k);
if (m) { prod = m; break; }
}
}
const prod = findProductionDataForPiece(productionData, piece, selectedOF);
return {
id: String(expressID),
pieceMark: pmark,
pieceMark: piece.pieceMark || 'Sem Marca',
phase: piece.phase,
section: piece.section,
name: piece.name,
@@ -775,13 +940,14 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
let left = toastInfo.position.x - rect.left + 14;
let top = toastInfo.position.y - rect.top - 20;
if (left + 230 > rect.width) {
left = Math.max(12, left - 245);
if (left + 310 > rect.width) {
left = Math.max(12, left - 325);
}
if (top + 160 > rect.height) {
top = Math.max(12, rect.height - 170);
if (top + 230 > rect.height) {
top = Math.max(12, rect.height - 240);
}
if (top < 12) top = 12;
if (left < 12) left = 12;
return { left: `${left}px`, top: `${top}px` };
}, [toastInfo]);
@@ -943,7 +1109,7 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
</div>
</div>
{/* 2. Top Right: Cubo 3D Compacto + Card de Ações da Seleção (Print 3) */}
{/* 2. Top Right: Cubo 3D Compacto + Card de Giro 90° + Card de Ações da Seleção */}
<div className="absolute top-16 right-3 z-20 flex flex-col items-end gap-2 pointer-events-auto">
{/* Cubo Compacto Discreto */}
<ViewCube
@@ -953,11 +1119,57 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
compact={true}
/>
{/* Card de Ajuste e Travamento de Rotação 3D (Tombamento 90° + Cadeado Memorizável) */}
<div className="w-[140px] flex items-center gap-1.5 p-1.5 rounded-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-slate-300 dark:border-slate-700 shadow-lg shadow-slate-300/30 dark:shadow-slate-950/50 pointer-events-auto">
{/* Botão Rotacionar / Tombar 90° */}
<button
type="button"
onClick={handleRotate90X}
disabled={!modelData}
className={`flex-1 min-w-0 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-xl text-[10px] font-bold transition-all duration-150 active:scale-95 ${
modelData
? 'bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-800 dark:text-slate-100 cursor-pointer shadow-sm'
: 'bg-slate-100/50 dark:bg-slate-800/30 text-slate-400 dark:text-slate-600 cursor-not-allowed opacity-50'
}`}
title="Girar o modelo 90° no eixo de tombamento (adequar orientação Z/Y)"
>
<RotateCw className="w-3 h-3 text-blue-600 dark:text-blue-400 shrink-0" />
<span className="whitespace-nowrap">Girar 90°</span>
<span className="text-[9px] font-mono font-normal text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-950 px-1 py-0.2 rounded border border-slate-200 dark:border-slate-800 shrink-0">
{rotationX}°
</span>
</button>
{/* Botão Cadeado: Travar / Gravar ou Destravar */}
<button
type="button"
onClick={handleToggleLockRotation}
disabled={!modelData}
className={`flex items-center justify-center p-1.5 rounded-xl transition-all duration-200 active:scale-95 border shrink-0 ${
!modelData
? 'bg-slate-100/50 dark:bg-slate-800/30 text-slate-400 border-transparent cursor-not-allowed opacity-40'
: isRotationLocked
? 'bg-emerald-50 hover:bg-emerald-100 text-emerald-700 border-emerald-300 dark:bg-emerald-950/60 dark:hover:bg-emerald-900/80 dark:text-emerald-300 dark:border-emerald-700/80 shadow-sm cursor-pointer'
: 'bg-amber-50 hover:bg-amber-100 text-amber-700 border-amber-300 dark:bg-amber-950/50 dark:hover:bg-amber-900/70 dark:text-amber-300 dark:border-amber-700/70 shadow-sm cursor-pointer animate-pulse'
}`}
title={
isRotationLocked
? 'Posição TRAVADA e salva para esta OF. Clique para destravar e ajustar.'
: 'Posição DESTRAVADA. Clique aqui no cadeado para travar e salvar a posição ideal.'
}
>
{isRotationLocked ? (
<Lock className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400" />
) : (
<Unlock className="w-3.5 h-3.5 text-amber-600 dark:text-amber-400" />
)}
</button>
</div>
{/* Card Flutuante de Seleção com "Gerar lista da seleção" (Print 3) */}
{selectedCount > 0 && (
<div
className="flex flex-col items-stretch gap-1.5 p-2 rounded-2xl bg-white/95 dark:bg-slate-900/95 border border-slate-300 dark:border-slate-700 shadow-xl dark:shadow-slate-950/80 backdrop-blur-md animate-in fade-in zoom-in-95 duration-200"
style={{ minWidth: '135px' }}
className="w-[140px] flex flex-col items-stretch gap-1.5 p-2 rounded-2xl bg-white/95 dark:bg-slate-900/95 border border-slate-300 dark:border-slate-700 shadow-xl dark:shadow-slate-950/80 backdrop-blur-md animate-in fade-in zoom-in-95 duration-200"
>
{/* Badge Indicador de Seleção Ativa */}
<div className="flex items-center justify-center gap-1.5 px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-200 text-[10px] font-mono font-bold">
@@ -995,7 +1207,7 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
)}
</div>
{/* 3. Toast Discreto ao Lado da Peça Clicada */}
{/* 3. Toast Discreto ao Lado da Peça Clicada (Réplica Idêntica do Modo Completo - Print 2) */}
{toastInfo && (
<div
onClick={() => {
@@ -1003,63 +1215,100 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
setToastInfo(null);
}}
style={toastStyle}
className="absolute z-40 p-3 rounded-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur-xl border border-fuchsia-500/50 shadow-2xl shadow-fuchsia-950/20 text-slate-800 dark:text-slate-100 min-w-[210px] max-w-[260px] animate-in fade-in zoom-in-95 duration-150 cursor-pointer pointer-events-auto select-none transition-all group"
title="Toque no toast para fechar (a peça continua marcada)"
className="absolute z-40 p-4 rounded-2xl bg-slate-900/95 backdrop-blur-xl border border-cyan-500/40 shadow-2xl shadow-cyan-950/60 text-slate-100 min-w-[270px] max-w-[330px] animate-in fade-in zoom-in-95 duration-150 cursor-pointer pointer-events-auto select-none transition-all group"
title="Toque no card para fechar (a peça continua marcada)"
>
{/* Cabeçalho do Toast */}
<div className="flex items-center justify-between gap-1.5 pb-1.5 mb-1.5 border-b border-slate-200 dark:border-slate-800">
<div className="flex items-center gap-1.5 min-w-0">
<div className="w-2 h-2 rounded-full bg-fuchsia-500 shadow-sm" />
<span className="text-xs font-black text-fuchsia-600 dark:text-fuchsia-300 font-mono tracking-tight truncate">
{/* Cabeçalho do Toast com Ícone Box e Marca Principal de Engenharia */}
<div className="flex items-center justify-between gap-2 pb-2.5 mb-2.5 border-b border-slate-800">
<div className="flex items-center gap-2 min-w-0">
<div className="p-1.5 rounded-lg bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 shrink-0">
<Box className="w-4 h-4" />
</div>
<div className="min-w-0">
<span className="text-[10px] uppercase font-mono tracking-widest text-slate-400 block leading-tight">
Marca Principal
</span>
<span className="text-base font-extrabold text-cyan-300 font-mono tracking-tight truncate block">
{toastInfo.piece.pieceMark || 'Sem Marca'}
</span>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{toastInfo.piece.phase && (
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold font-mono bg-slate-800 text-slate-300 border border-slate-700">
Fase {toastInfo.piece.phase}
</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setToastInfo(null);
}}
className="p-1 rounded-lg text-slate-400 hover:text-slate-700 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
{/* Dados Rápidos da Peça */}
<div className="space-y-1 text-[11px]">
{toastInfo.piece.phase && (
<div className="flex items-center justify-between text-slate-600 dark:text-slate-300">
<span className="text-slate-400">Fase:</span>
<span className="font-bold text-slate-800 dark:text-slate-200">Fase {toastInfo.piece.phase}</span>
</div>
)}
{/* Detalhes Técnicos da Peça */}
<div className="space-y-1.5 text-xs">
{toastInfo.piece.section && (
<div className="flex items-center justify-between text-slate-600 dark:text-slate-300">
<span className="text-slate-400">Perfil:</span>
<span className="font-mono font-semibold text-slate-800 dark:text-slate-100 truncate max-w-[120px]">
<div className="flex items-center justify-between text-slate-300">
<span className="text-slate-400">Perfil / Bitola:</span>
<span className="font-semibold text-white font-mono bg-slate-950/60 px-2 py-0.5 rounded border border-slate-800">
{toastInfo.piece.section}
</span>
</div>
)}
{toastInfo.piece.singlePartMark && toastInfo.piece.singlePartMark !== toastInfo.piece.pieceMark && (
<div className="flex items-center justify-between text-slate-300">
<span className="text-slate-400">Sub-peça / Corte:</span>
<span className="font-semibold text-slate-200 font-mono bg-slate-950/60 px-2 py-0.5 rounded border border-slate-800">
Pos. {toastInfo.piece.singlePartMark}
</span>
</div>
)}
{/* Apontamentos e Etapa Atual */}
{toastInfo.prod && (
<div className="pt-1 mt-1 border-t border-slate-200 dark:border-slate-800/80 flex items-center justify-between">
<span className="text-slate-400">Status:</span>
<div className="mt-3 pt-2.5 border-t border-slate-800/80 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-slate-400 flex items-center gap-1.5">
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" />
Apontamento:
</span>
<span className="font-bold text-emerald-400 font-mono">
{toastInfo.prod.pointedQtd ?? 0} de {toastInfo.prod.totalQtd ?? 1} peças
</span>
</div>
{toastInfo.prod.currentProcessName && (
<div className="flex items-center justify-between text-[11px]">
<span className="text-slate-400 flex items-center gap-1">
<Clock className="w-3 h-3 text-amber-400" />
Etapa Atual:
</span>
<span
className="font-bold px-1.5 py-0.5 rounded text-[10px] uppercase tracking-wider"
className="px-2 py-0.5 rounded-full font-bold uppercase tracking-wider text-[10px]"
style={{
backgroundColor: `${toastInfo.prod.processColor || '#10b981'}25`,
color: toastInfo.prod.processColor || '#10b981',
border: `1px solid ${toastInfo.prod.processColor || '#10b981'}40`,
}}
>
{toastInfo.prod.currentProcessName || 'Produção'} ({toastInfo.prod.pointedQtd}/{toastInfo.prod.totalQtd})
{toastInfo.prod.currentProcessName}
</span>
</div>
)}
</div>
)}
</div>
{/* Dica para Fechar */}
<div className="mt-2 pt-1 border-t border-slate-200 dark:border-slate-800 text-[9px] text-center text-slate-400 font-medium">
<div className="mt-2.5 pt-1.5 border-t border-slate-800 text-[9px] text-center text-slate-400 font-medium">
Toque no card para fechar ✕
</div>
</div>
+5 -6
View File
@@ -118,14 +118,13 @@ export const useApontamentoDiarioChart = (periodo: PeriodoType) => {
const ofNumber = apontamento.of_number;
const processoNome = apontamento.processo?.nome || 'Processo Desconhecido';
// Calcular peso baseado no tipo de apontamento
let pesoUnitario = 0;
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.peso_unitario) {
pesoUnitario = Number(apontamento.componente.peso_unitario);
} else if (apontamento.tipo_apontamento === 'peca' && apontamento.peca?.peso_unitario) {
pesoUnitario = Number(apontamento.peca.peso_unitario);
// Blindagem contra dupla contagem: apenas peças mestres computam peso nos gráficos de produção.
// Componentes são rastreados para controle de kit/corte, mas não somam peso na balança global.
if (apontamento.tipo_apontamento !== 'peca') {
return;
}
const pesoUnitario = Number(apontamento.peca?.peso_unitario || 0);
let pesoTotal = pesoUnitario * Number(apontamento.quantidade_produzida);
// Para projeção, calcular média diária e multiplicar por 7
+64
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner';
import { sincronizarPecaKitNoCorte, sincronizarTodosKitsOFCorte } from '@/utils/sincronizacaoKitsCorte';
// Função utilitária para retry de requisições
const retryRequest = async <T,>(
@@ -378,6 +379,36 @@ interface InsertApontamentoData {
throw error;
}
// REGRA DO KIT COMPLETO:
// Se apontou componente no processo de Corte, verificar se todos os componentes da peça pai completaram o kit.
// Se completou, cria automaticamente o apontamento da Peça Principal no Corte (tipo_apontamento = 'peca'),
// computando o peso integral da peça mestre no Corte e liberando-a para a Solda!
if (insertData.tipo_apontamento === 'componente' && insertData.peca_id) {
try {
const procAtual = processos.find(p => p.id === insertData.processo_id);
const isProcessoCorte = procAtual
? (procAtual.nome.toLowerCase().includes('corte') || procAtual.nome.toLowerCase().includes('prepar') || procAtual.ordem === 1)
: true;
if (isProcessoCorte) {
const resSync = await sincronizarPecaKitNoCorte({
ofNumber: insertData.of_number,
pecaId: insertData.peca_id,
processoCorteId: insertData.processo_id,
userId: user.id,
usuarioNome: user.name || user.username || user.email?.split('@')[0] || 'Operador',
dataApontamento: insertData.data_apontamento
});
if (resSync && resSync.delta_liberado > 0) {
toast.success(`Kit 100% cortado! +${resSync.delta_liberado} un da peça ${resSync.marca} liberada(s) para Solda (${resSync.kits_completos}/${resSync.quantidade_total}).`);
}
}
} catch (kitError) {
console.error('Erro ao verificar regra de kit completo:', kitError);
}
}
console.log('✅ Apontamento criado com sucesso, forçando atualização completa...');
// FORÇA ATUALIZAÇÃO COMPLETA DOS DADOS
@@ -507,6 +538,38 @@ interface InsertApontamentoData {
}
}, [user, fetchApontamentos, fetchProcessos]);
const sincronizarKitsOF = useCallback(async (ofNumber: string, processoCorteId?: string) => {
try {
const procCorte = processoCorteId || processos.find(p => {
const n = (p.nome || '').toLowerCase();
return n.includes('corte') || n.includes('prepar') || p.ordem === 1;
})?.id;
if (!procCorte) {
console.warn('Processo de corte não encontrado para sincronização de kits.');
return [];
}
const resultados = await sincronizarTodosKitsOFCorte({
ofNumber,
processoCorteId: procCorte,
userId: user?.id,
usuarioNome: user?.name || user?.username || user?.email?.split('@')[0] || 'Operador'
});
const totalLiberadas = resultados.reduce((sum, r) => sum + r.delta_liberado, 0);
if (totalLiberadas > 0) {
toast.success(`Sincronização: +${totalLiberadas} kit(s) completos liberados para Solda!`);
await fetchApontamentos();
}
return resultados;
} catch (e) {
console.error('Erro ao sincronizar kits da OF:', e);
return [];
}
}, [processos, user, fetchApontamentos]);
return {
apontamentos,
processos,
@@ -516,6 +579,7 @@ interface InsertApontamentoData {
atualizarProcesso,
exportarModeloCSV,
buscarQuantidadeProcessada,
sincronizarKitsOF,
refetch
};
};
+22 -5
View File
@@ -24,16 +24,33 @@ import {
// Sincroniza user Logto com profiles Supabase e retorna o UUID real do Supabase
async function syncUserToProfile(user: LogtoUser): Promise<string> {
if (!user?.sub) return user.sub;
const email = user.email || `${user.username || 'user'}@logto.local`;
const email = (user.email || `${user.username || 'user'}@logto.local`).trim();
try {
// 1. Tenta buscar o perfil existente pelo email
const { data: existing } = await supabase
// 1. Tenta buscar o perfil existente pelo email (case-insensitive)
let { data: existing, error: selectError } = await supabase
.from('profiles')
.select('id')
.eq('email', email)
.ilike('email', email)
.maybeSingle();
// Em caso de falha transitória de rede, faz 1 retry rápido antes de desistir
if (selectError) {
await new Promise((r) => setTimeout(r, 800));
const retry = await supabase
.from('profiles')
.select('id')
.ilike('email', email)
.maybeSingle();
existing = retry.data;
selectError = retry.error;
}
if (selectError) {
console.warn('Erro de rede ao consultar profile no Supabase:', selectError);
return user.sub;
}
let profileId = existing?.id;
// 2. Se existe, atualizamos o nome e retornamos o UUID
@@ -47,7 +64,7 @@ async function syncUserToProfile(user: LogtoUser): Promise<string> {
return profileId;
}
// 3. Se não existir, criamos um novo com um UUID gerado
// 3. Se confirmadamente não existir, criamos um novo com um UUID gerado
profileId = crypto.randomUUID();
const { error: insertError } = await supabase.from('profiles').insert({
id: profileId,
+33 -4
View File
@@ -3,6 +3,14 @@ import { useState, useEffect, useMemo } from 'react';
import { useComponentesPeca } from '@/hooks/useComponentesPeca';
import { useDebounce } from '@/hooks/useDebounce';
export interface ComponenteVinculoDetalhe {
componente_id: string;
peca_id: string;
peca_marca: string;
quantidade_por_peca: number;
quantidade_necessaria: number;
}
export interface ComponenteAgrupado {
marca_componente: string;
descricao: string;
@@ -12,6 +20,7 @@ export interface ComponenteAgrupado {
peca_pai_info: string;
componente_ids: string[];
peca_ids: string[];
vinculos: ComponenteVinculoDetalhe[];
}
// Cache local para evitar buscas repetitivas
@@ -36,7 +45,7 @@ export const useComponentesAgrupados = (ofNumber: string, fase: string, pecasDat
peca &&
peca.of_number === debouncedOfNumber &&
peca.etapa_fase === debouncedFase &&
peca.tem_componentes === true // Apenas peças com componentes
peca.tem_componentes !== false // Peças com componentes (ou não marcadas como S/M)
);
}, [pecasData, debouncedOfNumber, debouncedFase]);
@@ -101,22 +110,40 @@ export const useComponentesAgrupados = (ofNumber: string, fase: string, pecasDat
if (!pecaPai) return;
const porPeca = Math.max(1, Number(comp.quantidade_por_peca) || 1);
const qtdTotalPeca = Number(pecaPai.quantidade) || 0;
const qtdNecessaria = porPeca * qtdTotalPeca;
const vinculo: ComponenteVinculoDetalhe = {
componente_id: comp.id,
peca_id: comp.peca_id,
peca_marca: pecaPai.marca,
quantidade_por_peca: porPeca,
quantidade_necessaria: qtdNecessaria,
};
if (grupos.has(marca)) {
const grupo = grupos.get(marca)!;
grupo.quantidade_total += comp.quantidade_por_peca * (pecaPai.quantidade || 0);
grupo.quantidade_total += qtdNecessaria;
if (!grupo.peca_pai_info.includes(pecaPai.marca)) {
grupo.peca_pai_info += `, ${pecaPai.marca}`;
}
grupo.componente_ids.push(comp.id);
if (!grupo.peca_ids.includes(comp.peca_id)) {
grupo.peca_ids.push(comp.peca_id);
}
grupo.vinculos.push(vinculo);
} else {
grupos.set(marca, {
marca_componente: marca,
descricao: comp.descricao || '',
perfil: comp.perfil || '',
peso_unitario: comp.peso_unitario,
quantidade_total: comp.quantidade_por_peca * (pecaPai.quantidade || 0),
quantidade_total: qtdNecessaria,
peca_pai_info: pecaPai.marca,
componente_ids: [comp.id],
peca_ids: [comp.peca_id]
peca_ids: [comp.peca_id],
vinculos: [vinculo],
});
}
});
@@ -133,6 +160,8 @@ export const useComponentesAgrupados = (ofNumber: string, fase: string, pecasDat
return {
componentesAgrupados,
componentesRaw: componentes || [],
loading: loading || componentesLoading
};
};
+9 -24
View File
@@ -171,16 +171,11 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
const processoCorteId = processoCorte.data?.id;
const pecasInfoMap = new Map((pecasData || []).map(p => [p.id, p]));
// 1. Somar peças com componentes apontadas na Solda
// 1. Somar peças com componentes apontadas na Solda (apenas tipo peca)
if (processoSoldaId) {
const apontamentosSolda = apontamentosData.filter(a => a.processo_id === processoSoldaId);
const apontamentosSolda = apontamentosData.filter(a => a.processo_id === processoSoldaId && a.tipo_apontamento === 'peca');
apontamentosSolda.forEach(apontamento => {
let pesoUnitario = 0;
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.peso_unitario) {
pesoUnitario = Number(apontamento.componente.peso_unitario);
} else if (apontamento.tipo_apontamento === 'peca' && apontamento.peca?.peso_unitario) {
pesoUnitario = Number(apontamento.peca.peso_unitario);
}
const pesoUnitario = Number(apontamento.peca?.peso_unitario || 0);
const pesoApontamento = pesoUnitario * Number(apontamento.quantidade_produzida || 0);
pesoTotalFabricado += pesoApontamento;
});
@@ -357,15 +352,10 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
let pesoFabricadoProcesso = 0;
apontamentosProcesso.forEach(a => {
let pesoUnitario = 0;
if (a.tipo_apontamento === 'componente' && a.componente?.peso_unitario) {
pesoUnitario = Number(a.componente.peso_unitario);
} else if (a.tipo_apontamento === 'peca' && a.peca?.peso_unitario) {
pesoUnitario = Number(a.peca.peso_unitario);
// Apenas apontamentos de peça mestre somam peso no processo para evitar dupla contagem com subcomponentes
if (a.tipo_apontamento === 'peca' && a.peca?.peso_unitario) {
pesoFabricadoProcesso += Number(a.peca.peso_unitario) * Number(a.quantidade_produzida);
}
pesoFabricadoProcesso += pesoUnitario * Number(a.quantidade_produzida);
});
const isProcessoSolda = processo.nome.toLowerCase().includes('solda');
@@ -508,15 +498,10 @@ export const useDashboardProducaoOtimizado = (ofNumber: string) => {
});
let realizado = 0;
apontamentosAteData.forEach(a => {
let pesoUnitario = 0;
if (a.tipo_apontamento === 'componente' && a.componente?.peso_unitario) {
pesoUnitario = Number(a.componente.peso_unitario);
} else if (a.tipo_apontamento === 'peca' && a.peca?.peso_unitario) {
pesoUnitario = Number(a.peca.peso_unitario);
// Apenas peças mestres computam peso realizado no gráfico
if (a.tipo_apontamento === 'peca' && a.peca?.peso_unitario) {
realizado += Number(a.peca.peso_unitario) * Number(a.quantidade_produzida);
}
realizado += pesoUnitario * Number(a.quantidade_produzida);
});
dadosGrafico.push({
+502
View File
@@ -0,0 +1,502 @@
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;
peso_unitario: number;
peso_total_peca: number;
peso_kits_liberados: number;
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;
peso100PorcentoKg: number;
pesoUnidadesLiberadasKg: number;
pesoParciaisKg: number;
pesoNaoIniciadosKg: 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;
peso_unitario?: number | null;
peso_total?: number | 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;
const pesoUnitPeca = Number(peca.peso_unitario) || (qtdTotalPeca > 0 && peca.peso_total ? Number(peca.peso_total) / qtdTotalPeca : 0);
const pesoTotalPeca = Number(peca.peso_total) || (qtdTotalPeca * pesoUnitPeca);
// 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);
const pesoCortado = kitsCompletos * pesoUnitPeca;
const pesoLiberado = kitsCompletos * pesoUnitPeca;
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,
peso_unitario: pesoUnitPeca,
peso_total_peca: pesoTotalPeca,
peso_kits_liberados: pesoLiberado,
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: pesoTotalPeca,
peso_cortado_componentes: pesoCortado,
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`;
}
}
const pesoUnitComps = pesoTotalComps > 0 && qtdTotalPeca > 0 ? pesoTotalComps / qtdTotalPeca : 0;
const pesoUnitPecaFinal = Number(peca.peso_unitario) || pesoUnitComps;
const pesoTotalPecaFinal = pesoTotalComps > 0 ? pesoTotalComps : (Number(peca.peso_total) || (qtdTotalPeca * pesoUnitPecaFinal));
const pesoLiberadoFinal = kitsCompletos * pesoUnitPecaFinal;
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,
peso_unitario: pesoUnitPecaFinal,
peso_total_peca: pesoTotalPecaFinal,
peso_kits_liberados: pesoLiberadoFinal,
componentes: listaCompsInfo,
kits_completos_cortados: kitsCompletos,
percentual_termico: percentualTermico,
status_termico: statusTermico,
componente_gargalo: gargaloComp,
peso_total_componentes: pesoTotalPecaFinal,
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, peso_unitario, peso_total')
.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 peso100 = 0;
let pesoUnLiberadas = 0;
let pesoParciais = 0;
let pesoNaoIniciados = 0;
let unTot = 0;
let unLib = 0;
lista.forEach((item) => {
unTot += item.quantidade_total;
unLib += item.kits_completos_cortados;
pesoTot += item.peso_total_peca;
pesoCort += item.peso_cortado_componentes;
pesoUnLiberadas += item.peso_kits_liberados;
if (item.status_termico === 'verde') {
total100++;
peso100 += item.peso_total_peca;
} else if (item.status_termico === 'verde_parcial' || item.status_termico === 'amarelo') {
totalParciais++;
pesoParciais += item.peso_total_peca;
} else {
totalTravados++;
pesoNaoIniciados += item.peso_total_peca;
}
});
const resumo: ResumoKitsOF = {
totalPecas: lista.length,
totalKits100Porcento: total100,
totalKitsParciais: totalParciais,
totalKitsNaoIniciados: totalTravados,
pesoTotalOFKg: pesoTot,
pesoCortadoTotalKg: pesoCort,
peso100PorcentoKg: peso100,
pesoUnidadesLiberadasKg: pesoUnLiberadas,
pesoParciaisKg: pesoParciais,
pesoNaoIniciadosKg: pesoNaoIniciados,
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);
}
+7 -3
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { useAuth } from '@/hooks/useAuth';
import { extrairComprimentoSeguro, extrairComprimentoComponenteSeguro } from '@/utils/importDiffService';
export interface Peca {
id: string;
@@ -425,6 +426,7 @@ export function usePecas() {
pecasData.forEach((item, index) => {
const pecaKey = `${item.of_number || ''}-${item.etapa_fase || ''}-${item.marca || ''}`;
const comprimentoPeca = extrairComprimentoSeguro(item);
const pecaData = {
of_number: item.of_number || '',
@@ -434,7 +436,7 @@ export function usePecas() {
quantidade: Number(item.quantidade) || 0,
peso_unitario: Number(item.peso_unitario) || 0,
peso_total: Number(item.peso_total) || 0,
comprimento: item.comprimento ? Number(item.comprimento) : (item.comprimento_ref ? Number(item.comprimento_ref) : null),
comprimento: comprimentoPeca,
tratamento_superficial: item.tratamento_superficial || '',
material: item.material || '',
perfil_principal: item.perfil_principal || '',
@@ -445,6 +447,8 @@ export function usePecas() {
if (!pecasMap.has(pecaKey)) {
pecasMap.set(pecaKey, pecaData);
} else if (comprimentoPeca && !pecasMap.get(pecaKey).comprimento) {
pecasMap.get(pecaKey).comprimento = comprimentoPeca;
}
if (item.marca_componente && String(item.marca_componente).trim()) {
@@ -470,12 +474,13 @@ export function usePecas() {
}
cleanMarca = String(numVal);
const compComprimento = extrairComprimentoComponenteSeguro(item);
const componenteData = {
marca_componente: cleanMarca,
descricao: item.descricao_componente || item.descricao || '',
perfil: item.perfil_componente || item.perfil_principal || '',
peso_unitario: Number(item.peso_unitario_componente) || 0,
comprimento: item.comprimento_componente ? Number(item.comprimento_componente) : null,
comprimento: compComprimento,
quantidade_por_peca: Math.max(1, Math.round(Number(item.quantidade_por_peca) || 1)),
user_id: user.id
};
@@ -501,7 +506,6 @@ export function usePecas() {
const { data: pecasExistentes, error: queryExistentesError } = await supabase
.from('pecas')
.select('id, of_number, etapa_fase, marca')
.eq('user_id', user.id)
.in('of_number', ofsParaConsultar);
if (queryExistentesError) {
+4 -7
View File
@@ -81,14 +81,11 @@ export const useProcessChartData = (ofNumber: string) => {
if (apontamentos && apontamentos.length > 0) {
apontamentos.forEach(apontamento => {
let pesoUnitario = 0;
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.peso_unitario) {
pesoUnitario = apontamento.componente.peso_unitario;
} else if (apontamento.tipo_apontamento === 'peca' && apontamento.peca?.peso_unitario) {
pesoUnitario = apontamento.peca.peso_unitario;
}
// Apenas peças mestres somam peso nas médias diárias e metas
if (apontamento.tipo_apontamento !== 'peca') return;
const pesoTotal = pesoUnitario * apontamento.quantidade_produzida;
const pesoUnitario = Number(apontamento.peca?.peso_unitario || 0);
const pesoTotal = pesoUnitario * Number(apontamento.quantidade_produzida);
pesoTotalProcessado += pesoTotal;
if (apontamento.data_apontamento) {
+6 -1
View File
@@ -12,6 +12,7 @@ interface ApontamentoDiario {
descricao: string;
processo: string;
quantidade: number;
tipo_apontamento: 'peca' | 'componente';
peso_unitario: number;
peso_total: number;
data_apontamento: string;
@@ -105,6 +106,7 @@ export const useRelatorioDiario = (selectedDate: string) => {
descricao,
processo: apt.processo?.nome || 'N/A',
quantidade: apt.quantidade_produzida,
tipo_apontamento: (apt.tipo_apontamento || 'peca') as 'peca' | 'componente',
peso_unitario,
peso_total,
data_apontamento: apt.data_apontamento,
@@ -139,11 +141,14 @@ export const useRelatorioDiario = (selectedDate: string) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDate]);
// Calcular resumo por processo
// Calcular resumo por processo (apenas peças principais computam quantidade e peso no resumo da produção)
const resumoProcessos: ResumoProcesso[] = React.useMemo(() => {
const resumo = new Map<string, { quantidade: number; apontamentos: number; peso_total: number }>();
apontamentos.forEach(apt => {
// Ignorar componentes no resumo global de produção para evitar peso e contagem duplicados
if (apt.tipo_apontamento !== 'peca') return;
const processo = apt.processo;
if (!resumo.has(processo)) {
resumo.set(processo, { quantidade: 0, apontamentos: 0, peso_total: 0 });
+15 -11
View File
@@ -128,12 +128,15 @@ export const usePecasPintura = (ofNumber?: string) => {
.from('apontamentos_producao')
.select(`
peca_id,
componente_id,
tipo_apontamento,
quantidade_produzida,
pecas (
id,
marca,
etapa_fase,
descricao,
quantidade,
peso_unitario,
comprimento,
of_number
@@ -151,15 +154,16 @@ export const usePecasPintura = (ofNumber?: string) => {
const { data: itensExpedidos, error: expedidosError } = await supabase
.from('itens_romaneio_pecas')
.select('peca_id, quantidade_expedida')
.in('peca_id', apontamentosPintura?.map(a => a.peca_id) || []);
.in('peca_id', apontamentosPintura?.map(a => a.peca_id).filter(Boolean) || []);
if (expedidosError) throw expedidosError;
// Calcular disponibilidade
// Calcular disponibilidade (apenas peças principais, ignorando componentes)
const pecasPintura: PecaPintura[] = [];
const pecasMap = new Map();
apontamentosPintura?.forEach(apontamento => {
if (apontamento.tipo_apontamento === 'componente' || apontamento.componente_id) return;
const pecaId = apontamento.peca_id;
const peca = apontamento.pecas;
@@ -173,6 +177,7 @@ export const usePecasPintura = (ofNumber?: string) => {
marca: peca.marca,
etapa_fase: peca.etapa_fase,
descricao: peca.descricao,
quantidade_total: peca.quantidade,
peso_unitario: peca.peso_unitario,
comprimento: peca.comprimento,
quantidade_produzida_pintura: apontamento.quantidade_produzida,
@@ -186,10 +191,14 @@ export const usePecasPintura = (ofNumber?: string) => {
?.filter(item => item.peca_id === pecaId)
.reduce((sum, item) => sum + item.quantidade_expedida, 0) || 0;
const qtdProdLimitada = peca.quantidade_total
? Math.min(Number(peca.quantidade_total), peca.quantidade_produzida_pintura)
: peca.quantidade_produzida_pintura;
pecasPintura.push({
...peca,
quantidade_ja_expedida: totalExpedido,
quantidade_disponivel: peca.quantidade_produzida_pintura - totalExpedido
quantidade_disponivel: qtdProdLimitada - totalExpedido
});
});
@@ -280,16 +289,11 @@ export const useAtualizarRomaneio = () => {
console.log('📋 Status atual do romaneio:', romaneioAtual?.status);
console.log('📋 Novo status a ser definido:', romaneio.status);
// Validação rigorosa: Não permitir passar para Entregue se houver pendências nos processos anteriores
if (romaneio.status === 'Entregue') {
// Se status for Entregue e não passou pelo modal de confirmação, validar amigavelmente
if (romaneio.status === 'Entregue' && romaneioAtual?.status !== 'Entregue') {
const validacao = await validarRomaneioParaEntrega(id, romaneio.of_number || romaneioAtual?.of_number);
if (!validacao.valido) {
const msg = validacao.mensagem || (
validacao.pendencias && validacao.pendencias.length > 0
? `Não é possível marcar como "Entregue": existem ${validacao.pendencias.length} peça(s) com apontamentos pendentes nos processos anteriores.`
: 'Não é possível marcar como "Entregue": pendências encontradas.'
);
throw new Error(msg);
console.warn('⚠️ Romaneio possui pendências fabris ao passar para Entregue:', validacao.pendencias?.length);
}
}
+13 -4
View File
@@ -11,7 +11,8 @@ export const useUserFunction = () => {
queryFn: async () => {
if (!user?.id) return null;
const { data, error } = await supabase
const isValidUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(user.id || '');
let query = supabase
.from('profiles')
.select(`
function_id,
@@ -19,9 +20,17 @@ export const useUserFunction = () => {
name,
description
)
`)
.eq('id', user.id)
.single();
`);
if (isValidUuid) {
query = query.eq('id', user.id);
} else if (user.email) {
query = query.ilike('email', user.email.trim());
} else {
query = query.eq('id', user.id);
}
const { data, error } = await query.maybeSingle();
if (error) {
console.error('Erro ao buscar função do usuário:', error);
@@ -24,26 +24,56 @@ export function useUserPermissions() {
// Load user permissions
const loadUserPermissions = useCallback(async () => {
if (!user?.id) {
if (!user?.id && !user?.email) {
setLoading(false);
return;
}
try {
console.log(`🔄 Loading permissions for user: ${user.email}`);
const isUuid = Boolean(user.id && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(user.id));
// Load functional permissions from user privilege
const { data: profile } = await supabase
// Load functional permissions from user privilege (by UUID or fallback by email)
let profileQuery = supabase
.from('profiles')
.select(`
id,
privilege_id,
privileges!inner (
name,
permissions
)
`);
if (isUuid) {
profileQuery = profileQuery.eq('id', user.id);
} else if (user.email) {
profileQuery = profileQuery.ilike('email', user.email.trim());
} else {
setLoading(false);
return;
}
let { data: profile } = await profileQuery.maybeSingle();
// Se buscou por UUID e não achou, tenta fallback por email
if (!profile && isUuid && user.email) {
const retryByEmail = await supabase
.from('profiles')
.select(`
id,
privilege_id,
privileges!inner (
name,
permissions
)
`)
.eq('id', user.id)
.single();
.ilike('email', user.email.trim())
.maybeSingle();
profile = retryByEmail.data;
}
const resolvedUserId = profile?.id || (isUuid ? user.id : null);
if (profile?.privileges?.permissions) {
// Safe type conversion with validation
@@ -61,10 +91,11 @@ export function useUserPermissions() {
}
// Load specific resource permissions
if (resolvedUserId) {
const { data: resourcePerms } = await supabase
.from('user_interface_permissions')
.select('resource_key, permission')
.eq('user_id', user.id);
.eq('user_id', resolvedUserId);
if (resourcePerms) {
const permsMap = resourcePerms.reduce((acc, perm) => {
@@ -74,13 +105,14 @@ export function useUserPermissions() {
setResourcePermissions(permsMap);
console.log('✅ Resource permissions loaded:', permsMap);
}
}
} catch (error) {
console.error('❌ Error loading permissions:', error);
} finally {
setLoading(false);
}
}, [user?.id]);
}, [user?.id, user?.email]);
useEffect(() => {
loadUserPermissions();
+13 -4
View File
@@ -30,11 +30,20 @@ export function useUserProfile() {
setLoading(true);
try {
const { data, error } = await supabase
const isValidUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(user.id || '');
let query = supabase
.from('profiles')
.select('full_name, email, profile_image_url, status')
.eq('id', user.id)
.maybeSingle();
.select('full_name, email, profile_image_url, status');
if (isValidUuid) {
query = query.eq('id', user.id);
} else if (user.email) {
query = query.ilike('email', user.email.trim());
} else {
query = query.eq('id', user.id);
}
const { data, error } = await query.maybeSingle();
if (!error && data) {
setProfile(data);
+12 -30
View File
@@ -119,6 +119,15 @@ export const useValidacaoSequencialProcessos = () => {
return { valido: true };
}
// Componentes só podem ser processados no primeiro processo (Corte / Preparação)
const itemEComponente = historico.some(h => h.marca_item === marcaItem && h.tipo_item === 'componente');
if (itemEComponente) {
return {
valido: false,
erro: `Componente ${marcaItem} não pode ser apontado após a etapa de Corte / Preparação.`
};
}
// Buscar histórico do item específico
const historicoItem = historico.filter(h => h.marca_item === marcaItem);
@@ -238,41 +247,14 @@ export const useValidacaoSequencialProcessos = () => {
}
}
// Processar componentes
for (const componente of componentes) {
const marcaComp = String(componente.marca_componente || '');
const qtdComp = Number(componente.quantidade_total || 0);
// Processar componentes: apenas disponíveis no primeiro processo (Corte / Preparação)
if (ordemAtual === 1) {
// Primeiro processo: todos os componentes estão disponíveis
for (const componente of componentes) {
const qtdComp = Number(componente.quantidade_total || 0);
componentesDisponiveis.push({
...componente,
quantidade_disponivel: qtdComp
});
} else {
// Calcular quantidade disponível baseada no processo anterior
const quantidadeProcessadaAnterior = await calcularQuantidadeDisponivelProcessoAnterior(
marcaComp,
ordemAtual - 1,
ofNumber,
'componente'
);
const quantidadeJaProcessadaAtual = await buscarQuantidadeProcessada(
marcaComp,
processoId,
ofNumber,
'componente'
);
const quantidadeDisponivel = quantidadeProcessadaAnterior - quantidadeJaProcessadaAtual;
if (quantidadeDisponivel > 0) {
componentesDisponiveis.push({
...componente,
quantidade_disponivel: quantidadeDisponivel
});
}
}
}
+56 -28
View File
@@ -1,6 +1,6 @@
import * as THREE from 'three';
import { VRMLLoader } from 'three-stdlib';
import { PieceInfo, LoadedIFCResult, IFCQualityAudit, getColorForMaterialName, isValidMark } from '@/lib/ifc/ifcLoaderService';
import { PieceInfo, LoadedIFCResult, IFCQualityAudit, getColorForMaterialName, isValidMark, isSinglePartCutNumber } from '@/lib/ifc/ifcLoaderService';
/**
* Função utilitária para descompactar arquivos gzip (como .wrz ou .wrl comprimidos) no navegador.
@@ -57,6 +57,9 @@ function extractPhaseFromAncestors(ancestors: string[]): string | undefined {
return undefined;
}
/**
* Analisa e normaliza a marca da peça, marca do conjunto e tipo estrutural com suporte completo a Tekla / Bocad.
*/
/**
* Analisa e normaliza a marca da peça, marca do conjunto e tipo estrutural com suporte completo a Tekla / Bocad.
*/
@@ -65,10 +68,15 @@ function parseWrlNodeName(
inheritedPhase?: string,
ancestorAssembly?: string
): {
mainMark: string;
cleanMainMark: string;
pieceMark: string;
cleanMark: string;
assemblyMark?: string;
cleanAssemblyMark?: string;
singlePartMark?: string;
profile?: string;
section?: string;
phase?: string;
detectedType: string;
} {
@@ -79,20 +87,22 @@ function parseWrlNodeName(
let assemblyMark = ancestorAssembly ? ancestorAssembly.trim() : undefined;
let pieceMark = name;
let singlePartMark: string | undefined = undefined;
let profile = '';
const parts = name.split('_');
// CASO 1: Tekla composto com 2 números no início: [Conjunto]_[Peca]_[Perfil] (ex: "121_1127_TUBO44x45X3_121293")
// CASO 1: Tekla composto com 2 números no início: [Conjunto]_[Peca]_[Perfil] (ex: "1_1001_W360X44_0", "121_1127_TUBO44x45X3_121293")
if (parts.length >= 3 && /^\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) {
assemblyMark = parts[0];
pieceMark = parts[1];
profile = parts[2];
singlePartMark = parts[1];
let rest = parts.slice(2).join('_');
rest = rest.replace(/_\d{5,}$/, '');
profile = rest;
}
// CASO 2: Tekla padrão [MarcaPeca]_[Perfil] (ex: "5_W360X44_0", "6_W360X32_9", "1_W360X44_0", "6_VIGA")
else if (parts.length >= 2 && /^\d+$/.test(parts[0])) {
// Se o primeiro segmento é numérico e o segundo não é outro número (é perfil ou descrição),
// a marca real da peça é o primeiro segmento (ex: "5", "6", "1")!
pieceMark = parts[0];
assemblyMark = parts[0];
profile = parts.slice(1).join('_');
@@ -100,7 +110,7 @@ function parseWrlNodeName(
// CASO 3: Prefixo de conjunto alfanumérico (ex: "V101_1_CHAPA", "P1_2_CANTON")
else if (parts.length >= 2 && /^[A-Za-z]+\d+$/i.test(parts[0])) {
assemblyMark = parts[0];
pieceMark = parts[0]; // ou parts[1]
pieceMark = parts[0];
profile = parts.slice(1).join('_');
}
// CASO 4: Parafusos / Fixadores
@@ -113,18 +123,32 @@ function parseWrlNodeName(
pieceMark = pieceMark.replace(/^(?:Part|Piece|Peca|Elem|Item|Member|Profile|Mesh)[-_]/i, '');
}
// Normalização do Perfil / Bitola (ex: W360X44_0 -> W360X44.0, W360X32_9 -> W360X32.9)
if (profile) {
profile = profile.replace(/_(\d+)$/, '.$1').replace(/_/g, ' ').trim();
}
let cleanMark = pieceMark.replace(/[-_.#]\d+$/, '').trim().toUpperCase();
if (!cleanMark) cleanMark = pieceMark.toUpperCase();
const cleanAssemblyMark = assemblyMark ? assemblyMark.replace(/[-_.#]\d+$/, '').trim().toUpperCase() : cleanMark;
// Em estruturas metálicas, a marca da peça apontada na produção é a marca do conjunto (Assembly Mark).
// Se a sub-peça for um número de corte (>= 1000) e houver conjunto pai, a marca principal é o conjunto!
let mainMark = pieceMark;
let cleanMainMark = cleanMark;
if (assemblyMark && (isSinglePartCutNumber(pieceMark) || !mainMark || mainMark === 'indefinido')) {
mainMark = assemblyMark;
cleanMainMark = cleanAssemblyMark;
}
// Detecção de tipo de elemento estrutural
let detectedType = 'ELEMENT';
const searchStr = `${profile} ${name} ${cleanMark} ${cleanAssemblyMark}`.toUpperCase();
const searchStr = `${profile} ${name} ${cleanMark} ${cleanAssemblyMark} ${mainMark}`.toUpperCase();
if (searchStr.includes('VIGA') || searchStr.includes('BEAM') || cleanMark.startsWith('V')) {
if (searchStr.includes('VIGA') || searchStr.includes('BEAM') || cleanMainMark.startsWith('V')) {
detectedType = 'BEAM';
} else if (searchStr.includes('COLUNA') || searchStr.includes('PILAR') || searchStr.includes('COLUMN') || cleanMark.startsWith('P')) {
} else if (searchStr.includes('COLUNA') || searchStr.includes('PILAR') || searchStr.includes('COLUMN') || cleanMainMark.startsWith('P')) {
detectedType = 'COLUMN';
} else if (searchStr.includes('CHAPA') || searchStr.includes('PLATE') || cleanMark.startsWith('PL') || cleanMark.startsWith('CH')) {
detectedType = 'PLATE';
@@ -145,10 +169,15 @@ function parseWrlNodeName(
}
return {
mainMark: mainMark.toUpperCase(),
cleanMainMark: cleanMainMark.toUpperCase(),
pieceMark: pieceMark.toUpperCase(),
cleanMark,
assemblyMark: assemblyMark ? assemblyMark.toUpperCase() : undefined,
cleanAssemblyMark,
singlePartMark,
profile: profile || undefined,
section: profile || undefined,
phase: inheritedPhase,
detectedType,
};
@@ -503,19 +532,22 @@ export async function loadAndAuditWRL(
}
mesh.material = material;
const pieceSection = parsed.cleanMark || parsed.pieceMark || 'Perfil';
const pieceSection = parsed.profile || parsed.section || parsed.cleanMainMark || 'Perfil';
const mainMark = parsed.mainMark || parsed.cleanMainMark || parsed.pieceMark;
const cleanMain = parsed.cleanMainMark || parsed.cleanMark;
const pieceInfo: PieceInfo = {
expressID,
guid,
name: rawNodeName,
type: parsed.detectedType,
pieceMark: parsed.pieceMark,
cleanMark: parsed.cleanMark,
assemblyMark: parsed.assemblyMark,
cleanAssemblyMark: parsed.cleanAssemblyMark,
pieceMark: mainMark,
cleanMark: cleanMain,
assemblyMark: parsed.assemblyMark || mainMark,
cleanAssemblyMark: parsed.cleanAssemblyMark || cleanMain,
section: pieceSection,
phase: parsed.phase,
singlePartMark: parsed.singlePartMark,
mesh,
};
@@ -524,10 +556,11 @@ export async function loadAndAuditWRL(
ifcId: expressID, // Identificador primordial compatível com Raycaster, Hover e Seleção do visualizador
expressID,
guid,
pieceMark: parsed.pieceMark,
cleanMark: parsed.cleanMark,
assemblyMark: parsed.assemblyMark,
cleanAssemblyMark: parsed.cleanAssemblyMark,
pieceMark: mainMark, // Peça principal da produção (ex: 1, 8, V101)
cleanMark: cleanMain,
singlePartMark: parsed.singlePartMark, // Componente de corte (ex: 1001)
assemblyMark: parsed.assemblyMark || mainMark,
cleanAssemblyMark: parsed.cleanAssemblyMark || cleanMain,
section: pieceSection,
phase: parsed.phase,
type: parsed.detectedType,
@@ -538,17 +571,12 @@ export async function loadAndAuditWRL(
pieceByExpressID.set(expressID, pieceInfo);
// Agrupa peças por marca
const markKey = parsed.cleanMark || parsed.pieceMark;
if (markKey) {
const list = piecesByMark.get(markKey) || [];
// Agrupa peças por marca de conjunto e por marca de componente
const markKeys = [mainMark, cleanMain, parsed.singlePartMark, parsed.pieceMark, parsed.cleanMark].filter(Boolean) as string[];
for (const mKey of Array.from(new Set(markKeys))) {
const list = piecesByMark.get(mKey) || [];
list.push(pieceInfo);
piecesByMark.set(markKey, list);
}
if (parsed.cleanAssemblyMark && parsed.cleanAssemblyMark !== markKey) {
const listAss = piecesByMark.get(parsed.cleanAssemblyMark) || [];
listAss.push(pieceInfo);
piecesByMark.set(parsed.cleanAssemblyMark, listAss);
piecesByMark.set(mKey, list);
}
}
});
+1
View File
@@ -33,6 +33,7 @@ export interface PieceInfo {
parentAssemblyID?: number;
phase?: string;
ofNumber?: string;
singlePartMark?: string;
section?: string;
span?: number;
material?: string;
+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">
+8 -2
View File
@@ -110,11 +110,11 @@ export default function Visualizador3D() {
.select('id, of_number, etapa_fase, marca, descricao, quantidade, peso_unitario, peso_total, perfil_principal, material')
.eq('of_number', selectedOF);
// Fetch apontamentos com join de peca e processo
// Fetch apontamentos com join de peca e processo (apenas peças mestres)
const { data: apontamentosData, error: errorApontamentos } = await supabase
.from('apontamentos_producao' as any)
.select(`
id, of_number, peca_id, quantidade_produzida,
id, of_number, peca_id, quantidade_produzida, tipo_apontamento, componente_id,
peca:pecas!apontamentos_producao_peca_id_fkey(marca, etapa_fase),
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, cor, ordem)
`)
@@ -148,6 +148,8 @@ export default function Visualizador3D() {
marca: marcaStr,
fase,
ofNumber: selectedOF,
descricao: p.descricao || '',
perfilPrincipal: p.perfil_principal || p.descricao || '',
totalQtd: Number(p.quantidade || 1),
pesoTotal: Number(p.peso_total || 0),
pesoUnitario: Number(p.peso_unitario || 0),
@@ -186,6 +188,10 @@ export default function Visualizador3D() {
apontamentosData.sort((a: any, b: any) => (a.processo?.ordem || 0) - (b.processo?.ordem || 0));
apontamentosData.forEach((ap: any) => {
// Ignora apontamentos de componentes para não inflar as peças mestres
if (ap.tipo_apontamento && ap.tipo_apontamento !== 'peca') return;
if (ap.componente_id) return;
const marca = ap.peca?.marca ? String(ap.peca.marca).trim() : '';
const fase = ap.peca?.etapa_fase ? String(ap.peca.etapa_fase).trim() : '';
if (marca) {
@@ -0,0 +1,442 @@
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';
import { sincronizarPecaKitNoCorte } from '@/utils/sincronizacaoKitsCorte';
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;
// Sincronizar liberação de kits completos da peça mestre no Corte para a Solda
const resSync = await sincronizarPecaKitNoCorte({
ofNumber,
pecaId: pecaKit.peca_id,
processoCorteId,
userId: user?.id,
usuarioNome: nomeUsuario,
dataApontamento: hoje,
});
smartAudio.playSuccess();
const infoLiberado =
resSync && resSync.delta_liberado > 0
? ` (+${resSync.delta_liberado} peça(s) liberada(s) para Solda)`
: '';
toast.success(
`✅ ${multiplicadorKits} Kit Completo da peça ${pecaKit.marca} cortado com sucesso! (+${pesoTotalKg.toFixed(1)} kg)${infoLiberado}`
);
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;
// Sincronizar se este corte completou novos kits completos (respeitando estritamente o gargalo de todos os componentes)
const resSync = await sincronizarPecaKitNoCorte({
ofNumber,
pecaId: pecaKit.peca_id,
processoCorteId,
userId: user?.id,
usuarioNome: nomeUsuario,
dataApontamento: hoje,
});
const pesoKg = qtd * (comp.peso_unitario || 0);
smartAudio.playSuccess();
if (resSync && resSync.delta_liberado > 0) {
toast.success(
`✂️ Apontado no Corte: ${qtd}x ${comp.marca_componente} (+${pesoKg.toFixed(1)} kg) • ⚡ Kit completo formado: +${resSync.delta_liberado} un de ${pecaKit.marca} liberada(s) para Solda!`
);
} else {
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>
);
};
@@ -90,10 +90,10 @@ export const SmartConsultaRelatoriosFlow: React.FC<SmartConsultaRelatoriosFlowPr
.from('processos_fabricacao')
.select('id, nome, ordem'),
// 3. Apontamentos da OF
// 3. Apontamentos da OF (apenas peças mestres)
supabase
.from('apontamentos_producao')
.select('peca_id, processo_id, quantidade_produzida')
.select('peca_id, processo_id, quantidade_produzida, tipo_apontamento, componente_id')
.eq('of_number', obra.of_number),
// 4. Expedição em Romaneios (APENAS ROMANEIOS ENTREGUES E MAPEANDO POR PECA_ID PARA EVITAR CONFLITO DE FASE)
@@ -132,6 +132,10 @@ export const SmartConsultaRelatoriosFlow: React.FC<SmartConsultaRelatoriosFlowPr
>();
(resApontamentos.data || []).forEach((ap: any) => {
// Ignora apontamentos de componentes para não inflar as quantidades da peça mestre
if (ap.tipo_apontamento && ap.tipo_apontamento !== 'peca') return;
if (ap.componente_id) return;
const pecaId = ap.peca_id;
const nomeProc = mapaNomeProcesso.get(ap.processo_id) || '';
const qtd = Number(ap.quantidade_produzida || 0);
+290 -14
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,10 @@ 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';
import { sincronizarTodosKitsOFCorte } from '@/utils/sincronizacaoKitsCorte';
interface SmartProducaoFlowProps {
obra: OFAtiva;
@@ -58,7 +63,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,15 +74,27 @@ 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;
const kitInfo = mapaKits?.get(peca.id);
const temControleComponentes = Boolean(kitInfo && kitInfo.tem_componentes && (kitInfo.componentes?.length || 0) > 0);
// REGRA 1: Peças sem montagem (S/M) não passam por Solda
if (ehSemMontagem && ehSolda) {
qtdDisponivelParaEntrar = 0;
motivoBloqueio = 'Peça S/M (Pula Solda - vai direto para Pintura)';
} else if (ehSolda && !ehSemMontagem && temControleComponentes && kitInfo) {
// 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!
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;
@@ -94,7 +112,8 @@ export const calcularSaldoProcessoPeca = (
if (procAnteriorValido) {
const keyAnt = `${peca.id}_${procAnteriorValido.id}`;
const qtdApontadaAnterior = mapaProd.get(keyAnt) || 0;
qtdDisponivelParaEntrar = qtdApontadaAnterior;
// Trava de teto: o saldo para entrar jamais pode exceder o total da peça
qtdDisponivelParaEntrar = Math.min(Number(peca.quantidade) || 0, qtdApontadaAnterior);
if (qtdApontadaAnterior === 0) {
motivoBloqueio = `Aguardando ${procAnteriorValido.nome}`;
@@ -127,7 +146,7 @@ export const calcularSaldoProcessoPeca = (
}
}
const saldo = Math.max(0, qtdDisponivelParaEntrar - jaProduzidoAtual);
const saldo = Math.max(0, Math.min(Number(peca.quantidade) || 0, qtdDisponivelParaEntrar) - jaProduzidoAtual);
return {
saldo,
@@ -149,6 +168,8 @@ interface ApontamentoItem {
status_confirmacao?: string;
forcado_por_user_nome?: string;
usuario_nome?: string;
tipo_apontamento?: string;
componente_id?: string | null;
}
export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
@@ -173,12 +194,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);
@@ -190,12 +216,28 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
.eq('of_number', obra.of_number),
supabase
.from('apontamentos_producao')
.select('id, peca_id, processo_id, quantidade_produzida, is_forcado, status_confirmacao, forcado_por_user_nome, usuario_nome')
.select('id, peca_id, processo_id, quantidade_produzida, is_forcado, status_confirmacao, forcado_por_user_nome, usuario_nome, tipo_apontamento, componente_id')
.eq('of_number', obra.of_number),
]);
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);
@@ -224,10 +266,14 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
return lista.length > 0 ? lista : ['Geral'];
}, [pecas]);
// Mapa de produção acumulada por peca_id e processo_id
// Mapa de produção acumulada por peca_id e processo_id (apenas apontamentos de peças mestres)
const mapaProducao = useMemo(() => {
const mapa = new Map<string, number>();
apontamentosExistentes.forEach((ap) => {
// Ignora apontamentos de componentes para não inflar a contagem da peça pai
if (ap.tipo_apontamento && ap.tipo_apontamento !== 'peca') return;
if (ap.componente_id) return;
const key = `${ap.peca_id}_${ap.processo_id}`;
const atual = mapa.get(key) || 0;
mapa.set(key, atual + Number(ap.quantidade_produzida || 0));
@@ -238,7 +284,11 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
// Apontamentos forçados que ainda estão pendentes de confirmação de baixa
const apontamentosPendentes = useMemo(() => {
return apontamentosExistentes.filter(
(ap) => ap.is_forcado && ap.status_confirmacao === 'pendente_confirmacao'
(ap) =>
(!ap.tipo_apontamento || ap.tipo_apontamento === 'peca') &&
!ap.componente_id &&
ap.is_forcado &&
ap.status_confirmacao === 'pendente_confirmacao'
);
}, [apontamentosExistentes]);
@@ -281,6 +331,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 [];
@@ -291,7 +367,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,
@@ -309,7 +385,7 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
if (a.saldo === 0 && b.saldo > 0) return 1;
return a.marca.localeCompare(b.marca, undefined, { numeric: true, sensitivity: 'base' });
});
}, [pecas, faseSelecionada, processoSelecionado, mapaProducao, processos, pecasPendentesMap]);
}, [pecas, faseSelecionada, processoSelecionado, mapaProducao, processos, pecasPendentesMap, mapaKits]);
// Helper de ícone por processo
const getIconeProcesso = (nome: string) => {
@@ -328,6 +404,23 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
smartAudio.playClick();
setProcessoSelecionado(proc);
setEtapaAtual('fase');
// Ao selecionar Solda ou Montagem, rodar sincronização preventiva de kits da OF
const nomeP = (proc.nome || '').toLowerCase();
if ((nomeP.includes('solda') || nomeP.includes('montag')) && procCorteId && obra.of_number) {
sincronizarTodosKitsOFCorte({
ofNumber: obra.of_number,
processoCorteId: procCorteId,
userId: user?.id,
usuarioNome: user?.name || user?.username || 'Operador',
})
.then((res) => {
if (res.some((r) => r.delta_liberado > 0)) {
carregarDadosOF();
}
})
.catch((e) => console.warn('Erro na sincronização preventiva de kits:', e));
}
};
const handleEscolherFase = (fase: string) => {
@@ -388,7 +481,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.`);
@@ -749,6 +842,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">
@@ -794,6 +900,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
@@ -875,7 +985,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>
@@ -893,6 +1003,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">
@@ -916,6 +1088,71 @@ 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">
{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>
<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}
@@ -951,6 +1188,8 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
TODAS ({item.saldo})
</Button>
)}
</>
)}
</div>
</div>
);
@@ -987,6 +1226,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 && pecaKit.componentes.length > 0) {
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>
);
}
@@ -999,7 +1274,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;
@@ -20,6 +20,7 @@ export const SmartViewer3DFlow: React.FC<SmartViewer3DFlowProps> = ({
const [modelData, setModelData] = useState<LoadedModel3DResult | null>(null);
const [phasesList, setPhasesList] = useState<string[]>([]);
const [productionMap, setProductionMap] = useState<Map<string, ProductionPieceStatus>>(new Map());
const [initialRotation, setInitialRotation] = useState<{ x: number; y: number; z: number } | null>(null);
// Loading States
const [isLoading, setIsLoading] = useState<boolean>(true);
@@ -35,10 +36,10 @@ export const SmartViewer3DFlow: React.FC<SmartViewer3DFlowProps> = ({
setLoadingPercent(15);
try {
// 1.1. Buscar registro da OF para obter URL do IFC
// 1.1. Buscar registro da OF para obter URL do IFC e rotação memorizada
const { data: ofData, error: ofError } = await supabase
.from('ordens_fabricacao' as any)
.select('id, num_of, descritivo, ifc_url, ifc_filename')
.select('id, num_of, descritivo, ifc_url, ifc_filename, model_3d_rotation')
.eq('num_of', obra.of_number)
.maybeSingle();
@@ -52,6 +53,9 @@ export const SmartViewer3DFlow: React.FC<SmartViewer3DFlowProps> = ({
const ifcUrl = (ofData as any).ifc_url;
const ifcFilename = (ofData as any).ifc_filename;
if ((ofData as any).model_3d_rotation) {
setInitialRotation((ofData as any).model_3d_rotation);
}
// 1.2. Buscar Peças e Apontamentos em paralelo
setLoadingStep('Carregando dados das peças e apontamentos...');
@@ -65,7 +69,7 @@ export const SmartViewer3DFlow: React.FC<SmartViewer3DFlowProps> = ({
supabase
.from('apontamentos_producao' as any)
.select(`
id, of_number, peca_id, quantidade_produzida,
id, of_number, peca_id, quantidade_produzida, tipo_apontamento, componente_id,
peca:pecas!apontamentos_producao_peca_id_fkey(marca, etapa_fase),
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, cor, ordem)
`)
@@ -87,6 +91,8 @@ export const SmartViewer3DFlow: React.FC<SmartViewer3DFlowProps> = ({
marca: marcaStr,
fase,
etapa_fase: fase,
descricao: p.descricao || '',
perfilPrincipal: p.perfil_principal || p.descricao || '',
totalQtd: Number(p.quantidade || 1),
pointedQtd: 0,
currentProcessName: 'Pendente',
@@ -133,6 +139,10 @@ export const SmartViewer3DFlow: React.FC<SmartViewer3DFlowProps> = ({
);
sortedApontamentos.forEach((ap: any) => {
// Ignora apontamentos de componentes para não inflar as peças mestres
if (ap.tipo_apontamento && ap.tipo_apontamento !== 'peca') return;
if (ap.componente_id) return;
const marca = ap.peca?.marca ? String(ap.peca.marca).trim() : '';
const fase = ap.peca?.etapa_fase ? String(ap.peca.etapa_fase).trim() : '';
if (marca) {
@@ -277,6 +287,7 @@ export const SmartViewer3DFlow: React.FC<SmartViewer3DFlowProps> = ({
selectedOF={obra.of_number}
cliente={obra.cliente}
phasesList={phasesList}
initialRotation={initialRotation}
onVoltar={onVoltarHub}
/>
</div>
+76
View File
@@ -0,0 +1,76 @@
export type NivelRiscoImportacao = 'nenhum' | 'baixo' | 'medio' | 'critico';
export interface DiffCampo {
campo: string;
label: string;
valorAntigo: any;
valorNovo: any;
isCritico: boolean;
}
export interface DiffComponente {
marca_componente: string;
descricao?: string;
perfil?: string;
tipo: 'adicionado' | 'removido' | 'modificado' | 'inalterado';
detalhe?: string;
quantidadeAntiga?: number;
quantidadeNova?: number;
comprimentoAntigo?: number | null;
comprimentoNovo?: number | null;
pesoUnitAntigo?: number | null;
pesoUnitNovo?: number | null;
}
export interface ProcessoApontado {
processoId: string;
processoNome: string;
ordem: number;
quantidadeProduzida: number;
}
export interface ApontamentoResumoPeca {
totalProduzido: number;
processos: ProcessoApontado[];
ultimoProcessoNome?: string;
temApontamento: boolean;
emRomaneio: boolean;
}
export interface ItemAnaliseImportacao {
key: string; // `${of_number}-${etapa_fase}-${marca}`
of_number: string;
etapa_fase: string;
marca: string;
descricao: string;
isNova: boolean;
isIdentica: boolean;
temDivergencias: boolean;
camposDivergentes: DiffCampo[];
componentesDivergentes: DiffComponente[];
dadosImportados: Record<string, any>;
dadosExistentes?: Record<string, any>;
pecaIdExistente?: string;
apontamentoResumo: ApontamentoResumoPeca;
nivelRisco: NivelRiscoImportacao;
// Ações de usuário selecionadas
sobrepor: boolean; // true para atualizar no banco, false para ignorar
atencaoEspecial: boolean; // true se deve gerar ordem de intervenção impressa
motivoAlerta?: string;
acaoRecomendadaFabril?: string;
}
export interface ResultadoAnaliseImportacao {
totalImportadas: number;
totalNovas: number;
totalIdenticas: number;
totalComDivergencia: number;
totalComApontamento: number;
totalCriticas: number;
itens: ItemAnaliseImportacao[];
}
+633
View File
@@ -0,0 +1,633 @@
import { supabase } from '@/integrations/supabase/client';
import {
DiffCampo,
DiffComponente,
ItemAnaliseImportacao,
NivelRiscoImportacao,
ProcessoApontado,
ResultadoAnaliseImportacao,
} from '@/types/importDiff';
interface CampoConfig {
key: string;
label: string;
isCritico: boolean;
isNumeric?: boolean;
}
const CAMPOS_MONITORADOS: CampoConfig[] = [
{ key: 'quantidade', label: 'Quantidade (un)', isCritico: true, isNumeric: true },
{ key: 'comprimento', label: 'Comprimento (mm)', isCritico: true, isNumeric: true },
{ key: 'perfil_principal', label: 'Perfil / Bitola', isCritico: true },
{ key: 'peso_unitario', label: 'Peso Unitário (kg)', isCritico: true, isNumeric: true },
{ key: 'peso_total', label: 'Peso Total (kg)', isCritico: true, isNumeric: true },
{ key: 'material', label: 'Material', isCritico: false },
{ key: 'tratamento_superficial', label: 'Tratamento', isCritico: false },
{ key: 'descricao', label: 'Descrição', isCritico: false },
{ key: 'tem_componentes', label: 'Tem Componentes', isCritico: true },
];
/**
* Função utilitária resiliente para extrair o comprimento numérico de uma peça principal.
* Suporta múltiplos aliases de importação (Bocad, Advance Steel, Excel, CSV) e formatações de string.
*/
export function extrairComprimentoSeguro(item: any): number | null {
if (item === undefined || item === null || item === '' || item === '-') return null;
let raw: any;
if (typeof item === 'number' || typeof item === 'string') {
raw = item;
} else if (typeof item === 'object') {
raw =
item.comprimento ??
item.comprimento_ref ??
item.comprimentoRef ??
item.comprimentoMax ??
item.comprimento_max ??
item.comprimento_peca ??
item['Comprimento'] ??
item['Comprimento (mm)'] ??
item['Comprimento Ref. (mm)'] ??
item['Comprimento Max. (mm)'] ??
item['Comp. (mm)'] ??
item['Comp (mm)'] ??
item.compr ??
item.comp;
}
if (raw === undefined || raw === null || raw === '' || raw === '-') return null;
if (typeof raw === 'number') {
return isNaN(raw) || raw <= 0 ? null : Math.round(raw);
}
// String cleanup: trata formatos como "3.241", "3,241", "6000 mm", "5850.5"
let str = String(raw).trim().replace(/mm/gi, '').trim();
if (str.includes('.') && str.includes(',')) {
if (str.indexOf('.') < str.indexOf(',')) {
str = str.replace(/\./g, '').replace(',', '.');
} else {
str = str.replace(/,/g, '');
}
} else if (str.includes(',')) {
str = str.replace(',', '.');
}
const num = parseFloat(str);
return isNaN(num) || num <= 0 ? null : Math.round(num);
}
/**
* Função utilitária resiliente para extrair o comprimento numérico de subcomponentes.
*/
export function extrairComprimentoComponenteSeguro(item: any): number | null {
if (item === undefined || item === null || item === '' || item === '-') return null;
let raw: any;
if (typeof item === 'number' || typeof item === 'string') {
raw = item;
} else if (typeof item === 'object') {
raw =
item.comprimento_componente ??
item.comprimentoComponente ??
item.comprimento ??
item.comprimento_ref ??
item.comprimentoRef ??
item.compr ??
item.comp ??
item['Comprimento Componente'] ??
item['Comprimento (mm)'] ??
item['Comprimento'] ??
item['Comp. (mm)'];
}
if (raw === undefined || raw === null || raw === '' || raw === '-') return null;
if (typeof raw === 'number') {
return isNaN(raw) || raw <= 0 ? null : Math.round(raw);
}
let str = String(raw).trim().replace(/mm/gi, '').trim();
if (str.includes('.') && str.includes(',')) {
if (str.indexOf('.') < str.indexOf(',')) {
str = str.replace(/\./g, '').replace(',', '.');
} else {
str = str.replace(/,/g, '');
}
} else if (str.includes(',')) {
str = str.replace(',', '.');
}
const num = parseFloat(str);
return isNaN(num) || num <= 0 ? null : Math.round(num);
}
/**
* Normaliza e agrupa linhas de peças e subcomponentes
*/
export function consolidarPecasEComponentes(rawItems: any[]): {
pecasMap: Map<string, any>;
componentesMap: Map<string, any[]>;
} {
const pecasMap = new Map<string, any>();
const componentesMap = new Map<string, any[]>();
rawItems.forEach((item) => {
const ofNumber = String(item.of_number || '').trim();
const etapaFase = String(item.etapa_fase || '1').trim();
const marca = String(item.marca || '').trim();
if (!marca) return;
const pecaKey = `${ofNumber}-${etapaFase}-${marca}`;
const comprimentoFinal = extrairComprimentoSeguro(item);
const pecaData = {
of_number: ofNumber,
etapa_fase: etapaFase,
marca: marca,
descricao: item.descricao || item.perfil_principal || '',
quantidade: Number(item.quantidade) || 0,
peso_unitario: Number(item.peso_unitario) || 0,
peso_total: Number(item.peso_total) || 0,
comprimento: comprimentoFinal,
tratamento_superficial: item.tratamento_superficial || 'pintura',
material: item.material || 'Aço A36',
perfil_principal: item.perfil_principal || item.descricao || '',
tem_componentes: Boolean(item.tem_componentes),
};
if (!pecasMap.has(pecaKey)) {
pecasMap.set(pecaKey, pecaData);
} else if (comprimentoFinal && !pecasMap.get(pecaKey).comprimento) {
// Se a peça já estava no mapa mas sem comprimento e esta linha tem, atualiza
pecasMap.get(pecaKey).comprimento = comprimentoFinal;
}
// Se o item contém um subcomponente
if (item.marca_componente && String(item.marca_componente).trim()) {
const rawMarca = String(item.marca_componente).trim();
let cleanMarca = rawMarca;
const matchSuffix = rawMarca.match(/-?(\d{4,})$/) || rawMarca.match(/(\d{4,})/);
if (matchSuffix) {
cleanMarca = matchSuffix[1];
} else {
const digits = rawMarca.replace(/\D/g, '');
if (digits.length >= 4) {
cleanMarca = digits.slice(-4);
} else {
cleanMarca = String(1000 + (componentesMap.get(pecaKey)?.length || 0));
}
}
let numVal = parseInt(cleanMarca, 10);
if (isNaN(numVal) || numVal < 1000 || numVal > 9999) {
numVal = 1000 + (componentesMap.get(pecaKey)?.length || 0);
}
cleanMarca = String(numVal);
const compComprimento = extrairComprimentoComponenteSeguro(item);
const compData = {
marca_componente: cleanMarca,
descricao: item.descricao_componente || item.descricao || '',
perfil: item.perfil_componente || item.perfil_principal || '',
peso_unitario: Number(item.peso_unitario_componente) || 0,
comprimento: compComprimento,
quantidade_por_peca: Math.max(1, Math.round(Number(item.quantidade_por_peca) || 1)),
};
if (!componentesMap.has(pecaKey)) {
componentesMap.set(pecaKey, []);
}
// Evita duplicatas idênticas no mesmo lote
const comps = componentesMap.get(pecaKey)!;
const exists = comps.some((c) => c.marca_componente === cleanMarca);
if (!exists) {
comps.push(compData);
} else if (compComprimento) {
// Se já existia mas sem comprimento, atualiza
const cExistente = comps.find((c) => c.marca_componente === cleanMarca);
if (cExistente && !cExistente.comprimento) {
cExistente.comprimento = compComprimento;
}
}
}
});
return { pecasMap, componentesMap };
}
/**
* Analisa a lista de importação e cruza com a base de dados
*/
export async function analisarConflitosImportacao(
rawItems: any[]
): Promise<ResultadoAnaliseImportacao> {
const { pecasMap, componentesMap } = consolidarPecasEComponentes(rawItems);
const pecasImportadas = Array.from(pecasMap.values());
if (pecasImportadas.length === 0) {
return {
totalImportadas: 0,
totalNovas: 0,
totalIdenticas: 0,
totalComDivergencia: 0,
totalComApontamento: 0,
totalCriticas: 0,
itens: [],
};
}
// 1. Extrair OFs únicas
const ofsUnicas = Array.from(
new Set(pecasImportadas.map((p) => p.of_number).filter(Boolean))
);
// 2. Buscar peças existentes no Supabase
let pecasExistentes: any[] = [];
if (ofsUnicas.length > 0) {
const { data, error } = await supabase
.from('pecas')
.select('*')
.in('of_number', ofsUnicas);
if (error) {
console.error('❌ Erro ao consultar peças existentes para diff:', error);
} else {
pecasExistentes = data || [];
}
}
// Mapear peças existentes por chave
const existingMap = new Map<string, any>();
const existingIds: string[] = [];
pecasExistentes.forEach((p) => {
const key = `${p.of_number || ''}-${p.etapa_fase || ''}-${p.marca || ''}`;
existingMap.set(key, p);
existingIds.push(p.id);
});
// 3. Buscar componentes existentes
const existingComponentsByPecaId = new Map<string, any[]>();
if (existingIds.length > 0) {
const { data: compData, error: compErr } = await supabase
.from('componentes_peca')
.select('*')
.in('peca_id', existingIds);
if (!compErr && compData) {
compData.forEach((c) => {
if (!existingComponentsByPecaId.has(c.peca_id)) {
existingComponentsByPecaId.set(c.peca_id, []);
}
existingComponentsByPecaId.get(c.peca_id)!.push(c);
});
}
}
// 4. Buscar apontamentos de produção existentes
const apontamentosByPecaId = new Map<string, any[]>();
if (existingIds.length > 0) {
const { data: aptData, error: aptErr } = await supabase
.from('apontamentos_producao')
.select(`
id,
peca_id,
processo_id,
quantidade_produzida,
data_apontamento,
processo:processos_fabricacao(id, nome, ordem)
`)
.in('peca_id', existingIds);
if (!aptErr && aptData) {
aptData.forEach((a) => {
if (!apontamentosByPecaId.has(a.peca_id)) {
apontamentosByPecaId.set(a.peca_id, []);
}
apontamentosByPecaId.get(a.peca_id)!.push(a);
});
}
}
// 5. Buscar romaneios de expedição
const pecaIdsEmRomaneio = new Set<string>();
if (existingIds.length > 0) {
const { data: romData, error: romErr } = await supabase
.from('itens_romaneio_pecas')
.select('peca_id')
.in('peca_id', existingIds);
if (!romErr && romData) {
romData.forEach((r) => pecaIdsEmRomaneio.add(r.peca_id));
}
}
// 6. Construir a análise detalhada item a item
const itensResultado: ItemAnaliseImportacao[] = [];
for (const pecaImportada of pecasImportadas) {
const key = `${pecaImportada.of_number || ''}-${pecaImportada.etapa_fase || ''}-${pecaImportada.marca || ''}`;
const pecaExistente = existingMap.get(key);
const incomingComps = componentesMap.get(key) || [];
if (!pecaExistente) {
// Peça NOVA
itensResultado.push({
key,
of_number: pecaImportada.of_number,
etapa_fase: pecaImportada.etapa_fase,
marca: pecaImportada.marca,
descricao: pecaImportada.descricao || pecaImportada.perfil_principal || '',
isNova: true,
isIdentica: false,
temDivergencias: false,
camposDivergentes: [],
componentesDivergentes: incomingComps.map((c) => ({
marca_componente: c.marca_componente,
descricao: c.descricao,
perfil: c.perfil,
tipo: 'adicionado',
quantidadeNova: c.quantidade_por_peca,
comprimentoNovo: c.comprimento,
detalhe: 'Novo componente na peça',
})),
dadosImportados: pecaImportada,
apontamentoResumo: {
totalProduzido: 0,
processos: [],
temApontamento: false,
emRomaneio: false,
},
nivelRisco: 'nenhum',
sobrepor: true,
atencaoEspecial: false,
});
continue;
}
// Peça EXISTENTE: Comparar campos
const pecaId = pecaExistente.id;
const camposDivergentes: DiffCampo[] = [];
CAMPOS_MONITORADOS.forEach(({ key: campoKey, label, isCritico, isNumeric }) => {
let valAntigo = pecaExistente[campoKey];
let valNovo = pecaImportada[campoKey];
if (isNumeric) {
valAntigo = valAntigo !== null && valAntigo !== undefined ? Number(valAntigo) : null;
valNovo = valNovo !== null && valNovo !== undefined ? Number(valNovo) : null;
if (valAntigo !== null && valNovo !== null) {
if (Math.abs(valAntigo - valNovo) > 0.001) {
camposDivergentes.push({
campo: campoKey,
label,
valorAntigo: valAntigo,
valorNovo: valNovo,
isCritico,
});
}
} else if (valAntigo !== valNovo) {
camposDivergentes.push({
campo: campoKey,
label,
valorAntigo: valAntigo,
valorNovo: valNovo,
isCritico,
});
}
} else {
const strAntigo = String(valAntigo || '').trim().toLowerCase();
const strNovo = String(valNovo || '').trim().toLowerCase();
if (strAntigo !== strNovo) {
camposDivergentes.push({
campo: campoKey,
label,
valorAntigo: pecaExistente[campoKey] || '-',
valorNovo: pecaImportada[campoKey] || '-',
isCritico,
});
}
}
});
// Comparar componentes existentes vs importados
const existingComps = existingComponentsByPecaId.get(pecaId) || [];
const componentesDivergentes: DiffComponente[] = [];
const existingCompsMap = new Map<string, any>();
existingComps.forEach((c) => existingCompsMap.set(String(c.marca_componente).trim(), c));
const incomingCompsMap = new Map<string, any>();
incomingComps.forEach((c) => incomingCompsMap.set(String(c.marca_componente).trim(), c));
// Verificar adicionados ou modificados
incomingComps.forEach((inc) => {
const matchMarca = String(inc.marca_componente).trim();
const ext = existingCompsMap.get(matchMarca);
if (!ext) {
componentesDivergentes.push({
marca_componente: matchMarca,
descricao: inc.descricao,
perfil: inc.perfil,
tipo: 'adicionado',
quantidadeNova: inc.quantidade_por_peca,
comprimentoNovo: inc.comprimento,
detalhe: `Novo componente Pos. ${matchMarca} adicionado`,
});
} else {
const mudouQtd = Number(ext.quantidade_por_peca || 1) !== Number(inc.quantidade_por_peca || 1);
const mudouCompr =
ext.comprimento !== null && inc.comprimento !== null
? Math.abs(Number(ext.comprimento) - Number(inc.comprimento)) > 0.1
: ext.comprimento !== inc.comprimento;
const mudouPerfil =
String(ext.perfil || '').trim().toLowerCase() !== String(inc.perfil || '').trim().toLowerCase();
if (mudouQtd || mudouCompr || mudouPerfil) {
const detalhes: string[] = [];
if (mudouQtd) detalhes.push(`Qtd: ${ext.quantidade_por_peca} ➔ ${inc.quantidade_por_peca}`);
if (mudouCompr) detalhes.push(`Compr: ${ext.comprimento || 0} ➔ ${inc.comprimento || 0} mm`);
if (mudouPerfil) detalhes.push(`Perfil: ${ext.perfil} ➔ ${inc.perfil}`);
componentesDivergentes.push({
marca_componente: matchMarca,
descricao: inc.descricao || ext.descricao,
perfil: inc.perfil || ext.perfil,
tipo: 'modificado',
quantidadeAntiga: ext.quantidade_por_peca,
quantidadeNova: inc.quantidade_por_peca,
comprimentoAntigo: ext.comprimento,
comprimentoNovo: inc.comprimento,
detalhe: detalhes.join(' | '),
});
}
}
});
// Verificar componentes removidos
existingComps.forEach((ext) => {
const matchMarca = String(ext.marca_componente).trim();
if (!incomingCompsMap.has(matchMarca)) {
componentesDivergentes.push({
marca_componente: matchMarca,
descricao: ext.descricao,
perfil: ext.perfil,
tipo: 'removido',
quantidadeAntiga: ext.quantidade_por_peca,
comprimentoAntigo: ext.comprimento,
detalhe: `Componente Pos. ${matchMarca} removido na nova lista`,
});
}
});
// Analisar Apontamentos Fabris
const apontamentosPeca = apontamentosByPecaId.get(pecaId) || [];
const processosMap = new Map<string, ProcessoApontado>();
apontamentosPeca.forEach((apt) => {
const procNome = apt.processo?.nome || 'Geral';
const procOrdem = apt.processo?.ordem || 99;
const procId = apt.processo_id;
if (!processosMap.has(procId)) {
processosMap.set(procId, {
processoId: procId,
processoNome: procNome,
ordem: procOrdem,
quantidadeProduzida: 0,
});
}
processosMap.get(procId)!.quantidadeProduzida += Number(apt.quantidade_produzida || 0);
});
const processosApontados = Array.from(processosMap.values()).sort(
(a, b) => a.ordem - b.ordem
);
const totalProduzidoMax = processosApontados.reduce(
(max, p) => Math.max(max, p.quantidadeProduzida),
0
);
const ultimoProc =
processosApontados.length > 0
? processosApontados[processosApontados.length - 1].processoNome
: undefined;
const temApontamento = apontamentosPeca.length > 0;
const emRomaneio = pecaIdsEmRomaneio.has(pecaId);
const temDivergencias = camposDivergentes.length > 0 || componentesDivergentes.length > 0;
const isIdentica = !temDivergencias;
// Calcular nível de risco
let nivelRisco: NivelRiscoImportacao = 'nenhum';
let motivoAlerta = '';
let acaoRecomendadaFabril = '';
if (temDivergencias) {
const temAlteracaoCritica =
camposDivergentes.some((c) => c.isCritico) || componentesDivergentes.length > 0;
if (!temApontamento && !emRomaneio) {
nivelRisco = 'baixo';
motivoAlerta = 'Peça ainda não iniciada na fábrica. Atualização cadastral limpa.';
acaoRecomendadaFabril = 'Nenhuma ação necessária no galpão.';
} else {
// Já tem apontamento ou romaneio
if (emRomaneio) {
nivelRisco = 'critico';
motivoAlerta = `ALERTA MÁXIMO: Peça já foi expedida ou carregada em romaneio!`;
acaoRecomendadaFabril =
'Acionar PCP e Engenharia de Obra imediatamente para conferência do lote expedido.';
} else if (temAlteracaoCritica) {
nivelRisco = 'critico';
const etapaStr = ultimoProc ? `no setor de ${ultimoProc.toUpperCase()}` : 'na produção';
motivoAlerta = `Peça em andamento ${etapaStr} (${totalProduzidoMax} un produzidas) com alterações físicas/dimensionais.`;
const compReduzido = camposDivergentes.find(
(c) => c.campo === 'comprimento' && Number(c.valorNovo) < Number(c.valorAntigo)
);
const compAumentado = camposDivergentes.find(
(c) => c.campo === 'comprimento' && Number(c.valorNovo) > Number(c.valorAntigo)
);
const qtdReduzida = camposDivergentes.find(
(c) => c.campo === 'quantidade' && Number(c.valorNovo) < Number(c.valorAntigo)
);
if (compReduzido) {
acaoRecomendadaFabril = `Retornar peça para corte/serra: comprimento deve ser reduzido em ${Math.abs(
Number(compReduzido.valorAntigo) - Number(compReduzido.valorNovo)
)} mm.`;
} else if (compAumentado) {
acaoRecomendadaFabril = `ATENÇÃO: Peça cresceu ${
Number(compAumentado.valorNovo) - Number(compAumentado.valorAntigo)
} mm. Descartar/reaproveitar corte antigo e cortar nova barra.`;
} else if (qtdReduzida) {
acaoRecomendadaFabril = `Redução de lote: verificar se excedente já fabricado deve ser estornado ou enviado para estoque de peças prontas.`;
} else if (componentesDivergentes.length > 0) {
acaoRecomendadaFabril = `Interromper solda/pintura: verificar adição/remoção de componentes na montagem antes de avançar.`;
} else {
acaoRecomendadaFabril = `Inspecionar peça na bancada de trabalho perante os novos dados técnicos.`;
}
} else {
nivelRisco = 'medio';
motivoAlerta = 'Alteração apenas cadastral/superficial em peça com apontamento.';
acaoRecomendadaFabril = 'Verificar etiquetas e conferir especificação na pintura.';
}
}
}
// Configura ações padrão
itensResultado.push({
key,
of_number: pecaImportada.of_number,
etapa_fase: pecaImportada.etapa_fase,
marca: pecaImportada.marca,
descricao: pecaImportada.descricao || pecaExistente.descricao || '',
isNova: false,
isIdentica,
temDivergencias,
camposDivergentes,
componentesDivergentes,
dadosImportados: pecaImportada,
dadosExistentes: pecaExistente,
pecaIdExistente: pecaId,
apontamentoResumo: {
totalProduzido: totalProduzidoMax,
processos: processosApontados,
ultimoProcessoNome: ultimoProc,
temApontamento,
emRomaneio,
},
nivelRisco,
sobrepor: temDivergencias, // Sobrepor marcado por padrão se tem divergência
atencaoEspecial: nivelRisco === 'critico', // Atenção especial marcada automaticamente se crítico
motivoAlerta,
acaoRecomendadaFabril,
});
}
// Ordenar: críticas primeiro, depois com divergência, depois novas, depois idênticas
itensResultado.sort((a, b) => {
const pesoRisco = { critico: 4, medio: 3, baixo: 2, nenhum: 1 };
const diffRisco = pesoRisco[b.nivelRisco] - pesoRisco[a.nivelRisco];
if (diffRisco !== 0) return diffRisco;
if (b.temDivergencias !== a.temDivergencias) return b.temDivergencias ? 1 : -1;
return a.marca.localeCompare(b.marca, undefined, { numeric: true });
});
return {
totalImportadas: itensResultado.length,
totalNovas: itensResultado.filter((i) => i.isNova).length,
totalIdenticas: itensResultado.filter((i) => i.isIdentica).length,
totalComDivergencia: itensResultado.filter((i) => i.temDivergencias).length,
totalComApontamento: itensResultado.filter((i) => i.apontamentoResumo.temApontamento).length,
totalCriticas: itensResultado.filter((i) => i.nivelRisco === 'critico').length,
itens: itensResultado,
};
}
+255
View File
@@ -0,0 +1,255 @@
import { supabase } from '@/integrations/supabase/client';
import { ItemAnaliseImportacao } from '@/types/importDiff';
import { extrairComprimentoSeguro } from '@/utils/importDiffService';
export interface ExecucaoImportacaoParams {
itens: ItemAnaliseImportacao[];
componentesMap: Map<string, any[]>;
userId: string;
userNome?: string;
userEmail?: string;
motivoRevisao?: string;
}
export interface ResultadoExecucaoImportacao {
sucesso: boolean;
totalInseridas: number;
totalAtualizadas: number;
totalIgnoradas: number;
totalComAtencaoEspecial: number;
itensAtencaoEspecial: ItemAnaliseImportacao[];
mensagem: string;
}
/**
* Executa a importação conciliada respeitando as decisões tomadas pelo usuário no modal.
* Preserva 100% dos IDs existentes, garantindo que nenhum apontamento de produção ou romaneio seja afetado.
*/
export async function executarImportacaoConciliada(
params: ExecucaoImportacaoParams
): Promise<ResultadoExecucaoImportacao> {
const {
itens,
componentesMap,
userId,
userNome = 'Operador',
userEmail = '',
motivoRevisao = 'Atualização de projeto via importação de lista de peças',
} = params;
console.log('🚀 Executando importação conciliada para', itens.length, 'itens analisados');
let totalInseridas = 0;
let totalAtualizadas = 0;
let totalIgnoradas = 0;
const itensAtencaoEspecial: ItemAnaliseImportacao[] = [];
try {
// 1. Separar itens por ação
const pecasNovas = itens.filter((i) => i.isNova && i.sobrepor);
const pecasParaAtualizar = itens.filter(
(i) => !i.isNova && i.sobrepor && (i.temDivergencias || i.isIdentica)
);
const pecasIgnoradas = itens.filter((i) => !i.sobrepor);
totalIgnoradas = pecasIgnoradas.length;
// 2. Atualizar peças existentes selecionadas mantendo o UUID
for (const item of pecasParaAtualizar) {
if (!item.pecaIdExistente) continue;
const pecaId = item.pecaIdExistente;
const importado = item.dadosImportados;
const existente = item.dadosExistentes || {};
// Se é idêntica e estava marcada, apenas pula a escrita desnecessária
if (item.isIdentica) {
continue;
}
// Se marcada para atenção especial, memorizar para a lista impressa
if (item.atencaoEspecial) {
itensAtencaoEspecial.push(item);
}
const qtdFinal = Number(importado.quantidade || existente.quantidade || 0);
const pesoUnitFinal = Number(importado.peso_unitario || existente.peso_unitario || 0);
const pesoTotalCalculado = Number((qtdFinal * pesoUnitFinal).toFixed(3)) || Number(importado.peso_total || 0);
const comprFinal = extrairComprimentoSeguro(importado) ?? extrairComprimentoSeguro(existente);
const payloadUpdate: any = {
descricao: importado.descricao || existente.descricao || '',
quantidade: qtdFinal,
peso_unitario: pesoUnitFinal,
peso_total: pesoTotalCalculado,
comprimento: comprFinal,
tratamento_superficial: importado.tratamento_superficial || existente.tratamento_superficial || 'pintura',
material: importado.material || existente.material || 'Aço A36',
perfil_principal: importado.perfil_principal || existente.perfil_principal || '',
tem_componentes: importado.tem_componentes !== undefined ? importado.tem_componentes : existente.tem_componentes,
updated_at: new Date().toISOString(),
};
const { error: updateErr } = await supabase
.from('pecas')
.update(payloadUpdate)
.eq('id', pecaId);
if (updateErr) {
console.error(`❌ Erro ao atualizar peça ${item.marca}:`, updateErr);
continue;
}
totalAtualizadas++;
// Atualizar componentes da peça existente
const novosComponentes = componentesMap.get(item.key) || [];
if (novosComponentes.length > 0) {
// Limpar componentes antigos e inserir os novos
await supabase.from('componentes_peca').delete().eq('peca_id', pecaId);
const componentesFormatados = novosComponentes.map((comp) => ({
...comp,
peca_id: pecaId,
user_id: userId,
}));
await supabase.from('componentes_peca').insert(componentesFormatados);
} else if (item.camposDivergentes.some((c) => c.campo === 'tem_componentes' && !c.valorNovo)) {
// Peça virou simples sem componentes: limpar antigos
await supabase.from('componentes_peca').delete().eq('peca_id', pecaId);
}
// Registrar Histórico de Revisão na tabela 'revisoes' se houve divergência
if (item.temDivergencias) {
const camposAlterados = item.camposDivergentes.map((c) => c.campo);
if (item.componentesDivergentes.length > 0) {
camposAlterados.push('componentes');
}
const proxNumeroRevisao = existente.revisao_atual
? `R${(Number(existente.revisao_atual.replace(/\D/g, '')) || 0) + 1}`
: 'R1';
try {
await supabase.from('revisoes' as any).insert({
of_number: item.of_number,
peca_id: pecaId,
numero_revisao: proxNumeroRevisao,
tipo_revisao: item.nivelRisco === 'critico' ? 'critica' : 'simples',
descricao_motivo: motivoRevisao,
observacoes: item.motivoAlerta || item.acaoRecomendadaFabril || null,
usuario_id: userId || null,
usuario_nome: userNome,
usuario_email: userEmail,
dados_anteriores: existente,
dados_novos: importado,
campos_alterados: camposAlterados,
afeta_apontamentos: item.apontamentoResumo.temApontamento,
impacto_processos: {
divergencias: item.camposDivergentes.map((c) => ({
campo: c.label,
antes: c.valorAntigo,
depois: c.valorNovo,
})),
componentes_alterados: item.componentesDivergentes,
ultimo_processo_afetado: item.apontamentoResumo.ultimoProcessoNome || null,
total_produzido: item.apontamentoResumo.totalProduzido,
atencao_especial_marcada: item.atencaoEspecial,
},
data_revisao: new Date().toISOString(),
});
} catch (revError) {
console.warn('Aviso: Não foi possível gravar histórico em revisoes:', revError);
}
}
}
// 3. Inserir peças novas
if (pecasNovas.length > 0) {
const pecasParaInsert = pecasNovas.map((p) => {
const imp = p.dadosImportados;
const qtd = Number(imp.quantidade) || 0;
const pesoU = Number(imp.peso_unitario) || 0;
const pesoT = Number(imp.peso_total) || Number((qtd * pesoU).toFixed(3));
const comprFinal = extrairComprimentoSeguro(imp);
return {
of_number: imp.of_number,
etapa_fase: imp.etapa_fase,
marca: imp.marca,
descricao: imp.descricao || imp.perfil_principal || '',
quantidade: qtd,
peso_unitario: pesoU,
peso_total: pesoT,
comprimento: comprFinal,
tratamento_superficial: imp.tratamento_superficial || 'pintura',
material: imp.material || 'Aço A36',
perfil_principal: imp.perfil_principal || '',
tem_componentes: Boolean(imp.tem_componentes),
user_id: userId,
prioridade: 'P4',
};
});
const { data: pecasInseridas, error: insertError } = await supabase
.from('pecas')
.insert(pecasParaInsert)
.select('id, of_number, etapa_fase, marca');
if (insertError) {
console.error('❌ Erro ao inserir peças novas:', insertError);
throw insertError;
}
if (pecasInseridas) {
totalInseridas = pecasInseridas.length;
// Inserir componentes das peças novas
const componentesParaInsert: any[] = [];
pecasInseridas.forEach((p) => {
const key = `${p.of_number || ''}-${p.etapa_fase || ''}-${p.marca || ''}`;
const comps = componentesMap.get(key) || [];
comps.forEach((c) => {
componentesParaInsert.push({
...c,
peca_id: p.id,
user_id: userId,
});
});
});
if (componentesParaInsert.length > 0) {
await supabase.from('componentes_peca').insert(componentesParaInsert);
}
}
}
let mensagem = `✅ Importação concluída!`;
if (totalInseridas > 0) mensagem += ` ${totalInseridas} nova(s) peça(s) inserida(s).`;
if (totalAtualizadas > 0) mensagem += ` ${totalAtualizadas} peça(s) sobreposta(s)/atualizada(s).`;
if (totalIgnoradas > 0) mensagem += ` ${totalIgnoradas} peça(s) mantida(s) no cadastro original.`;
return {
sucesso: true,
totalInseridas,
totalAtualizadas,
totalIgnoradas,
totalComAtencaoEspecial: itensAtencaoEspecial.length,
itensAtencaoEspecial,
mensagem,
};
} catch (error: any) {
console.error('❌ Falha na execução da importação conciliada:', error);
return {
sucesso: false,
totalInseridas: 0,
totalAtualizadas: 0,
totalIgnoradas: 0,
totalComAtencaoEspecial: 0,
itensAtencaoEspecial: [],
mensagem: `Erro na importação: ${error?.message || 'Falha de comunicação com o banco'}`,
};
}
}
+334
View File
@@ -0,0 +1,334 @@
import { supabase } from '@/integrations/supabase/client';
export interface ComponenteNecessario {
id: string;
quantidade_por_peca: number;
marca_componente?: string;
}
export interface ResultadoSincronizacaoPeca {
peca_id: string;
marca: string;
quantidade_total: number;
kits_completos: number;
ja_apontado_anteriormente: number;
delta_liberado: number;
}
/**
* Calcula estritamente a quantidade de kits completos cortados para uma peça mestre.
* Respeita a regra do GARGALO (bottleneck):
* Se um ou mais componentes têm corte para N peças, mas qualquer outro componente
* tem corte para apenas M peças (onde M < N), APENAS M peças são consideradas completas.
* A sobra (N - M) NÃO é liberada para Solda!
*/
export function calcularKitsCompletosPeca(
pecaQuantidade: number,
componentes: ComponenteNecessario[],
cortadoPorComponenteId: Map<string, number>
): { kitsCompletos: number; menorComponenteId?: string; razaoPorComp: Map<string, number> } {
const qtdTotal = Number(pecaQuantidade) || 0;
if (qtdTotal <= 0 || !componentes || componentes.length === 0) {
return { kitsCompletos: 0, razaoPorComp: new Map() };
}
let menorKits = qtdTotal;
let menorComponenteId: string | undefined = undefined;
const razaoPorComp = new Map<string, number>();
for (const comp of componentes) {
const cortado = cortadoPorComponenteId.get(comp.id) || 0;
const porPeca = Math.max(1, Number(comp.quantidade_por_peca) || 1);
const kitsDeste = Math.floor(cortado / porPeca);
razaoPorComp.set(comp.id, kitsDeste);
if (kitsDeste < menorKits) {
menorKits = kitsDeste;
menorComponenteId = comp.id;
}
}
const kitsCompletos = Math.max(0, Math.min(qtdTotal, menorKits));
return { kitsCompletos, menorComponenteId, razaoPorComp };
}
/**
* Sincroniza uma peça específica:
* Verifica o corte de todos os seus componentes, calcula os kits 100% completos,
* e gera o apontamento da peça mestre no processo de Corte para o delta liberado.
*/
export async function sincronizarPecaKitNoCorte(params: {
ofNumber: string;
pecaId: string;
processoCorteId: string;
userId?: string;
usuarioNome?: string;
dataApontamento?: string;
}): Promise<ResultadoSincronizacaoPeca | null> {
const { ofNumber, pecaId, processoCorteId, userId, usuarioNome, dataApontamento } = params;
try {
// 1. Buscar a peça mestre
const { data: peca, error: pecaError } = await supabase
.from('pecas')
.select('id, marca, quantidade, of_number, tem_componentes')
.eq('id', pecaId)
.single();
if (pecaError || !peca) {
console.warn('Peça não encontrada para sincronização de kit:', pecaId);
return null;
}
// Se for peça S/M (sem componentes), ela não depende de kit
if (peca.tem_componentes === false) {
return null;
}
// 2. Buscar componentes cadastrados para esta peça
const { data: comps, error: compsError } = await supabase
.from('componentes_peca')
.select('id, marca_componente, quantidade_por_peca')
.eq('peca_id', pecaId);
if (compsError || !comps || comps.length === 0) {
return null;
}
// 3. Buscar todos os apontamentos de componentes já feitos para essa peça no processo de Corte
const { data: aptsComp, error: aptsCompError } = await supabase
.from('apontamentos_producao')
.select('componente_id, quantidade_produzida')
.eq('peca_id', pecaId)
.eq('processo_id', processoCorteId)
.eq('tipo_apontamento', 'componente');
if (aptsCompError) {
console.error('Erro ao buscar apontamentos de componente:', aptsCompError);
return null;
}
const cortadoPorComp = new Map<string, number>();
(aptsComp || []).forEach((a) => {
if (a.componente_id) {
cortadoPorComp.set(
a.componente_id,
(cortadoPorComp.get(a.componente_id) || 0) + Number(a.quantidade_produzida || 0)
);
}
});
// 4. Calcular kits completos estritos
const { kitsCompletos } = calcularKitsCompletosPeca(peca.quantidade, comps, cortadoPorComp);
// 5. Buscar quanto já foi apontado como peça mestre no corte
const { data: aptsPeca, error: aptsPecaError } = await supabase
.from('apontamentos_producao')
.select('quantidade_produzida')
.eq('peca_id', pecaId)
.eq('processo_id', processoCorteId)
.eq('tipo_apontamento', 'peca');
if (aptsPecaError) {
console.error('Erro ao buscar apontamentos de peça mestre:', aptsPecaError);
return null;
}
const totalJaApontadoPeca = (aptsPeca || []).reduce(
(sum, a) => sum + Number(a.quantidade_produzida || 0),
0
);
const delta = kitsCompletos - totalJaApontadoPeca;
// 6. Se houver delta positivo de kits completos formados, registrar apontamento no Corte
if (delta > 0) {
const hoje = new Date().toISOString().split('T')[0];
const payload = {
of_number: peca.of_number,
peca_id: peca.id,
processo_id: processoCorteId,
tipo_apontamento: 'peca' as const,
quantidade_produzida: delta,
data_apontamento: dataApontamento || hoje,
created_by: userId || null,
usuario_nome: usuarioNome || 'Sistema (Liberação de Kits)',
observacoes: `Liberação automática de Kit 100% cortado (${kitsCompletos}/${peca.quantidade} un)`,
status_confirmacao: 'confirmado',
is_forcado: false,
};
const { error: insertError } = await supabase.from('apontamentos_producao').insert(payload);
if (insertError) {
console.error('Erro ao inserir apontamento da peça mestre no corte:', insertError);
throw insertError;
}
console.log(`✅ Peça ${peca.marca}: +${delta} kit(s) completos liberados para Solda (${kitsCompletos}/${peca.quantidade})`);
}
return {
peca_id: peca.id,
marca: peca.marca,
quantidade_total: peca.quantidade,
kits_completos: kitsCompletos,
ja_apontado_anteriormente: totalJaApontadoPeca,
delta_liberado: Math.max(0, delta),
};
} catch (error) {
console.error('Erro ao sincronizar kits da peça no corte:', error);
return null;
}
}
/**
* Sincroniza em lote todas as peças com componentes de uma OF.
* Útil ao entrar no processo de Solda ou ao finalizar apontamentos em lote.
*/
export async function sincronizarTodosKitsOFCorte(params: {
ofNumber: string;
processoCorteId: string;
userId?: string;
usuarioNome?: string;
}): Promise<ResultadoSincronizacaoPeca[]> {
const { ofNumber, processoCorteId, userId, usuarioNome } = params;
try {
// 1. Buscar peças com componentes da OF
const { data: pecas, error: pecasError } = await supabase
.from('pecas')
.select('id, marca, quantidade, of_number, tem_componentes')
.eq('of_number', ofNumber);
if (pecasError || !pecas || pecas.length === 0) return [];
const pecasComComponentes = pecas.filter((p) => p.tem_componentes !== false);
if (pecasComComponentes.length === 0) return [];
const pecaIds = pecasComComponentes.map((p) => p.id);
// 2. Buscar componentes de todas as peças
const { data: todosComponentes, error: compsError } = await supabase
.from('componentes_peca')
.select('id, peca_id, marca_componente, quantidade_por_peca')
.in('peca_id', pecaIds);
if (compsError || !todosComponentes || todosComponentes.length === 0) return [];
const compsPorPeca = new Map<string, ComponenteNecessario[]>();
todosComponentes.forEach((c) => {
if (!compsPorPeca.has(c.peca_id)) {
compsPorPeca.set(c.peca_id, []);
}
compsPorPeca.get(c.peca_id)!.push({
id: c.id,
quantidade_por_peca: c.quantidade_por_peca,
marca_componente: c.marca_componente,
});
});
// 3. Buscar todos os apontamentos de componentes no processo de Corte
const { data: todosAptsComp, error: aptsCompError } = await supabase
.from('apontamentos_producao')
.select('peca_id, componente_id, quantidade_produzida')
.eq('of_number', ofNumber)
.eq('processo_id', processoCorteId)
.eq('tipo_apontamento', 'componente');
if (aptsCompError) {
console.error('Erro ao buscar apontamentos de componentes da OF:', aptsCompError);
return [];
}
const cortadoPorPecaEComp = new Map<string, Map<string, number>>();
(todosAptsComp || []).forEach((a) => {
if (a.peca_id && a.componente_id) {
if (!cortadoPorPecaEComp.has(a.peca_id)) {
cortadoPorPecaEComp.set(a.peca_id, new Map());
}
const mapComp = cortadoPorPecaEComp.get(a.peca_id)!;
mapComp.set(
a.componente_id,
(mapComp.get(a.componente_id) || 0) + Number(a.quantidade_produzida || 0)
);
}
});
// 4. Buscar apontamentos de peças mestres existentes no Corte
const { data: todosAptsPeca, error: aptsPecaError } = await supabase
.from('apontamentos_producao')
.select('peca_id, quantidade_produzida')
.eq('of_number', ofNumber)
.eq('processo_id', processoCorteId)
.eq('tipo_apontamento', 'peca');
if (aptsPecaError) {
console.error('Erro ao buscar apontamentos de peça da OF:', aptsPecaError);
return [];
}
const jaApontadoPorPeca = new Map<string, number>();
(todosAptsPeca || []).forEach((a) => {
if (a.peca_id) {
jaApontadoPorPeca.set(
a.peca_id,
(jaApontadoPorPeca.get(a.peca_id) || 0) + Number(a.quantidade_produzida || 0)
);
}
});
// 5. Identificar deltas e preparar inserções em lote
const hoje = new Date().toISOString().split('T')[0];
const insertsNovos: any[] = [];
const resultados: ResultadoSincronizacaoPeca[] = [];
for (const peca of pecasComComponentes) {
const comps = compsPorPeca.get(peca.id) || [];
if (comps.length === 0) continue;
const cortados = cortadoPorPecaEComp.get(peca.id) || new Map();
const { kitsCompletos } = calcularKitsCompletosPeca(peca.quantidade, comps, cortados);
const jaApontado = jaApontadoPorPeca.get(peca.id) || 0;
const delta = kitsCompletos - jaApontado;
if (delta > 0) {
insertsNovos.push({
of_number: peca.of_number,
peca_id: peca.id,
processo_id: processoCorteId,
tipo_apontamento: 'peca',
quantidade_produzida: delta,
data_apontamento: hoje,
created_by: userId || null,
usuario_nome: usuarioNome || 'Sistema (Liberação de Kits)',
observacoes: `Liberação automática de Kit 100% cortado (${kitsCompletos}/${peca.quantidade} un)`,
status_confirmacao: 'confirmado',
is_forcado: false,
});
}
resultados.push({
peca_id: peca.id,
marca: peca.marca,
quantidade_total: peca.quantidade,
kits_completos: kitsCompletos,
ja_apontado_anteriormente: jaApontado,
delta_liberado: Math.max(0, delta),
});
}
if (insertsNovos.length > 0) {
const { error: insertError } = await supabase.from('apontamentos_producao').insert(insertsNovos);
if (insertError) {
console.error('Erro ao sincronizar peças mestre em lote:', insertError);
} else {
console.log(`✅ Sincronização de kits concluída: ${insertsNovos.length} peças atualizadas no Corte.`);
}
}
return resultados;
} catch (err) {
console.error('Erro na sincronização geral de kits da OF:', err);
return [];
}
}
+247 -50
View File
@@ -1,31 +1,54 @@
import { supabase } from '@/integrations/supabase/client';
import { ItemPendenciaProducao } from '@/components/expedicao/PendenciasProducaoModal';
export interface ProcessoFaltanteItem {
processo_id: string;
nome: string;
ordem: number;
qtdFaltante: number;
qtdFeita: number;
qtdNecessaria: number;
}
export interface PecaAnaliseRomaneio {
peca_id: string;
marca: string;
fase?: string;
quantidade_expedida: number;
tem_componentes: boolean;
processosFaltantes: ProcessoFaltanteItem[];
liberada: boolean;
qtdJaExpedida: number;
}
export interface ResultadoValidacaoEntrega {
valido: boolean;
mensagem?: string;
pendencias?: ItemPendenciaProducao[];
pecasAnalise?: PecaAnaliseRomaneio[];
totalItens?: number;
totalLiberadas?: number;
totalPendentes?: number;
}
/**
* Valida se um romaneio pode ser marcado com o status "Entregue".
* Verifica se todas as peças cadastradas no romaneio foram devidamente apontadas
* em todos os processos de fabricação anteriores à Expedição (Detalhamento, Corte, Solda - quando aplicável -, Pintura/Galv).
* Valida e analisa se um romaneio pode ser marcado com o status "Entregue".
* Identifica para cada peça do romaneio se todos os processos anteriores foram concluídos,
* e detalha com precisão cirúrgica quais processos estão faltantes.
*/
export async function validarRomaneioParaEntrega(
romaneioId?: string,
ofNumber?: string
): Promise<ResultadoValidacaoEntrega> {
// Se não tem ID do romaneio (está criando um novo romaneio no momento)
if (!romaneioId) {
return {
valido: false,
mensagem: 'Não é possível criar um novo romaneio diretamente com o status "Entregue". Crie o romaneio primeiro em planejamento, adicione as peças e garanta que todos os processos anteriores foram apontados na produção.'
mensagem: 'Não é possível criar um novo romaneio diretamente com o status "Entregue". Crie o romaneio primeiro em planejamento, adicione as peças e confirme a entrega.'
};
}
try {
// 1. Buscar as peças cadastradas no romaneio
// 1. Buscar peças cadastradas no romaneio
const { data: itens, error: itensError } = await supabase
.from('itens_romaneio_pecas')
.select('id, peca_id, marca, quantidade_expedida, fase')
@@ -61,10 +84,10 @@ export async function validarRomaneioParaEntrega(
};
}
// Encontrar o processo de Expedição (ordem 5 ou nome contendo 'expedi')
// Processo de Expedição (ordem 5 ou nome contendo 'expedi')
const processoExpedicao = processos.find(p =>
p.nome?.toLowerCase().includes('expedi') || p.ordem === 5
) || { ordem: 5 };
) || { id: '', nome: 'Expedicao', ordem: 5 };
// Processos anteriores que precisam estar concluídos
const processosAnteriores = processos.filter(p => p.ordem < processoExpedicao.ordem);
@@ -73,7 +96,7 @@ export async function validarRomaneioParaEntrega(
const pecaIds = Array.from(new Set(itens.map(i => i.peca_id).filter(Boolean)));
const { data: pecasInfo, error: pecasError } = await supabase
.from('pecas')
.select('id, marca, tem_componentes')
.select('id, marca, tem_componentes, fase')
.in('id', pecaIds);
if (pecasError) {
@@ -82,10 +105,10 @@ export async function validarRomaneioParaEntrega(
const pecasMap = new Map((pecasInfo || []).map(p => [p.id, p]));
// 4. Buscar apontamentos de produção existentes para essas peças
// 4. Buscar apontamentos existentes para essas peças
let queryApontamentos = supabase
.from('apontamentos_producao')
.select('peca_id, processo_id, quantidade_produzida')
.select('peca_id, processo_id, quantidade_produzida, tipo_apontamento')
.in('peca_id', pecaIds);
if (ofNumber) {
@@ -98,10 +121,13 @@ export async function validarRomaneioParaEntrega(
console.error('❌ Erro ao buscar apontamentos:', aptError);
}
// Mapear quantidades apontadas: peca_id -> processo_id -> soma(quantidade_produzida)
// Mapear quantidades apontadas por peça mestre: peca_id -> processo_id -> soma(quantidade_produzida)
const apontamentosMap = new Map<string, Map<string, number>>();
(apontamentos || []).forEach(apt => {
// Ignorar apontamento de subcomponente para não inflar a contagem da peça mestre
if (apt.tipo_apontamento && apt.tipo_apontamento !== 'peca') return;
if (!apt.peca_id || !apt.processo_id) return;
if (!apontamentosMap.has(apt.peca_id)) {
apontamentosMap.set(apt.peca_id, new Map<string, number>());
}
@@ -110,58 +136,73 @@ export async function validarRomaneioParaEntrega(
procMap.set(apt.processo_id, atual + Number(apt.quantidade_produzida || 0));
});
// 5. Consolidar quantidade total exigida por peça no romaneio
const somaQtdRomaneioPorPeca = new Map<string, { marca: string; totalQtd: number }>();
itens.forEach(item => {
const atual = somaQtdRomaneioPorPeca.get(item.peca_id) || {
marca: item.marca,
totalQtd: 0
};
atual.totalQtd += Number(item.quantidade_expedida || 0);
somaQtdRomaneioPorPeca.set(item.peca_id, atual);
});
// 6. Verificar conformidade em todos os processos anteriores
// 5. Analisar cada item do romaneio
const pendencias: ItemPendenciaProducao[] = [];
const pecasAnalise: PecaAnaliseRomaneio[] = [];
for (const [pecaId, { marca, totalQtd }] of somaQtdRomaneioPorPeca.entries()) {
const peca = pecasMap.get(pecaId);
const temComponentes = peca?.tem_componentes ?? false;
const procMap = apontamentosMap.get(pecaId) || new Map<string, number>();
itens.forEach(item => {
const peca = pecasMap.get(item.peca_id);
const temComp = peca?.tem_componentes ?? false;
const procMap = apontamentosMap.get(item.peca_id) || new Map<string, number>();
const qtdRomaneio = Number(item.quantidade_expedida || 0);
for (const processo of processosAnteriores) {
// Se for Solda e a peça NÃO tem componentes (peça solta), ela não passa por solda
const isSolda = processo.ordem === 3 || processo.nome.toLowerCase().includes('solda');
if (isSolda && !temComponentes) {
const processosFaltantes: ProcessoFaltanteItem[] = [];
for (const proc of processosAnteriores) {
// Se for Solda e a peça NÃO tem componentes (peça simples sem montagem), não passa por solda
const isSolda = proc.ordem === 3 || proc.nome.toLowerCase().includes('solda');
if (isSolda && !temComp) {
continue;
}
const qtdApontada = procMap.get(processo.id) || 0;
const qtdFeita = procMap.get(proc.id) || 0;
if (qtdFeita < qtdRomaneio) {
const qtdFaltante = Math.max(0, qtdRomaneio - qtdFeita);
processosFaltantes.push({
processo_id: proc.id,
nome: proc.nome,
ordem: proc.ordem,
qtdNecessaria: qtdRomaneio,
qtdFeita,
qtdFaltante
});
if (qtdApontada < totalQtd) {
pendencias.push({
peca_id: pecaId,
marca: marca || peca?.marca || 'Peça',
processo: processo.nome,
ordem: processo.ordem,
qtdNecessaria: totalQtd,
qtdApontada,
qtdFaltante: Math.max(0, totalQtd - qtdApontada)
peca_id: item.peca_id,
marca: item.marca || peca?.marca || 'Peça',
processo: proc.nome,
ordem: proc.ordem,
qtdNecessaria: qtdRomaneio,
qtdApontada: qtdFeita,
qtdFaltante
});
}
}
}
if (pendencias.length > 0) {
console.warn(`⚠️ Romaneio ${romaneioId} possui ${pendencias.length} pendência(s) de processos anteriores:`, pendencias);
return {
valido: false,
pendencias
};
}
const qtdJaExpedida = procMap.get(processoExpedicao.id) || 0;
pecasAnalise.push({
peca_id: item.peca_id,
marca: item.marca || peca?.marca || 'Peça',
fase: item.fase || peca?.fase || '-',
quantidade_expedida: qtdRomaneio,
tem_componentes: temComp,
processosFaltantes,
liberada: processosFaltantes.length === 0,
qtdJaExpedida
});
});
const totalLiberadas = pecasAnalise.filter(p => p.liberada).length;
const totalPendentes = pecasAnalise.filter(p => !p.liberada).length;
return {
valido: true
valido: pendencias.length === 0,
pendencias,
pecasAnalise,
totalItens: pecasAnalise.length,
totalLiberadas,
totalPendentes
};
} catch (err: any) {
console.error('❌ Exceção ao validar romaneio para entrega:', err);
@@ -171,3 +212,159 @@ export async function validarRomaneioParaEntrega(
};
}
}
/**
* Executa o apontamento automático para o romaneio entregue:
* - Grava na Data Real informada pelo usuário.
* - Suporta apontamento em cascata nos processos anteriores pendentes (se autorizado pelo usuário).
* - Registra apontamento oficial no processo de Expedição.
* - Atualiza o status do romaneio para "Entregue".
*/
export async function executarApontamentoRomaneioEntregue({
romaneioId,
ofNumber,
numeroRomaneio,
dataEntregaReal,
autorizarCascata = false,
apontarApenasLiberadas = false,
userId
}: {
romaneioId: string;
ofNumber: string;
numeroRomaneio: string;
dataEntregaReal: string;
autorizarCascata?: boolean;
apontarApenasLiberadas?: boolean;
userId?: string;
}): Promise<{ sucesso: boolean; mensagem: string; totalApontamentos: number; pecasProcessadas: string[] }> {
try {
const analise = await validarRomaneioParaEntrega(romaneioId, ofNumber);
if (!analise.pecasAnalise || analise.pecasAnalise.length === 0) {
throw new Error(analise.mensagem || 'Nenhuma peça encontrada no romaneio');
}
// Buscar processo de Expedição
const { data: processos } = await supabase
.from('processos_fabricacao')
.select('id, nome, ordem')
.eq('ativo', true)
.order('ordem');
const processoExpedicao = processos?.find(p =>
p.nome?.toLowerCase().includes('expedi') || p.ordem === 5
);
if (!processoExpedicao) {
throw new Error('Processo Expedição não encontrado');
}
const apontamentosParaInserir: any[] = [];
const pecasProcessadas: string[] = [];
const obsOrigem = `Apontamento via Romaneio ${numeroRomaneio} (ID: ${romaneioId})`;
for (const peca of analise.pecasAnalise) {
// Cenário 1: Peça com processos anteriores pendentes
if (!peca.liberada) {
if (autorizarCascata) {
// Criar apontamento para cada processo faltante
for (const faltante of peca.processosFaltantes) {
apontamentosParaInserir.push({
of_number: ofNumber,
peca_id: peca.peca_id,
processo_id: faltante.processo_id,
quantidade_produzida: faltante.qtdFaltante,
data_apontamento: dataEntregaReal,
observacoes: `Apontamento em cascata (${faltante.nome}) - ${obsOrigem}`,
tipo_apontamento: 'peca',
created_by: userId
});
}
// Criar apontamento de Expedição
apontamentosParaInserir.push({
of_number: ofNumber,
peca_id: peca.peca_id,
processo_id: processoExpedicao.id,
quantidade_produzida: peca.quantidade_expedida,
data_apontamento: dataEntregaReal,
observacoes: `Apontamento de expedição - ${obsOrigem}`,
tipo_apontamento: 'peca',
created_by: userId
});
pecasProcessadas.push(`${peca.marca} (${peca.quantidade_expedida} un - com cascata)`);
} else if (apontarApenasLiberadas) {
// Pular peça pendente
continue;
} else {
// Não autorizado e não pediu para apontar parciais
continue;
}
} else {
// Cenário 2: Peça 100% liberada nos processos anteriores
apontamentosParaInserir.push({
of_number: ofNumber,
peca_id: peca.peca_id,
processo_id: processoExpedicao.id,
quantidade_produzida: peca.quantidade_expedida,
data_apontamento: dataEntregaReal,
observacoes: `Apontamento de expedição - ${obsOrigem}`,
tipo_apontamento: 'peca',
created_by: userId
});
pecasProcessadas.push(`${peca.marca} (${peca.quantidade_expedida} un)`);
}
}
if (apontamentosParaInserir.length === 0) {
return {
sucesso: false,
mensagem: 'Nenhum apontamento foi inserido pois não houve peças autorizadas para expedição.',
totalApontamentos: 0,
pecasProcessadas: []
};
}
// Inserir os apontamentos na tabela oficial
const { error: insertError } = await supabase
.from('apontamentos_producao')
.insert(apontamentosParaInserir);
if (insertError) {
console.error('❌ Erro ao inserir apontamentos:', insertError);
throw new Error(`Falha ao registrar apontamentos: ${insertError.message}`);
}
// Atualizar o status do romaneio para "Entregue"
await supabase
.from('romaneios_expedicao')
.update({
status: 'Entregue',
updated_at: new Date().toISOString()
})
.eq('id', romaneioId);
// Registrar log em logs_apontamentos_automaticos
await supabase
.from('logs_apontamentos_automaticos')
.insert({
romaneio_id: romaneioId,
numero_romaneio: numeroRomaneio,
of_number: ofNumber,
total_pecas_processadas: pecasProcessadas.length,
status_operacao: 'sucesso',
usuario_executou: userId || null
});
return {
sucesso: true,
mensagem: `${apontamentosParaInserir.length} apontamento(s) realizado(s) com sucesso na data ${dataEntregaReal}!`,
totalApontamentos: apontamentosParaInserir.length,
pecasProcessadas
};
} catch (err: any) {
console.error('❌ Erro na execução de apontamentos do romaneio:', err);
throw err;
}
}
+3 -2
View File
@@ -26,8 +26,9 @@ else
git commit -m "$MSG"
fi
echo -e "${CYAN}📤 Enviando alterações para o GitHub (origin main)...${NC}"
git push origin main
echo -e "${CYAN}📤 Enviando alterações para o repositório (gitea & origin)...${NC}"
git push gitea main || true
git push origin main || true
# 2. Build local do Frontend
echo -e "\n${YELLOW}🔨 Executando build de produção local (npm run build)...${NC}"