From 445b7b58127ff8f0ee6d294b9940dcb75996dbe7 Mon Sep 17 00:00:00 2001 From: Diego Freitas Date: Sun, 12 Jul 2026 15:14:26 -0300 Subject: [PATCH] adicionado criacao de OSs Oriontard --- server/connector.js | 28 + server/controllers/checkoutController.js | 2 +- .../controllers/oriontard.pdf.controller.js | 424 ++++ server/controllers/oriontardproController.js | 2013 ++++++++++++++++- server/package-lock.json | 155 +- server/package.json | 1 + server/server.js | 2 + server/services/orionPdfService.js | 1464 ++++++++++++ src/app/app-routing.module.ts | 24 +- src/app/app.module.ts | 6 +- src/app/chaves/chaves.component.ts | 2 +- src/app/dashboard/dashboard.component.ts | 27 +- src/app/models/orionModel.ts | 565 ++++- .../orion-dashboard.component.html | 0 .../orion-dashboard.component.scss | 0 .../orion-dashboard.component.spec.ts | 0 .../orion-dashboard.component.ts | 4 +- .../orion-ordem-servico-form.component.html | 720 ++++++ .../orion-ordem-servico-form.component.scss | 589 +++++ ...orion-ordem-servico-form.component.spec.ts | 25 + .../orion-ordem-servico-form.component.ts | 911 ++++++++ .../orion-ordens-servico.component.html | 387 ++++ .../orion-ordens-servico.component.scss | 630 ++++++ .../orion-ordens-servico.component.spec.ts | 25 + .../orion-ordens-servico.component.ts | 429 ++++ src/app/services/orionService.ts | 494 +++- 26 files changed, 8805 insertions(+), 122 deletions(-) create mode 100644 server/controllers/oriontard.pdf.controller.js create mode 100644 server/services/orionPdfService.js rename src/app/{ => oriontard}/orion-dashboard/orion-dashboard.component.html (100%) rename src/app/{ => oriontard}/orion-dashboard/orion-dashboard.component.scss (100%) rename src/app/{ => oriontard}/orion-dashboard/orion-dashboard.component.spec.ts (100%) rename src/app/{ => oriontard}/orion-dashboard/orion-dashboard.component.ts (98%) create mode 100644 src/app/oriontard/orion-ordem-servico-form/orion-ordem-servico-form.component.html create mode 100644 src/app/oriontard/orion-ordem-servico-form/orion-ordem-servico-form.component.scss create mode 100644 src/app/oriontard/orion-ordem-servico-form/orion-ordem-servico-form.component.spec.ts create mode 100644 src/app/oriontard/orion-ordem-servico-form/orion-ordem-servico-form.component.ts create mode 100644 src/app/oriontard/orion-ordens-servico/orion-ordens-servico.component.html create mode 100644 src/app/oriontard/orion-ordens-servico/orion-ordens-servico.component.scss create mode 100644 src/app/oriontard/orion-ordens-servico/orion-ordens-servico.component.spec.ts create mode 100644 src/app/oriontard/orion-ordens-servico/orion-ordens-servico.component.ts diff --git a/server/connector.js b/server/connector.js index 91a9fea..1213368 100644 --- a/server/connector.js +++ b/server/connector.js @@ -124,3 +124,31 @@ global.ExecuteQueryAgro = function ExecuteQueryAgro(sqlQuery, req, res, params = //#endregion +//#region ORIONTARD +var ConfigMySQLOriontard = { + connectionLimit : 10, + host : 'zendioninc.com.br', + port : '3306', + user : 'zendion', + password : 'Z210203!', + database : 'oriontard', + debug : false, + multipleStatements : true +}; + +global.AbreConexaoOriontardMySQL = function AbreConexaoOriontardMySQL() { + global.ConexaoMySQL_Oriontard = mysql.createPool(ConfigMySQLOriontard); + console.log("MySQL Conectado! Oriontard"); +} +AbreConexaoOriontardMySQL(); + +global.ExecuteQueryOriontard = function ExecuteQueryOriontard(sqlQuery, req, res, params = [], callback = null) { + ConexaoMySQL_Oriontard.query(sqlQuery, params, function (error, results, fields) { + console.log(`${new Date().toUTCString()} - ${req.originalUrl} ${(error) ? ' 500 error' : ' 200 success'}`); + res.send(JSON.stringify({ "status": ((error) ? 500 : 200), "error": error, "response": results })) + }); +} + + +//#endregion + diff --git a/server/controllers/checkoutController.js b/server/controllers/checkoutController.js index 7cb671f..5fdaee2 100644 --- a/server/controllers/checkoutController.js +++ b/server/controllers/checkoutController.js @@ -37,7 +37,7 @@ var routes = function () { const produto = results[0]; if (produto['idtiposlicenca'] == 6) { const sqlGerarChave = `CALL GerarInserirChave(?, ?, ?, @SerialKey, @idChave);`; - mysqlConnection.query(sqlGerarChave, [produto['cpf'], 30, produto['idsistemas']], function (chaveError, resChave) { + mysqlConnection.query(sqlGerarChave, [produto['cpf'], 90, produto['idsistemas']], function (chaveError, resChave) { if (chaveError) { return res.status(500).send({ error: 'Erro ao gerar chave.' }); } diff --git a/server/controllers/oriontard.pdf.controller.js b/server/controllers/oriontard.pdf.controller.js new file mode 100644 index 0000000..cfc54cb --- /dev/null +++ b/server/controllers/oriontard.pdf.controller.js @@ -0,0 +1,424 @@ +'use strict'; + +require('../connector'); + +const express = require('express'); +const router = express.Router(); + +const { + gerarOrdemServicoPdf, + gerarRelatorioOrdensServicoPdf +} = require('../services/orionPdfService'); + +function getPool() { + if (!global.ConexaoMySQL_Oriontard) { + throw new Error('Pool do banco Oriontard não foi inicializado.'); + } + + return global.ConexaoMySQL_Oriontard; +} + +function dbQuery(sql, params = []) { + return new Promise((resolve, reject) => { + getPool().query(sql, params, (error, results, fields) => { + if (error) { + reject(error); + return; + } + + resolve({ results, fields }); + }); + }); +} + +function positiveInteger(value, fieldName) { + const number = Number(value); + + if (!Number.isInteger(number) || number <= 0) { + const error = new Error(`${fieldName} inválido.`); + error.httpStatus = 400; + throw error; + } + + return number; +} + +function sendError(req, res, error) { + const status = error.httpStatus || 500; + const message = status >= 500 + ? 'Erro interno ao gerar o relatório.' + : error.message; + + console.error( + `${new Date().toUTCString()} - ${req.originalUrl} ${status} error`, + error + ); + + if (res.headersSent) { + res.destroy(error); + return; + } + + res.status(status).json({ + status, + error: { + code: error.code || null, + message + }, + response: null + }); +} + +function dateToSql(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; +} + +function parseDateOrDefault(value, fallback) { + if (!value) { + return fallback; + } + + const date = new Date(`${value}T00:00:00`); + + if (Number.isNaN(date.getTime())) { + const error = new Error('Data de filtro inválida.'); + error.httpStatus = 400; + throw error; + } + + return date; +} + +async function loadFullOrder(id) { + const [ + { results: orders }, + { results: activities }, + { results: technicians }, + { results: materials }, + { results: odometers }, + { results: payments } + ] = await Promise.all([ + 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 os.id = ? + AND os.excluido_em IS NULL + LIMIT 1; + `, + [id] + ), + dbQuery( + ` + SELECT * + FROM manut_ordem_servico_atividades + WHERE ordem_servico_id = ? + ORDER BY data_atividade, ordem_exibicao, id; + `, + [id] + ), + dbQuery( + ` + SELECT + 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 = ? + ORDER BY t.nome; + `, + [id] + ), + dbQuery( + ` + SELECT 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 = ? + ORDER BY mat.id; + `, + [id] + ), + dbQuery( + ` + SELECT * + FROM manut_equipamento_odometro_leituras + WHERE ordem_servico_id = ? + ORDER BY leitura_em, id; + `, + [id] + ), + dbQuery( + ` + SELECT * + FROM manut_ordem_servico_pagamentos + WHERE ordem_servico_id = ? + AND cancelado_em IS NULL + ORDER BY data_pagamento, id; + `, + [id] + ) + ]); + + if (!orders.length) { + const error = new Error('Ordem de serviço não encontrada.'); + error.httpStatus = 404; + throw error; + } + + const techniciansByActivity = new Map(); + technicians.forEach(item => { + if (!techniciansByActivity.has(item.atividade_id)) { + techniciansByActivity.set(item.atividade_id, []); + } + + techniciansByActivity.get(item.atividade_id).push(item); + }); + + const materialsByActivity = new Map(); + materials.forEach(item => { + if (!materialsByActivity.has(item.atividade_id)) { + materialsByActivity.set(item.atividade_id, []); + } + + materialsByActivity.get(item.atividade_id).push(item); + }); + + const nestedActivities = activities.map(activity => ({ + ...activity, + tecnicos: techniciansByActivity.get(activity.id) || [], + materiais: materialsByActivity.get(activity.id) || [] + })); + + return { + ...orders[0], + atividades: nestedActivities, + odometros_da_os: odometers, + pagamentos: payments + }; +} + +function buildReportFilters(query) { + const now = new Date(); + const firstDay = new Date(now.getFullYear(), now.getMonth(), 1); + const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0); + + const dataInicio = parseDateOrDefault(query.data_inicio, firstDay); + const dataFim = parseDateOrDefault(query.data_fim, lastDay); + + if (dataFim < dataInicio) { + const error = new Error('A data final não pode ser anterior à data inicial.'); + error.httpStatus = 400; + throw error; + } + + const criterios = { + data_entrada: { + coluna: 'os.data_entrada', + label: 'data de entrada' + }, + data_finalizacao: { + coluna: 'os.data_finalizacao', + label: 'data de finalização' + }, + data_retirada: { + coluna: 'os.data_retirada', + label: 'data de retirada' + } + }; + + const criterio = criterios[query.criterio] || criterios.data_finalizacao; + + return { + dataInicio, + dataFim, + dataInicioSql: dateToSql(dataInicio), + dataFimSql: dateToSql(dataFim), + criterio, + clienteId: query.cliente_id + ? positiveInteger(query.cliente_id, 'Cliente') + : null, + equipamentoId: query.equipamento_id + ? positiveInteger(query.equipamento_id, 'Equipamento') + : null, + status: query.status || null, + statusPagamento: query.status_pagamento || null, + tipo: query.tipo || null, + busca: query.busca ? String(query.busca).trim() : null, + cobrancaAberta: + query.cobranca_aberta === '1' || + query.cobranca_aberta === 'true' + }; +} + +async function loadReportOrders(query) { + const filtros = buildReportFilters(query); + const where = [ + 'os.excluido_em IS NULL', + "os.status NOT IN ('RASCUNHO', 'CANCELADA')", + `${filtros.criterio.coluna} >= ?`, + `${filtros.criterio.coluna} < DATE_ADD(?, INTERVAL 1 DAY)` + ]; + + const params = [ + filtros.dataInicioSql, + filtros.dataFimSql + ]; + + if (filtros.clienteId) { + where.push('os.cliente_id = ?'); + params.push(filtros.clienteId); + } + + if (filtros.equipamentoId) { + where.push('os.equipamento_id = ?'); + params.push(filtros.equipamentoId); + } + + if (filtros.status) { + where.push('os.status = ?'); + params.push(filtros.status); + } + + if (filtros.statusPagamento) { + where.push('os.status_pagamento = ?'); + params.push(filtros.statusPagamento); + } + + if (filtros.tipo) { + where.push('os.tipo = ?'); + params.push(filtros.tipo); + } + + if (filtros.cobrancaAberta) { + where.push("os.status_pagamento IN ('PENDENTE', 'PARCIAL')"); + } + + if (filtros.busca) { + const search = `%${filtros.busca}%`; + where.push(`( + CAST(os.id AS CHAR) LIKE ? OR + os.equipamento_nome_snapshot LIKE ? OR + os.numero_serie_snapshot LIKE ? OR + os.problema_relatado LIKE ? OR + os.cliente_nome_snapshot LIKE ? + )`); + + params.push(search, search, search, search, search); + } + + const { results } = await dbQuery( + ` + SELECT + os.id, + os.cliente_id, + os.cliente_nome_snapshot, + os.equipamento_nome_snapshot, + os.numero_serie_snapshot, + os.tipo, + os.status, + os.status_pagamento, + os.data_entrada, + os.data_finalizacao, + os.data_retirada, + os.total_horas_expediente, + os.total_horas_extras, + os.valor_mao_obra, + os.valor_insumos, + os.valor_desconto, + os.valor_acrescimo, + os.valor_total, + os.valor_pago, + GREATEST(os.valor_total - os.valor_pago, 0) AS valor_pendente + FROM manut_ordens_servico os + WHERE ${where.join(' AND ')} + ORDER BY + os.cliente_nome_snapshot, + ${filtros.criterio.coluna}, + os.id; + `, + params + ); + + let clienteNome = null; + + if (filtros.clienteId) { + const { results: clientes } = await dbQuery( + ` + SELECT nome_fantasia + FROM manut_clientes + WHERE id = ? + LIMIT 1; + `, + [filtros.clienteId] + ); + + clienteNome = clientes.length + ? clientes[0].nome_fantasia + : null; + } + + return { + ordens: results, + filtros: { + dataInicio: filtros.dataInicio, + dataFim: filtros.dataFim, + criterioLabel: filtros.criterio.label, + clienteNome + } + }; +} + +var routes = function () { + router.get('/downloadOrdemServicoPdf/:id', async function (req, res) { + try { + const id = positiveInteger(req.params.id, 'Ordem de serviço'); + const interno = + req.query.interno === '1' || + req.query.interno === 'true'; + + const ordem = await loadFullOrder(id); + + gerarOrdemServicoPdf(res, ordem, { + interno + }); + } catch (error) { + sendError(req, res, error); + } + }); + + router.get('/downloadRelatorioOrdensServicoPdf', async function (req, res) { + try { + const resultado = await loadReportOrders(req.query || {}); + + gerarRelatorioOrdensServicoPdf( + res, + resultado.ordens, + resultado.filtros + ); + } catch (error) { + sendError(req, res, error); + } + }); + + return router; +}; + +module.exports = routes; diff --git a/server/controllers/oriontardproController.js b/server/controllers/oriontardproController.js index 52cf53e..2260bc1 100644 --- a/server/controllers/oriontardproController.js +++ b/server/controllers/oriontardproController.js @@ -1,24 +1,2007 @@ require('../connector'); + const express = require('express'); const router = express.Router(); -var routes = function () { +const TIPOS_OS = new Set([ + 'CORRETIVA', + 'PREVENTIVA', + 'REFORMA', + 'DESENVOLVIMENTO', + 'FABRICACAO', + 'GARANTIA', + 'CORTESIA', + 'INTERNA', + 'OUTRO' +]); - router.get('/getAllLogs', function (req, res) { - ExecuteQuery(`select * from vw_oriontard_logs where dataLog > DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 3 MONTH);`, req, res); - }); +const STATUS_OS = new Set([ + 'RASCUNHO', + 'ABERTA', + 'EM_DIAGNOSTICO', + 'AGUARDANDO_APROVACAO', + 'AGUARDANDO_PECA', + 'EM_EXECUCAO', + 'EM_TESTES', + 'FINALIZADA', + 'RETIRADA', + 'CANCELADA' +]); - router.post('/insertLog', function (req, res) { - const cpf = req.body.cpf; - const perfil = req.body.perfil; - const latitude = req.body.longitude; - const longitude = req.body.latitude; - const log = req.body.log; - ExecuteQuery(`insert into orionTardProLogs (cpf, perfil, latitude, longitude, log) values (?, ?, ?, ?, ?);`, - req, res, [cpf, perfil, latitude, longitude, log]); - }); +const STATUS_PAGAMENTO = new Set([ + 'NAO_APLICAVEL', + 'PENDENTE', + 'PARCIAL', + 'RECEBIDO', + 'CORTESIA' +]); - return router; +const PRIORIDADES = new Set(['BAIXA', 'NORMAL', 'ALTA', 'URGENTE']); +const PAPEIS_TECNICO = new Set(['RESPONSAVEL', 'EXECUTOR', 'APOIO']); +const FORMAS_PAGAMENTO = new Set([ + 'DINHEIRO', + 'PIX', + 'TRANSFERENCIA', + 'BOLETO', + 'CARTAO', + 'OUTRO' +]); +const TIPOS_ODOMETRO = new Set([ + 'CADASTRO_INICIAL', + 'ENTRADA_MANUTENCAO', + 'SAIDA_MANUTENCAO', + 'ATUALIZACAO_MANUAL', + 'TELEMETRIA' +]); + +function getPool() { + if (!global.ConexaoMySQL_Oriontard) { + throw new Error('Pool do banco Oriontard não foi inicializado.'); + } + + return global.ConexaoMySQL_Oriontard; } -module.exports = routes; \ No newline at end of file +function dbQuery(sql, params = [], connection = null) { + const db = connection || getPool(); + + return new Promise((resolve, reject) => { + db.query(sql, params, (error, results, fields) => { + if (error) { + reject(error); + return; + } + + resolve({ results, fields }); + }); + }); +} + +function getConnection() { + return new Promise((resolve, reject) => { + getPool().getConnection((error, connection) => { + if (error) { + reject(error); + return; + } + + resolve(connection); + }); + }); +} + +function beginTransaction(connection) { + return new Promise((resolve, reject) => { + connection.beginTransaction(error => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); +} + +function commit(connection) { + return new Promise((resolve, reject) => { + connection.commit(error => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); +} + +function rollback(connection) { + return new Promise(resolve => { + connection.rollback(() => resolve()); + }); +} + +async function withTransaction(handler) { + const connection = await getConnection(); + + try { + await beginTransaction(connection); + const result = await handler(connection); + await commit(connection); + return result; + } catch (error) { + await rollback(connection); + throw error; + } finally { + connection.release(); + } +} + +function sendSuccess(res, response, status = 200) { + return res.status(status).json({ + status, + error: null, + response + }); +} + +function sendError(req, res, error, status = 500, message = 'Erro interno do servidor.') { + console.error( + `${new Date().toUTCString()} - ${req.originalUrl} ${status} error`, + error + ); + + return res.status(status).json({ + status, + error: { + code: error && error.code ? error.code : null, + message + }, + response: null + }); +} + +function createHttpError(status, message) { + const error = new Error(message); + error.httpStatus = status; + return error; +} + +function handleRouteError(req, res, error) { + const status = error.httpStatus || 500; + const message = status >= 500 + ? 'Erro interno ao processar a operação.' + : error.message; + + return sendError(req, res, error, status, message); +} + +function positiveInteger(value, fieldName, nullable = false) { + if ((value === null || value === undefined || value === '') && nullable) { + return null; + } + + const number = Number(value); + + if (!Number.isInteger(number) || number <= 0) { + throw createHttpError(400, `${fieldName} inválido.`); + } + + return number; +} + +function nonNegativeNumber(value, fieldName, defaultValue = 0) { + if (value === null || value === undefined || value === '') { + return defaultValue; + } + + const number = Number(value); + + if (!Number.isFinite(number) || number < 0) { + throw createHttpError(400, `${fieldName} deve ser um número maior ou igual a zero.`); + } + + return number; +} + +function nonNegativeInteger(value, fieldName, defaultValue = 0) { + const number = nonNegativeNumber(value, fieldName, defaultValue); + + if (!Number.isInteger(number)) { + throw createHttpError(400, `${fieldName} deve ser informado em segundos inteiros.`); + } + + return number; +} + +function optionalText(value) { + if (value === null || value === undefined) { + return null; + } + + const text = String(value).trim(); + return text || null; +} + +function requiredText(value, fieldName) { + const text = optionalText(value); + + if (!text) { + throw createHttpError(400, `${fieldName} é obrigatório.`); + } + + return text; +} + +function enumValue(value, allowedValues, fieldName, defaultValue = null) { + const finalValue = value || defaultValue; + + if (!allowedValues.has(finalValue)) { + throw createHttpError(400, `${fieldName} inválido.`); + } + + return finalValue; +} + +function booleanToTinyInt(value, defaultValue = true) { + if (value === undefined || value === null) { + return defaultValue ? 1 : 0; + } + + return value === true || value === 1 || value === '1' ? 1 : 0; +} + +function normalizeNullableDate(value) { + return value === null || value === undefined || value === '' ? null : value; +} + +function sanitizePagination(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 + }; +} + +async function getEquipmentSnapshot(connection, equipamentoId, clienteIdInformado = null) { + const { results: equipamentos } = await dbQuery( + ` + SELECT + e.id, + e.cliente_id, + e.nome, + e.numero_serie, + e.versao, + e.odometro_total_segundos, + e.odometro_conectado_segundos, + e.odometro_movimento_segundos, + e.odometro_parcial_segundos, + m.nome AS modelo_nome + FROM manut_equipamentos e + LEFT JOIN manut_equipamento_modelos m ON m.id = e.modelo_id + WHERE e.id = ? + AND e.ativo = 1 + LIMIT 1; + `, + [equipamentoId], + connection + ); + + if (!equipamentos.length) { + throw createHttpError(404, 'Equipamento não encontrado ou inativo.'); + } + + const equipamento = equipamentos[0]; + const clienteId = clienteIdInformado || equipamento.cliente_id || null; + let clienteNome = null; + + if (clienteId) { + const { results: clientes } = await dbQuery( + ` + SELECT id, nome_fantasia + FROM manut_clientes + WHERE id = ? + AND ativo = 1 + LIMIT 1; + `, + [clienteId], + connection + ); + + if (!clientes.length) { + throw createHttpError(404, 'Cliente não encontrado ou inativo.'); + } + + clienteNome = clientes[0].nome_fantasia; + } + + return { + equipamento, + clienteId, + clienteNome + }; +} + +function parseOdometer(payload) { + if (!payload) { + return null; + } + + return { + total: nonNegativeInteger( + payload.odometro_total_segundos ?? payload.total_segundos, + 'Odômetro total' + ), + conectado: nonNegativeInteger( + payload.odometro_conectado_segundos ?? payload.conectado_segundos, + 'Odômetro conectado' + ), + movimento: nonNegativeInteger( + payload.odometro_movimento_segundos ?? payload.movimento_segundos, + 'Odômetro de movimento' + ), + parcial: nonNegativeInteger( + payload.odometro_parcial_segundos ?? payload.parcial_segundos, + 'Odômetro parcial' + ), + leituraEm: normalizeNullableDate(payload.leitura_em), + observacoes: optionalText(payload.observacoes) + }; +} + +function validateCumulativeOdometer(current, incoming) { + const comparisons = [ + ['total', Number(current.odometro_total_segundos || 0), incoming.total], + ['conectado', Number(current.odometro_conectado_segundos || 0), incoming.conectado], + ['movimento', Number(current.odometro_movimento_segundos || 0), incoming.movimento] + ]; + + for (const [name, oldValue, newValue] of comparisons) { + if (newValue < oldValue) { + throw createHttpError( + 409, + `O odômetro ${name} não pode diminuir. Atual: ${oldValue}; informado: ${newValue}.` + ); + } + } +} + +async function insertOdometerReading( + connection, + { + equipamento, + ordemServicoId, + odometro, + tipoLeitura, + usuarioId, + origem = 'SISTEMA' + } +) { + validateCumulativeOdometer(equipamento, odometro); + + const leituraEm = odometro.leituraEm || new Date(); + + await dbQuery( + ` + INSERT INTO manut_equipamento_odometro_leituras ( + equipamento_id, + ordem_servico_id, + tipo_leitura, + origem, + leitura_em, + odometro_total_segundos, + odometro_conectado_segundos, + odometro_movimento_segundos, + odometro_parcial_segundos, + observacoes, + registrado_por_usuario_id + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + `, + [ + equipamento.id, + ordemServicoId, + tipoLeitura, + origem, + leituraEm, + odometro.total, + odometro.conectado, + odometro.movimento, + odometro.parcial, + odometro.observacoes, + usuarioId + ], + connection + ); + + await dbQuery( + ` + UPDATE manut_equipamentos + SET + odometro_total_segundos = ?, + odometro_conectado_segundos = ?, + odometro_movimento_segundos = ?, + odometro_parcial_segundos = ?, + odometro_atualizado_em = ?, + atualizado_por_usuario_id = ? + WHERE id = ?; + `, + [ + odometro.total, + odometro.conectado, + odometro.movimento, + odometro.parcial, + leituraEm, + usuarioId, + equipamento.id + ], + connection + ); +} + +async function recalculateOrderTotals(connection, ordemServicoId) { + const { results: rows } = await dbQuery( + ` + SELECT + os.valor_desconto, + os.valor_acrescimo, + os.status_pagamento, + COALESCE(a.total_horas_expediente, 0) AS total_horas_expediente, + COALESCE(a.total_horas_extras, 0) AS total_horas_extras, + COALESCE(a.valor_mao_obra, 0) AS valor_mao_obra, + COALESCE(m.custo_insumos, 0) AS custo_insumos, + COALESCE(m.valor_insumos, 0) AS valor_insumos, + COALESCE(p.valor_pago, 0) AS valor_pago + FROM manut_ordens_servico os + LEFT JOIN ( + SELECT + ordem_servico_id, + SUM(horas_expediente) AS total_horas_expediente, + SUM(horas_extras) AS total_horas_extras, + SUM(valor_mao_obra) AS valor_mao_obra + FROM manut_ordem_servico_atividades + WHERE ordem_servico_id = ? + GROUP BY ordem_servico_id + ) a ON a.ordem_servico_id = os.id + LEFT JOIN ( + SELECT + atv.ordem_servico_id, + SUM(mat.valor_custo_total) AS custo_insumos, + SUM(mat.valor_cobrado_total) AS valor_insumos + FROM manut_ordem_servico_atividades atv + INNER JOIN manut_ordem_servico_atividade_materiais mat + ON mat.atividade_id = atv.id + WHERE atv.ordem_servico_id = ? + GROUP BY atv.ordem_servico_id + ) m ON m.ordem_servico_id = os.id + LEFT JOIN ( + SELECT + ordem_servico_id, + SUM(valor) AS valor_pago + FROM manut_ordem_servico_pagamentos + WHERE ordem_servico_id = ? + AND cancelado_em IS NULL + GROUP BY ordem_servico_id + ) p ON p.ordem_servico_id = os.id + WHERE os.id = ? + AND os.excluido_em IS NULL + LIMIT 1; + `, + [ordemServicoId, ordemServicoId, ordemServicoId, ordemServicoId], + connection + ); + + if (!rows.length) { + throw createHttpError(404, 'Ordem de serviço não encontrada.'); + } + + const data = rows[0]; + const valorTotal = Math.max( + 0, + Number(data.valor_mao_obra) + + Number(data.valor_insumos) + + Number(data.valor_acrescimo) - + Number(data.valor_desconto) + ); + const valorPago = Number(data.valor_pago); + + let statusPagamento = data.status_pagamento; + + if (!['CORTESIA', 'NAO_APLICAVEL'].includes(statusPagamento)) { + if (valorPago <= 0) { + statusPagamento = 'PENDENTE'; + } else if (valorPago + 0.005 < valorTotal) { + statusPagamento = 'PARCIAL'; + } else { + statusPagamento = 'RECEBIDO'; + } + } + + await dbQuery( + ` + UPDATE manut_ordens_servico + SET + total_horas_expediente = ?, + total_horas_extras = ?, + valor_mao_obra = ?, + custo_insumos = ?, + valor_insumos = ?, + valor_total = ?, + valor_pago = ?, + status_pagamento = ? + WHERE id = ?; + `, + [ + data.total_horas_expediente, + data.total_horas_extras, + data.valor_mao_obra, + data.custo_insumos, + data.valor_insumos, + valorTotal, + valorPago, + statusPagamento, + ordemServicoId + ], + connection + ); + + return { + total_horas_expediente: Number(data.total_horas_expediente), + total_horas_extras: Number(data.total_horas_extras), + valor_mao_obra: Number(data.valor_mao_obra), + custo_insumos: Number(data.custo_insumos), + valor_insumos: Number(data.valor_insumos), + valor_total: valorTotal, + valor_pago: valorPago, + status_pagamento: statusPagamento + }; +} + +async function insertActivityTechnicians(connection, atividadeId, tecnicos) { + if (!Array.isArray(tecnicos) || !tecnicos.length) { + return; + } + + const uniqueTechnicians = new Map(); + + for (const item of tecnicos) { + const tecnicoId = positiveInteger(item.tecnico_id, 'Técnico'); + + uniqueTechnicians.set(tecnicoId, { + tecnicoId, + papel: enumValue(item.papel, PAPEIS_TECNICO, 'Papel do técnico', 'EXECUTOR'), + horasExpediente: item.horas_trabalhadas_expediente === null || item.horas_trabalhadas_expediente === undefined + ? null + : nonNegativeNumber(item.horas_trabalhadas_expediente, 'Horas trabalhadas em expediente'), + horasExtras: item.horas_trabalhadas_extras === null || item.horas_trabalhadas_extras === undefined + ? null + : nonNegativeNumber(item.horas_trabalhadas_extras, 'Horas trabalhadas extras') + }); + } + + for (const item of uniqueTechnicians.values()) { + await dbQuery( + ` + INSERT INTO manut_ordem_servico_atividade_tecnicos ( + atividade_id, + tecnico_id, + papel, + horas_trabalhadas_expediente, + horas_trabalhadas_extras + ) + VALUES (?, ?, ?, ?, ?); + `, + [ + atividadeId, + item.tecnicoId, + item.papel, + item.horasExpediente, + item.horasExtras + ], + connection + ); + } +} + +async function resolveMaterialSnapshot(connection, material) { + const materialId = material.material_id + ? positiveInteger(material.material_id, 'Material') + : null; + + let catalogMaterial = null; + + if (materialId) { + const { results } = await dbQuery( + ` + SELECT id, descricao, unidade, custo_padrao, preco_venda_padrao + FROM manut_materiais + WHERE id = ? + AND ativo = 1 + LIMIT 1; + `, + [materialId], + connection + ); + + if (!results.length) { + throw createHttpError(404, `Material ${materialId} não encontrado ou inativo.`); + } + + catalogMaterial = results[0]; + } + + return { + materialId, + descricao: optionalText(material.descricao_snapshot) || + (catalogMaterial ? catalogMaterial.descricao : null), + unidade: optionalText(material.unidade_snapshot) || + (catalogMaterial ? catalogMaterial.unidade : 'UN'), + quantidade: nonNegativeNumber(material.quantidade, 'Quantidade', 1), + custoUnitario: nonNegativeNumber( + material.valor_custo_unitario, + 'Valor de custo unitário', + catalogMaterial ? Number(catalogMaterial.custo_padrao) : 0 + ), + cobradoUnitario: nonNegativeNumber( + material.valor_cobrado_unitario, + 'Valor cobrado unitário', + catalogMaterial ? Number(catalogMaterial.preco_venda_padrao) : 0 + ), + cobravel: booleanToTinyInt(material.cobravel, true), + observacoes: optionalText(material.observacoes) + }; +} + +async function insertActivityMaterials(connection, atividadeId, materiais) { + if (!Array.isArray(materiais) || !materiais.length) { + return; + } + + for (const material of materiais) { + const item = await resolveMaterialSnapshot(connection, material); + + if (!item.descricao) { + throw createHttpError(400, 'A descrição do material é obrigatória.'); + } + + if (item.quantidade <= 0) { + throw createHttpError(400, 'A quantidade do material deve ser maior que zero.'); + } + + await dbQuery( + ` + INSERT INTO manut_ordem_servico_atividade_materiais ( + atividade_id, + material_id, + descricao_snapshot, + unidade_snapshot, + quantidade, + valor_custo_unitario, + valor_cobrado_unitario, + cobravel, + observacoes + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); + `, + [ + atividadeId, + item.materialId, + item.descricao, + item.unidade, + item.quantidade, + item.custoUnitario, + item.cobradoUnitario, + item.cobravel, + item.observacoes + ], + connection + ); + } +} + +async function getOrderIdByActivity(connection, atividadeId) { + const { results } = await dbQuery( + ` + SELECT ordem_servico_id + FROM manut_ordem_servico_atividades + WHERE id = ? + LIMIT 1; + `, + [atividadeId], + connection + ); + + if (!results.length) { + throw createHttpError(404, 'Atividade não encontrada.'); + } + + return results[0].ordem_servico_id; +} + +var routes = function () { + // ========================================================== + // LOGS EXISTENTES + // ========================================================== + router.get('/getAllLogs', async function (req, res) { + try { + const { results } = await dbQuery(` + SELECT * + FROM vw_oriontard_logs + WHERE dataLog > DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 3 MONTH) + ORDER BY dataLog DESC; + `); + + return sendSuccess(res, results); + } catch (error) { + return handleRouteError(req, res, error); + } + }); + + router.post('/insertLog', async function (req, res) { + try { + const cpf = optionalText(req.body.cpf); + const perfil = optionalText(req.body.perfil); + const log = requiredText(req.body.log, 'Log'); + + // Mantém a compatibilidade histórica com a view atual. + const latitude = req.body.longitude ?? null; + const longitude = req.body.latitude ?? null; + + const { results } = await dbQuery( + ` + INSERT INTO orionTardProLogs ( + cpf, + perfil, + latitude, + longitude, + log + ) + VALUES (?, ?, ?, ?, ?); + `, + [cpf, perfil, latitude, longitude, log] + ); + + return sendSuccess(res, { id: results.insertId }, 201); + } catch (error) { + return handleRouteError(req, res, error); + } + }); + + // ========================================================== + // CONSULTAS AUXILIARES PARA COMBOS DO ANGULAR + // Cadastro continua sendo feito diretamente no banco por agora. + // ========================================================== + router.get('/getClientesManutencao', async function (req, res) { + try { + const { results } = await dbQuery(` + SELECT id, nome_fantasia, razao_social, documento + FROM manut_clientes + WHERE ativo = 1 + ORDER BY nome_fantasia; + `); + + return sendSuccess(res, results); + } catch (error) { + return handleRouteError(req, res, error); + } + }); + + router.get('/getEquipamentosManutencao', async function (req, res) { + try { + 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 + 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.ativo = 1 + ORDER BY e.nome, e.numero_serie; + `); + + return sendSuccess(res, results); + } catch (error) { + return handleRouteError(req, res, error); + } + }); + + router.get('/getTecnicosManutencao', async function (req, res) { + try { + const { results } = await dbQuery(` + SELECT + id, + usuario_sistema_id, + nome, + especialidade, + valor_hora_expediente_padrao, + valor_hora_extra_padrao + FROM manut_tecnicos + WHERE ativo = 1 + ORDER BY nome; + `); + + return sendSuccess(res, results); + } catch (error) { + return handleRouteError(req, res, error); + } + }); + + router.get('/getMateriaisManutencao', async function (req, res) { + try { + const { results } = await dbQuery(` + SELECT + id, + codigo, + descricao, + tipo, + unidade, + custo_padrao, + preco_venda_padrao + FROM manut_materiais + WHERE ativo = 1 + ORDER BY descricao; + `); + + return sendSuccess(res, results); + } catch (error) { + return handleRouteError(req, res, error); + } + }); + + // ========================================================== + // ORDENS DE SERVICO + // ========================================================== + router.get('/getOrdensServico', async function (req, res) { + try { + const { page, pageSize, offset } = sanitizePagination(req.query); + const where = ['os.excluido_em IS NULL']; + const params = []; + + if (req.query.status) { + where.push('os.status = ?'); + params.push(enumValue(req.query.status, STATUS_OS, 'Status')); + } + + if (req.query.status_pagamento) { + where.push('os.status_pagamento = ?'); + params.push(enumValue( + req.query.status_pagamento, + STATUS_PAGAMENTO, + 'Status do pagamento' + )); + } + + if (req.query.tipo) { + where.push('os.tipo = ?'); + params.push(enumValue(req.query.tipo, TIPOS_OS, 'Tipo da OS')); + } + + if (req.query.equipamento_id) { + where.push('os.equipamento_id = ?'); + params.push(positiveInteger(req.query.equipamento_id, 'Equipamento')); + } + + if (req.query.cliente_id) { + where.push('os.cliente_id = ?'); + params.push(positiveInteger(req.query.cliente_id, 'Cliente')); + } + + if (req.query.data_inicio) { + where.push('os.data_entrada >= ?'); + params.push(req.query.data_inicio); + } + + if (req.query.data_fim) { + where.push('os.data_entrada < DATE_ADD(?, INTERVAL 1 DAY)'); + params.push(req.query.data_fim); + } + + if (req.query.busca) { + const search = `%${String(req.query.busca).trim()}%`; + where.push(`( + CAST(os.id AS CHAR) LIKE ? OR + os.equipamento_nome_snapshot LIKE ? OR + os.numero_serie_snapshot LIKE ? OR + os.problema_relatado LIKE ? OR + os.cliente_nome_snapshot LIKE ? + )`); + params.push(search, search, search, search, search); + } + + const whereSql = where.join(' AND '); + + const { results: countRows } = await dbQuery( + ` + SELECT COUNT(*) AS total + FROM manut_ordens_servico os + WHERE ${whereSql}; + `, + params + ); + + const listParams = [...params, pageSize, offset]; + const { results } = await dbQuery( + ` + SELECT + os.id, + os.cliente_id, + os.equipamento_id, + os.tecnico_responsavel_id, + os.cliente_nome_snapshot, + os.equipamento_nome_snapshot, + os.numero_serie_snapshot, + os.modelo_snapshot, + os.versao_snapshot, + os.tipo, + os.status, + os.status_pagamento, + os.prioridade, + os.data_entrada, + os.data_previsao, + os.data_finalizacao, + os.data_retirada, + os.problema_relatado, + os.total_horas_expediente, + os.total_horas_extras, + os.valor_mao_obra, + os.valor_insumos, + os.valor_desconto, + os.valor_acrescimo, + os.valor_total, + os.valor_pago, + GREATEST(os.valor_total - os.valor_pago, 0) AS valor_pendente, + t.nome AS tecnico_responsavel_nome, + os.created_at, + os.updated_at + 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 ?; + `, + 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('/getOrdemServico/:id', async function (req, res) { + try { + const id = positiveInteger(req.params.id, 'Ordem de serviço'); + + const [ + { results: orders }, + { results: activities }, + { results: technicians }, + { results: materials }, + { results: odometers }, + { results: payments }, + { results: statusHistory } + ] = await Promise.all([ + 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 os.id = ? + AND os.excluido_em IS NULL + LIMIT 1; + `, + [id] + ), + dbQuery( + ` + SELECT * + FROM manut_ordem_servico_atividades + WHERE ordem_servico_id = ? + ORDER BY data_atividade, ordem_exibicao, id; + `, + [id] + ), + dbQuery( + ` + SELECT + 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 = ? + ORDER BY t.nome; + `, + [id] + ), + dbQuery( + ` + SELECT 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 = ? + ORDER BY mat.id; + `, + [id] + ), + dbQuery( + ` + SELECT + l.*, + LAG(l.odometro_total_segundos) OVER ( + PARTITION BY l.equipamento_id ORDER BY l.leitura_em, l.id + ) AS odometro_total_anterior, + LAG(l.odometro_conectado_segundos) OVER ( + PARTITION BY l.equipamento_id ORDER BY l.leitura_em, l.id + ) AS odometro_conectado_anterior, + LAG(l.odometro_movimento_segundos) OVER ( + PARTITION BY l.equipamento_id ORDER BY l.leitura_em, l.id + ) AS odometro_movimento_anterior + FROM manut_equipamento_odometro_leituras l + WHERE l.equipamento_id = ( + SELECT equipamento_id + FROM manut_ordens_servico + WHERE id = ? + ) + ORDER BY l.leitura_em, l.id; + `, + [id] + ), + dbQuery( + ` + SELECT * + FROM manut_ordem_servico_pagamentos + WHERE ordem_servico_id = ? + ORDER BY data_pagamento, id; + `, + [id] + ), + dbQuery( + ` + SELECT * + FROM manut_ordem_servico_historico_status + WHERE ordem_servico_id = ? + ORDER BY created_at, id; + `, + [id] + ) + ]); + + if (!orders.length) { + throw createHttpError(404, 'Ordem de serviço não encontrada.'); + } + + const techniciansByActivity = new Map(); + for (const item of technicians) { + if (!techniciansByActivity.has(item.atividade_id)) { + techniciansByActivity.set(item.atividade_id, []); + } + techniciansByActivity.get(item.atividade_id).push(item); + } + + const materialsByActivity = new Map(); + for (const item of materials) { + if (!materialsByActivity.has(item.atividade_id)) { + materialsByActivity.set(item.atividade_id, []); + } + materialsByActivity.get(item.atividade_id).push(item); + } + + const nestedActivities = activities.map(activity => ({ + ...activity, + tecnicos: techniciansByActivity.get(activity.id) || [], + materiais: materialsByActivity.get(activity.id) || [] + })); + + const orderOdometers = odometers.filter(item => Number(item.ordem_servico_id) === id); + const entryOdometer = orderOdometers.find(item => item.tipo_leitura === 'ENTRADA_MANUTENCAO') || null; + + let intervalSincePreviousMaintenance = null; + if (entryOdometer) { + intervalSincePreviousMaintenance = { + total_segundos: entryOdometer.odometro_total_anterior === null + ? null + : Number(entryOdometer.odometro_total_segundos) - Number(entryOdometer.odometro_total_anterior), + conectado_segundos: entryOdometer.odometro_conectado_anterior === null + ? null + : Number(entryOdometer.odometro_conectado_segundos) - Number(entryOdometer.odometro_conectado_anterior), + movimento_segundos: entryOdometer.odometro_movimento_anterior === null + ? null + : Number(entryOdometer.odometro_movimento_segundos) - Number(entryOdometer.odometro_movimento_anterior) + }; + } + + return sendSuccess(res, { + ...orders[0], + atividades: nestedActivities, + odometros_da_os: orderOdometers, + intervalo_desde_leitura_anterior: intervalSincePreviousMaintenance, + pagamentos: payments, + historico_status: statusHistory + }); + } catch (error) { + return handleRouteError(req, res, error); + } + }); + + router.post('/insertOrdemServico', async function (req, res) { + try { + const result = await withTransaction(async connection => { + const body = req.body || {}; + const status = enumValue(body.status, STATUS_OS, 'Status', 'ABERTA'); + const tipo = enumValue(body.tipo, TIPOS_OS, 'Tipo', 'CORRETIVA'); + const prioridade = enumValue(body.prioridade, PRIORIDADES, 'Prioridade', 'NORMAL'); + + let statusPagamentoPadrao = 'PENDENTE'; + + if (tipo === 'CORTESIA') { + statusPagamentoPadrao = 'CORTESIA'; + } else if (tipo === 'GARANTIA') { + statusPagamentoPadrao = 'NAO_APLICAVEL'; + } + + const statusPagamento = enumValue( + body.status_pagamento, + STATUS_PAGAMENTO, + 'Status do pagamento', + statusPagamentoPadrao + ); + + const equipamentoId = body.equipamento_id + ? positiveInteger(body.equipamento_id, 'Equipamento') + : null; + const clienteIdInformado = body.cliente_id + ? positiveInteger(body.cliente_id, 'Cliente') + : null; + const tecnicoResponsavelId = body.tecnico_responsavel_id + ? positiveInteger(body.tecnico_responsavel_id, 'Técnico responsável') + : null; + const usuarioId = body.usuario_id + ? positiveInteger(body.usuario_id, 'Usuário') + : null; + + const problemaRelatado = status === 'RASCUNHO' + ? optionalText(body.problema_relatado) + : requiredText(body.problema_relatado, 'Problema relatado'); + + let snapshot = { + clienteId: clienteIdInformado, + clienteNome: null, + equipamento: null + }; + + if (equipamentoId) { + snapshot = await getEquipmentSnapshot( + connection, + equipamentoId, + clienteIdInformado + ); + } else if (clienteIdInformado) { + const { results: clientes } = await dbQuery( + ` + SELECT id, nome_fantasia + FROM manut_clientes + WHERE id = ? AND ativo = 1 + LIMIT 1; + `, + [clienteIdInformado], + connection + ); + + if (!clientes.length) { + throw createHttpError(404, 'Cliente não encontrado ou inativo.'); + } + + snapshot.clienteNome = clientes[0].nome_fantasia; + } + + const odometroEntrada = parseOdometer(body.odometro_entrada); + + if (equipamentoId && status !== 'RASCUNHO' && !odometroEntrada) { + throw createHttpError( + 400, + 'Informe o odômetro de entrada do equipamento.' + ); + } + + if (!equipamentoId && odometroEntrada) { + throw createHttpError( + 400, + 'Não é possível registrar odômetro sem selecionar um equipamento.' + ); + } + + const { results: insertResult } = await dbQuery( + ` + INSERT INTO manut_ordens_servico ( + cliente_id, + equipamento_id, + tecnico_responsavel_id, + cliente_nome_snapshot, + equipamento_nome_snapshot, + numero_serie_snapshot, + modelo_snapshot, + versao_snapshot, + tipo, + status, + status_pagamento, + prioridade, + data_entrada, + data_previsao, + data_finalizacao, + data_retirada, + problema_relatado, + diagnostico, + solucao_resumo, + responsavel_entrega, + responsavel_retirada, + observacoes_internas, + observacoes_cliente, + garantia_dias, + garantia_ate, + valor_desconto, + valor_acrescimo, + justificativa_ajuste_valor, + origem, + criado_por_usuario_id, + atualizado_por_usuario_id + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'SISTEMA', ?, ?); + `, + [ + snapshot.clienteId, + equipamentoId, + tecnicoResponsavelId, + snapshot.clienteNome, + snapshot.equipamento ? snapshot.equipamento.nome : optionalText(body.equipamento_nome), + snapshot.equipamento ? snapshot.equipamento.numero_serie : optionalText(body.numero_serie), + snapshot.equipamento ? snapshot.equipamento.modelo_nome : optionalText(body.modelo), + snapshot.equipamento ? snapshot.equipamento.versao : optionalText(body.versao), + tipo, + status, + statusPagamento, + prioridade, + normalizeNullableDate(body.data_entrada), + normalizeNullableDate(body.data_previsao), + normalizeNullableDate(body.data_finalizacao), + normalizeNullableDate(body.data_retirada), + problemaRelatado, + optionalText(body.diagnostico), + optionalText(body.solucao_resumo), + optionalText(body.responsavel_entrega), + optionalText(body.responsavel_retirada), + optionalText(body.observacoes_internas), + optionalText(body.observacoes_cliente), + body.garantia_dias === null || body.garantia_dias === undefined + ? null + : nonNegativeInteger(body.garantia_dias, 'Garantia em dias'), + normalizeNullableDate(body.garantia_ate), + nonNegativeNumber(body.valor_desconto, 'Valor de desconto'), + nonNegativeNumber(body.valor_acrescimo, 'Valor de acréscimo'), + optionalText(body.justificativa_ajuste_valor), + usuarioId, + usuarioId + ], + connection + ); + + const ordemServicoId = insertResult.insertId; + + await dbQuery( + ` + INSERT INTO manut_ordem_servico_historico_status ( + ordem_servico_id, + status_anterior, + status_novo, + observacoes, + alterado_por_usuario_id + ) + VALUES (?, NULL, ?, 'Ordem de serviço criada.', ?); + `, + [ordemServicoId, status, usuarioId], + connection + ); + + if (snapshot.equipamento && odometroEntrada) { + await insertOdometerReading(connection, { + equipamento: snapshot.equipamento, + ordemServicoId, + odometro: odometroEntrada, + tipoLeitura: 'ENTRADA_MANUTENCAO', + usuarioId + }); + } + + const totals = await recalculateOrderTotals(connection, ordemServicoId); + + return { + id: ordemServicoId, + ...totals + }; + }); + + return sendSuccess(res, result, 201); + } catch (error) { + return handleRouteError(req, res, error); + } + }); + + router.put('/updateOrdemServico/:id', async function (req, res) { + try { + const id = positiveInteger(req.params.id, 'Ordem de serviço'); + + const result = await withTransaction(async connection => { + const { results: currentRows } = await dbQuery( + ` + SELECT * + FROM manut_ordens_servico + WHERE id = ? + AND excluido_em IS NULL + FOR UPDATE; + `, + [id], + connection + ); + + if (!currentRows.length) { + throw createHttpError(404, 'Ordem de serviço não encontrada.'); + } + + const current = currentRows[0]; + const body = req.body || {}; + + if ( + body.equipamento_id !== undefined && + Number(body.equipamento_id) !== Number(current.equipamento_id) + ) { + throw createHttpError( + 409, + 'O equipamento da OS não pode ser alterado nesta versão. Exclua a OS em rascunho e crie outra.' + ); + } + + const fields = []; + const params = []; + + function setField(column, value) { + fields.push(`${column} = ?`); + params.push(value); + } + + if (body.tecnico_responsavel_id !== undefined) { + setField( + 'tecnico_responsavel_id', + body.tecnico_responsavel_id + ? positiveInteger(body.tecnico_responsavel_id, 'Técnico responsável') + : null + ); + } + + if (body.tipo !== undefined) { + setField('tipo', enumValue(body.tipo, TIPOS_OS, 'Tipo')); + } + + let newStatus = current.status; + if (body.status !== undefined) { + newStatus = enumValue(body.status, STATUS_OS, 'Status'); + setField('status', newStatus); + } + + if (body.status_pagamento !== undefined) { + setField( + 'status_pagamento', + enumValue(body.status_pagamento, STATUS_PAGAMENTO, 'Status do pagamento') + ); + } + + if (body.prioridade !== undefined) { + setField('prioridade', enumValue(body.prioridade, PRIORIDADES, 'Prioridade')); + } + + const plainFields = [ + ['data_entrada', 'data_entrada', normalizeNullableDate], + ['data_previsao', 'data_previsao', normalizeNullableDate], + ['data_finalizacao', 'data_finalizacao', normalizeNullableDate], + ['data_retirada', 'data_retirada', normalizeNullableDate], + ['problema_relatado', 'problema_relatado', optionalText], + ['diagnostico', 'diagnostico', optionalText], + ['solucao_resumo', 'solucao_resumo', optionalText], + ['responsavel_entrega', 'responsavel_entrega', optionalText], + ['responsavel_retirada', 'responsavel_retirada', optionalText], + ['observacoes_internas', 'observacoes_internas', optionalText], + ['observacoes_cliente', 'observacoes_cliente', optionalText], + ['garantia_ate', 'garantia_ate', normalizeNullableDate], + ['justificativa_ajuste_valor', 'justificativa_ajuste_valor', optionalText] + ]; + + for (const [bodyField, column, transformer] of plainFields) { + if (body[bodyField] !== undefined) { + setField(column, transformer(body[bodyField])); + } + } + + if (body.garantia_dias !== undefined) { + setField( + 'garantia_dias', + body.garantia_dias === null + ? null + : nonNegativeInteger(body.garantia_dias, 'Garantia em dias') + ); + } + + if (body.valor_desconto !== undefined) { + setField( + 'valor_desconto', + nonNegativeNumber(body.valor_desconto, 'Valor de desconto') + ); + } + + if (body.valor_acrescimo !== undefined) { + setField( + 'valor_acrescimo', + nonNegativeNumber(body.valor_acrescimo, 'Valor de acréscimo') + ); + } + + const usuarioId = body.usuario_id + ? positiveInteger(body.usuario_id, 'Usuário') + : null; + + if (newStatus !== 'RASCUNHO') { + const finalProblem = body.problema_relatado !== undefined + ? optionalText(body.problema_relatado) + : optionalText(current.problema_relatado); + + if (!finalProblem) { + throw createHttpError(400, 'Problema relatado é obrigatório fora de rascunho.'); + } + } + + if (newStatus === 'FINALIZADA' && !body.data_finalizacao && !current.data_finalizacao) { + fields.push('data_finalizacao = CURRENT_TIMESTAMP'); + } + + if (newStatus === 'RETIRADA' && !body.data_retirada && !current.data_retirada) { + fields.push('data_retirada = CURRENT_TIMESTAMP'); + } + + if (fields.length) { + setField('atualizado_por_usuario_id', usuarioId); + params.push(id); + + await dbQuery( + ` + UPDATE manut_ordens_servico + SET ${fields.join(', ')} + WHERE id = ?; + `, + params, + connection + ); + } + + if (newStatus !== current.status) { + await dbQuery( + ` + INSERT INTO manut_ordem_servico_historico_status ( + ordem_servico_id, + status_anterior, + status_novo, + observacoes, + alterado_por_usuario_id + ) + VALUES (?, ?, ?, ?, ?); + `, + [ + id, + current.status, + newStatus, + optionalText(body.observacao_status), + usuarioId + ], + connection + ); + } + + const totals = await recalculateOrderTotals(connection, id); + return { id, ...totals }; + }); + + return sendSuccess(res, result); + } catch (error) { + return handleRouteError(req, res, error); + } + }); + + router.delete('/deleteOrdemServico/:id', async function (req, res) { + try { + const id = positiveInteger(req.params.id, 'Ordem de serviço'); + const usuarioId = req.body && req.body.usuario_id + ? positiveInteger(req.body.usuario_id, 'Usuário') + : null; + + const { results } = await dbQuery( + ` + UPDATE manut_ordens_servico + SET + excluido_em = CURRENT_TIMESTAMP, + excluido_por_usuario_id = ?, + atualizado_por_usuario_id = ? + WHERE id = ? + AND excluido_em IS NULL; + `, + [usuarioId, usuarioId, id] + ); + + if (!results.affectedRows) { + throw createHttpError(404, 'Ordem de serviço não encontrada.'); + } + + return sendSuccess(res, { id, excluida: true }); + } catch (error) { + return handleRouteError(req, res, error); + } + }); + + // ========================================================== + // ATIVIDADES DA OS + // ========================================================== + router.post('/insertAtividadeOrdemServico/:ordemServicoId', async function (req, res) { + try { + const ordemServicoId = positiveInteger( + req.params.ordemServicoId, + 'Ordem de serviço' + ); + + const result = await withTransaction(async connection => { + const { results: orders } = await dbQuery( + ` + SELECT id + FROM manut_ordens_servico + WHERE id = ? + AND excluido_em IS NULL + LIMIT 1; + `, + [ordemServicoId], + connection + ); + + if (!orders.length) { + throw createHttpError(404, 'Ordem de serviço não encontrada.'); + } + + const body = req.body || {}; + const { results: orderRows } = await dbQuery( + ` + SELECT COALESCE(MAX(ordem_exibicao), 0) + 1 AS proxima_ordem + FROM manut_ordem_servico_atividades + WHERE ordem_servico_id = ?; + `, + [ordemServicoId], + connection + ); + + const usuarioId = body.usuario_id + ? positiveInteger(body.usuario_id, 'Usuário') + : null; + + const { results: insertResult } = await dbQuery( + ` + INSERT INTO manut_ordem_servico_atividades ( + ordem_servico_id, + data_atividade, + descricao, + ordem_exibicao, + horas_expediente, + horas_extras, + valor_hora_expediente, + valor_hora_extra, + cobravel, + observacoes_internas, + observacoes_cliente, + criado_por_usuario_id, + atualizado_por_usuario_id + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + `, + [ + ordemServicoId, + body.data_atividade || new Date(), + requiredText(body.descricao, 'Descrição da atividade'), + body.ordem_exibicao === undefined + ? orderRows[0].proxima_ordem + : nonNegativeInteger(body.ordem_exibicao, 'Ordem de exibição'), + nonNegativeNumber(body.horas_expediente, 'Horas de expediente'), + nonNegativeNumber(body.horas_extras, 'Horas extras'), + nonNegativeNumber(body.valor_hora_expediente, 'Valor da hora de expediente'), + nonNegativeNumber(body.valor_hora_extra, 'Valor da hora extra'), + booleanToTinyInt(body.cobravel, true), + optionalText(body.observacoes_internas), + optionalText(body.observacoes_cliente), + usuarioId, + usuarioId + ], + connection + ); + + const atividadeId = insertResult.insertId; + await insertActivityTechnicians(connection, atividadeId, body.tecnicos); + await insertActivityMaterials(connection, atividadeId, body.materiais); + + const totals = await recalculateOrderTotals(connection, ordemServicoId); + + return { + id: atividadeId, + ordem_servico_id: ordemServicoId, + totais_os: totals + }; + }); + + return sendSuccess(res, result, 201); + } catch (error) { + return handleRouteError(req, res, error); + } + }); + + router.put('/updateAtividadeOrdemServico/:atividadeId', async function (req, res) { + try { + const atividadeId = positiveInteger(req.params.atividadeId, 'Atividade'); + + const result = await withTransaction(async connection => { + const ordemServicoId = await getOrderIdByActivity(connection, atividadeId); + const body = req.body || {}; + const fields = []; + const params = []; + + function setField(column, value) { + fields.push(`${column} = ?`); + params.push(value); + } + + const simpleFields = [ + ['data_atividade', 'data_atividade', value => value], + ['descricao', 'descricao', value => requiredText(value, 'Descrição da atividade')], + ['observacoes_internas', 'observacoes_internas', optionalText], + ['observacoes_cliente', 'observacoes_cliente', optionalText] + ]; + + for (const [bodyField, column, transformer] of simpleFields) { + if (body[bodyField] !== undefined) { + setField(column, transformer(body[bodyField])); + } + } + + const numericFields = [ + ['ordem_exibicao', 'ordem_exibicao', true], + ['horas_expediente', 'horas_expediente', false], + ['horas_extras', 'horas_extras', false], + ['valor_hora_expediente', 'valor_hora_expediente', false], + ['valor_hora_extra', 'valor_hora_extra', false] + ]; + + for (const [bodyField, column, integer] of numericFields) { + if (body[bodyField] !== undefined) { + setField( + column, + integer + ? nonNegativeInteger(body[bodyField], bodyField) + : nonNegativeNumber(body[bodyField], bodyField) + ); + } + } + + if (body.cobravel !== undefined) { + setField('cobravel', booleanToTinyInt(body.cobravel, true)); + } + + if (body.usuario_id !== undefined) { + setField( + 'atualizado_por_usuario_id', + body.usuario_id + ? positiveInteger(body.usuario_id, 'Usuário') + : null + ); + } + + if (fields.length) { + params.push(atividadeId); + await dbQuery( + ` + UPDATE manut_ordem_servico_atividades + SET ${fields.join(', ')} + WHERE id = ?; + `, + params, + connection + ); + } + + if (Array.isArray(body.tecnicos)) { + await dbQuery( + `DELETE FROM manut_ordem_servico_atividade_tecnicos WHERE atividade_id = ?;`, + [atividadeId], + connection + ); + await insertActivityTechnicians(connection, atividadeId, body.tecnicos); + } + + if (Array.isArray(body.materiais)) { + await dbQuery( + `DELETE FROM manut_ordem_servico_atividade_materiais WHERE atividade_id = ?;`, + [atividadeId], + connection + ); + await insertActivityMaterials(connection, atividadeId, body.materiais); + } + + const totals = await recalculateOrderTotals(connection, ordemServicoId); + + return { + id: atividadeId, + ordem_servico_id: ordemServicoId, + totais_os: totals + }; + }); + + return sendSuccess(res, result); + } catch (error) { + return handleRouteError(req, res, error); + } + }); + + router.delete('/deleteAtividadeOrdemServico/:atividadeId', async function (req, res) { + try { + const atividadeId = positiveInteger(req.params.atividadeId, 'Atividade'); + + const result = await withTransaction(async connection => { + const ordemServicoId = await getOrderIdByActivity(connection, atividadeId); + + await dbQuery( + `DELETE FROM manut_ordem_servico_atividades WHERE id = ?;`, + [atividadeId], + connection + ); + + const totals = await recalculateOrderTotals(connection, ordemServicoId); + + return { + id: atividadeId, + excluida: true, + ordem_servico_id: ordemServicoId, + totais_os: totals + }; + }); + + return sendSuccess(res, result); + } catch (error) { + return handleRouteError(req, res, error); + } + }); + + // ========================================================== + // ODOMETROS + // ========================================================== + router.post('/insertOdometroOrdemServico/:ordemServicoId', async function (req, res) { + try { + const ordemServicoId = positiveInteger( + req.params.ordemServicoId, + 'Ordem de serviço' + ); + + const result = await withTransaction(async connection => { + const { results: orders } = await dbQuery( + ` + SELECT equipamento_id + FROM manut_ordens_servico + WHERE id = ? + AND excluido_em IS NULL + LIMIT 1; + `, + [ordemServicoId], + connection + ); + + if (!orders.length) { + throw createHttpError(404, 'Ordem de serviço não encontrada.'); + } + + if (!orders[0].equipamento_id) { + throw createHttpError(409, 'A OS não possui equipamento associado.'); + } + + const { equipamento } = await getEquipmentSnapshot( + connection, + orders[0].equipamento_id + ); + const odometro = parseOdometer(req.body); + + if (!odometro) { + throw createHttpError(400, 'Informe os dados do odômetro.'); + } + + const tipoLeitura = enumValue( + req.body.tipo_leitura, + TIPOS_ODOMETRO, + 'Tipo de leitura', + 'SAIDA_MANUTENCAO' + ); + const usuarioId = req.body.usuario_id + ? positiveInteger(req.body.usuario_id, 'Usuário') + : null; + + await insertOdometerReading(connection, { + equipamento, + ordemServicoId, + odometro, + tipoLeitura, + usuarioId + }); + + return { + ordem_servico_id: ordemServicoId, + equipamento_id: equipamento.id, + tipo_leitura: tipoLeitura + }; + }); + + return sendSuccess(res, result, 201); + } catch (error) { + return handleRouteError(req, res, error); + } + }); + + // ========================================================== + // PAGAMENTOS + // ========================================================== + router.post('/insertPagamentoOrdemServico/:ordemServicoId', async function (req, res) { + try { + const ordemServicoId = positiveInteger( + req.params.ordemServicoId, + 'Ordem de serviço' + ); + + const result = await withTransaction(async connection => { + const valor = nonNegativeNumber(req.body.valor, 'Valor do pagamento'); + if (valor <= 0) { + throw createHttpError(400, 'O valor do pagamento deve ser maior que zero.'); + } + + const formaPagamento = enumValue( + req.body.forma_pagamento, + FORMAS_PAGAMENTO, + 'Forma de pagamento', + 'PIX' + ); + const usuarioId = req.body.usuario_id + ? positiveInteger(req.body.usuario_id, 'Usuário') + : null; + + const { results: insertResult } = await dbQuery( + ` + INSERT INTO manut_ordem_servico_pagamentos ( + ordem_servico_id, + data_pagamento, + valor, + forma_pagamento, + referencia, + observacoes, + registrado_por_usuario_id + ) + VALUES (?, COALESCE(?, CURRENT_TIMESTAMP), ?, ?, ?, ?, ?); + `, + [ + ordemServicoId, + normalizeNullableDate(req.body.data_pagamento), + valor, + formaPagamento, + optionalText(req.body.referencia), + optionalText(req.body.observacoes), + usuarioId + ], + connection + ); + + const totals = await recalculateOrderTotals(connection, ordemServicoId); + + return { + id: insertResult.insertId, + ordem_servico_id: ordemServicoId, + totais_os: totals + }; + }); + + return sendSuccess(res, result, 201); + } catch (error) { + return handleRouteError(req, res, error); + } + }); + + router.delete('/cancelPagamentoOrdemServico/:pagamentoId', async function (req, res) { + try { + const pagamentoId = positiveInteger(req.params.pagamentoId, 'Pagamento'); + + const result = await withTransaction(async connection => { + const { results: paymentRows } = await dbQuery( + ` + SELECT ordem_servico_id + FROM manut_ordem_servico_pagamentos + WHERE id = ? + AND cancelado_em IS NULL + LIMIT 1; + `, + [pagamentoId], + connection + ); + + if (!paymentRows.length) { + throw createHttpError(404, 'Pagamento não encontrado ou já cancelado.'); + } + + const ordemServicoId = paymentRows[0].ordem_servico_id; + const usuarioId = req.body && req.body.usuario_id + ? positiveInteger(req.body.usuario_id, 'Usuário') + : null; + + await dbQuery( + ` + UPDATE manut_ordem_servico_pagamentos + SET + cancelado_em = CURRENT_TIMESTAMP, + cancelado_por_usuario_id = ? + WHERE id = ?; + `, + [usuarioId, pagamentoId], + connection + ); + + const totals = await recalculateOrderTotals(connection, ordemServicoId); + + return { + id: pagamentoId, + cancelado: true, + ordem_servico_id: ordemServicoId, + totais_os: totals + }; + }); + + return sendSuccess(res, result); + } catch (error) { + return handleRouteError(req, res, error); + } + }); + + return router; +}; + +module.exports = routes; diff --git a/server/package-lock.json b/server/package-lock.json index 76c45ac..75351aa 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -203,6 +203,16 @@ "yargs": "^16.2.0" } }, + "@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==" + }, + "@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==" + }, "@panva/asn1.js": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz", @@ -298,6 +308,21 @@ "@sendgrid/helpers": "^8.0.0" } }, + "@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "requires": { + "tslib": "^2.8.0" + }, + "dependencies": { + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + } + }, "@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -510,8 +535,7 @@ "base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "optional": true + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, "bignumber.js": { "version": "4.0.4", @@ -569,6 +593,29 @@ "fill-range": "^7.1.1" } }, + "brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "requires": { + "base64-js": "^1.1.2" + } + }, + "browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "requires": { + "pako": "~1.0.5" + }, + "dependencies": { + "pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + } + } + }, "buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -605,6 +652,11 @@ "wrap-ansi": "^7.0.0" } }, + "clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==" + }, "color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -738,6 +790,11 @@ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" }, + "dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==" + }, "dicer": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.3.1.tgz", @@ -997,8 +1054,7 @@ "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "optional": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "fast-text-encoding": { "version": "1.0.3", @@ -1113,6 +1169,22 @@ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==" }, + "fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "requires": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -1571,6 +1643,11 @@ "@panva/asn1.js": "^1.0.0" } }, + "js-md5": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==" + }, "json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -1671,6 +1748,22 @@ "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" }, + "linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "requires": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + }, + "dependencies": { + "base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==" + } + } + }, "lodash": { "version": "4.17.10", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz", @@ -1976,6 +2069,11 @@ "yocto-queue": "^0.1.0" } }, + "pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==" + }, "parseurl": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.2.tgz", @@ -1996,11 +2094,32 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" }, + "pdfkit": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz", + "integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==", + "requires": { + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", + "fontkit": "^2.0.4", + "js-md5": "^0.8.3", + "linebreak": "^1.1.0", + "png-js": "^1.1.0" + } + }, "picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==" }, + "png-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "requires": { + "browserify-zlib": "^0.2.0" + } + }, "process-nextick-args": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", @@ -2147,6 +2266,11 @@ "path-parse": "^1.0.6" } }, + "restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==" + }, "retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -2354,6 +2478,11 @@ "uuid": "^8.0.0" } }, + "tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==" + }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2400,6 +2529,24 @@ "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==" }, + "unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "requires": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "requires": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, "unique-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", diff --git a/server/package.json b/server/package.json index a8efd91..c30bbd4 100644 --- a/server/package.json +++ b/server/package.json @@ -24,6 +24,7 @@ "mysql": "^2.15.0", "nodemailer": "^6.10.1", "nodemon": "^3.1.14", + "pdfkit": "^0.19.1", "shelljs": "^0.8.4", "unzipper": "^0.12.3" } diff --git a/server/server.js b/server/server.js index 0bacfa2..d14bf82 100644 --- a/server/server.js +++ b/server/server.js @@ -29,6 +29,8 @@ app.use('/email', require('./controllers/emailController').router); app.use('/checkout', require('./controllers/checkoutController')()); app.use('/otp', require('./controllers/oriontardproController')()); +app.use('/otp', require('./controllers/oriontard.pdf.controller')()); + // Allteeth app.use('/api_5/sincronia', require('./controllers/allteeth/sincroniaController')()); diff --git a/server/services/orionPdfService.js b/server/services/orionPdfService.js new file mode 100644 index 0000000..9538218 --- /dev/null +++ b/server/services/orionPdfService.js @@ -0,0 +1,1464 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const PDFDocument = require('pdfkit'); + +const CORES = { + azul: '#175CD3', + azulEscuro: '#1849A9', + azulClaro: '#EFF8FF', + texto: '#344054', + textoForte: '#101828', + textoSuave: '#667085', + borda: '#D0D5DD', + bordaClara: '#EAECF0', + fundo: '#F8FAFC', + sucesso: '#027A48', + sucessoFundo: '#ECFDF3', + alerta: '#B54708', + alertaFundo: '#FFFAEB', + erro: '#B42318', + erroFundo: '#FEF3F2', + branco: '#FFFFFF' +}; + +const MARGEM = 42; +const TOPO_CONTEUDO = 102; +const RODAPE_Y = 806; +const LARGURA_A4 = 595.28; +const ALTURA_A4 = 841.89; +const LARGURA_CONTEUDO = LARGURA_A4 - (MARGEM * 2); + +const STATUS_OS_LABEL = { + RASCUNHO: 'Rascunho', + ABERTA: 'Aberta', + EM_DIAGNOSTICO: 'Em diagnóstico', + AGUARDANDO_APROVACAO: 'Aguardando aprovação', + AGUARDANDO_PECA: 'Aguardando peça', + EM_EXECUCAO: 'Em execução', + EM_TESTES: 'Em testes', + FINALIZADA: 'Finalizada', + AGUARDANDO_RETIRADA: 'Aguardando retirada', + RETIRADA: 'Retirada', + CANCELADA: 'Cancelada' +}; + +const STATUS_PAGAMENTO_LABEL = { + NAO_APLICAVEL: 'Não aplicável', + PENDENTE: 'Pendente', + PARCIAL: 'Parcial', + RECEBIDO: 'Recebido', + CORTESIA: 'Cortesia' +}; + +const TIPO_OS_LABEL = { + CORRETIVA: 'Corretiva', + PREVENTIVA: 'Preventiva', + REFORMA: 'Reforma', + DESENVOLVIMENTO: 'Desenvolvimento', + FABRICACAO: 'Fabricação', + GARANTIA: 'Garantia', + CORTESIA: 'Cortesia', + INTERNA: 'Interna', + OUTRO: 'Outro' +}; + +const TIPO_ODOMETRO_LABEL = { + CADASTRO_INICIAL: 'Cadastro inicial', + ENTRADA_MANUTENCAO: 'Entrada na manutenção', + SAIDA_MANUTENCAO: 'Saída da manutenção', + ATUALIZACAO_MANUAL: 'Atualização manual', + TELEMETRIA: 'Telemetria' +}; + +function empresaConfig() { + return { + nome: process.env.ORION_PDF_EMPRESA_NOME || 'Zendion INC', + documento: process.env.ORION_PDF_EMPRESA_DOCUMENTO || '', + endereco: process.env.ORION_PDF_EMPRESA_ENDERECO || 'Rua Custódio Pereira, 690, Leme, SP', + contato: process.env.ORION_PDF_EMPRESA_CONTATO || '+55 (19) 99871-7560', + site: process.env.ORION_PDF_EMPRESA_SITE || 'https://zendioninc.com.br', + logo: process.env.ORION_PDF_LOGO || '' + }; +} + +function texto(valor, fallback = '-') { + if (valor === null || valor === undefined || valor === '') { + return fallback; + } + + return String(valor); +} + +function numero(valor) { + const convertido = Number(valor); + return Number.isFinite(convertido) ? convertido : 0; +} + +function moeda(valor) { + return numero(valor).toLocaleString('pt-BR', { + style: 'currency', + currency: 'BRL' + }); +} + +function decimal(valor, casas = 2) { + return numero(valor).toLocaleString('pt-BR', { + minimumFractionDigits: casas, + maximumFractionDigits: casas + }); +} + +function dataBR(valor) { + if (!valor) { + return '-'; + } + + const data = valor instanceof Date ? valor : new Date(valor); + + if (Number.isNaN(data.getTime())) { + return texto(valor); + } + + return data.toLocaleDateString('pt-BR'); +} + +function dataHoraBR(valor) { + if (!valor) { + return '-'; + } + + const data = valor instanceof Date ? valor : new Date(valor); + + if (Number.isNaN(data.getTime())) { + return texto(valor); + } + + return data.toLocaleString('pt-BR', { + dateStyle: 'short', + timeStyle: 'short' + }); +} + +function segundosParaTempo(valor) { + let total = Math.max(0, Math.floor(numero(valor))); + const horas = Math.floor(total / 3600); + total %= 3600; + const minutos = Math.floor(total / 60); + const segundos = total % 60; + + return [ + String(horas).padStart(2, '0'), + String(minutos).padStart(2, '0'), + String(segundos).padStart(2, '0') + ].join(':'); +} + +function nomeArquivoSeguro(valor) { + return texto(valor, 'documento') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-zA-Z0-9._-]+/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, ''); +} + +function statusCor(status) { + if (status === 'RECEBIDO' || status === 'RETIRADA' || status === 'FINALIZADA') { + return { + fundo: CORES.sucessoFundo, + texto: CORES.sucesso + }; + } + + if ( + status === 'PENDENTE' || + status === 'CANCELADA' + ) { + return { + fundo: CORES.erroFundo, + texto: CORES.erro + }; + } + + if ( + status === 'PARCIAL' || + status === 'AGUARDANDO_PECA' || + status === 'AGUARDANDO_APROVACAO' || + status === 'AGUARDANDO_RETIRADA' + ) { + return { + fundo: CORES.alertaFundo, + texto: CORES.alerta + }; + } + + return { + fundo: CORES.azulClaro, + texto: CORES.azulEscuro + }; +} + +function configurarRespostaPdf(res, nomeArquivo) { + res.status(200); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${nomeArquivoSeguro(nomeArquivo)}"` + ); + res.setHeader('Cache-Control', 'no-store'); +} + +function criarDocumento(res, nomeArquivo, titulo, subtitulo) { + configurarRespostaPdf(res, nomeArquivo); + + const doc = new PDFDocument({ + size: 'A4', + margins: { + top: TOPO_CONTEUDO, + right: MARGEM, + bottom: 58, + left: MARGEM + }, + bufferPages: true, + info: { + Title: titulo, + Subject: subtitulo, + Author: empresaConfig().nome, + Creator: 'Oriontard' + } + }); + + doc.pipe(res); + + const contexto = { + titulo, + subtitulo, + empresa: empresaConfig() + }; + + doc.on('pageAdded', () => { + desenharCabecalho(doc, contexto); + }); + + desenharCabecalho(doc, contexto); + + return doc; +} + +function desenharCabecalho(doc, contexto) { + const empresa = contexto.empresa; + const y = 28; + + doc.save(); + + if (empresa.logo && fs.existsSync(empresa.logo)) { + try { + doc.image(empresa.logo, MARGEM, y, { + fit: [52, 40], + align: 'left', + valign: 'center' + }); + } catch (error) { + desenharMarcaPadrao(doc, y); + } + } else { + desenharMarcaPadrao(doc, y); + } + + doc + .font('Helvetica-Bold') + .fontSize(14) + .fillColor(CORES.textoForte) + .text(empresa.nome, MARGEM + 64, y + 1, { + width: 220 + }); + + const linhaEmpresa = [ + empresa.documento, + empresa.contato, + empresa.site + ].filter(Boolean).join(' · '); + + if (linhaEmpresa) { + doc + .font('Helvetica') + .fontSize(7.5) + .fillColor(CORES.textoSuave) + .text(linhaEmpresa, MARGEM + 64, y + 20, { + width: 250 + }); + } + + doc + .font('Helvetica-Bold') + .fontSize(11) + .fillColor(CORES.azulEscuro) + .text(contexto.titulo, 320, y + 1, { + width: LARGURA_A4 - MARGEM - 320, + align: 'right' + }); + + doc + .font('Helvetica') + .fontSize(7.5) + .fillColor(CORES.textoSuave) + .text(contexto.subtitulo, 310, y + 20, { + width: LARGURA_A4 - MARGEM - 310, + align: 'right' + }); + + doc + .moveTo(MARGEM, 78) + .lineTo(LARGURA_A4 - MARGEM, 78) + .lineWidth(1) + .strokeColor(CORES.bordaClara) + .stroke(); + + doc.restore(); + doc.x = MARGEM; + doc.y = TOPO_CONTEUDO; +} + +function desenharMarcaPadrao(doc, y) { + doc + .roundedRect(MARGEM, y, 48, 40, 8) + .fill(CORES.azulEscuro); + + doc + .font('Helvetica-Bold') + .fontSize(19) + .fillColor(CORES.branco) + .text('Z', MARGEM, y + 9, { + width: 48, + align: 'center' + }); +} + +function garantirEspaco(doc, alturaNecessaria) { + if (doc.y + alturaNecessaria > RODAPE_Y - 18) { + doc.addPage(); + } +} + +function tituloSecao(doc, titulo, subtitulo = '') { + garantirEspaco(doc, subtitulo ? 50 : 34); + + doc + .font('Helvetica-Bold') + .fontSize(11) + .fillColor(CORES.textoForte) + .text(titulo, MARGEM, doc.y, { + width: LARGURA_CONTEUDO + }); + + doc.y += 4; + + doc + .moveTo(MARGEM, doc.y) + .lineTo(MARGEM + LARGURA_CONTEUDO, doc.y) + .lineWidth(1.4) + .strokeColor(CORES.azul) + .stroke(); + + doc.y += 8; + + if (subtitulo) { + doc + .font('Helvetica') + .fontSize(8) + .fillColor(CORES.textoSuave) + .text(subtitulo, { + width: LARGURA_CONTEUDO + }); + + doc.y += 7; + } +} + +function badge(doc, x, y, label, status, largura = 112) { + const cor = statusCor(status); + const cursorX = doc.x; + const cursorY = doc.y; + + doc + .roundedRect(x, y, largura, 22, 11) + .fill(cor.fundo); + + doc + .font('Helvetica-Bold') + .fontSize(7.5) + .fillColor(cor.texto) + .text(label, x + 8, y + 7, { + width: largura - 16, + height: 10, + align: 'center', + ellipsis: true + }); + + // Desenhos absolutos não podem alterar o fluxo vertical do documento. + doc.x = cursorX; + doc.y = cursorY; +} + +function cardInfo(doc, x, y, largura, rotulo, valor, opcoes = {}) { + const altura = opcoes.altura || 48; + const cursorX = doc.x; + const cursorY = doc.y; + + doc + .roundedRect(x, y, largura, altura, 7) + .lineWidth(0.8) + .fillAndStroke(CORES.fundo, CORES.bordaClara); + + doc + .font('Helvetica-Bold') + .fontSize(6.5) + .fillColor(CORES.textoSuave) + .text(rotulo.toUpperCase(), x + 10, y + 9, { + width: largura - 20, + height: 9, + ellipsis: true + }); + + doc + .font(opcoes.negrito === false ? 'Helvetica' : 'Helvetica-Bold') + .fontSize(opcoes.fonte || 9.2) + .fillColor(CORES.textoForte) + .text(texto(valor), x + 10, y + 23, { + width: largura - 20, + height: altura - 28, + ellipsis: true + }); + + // doc.text() move doc.y mesmo quando usamos coordenadas absolutas. + // Restaurar o cursor mantém todos os cards da mesma linha alinhados. + doc.x = cursorX; + doc.y = cursorY; +} + +function blocoTexto(doc, rotulo, conteudo) { + const valor = texto(conteudo, ''); + if (!valor) { + return; + } + + const alturaTexto = doc + .font('Helvetica') + .fontSize(8.5) + .heightOfString(valor, { + width: LARGURA_CONTEUDO - 22, + lineGap: 2 + }); + + const altura = Math.max(52, alturaTexto + 31); + garantirEspaco(doc, altura + 8); + + const y = doc.y; + + doc + .roundedRect(MARGEM, y, LARGURA_CONTEUDO, altura, 7) + .lineWidth(0.8) + .fillAndStroke(CORES.fundo, CORES.bordaClara); + + doc + .font('Helvetica-Bold') + .fontSize(6.5) + .fillColor(CORES.textoSuave) + .text(rotulo.toUpperCase(), MARGEM + 11, y + 9, { + width: LARGURA_CONTEUDO - 22 + }); + + doc + .font('Helvetica') + .fontSize(8.5) + .fillColor(CORES.texto) + .text(valor, MARGEM + 11, y + 23, { + width: LARGURA_CONTEUDO - 22, + lineGap: 2 + }); + + doc.y = y + altura + 8; +} + +function nomesTecnicos(atividade) { + const nomes = (atividade.tecnicos || []) + .map(item => item.tecnico_nome) + .filter(Boolean); + + return nomes.length ? nomes.join(', ') : '-'; +} + +function desenharMateriaisAtividade(doc, materiais, interno) { + if (!materiais || !materiais.length) { + return; + } + + garantirEspaco(doc, 46); + + const x = MARGEM + 12; + const largura = LARGURA_CONTEUDO - 24; + const colunas = interno + ? [230, 48, 55, 78, 78] + : [270, 55, 70, 90]; + + const cabecalhos = interno + ? ['Material / insumo', 'Qtd.', 'Un.', 'Custo', 'Cobrado'] + : ['Material / insumo', 'Qtd.', 'Un.', 'Valor']; + + function cabecalhoTabela() { + garantirEspaco(doc, 30); + + const y = doc.y; + doc + .rect(x, y, largura, 22) + .fill(CORES.azulClaro); + + let cx = x; + + cabecalhos.forEach((cabecalho, indice) => { + doc + .font('Helvetica-Bold') + .fontSize(6.5) + .fillColor(CORES.azulEscuro) + .text(cabecalho, cx + 5, y + 7, { + width: colunas[indice] - 10, + align: indice === 0 ? 'left' : 'right' + }); + + cx += colunas[indice]; + }); + + doc.y = y + 22; + } + + cabecalhoTabela(); + + materiais.forEach((material, indice) => { + garantirEspaco(doc, 28); + + if (doc.y < TOPO_CONTEUDO + 5) { + cabecalhoTabela(); + } + + const y = doc.y; + const alturaDescricao = doc + .font('Helvetica') + .fontSize(7) + .heightOfString(texto(material.descricao_snapshot), { + width: colunas[0] - 10 + }); + + const altura = Math.max(24, alturaDescricao + 12); + + if (doc.y + altura > RODAPE_Y - 18) { + doc.addPage(); + cabecalhoTabela(); + } + + if (indice % 2 === 1) { + doc + .rect(x, doc.y, largura, altura) + .fill(CORES.fundo); + } + + let cx = x; + const valores = interno + ? [ + texto(material.descricao_snapshot), + decimal(material.quantidade, 3), + texto(material.unidade_snapshot), + moeda(material.valor_custo_total), + material.cobravel ? moeda(material.valor_cobrado_total) : 'Não cobrado' + ] + : [ + texto(material.descricao_snapshot), + decimal(material.quantidade, 3), + texto(material.unidade_snapshot), + material.cobravel ? moeda(material.valor_cobrado_total) : 'Não cobrado' + ]; + + valores.forEach((valor, coluna) => { + doc + .font(coluna === 0 ? 'Helvetica' : 'Helvetica') + .fontSize(7) + .fillColor(CORES.texto) + .text(valor, cx + 5, doc.y + 6, { + width: colunas[coluna] - 10, + height: altura - 8, + align: coluna === 0 ? 'left' : 'right', + ellipsis: true + }); + + cx += colunas[coluna]; + }); + + doc + .moveTo(x, doc.y + altura) + .lineTo(x + largura, doc.y + altura) + .lineWidth(0.5) + .strokeColor(CORES.bordaClara) + .stroke(); + + doc.y += altura; + }); + + doc.y += 6; +} + +function desenharAtividade(doc, atividade, indice, interno) { + garantirEspaco(doc, 108); + + const yInicial = doc.y; + + doc + .roundedRect(MARGEM, yInicial, LARGURA_CONTEUDO, 30, 7) + .fill(CORES.azulClaro); + + doc + .font('Helvetica-Bold') + .fontSize(8.5) + .fillColor(CORES.azulEscuro) + .text( + `${String(indice + 1).padStart(2, '0')} · ${dataBR(atividade.data_atividade)}`, + MARGEM + 11, + yInicial + 10, + { width: 135 } + ); + + const horasTotais = + numero(atividade.horas_expediente) + + numero(atividade.horas_extras); + + doc + .font('Helvetica') + .fontSize(7.2) + .fillColor(CORES.textoSuave) + .text( + `Técnicos: ${nomesTecnicos(atividade)}`, + MARGEM + 145, + yInicial + 10, + { width: 235, ellipsis: true } + ); + + doc + .font('Helvetica-Bold') + .fontSize(7.2) + .fillColor(CORES.azulEscuro) + .text( + `${decimal(horasTotais)} h`, + MARGEM + 395, + yInicial + 10, + { width: 62, align: 'right' } + ); + + doc + .font('Helvetica-Bold') + .fontSize(7.2) + .fillColor(CORES.azulEscuro) + .text( + atividade.cobravel ? moeda(atividade.valor_mao_obra) : 'Não cobrada', + MARGEM + 462, + yInicial + 10, + { width: 38, align: 'right' } + ); + + doc.y = yInicial + 38; + + const descricao = texto(atividade.descricao, ''); + doc + .font('Helvetica') + .fontSize(8.5) + .fillColor(CORES.texto) + .text(descricao, MARGEM + 8, doc.y, { + width: LARGURA_CONTEUDO - 16, + lineGap: 2 + }); + + doc.y += 7; + + const observacao = interno + ? atividade.observacoes_internas + : atividade.observacoes_cliente; + + if (observacao) { + doc + .font('Helvetica-Oblique') + .fontSize(7.5) + .fillColor(CORES.textoSuave) + .text(`Observação: ${observacao}`, MARGEM + 8, doc.y, { + width: LARGURA_CONTEUDO - 16 + }); + + doc.y += 7; + } + + desenharMateriaisAtividade(doc, atividade.materiais || [], interno); + + doc + .moveTo(MARGEM, doc.y) + .lineTo(MARGEM + LARGURA_CONTEUDO, doc.y) + .lineWidth(0.7) + .strokeColor(CORES.bordaClara) + .stroke(); + + doc.y += 12; +} + +function desenharResumoFinanceiro(doc, ordem, interno) { + // Mantém o título e os primeiros cards juntos na mesma página. + garantirEspaco(doc, interno ? 178 : 154); + + tituloSecao( + doc, + interno ? 'Resumo financeiro e custos' : 'Resumo financeiro', + interno + ? 'Valores cobrados do cliente e custos internos registrados.' + : 'Valores consolidados desta ordem de serviço.' + ); + + const y = doc.y; + const largura = interno ? 120 : 150; + const gap = 10; + const itens = interno + ? [ + ['Mão de obra', moeda(ordem.valor_mao_obra)], + ['Insumos cobrados', moeda(ordem.valor_insumos)], + ['Custo de insumos', moeda(ordem.custo_insumos)], + ['Desconto', moeda(ordem.valor_desconto)] + ] + : [ + ['Mão de obra', moeda(ordem.valor_mao_obra)], + ['Materiais e insumos', moeda(ordem.valor_insumos)], + ['Desconto', moeda(ordem.valor_desconto)] + ]; + + itens.forEach((item, indice) => { + cardInfo( + doc, + MARGEM + ((largura + gap) * indice), + y, + largura, + item[0], + item[1], + { altura: 48, fonte: 10 } + ); + }); + + doc.y = y + 59; + + const totalLargura = 160; + cardInfo( + doc, + MARGEM, + doc.y, + totalLargura, + 'Valor total', + moeda(ordem.valor_total), + { altura: 51, fonte: 12 } + ); + + cardInfo( + doc, + MARGEM + totalLargura + gap, + doc.y, + totalLargura, + 'Valor pago', + moeda(ordem.valor_pago), + { altura: 51, fonte: 12 } + ); + + cardInfo( + doc, + MARGEM + ((totalLargura + gap) * 2), + doc.y, + totalLargura, + 'Valor pendente', + moeda(ordem.valor_pendente), + { altura: 51, fonte: 12 } + ); + + doc.y += 62; + + const labelPagamento = + STATUS_PAGAMENTO_LABEL[ordem.status_pagamento] || + texto(ordem.status_pagamento); + + badge( + doc, + MARGEM, + doc.y, + `Pagamento: ${labelPagamento}`, + ordem.status_pagamento, + 155 + ); + + if (ordem.tipo === 'GARANTIA') { + badge(doc, MARGEM + 165, doc.y, 'Serviço em garantia', 'PARCIAL', 135); + } else if (ordem.tipo === 'CORTESIA') { + badge(doc, MARGEM + 165, doc.y, 'Serviço em cortesia', 'PARCIAL', 135); + } + + doc.y += 34; +} + +function desenharOdometros(doc, odometros) { + if (!odometros || !odometros.length) { + return; + } + + // Evita deixar o título da seção sozinho no fim da página. + garantirEspaco(doc, 118); + + tituloSecao( + doc, + 'Leituras de odômetro', + 'Leituras vinculadas à entrada, aos testes ou à saída da manutenção.' + ); + + odometros.forEach(leitura => { + garantirEspaco(doc, 76); + + const y = doc.y; + + doc + .font('Helvetica-Bold') + .fontSize(8) + .fillColor(CORES.textoForte) + .text( + TIPO_ODOMETRO_LABEL[leitura.tipo_leitura] || + texto(leitura.tipo_leitura), + MARGEM, + y, + { width: 230 } + ); + + doc + .font('Helvetica') + .fontSize(7) + .fillColor(CORES.textoSuave) + .text(dataHoraBR(leitura.leitura_em), MARGEM + 250, y, { + width: 260, + align: 'right' + }); + + const valores = [ + ['Total', segundosParaTempo(leitura.odometro_total_segundos)], + ['Conectado', segundosParaTempo(leitura.odometro_conectado_segundos)], + ['Movimento', segundosParaTempo(leitura.odometro_movimento_segundos)], + ['Parcial', segundosParaTempo(leitura.odometro_parcial_segundos)] + ]; + + valores.forEach((item, indice) => { + cardInfo( + doc, + MARGEM + (indice * 126), + y + 18, + 116, + item[0], + item[1], + { altura: 44, fonte: 9 } + ); + }); + + doc.y = y + 72; + }); +} + +function desenharAssinaturas(doc, ordem) { + garantirEspaco(doc, 112); + + doc.y += 10; + + const y = doc.y + 44; + const largura = 220; + + doc + .moveTo(MARGEM, y) + .lineTo(MARGEM + largura, y) + .lineWidth(0.7) + .strokeColor(CORES.textoSuave) + .stroke(); + + doc + .moveTo(LARGURA_A4 - MARGEM - largura, y) + .lineTo(LARGURA_A4 - MARGEM, y) + .stroke(); + + doc + .font('Helvetica') + .fontSize(7) + .fillColor(CORES.textoSuave) + .text( + 'Responsável técnico', + MARGEM, + y + 6, + { width: largura, align: 'center' } + ); + + doc + .text( + 'Cliente / responsável pela retirada', + LARGURA_A4 - MARGEM - largura, + y + 6, + { width: largura, align: 'center' } + ); + + if (ordem.tecnico_responsavel_nome) { + doc + .font('Helvetica-Bold') + .fontSize(7.5) + .fillColor(CORES.texto) + .text( + ordem.tecnico_responsavel_nome, + MARGEM, + y + 19, + { width: largura, align: 'center' } + ); + } + + if (ordem.responsavel_retirada) { + doc + .font('Helvetica-Bold') + .fontSize(7.5) + .fillColor(CORES.texto) + .text( + ordem.responsavel_retirada, + LARGURA_A4 - MARGEM - largura, + y + 19, + { width: largura, align: 'center' } + ); + } + + doc.y = y + 42; +} + +function adicionarRodapes(doc, identificador) { + const paginas = doc.bufferedPageRange(); + const ultimaPaginaOriginal = paginas.start + paginas.count - 1; + + for (let i = paginas.start; i <= ultimaPaginaOriginal; i += 1) { + doc.switchToPage(i); + + const cursorX = doc.x; + const cursorY = doc.y; + const margemInferiorOriginal = doc.page.margins.bottom; + + // O rodapé fica fisicamente dentro do A4, mas abaixo da margem de fluxo. + // Sem isto, o PDFKit cria uma página nova para cada doc.text() do rodapé. + doc.page.margins.bottom = 0; + + doc + .moveTo(MARGEM, RODAPE_Y) + .lineTo(LARGURA_A4 - MARGEM, RODAPE_Y) + .lineWidth(0.6) + .strokeColor(CORES.bordaClara) + .stroke(); + + doc + .font('Helvetica') + .fontSize(6.5) + .fillColor(CORES.textoSuave) + .text( + `${identificador} · Gerado em ${dataHoraBR(new Date())}`, + MARGEM, + RODAPE_Y + 8, + { + width: 350, + height: 9, + lineBreak: false, + ellipsis: true + } + ); + + doc + .text( + `Página ${i - paginas.start + 1} de ${paginas.count}`, + 420, + RODAPE_Y + 8, + { + width: LARGURA_A4 - MARGEM - 420, + height: 9, + align: 'right', + lineBreak: false + } + ); + + doc.page.margins.bottom = margemInferiorOriginal; + doc.x = cursorX; + doc.y = cursorY; + } + + // Termina posicionado na última página real, sem criar páginas vazias. + doc.switchToPage(ultimaPaginaOriginal); +} + +function gerarOrdemServicoPdf(res, ordem, opcoes = {}) { + const interno = opcoes.interno === true; + const equipamento = ordem.equipamento_nome_snapshot || + ordem.modelo_snapshot || + 'Equipamento'; + + const nomeArquivo = + `OS_${ordem.id}_${equipamento}` + + (ordem.numero_serie_snapshot ? `_NS_${ordem.numero_serie_snapshot}` : '') + + (interno ? '_INTERNA' : '_CLIENTE') + + '.pdf'; + + const doc = criarDocumento( + res, + nomeArquivo, + `Ordem de Serviço #${ordem.id}`, + interno + ? 'Relatório técnico interno de manutenção' + : 'Relatório técnico de manutenção' + ); + + const y = doc.y; + + doc + .font('Helvetica-Bold') + .fontSize(18) + .fillColor(CORES.textoForte) + .text(`Ordem de Serviço #${ordem.id}`, MARGEM, y, { + width: 260 + }); + + doc + .font('Helvetica') + .fontSize(8) + .fillColor(CORES.textoSuave) + .text( + `${TIPO_OS_LABEL[ordem.tipo] || texto(ordem.tipo)} · ` + + `${texto(ordem.prioridade, 'Normal')}`, + MARGEM, + y + 23, + { width: 250 } + ); + + badge( + doc, + 340, + y, + STATUS_OS_LABEL[ordem.status] || texto(ordem.status), + ordem.status, + 150 + ); + + doc.y = y + 46; + + const cardW = (LARGURA_CONTEUDO - 20) / 3; + + cardInfo( + doc, + MARGEM, + doc.y, + cardW, + 'Equipamento', + equipamento, + { altura: 53 } + ); + + cardInfo( + doc, + MARGEM + cardW + 10, + doc.y, + cardW, + 'Número de série', + ordem.numero_serie_snapshot || '-', + { altura: 53 } + ); + + cardInfo( + doc, + MARGEM + ((cardW + 10) * 2), + doc.y, + cardW, + 'Cliente', + ordem.cliente_nome_snapshot || '-', + { altura: 53 } + ); + + doc.y += 64; + + cardInfo( + doc, + MARGEM, + doc.y, + cardW, + 'Entrada', + dataHoraBR(ordem.data_entrada), + { altura: 48 } + ); + + cardInfo( + doc, + MARGEM + cardW + 10, + doc.y, + cardW, + 'Finalização', + dataHoraBR(ordem.data_finalizacao), + { altura: 48 } + ); + + cardInfo( + doc, + MARGEM + ((cardW + 10) * 2), + doc.y, + cardW, + 'Retirada', + dataHoraBR(ordem.data_retirada), + { altura: 48 } + ); + + doc.y += 60; + + blocoTexto(doc, 'Problema relatado', ordem.problema_relatado); + blocoTexto(doc, 'Diagnóstico técnico', ordem.diagnostico); + blocoTexto(doc, 'Solução executada', ordem.solucao_resumo); + + if (!interno && ordem.observacoes_cliente) { + blocoTexto(doc, 'Observações ao cliente', ordem.observacoes_cliente); + } + + if (interno && ordem.observacoes_internas) { + blocoTexto(doc, 'Observações internas', ordem.observacoes_internas); + } + + tituloSecao( + doc, + 'Atividades realizadas', + 'Histórico cronológico das etapas executadas nesta manutenção.' + ); + + if (!ordem.atividades || !ordem.atividades.length) { + blocoTexto(doc, 'Atividades', 'Nenhuma atividade registrada.'); + } else { + ordem.atividades.forEach((atividade, indice) => { + desenharAtividade(doc, atividade, indice, interno); + }); + } + + desenharOdometros(doc, ordem.odometros_da_os || []); + desenharResumoFinanceiro(doc, ordem, interno); + + if (ordem.garantia_dias || ordem.garantia_ate) { + blocoTexto( + doc, + 'Garantia do serviço', + [ + ordem.garantia_dias + ? `${ordem.garantia_dias} dia(s)` + : null, + ordem.garantia_ate + ? `válida até ${dataBR(ordem.garantia_ate)}` + : null + ].filter(Boolean).join(', ') + ); + } + + desenharAssinaturas(doc, ordem); + adicionarRodapes(doc, `OS #${ordem.id}`); + doc.end(); +} + +function cabecalhoTabelaRelatorio(doc, colunas, y) { + doc + .rect(MARGEM, y, LARGURA_CONTEUDO, 24) + .fill(CORES.azulEscuro); + + let x = MARGEM; + + colunas.forEach(coluna => { + doc + .font('Helvetica-Bold') + .fontSize(6.2) + .fillColor(CORES.branco) + .text(coluna.titulo, x + 4, y + 8, { + width: coluna.largura - 8, + align: coluna.alinhamento || 'left' + }); + + x += coluna.largura; + }); + + doc.y = y + 24; +} + +function linhaTabelaRelatorio(doc, colunas, valores, indice) { + const alturas = valores.map((valor, coluna) => { + return doc + .font('Helvetica') + .fontSize(6.5) + .heightOfString(texto(valor), { + width: colunas[coluna].largura - 8 + }); + }); + + const altura = Math.max(27, Math.min(44, Math.max(...alturas) + 10)); + + if (doc.y + altura > RODAPE_Y - 18) { + doc.addPage(); + cabecalhoTabelaRelatorio(doc, colunas, doc.y); + } + + const y = doc.y; + + if (indice % 2 === 1) { + doc + .rect(MARGEM, y, LARGURA_CONTEUDO, altura) + .fill(CORES.fundo); + } + + let x = MARGEM; + + valores.forEach((valor, coluna) => { + doc + .font('Helvetica') + .fontSize(6.5) + .fillColor(CORES.texto) + .text(texto(valor), x + 4, y + 6, { + width: colunas[coluna].largura - 8, + height: altura - 8, + ellipsis: true, + align: colunas[coluna].alinhamento || 'left' + }); + + x += colunas[coluna].largura; + }); + + doc + .moveTo(MARGEM, y + altura) + .lineTo(MARGEM + LARGURA_CONTEUDO, y + altura) + .lineWidth(0.45) + .strokeColor(CORES.bordaClara) + .stroke(); + + doc.y = y + altura; +} + +function gerarRelatorioOrdensServicoPdf(res, ordens, filtros = {}) { + const inicio = filtros.dataInicio; + const fim = filtros.dataFim; + const periodo = `${dataBR(inicio)} a ${dataBR(fim)}`; + + const nomeArquivo = `Relatorio_OS_${nomeArquivoSeguro( + dataBR(inicio) + )}_${nomeArquivoSeguro(dataBR(fim))}.pdf`; + + const doc = criarDocumento( + res, + nomeArquivo, + 'Relatório de Serviços e Cobranças', + `Período: ${periodo}` + ); + + const totalHoras = ordens.reduce( + (soma, ordem) => + soma + + numero(ordem.total_horas_expediente) + + numero(ordem.total_horas_extras), + 0 + ); + + const totalServicos = ordens.reduce( + (soma, ordem) => soma + numero(ordem.valor_mao_obra), + 0 + ); + + const totalInsumos = ordens.reduce( + (soma, ordem) => soma + numero(ordem.valor_insumos), + 0 + ); + + const totalGeral = ordens.reduce( + (soma, ordem) => soma + numero(ordem.valor_total), + 0 + ); + + const totalPago = ordens.reduce( + (soma, ordem) => soma + numero(ordem.valor_pago), + 0 + ); + + const totalPendente = ordens.reduce( + (soma, ordem) => soma + numero(ordem.valor_pendente), + 0 + ); + + doc + .font('Helvetica-Bold') + .fontSize(18) + .fillColor(CORES.textoForte) + .text('Relatório de Serviços e Cobranças'); + + doc + .font('Helvetica') + .fontSize(8) + .fillColor(CORES.textoSuave) + .text( + `Período: ${periodo} · Critério: ${texto(filtros.criterioLabel)}`, + { width: LARGURA_CONTEUDO } + ); + + doc.y += 16; + + const resumo = [ + ['Ordens', ordens.length], + ['Horas', `${decimal(totalHoras)} h`], + ['Mão de obra', moeda(totalServicos)], + ['Insumos', moeda(totalInsumos)] + ]; + + const cardW = (LARGURA_CONTEUDO - 30) / 4; + + resumo.forEach((item, indice) => { + cardInfo( + doc, + MARGEM + ((cardW + 10) * indice), + doc.y, + cardW, + item[0], + item[1], + { altura: 50, fonte: 10 } + ); + }); + + doc.y += 62; + + const resumoFinanceiro = [ + ['Total faturado', moeda(totalGeral)], + ['Total recebido', moeda(totalPago)], + ['Total pendente', moeda(totalPendente)] + ]; + + const cardFinanceiroW = (LARGURA_CONTEUDO - 20) / 3; + + resumoFinanceiro.forEach((item, indice) => { + cardInfo( + doc, + MARGEM + ((cardFinanceiroW + 10) * indice), + doc.y, + cardFinanceiroW, + item[0], + item[1], + { altura: 53, fonte: 11.5 } + ); + }); + + doc.y += 72; + + tituloSecao( + doc, + 'Ordens incluídas', + filtros.clienteNome + ? `Cliente: ${filtros.clienteNome}` + : 'Relação consolidada das ordens que atendem aos filtros informados.' + ); + + if (!ordens.length) { + blocoTexto(doc, 'Resultado', 'Nenhuma ordem de serviço encontrada para o período.'); + } else { + const colunas = [ + { titulo: 'OS', largura: 31, alinhamento: 'center' }, + { titulo: 'Equipamento', largura: 104 }, + { titulo: 'Finalização', largura: 58, alinhamento: 'center' }, + { titulo: 'Horas', largura: 45, alinhamento: 'right' }, + { titulo: 'Mão de obra', largura: 65, alinhamento: 'right' }, + { titulo: 'Insumos', largura: 58, alinhamento: 'right' }, + { titulo: 'Total', largura: 67, alinhamento: 'right' }, + { titulo: 'Pendente', largura: 83, alinhamento: 'right' } + ]; + + cabecalhoTabelaRelatorio(doc, colunas, doc.y); + + ordens.forEach((ordem, indice) => { + linhaTabelaRelatorio( + doc, + colunas, + [ + `#${ordem.id}`, + `${texto(ordem.equipamento_nome_snapshot)}${ + ordem.numero_serie_snapshot + ? ` · NS ${ordem.numero_serie_snapshot}` + : '' + }`, + dataBR( + ordem.data_finalizacao || + ordem.data_retirada || + ordem.data_entrada + ), + decimal( + numero(ordem.total_horas_expediente) + + numero(ordem.total_horas_extras) + ), + moeda(ordem.valor_mao_obra), + moeda(ordem.valor_insumos), + moeda(ordem.valor_total), + moeda(ordem.valor_pendente) + ], + indice + ); + }); + + doc.y += 12; + } + + tituloSecao(doc, 'Consolidação financeira'); + + garantirEspaco(doc, 98); + + const linhas = [ + ['Mão de obra', totalServicos], + ['Materiais e insumos', totalInsumos], + ['Valor total das OS', totalGeral], + ['Valor recebido', totalPago], + ['Valor pendente', totalPendente] + ]; + + linhas.forEach((linha, indice) => { + const y = doc.y; + + if (indice % 2 === 0) { + doc + .rect(MARGEM, y, LARGURA_CONTEUDO, 24) + .fill(CORES.fundo); + } + + doc + .font(indice >= 2 ? 'Helvetica-Bold' : 'Helvetica') + .fontSize(8) + .fillColor(CORES.texto) + .text(linha[0], MARGEM + 8, y + 8, { + width: 300 + }); + + doc + .font('Helvetica-Bold') + .text(moeda(linha[1]), MARGEM + 320, y + 8, { + width: LARGURA_CONTEUDO - 328, + align: 'right' + }); + + doc.y = y + 24; + }); + + doc.y += 18; + + blocoTexto( + doc, + 'Observação', + 'Este relatório consolida os valores registrados nas ordens de serviço. ' + + 'Pagamentos cancelados não compõem o valor recebido.' + ); + + adicionarRodapes(doc, 'Relatório de serviços'); + doc.end(); +} + +module.exports = { + gerarOrdemServicoPdf, + gerarRelatorioOrdensServicoPdf, + nomeArquivoSeguro +}; diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index b8ef8b1..c82d485 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -26,7 +26,7 @@ import { BingoSortComponent } from './bingo-sort/bingo-sort.component'; import { BingoComponent } from './bingo/bingo.component'; import { BingoDashboardComponent } from './bingo-dashboard/bingo-dashboard.component'; import { BingoService } from './services/bingoService'; -import { OrionDashboardComponent } from './orion-dashboard/orion-dashboard.component'; +import { OrionDashboardComponent } from './oriontard/orion-dashboard/orion-dashboard.component'; import { OrionService } from './services/orionService'; import { ProdutosComponent } from './produtos/produtos.component'; import { ProdutosService } from './services/produtosService'; @@ -36,6 +36,8 @@ import { AgrobaseService } from './services/agrobase.service'; import { TermosUsoComponent } from './termos-uso/termos-uso.component'; import { PoliticaPrivacidadeComponent } from './politica-privacidade/politica-privacidade.component'; 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'; const routes: Routes = [ @@ -149,10 +151,24 @@ const routes: Routes = [ component: BingoSortComponent, }, ] - }, { - path: 'orion-dashboard', + }, + { + path: 'orion-dashboard', component: OrionDashboardComponent - }, { + }, + { + path: 'orion-ordens-servico/nova', + component: OrionOrdemServicoFormComponent + }, + { + path: 'orion-ordens-servico/:id/editar', + component: OrionOrdemServicoFormComponent + }, + { + path: 'orion-ordens-servico', + component: OrionOrdensServicoComponent + }, + { path: 'agrobase', children: [ { diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 407eb42..d402cb6 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -26,7 +26,7 @@ import { EasyAVAComponent } from './easy-ava/easy-ava.component'; import { BingoSortComponent } from './bingo-sort/bingo-sort.component'; import { BingoComponent } from './bingo/bingo.component'; import { BingoDashboardComponent } from './bingo-dashboard/bingo-dashboard.component'; -import { OrionDashboardComponent } from './orion-dashboard/orion-dashboard.component'; +import { OrionDashboardComponent } from './oriontard/orion-dashboard/orion-dashboard.component'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { MatDatepickerModule } from '@angular/material/datepicker'; @@ -49,6 +49,8 @@ import { TipoArquivoNomePipe } from './pipes/tipo-arquivo-nome.pipe'; import { SobreComponent } from './sobre/sobre.component'; import { PoliticaPrivacidadeComponent } from './politica-privacidade/politica-privacidade.component'; 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'; // Registrar a localidade pt-BR @@ -79,7 +81,7 @@ registerLocaleData(localePt); ProdutosComponent, AgrobaseLoginComponent, AgrobaseVersionamentoComponent, - TipoArquivoNomePipe, SobreComponent, PoliticaPrivacidadeComponent, TermosUsoComponent + TipoArquivoNomePipe, SobreComponent, PoliticaPrivacidadeComponent, TermosUsoComponent, OrionOrdensServicoComponent, OrionOrdemServicoFormComponent ], imports: [ BrowserModule, diff --git a/src/app/chaves/chaves.component.ts b/src/app/chaves/chaves.component.ts index 0941af5..cf26eb1 100644 --- a/src/app/chaves/chaves.component.ts +++ b/src/app/chaves/chaves.component.ts @@ -71,7 +71,7 @@ export class ChavesComponent implements OnInit { return; } if (confirm(Mensagem)) { - this.GerarChave(90); + this.GerarChave(30); } else { return; diff --git a/src/app/dashboard/dashboard.component.ts b/src/app/dashboard/dashboard.component.ts index cca6f56..c45b3ad 100644 --- a/src/app/dashboard/dashboard.component.ts +++ b/src/app/dashboard/dashboard.component.ts @@ -149,12 +149,33 @@ export class DashboardComponent implements OnInit { { index: 5, texto: "OrionTard Pro", - link: "/orion-dashboard", - havesub: false, + link: "", + havesub: true, dropicon: "arrow_drop_down", icon: "home", issub: false, - sub: null, + sub: [ + { + index: 0, + texto: "Logs", + link: "/orion-dashboard", + icon: "update", + havesub: false, + dropicon: "", + issub: true, + sub: null, + }, + { + index: 1, + texto: "Ordens de Serviço", + link: "/orion-ordens-servico", + icon: "update", + havesub: false, + dropicon: "", + issub: true, + sub: null, + }, + ], }, { index: 6, diff --git a/src/app/models/orionModel.ts b/src/app/models/orionModel.ts index b480922..8d42590 100644 --- a/src/app/models/orionModel.ts +++ b/src/app/models/orionModel.ts @@ -1,92 +1,513 @@ export class OrionLogsModel { - dataLog: Date; - nome: string; - coordenadas: string; - perfil: string; - log: string; - logObj: OrionLogsDetalhesModel; + dataLog: Date; + nome: string; + coordenadas: string; + perfil: string; + log: string; + logObj: OrionLogsDetalhesModel; } export class OrionLogsDetalhesModel { - data: Date; - firmwareVersao: number; - robotNs: number; - modelo: string; - versao: string; - cpf: string; - usuario: string; - perfil: string; - camera: string; - servico: OrionLogServicoModel; - odometroTotal: number; - odometroConectado: number; - odometroMotores: number; - odometroMotoresParcial: number; - latitude: number; - longitude: number; - altitude: number; - tempoTotal: number; - tempoConectado: number; - sensores: OrionLogSensorModel[]; - portaCOM: string; - baudRate: number; - conectado: boolean; - distancia: number; + data: Date; + firmwareVersao: number; + robotNs: number; + modelo: string; + versao: string; + cpf: string; + usuario: string; + perfil: string; + camera: string; + servico: OrionLogServicoModel; + odometroTotal: number; + odometroConectado: number; + odometroMotores: number; + odometroMotoresParcial: number; + latitude: number; + longitude: number; + altitude: number; + tempoTotal: number; + tempoConectado: number; + sensores: OrionLogSensorModel[]; + portaCOM: string; + baudRate: number; + conectado: boolean; + distancia: number; - HoraInicio: string; - HoraFim: string; - Duracao: string; - Coordenadas: string; - Localizacao: string; + HoraInicio: string; + HoraFim: string; + Duracao: string; + Coordenadas: string; + Localizacao: string; } export class OrionLogServicoModel { - Nome: string; - Cidade: string; - Responsavel: string; - Servico: string; - Path: string; - DataCadastro: Date; + Nome: string; + Cidade: string; + Responsavel: string; + Servico: string; + Path: string; + DataCadastro: Date; } export class OrionLogSensorModel { - sensor: number; - descricao: string; - minimo: number; - maximo: number; - media: number; - moda: number; - mediana: number; - lista: number[]; + sensor: number; + descricao: string; + minimo: number; + maximo: number; + media: number; + moda: number; + mediana: number; + lista: number[]; } export class GeoCoordenadasResponseModel { - data: GeoCoordenadasModel[]; + data: GeoCoordenadasModel[]; } export class GeoCoordenadasModel { - latitude: number; - longitude: number; - type: string; - distance: number; - name: string; - number: string; - postal_code: string; - street: string; - confidence: number; - region: string; - region_code: string; - county: string; - lacality: string; - administrative_area: string; - neighborhood: string; - country: string; - country_code: string; - continent: string; - label: string; + latitude: number; + longitude: number; + type: string; + distance: number; + name: string; + number: string; + postal_code: string; + street: string; + confidence: number; + region: string; + region_code: string; + county: string; + lacality: string; + administrative_area: string; + neighborhood: string; + country: string; + country_code: string; + continent: string; + label: string; } export class LogAgrupado { - key: string; - value: OrionLogsDetalhesModel[]; -} \ No newline at end of file + key: string; + value: OrionLogsDetalhesModel[]; +} + +// ============================================================ +// RESPOSTA PADRÃO DA API +// ============================================================ + +export interface OrionApiErrorModel { + code?: string; + message: string; +} + +export interface OrionApiResponseModel { + status: number; + error: OrionApiErrorModel | null; + response: T; +} + +// ============================================================ +// CADASTROS AUXILIARES +// ============================================================ + +export interface ClienteManutencaoModel { + id: number; + nome_fantasia: string; + razao_social?: string; + documento?: string; +} + +export interface EquipamentoManutencaoModel { + id: number; + cliente_id?: number; + modelo_id?: number; + codigo_interno?: string; + nome: string; + numero_serie: string; + versao?: string; + + odometro_total_segundos: number; + odometro_conectado_segundos: number; + odometro_movimento_segundos: number; + odometro_parcial_segundos: number; + odometro_atualizado_em?: string; + + modelo_nome?: string; + cliente_nome?: string; +} + +export interface TecnicoManutencaoModel { + id: number; + usuario_sistema_id?: number; + nome: string; + especialidade?: string; + valor_hora_expediente_padrao: number; + valor_hora_extra_padrao: number; +} + +export interface MaterialManutencaoModel { + id: number; + codigo?: string; + descricao: string; + tipo: MaterialManutencaoTipo; + unidade: string; + custo_padrao: number; + preco_venda_padrao: number; +} + +export type MaterialManutencaoTipo = + | 'PECA' + | 'INSUMO' + | 'SERVICO_TERCEIRO' + | 'FRETE' + | 'FERRAMENTA' + | 'OUTRO'; + +// ============================================================ +// ORDENS DE SERVIÇO +// ============================================================ + +export interface OrdemServicoListaModel { + id: number; + + cliente_id?: number; + equipamento_id?: number; + tecnico_responsavel_id?: number; + + cliente_nome_snapshot?: string; + equipamento_nome_snapshot?: string; + numero_serie_snapshot?: string; + modelo_snapshot?: string; + versao_snapshot?: string; + + tipo: OrdemServicoTipo; + status: OrdemServicoStatus; + status_pagamento: OrdemServicoStatusPagamento; + prioridade: OrdemServicoPrioridade; + + data_entrada: string; + data_previsao?: string; + data_finalizacao?: string; + data_retirada?: string; + + problema_relatado?: string; + + total_horas_expediente: number; + total_horas_extras: number; + + valor_mao_obra: number; + valor_insumos: number; + valor_desconto: number; + valor_acrescimo: number; + valor_total: number; + valor_pago: number; + valor_pendente: number; + + tecnico_responsavel_nome?: string; + + created_at: string; + updated_at: string; +} + +export interface OrdemServicoPaginacaoModel { + page: number; + pageSize: number; + total: number; + totalPages: number; +} + +export interface OrdemServicoListaResponseModel { + items: OrdemServicoListaModel[]; + pagination: OrdemServicoPaginacaoModel; +} + +export interface OrdemServicoFiltrosModel { + page?: number; + pageSize?: number; + busca?: string; + status?: OrdemServicoStatus | ''; + status_pagamento?: OrdemServicoStatusPagamento | ''; + tipo?: OrdemServicoTipo | ''; + equipamento_id?: number; + cliente_id?: number; + data_inicio?: string; + data_fim?: string; +} + +export interface OrdemServicoDetalhesModel extends OrdemServicoListaModel { + diagnostico?: string; + solucao_resumo?: string; + + responsavel_entrega?: string; + responsavel_retirada?: string; + + observacoes_internas?: string; + observacoes_cliente?: string; + + garantia_dias?: number; + garantia_ate?: string; + + custo_insumos: number; + justificativa_ajuste_valor?: string; + origem?: string; + + atividades: OrdemServicoAtividadeModel[]; + odometros_da_os: OrdemServicoOdometroModel[]; + intervalo_desde_leitura_anterior?: OrdemServicoIntervaloOdometroModel; + pagamentos: OrdemServicoPagamentoModel[]; + historico_status: OrdemServicoHistoricoStatusModel[]; +} + +export interface OrdemServicoAtividadeModel { + id: number; + ordem_servico_id: number; + + data_atividade: string; + descricao: string; + ordem_exibicao: number; + + horas_expediente: number; + horas_extras: number; + valor_hora_expediente: number; + valor_hora_extra: number; + valor_mao_obra: number; + + cobravel: boolean | number; + + observacoes_internas?: string; + observacoes_cliente?: string; + + tecnicos: OrdemServicoAtividadeTecnicoModel[]; + materiais: OrdemServicoAtividadeMaterialModel[]; + + created_at?: string; + updated_at?: string; +} + +export interface OrdemServicoAtividadeTecnicoModel { + atividade_id?: number; + tecnico_id: number; + tecnico_nome?: string; + papel: OrdemServicoTecnicoPapel; + horas_trabalhadas_expediente?: number; + horas_trabalhadas_extras?: number; +} + +export interface OrdemServicoAtividadeMaterialModel { + id?: number; + atividade_id?: number; + material_id?: number; + + descricao_snapshot: string; + unidade_snapshot: string; + + quantidade: number; + valor_custo_unitario: number; + valor_cobrado_unitario: number; + valor_custo_total?: number; + valor_cobrado_total?: number; + + cobravel: boolean | number; + observacoes?: string; +} + +export interface OrdemServicoOdometroModel { + id?: number; + equipamento_id?: number; + ordem_servico_id?: number; + + tipo_leitura: OrdemServicoTipoLeituraOdometro; + origem?: string; + leitura_em?: string; + + odometro_total_segundos: number; + odometro_conectado_segundos: number; + odometro_movimento_segundos: number; + odometro_parcial_segundos: number; + + odometro_total_anterior?: number; + odometro_conectado_anterior?: number; + odometro_movimento_anterior?: number; + + observacoes?: string; + usuario_id?: number; +} + +export interface OrdemServicoIntervaloOdometroModel { + total_segundos?: number; + conectado_segundos?: number; + movimento_segundos?: number; +} + +export interface OrdemServicoPagamentoModel { + id?: number; + ordem_servico_id?: number; + data_pagamento?: string; + valor: number; + forma_pagamento: OrdemServicoFormaPagamento; + referencia?: string; + observacoes?: string; + usuario_id?: number; + cancelado_em?: string; +} + +export interface OrdemServicoHistoricoStatusModel { + id: number; + ordem_servico_id: number; + status_anterior?: OrdemServicoStatus; + status_novo: OrdemServicoStatus; + observacoes?: string; + alterado_por_usuario_id?: number; + created_at: string; +} + +export interface OrdemServicoSalvarModel { + cliente_id?: number; + equipamento_id?: number; + tecnico_responsavel_id?: number; + + equipamento_nome?: string; + numero_serie?: string; + modelo?: string; + versao?: string; + + tipo?: OrdemServicoTipo; + status?: OrdemServicoStatus; + status_pagamento?: OrdemServicoStatusPagamento; + prioridade?: OrdemServicoPrioridade; + + data_entrada?: string; + data_previsao?: string; + data_finalizacao?: string; + data_retirada?: string; + + problema_relatado?: string; + diagnostico?: string; + solucao_resumo?: string; + + responsavel_entrega?: string; + responsavel_retirada?: string; + + observacoes_internas?: string; + observacoes_cliente?: string; + + garantia_dias?: number; + garantia_ate?: string; + + valor_desconto?: number; + valor_acrescimo?: number; + justificativa_ajuste_valor?: string; + + observacao_status?: string; + usuario_id?: number; + + odometro_entrada?: OrdemServicoOdometroModel; +} + +export interface OrdemServicoAtividadeSalvarModel { + data_atividade?: string; + descricao: string; + ordem_exibicao?: number; + + horas_expediente?: number; + horas_extras?: number; + valor_hora_expediente?: number; + valor_hora_extra?: number; + cobravel?: boolean; + + observacoes_internas?: string; + observacoes_cliente?: string; + usuario_id?: number; + + tecnicos?: OrdemServicoAtividadeTecnicoModel[]; + materiais?: OrdemServicoAtividadeMaterialModel[]; +} + +export interface OrdemServicoTotaisModel { + total_horas_expediente: number; + total_horas_extras: 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; + status_pagamento: OrdemServicoStatusPagamento; +} + +export interface OrdemServicoOperacaoResultModel { + id: number; + ordem_servico_id?: number; + equipamento_id?: number; + excluida?: boolean; + cancelado?: boolean; + tipo_leitura?: OrdemServicoTipoLeituraOdometro; + totais_os?: OrdemServicoTotaisModel; +} + +export interface OrionLogInsertModel { + cpf?: string; + perfil?: string; + latitude?: number; + longitude?: number; + log: string; +} + +export type OrdemServicoTipo = + | 'CORRETIVA' + | 'PREVENTIVA' + | 'REFORMA' + | 'DESENVOLVIMENTO' + | 'FABRICACAO' + | 'GARANTIA' + | 'CORTESIA' + | 'INTERNA' + | 'OUTRO'; + +export type OrdemServicoStatus = + | 'RASCUNHO' + | 'ABERTA' + | 'EM_DIAGNOSTICO' + | 'AGUARDANDO_APROVACAO' + | 'AGUARDANDO_PECA' + | 'EM_EXECUCAO' + | 'EM_TESTES' + | 'FINALIZADA' + | 'AGUARDANDO_RETIRADA' + | 'RETIRADA' + | 'CANCELADA'; + +export type OrdemServicoStatusPagamento = + | 'NAO_APLICAVEL' + | 'PENDENTE' + | 'PARCIAL' + | 'RECEBIDO' + | 'CORTESIA'; + +export type OrdemServicoPrioridade = + | 'BAIXA' + | 'NORMAL' + | 'ALTA' + | 'URGENTE'; + +export type OrdemServicoTecnicoPapel = + | 'RESPONSAVEL' + | 'EXECUTOR' + | 'APOIO'; + +export type OrdemServicoFormaPagamento = + | 'DINHEIRO' + | 'PIX' + | 'TRANSFERENCIA' + | 'BOLETO' + | 'CARTAO' + | 'OUTRO'; + +export type OrdemServicoTipoLeituraOdometro = + | 'CADASTRO_INICIAL' + | 'ENTRADA_MANUTENCAO' + | 'SAIDA_MANUTENCAO' + | 'ATUALIZACAO_MANUAL' + | 'TELEMETRIA'; \ No newline at end of file diff --git a/src/app/orion-dashboard/orion-dashboard.component.html b/src/app/oriontard/orion-dashboard/orion-dashboard.component.html similarity index 100% rename from src/app/orion-dashboard/orion-dashboard.component.html rename to src/app/oriontard/orion-dashboard/orion-dashboard.component.html diff --git a/src/app/orion-dashboard/orion-dashboard.component.scss b/src/app/oriontard/orion-dashboard/orion-dashboard.component.scss similarity index 100% rename from src/app/orion-dashboard/orion-dashboard.component.scss rename to src/app/oriontard/orion-dashboard/orion-dashboard.component.scss diff --git a/src/app/orion-dashboard/orion-dashboard.component.spec.ts b/src/app/oriontard/orion-dashboard/orion-dashboard.component.spec.ts similarity index 100% rename from src/app/orion-dashboard/orion-dashboard.component.spec.ts rename to src/app/oriontard/orion-dashboard/orion-dashboard.component.spec.ts diff --git a/src/app/orion-dashboard/orion-dashboard.component.ts b/src/app/oriontard/orion-dashboard/orion-dashboard.component.ts similarity index 98% rename from src/app/orion-dashboard/orion-dashboard.component.ts rename to src/app/oriontard/orion-dashboard/orion-dashboard.component.ts index a294a3b..3fc8547 100644 --- a/src/app/orion-dashboard/orion-dashboard.component.ts +++ b/src/app/oriontard/orion-dashboard/orion-dashboard.component.ts @@ -1,8 +1,8 @@ import { Component, OnInit, ViewChild } from '@angular/core'; import { MatPaginator } from '@angular/material/paginator'; import { Chart } from 'chart.js'; -import { LogAgrupado, OrionLogsDetalhesModel, OrionLogSensorModel, OrionLogsModel } from '../models/orionModel'; -import { OrionService } from '../services/orionService'; +import { LogAgrupado, OrionLogsDetalhesModel, OrionLogSensorModel, OrionLogsModel } from '../../models/orionModel'; +import { OrionService } from '../../services/orionService'; import { MatTableDataSource } from '@angular/material/table'; import { MatTabChangeEvent } from '@angular/material'; diff --git a/src/app/oriontard/orion-ordem-servico-form/orion-ordem-servico-form.component.html b/src/app/oriontard/orion-ordem-servico-form/orion-ordem-servico-form.component.html new file mode 100644 index 0000000..c5a90a3 --- /dev/null +++ b/src/app/oriontard/orion-ordem-servico-form/orion-ordem-servico-form.component.html @@ -0,0 +1,720 @@ +
+ + + +
{{ erro }}
+
{{ sucesso }}
+ +
+
+ Carregando dados da ordem de serviço... +
+ + + +
+
+
+ DADOS GERAIS +

Entrada e identificação

+
+
+ +
+ + Equipamento + + + {{ equipamento.nome }} · NS {{ equipamento.numero_serie }} + · {{ equipamento.modelo_nome }} + + + Selecione o equipamento. + + + + Cliente + + Não informado + + {{ cliente.nome_fantasia }} + + + + + + Técnico responsável + + Não definido + + {{ tecnico.nome }} + + + + + + Tipo + + + + + {{ opcao.texto }} + + + + + + + Situação financeira + + + + Não aplicável + + + + Pendente + + + + Parcial + + + + Recebido + + + + Cortesia + + + + + + Status + + + {{ opcao.texto }} + + + + + + Prioridade + + + {{ opcao.texto }} + + + + + + Data e hora de entrada + + Informe a data de entrada. + + + + Previsão + + + + + Finalização + + + + + Retirada + + + + + Responsável pela entrega + + + + + Responsável pela retirada + + + + + Problema relatado pelo cliente + + Descreva o problema relatado. + + + + Diagnóstico + + + + + Solução resumida + + + + + Observações internas + + + + + Observações para o cliente + + + + + Garantia em dias + + + + + Garantia até + + + + + Desconto + + R$  + + + + Acréscimo + + R$  + + + + Justificativa do ajuste financeiro + + + + + Observação da alteração de status + + +
+
+ +
+
+
+ LEITURA DE ENTRADA +

Odômetros recebidos

+

Confirme os valores exibidos pelo robô quando ele chegou ao laboratório.

+
+
+ +
+ + Total em segundos + + {{ segundosParaTempo(formOdometroEntrada.get('odometro_total_segundos').value) }} + + + + Conectado em segundos + + {{ segundosParaTempo(formOdometroEntrada.get('odometro_conectado_segundos').value) }} + + + + Movimento em segundos + + {{ segundosParaTempo(formOdometroEntrada.get('odometro_movimento_segundos').value) }} + + + + Parcial em segundos + + {{ segundosParaTempo(formOdometroEntrada.get('odometro_parcial_segundos').value) }} + + + + Momento da leitura + + + + + Observações da leitura + + +
+
+ + + + +
+
+
+

Atividades realizadas

+

Adicione um registro para cada dia ou etapa da manutenção.

+
+ + +
+ +
+
+
+ ATIVIDADE +

{{ atividadeEditandoId ? 'Editar atividade' : 'Nova atividade' }}

+
+ +
+ +
+ + Data + + + + + Técnicos + + + {{ tecnico.nome }} + + + + + + Ordem de exibição + + + + + Descrição do serviço realizado + + + + + Horas de expediente + + + + + Horas extras + + + + + Valor hora expediente + + R$  + + + + Valor hora extra + + R$  + + + + + + Observações internas + + + + + Observações para o cliente + + +
+ +
+
+
+

Materiais e insumos

+

Use o catálogo ou registre um item avulso.

+
+ +
+ +
+ + + Catálogo + + Item avulso + + {{ material.descricao }} + + + + + + Descrição + + + + + Unidade + + + + + Quantidade + + + + + Custo unitário + + R$  + + + + Valor cobrado + + R$  + + + + + +
+ +
+ Nenhum material adicionado nesta atividade. +
+
+ +
+ + +
+
+ +
+
+
+ {{ atividade.data_atividade | date:'dd/MM/yyyy' }} +

{{ atividade.descricao }}

+
+ +
+ + +
+
+ +
+
+ Técnicos + {{ nomeTecnicosAtividade(atividade) }} +
+
+ Horas + {{ totalHorasAtividade(atividade) | number:'1.2-2' }} + {{ atividade.horas_expediente | number:'1.2-2' }} exp. · {{ atividade.horas_extras | number:'1.2-2' }} extra +
+
+ Mão de obra + {{ atividade.valor_mao_obra | currency:'BRL':'symbol':'1.2-2' }} +
+
+ Materiais + {{ valorMateriaisAtividade(atividade) | currency:'BRL':'symbol':'1.2-2' }} +
+
+ +
+
+ {{ material.quantidade | number:'1.0-3' }} {{ material.unidade_snapshot }} · {{ material.descricao_snapshot }} +
+
+
+ +
+ Nenhuma atividade registrada. +

Adicione o primeiro serviço realizado nesta manutenção.

+
+
+
+ + +
+
+
Mão de obra{{ ordem.valor_mao_obra | currency:'BRL':'symbol':'1.2-2' }}
+
Insumos{{ ordem.valor_insumos | currency:'BRL':'symbol':'1.2-2' }}
+
Desconto{{ ordem.valor_desconto | currency:'BRL':'symbol':'1.2-2' }}
+
Acréscimo{{ ordem.valor_acrescimo | currency:'BRL':'symbol':'1.2-2' }}
+
Valor total{{ ordem.valor_total | currency:'BRL':'symbol':'1.2-2' }}
+
Pago{{ ordem.valor_pago | currency:'BRL':'symbol':'1.2-2' }}
+
Pendente{{ valorPendente | currency:'BRL':'symbol':'1.2-2' }}
+
+ +
+
+

Pagamentos

+

O status financeiro é calculado pelos pagamentos registrados.

+
+ +
+ +
+
+ + Data e hora + + + + + Valor + + R$  + + + + Forma + + + {{ opcao.texto }} + + + + + + Referência + + + + + Observações + + +
+ +
+ + +
+
+ +
+
+ {{ pagamento.valor | currency:'BRL':'symbol':'1.2-2' }} + {{ pagamento.data_pagamento | date:'dd/MM/yyyy HH:mm' }} · {{ pagamento.forma_pagamento }} + {{ pagamento.referencia }} +
+ +
+ +
+ Nenhum pagamento registrado. +
+
+ + Esta OS é uma cortesia. Não é necessário registrar pagamento. + +
+ +
+ + Esta OS está em garantia. As horas e os materiais continuam + registrados para controle interno, mas não são cobrados. + +
+
+
+ + +
+
+
+

Histórico de odômetros

+

Leituras registradas na entrada, durante os testes e na saída.

+
+ +
+ +
+
+ + Tipo de leitura + + + {{ opcao.texto }} + + + + + + Data e hora + + + + + Total em segundos + + {{ segundosParaTempo(formOdometro.get('odometro_total_segundos').value) }} + + + + Conectado em segundos + + {{ segundosParaTempo(formOdometro.get('odometro_conectado_segundos').value) }} + + + + Movimento em segundos + + {{ segundosParaTempo(formOdometro.get('odometro_movimento_segundos').value) }} + + + + Parcial em segundos + + {{ segundosParaTempo(formOdometro.get('odometro_parcial_segundos').value) }} + + + + Observações + + +
+ +
+ + +
+
+ +
+
+
+ {{ leitura.tipo_leitura }} + {{ leitura.leitura_em | date:'dd/MM/yyyy HH:mm' }} +
+
+ +
+
Total{{ segundosParaTempo(leitura.odometro_total_segundos) }}
+
Conectado{{ segundosParaTempo(leitura.odometro_conectado_segundos) }}
+
Movimento{{ segundosParaTempo(leitura.odometro_movimento_segundos) }}
+
Parcial{{ segundosParaTempo(leitura.odometro_parcial_segundos) }}
+
+ +

{{ leitura.observacoes }}

+
+ +
+ Nenhuma leitura vinculada a esta OS. +
+
+
+ + +
+
+
+

Histórico de status

+

Alterações registradas durante a vida da OS.

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

{{ item.observacoes }}

+
+
+ +
+ Nenhuma alteração de status registrada. +
+
+
+
+
+
\ No newline at end of file diff --git a/src/app/oriontard/orion-ordem-servico-form/orion-ordem-servico-form.component.scss b/src/app/oriontard/orion-ordem-servico-form/orion-ordem-servico-form.component.scss new file mode 100644 index 0000000..3881d58 --- /dev/null +++ b/src/app/oriontard/orion-ordem-servico-form/orion-ordem-servico-form.component.scss @@ -0,0 +1,589 @@ +:host { + display: block; + min-height: 100vh; + background: #f4f6f8; + color: #1f2933; +} + +.os-form-page { + max-width: 1680px; + margin: 0 auto; + padding: 24px; + box-sizing: border-box; +} + +.page-header, +.header-main, +.header-actions, +.activity-actions, +.inline-form-actions, +.section-heading, +.inline-form-header, +.subsection-heading, +.tab-toolbar, +.activity-header, +.odometer-header { + display: flex; +} + +.page-header, +.section-heading, +.inline-form-header, +.subsection-heading, +.tab-toolbar, +.activity-header, +.odometer-header { + justify-content: space-between; + align-items: flex-start; + gap: 18px; +} + +.page-header { + margin-bottom: 18px; +} + +.header-main, +.header-actions, +.activity-actions, +.inline-form-actions { + align-items: center; + gap: 10px; +} + +.header-main { + align-items: flex-start; + + h1 { + margin: 4px 0 5px; + color: #101828; + font-size: 30px; + line-height: 1.15; + } + + p { + margin: 0; + color: #667085; + font-size: 14px; + } +} + +.header-actions { + flex-wrap: wrap; + justify-content: flex-end; +} + +.eyebrow, +.section-kicker { + color: #1769aa; + font-size: 11px; + font-weight: 700; + letter-spacing: 1.1px; +} + +.section-card, +.inline-form-card, +.activity-card, +.loading-card, +.odometer-card, +.simple-list-item, +.os-tabs { + background: #fff; + border: 1px solid #e4e7ec; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(16, 24, 40, 0.04); +} + +.section-card { + margin-bottom: 18px; + padding: 20px; +} + +.section-heading, +.inline-form-header, +.subsection-heading { + margin-bottom: 16px; +} + +.section-heading h2, +.tab-toolbar h2, +.inline-form-header h3, +.subsection-heading h4 { + margin: 4px 0 0; + color: #101828; +} + +.section-heading p, +.tab-toolbar p, +.subsection-heading p { + margin: 5px 0 0; + color: #667085; + font-size: 13px; +} + +.form-grid { + display: grid; + gap: 12px; + align-items: start; +} + +.form-grid-4 { + grid-template-columns: repeat(4, minmax(160px, 1fr)); +} + +.span-2 { grid-column: span 2; } +.span-3 { grid-column: span 3; } +.span-4 { grid-column: span 4; } + +.form-grid .mat-form-field { + width: 100%; +} + +.os-tabs { + display: block; + margin-top: 6px; + overflow: hidden; +} + +.tab-content { + padding: 20px; +} + +.tab-toolbar { + align-items: center; + margin-bottom: 18px; +} + +.inline-form-card { + margin-bottom: 18px; + padding: 18px; + background: #fbfcfe; +} + +.inline-form-actions { + justify-content: flex-end; + margin-top: 16px; +} + +.checkbox-field { + display: flex; + align-items: center; + gap: 8px; + min-height: 38px; + color: #344054; + font-size: 13px; + + input { + width: 16px; + height: 16px; + } +} + +.compact-checkbox { + align-self: center; +} + +.materials-section { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #eaecf0; +} + +.material-row { + display: grid; + grid-template-columns: + minmax(170px, 1.1fr) + minmax(220px, 1.8fr) + 90px + 110px + 140px + 140px + 90px + auto; + gap: 10px; + align-items: start; + margin-bottom: 10px; + padding: 12px; + background: #fff; + border: 1px solid #eaecf0; + border-radius: 9px; +} + +.empty-subsection, +.empty-state { + padding: 28px; + color: #667085; + text-align: center; + background: #f8fafc; + border: 1px dashed #d0d5dd; + border-radius: 9px; +} + +.empty-state { + strong { + display: block; + color: #344054; + font-size: 15px; + } + + p { + margin: 5px 0 0; + } +} + +.activity-card { + margin-bottom: 14px; + padding: 18px; +} + +.activity-date { + color: #1769aa; + font-size: 12px; + font-weight: 700; +} + +.activity-header h3 { + max-width: 1000px; + margin: 6px 0 0; + color: #344054; + font-size: 15px; + line-height: 1.55; + font-weight: 600; +} + +.activity-meta-grid, +.financial-grid, +.odometer-grid { + display: grid; + gap: 12px; + margin-top: 16px; +} + +.activity-meta-grid { + grid-template-columns: 2fr repeat(3, minmax(140px, 1fr)); +} + +.activity-meta-grid > div, +.odometer-grid > div { + padding: 12px; + background: #f8fafc; + border-radius: 8px; +} + +.activity-meta-grid span, +.odometer-grid span, +.financial-grid span { + display: block; + color: #667085; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.35px; +} + +.activity-meta-grid strong, +.odometer-grid strong { + display: block; + margin-top: 5px; + color: #344054; + font-size: 13px; +} + +.activity-meta-grid small { + display: block; + margin-top: 4px; + color: #98a2b3; + font-size: 11px; +} + +.materials-list { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 14px; +} + +.material-chip { + padding: 5px 9px; + background: #eff8ff; + color: #175cd3; + border-radius: 999px; + font-size: 11px; + font-weight: 600; +} + +.financial-grid { + grid-template-columns: repeat(7, minmax(140px, 1fr)); + margin-bottom: 24px; +} + +.financial-grid article { + padding: 16px; + background: #f8fafc; + border-radius: 9px; + + strong { + display: block; + margin-top: 7px; + color: #101828; + font-size: 17px; + } +} + +.financial-grid .financial-total { + background: #eef6ff; + + strong { + color: #175cd3; + } +} + +.simple-list-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-bottom: 10px; + padding: 14px 16px; + + strong, + span, + small { + display: block; + } + + strong { color: #344054; } + + span, + small { + margin-top: 3px; + color: #667085; + font-size: 12px; + } +} + +.odometer-card { + margin-bottom: 12px; + padding: 16px; +} + +.odometer-header { + strong, + span { display: block; } + strong { color: #344054; } + span { + margin-top: 3px; + color: #667085; + font-size: 12px; + } +} + +.odometer-grid { + grid-template-columns: repeat(4, minmax(140px, 1fr)); +} + +.odometer-card p { + margin: 12px 0 0; + color: #667085; + font-size: 13px; +} + +.timeline-item { + position: relative; + display: grid; + grid-template-columns: 18px 1fr; + gap: 12px; + padding-bottom: 20px; + + &::before { + content: ''; + position: absolute; + top: 14px; + bottom: -2px; + left: 6px; + width: 2px; + background: #e4e7ec; + } + + &:last-child::before { display: none; } + + strong, + span { display: block; } + strong { color: #344054; } + span { + margin-top: 3px; + color: #667085; + font-size: 12px; + } + p { + margin: 7px 0 0; + color: #475467; + font-size: 13px; + } +} + +.timeline-dot { + position: relative; + z-index: 1; + width: 12px; + height: 12px; + margin-top: 3px; + background: #1769aa; + border: 3px solid #eaf4ff; + border-radius: 50%; + box-sizing: border-box; +} + +.status-badge { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + white-space: nowrap; +} + +.badge-sucesso { color: #027a48; background: #ecfdf3; } +.badge-alerta { color: #b54708; background: #fffaeb; } +.badge-erro { color: #b42318; background: #fef3f2; } +.badge-info { color: #175cd3; background: #eff8ff; } +.badge-neutro { color: #475467; background: #f2f4f7; } + +.message { + margin-bottom: 14px; + padding: 12px 15px; + border-radius: 9px; + font-size: 13px; + font-weight: 600; +} + +.message-error { + color: #b42318; + background: #fef3f2; + border: 1px solid #fecdca; +} + +.message-success { + color: #027a48; + background: #ecfdf3; + border: 1px solid #abefc6; +} + +.loading-card { + min-height: 220px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #667085; +} + +.loading-bar { + width: 170px; + height: 4px; + margin-bottom: 15px; + border-radius: 999px; + background: linear-gradient(90deg, #d0d5dd 0%, #1769aa 45%, #d0d5dd 100%); + background-size: 200% 100%; + animation: loading 1.2s linear infinite; +} + +.danger-button { color: #b42318 !important; } + +@keyframes loading { + from { background-position: 200% 0; } + to { background-position: -200% 0; } +} + +/* Blindagem contra o tema global antigo */ +:host ::ng-deep .os-form-page .mat-form-field, +:host ::ng-deep .os-form-page .mat-input-element, +:host ::ng-deep .os-form-page .mat-select-value, +:host ::ng-deep .os-form-page .mat-select-value-text { + color: #101828 !important; +} + +:host ::ng-deep .os-form-page .mat-form-field-label, +:host ::ng-deep .os-form-page .mat-select-arrow, +:host ::ng-deep .os-form-page .mat-hint { + color: #667085 !important; +} + +:host ::ng-deep .os-form-page .mat-form-field-outline { + color: #d0d5dd !important; +} + +:host ::ng-deep .os-form-page .mat-form-field-outline-thick { + color: #1769aa !important; +} + +:host ::ng-deep .os-form-page .mat-form-field-flex { + background: transparent !important; +} + +:host ::ng-deep .os-form-page .mat-tab-label { + color: #344054 !important; + opacity: 1 !important; +} + +:host ::ng-deep .os-form-page .mat-tab-label-active { + color: #1769aa !important; +} + +:host ::ng-deep .os-form-page .mat-tab-header { + background: #f8fafc !important; +} + +@media (max-width: 1350px) { + .form-grid-4 { grid-template-columns: repeat(2, minmax(180px, 1fr)); } + .span-3, + .span-4 { grid-column: span 2; } + .material-row { grid-template-columns: repeat(3, minmax(160px, 1fr)); } + .financial-grid { grid-template-columns: repeat(4, minmax(150px, 1fr)); } + .activity-meta-grid { grid-template-columns: repeat(2, minmax(180px, 1fr)); } +} + +@media (max-width: 760px) { + .os-form-page { padding: 14px; } + + .page-header, + .header-main, + .header-actions, + .section-heading, + .tab-toolbar, + .activity-header, + .subsection-heading { + flex-direction: column; + } + + .header-actions { + align-items: stretch; + width: 100%; + + button { width: 100%; } + } + + .form-grid-4, + .material-row, + .financial-grid, + .activity-meta-grid, + .odometer-grid { + grid-template-columns: 1fr; + } + + .span-2, + .span-3, + .span-4 { + grid-column: span 1; + } +} + +.billing-notice { + grid-column: span 4; + padding: 12px 14px; + color: #175cd3; + background: #eff8ff; + border: 1px solid #b2ddff; + border-radius: 8px; + font-size: 13px; +} \ No newline at end of file diff --git a/src/app/oriontard/orion-ordem-servico-form/orion-ordem-servico-form.component.spec.ts b/src/app/oriontard/orion-ordem-servico-form/orion-ordem-servico-form.component.spec.ts new file mode 100644 index 0000000..9324078 --- /dev/null +++ b/src/app/oriontard/orion-ordem-servico-form/orion-ordem-servico-form.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { OrionOrdemServicoFormComponent } from './orion-ordem-servico-form.component'; + +describe('OrionOrdemServicoFormComponent', () => { + let component: OrionOrdemServicoFormComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ OrionOrdemServicoFormComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(OrionOrdemServicoFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/oriontard/orion-ordem-servico-form/orion-ordem-servico-form.component.ts b/src/app/oriontard/orion-ordem-servico-form/orion-ordem-servico-form.component.ts new file mode 100644 index 0000000..80befa2 --- /dev/null +++ b/src/app/oriontard/orion-ordem-servico-form/orion-ordem-servico-form.component.ts @@ -0,0 +1,911 @@ +import { Component, OnInit } from '@angular/core'; +import { + AbstractControl, + FormArray, + FormBuilder, + FormGroup, + Validators +} from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { + ClienteManutencaoModel, + EquipamentoManutencaoModel, + MaterialManutencaoModel, + OrdemServicoAtividadeMaterialModel, + OrdemServicoAtividadeModel, + OrdemServicoAtividadeSalvarModel, + OrdemServicoAtividadeTecnicoModel, + OrdemServicoDetalhesModel, + OrdemServicoOdometroModel, + OrdemServicoPagamentoModel, + OrdemServicoSalvarModel, + OrdemServicoStatus, + OrdemServicoStatusPagamento, + OrdemServicoTipo, + TecnicoManutencaoModel +} from '../../models/orionModel'; + +import { OrionService } from '../../services/orionService'; + +@Component({ + selector: 'app-orion-ordem-servico-form', + templateUrl: './orion-ordem-servico-form.component.html', + styleUrls: ['./orion-ordem-servico-form.component.scss'] +}) +export class OrionOrdemServicoFormComponent implements OnInit { + + modoEdicao = false; + ordemServicoId: number = null; + ordem: OrdemServicoDetalhesModel = null; + + carregando = true; + salvando = false; + salvandoAtividade = false; + salvandoPagamento = false; + salvandoOdometro = false; + + erro = ''; + sucesso = ''; + + formOrdem: FormGroup; + formOdometroEntrada: FormGroup; + formAtividade: FormGroup; + formPagamento: FormGroup; + formOdometro: FormGroup; + + clientes: ClienteManutencaoModel[] = []; + equipamentos: EquipamentoManutencaoModel[] = []; + tecnicos: TecnicoManutencaoModel[] = []; + materiais: MaterialManutencaoModel[] = []; + + exibirFormAtividade = false; + atividadeEditandoId: number = null; + exibirFormPagamento = false; + exibirFormOdometro = false; + + statusOpcoes = [ + { 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' } + ]; + + tipoOpcoes = [ + { 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' } + ]; + + prioridadeOpcoes = [ + { valor: 'BAIXA', texto: 'Baixa' }, + { valor: 'NORMAL', texto: 'Normal' }, + { valor: 'ALTA', texto: 'Alta' }, + { valor: 'URGENTE', texto: 'Urgente' } + ]; + + formaPagamentoOpcoes = [ + { valor: 'DINHEIRO', texto: 'Dinheiro' }, + { valor: 'PIX', texto: 'PIX' }, + { valor: 'TRANSFERENCIA', texto: 'Transferência' }, + { valor: 'BOLETO', texto: 'Boleto' }, + { valor: 'CARTAO', texto: 'Cartão' }, + { valor: 'OUTRO', texto: 'Outro' } + ]; + + tipoLeituraOpcoes = [ + { valor: 'SAIDA_MANUTENCAO', texto: 'Saída da manutenção' }, + { valor: 'ATUALIZACAO_MANUAL', texto: 'Atualização manual' }, + { valor: 'TELEMETRIA', texto: 'Telemetria' } + ]; + + baixandoPdf: boolean = false; + + constructor( + private formBuilder: FormBuilder, + private route: ActivatedRoute, + private router: Router, + private orionService: OrionService + ) { + this.criarFormularios(); + } + + ngOnInit() { + var idParam = this.route.snapshot.paramMap.get('id'); + var id = Number(idParam); + + this.modoEdicao = !!idParam && !isNaN(id) && id > 0; + this.ordemServicoId = this.modoEdicao ? id : null; + + this.carregarDadosIniciais(); + } + + tipoOrdemAlterado(tipo: OrdemServicoTipo) { + var statusPagamentoAtual = + this.formOrdem.get('status_pagamento').value; + + if (tipo === 'CORTESIA') { + this.formOrdem.patchValue({ + status_pagamento: 'CORTESIA' + }); + + this.formAtividade.patchValue({ + cobravel: false + }); + + return; + } + + if (tipo === 'GARANTIA') { + this.formOrdem.patchValue({ + status_pagamento: 'NAO_APLICAVEL' + }); + + this.formAtividade.patchValue({ + cobravel: false + }); + + return; + } + + if ( + statusPagamentoAtual === 'CORTESIA' || + statusPagamentoAtual === 'NAO_APLICAVEL' + ) { + this.formOrdem.patchValue({ + status_pagamento: 'PENDENTE' + }); + } + } + + private criarFormularios() { + this.formOrdem = this.formBuilder.group({ + cliente_id: [null], + equipamento_id: [null, Validators.required], + tecnico_responsavel_id: [null], + tipo: ['CORRETIVA', Validators.required], + status: ['ABERTA', Validators.required], + status_pagamento: ['PENDENTE'], + prioridade: ['NORMAL', Validators.required], + data_entrada: [this.formatarDataHoraLocal(new Date()), Validators.required], + data_previsao: [''], + data_finalizacao: [''], + data_retirada: [''], + problema_relatado: ['', Validators.required], + diagnostico: [''], + solucao_resumo: [''], + responsavel_entrega: [''], + responsavel_retirada: [''], + observacoes_internas: [''], + observacoes_cliente: [''], + garantia_dias: [null], + garantia_ate: [''], + valor_desconto: [0], + valor_acrescimo: [0], + justificativa_ajuste_valor: [''], + observacao_status: [''] + }); + + this.formOdometroEntrada = this.formBuilder.group({ + odometro_total_segundos: [0, [Validators.required, Validators.min(0)]], + odometro_conectado_segundos: [0, [Validators.required, Validators.min(0)]], + odometro_movimento_segundos: [0, [Validators.required, Validators.min(0)]], + odometro_parcial_segundos: [0, [Validators.required, Validators.min(0)]], + leitura_em: [this.formatarDataHoraLocal(new Date())], + observacoes: [''] + }); + + this.formAtividade = this.formBuilder.group({ + data_atividade: [this.formatarData(new Date()), Validators.required], + descricao: ['', Validators.required], + ordem_exibicao: [0], + horas_expediente: [0, [Validators.min(0)]], + horas_extras: [0, [Validators.min(0)]], + valor_hora_expediente: [60, [Validators.min(0)]], + valor_hora_extra: [90, [Validators.min(0)]], + cobravel: [true], + observacoes_internas: [''], + observacoes_cliente: [''], + tecnico_ids: [[]], + materiais: this.formBuilder.array([]) + }); + + this.formPagamento = this.formBuilder.group({ + data_pagamento: [this.formatarDataHoraLocal(new Date()), Validators.required], + valor: [0, [Validators.required, Validators.min(0.01)]], + forma_pagamento: ['PIX', Validators.required], + referencia: [''], + observacoes: [''] + }); + + this.formOdometro = this.formBuilder.group({ + tipo_leitura: ['SAIDA_MANUTENCAO', Validators.required], + leitura_em: [this.formatarDataHoraLocal(new Date()), Validators.required], + odometro_total_segundos: [0, [Validators.required, Validators.min(0)]], + odometro_conectado_segundos: [0, [Validators.required, Validators.min(0)]], + odometro_movimento_segundos: [0, [Validators.required, Validators.min(0)]], + odometro_parcial_segundos: [0, [Validators.required, Validators.min(0)]], + observacoes: [''] + }); + } + + private carregarDadosIniciais() { + this.carregando = true; + this.erro = ''; + + Promise.all([ + this.orionService.getClientesManutencao(), + this.orionService.getEquipamentosManutencao(), + this.orionService.getTecnicosManutencao(), + this.orionService.getMateriaisManutencao() + ]) + .then(resultados => { + this.clientes = resultados[0] || []; + this.equipamentos = resultados[1] || []; + this.tecnicos = resultados[2] || []; + this.materiais = resultados[3] || []; + + if (this.modoEdicao) { + return this.carregarOrdem(); + } + + return null; + }) + .catch(error => { + console.error('[OS FORM] Erro ao carregar dados:', error); + this.erro = this.obterMensagemErro(error, 'Não foi possível carregar os dados necessários.'); + }) + .then(() => { + this.carregando = false; + }); + } + + private carregarOrdem(): Promise { + return this.orionService.getOrdemServico(this.ordemServicoId) + .then(ordem => { + this.ordem = ordem; + this.preencherFormularioOrdem(ordem); + this.preencherNovoOdometroComUltimaLeitura(); + }); + } + + private preencherFormularioOrdem(ordem: OrdemServicoDetalhesModel) { + this.formOrdem.patchValue({ + cliente_id: ordem.cliente_id || null, + equipamento_id: ordem.equipamento_id || null, + tecnico_responsavel_id: ordem.tecnico_responsavel_id || null, + tipo: ordem.tipo || 'CORRETIVA', + status: ordem.status || 'ABERTA', + status_pagamento: ordem.status_pagamento || 'PENDENTE', + prioridade: ordem.prioridade || 'NORMAL', + data_entrada: this.converterDataApiParaLocal(ordem.data_entrada), + data_previsao: this.converterDataApiParaLocal(ordem.data_previsao), + data_finalizacao: this.converterDataApiParaLocal(ordem.data_finalizacao), + data_retirada: this.converterDataApiParaLocal(ordem.data_retirada), + problema_relatado: ordem.problema_relatado || '', + diagnostico: ordem.diagnostico || '', + solucao_resumo: ordem.solucao_resumo || '', + responsavel_entrega: ordem.responsavel_entrega || '', + responsavel_retirada: ordem.responsavel_retirada || '', + observacoes_internas: ordem.observacoes_internas || '', + observacoes_cliente: ordem.observacoes_cliente || '', + garantia_dias: ordem.garantia_dias || null, + garantia_ate: ordem.garantia_ate ? String(ordem.garantia_ate).substring(0, 10) : '', + valor_desconto: this.numero(ordem.valor_desconto), + valor_acrescimo: this.numero(ordem.valor_acrescimo), + justificativa_ajuste_valor: ordem.justificativa_ajuste_valor || '', + observacao_status: '' + }); + } + + equipamentoSelecionado(equipamentoId: number) { + if (!equipamentoId || this.modoEdicao) { + return; + } + + var equipamento = this.buscarEquipamento(equipamentoId); + if (!equipamento) { + return; + } + + this.formOrdem.patchValue({ cliente_id: equipamento.cliente_id || null }); + this.formOdometroEntrada.patchValue({ + 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) + }); + } + + salvarOrdem() { + this.limparMensagens(); + + if (this.formOrdem.invalid) { + this.marcarFormularioComoTocado(this.formOrdem); + this.erro = 'Preencha os campos obrigatórios da ordem de serviço.'; + return; + } + + if (!this.modoEdicao && this.formOdometroEntrada.invalid) { + this.marcarFormularioComoTocado(this.formOdometroEntrada); + this.erro = 'Confira os odômetros de entrada.'; + return; + } + + this.salvando = true; + var payload = this.montarPayloadOrdem(); + var operacao = this.modoEdicao + ? this.orionService.updateOrdemServico(this.ordemServicoId, payload) + : this.orionService.insertOrdemServico(payload); + + operacao + .then(resultado => { + if (!this.modoEdicao) { + var novoId = resultado.id || resultado.ordem_servico_id; + this.router.navigate(['/orion-ordens-servico', novoId, 'editar']); + return; + } + + this.sucesso = 'Ordem de serviço atualizada com sucesso.'; + return this.recarregarOrdem(); + }) + .catch(error => { + console.error('[OS FORM] Erro ao salvar OS:', error); + this.erro = this.obterMensagemErro(error, 'Não foi possível salvar a ordem de serviço.'); + }) + .then(() => { + this.salvando = false; + }); + } + + private montarPayloadOrdem(): OrdemServicoSalvarModel { + var valor = this.formOrdem.getRawValue(); + var payload: OrdemServicoSalvarModel = { + cliente_id: this.numeroOuNull(valor.cliente_id), + equipamento_id: this.numeroOuNull(valor.equipamento_id), + tecnico_responsavel_id: this.numeroOuNull(valor.tecnico_responsavel_id), + tipo: valor.tipo, + status: valor.status, + status_pagamento: valor.status_pagamento, + prioridade: valor.prioridade, + data_entrada: this.normalizarDataHoraApi(valor.data_entrada), + data_previsao: this.normalizarDataHoraApi(valor.data_previsao), + data_finalizacao: this.normalizarDataHoraApi(valor.data_finalizacao), + data_retirada: this.normalizarDataHoraApi(valor.data_retirada), + problema_relatado: this.textoOuNull(valor.problema_relatado), + diagnostico: this.textoOuNull(valor.diagnostico), + solucao_resumo: this.textoOuNull(valor.solucao_resumo), + responsavel_entrega: this.textoOuNull(valor.responsavel_entrega), + responsavel_retirada: this.textoOuNull(valor.responsavel_retirada), + observacoes_internas: this.textoOuNull(valor.observacoes_internas), + observacoes_cliente: this.textoOuNull(valor.observacoes_cliente), + garantia_dias: this.numeroOuNull(valor.garantia_dias), + garantia_ate: valor.garantia_ate || null, + valor_desconto: this.numero(valor.valor_desconto), + valor_acrescimo: this.numero(valor.valor_acrescimo), + justificativa_ajuste_valor: this.textoOuNull(valor.justificativa_ajuste_valor), + observacao_status: this.textoOuNull(valor.observacao_status) + }; + + if (!this.modoEdicao) { + var odometro = this.formOdometroEntrada.getRawValue(); + payload.odometro_entrada = { + tipo_leitura: 'ENTRADA_MANUTENCAO', + leitura_em: this.normalizarDataHoraApi(odometro.leitura_em), + odometro_total_segundos: this.numero(odometro.odometro_total_segundos), + odometro_conectado_segundos: this.numero(odometro.odometro_conectado_segundos), + odometro_movimento_segundos: this.numero(odometro.odometro_movimento_segundos), + odometro_parcial_segundos: this.numero(odometro.odometro_parcial_segundos), + observacoes: this.textoOuNull(odometro.observacoes) + }; + } + + return payload; + } + + abrirNovaAtividade() { + this.atividadeEditandoId = null; + this.exibirFormAtividade = true; + this.resetarFormAtividade(); + } + + editarAtividade(atividade: OrdemServicoAtividadeModel) { + this.atividadeEditandoId = atividade.id; + this.exibirFormAtividade = true; + + this.formAtividade.patchValue({ + data_atividade: atividade.data_atividade ? String(atividade.data_atividade).substring(0, 10) : this.formatarData(new Date()), + descricao: atividade.descricao || '', + 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), + cobravel: !!atividade.cobravel, + observacoes_internas: atividade.observacoes_internas || '', + observacoes_cliente: atividade.observacoes_cliente || '', + tecnico_ids: (atividade.tecnicos || []).map(tecnico => tecnico.tecnico_id) + }); + + this.limparMateriaisAtividade(); + (atividade.materiais || []).forEach(material => this.adicionarMaterialAtividade(material)); + } + + cancelarEdicaoAtividade() { + this.exibirFormAtividade = false; + this.atividadeEditandoId = null; + this.resetarFormAtividade(); + } + + salvarAtividade() { + this.limparMensagens(); + + if (this.formAtividade.invalid) { + this.marcarFormularioComoTocado(this.formAtividade); + this.erro = 'Preencha a data e a descrição da atividade.'; + return; + } + + this.salvandoAtividade = true; + var payload = this.montarPayloadAtividade(); + var operacao = this.atividadeEditandoId + ? this.orionService.updateAtividadeOrdemServico(this.atividadeEditandoId, payload) + : this.orionService.insertAtividadeOrdemServico(this.ordemServicoId, payload); + + operacao + .then(() => { + this.sucesso = this.atividadeEditandoId + ? 'Atividade atualizada com sucesso.' + : 'Atividade adicionada com sucesso.'; + this.cancelarEdicaoAtividade(); + return this.recarregarOrdem(); + }) + .catch(error => { + this.erro = this.obterMensagemErro(error, 'Não foi possível salvar a atividade.'); + }) + .then(() => { + this.salvandoAtividade = false; + }); + } + + excluirAtividade(atividade: OrdemServicoAtividadeModel) { + if (!window.confirm('Deseja realmente excluir esta atividade?')) { + return; + } + + this.orionService.deleteAtividadeOrdemServico(atividade.id) + .then(() => { + this.sucesso = 'Atividade excluída com sucesso.'; + return this.recarregarOrdem(); + }) + .catch(error => { + this.erro = this.obterMensagemErro(error, 'Não foi possível excluir a atividade.'); + }); + } + + private montarPayloadAtividade(): OrdemServicoAtividadeSalvarModel { + var valor = this.formAtividade.getRawValue(); + var tecnicosAtividade: OrdemServicoAtividadeTecnicoModel[] = []; + var materiaisAtividade: OrdemServicoAtividadeMaterialModel[] = []; + + (valor.tecnico_ids || []).forEach(tecnicoId => { + tecnicosAtividade.push({ tecnico_id: this.numero(tecnicoId), papel: 'EXECUTOR' }); + }); + + (valor.materiais || []).forEach(material => { + materiaisAtividade.push({ + material_id: this.numeroOuNull(material.material_id), + descricao_snapshot: material.descricao_snapshot || 'Material não informado', + unidade_snapshot: material.unidade_snapshot || 'UN', + quantidade: this.numero(material.quantidade), + valor_custo_unitario: this.numero(material.valor_custo_unitario), + valor_cobrado_unitario: this.numero(material.valor_cobrado_unitario), + cobravel: !!material.cobravel, + observacoes: this.textoOuNull(material.observacoes) + }); + }); + + return { + data_atividade: valor.data_atividade, + descricao: valor.descricao, + ordem_exibicao: this.numero(valor.ordem_exibicao), + horas_expediente: this.numero(valor.horas_expediente), + horas_extras: this.numero(valor.horas_extras), + valor_hora_expediente: this.numero(valor.valor_hora_expediente), + valor_hora_extra: this.numero(valor.valor_hora_extra), + cobravel: !!valor.cobravel, + observacoes_internas: this.textoOuNull(valor.observacoes_internas), + observacoes_cliente: this.textoOuNull(valor.observacoes_cliente), + tecnicos: tecnicosAtividade, + materiais: materiaisAtividade + }; + } + + get materiaisAtividade(): FormArray { + return this.formAtividade.get('materiais') as FormArray; + } + + adicionarMaterialAtividade(materialExistente?: OrdemServicoAtividadeMaterialModel) { + this.materiaisAtividade.push(this.formBuilder.group({ + material_id: [materialExistente ? materialExistente.material_id || null : null], + descricao_snapshot: [materialExistente ? materialExistente.descricao_snapshot || '' : '', Validators.required], + unidade_snapshot: [materialExistente ? materialExistente.unidade_snapshot || 'UN' : 'UN'], + quantidade: [materialExistente ? this.numero(materialExistente.quantidade) : 1, [Validators.required, Validators.min(0.001)]], + valor_custo_unitario: [materialExistente ? this.numero(materialExistente.valor_custo_unitario) : 0, [Validators.min(0)]], + valor_cobrado_unitario: [materialExistente ? this.numero(materialExistente.valor_cobrado_unitario) : 0, [Validators.min(0)]], + cobravel: [materialExistente ? !!materialExistente.cobravel : true], + observacoes: [materialExistente ? materialExistente.observacoes || '' : ''] + })); + } + + removerMaterialAtividade(index: number) { + this.materiaisAtividade.removeAt(index); + } + + materialCatalogoSelecionado(index: number, materialId: number) { + var material = this.buscarMaterial(materialId); + var grupo = this.materiaisAtividade.at(index) as FormGroup; + if (!material || !grupo) { + return; + } + + grupo.patchValue({ + descricao_snapshot: material.descricao, + unidade_snapshot: material.unidade || 'UN', + valor_custo_unitario: this.numero(material.custo_padrao), + valor_cobrado_unitario: this.numero(material.preco_venda_padrao) + }); + } + + registrarPagamento() { + this.limparMensagens(); + if (this.formPagamento.invalid) { + this.marcarFormularioComoTocado(this.formPagamento); + this.erro = 'Informe a data, o valor e a forma de pagamento.'; + return; + } + + this.salvandoPagamento = true; + var valor = this.formPagamento.getRawValue(); + var payload: OrdemServicoPagamentoModel = { + data_pagamento: this.normalizarDataHoraApi(valor.data_pagamento), + valor: this.numero(valor.valor), + forma_pagamento: valor.forma_pagamento, + referencia: this.textoOuNull(valor.referencia), + observacoes: this.textoOuNull(valor.observacoes) + }; + + this.orionService.insertPagamentoOrdemServico(this.ordemServicoId, payload) + .then(() => { + this.sucesso = 'Pagamento registrado com sucesso.'; + this.exibirFormPagamento = false; + this.resetarFormPagamento(); + return this.recarregarOrdem(); + }) + .catch(error => { + this.erro = this.obterMensagemErro(error, 'Não foi possível registrar o pagamento.'); + }) + .then(() => { + this.salvandoPagamento = false; + }); + } + + cancelarPagamento(pagamento: OrdemServicoPagamentoModel) { + if (!pagamento.id || !window.confirm('Deseja realmente cancelar este pagamento?')) { + return; + } + + this.orionService.cancelPagamentoOrdemServico(pagamento.id) + .then(() => this.recarregarOrdem()) + .catch(error => { + this.erro = this.obterMensagemErro(error, 'Não foi possível cancelar o pagamento.'); + }); + } + + registrarOdometro() { + this.limparMensagens(); + if (this.formOdometro.invalid) { + this.marcarFormularioComoTocado(this.formOdometro); + this.erro = 'Confira os campos da leitura de odômetro.'; + return; + } + + this.salvandoOdometro = true; + var valor = this.formOdometro.getRawValue(); + var payload: OrdemServicoOdometroModel = { + tipo_leitura: valor.tipo_leitura, + leitura_em: this.normalizarDataHoraApi(valor.leitura_em), + odometro_total_segundos: this.numero(valor.odometro_total_segundos), + odometro_conectado_segundos: this.numero(valor.odometro_conectado_segundos), + odometro_movimento_segundos: this.numero(valor.odometro_movimento_segundos), + odometro_parcial_segundos: this.numero(valor.odometro_parcial_segundos), + observacoes: this.textoOuNull(valor.observacoes) + }; + + this.orionService.insertOdometroOrdemServico(this.ordemServicoId, payload) + .then(() => { + this.sucesso = 'Leitura registrada com sucesso.'; + this.exibirFormOdometro = false; + return this.recarregarOrdem(); + }) + .catch(error => { + this.erro = this.obterMensagemErro(error, 'Não foi possível registrar a leitura.'); + }) + .then(() => { + this.salvandoOdometro = false; + }); + } + + get atividadesOrdenadas(): OrdemServicoAtividadeModel[] { + if (!this.ordem || !this.ordem.atividades) { + return []; + } + + return this.ordem.atividades.slice().sort((a, b) => { + var dataA = new Date(a.data_atividade).getTime(); + var dataB = new Date(b.data_atividade).getTime(); + return dataA === dataB + ? this.numero(a.ordem_exibicao) - this.numero(b.ordem_exibicao) + : dataB - dataA; + }); + } + + totalHorasAtividade(atividade: OrdemServicoAtividadeModel): number { + return this.numero(atividade.horas_expediente) + this.numero(atividade.horas_extras); + } + + valorMateriaisAtividade(atividade: OrdemServicoAtividadeModel): number { + return (atividade.materiais || []).reduce((total, material) => { + return total + this.numero(material.valor_cobrado_total); + }, 0); + } + + nomeTecnicosAtividade(atividade: OrdemServicoAtividadeModel): string { + var nomes = (atividade.tecnicos || []).map(tecnico => { + return tecnico.tecnico_nome || ('Técnico #' + tecnico.tecnico_id); + }); + return nomes.length > 0 ? nomes.join(', ') : 'Não informado'; + } + + get valorPendente(): number { + if (!this.ordem) { + return 0; + } + return Math.max(0, this.numero(this.ordem.valor_total) - this.numero(this.ordem.valor_pago)); + } + + labelStatus(status: OrdemServicoStatus): string { + return this.buscarTexto(this.statusOpcoes, status); + } + + labelTipo(tipo: OrdemServicoTipo): string { + return this.buscarTexto(this.tipoOpcoes, tipo); + } + + labelPagamento(status: OrdemServicoStatusPagamento): string { + return this.buscarTexto([ + { valor: 'NAO_APLICAVEL', texto: 'Não aplicável' }, + { valor: 'PENDENTE', texto: 'Pendente' }, + { valor: 'PARCIAL', texto: 'Parcial' }, + { valor: 'RECEBIDO', texto: 'Recebido' }, + { valor: 'CORTESIA', texto: 'Cortesia' } + ], status); + } + + classeStatus(status: OrdemServicoStatus): string { + if (status === 'RETIRADA' || status === 'FINALIZADA') return 'badge-sucesso'; + if (status === 'AGUARDANDO_RETIRADA' || status === 'AGUARDANDO_PECA' || status === 'AGUARDANDO_APROVACAO') return 'badge-alerta'; + if (status === 'CANCELADA') return 'badge-erro'; + if (status === 'RASCUNHO') return 'badge-neutro'; + return 'badge-info'; + } + + classePagamento(status: OrdemServicoStatusPagamento): string { + if (status === 'RECEBIDO') return 'badge-sucesso'; + if (status === 'PENDENTE') return 'badge-erro'; + if (status === 'PARCIAL') return 'badge-alerta'; + return 'badge-neutro'; + } + + segundosParaTempo(segundos: number): string { + var total = Math.max(0, Math.floor(this.numero(segundos))); + var horas = Math.floor(total / 3600); + var minutos = Math.floor((total % 3600) / 60); + var segundosRestantes = total % 60; + return this.preencherZero(horas) + ':' + this.preencherZero(minutos) + ':' + this.preencherZero(segundosRestantes); + } + + voltar() { + this.router.navigate(['/orion-ordens-servico']); + } + + private recarregarOrdem(): Promise { + return this.modoEdicao && this.ordemServicoId ? this.carregarOrdem() : Promise.resolve(); + } + + private preencherNovoOdometroComUltimaLeitura() { + if (!this.ordem || !this.ordem.odometros_da_os || this.ordem.odometros_da_os.length === 0) { + return; + } + + var leituras = this.ordem.odometros_da_os.slice().sort((a, b) => { + return new Date(b.leitura_em).getTime() - new Date(a.leitura_em).getTime(); + }); + var ultima = leituras[0]; + + this.formOdometro.patchValue({ + odometro_total_segundos: this.numero(ultima.odometro_total_segundos), + odometro_conectado_segundos: this.numero(ultima.odometro_conectado_segundos), + odometro_movimento_segundos: this.numero(ultima.odometro_movimento_segundos), + odometro_parcial_segundos: this.numero(ultima.odometro_parcial_segundos), + leitura_em: this.formatarDataHoraLocal(new Date()) + }); + } + + private resetarFormAtividade() { + this.formAtividade.reset({ + data_atividade: this.formatarData(new Date()), + descricao: '', + ordem_exibicao: 0, + horas_expediente: 0, + horas_extras: 0, + valor_hora_expediente: 60, + valor_hora_extra: 90, + cobravel: true, + observacoes_internas: '', + observacoes_cliente: '', + tecnico_ids: [] + }); + this.limparMateriaisAtividade(); + } + + private resetarFormPagamento() { + this.formPagamento.reset({ + data_pagamento: this.formatarDataHoraLocal(new Date()), + valor: 0, + forma_pagamento: 'PIX', + referencia: '', + observacoes: '' + }); + } + + private limparMateriaisAtividade() { + while (this.materiaisAtividade.length > 0) { + this.materiaisAtividade.removeAt(0); + } + } + + private buscarEquipamento(equipamentoId: number): EquipamentoManutencaoModel { + var id = this.numero(equipamentoId); + for (var i = 0; i < this.equipamentos.length; i++) { + if (this.numero(this.equipamentos[i].id) === id) return this.equipamentos[i]; + } + return null; + } + + private buscarMaterial(materialId: number): MaterialManutencaoModel { + var id = this.numero(materialId); + for (var i = 0; i < this.materiais.length; i++) { + if (this.numero(this.materiais[i].id) === id) return this.materiais[i]; + } + return null; + } + + private buscarTexto(opcoes: any[], valor: string): string { + for (var i = 0; i < opcoes.length; i++) { + if (opcoes[i].valor === valor) return opcoes[i].texto; + } + return valor || ''; + } + + private marcarFormularioComoTocado(controle: AbstractControl) { + if (controle instanceof FormGroup) { + Object.keys(controle.controls).forEach(chave => this.marcarFormularioComoTocado(controle.controls[chave])); + } else if (controle instanceof FormArray) { + controle.controls.forEach(item => this.marcarFormularioComoTocado(item)); + } else { + controle.markAsTouched(); + } + } + + private formatarData(data: Date): string { + return data.getFullYear() + '-' + this.preencherZero(data.getMonth() + 1) + '-' + this.preencherZero(data.getDate()); + } + + private formatarDataHoraLocal(data: Date): string { + return this.formatarData(data) + 'T' + this.preencherZero(data.getHours()) + ':' + this.preencherZero(data.getMinutes()); + } + + private converterDataApiParaLocal(valor: string): string { + if (!valor) return ''; + var texto = String(valor).replace(' ', 'T'); + return texto.length >= 16 ? texto.substring(0, 16) : texto; + } + + private normalizarDataHoraApi(valor: string): string { + if (!valor) return null; + var texto = String(valor).replace('T', ' '); + return texto.length === 16 ? texto + ':00' : texto; + } + + private textoOuNull(valor: any): string { + if (valor === null || valor === undefined) return null; + var texto = String(valor).trim(); + return texto ? texto : null; + } + + private numero(valor: any): number { + var convertido = Number(valor); + return isNaN(convertido) ? 0 : convertido; + } + + private numeroOuNull(valor: any): number { + if (valor === null || valor === undefined || valor === '') return null; + var convertido = Number(valor); + return isNaN(convertido) ? null : convertido; + } + + private preencherZero(valor: number): string { + return valor < 10 ? '0' + valor : String(valor); + } + + private limparMensagens() { + this.erro = ''; + this.sucesso = ''; + } + + private obterMensagemErro(error: any, fallback: string): string { + return error && error.message ? error.message : fallback; + } + + + baixarPdfCliente() { + if (!this.modoEdicao || !this.ordemServicoId || this.baixandoPdf) { + return; + } + + this.baixandoPdf = true; + this.erro = ''; + + this.orionService + .downloadOrdemServicoPdf(this.ordemServicoId, false) + .then(blob => { + var url = window.URL.createObjectURL(blob); + var link = document.createElement('a'); + + link.href = url; + link.download = 'OS_' + this.ordemServicoId + '.pdf'; + link.style.display = 'none'; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + window.setTimeout(() => { + window.URL.revokeObjectURL(url); + }, 1000); + }) + .catch(error => { + console.error('[OS FORM] Erro ao gerar PDF:', error); + this.erro = + 'Não foi possível gerar o PDF da ordem de serviço.'; + }) + .then(() => { + this.baixandoPdf = false; + }); + } + +} \ No newline at end of file diff --git a/src/app/oriontard/orion-ordens-servico/orion-ordens-servico.component.html b/src/app/oriontard/orion-ordens-servico/orion-ordens-servico.component.html new file mode 100644 index 0000000..34b6c2c --- /dev/null +++ b/src/app/oriontard/orion-ordens-servico/orion-ordens-servico.component.html @@ -0,0 +1,387 @@ +
+ + + +
+
+ + + Pesquisar + + + + + Status + + + {{ opcao.texto }} + + + + + + Pagamento + + + {{ opcao.texto }} + + + + + + Tipo + + + {{ opcao.texto }} + + + + + + Entrada inicial + + + + + + + Entrada final + + + + + +
+ +
+ + + +
+
+ +
+
+ OS encontradas + {{ totalRegistros }} + {{ dataSource.data.length }} nesta página +
+ +
+ Horas na página + {{ totalHorasPagina | number:'1.2-2' }} + Expediente + horas extras +
+ +
+ Valor na página + {{ valorTotalPagina | currency:'BRL':'symbol':'1.2-2' }} + Soma das OS exibidas +
+ +
+ Pagamentos abertos + {{ pendentesNaPagina }} + Pendentes ou parciais na página +
+
+ +
+ +
+
+ Carregando ordens de serviço... +
+ +
+ Não foi possível carregar as OS. +

{{ erro }}

+ +
+ +
+ Nenhuma ordem de serviço encontrada. +

Altere os filtros ou confira se a importação foi concluída.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OS + #{{ ordem.id }} + Equipamento +
+ {{ nomeEquipamento(ordem) }} + + NS {{ ordem.numero_serie_snapshot }} + +
+
Entrada +
+ {{ ordem.data_entrada | date:'dd/MM/yyyy' }} + + Finalizada em {{ ordem.data_finalizacao | date:'dd/MM/yyyy' }} + +
+
Problema relatado + + {{ ordem.problema_relatado || 'Sem descrição' }} + + Tipo + {{ labelTipo(ordem.tipo) }} + Status + + {{ labelStatus(ordem.status) }} + + Pagamento + + {{ labelPagamento(ordem.status_pagamento) }} + + Horas +
+ {{ totalHoras(ordem) | number:'1.2-2' }} + + {{ ordem.total_horas_expediente | number:'1.2-2' }} exp. + · + {{ ordem.total_horas_extras | number:'1.2-2' }} extra + +
+
Total +
+ + {{ ordem.valor_total | currency:'BRL':'symbol':'1.2-2' }} + + + Pendente: + {{ ordem.valor_pendente | currency:'BRL':'symbol':'1.2-2' }} + +
+
+
+ + + +
+
+
+ + + + +
+ +
+
+
+ VISUALIZAÇÃO RÁPIDA +

OS #{{ ordemSelecionada.id }}

+
+ + +
+ +
+
+ Equipamento + {{ nomeEquipamento(ordemSelecionada) }} + + NS {{ ordemSelecionada.numero_serie_snapshot }} + +
+ +
+ Entrada + {{ ordemSelecionada.data_entrada | date:'dd/MM/yyyy' }} + + {{ labelTipo(ordemSelecionada.tipo) }} + +
+ +
+ Horas + {{ totalHoras(ordemSelecionada) | number:'1.2-2' }} + + {{ ordemSelecionada.total_horas_extras | number:'1.2-2' }} + hora(s) extra(s) + +
+ +
+ Valor total + + {{ ordemSelecionada.valor_total | currency:'BRL':'symbol':'1.2-2' }} + + + Pago: + {{ ordemSelecionada.valor_pago | currency:'BRL':'symbol':'1.2-2' }} + +
+
+ +
+ Problema relatado +

{{ ordemSelecionada.problema_relatado || 'Sem descrição' }}

+
+
+ +
\ No newline at end of file diff --git a/src/app/oriontard/orion-ordens-servico/orion-ordens-servico.component.scss b/src/app/oriontard/orion-ordens-servico/orion-ordens-servico.component.scss new file mode 100644 index 0000000..d8f4d5e --- /dev/null +++ b/src/app/oriontard/orion-ordens-servico/orion-ordens-servico.component.scss @@ -0,0 +1,630 @@ +:host { + display: block; + min-height: 100%; + background: #f4f6f8; + color: #1f2933; +} + +.os-page { + padding: 24px; + max-width: 1680px; + margin: 0 auto; + box-sizing: border-box; +} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + margin-bottom: 20px; + + h1 { + margin: 4px 0 6px; + font-size: 30px; + font-weight: 700; + letter-spacing: -0.5px; + } + + p { + margin: 0; + color: #667085; + font-size: 14px; + } +} + +.eyebrow { + display: inline-block; + color: #1769aa; + font-size: 11px; + font-weight: 700; + letter-spacing: 1.2px; +} + +.header-actions, +.filters-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.filters-card, +.table-card, +.quick-view { + background: #ffffff; + border: 1px solid #e4e7ec; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(16, 24, 40, 0.04); +} + +.filters-card { + padding: 18px; + margin-bottom: 16px; +} + +.filters-grid { + display: grid; + grid-template-columns: + minmax(260px, 2fr) + repeat(3, minmax(150px, 1fr)) + repeat(2, minmax(150px, 1fr)); + gap: 12px; + align-items: start; +} + +.filters-grid .mat-form-field { + width: 100%; +} + +.filters-actions { + justify-content: flex-end; + margin-top: 4px; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(180px, 1fr)); + gap: 14px; + margin-bottom: 16px; +} + +.summary-card { + background: #ffffff; + border: 1px solid #e4e7ec; + border-radius: 12px; + padding: 16px 18px; + box-shadow: 0 2px 8px rgba(16, 24, 40, 0.03); + + strong { + display: block; + margin-top: 6px; + font-size: 24px; + line-height: 1.2; + color: #101828; + } + + small { + display: block; + margin-top: 5px; + color: #98a2b3; + font-size: 12px; + } +} + +.summary-label { + color: #667085; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.table-card { + overflow: hidden; +} + +.table-scroll { + width: 100%; + overflow-x: auto; +} + +.os-table { + width: 100%; + min-width: 1380px; + border-collapse: collapse; + + th.mat-header-cell { + background: #f8fafc; + color: #475467; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.45px; + white-space: nowrap; + } + + td.mat-cell, + th.mat-header-cell { + padding: 12px 14px; + border-bottom-color: #edf0f3; + } + + tr.mat-row { + transition: background 120ms ease; + } + + tr.mat-row:hover { + background: #f8fbff; + } + + tr.selected-row { + background: #eef6ff; + } +} + +.os-number { + color: #1769aa; + font-weight: 700; +} + +.equipment-cell, +.date-cell, +.hours-cell, +.money-cell { + display: flex; + flex-direction: column; + gap: 3px; + + strong { + color: #344054; + font-size: 13px; + } + + span { + color: #98a2b3; + font-size: 11px; + } +} + +.problem-text { + display: block; + max-width: 340px; + color: #475467; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status-badge { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 2px 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + 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; +} + +.loading-state, +.error-state, +.empty-state { + min-height: 220px; + padding: 36px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + + strong { + color: #344054; + font-size: 16px; + } + + p, + span { + color: #667085; + font-size: 13px; + } +} + +.loading-bar { + width: 160px; + height: 4px; + margin-bottom: 16px; + border-radius: 999px; + overflow: hidden; + background: + linear-gradient( + 90deg, + #d0d5dd 0%, + #1769aa 45%, + #d0d5dd 100% + ); + background-size: 200% 100%; + animation: loading 1.2s linear infinite; +} + +@keyframes loading { + from { + background-position: 200% 0; + } + + to { + background-position: -200% 0; + } +} + +.quick-view { + margin-top: 16px; + padding: 20px; +} + +.quick-view-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + + h2 { + margin: 4px 0 0; + font-size: 22px; + } +} + +.quick-view-grid { + display: grid; + grid-template-columns: repeat(4, minmax(160px, 1fr)); + gap: 14px; + margin-top: 18px; + + > div { + padding: 14px; + background: #f8fafc; + border-radius: 9px; + } + + span, + small { + display: block; + color: #667085; + font-size: 11px; + } + + strong { + display: block; + margin: 5px 0; + color: #344054; + font-size: 14px; + } +} + +.quick-view-problem { + margin-top: 14px; + padding: 14px; + border: 1px solid #eaecf0; + border-radius: 9px; + + span { + color: #667085; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + } + + p { + margin: 7px 0 0; + color: #344054; + line-height: 1.55; + } +} + +mat-paginator { + border-top: 1px solid #edf0f3; +} + +@media (max-width: 1250px) { + .filters-grid { + grid-template-columns: repeat(3, minmax(180px, 1fr)); + } + + .summary-grid, + .quick-view-grid { + grid-template-columns: repeat(2, minmax(180px, 1fr)); + } +} + +@media (max-width: 760px) { + .os-page { + padding: 14px; + } + + .page-header { + flex-direction: column; + } + + .header-actions { + width: 100%; + + button { + flex: 1; + } + } + + .filters-grid, + .summary-grid, + .quick-view-grid { + grid-template-columns: 1fr; + } + + .filters-actions { + button { + flex: 1; + } + } +} + +// ============================================================ +// ISOLAMENTO CONTRA ESTILOS GLOBAIS E TEMA ESCURO DO MATERIAL +// ============================================================ + +:host { + display: block; + min-height: 100vh; + background: #f4f6f8; + color: #1f2933; +} + +/* ------------------------------------------------------------ + CAMPOS DOS FILTROS +------------------------------------------------------------ */ + +: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; +} + +: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 .mat-datepicker-toggle { + color: #667085 !important; +} + +:host ::ng-deep .filters-card input::placeholder { + color: #98a2b3 !important; + opacity: 1; +} + +/* Remove qualquer fundo escuro herdado */ +:host ::ng-deep .filters-card .mat-form-field-flex { + background: transparent !important; +} + +/* ------------------------------------------------------------ + TABELA +------------------------------------------------------------ */ + +:host ::ng-deep .os-table, +:host ::ng-deep table.os-table { + background: #ffffff !important; + color: #344054 !important; +} + +:host ::ng-deep .os-table tr.mat-header-row { + background: #f8fafc !important; +} + +:host ::ng-deep .os-table tr.mat-row { + background: #ffffff !important; + color: #344054 !important; +} + +/* + * Sobrescreve o tr:nth-child(even) global. + */ +:host ::ng-deep .os-table tr.mat-row:nth-child(even) { + background: #fbfcfd !important; +} + +:host ::ng-deep .os-table tr.mat-row:hover { + background: #f2f7fc !important; +} + +:host ::ng-deep .os-table tr.mat-row.selected-row { + background: #eaf4ff !important; +} + +:host ::ng-deep .os-table th.mat-header-cell { + background: #f8fafc !important; + color: #475467 !important; + border-color: #eaecf0 !important; +} + +:host ::ng-deep .os-table td.mat-cell { + background: transparent !important; + color: #344054 !important; + border-color: #eaecf0 !important; +} + +:host ::ng-deep .os-table td, +:host ::ng-deep .os-table th { + color: #344054 !important; + border-left: 0 !important; + border-right: 0 !important; +} + +/* Textos internos das células */ +:host ::ng-deep .os-table .equipment-cell strong, +:host ::ng-deep .os-table .date-cell strong, +:host ::ng-deep .os-table .hours-cell strong, +:host ::ng-deep .os-table .money-cell strong { + color: #344054 !important; +} + +:host ::ng-deep .os-table .equipment-cell span, +:host ::ng-deep .os-table .date-cell span, +:host ::ng-deep .os-table .hours-cell span, +:host ::ng-deep .os-table .money-cell span { + color: #667085 !important; +} + +:host ::ng-deep .os-table .problem-text { + color: #475467 !important; +} + +:host ::ng-deep .os-table .os-number { + color: #1769aa !important; +} + +/* ------------------------------------------------------------ + BOTÕES +------------------------------------------------------------ */ + +:host ::ng-deep .header-actions .mat-stroked-button, +:host ::ng-deep .filters-actions .mat-button, +:host ::ng-deep .os-table .mat-stroked-button, +:host ::ng-deep .quick-view .mat-button { + color: #344054 !important; +} + +:host ::ng-deep .header-actions .mat-stroked-button, +:host ::ng-deep .os-table .mat-stroked-button { + border-color: #d0d5dd !important; + background: #ffffff !important; +} + +:host ::ng-deep .header-actions .mat-raised-button[disabled] { + background: #e4e7ec !important; + color: #98a2b3 !important; + opacity: 1 !important; +} + +:host ::ng-deep .filters-actions .mat-raised-button { + color: #ffffff !important; +} + +/* ------------------------------------------------------------ + PAGINADOR +------------------------------------------------------------ */ + +: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 { + color: #667085 !important; +} + +:host ::ng-deep .mat-paginator .mat-icon-button { + color: #475467 !important; +} + +:host ::ng-deep .mat-paginator .mat-icon-button[disabled] { + color: #d0d5dd !important; +} + +/* ------------------------------------------------------------ + BADGES +------------------------------------------------------------ */ + +: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; +} + +.row-actions { + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} diff --git a/src/app/oriontard/orion-ordens-servico/orion-ordens-servico.component.spec.ts b/src/app/oriontard/orion-ordens-servico/orion-ordens-servico.component.spec.ts new file mode 100644 index 0000000..cbd3775 --- /dev/null +++ b/src/app/oriontard/orion-ordens-servico/orion-ordens-servico.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { OrionOrdensServicoComponent } from './orion-ordens-servico.component'; + +describe('OrionOrdensServicoComponent', () => { + let component: OrionOrdensServicoComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ OrionOrdensServicoComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(OrionOrdensServicoComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/oriontard/orion-ordens-servico/orion-ordens-servico.component.ts b/src/app/oriontard/orion-ordens-servico/orion-ordens-servico.component.ts new file mode 100644 index 0000000..a2e483e --- /dev/null +++ b/src/app/oriontard/orion-ordens-servico/orion-ordens-servico.component.ts @@ -0,0 +1,429 @@ +import { Component, OnInit } from '@angular/core'; +import { PageEvent } from '@angular/material/paginator'; +import { MatTableDataSource } from '@angular/material/table'; +import { Router } from '@angular/router'; + +import { + OrdemServicoFiltrosModel, + OrdemServicoListaModel, + OrdemServicoStatus, + OrdemServicoStatusPagamento, + OrdemServicoTipo +} from '../../models/orionModel'; + +import { OrionService } from '../../services/orionService'; + +@Component({ + selector: 'app-orion-ordens-servico', + templateUrl: './orion-ordens-servico.component.html', + styleUrls: ['./orion-ordens-servico.component.scss'] +}) +export class OrionOrdensServicoComponent implements OnInit { + + displayedColumns: string[] = [ + 'id', + 'equipamento', + 'data_entrada', + 'problema_relatado', + 'tipo', + 'status', + 'status_pagamento', + 'horas', + 'valor_total', + 'acoes' + ]; + + dataSource = new MatTableDataSource([]); + ordemSelecionada: OrdemServicoListaModel = null; + + carregando: boolean = false; + erro: string = ''; + + paginaAtual: number = 1; + tamanhoPagina: number = 25; + totalRegistros: number = 0; + totalPaginas: number = 0; + opcoesTamanhoPagina: number[] = [10, 25, 50, 100]; + + busca: string = ''; + statusSelecionado: OrdemServicoStatus | '' = ''; + pagamentoSelecionado: OrdemServicoStatusPagamento | '' = ''; + tipoSelecionado: OrdemServicoTipo | '' = ''; + dataInicio: Date = null; + dataFim: Date = null; + + 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: 'RETIRADA', texto: 'Retirada' }, + { valor: 'CANCELADA', texto: 'Cancelada' } + ]; + + pagamentoOpcoes = [ + { valor: '', texto: 'Todos os pagamentos' }, + { valor: 'NAO_APLICAVEL', texto: 'Não aplicável' }, + { valor: 'PENDENTE', texto: 'Pendente' }, + { valor: 'PARCIAL', texto: 'Parcial' }, + { valor: 'RECEBIDO', texto: 'Recebido' }, + { valor: 'CORTESIA', texto: 'Cortesia' } + ]; + + 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' } + ]; + + baixandoPdfId: number = null; + baixandoRelatorio: boolean = false; + + constructor( + private orionService: OrionService, + private router: Router + ) { } + + ngOnInit() { + this.carregarOrdens(); + } + + carregarOrdens() { + this.carregando = true; + this.erro = ''; + + var filtros: OrdemServicoFiltrosModel = { + page: this.paginaAtual, + pageSize: this.tamanhoPagina, + busca: this.normalizarTexto(this.busca), + status: this.statusSelecionado, + status_pagamento: this.pagamentoSelecionado, + tipo: this.tipoSelecionado, + data_inicio: this.formatarDataApi(this.dataInicio), + data_fim: this.formatarDataApi(this.dataFim) + }; + + this.orionService + .getOrdensServico(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.ordemSelecionada && + !this.dataSource.data.some( + ordem => ordem.id === this.ordemSelecionada.id + ) + ) { + this.ordemSelecionada = null; + } + }) + .catch(error => { + console.error('[OS] Erro ao carregar ordens:', error); + + this.dataSource.data = []; + this.totalRegistros = 0; + this.totalPaginas = 0; + + this.erro = error && error.message + ? error.message + : 'Não foi possível carregar as ordens de serviço.'; + }) + .then(() => { + this.carregando = false; + }); + } + + novaOrdem() { + this.router.navigate([ + '/orion-ordens-servico', + 'nova' + ]); + } + + abrirOrdem(ordem: OrdemServicoListaModel) { + this.router.navigate([ + '/orion-ordens-servico', + ordem.id, + 'editar' + ]); + } + + aplicarFiltros() { + this.paginaAtual = 1; + this.ordemSelecionada = null; + this.carregarOrdens(); + } + + limparFiltros() { + this.busca = ''; + this.statusSelecionado = ''; + this.pagamentoSelecionado = ''; + this.tipoSelecionado = ''; + this.dataInicio = null; + this.dataFim = null; + this.paginaAtual = 1; + this.ordemSelecionada = null; + + this.carregarOrdens(); + } + + atualizar() { + this.carregarOrdens(); + } + + onPageChange(event: PageEvent) { + this.paginaAtual = event.pageIndex + 1; + this.tamanhoPagina = event.pageSize; + this.ordemSelecionada = null; + + this.carregarOrdens(); + } + + selecionarOrdem(ordem: OrdemServicoListaModel) { + this.ordemSelecionada = ordem; + } + + trackById(index: number, ordem: OrdemServicoListaModel): number { + return ordem.id; + } + + get totalHorasPagina(): number { + return this.dataSource.data.reduce( + (total, ordem) => + total + + this.numero(ordem.total_horas_expediente) + + this.numero(ordem.total_horas_extras), + 0 + ); + } + + get valorTotalPagina(): number { + return this.dataSource.data.reduce( + (total, ordem) => total + this.numero(ordem.valor_total), + 0 + ); + } + + get pendentesNaPagina(): number { + return this.dataSource.data.filter( + ordem => + ordem.status_pagamento === 'PENDENTE' || + ordem.status_pagamento === 'PARCIAL' + ).length; + } + + totalHoras(ordem: OrdemServicoListaModel): number { + return ( + this.numero(ordem.total_horas_expediente) + + this.numero(ordem.total_horas_extras) + ); + } + + nomeEquipamento(ordem: OrdemServicoListaModel): string { + return ( + ordem.equipamento_nome_snapshot || + ordem.modelo_snapshot || + 'Equipamento não informado' + ); + } + + labelStatus(status: OrdemServicoStatus): string { + return this.buscarTextoOpcao(this.statusOpcoes, status); + } + + labelPagamento(status: OrdemServicoStatusPagamento): string { + return this.buscarTextoOpcao(this.pagamentoOpcoes, status); + } + + labelTipo(tipo: OrdemServicoTipo): string { + return this.buscarTextoOpcao(this.tipoOpcoes, 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': + return 'badge-alerta'; + + case 'CANCELADA': + return 'badge-erro'; + + case 'RASCUNHO': + return 'badge-neutro'; + + default: + return 'badge-info'; + } + } + + classePagamento(status: OrdemServicoStatusPagamento): string { + switch (status) { + case 'RECEBIDO': + return 'badge-sucesso'; + + case 'PARCIAL': + return 'badge-alerta'; + + case 'PENDENTE': + return 'badge-erro'; + + case 'CORTESIA': + return 'badge-info'; + + default: + return 'badge-neutro'; + } + } + + private buscarTextoOpcao(opcoes: any[], valor: string): string { + for (var i = 0; i < opcoes.length; i++) { + if (opcoes[i].valor === valor) { + return opcoes[i].texto; + } + } + + return valor; + } + + private normalizarTexto(valor: string): string { + return valor ? valor.trim() : ''; + } + + private formatarDataApi(data: Date): string { + if (!data) { + return ''; + } + + var ano = data.getFullYear(); + var mes = data.getMonth() + 1; + var dia = data.getDate(); + + return ( + ano + + '-' + + (mes < 10 ? '0' : '') + mes + + '-' + + (dia < 10 ? '0' : '') + dia + ); + } + + private numero(valor: any): number { + var convertido = Number(valor); + return isNaN(convertido) ? 0 : convertido; + } + + + baixarPdf(ordem: OrdemServicoListaModel) { + if (!ordem || this.baixandoPdfId !== null) { + return; + } + + this.baixandoPdfId = ordem.id; + + this.orionService + .downloadOrdemServicoPdf(ordem.id, false) + .then(blob => { + this.salvarBlob( + blob, + 'OS_' + + ordem.id + + '_' + + this.nomeEquipamento(ordem).replace(/[^a-zA-Z0-9]+/g, '_') + + '.pdf' + ); + }) + .catch(error => { + console.error('[OS] Erro ao baixar PDF:', error); + this.erro = + 'Não foi possível gerar o PDF da ordem de serviço.'; + }) + .then(() => { + this.baixandoPdfId = null; + }); + } + + baixarRelatorioPdf() { + if (this.baixandoRelatorio) { + return; + } + + this.baixandoRelatorio = true; + this.erro = ''; + + var filtros: any = { + busca: this.normalizarTexto(this.busca), + status: this.statusSelecionado, + status_pagamento: this.pagamentoSelecionado, + tipo: this.tipoSelecionado, + data_inicio: this.formatarDataApi(this.dataInicio), + data_fim: this.formatarDataApi(this.dataFim), + + // Para fechamento mensal, a data de finalização é o critério mais seguro. + criterio: 'data_finalizacao' + }; + + this.orionService + .downloadRelatorioOrdensServicoPdf(filtros) + .then(blob => { + var inicio = filtros.data_inicio || 'inicio'; + var fim = filtros.data_fim || 'fim'; + + this.salvarBlob( + blob, + 'Relatorio_OS_' + inicio + '_' + fim + '.pdf' + ); + }) + .catch(error => { + console.error('[OS] Erro ao baixar relatório:', error); + this.erro = + 'Não foi possível gerar o relatório de serviços.'; + }) + .then(() => { + this.baixandoRelatorio = false; + }); + } + + private salvarBlob(blob: Blob, nomeArquivo: string) { + var url = window.URL.createObjectURL(blob); + var link = document.createElement('a'); + + link.href = url; + link.download = nomeArquivo; + link.style.display = 'none'; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + window.setTimeout(() => { + window.URL.revokeObjectURL(url); + }, 1000); + } + +} \ No newline at end of file diff --git a/src/app/services/orionService.ts b/src/app/services/orionService.ts index 98f2f62..2bd5368 100644 --- a/src/app/services/orionService.ts +++ b/src/app/services/orionService.ts @@ -1,31 +1,489 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { environment } from 'src/environments/environment'; -import { GeoCoordenadasResponseModel, OrionLogsModel } from '../models/orionModel'; + +import { + ClienteManutencaoModel, + EquipamentoManutencaoModel, + GeoCoordenadasResponseModel, + MaterialManutencaoModel, + OrionApiResponseModel, + OrionLogInsertModel, + OrionLogsModel, + OrdemServicoAtividadeSalvarModel, + OrdemServicoDetalhesModel, + OrdemServicoFiltrosModel, + OrdemServicoListaModel, + OrdemServicoListaResponseModel, + OrdemServicoOdometroModel, + OrdemServicoOperacaoResultModel, + OrdemServicoPagamentoModel, + OrdemServicoSalvarModel, + TecnicoManutencaoModel +} from '../models/orionModel'; @Injectable() export class OrionService { - Rota: String = environment.ApiUrl + "/otp"; + private readonly Rota: string = environment.ApiUrl + '/otp'; constructor(private http: HttpClient) { } - getAllLogs() { - return this.http.get(`${this.Rota}/getAllLogs`) - .toPromise() - .then(response => response) - .then(data => { - return data['response']; - }) + // ========================================================== + // RESPOSTA PADRÃO + // ========================================================== + + private executar( + requisicao: Promise> + ): Promise { + return requisicao.then(resultado => { + if (!resultado) { + throw new Error('A API não retornou uma resposta.'); + } + + if (resultado.status >= 400 || resultado.error) { + const mensagem = resultado.error && resultado.error.message + ? resultado.error.message + : 'Não foi possível concluir a operação.'; + + throw new Error(mensagem); + } + + return resultado.response; + }); } - coordenadaToEndereco(Coordenadas: string) { - return this.http.get(`http://api.positionstack.com/v1/reverse?access_key=${environment.GeoAPI}&query=${Coordenadas}`) - .toPromise() - .then(response => response) - .then(data => { - return data; - }) + private montarParametros(filtros?: any): HttpParams { + let params = new HttpParams(); + + if (!filtros) { + return params; + } + + Object.keys(filtros).forEach(chave => { + const valor = filtros[chave]; + + if (valor !== undefined && valor !== null && valor !== '') { + params = params.set(chave, String(valor)); + } + }); + + return params; } -} + private numero(valor: any): number { + const convertido = Number(valor); + return Number.isFinite(convertido) ? convertido : 0; + } + + private normalizarOrdemLista( + ordem: OrdemServicoListaModel + ): OrdemServicoListaModel { + return { + ...ordem, + id: this.numero(ordem.id), + cliente_id: ordem.cliente_id ? this.numero(ordem.cliente_id) : null, + equipamento_id: ordem.equipamento_id ? this.numero(ordem.equipamento_id) : null, + tecnico_responsavel_id: ordem.tecnico_responsavel_id + ? this.numero(ordem.tecnico_responsavel_id) + : null, + + total_horas_expediente: this.numero(ordem.total_horas_expediente), + total_horas_extras: this.numero(ordem.total_horas_extras), + + valor_mao_obra: this.numero(ordem.valor_mao_obra), + valor_insumos: this.numero(ordem.valor_insumos), + valor_desconto: this.numero(ordem.valor_desconto), + valor_acrescimo: this.numero(ordem.valor_acrescimo), + valor_total: this.numero(ordem.valor_total), + valor_pago: this.numero(ordem.valor_pago), + valor_pendente: this.numero(ordem.valor_pendente) + }; + } + + // ========================================================== + // LOGS + // ========================================================== + + getAllLogs(): Promise { + return this.executar( + this.http + .get>( + `${this.Rota}/getAllLogs` + ) + .toPromise() + ); + } + + insertLog(log: OrionLogInsertModel): Promise<{ id: number }> { + return this.executar( + this.http + .post>( + `${this.Rota}/insertLog`, + log + ) + .toPromise() + ); + } + + coordenadaToEndereco( + coordenadas: string + ): Promise { + return this.http + .get( + `http://api.positionstack.com/v1/reverse` + + `?access_key=${environment.GeoAPI}` + + `&query=${encodeURIComponent(coordenadas)}` + ) + .toPromise(); + } + + // ========================================================== + // CADASTROS AUXILIARES + // ========================================================== + + getClientesManutencao(): Promise { + return this.executar( + this.http + .get>( + `${this.Rota}/getClientesManutencao` + ) + .toPromise() + ); + } + + getEquipamentosManutencao(): Promise { + return this.executar( + this.http + .get>( + `${this.Rota}/getEquipamentosManutencao` + ) + .toPromise() + ).then(equipamentos => + equipamentos.map(equipamento => ({ + ...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 + ) + })) + ); + } + + getTecnicosManutencao(): Promise { + return this.executar( + this.http + .get>( + `${this.Rota}/getTecnicosManutencao` + ) + .toPromise() + ).then(tecnicos => + tecnicos.map(tecnico => ({ + ...tecnico, + id: this.numero(tecnico.id), + usuario_sistema_id: tecnico.usuario_sistema_id + ? this.numero(tecnico.usuario_sistema_id) + : null, + valor_hora_expediente_padrao: this.numero( + tecnico.valor_hora_expediente_padrao + ), + valor_hora_extra_padrao: this.numero( + tecnico.valor_hora_extra_padrao + ) + })) + ); + } + + getMateriaisManutencao(): Promise { + return this.executar( + this.http + .get>( + `${this.Rota}/getMateriaisManutencao` + ) + .toPromise() + ).then(materiais => + materiais.map(material => ({ + ...material, + id: this.numero(material.id), + custo_padrao: this.numero(material.custo_padrao), + preco_venda_padrao: this.numero(material.preco_venda_padrao) + })) + ); + } + + // ========================================================== + // ORDENS DE SERVIÇO + // ========================================================== + + getOrdensServico( + filtros: OrdemServicoFiltrosModel = {} + ): Promise { + const params = this.montarParametros(filtros); + + return this.executar( + this.http + .get>( + `${this.Rota}/getOrdensServico`, + { params } + ) + .toPromise() + ).then(resultado => ({ + items: resultado.items.map(item => this.normalizarOrdemLista(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) + } + })); + } + + getOrdemServico(id: number): Promise { + return this.executar( + this.http + .get>( + `${this.Rota}/getOrdemServico/${id}` + ) + .toPromise() + ).then(ordem => { + const normalizada = this.normalizarOrdemLista(ordem); + + return { + ...ordem, + ...normalizada, + custo_insumos: this.numero(ordem.custo_insumos), + atividades: (ordem.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_da_os: ordem.odometros_da_os || [], + pagamentos: (ordem.pagamentos || []).map(pagamento => ({ + ...pagamento, + valor: this.numero(pagamento.valor) + })), + historico_status: ordem.historico_status || [] + }; + }); + } + + insertOrdemServico( + ordem: OrdemServicoSalvarModel + ): Promise { + return this.executar( + this.http + .post>( + `${this.Rota}/insertOrdemServico`, + ordem + ) + .toPromise() + ); + } + + updateOrdemServico( + id: number, + ordem: OrdemServicoSalvarModel + ): Promise { + return this.executar( + this.http + .put>( + `${this.Rota}/updateOrdemServico/${id}`, + ordem + ) + .toPromise() + ); + } + + deleteOrdemServico( + id: number, + usuarioId?: number + ): Promise { + return this.executar( + this.http + .request>( + 'DELETE', + `${this.Rota}/deleteOrdemServico/${id}`, + { + body: { + usuario_id: usuarioId || null + } + } + ) + .toPromise() + ); + } + + // ========================================================== + // ATIVIDADES + // ========================================================== + + insertAtividadeOrdemServico( + ordemServicoId: number, + atividade: OrdemServicoAtividadeSalvarModel + ): Promise { + return this.executar( + this.http + .post>( + `${this.Rota}/insertAtividadeOrdemServico/${ordemServicoId}`, + atividade + ) + .toPromise() + ); + } + + updateAtividadeOrdemServico( + atividadeId: number, + atividade: OrdemServicoAtividadeSalvarModel + ): Promise { + return this.executar( + this.http + .put>( + `${this.Rota}/updateAtividadeOrdemServico/${atividadeId}`, + atividade + ) + .toPromise() + ); + } + + deleteAtividadeOrdemServico( + atividadeId: number + ): Promise { + return this.executar( + this.http + .delete>( + `${this.Rota}/deleteAtividadeOrdemServico/${atividadeId}` + ) + .toPromise() + ); + } + + // ========================================================== + // ODÔMETROS + // ========================================================== + + insertOdometroOrdemServico( + ordemServicoId: number, + odometro: OrdemServicoOdometroModel + ): Promise { + return this.executar( + this.http + .post>( + `${this.Rota}/insertOdometroOrdemServico/${ordemServicoId}`, + odometro + ) + .toPromise() + ); + } + + // ========================================================== + // PAGAMENTOS + // ========================================================== + + insertPagamentoOrdemServico( + ordemServicoId: number, + pagamento: OrdemServicoPagamentoModel + ): Promise { + return this.executar( + this.http + .post>( + `${this.Rota}/insertPagamentoOrdemServico/${ordemServicoId}`, + pagamento + ) + .toPromise() + ); + } + + cancelPagamentoOrdemServico( + pagamentoId: number, + usuarioId?: number + ): Promise { + return this.executar( + this.http + .request>( + 'DELETE', + `${this.Rota}/cancelPagamentoOrdemServico/${pagamentoId}`, + { + body: { + usuario_id: usuarioId || null + } + } + ) + .toPromise() + ); + } + + downloadOrdemServicoPdf( + id: number, + interno: boolean = false + ): Promise { + var url = + this.Rota + + '/downloadOrdemServicoPdf/' + + id + + '?interno=' + + (interno ? '1' : '0'); + + return this.http + .get(url, { + responseType: 'blob' as 'json' + }) + .toPromise() + .then(response => response as Blob); + } + + downloadRelatorioOrdensServicoPdf( + filtros: any = {} + ): Promise { + var params = this.montarParametros(filtros); + + return this.http + .get( + this.Rota + '/downloadRelatorioOrdensServicoPdf', + { + params: params, + responseType: 'blob' as 'json' + } + ) + .toPromise() + .then(response => response as Blob); + } + +} \ No newline at end of file