# Approccio C — ExcelJS Client-Side Generation

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Spostare la generazione Excel dal server (PHP/PhpSpreadsheet, lenta e soggetta a timeout) al browser (ExcelJS), lasciando al PHP solo la lettura rapida dei file.

**Architecture:**
1. `api/genera.php` — legge capitolato + preventivi via `lettore.php`, restituisce JSON `{struttura, imprese, medie, nomi, soglia}`
2. `assets/js/excel-generator.js` — ExcelJS genera il file `.xlsx` in browser (logica identica a PHP `foglioComparativo` + `foglioRiepilogo`)
3. `api/upload_output.php` — riceve il `.xlsx` generato, lo salva in `storage/output/{id}/`, aggiorna il DB
4. `assets/js/comparativo.js` — `generaComparativo()` orchestra i tre passi, mostra log in tempo reale

**Tech Stack:** PHP + lettore.php esistente · ExcelJS 0.18.x (MIT, CDN jsDelivr) · JS vanilla · SQLite/PDO

---

## Costanti colori (da config/app.php) — da replicare in JS

```
COL_HEADER_BG   = '1F4E79'   // header/navbar
COL_SECTION_BG  = '2E75B6'   // imprese
COL_ANOMALO_ALTO  = 'FFCCCC' // rosso
COL_ANOMALO_BASSO = 'CCFFCC' // verde
COL_MEDIA_BG    = 'E2EFDA'   // media/riepilogo
COL_MANCANTE_BG = 'FFF2CC'   // giallo
FONT_NAME       = 'Arial'
```

---

## Task 1: Modifica `api/genera.php` — solo lettura, ritorna JSON

**Files:**
- Modify: `comparativo/api/genera.php`

**Step 1: Sostituire il corpo di genera.php**

Il nuovo file deve:
- Richiedere login e metodo POST
- Leggere `comparativo_id` dal body JSON
- Caricare il comparativo dal DB (nome, soglia)
- Leggere il file capitolato dal DB + disco
- Leggere i preventivi dal DB + disco
- Chiamare `leggiCapitolato()` → struttura
- Chiamare `estraiFirma()` → firma
- Per ogni preventivo chiamare `leggiPreventivo()` → voci
- Calcolare le medie (stesso algoritmo del vecchio generatore)
- Rispondere con JSON:

```json
{
  "ok": true,
  "struttura": [...],
  "imprese": [{"nome": "Impresa A", "voci": {"1.1": 123.45, ...}}, ...],
  "medie": {"1.1": 100.00, ...},
  "nomi": ["Impresa A", "Impresa B"],
  "soglia": 20
}
```

**Step 2: Scrivere il codice completo di genera.php**

```php
<?php
require_once __DIR__ . '/../core/auth.php';
richiedeLogin();

header('Content-Type: application/json');

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    echo json_encode(['ok' => false, 'errore' => 'Metodo non consentito']);
    exit;
}

require_once __DIR__ . '/../core/lettore.php';

$input = json_decode(file_get_contents('php://input'), true);
$comparativoId = intval($input['comparativo_id'] ?? 0);
if (!$comparativoId) {
    echo json_encode(['ok' => false, 'errore' => 'comparativo_id mancante']);
    exit;
}

$db = getDB();

// Leggi comparativo
$stmt = $db->prepare('SELECT * FROM comparativi WHERE id = ?');
$stmt->execute([$comparativoId]);
$comp = $stmt->fetch();
if (!$comp) {
    echo json_encode(['ok' => false, 'errore' => 'Comparativo non trovato']);
    exit;
}
$soglia = intval($comp['soglia']);

// Leggi capitolato
$stmt = $db->prepare('SELECT * FROM file_comparativo WHERE comparativo_id = ? AND tipo = ? LIMIT 1');
$stmt->execute([$comparativoId, 'capitolato']);
$fileCapitolato = $stmt->fetch();
if (!$fileCapitolato || !file_exists($fileCapitolato['percorso'])) {
    echo json_encode(['ok' => false, 'errore' => 'Capitolato non trovato']);
    exit;
}

// Leggi preventivi
$stmt = $db->prepare('SELECT * FROM file_comparativo WHERE comparativo_id = ? AND tipo = ? ORDER BY id');
$stmt->execute([$comparativoId, 'preventivo']);
$filePreventivi = $stmt->fetchAll();
if (empty($filePreventivi)) {
    echo json_encode(['ok' => false, 'errore' => 'Nessun preventivo caricato']);
    exit;
}

// Lettura capitolato
$struttura = leggiCapitolato($fileCapitolato['percorso']);
$numeriVoce = [];
$righeDati = [];
foreach ($struttura as $r) {
    if ($r['tipo'] === 'dati') {
        $numeriVoce[$r['num_voce']] = true;
        $righeDati[] = $r;
    }
}

// Firma per riconoscimento intestazione preventivi
$firma = estraiFirma($fileCapitolato['percorso']);

// Lettura preventivi
$imprese = [];
$nomi = [];
foreach ($filePreventivi as $fp) {
    $nome = $fp['nome_impresa'] ?: 'Impresa';
    $nomi[] = $nome;
    $voci = leggiPreventivo($fp['percorso'], $firma, $numeriVoce);
    $imprese[] = ['nome' => $nome, 'voci' => $voci];
}

// Calcolo medie
$medie = [];
foreach ($righeDati as $r) {
    $num = $r['num_voce'];
    if (isset($medie[$num])) continue;
    $prezziValidi = [];
    foreach ($imprese as $imp) {
        $p = $imp['voci'][$num] ?? null;
        if ($p !== null && $p > 0) $prezziValidi[] = $p;
    }
    $medie[$num] = count($prezziValidi) > 0
        ? array_sum($prezziValidi) / count($prezziValidi)
        : null;
}

echo json_encode([
    'ok'       => true,
    'struttura' => $struttura,
    'imprese'   => $imprese,
    'medie'     => $medie,
    'nomi'      => $nomi,
    'soglia'    => $soglia,
]);
```

