ajustado dashboard telemetria oriontardpro

This commit is contained in:
Diego Freitas 2026-09-22 21:32:20 -03:00
parent 445b7b5812
commit 59e0ab7e12
18 changed files with 6216 additions and 426 deletions

View File

@ -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(`

View File

@ -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: [

View File

@ -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,

View File

@ -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,
},
],
},
{

View File

@ -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';
| 'TELEMETRIA';

View File

@ -1,181 +1,435 @@
<div>
<mat-sidenav-container>
<mat-sidenav mode="side" opened>
<div style="margin-left: 1em; margin-right: 1em;">
<label>Data</label>
<br>
<mat-form-field>
<input matInput [matDatepicker]="picker" [(ngModel)]="DataSelecionada">
<mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker [dateClass]="highlightDates"></mat-datepicker>
</mat-form-field>
<div class="dashboard-shell">
<aside class="filters-panel">
<div class="brand-block">
<div class="eyebrow">ORION TARD PRO</div>
<h2>Telemetria de operação</h2>
<p>Histórico, sessões e leitura operacional do robô.</p>
</div>
<br><br>
<div class="filter-block">
<label>Data</label>
<mat-form-field appearance="outline">
<input matInput [matDatepicker]="picker" [(ngModel)]="DataSelecionada" placeholder="Escolha um dia">
<mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker [dateClass]="highlightDates"></mat-datepicker>
</mat-form-field>
<label>Hora</label>
<ng5-slider [(value)]="HoraInicial" [(highValue)]="HoraFinal" [options]="sliderOptions"></ng5-slider>
<br><br>
<button mat-raised-button color="primary" (click)="btnFiltrarClick()">Filtrar</button>
<br><br>
<label *ngIf="NomesFiltro.length > 0">Usuário</label>
<br>
<mat-form-field *ngIf="NomesFiltro.length > 0">
<mat-select (selectionChange)="txtNomeChange($event.value)">
<mat-option *ngFor="let Nome of NomesFiltro" [value]="Nome">{{Nome}}</mat-option>
</mat-select>
</mat-form-field>
<br><br>
<label *ngIf="NomesFiltro.length > 0 && PerfisFiltro.length > 0">Perfil</label>
<br>
<mat-form-field *ngIf="NomesFiltro.length > 0 && PerfisFiltro.length > 0">
<mat-select (selectionChange)="txtPerfilChange($event.value)">
<mat-option *ngFor="let Perfil of PerfisFiltro" [value]="Perfil">{{Perfil}}</mat-option>
</mat-select>
</mat-form-field>
<br><br>
<label *ngIf="trabalhos.length > 1">Trabalho</label>
<br>
<mat-form-field *ngIf="trabalhos.length > 1">
<mat-select (selectionChange)="onTrabalhoSelecionado($event.value)">
<mat-option *ngFor="let trabalho of trabalhos" [value]="trabalho">
{{trabalho[0].dataLog | date: 'HH:mm'}} ({{trabalho.length}} logs)
</mat-option>
</mat-select>
</mat-form-field>
<br><br>
<button mat-raised-button color="accent" (click)="btnAtualizarClick()" *ngIf="NomesFiltro.length > 0 && PerfisFiltro.length > 0">Atualizar</button>
</div>
</mat-sidenav>
<mat-sidenav-content style="height: 100vh;">
<div *ngIf="FiltroSelecionado != null">
<table>
<tr>
<td class="TabelaDados">
<label>Data: {{FiltroSelecionado.data | date:'dd/MM/yyyy'}}</label>
</td>
<td class="TabelaDados">
<label>Início: {{FiltroSelecionado.HoraInicio}}</label>
</td>
<td class="TabelaDados">
<label>Fim: {{FiltroSelecionado.HoraFim}}</label>
</td>
<td class="TabelaDados">
<label>Duração: {{FiltroSelecionado.Duracao}}</label>
</td>
<td class="TabelaDados">
<label>Serviço: {{(FiltroSelecionado.servico != null) ? FiltroSelecionado.servico.Nome : ""}}</label><br>
</td>
</tr>
<tr>
<td class="TabelaDados">
<label>Versão Software: {{FiltroSelecionado.versao}}</label>
</td>
<td class="TabelaDados">
<label>Conectado: {{FiltroSelecionado.conectado == true ? "Sim" : "Não"}}</label>
</td>
<td class="TabelaDados">
<label>Porta COM: {{FiltroSelecionado.portaCOM}}</label><br>
</td>
<td class="TabelaDados">
<label>Baud Rate: {{FiltroSelecionado.baudRate}}</label>
</td>
<td class="TabelaDados">
<label>{{FiltroSelecionado.camera}}</label>
</td>
</tr>
<tr>
<td class="TabelaDados">
<label>Odômetro Total: {{SegundosParaTempo(FiltroSelecionado.odometroTotal)}}</label>
</td>
<td class="TabelaDados">
<label>Odômetro Conectado: {{SegundosParaTempo(FiltroSelecionado.odometroConectado)}}</label><br>
</td>
<td class="TabelaDados">
<label>Odômetro Motores: {{SegundosParaTempo(FiltroSelecionado.odometroMotores)}}</label>
</td>
<td class="TabelaDados">
<label>Odômetro Motores Parcial: {{SegundosParaTempo(FiltroSelecionado.odometroMotoresParcial)}}</label>
</td>
<td class="TabelaDados">
<label>Tempo Conectado: {{SegundosParaTempo(FiltroSelecionado.tempoConectado)}}</label>
</td>
</tr>
</table>
<table>
<tr>
<td class="TabelaDados">
<Label>Equipamento: {{FiltroSelecionado.modelo}}</Label>
</td>
<td class="TabelaDados">
<Label>NS: {{FiltroSelecionado.robotNs}}</Label>
</td>
<td class="TabelaDados">
<Label>Versão da Firmware: {{FiltroSelecionado.firmwareVersao}}</Label>
</td>
<td class="TabelaDados">
<label>Coordenadas: {{FiltroSelecionado.Coordenadas}}</label>
</td>
<td class="TabelaDados">
<Label>Localização: {{FiltroSelecionado.Localizacao}}</Label>
</td>
</tr>
</table>
<mat-tab-group (selectedTabChange)="onTabChange($event)">
<mat-tab *ngFor="let Sensor of FiltroSelecionado.sensores" label="{{Sensor.descricao}}">
<canvas [id]="'chart_' + Sensor.sensor + '_' + Sensor.descricao"></canvas>
</mat-tab>
</mat-tab-group>
<div class="calendar-legend">
<span><i class="legend-dot v2"></i> operação / log schema 2</span>
<span><i class="legend-dot legacy"></i> log legado</span>
</div>
<br><br>
<label>Faixa de horário</label>
<ng5-slider
[(value)]="HoraInicial"
[(highValue)]="HoraFinal"
[options]="sliderOptions">
</ng5-slider>
<div *ngIf="false">
<mat-table [dataSource]="dataSource" class="mat-elevation-z8">
<!-- Colunas -->
<ng-container matColumnDef="data">
<mat-header-cell *matHeaderCellDef> Data </mat-header-cell>
<mat-cell *matCellDef="let log"> {{log.logObj.data | date:'short'}} </mat-cell>
</ng-container>
<button mat-raised-button color="primary" class="full-button" (click)="btnFiltrarClick()">
Aplicar período
</button>
</div>
<ng-container matColumnDef="nome">
<mat-header-cell *matHeaderCellDef> Nome </mat-header-cell>
<mat-cell *matCellDef="let log"> {{log.nome}} </mat-cell>
</ng-container>
<div class="filter-block" *ngIf="NomesFiltro.length > 0">
<label>Usuário</label>
<mat-form-field appearance="outline">
<mat-select [(ngModel)]="UsuarioSelecionado" (selectionChange)="txtNomeChange($event.value)">
<mat-option *ngFor="let nome of NomesFiltro" [value]="nome">
{{nome}}
</mat-option>
</mat-select>
</mat-form-field>
<ng-container matColumnDef="perfil">
<mat-header-cell *matHeaderCellDef> Perfil </mat-header-cell>
<mat-cell *matCellDef="let log"> {{log.perfil}} </mat-cell>
</ng-container>
<label *ngIf="PerfisFiltro.length > 0">Perfil</label>
<mat-form-field appearance="outline" *ngIf="PerfisFiltro.length > 0">
<mat-select [(ngModel)]="PerfilSelecionado" (selectionChange)="txtPerfilChange($event.value)">
<mat-option *ngFor="let perfil of PerfisFiltro" [value]="perfil">
{{perfil}}
</mat-option>
</mat-select>
</mat-form-field>
<ng-container matColumnDef="conectado">
<mat-header-cell *matHeaderCellDef> Conectado </mat-header-cell>
<mat-cell *matCellDef="let log"> {{log.logObj.conectado ? 'Sim' : 'Não'}} </mat-cell>
</ng-container>
<label *ngIf="trabalhos.length > 0">Operação</label>
<mat-form-field appearance="outline" *ngIf="trabalhos.length > 0">
<mat-select [value]="trabalhoSelecionadoOpcao" (selectionChange)="onTrabalhoSelecionado($event.value)">
<mat-option *ngFor="let trabalho of trabalhos" [value]="trabalho">
{{trabalhoLabel(trabalho)}}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<ng-container matColumnDef="camera">
<mat-header-cell *matHeaderCellDef> Câmera </mat-header-cell>
<mat-cell *matCellDef="let log"> {{log.logObj.camera}} </mat-cell>
</ng-container>
<div class="side-summary">
<div><strong>{{LogsDetalhes.length}}</strong><span>logs carregados</span></div>
<div><strong>{{Datas.length}}</strong><span>dias com registros</span></div>
<div><strong>{{LogsPeriodo.length}}</strong><span>no período</span></div>
<div *ngIf="LogsInvalidos > 0" class="warning"><strong>{{LogsInvalidos}}</strong><span>JSON inválido</span></div>
</div>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
</mat-table>
<button mat-button class="full-button refresh-button" (click)="carregarLogs()">
Recarregar dados
</button>
</aside>
<!-- Paginator -->
<mat-paginator [pageSizeOptions]="[10, 25, 50]" showFirstLastButtons></mat-paginator>
</div>
</mat-sidenav-content>
</mat-sidenav-container>
<main class="content-panel">
<div class="state-card" *ngIf="Carregando">Carregando logs do Orion...</div>
<div class="state-card error" *ngIf="!Carregando && ErroCarregamento">
<strong>Falha ao carregar os logs.</strong>
<span>{{ErroCarregamento}}</span>
</div>
<div class="state-card" *ngIf="!Carregando && !ErroCarregamento && LogsDetalhes.length === 0">
Nenhum log encontrado no período disponibilizado pela API.
</div>
<div class="state-card selection-hint" *ngIf="!Carregando && !ErroCarregamento && LogsDetalhes.length > 0 && FiltroSelecionado == null">
<strong>Escolha uma operação para abrir a telemetria.</strong>
<span>Selecione um dia destacado, aplique o período e escolha a sessão que deseja analisar.</span>
</div>
<ng-container *ngIf="FiltroSelecionado != null">
<section class="hero-card">
<div>
<div class="eyebrow">OPERAÇÃO SELECIONADA</div>
<h1>
{{FiltroSelecionado.modelo || 'Equipamento'}}
<span class="separator">•</span>
{{FiltroSelecionado.robotNs || 'NS'}}
</h1>
<p>
{{FiltroSelecionado.usuario || UsuarioSelecionado}}
<span class="separator">•</span>
{{PerfilSelecionado}}
</p>
</div>
<div class="hero-actions">
<span class="event-badge">{{eventoAtual()}}</span>
<div class="status-pill" [class.online]="FiltroSelecionado.conectado">
{{FiltroSelecionado.conectado ? 'Conectado' : 'Desconectado'}}
</div>
</div>
</section>
<section class="metric-grid headline-metrics">
<div class="metric-card emphasis">
<span>Início</span>
<strong>{{resumoTrabalho.inicio | date:'HH:mm:ss'}}</strong>
<small>{{resumoTrabalho.inicio | date:'dd/MM/yyyy'}}</small>
</div>
<div class="metric-card emphasis">
<span>Fim</span>
<strong>{{resumoTrabalho.fim | date:'HH:mm:ss'}}</strong>
<small>{{resumoTrabalho.fim | date:'dd/MM/yyyy'}}</small>
</div>
<div class="metric-card">
<span>Duração</span>
<strong>{{SegundosParaTempo(resumoTrabalho.duracaoSegundos)}}</strong>
<small>janela real dos logs</small>
</div>
<div class="metric-card">
<span>Distância</span>
<strong>{{resumoTrabalho.distanciaPercorrida | number:'1.1-2'}} m</strong>
<small>movimento estimado</small>
</div>
<div class="metric-card">
<span>Velocidade média</span>
<strong>{{formatarMedida(telemetria.velocidadeMedia, 2, 'm/s')}}</strong>
<small>máx. {{formatarMedida(telemetria.velocidadeMax, 2, 'm/s')}}</small>
</div>
<div class="metric-card">
<span>Bateria mínima</span>
<strong>{{formatarMedida(telemetria.bateriaMin, 1, '%')}}</strong>
<small>final {{formatarMedida(telemetria.bateriaFinal, 1, '%')}}</small>
</div>
</section>
<section class="story-section" *ngIf="telemetria.temEnergia">
<div class="story-heading">
<div>
<div class="eyebrow">ENERGIA</div>
<h2>Como o sistema elétrico se comportou</h2>
<p>Tensão, carga da bateria, corrente e potência no mesmo contexto operacional.</p>
</div>
</div>
<div class="story-kpis">
<div class="story-kpi"><span>Tensão média</span><strong>{{formatarMedida(telemetria.tensaoMedia, 2, 'V')}}</strong><small>mín. {{formatarMedida(telemetria.tensaoMin, 2, 'V')}}</small></div>
<div class="story-kpi"><span>Bateria média</span><strong>{{formatarMedida(telemetria.bateriaMedia, 1, '%')}}</strong><small>mín. {{formatarMedida(telemetria.bateriaMin, 1, '%')}}</small></div>
<div class="story-kpi"><span>Corrente média</span><strong>{{formatarCorrente(telemetria.correnteMedia)}}</strong><small>pico {{formatarCorrente(telemetria.correnteMax)}}</small></div>
<div class="story-kpi"><span>Potência média</span><strong>{{formatarMedida(telemetria.potenciaMediaW, 1, 'W')}}</strong><small>quando disponível no protocolo</small></div>
</div>
<div class="chart-grid two-columns">
<article class="telemetry-card" *ngIf="telemetria.temTensao">
<div class="card-heading"><div><span>Tensão</span><strong>Bateria sob carga</strong></div><small>V</small></div>
<div class="chart-box"><canvas id="chart_tensao"></canvas></div>
</article>
<article class="telemetry-card" *ngIf="telemetria.temBateria">
<div class="card-heading"><div><span>Carga</span><strong>Bateria disponível</strong></div><small>%</small></div>
<div class="chart-box"><canvas id="chart_bateria"></canvas></div>
</article>
<article class="telemetry-card wide-card" *ngIf="telemetria.temCorrente">
<div class="card-heading"><div><span>Consumo</span><strong>Corrente ao longo da operação</strong></div><small>escala convertida para A</small></div>
<div class="chart-box"><canvas id="chart_corrente"></canvas></div>
</article>
</div>
</section>
<section class="story-section" *ngIf="telemetria.temMovimento">
<div class="story-heading">
<div>
<div class="eyebrow">MOVIMENTO</div>
<h2>Comando, velocidade e avanço</h2>
<p>Relaciona o que foi comandado com a resposta de velocidade e a progressão de distância.</p>
</div>
</div>
<div class="story-kpis">
<div class="story-kpi"><span>Velocidade média</span><strong>{{formatarMedida(telemetria.velocidadeMedia, 2, 'm/s')}}</strong><small>máx. {{formatarMedida(telemetria.velocidadeMax, 2, 'm/s')}}</small></div>
<div class="story-kpi"><span>Comando médio</span><strong>{{formatarMedida(telemetria.comandoVelMedia, 1, '%')}}</strong><small>máx. {{formatarMedida(telemetria.comandoVelMax, 1, '%')}}</small></div>
<div class="story-kpi"><span>Distância final</span><strong>{{formatarMedida(telemetria.distanciaFinal, 2, 'm')}}</strong><small>posição acumulada</small></div>
<div class="story-kpi"><span>Deslocamento líquido</span><strong>{{formatarMedida(diagnosticoTrabalho.deslocamentoLiquido, 2, 'm')}}</strong><small>fim menos início</small></div>
</div>
<div class="chart-grid two-columns">
<article class="telemetry-card wide-card" *ngIf="telemetria.temVelocidade || telemetria.temComandoVel">
<div class="card-heading"><div><span>Resposta dinâmica</span><strong>Velocidade x comando</strong></div><small>m/s + %</small></div>
<div class="chart-box large"><canvas id="chart_velocidade"></canvas></div>
</article>
<article class="telemetry-card wide-card" *ngIf="telemetria.temDistancia">
<div class="card-heading"><div><span>Progressão</span><strong>Distância acumulada</strong></div><small>m</small></div>
<div class="chart-box"><canvas id="chart_distancia"></canvas></div>
</article>
</div>
</section>
<section class="story-section" *ngIf="telemetria.temEstabilidade">
<div class="story-heading">
<div>
<div class="eyebrow">ESTABILIDADE</div>
<h2>Postura do robô durante o trabalho</h2>
<p>Inclinação frontal e lateral agrupadas para revelar picos, terreno e momentos críticos.</p>
</div>
</div>
<div class="story-kpis">
<div class="story-kpi"><span>Frontal média</span><strong>{{formatarMedida(telemetria.inclinacaoFrontalMedia, 2, '°')}}</strong><small>|máx.| {{formatarMedida(telemetria.inclinacaoFrontalMaxAbs, 2, '°')}}</small></div>
<div class="story-kpi"><span>Lateral média</span><strong>{{formatarMedida(telemetria.inclinacaoLateralMedia, 2, '°')}}</strong><small>|máx.| {{formatarMedida(telemetria.inclinacaoLateralMaxAbs, 2, '°')}}</small></div>
<div class="story-kpi"><span>|Roll| máximo</span><strong>{{formatarMedida(diagnosticoTrabalho.rollMaxAbs, 2, '°')}}</strong><small>schema novo</small></div>
<div class="story-kpi"><span>|Pitch| máximo</span><strong>{{formatarMedida(diagnosticoTrabalho.pitchMaxAbs, 2, '°')}}</strong><small>schema novo</small></div>
</div>
<div class="chart-grid">
<article class="telemetry-card wide-card" *ngIf="telemetria.temInclinacaoFrontal || telemetria.temInclinacaoLateral">
<div class="card-heading"><div><span>Inclinação</span><strong>Frontal x lateral</strong></div><small>graus</small></div>
<div class="chart-box large"><canvas id="chart_inclinacao"></canvas></div>
</article>
</div>
</section>
<section class="story-section" *ngIf="telemetria.temAmbiente">
<div class="story-heading">
<div>
<div class="eyebrow">AMBIENTE</div>
<h2>Condições ao redor do equipamento</h2>
<p>Variáveis ambientais separadas por unidade para evitar escalas enganadoras.</p>
</div>
</div>
<div class="environment-grid">
<article class="telemetry-card" *ngIf="telemetria.temTemperatura">
<div class="card-heading"><div><span>Temperatura</span><strong>{{formatarMedida(telemetria.temperaturaMedia, 1, '°C')}}</strong></div><small>{{formatarMedida(telemetria.temperaturaMin, 1, '°C')}} → {{formatarMedida(telemetria.temperaturaMax, 1, '°C')}}</small></div>
<div class="chart-box compact"><canvas id="chart_temperatura"></canvas></div>
</article>
<article class="telemetry-card" *ngIf="telemetria.temAltitude">
<div class="card-heading"><div><span>Altitude</span><strong>{{formatarMedida(telemetria.altitudeMedia, 1, 'm')}}</strong></div><small>{{formatarMedida(telemetria.altitudeMin, 1, 'm')}} → {{formatarMedida(telemetria.altitudeMax, 1, 'm')}}</small></div>
<div class="chart-box compact"><canvas id="chart_altitude"></canvas></div>
</article>
<article class="telemetry-card" *ngIf="telemetria.temUmidade">
<div class="card-heading"><div><span>Umidade</span><strong>{{formatarMedida(telemetria.umidadeMedia, 1, '%')}}</strong></div><small>{{formatarMedida(telemetria.umidadeMin, 1, '%')}} → {{formatarMedida(telemetria.umidadeMax, 1, '%')}}</small></div>
<div class="chart-box compact"><canvas id="chart_umidade"></canvas></div>
</article>
<article class="telemetry-card" *ngIf="telemetria.temGas">
<div class="card-heading"><div><span>Gás</span><strong>{{formatarMedida(telemetria.gasMedia, 2, '')}}</strong></div><small>máx. {{formatarMedida(telemetria.gasMax, 2, '')}}</small></div>
<div class="chart-box compact"><canvas id="chart_gas"></canvas></div>
</article>
</div>
</section>
<section class="story-section safety-section" *ngIf="telemetria.temSeguranca || diagnosticoTrabalho.mascaraSaudeUltima !== null">
<div class="story-heading">
<div>
<div class="eyebrow">SEGURANÇA</div>
<h2>Saúde e eventos críticos</h2>
<p>Sinais que merecem leitura rápida, sem ocupar espaço com gráficos vazios.</p>
</div>
</div>
<div class="safety-grid">
<div class="safety-card" [class.alarm]="diagnosticoTrabalho.infiltracaoDetectada">
<span>Infiltração</span>
<strong>{{diagnosticoTrabalho.infiltracaoDetectada ? 'ALARME DETECTADO' : 'Sem alarme'}}</strong>
<small>raw máx. {{diagnosticoTrabalho.infiltracaoRawMax !== null ? diagnosticoTrabalho.infiltracaoRawMax : '-'}}</small>
</div>
<div class="safety-card">
<span>Máscara de saúde</span>
<strong>{{mascaraSaudeTexto()}}</strong>
<small>estado final reportado</small>
</div>
<div class="safety-card" *ngIf="telemetria.temUltrassom">
<span>Ultrassom</span>
<strong>{{formatarMedida(telemetria.ultrassomMin, 2, '')}}</strong>
<small>menor leitura da operação</small>
</div>
<div class="safety-card" *ngIf="!telemetria.temUltrassom">
<span>Ultrassom</span>
<strong>Sem dados</strong>
<small>nenhuma amostra válida</small>
</div>
</div>
<article class="telemetry-card safety-chart" *ngIf="telemetria.temUltrassom">
<div class="card-heading"><div><span>Proximidade</span><strong>Ultrassom ao longo da operação</strong></div></div>
<div class="chart-box compact"><canvas id="chart_ultrassom"></canvas></div>
</article>
</section>
<section class="info-card compact-info-card">
<div class="section-title">
<div>
<div class="eyebrow">CONTEXTO</div>
<h3>Equipamento e sessão</h3>
</div>
<span class="event-badge">schema {{resumoTrabalho.schemas}}</span>
</div>
<div class="info-grid equipment-grid">
<div><span>NS</span><strong>{{FiltroSelecionado.robotNs || '-'}}</strong></div>
<div><span>Software</span><strong>{{FiltroSelecionado.versao || '-'}}</strong></div>
<div><span>Firmware</span><strong>{{FiltroSelecionado.firmwareVersaoStr || FiltroSelecionado.firmwareVersao || '-'}}</strong></div>
<div><span>Protocolo</span><strong>{{FiltroSelecionado.protocoloVersao || '-'}}</strong></div>
<div><span>Hardware</span><strong>{{FiltroSelecionado.revisaoHardware || '-'}}</strong></div>
<div><span>Porta / Baud</span><strong>{{FiltroSelecionado.portaCOM || '-'}} / {{FiltroSelecionado.baudRate || '-'}}</strong></div>
</div>
<div class="odometer-heading">
<div>
<span>ODÔMETROS DA SESSÃO</span>
<strong>Inicial → final e variação capturada</strong>
</div>
<div class="odometer-duty" *ngIf="odometrosTrabalho.motores.delta !== null">
<span>Motores ativos</span>
<strong>{{SegundosParaTempo(odometrosTrabalho.motores.delta)}}</strong>
<small *ngIf="odometrosTrabalho.movimentoPercentual !== null">{{formatarMedida(odometrosTrabalho.movimentoPercentual, 1, '%')}} do intervalo medido</small>
</div>
</div>
<div class="odometer-grid">
<article class="odometer-card">
<div class="odometer-title">Total</div>
<div class="odometer-values">
<div><span>Inicial</span><strong>{{odometrosTrabalho.total.inicial !== null ? SegundosParaTempo(odometrosTrabalho.total.inicial) : '-'}}</strong></div>
<div class="odometer-arrow">→</div>
<div><span>Final</span><strong>{{odometrosTrabalho.total.final !== null ? SegundosParaTempo(odometrosTrabalho.total.final) : '-'}}</strong></div>
</div>
<div class="odometer-delta"><span>Δ sessão</span><strong>{{formatarDeltaOdometro(odometrosTrabalho.total)}}</strong></div>
</article>
<article class="odometer-card">
<div class="odometer-title">Conectado</div>
<div class="odometer-values">
<div><span>Inicial</span><strong>{{odometrosTrabalho.conectado.inicial !== null ? SegundosParaTempo(odometrosTrabalho.conectado.inicial) : '-'}}</strong></div>
<div class="odometer-arrow">→</div>
<div><span>Final</span><strong>{{odometrosTrabalho.conectado.final !== null ? SegundosParaTempo(odometrosTrabalho.conectado.final) : '-'}}</strong></div>
</div>
<div class="odometer-delta"><span>Δ sessão</span><strong>{{formatarDeltaOdometro(odometrosTrabalho.conectado)}}</strong></div>
</article>
<article class="odometer-card emphasis">
<div class="odometer-title">Motores / movimento</div>
<div class="odometer-values">
<div><span>Inicial</span><strong>{{odometrosTrabalho.motores.inicial !== null ? SegundosParaTempo(odometrosTrabalho.motores.inicial) : '-'}}</strong></div>
<div class="odometer-arrow">→</div>
<div><span>Final</span><strong>{{odometrosTrabalho.motores.final !== null ? SegundosParaTempo(odometrosTrabalho.motores.final) : '-'}}</strong></div>
</div>
<div class="odometer-delta"><span>Tempo em movimento</span><strong>{{formatarDeltaOdometro(odometrosTrabalho.motores)}}</strong></div>
</article>
<article class="odometer-card">
<div class="odometer-title">Parcial</div>
<div class="odometer-values">
<div><span>Inicial</span><strong>{{odometrosTrabalho.parcial.inicial !== null ? SegundosParaTempo(odometrosTrabalho.parcial.inicial) : '-'}}</strong></div>
<div class="odometer-arrow">→</div>
<div><span>Final</span><strong>{{odometrosTrabalho.parcial.final !== null ? SegundosParaTempo(odometrosTrabalho.parcial.final) : '-'}}</strong></div>
</div>
<div class="odometer-delta"><span>Δ sessão</span><strong>{{formatarDeltaOdometro(odometrosTrabalho.parcial)}}</strong></div>
</article>
</div>
</section>
<section class="info-card" *ngIf="FiltroSelecionado.servico">
<div class="section-title"><div><div class="eyebrow">SERVIÇO</div><h3>Contexto do atendimento</h3></div></div>
<div class="info-grid">
<div><span>Nome</span><strong>{{FiltroSelecionado.servico.Nome || '-'}}</strong></div>
<div><span>Cidade</span><strong>{{FiltroSelecionado.servico.Cidade || '-'}}</strong></div>
<div><span>Responsável</span><strong>{{FiltroSelecionado.servico.Responsavel || '-'}}</strong></div>
<div><span>Serviço</span><strong>{{FiltroSelecionado.servico.Servico || '-'}}</strong></div>
</div>
</section>
<section class="info-card location-card">
<div class="section-title"><div><div class="eyebrow">LOCAL</div><h3>Posição registrada</h3></div></div>
<div class="location-line">
<strong>{{FiltroSelecionado.Coordenadas || 'Sem coordenadas'}}</strong>
<span>{{FiltroSelecionado.Localizacao || 'Endereço não resolvido'}}</span>
</div>
</section>
<details class="advanced-panel" *ngIf="sensoresDiagnostico.length > 0 || diagnosticoTrabalho.logIdUltimo">
<summary>
<span>
<span class="eyebrow">DIAGNÓSTICO AVANÇADO</span>
<strong>Dados brutos e estatísticas dos sensores</strong>
</span>
<small>{{sensoresDiagnostico.length}} sinais com dados</small>
</summary>
<div class="advanced-content">
<div class="diagnostic-lines" *ngIf="diagnosticoTrabalho.capacidades || diagnosticoTrabalho.logIdUltimo || diagnosticoTrabalho.mensagensEventos.length > 0">
<div *ngIf="diagnosticoTrabalho.capacidades"><span>Capacidades</span><strong>{{diagnosticoTrabalho.capacidades}}</strong></div>
<div *ngIf="diagnosticoTrabalho.logIdUltimo"><span>Último log ID</span><strong class="mono">{{diagnosticoTrabalho.logIdUltimo}}</strong></div>
<div *ngIf="diagnosticoTrabalho.mensagensEventos.length > 0">
<span>Mensagens de evento</span>
<strong *ngFor="let mensagem of diagnosticoTrabalho.mensagensEventos">{{mensagem}}</strong>
</div>
</div>
<div class="diagnostic-table-wrap" *ngIf="sensoresDiagnostico.length > 0">
<table class="diagnostic-table">
<thead>
<tr><th>Sinal</th><th>Mín.</th><th>Média</th><th>Mediana</th><th>Máx.</th><th>Último</th><th>Amostras</th></tr>
</thead>
<tbody>
<tr *ngFor="let item of sensoresDiagnostico">
<td><strong>{{item.nome}}</strong><small>ID {{item.sensor.sensor}}</small></td>
<td>{{item.sensor.minimo | number:'1.0-3'}}</td>
<td>{{item.sensor.media | number:'1.0-3'}}</td>
<td>{{item.sensor.mediana | number:'1.0-3'}}</td>
<td>{{item.sensor.maximo | number:'1.0-3'}}</td>
<td>{{item.sensor.ultimo | number:'1.0-3'}}</td>
<td>{{item.sensor.quantidadeAmostras || 0}}</td>
</tr>
</tbody>
</table>
</div>
</div>
</details>
<section class="state-card" *ngIf="!temDadosTelemetria()">
Esta operação não possui séries de telemetria suficientes para montar os painéis.
</section>
</ng-container>
</main>
</div>

View File

@ -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%;
}
.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;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,361 @@
<div class="prontuario-page">
<div class="loading-main" *ngIf="carregandoProntuario && !prontuario">
<div class="loading-bar"></div>
<span>Montando prontuário do equipamento...</span>
</div>
<div class="error-main" *ngIf="!carregandoProntuario && erroProntuario">
<strong>Não foi possível abrir o prontuário.</strong>
<p>{{ erroProntuario }}</p>
<div class="error-actions">
<button mat-stroked-button type="button" (click)="voltar()">Voltar</button>
<button mat-raised-button color="primary" type="button" (click)="carregarProntuario()">
Tentar novamente
</button>
</div>
</div>
<ng-container *ngIf="prontuario">
<header class="hero-card">
<div class="hero-main">
<button mat-button type="button" class="back-button" (click)="voltar()">
← Equipamentos
</button>
<div class="identity-row">
<div class="robot-mark">OT</div>
<div>
<span class="eyebrow">PRONTUÁRIO TÉCNICO</span>
<h1>{{ equipamento.nome || equipamento.modelo_nome }}</h1>
<div class="identity-meta">
<span class="serial-chip">NS {{ equipamento.numero_serie }}</span>
<span>{{ equipamento.modelo_nome || 'Modelo não informado' }}</span>
<span *ngIf="equipamento.versao">Versão {{ equipamento.versao }}</span>
<span *ngIf="equipamento.codigo_interno">{{ equipamento.codigo_interno }}</span>
</div>
</div>
</div>
</div>
<div class="hero-side">
<span class="hero-side-label">Cliente atual</span>
<strong>{{ equipamento.cliente_nome || 'Sem cliente vinculado' }}</strong>
<small *ngIf="equipamento.cliente_razao_social">
{{ equipamento.cliente_razao_social }}
</small>
<button mat-stroked-button type="button" (click)="atualizar()" [disabled]="carregandoProntuario || carregandoTimeline">
Atualizar prontuário
</button>
</div>
</header>
<section class="metrics-grid usage-grid">
<article class="metric-card metric-primary">
<span>Movimento vitalício</span>
<strong>{{ indicadores.horas_movimento | number:'1.1-1' }} h</strong>
<small>Odômetro principal do equipamento</small>
</article>
<article class="metric-card">
<span>Tempo conectado</span>
<strong>{{ indicadores.horas_conectado | number:'1.1-1' }} h</strong>
<small>Total acumulado ligado</small>
</article>
<article class="metric-card">
<span>Odômetro parcial</span>
<strong>{{ indicadores.horas_parcial | number:'1.1-1' }} h</strong>
<small>Desde o último reset parcial</small>
</article>
<article class="metric-card">
<span>Desde a última saída</span>
<strong *ngIf="indicadores.movimento_desde_ultima_saida_segundos !== null && indicadores.movimento_desde_ultima_saida_segundos !== undefined">
{{ horasSegundos(indicadores.movimento_desde_ultima_saida_segundos) | number:'1.1-1' }} h
</strong>
<strong *ngIf="indicadores.movimento_desde_ultima_saida_segundos === null || indicadores.movimento_desde_ultima_saida_segundos === undefined">—</strong>
<small>Movimento após a manutenção</small>
</article>
</section>
<div class="overview-grid">
<section class="overview-card maintenance-card">
<div class="section-heading">
<div>
<span class="section-kicker">MANUTENÇÃO</span>
<h2>Resumo de atendimentos</h2>
</div>
<span class="big-number">{{ indicadores.quantidade_os_efetivas }}</span>
</div>
<div class="maintenance-stats">
<div><strong>{{ indicadores.quantidade_os_abertas }}</strong><span>OS abertas</span></div>
<div><strong>{{ indicadores.quantidade_corretivas }}</strong><span>Corretivas</span></div>
<div><strong>{{ indicadores.quantidade_preventivas }}</strong><span>Preventivas</span></div>
<div>
<strong *ngIf="indicadores.media_movimento_entre_manutencoes_segundos !== null && indicadores.media_movimento_entre_manutencoes_segundos !== undefined">
{{ horasSegundos(indicadores.media_movimento_entre_manutencoes_segundos) | number:'1.0-1' }} h
</strong>
<strong *ngIf="indicadores.media_movimento_entre_manutencoes_segundos === null || indicadores.media_movimento_entre_manutencoes_segundos === undefined">—</strong>
<span>Média entre manutenções</span>
</div>
</div>
<div class="last-maintenance" *ngIf="prontuario.ultima_os">
<div>
<span>Última ordem de serviço</span>
<strong>OS #{{ prontuario.ultima_os.id }}</strong>
<small>{{ prontuario.ultima_os.data_entrada | date:'dd/MM/yyyy HH:mm' }}</small>
</div>
<span class="status-badge" [ngClass]="classeStatus(prontuario.ultima_os.status)">
{{ labelStatus(prontuario.ultima_os.status) }}
</span>
</div>
</section>
<section class="overview-card financial-card">
<div class="section-heading">
<div>
<span class="section-kicker">CUSTO DE VIDA</span>
<h2>Histórico financeiro</h2>
</div>
<strong class="total-cost">{{ indicadores.valor_total | currency:'BRL':'symbol':'1.2-2' }}</strong>
</div>
<div class="financial-rows">
<div><span>Mão de obra</span><strong>{{ indicadores.valor_mao_obra | currency:'BRL':'symbol':'1.2-2' }}</strong></div>
<div><span>Peças e insumos</span><strong>{{ indicadores.valor_insumos | currency:'BRL':'symbol':'1.2-2' }}</strong></div>
<div><span>Acréscimos</span><strong>{{ indicadores.valor_acrescimo | currency:'BRL':'symbol':'1.2-2' }}</strong></div>
<div><span>Descontos</span><strong>- {{ indicadores.valor_desconto | currency:'BRL':'symbol':'1.2-2' }}</strong></div>
<div class="financial-divider"><span>Recebido</span><strong>{{ indicadores.valor_pago | currency:'BRL':'symbol':'1.2-2' }}</strong></div>
<div [class.pending-value]="indicadores.valor_pendente > 0"><span>Pendente</span><strong>{{ indicadores.valor_pendente | currency:'BRL':'symbol':'1.2-2' }}</strong></div>
</div>
<div class="financial-footer">
<span>Média por OS: <strong>{{ indicadores.custo_medio_por_os | currency:'BRL':'symbol':'1.2-2' }}</strong></span>
<span *ngIf="indicadores.custo_por_hora_movimento !== null && indicadores.custo_por_hora_movimento !== undefined">
Por hora em movimento: <strong>{{ indicadores.custo_por_hora_movimento | currency:'BRL':'symbol':'1.2-2' }}</strong>
</span>
</div>
</section>
</div>
<section class="history-section">
<div class="history-heading">
<div>
<span class="section-kicker">LINHA DO TEMPO</span>
<h2>Histórico completo do equipamento</h2>
<p>Manutenções, peças, mão de obra, odômetros e alterações de status.</p>
</div>
<span class="history-count">{{ totalRegistros }} OS</span>
</div>
<div class="filters-card">
<div class="filters-grid">
<mat-form-field appearance="outline" class="field-search">
<mat-label>Buscar no histórico</mat-label>
<input matInput [(ngModel)]="busca" (keyup.enter)="aplicarFiltros()" placeholder="Motor, conversor RS485, diagnóstico, peça...">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Tipo</mat-label>
<mat-select [(ngModel)]="tipoSelecionado">
<mat-option *ngFor="let opcao of tipoOpcoes" [value]="opcao.valor">{{ opcao.texto }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Status</mat-label>
<mat-select [(ngModel)]="statusSelecionado">
<mat-option *ngFor="let opcao of statusOpcoes" [value]="opcao.valor">{{ opcao.texto }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Data inicial</mat-label>
<input matInput [matDatepicker]="pickerInicio" [(ngModel)]="dataInicio">
<mat-datepicker-toggle matSuffix [for]="pickerInicio"></mat-datepicker-toggle>
<mat-datepicker #pickerInicio></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Data final</mat-label>
<input matInput [matDatepicker]="pickerFim" [(ngModel)]="dataFim">
<mat-datepicker-toggle matSuffix [for]="pickerFim"></mat-datepicker-toggle>
<mat-datepicker #pickerFim></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Peças e insumos</mat-label>
<mat-select [(ngModel)]="somenteComMateriais">
<mat-option *ngFor="let opcao of materiaisOpcoes" [value]="opcao.valor">{{ opcao.texto }}</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="filters-actions">
<button mat-button type="button" (click)="limparFiltros()" [disabled]="carregandoTimeline">Limpar</button>
<button mat-raised-button color="primary" type="button" (click)="aplicarFiltros()" [disabled]="carregandoTimeline">Pesquisar</button>
</div>
</div>
<div class="timeline-loading" *ngIf="carregandoTimeline">
<div class="loading-bar"></div>
<span>Consultando histórico...</span>
</div>
<div class="timeline-error" *ngIf="!carregandoTimeline && erroTimeline">
<strong>Não foi possível carregar a linha do tempo.</strong>
<p>{{ erroTimeline }}</p>
<button mat-stroked-button type="button" (click)="carregarTimeline()">Tentar novamente</button>
</div>
<div class="timeline-empty" *ngIf="!carregandoTimeline && !erroTimeline && timeline.length === 0">
<strong>Nenhum evento encontrado.</strong>
<p>Altere os filtros ou confira se já existem OSs para o equipamento.</p>
</div>
<div class="timeline" *ngIf="!carregandoTimeline && !erroTimeline && timeline.length > 0">
<article class="timeline-event" *ngFor="let ordem of timeline; trackBy: trackById">
<div class="timeline-marker">
<span></span>
</div>
<div class="os-card" [class.expanded]="ordemExpandida(ordem.id)">
<button class="os-card-header" type="button" (click)="alternarOrdem(ordem.id)">
<div class="os-date">
<strong>{{ ordem.data_entrada | date:'dd' }}</strong>
<span>{{ ordem.data_entrada | date:'MMM yyyy' }}</span>
<small>{{ ordem.data_entrada | date:'HH:mm' }}</small>
</div>
<div class="os-title">
<div class="os-title-line">
<strong>OS #{{ ordem.id }}</strong>
<span class="type-chip">{{ labelTipo(ordem.tipo) }}</span>
<span class="status-badge" [ngClass]="classeStatus(ordem.status)">{{ labelStatus(ordem.status) }}</span>
</div>
<p>{{ ordem.problema_relatado || 'Sem problema relatado.' }}</p>
<div class="os-resume">
<span>{{ ordem.atividades.length }} atividade(s)</span>
<span>{{ quantidadeMateriais(ordem) }} peça(s)/insumo(s)</span>
<span>{{ totalHorasOrdem(ordem) | number:'1.2-2' }} h de trabalho</span>
<span>{{ ordem.valor_total | currency:'BRL':'symbol':'1.2-2' }}</span>
</div>
</div>
<span class="expand-icon">{{ ordemExpandida(ordem.id) ? '−' : '+' }}</span>
</button>
<div class="os-card-body" *ngIf="ordemExpandida(ordem.id)">
<div class="os-actions">
<button mat-stroked-button color="primary" type="button" (click)="abrirOrdem(ordem)">Abrir OS completa</button>
</div>
<div class="narrative-grid">
<div class="narrative-item">
<span>Problema relatado</span>
<p>{{ ordem.problema_relatado || 'Não informado.' }}</p>
</div>
<div class="narrative-item">
<span>Diagnóstico</span>
<p>{{ ordem.diagnostico || 'Não informado.' }}</p>
</div>
<div class="narrative-item">
<span>Solução</span>
<p>{{ ordem.solucao_resumo || 'Não informada.' }}</p>
</div>
</div>
<div class="odometer-pair" *ngIf="leituraEntrada(ordem) || leituraSaida(ordem)">
<div *ngIf="leituraEntrada(ordem)">
<span>Entrada na manutenção</span>
<strong>{{ horasSegundos(leituraEntrada(ordem).odometro_movimento_segundos) | number:'1.1-1' }} h</strong>
<small>Movimento · parcial {{ horasSegundos(leituraEntrada(ordem).odometro_parcial_segundos) | number:'1.1-1' }} h</small>
</div>
<div *ngIf="leituraSaida(ordem)">
<span>Saída da manutenção</span>
<strong>{{ horasSegundos(leituraSaida(ordem).odometro_movimento_segundos) | number:'1.1-1' }} h</strong>
<small>Movimento · parcial {{ horasSegundos(leituraSaida(ordem).odometro_parcial_segundos) | number:'1.1-1' }} h</small>
</div>
</div>
<section class="detail-section" *ngIf="ordem.atividades.length">
<h3>Atividades executadas</h3>
<div class="activity-list">
<article class="activity-card" *ngFor="let atividade of ordem.atividades; let i = index">
<div class="activity-number">{{ i + 1 }}</div>
<div class="activity-content">
<div class="activity-header">
<strong>{{ atividade.descricao }}</strong>
<span>{{ atividade.data_atividade | date:'dd/MM/yyyy HH:mm' }}</span>
</div>
<div class="activity-meta">
<span>{{ atividade.horas_expediente | number:'1.2-2' }} h expediente</span>
<span *ngIf="atividade.horas_extras > 0">{{ atividade.horas_extras | number:'1.2-2' }} h extras</span>
<span>{{ atividade.valor_mao_obra | currency:'BRL':'symbol':'1.2-2' }} de mão de obra</span>
</div>
<div class="technicians" *ngIf="atividade.tecnicos.length">
<span *ngFor="let tecnico of atividade.tecnicos">
{{ tecnico.tecnico_nome }} · {{ tecnico.papel }}
</span>
</div>
<p class="activity-notes" *ngIf="atividade.observacoes_cliente">{{ atividade.observacoes_cliente }}</p>
<div class="materials-table" *ngIf="atividade.materiais.length">
<div class="material-row material-header">
<span>Peça / insumo</span><span>Qtd.</span><span>Unitário</span><span>Total</span>
</div>
<div class="material-row" *ngFor="let material of atividade.materiais">
<span><strong>{{ material.descricao_snapshot }}</strong><small *ngIf="material.observacoes">{{ material.observacoes }}</small></span>
<span>{{ material.quantidade | number:'1.0-2' }} {{ material.unidade_snapshot }}</span>
<span>{{ material.valor_cobrado_unitario | currency:'BRL':'symbol':'1.2-2' }}</span>
<span>{{ valorMaterial(material) | currency:'BRL':'symbol':'1.2-2' }}</span>
</div>
</div>
</div>
</article>
</div>
</section>
<div class="bottom-details">
<section class="detail-section status-history" *ngIf="ordem.historico_status.length">
<h3>Evolução do status</h3>
<div class="status-step" *ngFor="let item of ordem.historico_status">
<span class="status-dot"></span>
<div>
<strong>{{ labelStatus(item.status_novo) }}</strong>
<small>{{ item.created_at | date:'dd/MM/yyyy HH:mm' }}</small>
<p *ngIf="item.observacoes">{{ item.observacoes }}</p>
</div>
</div>
</section>
<section class="detail-section order-financial">
<h3>Fechamento da OS</h3>
<div><span>Mão de obra</span><strong>{{ ordem.valor_mao_obra | currency:'BRL':'symbol':'1.2-2' }}</strong></div>
<div><span>Peças e insumos</span><strong>{{ ordem.valor_insumos | currency:'BRL':'symbol':'1.2-2' }}</strong></div>
<div class="order-total"><span>Total</span><strong>{{ ordem.valor_total | currency:'BRL':'symbol':'1.2-2' }}</strong></div>
</section>
</div>
</div>
</div>
</article>
</div>
<mat-paginator
*ngIf="!erroTimeline && totalRegistros > 0"
[length]="totalRegistros"
[pageIndex]="paginaAtual - 1"
[pageSize]="tamanhoPagina"
[pageSizeOptions]="opcoesTamanhoPagina"
[showFirstLastButtons]="true"
(page)="onPageChange($event)">
</mat-paginator>
</section>
</ng-container>
</div>

View File

@ -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; }
}

View File

@ -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<OrionEquipamentoProntuarioComponent>;
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();
});
});

View File

@ -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<number>();
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;
}
}

View File

@ -0,0 +1,294 @@
<div class="equipamentos-page">
<div class="page-header">
<div>
<span class="eyebrow">ORIONTARD</span>
<h1>Equipamentos</h1>
<p>Visão individual dos robôs, histórico de manutenção, utilização e custos.</p>
</div>
<div class="header-actions">
<button
mat-stroked-button
type="button"
(click)="atualizar()"
[disabled]="carregando">
Atualizar
</button>
</div>
</div>
<section class="filters-card">
<div class="filters-grid">
<mat-form-field appearance="outline" class="field-search">
<mat-label>Pesquisar</mat-label>
<input
matInput
[(ngModel)]="busca"
(keyup.enter)="aplicarFiltros()"
placeholder="Número de série, nome, modelo ou cliente">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Modelo</mat-label>
<mat-select
[(ngModel)]="modeloSelecionado"
[disabled]="carregandoFiltros">
<mat-option value="">Todos os modelos</mat-option>
<mat-option *ngFor="let modelo of modelos" [value]="modelo.nome">
{{ modelo.nome }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Cliente</mat-label>
<mat-select
[(ngModel)]="clienteSelecionado"
[disabled]="carregandoFiltros">
<mat-option [value]="null">Todos os clientes</mat-option>
<mat-option *ngFor="let cliente of clientes" [value]="cliente.id">
{{ cliente.nome_fantasia }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Versão</mat-label>
<input
matInput
[(ngModel)]="versao"
(keyup.enter)="aplicarFiltros()"
placeholder="Ex.: V3">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Situação da manutenção</mat-label>
<mat-select [(ngModel)]="possuiOsAberta">
<mat-option *ngFor="let opcao of osAbertaOpcoes" [value]="opcao.valor">
{{ opcao.texto }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Status da última OS</mat-label>
<mat-select [(ngModel)]="statusUltimaOs">
<mat-option *ngFor="let opcao of statusOpcoes" [value]="opcao.valor">
{{ opcao.texto }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="filters-actions">
<button
mat-button
type="button"
(click)="limparFiltros()"
[disabled]="carregando">
Limpar
</button>
<button
mat-raised-button
color="primary"
type="button"
(click)="aplicarFiltros()"
[disabled]="carregando">
Filtrar
</button>
</div>
</section>
<section class="summary-grid">
<article class="summary-card">
<span class="summary-label">Equipamentos encontrados</span>
<strong>{{ totalRegistros }}</strong>
<small>{{ dataSource.data.length }} nesta página</small>
</article>
<article class="summary-card summary-card-alerta">
<span class="summary-label">OS abertas na página</span>
<strong>{{ osAbertasNaPagina }}</strong>
<small>Equipamentos em atendimento</small>
</article>
<article class="summary-card">
<span class="summary-label">Movimento acumulado</span>
<strong>{{ horas(movimentoNaPagina) | number:'1.1-1' }} h</strong>
<small>Soma dos equipamentos exibidos</small>
</article>
<article class="summary-card">
<span class="summary-label">Custo histórico na página</span>
<strong>{{ custoNaPagina | currency:'BRL':'symbol':'1.2-2' }}</strong>
<small>OS efetivas exibidas</small>
</article>
</section>
<section class="table-card">
<div class="loading-state" *ngIf="carregando">
<div class="loading-bar"></div>
<span>Carregando equipamentos...</span>
</div>
<div class="error-state" *ngIf="!carregando && erro">
<strong>Não foi possível carregar os equipamentos.</strong>
<p>{{ erro }}</p>
<button mat-stroked-button type="button" (click)="atualizar()">
Tentar novamente
</button>
</div>
<div
class="empty-state"
*ngIf="!carregando && !erro && dataSource.data.length === 0">
<strong>Nenhum equipamento encontrado.</strong>
<p>Altere os filtros ou confira o cadastro dos equipamentos.</p>
</div>
<div
class="table-scroll"
*ngIf="!carregando && !erro && dataSource.data.length > 0">
<table mat-table [dataSource]="dataSource" class="equipamentos-table">
<ng-container matColumnDef="equipamento">
<th mat-header-cell *matHeaderCellDef>Equipamento</th>
<td mat-cell *matCellDef="let equipamento">
<div class="equipment-cell">
<strong>{{ nomeEquipamento(equipamento) }}</strong>
<span class="serial-number">NS {{ equipamento.numero_serie }}</span>
<small *ngIf="equipamento.codigo_interno">
{{ equipamento.codigo_interno }}
</small>
</div>
</td>
</ng-container>
<ng-container matColumnDef="modelo">
<th mat-header-cell *matHeaderCellDef>Modelo / versão</th>
<td mat-cell *matCellDef="let equipamento">
<div class="stacked-cell">
<strong>{{ equipamento.modelo_nome || 'Não informado' }}</strong>
<span>{{ equipamento.versao || 'Sem versão' }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="cliente">
<th mat-header-cell *matHeaderCellDef>Cliente</th>
<td mat-cell *matCellDef="let equipamento">
{{ equipamento.cliente_nome || 'Sem cliente vinculado' }}
</td>
</ng-container>
<ng-container matColumnDef="movimento">
<th mat-header-cell *matHeaderCellDef>Movimento</th>
<td mat-cell *matCellDef="let equipamento">
<div class="metric-cell">
<strong>{{ horas(equipamento.odometro_movimento_segundos) | number:'1.1-1' }} h</strong>
<span>
Parcial: {{ horas(equipamento.odometro_parcial_segundos) | number:'1.1-1' }} h
</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="ordens">
<th mat-header-cell *matHeaderCellDef>Ordens</th>
<td mat-cell *matCellDef="let equipamento">
<div class="orders-cell">
<strong>{{ equipamento.quantidade_os }}</strong>
<span
*ngIf="equipamento.quantidade_os_abertas > 0"
class="open-order">
{{ equipamento.quantidade_os_abertas }} aberta(s)
</span>
<span *ngIf="equipamento.quantidade_os_abertas === 0">
Nenhuma aberta
</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="ultima_manutencao">
<th mat-header-cell *matHeaderCellDef>Última manutenção</th>
<td mat-cell *matCellDef="let equipamento">
<button
*ngIf="equipamento.ultima_os_id"
mat-button
type="button"
class="os-link"
(click)="abrirUltimaOs($event, equipamento)">
<strong>OS #{{ equipamento.ultima_os_id }}</strong>
<span>{{ equipamento.ultima_os_data_entrada | date:'dd/MM/yyyy' }}</span>
</button>
<span *ngIf="!equipamento.ultima_os_id" class="muted-text">
Sem histórico
</span>
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Último status</th>
<td mat-cell *matCellDef="let equipamento">
<span
class="status-badge"
[ngClass]="classeStatus(equipamento.ultima_os_status)">
{{ labelStatus(equipamento.ultima_os_status) }}
</span>
</td>
</ng-container>
<ng-container matColumnDef="custo">
<th mat-header-cell *matHeaderCellDef>Custo histórico</th>
<td mat-cell *matCellDef="let equipamento">
<div class="metric-cell cost-cell">
<strong>
{{ equipamento.valor_total_historico | currency:'BRL':'symbol':'1.2-2' }}
</strong>
<span>
M.O. {{ equipamento.valor_mao_obra_historico | currency:'BRL':'symbol':'1.0-0' }}
· Peças {{ equipamento.valor_insumos_historico | currency:'BRL':'symbol':'1.0-0' }}
</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="acoes">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let equipamento">
<button
mat-stroked-button
color="primary"
type="button"
(click)="abrirProntuario(equipamento)">
Ver histórico
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr
mat-row
*matRowDef="let equipamento; columns: displayedColumns"
[class.selected-row]="equipamentoSelecionado?.id === equipamento.id"
(click)="selecionarEquipamento(equipamento)"
(dblclick)="abrirProntuario(equipamento)">
</tr>
</table>
</div>
<mat-paginator
*ngIf="!erro && totalRegistros > 0"
[length]="totalRegistros"
[pageIndex]="paginaAtual - 1"
[pageSize]="tamanhoPagina"
[pageSizeOptions]="opcoesTamanhoPagina"
[showFirstLastButtons]="true"
(page)="onPageChange($event)">
</mat-paginator>
</section>
</div>

View File

@ -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;
}
}

View File

@ -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<OrionEquipamentosComponent>;
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();
});
});

View File

@ -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<EquipamentoHistoricoListaModel>([]);
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<string, OpcaoModelo>();
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;
}
}

View File

@ -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' }
];

View File

@ -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<OrionLogsModel[]> {
getAllLogs(meses: number = 3): Promise<OrionLogsModel[]> {
const params = new HttpParams().set('meses', String(meses));
return this.executar(
this.http
.get<OrionApiResponseModel<OrionLogsModel[]>>(
`${this.Rota}/getAllLogs`
`${this.Rota}/getAllLogs`,
{ params }
)
.toPromise()
);
@ -131,7 +142,7 @@ export class OrionService {
): Promise<GeoCoordenadasResponseModel> {
return this.http
.get<GeoCoordenadasResponseModel>(
`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<EquipamentoHistoricoListaResponseModel> {
const params = this.montarParametros(filtros);
return this.executar(
this.http
.get<OrionApiResponseModel<EquipamentoHistoricoListaResponseModel>>(
`${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<EquipamentoProntuarioResponseModel> {
return this.executar(
this.http
.get<OrionApiResponseModel<EquipamentoProntuarioResponseModel>>(
`${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<EquipamentoTimelineResponseModel> {
const params = this.montarParametros(filtros);
return this.executar(
this.http
.get<OrionApiResponseModel<EquipamentoTimelineResponseModel>>(
`${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<TecnicoManutencaoModel[]> {
return this.executar(
this.http
@ -486,4 +734,4 @@ export class OrionService {
.then(response => response as Blob);
}
}
}