diff --git a/package.json b/package.json index cf9149c..68e567e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dexie": "^3.2.7", "firebase": "^10.14.1", "html2pdf.js": "^0.10.3", + "pdf-lib": "^1.17.1", "react": "^18.3.1", "react-chartjs-2": "^5.3.1", "react-dom": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5e992a..5e8b2b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: html2pdf.js: specifier: ^0.10.3 version: 0.10.3 + pdf-lib: + specifier: ^1.17.1 + version: 1.17.1 react: specifier: ^18.3.1 version: 18.3.1 @@ -593,6 +596,12 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@pdf-lib/standard-fonts@1.0.0': + resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==} + + '@pdf-lib/upng@1.0.1': + resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -1562,6 +1571,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + pako@2.1.0: resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} @@ -1601,6 +1613,9 @@ packages: pathval@1.1.1: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + pdf-lib@1.17.1: + resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==} + performance-now@2.1.0: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} @@ -1920,6 +1935,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2693,6 +2711,14 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@pdf-lib/standard-fonts@1.0.0': + dependencies: + pako: 1.0.11 + + '@pdf-lib/upng@1.0.1': + dependencies: + pako: 1.0.11 + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -3680,6 +3706,8 @@ snapshots: dependencies: p-limit: 3.1.0 + pako@1.0.11: {} + pako@2.1.0: {} parent-module@1.0.1: @@ -3704,6 +3732,13 @@ snapshots: pathval@1.1.1: {} + pdf-lib@1.17.1: + dependencies: + '@pdf-lib/standard-fonts': 1.0.0 + '@pdf-lib/upng': 1.0.1 + pako: 1.0.11 + tslib: 1.14.1 + performance-now@2.1.0: optional: true @@ -4038,6 +4073,8 @@ snapshots: ts-interface-checker@0.1.13: {} + tslib@1.14.1: {} + tslib@2.8.1: {} type-check@0.4.0: diff --git a/src/App.tsx b/src/App.tsx index 479804b..da5c441 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import Cable from './pages/Cable'; import Breaker from './pages/Breaker'; import Tools from './pages/Tools'; import Panel from './pages/Panel'; +import EMReports from './pages/EMReports'; import './index.css'; function App() { @@ -30,6 +31,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index b3edc01..6d5dcc9 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -26,6 +26,7 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => { const otherItems = [ { path: '/multiphase', label: 'Multi-Fase', icon: ChartBarIcon, color: 'text-gray-400' }, { path: '/panel', label: 'Painel', icon: Squares2X2Icon, color: 'text-gray-400' }, + { path: '/em-reports', label: 'Eletromecânico', icon: DocumentTextIcon, color: 'text-gray-400' }, { path: '/reports', label: 'Relatórios', icon: DocumentTextIcon, color: 'text-gray-400' }, { path: '/parameters', label: 'Parâmetros', icon: CogIcon, color: 'text-gray-400' }, { path: '/equipment', label: 'Equipamentos', icon: CircleStackIcon, color: 'text-gray-400' }, diff --git a/src/db/database.ts b/src/db/database.ts index d5514fd..e0a6e99 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -1,6 +1,7 @@ import Dexie, { Table } from 'dexie'; import { IRReport, + EletroMecanicoReport, MultiPhaseConfig, MultiPhaseReport, AILearningHistory, @@ -33,10 +34,11 @@ export class EletriLabDB extends Dexie { aiLearningHistory!: Table; categoryProfiles!: Table; systemConfigs!: Table; + emReports!: Table; constructor() { super('EletriLabDB'); - + this.version(4).stores({ // Tabelas originais equipment: 'id, tag, category, status, location, manufacturer', @@ -51,7 +53,27 @@ export class EletriLabDB extends Dexie { multiPhaseReports: '++id, configId, createdAt, isSaved', aiLearningHistory: '++id, category, phaseCount, createdAt', categoryProfiles: '++id, category, createdAt', - systemConfigs: '++id, createdAt' + systemConfigs: '++id, createdAt', + // OBS: versão 4 não tinha EM; mantemos aqui para não quebrar ambientes já com v4. + // A tabela EM real e indexação correta fica na v5. + emReports: '++id, module, discipline, createdAt' + }); + + // Versão 5: Tabela EM com chave string (id) e índices úteis. + // Importante: usar `id` (não ++id) porque `EletroMecanicoReport.id` já é string. + this.version(5).stores({ + equipment: 'id, tag, category, status, location, manufacturer', + report: 'id, number, date, client, status, responsible', + test: 'id, reportId, equipmentId, testType, result, performedAt', + configuration: 'id', + irReports: '++id, category, createdAt, isSaved', + parameters: '++id, key, category', + multiPhaseConfigs: '++id, equipmentType, createdAt', + multiPhaseReports: '++id, configId, createdAt, isSaved', + aiLearningHistory: '++id, category, phaseCount, createdAt', + categoryProfiles: '++id, category, createdAt', + systemConfigs: '++id, createdAt', + emReports: 'id, module, discipline, createdAt' }); } } @@ -416,6 +438,43 @@ export const dbUtils = { } }, + // === ELETROMECÂNICO (novo) === + async getAllEMReports(): Promise { + try { + return await db.emReports.orderBy('createdAt').reverse().toArray(); + } catch (error) { + console.error('Erro ao buscar relatórios EM:', error); + return []; + } + }, + + async getEMReport(id: string): Promise { + try { + return await db.emReports.get(id) || null; + } catch (error) { + console.error('Erro ao buscar relatório EM:', error); + return null; + } + }, + + async saveEMReport(report: EletroMecanicoReport): Promise { + try { + await db.emReports.put(report); + } catch (error) { + console.error('Erro ao salvar relatório EM:', error); + throw error; + } + }, + + async deleteEMReport(id: string): Promise { + try { + await db.emReports.delete(id); + } catch (error) { + console.error('Erro ao deletar relatório EM:', error); + throw error; + } + }, + async getSavedIRReports(): Promise { try { return await db.irReports.where('isSaved').equals(1).toArray(); @@ -598,7 +657,7 @@ export const dbUtils = { // Backup/Restore simples (banco local IndexedDB) async exportAll(): Promise { try { - const [equipment, reports, tests, irReports, multiConfigs, multiReports, ai, profiles, system, config] = await Promise.all([ + const [equipment, reports, tests, irReports, multiConfigs, multiReports, ai, profiles, system, config, emReports] = await Promise.all([ db.equipment.toArray(), db.report.toArray(), db.test.toArray(), @@ -608,7 +667,8 @@ export const dbUtils = { db.aiLearningHistory.toArray(), db.categoryProfiles.toArray(), db.systemConfigs.toArray(), - db.configuration.toArray() + db.configuration.toArray(), + db.emReports.toArray(), ]); return { version: 1, @@ -619,6 +679,7 @@ export const dbUtils = { irReports, multiConfigs, multiReports, + emReports, ai, profiles, system, @@ -641,11 +702,12 @@ export const dbUtils = { db.test.clear(), ]); }); - await db.transaction('rw', db.irReports, db.multiPhaseConfigs, db.multiPhaseReports, async () => { + await db.transaction('rw', db.irReports, db.multiPhaseConfigs, db.multiPhaseReports, db.emReports, async () => { await Promise.all([ db.irReports.clear(), db.multiPhaseConfigs.clear(), db.multiPhaseReports.clear(), + db.emReports.clear(), ]); }); await db.transaction('rw', db.aiLearningHistory, db.categoryProfiles, db.systemConfigs, db.configuration, async () => { @@ -663,10 +725,11 @@ export const dbUtils = { if (data.reports?.length) await db.report.bulkAdd(data.reports); if (data.tests?.length) await db.test.bulkAdd(data.tests); }); - await db.transaction('rw', db.irReports, db.multiPhaseConfigs, db.multiPhaseReports, async () => { + await db.transaction('rw', db.irReports, db.multiPhaseConfigs, db.multiPhaseReports, db.emReports, async () => { if (data.irReports?.length) await db.irReports.bulkAdd(data.irReports); if (data.multiConfigs?.length) await db.multiPhaseConfigs.bulkAdd(data.multiConfigs); if (data.multiReports?.length) await db.multiPhaseReports.bulkAdd(data.multiReports); + if (data.emReports?.length) await db.emReports.bulkAdd(data.emReports); }); await db.transaction('rw', db.aiLearningHistory, db.categoryProfiles, db.systemConfigs, db.configuration, async () => { if (data.ai?.length) await db.aiLearningHistory.bulkAdd(data.ai); @@ -821,6 +884,7 @@ export const dbUtils = { await db.test.clear(); await db.configuration.clear(); await db.irReports.clear(); + await db.emReports.clear(); await db.parameters.clear(); await db.multiPhaseConfigs.clear(); await db.multiPhaseReports.clear(); diff --git a/src/em/adapters.ts b/src/em/adapters.ts new file mode 100644 index 0000000..d675331 --- /dev/null +++ b/src/em/adapters.ts @@ -0,0 +1,148 @@ +import type { + EletroMecanicoReport, + EMHeaderMeta, + EMResponsible, + IRReport, + MultiPhaseReport, + ReportOriginal, + TestOriginal, +} from '../types'; + +function isoDateOnly(d: Date): string { + return d.toISOString().slice(0, 10); +} + +function coalesce(...values: Array): string { + for (const v of values) { + if (v && String(v).trim()) return String(v); + } + return ''; +} + +function buildResponsible(name: string | undefined): EMResponsible { + const trimmed = (name || '').trim(); + return { name: trimmed || 'N/A' }; +} + +export function buildDefaultHeaderMeta(input: Partial): EMHeaderMeta { + return { + reportNumber: input.reportNumber, + date: input.date || isoDateOnly(new Date()), + client: input.client || 'N/A', + site: input.site || 'N/A', + tag: input.tag, + equipmentDescription: input.equipmentDescription, + responsible: input.responsible || { name: 'N/A' }, + reviewers: input.reviewers, + signatures: input.signatures, + observations: input.observations, + recommendations: input.recommendations, + }; +} + +export function emFromIRReport(ir: IRReport, header?: Partial): EletroMecanicoReport { + const date = + (ir.date && ir.date.slice(0, 10)) || + (ir.createdAt instanceof Date ? isoDateOnly(ir.createdAt) : isoDateOnly(new Date(ir.createdAt))); + + const hdr = buildDefaultHeaderMeta({ + reportNumber: ir.number, + date, + client: coalesce(ir.client, header?.client), + site: coalesce(ir.site, header?.site), + tag: coalesce(ir.tag, header?.tag) || undefined, + equipmentDescription: header?.equipmentDescription, + responsible: header?.responsible || buildResponsible(coalesce(ir.operator, ir.responsible)), + observations: coalesce(ir.observations, ir.notes, header?.observations) || undefined, + recommendations: coalesce(ir.recommendations, header?.recommendations) || undefined, + reviewers: header?.reviewers, + signatures: header?.signatures, + }); + + return { + id: `em_${ir.id}`, + discipline: 'eletrica', + module: 'megger_ir', + createdAt: ir.createdAt instanceof Date ? ir.createdAt : new Date(ir.createdAt), + header: hdr, + payload: { discipline: 'eletrica', module: 'megger_ir', data: ir }, + version: 1, + }; +} + +export function emFromMultiPhaseReport(mp: MultiPhaseReport, header?: Partial): EletroMecanicoReport { + const createdAt = mp.createdAt instanceof Date ? mp.createdAt : new Date(mp.createdAt); + const hdr = buildDefaultHeaderMeta({ + reportNumber: header?.reportNumber, + date: header?.date || isoDateOnly(createdAt), + client: header?.client || 'N/A', + site: header?.site || 'N/A', + tag: header?.tag || mp.equipmentTag || mp.equipment?.tag, + equipmentDescription: header?.equipmentDescription || mp.equipment?.model, + responsible: header?.responsible || buildResponsible(mp.operator), + reviewers: header?.reviewers, + signatures: header?.signatures, + observations: header?.observations, + recommendations: header?.recommendations, + }); + + return { + id: `em_${mp.id}`, + discipline: 'eletrica', + module: 'multiphase_megger', + createdAt, + header: hdr, + payload: { discipline: 'eletrica', module: 'multiphase_megger', data: mp }, + version: 1, + }; +} + +export function emFromOriginalReport( + report: ReportOriginal, + _tests: TestOriginal[], + header?: Partial +): EletroMecanicoReport { + const createdAt = report.createdAt ? new Date(report.createdAt) : new Date(); + const hdr = buildDefaultHeaderMeta({ + reportNumber: report.number || header?.reportNumber, + date: report.date ? report.date.slice(0, 10) : header?.date || isoDateOnly(createdAt), + client: coalesce(report.client, header?.client) || 'N/A', + site: coalesce(report.location, header?.site) || 'N/A', + tag: header?.tag, + equipmentDescription: header?.equipmentDescription, + responsible: header?.responsible || buildResponsible(coalesce(report.responsible)), + observations: coalesce(report.observations, header?.observations) || undefined, + recommendations: coalesce(report.recommendations, header?.recommendations) || undefined, + reviewers: header?.reviewers, + signatures: header?.signatures, + }); + + // Por ora, a migração “original” cai no módulo megger_ir com um IR vazio + // (mantemos compat e preservamos cabeçalho/rodapé para template corporativo). + const irStub: IRReport = { + id: report.id, + number: report.number, + category: 'outro', + kv: 1.0, + client: report.client, + site: report.location, + operator: report.responsible, + readings: [], + dai: 'Undefined', + createdAt, + isSaved: true, + notes: report.observations, + recommendations: report.recommendations, + }; + + return { + id: `em_${report.id}`, + discipline: 'eletrica', + module: 'megger_ir', + createdAt, + header: hdr, + payload: { discipline: 'eletrica', module: 'megger_ir', data: irStub }, + version: 1, + }; +} + diff --git a/src/em/index.ts b/src/em/index.ts new file mode 100644 index 0000000..689c2fc --- /dev/null +++ b/src/em/index.ts @@ -0,0 +1,2 @@ +export * from './adapters'; + diff --git a/src/pages/EMReports.tsx b/src/pages/EMReports.tsx new file mode 100644 index 0000000..15bfb2b --- /dev/null +++ b/src/pages/EMReports.tsx @@ -0,0 +1,131 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { ArrowDownTrayIcon, EyeIcon, DocumentTextIcon } from '@heroicons/react/24/outline'; +import type { EletroMecanicoReport } from '../types'; +import { dbUtils } from '../db/database'; + +const EMReports: React.FC = () => { + const [loading, setLoading] = useState(true); + const [reports, setReports] = useState([]); + const [search, setSearch] = useState(''); + + useEffect(() => { + const load = async () => { + try { + setLoading(true); + const all = await dbUtils.getAllEMReports(); + setReports(all); + } finally { + setLoading(false); + } + }; + load(); + }, []); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return reports; + return reports.filter(r => { + const hay = [ + r.header.reportNumber, + r.header.client, + r.header.site, + r.header.tag, + r.header.responsible?.name, + r.module, + r.discipline, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return hay.includes(q); + }); + }, [reports, search]); + + const formatDate = (d: Date) => + (d instanceof Date ? d : new Date(d)).toLocaleDateString('pt-BR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+