**Step 3: Verificare la risposta manualmente**

Aprire browser DevTools → Network → premere "GENERA COMPARATIVO" → verificare che la response sia JSON valido con `ok: true`, `struttura` array non vuoto, `imprese` array con `voci`.

---

## Task 2: Crea `api/upload_output.php` — salva xlsx dal browser

**Files:**
- Create: `comparativo/api/upload_output.php`

**Step 1: Scrivere upload_output.php**

```php
<?php
require_once __DIR__ . '/../core/auth.php';
richiedeLogin();

header('Content-Type: application/json');

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    echo json_encode(['ok' => false, 'errore' => 'Metodo non consentito']);
    exit;
}

$comparativoId = intval($_POST['comparativo_id'] ?? 0);
if (!$comparativoId) {
    echo json_encode(['ok' => false, 'errore' => 'comparativo_id mancante']);
    exit;
}

if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
    echo json_encode(['ok' => false, 'errore' => 'File non ricevuto']);
    exit;
}

$db = getDB();

// Verifica che il comparativo esista e appartenga all'utente
$stmt = $db->prepare('SELECT id FROM comparativi WHERE id = ?');
$stmt->execute([$comparativoId]);
if (!$stmt->fetch()) {
    echo json_encode(['ok' => false, 'errore' => 'Comparativo non trovato']);
    exit;
}

// Salva il file
$outputDir = OUTPUT_PATH . "/$comparativoId";
if (!is_dir($outputDir)) mkdir($outputDir, 0755, true);

$dataFile   = date('Y-m-d_His');
$outputPath = "$outputDir/comparativo_{$dataFile}.xlsx";

if (!move_uploaded_file($_FILES['file']['tmp_name'], $outputPath)) {
    echo json_encode(['ok' => false, 'errore' => 'Errore salvataggio file']);
    exit;
}

// Aggiorna stato DB
$db->prepare('UPDATE comparativi SET stato = ?, aggiornato_il = ? WHERE id = ?')
   ->execute(['generato', date('Y-m-d H:i:s'), $comparativoId]);

require_once __DIR__ . '/../core/logger.php';
logAzione('info', 'comparativo_generato', "ID: $comparativoId (client-side)", intval($_SESSION['utente_id']));

echo json_encode(['ok' => true, 'path' => basename($outputPath)]);
```

**Step 2: Verificare che download.php funzioni ancora**

Il file `api/download.php` cerca il file più recente in `storage/output/{id}/comparativo_*.xlsx`. Verificare che questa logica continui a funzionare con i file salvati da `upload_output.php`.

---

## Task 3: Crea `assets/js/excel-generator.js` — generazione ExcelJS

**Files:**
- Create: `comparativo/assets/js/excel-generator.js`

Questo è il task più complesso. Il file deve replicare esattamente la logica di `core/generatore.php` (`foglioComparativo` + `foglioRiepilogo`) usando ExcelJS.

**Step 1: Struttura base del file**

```javascript
// excel-generator.js — generazione Excel client-side con ExcelJS
// Replica la logica di core/generatore.php (foglioComparativo + foglioRiepilogo)

// Colori (identici a config/app.php)
const COL_HEADER_BG    = '1F4E79';
const COL_SECTION_BG   = '2E75B6';
const COL_ANOMALO_ALTO  = 'FFCCCC';
const COL_ANOMALO_BASSO = 'CCFFCC';
const COL_MEDIA_BG     = 'E2EFDA';
const COL_MANCANTE_BG  = 'FFF2CC';
const FONT_NAME        = 'Arial';

/**
 * Genera il file Excel comparativo in browser.
 * @param {object} dati - {struttura, imprese, medie, nomi, soglia}
 * @param {function} onLog - callback(msg) per aggiornamenti log
 * @returns {Promise<Blob>} - file .xlsx come Blob
 */
async function generaExcel(dati, onLog = () => {}) {
    const { struttura, imprese, medie, nomi, soglia } = dati;
    const nImprese = nomi.length;

    const wb = new ExcelJS.Workbook();
    wb.creator = 'Comparativo Preventivi';
    wb.created = new Date();

    onLog('Scrittura foglio Comparativo...');
    await _foglioComparativo(wb, struttura, imprese, medie, nImprese, soglia);

    onLog('Scrittura foglio Riepilogo...');
    await _foglioRiepilogo(wb, struttura, imprese, medie, nImprese);

    onLog('Generazione file Excel...');
    const buffer = await wb.xlsx.writeBuffer();
    return new Blob([buffer], {
        type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
    });
}
```

