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 @@
-
-
-
-
-
Data
-
-
-
-
-
-
+
+
+
+
ORION TARD PRO
+
Telemetria de operação
+
Histórico, sessões e leitura operacional do robô.
+
-
+
+ Data
+
+
+
+
+
- Hora
-
-
-
-
- Filtrar
-
-
-
- 0">Usuário
-
- 0">
-
- {{Nome}}
-
-
-
-
-
- 0 && PerfisFiltro.length > 0">Perfil
-
- 0 && PerfisFiltro.length > 0">
-
- {{Perfil}}
-
-
-
-
-
- 1">Trabalho
-
- 1">
-
-
- {{trabalho[0].dataLog | date: 'HH:mm'}} ({{trabalho.length}} logs)
-
-
-
-
-
-
- 0 && PerfisFiltro.length > 0">Atualizar
-
-
-
-
-
-
-
-
- Data: {{FiltroSelecionado.data | date:'dd/MM/yyyy'}}
-
-
- Início: {{FiltroSelecionado.HoraInicio}}
-
-
- Fim: {{FiltroSelecionado.HoraFim}}
-
-
- Duração: {{FiltroSelecionado.Duracao}}
-
-
- Serviço: {{(FiltroSelecionado.servico != null) ? FiltroSelecionado.servico.Nome : ""}}
-
-
-
-
- Versão Software: {{FiltroSelecionado.versao}}
-
-
- Conectado: {{FiltroSelecionado.conectado == true ? "Sim" : "Não"}}
-
-
- Porta COM: {{FiltroSelecionado.portaCOM}}
-
-
- Baud Rate: {{FiltroSelecionado.baudRate}}
-
-
- {{FiltroSelecionado.camera}}
-
-
-
-
- Odômetro Total: {{SegundosParaTempo(FiltroSelecionado.odometroTotal)}}
-
-
- Odômetro Conectado: {{SegundosParaTempo(FiltroSelecionado.odometroConectado)}}
-
-
- Odômetro Motores: {{SegundosParaTempo(FiltroSelecionado.odometroMotores)}}
-
-
- Odômetro Motores Parcial: {{SegundosParaTempo(FiltroSelecionado.odometroMotoresParcial)}}
-
-
- Tempo Conectado: {{SegundosParaTempo(FiltroSelecionado.tempoConectado)}}
-
-
-
-
-
-
- Equipamento: {{FiltroSelecionado.modelo}}
-
-
- NS: {{FiltroSelecionado.robotNs}}
-
-
- Versão da Firmware: {{FiltroSelecionado.firmwareVersao}}
-
-
- Coordenadas: {{FiltroSelecionado.Coordenadas}}
-
-
- Localização: {{FiltroSelecionado.Localizacao}}
-
-
-
-
-
-
-
-
-
+
+ operação / log schema 2
+ log legado
-
+
Faixa de horário
+
+
-
-
-
-
- Data
- {{log.logObj.data | date:'short'}}
-
+
+ Aplicar período
+
+
-
- Nome
- {{log.nome}}
-
+
0">
+ Usuário
+
+
+
+ {{nome}}
+
+
+
-
- Perfil
- {{log.perfil}}
-
+ 0">Perfil
+ 0">
+
+
+ {{perfil}}
+
+
+
-
- Conectado
- {{log.logObj.conectado ? 'Sim' : 'Não'}}
-
+ 0">Operação
+ 0">
+
+
+ {{trabalhoLabel(trabalho)}}
+
+
+
+
-
- Câmera
- {{log.logObj.camera}}
-
+
+
{{LogsDetalhes.length}} logs carregados
+
{{Datas.length}} dias com registros
+
{{LogsPeriodo.length}} no período
+
0" class="warning">{{LogsInvalidos}} JSON inválido
+
-
-
-
+
+ Recarregar dados
+
+
-
-
-
-
-
+
+ Carregando logs do Orion...
+
+
+ Falha ao carregar os logs.
+ {{ErroCarregamento}}
+
+
+
+ Nenhum log encontrado no período disponibilizado pela API.
+
+
+ 0 && FiltroSelecionado == null">
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+ Consumo Corrente 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âmica Velocidade x comando
m/s + %
+
+
+
+ Progressão Distâ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ção Frontal 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
+
+
+
+
+ Proximidade Ultrassom 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 || '-'}}
+
+
+
+
+
+
+ {{FiltroSelecionado.Coordenadas || 'Sem coordenadas'}}
+ {{FiltroSelecionado.Localizacao || 'Endereço não resolvido'}}
+
+
+
+ 0 || diagnosticoTrabalho.logIdUltimo">
+
+
+ DIAGNÓSTICO AVANÇADO
+ Dados brutos e estatísticas dos sensores
+
+ {{sensoresDiagnostico.length}} sinais com dados
+
+
+
+
0">
+
Capacidades {{diagnosticoTrabalho.capacidades}}
+
Último log ID {{diagnosticoTrabalho.logIdUltimo}}
+
0">
+ Mensagens de evento
+ {{mensagem}}
+
+
+
+
0">
+
+
+ Sinal Mín. Média Mediana Máx. Último Amostras
+
+
+
+ {{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 }}
+
+ Voltar
+
+ Tentar novamente
+
+
+
+
+
+
+
+
+
+ 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' }}
+
0">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
+
+
+
+
+
+
+
Consultando histórico...
+
+
+
+
Não foi possível carregar a linha do tempo.
+
{{ erroTimeline }}
+
Tentar novamente
+
+
+
+
Nenhum evento encontrado.
+
Altere os filtros ou confira se já existem OSs para o equipamento.
+
+
+ 0">
+
+
+
+
+
+
+
+
+
+
+ Abrir OS completa
+
+
+
+
+
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.horas_expediente | number:'1.2-2' }} h expediente
+ 0">{{ 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 }}
+
+
+
+
+ {{ 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' }}
+
+
+
+
+
+
+
+ 0"
+ [length]="totalRegistros"
+ [pageIndex]="paginaAtual - 1"
+ [pageSize]="tamanhoPagina"
+ [pageSizeOptions]="opcoesTamanhoPagina"
+ [showFirstLastButtons]="true"
+ (page)="onPageChange($event)">
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+ 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 }}
+
+ Tentar novamente
+
+
+
+
+
Nenhum equipamento encontrado.
+
Altere os filtros ou confira o cadastro dos equipamentos.
+
+
+
+
+ 0"
+ [length]="totalRegistros"
+ [pageIndex]="paginaAtual - 1"
+ [pageSize]="tamanhoPagina"
+ [pageSizeOptions]="opcoesTamanhoPagina"
+ [showFirstLastButtons]="true"
+ (page)="onPageChange($event)">
+
+
+
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
+}