Relatórios EM

+

Eletromecânico (unificado)

+
+ + {filtered.length} de {reports.length} + +
+ +
+ setSearch(e.target.value)} + placeholder="Buscar por cliente, local, tag, número..." + className="w-full px-4 py-2.5 bg-gray-950 border border-gray-800 rounded-lg text-white text-sm placeholder-gray-600 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-600" + /> +
+ +
+ {filtered.length === 0 ? ( +
+
+ +
+

Nenhum relatório EM encontrado

+

Gere um Megger e salve como EM para começar

+
+ ) : ( +
+ {filtered.map((r) => ( +
+
+

+ {r.header.reportNumber ? `${r.header.reportNumber} · ` : ''} + {r.module.toUpperCase()} · {r.header.tag || 'Sem TAG'} +

+

+ {r.header.client} · {r.header.site} · {formatDate(r.createdAt)} +

+
+
+ + + + +
+
+ ))} +
+ )} +
+
+ ); +}; + +export default EMReports; + diff --git a/src/pages/GenerateReport.tsx b/src/pages/GenerateReport.tsx index 96c8ccc..6cea329 100644 --- a/src/pages/GenerateReport.tsx +++ b/src/pages/GenerateReport.tsx @@ -24,6 +24,7 @@ import { exportCupomPDF } from '../utils/export'; import { exportMeggerExcel } from '../utils/export-excel'; import { dbUtils } from '../db/database'; import { calculateDAI, formatVoltage } from '../utils/units'; +import { emFromIRReport } from '../em'; type InputMode = 'generate' | 'manual'; @@ -246,6 +247,22 @@ const GenerateReport: React.FC = () => { await dbUtils.saveIRReport(savedReport); setGeneratedReport(savedReport); + + // Também salva uma versão unificada EM (para o futuro template corporativo) + try { + const em = emFromIRReport(savedReport, { + reportNumber: savedReport.number, + client: savedReport.client || 'N/A', + site: savedReport.site || 'N/A', + tag: savedReport.tag, + responsible: { name: savedReport.operator || savedReport.responsible || 'N/A' }, + observations: savedReport.observations || savedReport.notes, + recommendations: savedReport.recommendations, + }); + await dbUtils.saveEMReport(em); + } catch (e) { + console.warn('Falha ao salvar EM (não bloqueante):', e); + } // Mostrar feedback showNotificationMessage('success', 'Relatório salvo com sucesso!'); diff --git a/src/types/index.ts b/src/types/index.ts index 544d067..7bb0568 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -425,3 +425,127 @@ export interface EnvironmentalFactors { temperature: number; // °C humidity: number; // % } + +// ============================== +// === ELETROMECÂNICO (NOVO) === +// ============================== + +export type EMDiscipline = 'eletrica' | 'mecanica'; + +export type EMElectricalModule = + | 'megger_ir' + | 'microhm' + | 'hipot' + | 'cable' + | 'breaker' + | 'multiphase_megger'; + +export type EMMechanicalModule = + | 'vibracao' + | 'alinhamento' + | 'termografia' + | 'lubrificacao'; + +export type EMModule = EMElectricalModule | EMMechanicalModule; + +export interface EMResponsible { + name: string; + crea?: string; + role?: string; // ex: Engenheiro Eletricista +} + +export interface EMSignature { + label: string; // ex: "Responsável Técnico" + signedBy?: string; + imageDataUrl?: string; // PNG/JPG data URL (opcional) + date?: string; // ISO ou string livre +} + +export interface EMHeaderMeta { + reportNumber?: string; + date: string; // ISO yyyy-mm-dd + client: string; + site: string; // obra/local + tag?: string; + equipmentDescription?: string; + responsible: EMResponsible; + reviewers?: EMResponsible[]; + signatures?: EMSignature[]; + observations?: string; + recommendations?: string; +} + +export type EMElectricalPayload = + | { module: 'megger_ir'; data: IRReport } + | { + module: 'microhm'; + data: { + voltage_V: number; + current_A: number; + reference_Ohm: number; + R_Ohm: number; + percentDelta: number; + status: string; + possibleBadContact: boolean; + }; + } + | { + module: 'hipot'; + data: { + nominalVoltage_V: number; + Vteste_V: number; + formulaUsed: string; + }; + } + | { + module: 'cable'; + data: { + power: number; + voltage: number; + powerFactor: number; + systemType: string; + distance: number; + voltageDropPercent: number; + current_A: number; + minSection_mm2: number; + resistance_Ohm: number; + actualDrop: number; + status: string; + breakerIn?: number; + breakerCurve?: string; + coordinationOk?: boolean; + }; + } + | { + module: 'breaker'; + data: { + loadCurrent_A: number; + loadType: string; + cableMaxCurrent_A: number; + In_A: number; + curve: string; + coordinationOk: boolean; + }; + } + | { module: 'multiphase_megger'; data: MultiPhaseReport }; + +export type EMMechanicalPayload = { + module: EMMechanicalModule; + // payload mecânico será tipado por módulo quando os módulos forem implementados + data: Record; +}; + +export type EMPayload = + | ({ discipline: 'eletrica' } & EMElectricalPayload) + | ({ discipline: 'mecanica' } & EMMechanicalPayload); + +export interface EletroMecanicoReport { + id: string; + discipline: EMDiscipline; + module: EMModule; + createdAt: Date; + updatedAt?: Date; + header: EMHeaderMeta; + payload: EMPayload; + version: number; // incrementável para migração do schema do relatório +} diff --git a/src/utils/export-template/index.ts b/src/utils/export-template/index.ts new file mode 100644 index 0000000..a3348cc --- /dev/null +++ b/src/utils/export-template/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './pdf-template'; + diff --git a/src/utils/export-template/pdf-template.ts b/src/utils/export-template/pdf-template.ts new file mode 100644 index 0000000..09c33a5 --- /dev/null +++ b/src/utils/export-template/pdf-template.ts @@ -0,0 +1,94 @@ +import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'; +import type { EMHeaderMeta, EletroMecanicoReport } from '../../types'; +import type { EMTemplateMapping, PDFPlacement, PDFTextPlacement } from './types'; + +function toDateStr(date: string | undefined): string { + if (!date) return ''; + return date.slice(0, 10); +} + +function safeText(v: unknown): string { + if (v === null || v === undefined) return ''; + return String(v); +} + +function fieldValueFromHeader(key: string, header: EMHeaderMeta): string { + switch (key) { + case 'header.reportNumber': + return safeText(header.reportNumber); + case 'header.date': + return toDateStr(header.date); + case 'header.client': + return safeText(header.client); + case 'header.site': + return safeText(header.site); + case 'header.tag': + return safeText(header.tag); + case 'header.equipmentDescription': + return safeText(header.equipmentDescription); + case 'header.responsible.name': + return safeText(header.responsible?.name); + case 'header.responsible.crea': + return safeText(header.responsible?.crea); + case 'header.responsible.role': + return safeText(header.responsible?.role); + case 'header.observations': + return safeText(header.observations); + case 'header.recommendations': + return safeText(header.recommendations); + default: + return ''; + } +} + +/** + * Preenche um PDF base com os dados do cabeçalho/rodapé (template corporativo). + * + * Observações importantes: + * - Sem o PDF base, este método não consegue gerar um PDF "corporativo". + * - Estratégia: overlay por coordenadas. + * - Quando o PDF base tiver campos (AcroForm), podemos evoluir este módulo para preencher forms. + */ +export async function exportEMReportWithPdfTemplate( + report: EletroMecanicoReport, + templatePdfBytes: ArrayBuffer, + mapping: EMTemplateMapping +): Promise { + if (!templatePdfBytes || templatePdfBytes.byteLength === 0) { + throw new Error('PDF base do template não fornecido'); + } + + const pdfDoc = await PDFDocument.load(templatePdfBytes); + + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + const defaultSize = 10; + const color = { r: 0, g: 0, b: 0 }; + + const header: EMHeaderMeta = report.header; + const placements: PDFPlacement[] = mapping.placements || []; + + for (const p of placements) { + if (p.type !== 'text') continue; + + const t = p as PDFTextPlacement; + const page = pdfDoc.getPage(t.page ?? 0); + const value = fieldValueFromHeader(t.key, header); + if (!value) continue; + + const size = t.size ?? defaultSize; + + page.drawText(value, { + x: t.x, + y: t.y, + size, + font, + color: rgb(color.r / 255, color.g / 255, color.b / 255), + maxWidth: t.maxWidth, + }); + } + + const bytes = await pdfDoc.save(); + // `bytes` pode vir com `ArrayBufferLike` em tipos; normalizamos para ArrayBuffer. + return new Blob([bytes.buffer as ArrayBuffer], { type: 'application/pdf' }); +} + diff --git a/src/utils/export-template/types.ts b/src/utils/export-template/types.ts new file mode 100644 index 0000000..6fe586c --- /dev/null +++ b/src/utils/export-template/types.ts @@ -0,0 +1,43 @@ +import type { EletroMecanicoReport } from '../../types'; + +export type PDFPlacementUnit = 'pt'; + +export type PDFTextAlign = 'left' | 'center' | 'right'; + +export interface PDFTextPlacement { + type: 'text'; + key: string; // ex: "header.client" + page: number; // 0-based + x: number; + y: number; + size?: number; + maxWidth?: number; + align?: PDFTextAlign; +} + +export interface PDFImagePlacement { + type: 'image'; + key: string; // ex: "header.signatures[0].imageDataUrl" + page: number; // 0-based + x: number; + y: number; + width: number; + height: number; +} + +export type PDFPlacement = PDFTextPlacement | PDFImagePlacement; + +export interface EMTemplateMapping { + id: string; + name: string; + unit: PDFPlacementUnit; // atualmente pt + placements: PDFPlacement[]; +} + +export interface ExportEMTemplatePDFOptions { + templatePdfBytes: ArrayBuffer; // PDF base (corporativo) + mapping: EMTemplateMapping; + report: EletroMecanicoReport; + fontSizeDefault?: number; +} +