**Step 2: Helper di stile**

```javascript
// Helper: bordo sottile su tutti i lati
function _bordo(color = 'BFBFBF') {
    const s = { style: 'thin', color: { argb: 'FF' + color } };
    return { top: s, left: s, right: s, bottom: s };
}

// Helper: sfondo solido
function _fill(hex) {
    return { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF' + hex } };
}

// Helper: font base
function _font(opts = {}) {
    return { name: FONT_NAME, size: 9, ...opts };
}

// Helper: formato numero italiano
function _numFmt() { return '#,##0.00'; }
function _eurFmt() { return '€#,##0.00'; }
```

**Step 3: `_foglioComparativo()` — struttura colonne e intestazioni**

La struttura colonne segue esattamente PHP:
- Col A: numero voce (larghezza 8)
- Col B: descrizione (larghezza 52)
- Col C: U.M. (larghezza 9)
- Col D: quantità (larghezza 11)
- Col E..E+nImprese-1: prezzi unitari imprese (larghezza 14 ciascuna)
- Col E+nImprese: media P.U. (larghezza 14)
- Col E+nImprese+1..E+nImprese+nImprese: totali imprese (larghezza 14 ciascuna)
- Ultima col: note anomalie (larghezza 32)

```javascript
async function _foglioComparativo(wb, struttura, imprese, medie, nImprese, soglia) {
    const ws = wb.addWorksheet('Comparativo');

    const colPuInizio  = 5;                        // colonna E (1-based)
    const colMedia     = colPuInizio + nImprese;
    const colTotInizio = colMedia + 1;
    const colNote      = colTotInizio + nImprese;

    // Larghezze
    ws.getColumn(1).width = 8;
    ws.getColumn(2).width = 52;
    ws.getColumn(3).width = 9;
    ws.getColumn(4).width = 11;
    for (let i = 0; i < nImprese; i++) {
        ws.getColumn(colPuInizio + i).width = 14;
    }
    ws.getColumn(colMedia).width = 14;
    for (let i = 0; i < nImprese; i++) {
        ws.getColumn(colTotInizio + i).width = 14;
    }
    ws.getColumn(colNote).width = 32;

    // Riga 1: Titolo
    const dataOra = new Date().toLocaleString('it-IT');
    ws.getRow(1).height = 28;
    ws.mergeCells(1, 1, 1, colNote);
    const r1 = ws.getCell(1, 1);
    r1.value = `COMPARATIVO OFFERTE — ${dataOra}`;
    r1.font = _font({ bold: true, size: 12, color: { argb: 'FFFFFFFF' } });
    r1.fill = _fill(COL_HEADER_BG);
    r1.alignment = { horizontal: 'left', indent: 2 };

    // Riga 2: Legenda
    ws.getRow(2).height = 16;
    ws.mergeCells(2, 1, 2, colNote);
    const r2 = ws.getCell(2, 1);
    r2.value = `Soglia anomalie: ±${soglia}%  |  ROSSO = prezzo alto   VERDE = prezzo basso   GIALLO = voce mancante (usata media)`;
    r2.font = _font({ size: 8, italic: true, color: { argb: 'FF404040' } });
    r2.fill = _fill('D6E4F0');
    r2.alignment = { horizontal: 'left', indent: 2 };

    // Riga 3: Raggruppamento colonne
    ws.getRow(3).height = 18;
    // Colonne A-D: sfondo header
    for (let c = 1; c <= 4; c++) {
        ws.getCell(3, c).fill = _fill(COL_HEADER_BG);
        ws.getCell(3, c).border = _bordo();
    }
    // "PREZZI UNITARI (€)" merge su colonne PU
    ws.mergeCells(3, colPuInizio, 3, colPuInizio + nImprese - 1);
    const r3pu = ws.getCell(3, colPuInizio);
    r3pu.value = 'PREZZI UNITARI (€)';
    r3pu.font = _font({ bold: true, color: { argb: 'FFFFFFFF' } });
    r3pu.fill = _fill(COL_SECTION_BG);
    r3pu.alignment = { horizontal: 'center', vertical: 'middle' };
    for (let c = colPuInizio; c <= colPuInizio + nImprese - 1; c++) {
        ws.getCell(3, c).border = _bordo();
    }
    // Cella media: verde scuro
    ws.getCell(3, colMedia).fill = _fill('375623');
    ws.getCell(3, colMedia).border = _bordo();
    // "TOTALI (€)" merge su colonne totali
    ws.mergeCells(3, colTotInizio, 3, colTotInizio + nImprese - 1);
    const r3tot = ws.getCell(3, colTotInizio);
    r3tot.value = 'TOTALI (€)';
    r3tot.font = _font({ bold: true, color: { argb: 'FFFFFFFF' } });
    r3tot.fill = _fill(COL_SECTION_BG);
    r3tot.alignment = { horizontal: 'center', vertical: 'middle' };
    for (let c = colTotInizio; c <= colTotInizio + nImprese - 1; c++) {
        ws.getCell(3, c).border = _bordo();
    }
    // Cella note riga 3
    ws.getCell(3, colNote).fill = _fill(COL_HEADER_BG);
    ws.getCell(3, colNote).border = _bordo();

    // Riga 4: Intestazioni colonne
    ws.getRow(4).height = 30;
    const intABCD = ['N°', 'DESCRIZIONE LAVORAZIONE', 'U.M.', 'QUANTITÀ'];
    for (let c = 1; c <= 4; c++) {
        const cell = ws.getCell(4, c);
        cell.value = intABCD[c - 1];
        cell.font = _font({ bold: true, color: { argb: 'FFFFFFFF' } });
        cell.fill = _fill(COL_HEADER_BG);
        cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
        cell.border = _bordo();
    }
    // Nomi imprese PU
    for (let i = 0; i < nImprese; i++) {
        const cell = ws.getCell(4, colPuInizio + i);
        cell.value = imprese[i].nome.toUpperCase();
        cell.font = _font({ bold: true, color: { argb: 'FFFFFFFF' } });
        cell.fill = _fill(COL_SECTION_BG);
        cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
        cell.border = _bordo();
    }
    // MEDIA P.U.
    const cellMedia4 = ws.getCell(4, colMedia);
    cellMedia4.value = 'MEDIA P.U.';
    cellMedia4.font = _font({ bold: true, color: { argb: 'FF1F4E79' } });
    cellMedia4.fill = _fill(COL_MEDIA_BG);
    cellMedia4.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
    cellMedia4.border = _bordo();
    // Nomi imprese Totali
    for (let i = 0; i < nImprese; i++) {
        const cell = ws.getCell(4, colTotInizio + i);
        cell.value = imprese[i].nome.toUpperCase();
        cell.font = _font({ bold: true, color: { argb: 'FFFFFFFF' } });
        cell.fill = _fill(COL_SECTION_BG);
        cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
        cell.border = _bordo();
    }
    // NOTE ANOMALIE
    const cellNote4 = ws.getCell(4, colNote);
    cellNote4.value = 'NOTE ANOMALIE';
    cellNote4.font = _font({ bold: true, color: { argb: 'FFFFFFFF' } });
    cellNote4.fill = _fill(COL_HEADER_BG);
    cellNote4.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
    cellNote4.border = _bordo();

    // Freeze riga 1-4
    ws.views = [{ state: 'frozen', xSplit: 0, ySplit: 4 }];

    // ---- Righe dati ----
    let rigaCorrente = 5;
    const righeTotali = []; // righe con dati per formula totale finale

    for (const riga of struttura) {
        if (riga.tipo === 'vuota') {
            ws.getRow(rigaCorrente).height = 8;
            rigaCorrente++;
            continue;
        }

        if (riga.tipo === 'testo') {
            ws.getRow(rigaCorrente).height = 14;
            if (riga.col_a) {
                const ca = ws.getCell(rigaCorrente, 1);
                ca.value = riga.col_a;
                ca.font = _font({ bold: true, color: { argb: 'FF' + COL_HEADER_BG } });
                ca.alignment = { horizontal: 'center', vertical: 'middle' };
            }
            if (riga.col_b) {
                const cb = ws.getCell(rigaCorrente, 2);
                cb.value = riga.col_b;
                cb.font = _font({ color: { argb: 'FF404040' } });
                cb.alignment = { horizontal: 'left', vertical: 'middle', wrapText: true };
            }
            rigaCorrente++;
            continue;
        }

        if (riga.tipo === 'dati') {
            const numVoce = riga.num_voce ?? null;
            const qta = riga.col_d;
            const media = numVoce !== null ? (medie[numVoce] ?? null) : null;

            ws.getRow(rigaCorrente).height = 16;

            // Col A
            if (riga.col_a) {
                const ca = ws.getCell(rigaCorrente, 1);
                ca.value = riga.col_a;
                ca.font = _font({ bold: true, color: { argb: 'FF' + COL_HEADER_BG } });
                ca.alignment = { horizontal: 'center', vertical: 'middle' };
                ca.fill = _fill('EEF4FB');
                ca.border = _bordo();
            }
            // Col B
            if (riga.col_b) {
                const cb = ws.getCell(rigaCorrente, 2);
                cb.value = riga.col_b;
                cb.font = _font({ bold: true });
                cb.alignment = { horizontal: 'left', vertical: 'middle', wrapText: true };
                cb.fill = _fill('EEF4FB');
                cb.border = _bordo();
            }
            // Col C: U.M.
            const cc = ws.getCell(rigaCorrente, 3);
            cc.value = riga.col_c ?? '';
            cc.font = _font();
            cc.alignment = { horizontal: 'center', vertical: 'middle' };
            cc.border = _bordo();
            // Col D: quantità
            const cd = ws.getCell(rigaCorrente, 4);
            cd.value = qta;
            cd.font = _font();
            cd.numFmt = _numFmt();
            cd.alignment = { horizontal: 'right', vertical: 'middle' };
            cd.border = _bordo();

            // Colonne PU
            const noteAnomalie = [];
            for (let i = 0; i < nImprese; i++) {
                const prezzo = numVoce !== null ? (imprese[i].voci[numVoce] ?? null) : null;
                const mancante = (prezzo === null || prezzo === 0);
                const prezzoDisplay = (mancante && media) ? media : (prezzo || 0);

                let eAlto = false, eBasso = false;
                if (media && media > 0 && !mancante && prezzoDisplay) {
                    const delta = (prezzoDisplay - media) / media * 100;
                    if (delta > soglia) {
                        eAlto = true;
                        noteAnomalie.push(`${imprese[i].nome}: +${Math.round(delta)}% (ALTO)`);
                    } else if (delta < -soglia) {
                        eBasso = true;
                        noteAnomalie.push(`${imprese[i].nome}: ${Math.round(delta)}% (BASSO)`);
                    }
                }

                const fontColor = eAlto ? 'C00000' : eBasso ? '375623' : mancante ? '7F7F7F' : '000000';
                const fillColor = mancante ? COL_MANCANTE_BG : eAlto ? COL_ANOMALO_ALTO : eBasso ? COL_ANOMALO_BASSO : 'FFFFFF';

                const cellPu = ws.getCell(rigaCorrente, colPuInizio + i);
                cellPu.value = prezzoDisplay;
                cellPu.font = _font({ bold: eAlto || eBasso, italic: mancante, color: { argb: 'FF' + fontColor } });
                cellPu.numFmt = _numFmt();
                cellPu.alignment = { horizontal: 'right', vertical: 'middle' };
                cellPu.fill = _fill(fillColor);
                cellPu.border = _bordo();
            }

            // Colonna Media P.U.
            const cellMed = ws.getCell(rigaCorrente, colMedia);
            cellMed.value = media !== null ? media : '—';
            if (media !== null) cellMed.numFmt = _numFmt();
            cellMed.font = _font({ bold: true, color: { argb: 'FF375623' } });
            cellMed.fill = _fill(COL_MEDIA_BG);
            cellMed.alignment = { horizontal: 'right', vertical: 'middle' };
            cellMed.border = _bordo();

            // Colonne Totali — formula Excel =D{r}*{colPu}{r}
            for (let i = 0; i < nImprese; i++) {
                const colPuLet = _colLetter(colPuInizio + i);
                const cellTot = ws.getCell(rigaCorrente, colTotInizio + i);
                cellTot.value = { formula: `D${rigaCorrente}*${colPuLet}${rigaCorrente}` };
                const prezzoI = numVoce !== null ? (imprese[i].voci[numVoce] ?? null) : null;
                const mancanteI = (prezzoI === null || prezzoI === 0);
                cellTot.numFmt = _eurFmt();
                cellTot.font = _font();
                cellTot.alignment = { horizontal: 'right', vertical: 'middle' };
                cellTot.fill = _fill(mancanteI ? COL_MANCANTE_BG : 'F8F8F8');
                cellTot.border = _bordo();
            }

            // Colonna Note
            const cellNoteR = ws.getCell(rigaCorrente, colNote);
            cellNoteR.value = noteAnomalie.join(' | ');
            cellNoteR.font = _font({
                size: 8, italic: true,
                color: { argb: 'FF' + (noteAnomalie.length > 0 ? 'C00000' : '7F7F7F') }
            });
            cellNoteR.alignment = { horizontal: 'left', vertical: 'middle', wrapText: true };
            cellNoteR.border = _bordo();

            righeTotali.push(rigaCorrente);
            rigaCorrente++;
        }
    }

    // ---- Riga totale finale ----
    rigaCorrente++;
    ws.getRow(rigaCorrente).height = 26;
    ws.mergeCells(rigaCorrente, 1, rigaCorrente, 4);
    const cellTotLabel = ws.getCell(rigaCorrente, 1);
    cellTotLabel.value = 'TOTALE OFFERTA (IVA ESCLUSA)';
    cellTotLabel.font = _font({ bold: true, size: 10, color: { argb: 'FFFFFFFF' } });
    cellTotLabel.fill = _fill(COL_HEADER_BG);
    cellTotLabel.alignment = { horizontal: 'right', vertical: 'middle' };
    cellTotLabel.border = _bordo();

    // PU vuote
    for (let i = 0; i < nImprese; i++) {
        const c = ws.getCell(rigaCorrente, colPuInizio + i);
        c.value = '—'; c.font = _font({ bold: true, color: { argb: 'FFFFFFFF' } });
        c.fill = _fill(COL_HEADER_BG); c.alignment = { horizontal: 'center', vertical: 'middle' };
        c.border = _bordo();
    }
    // Media vuota
    const cMed = ws.getCell(rigaCorrente, colMedia);
    cMed.value = '—'; cMed.font = _font({ bold: true, color: { argb: 'FFFFFFFF' } });
    cMed.fill = _fill(COL_HEADER_BG); cMed.alignment = { horizontal: 'center', vertical: 'middle' };
    cMed.border = _bordo();

    // Totali per impresa — formula somma
    for (let i = 0; i < nImprese; i++) {
        const colLet = _colLetter(colTotInizio + i);
        const formulaParts = righeTotali.map(r => `${colLet}${r}`);
        const cellTotFin = ws.getCell(rigaCorrente, colTotInizio + i);
        cellTotFin.value = { formula: '=' + formulaParts.join('+') };
        cellTotFin.font = _font({ bold: true, size: 10, color: { argb: 'FFFFFFFF' } });
        cellTotFin.fill = _fill(COL_HEADER_BG);
        cellTotFin.numFmt = _eurFmt();
        cellTotFin.alignment = { horizontal: 'right', vertical: 'middle' };
        cellTotFin.border = _bordo();
    }
    // Note vuota
    ws.getCell(rigaCorrente, colNote).fill = _fill(COL_HEADER_BG);
    ws.getCell(rigaCorrente, colNote).border = _bordo();
}
```

