Compare commits

...

13 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
30 changed files with 4078 additions and 725 deletions
-2
View File
@@ -1,2 +0,0 @@
// Script temporário de auditoria da OF B134
export {};
+124 -67
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(() => {
@@ -136,21 +136,26 @@ export const ApontamentoForm = () => {
let qtdDisponivelParaEntrar = Number(peca.quantidade) || 0;
// 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:
// Peças compostas são produzidas através do corte de seus subcomponentes.
// Ao atingir 100% dos componentes cortados, o kit é concluído e a peça mestre é liberada para Solda.
if (isCorte && peca.tem_componentes) {
// 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 (!peca.tem_componentes && procNomeLower.includes('solda')) {
// Regra: peças sem componentes não passam por solda
} 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;
@@ -161,18 +166,25 @@ export const ApontamentoForm = () => {
.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);
// Fallback de retrocompatibilidade para peças com componentes cortados antes da automação
if (peca.tem_componentes && qtdApontadaAnterior === 0 && processoAnteriorValido.nome.toLowerCase().includes('corte')) {
const compsDaPeca = componentesAgrupados?.filter(c => c.peca_ids?.includes(peca.id)) || [];
if (compsDaPeca.length > 0) {
const todosCortados = compsDaPeca.every(c => {
const aptsComp = apontamentos
.filter(a => a.tipo_apontamento === 'componente' && c.componente_ids.includes(a.componente_id || '') && a.processo_id === processoAnteriorValido.id)
// 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);
return aptsComp >= c.quantidade_total;
const porPeca = Math.max(1, Number(comp.quantidade_por_peca) || 1);
const kitsDeste = Math.floor(cortado / porPeca);
if (kitsDeste < menorKits) {
menorKits = kitsDeste;
}
});
if (todosCortados) {
qtdApontadaAnterior = Number(peca.quantidade) || 0;
const kitsCompletosCalculados = Math.max(0, Math.min(Number(peca.quantidade) || 0, menorKits));
if (kitsCompletosCalculados > qtdApontadaAnterior) {
qtdApontadaAnterior = kitsCompletosCalculados;
}
}
}
@@ -281,23 +293,12 @@ 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}`
};
const result = await registrarItemComDistribuicao(
item,
item.quantidade_disponivel,
`Registro em lote - ${tipoTexto}`
);
if (item.tipo === 'componente') {
apontamentoData.componente_id = item.id;
} else {
apontamentoData.peca_id = item.id;
}
const result = await criarApontamento(apontamentoData);
if (result.success) {
sucessos++;
} else {
@@ -359,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) => {
@@ -385,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');
@@ -398,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!`);
@@ -461,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)}
@@ -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
item={item}
isSelected={itemSelecionado?.id === item.id}
/>
{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>
<ItemDisponivelCard
key={item.id}
item={item}
isSelected={itemSelecionado?.id === item.id}
isAdmin={isAdmin}
onSelect={handleItemSelect}
onAdminAction={handleAdminAction}
onApontarItemDireto={onApontarItemDireto}
/>
))}
{pecasOrdenadas.length === 0 && (
<div className="text-center text-muted-foreground py-8">
@@ -645,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>
);
};
@@ -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>
);
};
+124 -30
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">
<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"
>
<Edit2 className="h-3 w-3" />
</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"
>
<Trash2 className="h-3 w-3" />
</Button>
{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-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.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoverItem(item.id)}
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.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'}`}>
<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"
>
<Edit2 className="h-3 w-3" />
</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"
>
<Trash2 className="h-3 w-3" />
</Button>
{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-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.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoverItem(item.id)}
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.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>
+34 -51
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,33 +117,22 @@ 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
return;
}
// Abre o modal inteligente de confirmação de entrega
setModalConfirmacaoAberto(true);
return;
}
setFormData(prev => ({ ...prev, status: novoStatus }));
@@ -153,24 +141,16 @@ 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.');
return;
}
// 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)}
/>
)}
</>
);
};
+45 -76
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,7 +379,7 @@ interface InsertApontamentoData {
throw error;
}
// REGRA DO KIT COMPLETO (Passo 3 + Opção 1):
// 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!
@@ -390,82 +391,17 @@ interface InsertApontamentoData {
: true;
if (isProcessoCorte) {
const pecaId = insertData.peca_id;
// 1. Buscar todos os componentes requeridos da peça pai
const { data: compsPeca } = await supabase
.from('componentes_peca')
.select('id, quantidade_por_peca')
.eq('peca_id', pecaId);
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
});
// 2. Buscar a peça pai
const { data: pecaPai } = await supabase
.from('pecas')
.select('id, marca, quantidade, of_number')
.eq('id', pecaId)
.single();
if (pecaPai && compsPeca && compsPeca.length > 0) {
// 3. Buscar todos os apontamentos de componentes já feitos para essa peça pai neste processo de corte
const { data: todosAptsComp } = await supabase
.from('apontamentos_producao')
.select('componente_id, quantidade_produzida')
.eq('peca_id', pecaId)
.eq('processo_id', insertData.processo_id)
.eq('tipo_apontamento', 'componente');
const cortadoPorComp = new Map<string, number>();
(todosAptsComp || []).forEach(a => {
if (a.componente_id) {
cortadoPorComp.set(a.componente_id, (cortadoPorComp.get(a.componente_id) || 0) + Number(a.quantidade_produzida || 0));
}
});
let kitsCompletos = Number(pecaPai.quantidade) || 0;
for (const comp of compsPeca) {
const cortado = cortadoPorComp.get(comp.id) || 0;
const porPeca = Number(comp.quantidade_por_peca) || 1;
const kitsDeste = Math.floor(cortado / porPeca);
if (kitsDeste < kitsCompletos) {
kitsCompletos = kitsDeste;
}
}
// 4. Verificar quanto já foi apontado como 'peca' mestre no corte
const { data: aptsPecaJaRegistrados } = await supabase
.from('apontamentos_producao')
.select('quantidade_produzida')
.eq('peca_id', pecaId)
.eq('processo_id', insertData.processo_id)
.eq('tipo_apontamento', 'peca');
const totalPecaJaApontada = (aptsPecaJaRegistrados || []).reduce(
(sum, a) => sum + Number(a.quantidade_produzida || 0), 0
);
// 5. Se há novos kits completos, registrar apontamento da peça mestre no Corte
const qtdLiberar = kitsCompletos - totalPecaJaApontada;
if (qtdLiberar > 0) {
const { error: errPeca } = await supabase
.from('apontamentos_producao')
.insert({
of_number: pecaPai.of_number,
peca_id: pecaPai.id,
processo_id: insertData.processo_id,
tipo_apontamento: 'peca',
quantidade_produzida: qtdLiberar,
data_apontamento: insertData.data_apontamento,
observacoes: `Liberação automática de Kit 100% cortado (${kitsCompletos}/${pecaPai.quantidade} un)`,
created_by: user.id
});
if (!errPeca) {
console.log(`✅ Kit 100% completo! ${qtdLiberar} un da peça mestre ${pecaPai.marca} registradas no Corte.`);
toast.success(`Kit 100% cortado! ${qtdLiberar} un da peça ${pecaPai.marca} liberada(s) para Solda.`);
} else {
console.warn('Aviso ao registrar liberação automática de peça:', errPeca);
}
}
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) {
@@ -602,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,
@@ -611,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,
+35 -6
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.peca_pai_info += `, ${pecaPai.marca}`;
grupo.quantidade_total += qtdNecessaria;
if (!grupo.peca_pai_info.includes(pecaPai.marca)) {
grupo.peca_pai_info += `, ${pecaPai.marca}`;
}
grupo.componente_ids.push(comp.id);
grupo.peca_ids.push(comp.peca_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
};
};
+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);
}
+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
)
`)
.eq('id', user.id)
.single();
`);
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
)
`)
.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,18 +91,20 @@ export function useUserPermissions() {
}
// Load specific resource permissions
const { data: resourcePerms } = await supabase
.from('user_interface_permissions')
.select('resource_key, permission')
.eq('user_id', user.id);
if (resolvedUserId) {
const { data: resourcePerms } = await supabase
.from('user_interface_permissions')
.select('resource_key, permission')
.eq('user_id', resolvedUserId);
if (resourcePerms) {
const permsMap = resourcePerms.reduce((acc, perm) => {
acc[perm.resource_key] = perm.permission as PermissionLevel;
return acc;
}, {} as Record<string, PermissionLevel>);
setResourcePermissions(permsMap);
console.log('✅ Resource permissions loaded:', permsMap);
if (resourcePerms) {
const permsMap = resourcePerms.reduce((acc, perm) => {
acc[perm.resource_key] = perm.permission as PermissionLevel;
return acc;
}, {} as Record<string, PermissionLevel>);
setResourcePermissions(permsMap);
console.log('✅ Resource permissions loaded:', permsMap);
}
}
} catch (error) {
@@ -80,7 +112,7 @@ export function useUserPermissions() {
} 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);
+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">
+6 -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)
`)
@@ -188,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);
+323 -47
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,40 +1088,107 @@ export const SmartProducaoFlow: React.FC<SmartProducaoFlowProps> = ({
{/* Botões de Ação no Card */}
<div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-2">
<Button
type="button"
disabled={concluida || item.saldo <= 0}
onClick={() => handleIniciarApontamentoPeca(item, item.saldo)}
className={`sm:col-span-2 h-13 text-sm font-black rounded-xl uppercase tracking-wider transition-all active:scale-[0.98] ${
concluida
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-500 dark:border-slate-700 cursor-not-allowed'
: item.saldo <= 0
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700 cursor-not-allowed'
: 'bg-amber-500 hover:bg-amber-400 active:bg-amber-600 text-slate-950 shadow-md shadow-amber-900/20 dark:shadow-amber-950/30'
}`}
>
{concluida
? 'Processo Concluído'
: item.saldo <= 0
? item.motivoBloqueio || 'Aguardando processo anterior'
: `DIGITAR / ESCOLHER QUANTIDADE`}
</Button>
{ehCorte && temComponentes && kitInfo ? (
<>
<Button
type="button"
onClick={() => {
smartAudio.playClick();
setPecaParaCorteModal(kitInfo);
}}
className="sm:col-span-2 h-13 text-xs sm:text-sm font-black rounded-xl uppercase tracking-wider bg-orange-600 hover:bg-orange-500 active:bg-orange-700 text-white shadow-md shadow-orange-950/20 active:scale-95 transition-all flex items-center justify-center gap-2"
title="Apontar componentes individuais cortados ou cortar 1 kit completo"
>
<Scissors className="w-4 h-4 shrink-0" />
<span>Cortar Componentes ({kitInfo.componentes.length} itens)</span>
</Button>
{!concluida && item.saldo > 0 && (
<Button
type="button"
onClick={() => {
smartAudio.playClick();
setPecaSelecionada(item);
setQtdApontar(item.saldo);
setModoEdicao(false);
setEtapaAtual('quantidade');
}}
className="h-13 bg-amber-50 hover:bg-amber-100 active:bg-amber-200 text-amber-800 border border-amber-300 dark:bg-slate-700 dark:hover:bg-slate-650 dark:active:bg-slate-600 dark:text-amber-400 dark:border-slate-600 text-xs font-black rounded-xl uppercase active:scale-95 transition-colors"
title="Apontar todo o saldo disponível de uma vez"
>
TODAS ({item.saldo})
</Button>
<Button
type="button"
disabled={concluida || item.saldo <= 0}
onClick={() => handleIniciarApontamentoPeca(item, item.saldo)}
className="h-13 bg-amber-50 hover:bg-amber-100 active:bg-amber-200 text-amber-800 border border-amber-300 dark:bg-slate-700 dark:hover:bg-slate-650 dark:active:bg-slate-600 dark:text-amber-400 dark:border-slate-600 text-xs font-black rounded-xl uppercase active:scale-95 transition-colors disabled:opacity-50"
title="Apontar peça mestre inteira"
>
Peça Cheia ({item.saldo})
</Button>
</>
) : ehSolda && temComponentes && kitInfo ? (
<>
<Button
type="button"
disabled={concluida || item.saldo <= 0}
onClick={() => handleIniciarApontamentoPeca(item, item.saldo)}
className={`sm:col-span-2 h-13 text-xs sm:text-sm font-black rounded-xl uppercase tracking-wider transition-all active:scale-[0.98] ${
concluida
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-500 dark:border-slate-700 cursor-not-allowed'
: item.saldo <= 0
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700 cursor-not-allowed'
: 'bg-amber-500 hover:bg-amber-400 active:bg-amber-600 text-slate-950 shadow-md shadow-amber-900/20 dark:shadow-amber-950/30'
}`}
>
{concluida
? 'Processo Concluído'
: item.saldo <= 0
? (item.motivoBloqueio || 'Aguardando kits completos no Corte')
: `⚡ SOLDAR (${item.saldo} KITS LIBERADOS)`}
</Button>
{!concluida && item.saldo > 0 && (
<Button
type="button"
onClick={() => {
smartAudio.playClick();
setPecaSelecionada(item);
setQtdApontar(item.saldo);
setModoEdicao(false);
setEtapaAtual('quantidade');
}}
className="h-13 bg-amber-50 hover:bg-amber-100 active:bg-amber-200 text-amber-800 border border-amber-300 dark:bg-slate-700 dark:hover:bg-slate-650 dark:active:bg-slate-600 dark:text-amber-400 dark:border-slate-600 text-xs font-black rounded-xl uppercase active:scale-95 transition-colors"
title="Apontar todos os kits disponíveis de uma vez"
>
TODAS ({item.saldo})
</Button>
)}
</>
) : (
<>
<Button
type="button"
disabled={concluida || item.saldo <= 0}
onClick={() => handleIniciarApontamentoPeca(item, item.saldo)}
className={`sm:col-span-2 h-13 text-sm font-black rounded-xl uppercase tracking-wider transition-all active:scale-[0.98] ${
concluida
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-500 dark:border-slate-700 cursor-not-allowed'
: item.saldo <= 0
? 'bg-slate-100 text-slate-400 border border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700 cursor-not-allowed'
: 'bg-amber-500 hover:bg-amber-400 active:bg-amber-600 text-slate-950 shadow-md shadow-amber-900/20 dark:shadow-amber-950/30'
}`}
>
{concluida
? 'Processo Concluído'
: item.saldo <= 0
? item.motivoBloqueio || 'Aguardando processo anterior'
: `DIGITAR / ESCOLHER QUANTIDADE`}
</Button>
{!concluida && item.saldo > 0 && (
<Button
type="button"
onClick={() => {
smartAudio.playClick();
setPecaSelecionada(item);
setQtdApontar(item.saldo);
setModoEdicao(false);
setEtapaAtual('quantidade');
}}
className="h-13 bg-amber-50 hover:bg-amber-100 active:bg-amber-200 text-amber-800 border border-amber-300 dark:bg-slate-700 dark:hover:bg-slate-650 dark:active:bg-slate-600 dark:text-amber-400 dark:border-slate-600 text-xs font-black rounded-xl uppercase active:scale-95 transition-colors"
title="Apontar todo o saldo disponível de uma vez"
>
TODAS ({item.saldo})
</Button>
)}
</>
)}
</div>
</div>
@@ -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;
@@ -69,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)
`)
@@ -139,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) {
+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;
}
}