diff --git a/docs/superpowers/plans/2026-03-20-anagrafica-redesign.md b/docs/superpowers/plans/2026-03-20-anagrafica-redesign.md new file mode 100644 index 0000000..a39e9f0 --- /dev/null +++ b/docs/superpowers/plans/2026-03-20-anagrafica-redesign.md @@ -0,0 +1,852 @@ +# Anagrafica Redesign + Footer Branding — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ridisegnare la Sezione 1 (Anagrafica) del report PDF con stile "Ibrido elegante", riordinare i campi come nel vecchio report DevExpress, portare la tabella Sottostanti in pagina 1, e aggiungere il parametro `?branding=true` per il footer Smart Roots. + +**Architecture:** Quattro modifiche indipendenti in cascata: (1) propagazione del flag `ShowBranding` dal controller fino a `CertificateReportData`; (2) nuovi colori e helper `DrawFooter` in `PdfTheme`; (3) riscrittura completa di `AnagraficaSectionRenderer`; (4) aggiornamento cache key per includere il parametro branding. + +**Tech Stack:** ASP.NET Core 8, Syncfusion.Pdf.Net.Core v33, C# 12. Nessun test runner — verifica manuale via `dotnet run` + browser con ISIN reale. + +**Spec:** `docs/superpowers/specs/2026-03-20-anagrafica-redesign-design.md` + +--- + +## File Map + +| File | Azione | Responsabilità | +|------|--------|----------------| +| `CertReports.Syncfusion/Services/Interfaces/IServices.cs` | Modify | Aggiungere overload `GenerateReportAsync(string isin, bool showBranding)` a `IReportOrchestrator` | +| `CertReports.Syncfusion/Models/CertificateModels.cs` | Modify | Aggiungere `bool ShowBranding` a `CertificateReportData` | +| `CertReports.Syncfusion/Controllers/ReportController.cs` | Modify | Leggere `?branding=` da query string, passarlo all'orchestratore | +| `CertReports.Syncfusion/Services/Implementations/ReportOrchestrator.cs` | Modify | Firma `GenerateReportAsync` con `bool showBranding`, impostare `reportData.ShowBranding` | +| `CertReports.Syncfusion/Helpers/PdfTheme.cs` | Modify | Nuovi colori, riduzione margini/row height, metodo `DrawFooter()` | +| `CertReports.Syncfusion/Services/Implementations/AnagraficaSectionRenderer.cs` | Rewrite | Nuova struttura 3 sezioni, stile C, sottostanti in pagina 1 | + +--- + +## Task 1: Propagazione flag `ShowBranding` + +Aggiunge `ShowBranding` al modello dati e al flusso controller → orchestratore, senza ancora usarlo nel rendering. + +**Files:** +- Modify: `CertReports.Syncfusion/Models/CertificateModels.cs` +- Modify: `CertReports.Syncfusion/Services/Interfaces/IServices.cs` +- Modify: `CertReports.Syncfusion/Controllers/ReportController.cs` +- Modify: `CertReports.Syncfusion/Services/Implementations/ReportOrchestrator.cs` + +- [ ] **Step 1.1 — Aggiungere `ShowBranding` a `CertificateReportData`** + + In `Models/CertificateModels.cs`, nella classe `CertificateReportData` (riga 152), aggiungere la proprietà: + + ```csharp + public class CertificateReportData + { + public CertificateInfo Info { get; set; } = new(); + public List Eventi { get; set; } = new(); + public ScenarioAnalysis Scenario { get; set; } = new(); + public byte[]? ChartImage { get; set; } + public bool ShowBranding { get; set; } = false; // ← aggiunto + } + ``` + +- [ ] **Step 1.2 — Aggiornare l'interfaccia `IReportOrchestrator`** + + In `Services/Interfaces/IServices.cs`, aggiungere il parametro `showBranding`: + + ```csharp + public interface IReportOrchestrator + { + Task GenerateReportAsync(string isin, bool showBranding = false); + } + ``` + +- [ ] **Step 1.3 — Aggiornare `ReportOrchestrator.GenerateReportAsync`** + + In `Services/Implementations/ReportOrchestrator.cs`: + + ```csharp + public async Task GenerateReportAsync(string isin, bool showBranding = false) + { + // ── Cache check (chiave include branding) ───────────────────────── + var cacheKey = showBranding ? $"{isin}:branded" : isin; + var cached = _cache.Get(cacheKey); + if (cached != null) + { + _logger.LogInformation("Report per ISIN {Isin} servito da cache ({Size} bytes)", isin, cached.Length); + return cached; + } + + _logger.LogInformation("Inizio generazione report per ISIN {Isin}", isin); + + // ── 1. Recupera tutti i dati ─────────────────────────────────── + var reportData = new CertificateReportData + { + Info = await _dataService.GetCertificateInfoAsync(isin), + Eventi = await _dataService.GetCertificateEventsAsync(isin), + Scenario = await _dataService.GetScenarioAnalysisAsync(isin), + // ChartImage viene popolato più avanti dall'orchestratore — non aggiungere qui + ShowBranding = showBranding, + }; + + // Tutto il resto del metodo è invariato. + // Sostituire SOLO le due righe della cache: + // _cache.Get(isin) → _cache.Get(cacheKey) + // _cache.Set(isin, ...) → _cache.Set(cacheKey, finalPdf) + } + ``` + + > Nota: modificare solo la firma, l'inizializzazione di `reportData` e le due righe cache. Il resto del metodo rimane identico. + +- [ ] **Step 1.4 — Aggiornare `ReportController` per leggere `?branding=`** + + In `Controllers/ReportController.cs`, aggiornare tutti e tre i punti dove si chiama `_orchestrator.GenerateReportAsync`: + + **Endpoint `GenerateReport` (GET /api/report):** + ```csharp + [HttpGet] + public async Task GenerateReport( + [FromQuery(Name = "p")] string? encryptedIsin = null, + [FromQuery(Name = "alias")] string? aliasId = null, + [FromQuery(Name = "branding")] bool showBranding = false) // ← aggiunto + { + // ... logica risoluzione ISIN invariata ... + return await GenerateAndReturnPdf(isin, showBranding); + } + ``` + + **Endpoint `GenerateReportByIsin` (GET /api/report/by-isin/{isin}):** + ```csharp + [HttpGet("by-isin/{isin}")] + public async Task GenerateReportByIsin( + string isin, + [FromQuery(Name = "branding")] bool showBranding = false) // ← aggiunto + { + if (string.IsNullOrWhiteSpace(isin) || isin.Length < 12) + return BadRequest("ISIN non valido."); + + return await GenerateAndReturnPdf(isin, showBranding); + } + ``` + + **Endpoint `DownloadReport` (GET /api/report/download):** + ```csharp + [HttpGet("download")] + public async Task DownloadReport( + [FromQuery(Name = "p")] string? encryptedIsin = null, + [FromQuery(Name = "alias")] string? aliasId = null, + [FromQuery(Name = "branding")] bool showBranding = false) // ← aggiunto + { + // ... logica risoluzione ISIN invariata ... + var pdfBytes = await _orchestrator.GenerateReportAsync(isin, showBranding); + return File(pdfBytes, "application/pdf", $"{isin}.pdf"); + } + ``` + + **Helper privato `GenerateAndReturnPdf`:** + ```csharp + private async Task GenerateAndReturnPdf(string isin, bool showBranding) + { + try + { + _logger.LogInformation("Richiesta report per ISIN {Isin}", isin); + var pdfBytes = await _orchestrator.GenerateReportAsync(isin, showBranding); + Response.Headers.Append("Content-Disposition", $"inline; filename={isin}.pdf"); + return File(pdfBytes, "application/pdf"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore generazione report per ISIN {Isin}", isin); + return StatusCode(500, "Errore nella generazione del report."); + } + } + ``` + +- [ ] **Step 1.5 — Build e verifica compilazione** + + ```bash + cd "CertReports.Syncfusion" + dotnet build + ``` + + Atteso: `Build succeeded. 0 Error(s)`. Nessun warning su interfacce non implementate. + +- [ ] **Step 1.6 — Commit** + + ```bash + git add CertReports.Syncfusion/Models/CertificateModels.cs \ + CertReports.Syncfusion/Services/Interfaces/IServices.cs \ + CertReports.Syncfusion/Controllers/ReportController.cs \ + CertReports.Syncfusion/Services/Implementations/ReportOrchestrator.cs + git commit -m "feat: add ShowBranding flag propagation from API to report data" + ``` + +--- + +## Task 2: Aggiornare `PdfTheme` — nuovi colori, margini ridotti, `DrawFooter()` + +**Files:** +- Modify: `CertReports.Syncfusion/Helpers/PdfTheme.cs` + +- [ ] **Step 2.1 — Aggiungere nuovi colori e costanti** + + Aggiungere in coda al blocco `// ─── Colori ───` (dopo riga 24): + + ```csharp + // Nuovi colori stile "Ibrido elegante" + public static readonly Color AccentBlue = Color.FromArgb(255, 21, 101, 192); // #1565C0 + public static readonly Color NegativeRed = Color.FromArgb(255, 204, 0, 0); // #CC0000 + public static readonly Color PositiveGreen = Color.FromArgb(255, 46, 125, 50); // #2E7D32 + public static readonly Color FooterText = Color.FromArgb(255, 102, 102, 102); // #666666 + public static readonly Color SeparatorLine = Color.FromArgb(255, 221, 221, 221); // #DDDDDD + public static readonly Color TableBorder = Color.FromArgb(255, 187, 187, 187); // #BBBBBB + public static readonly Color TableAltRow = Color.FromArgb(255, 247, 249, 252); // #F7F9FC + public static readonly Color TableHeaderBg = Color.FromArgb(255, 21, 101, 192); // #1565C0 (same as AccentBlue) + ``` + + Aggiungere i brush/pen corrispondenti in coda al blocco `// ─── Brushes ───`: + + ```csharp + public static PdfBrush AccentBlueBrush => new PdfSolidBrush(AccentBlue); + public static PdfBrush NegativeBrush2 => new PdfSolidBrush(NegativeRed); + public static PdfBrush PositiveBrush2 => new PdfSolidBrush(PositiveGreen); + public static PdfBrush FooterTextBrush => new PdfSolidBrush(FooterText); + public static PdfBrush TableAltRowBrush => new PdfSolidBrush(TableAltRow); + public static PdfBrush TableHeaderBrush => new PdfSolidBrush(TableHeaderBg); + public static PdfPen SeparatorPen => new PdfPen(SeparatorLine, 0.5f); + public static PdfPen TableBorderPen => new PdfPen(TableBorder, 0.5f); + public static PdfPen AccentBluePen => new PdfPen(AccentBlue, 2.5f); + ``` + +- [ ] **Step 2.2 — Ridurre margini e row height** + + Modificare le costanti di layout esistenti (righe 43-46): + + ```csharp + // ─── Margini & Layout ────────────────────────────────────────────── + public const float PageMargin = 36f; // era 40f + public const float RowHeight = 14f; // era 18f — KV pairs sezione Analisi + public const float HeaderRowHeight = 18f; // era 22f — header sezioni + public const float CellPadding = 4f; + public const float FooterHeight = 28f; // spazio riservato footer in fondo alla pagina + ``` + +- [ ] **Step 2.3 — Aggiungere font per footer e tabella sottostanti** + + In coda al blocco `// ─── Font ───`: + + ```csharp + private static readonly PdfStandardFont _fontFooter = new(PdfFontFamily.Helvetica, 7f); + private static readonly PdfStandardFont _fontFooterBold = new(PdfFontFamily.Helvetica, 7f, PdfFontStyle.Bold); + private static readonly PdfStandardFont _fontTable = new(PdfFontFamily.Helvetica, 7.5f); + private static readonly PdfStandardFont _fontTableBold = new(PdfFontFamily.Helvetica, 7.5f, PdfFontStyle.Bold); + + public static PdfFont Footer => _fontFooter; + public static PdfFont FooterBold => _fontFooterBold; + public static PdfFont TableFont => _fontTable; + public static PdfFont TableBold => _fontTableBold; + ``` + +- [ ] **Step 2.4 — Aggiungere helper `DrawFooter()`** + + Aggiungere alla fine della classe `PdfTheme`, prima della chiusura `}`: + + ```csharp + /// + /// Disegna il footer in fondo alla pagina. + /// Se showBranding=true: "Powered by [Smart Roots↗]" a sx + numero pagina a dx. + /// Se showBranding=false: solo numero pagina centrato. + /// + public static void DrawFooter( + PdfGraphics g, + float pageWidth, + float pageHeight, + int pageNumber, + bool showBranding) + { + float footerY = pageHeight - FooterHeight + 6f; + + // Linea separatrice + g.DrawLine(SeparatorPen, 0, footerY - 4f, pageWidth, footerY - 4f); + + string pageLabel = pageNumber.ToString(); + + if (!showBranding) + { + // Solo numero pagina centrato + g.DrawString(pageLabel, Footer, FooterTextBrush, + new RectangleF(0, footerY, pageWidth, 12f), + new PdfStringFormat(PdfTextAlignment.Center)); + return; + } + + // "Powered by " testo normale + const string prefix = "Powered by "; + const string linkText = "Smart Roots"; + const string linkUrl = "https://www.smart-roots.net"; + + float prefixWidth = Footer.MeasureString(prefix).Width; + float linkWidth = Footer.MeasureString(linkText).Width; + + g.DrawString(prefix, Footer, FooterTextBrush, + new RectangleF(0, footerY, prefixWidth + 2f, 12f)); + + // "Smart Roots" come hyperlink PDF + var webLink = new PdfTextWebLink + { + Url = linkUrl, + Text = linkText, + Font = Footer, + Brush = AccentBlueBrush, + }; + webLink.DrawTextWebLink(g, new PointF(prefixWidth, footerY)); + + // Numero pagina a destra + g.DrawString(pageLabel, Footer, FooterTextBrush, + new RectangleF(0, footerY, pageWidth, 12f), + new PdfStringFormat(PdfTextAlignment.Right)); + } + ``` + + > `PdfTextWebLink` richiede `using Syncfusion.Pdf.Interactive;` — aggiungere all'inizio del file `PdfTheme.cs`. + +- [ ] **Step 2.5 — Build e verifica** + + ```bash + dotnet build + ``` + + Atteso: `Build succeeded. 0 Error(s)`. + +- [ ] **Step 2.6 — Commit** + + ```bash + git add CertReports.Syncfusion/Helpers/PdfTheme.cs + git commit -m "feat: add AccentBlue palette, reduced margins, DrawFooter with branding support" + ``` + +--- + +## Task 3: Riscrivere `AnagraficaSectionRenderer` + +Sostituzione completa del metodo `Render()` e degli helper privati. La struttura passa da 4 blocchi KV orizzontali a 3 sezioni verticali: Caratteristiche Prodotto, Analisi, Sottostanti. + +**Files:** +- Rewrite: `CertReports.Syncfusion/Services/Implementations/AnagraficaSectionRenderer.cs` + +- [ ] **Step 3.1 — Sostituire la classe completa** + + Sostituire l'intero contenuto del file con: + + ```csharp + using CertReports.Syncfusion.Helpers; + using CertReports.Syncfusion.Models; + using CertReports.Syncfusion.Services.Interfaces; + using Syncfusion.Drawing; + using Syncfusion.Pdf; + using Syncfusion.Pdf.Graphics; + using Syncfusion.Pdf.Grid; + + namespace CertReports.Syncfusion.Services.Implementations; + + /// + /// Sezione 1 — Prima pagina del report: Anagrafica + Analisi + Sottostanti. + /// Struttura identica al vecchio report DevExpress, stile "Ibrido elegante". + /// + public class AnagraficaSectionRenderer : IPdfSectionRenderer + { + public string SectionName => "Anagrafica"; + public int Order => 1; + + // Larghezza area utile (A4 595pt - 2*36 margini) + private const float PageW = 595f - 2 * PdfTheme.PageMargin; + // Altezza area utile (A4 842pt - 2*36 margini - footer) + private const float PageH = 842f - 2 * PdfTheme.PageMargin - PdfTheme.FooterHeight; + private const float ColGap = 10f; + private const float ColW = (PageW - ColGap) / 2f; + private const float SectionGap = 10f; + + public PdfDocument Render(CertificateReportData data) + { + var doc = new PdfDocument(); + var page = PdfTheme.AddA4Page(doc); + var g = page.Graphics; + var info = data.Info; + float y = 0f; + + // ── TITOLO ──────────────────────────────────────────────────── + y = DrawTitle(g, info, PageW, y); + + // ── SEZIONE A: CARATTERISTICHE PRODOTTO ─────────────────────── + y = DrawSectionLabel(g, "Caratteristiche Prodotto", y); + y = DrawCaratteristiche(g, info, y); + y += SectionGap; + + // ── SEZIONE B: ANALISI ──────────────────────────────────────── + y = DrawSectionLabel(g, "Analisi", y); + y = DrawAnalisi(g, info, y); + y += SectionGap; + + // ── SEZIONE C: SOTTOSTANTI ──────────────────────────────────── + if (info.Sottostanti.Count > 0) + { + // Se lo spazio rimanente è meno di 80pt, nuova pagina + if (y > PageH - 80f) + { + PdfTheme.DrawFooter(g, PageW, PageH, 1, data.ShowBranding); + page = doc.Pages.Add(); + g = page.Graphics; + y = 0f; + } + + y = DrawSectionLabel(g, "Sottostanti", y); + DrawSottostanti(g, info.Sottostanti, PageW, y); + } + + PdfTheme.DrawFooter(g, PageW, PageH, doc.Pages.Count, data.ShowBranding); + + return doc; + } + + // ═══════════════════════════════════════════════════════════════ + // TITOLO con Bid/Ask a destra + // ═══════════════════════════════════════════════════════════════ + + private float DrawTitle(PdfGraphics g, CertificateInfo info, float w, float y) + { + // ISIN + nome prodotto a sinistra + g.DrawString($"Scheda Prodotto {info.Isin}", + PdfTheme.SectionTitleFont, + new PdfSolidBrush(PdfTheme.AccentBlue), + new RectangleF(0, y, w * 0.65f, 20f)); + + // Bid/Ask a destra + if (!string.IsNullOrEmpty(info.Bid) && !string.IsNullOrEmpty(info.Ask)) + { + string bidAsk = $"{info.Bid} BID {info.Ask} ASK"; + g.DrawString(info.LastPriceDate, PdfTheme.Small, + new PdfSolidBrush(PdfTheme.TextSecondary), + new RectangleF(w * 0.65f, y, w * 0.35f, 10f), + new PdfStringFormat(PdfTextAlignment.Right)); + g.DrawString(bidAsk, PdfTheme.Bold, + new PdfSolidBrush(PdfTheme.AccentBlue), + new RectangleF(w * 0.65f, y + 10f, w * 0.35f, 12f), + new PdfStringFormat(PdfTextAlignment.Right)); + } + + y += 22f; + + // Tipologia sotto il titolo + if (!string.IsNullOrEmpty(info.Categoria)) + { + g.DrawString($"Tipologia: {info.Categoria}", PdfTheme.Regular, + new PdfSolidBrush(PdfTheme.TextSecondary), + new RectangleF(0, y, w, 12f)); + y += 13f; + } + + // Linea separatrice blu + g.DrawLine(PdfTheme.AccentBluePen, 0, y + 2f, w, y + 2f); + y += 8f; + + return y; + } + + // ═══════════════════════════════════════════════════════════════ + // INTESTAZIONE DI SEZIONE (accent line laterale + testo blu) + // ═══════════════════════════════════════════════════════════════ + + private float DrawSectionLabel(PdfGraphics g, string title, float y) + { + // Accent line verticale sinistra + g.DrawRectangle(PdfTheme.AccentBlueBrush, new RectangleF(0, y, 3f, 14f)); + g.DrawString(title, PdfTheme.Bold, + new PdfSolidBrush(PdfTheme.AccentBlue), + new RectangleF(6f, y, PageW, 14f)); + return y + 16f; + } + + // ═══════════════════════════════════════════════════════════════ + // SEZIONE A: CARATTERISTICHE PRODOTTO + // Sinistra: tabella emittente | Destra: cedole + rendimento totale + // ═══════════════════════════════════════════════════════════════ + + private float DrawCaratteristiche(PdfGraphics g, CertificateInfo info, float y) + { + float startY = y; + + // ── Colonna sinistra: tabella emittente ── + float leftY = DrawEmittenteTable(g, info, 0, ColW, y); + + // ── Colonna destra: cedole + rendimento totale ── + float rightX = ColW + ColGap; + float rightY = DrawCedoleTable(g, info, rightX, ColW, y); + + return Math.Max(leftY, rightY); + } + + private float DrawEmittenteTable(PdfGraphics g, CertificateInfo info, float x, float w, float y) + { + float rh = PdfTheme.RowHeight; + float pad = PdfTheme.CellPadding; + + // Header blu + g.DrawRectangle(PdfTheme.TableHeaderBrush, new RectangleF(x, y, w, rh)); + string headerText = string.IsNullOrEmpty(info.Emittente) + ? "EMITTENTE" + : $"EMITTENTE {info.Emittente.ToUpper()}"; + g.DrawString(headerText, PdfTheme.TableBold, + PdfTheme.HeaderTextBrush, + new RectangleF(x + pad, y + 3f, w - pad * 2, rh)); + y += rh; + + // Righe dati + var rows = new[] + { + ("ISIN", info.Isin), + ("Mercato", info.Mercato), + ("Valuta", info.Valuta), + ("Data Emissione", info.DataEmissione), + ("Data Scadenza", info.Scadenza), + ("Prossimo Autocall", info.NextAutocallDate), + }; + + for (int i = 0; i < rows.Length; i++) + { + var (label, value) = rows[i]; + if (string.IsNullOrWhiteSpace(value)) continue; + + bool alt = i % 2 == 1; + if (alt) + g.DrawRectangle(new PdfSolidBrush(PdfTheme.TableAltRow), + new RectangleF(x, y, w, rh)); + + g.DrawLine(PdfTheme.TableBorderPen, x, y, x + w, y); + + g.DrawString(label, PdfTheme.TableFont, + new PdfSolidBrush(PdfTheme.TextSecondary), + new RectangleF(x + pad, y + 2f, w * 0.5f, rh)); + + var valueBrush = (label == "ISIN" || label == "Prossimo Autocall") + ? new PdfSolidBrush(PdfTheme.AccentBlue) + : new PdfSolidBrush(PdfTheme.TextPrimary); + + g.DrawString(value, PdfTheme.TableBold, valueBrush, + new RectangleF(x + w * 0.5f, y + 2f, w * 0.5f - pad, rh), + new PdfStringFormat(PdfTextAlignment.Right)); + + y += rh; + } + + // Bordo esterno tabella + g.DrawRectangle(PdfTheme.TableBorderPen, + new RectangleF(x, startY: y - rows.Length * rh - rh, w, rows.Length * rh + rh)); + + return y + 2f; + } + + private float DrawCedoleTable(PdfGraphics g, CertificateInfo info, float x, float w, float y) + { + float rh = PdfTheme.RowHeight; + float pad = PdfTheme.CellPadding; + + // Header blu + g.DrawRectangle(PdfTheme.TableHeaderBrush, new RectangleF(x, y, w, rh)); + g.DrawString("CEDOLE E RENDIMENTO", PdfTheme.TableBold, + PdfTheme.HeaderTextBrush, + new RectangleF(x + pad, y + 3f, w - pad * 2, rh)); + y += rh; + + var rows = new (string Label, string Value, bool IsNegative)[] + { + ("Importo Cedole Pagate", info.CpnPagati?.ToString("N2") ?? "-", false), + ("Importo Cedole da Pagare", info.CpnDaPagare?.ToString("N2") ?? "-", false), + ("Importo Cedole in Memoria",info.CpnInMemoria?.ToString("N2") ?? "-", false), + ("Rendimento Totale", info.RendimentoAttuale, IsNegativeValue(info.RendimentoAttuale)), + }; + + for (int i = 0; i < rows.Length; i++) + { + var (label, value, isNeg) = rows[i]; + bool alt = i % 2 == 1; + if (alt) + g.DrawRectangle(new PdfSolidBrush(PdfTheme.TableAltRow), + new RectangleF(x, y, w, rh)); + + g.DrawLine(PdfTheme.TableBorderPen, x, y, x + w, y); + + g.DrawString(label, PdfTheme.TableFont, + new PdfSolidBrush(PdfTheme.TextSecondary), + new RectangleF(x + pad, y + 2f, w * 0.65f, rh)); + + var valueBrush = isNeg + ? new PdfSolidBrush(PdfTheme.NegativeRed) + : new PdfSolidBrush(PdfTheme.TextPrimary); + + var valueFont = label == "Rendimento Totale" ? PdfTheme.TableBold : PdfTheme.TableFont; + + g.DrawString(value, valueFont, valueBrush, + new RectangleF(x + w * 0.65f, y + 2f, w * 0.35f - pad, rh), + new PdfStringFormat(PdfTextAlignment.Right)); + + y += rh; + } + + g.DrawLine(PdfTheme.TableBorderPen, x, y, x + w, y); + + return y + 2f; + } + + // ═══════════════════════════════════════════════════════════════ + // SEZIONE B: ANALISI + // Sinistra: caratteristiche prodotto | Destra: rendimenti + // ═══════════════════════════════════════════════════════════════ + + private float DrawAnalisi(PdfGraphics g, CertificateInfo info, float y) + { + var leftItems = new (string Label, string Value)[] + { + ("Importo Cedola (p.a.)", info.NominalAnnualYield), + ("Frequenza Cedola", info.FrequenzaCedole), + ("Valore Nominale", info.NominalValue?.ToString("N0") ?? "-"), + ("Prezzo Emissione", info.PrezzoEmissione?.ToString("N0") ?? "-"), + ("Barriera Capitale", info.LivelloBarriera), + ("Tipo Barriera", info.BarrierType), + ("Tipo Basket", info.BasketType), + ("Leva", string.IsNullOrWhiteSpace(info.Leva) ? "—" : info.Leva), + }; + + var rightItems = new (string Label, string Value)[] + { + ("Rend. Capitale a Scadenza", info.CapitalReturnAtMaturity), + ("IRR", info.IRR), + ("Protezione Capitale", info.BufferKProt), + ("Protezione Coupon", info.BufferCPNProt), + ("Valore Autocall", info.AutocallValue), + ("Distanza Autocall", info.TriggerAutocallDistance), + ("Rendimento Autocall", info.AutocallReturn), + ("Fattore Airbag", string.IsNullOrWhiteSpace(info.FattoreAirbag) ? "—" : info.FattoreAirbag), + ("Trigger OneStar", string.IsNullOrWhiteSpace(info.TriggerOneStar) ? "—" : info.TriggerOneStar), + }; + + float leftY = DrawKVList(g, leftItems, 0, ColW, y); + float rightY = DrawKVList(g, rightItems, ColW + ColGap, ColW, y); + + return Math.Max(leftY, rightY); + } + + private float DrawKVList(PdfGraphics g, (string Label, string Value)[] items, float x, float w, float y) + { + float rh = PdfTheme.RowHeight; + float pad = PdfTheme.CellPadding; + float labelW = w * 0.58f; + float valueW = w * 0.42f; + + foreach (var (label, value) in items) + { + if (string.IsNullOrWhiteSpace(value)) continue; + + g.DrawString(label, PdfTheme.TableFont, + new PdfSolidBrush(PdfTheme.TextSecondary), + new RectangleF(x + pad, y + 1f, labelW, rh)); + + bool isNeg = IsNegativeValue(value); + bool isKey = label is "Importo Cedola (p.a.)" or "Barriera Capitale"; + + var brush = isNeg ? new PdfSolidBrush(PdfTheme.NegativeRed) + : isKey ? new PdfSolidBrush(PdfTheme.AccentBlue) + : new PdfSolidBrush(PdfTheme.TextPrimary); + + g.DrawString(value, PdfTheme.TableBold, brush, + new RectangleF(x + labelW, y + 1f, valueW - pad, rh), + new PdfStringFormat(PdfTextAlignment.Right)); + + g.DrawLine(new PdfPen(PdfTheme.SeparatorLine, 0.3f), + x, y + rh, x + w, y + rh); + + y += rh; + } + return y; + } + + // ═══════════════════════════════════════════════════════════════ + // SEZIONE C: SOTTOSTANTI (9 colonne, font 7.5pt) + // ═══════════════════════════════════════════════════════════════ + + private void DrawSottostanti(PdfGraphics g, List sottostanti, float w, float y) + { + var grid = new PdfGrid(); + grid.Style.CellPadding = new PdfPaddings(2, 2, 2, 2); + + // 9 colonne (Dist.AC rimossa rispetto alla versione precedente) + string[] headers = { "Nome", "Strike", "Last", "% Perf.", "Barr.K", + "Buffer K", "Trig.CPN", "Buf.CPN", "Trig.AC" }; + + foreach (var _ in headers) grid.Columns.Add(); + + // Header + var hr = grid.Headers.Add(1)[0]; + for (int i = 0; i < headers.Length; i++) + { + hr.Cells[i].Value = headers[i]; + hr.Cells[i].Style.Font = PdfTheme.TableBold; + hr.Cells[i].Style.BackgroundBrush = PdfTheme.TableHeaderBrush; + hr.Cells[i].Style.TextBrush = PdfTheme.HeaderTextBrush as PdfBrush; + hr.Cells[i].StringFormat = new PdfStringFormat( + i == 0 ? PdfTextAlignment.Left : PdfTextAlignment.Center); + } + + // Righe dati + for (int i = 0; i < sottostanti.Count; i++) + { + var s = sottostanti[i]; + var row = grid.Rows.Add(); + + row.Cells[0].Value = s.Nome; + row.Cells[1].Value = s.Strike; + row.Cells[2].Value = s.LastPrice; + row.Cells[3].Value = s.Performance; + row.Cells[4].Value = s.CapitalBarrier; + row.Cells[5].Value = s.ULCapitalBarrierBuffer; + row.Cells[6].Value = s.CouponBarrier; + row.Cells[7].Value = s.ULCouponBarrierBuffer; + row.Cells[8].Value = s.TriggerAutocall; + + for (int c = 0; c < headers.Length; c++) + { + row.Cells[c].Style.Font = PdfTheme.TableFont; + row.Cells[c].StringFormat = new PdfStringFormat( + c == 0 ? PdfTextAlignment.Left : PdfTextAlignment.Right); + } + + // Colori performance e buffer negativi + ColorPerformanceCell(row.Cells[3], s.Performance); // % Perf. + ColorPerformanceCell(row.Cells[5], s.ULCapitalBarrierBuffer); // Buffer K + ColorPerformanceCell(row.Cells[7], s.ULCouponBarrierBuffer); // Buf.CPN + + // Righe alternate + if (i % 2 == 1) + for (int c = 0; c < headers.Length; c++) + row.Cells[c].Style.BackgroundBrush = PdfTheme.TableAltRowBrush; + } + + // Larghezze proporzionali (totale = PageW) + float[] cw = { 90f, 52f, 52f, 46f, 52f, 46f, 52f, 46f, 46f }; + float total = cw.Sum(); + float scale = w / total; + for (int i = 0; i < cw.Length; i++) + grid.Columns[i].Width = cw[i] * scale; + + PdfTheme.ApplyThinBorders(grid); + grid.Draw(g, new PointF(0, y)); + } + + // ═══════════════════════════════════════════════════════════════ + // Helper + // ═══════════════════════════════════════════════════════════════ + + private static bool IsNegativeValue(string value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + // Considera negativo se inizia con '-' dopo trim di spazi e simboli + var trimmed = value.TrimStart(); + return trimmed.StartsWith('-'); + } + + private static void ColorPerformanceCell(PdfGridCell cell, string value) + { + if (IsNegativeValue(value)) + cell.Style.TextBrush = new PdfSolidBrush(PdfTheme.NegativeRed); + else if (!string.IsNullOrWhiteSpace(value) && value != "-" && value != "—") + cell.Style.TextBrush = new PdfSolidBrush(PdfTheme.PositiveGreen); + } + } + ``` + + > **Nota su `DrawEmittenteTable`:** La variabile `startY` usata nel calcolo del bordo esterno va dichiarata prima del ciclo delle righe. Aggiungere `float startY = y;` subito dopo `y += rh;` (dopo l'header). + +- [ ] **Step 3.2 — Correggere bordo inferiore in `DrawEmittenteTable`** + + Il codice dell'Step 3.1 contiene `new RectangleF(x, startY: y - ..., ...)` che non compila (`RectangleF` non supporta named arguments). Rimuovere completamente quel `DrawRectangle` e aggiungere invece una sola linea orizzontale finale sotto l'ultima riga: + + ```csharp + // Sostituire il blocco DrawRectangle bordo esterno con questa singola riga: + g.DrawLine(PdfTheme.TableBorderPen, x, y, x + w, y); + ``` + + Le linee orizzontali su ogni riga + l'header blu rendono il bordo esterno superfluo. + +- [ ] **Step 3.3 — Build e verifica** + + ```bash + dotnet build + ``` + + Atteso: `Build succeeded. 0 Error(s)`. Se ci sono errori di compilazione, correggerli prima di procedere. + +- [ ] **Step 3.4 — Avviare l'applicazione e testare visivamente** + + ```bash + dotnet run --project CertReports.Syncfusion + ``` + + Aprire nel browser: + - `https://localhost:{porta}/api/report/by-isin/DE000UL00754` → verifica pagina 1 con nuova struttura + - `https://localhost:{porta}/api/report/by-isin/DE000UL00754?branding=true` → verifica footer con link Smart Roots + + **Checklist visiva pagina 1:** + - [ ] Titolo "Scheda Prodotto DE000UL00754" in blu, Bid/Ask a destra + - [ ] Linea separatrice blu sotto il titolo + - [ ] Sezione "Caratteristiche Prodotto" — tabella emittente a sinistra con header blu + - [ ] Cedole pagate/da pagare/in memoria + Rendimento Totale a destra (rosso se negativo) + - [ ] Sezione "Analisi" — 8 campi a sinistra, 9 a destra con KV pairs + - [ ] Leva, Fattore Airbag, Trigger OneStar mostrati come "—" + - [ ] Sezione "Sottostanti" — 9 colonne, valori negativi in rosso, tutto in pagina 1 + - [ ] Footer: solo "1" centrato senza branding + - [ ] Footer con `?branding=true`: "Powered by Smart Roots" (link blu) + "1" a destra + +- [ ] **Step 3.5 — Commit finale** + + ```bash + git add CertReports.Syncfusion/Services/Implementations/AnagraficaSectionRenderer.cs + git commit -m "feat: redesign page 1 - hybrid style, 3-section layout, sottostanti on page 1" + ``` + +--- + +## Task 4: Verifica regressione sezioni 2-4 + +Controllare che le sezioni successive (Eventi, Scenario, Grafico) non siano state impattate dalla riduzione dei margini in `PdfTheme`. + +**Files:** nessuno da modificare — solo verifica. + +- [ ] **Step 4.1 — Verificare le sezioni successive** + + Aprire nel browser il report completo e scorrere tutte le pagine: + + - **Pagina 2 (Lista Eventi):** le colonne della tabella devono essere leggibili e non tagliate + - **Pagina 3+ (Analisi Scenario):** gradiente rosso→verde intatto, valori negativi in rosso + - **Ultima pagina (Grafico):** linee, legenda e assi corretti + + Se la tabella Eventi o Scenario risulta più larga del previsto (per via del PageMargin ridotto da 40 a 36), aggiornare le larghezze colonne nei rispettivi renderer per sfruttare i 8pt extra disponibili. + +- [ ] **Step 4.2 — Commit di eventuali aggiustamenti** + + Se sono stati necessari aggiustamenti ai renderer esistenti: + + ```bash + # Aggiungere solo i renderer effettivamente modificati (non AnagraficaSectionRenderer) + # Esempi: + git add CertReports.Syncfusion/Services/Implementations/EventiSectionRenderer.cs + git add CertReports.Syncfusion/Services/Implementations/ScenarioSectionRenderer.cs + git commit -m "fix: adjust section renderers for updated PageMargin (36pt)" + ``` + +--- + +## Note implementative + +- **`PdfTextWebLink`** richiede `using Syncfusion.Pdf.Interactive;` — aggiungere all'header di `PdfTheme.cs` +- La cache key ora è `{isin}:branded` per le richieste con branding, `{isin}` per le normali — le due versioni sono cacheate separatamente +- I renderer Scenario, Eventi e Grafico chiamano già `renderer.Render(reportData)` passando `reportData` — `ShowBranding` è disponibile nel dato ma queste sezioni non lo usano (per ora non hanno footer) +- Se in futuro si vuole aggiungere il footer anche alle altre sezioni, basta aggiungere `PdfTheme.DrawFooter(...)` alla fine di ogni renderer diff --git a/docs/superpowers/specs/2026-03-20-anagrafica-redesign-design.md b/docs/superpowers/specs/2026-03-20-anagrafica-redesign-design.md new file mode 100644 index 0000000..358f803 --- /dev/null +++ b/docs/superpowers/specs/2026-03-20-anagrafica-redesign-design.md @@ -0,0 +1,175 @@ +# Design Spec: Redesign Pagina 1 Report + Parametro Footer + +**Data:** 2026-03-20 +**Progetto:** CertReports.Syncfusion +**Scope:** `AnagraficaSectionRenderer.cs`, `ReportController.cs`, `PdfTheme.cs`, modelli dati + +--- + +## Obiettivo + +Ridisegnare la prima pagina del report PDF per renderla graficamente più professionale, riallineare i campi all'ordine del vecchio report DevExpress, e fare entrare la tabella Sottostanti nella prima pagina. Aggiungere inoltre un parametro API opzionale per abilitare/disabilitare il footer con branding Smart Roots. + +--- + +## 1. Stile visivo (Opzione C — "Ibrido elegante") + +Approvato dall'utente. + +### Titolo pagina +- Testo: `"Scheda Prodotto {ISIN}"` — grande, **blu** (`#1565C0`), font bold 17pt +- Sottotitolo: `"Tipologia: {Categoria}"` — grigio, 9.5pt +- Separatore: linea orizzontale blu `#1565C0` spessore 2.5pt sotto il titolo +- **Bid/Ask in alto a destra** del titolo: data/ora piccola in grigio, Bid e Ask in blu bold 13pt + +### Intestazioni di sezione +- Niente header-bar scura (attuale). Sostituire con: + `font bold 10.5pt colore #1565C0` + accent line verticale sinistra 3pt blu +- Esempio: `"Caratteristiche Prodotto"`, `"Analisi"`, `"Sottostanti"` + +### Tabelle dati emittente +- Border 1px `#bbb`, border-radius 2px +- Prima riga: header piena `#1565C0` bianco bold (es. `"EMITTENTE UBS"`) +- Righe alternate: sfondo `#f9f9f9` / bianco +- Bordi interni `#e5e5e5` 0.5pt + +### KV pairs (sezione Analisi) +- Label a sinistra in grigio `#555`, valore a destra bold +- Separatori orizzontali `#e8e8e8` 0.3pt +- Valori negativi in rosso `#CC0000`, valori chiave (cedola) in blu `#1565C0` + +--- + +## 2. Struttura e ordine campi — Pagina 1 + +La prima pagina è divisa in **3 sezioni** verticali, tutte dentro una singola pagina A4. + +### Sezione A — "Caratteristiche Prodotto" + +Due colonne: + +| Colonna sinistra (tabella con header) | Colonna destra (KV list) | +|---------------------------------------|--------------------------| +| EMITTENTE (header blu) | Importo Cedole Pagate | +| ISIN | Importo Cedole da Pagare | +| Mercato | Importo Cedole in Memoria | +| Valuta | Rendimento Totale (rosso se negativo) | +| Data Emissione | | +| Data Scadenza | | +| Prossimo Autocall | | + +> La colonna destra non contiene più il box Bid/Ask separato: Bid/Ask sono già nel titolo pagina. + +### Sezione B — "Analisi" + +Due colonne KV: + +| Colonna sinistra | Colonna destra | +|------------------|----------------| +| Importo Cedola (p.a.) | Rendimento Capitale a Scadenza | +| Frequenza Cedola | IRR | +| Valore Nominale | Protezione Capitale | +| Prezzo Emissione | Protezione Coupon | +| Barriera Capitale | Valore Autocall | +| Tipo Barriera | Distanza Autocall | +| Tipo Basket | Rendimento Autocall | +| Leva | Fattore Airbag | +| | Trigger OneStar | + +> I campi vuoti (Leva, Fattore Airbag, Trigger OneStar) vengono mostrati con valore `"—"` invece di essere omessi, per mantenere la struttura fissa come nel vecchio report. + +### Sezione C — "Sottostanti" + +Tabella full-width con **9 colonne** (la colonna "Dist. AC" presente nel report attuale viene rimossa per consentire l'inserimento nella pagina 1): + +| Nome | Strike | Last | % Perf. | Barr. Cap. | Buffer K | Trigger CPN | Buffer CPN | Trig. AC | +|------|--------|------|---------|------------|----------|-------------|------------|----------| + +- Header: sfondo `#1565C0`, testo bianco bold 7.5pt +- Righe alternate: `#f7f9fc` / bianco +- Valori performance negativi: rosso `#CC0000`; positivi: verde `#2E7D32` +- Font celle sottostanti: 7.5pt (sia header che celle dati) — sovrascrive il valore generico `Small` 6.5pt di `PdfTheme` + +--- + +## 3. Gestione spazio e pagina + +Per fare entrare tutto in una pagina A4 (area utile ~757pt): + +- Ridurre `PageMargin` da 40pt a 36pt +- `RowHeight` per KV pairs: 14pt (attuale 18pt) +- `RowHeight` per tabella sottostanti: 13pt +- Usare font `Small` (6.5pt) per celle sottostanti, `SmallBold` per header +- Nelle sezioni Analisi, saltare i campi null/vuoti **eccetto** questi tre che vengono sempre mostrati con `"—"`: `Leva`, `Fattore Airbag`, `Trigger OneStar` (sono visibili anche nel vecchio report anche quando vuoti) + +Se la tabella Sottostanti non entra comunque (certificati con molti sottostanti), aggiungere una nuova pagina solo per essa — non alterare la struttura della pagina 1. + +--- + +## 4. Parametro API footer + +### Modifica API + +Tutti gli endpoint di generazione report accettano un nuovo parametro query opzionale: + +``` +GET /api/report/by-isin/{isin}?branding=true +GET /api/report?p={encrypted}&branding=true +GET /api/report?alias={id}&branding=true +GET /api/report/download?p={encrypted}&branding=true +``` + +Default: `branding=false` (nessuna modifica comportamentale agli URL esistenti). + +### Comportamento footer + +| `branding` | Footer | +|------------|--------| +| `false` (default) | Solo numero di pagina centrato, nessuna scritta | +| `true` | `"Powered by [Smart Roots](https://www.smart-roots.net)"` a sinistra + numero pagina a destra. "Smart Roots" è un hyperlink PDF cliccabile che punta a `https://www.smart-roots.net` | + +### Implementazione + +- Aggiungere `bool ShowBranding` a un nuovo modello `ReportOptions` (o direttamente a `ReportRequest`) +- `ReportController` legge `branding` dalla query string e lo passa a `ReportOrchestrator` +- `ReportOrchestrator` passa `ShowBranding` ai renderer aggiungendo `bool ShowBranding` direttamente a `CertificateReportData` (evita di cambiare la firma di `IPdfSectionRenderer.Render`) +- Ogni renderer (a partire da `AnagraficaSectionRenderer`) chiama un helper centralizzato `PdfTheme.DrawFooter(g, pageWidth, pageHeight, pageNumber, showBranding)` + +### Footer layout + +``` +[Solo numero pagina] +──────────────────────────────────────────────── + 1 + +[Con branding] +──────────────────────────────────────────────── +Powered by [Smart Roots↗] Pagina 1 + ↑ hyperlink → https://www.smart-roots.net +``` + +- Linea separatrice: `#dddddd` 0.5pt +- Testo branding: `"Powered by "` in `Small` 7pt grigio `#666`, seguito da `"Smart Roots"` come `PdfTextWebLink` blu `#1565C0` che punta a `https://www.smart-roots.net` +- Numero pagina: `Small` 7pt grigio, allineato a destra + +--- + +## 5. File modificati + +| File | Modifica | +|------|----------| +| `AnagraficaSectionRenderer.cs` | Riscrivere completamente il metodo `Render()` con nuova struttura 3-sezioni | +| `PdfTheme.cs` | Ridurre margini/row height, aggiungere `DrawFooter()`, aggiungere colori `AccentBlue`, `NegativeRed`, `PositiveGreen` | +| `ReportController.cs` | Leggere parametro `branding` da query string | +| `CertificateReportData.cs` (o nuovo `ReportOptions.cs`) | Aggiungere `bool ShowBranding` | +| `ReportOrchestrator.cs` | Propagare `ShowBranding` ai renderer | +| `IServices.cs` | Aggiornare firma `Render()` se necessario | + +--- + +## 6. Fuori scope + +- Nessuna modifica alle altre sezioni (Eventi, Scenario, Grafico) +- Nessuna modifica alle stored procedure +- Nessuna modifica al `ChartController` +- Nessuna aggiunta di nuovi campi al modello `CertificateInfo` — si usano i campi già esistenti riorganizzati