**Step 4: `_foglioRiepilogo()` — totali per sezione**

```javascript
async function _foglioRiepilogo(wb, struttura, imprese, medie, nImprese) {
    const ws = wb.addWorksheet('Riepilogo');
    const nCols = nImprese + 2;

    // Larghezze
    ws.getColumn(1).width = 38;
    for (let i = 1; i <= nImprese + 1; i++) {
        ws.getColumn(i + 1).width = 18;
    }

    // Riga 1: Titolo
    ws.mergeCells(1, 1, 1, nCols);
    const r1 = ws.getCell(1, 1);
    r1.value = 'RIEPILOGO PER SEZIONE';
    r1.font = _font({ bold: true, size: 12, color: { argb: 'FFFFFFFF' } });
    r1.fill = _fill(COL_HEADER_BG);
    r1.alignment = { horizontal: 'left', vertical: 'middle' };

    // Riga 2: Intestazioni
    ws.getRow(2).height = 25;
    const cellSez = ws.getCell(2, 1);
    cellSez.value = 'SEZIONE';
    cellSez.font = _font({ bold: true, color: { argb: 'FFFFFFFF' } });
    cellSez.fill = _fill(COL_HEADER_BG);
    cellSez.alignment = { horizontal: 'left', vertical: 'middle' };
    cellSez.border = _bordo();

    for (let i = 0; i < nImprese; i++) {
        const c = ws.getCell(2, i + 2);
        c.value = imprese[i].nome.toUpperCase();
        c.font = _font({ bold: true, color: { argb: 'FFFFFFFF' } });
        c.fill = _fill(COL_SECTION_BG);
        c.alignment = { horizontal: 'center', vertical: 'middle' };
        c.border = _bordo();
    }
    const cellScarto2 = ws.getCell(2, nImprese + 2);
    cellScarto2.value = 'SCARTO MAX-MIN';
    cellScarto2.font = _font({ bold: true, color: { argb: 'FF1F4E79' } });
    cellScarto2.fill = _fill(COL_MEDIA_BG);
    cellScarto2.alignment = { horizontal: 'center', vertical: 'middle' };
    cellScarto2.border = _bordo();

    // Identificazione sezioni (righe testo precedute da riga vuota)
    const sezioniTrovate = [];
    for (let idx = 0; idx < struttura.length; idx++) {
        const r = struttura[idx];
        if (r.tipo === 'testo' && r.col_b) {
            const prec = idx > 0 ? struttura[idx - 1] : null;
            if (prec && prec.tipo === 'vuota') {
                sezioniTrovate.push(r.col_b);
            }
        }
    }

    // Calcolo totali per sezione
    const totaliSezione = {};
    for (const s of sezioniTrovate) {
        totaliSezione[s] = {};
        for (const imp of imprese) totaliSezione[s][imp.nome] = 0;
    }
    totaliSezione['TOTALE GENERALE'] = {};
    for (const imp of imprese) totaliSezione['TOTALE GENERALE'][imp.nome] = 0;

    let sezioneCorrente = null;
    for (const r of struttura) {
        if (r.tipo === 'testo' && r.col_b && totaliSezione[r.col_b] !== undefined) {
            sezioneCorrente = r.col_b;
        } else if (r.tipo === 'dati') {
            const num = r.num_voce ?? null;
            const qta = r.col_d ?? 0;
            for (const imp of imprese) {
                let prezzo = num !== null ? (imp.voci[num] ?? null) : null;
                if (prezzo === null || prezzo === 0) {
                    prezzo = num !== null ? (medie[num] ?? 0) : 0;
                }
                const totaleVoce = qta * prezzo;
                if (sezioneCorrente && totaliSezione[sezioneCorrente]) {
                    totaliSezione[sezioneCorrente][imp.nome] += totaleVoce;
                }
                totaliSezione['TOTALE GENERALE'][imp.nome] += totaleVoce;
            }
        }
    }

    // Scrittura righe
    let rigaCorrente = 3;
    let rigaIdx = 0;
    for (const [nomeSezione, totaliImp] of Object.entries(totaliSezione)) {
        const eTotale = nomeSezione === 'TOTALE GENERALE';
        const colSfondo = eTotale ? COL_HEADER_BG : (rigaIdx % 2 === 0 ? 'F2F2F2' : 'FFFFFF');
        const colTesto = eTotale ? 'FFFFFF' : '000000';

        ws.getRow(rigaCorrente).height = 18;

        const cellNomeSez = ws.getCell(rigaCorrente, 1);
        cellNomeSez.value = nomeSezione;
        cellNomeSez.font = _font({ bold: eTotale, color: { argb: 'FF' + colTesto } });
        cellNomeSez.fill = _fill(colSfondo);
        cellNomeSez.alignment = { horizontal: 'left', vertical: 'middle', indent: 1 };
        cellNomeSez.border = _bordo();

        const valori = [];
        for (let i = 0; i < nImprese; i++) {
            const val = totaliImp[imprese[i].nome];
            valori.push(val);
            const c = ws.getCell(rigaCorrente, i + 2);
            c.value = val;
            c.font = _font({ bold: eTotale, color: { argb: 'FF' + colTesto } });
            c.fill = _fill(colSfondo);
            c.numFmt = _eurFmt();
            c.alignment = { horizontal: 'right', vertical: 'middle' };
            c.border = _bordo();
        }

        // Scarto max-min
        let scartoTesto = '—';
        if (valori.length >= 2) {
            const scarto = Math.max(...valori) - Math.min(...valori);
            const minVal = Math.min(...valori);
            const scartoPct = minVal > 0 ? (scarto / minVal * 100) : 0;
            scartoTesto = `€${scarto.toLocaleString('it-IT', {minimumFractionDigits:0, maximumFractionDigits:0})}  (${scartoPct.toLocaleString('it-IT', {minimumFractionDigits:1, maximumFractionDigits:1})}%)`;
        }
        const scartoPctNum = valori.length >= 2 && Math.min(...valori) > 0
            ? (Math.max(...valori) - Math.min(...valori)) / Math.min(...valori) * 100
            : 0;
        const fontColorScarto = eTotale ? colTesto : (scartoPctNum > 15 ? 'C00000' : '000000');
        const fillScarto = eTotale ? colSfondo : COL_MEDIA_BG;

        const cellSc = ws.getCell(rigaCorrente, nImprese + 2);
        cellSc.value = scartoTesto;
        cellSc.font = _font({ bold: eTotale, color: { argb: 'FF' + fontColorScarto } });
        cellSc.fill = _fill(fillScarto);
        cellSc.alignment = { horizontal: 'right', vertical: 'middle' };
        cellSc.border = _bordo();

        rigaCorrente++;
        rigaIdx++;
    }

    // Freeze
    ws.views = [{ state: 'frozen', xSplit: 0, ySplit: 2 }];
}
```

