diff --git a/server/controllers/oriontardproController.js b/server/controllers/oriontardproController.js index 2260bc1..9096b2c 100644 --- a/server/controllers/oriontardproController.js +++ b/server/controllers/oriontardproController.js @@ -24,6 +24,7 @@ const STATUS_OS = new Set([ 'EM_EXECUCAO', 'EM_TESTES', 'FINALIZADA', + 'AGUARDANDO_RETIRADA', 'RETIRADA', 'CANCELADA' ]); @@ -177,6 +178,27 @@ function handleRouteError(req, res, error) { return sendError(req, res, error, status, message); } +function parsePagination(query = {}) { + const page = Math.max( + 1, + Number.parseInt(query.page, 10) || 1 + ); + + const pageSize = Math.min( + 200, + Math.max( + 1, + Number.parseInt(query.pageSize, 10) || 50 + ) + ); + + return { + page, + pageSize, + offset: (page - 1) * pageSize + }; +} + function positiveInteger(value, fieldName, nullable = false) { if ((value === null || value === undefined || value === '') && nullable) { return null; @@ -728,14 +750,87 @@ var routes = function () { // ========================================================== router.get('/getAllLogs', async function (req, res) { try { + // A dashboard precisa enxergar o registro bruto independentemente da + // versão do JSON. A view histórica continua sendo usada somente para + // expor dataLog/filtro temporal; o conteúdo do campo log é normalizado + // abaixo para manter compatibilidade entre os schemas antigos e o v2. + const mesesSolicitados = Number.parseInt(req.query.meses, 10); + const meses = Number.isFinite(mesesSolicitados) + ? Math.min(12, Math.max(1, mesesSolicitados)) + : 3; + + // dataLog não é coluna física de orionTardProLogs. Ela é exposta + // pela view histórica vw_oriontard_logs. Mantemos a view somente como + // fonte SQL para paginação/filtro temporal e fazemos toda a + // compatibilidade de schema 1/2 abaixo, a partir do campo log bruto. const { results } = await dbQuery(` SELECT * FROM vw_oriontard_logs - WHERE dataLog > DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 3 MONTH) + WHERE dataLog > DATE_SUB(CURRENT_TIMESTAMP, INTERVAL ${meses} MONTH) ORDER BY dataLog DESC; `); - return sendSuccess(res, results); + const normalizados = results.map(row => { + let parsed = null; + let jsonValido = false; + + try { + parsed = typeof row.log === 'string' ? JSON.parse(row.log) : row.log; + if (typeof parsed === 'string') { + parsed = JSON.parse(parsed); + } + jsonValido = !!parsed && typeof parsed === 'object'; + } catch (error) { + parsed = null; + } + + const latitudeJson = parsed ? Number(parsed.latitude) : NaN; + const longitudeJson = parsed ? Number(parsed.longitude) : NaN; + + // Os campos latitude/longitude da tabela possuem inversão histórica + // por compatibilidade. Para JSON válido, o próprio payload é a fonte + // de verdade. Para legado inválido, preservamos o fallback invertido. + const latitude = Number.isFinite(latitudeJson) + ? latitudeJson + : Number(row.longitude); + const longitude = Number.isFinite(longitudeJson) + ? longitudeJson + : Number(row.latitude); + + const dataOperacao = parsed + ? (parsed.periodoFim || parsed.data || row.dataLog) + : row.dataLog; + + return { + ...row, + nome: (parsed && (parsed.usuario || parsed.cpf)) || row.cpf || 'Não identificado', + perfil: (parsed && parsed.perfil) || row.perfil || 'Sem perfil', + coordenadas: Number.isFinite(latitude) && Number.isFinite(longitude) + ? `${latitude},${longitude}` + : '', + dataOperacao, + schemaVersao: parsed && parsed.schemaVersao !== undefined + ? Number(parsed.schemaVersao) + : null, + tipoEvento: parsed ? (parsed.tipoEvento || null) : null, + periodoInicio: parsed ? (parsed.periodoInicio || null) : null, + periodoFim: parsed ? (parsed.periodoFim || null) : null, + jsonValido + }; + }); + + // dataLog representa a inserção no banco. Para logs reenviados após + // ficar offline, a data real da operação pode ser anterior. Ordenamos + // pela data do payload quando ela existe e usamos dataLog como fallback. + normalizados.sort((a, b) => { + const dataA = new Date(a.dataOperacao || a.dataLog).getTime(); + const dataB = new Date(b.dataOperacao || b.dataLog).getTime(); + const valorA = Number.isFinite(dataA) ? dataA : 0; + const valorB = Number.isFinite(dataB) ? dataB : 0; + return valorB - valorA; + }); + + return sendSuccess(res, normalizados); } catch (error) { return handleRouteError(req, res, error); } @@ -821,6 +916,551 @@ var routes = function () { } }); + // ========================================================== + // PRONTUARIO DOS EQUIPAMENTOS + // ========================================================== + router.get('/getEquipamentosHistorico', async function (req, res) { + try { + const { page, pageSize, offset } = parsePagination(req.query); + const where = ['e.ativo = 1']; + const params = []; + + if (req.query.busca) { + const busca = `%${String(req.query.busca).trim()}%`; + where.push(`( + e.numero_serie LIKE ? OR + e.codigo_interno LIKE ? OR + e.nome LIKE ? OR + e.versao LIKE ? OR + m.nome LIKE ? OR + c.nome_fantasia LIKE ? + )`); + params.push(busca, busca, busca, busca, busca, busca); + } + + if (req.query.modelo_id) { + where.push('e.modelo_id = ?'); + params.push(positiveInteger(req.query.modelo_id, 'Modelo')); + } + + if (req.query.modelo) { + where.push('m.nome LIKE ?'); + params.push(`%${String(req.query.modelo).trim()}%`); + } + + if (req.query.cliente_id) { + where.push('e.cliente_id = ?'); + params.push(positiveInteger(req.query.cliente_id, 'Cliente')); + } + + if (req.query.versao) { + where.push('e.versao LIKE ?'); + params.push(`%${String(req.query.versao).trim()}%`); + } + + if (req.query.possui_os_aberta !== undefined) { + const possuiOsAberta = String(req.query.possui_os_aberta).toLowerCase(); + if (!['true', 'false', '1', '0'].includes(possuiOsAberta)) { + throw createHttpError(400, 'Filtro de OS aberta invalido.'); + } + + where.push(`${['true', '1'].includes(possuiOsAberta) ? '' : 'NOT '}EXISTS ( + SELECT 1 + FROM manut_ordens_servico os_aberta + WHERE os_aberta.equipamento_id = e.id + AND os_aberta.excluido_em IS NULL + AND os_aberta.status NOT IN ('RASCUNHO', 'RETIRADA', 'CANCELADA') + )`); + } + + if (req.query.status_ultima_os) { + const statusUltimaOs = enumValue( + req.query.status_ultima_os, + STATUS_OS, + 'Status da ultima OS' + ); + where.push(`( + SELECT os_status.status + FROM manut_ordens_servico os_status + WHERE os_status.equipamento_id = e.id + AND os_status.excluido_em IS NULL + ORDER BY os_status.data_entrada DESC, os_status.id DESC + LIMIT 1 + ) = ?`); + params.push(statusUltimaOs); + } + + const whereSql = where.join('\n AND '); + const { results: countRows } = await dbQuery( + ` + SELECT COUNT(*) AS total + FROM manut_equipamentos e + LEFT JOIN manut_equipamento_modelos m ON m.id = e.modelo_id + LEFT JOIN manut_clientes c ON c.id = e.cliente_id + WHERE ${whereSql}; + `, + params + ); + + const listParams = [...params, pageSize, offset]; + const { results } = await dbQuery( + ` + SELECT + e.id, + e.cliente_id, + e.modelo_id, + e.codigo_interno, + e.nome, + e.numero_serie, + e.versao, + e.odometro_total_segundos, + e.odometro_conectado_segundos, + e.odometro_movimento_segundos, + e.odometro_parcial_segundos, + e.odometro_atualizado_em, + m.nome AS modelo_nome, + c.nome_fantasia AS cliente_nome, + COALESCE(resumo.quantidade_os, 0) AS quantidade_os, + COALESCE(resumo.quantidade_os_abertas, 0) AS quantidade_os_abertas, + COALESCE(resumo.valor_mao_obra, 0) AS valor_mao_obra_historico, + COALESCE(resumo.valor_insumos, 0) AS valor_insumos_historico, + COALESCE(resumo.valor_total, 0) AS valor_total_historico, + COALESCE(resumo.valor_pago, 0) AS valor_pago_historico, + COALESCE(resumo.valor_pendente, 0) AS valor_pendente_historico, + ultima_os.id AS ultima_os_id, + ultima_os.status AS ultima_os_status, + ultima_os.tipo AS ultima_os_tipo, + ultima_os.data_entrada AS ultima_os_data_entrada, + ultima_os.data_finalizacao AS ultima_os_data_finalizacao, + ultima_os.data_retirada AS ultima_os_data_retirada + FROM manut_equipamentos e + LEFT JOIN manut_equipamento_modelos m ON m.id = e.modelo_id + LEFT JOIN manut_clientes c ON c.id = e.cliente_id + LEFT JOIN ( + SELECT + equipamento_id, + COUNT(*) AS quantidade_os, + SUM(status NOT IN ('RASCUNHO', 'RETIRADA', 'CANCELADA')) AS quantidade_os_abertas, + SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN valor_mao_obra ELSE 0 END) AS valor_mao_obra, + SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN valor_insumos ELSE 0 END) AS valor_insumos, + SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN valor_total ELSE 0 END) AS valor_total, + SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN valor_pago ELSE 0 END) AS valor_pago, + SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN GREATEST(valor_total - valor_pago, 0) ELSE 0 END) AS valor_pendente + FROM manut_ordens_servico + WHERE excluido_em IS NULL + GROUP BY equipamento_id + ) resumo ON resumo.equipamento_id = e.id + LEFT JOIN manut_ordens_servico ultima_os + ON ultima_os.id = ( + SELECT os_ultima.id + FROM manut_ordens_servico os_ultima + WHERE os_ultima.equipamento_id = e.id + AND os_ultima.excluido_em IS NULL + ORDER BY os_ultima.data_entrada DESC, os_ultima.id DESC + LIMIT 1 + ) + WHERE ${whereSql} + ORDER BY e.nome, e.numero_serie + LIMIT ? OFFSET ?; + `, + listParams + ); + + return sendSuccess(res, { + items: results, + pagination: { + page, + pageSize, + total: Number(countRows[0].total), + totalPages: Math.ceil(Number(countRows[0].total) / pageSize) + } + }); + } catch (error) { + return handleRouteError(req, res, error); + } + }); + + router.get('/getEquipamentoProntuario/:equipamentoId', async function (req, res) { + try { + const equipamentoId = positiveInteger(req.params.equipamentoId, 'Equipamento'); + + const [ + { results: equipamentos }, + { results: totaisRows }, + { results: ultimaOsRows }, + { results: leiturasManutencao } + ] = await Promise.all([ + dbQuery( + ` + SELECT + e.*, + m.nome AS modelo_nome, + c.nome_fantasia AS cliente_nome, + c.razao_social AS cliente_razao_social + FROM manut_equipamentos e + LEFT JOIN manut_equipamento_modelos m ON m.id = e.modelo_id + LEFT JOIN manut_clientes c ON c.id = e.cliente_id + WHERE e.id = ? + AND e.ativo = 1 + LIMIT 1; + `, + [equipamentoId] + ), + dbQuery( + ` + SELECT + COUNT(*) AS quantidade_os, + SUM(status NOT IN ('RASCUNHO', 'RETIRADA', 'CANCELADA')) AS quantidade_os_abertas, + SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN 1 ELSE 0 END) AS quantidade_os_efetivas, + SUM(CASE WHEN tipo = 'CORRETIVA' AND status NOT IN ('RASCUNHO', 'CANCELADA') THEN 1 ELSE 0 END) AS quantidade_corretivas, + SUM(CASE WHEN tipo = 'PREVENTIVA' AND status NOT IN ('RASCUNHO', 'CANCELADA') THEN 1 ELSE 0 END) AS quantidade_preventivas, + COALESCE(SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN total_horas_expediente ELSE 0 END), 0) AS total_horas_expediente, + COALESCE(SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN total_horas_extras ELSE 0 END), 0) AS total_horas_extras, + COALESCE(SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN valor_mao_obra ELSE 0 END), 0) AS valor_mao_obra, + COALESCE(SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN custo_insumos ELSE 0 END), 0) AS custo_insumos, + COALESCE(SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN valor_insumos ELSE 0 END), 0) AS valor_insumos, + COALESCE(SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN valor_desconto ELSE 0 END), 0) AS valor_desconto, + COALESCE(SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN valor_acrescimo ELSE 0 END), 0) AS valor_acrescimo, + COALESCE(SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN valor_total ELSE 0 END), 0) AS valor_total, + COALESCE(SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN valor_pago ELSE 0 END), 0) AS valor_pago, + COALESCE(SUM(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN GREATEST(valor_total - valor_pago, 0) ELSE 0 END), 0) AS valor_pendente, + MIN(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN data_entrada END) AS primeira_manutencao_em, + MAX(CASE WHEN status NOT IN ('RASCUNHO', 'CANCELADA') THEN data_entrada END) AS ultima_manutencao_em + FROM manut_ordens_servico + WHERE equipamento_id = ? + AND excluido_em IS NULL; + `, + [equipamentoId] + ), + dbQuery( + ` + SELECT + id, tipo, status, status_pagamento, prioridade, + data_entrada, data_finalizacao, data_retirada, + problema_relatado, diagnostico, solucao_resumo, + valor_mao_obra, valor_insumos, valor_total + FROM manut_ordens_servico + WHERE equipamento_id = ? + AND excluido_em IS NULL + ORDER BY data_entrada DESC, id DESC + LIMIT 1; + `, + [equipamentoId] + ), + dbQuery( + ` + SELECT + id, + ordem_servico_id, + tipo_leitura, + leitura_em, + odometro_total_segundos, + odometro_conectado_segundos, + odometro_movimento_segundos, + odometro_parcial_segundos, + observacoes + FROM manut_equipamento_odometro_leituras + WHERE equipamento_id = ? + AND tipo_leitura IN ('ENTRADA_MANUTENCAO', 'SAIDA_MANUTENCAO') + ORDER BY leitura_em, id; + `, + [equipamentoId] + ) + ]); + + if (!equipamentos.length) { + throw createHttpError(404, 'Equipamento nao encontrado ou inativo.'); + } + + const equipamento = equipamentos[0]; + const totais = totaisRows[0]; + const entradas = leiturasManutencao.filter( + leitura => leitura.tipo_leitura === 'ENTRADA_MANUTENCAO' + ); + const intervalosMovimento = []; + + for (let index = 1; index < entradas.length; index += 1) { + intervalosMovimento.push( + Number(entradas[index].odometro_movimento_segundos) - + Number(entradas[index - 1].odometro_movimento_segundos) + ); + } + + const ultimaSaida = [...leiturasManutencao] + .reverse() + .find(leitura => leitura.tipo_leitura === 'SAIDA_MANUTENCAO') || null; + const movimentoAtual = Number(equipamento.odometro_movimento_segundos || 0); + const horasMovimento = movimentoAtual / 3600; + const valorTotal = Number(totais.valor_total || 0); + + return sendSuccess(res, { + equipamento, + indicadores: { + ...totais, + horas_movimento: horasMovimento, + horas_conectado: Number(equipamento.odometro_conectado_segundos || 0) / 3600, + horas_total: Number(equipamento.odometro_total_segundos || 0) / 3600, + horas_parcial: Number(equipamento.odometro_parcial_segundos || 0) / 3600, + movimento_desde_ultima_saida_segundos: ultimaSaida + ? Math.max(0, movimentoAtual - Number(ultimaSaida.odometro_movimento_segundos)) + : null, + media_movimento_entre_manutencoes_segundos: intervalosMovimento.length + ? intervalosMovimento.reduce((total, valor) => total + valor, 0) / intervalosMovimento.length + : null, + custo_medio_por_os: Number(totais.quantidade_os_efetivas || 0) + ? valorTotal / Number(totais.quantidade_os_efetivas) + : 0, + custo_por_hora_movimento: horasMovimento > 0 ? valorTotal / horasMovimento : null + }, + ultima_os: ultimaOsRows[0] || null, + ultima_saida_manutencao: ultimaSaida, + leituras_manutencao: leiturasManutencao + }); + } catch (error) { + return handleRouteError(req, res, error); + } + }); + + router.get('/getEquipamentoTimeline/:equipamentoId', async function (req, res) { + try { + const equipamentoId = positiveInteger(req.params.equipamentoId, 'Equipamento'); + const { page, pageSize, offset } = parsePagination(req.query); + const where = [ + 'os.equipamento_id = ?', + 'os.excluido_em IS NULL' + ]; + const params = [equipamentoId]; + + if (req.query.tipo) { + where.push('os.tipo = ?'); + params.push(enumValue(req.query.tipo, TIPOS_OS, 'Tipo')); + } + + if (req.query.status) { + where.push('os.status = ?'); + params.push(enumValue(req.query.status, STATUS_OS, 'Status')); + } + + if (req.query.data_inicio) { + where.push('os.data_entrada >= ?'); + params.push(normalizeNullableDate(req.query.data_inicio)); + } + + if (req.query.data_fim) { + where.push('os.data_entrada < DATE_ADD(?, INTERVAL 1 DAY)'); + params.push(normalizeNullableDate(req.query.data_fim)); + } + + if (req.query.somente_com_materiais === 'true' || req.query.somente_com_materiais === '1') { + where.push(`EXISTS ( + SELECT 1 + FROM manut_ordem_servico_atividades atv_mat + INNER JOIN manut_ordem_servico_atividade_materiais mat_busca + ON mat_busca.atividade_id = atv_mat.id + WHERE atv_mat.ordem_servico_id = os.id + )`); + } + + if (req.query.busca) { + const busca = `%${String(req.query.busca).trim()}%`; + where.push(`( + CAST(os.id AS CHAR) LIKE ? OR + os.problema_relatado LIKE ? OR + os.diagnostico LIKE ? OR + os.solucao_resumo LIKE ? OR + os.observacoes_internas LIKE ? OR + os.observacoes_cliente LIKE ? OR + EXISTS ( + SELECT 1 + FROM manut_ordem_servico_atividades atv_busca + WHERE atv_busca.ordem_servico_id = os.id + AND ( + atv_busca.descricao LIKE ? OR + atv_busca.observacoes_internas LIKE ? OR + atv_busca.observacoes_cliente LIKE ? + ) + ) OR + EXISTS ( + SELECT 1 + FROM manut_ordem_servico_atividades atv_busca_mat + INNER JOIN manut_ordem_servico_atividade_materiais mat_busca_texto + ON mat_busca_texto.atividade_id = atv_busca_mat.id + WHERE atv_busca_mat.ordem_servico_id = os.id + AND ( + mat_busca_texto.descricao_snapshot LIKE ? OR + mat_busca_texto.observacoes LIKE ? + ) + ) OR + EXISTS ( + SELECT 1 + FROM manut_ordem_servico_historico_status hs_busca + WHERE hs_busca.ordem_servico_id = os.id + AND hs_busca.observacoes LIKE ? + ) + )`); + params.push( + busca, busca, busca, busca, busca, busca, + busca, busca, busca, busca, busca, busca + ); + } + + const whereSql = where.join('\n AND '); + const { results: countRows } = await dbQuery( + `SELECT COUNT(*) AS total FROM manut_ordens_servico os WHERE ${whereSql};`, + params + ); + + const { results: orders } = await dbQuery( + ` + SELECT + os.*, + t.nome AS tecnico_responsavel_nome, + GREATEST(os.valor_total - os.valor_pago, 0) AS valor_pendente + FROM manut_ordens_servico os + LEFT JOIN manut_tecnicos t ON t.id = os.tecnico_responsavel_id + WHERE ${whereSql} + ORDER BY os.data_entrada DESC, os.id DESC + LIMIT ? OFFSET ?; + `, + [...params, pageSize, offset] + ); + + if (!orders.length) { + return sendSuccess(res, { + items: [], + pagination: { + page, + pageSize, + total: Number(countRows[0].total), + totalPages: Math.ceil(Number(countRows[0].total) / pageSize) + } + }); + } + + const orderIds = orders.map(order => Number(order.id)); + const placeholders = orderIds.map(() => '?').join(', '); + const [ + { results: activities }, + { results: technicians }, + { results: materials }, + { results: odometers }, + { results: statusHistory } + ] = await Promise.all([ + dbQuery( + `SELECT * FROM manut_ordem_servico_atividades WHERE ordem_servico_id IN (${placeholders}) ORDER BY data_atividade, ordem_exibicao, id;`, + orderIds + ), + dbQuery( + ` + SELECT + a.ordem_servico_id, + at.atividade_id, + at.tecnico_id, + at.papel, + at.horas_trabalhadas_expediente, + at.horas_trabalhadas_extras, + t.nome AS tecnico_nome + FROM manut_ordem_servico_atividade_tecnicos at + INNER JOIN manut_tecnicos t ON t.id = at.tecnico_id + INNER JOIN manut_ordem_servico_atividades a ON a.id = at.atividade_id + WHERE a.ordem_servico_id IN (${placeholders}) + ORDER BY t.nome; + `, + orderIds + ), + dbQuery( + ` + SELECT a.ordem_servico_id, mat.* + FROM manut_ordem_servico_atividade_materiais mat + INNER JOIN manut_ordem_servico_atividades a ON a.id = mat.atividade_id + WHERE a.ordem_servico_id IN (${placeholders}) + ORDER BY mat.id; + `, + orderIds + ), + dbQuery( + ` + SELECT * + FROM manut_equipamento_odometro_leituras + WHERE equipamento_id = ? + AND ordem_servico_id IN (${placeholders}) + ORDER BY leitura_em, id; + `, + [equipamentoId, ...orderIds] + ), + dbQuery( + ` + SELECT * + FROM manut_ordem_servico_historico_status + WHERE ordem_servico_id IN (${placeholders}) + ORDER BY created_at, id; + `, + orderIds + ) + ]); + + const techniciansByActivity = new Map(); + for (const technician of technicians) { + if (!techniciansByActivity.has(technician.atividade_id)) { + techniciansByActivity.set(technician.atividade_id, []); + } + techniciansByActivity.get(technician.atividade_id).push(technician); + } + + const materialsByActivity = new Map(); + for (const material of materials) { + if (!materialsByActivity.has(material.atividade_id)) { + materialsByActivity.set(material.atividade_id, []); + } + materialsByActivity.get(material.atividade_id).push(material); + } + + const activitiesByOrder = new Map(); + for (const activity of activities) { + if (!activitiesByOrder.has(activity.ordem_servico_id)) { + activitiesByOrder.set(activity.ordem_servico_id, []); + } + activitiesByOrder.get(activity.ordem_servico_id).push({ + ...activity, + tecnicos: techniciansByActivity.get(activity.id) || [], + materiais: materialsByActivity.get(activity.id) || [] + }); + } + + const groupByOrder = rows => { + const map = new Map(); + for (const row of rows) { + if (!map.has(row.ordem_servico_id)) { + map.set(row.ordem_servico_id, []); + } + map.get(row.ordem_servico_id).push(row); + } + return map; + }; + + const odometersByOrder = groupByOrder(odometers); + const statusByOrder = groupByOrder(statusHistory); + const items = orders.map(order => ({ + ...order, + atividades: activitiesByOrder.get(order.id) || [], + odometros: odometersByOrder.get(order.id) || [], + historico_status: statusByOrder.get(order.id) || [] + })); + + return sendSuccess(res, { + items, + pagination: { + page, + pageSize, + total: Number(countRows[0].total), + totalPages: Math.ceil(Number(countRows[0].total) / pageSize) + } + }); + } catch (error) { + return handleRouteError(req, res, error); + } + }); + router.get('/getTecnicosManutencao', async function (req, res) { try { const { results } = await dbQuery(` diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index c82d485..bdb3b90 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -38,6 +38,8 @@ import { PoliticaPrivacidadeComponent } from './politica-privacidade/politica-pr import { SobreComponent } from './sobre/sobre.component'; import { OrionOrdensServicoComponent } from './oriontard/orion-ordens-servico/orion-ordens-servico.component'; import { OrionOrdemServicoFormComponent } from './oriontard/orion-ordem-servico-form/orion-ordem-servico-form.component'; +import { OrionEquipamentosComponent } from './oriontard/orion-equipamentos/orion-equipamentos.component'; +import { OrionEquipamentoProntuarioComponent } from './oriontard/orion-equipamento-prontuario/orion-equipamento-prontuario.component'; const routes: Routes = [ @@ -168,6 +170,14 @@ const routes: Routes = [ path: 'orion-ordens-servico', component: OrionOrdensServicoComponent }, + { + path: 'orion-equipamentos', + component: OrionEquipamentosComponent + }, + { + path: 'orion-equipamentos/:id/historico', + component: OrionEquipamentoProntuarioComponent + }, { path: 'agrobase', children: [ diff --git a/src/app/app.module.ts b/src/app/app.module.ts index d402cb6..0c86bd4 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -51,6 +51,8 @@ import { PoliticaPrivacidadeComponent } from './politica-privacidade/politica-pr import { TermosUsoComponent } from './termos-uso/termos-uso.component'; import { OrionOrdensServicoComponent } from './oriontard/orion-ordens-servico/orion-ordens-servico.component'; import { OrionOrdemServicoFormComponent } from './oriontard/orion-ordem-servico-form/orion-ordem-servico-form.component'; +import { OrionEquipamentosComponent } from './oriontard/orion-equipamentos/orion-equipamentos.component'; +import { OrionEquipamentoProntuarioComponent } from './oriontard/orion-equipamento-prontuario/orion-equipamento-prontuario.component'; // Registrar a localidade pt-BR @@ -81,7 +83,14 @@ registerLocaleData(localePt); ProdutosComponent, AgrobaseLoginComponent, AgrobaseVersionamentoComponent, - TipoArquivoNomePipe, SobreComponent, PoliticaPrivacidadeComponent, TermosUsoComponent, OrionOrdensServicoComponent, OrionOrdemServicoFormComponent + TipoArquivoNomePipe, + SobreComponent, + PoliticaPrivacidadeComponent, + TermosUsoComponent, + OrionOrdensServicoComponent, + OrionOrdemServicoFormComponent, + OrionEquipamentosComponent, + OrionEquipamentoProntuarioComponent ], imports: [ BrowserModule, diff --git a/src/app/dashboard/dashboard.component.ts b/src/app/dashboard/dashboard.component.ts index c45b3ad..3941e5d 100644 --- a/src/app/dashboard/dashboard.component.ts +++ b/src/app/dashboard/dashboard.component.ts @@ -175,6 +175,16 @@ export class DashboardComponent implements OnInit { issub: true, sub: null, }, + { + index: 2, + texto: "Equipamentos", + link: "/orion-equipamentos", + icon: "update", + havesub: false, + dropicon: "", + issub: true, + sub: null, + }, ], }, { diff --git a/src/app/models/orionModel.ts b/src/app/models/orionModel.ts index 8d42590..fe72bc8 100644 --- a/src/app/models/orionModel.ts +++ b/src/app/models/orionModel.ts @@ -1,14 +1,44 @@ export class OrionLogsModel { - dataLog: Date; + id?: number; + dataLog: Date | string; nome: string; coordenadas: string; perfil: string; log: string; logObj: OrionLogsDetalhesModel; + + // Metadados normalizados pela API/tela. Mantêm compatibilidade com logs antigos. + dataOperacao?: Date | string; + schemaVersao?: number; + tipoEvento?: string; + periodoInicio?: Date | string; + periodoFim?: Date | string; + jsonValido?: boolean; + erroParse?: string; } export class OrionLogsDetalhesModel { - data: Date; + // Schema 2 + schemaVersao?: number; + logId?: string; + tipoEvento?: string; + mensagemEvento?: string; + periodoInicio?: Date | string; + periodoFim?: Date | string; + protocoloVersao?: number; + revisaoHardware?: string; + capacidades?: string; + mascaraSaude?: number; + alarmeInfiltracao?: boolean; + infiltracaoRaw?: number; + potenciaW?: number; + rollDeg?: number; + pitchDeg?: number; + firmwareVersaoStr?: string; + idCliente?: number; + + // Campos históricos + data: Date | string; firmwareVersao: number; robotNs: number; modelo: string; @@ -33,6 +63,7 @@ export class OrionLogsDetalhesModel { conectado: boolean; distancia: number; + // Campos derivados exclusivamente para exibição. HoraInicio: string; HoraFim: string; Duracao: string; @@ -57,6 +88,8 @@ export class OrionLogSensorModel { media: number; moda: number; mediana: number; + ultimo?: number; + quantidadeAmostras?: number; lista: number[]; } @@ -136,6 +169,111 @@ export interface EquipamentoManutencaoModel { cliente_nome?: string; } +// ============================================================ +// HISTORICO / PRONTUARIO DOS EQUIPAMENTOS +// ============================================================ + +export interface EquipamentoHistoricoListaModel extends EquipamentoManutencaoModel { + quantidade_os: number; + quantidade_os_abertas: number; + + valor_mao_obra_historico: number; + valor_insumos_historico: number; + valor_total_historico: number; + valor_pago_historico: number; + valor_pendente_historico: number; + + ultima_os_id?: number; + ultima_os_status?: OrdemServicoStatus; + ultima_os_tipo?: OrdemServicoTipo; + ultima_os_data_entrada?: string; + ultima_os_data_finalizacao?: string; + ultima_os_data_retirada?: string; +} + +export interface EquipamentoHistoricoFiltrosModel { + page?: number; + pageSize?: number; + busca?: string; + modelo_id?: number; + modelo?: string; + cliente_id?: number; + versao?: string; + possui_os_aberta?: boolean | ''; + status_ultima_os?: OrdemServicoStatus | ''; +} + +export interface EquipamentoHistoricoListaResponseModel { + items: EquipamentoHistoricoListaModel[]; + pagination: OrdemServicoPaginacaoModel; +} + +export interface EquipamentoProntuarioModel extends EquipamentoManutencaoModel { + cliente_razao_social?: string; + ativo?: boolean | number; + created_at?: string; + updated_at?: string; +} + +export interface EquipamentoProntuarioIndicadoresModel { + quantidade_os: number; + quantidade_os_abertas: number; + quantidade_os_efetivas: number; + quantidade_corretivas: number; + quantidade_preventivas: number; + + total_horas_expediente: number; + total_horas_extras: number; + horas_movimento: number; + horas_conectado: number; + horas_total: number; + horas_parcial: number; + + valor_mao_obra: number; + custo_insumos: number; + valor_insumos: number; + valor_desconto: number; + valor_acrescimo: number; + valor_total: number; + valor_pago: number; + valor_pendente: number; + + primeira_manutencao_em?: string; + ultima_manutencao_em?: string; + movimento_desde_ultima_saida_segundos?: number; + media_movimento_entre_manutencoes_segundos?: number; + custo_medio_por_os: number; + custo_por_hora_movimento?: number; +} + +export interface EquipamentoProntuarioResponseModel { + equipamento: EquipamentoProntuarioModel; + indicadores: EquipamentoProntuarioIndicadoresModel; + ultima_os?: OrdemServicoListaModel; + ultima_saida_manutencao?: OrdemServicoOdometroModel; + leituras_manutencao: OrdemServicoOdometroModel[]; +} + +export interface EquipamentoTimelineFiltrosModel { + page?: number; + pageSize?: number; + busca?: string; + tipo?: OrdemServicoTipo | ''; + status?: OrdemServicoStatus | ''; + data_inicio?: string; + data_fim?: string; + somente_com_materiais?: boolean; +} + +export interface EquipamentoTimelineItemModel extends OrdemServicoDetalhesModel { + odometros: OrdemServicoOdometroModel[]; +} + +export interface EquipamentoTimelineResponseModel { + items: EquipamentoTimelineItemModel[]; + pagination: OrdemServicoPaginacaoModel; +} + export interface TecnicoManutencaoModel { id: number; usuario_sistema_id?: number; @@ -510,4 +648,4 @@ export type OrdemServicoTipoLeituraOdometro = | 'ENTRADA_MANUTENCAO' | 'SAIDA_MANUTENCAO' | 'ATUALIZACAO_MANUAL' - | 'TELEMETRIA'; \ No newline at end of file + | 'TELEMETRIA'; diff --git a/src/app/oriontard/orion-dashboard/orion-dashboard.component.html b/src/app/oriontard/orion-dashboard/orion-dashboard.component.html index 5352440..d26b94b 100644 --- a/src/app/oriontard/orion-dashboard/orion-dashboard.component.html +++ b/src/app/oriontard/orion-dashboard/orion-dashboard.component.html @@ -1,181 +1,435 @@ -
- - -
- -
- - - - - +
+ - - -
- - +
+
Carregando logs do Orion...
+ +
+ Falha ao carregar os logs. + {{ErroCarregamento}} +
+ +
+ Nenhum log encontrado no período disponibilizado pela API. +
+ +
+ Escolha uma operação para abrir a telemetria. + Selecione um dia destacado, aplique o período e escolha a sessão que deseja analisar. +
+ + +
+
+
OPERAÇÃO SELECIONADA
+

+ {{FiltroSelecionado.modelo || 'Equipamento'}} + • + {{FiltroSelecionado.robotNs || 'NS'}} +

+

+ {{FiltroSelecionado.usuario || UsuarioSelecionado}} + • + {{PerfilSelecionado}} +

+
+
+ {{eventoAtual()}} +
+ {{FiltroSelecionado.conectado ? 'Conectado' : 'Desconectado'}} +
+
+
+ +
+
+ Início + {{resumoTrabalho.inicio | date:'HH:mm:ss'}} + {{resumoTrabalho.inicio | date:'dd/MM/yyyy'}} +
+
+ Fim + {{resumoTrabalho.fim | date:'HH:mm:ss'}} + {{resumoTrabalho.fim | date:'dd/MM/yyyy'}} +
+
+ Duração + {{SegundosParaTempo(resumoTrabalho.duracaoSegundos)}} + janela real dos logs +
+
+ Distância + {{resumoTrabalho.distanciaPercorrida | number:'1.1-2'}} m + movimento estimado +
+
+ Velocidade média + {{formatarMedida(telemetria.velocidadeMedia, 2, 'm/s')}} + máx. {{formatarMedida(telemetria.velocidadeMax, 2, 'm/s')}} +
+
+ Bateria mínima + {{formatarMedida(telemetria.bateriaMin, 1, '%')}} + final {{formatarMedida(telemetria.bateriaFinal, 1, '%')}} +
+
+ +
+
+
+
ENERGIA
+

Como o sistema elétrico se comportou

+

Tensão, carga da bateria, corrente e potência no mesmo contexto operacional.

+
+
+ +
+
Tensão média{{formatarMedida(telemetria.tensaoMedia, 2, 'V')}}mín. {{formatarMedida(telemetria.tensaoMin, 2, 'V')}}
+
Bateria média{{formatarMedida(telemetria.bateriaMedia, 1, '%')}}mín. {{formatarMedida(telemetria.bateriaMin, 1, '%')}}
+
Corrente média{{formatarCorrente(telemetria.correnteMedia)}}pico {{formatarCorrente(telemetria.correnteMax)}}
+
Potência média{{formatarMedida(telemetria.potenciaMediaW, 1, 'W')}}quando disponível no protocolo
+
+ +
+
+
TensãoBateria sob carga
V
+
+
+
+
CargaBateria disponível
%
+
+
+
+
ConsumoCorrente ao longo da operação
escala convertida para A
+
+
+
+
+ +
+
+
+
MOVIMENTO
+

Comando, velocidade e avanço

+

Relaciona o que foi comandado com a resposta de velocidade e a progressão de distância.

+
+
+ +
+
Velocidade média{{formatarMedida(telemetria.velocidadeMedia, 2, 'm/s')}}máx. {{formatarMedida(telemetria.velocidadeMax, 2, 'm/s')}}
+
Comando médio{{formatarMedida(telemetria.comandoVelMedia, 1, '%')}}máx. {{formatarMedida(telemetria.comandoVelMax, 1, '%')}}
+
Distância final{{formatarMedida(telemetria.distanciaFinal, 2, 'm')}}posição acumulada
+
Deslocamento líquido{{formatarMedida(diagnosticoTrabalho.deslocamentoLiquido, 2, 'm')}}fim menos início
+
+ +
+
+
Resposta dinâmicaVelocidade x comando
m/s + %
+
+
+
+
ProgressãoDistância acumulada
m
+
+
+
+
+ +
+
+
+
ESTABILIDADE
+

Postura do robô durante o trabalho

+

Inclinação frontal e lateral agrupadas para revelar picos, terreno e momentos críticos.

+
+
+ +
+
Frontal média{{formatarMedida(telemetria.inclinacaoFrontalMedia, 2, '°')}}|máx.| {{formatarMedida(telemetria.inclinacaoFrontalMaxAbs, 2, '°')}}
+
Lateral média{{formatarMedida(telemetria.inclinacaoLateralMedia, 2, '°')}}|máx.| {{formatarMedida(telemetria.inclinacaoLateralMaxAbs, 2, '°')}}
+
|Roll| máximo{{formatarMedida(diagnosticoTrabalho.rollMaxAbs, 2, '°')}}schema novo
+
|Pitch| máximo{{formatarMedida(diagnosticoTrabalho.pitchMaxAbs, 2, '°')}}schema novo
+
+ +
+
+
InclinaçãoFrontal x lateral
graus
+
+
+
+
+ +
+
+
+
AMBIENTE
+

Condições ao redor do equipamento

+

Variáveis ambientais separadas por unidade para evitar escalas enganadoras.

+
+
+ +
+
+
Temperatura{{formatarMedida(telemetria.temperaturaMedia, 1, '°C')}}
{{formatarMedida(telemetria.temperaturaMin, 1, '°C')}} → {{formatarMedida(telemetria.temperaturaMax, 1, '°C')}}
+
+
+
+
Altitude{{formatarMedida(telemetria.altitudeMedia, 1, 'm')}}
{{formatarMedida(telemetria.altitudeMin, 1, 'm')}} → {{formatarMedida(telemetria.altitudeMax, 1, 'm')}}
+
+
+
+
Umidade{{formatarMedida(telemetria.umidadeMedia, 1, '%')}}
{{formatarMedida(telemetria.umidadeMin, 1, '%')}} → {{formatarMedida(telemetria.umidadeMax, 1, '%')}}
+
+
+
+
Gás{{formatarMedida(telemetria.gasMedia, 2, '')}}
máx. {{formatarMedida(telemetria.gasMax, 2, '')}}
+
+
+
+
+ +
+
+
+
SEGURANÇA
+

Saúde e eventos críticos

+

Sinais que merecem leitura rápida, sem ocupar espaço com gráficos vazios.

+
+
+ +
+
+ Infiltração + {{diagnosticoTrabalho.infiltracaoDetectada ? 'ALARME DETECTADO' : 'Sem alarme'}} + raw máx. {{diagnosticoTrabalho.infiltracaoRawMax !== null ? diagnosticoTrabalho.infiltracaoRawMax : '-'}} +
+
+ Máscara de saúde + {{mascaraSaudeTexto()}} + estado final reportado +
+
+ Ultrassom + {{formatarMedida(telemetria.ultrassomMin, 2, '')}} + menor leitura da operação +
+
+ Ultrassom + Sem dados + nenhuma amostra válida +
+
+ +
+
ProximidadeUltrassom ao longo da operação
+
+
+
+ +
+
+
+
CONTEXTO
+

Equipamento e sessão

+
+ schema {{resumoTrabalho.schemas}} +
+ +
+
NS{{FiltroSelecionado.robotNs || '-'}}
+
Software{{FiltroSelecionado.versao || '-'}}
+
Firmware{{FiltroSelecionado.firmwareVersaoStr || FiltroSelecionado.firmwareVersao || '-'}}
+
Protocolo{{FiltroSelecionado.protocoloVersao || '-'}}
+
Hardware{{FiltroSelecionado.revisaoHardware || '-'}}
+
Porta / Baud{{FiltroSelecionado.portaCOM || '-'}} / {{FiltroSelecionado.baudRate || '-'}}
+
+ +
+
+ ODÔMETROS DA SESSÃO + Inicial → final e variação capturada +
+
+ Motores ativos + {{SegundosParaTempo(odometrosTrabalho.motores.delta)}} + {{formatarMedida(odometrosTrabalho.movimentoPercentual, 1, '%')}} do intervalo medido +
+
+ +
+
+
Total
+
+
Inicial{{odometrosTrabalho.total.inicial !== null ? SegundosParaTempo(odometrosTrabalho.total.inicial) : '-'}}
+
→
+
Final{{odometrosTrabalho.total.final !== null ? SegundosParaTempo(odometrosTrabalho.total.final) : '-'}}
+
+
Δ sessão{{formatarDeltaOdometro(odometrosTrabalho.total)}}
+
+ +
+
Conectado
+
+
Inicial{{odometrosTrabalho.conectado.inicial !== null ? SegundosParaTempo(odometrosTrabalho.conectado.inicial) : '-'}}
+
→
+
Final{{odometrosTrabalho.conectado.final !== null ? SegundosParaTempo(odometrosTrabalho.conectado.final) : '-'}}
+
+
Δ sessão{{formatarDeltaOdometro(odometrosTrabalho.conectado)}}
+
+ +
+
Motores / movimento
+
+
Inicial{{odometrosTrabalho.motores.inicial !== null ? SegundosParaTempo(odometrosTrabalho.motores.inicial) : '-'}}
+
→
+
Final{{odometrosTrabalho.motores.final !== null ? SegundosParaTempo(odometrosTrabalho.motores.final) : '-'}}
+
+
Tempo em movimento{{formatarDeltaOdometro(odometrosTrabalho.motores)}}
+
+ +
+
Parcial
+
+
Inicial{{odometrosTrabalho.parcial.inicial !== null ? SegundosParaTempo(odometrosTrabalho.parcial.inicial) : '-'}}
+
→
+
Final{{odometrosTrabalho.parcial.final !== null ? SegundosParaTempo(odometrosTrabalho.parcial.final) : '-'}}
+
+
Δ sessão{{formatarDeltaOdometro(odometrosTrabalho.parcial)}}
+
+
+
+ +
+
SERVIÇO

Contexto do atendimento

+
+
Nome{{FiltroSelecionado.servico.Nome || '-'}}
+
Cidade{{FiltroSelecionado.servico.Cidade || '-'}}
+
Responsável{{FiltroSelecionado.servico.Responsavel || '-'}}
+
Serviço{{FiltroSelecionado.servico.Servico || '-'}}
+
+
+ +
+
LOCAL

Posição registrada

+
+ {{FiltroSelecionado.Coordenadas || 'Sem coordenadas'}} + {{FiltroSelecionado.Localizacao || 'Endereço não resolvido'}} +
+
+ +
+ + + DIAGNÓSTICO AVANÇADO + Dados brutos e estatísticas dos sensores + + {{sensoresDiagnostico.length}} sinais com dados + + +
+
+
Capacidades{{diagnosticoTrabalho.capacidades}}
+
Último log ID{{diagnosticoTrabalho.logIdUltimo}}
+
+ Mensagens de evento + {{mensagem}} +
+
+ +
+ + + + + + + + + + + + + + + +
SinalMín.MédiaMedianaMáx.ÚltimoAmostras
{{item.nome}}ID {{item.sensor.sensor}}{{item.sensor.minimo | number:'1.0-3'}}{{item.sensor.media | number:'1.0-3'}}{{item.sensor.mediana | number:'1.0-3'}}{{item.sensor.maximo | number:'1.0-3'}}{{item.sensor.ultimo | number:'1.0-3'}}{{item.sensor.quantidadeAmostras || 0}}
+
+
+
+ +
+ Esta operação não possui séries de telemetria suficientes para montar os painéis. +
+
+
diff --git a/src/app/oriontard/orion-dashboard/orion-dashboard.component.scss b/src/app/oriontard/orion-dashboard/orion-dashboard.component.scss index 40ff643..f62f168 100644 --- a/src/app/oriontard/orion-dashboard/orion-dashboard.component.scss +++ b/src/app/oriontard/orion-dashboard/orion-dashboard.component.scss @@ -1,30 +1,877 @@ -#divChart{ - display: block; - width: 90%; - height: auto; +:host { + display: block; + min-height: 100vh; + background: #eef2f5; + color: #1c2730; } -.divChart{ - display: block; - width: 30%; - position: relative; - float: left; - margin-right: 3%; - height: 350px; +.dashboard-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 304px minmax(0, 1fr); } -.TabelaDados { - padding: 20px; +.filters-panel { + box-sizing: border-box; + min-height: 100vh; + padding: 28px 22px; + background: linear-gradient(180deg, #172129 0%, #202d36 100%); + color: #eef3f6; + position: sticky; + top: 0; + align-self: start; } -.DivGraficoFora { - width: 100%; - height: auto; - position: relative; +.brand-block h2, +.hero-card h1, +.section-title h3, +.story-heading h2 { + margin: 4px 0 0; + font-weight: 650; +} + +.brand-block p, +.hero-card p, +.story-heading p { + margin: 8px 0 0; + color: #9eafb9; +} + +.eyebrow { + font-size: 11px; + letter-spacing: 0.14em; + font-weight: 800; + color: #718896; +} + +.filter-block { + margin-top: 28px; + padding-top: 20px; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.filter-block label { + display: block; + margin: 10px 0 6px; + font-size: 12px; + font-weight: 650; + color: #c7d1d8; +} + +.filters-panel mat-form-field, +.full-button { + width: 100%; +} + +.refresh-button { + margin-top: 16px; + color: #d7e0e6; +} + +.side-summary { + margin-top: 24px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.side-summary > div { + padding: 12px; + border-radius: 11px; + background: rgba(255, 255, 255, 0.06); +} + +.side-summary strong, +.side-summary span { + display: block; +} + +.side-summary strong { + font-size: 18px; +} + +.side-summary span { + margin-top: 3px; + font-size: 11px; + color: #aebbc4; +} + +.side-summary .warning { + grid-column: 1 / -1; + background: rgba(255, 176, 32, 0.12); +} + +.content-panel { + box-sizing: border-box; + padding: 28px; + min-width: 0; + max-width: 1600px; + width: 100%; + margin: 0 auto; +} + +.hero-card, +.info-card, +.state-card, +.story-section, +.advanced-panel { + background: #fff; + border: 1px solid #dfe6ea; + border-radius: 16px; + box-shadow: 0 5px 18px rgba(20, 34, 46, 0.045); +} + +.hero-card { + padding: 22px 24px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} + +.hero-card h1 { + font-size: 28px; + color: #16242d; +} + +.separator { + margin: 0 7px; +} + +.hero-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.status-pill, +.event-badge { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 7px 11px; + font-size: 12px; + font-weight: 750; + background: #edf1f4; + color: #5d6b75; +} + +.status-pill.online { + background: #e5f6ec; + color: #25764a; +} + +.metric-grid { + margin-top: 14px; + display: grid; + grid-template-columns: repeat(6, minmax(120px, 1fr)); + gap: 10px; +} + +.metric-card { + min-width: 0; + padding: 15px 16px; + border-radius: 13px; + background: #fff; + border: 1px solid #e1e7eb; + box-shadow: 0 3px 12px rgba(20, 34, 46, 0.03); +} + +.metric-card.emphasis { + background: #1e2b34; + border-color: #1e2b34; + color: #fff; +} + +.metric-card span, +.metric-card strong, +.metric-card small, +.story-kpi span, +.story-kpi strong, +.story-kpi small { + display: block; +} + +.metric-card span, +.story-kpi span { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #75848f; + font-weight: 700; +} + +.metric-card.emphasis span, +.metric-card.emphasis small { + color: #b7c5cd; +} + +.metric-card strong { + margin-top: 7px; + font-size: 19px; + overflow-wrap: anywhere; +} + +.metric-card small, +.story-kpi small { + margin-top: 4px; + color: #8d9aa3; + font-size: 11px; +} + +.story-section { + margin-top: 18px; + padding: 22px; + overflow: hidden; +} + +.story-heading { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: flex-start; +} + +.story-heading h2 { + font-size: 21px; + color: #1d2a33; +} + +.story-heading p { + color: #74838d; + font-size: 13px; +} + +.story-kpis { + margin-top: 18px; + display: grid; + grid-template-columns: repeat(4, minmax(130px, 1fr)); + gap: 9px; +} + +.story-kpi { + min-width: 0; + border-radius: 12px; + background: #f6f8fa; + border: 1px solid #edf1f3; + padding: 13px 14px; +} + +.story-kpi strong { + margin-top: 5px; + font-size: 18px; + color: #263640; +} + +.chart-grid { + margin-top: 14px; + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; +} + +.chart-grid.two-columns { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.telemetry-card { + min-width: 0; + padding: 16px; + border-radius: 13px; + background: #fbfcfd; + border: 1px solid #e7ecef; +} + +.telemetry-card.wide-card { + grid-column: 1 / -1; +} + +.card-heading { + min-height: 42px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.card-heading span, +.card-heading strong, +.card-heading small { + display: block; +} + +.card-heading span { + color: #81909a; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 750; +} + +.card-heading strong { + margin-top: 3px; + color: #263640; + font-size: 15px; +} + +.card-heading small { + color: #8997a0; + font-size: 11px; + text-align: right; +} + +.chart-box { + position: relative; + width: 100%; + height: 255px; +} + +.chart-box.large { + height: 330px; +} + +.chart-box.compact { + height: 210px; +} + +.environment-grid { + margin-top: 16px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.safety-section { + background: linear-gradient(180deg, #ffffff 0%, #fbfcfd 100%); +} + +.safety-grid { + margin-top: 16px; + display: grid; + grid-template-columns: repeat(3, minmax(150px, 1fr)); + gap: 10px; +} + +.safety-card { + padding: 15px 16px; + border-radius: 13px; + background: #eef8f2; + border: 1px solid #d9eee1; +} + +.safety-card.alarm { + background: #fff0f0; + border-color: #f1cece; +} + +.safety-card span, +.safety-card strong, +.safety-card small { + display: block; +} + +.safety-card span { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #75848f; + font-weight: 750; +} + +.safety-card strong { + margin-top: 6px; + color: #24613f; + font-size: 16px; +} + +.safety-card.alarm strong { + color: #a33b3b; +} + +.safety-card small { + margin-top: 4px; + color: #82908a; +} + +.safety-chart { + margin-top: 12px; +} + +.info-card { + margin-top: 16px; + padding: 20px 22px; +} + +.compact-info-card { + padding-bottom: 18px; +} + +.section-title { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-bottom: 16px; +} + +.info-grid { + display: grid; + grid-template-columns: repeat(4, minmax(150px, 1fr)); + gap: 9px; +} + +.info-grid > div { + padding: 11px 13px; + border-radius: 9px; + background: #f7f9fa; +} + +.info-grid span, +.info-grid strong { + display: block; +} + +.info-grid span { + font-size: 10px; + color: #7d8b95; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.info-grid strong { + margin-top: 4px; + font-size: 13px; + overflow-wrap: anywhere; } -::ng-deep .highlight-date .mat-calendar-body-cell-content { - background-color: #ffcc80; /* Cor de destaque */ - border-radius: 50%; -} \ No newline at end of file +.odometer-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + margin: 20px 0 10px; + padding-top: 17px; + border-top: 1px solid #e8edf0; +} + +.odometer-heading > div:first-child span, +.odometer-duty span { + display: block; + font-size: 10px; + color: #7d8b95; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.odometer-heading > div:first-child strong { + display: block; + margin-top: 4px; + font-size: 14px; +} + +.odometer-duty { + min-width: 190px; + padding: 10px 13px; + border-radius: 10px; + background: #eef8f3; + text-align: right; +} + +.odometer-duty strong { + display: block; + margin-top: 2px; + font-size: 18px; + color: #177a4b; +} + +.odometer-duty small { + display: block; + margin-top: 2px; + color: #678377; + font-size: 10px; +} + +.odometer-grid { + display: grid; + grid-template-columns: repeat(4, minmax(190px, 1fr)); + gap: 10px; +} + +.odometer-card { + padding: 13px 14px; + border: 1px solid #e6ecef; + border-radius: 11px; + background: #fbfcfd; +} + +.odometer-card.emphasis { + background: #f2faf6; + border-color: #d5ebdf; +} + +.odometer-title { + margin-bottom: 9px; + font-size: 11px; + font-weight: 700; + color: #44545e; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.odometer-values { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: end; + gap: 7px; +} + +.odometer-values span, +.odometer-delta span { + display: block; + font-size: 9px; + color: #8a979f; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.odometer-values strong { + display: block; + margin-top: 3px; + font-size: 12px; +} + +.odometer-arrow { + padding-bottom: 1px; + color: #9aa6ad; + font-size: 15px; +} + +.odometer-delta { + margin-top: 10px; + padding-top: 9px; + border-top: 1px solid #e9edef; +} + +.odometer-delta strong { + display: block; + margin-top: 3px; + font-size: 15px; + color: #172b38; +} + +.odometer-card.emphasis .odometer-delta strong { + color: #177a4b; +} + +.location-line { + display: flex; + flex-direction: column; + gap: 6px; +} + +.location-line span { + color: #697781; +} + +.advanced-panel { + margin-top: 16px; + overflow: hidden; +} + +.advanced-panel summary { + list-style: none; + cursor: pointer; + padding: 18px 22px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + background: #f9fafb; +} + +.advanced-panel summary::-webkit-details-marker { + display: none; +} + +.advanced-panel summary > span, +.advanced-panel summary strong, +.advanced-panel summary small { + display: block; +} + +.advanced-panel summary strong { + margin-top: 3px; + color: #2e3e48; +} + +.advanced-panel summary small { + color: #84919a; +} + +.advanced-panel[open] summary { + border-bottom: 1px solid #e5eaed; +} + +.advanced-content { + padding: 18px 20px 22px; +} + +.diagnostic-lines { + display: grid; + gap: 8px; + margin-bottom: 14px; +} + +.diagnostic-lines > div { + padding: 11px 13px; + border-radius: 9px; + background: #f7f9fa; +} + +.diagnostic-lines span, +.diagnostic-lines strong { + display: block; +} + +.diagnostic-lines span { + font-size: 10px; + color: #7d8b95; + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.diagnostic-lines strong + strong { + margin-top: 4px; +} + +.diagnostic-table-wrap { + overflow-x: auto; +} + +.diagnostic-table { + width: 100%; + min-width: 760px; + border-collapse: collapse; + font-size: 12px; +} + +.diagnostic-table th, +.diagnostic-table td { + padding: 10px 11px; + border-bottom: 1px solid #e8edf0; + text-align: right; + white-space: nowrap; +} + +.diagnostic-table th { + color: #75848f; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + background: #fafbfc; +} + +.diagnostic-table th:first-child, +.diagnostic-table td:first-child { + text-align: left; +} + +.diagnostic-table td:first-child strong, +.diagnostic-table td:first-child small { + display: block; +} + +.diagnostic-table td:first-child small { + margin-top: 2px; + color: #9aa5ac; +} + +.mono { + font-family: Consolas, "Courier New", monospace; + font-size: 12px; + overflow-wrap: anywhere; +} + +.state-card { + padding: 22px; + color: #66757f; +} + +.state-card.error { + border-color: #f0c9c9; + background: #fff8f8; + color: #9b3535; +} + +.state-card.error strong, +.state-card.error span, +.selection-hint strong, +.selection-hint span { + display: block; +} + +.state-card.error span, +.selection-hint span { + margin-top: 6px; +} + +.selection-hint span { + color: #84919a; +} + +.calendar-legend { + display: flex; + flex-direction: column; + gap: 5px; + margin: -6px 0 14px; + font-size: 11px; + color: #aebbc4; +} + +.calendar-legend span { + display: inline-flex; + align-items: center; + gap: 7px; +} + +.legend-dot { + width: 9px; + height: 9px; + border-radius: 50%; + display: inline-block; + box-sizing: border-box; +} + +.legend-dot.v2 { + background: #43a047; + box-shadow: 0 0 0 2px rgba(67, 160, 71, 0.18); +} + +.legend-dot.legacy { + background: #ffb74d; + box-shadow: 0 0 0 2px rgba(255, 183, 77, 0.18); +} + +::ng-deep .filters-panel .mat-form-field-label, +::ng-deep .filters-panel .mat-select-value, +::ng-deep .filters-panel input.mat-input-element, +::ng-deep .filters-panel .mat-datepicker-toggle { + color: #e8eef2 !important; +} + +::ng-deep .filters-panel .mat-form-field-outline { + color: rgba(255, 255, 255, 0.18) !important; +} + +::ng-deep .mat-calendar-body-cell.orion-date-v2 .mat-calendar-body-cell-content, +::ng-deep .orion-date-v2 .mat-calendar-body-cell-content { + background: #43a047 !important; + color: #fff !important; + font-weight: 700 !important; + border-radius: 50% !important; +} + +::ng-deep .mat-calendar-body-cell.orion-date-legacy .mat-calendar-body-cell-content, +::ng-deep .orion-date-legacy .mat-calendar-body-cell-content { + background: #ffcc80 !important; + color: #263238 !important; + font-weight: 700 !important; + border-radius: 50% !important; +} + +@media (max-width: 1280px) { + .metric-grid { + grid-template-columns: repeat(3, minmax(140px, 1fr)); + } + + .story-kpis, + .info-grid { + grid-template-columns: repeat(2, minmax(150px, 1fr)); + } +} + +@media (max-width: 980px) { + .dashboard-shell { + grid-template-columns: 1fr; + } + + .filters-panel { + min-height: auto; + position: static; + } + + .chart-grid.two-columns, + .environment-grid, + .safety-grid { + grid-template-columns: 1fr; + } + + .telemetry-card.wide-card { + grid-column: auto; + } +} + +@media (max-width: 640px) { + .content-panel { + padding: 14px; + } + + .hero-card { + align-items: flex-start; + flex-direction: column; + } + + .hero-actions { + justify-content: flex-start; + } + + .metric-grid, + .story-kpis, + .info-grid { + grid-template-columns: 1fr 1fr; + } + + .story-section { + padding: 17px; + } + + .chart-box, + .chart-box.large { + height: 250px; + } +} + +@media (max-width: 430px) { + .metric-grid, + .story-kpis, + .info-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 1180px) { + .odometer-grid { + grid-template-columns: repeat(2, minmax(190px, 1fr)); + } +} + +@media (max-width: 720px) { + .odometer-heading { + align-items: stretch; + flex-direction: column; + } + + .odometer-duty { + min-width: 0; + text-align: left; + } + + .odometer-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/app/oriontard/orion-dashboard/orion-dashboard.component.ts b/src/app/oriontard/orion-dashboard/orion-dashboard.component.ts index 3fc8547..0dae98b 100644 --- a/src/app/oriontard/orion-dashboard/orion-dashboard.component.ts +++ b/src/app/oriontard/orion-dashboard/orion-dashboard.component.ts @@ -1,10 +1,16 @@ -import { Component, OnInit, ViewChild } from '@angular/core'; -import { MatPaginator } from '@angular/material/paginator'; +import { Component, OnInit } from '@angular/core'; import { Chart } from 'chart.js'; -import { LogAgrupado, OrionLogsDetalhesModel, OrionLogSensorModel, OrionLogsModel } from '../../models/orionModel'; +import { + OrionLogsDetalhesModel, + OrionLogSensorModel, + OrionLogsModel +} from '../../models/orionModel'; import { OrionService } from '../../services/orionService'; -import { MatTableDataSource } from '@angular/material/table'; -import { MatTabChangeEvent } from '@angular/material'; + +interface SensorDiagnosticoView { + nome: string; + sensor: OrionLogSensorModel; +} @Component({ selector: 'app-orion-dashboard', @@ -12,289 +18,1367 @@ import { MatTabChangeEvent } from '@angular/material'; styleUrls: ['./orion-dashboard.component.scss'] }) export class OrionDashboardComponent implements OnInit { - @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; - - constructor( - private orionService: OrionService - ) { } + constructor(private orionService: OrionService) { } Datas: string[] = []; - DataSelecionada: Date; - LogsDetalhes: OrionLogsModel[]; - LogsDetalhesFiltrado: OrionLogsModel[]; - displayedColumns: string[] = ['data', 'nome', 'perfil', 'conectado', 'camera']; - dataSource = new MatTableDataSource(); + DataSelecionada: Date = null; + LogsDetalhes: OrionLogsModel[] = []; + LogsPeriodo: OrionLogsModel[] = []; + LogsDetalhesFiltrado: OrionLogsModel[] = []; - MapNome: any; + MapNome: Map = new Map(); NomesFiltro: string[] = []; - MapPerfil: any; + MapPerfil: Map = new Map(); PerfisFiltro: string[] = []; - HoraInicial: number = 0; // Slider range start (0 hours) - HoraFinal: number = 23; // Slider range end (23 hours) - FiltroSelecionado: OrionLogsDetalhesModel = null; - selectedSensor: OrionLogSensorModel; - trabalhos = []; - trabalhoSelecionado; - tempoLog: number = 10; + UsuarioSelecionado = ''; + PerfilSelecionado = ''; + HoraInicial = 0; + HoraFinal = 23; + + FiltroSelecionado: OrionLogsDetalhesModel = null; + sensoresTrabalho: OrionLogSensorModel[] = []; + sensoresDiagnostico: SensorDiagnosticoView[] = []; + + trabalhos: OrionLogsModel[][] = []; + trabalhoSelecionado: OrionLogsModel[] = []; + trabalhoSelecionadoOpcao: OrionLogsModel[] = []; + tempoLog = 10; + + Carregando = false; + ErroCarregamento = ''; + LogsInvalidos = 0; + + resumoTrabalho = { + inicio: null as Date, + fim: null as Date, + duracaoSegundos: 0, + totalLogs: 0, + eventos: '', + schemas: '', + distanciaPercorrida: 0 + }; + + telemetria = this.criarResumoTelemetriaVazio(); + + odometrosTrabalho = this.criarResumoOdometrosVazio(); + + diagnosticoTrabalho = { + potenciaMediaW: null as number, + potenciaUltimaW: null as number, + rollMaxAbs: null as number, + pitchMaxAbs: null as number, + infiltracaoDetectada: false, + infiltracaoRawMax: null as number, + mascaraSaudeUltima: null as number, + capacidades: '', + altitudeMedia: null as number, + odometroParcial: 0, + tempoTotal: 0, + idCliente: null as number, + cpf: '', + logIdUltimo: '', + deslocamentoLiquido: 0, + mensagensEventos: [] as string[] + }; + + private charts: { [key: string]: any } = {}; sliderOptions = { floor: 0, ceil: 23, step: 1, noSwitching: true, - translate: (value: number): string => { - return value + ':00'; - } + translate: (value: number): string => value + ':00' }; - highlightedDates: Set = new Set(); // Datas que possuem logs + highlightedDatesV2: Set = new Set(); + highlightedDatesLegacy: Set = new Set(); ngOnInit() { + this.carregarLogs(); + } + + carregarLogs() { + this.Carregando = true; + this.ErroCarregamento = ''; + this.clearCharts(); + + const dataAnterior = this.DataSelecionada + ? new Date(this.DataSelecionada.getTime()) + : null; + this.orionService.getAllLogs().then(resLogs => { - resLogs.forEach(log => log.logObj = JSON.parse(log.log)); + const normalizados: OrionLogsModel[] = []; + this.LogsInvalidos = 0; - // Ordena os logs por data do mais novo para o mais antigo - /*resLogs.sort((a, b) => { - const dateA = new Date(a.logObj.data).getTime(); - const dateB = new Date(b.logObj.data).getTime(); - return dateB - dateA; // Ordena do mais novo para o mais antigo - });*/ + (resLogs || []).forEach(log => { + const item = this.normalizarLog(log); + if (item) { + normalizados.push(item); + if (item.jsonValido === false) this.LogsInvalidos++; + } + }); - this.LogsDetalhes = resLogs; + normalizados.sort((a, b) => + this.dataFimLog(b).getTime() - this.dataFimLog(a).getTime() + ); - var MapDatas = this.groupBy(this.LogsDetalhes, log => new Date(log.logObj.data).toLocaleDateString()); - this.Datas = Array.from(MapDatas.keys()); + this.LogsDetalhes = normalizados; + this.highlightedDatesV2.clear(); + this.highlightedDatesLegacy.clear(); - // Adiciona as datas ao conjunto de datas destacadas - this.Datas.forEach(date => this.highlightedDates.add(date)); + const datas = new Set(); + this.LogsDetalhes.forEach(log => this.registrarDatasCalendario(log, datas)); + this.Datas = Array.from(datas.values()).sort().reverse(); - // Inicializa a tabela com todos os logs - this.dataSource.data = this.LogsDetalhes; - this.dataSource.paginator = this.paginator; + const chaveAnterior = dataAnterior ? this.chaveData(dataAnterior) : ''; + const dataAindaExiste = !!chaveAnterior && + (this.highlightedDatesV2.has(chaveAnterior) || + this.highlightedDatesLegacy.has(chaveAnterior)); + + if (dataAnterior && dataAindaExiste) { + this.DataSelecionada = dataAnterior; + this.btnFiltrarClick(); + } else { + this.DataSelecionada = null; + this.UsuarioSelecionado = ''; + this.PerfilSelecionado = ''; + this.limparSelecao(); + } + + this.Carregando = false; + }).catch(error => { + console.error('[ORION DASHBOARD] Falha ao carregar logs', error); + this.Carregando = false; + this.ErroCarregamento = error && error.message + ? error.message + : 'Não foi possível carregar os logs.'; }); } - // Método para destacar datas no calendário highlightDates = (date: Date): string => { - const dateStr = date.toLocaleDateString(); - return this.highlightedDates.has(dateStr) ? 'highlight-date' : ''; + const chave = this.chaveData(date); + if (this.highlightedDatesV2.has(chave)) return 'orion-date-v2'; + if (this.highlightedDatesLegacy.has(chave)) return 'orion-date-legacy'; + return ''; }; btnFiltrarClick() { - // Limpa gráficos e dados antes de aplicar o filtro this.clearCharts(); this.FiltroSelecionado = null; - this.trabalhoSelecionado = null; - - // Extrai a data no formato YYYY-MM-DD - let DataSelecionadaStr = this.DataSelecionada.toISOString().split('T')[0]; + this.trabalhoSelecionado = []; + this.trabalhoSelecionadoOpcao = []; + this.sensoresTrabalho = []; + this.sensoresDiagnostico = []; + this.telemetria = this.criarResumoTelemetriaVazio(); + this.odometrosTrabalho = this.criarResumoOdometrosVazio(); - // Concatena a data formatada com a hora inicial e final - let DataInicial: Date = new Date(`${DataSelecionadaStr}T${(this.HoraInicial < 10 ? "0" : "") + this.HoraInicial}:00:00`); - let DataFinal: Date = new Date(`${DataSelecionadaStr}T${(this.HoraFinal < 0 ? "0" : "") + this.HoraFinal}:59:59`); + if (!this.DataSelecionada) { + this.LogsPeriodo = []; + this.NomesFiltro = []; + this.PerfisFiltro = []; + this.trabalhos = []; + return; + } - this.LogsDetalhesFiltrado = this.LogsDetalhes.filter(log => new Date(log.logObj.data) >= DataInicial && new Date(log.logObj.data) <= DataFinal); - this.dataSource.data = this.LogsDetalhesFiltrado; - this.dataSource.paginator = this.paginator; + const inicio = new Date( + this.DataSelecionada.getFullYear(), + this.DataSelecionada.getMonth(), + this.DataSelecionada.getDate(), + this.HoraInicial, 0, 0, 0 + ); - this.MapNome = this.groupBy(this.LogsDetalhesFiltrado, log => log.nome); - this.NomesFiltro = Array.from(this.MapNome.keys()); - this.txtPerfilChange(""); + const fim = new Date( + this.DataSelecionada.getFullYear(), + this.DataSelecionada.getMonth(), + this.DataSelecionada.getDate(), + this.HoraFinal, 59, 59, 999 + ); + + this.LogsPeriodo = this.LogsDetalhes + .filter(log => { + const inicioLog = this.dataInicioLog(log); + const fimLog = this.dataFimLog(log); + return fimLog >= inicio && inicioLog <= fim; + }) + .sort((a, b) => this.dataFimLog(a).getTime() - this.dataFimLog(b).getTime()); + + this.MapNome = this.groupBy(this.LogsPeriodo, log => this.nomeUsuario(log)); + this.NomesFiltro = Array.from(this.MapNome.keys()).sort((a, b) => a.localeCompare(b)); + + if (this.NomesFiltro.length === 0) { + this.UsuarioSelecionado = ''; + this.PerfilSelecionado = ''; + this.PerfisFiltro = []; + this.trabalhos = []; + return; + } + + if (this.NomesFiltro.indexOf(this.UsuarioSelecionado) < 0) { + this.UsuarioSelecionado = this.NomesFiltro[0]; + } + + this.txtNomeChange(this.UsuarioSelecionado); } - txtNomeChange(NomeSelecionado: string) { - // Limpa todas as informações de perfil, trabalho e gráficos ao selecionar novo nome - this.LogsDetalhesFiltrado = []; + txtNomeChange(nomeSelecionado: string) { + this.clearCharts(); this.FiltroSelecionado = null; + this.trabalhoSelecionado = []; + this.trabalhoSelecionadoOpcao = []; + this.sensoresTrabalho = []; + this.sensoresDiagnostico = []; + this.telemetria = this.criarResumoTelemetriaVazio(); + this.odometrosTrabalho = this.criarResumoOdometrosVazio(); this.PerfisFiltro = []; - this.trabalhoSelecionado = null; - - // Limpa gráficos - this.clearCharts(); - - // Carrega os perfis do novo usuário selecionado - this.MapPerfil = this.groupBy(this.MapNome.get(NomeSelecionado), log => log.perfil); - this.PerfisFiltro = Array.from(this.MapPerfil.keys()); + this.trabalhos = []; + + this.UsuarioSelecionado = nomeSelecionado || ''; + const logsUsuario = this.MapNome.get(this.UsuarioSelecionado) || []; + + this.MapPerfil = this.groupBy(logsUsuario, log => this.nomePerfil(log)); + this.PerfisFiltro = Array.from(this.MapPerfil.keys()).sort((a, b) => a.localeCompare(b)); + + if (this.PerfisFiltro.length === 0) { + this.PerfilSelecionado = ''; + return; + } + + if (this.PerfisFiltro.indexOf(this.PerfilSelecionado) < 0) { + this.PerfilSelecionado = this.PerfisFiltro[0]; + } + + this.txtPerfilChange(this.PerfilSelecionado); } - - txtPerfilChange(PerfilSelecionado: string) { - if (PerfilSelecionado != "") { - this.LogsDetalhesFiltrado = this.MapPerfil.get(PerfilSelecionado); - } - else { - this.LogsDetalhesFiltrado = []; - } - - // Agrupando logs por trabalhos baseados no intervalo de 5 minutos - let trabalhos = []; - let trabalhoAtual = []; - // Limpa os gráficos e trabalhos anteriores ao selecionar novo perfil - this.FiltroSelecionado = null; - this.trabalhoSelecionado = null; - this.selectedSensor = null; - - // Limpa gráficos + txtPerfilChange(perfilSelecionado: string) { this.clearCharts(); - - // Percorre os logs e separa em grupos - for (let i = 0; i < this.LogsDetalhesFiltrado.length; i++) { - const logAtual = this.LogsDetalhesFiltrado[i]; - const dataLogAtual = new Date(logAtual.dataLog); - - if (trabalhoAtual.length === 0) { - trabalhoAtual.push(logAtual); // Adiciona o primeiro log ao trabalho atual - } else { - const ultimoLog = trabalhoAtual[trabalhoAtual.length - 1]; - const dataUltimoLog = new Date(ultimoLog.dataLog); - - // Calcula a diferença em minutos entre o log atual e o último log do trabalho atual - const diffInMinutes = (dataLogAtual.getTime() - dataUltimoLog.getTime()) / 1000 / 60; - - if (diffInMinutes > this.tempoLog) { - // Se a diferença for maior que 5 minutos, inicia um novo trabalho - trabalhos.push(trabalhoAtual); - trabalhoAtual = [logAtual]; - } else { - // Caso contrário, adiciona o log ao trabalho atual - trabalhoAtual.push(logAtual); - } + this.FiltroSelecionado = null; + this.trabalhoSelecionado = []; + this.trabalhoSelecionadoOpcao = []; + this.sensoresTrabalho = []; + this.sensoresDiagnostico = []; + this.telemetria = this.criarResumoTelemetriaVazio(); + this.odometrosTrabalho = this.criarResumoOdometrosVazio(); + this.trabalhos = []; + this.PerfilSelecionado = perfilSelecionado || ''; + + if (!this.PerfilSelecionado) { + this.LogsDetalhesFiltrado = []; + return; + } + + this.LogsDetalhesFiltrado = (this.MapPerfil.get(this.PerfilSelecionado) || []) + .slice() + .sort((a, b) => this.dataInicioLog(a).getTime() - this.dataInicioLog(b).getTime()); + + this.trabalhos = this.agruparTrabalhos(this.LogsDetalhesFiltrado); + this.trabalhoSelecionadoOpcao = null; + } + + private agruparTrabalhos(logs: OrionLogsModel[]): OrionLogsModel[][] { + const trabalhos: OrionLogsModel[][] = []; + let atual: OrionLogsModel[] = []; + let fimAtual: Date = null; + + logs.forEach(log => { + const inicioLog = this.dataInicioLog(log); + const fimLog = this.dataFimLog(log); + + if (atual.length === 0) { + atual = [log]; + fimAtual = fimLog; + return; } - } - - // Adiciona o último trabalho ao array de trabalhos - if (trabalhoAtual.length > 0) { - trabalhos.push(trabalhoAtual); - } - - // Atualiza o estado com os trabalhos identificados - this.trabalhos = trabalhos; - if (this.trabalhos.length == 1) { - this.onTrabalhoSelecionado(trabalhos[0]); - } + + const gapMinutos = (inicioLog.getTime() - fimAtual.getTime()) / 60000; + + if (gapMinutos > this.tempoLog) { + trabalhos.push(atual); + atual = [log]; + fimAtual = fimLog; + } else { + atual.push(log); + if (fimLog > fimAtual) fimAtual = fimLog; + } + }); + + if (atual.length > 0) trabalhos.push(atual); + return trabalhos; } onTrabalhoSelecionado(trabalhoSelecionado: OrionLogsModel[]) { - this.trabalhoSelecionado = trabalhoSelecionado; - this.FiltroSelecionado = trabalhoSelecionado[trabalhoSelecionado.length - 1].logObj; - - // Atualizar detalhes como horário de início, fim, duração, etc. - let Ordenado = trabalhoSelecionado.sort((a, b) => b.dataLog < a.dataLog ? 1 : a.dataLog > b.dataLog ? -1 : 0); - - this.FiltroSelecionado.HoraInicio = new Date(Ordenado[0].dataLog).toLocaleTimeString(); - this.FiltroSelecionado.HoraFim = new Date(Ordenado[Ordenado.length - 1].dataLog).toLocaleTimeString(); - - var Dif = (new Date(Ordenado[Ordenado.length - 1].dataLog).getTime() - new Date(Ordenado[0].dataLog).getTime()) / 1000; - this.FiltroSelecionado.Duracao = this.SegundosParaTempo(Dif); - - this.FiltroSelecionado.Coordenadas = trabalhoSelecionado[trabalhoSelecionado.length - 1].coordenadas; - this.orionService.coordenadaToEndereco(this.FiltroSelecionado.Coordenadas).then(res => { - console.log(res); - if (res && res.data && res.data.length > 0) { - this.FiltroSelecionado.Localizacao = res.data[0].label; - } - }) - - // Atualiza a interface com os dados filtrados - this.btnAtualizarClick(); - } - - btnAtualizarClick() { - if (this.selectedSensor == null) { - this.selectedSensor = this.FiltroSelecionado.sensores[0]; + if (!trabalhoSelecionado || trabalhoSelecionado.length === 0) { + this.FiltroSelecionado = null; + return; } - this.renderChart(this.selectedSensor, 'chart_' + this.selectedSensor.sensor + '_' + this.selectedSensor.descricao); + + this.clearCharts(); + + const ordenado = trabalhoSelecionado + .slice() + .sort((a, b) => this.dataInicioLog(a).getTime() - this.dataInicioLog(b).getTime()); + + this.trabalhoSelecionadoOpcao = trabalhoSelecionado; + this.trabalhoSelecionado = ordenado; + + const inicio = ordenado.reduce( + (menor, log) => this.dataInicioLog(log) < menor ? this.dataInicioLog(log) : menor, + this.dataInicioLog(ordenado[0]) + ); + + const fim = ordenado.reduce( + (maior, log) => this.dataFimLog(log) > maior ? this.dataFimLog(log) : maior, + this.dataFimLog(ordenado[0]) + ); + + const ultimo = ordenado[ordenado.length - 1]; + const detalhes = Object.assign({}, ultimo.logObj || {}) as OrionLogsDetalhesModel; + + detalhes.HoraInicio = inicio.toLocaleTimeString(); + detalhes.HoraFim = fim.toLocaleTimeString(); + detalhes.Duracao = this.SegundosParaTempo(Math.max(0, (fim.getTime() - inicio.getTime()) / 1000)); + detalhes.Coordenadas = this.coordenadasLog(ultimo); + detalhes.Localizacao = ''; + + this.sensoresTrabalho = this.obterSensoresTrabalho(ordenado); + detalhes.sensores = this.sensoresTrabalho; + this.FiltroSelecionado = detalhes; + + const eventos = new Set(); + const schemas = new Set(); + const distancias: number[] = []; + const potencias: number[] = []; + const rolls: number[] = []; + const pitches: number[] = []; + const infiltracoes: number[] = []; + const altitudes: number[] = []; + const mensagensEventos = new Set(); + let infiltracaoDetectada = false; + let mascaraSaudeUltima: number = null; + let capacidades = ''; + + ordenado.forEach(log => { + const obj = log.logObj; + if (!obj) return; + + if (obj.tipoEvento) eventos.add(obj.tipoEvento); + if (obj.schemaVersao !== undefined && obj.schemaVersao !== null) { + schemas.add(String(obj.schemaVersao)); + } else { + schemas.add('legado'); + } + + const distancia = Number(obj.distancia); + if (Number.isFinite(distancia)) distancias.push(distancia); + + const protocoloNovo = Number(obj.protocoloVersao) >= 2; + + const potencia = Number(obj.potenciaW); + if (Number.isFinite(potencia) && (protocoloNovo || potencia !== 0)) potencias.push(potencia); + + const roll = Number(obj.rollDeg); + if (Number.isFinite(roll) && (protocoloNovo || roll !== 0)) rolls.push(roll); + + const pitch = Number(obj.pitchDeg); + if (Number.isFinite(pitch) && (protocoloNovo || pitch !== 0)) pitches.push(pitch); + + const infiltracaoRaw = Number(obj.infiltracaoRaw); + if (Number.isFinite(infiltracaoRaw)) infiltracoes.push(infiltracaoRaw); + + const altitude = Number(obj.altitude); + if (Number.isFinite(altitude)) altitudes.push(altitude); + + if (obj.alarmeInfiltracao === true) infiltracaoDetectada = true; + if (obj.mascaraSaude !== undefined && obj.mascaraSaude !== null) { + const mascara = Number(obj.mascaraSaude); + if (Number.isFinite(mascara) && (protocoloNovo || mascara !== 0)) { + mascaraSaudeUltima = mascara; + } + } + if (obj.capacidades) capacidades = String(obj.capacidades); + if (obj.mensagemEvento) mensagensEventos.add(String(obj.mensagemEvento)); + }); + + let distanciaAproximada = 0; + for (let i = 1; i < distancias.length; i++) { + distanciaAproximada += Math.abs(distancias[i] - distancias[i - 1]); + } + + const deslocamentoLiquido = distancias.length > 1 + ? Math.abs(distancias[distancias.length - 1] - distancias[0]) + : 0; + + this.resumoTrabalho = { + inicio, + fim, + duracaoSegundos: Math.max(0, (fim.getTime() - inicio.getTime()) / 1000), + totalLogs: ordenado.length, + eventos: Array.from(eventos.values()).join(', ') || 'Legado', + schemas: Array.from(schemas.values()).join(', '), + distanciaPercorrida: distanciaAproximada + }; + + this.montarResumoOdometros(ordenado); + + this.diagnosticoTrabalho = { + potenciaMediaW: potencias.length + ? potencias.reduce((total, valor) => total + valor, 0) / potencias.length + : null, + potenciaUltimaW: potencias.length ? potencias[potencias.length - 1] : null, + rollMaxAbs: rolls.length ? Math.max.apply(null, rolls.map(valor => Math.abs(valor))) : null, + pitchMaxAbs: pitches.length ? Math.max.apply(null, pitches.map(valor => Math.abs(valor))) : null, + infiltracaoDetectada, + infiltracaoRawMax: infiltracoes.length ? Math.max.apply(null, infiltracoes) : null, + mascaraSaudeUltima, + capacidades, + altitudeMedia: altitudes.length + ? altitudes.reduce((total, valor) => total + valor, 0) / altitudes.length + : null, + odometroParcial: Number(detalhes.odometroMotoresParcial) || 0, + tempoTotal: Number(detalhes.tempoTotal) || 0, + idCliente: detalhes.idCliente !== undefined && detalhes.idCliente !== null + ? Number(detalhes.idCliente) + : null, + cpf: detalhes.cpf || '', + logIdUltimo: detalhes.logId || '', + deslocamentoLiquido, + mensagensEventos: Array.from(mensagensEventos.values()) + }; + + this.montarResumoTelemetria(); + this.montarSensoresDiagnostico(); + + const coordenadas = detalhes.Coordenadas; + if (coordenadas) { + this.orionService.coordenadaToEndereco(coordenadas).then(res => { + if (this.FiltroSelecionado && res && res.data && res.data.length > 0) { + this.FiltroSelecionado.Localizacao = res.data[0].label; + } + }).catch(() => { }); + } + + setTimeout(() => this.renderizarDashboardTelemetria(), 100); } - groupBy(list, keyGetter) { - const map = new Map(); - list.forEach((item) => { + private criarResumoOdometrosVazio() { + const vazio = () => ({ + inicial: null as number, + final: null as number, + delta: null as number, + reiniciado: false + }); + + return { + total: vazio(), + conectado: vazio(), + motores: vazio(), + parcial: vazio(), + movimentoPercentual: null as number, + conectadoPercentual: null as number + }; + } + + private montarResumoOdometros(logs: OrionLogsModel[]) { + if (!logs || logs.length === 0) { + this.odometrosTrabalho = this.criarResumoOdometrosVazio(); + return; + } + + const primeiro = logs[0] && logs[0].logObj ? logs[0].logObj : null; + const ultimo = logs[logs.length - 1] && logs[logs.length - 1].logObj + ? logs[logs.length - 1].logObj + : null; + + const linha = (campo: string) => { + const inicial = primeiro ? this.numeroOuNull((primeiro as any)[campo]) : null; + const final = ultimo ? this.numeroOuNull((ultimo as any)[campo]) : null; + const reiniciado = inicial !== null && final !== null && final < inicial; + + return { + inicial, + final, + delta: inicial !== null && final !== null && !reiniciado + ? final - inicial + : null, + reiniciado + }; + }; + + const total = linha('odometroTotal'); + const conectado = linha('odometroConectado'); + const motores = linha('odometroMotores'); + const parcial = linha('odometroMotoresParcial'); + + const baseDelta = total.delta !== null && total.delta > 0 + ? total.delta + : null; + + this.odometrosTrabalho = { + total, + conectado, + motores, + parcial, + movimentoPercentual: baseDelta !== null && motores.delta !== null + ? Math.max(0, Math.min(100, motores.delta * 100 / baseDelta)) + : null, + conectadoPercentual: baseDelta !== null && conectado.delta !== null + ? Math.max(0, Math.min(100, conectado.delta * 100 / baseDelta)) + : null + }; + } + + formatarDeltaOdometro(item: any): string { + if (!item) return '-'; + if (item.reiniciado) return 'reiniciado'; + if (item.delta === null || item.delta === undefined) return '-'; + return '+' + this.SegundosParaTempo(item.delta); + } + + groupBy(list: T[], keyGetter: (item: T) => string): Map { + const map = new Map(); + (list || []).forEach(item => { const key = keyGetter(item); const collection = map.get(key); - if (!collection) { - map.set(key, [item]); - } - else { - collection.push(item); - } + if (!collection) map.set(key, [item]); + else collection.push(item); }); return map; } - SegundosParaTempo(Segundos: number) : string { - return new Date(Segundos * 1000).toISOString().substring(11, 19); + SegundosParaTempo(segundos: number): string { + const total = Math.max(0, Math.floor(Number(segundos) || 0)); + const horas = Math.floor(total / 3600); + const minutos = Math.floor((total % 3600) / 60); + const segundosRestantes = total % 60; + return this.pad2(horas) + ':' + this.pad2(minutos) + ':' + this.pad2(segundosRestantes); } - onTabChange(event: MatTabChangeEvent) { - this.selectedSensor = this.FiltroSelecionado.sensores[event.index]; - const chartId = `chart_${this.selectedSensor.sensor}_${this.selectedSensor.descricao}`; - this.renderChart(this.selectedSensor, chartId); - } - - renderChart(sensor: OrionLogSensorModel, chartId: string) { - setTimeout(() => { - const canvasElement = document.getElementById(chartId) as HTMLCanvasElement; - if (canvasElement) { - const context = canvasElement.getContext('2d'); - if (context) { - // Use os logs filtrados com base no trabalho selecionado - const DadosSensoresModa = this.trabalhoSelecionado.map(log => log.logObj.sensores.filter(s => s.sensor === sensor.sensor && s.descricao === sensor.descricao)[0].moda); - const DadosSensoresMedia = this.trabalhoSelecionado.map(log => log.logObj.sensores.filter(s => s.sensor === sensor.sensor && s.descricao === sensor.descricao)[0].media); - const DadosSensoresMediana = this.trabalhoSelecionado.map(log => log.logObj.sensores.filter(s => s.sensor === sensor.sensor && s.descricao === sensor.descricao)[0].mediana); + trabalhoLabel(trabalho: OrionLogsModel[]): string { + if (!trabalho || trabalho.length === 0) return ''; + const inicio = this.dataInicioLog(trabalho[0]); + const fim = this.dataFimLog(trabalho[trabalho.length - 1]); + return inicio.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + + ' - ' + + fim.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + + ' (' + trabalho.length + ' logs)'; + } - const myChart = new Chart(chartId, { - type: 'line', - data: { - labels: this.trabalhoSelecionado.map(log => new Date(log.logObj.data).toLocaleTimeString()), - datasets: [ - { - label: 'Moda', - data: DadosSensoresModa, - backgroundColor: 'rgba(255, 99, 132, 0.2)', - borderColor: 'rgba(255, 99, 132, 1)', - borderWidth: 1, - }, - { - label: 'Média', - data: DadosSensoresMedia, - backgroundColor: 'rgba(54, 162, 235, 0.2)', - borderColor: 'rgba(54, 162, 235, 1)', - borderWidth: 1, - }, - { - label: 'Mediana', - data: DadosSensoresMediana, - backgroundColor: 'rgba(255, 206, 86, 0.2)', - borderColor: 'rgba(255, 206, 86, 1)', - borderWidth: 1, - }, - ], - }, - options: { - scales: { - y: { - beginAtZero: true, - }, - }, - }, - }); - } else { - console.error(`Não foi possível adquirir o contexto para o gráfico com id ${chartId}`); - } + eventoAtual(): string { + if (!this.FiltroSelecionado) return ''; + return this.FiltroSelecionado.tipoEvento || 'Log legado'; + } + + formatarMedida(valor: any, casas: number = 1, unidade: string = ''): string { + if (valor === null || valor === undefined || !Number.isFinite(Number(valor))) return '-'; + const numero = Number(valor); + const texto = numero.toFixed(Math.max(0, casas)).replace('.', ','); + return unidade ? texto + ' ' + unidade : texto; + } + + formatarCorrente(valor: any): string { + if (valor === null || valor === undefined || !Number.isFinite(Number(valor))) return '-'; + const numero = Number(valor); + if (Math.abs(numero) >= 100) return this.formatarMedida(numero / 1000, 2, 'A'); + return this.formatarMedida(numero, 2, 'A'); + } + + mascaraSaudeTexto(): string { + const valor = this.diagnosticoTrabalho.mascaraSaudeUltima; + if (valor === null || valor === undefined || !Number.isFinite(Number(valor))) return '-'; + const numero = Math.max(0, Math.floor(Number(valor))); + const hex = numero.toString(16).toUpperCase(); + return '0x' + (hex.length < 2 ? '0' + hex : hex) + ' (' + numero + ')'; + } + + nomeSensorAmigavel(sensor: OrionLogSensorModel): string { + if (!sensor) return 'Sensor'; + const descricao = (sensor.descricao || '').trim(); + const id = Number(sensor.sensor); + + if (id === 5 && descricao.toLowerCase() === 'porcentagem') return 'Bateria (%)'; + if (id === 6 && descricao.toLowerCase() === 'porcentagem') return 'Corrente (% da escala)'; + if (id === 7 && descricao.toLowerCase() === 'porcentagem') return 'Velocidade comandada (%)'; + if (id === 7 && descricao.toLowerCase() === 'velocidade') return 'Velocidade (m/s)'; + if (id === 9 && descricao.toLowerCase() === 'distância') return 'Distância acumulada (m)'; + if (id === 9 && descricao.toLowerCase() === 'distancia') return 'Distância acumulada (m)'; + if (id === 5 && descricao.toLowerCase() === 'tensão') return 'Tensão da bateria (V)'; + if (id === 5 && descricao.toLowerCase() === 'tensao') return 'Tensão da bateria (V)'; + if (id === 6 && descricao.toLowerCase() === 'corrente') return 'Corrente'; + if (id === 3 && descricao.toLowerCase() === 'inclinação frontal') return 'Inclinação frontal (°)'; + if (id === 3 && descricao.toLowerCase() === 'inclinação lateral') return 'Inclinação lateral (°)'; + if (id === 3 && descricao.toLowerCase() === 'altitude') return 'Altitude (m)'; + if (id === 8 && descricao.toLowerCase() === 'temperatura') return 'Temperatura (°C)'; + if (id === 2 && descricao.toLowerCase() === 'umidade') return 'Umidade (%)'; + return descricao || 'Sensor ' + id; + } + + temDadosTelemetria(): boolean { + return this.telemetria.temEnergia || + this.telemetria.temMovimento || + this.telemetria.temEstabilidade || + this.telemetria.temAmbiente || + this.telemetria.temSeguranca; + } + + private montarResumoTelemetria() { + const bateria = this.sensorAgregado(5, 'Porcentagem'); + const tensao = this.sensorAgregadoFlexivel(5, ['Tensão', 'Tensao']); + const corrente = this.sensorAgregado(6, 'Corrente'); + const correntePct = this.sensorAgregado(6, 'Porcentagem'); + const velocidade = this.sensorAgregado(7, 'Velocidade'); + const comandoVel = this.sensorAgregado(7, 'Porcentagem'); + const distancia = this.sensorAgregadoFlexivel(9, ['Distância', 'Distancia']); + const inclinacaoFrontal = this.sensorAgregado(3, 'Inclinação Frontal'); + const inclinacaoLateral = this.sensorAgregado(3, 'Inclinação Lateral'); + const temperatura = this.sensorAgregado(8, 'Temperatura'); + const altitudeSensor = this.sensorAgregado(3, 'Altitude'); + const umidade = this.sensorAgregado(2, 'Umidade'); + const gas = this.sensorAgregado(1, 'Gás'); + const ultrassom = this.sensorAgregado(4, 'Ultrassom'); + const infiltracaoRisco = this.sensorAgregado(12, 'Infiltração - nível de risco'); + const infiltracaoRaw = this.sensorAgregado(12, 'Infiltração - leitura bruta'); + + this.telemetria = { + bateriaMin: this.valorSensor(bateria, 'minimo'), + bateriaMedia: this.valorSensor(bateria, 'media'), + bateriaFinal: this.valorSensor(bateria, 'ultimo'), + tensaoMin: this.valorSensor(tensao, 'minimo'), + tensaoMedia: this.valorSensor(tensao, 'media'), + tensaoFinal: this.valorSensor(tensao, 'ultimo'), + correnteMedia: this.valorSensor(corrente, 'media'), + correnteMax: this.valorSensor(corrente, 'maximo'), + correntePctMedia: this.valorSensor(correntePct, 'media'), + potenciaMediaW: this.diagnosticoTrabalho.potenciaMediaW, + velocidadeMedia: this.valorSensor(velocidade, 'media'), + velocidadeMax: this.valorSensor(velocidade, 'maximo'), + velocidadeFinal: this.valorSensor(velocidade, 'ultimo'), + comandoVelMedia: this.valorSensor(comandoVel, 'media'), + comandoVelMax: this.valorSensor(comandoVel, 'maximo'), + comandoVelFinal: this.valorSensor(comandoVel, 'ultimo'), + distanciaFinal: this.valorSensor(distancia, 'ultimo'), + inclinacaoFrontalMedia: this.valorSensor(inclinacaoFrontal, 'media'), + inclinacaoFrontalMaxAbs: this.maxAbsSensor(inclinacaoFrontal), + inclinacaoLateralMedia: this.valorSensor(inclinacaoLateral, 'media'), + inclinacaoLateralMaxAbs: this.maxAbsSensor(inclinacaoLateral), + temperaturaMedia: this.valorSensor(temperatura, 'media'), + temperaturaMin: this.valorSensor(temperatura, 'minimo'), + temperaturaMax: this.valorSensor(temperatura, 'maximo'), + altitudeMedia: this.valorSensor(altitudeSensor, 'media'), + altitudeMin: this.valorSensor(altitudeSensor, 'minimo'), + altitudeMax: this.valorSensor(altitudeSensor, 'maximo'), + umidadeMedia: this.valorSensor(umidade, 'media'), + umidadeMin: this.valorSensor(umidade, 'minimo'), + umidadeMax: this.valorSensor(umidade, 'maximo'), + gasMedia: this.valorSensor(gas, 'media'), + gasMax: this.valorSensor(gas, 'maximo'), + ultrassomMin: this.valorSensor(ultrassom, 'minimo'), + infiltracaoRiscoMax: this.valorSensor(infiltracaoRisco, 'maximo'), + infiltracaoRawMax: this.valorSensor(infiltracaoRaw, 'maximo'), + temEnergia: this.sensorComDados(bateria) || this.sensorComDados(tensao) || this.sensorComDados(corrente) || this.diagnosticoTrabalho.potenciaMediaW !== null, + temMovimento: this.sensorComDados(velocidade) || this.sensorComDados(comandoVel) || this.sensorComDados(distancia), + temEstabilidade: this.sensorComDados(inclinacaoFrontal) || this.sensorComDados(inclinacaoLateral) || this.diagnosticoTrabalho.rollMaxAbs !== null || this.diagnosticoTrabalho.pitchMaxAbs !== null, + temAmbiente: this.sensorComDados(temperatura) || this.sensorComDados(altitudeSensor) || this.sensorComDados(umidade) || this.sensorComDados(gas), + temSeguranca: this.sensorComDados(ultrassom) || this.sensorComDados(infiltracaoRisco) || this.sensorComDados(infiltracaoRaw) || this.diagnosticoTrabalho.infiltracaoDetectada, + temBateria: this.sensorComDados(bateria), + temTensao: this.sensorComDados(tensao), + temCorrente: this.sensorComDados(corrente), + temVelocidade: this.sensorComDados(velocidade), + temComandoVel: this.sensorComDados(comandoVel), + temDistancia: this.sensorComDados(distancia), + temInclinacaoFrontal: this.sensorComDados(inclinacaoFrontal), + temInclinacaoLateral: this.sensorComDados(inclinacaoLateral), + temTemperatura: this.sensorComDados(temperatura), + temAltitude: this.sensorComDados(altitudeSensor), + temUmidade: this.sensorComDados(umidade), + temGas: this.sensorComDados(gas), + temUltrassom: this.sensorComDados(ultrassom), + temInfiltracaoSerie: this.sensorComDados(infiltracaoRisco) || this.sensorComDados(infiltracaoRaw) + }; + } + + private montarSensoresDiagnostico() { + this.sensoresDiagnostico = this.sensoresTrabalho + .filter(sensor => this.sensorComDados(sensor)) + .map(sensor => ({ + nome: this.nomeSensorAmigavel(sensor), + sensor + })) + .sort((a, b) => a.nome.localeCompare(b.nome)); + } + + private renderizarDashboardTelemetria() { + this.clearCharts(); + if (!this.trabalhoSelecionado || this.trabalhoSelecionado.length === 0) return; + + if (this.telemetria.temTensao) { + this.criarGraficoLinha('chart_tensao', [ + this.datasetSensor('Tensão média', 5, ['Tensão', 'Tensao'], 'media') + ]); + } + + if (this.telemetria.temBateria) { + this.criarGraficoLinha('chart_bateria', [ + this.datasetSensor('Bateria', 5, ['Porcentagem'], 'media') + ], true, 0, 100); + } + + if (this.telemetria.temCorrente) { + const serie = this.serieSensorFlexivel(6, ['Corrente'], 'media').map(valor => { + if (valor === null) return null; + return Math.abs(valor) >= 100 ? valor / 1000 : valor; + }); + this.criarGraficoLinha('chart_corrente', [{ label: 'Corrente', data: serie }]); + } + + if (this.telemetria.temVelocidade || this.telemetria.temComandoVel) { + const datasets: any[] = []; + if (this.telemetria.temVelocidade) { + datasets.push({ + label: 'Velocidade (m/s)', + data: this.serieSensorFlexivel(7, ['Velocidade'], 'media'), + yAxisID: 'y' + }); } - }, 100); // Pequeno atraso de 100ms + if (this.telemetria.temComandoVel) { + datasets.push({ + label: 'Comando (%)', + data: this.serieSensorFlexivel(7, ['Porcentagem'], 'media'), + yAxisID: 'yPercent' + }); + } + this.criarGraficoVelocidade('chart_velocidade', datasets); + } + + if (this.telemetria.temDistancia) { + this.criarGraficoLinha('chart_distancia', [ + this.datasetSensor('Distância acumulada', 9, ['Distância', 'Distancia'], 'ultimo') + ]); + } + + if (this.telemetria.temInclinacaoFrontal || this.telemetria.temInclinacaoLateral) { + const datasets: any[] = []; + if (this.telemetria.temInclinacaoFrontal) { + datasets.push(this.datasetSensor('Frontal', 3, ['Inclinação Frontal'], 'media')); + } + if (this.telemetria.temInclinacaoLateral) { + datasets.push(this.datasetSensor('Lateral', 3, ['Inclinação Lateral'], 'media')); + } + this.criarGraficoLinha('chart_inclinacao', datasets); + } + + if (this.telemetria.temTemperatura) { + this.criarGraficoLinha('chart_temperatura', [ + this.datasetSensor('Temperatura', 8, ['Temperatura'], 'media') + ]); + } + + if (this.telemetria.temAltitude) { + this.criarGraficoLinha('chart_altitude', [ + this.datasetSensor('Altitude', 3, ['Altitude'], 'media') + ]); + } + + if (this.telemetria.temUmidade) { + this.criarGraficoLinha('chart_umidade', [ + this.datasetSensor('Umidade', 2, ['Umidade'], 'media') + ], true, 0, 100); + } + + if (this.telemetria.temGas) { + this.criarGraficoLinha('chart_gas', [ + this.datasetSensor('Gás', 1, ['Gás'], 'media') + ]); + } + + if (this.telemetria.temUltrassom) { + this.criarGraficoLinha('chart_ultrassom', [ + this.datasetSensor('Ultrassom', 4, ['Ultrassom'], 'media') + ]); + } } - clearCharts() { - const canvasElements = document.querySelectorAll('canvas'); - canvasElements.forEach((canvas) => { - const context = canvas.getContext('2d'); - if (context) { - context.clearRect(0, 0, canvas.width, canvas.height); // Limpa os gráficos + private criarGraficoVelocidade(chartId: string, datasets: any[]) { + this.criarGrafico(chartId, datasets, { + y: { + beginAtZero: true, + title: { display: true, text: 'm/s' } + }, + yPercent: { + beginAtZero: true, + min: 0, + max: 100, + position: 'right', + grid: { drawOnChartArea: false }, + title: { display: true, text: '%' } } }); } - + private criarGraficoLinha( + chartId: string, + datasets: any[], + beginAtZero: boolean = false, + min: number = null, + max: number = null + ) { + const y: any = { beginAtZero }; + if (min !== null) y.min = min; + if (max !== null) y.max = max; + this.criarGrafico(chartId, datasets, { y }); + } + + private criarGrafico(chartId: string, datasets: any[], scales: any) { + const canvasElement = document.getElementById(chartId) as HTMLCanvasElement; + if (!canvasElement) return; + const context = canvasElement.getContext('2d'); + if (!context) return; + + const paleta = [ + '#2f7dd1', + '#ef8a3a', + '#39a96b', + '#9b66d9' + ]; + + /* + * Este projeto ainda usa Chart.js 2.x. A V2.3 nasceu com parte das + * opções no formato do Chart.js 3+, que é tolerado nos gráficos simples, + * mas quebra quando um dataset referencia um segundo eixo (yPercent). + * + * Mantemos suporte aos dois formatos para a dashboard não voltar a + * quebrar caso o pacote seja atualizado no futuro. + */ + const chartCtor: any = Chart as any; + const versaoChart = String(chartCtor.version || '2'); + const majorChart = parseInt(versaoChart.split('.')[0], 10); + const chartJs2 = !Number.isFinite(majorChart) || majorChart < 3; + + const datasetsValidos = (datasets || []) + .filter(dataset => + dataset && + Array.isArray(dataset.data) && + dataset.data.some((valor: any) => valor !== null && valor !== undefined) + ) + .map((dataset, index) => ({ + ...dataset, + borderColor: dataset.borderColor || paleta[index % paleta.length], + backgroundColor: dataset.backgroundColor || paleta[index % paleta.length], + borderWidth: index === 0 ? 2.4 : 1.8, + pointRadius: 2, + pointHoverRadius: 4, + fill: false, + // Chart.js 2.x usa lineTension; 3+ usa tension. + lineTension: 0.22, + tension: 0.22, + spanGaps: true + })); + + if (!datasetsValidos.length) return; + + if (this.charts[chartId]) { + try { this.charts[chartId].destroy(); } catch { } + delete this.charts[chartId]; + } + + const labels = this.trabalhoSelecionado.map(log => + this.dataFimLog(log).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + ); + + let options: any; + + if (chartJs2) { + const yAxes: any[] = []; + const definicoes = scales || {}; + + Object.keys(definicoes).forEach(axisId => { + const eixo = definicoes[axisId] || {}; + const ticks: any = { + beginAtZero: !!eixo.beginAtZero + }; + + if (eixo.min !== null && eixo.min !== undefined) + ticks.min = eixo.min; + if (eixo.max !== null && eixo.max !== undefined) + ticks.max = eixo.max; + + yAxes.push({ + id: axisId, + type: 'linear', + position: eixo.position || 'left', + ticks, + gridLines: { + drawOnChartArea: !(eixo.grid && eixo.grid.drawOnChartArea === false) + }, + scaleLabel: { + display: !!(eixo.title && eixo.title.display), + labelString: eixo.title && eixo.title.text ? eixo.title.text : '' + } + }); + }); + + // Sempre deixa pelo menos um eixo válido para o controller.line do v2. + if (yAxes.length === 0) { + yAxes.push({ + id: 'y', + type: 'linear', + position: 'left', + ticks: { beginAtZero: false } + }); + } + + options = { + responsive: true, + maintainAspectRatio: false, + legend: { + display: datasetsValidos.length > 1, + position: 'bottom' + }, + tooltips: { + mode: 'index', + intersect: false + }, + hover: { + mode: 'nearest', + intersect: false + }, + scales: { + xAxes: [{ + ticks: { + autoSkip: true, + maxTicksLimit: 10 + } + }], + yAxes + } + }; + } + else { + options = { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + plugins: { + legend: { + display: datasetsValidos.length > 1, + position: 'bottom' + } + }, + scales + }; + } + + try { + this.charts[chartId] = new Chart(context as any, { + type: 'line', + data: { + labels, + datasets: datasetsValidos + }, + options + } as any); + } + catch (ex) { + // Um gráfico com problema não pode impedir os demais cards de renderizar. + delete this.charts[chartId]; + console.error('[TELEMETRIA] Falha ao criar gráfico ' + chartId, ex); + } + } + + private datasetSensor( + label: string, + sensorId: number, + descricoes: string[], + campo: 'media' | 'mediana' | 'ultimo' | 'moda' + ): any { + return { + label, + data: this.serieSensorFlexivel(sensorId, descricoes, campo) + }; + } + + private serieSensorFlexivel( + sensorId: number, + descricoes: string[], + campo: 'media' | 'mediana' | 'ultimo' | 'moda' + ): Array { + return this.trabalhoSelecionado.map(log => { + const sensor = this.buscarSensorFlexivel(log, sensorId, descricoes); + + // Sensor presente no snapshot não significa leitura válida. Alguns logs + // carregam o objeto com estatísticas zeradas e quantidadeAmostras = 0. + // Tratar isso como zero cria quedas artificiais nos gráficos. + if (!sensor || !this.sensorComDados(sensor)) + return null; + + return this.numeroOuNull((sensor as any)[campo]); + }); + } + + clearCharts() { + Object.keys(this.charts).forEach(chave => { + try { this.charts[chave].destroy(); } catch { } + }); + this.charts = {}; + } + + private criarResumoTelemetriaVazio() { + return { + bateriaMin: null as number, + bateriaMedia: null as number, + bateriaFinal: null as number, + tensaoMin: null as number, + tensaoMedia: null as number, + tensaoFinal: null as number, + correnteMedia: null as number, + correnteMax: null as number, + correntePctMedia: null as number, + potenciaMediaW: null as number, + velocidadeMedia: null as number, + velocidadeMax: null as number, + velocidadeFinal: null as number, + comandoVelMedia: null as number, + comandoVelMax: null as number, + comandoVelFinal: null as number, + distanciaFinal: null as number, + inclinacaoFrontalMedia: null as number, + inclinacaoFrontalMaxAbs: null as number, + inclinacaoLateralMedia: null as number, + inclinacaoLateralMaxAbs: null as number, + temperaturaMedia: null as number, + temperaturaMin: null as number, + temperaturaMax: null as number, + altitudeMedia: null as number, + altitudeMin: null as number, + altitudeMax: null as number, + umidadeMedia: null as number, + umidadeMin: null as number, + umidadeMax: null as number, + gasMedia: null as number, + gasMax: null as number, + ultrassomMin: null as number, + infiltracaoRiscoMax: null as number, + infiltracaoRawMax: null as number, + temEnergia: false, + temMovimento: false, + temEstabilidade: false, + temAmbiente: false, + temSeguranca: false, + temBateria: false, + temTensao: false, + temCorrente: false, + temVelocidade: false, + temComandoVel: false, + temDistancia: false, + temInclinacaoFrontal: false, + temInclinacaoLateral: false, + temTemperatura: false, + temAltitude: false, + temUmidade: false, + temGas: false, + temUltrassom: false, + temInfiltracaoSerie: false + }; + } + + private sensorAgregado(sensorId: number, descricao: string): OrionLogSensorModel { + return this.sensoresTrabalho.find(sensor => + Number(sensor.sensor) === Number(sensorId) && + this.normalizarTextoSensor(sensor.descricao) === this.normalizarTextoSensor(descricao) + ) || null; + } + + private sensorAgregadoFlexivel(sensorId: number, descricoes: string[]): OrionLogSensorModel { + for (const descricao of descricoes) { + const sensor = this.sensorAgregado(sensorId, descricao); + if (sensor) return sensor; + } + return null; + } + + private sensorComDados(sensor: OrionLogSensorModel): boolean { + return !!sensor && (Number(sensor.quantidadeAmostras) || 0) > 0; + } + + private valorSensor(sensor: OrionLogSensorModel, campo: string): number { + if (!this.sensorComDados(sensor)) return null; + return this.numeroOuNull((sensor as any)[campo]); + } + + private maxAbsSensor(sensor: OrionLogSensorModel): number { + if (!this.sensorComDados(sensor)) return null; + const min = this.numeroOuNull(sensor.minimo); + const max = this.numeroOuNull(sensor.maximo); + if (min === null && max === null) return null; + return Math.max(Math.abs(min || 0), Math.abs(max || 0)); + } + + private registrarDatasCalendario(log: OrionLogsModel, datas: Set) { + const inicio = this.dataInicioLog(log); + const fim = this.dataFimLog(log); + if (isNaN(inicio.getTime()) || isNaN(fim.getTime())) return; + + let primeiro = inicio <= fim ? inicio : fim; + let ultimo = inicio <= fim ? fim : inicio; + primeiro = new Date(primeiro.getFullYear(), primeiro.getMonth(), primeiro.getDate()); + ultimo = new Date(ultimo.getFullYear(), ultimo.getMonth(), ultimo.getDate()); + + const schema2 = !!(log && log.logObj && + (Number(log.logObj.schemaVersao) >= 2 || log.logObj.periodoInicio || log.logObj.periodoFim)); + + const atual = new Date(primeiro.getTime()); + let dias = 0; + while (atual <= ultimo && dias < 31) { + const chave = this.chaveData(atual); + datas.add(chave); + if (schema2) this.highlightedDatesV2.add(chave); + else if (!this.highlightedDatesV2.has(chave)) this.highlightedDatesLegacy.add(chave); + atual.setDate(atual.getDate() + 1); + dias++; + } + } + + private normalizarLog(log: OrionLogsModel): OrionLogsModel { + if (!log) return null; + + let obj: any = null; + try { + obj = typeof log.log === 'string' ? JSON.parse(log.log) : log.log; + if (typeof obj === 'string') obj = JSON.parse(obj); + log.jsonValido = !!obj && typeof obj === 'object'; + } catch (error) { + log.jsonValido = false; + log.erroParse = error && (error as any).message + ? (error as any).message + : 'JSON inválido'; + obj = {}; + } + + if (!Array.isArray(obj.sensores)) obj.sensores = []; + + obj.sensores = obj.sensores.map(sensor => ({ + ...sensor, + lista: Array.isArray(sensor.lista) ? sensor.lista : [], + quantidadeAmostras: sensor.quantidadeAmostras !== undefined + ? Number(sensor.quantidadeAmostras) || 0 + : (Array.isArray(sensor.lista) ? sensor.lista.length : 0), + ultimo: sensor.ultimo !== undefined + ? Number(sensor.ultimo) || 0 + : (Array.isArray(sensor.lista) && sensor.lista.length > 0 + ? Number(sensor.lista[sensor.lista.length - 1]) || 0 + : 0) + })); + + log.logObj = obj as OrionLogsDetalhesModel; + log.nome = this.primeiroTexto([log.nome, obj.usuario, obj.cpf, 'Não identificado']); + log.perfil = this.primeiroTexto([log.perfil, obj.perfil, 'Sem perfil']); + log.schemaVersao = obj.schemaVersao !== undefined ? Number(obj.schemaVersao) : null; + log.tipoEvento = obj.tipoEvento || null; + log.periodoInicio = obj.periodoInicio || null; + log.periodoFim = obj.periodoFim || null; + log.dataOperacao = obj.periodoFim || obj.data || log.dataLog; + + const coordenadas = this.coordenadasObjeto(obj); + if (coordenadas) log.coordenadas = coordenadas; + return log; + } + + private obterSensoresTrabalho(logs: OrionLogsModel[]): OrionLogSensorModel[] { + const grupos = new Map(); + + logs.forEach(log => { + const lista = log.logObj && Array.isArray(log.logObj.sensores) + ? log.logObj.sensores + : []; + + lista.forEach(sensor => { + const chave = Number(sensor.sensor) + '|' + this.normalizarTextoSensor(sensor.descricao); + if (!grupos.has(chave)) grupos.set(chave, []); + grupos.get(chave).push(sensor); + }); + }); + + const resultado: OrionLogSensorModel[] = []; + + grupos.forEach(itens => { + if (!itens.length) return; + const valores: number[] = []; + + itens.forEach(item => { + if (Array.isArray(item.lista)) { + item.lista.forEach(valor => { + const numero = Number(valor); + if (Number.isFinite(numero)) valores.push(numero); + }); + } + }); + + const ultimoItem = itens[itens.length - 1]; + const ordenados = valores.slice().sort((a, b) => a - b); + const soma = valores.reduce((total, valor) => total + valor, 0); + + let mediana = Number(ultimoItem.mediana) || 0; + if (ordenados.length > 0) { + const meio = Math.floor(ordenados.length / 2); + mediana = ordenados.length % 2 === 0 + ? (ordenados[meio - 1] + ordenados[meio]) / 2 + : ordenados[meio]; + } + + resultado.push({ + sensor: ultimoItem.sensor, + descricao: ultimoItem.descricao, + minimo: valores.length ? Math.min.apply(null, valores) : Number(ultimoItem.minimo) || 0, + maximo: valores.length ? Math.max.apply(null, valores) : Number(ultimoItem.maximo) || 0, + media: valores.length ? soma / valores.length : Number(ultimoItem.media) || 0, + moda: Number(ultimoItem.moda) || 0, + mediana, + ultimo: ultimoItem.ultimo !== undefined ? Number(ultimoItem.ultimo) || 0 : 0, + quantidadeAmostras: valores.length || itens.reduce( + (total, item) => total + (Number(item.quantidadeAmostras) || 0), + 0 + ), + lista: valores + }); + }); + + return resultado.sort((a, b) => + this.nomeSensorAmigavel(a).localeCompare(this.nomeSensorAmigavel(b)) + ); + } + + private buscarSensorFlexivel( + log: OrionLogsModel, + sensorId: number, + descricoes: string[] + ): OrionLogSensorModel { + if (!log || !log.logObj || !Array.isArray(log.logObj.sensores)) return null; + + const normalizadas = (descricoes || []).map(descricao => this.normalizarTextoSensor(descricao)); + return log.logObj.sensores.find(sensor => + Number(sensor.sensor) === Number(sensorId) && + normalizadas.indexOf(this.normalizarTextoSensor(sensor.descricao)) >= 0 + ) || null; + } + + private normalizarTextoSensor(valor: string): string { + return (valor || '') + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .trim(); + } + + private dataInicioLog(log: OrionLogsModel): Date { + const obj = log && log.logObj ? log.logObj : {} as OrionLogsDetalhesModel; + return this.dataSegura(obj.periodoInicio || obj.data || (log ? log.dataLog : null)); + } + + private dataFimLog(log: OrionLogsModel): Date { + const obj = log && log.logObj ? log.logObj : {} as OrionLogsDetalhesModel; + return this.dataSegura( + obj.periodoFim || obj.data || (log ? log.dataOperacao : null) || (log ? log.dataLog : null) + ); + } + + private dataSegura(valor: any): Date { + const data = valor instanceof Date ? new Date(valor.getTime()) : new Date(valor); + if (!isNaN(data.getTime())) return data; + return new Date(0); + } + + private chaveData(data: Date): string { + return data.getFullYear() + '-' + this.pad2(data.getMonth() + 1) + '-' + this.pad2(data.getDate()); + } + + private pad2(valor: number): string { + const texto = String(Math.floor(Math.abs(valor))); + return texto.length >= 2 ? texto : '0' + texto; + } + + private nomeUsuario(log: OrionLogsModel): string { + return this.primeiroTexto([ + log ? log.nome : null, + log && log.logObj ? log.logObj.usuario : null, + log && log.logObj ? log.logObj.cpf : null, + 'Não identificado' + ]); + } + + nomePerfil(log: OrionLogsModel): string { + const bruto = this.primeiroTexto([ + log ? log.perfil : null, + log && log.logObj ? log.logObj.perfil : null, + 'Sem perfil' + ]); + + return bruto.replace(/^Perfis[\\/]/i, '').replace(/\.otpp$/i, ''); + } + + private primeiroTexto(valores: any[]): string { + for (let i = 0; i < valores.length; i++) { + if (valores[i] !== null && valores[i] !== undefined && String(valores[i]).trim()) { + return String(valores[i]).trim(); + } + } + return ''; + } + + private coordenadasObjeto(obj: any): string { + if (!obj) return ''; + const lat = Number(obj.latitude); + const lng = Number(obj.longitude); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return ''; + if (lat === 0 && lng === 0) return ''; + return lat.toFixed(6) + ',' + lng.toFixed(6); + } + + private coordenadasLog(log: OrionLogsModel): string { + if (!log) return ''; + const peloJson = this.coordenadasObjeto(log.logObj); + return peloJson || log.coordenadas || ''; + } + + private numeroOuNull(valor: any): number { + const numero = Number(valor); + return Number.isFinite(numero) ? numero : null; + } + + private limparSelecao() { + this.LogsPeriodo = []; + this.LogsDetalhesFiltrado = []; + this.NomesFiltro = []; + this.PerfisFiltro = []; + this.trabalhos = []; + this.FiltroSelecionado = null; + this.trabalhoSelecionado = []; + this.trabalhoSelecionadoOpcao = null; + this.sensoresTrabalho = []; + this.sensoresDiagnostico = []; + this.telemetria = this.criarResumoTelemetriaVazio(); + this.odometrosTrabalho = this.criarResumoOdometrosVazio(); + this.diagnosticoTrabalho = { + potenciaMediaW: null, + potenciaUltimaW: null, + rollMaxAbs: null, + pitchMaxAbs: null, + infiltracaoDetectada: false, + infiltracaoRawMax: null, + mascaraSaudeUltima: null, + capacidades: '', + altitudeMedia: null, + odometroParcial: 0, + tempoTotal: 0, + idCliente: null, + cpf: '', + logIdUltimo: '', + deslocamentoLiquido: 0, + mensagensEventos: [] + }; + } } diff --git a/src/app/oriontard/orion-equipamento-prontuario/orion-equipamento-prontuario.component.html b/src/app/oriontard/orion-equipamento-prontuario/orion-equipamento-prontuario.component.html new file mode 100644 index 0000000..1497327 --- /dev/null +++ b/src/app/oriontard/orion-equipamento-prontuario/orion-equipamento-prontuario.component.html @@ -0,0 +1,361 @@ +
+ +
+
+ Montando prontuário do equipamento... +
+ +
+ Não foi possível abrir o prontuário. +

{{ erroProntuario }}

+
+ + +
+
+ + +
+
+ + +
+
OT
+
+ PRONTUÁRIO TÉCNICO +

{{ equipamento.nome || equipamento.modelo_nome }}

+
+ NS {{ equipamento.numero_serie }} + {{ equipamento.modelo_nome || 'Modelo não informado' }} + Versão {{ equipamento.versao }} + {{ equipamento.codigo_interno }} +
+
+
+
+ +
+ Cliente atual + {{ equipamento.cliente_nome || 'Sem cliente vinculado' }} + + {{ equipamento.cliente_razao_social }} + + +
+
+ +
+
+ Movimento vitalício + {{ indicadores.horas_movimento | number:'1.1-1' }} h + Odômetro principal do equipamento +
+ +
+ Tempo conectado + {{ indicadores.horas_conectado | number:'1.1-1' }} h + Total acumulado ligado +
+ +
+ Odômetro parcial + {{ indicadores.horas_parcial | number:'1.1-1' }} h + Desde o último reset parcial +
+ +
+ Desde a última saída + + {{ horasSegundos(indicadores.movimento_desde_ultima_saida_segundos) | number:'1.1-1' }} h + + — + Movimento após a manutenção +
+
+ +
+
+
+
+ MANUTENÇÃO +

Resumo de atendimentos

+
+ {{ indicadores.quantidade_os_efetivas }} +
+ +
+
{{ indicadores.quantidade_os_abertas }}OS abertas
+
{{ indicadores.quantidade_corretivas }}Corretivas
+
{{ indicadores.quantidade_preventivas }}Preventivas
+
+ + {{ horasSegundos(indicadores.media_movimento_entre_manutencoes_segundos) | number:'1.0-1' }} h + + — + Média entre manutenções +
+
+ +
+
+ Última ordem de serviço + OS #{{ prontuario.ultima_os.id }} + {{ prontuario.ultima_os.data_entrada | date:'dd/MM/yyyy HH:mm' }} +
+ + {{ labelStatus(prontuario.ultima_os.status) }} + +
+
+ +
+
+
+ CUSTO DE VIDA +

Histórico financeiro

+
+ {{ indicadores.valor_total | currency:'BRL':'symbol':'1.2-2' }} +
+ +
+
Mão de obra{{ indicadores.valor_mao_obra | currency:'BRL':'symbol':'1.2-2' }}
+
Peças e insumos{{ indicadores.valor_insumos | currency:'BRL':'symbol':'1.2-2' }}
+
Acréscimos{{ indicadores.valor_acrescimo | currency:'BRL':'symbol':'1.2-2' }}
+
Descontos- {{ indicadores.valor_desconto | currency:'BRL':'symbol':'1.2-2' }}
+
Recebido{{ indicadores.valor_pago | currency:'BRL':'symbol':'1.2-2' }}
+
Pendente{{ indicadores.valor_pendente | currency:'BRL':'symbol':'1.2-2' }}
+
+ + +
+
+ +
+
+
+ LINHA DO TEMPO +

Histórico completo do equipamento

+

Manutenções, peças, mão de obra, odômetros e alterações de status.

+
+ {{ totalRegistros }} OS +
+ +
+
+ + Buscar no histórico + + + + + Tipo + + {{ opcao.texto }} + + + + + Status + + {{ opcao.texto }} + + + + + Data inicial + + + + + + + Data final + + + + + + + Peças e insumos + + {{ opcao.texto }} + + +
+ +
+ + +
+
+ +
+
+ Consultando histórico... +
+ +
+ Não foi possível carregar a linha do tempo. +

{{ erroTimeline }}

+ +
+ +
+ Nenhum evento encontrado. +

Altere os filtros ou confira se já existem OSs para o equipamento.

+
+ +
+
+
+ +
+ +
+ + +
+
+ +
+ +
+
+ Problema relatado +

{{ ordem.problema_relatado || 'Não informado.' }}

+
+
+ Diagnóstico +

{{ ordem.diagnostico || 'Não informado.' }}

+
+
+ Solução +

{{ ordem.solucao_resumo || 'Não informada.' }}

+
+
+ +
+
+ Entrada na manutenção + {{ horasSegundos(leituraEntrada(ordem).odometro_movimento_segundos) | number:'1.1-1' }} h + Movimento · parcial {{ horasSegundos(leituraEntrada(ordem).odometro_parcial_segundos) | number:'1.1-1' }} h +
+
+ Saída da manutenção + {{ horasSegundos(leituraSaida(ordem).odometro_movimento_segundos) | number:'1.1-1' }} h + Movimento · parcial {{ horasSegundos(leituraSaida(ordem).odometro_parcial_segundos) | number:'1.1-1' }} h +
+
+ +
+

Atividades executadas

+
+
+
{{ i + 1 }}
+
+
+ {{ atividade.descricao }} + {{ atividade.data_atividade | date:'dd/MM/yyyy HH:mm' }} +
+ +
+ {{ atividade.horas_expediente | number:'1.2-2' }} h expediente + {{ atividade.horas_extras | number:'1.2-2' }} h extras + {{ atividade.valor_mao_obra | currency:'BRL':'symbol':'1.2-2' }} de mão de obra +
+ +
+ + {{ tecnico.tecnico_nome }} · {{ tecnico.papel }} + +
+ +

{{ atividade.observacoes_cliente }}

+ +
+
+ Peça / insumoQtd.UnitárioTotal +
+
+ {{ material.descricao_snapshot }}{{ material.observacoes }} + {{ material.quantidade | number:'1.0-2' }} {{ material.unidade_snapshot }} + {{ material.valor_cobrado_unitario | currency:'BRL':'symbol':'1.2-2' }} + {{ valorMaterial(material) | currency:'BRL':'symbol':'1.2-2' }} +
+
+
+
+
+
+ +
+
+

Evolução do status

+
+ +
+ {{ labelStatus(item.status_novo) }} + {{ item.created_at | date:'dd/MM/yyyy HH:mm' }} +

{{ item.observacoes }}

+
+
+
+ +
+

Fechamento da OS

+
Mão de obra{{ ordem.valor_mao_obra | currency:'BRL':'symbol':'1.2-2' }}
+
Peças e insumos{{ ordem.valor_insumos | currency:'BRL':'symbol':'1.2-2' }}
+
Total{{ ordem.valor_total | currency:'BRL':'symbol':'1.2-2' }}
+
+
+
+
+
+
+ + + +
+
+
diff --git a/src/app/oriontard/orion-equipamento-prontuario/orion-equipamento-prontuario.component.scss b/src/app/oriontard/orion-equipamento-prontuario/orion-equipamento-prontuario.component.scss new file mode 100644 index 0000000..60294f7 --- /dev/null +++ b/src/app/oriontard/orion-equipamento-prontuario/orion-equipamento-prontuario.component.scss @@ -0,0 +1,733 @@ +:host { + display: block; + min-height: 100%; + background: #f4f6f8; + color: #1f2933; +} + +.prontuario-page { + max-width: 1680px; + margin: 0 auto; + padding: 24px; + box-sizing: border-box; +} + +.hero-card, +.metric-card, +.overview-card, +.history-section, +.filters-card, +.os-card { + background: #ffffff; + border: 1px solid #e4e7ec; + border-radius: 14px; + box-shadow: 0 4px 14px rgba(16, 24, 40, 0.045); +} + +.hero-card { + display: flex; + justify-content: space-between; + gap: 28px; + padding: 22px 24px; + margin-bottom: 18px; + background: linear-gradient(125deg, #ffffff 0%, #f8fbff 72%, #eef6ff 100%); +} + +.hero-main { min-width: 0; } + +.back-button { + margin: -7px 0 10px -12px; + color: #475467; +} + +.identity-row { + display: flex; + align-items: center; + gap: 16px; + + h1 { + margin: 3px 0 8px; + color: #101828; + font-size: 30px; + line-height: 1.1; + } +} + +.robot-mark { + flex: 0 0 58px; + width: 58px; + height: 58px; + display: grid; + place-items: center; + border-radius: 16px; + background: linear-gradient(145deg, #1769aa, #0d4e83); + color: #ffffff; + font-size: 18px; + font-weight: 800; + letter-spacing: 1px; + box-shadow: 0 8px 18px rgba(23, 105, 170, 0.22); +} + +.eyebrow, +.section-kicker { + color: #1769aa; + font-size: 10px; + font-weight: 800; + letter-spacing: 1.2px; +} + +.identity-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 14px; + color: #667085; + font-size: 13px; +} + +.serial-chip, +.type-chip { + display: inline-flex; + padding: 4px 9px; + border-radius: 999px; + background: #eff8ff; + color: #175cd3; + font-size: 11px; + font-weight: 800; +} + +.hero-side { + min-width: 250px; + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: center; + gap: 4px; + text-align: right; + + strong { color: #101828; font-size: 17px; } + small { color: #98a2b3; } + button { margin-top: 12px; } +} + +.hero-side-label { + color: #667085; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} + +.metrics-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 18px; +} + +.metric-card { + position: relative; + overflow: hidden; + padding: 17px 19px; + + &::before { + content: ''; + position: absolute; + inset: 0 auto 0 0; + width: 4px; + background: #98a2b3; + } + + span { + color: #667085; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + } + + strong { + display: block; + margin: 5px 0 3px; + color: #101828; + font-size: 24px; + } + + small { color: #98a2b3; font-size: 11px; } +} + +.metric-primary::before { background: #1769aa; } + +.overview-grid { + display: grid; + grid-template-columns: 1.15fr 0.85fr; + gap: 18px; + margin-bottom: 22px; +} + +.overview-card { padding: 20px; } + +.section-heading, +.history-heading { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 18px; + + h2 { + margin: 4px 0 0; + color: #101828; + font-size: 19px; + } +} + +.big-number { + color: #1769aa; + font-size: 34px; + line-height: 1; +} + +.maintenance-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + margin: 20px 0; + + div { + padding: 13px; + border-radius: 10px; + background: #f8fafc; + } + + strong { + display: block; + color: #101828; + font-size: 20px; + } + + span { color: #667085; font-size: 11px; } +} + +.last-maintenance { + display: flex; + align-items: center; + justify-content: space-between; + gap: 15px; + padding-top: 16px; + border-top: 1px solid #eaecf0; + + > div { display: flex; flex-direction: column; gap: 2px; } + span:first-child, small { color: #98a2b3; font-size: 11px; } + strong { color: #344054; } +} + +.total-cost { + color: #027a48; + font-size: 21px; +} + +.financial-rows { + margin-top: 16px; + + > div { + display: flex; + justify-content: space-between; + gap: 14px; + padding: 8px 0; + color: #667085; + font-size: 13px; + + strong { color: #344054; } + } +} + +.financial-divider { + margin-top: 5px; + padding-top: 13px !important; + border-top: 1px solid #eaecf0; +} + +.pending-value strong { color: #b42318 !important; } + +.financial-footer { + display: flex; + flex-wrap: wrap; + gap: 10px 20px; + margin-top: 12px; + padding: 12px; + border-radius: 9px; + background: #f8fafc; + color: #667085; + font-size: 11px; + + strong { color: #344054; } +} + +.history-section { padding: 22px; } + +.history-heading { + margin-bottom: 18px; + + p { margin: 5px 0 0; color: #667085; } +} + +.history-count { + padding: 6px 11px; + border-radius: 999px; + background: #f2f4f7; + color: #475467; + font-size: 12px; + font-weight: 700; +} + +.filters-card { + padding: 17px 17px 12px; + margin-bottom: 22px; + background: #f8fafc; + box-shadow: none; +} + +.filters-grid { + display: grid; + grid-template-columns: minmax(270px, 1.8fr) repeat(5, minmax(145px, 1fr)); + gap: 10px; + align-items: start; +} + +.filters-grid .mat-form-field { width: 100%; } + +.filters-actions { + display: flex; + justify-content: flex-end; + gap: 9px; +} + +.timeline { + position: relative; + padding-left: 25px; + + &::before { + content: ''; + position: absolute; + top: 4px; + bottom: 4px; + left: 7px; + width: 2px; + background: #dbe7f1; + } +} + +.timeline-event { + position: relative; + margin-bottom: 16px; +} + +.timeline-marker { + position: absolute; + top: 27px; + left: -25px; + width: 16px; + height: 16px; + display: grid; + place-items: center; + border-radius: 50%; + background: #ffffff; + border: 2px solid #1769aa; + z-index: 1; + + span { + width: 6px; + height: 6px; + border-radius: 50%; + background: #1769aa; + } +} + +.os-card { overflow: hidden; box-shadow: 0 2px 8px rgba(16, 24, 40, 0.035); } +.os-card.expanded { border-color: #b7d6ef; box-shadow: 0 7px 18px rgba(23, 105, 170, 0.08); } + +.os-card-header { + width: 100%; + display: grid; + grid-template-columns: 78px 1fr 32px; + gap: 16px; + align-items: center; + padding: 15px 17px; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.os-date { + padding-right: 14px; + border-right: 1px solid #eaecf0; + text-align: center; + + strong { display: block; color: #101828; font-size: 24px; line-height: 1; } + span { display: block; margin-top: 3px; color: #475467; font-size: 11px; text-transform: uppercase; } + small { color: #98a2b3; } +} + +.os-title { + min-width: 0; + + > p { + margin: 7px 0 5px; + color: #475467; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.os-title-line, +.os-resume { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 7px 12px; +} + +.os-title-line > strong { color: #101828; font-size: 16px; } +.os-resume { color: #98a2b3; font-size: 11px; } +.expand-icon { color: #1769aa; font-size: 25px; font-weight: 300; text-align: center; } + +.status-badge { + display: inline-flex; + align-items: center; + min-height: 23px; + padding: 2px 9px; + border-radius: 999px; + font-size: 10px; + font-weight: 800; + white-space: nowrap; +} + +.badge-sucesso { background: #ecfdf3; color: #027a48; } +.badge-processando { background: #eff8ff; color: #175cd3; } +.badge-alerta { background: #fffaeb; color: #b54708; } +.badge-erro { background: #fef3f2; color: #b42318; } +.badge-info { background: #f4f3ff; color: #5925dc; } +.badge-neutro { background: #f2f4f7; color: #475467; } + +.os-card-body { + padding: 0 18px 20px; + border-top: 1px solid #eaecf0; + background: #fcfdff; +} + +.os-actions { display: flex; justify-content: flex-end; padding: 12px 0 5px; } + +.narrative-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin: 8px 0 14px; +} + +.narrative-item { + padding: 13px; + border: 1px solid #eaecf0; + border-radius: 9px; + background: #ffffff; + + > span { color: #667085; font-size: 10px; font-weight: 800; text-transform: uppercase; } + p { margin: 6px 0 0; color: #344054; line-height: 1.5; white-space: pre-wrap; } +} + +.odometer-pair { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; + margin-bottom: 14px; + + > div { + display: flex; + align-items: baseline; + gap: 10px; + padding: 11px 13px; + border-radius: 9px; + background: #eef6ff; + } + + span { color: #475467; font-size: 11px; font-weight: 700; } + strong { color: #175cd3; } + small { margin-left: auto; color: #667085; } +} + +.detail-section { + margin-top: 16px; + + h3 { + margin: 0 0 11px; + color: #344054; + font-size: 14px; + } +} + +.activity-list { display: flex; flex-direction: column; gap: 10px; } + +.activity-card { + display: grid; + grid-template-columns: 28px 1fr; + gap: 11px; + padding: 13px; + border: 1px solid #eaecf0; + border-radius: 10px; + background: #ffffff; +} + +.activity-number { + width: 25px; + height: 25px; + display: grid; + place-items: center; + border-radius: 50%; + background: #eff8ff; + color: #175cd3; + font-size: 11px; + font-weight: 800; +} + +.activity-header { + display: flex; + justify-content: space-between; + gap: 12px; + + strong { color: #344054; } + span { color: #98a2b3; font-size: 11px; white-space: nowrap; } +} + +.activity-meta, +.technicians { + display: flex; + flex-wrap: wrap; + gap: 6px 12px; + margin-top: 7px; + color: #667085; + font-size: 11px; +} + +.technicians span { + padding: 3px 7px; + border-radius: 5px; + background: #f2f4f7; +} + +.activity-notes { margin: 9px 0 0; color: #475467; white-space: pre-wrap; } + +.materials-table { + margin-top: 11px; + overflow: hidden; + border: 1px solid #eaecf0; + border-radius: 8px; +} + +.material-row { + display: grid; + grid-template-columns: minmax(240px, 1fr) 100px 125px 125px; + gap: 10px; + padding: 8px 10px; + border-top: 1px solid #f0f2f5; + color: #475467; + font-size: 12px; + + &:first-child { border-top: 0; } + > span { display: flex; flex-direction: column; } + strong { color: #344054; } + small { color: #98a2b3; } +} + +.material-header { + background: #f8fafc; + color: #667085; + font-size: 10px; + font-weight: 800; + text-transform: uppercase; +} + +.bottom-details { + display: grid; + grid-template-columns: 1fr 330px; + gap: 18px; +} + +.status-history { position: relative; } + +.status-step { + position: relative; + display: grid; + grid-template-columns: 12px 1fr; + gap: 8px; + padding-bottom: 10px; + + &::before { + content: ''; + position: absolute; + top: 10px; + bottom: -2px; + left: 4px; + width: 1px; + background: #d0d5dd; + } + + &:last-child::before { display: none; } + strong { display: block; color: #344054; font-size: 12px; } + small { color: #98a2b3; } + p { margin: 3px 0 0; color: #667085; } +} + +.status-dot { + width: 9px; + height: 9px; + margin-top: 3px; + border-radius: 50%; + background: #1769aa; + z-index: 1; +} + +.order-financial { + padding: 13px; + border-radius: 9px; + background: #ffffff; + border: 1px solid #eaecf0; + + > div { + display: flex; + justify-content: space-between; + padding: 6px 0; + color: #667085; + font-size: 12px; + } + + strong { color: #344054; } +} + +.order-total { + margin-top: 5px; + padding-top: 10px !important; + border-top: 1px solid #eaecf0; + + strong { color: #027a48; font-size: 15px; } +} + +.loading-main, +.error-main, +.timeline-loading, +.timeline-error, +.timeline-empty { + min-height: 180px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + text-align: center; + + p { margin: 0 0 7px; color: #667085; } +} + +.loading-main, +.error-main { min-height: 420px; } +.error-actions { display: flex; gap: 9px; } + +.loading-bar { + width: 180px; + height: 4px; + overflow: hidden; + border-radius: 999px; + background: #dbeafe; + + &::after { + content: ''; + display: block; + width: 45%; + height: 100%; + border-radius: inherit; + background: #1769aa; + animation: loading 1s ease-in-out infinite alternate; + } +} + +@keyframes loading { + from { transform: translateX(0); } + to { transform: translateX(125%); } +} + +/* Blindagem contra o tema global antigo */ +:host ::ng-deep .filters-card .mat-form-field, +:host ::ng-deep .filters-card .mat-input-element, +:host ::ng-deep .filters-card .mat-select-value, +:host ::ng-deep .filters-card .mat-select-value-text { + color: #101828 !important; +} + +:host ::ng-deep .filters-card .mat-input-element { caret-color: #1769aa !important; } +:host ::ng-deep .filters-card .mat-form-field-label, +:host ::ng-deep .filters-card .mat-select-arrow, +:host ::ng-deep .filters-card .mat-datepicker-toggle { color: #667085 !important; } +:host ::ng-deep .filters-card .mat-form-field.mat-focused .mat-form-field-label { color: #1769aa !important; } +:host ::ng-deep .filters-card .mat-form-field-outline { color: #d0d5dd !important; opacity: 1 !important; } +:host ::ng-deep .filters-card .mat-form-field-outline-thick { color: #1769aa !important; } +:host ::ng-deep .filters-card .mat-form-field-flex { background: transparent !important; } +:host ::ng-deep .filters-card input::placeholder { color: #98a2b3 !important; opacity: 1 !important; } +:host ::ng-deep .filters-actions .mat-button { color: #344054 !important; } +:host ::ng-deep .filters-actions .mat-raised-button { color: #ffffff !important; } +:host ::ng-deep .hero-card .mat-stroked-button, +:host ::ng-deep .os-card .mat-stroked-button, +:host ::ng-deep .error-main .mat-stroked-button, +:host ::ng-deep .timeline-error .mat-stroked-button { + color: #344054 !important; + border-color: #d0d5dd !important; + background: #ffffff !important; +} +:host ::ng-deep .back-button.mat-button { color: #475467 !important; } +:host ::ng-deep .mat-paginator { background: #ffffff !important; color: #475467 !important; border-top: 1px solid #eaecf0; } +:host ::ng-deep .mat-paginator .mat-select-value, +:host ::ng-deep .mat-paginator .mat-select-value-text, +:host ::ng-deep .mat-paginator .mat-select-arrow, +:host ::ng-deep .mat-paginator .mat-icon-button { color: #475467 !important; } +:host ::ng-deep .mat-paginator .mat-icon-button[disabled] { color: #d0d5dd !important; } +:host ::ng-deep .status-badge.badge-sucesso { background: #ecfdf3 !important; color: #027a48 !important; } +:host ::ng-deep .status-badge.badge-processando { background: #eff8ff !important; color: #175cd3 !important; } +:host ::ng-deep .status-badge.badge-alerta { background: #fffaeb !important; color: #b54708 !important; } +:host ::ng-deep .status-badge.badge-erro { background: #fef3f2 !important; color: #b42318 !important; } +:host ::ng-deep .status-badge.badge-info { background: #f4f3ff !important; color: #5925dc !important; } +:host ::ng-deep .status-badge.badge-neutro { background: #f2f4f7 !important; color: #475467 !important; } + +@media (max-width: 1350px) { + .filters-grid { grid-template-columns: repeat(3, minmax(180px, 1fr)); } + .field-search { grid-column: span 2; } + .maintenance-stats { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 1000px) { + .metrics-grid { grid-template-columns: repeat(2, 1fr); } + .overview-grid { grid-template-columns: 1fr; } + .narrative-grid { grid-template-columns: 1fr; } + .bottom-details { grid-template-columns: 1fr; } +} + +@media (max-width: 760px) { + .prontuario-page { padding: 14px; } + .hero-card { flex-direction: column; } + .hero-side { align-items: flex-start; text-align: left; } + .filters-grid { grid-template-columns: 1fr; } + .field-search { grid-column: auto; } + .os-card-header { grid-template-columns: 58px 1fr 24px; gap: 9px; padding: 13px 10px; } + .os-resume { display: none; } + .odometer-pair { grid-template-columns: 1fr; } + .material-row { grid-template-columns: 1fr 70px 100px; } + .material-row > span:nth-child(3) { display: none; } + .activity-header { flex-direction: column; gap: 3px; } +} + +@media (max-width: 520px) { + .metrics-grid { grid-template-columns: 1fr; } + .identity-row { align-items: flex-start; } + .robot-mark { display: none; } + .maintenance-stats { grid-template-columns: repeat(2, 1fr); } + .history-section { padding: 14px; } + .timeline { padding-left: 18px; } + .timeline-marker { left: -18px; } +} diff --git a/src/app/oriontard/orion-equipamento-prontuario/orion-equipamento-prontuario.component.spec.ts b/src/app/oriontard/orion-equipamento-prontuario/orion-equipamento-prontuario.component.spec.ts new file mode 100644 index 0000000..4ef94dc --- /dev/null +++ b/src/app/oriontard/orion-equipamento-prontuario/orion-equipamento-prontuario.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { OrionEquipamentoProntuarioComponent } from './orion-equipamento-prontuario.component'; + +describe('OrionEquipamentoProntuarioComponent', () => { + let component: OrionEquipamentoProntuarioComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [OrionEquipamentoProntuarioComponent] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(OrionEquipamentoProntuarioComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('deve criar o componente', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/oriontard/orion-equipamento-prontuario/orion-equipamento-prontuario.component.ts b/src/app/oriontard/orion-equipamento-prontuario/orion-equipamento-prontuario.component.ts new file mode 100644 index 0000000..877c2f5 --- /dev/null +++ b/src/app/oriontard/orion-equipamento-prontuario/orion-equipamento-prontuario.component.ts @@ -0,0 +1,341 @@ +import { Component, OnInit } from '@angular/core'; +import { PageEvent } from '@angular/material/paginator'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { + EquipamentoProntuarioIndicadoresModel, + EquipamentoProntuarioResponseModel, + EquipamentoTimelineFiltrosModel, + EquipamentoTimelineItemModel, + OrdemServicoAtividadeMaterialModel, + OrdemServicoOdometroModel, + OrdemServicoStatus, + OrdemServicoTipo +} from '../../models/orionModel'; +import { OrionService } from '../../services/orionService'; + +@Component({ + selector: 'app-orion-equipamento-prontuario', + templateUrl: './orion-equipamento-prontuario.component.html', + styleUrls: ['./orion-equipamento-prontuario.component.scss'] +}) +export class OrionEquipamentoProntuarioComponent implements OnInit { + + equipamentoId: number = null; + prontuario: EquipamentoProntuarioResponseModel = null; + timeline: EquipamentoTimelineItemModel[] = []; + osExpandidas = new Set(); + + carregandoProntuario: boolean = false; + carregandoTimeline: boolean = false; + erroProntuario: string = ''; + erroTimeline: string = ''; + + paginaAtual: number = 1; + tamanhoPagina: number = 10; + totalRegistros: number = 0; + totalPaginas: number = 0; + opcoesTamanhoPagina: number[] = [5, 10, 20, 50]; + + busca: string = ''; + tipoSelecionado: OrdemServicoTipo | '' = ''; + statusSelecionado: OrdemServicoStatus | '' = ''; + dataInicio: Date = null; + dataFim: Date = null; + somenteComMateriais: boolean | '' = ''; + + tipoOpcoes = [ + { valor: '', texto: 'Todos os tipos' }, + { valor: 'CORRETIVA', texto: 'Corretiva' }, + { valor: 'PREVENTIVA', texto: 'Preventiva' }, + { valor: 'REFORMA', texto: 'Reforma' }, + { valor: 'DESENVOLVIMENTO', texto: 'Desenvolvimento' }, + { valor: 'FABRICACAO', texto: 'Fabricação' }, + { valor: 'GARANTIA', texto: 'Garantia' }, + { valor: 'CORTESIA', texto: 'Cortesia' }, + { valor: 'INTERNA', texto: 'Interna' }, + { valor: 'OUTRO', texto: 'Outro' } + ]; + + statusOpcoes = [ + { valor: '', texto: 'Todos os status' }, + { valor: 'RASCUNHO', texto: 'Rascunho' }, + { valor: 'ABERTA', texto: 'Aberta' }, + { valor: 'EM_DIAGNOSTICO', texto: 'Em diagnóstico' }, + { valor: 'AGUARDANDO_APROVACAO', texto: 'Aguardando aprovação' }, + { valor: 'AGUARDANDO_PECA', texto: 'Aguardando peça' }, + { valor: 'EM_EXECUCAO', texto: 'Em execução' }, + { valor: 'EM_TESTES', texto: 'Em testes' }, + { valor: 'FINALIZADA', texto: 'Finalizada' }, + { valor: 'AGUARDANDO_RETIRADA', texto: 'Aguardando retirada' }, + { valor: 'RETIRADA', texto: 'Retirada' }, + { valor: 'CANCELADA', texto: 'Cancelada' } + ]; + + materiaisOpcoes = [ + { valor: '', texto: 'Todas as OS' }, + { valor: true, texto: 'Somente com peças/insumos' } + ]; + + constructor( + private route: ActivatedRoute, + private router: Router, + private orionService: OrionService + ) { } + + ngOnInit() { + this.equipamentoId = Number(this.route.snapshot.paramMap.get('id')); + + if (!this.equipamentoId || this.equipamentoId <= 0) { + this.erroProntuario = 'Equipamento inválido.'; + return; + } + + this.carregarProntuario(); + this.carregarTimeline(); + } + + carregarProntuario() { + this.carregandoProntuario = true; + this.erroProntuario = ''; + + this.orionService + .getEquipamentoProntuario(this.equipamentoId) + .then(resultado => { + this.prontuario = resultado; + }) + .catch(error => { + console.error('[PRONTUARIO] Erro ao carregar resumo:', error); + this.prontuario = null; + this.erroProntuario = error && error.message + ? error.message + : 'Não foi possível carregar o prontuário do equipamento.'; + }) + .then(() => { + this.carregandoProntuario = false; + }); + } + + carregarTimeline() { + this.carregandoTimeline = true; + this.erroTimeline = ''; + + const filtros: EquipamentoTimelineFiltrosModel = { + page: this.paginaAtual, + pageSize: this.tamanhoPagina, + busca: this.normalizarTexto(this.busca), + tipo: this.tipoSelecionado, + status: this.statusSelecionado, + data_inicio: this.formatarDataApi(this.dataInicio), + data_fim: this.formatarDataApi(this.dataFim), + somente_com_materiais: this.somenteComMateriais === true + ? true + : undefined + }; + + this.orionService + .getEquipamentoTimeline(this.equipamentoId, filtros) + .then(resultado => { + this.timeline = resultado.items || []; + this.paginaAtual = resultado.pagination.page || 1; + this.tamanhoPagina = resultado.pagination.pageSize || 10; + this.totalRegistros = resultado.pagination.total || 0; + this.totalPaginas = resultado.pagination.totalPages || 0; + + if (this.timeline.length && this.osExpandidas.size === 0) { + this.osExpandidas.add(this.timeline[0].id); + } + }) + .catch(error => { + console.error('[PRONTUARIO] Erro ao carregar timeline:', error); + this.timeline = []; + this.totalRegistros = 0; + this.totalPaginas = 0; + this.erroTimeline = error && error.message + ? error.message + : 'Não foi possível carregar o histórico do equipamento.'; + }) + .then(() => { + this.carregandoTimeline = false; + }); + } + + atualizar() { + this.carregarProntuario(); + this.carregarTimeline(); + } + + voltar() { + this.router.navigate(['/orion-equipamentos']); + } + + abrirOrdem(ordem: EquipamentoTimelineItemModel) { + this.router.navigate([ + '/orion-ordens-servico', + ordem.id, + 'editar' + ]); + } + + alternarOrdem(ordemId: number) { + if (this.osExpandidas.has(ordemId)) { + this.osExpandidas.delete(ordemId); + return; + } + + this.osExpandidas.add(ordemId); + } + + ordemExpandida(ordemId: number): boolean { + return this.osExpandidas.has(ordemId); + } + + aplicarFiltros() { + this.paginaAtual = 1; + this.osExpandidas.clear(); + this.carregarTimeline(); + } + + limparFiltros() { + this.busca = ''; + this.tipoSelecionado = ''; + this.statusSelecionado = ''; + this.dataInicio = null; + this.dataFim = null; + this.somenteComMateriais = ''; + this.paginaAtual = 1; + this.osExpandidas.clear(); + this.carregarTimeline(); + } + + onPageChange(event: PageEvent) { + this.paginaAtual = event.pageIndex + 1; + this.tamanhoPagina = event.pageSize; + this.osExpandidas.clear(); + this.carregarTimeline(); + } + + get equipamento() { + return this.prontuario ? this.prontuario.equipamento : null; + } + + get indicadores(): EquipamentoProntuarioIndicadoresModel { + return this.prontuario ? this.prontuario.indicadores : null; + } + + horasSegundos(segundos: number): number { + return this.numero(segundos) / 3600; + } + + totalHorasOrdem(ordem: EquipamentoTimelineItemModel): number { + return this.numero(ordem.total_horas_expediente) + + this.numero(ordem.total_horas_extras); + } + + quantidadeMateriais(ordem: EquipamentoTimelineItemModel): number { + return (ordem.atividades || []).reduce( + (total, atividade) => total + (atividade.materiais || []).length, + 0 + ); + } + + leituraEntrada(ordem: EquipamentoTimelineItemModel): OrdemServicoOdometroModel { + return (ordem.odometros || []).find( + leitura => leitura.tipo_leitura === 'ENTRADA_MANUTENCAO' + ) || null; + } + + leituraSaida(ordem: EquipamentoTimelineItemModel): OrdemServicoOdometroModel { + return (ordem.odometros || []).find( + leitura => leitura.tipo_leitura === 'SAIDA_MANUTENCAO' + ) || null; + } + + valorMaterial(material: OrdemServicoAtividadeMaterialModel): number { + if (material.valor_cobrado_total !== null && material.valor_cobrado_total !== undefined) { + return this.numero(material.valor_cobrado_total); + } + + return this.numero(material.quantidade) * + this.numero(material.valor_cobrado_unitario); + } + + labelStatus(status: OrdemServicoStatus): string { + return this.buscarTexto(this.statusOpcoes, status, status || 'Sem status'); + } + + labelTipo(tipo: OrdemServicoTipo): string { + return this.buscarTexto(this.tipoOpcoes, tipo, tipo || 'Não informado'); + } + + labelLeitura(tipo: string): string { + const labels: any = { + ENTRADA_MANUTENCAO: 'Entrada na manutenção', + SAIDA_MANUTENCAO: 'Saída da manutenção', + CADASTRO_INICIAL: 'Cadastro inicial', + ATUALIZACAO_MANUAL: 'Atualização manual', + TELEMETRIA: 'Telemetria' + }; + + return labels[tipo] || tipo; + } + + classeStatus(status: OrdemServicoStatus): string { + switch (status) { + case 'RETIRADA': + case 'FINALIZADA': + return 'badge-sucesso'; + case 'EM_EXECUCAO': + case 'EM_DIAGNOSTICO': + case 'EM_TESTES': + return 'badge-processando'; + case 'AGUARDANDO_APROVACAO': + case 'AGUARDANDO_PECA': + case 'AGUARDANDO_RETIRADA': + return 'badge-alerta'; + case 'CANCELADA': + return 'badge-erro'; + case 'RASCUNHO': + return 'badge-neutro'; + default: + return 'badge-info'; + } + } + + trackById(index: number, ordem: EquipamentoTimelineItemModel): number { + return ordem.id; + } + + private buscarTexto(opcoes: any[], valor: string, padrao: string): string { + for (let index = 0; index < opcoes.length; index += 1) { + if (opcoes[index].valor === valor) { + return opcoes[index].texto; + } + } + + return padrao; + } + + private normalizarTexto(valor: string): string { + return valor ? valor.trim() : ''; + } + + private formatarDataApi(data: Date): string { + if (!data) { + return ''; + } + + const ano = data.getFullYear(); + const mes = data.getMonth() + 1; + const dia = data.getDate(); + + return ano + '-' + + (mes < 10 ? '0' : '') + mes + '-' + + (dia < 10 ? '0' : '') + dia; + } + + private numero(valor: any): number { + const convertido = Number(valor); + return isNaN(convertido) ? 0 : convertido; + } +} diff --git a/src/app/oriontard/orion-equipamentos/orion-equipamentos.component.html b/src/app/oriontard/orion-equipamentos/orion-equipamentos.component.html new file mode 100644 index 0000000..a6925f4 --- /dev/null +++ b/src/app/oriontard/orion-equipamentos/orion-equipamentos.component.html @@ -0,0 +1,294 @@ +
+ + + +
+
+ + Pesquisar + + + + + Modelo + + Todos os modelos + + {{ modelo.nome }} + + + + + + Cliente + + Todos os clientes + + {{ cliente.nome_fantasia }} + + + + + + Versão + + + + + Situação da manutenção + + + {{ opcao.texto }} + + + + + + Status da última OS + + + {{ opcao.texto }} + + + +
+ +
+ + + +
+
+ +
+
+ Equipamentos encontrados + {{ totalRegistros }} + {{ dataSource.data.length }} nesta página +
+ +
+ OS abertas na página + {{ osAbertasNaPagina }} + Equipamentos em atendimento +
+ +
+ Movimento acumulado + {{ horas(movimentoNaPagina) | number:'1.1-1' }} h + Soma dos equipamentos exibidos +
+ +
+ Custo histórico na página + {{ custoNaPagina | currency:'BRL':'symbol':'1.2-2' }} + OS efetivas exibidas +
+
+ +
+
+
+ Carregando equipamentos... +
+ +
+ Não foi possível carregar os equipamentos. +

{{ erro }}

+ +
+ +
+ Nenhum equipamento encontrado. +

Altere os filtros ou confira o cadastro dos equipamentos.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Equipamento +
+ {{ nomeEquipamento(equipamento) }} + NS {{ equipamento.numero_serie }} + + {{ equipamento.codigo_interno }} + +
+
Modelo / versão +
+ {{ equipamento.modelo_nome || 'Não informado' }} + {{ equipamento.versao || 'Sem versão' }} +
+
Cliente + {{ equipamento.cliente_nome || 'Sem cliente vinculado' }} + Movimento +
+ {{ horas(equipamento.odometro_movimento_segundos) | number:'1.1-1' }} h + + Parcial: {{ horas(equipamento.odometro_parcial_segundos) | number:'1.1-1' }} h + +
+
Ordens +
+ {{ equipamento.quantidade_os }} + + {{ equipamento.quantidade_os_abertas }} aberta(s) + + + Nenhuma aberta + +
+
Última manutenção + + + + Sem histórico + + Último status + + {{ labelStatus(equipamento.ultima_os_status) }} + + Custo histórico +
+ + {{ equipamento.valor_total_historico | currency:'BRL':'symbol':'1.2-2' }} + + + M.O. {{ equipamento.valor_mao_obra_historico | currency:'BRL':'symbol':'1.0-0' }} + · Peças {{ equipamento.valor_insumos_historico | currency:'BRL':'symbol':'1.0-0' }} + +
+
+ +
+
+ + + +
+
diff --git a/src/app/oriontard/orion-equipamentos/orion-equipamentos.component.scss b/src/app/oriontard/orion-equipamentos/orion-equipamentos.component.scss new file mode 100644 index 0000000..635a43d --- /dev/null +++ b/src/app/oriontard/orion-equipamentos/orion-equipamentos.component.scss @@ -0,0 +1,453 @@ +:host { + display: block; + min-height: 100%; + background: #f4f6f8; +} + +.equipamentos-page { + max-width: 1680px; + margin: 0 auto; + padding: 28px; + color: #1f2937; +} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + margin-bottom: 24px; + + h1 { + margin: 3px 0 5px; + font-size: 30px; + font-weight: 700; + letter-spacing: -0.02em; + } + + p { + margin: 0; + color: #64748b; + } +} + +.eyebrow { + color: #2563eb; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.15em; +} + +.header-actions, +.filters-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.filters-card, +.table-card, +.summary-card { + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 14px; + box-shadow: 0 7px 22px rgba(15, 23, 42, 0.045); +} + +.filters-card { + padding: 20px 20px 14px; + margin-bottom: 20px; +} + +.filters-grid { + display: grid; + grid-template-columns: minmax(270px, 1.7fr) repeat(5, minmax(155px, 1fr)); + gap: 12px; + align-items: start; +} + +.filters-grid .mat-form-field { + width: 100%; +} + +.filters-actions { + justify-content: flex-end; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 20px; +} + +.summary-card { + position: relative; + overflow: hidden; + padding: 18px 20px; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 4px; + height: 100%; + background: #2563eb; + } + + strong { + display: block; + margin: 5px 0 2px; + color: #0f172a; + font-size: 23px; + line-height: 1.2; + } + + small { + color: #94a3b8; + } +} + +.summary-card-alerta::before { + background: #f59e0b; +} + +.summary-label { + color: #64748b; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.table-card { + overflow: hidden; +} + +.table-scroll { + overflow-x: auto; +} + +.equipamentos-table { + width: 100%; + min-width: 1400px; + + th.mat-header-cell { + padding: 14px 13px; + border-bottom-color: #dbe2ea; + color: #64748b; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + white-space: nowrap; + } + + td.mat-cell { + padding: 14px 13px; + border-bottom-color: #edf1f5; + vertical-align: middle; + } + + tr.mat-row { + cursor: pointer; + transition: background 120ms ease; + + &:hover { + background: #f8fafc; + } + } +} + +.selected-row { + background: #eff6ff !important; +} + +.equipment-cell, +.stacked-cell, +.metric-cell, +.orders-cell { + display: flex; + flex-direction: column; + gap: 3px; + + strong { + color: #0f172a; + } + + span, + small { + color: #64748b; + font-size: 12px; + } +} + +.serial-number { + width: fit-content; + padding: 2px 7px; + border-radius: 5px; + background: #eaf2ff; + color: #1d4ed8 !important; + font-weight: 700; +} + +.open-order { + color: #b45309 !important; + font-weight: 700; +} + +.cost-cell { + min-width: 210px; +} + +.status-badge { + display: inline-flex; + align-items: center; + padding: 5px 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 800; + line-height: 1; + white-space: nowrap; +} + +.badge-sucesso { background: #dcfce7; color: #166534; } +.badge-processando { background: #dbeafe; color: #1d4ed8; } +.badge-alerta { background: #fef3c7; color: #92400e; } +.badge-erro { background: #fee2e2; color: #b91c1c; } +.badge-info { background: #e0f2fe; color: #0369a1; } +.badge-neutro { background: #e2e8f0; color: #475569; } + +.os-link { + min-width: 0; + padding: 2px 7px; + line-height: 1.25; + + strong, + span { + display: block; + } + + span { + color: #64748b; + font-size: 11px; + } +} + +.muted-text { + color: #94a3b8; + font-size: 12px; +} + +.loading-state, +.error-state, +.empty-state { + min-height: 190px; + padding: 35px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + text-align: center; + + p { + max-width: 560px; + margin: 0 0 8px; + color: #64748b; + } +} + +.loading-bar { + width: 180px; + height: 4px; + margin-bottom: 8px; + overflow: hidden; + border-radius: 999px; + background: #dbeafe; + + &::after { + content: ''; + display: block; + width: 45%; + height: 100%; + border-radius: inherit; + background: #2563eb; + animation: loading 1s ease-in-out infinite alternate; + } +} + +@keyframes loading { + from { transform: translateX(0); } + to { transform: translateX(125%); } +} + +/* ------------------------------------------------------------ + BLINDAGEM CONTRA O TEMA GLOBAL ANTIGO +------------------------------------------------------------ */ + +:host ::ng-deep .filters-card .mat-form-field { + color: #101828 !important; +} + +:host ::ng-deep .filters-card .mat-input-element { + color: #101828 !important; + caret-color: #1769aa !important; +} + +:host ::ng-deep .filters-card .mat-select-value, +:host ::ng-deep .filters-card .mat-select-value-text { + color: #101828 !important; +} + +:host ::ng-deep .filters-card .mat-form-field-label { + color: #667085 !important; +} + +:host ::ng-deep .filters-card .mat-form-field.mat-focused .mat-form-field-label { + color: #1769aa !important; +} + +:host ::ng-deep .filters-card .mat-form-field-outline { + color: #d0d5dd !important; + opacity: 1 !important; +} + +:host ::ng-deep .filters-card .mat-form-field-outline-thick { + color: #1769aa !important; +} + +:host ::ng-deep .filters-card .mat-select-arrow { + color: #667085 !important; +} + +:host ::ng-deep .filters-card input::placeholder { + color: #98a2b3 !important; + opacity: 1 !important; +} + +:host ::ng-deep .filters-card .mat-form-field-flex { + background: transparent !important; +} + +:host ::ng-deep .equipamentos-table, +:host ::ng-deep table.equipamentos-table { + background: #ffffff !important; + color: #344054 !important; +} + +:host ::ng-deep .equipamentos-table tr.mat-header-row { + background: #f8fafc !important; +} + +:host ::ng-deep .equipamentos-table tr.mat-row { + background: #ffffff !important; + color: #344054 !important; +} + +:host ::ng-deep .equipamentos-table tr.mat-row:nth-child(even) { + background: #fbfcfd !important; +} + +:host ::ng-deep .equipamentos-table tr.mat-row:hover { + background: #f2f7fc !important; +} + +:host ::ng-deep .equipamentos-table tr.mat-row.selected-row { + background: #eaf4ff !important; +} + +:host ::ng-deep .equipamentos-table th.mat-header-cell { + background: #f8fafc !important; + color: #475467 !important; + border-color: #eaecf0 !important; +} + +:host ::ng-deep .equipamentos-table td.mat-cell { + background: transparent !important; + color: #344054 !important; + border-color: #eaecf0 !important; +} + +:host ::ng-deep .equipamentos-table td, +:host ::ng-deep .equipamentos-table th { + color: #344054 !important; + border-left: 0 !important; + border-right: 0 !important; +} + +:host ::ng-deep .header-actions .mat-stroked-button, +:host ::ng-deep .equipamentos-table .mat-stroked-button { + color: #344054 !important; + border-color: #d0d5dd !important; + background: #ffffff !important; +} + +:host ::ng-deep .filters-actions .mat-button { + color: #344054 !important; +} + +:host ::ng-deep .filters-actions .mat-raised-button { + color: #ffffff !important; +} + +:host ::ng-deep .mat-paginator { + background: #ffffff !important; + color: #475467 !important; + border-top: 1px solid #eaecf0; +} + +:host ::ng-deep .mat-paginator .mat-select-value, +:host ::ng-deep .mat-paginator .mat-select-value-text { + color: #344054 !important; +} + +:host ::ng-deep .mat-paginator .mat-select-arrow, +:host ::ng-deep .mat-paginator .mat-icon-button { + color: #667085 !important; +} + +:host ::ng-deep .mat-paginator .mat-icon-button[disabled] { + color: #d0d5dd !important; +} + +@media (max-width: 1350px) { + .filters-grid { + grid-template-columns: repeat(3, minmax(180px, 1fr)); + } + + .field-search { + grid-column: span 2; + } +} + +@media (max-width: 900px) { + .equipamentos-page { + padding: 18px; + } + + .page-header { + flex-direction: column; + } + + .filters-grid, + .summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .field-search { + grid-column: span 2; + } +} + +@media (max-width: 600px) { + .filters-grid, + .summary-grid { + grid-template-columns: 1fr; + } + + .field-search { + grid-column: auto; + } +} diff --git a/src/app/oriontard/orion-equipamentos/orion-equipamentos.component.spec.ts b/src/app/oriontard/orion-equipamentos/orion-equipamentos.component.spec.ts new file mode 100644 index 0000000..52ef679 --- /dev/null +++ b/src/app/oriontard/orion-equipamentos/orion-equipamentos.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { OrionEquipamentosComponent } from './orion-equipamentos.component'; + +describe('OrionEquipamentosComponent', () => { + let component: OrionEquipamentosComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [OrionEquipamentosComponent] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(OrionEquipamentosComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('deve criar o componente', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/oriontard/orion-equipamentos/orion-equipamentos.component.ts b/src/app/oriontard/orion-equipamentos/orion-equipamentos.component.ts new file mode 100644 index 0000000..0973e84 --- /dev/null +++ b/src/app/oriontard/orion-equipamentos/orion-equipamentos.component.ts @@ -0,0 +1,319 @@ +import { Component, OnInit } from '@angular/core'; +import { PageEvent } from '@angular/material/paginator'; +import { MatTableDataSource } from '@angular/material/table'; +import { Router } from '@angular/router'; + +import { + ClienteManutencaoModel, + EquipamentoHistoricoFiltrosModel, + EquipamentoHistoricoListaModel, + EquipamentoManutencaoModel, + OrdemServicoStatus +} from '../../models/orionModel'; +import { OrionService } from '../../services/orionService'; + +interface OpcaoModelo { + id: number; + nome: string; +} + +@Component({ + selector: 'app-orion-equipamentos', + templateUrl: './orion-equipamentos.component.html', + styleUrls: ['./orion-equipamentos.component.scss'] +}) +export class OrionEquipamentosComponent implements OnInit { + + displayedColumns: string[] = [ + 'equipamento', + 'modelo', + 'cliente', + 'movimento', + 'ordens', + 'ultima_manutencao', + 'status', + 'custo', + 'acoes' + ]; + + dataSource = new MatTableDataSource([]); + equipamentoSelecionado: EquipamentoHistoricoListaModel = null; + + carregando: boolean = false; + carregandoFiltros: boolean = false; + erro: string = ''; + + paginaAtual: number = 1; + tamanhoPagina: number = 25; + totalRegistros: number = 0; + totalPaginas: number = 0; + opcoesTamanhoPagina: number[] = [10, 25, 50, 100]; + + busca: string = ''; + modeloSelecionado: string = ''; + clienteSelecionado: number = null; + versao: string = ''; + possuiOsAberta: boolean | '' = ''; + statusUltimaOs: OrdemServicoStatus | '' = ''; + + clientes: ClienteManutencaoModel[] = []; + modelos: OpcaoModelo[] = []; + + statusOpcoes = [ + { valor: '', texto: 'Todos os status' }, + { valor: 'RASCUNHO', texto: 'Rascunho' }, + { valor: 'ABERTA', texto: 'Aberta' }, + { valor: 'EM_DIAGNOSTICO', texto: 'Em diagnóstico' }, + { valor: 'AGUARDANDO_APROVACAO', texto: 'Aguardando aprovação' }, + { valor: 'AGUARDANDO_PECA', texto: 'Aguardando peça' }, + { valor: 'EM_EXECUCAO', texto: 'Em execução' }, + { valor: 'EM_TESTES', texto: 'Em testes' }, + { valor: 'FINALIZADA', texto: 'Finalizada' }, + { valor: 'AGUARDANDO_RETIRADA', texto: 'Aguardando retirada' }, + { valor: 'RETIRADA', texto: 'Retirada' }, + { valor: 'CANCELADA', texto: 'Cancelada' } + ]; + + osAbertaOpcoes = [ + { valor: '', texto: 'Todos' }, + { valor: true, texto: 'Com OS aberta' }, + { valor: false, texto: 'Sem OS aberta' } + ]; + + constructor( + private orionService: OrionService, + private router: Router + ) { } + + ngOnInit() { + this.carregarOpcoesFiltros(); + this.carregarEquipamentos(); + } + + carregarEquipamentos() { + this.carregando = true; + this.erro = ''; + + const filtros: EquipamentoHistoricoFiltrosModel = { + page: this.paginaAtual, + pageSize: this.tamanhoPagina, + busca: this.normalizarTexto(this.busca), + modelo: this.modeloSelecionado, + cliente_id: this.clienteSelecionado, + versao: this.normalizarTexto(this.versao), + possui_os_aberta: this.possuiOsAberta, + status_ultima_os: this.statusUltimaOs + }; + + this.orionService + .getEquipamentosHistorico(filtros) + .then(resultado => { + this.dataSource.data = resultado.items || []; + this.paginaAtual = resultado.pagination.page || 1; + this.tamanhoPagina = resultado.pagination.pageSize || 25; + this.totalRegistros = resultado.pagination.total || 0; + this.totalPaginas = resultado.pagination.totalPages || 0; + + if ( + this.equipamentoSelecionado && + !this.dataSource.data.some( + equipamento => equipamento.id === this.equipamentoSelecionado.id + ) + ) { + this.equipamentoSelecionado = null; + } + }) + .catch(error => { + console.error('[EQUIPAMENTOS] Erro ao carregar histórico:', error); + this.dataSource.data = []; + this.totalRegistros = 0; + this.totalPaginas = 0; + this.erro = error && error.message + ? error.message + : 'Não foi possível carregar os equipamentos.'; + }) + .then(() => { + this.carregando = false; + }); + } + + carregarOpcoesFiltros() { + this.carregandoFiltros = true; + + Promise.all([ + this.orionService.getClientesManutencao(), + this.orionService.getEquipamentosManutencao() + ]) + .then(resultados => { + this.clientes = resultados[0] || []; + this.modelos = this.extrairModelos(resultados[1] || []); + }) + .catch(error => { + console.error('[EQUIPAMENTOS] Erro ao carregar filtros:', error); + }) + .then(() => { + this.carregandoFiltros = false; + }); + } + + aplicarFiltros() { + this.paginaAtual = 1; + this.equipamentoSelecionado = null; + this.carregarEquipamentos(); + } + + limparFiltros() { + this.busca = ''; + this.modeloSelecionado = ''; + this.clienteSelecionado = null; + this.versao = ''; + this.possuiOsAberta = ''; + this.statusUltimaOs = ''; + this.paginaAtual = 1; + this.equipamentoSelecionado = null; + this.carregarEquipamentos(); + } + + atualizar() { + this.carregarEquipamentos(); + } + + onPageChange(event: PageEvent) { + this.paginaAtual = event.pageIndex + 1; + this.tamanhoPagina = event.pageSize; + this.equipamentoSelecionado = null; + this.carregarEquipamentos(); + } + + selecionarEquipamento(equipamento: EquipamentoHistoricoListaModel) { + this.equipamentoSelecionado = equipamento; + } + + abrirProntuario(equipamento: EquipamentoHistoricoListaModel) { + this.router.navigate([ + '/orion-equipamentos', + equipamento.id, + 'historico' + ]); + } + + abrirUltimaOs( + event: MouseEvent, + equipamento: EquipamentoHistoricoListaModel + ) { + event.stopPropagation(); + + if (!equipamento.ultima_os_id) { + return; + } + + this.router.navigate([ + '/orion-ordens-servico', + equipamento.ultima_os_id, + 'editar' + ]); + } + + trackById(index: number, equipamento: EquipamentoHistoricoListaModel): number { + return equipamento.id; + } + + get osAbertasNaPagina(): number { + return this.dataSource.data.reduce( + (total, equipamento) => total + this.numero(equipamento.quantidade_os_abertas), + 0 + ); + } + + get movimentoNaPagina(): number { + return this.dataSource.data.reduce( + (total, equipamento) => total + this.numero(equipamento.odometro_movimento_segundos), + 0 + ); + } + + get custoNaPagina(): number { + return this.dataSource.data.reduce( + (total, equipamento) => total + this.numero(equipamento.valor_total_historico), + 0 + ); + } + + horas(segundos: number): number { + return this.numero(segundos) / 3600; + } + + nomeEquipamento(equipamento: EquipamentoHistoricoListaModel): string { + return equipamento.nome || equipamento.modelo_nome || 'Equipamento sem nome'; + } + + labelStatus(status: OrdemServicoStatus): string { + if (!status) { + return 'Sem OS'; + } + + for (let i = 0; i < this.statusOpcoes.length; i += 1) { + if (this.statusOpcoes[i].valor === status) { + return this.statusOpcoes[i].texto; + } + } + + return status; + } + + classeStatus(status: OrdemServicoStatus): string { + switch (status) { + case 'RETIRADA': + case 'FINALIZADA': + return 'badge-sucesso'; + + case 'EM_EXECUCAO': + case 'EM_DIAGNOSTICO': + case 'EM_TESTES': + return 'badge-processando'; + + case 'AGUARDANDO_APROVACAO': + case 'AGUARDANDO_PECA': + case 'AGUARDANDO_RETIRADA': + return 'badge-alerta'; + + case 'CANCELADA': + return 'badge-erro'; + + case 'RASCUNHO': + return 'badge-neutro'; + + default: + return 'badge-info'; + } + } + + private extrairModelos(equipamentos: EquipamentoManutencaoModel[]): OpcaoModelo[] { + const unicos = new Map(); + + equipamentos.forEach(equipamento => { + const nome = this.normalizarTexto(equipamento.modelo_nome); + if (!nome || unicos.has(nome.toLowerCase())) { + return; + } + + unicos.set(nome.toLowerCase(), { + id: equipamento.modelo_id || 0, + nome + }); + }); + + return Array.from(unicos.values()).sort((a, b) => + a.nome.localeCompare(b.nome) + ); + } + + private normalizarTexto(valor: string): string { + return valor ? valor.trim() : ''; + } + + private numero(valor: any): number { + const convertido = Number(valor); + return isNaN(convertido) ? 0 : convertido; + } +} diff --git a/src/app/oriontard/orion-ordens-servico/orion-ordens-servico.component.ts b/src/app/oriontard/orion-ordens-servico/orion-ordens-servico.component.ts index a2e483e..5eb4a88 100644 --- a/src/app/oriontard/orion-ordens-servico/orion-ordens-servico.component.ts +++ b/src/app/oriontard/orion-ordens-servico/orion-ordens-servico.component.ts @@ -62,6 +62,7 @@ export class OrionOrdensServicoComponent implements OnInit { { valor: 'EM_EXECUCAO', texto: 'Em execução' }, { valor: 'EM_TESTES', texto: 'Em testes' }, { valor: 'FINALIZADA', texto: 'Finalizada' }, + { valor: 'AGUARDANDO_RETIRADA', texto: 'Aguardando retirada' }, { valor: 'RETIRADA', texto: 'Retirada' }, { valor: 'CANCELADA', texto: 'Cancelada' } ]; diff --git a/src/app/services/orionService.ts b/src/app/services/orionService.ts index 2bd5368..c1e31c0 100644 --- a/src/app/services/orionService.ts +++ b/src/app/services/orionService.ts @@ -4,7 +4,15 @@ import { environment } from 'src/environments/environment'; import { ClienteManutencaoModel, + EquipamentoHistoricoFiltrosModel, + EquipamentoHistoricoListaModel, + EquipamentoHistoricoListaResponseModel, EquipamentoManutencaoModel, + EquipamentoProntuarioIndicadoresModel, + EquipamentoProntuarioResponseModel, + EquipamentoTimelineFiltrosModel, + EquipamentoTimelineItemModel, + EquipamentoTimelineResponseModel, GeoCoordenadasResponseModel, MaterialManutencaoModel, OrionApiResponseModel, @@ -105,11 +113,14 @@ export class OrionService { // LOGS // ========================================================== - getAllLogs(): Promise { + getAllLogs(meses: number = 3): Promise { + const params = new HttpParams().set('meses', String(meses)); + return this.executar( this.http .get>( - `${this.Rota}/getAllLogs` + `${this.Rota}/getAllLogs`, + { params } ) .toPromise() ); @@ -131,7 +142,7 @@ export class OrionService { ): Promise { return this.http .get( - `http://api.positionstack.com/v1/reverse` + + `https://api.positionstack.com/v1/reverse` + `?access_key=${environment.GeoAPI}` + `&query=${encodeURIComponent(coordenadas)}` ) @@ -185,6 +196,243 @@ export class OrionService { ); } + getEquipamentosHistorico( + filtros: EquipamentoHistoricoFiltrosModel = {} + ): Promise { + const params = this.montarParametros(filtros); + + return this.executar( + this.http + .get>( + `${this.Rota}/getEquipamentosHistorico`, + { params } + ) + .toPromise() + ).then(resultado => ({ + items: (resultado.items || []).map(item => + this.normalizarEquipamentoHistorico(item) + ), + pagination: { + page: this.numero(resultado.pagination.page), + pageSize: this.numero(resultado.pagination.pageSize), + total: this.numero(resultado.pagination.total), + totalPages: this.numero(resultado.pagination.totalPages) + } + })); + } + + private normalizarEquipamentoHistorico( + equipamento: EquipamentoHistoricoListaModel + ): EquipamentoHistoricoListaModel { + return { + ...equipamento, + id: this.numero(equipamento.id), + cliente_id: equipamento.cliente_id + ? this.numero(equipamento.cliente_id) + : null, + modelo_id: equipamento.modelo_id + ? this.numero(equipamento.modelo_id) + : null, + odometro_total_segundos: this.numero( + equipamento.odometro_total_segundos + ), + odometro_conectado_segundos: this.numero( + equipamento.odometro_conectado_segundos + ), + odometro_movimento_segundos: this.numero( + equipamento.odometro_movimento_segundos + ), + odometro_parcial_segundos: this.numero( + equipamento.odometro_parcial_segundos + ), + quantidade_os: this.numero(equipamento.quantidade_os), + quantidade_os_abertas: this.numero( + equipamento.quantidade_os_abertas + ), + valor_mao_obra_historico: this.numero( + equipamento.valor_mao_obra_historico + ), + valor_insumos_historico: this.numero( + equipamento.valor_insumos_historico + ), + valor_total_historico: this.numero( + equipamento.valor_total_historico + ), + valor_pago_historico: this.numero( + equipamento.valor_pago_historico + ), + valor_pendente_historico: this.numero( + equipamento.valor_pendente_historico + ), + ultima_os_id: equipamento.ultima_os_id + ? this.numero(equipamento.ultima_os_id) + : null + }; + } + + getEquipamentoProntuario( + equipamentoId: number + ): Promise { + return this.executar( + this.http + .get>( + `${this.Rota}/getEquipamentoProntuario/${equipamentoId}` + ) + .toPromise() + ).then(resultado => ({ + ...resultado, + equipamento: { + ...resultado.equipamento, + id: this.numero(resultado.equipamento.id), + cliente_id: resultado.equipamento.cliente_id + ? this.numero(resultado.equipamento.cliente_id) + : null, + modelo_id: resultado.equipamento.modelo_id + ? this.numero(resultado.equipamento.modelo_id) + : null, + odometro_total_segundos: this.numero( + resultado.equipamento.odometro_total_segundos + ), + odometro_conectado_segundos: this.numero( + resultado.equipamento.odometro_conectado_segundos + ), + odometro_movimento_segundos: this.numero( + resultado.equipamento.odometro_movimento_segundos + ), + odometro_parcial_segundos: this.numero( + resultado.equipamento.odometro_parcial_segundos + ) + }, + indicadores: this.normalizarIndicadoresProntuario( + resultado.indicadores + ), + ultima_os: resultado.ultima_os + ? this.normalizarOrdemLista(resultado.ultima_os) + : null, + ultima_saida_manutencao: resultado.ultima_saida_manutencao || null, + leituras_manutencao: (resultado.leituras_manutencao || []).map( + leitura => this.normalizarOdometro(leitura) + ) + })); + } + + getEquipamentoTimeline( + equipamentoId: number, + filtros: EquipamentoTimelineFiltrosModel = {} + ): Promise { + const params = this.montarParametros(filtros); + + return this.executar( + this.http + .get>( + `${this.Rota}/getEquipamentoTimeline/${equipamentoId}`, + { params } + ) + .toPromise() + ).then(resultado => ({ + items: (resultado.items || []).map(item => + this.normalizarTimelineItem(item) + ), + pagination: { + page: this.numero(resultado.pagination.page), + pageSize: this.numero(resultado.pagination.pageSize), + total: this.numero(resultado.pagination.total), + totalPages: this.numero(resultado.pagination.totalPages) + } + })); + } + + private normalizarIndicadoresProntuario( + indicadores: EquipamentoProntuarioIndicadoresModel + ): EquipamentoProntuarioIndicadoresModel { + const normalizado: any = { ...indicadores }; + const campos = [ + 'quantidade_os', 'quantidade_os_abertas', 'quantidade_os_efetivas', + 'quantidade_corretivas', 'quantidade_preventivas', + 'total_horas_expediente', 'total_horas_extras', + 'horas_movimento', 'horas_conectado', 'horas_total', 'horas_parcial', + 'valor_mao_obra', 'custo_insumos', 'valor_insumos', + 'valor_desconto', 'valor_acrescimo', 'valor_total', + 'valor_pago', 'valor_pendente', + 'movimento_desde_ultima_saida_segundos', + 'media_movimento_entre_manutencoes_segundos', + 'custo_medio_por_os', 'custo_por_hora_movimento' + ]; + + campos.forEach(campo => { + if (normalizado[campo] !== null && normalizado[campo] !== undefined) { + normalizado[campo] = this.numero(normalizado[campo]); + } + }); + + return normalizado as EquipamentoProntuarioIndicadoresModel; + } + + private normalizarTimelineItem( + item: EquipamentoTimelineItemModel + ): EquipamentoTimelineItemModel { + const ordem = this.normalizarOrdemLista(item); + + return { + ...item, + ...ordem, + custo_insumos: this.numero(item.custo_insumos), + atividades: (item.atividades || []).map(atividade => ({ + ...atividade, + id: this.numero(atividade.id), + ordem_servico_id: this.numero(atividade.ordem_servico_id), + ordem_exibicao: this.numero(atividade.ordem_exibicao), + horas_expediente: this.numero(atividade.horas_expediente), + horas_extras: this.numero(atividade.horas_extras), + valor_hora_expediente: this.numero(atividade.valor_hora_expediente), + valor_hora_extra: this.numero(atividade.valor_hora_extra), + valor_mao_obra: this.numero(atividade.valor_mao_obra), + tecnicos: atividade.tecnicos || [], + materiais: (atividade.materiais || []).map(material => ({ + ...material, + quantidade: this.numero(material.quantidade), + valor_custo_unitario: this.numero(material.valor_custo_unitario), + valor_cobrado_unitario: this.numero(material.valor_cobrado_unitario), + valor_custo_total: this.numero(material.valor_custo_total), + valor_cobrado_total: this.numero(material.valor_cobrado_total) + })) + })), + odometros: (item.odometros || []).map( + leitura => this.normalizarOdometro(leitura) + ), + odometros_da_os: (item.odometros || []).map( + leitura => this.normalizarOdometro(leitura) + ), + pagamentos: item.pagamentos || [], + historico_status: item.historico_status || [] + }; + } + + private normalizarOdometro( + leitura: OrdemServicoOdometroModel + ): OrdemServicoOdometroModel { + return { + ...leitura, + id: leitura.id ? this.numero(leitura.id) : null, + equipamento_id: leitura.equipamento_id + ? this.numero(leitura.equipamento_id) + : null, + ordem_servico_id: leitura.ordem_servico_id + ? this.numero(leitura.ordem_servico_id) + : null, + odometro_total_segundos: this.numero(leitura.odometro_total_segundos), + odometro_conectado_segundos: this.numero( + leitura.odometro_conectado_segundos + ), + odometro_movimento_segundos: this.numero( + leitura.odometro_movimento_segundos + ), + odometro_parcial_segundos: this.numero( + leitura.odometro_parcial_segundos + ) + }; + } + getTecnicosManutencao(): Promise { return this.executar( this.http @@ -486,4 +734,4 @@ export class OrionService { .then(response => response as Blob); } -} \ No newline at end of file +}