diff --git a/efactura-generator/CHANGELOG.md b/efactura-generator/CHANGELOG.md index 4fa6519..27a960d 100644 --- a/efactura-generator/CHANGELOG.md +++ b/efactura-generator/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.9-beta-15 - 05.05.2026 + +### New Features +- Buton nou „PDF ANAF" în meniul Acțiuni: descarcă PDF-ul oficial al facturii generat direct de ANAF din XML-ul curent. Înainte de generare, ANAF validează factura — dacă apar erori, sunt afișate în loc să descarce un PDF cu informații greșite. Disponibil când serverul are configurat suport pentru API-ul ANAF. + +### Modifications +- Reorganizat header-ul aplicației: butoanele secundare (Printează, Descarcă PDF, PDF ANAF, Validare ANAF) sunt grupate într-un meniu „Acțiuni ▾" pentru reducerea aglomerării. În header rămân vizibile permanent doar acțiunile principale: Alege Fișier, Factură Nouă, Stornează, Salvează XML. +- Eliminat selectorul Standard/Compact și butonul „Printează" injectate dinamic în header — opțiunile sunt acum directe în meniul Acțiuni („Printează — Standard" și „Printează — Compact"), un singur click pentru orice variantă. +- Pe mobil meniul Acțiuni se deschide pe toată lățimea ecranului sub header. + +### Bugfixes +- Fixed: butonul „Validare ANAF" (existent în versiuni anterioare) și endpoint-ul folosit pentru transformarea XML → PDF foloseau ruta cu autentificare OAuth (`api.anaf.ro`) chiar și fără un token configurat — acum, în lipsa token-ului, se folosește ruta publică ANAF (`webservicesp.anaf.ro`) care nu necesită autentificare. Astfel funcționalitățile ANAF merg și pe servere fără token. +- Fixed: apelul anterior pentru transformarea în PDF folosea forma „skip validare" — în versiuni viitoare, când ar fi fost cablată, ar fi descărcat un PDF chiar și pentru XML-uri cu probleme. Acum validarea e implicită și erorile blochează descărcarea. + ## 0.9-beta-14 - 05.05.2026 ### Bugfixes diff --git a/efactura-generator/index.html b/efactura-generator/index.html index 9281fa4..d231247 100644 --- a/efactura-generator/index.html +++ b/efactura-generator/index.html @@ -21,10 +21,21 @@ - - +
+ + +
@@ -281,7 +292,7 @@ diff --git a/efactura-generator/js/anaf.js b/efactura-generator/js/anaf.js index 1c348f7..1ce34b3 100644 --- a/efactura-generator/js/anaf.js +++ b/efactura-generator/js/anaf.js @@ -5,13 +5,14 @@ * Pe hosting static (GitHub Pages, fără PHP), apelurile vor eșua cu eroare * "receiver indisponibil" — verificați cu probeReceiver() la inițializare. * - * Configurare necesară în config.json (server-side): - * "anaf_token": "" — necesar pentru validate + pdf + * Configurare opțională în config.json (server-side): + * "anaf_token": "" — folosește ruta OAuth (api.anaf.ro) + * Fără token: receiver folosește ruta publică webservicesp.anaf.ro (fără auth). * * Endpoints ANAF (proxied): - * Validate : POST https://api.anaf.ro/prod/FCTEL/rest/validare/FACT1 - * PDF/HTML : POST https://api.anaf.ro/prod/FCTEL/rest/transformare/FACT1/DA - * CIF info : POST https://webservicesp.anaf.ro/api/PlatitorTvaRest/v9/tva + * Validate : POST /FCTEL/rest/validare/FACT1 + * XmlToPdf : POST /FCTEL/rest/transformare/FACT1 (validează default; întoarce PDF) + * CIF info : POST https://webservicesp.anaf.ro/api/PlatitorTvaRest/v9/tva */ const RECEIVER = './receiver.php'; @@ -65,11 +66,14 @@ export async function anafValidate(xmlContent) { } /** - * Obține vizualizarea ANAF a facturii (ZIP cu HTML). - * Notă: ANAF /transformare returnează ZIP+HTML, nu PDF direct. - * PDF-ul real este generat client-side prin PR-PDF / html2pdf.js. + * Obține PDF-ul oficial ANAF al facturii (transformare XML → PDF). + * ANAF /transformare/FACT1 validează XML-ul și întoarce direct PDF binary. + * Dacă XML-ul nu trece validarea, ANAF întoarce JSON cu erori (status 400). + * * @param {string} xmlContent - XML ca string UTF-8 - * @returns {Promise} ZIP blob + * @returns {Promise<{pdf: Blob}|{errors: Array<{message:string,severity:string}>}>} + * - pdf: Blob `application/pdf` la succes + * - errors: listă mesaje validare la eșec */ export async function anafPdf(xmlContent) { let res; @@ -82,10 +86,29 @@ export async function anafPdf(xmlContent) { } catch (e) { throw new Error('Receiver.php indisponibil — ' + e.message); } - if (!res.ok) { - throw new Error(`ANAF vizualizare: HTTP ${res.status}`); + + const ct = (res.headers.get('Content-Type') || '').toLowerCase(); + + if (res.ok && ct.includes('application/pdf')) { + return { pdf: await res.blob() }; } - return res.blob(); + + // ANAF validation errors come back as JSON (HTTP 400 or 200 with JSON body) + if (ct.includes('application/json')) { + const data = await res.json().catch(() => null); + const messages = (data?.Messages || data?.messages || []).map(m => ({ + message: m.message || m.Message || String(m), + severity: (m.severity || m.Severity || 'ERROR').toUpperCase() + })); + if (messages.length) return { errors: messages }; + if (data?.error) throw new Error(data.error); + } + + if (!res.ok) { + const txt = await res.text().catch(() => ''); + throw new Error(`ANAF transformare: HTTP ${res.status}` + (txt ? ' — ' + txt.slice(0, 200) : '')); + } + throw new Error(`ANAF transformare: răspuns neașteptat (${ct || 'fără content-type'})`); } /** diff --git a/efactura-generator/js/script.js b/efactura-generator/js/script.js index 1d3542e..0d2d6a1 100644 --- a/efactura-generator/js/script.js +++ b/efactura-generator/js/script.js @@ -8,7 +8,7 @@ import JSZip from './vendor/jszip.mjs'; import { validateCIF } from './validation/cif.js'; import { validateIBAN } from './validation/iban.js'; import { runBRRules } from './validation/br-ro.js'; -import { probeReceiver, anafValidate, anafCifLookup } from './anaf.js'; +import { probeReceiver, anafValidate, anafCifLookup, anafPdf } from './anaf.js'; import { catalogAdd, catalogSearch, catalogDelete } from './catalog.js'; // Constants @@ -4572,6 +4572,63 @@ window.validateAnaf = async function() { } }; +/** + * Descarcă PDF-ul oficial generat de ANAF din XML-ul curent. + * Apelează endpoint-ul ANAF /transformare/FACT1 (validează implicit). + * Dacă XML-ul nu trece validarea ANAF, afișează erorile în loc să descarce. + * Necesită receiver.php disponibil pe server (proxy CORS). + */ +window.downloadPdfAnaf = async function() { + if (!currentInvoice) { + showToast('Nicio factură încărcată. Deschideți un XML eFactura mai întâi.', 'warning'); + return; + } + const btn = document.getElementById('btnPdfAnaf'); + if (btn) _btnLoading(btn, 'Generare PDF ANAF...'); + + try { + const xmlString = _currentXMLString(); + if (!xmlString) throw new Error('Nu s-a putut genera XML-ul facturii.'); + + const result = await anafPdf(xmlString); + + if (result.errors && result.errors.length) { + // ANAF a respins XML-ul la validare; afișează primele erori în toast. + const errs = result.errors.filter(m => m.severity === 'ERROR' || m.severity === 'FATAL'); + const sub = result.errors.slice(0, 3).map(m => m.message).join(' | '); + showToast( + `ANAF a respins XML-ul: ${errs.length || result.errors.length} mesaje`, + 'error', + sub.slice(0, 220) + ); + return; + } + + if (!(result.pdf instanceof Blob)) { + throw new Error('Răspuns ANAF fără PDF.'); + } + + // Descarcă blob-ul ca fișier PDF. + const inv = printHandler.collectInvoiceData(); + const safeNum = String(inv.invoiceNumber || 'factura').replace(/[^a-z0-9_-]+/gi, '_'); + const url = URL.createObjectURL(result.pdf); + const a = document.createElement('a'); + a.href = url; + a.download = `${safeNum}_ANAF.pdf`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + + showToast('PDF ANAF descărcat.', 'success'); + } catch (err) { + showToast('Eroare PDF ANAF: ' + err.message, 'error', + 'Verificați că receiver.php este disponibil pe server.'); + } finally { + if (btn) _btnDone(btn); + } +}; + /** * Caută datele firmei după CIF din câmpul supplierVAT / customerVAT. * @param {'supplier'|'customer'} party @@ -4647,11 +4704,12 @@ window.lookupCif = async function(party) { } }; -// Probează receiver.php la startup — dacă e disponibil, afișează butoanele ANAF. +// Probează receiver.php la startup — dacă e disponibil, afișează item-urile ANAF. (async () => { const available = await probeReceiver().catch(() => false); if (available) { - document.getElementById('btnValidateAnaf')?.style?.setProperty('display', ''); + // Item-urile ANAF din meniul "Acțiuni" sunt grupate într-un wrapper hidden. + document.querySelector('.actions-menu-anaf')?.removeAttribute('hidden'); document.querySelectorAll('.anaf-cif-btn').forEach(b => b.style.removeProperty('display')); } })(); @@ -5262,38 +5320,66 @@ const printHandler = new InvoicePrintHandler(); // Initialize when the document is ready document.addEventListener('DOMContentLoaded', () => { - // Add print controls to the UI - const headerButtonGroup = document.querySelector('.button-group'); - if (headerButtonGroup) { - const printControls = document.createElement('div'); - printControls.className = 'print-controls'; - printControls.style.display = 'flex'; - printControls.style.gap = '8px'; - printControls.style.alignItems = 'center'; - - // Create template selector - const templateSelect = document.createElement('select'); - templateSelect.className = 'form-input'; - templateSelect.style.width = 'auto'; - templateSelect.innerHTML = ` - - - `; - templateSelect.addEventListener('change', (e) => { - printHandler.setTemplate(e.target.value); - }); + setupActionsMenu(); +}); - // Create print button - const printButton = document.createElement('button'); - printButton.className = 'button'; - printButton.onclick = () => printHandler.print(); - printButton.innerHTML = 'Printează'; +/** + * Cablează meniul "Acțiuni ▾" din header: toggle, click-outside, Esc, item handlers. + * Înlocuiește vechea injecție DOM pentru print controls — toate output-urile + * (Printează Standard/Compact, Descarcă PDF, PDF ANAF, Validare ANAF) sunt în meniu. + */ +function setupActionsMenu() { + const trigger = document.getElementById('btnActionsMenu'); + const menu = document.getElementById('actionsMenu'); + if (!trigger || !menu) return; - // Add elements to controls - printControls.appendChild(templateSelect); - printControls.appendChild(printButton); - - // Add controls to header - headerButtonGroup.appendChild(printControls); - } -}); \ No newline at end of file + const open = () => { + menu.hidden = false; + trigger.setAttribute('aria-expanded', 'true'); + }; + const close = () => { + menu.hidden = true; + trigger.setAttribute('aria-expanded', 'false'); + }; + const toggle = () => (menu.hidden ? open() : close()); + + trigger.addEventListener('click', (e) => { + e.stopPropagation(); + toggle(); + }); + + document.addEventListener('click', (e) => { + if (!menu.hidden && !menu.contains(e.target) && e.target !== trigger) close(); + }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !menu.hidden) { + close(); + trigger.focus(); + } + }); + + // Item dispatch — fiecare buton are data-action (+ data-template pentru print). + menu.addEventListener('click', (e) => { + const item = e.target.closest('[data-action]'); + if (!item) return; + const action = item.dataset.action; + close(); + + switch (action) { + case 'print': + printHandler.setTemplate(item.dataset.template || 'standard'); + printHandler.print(); + break; + case 'downloadPdf': + window.downloadPDF?.(); + break; + case 'pdfAnaf': + window.downloadPdfAnaf?.(); + break; + case 'validateAnaf': + window.validateAnaf?.(); + break; + } + }); +} \ No newline at end of file diff --git a/efactura-generator/receiver.php b/efactura-generator/receiver.php index 0acd439..3738f99 100644 --- a/efactura-generator/receiver.php +++ b/efactura-generator/receiver.php @@ -4,8 +4,8 @@ // 1. Primire XML eFactura (POST fără action) → salvare în temp/ // 2. Proxy ANAF APIs: // ?action=ping — health check (no auth) -// ?action=validate — proxy validare ANAF (necesită anaf_token în config.json) -// ?action=pdf — proxy transformare ANAF (ZIP+HTML, necesită anaf_token) +// ?action=validate — proxy validare ANAF (anaf_token opțional → ruta OAuth; altfel ruta publică) +// ?action=pdf — proxy transformare XmlToPdf ANAF (PDF binary; anaf_token opțional) // ?action=cif — lookup contribuabil după CIF (nu necesită token OAuth) // 3. Curățare fișiere temporare (?cleanup=xml_XXXX.xml) // ============================================================================ @@ -175,10 +175,21 @@ function handleAnafValidate() { } /** - * Proxy transformare ANAF (vizualizare ZIP+HTML). - * Notă: ANAF returnează ZIP cu fișiere HTML, nu PDF direct. - * Necesită: "anaf_token": "Bearer XXX" în config.json - * POST https://api.anaf.ro/prod/FCTEL/rest/transformare/FACT1/DA + * Proxy transformare ANAF XmlToPdf — întoarce PDF binary direct. + * Doc: https://api.anaf.ro/prod/FCTEL/rest/transformare/{standard}/{novld} + * - standard = FACT1 (UBL Invoice / Credit Note) + * - novld absent → ANAF validează XML-ul; dacă invalid, întoarce JSON cu erori + * - novld = "DA" → skip validare (NEFOLOSIT aici — vrem validare implicită) + * + * Endpoint: + * - cu anaf_token configurat: api.anaf.ro (OAuth2) + * - fără token: webservicesp.anaf.ro (rută publică, fără auth) + * + * Răspuns: + * - PDF (application/pdf) la succes — body începe cu "%PDF-" + * - JSON cu erori validare la eșec (HTTP 200 sau 400) + * + * Frontend (anaf.js) detectează tipul după Content-Type și afișează corespunzător. */ function handleAnafPdf() { global $config; @@ -188,9 +199,12 @@ function handleAnafPdf() { $headers = ['Content-Type: text/plain; charset=utf-8']; if ($token) { $headers[] = "Authorization: Bearer $token"; + $url = 'https://api.anaf.ro/prod/FCTEL/rest/transformare/FACT1'; + } else { + $url = 'https://webservicesp.anaf.ro/prod/FCTEL/rest/transformare/FACT1'; } - $result = curlPost('https://api.anaf.ro/prod/FCTEL/rest/transformare/FACT1/DA', $xmlContent, $headers); + $result = curlPost($url, $xmlContent, $headers); if ($result['error']) { header('Content-Type: application/json'); @@ -198,9 +212,30 @@ function handleAnafPdf() { echo json_encode(['error' => 'cURL error: ' . $result['error']]); exit; } - header('Content-Type: application/zip'); - header('Content-Disposition: attachment; filename="vizualizare_anaf.zip"'); - echo $result['body']; + + $body = $result['body']; + + // Sniff response type — PDF binary începe cu "%PDF-", JSON cu "{". + if (substr($body, 0, 5) === '%PDF-') { + header('Content-Type: application/pdf'); + header('Content-Disposition: attachment; filename="factura_anaf.pdf"'); + echo $body; + exit; + } + + // JSON (erori validare) sau alt răspuns text — pasează către client. + $trimmed = ltrim($body); + if ($trimmed !== '' && ($trimmed[0] === '{' || $trimmed[0] === '[')) { + header('Content-Type: application/json'); + http_response_code(400); + echo $body; + exit; + } + + // Unknown response (HTML error page, etc.) + header('Content-Type: application/json'); + http_response_code(502); + echo json_encode(['error' => 'ANAF răspuns neașteptat', 'preview' => substr($body, 0, 200)]); exit; } diff --git a/efactura-generator/styles/main.css b/efactura-generator/styles/main.css index ed0b827..4312f6a 100644 --- a/efactura-generator/styles/main.css +++ b/efactura-generator/styles/main.css @@ -152,6 +152,114 @@ input.date-input { flex-wrap: wrap; } +/* ---------------------------------------------------------------------------- + Actions overflow menu (header dropdown) + ---------------------------------------------------------------------------- */ +.actions-menu-wrapper { + position: relative; + display: inline-flex; +} + +.actions-menu-chevron { + margin-left: 4px; + font-size: 10px; + line-height: 1; + transition: transform 120ms ease; + display: inline-block; +} + +#btnActionsMenu[aria-expanded="true"] .actions-menu-chevron { + transform: rotate(180deg); +} + +.actions-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + min-width: 220px; + background-color: #1e293b; /* slate-800 — slightly lighter than header-bg slate-900 */ + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: var(--radius-sm); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.32); + padding: 4px 0; + z-index: 950; + display: flex; + flex-direction: column; + gap: 0; + animation: actions-menu-in 120ms ease-out; +} + +.actions-menu[hidden] { + display: none; +} + +@keyframes actions-menu-in { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.actions-menu-item { + appearance: none; + background: transparent; + border: none; + color: #f1f5f9; + text-align: left; + padding: 8px 14px; + font-family: inherit; + font-size: 13px; + line-height: 1.3; + cursor: pointer; + width: 100%; + transition: background-color 80ms ease, color 80ms ease; +} + +.actions-menu-item:hover, +.actions-menu-item:focus-visible { + background-color: rgba(255, 255, 255, 0.06); + outline: none; +} + +.actions-menu-item:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.actions-menu-item.is-success { + color: #86efac; /* green ghost — ANAF actions */ +} + +.actions-menu-item.is-success:hover, +.actions-menu-item.is-success:focus-visible { + background-color: rgba(134, 239, 172, 0.10); +} + +.actions-menu-divider { + height: 1px; + background-color: rgba(255, 255, 255, 0.10); + margin: 4px 0; +} + +.actions-menu-anaf { + display: flex; + flex-direction: column; +} + +.actions-menu-anaf[hidden] { + display: none; +} + +@media (max-width: 768px) { + .actions-menu-wrapper { + position: static; + } + .actions-menu { + left: var(--space-3); + right: var(--space-3); + top: auto; + margin-top: 6px; + } +} + /* ---------------------------------------------------------------------------- Cards / form sections ---------------------------------------------------------------------------- */