**Step 5: Helper lettera colonna**

```javascript
// Converte indice 1-based in lettera colonna Excel (A, B, ..., Z, AA, AB, ...)
function _colLetter(n) {
    let s = '';
    while (n > 0) {
        n--;
        s = String.fromCharCode(65 + (n % 26)) + s;
        n = Math.floor(n / 26);
    }
    return s;
}
```

---

## Task 4: Modifica `assets/js/comparativo.js` — nuova orchestrazione

**Files:**
- Modify: `comparativo/assets/js/comparativo.js`

**Step 1: Sostituire `generaComparativo()`**

```javascript
async function generaComparativo() {
    const btn = document.getElementById('btn-genera');
    const testoOriginale = btn.innerHTML;
    btn.disabled = true;
    btn.innerHTML = '<span class="spinner-border spinner-sm" role="status"></span> Generazione in corso...';

    // Mostra area log
    const logCard = document.getElementById('log-card');
    const logContainer = document.getElementById('log-container');
    logCard.style.display = '';
    logContainer.innerHTML = '';

    function appendLog(msg, cls = 'log-line') {
        const div = document.createElement('div');
        div.className = cls;
        div.textContent = msg;
        logContainer.appendChild(div);
        logContainer.scrollTop = logContainer.scrollHeight;
    }

    try {
        // 1. Leggi dati dal server (PHP lettura Excel)
        appendLog('Lettura file dal server...');
        const dati = await apiFetch('../api/genera.php', {
            method: 'POST',
            body: JSON.stringify({ comparativo_id: COMP_ID })
        });

        if (!dati || !dati.ok) {
            appendLog(dati?.errore || 'Errore lettura dati', 'log-line log-errore');
            mostraAlert(dati?.errore || 'Errore lettura dati');
            return;
        }

        appendLog(`Capitolato: ${dati.struttura.filter(r => r.tipo === 'dati').length} voci dati.`);
        appendLog(`Preventivi: ${dati.nomi.length} imprese.`);

        // 2. Genera Excel in browser
        appendLog('Generazione Excel in corso...');
        const blob = await generaExcel(dati, appendLog);

        // 3. Mostra anteprima
        appendLog('Rendering anteprima...');
        renderPreview(
            _costruisciPreview(dati.struttura, dati.imprese, dati.medie, dati.soglia),
            dati.nomi
        );

        // 4. Carica file sul server
        appendLog('Caricamento file sul server...');
        const formData = new FormData();
        formData.append('comparativo_id', COMP_ID);
        formData.append('file', blob, `comparativo_${Date.now()}.xlsx`);

        const resp = await fetch('../api/upload_output.php', { method: 'POST', body: formData });
        const uploadData = await resp.json();

        if (!uploadData || !uploadData.ok) {
            appendLog(uploadData?.errore || 'Errore upload', 'log-line log-errore');
            mostraAlert(uploadData?.errore || 'Errore caricamento sul server');
            return;
        }

        // 5. Aggiorna UI
        appendLog('Completato!', 'log-line log-ok');
        document.getElementById('btn-download').style.display = '';
        const badge = document.getElementById('badge-stato');
        badge.textContent = 'generato';
        badge.className = 'badge bg-success';

    } catch (e) {
        console.error(e);
        appendLog('Errore: ' + e.message, 'log-line log-errore');
        mostraAlert('Errore durante la generazione: ' + e.message);
    } finally {
        btn.disabled = false;
        btn.innerHTML = testoOriginale;
    }
}
```

**Step 2: Aggiungere `_costruisciPreview()` per calcolare flag anomalie**

```javascript
// Costruisce i dati preview con flag anomalie (per renderPreview)
function _costruisciPreview(struttura, imprese, medie, soglia) {
    const preview = [];
    for (const r of struttura) {
        if (r.tipo === 'vuota') { preview.push({ tipo: 'vuota' }); continue; }
        if (r.tipo === 'testo') {
            preview.push({ tipo: 'testo', col_a: r.col_a, col_b: r.col_b });
            continue;
        }
        if (r.tipo === 'dati') {
            const numVoce = r.num_voce ?? null;
            const media = numVoce !== null ? (medie[numVoce] ?? null) : null;
            const prezziArr = [];
            for (const imp of imprese) {
                const prezzo = numVoce !== null ? (imp.voci[numVoce] ?? null) : null;
                const mancante = (prezzo === null || prezzo === 0);
                const prezzoDisplay = (mancante && media) ? media : (prezzo || 0);
                let flag = null;
                if (mancante) {
                    flag = 'mancante';
                } else if (media && media > 0) {
                    const delta = (prezzoDisplay - media) / media * 100;
                    if (delta > soglia)       flag = 'alto';
                    else if (delta < -soglia) flag = 'basso';
                }
                prezziArr.push({ valore: prezzoDisplay, flag });
            }
            preview.push({
                tipo: 'dati',
                col_a: r.col_a, col_b: r.col_b, col_c: r.col_c, col_d: r.col_d,
                media: media !== null ? Math.round(media * 100) / 100 : null,
                prezzi: prezziArr,
            });
        }
    }
    return preview;
}
```

**Step 3: Rimuovere le funzioni non più usate**

Rimuovere da `comparativo.js`:
- `aggiornaVociPreventivi()` — non serve più (il server aggiorna i conteggi solo tramite genera_job.php che non usiamo più)

Le funzioni di upload file (`uploadCapitolato`, `uploadPreventivi`, `rimuoviFile`, `aggiornaImpresa`) rimangono invariate.

---

## Task 5: Aggiorna `pages/comparativo.php` — aggiungi ExcelJS

**Files:**
- Modify: `comparativo/pages/comparativo.php`

**Step 1: Aggiungere ExcelJS e excel-generator.js agli script**

Aggiungere prima di `</body>`, dopo `bootstrap.bundle.min.js` e `app.js`:

```html
<script src="https://cdn.jsdelivr.net/npm/exceljs@4.4.0/dist/exceljs.min.js"></script>
<script src="../assets/js/excel-generator.js"></script>
<script src="../assets/js/comparativo.js"></script>
```

(Sostituire il tag script `comparativo.js` esistente, aggiungendo exceljs e excel-generator prima di esso.)

---

## Task 6: Rimuovi infrastruttura background job (opzionale cleanup)

**Files:**
- Delete or simplify: `comparativo/api/stato.php`
- Delete or simplify: `comparativo/core/genera_job.php`

**Nota:** Questi file non vengono più usati. Possono essere lasciati inutilizzati senza problemi oppure rimossi. Rimuoverli riduce la superficie del codebase. **Non eliminare `core/generatore.php`** — potrebbe tornare utile.

**Step 1: Rimuovi riferimenti in comparativo.js**

Verificare che in `comparativo.js` non ci siano più riferimenti a `stato.php` o polling. Le funzioni di polling `setInterval` e il file `_stato.json` non sono più usati.

---

## Esecuzione

Ordine consigliato (Tasks 1 e 2 possono andare in parallelo, Task 3 è indipendente, Task 4 dipende da 3, Task 5 da 4):

```
[Task 1: genera.php] ─┐
[Task 2: upload_output.php] ─┤─→ [Task 4: comparativo.js] → [Task 5: comparativo.php] → [Task 6: cleanup]
[Task 3: excel-generator.js] ─┘
```

Tasks 1, 2, 3 possono essere sviluppati in parallelo da agenti separati.
Task 4 dipende logicamente da Task 3 (usa `generaExcel()`).
Task 5 dipende da Task 4 (include il file).
Task 6 è cleanup finale indipendente.
